diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e500546..7ab67cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,20 +1,35 @@ # ───────────────────────────────────────────────────────────────────────── -# CI — Moteur Multi-Agents -# Lint (ruff, informatif) + tests (pytest). Config : pyproject.toml +# CI — vromvrom-engine (miroir public) +# Lint (ruff, informatif) + scan anti-fuite (bloquant) + pytest. +# Branche par défaut : main. Les mises à jour passent par PR. # ───────────────────────────────────────────────────────────────────────── name: CI on: push: - branches: [master] # post-merge uniquement (les branches passent via pull_request) + branches: [main] pull_request: + branches: [main] -# Annule les runs précédents encore en cours sur la même réf. concurrency: group: ci-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: + oss-secrets-scan: + name: 🔒 OSS secrets scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + - name: Scan IP LAN / tokens / clés + run: python scripts/oss_secrets_scan.py + lint: name: 🔍 Ruff (informatif) runs-on: ubuntu-latest @@ -25,8 +40,7 @@ jobs: python-version: "3.11" - name: Installer ruff run: pip install "ruff>=0.6.0" - # NON bloquant : dette ruff legacy (~320 violations). Voir plan de remédiation - # (item nettoyage lint). Retirer les `continue-on-error` une fois résorbée. + # NON bloquant : dette ruff legacy. Retirer continue-on-error une fois résorbée. - name: ruff check run: ruff check . --output-format=github continue-on-error: true @@ -37,6 +51,7 @@ jobs: tests: name: 🧪 Pytest runs-on: ubuntu-latest + needs: [oss-secrets-scan] steps: - uses: actions/checkout@v7 - uses: actions/setup-python@v7 @@ -48,6 +63,9 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt -r requirements-dev.txt - name: Seed des bases (modèles) - run: python seed_models_db.py + run: | + cp ha_commands.json.example ha_commands.json + cp pricing_strategy.json.example pricing_strategy.json + python seed_models_db.py - name: pytest run: pytest --tb=short -q diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 07ee63f..6729059 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -2,13 +2,16 @@ name: Python CI & Security on: pull_request: - branches: [ master ] + branches: [main] + push: + branches: [main] permissions: contents: read jobs: test-and-scan: + name: test-and-scan runs-on: ubuntu-latest steps: - name: Checkout repository @@ -24,18 +27,20 @@ jobs: python -m pip install --upgrade pip pip install bandit flake8 if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - # requirements-dev.txt : pytest, pytest-asyncio, paramiko (requis par test_failover), etc. if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi - name: Security Scan (Bandit) run: | bandit -r . -f custom -c pyproject.toml || true - # On ne bloque pas la CI pour Bandit au début, juste un warning + + - name: OSS secrets / LAN IP scan (bloquant) + run: python scripts/oss_secrets_scan.py - name: Seed models registry DB - # Peuple models_registry.db à partir du catalogue statique (aucun secret/réseau requis). - # Requis par tests/test_models_db.py qui asserte l'existence et le contenu de la base. - run: python seed_models_db.py + run: | + cp ha_commands.json.example ha_commands.json + cp pricing_strategy.json.example pricing_strategy.json + python seed_models_db.py - name: Run Pytest run: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dfb7023..0054fea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Contribuer à vromvrom-engine +# Contribuer à vromvrom-engine Merci de vouloir contribuer ! Ce projet est né d'un besoin personnel en domotique et a évolué vers quelque chose de potentiellement utile pour la communauté. Toute aide est bienvenue. @@ -9,6 +9,9 @@ Merci de vouloir contribuer ! Ce projet est né d'un besoin personnel en domotiq ## Workflow +**Toute mise à jour de `main` passe par une Pull Request** (branche protégée) : +CI obligatoire = scan anti-fuite OSS + pytest (+ Bandit informatif). + ```bash # Forker le repo, puis : git clone https://github.com//vromvrom-engine.git @@ -22,15 +25,25 @@ git checkout -b fix/nom-du-bug # Installer les dépendances de dev pip install -r requirements-dev.txt -# Travailler, puis lancer les tests +# Travailler, puis lancer les vérifs locales +python scripts/oss_secrets_scan.py pytest # Committer (convention Conventional Commits) git commit -m "feat(router): add OpenAI-compatible provider" -# Ouvrir une PR sur GitHub +# Pousser la branche et ouvrir une PR → attendre le CI vert → merge ``` +### Mainteneurs — sync depuis le miroir privé + +Ne **jamais** pousser directement sur `main`. Flux : + +1. Exporter / anonymiser (IP `192.168.0.x` → placeholders, aucun token/clé). +2. Branche `sync/…` + PR. +3. CI vert (`🔒 OSS secrets scan`, `🧪 Pytest`, `test-and-scan`). +4. Merge de la PR uniquement. + ## Conventions de code - **Python 3.11+** — typage strict avec annotations diff --git a/api/routes/agents_crud.py b/api/routes/agents_crud.py new file mode 100644 index 0000000..5cce06d --- /dev/null +++ b/api/routes/agents_crud.py @@ -0,0 +1,423 @@ +""" +api/routes/agents_crud.py — CRUD /api/agents pour la vue AgentsManager (#T159). + +Contrats RÉELS uniquement (leçon #T160 : ne jamais inventer un contrat côté vue) : +- Agents cœur (planner, executor, antigravity_agent, ha_agent, reviewer, + prompt_engineer, tool_maker) : le "modèle" est la clé `_model` de + config.json (tier leger/moyen/fort/automatique OU id de modèle littéral), + celle que core/factory.py et services/pipeline_service.py lisent réellement. +- Prompts systèmes : fichiers Markdown de #T188 (core/prompt_loader.py) pour + reviewer/planner/tool_maker/prompt_engineer. Les autres prompts vivent dans + le code Python et sont exposés en lecture seule (null → non éditable). +- Agents persistants (daemon, dreamer) : clés `persistent_agents.*` de + config.json (mêmes clés que /api/persistent-agents/config). +- Agents custom : section `custom_agents` de config.json, réellement chargée + par core/factory.py et services/pipeline_service.py (ExecutorAgent cloné). + Les agents custom dessinés dans l'éditeur de workflow restent listés + (source "workflow") mais se gèrent dans l'éditeur, pas ici. + +Toutes les écritures config.json : read-modify-write atomique sous FileLock +(même discipline que api/routes/daemon.py). +""" + +import json +import logging +import os +import re + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from core.prompt_loader import has_external_prompt, load_agent_prompt, save_agent_prompt +from core.safe_io import file_lock, safe_json_write + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["Agents CRUD"]) + +CONFIG_FILE = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "config.json" +) + +# Tiers acceptés comme valeur de "modèle" pour un agent (résolus par +# LLMGateway.get_provider_for_tier) — tout autre valeur = id de modèle littéral. +VALID_TIERS = ("leger", "moyen", "fort", "automatique") + +# Agents cœur du pipeline : nom → clé config.json portant leur modèle/tier. +# None = pas de clé config (le modèle est résolu en interne par l'agent). +CORE_AGENTS: dict[str, dict] = { + "planner": { + "config_key": "planner_model", + "default_model": "fort", + "label": "Planner (architecte du plan DAG)", + "prompt_agent": "planner", + }, + "executor": { + "config_key": "executor_model", + "default_model": "automatique", + "label": "Executor (boucle ReAct + outils)", + "prompt_agent": None, + }, + "antigravity_agent": { + "config_key": "antigravity_model", + "default_model": "fort", + "label": "Antigravity (raisonnement avancé)", + "prompt_agent": None, + }, + "ha_agent": { + "config_key": "ha_model", + "default_model": "moyen", + "label": "Home Assistant (domotique)", + "prompt_agent": None, + }, + "reviewer": { + "config_key": "reviewer_model", + "default_model": "moyen", + "label": "Reviewer (revue de code)", + "prompt_agent": "reviewer", + }, + "prompt_engineer": { + "config_key": "prompt_engineer_model", + "default_model": "fort", + "label": "Prompt Engineer (reformulation experte)", + "prompt_agent": "prompt_engineer", + }, + "tool_maker": { + "config_key": None, + "default_model": None, + "label": "Tool Maker (génération d'outils)", + "prompt_agent": "tool_maker", + }, +} + +# Agents persistants : nom → clés persistent_agents.* réellement lues par le moteur. +PERSISTENT_AGENTS: dict[str, dict] = { + "daemon": { + "model_key": "daemon_model", + "enabled_key": "daemon_enabled", + "interval_key": "daemon_interval_minutes", + "label": "Daemon Sentinelle 24/7", + }, + "dreamer": { + "model_key": "dreamer_model", + "enabled_key": "dreamer_enabled", + "interval_key": None, # planifié par dreamer_schedule (cron), pas un intervalle + "label": "Dreamer (consolidation nocturne + DreamCoder)", + }, +} + +_NAME_RE = re.compile(r"^[a-z][a-z0-9_]{1,40}$") + + +class AgentCreateBody(BaseModel): + name: str = Field(..., description="Identifiant snake_case unique de l'agent") + label: str | None = None + tier: str = Field("automatique", description="Tier ou id de modèle littéral") + system_prompt: str | None = None + + +class AgentPatchBody(BaseModel): + model: str | None = None + tier: str | None = None + label: str | None = None + enabled: bool | None = None + interval_minutes: int | None = None + system_prompt: str | None = None + + +def _load_config() -> dict: + with open(CONFIG_FILE, encoding="utf-8") as f: + return json.load(f) + + +def _mutate_config(mutator) -> dict: + """Read-modify-write atomique de config.json sous FileLock.""" + with file_lock(CONFIG_FILE): + with open(CONFIG_FILE, encoding="utf-8") as f: + config = json.load(f) + mutator(config) + safe_json_write(CONFIG_FILE, config, lock=False) + return config + + +def _tier_of(model_value: str | None) -> str: + """Tier affiché : la valeur elle-même si c'en est un, sinon 'automatique'.""" + return model_value if model_value in VALID_TIERS else "automatique" + + +def _core_agent_dict(name: str, meta: dict, config: dict) -> dict: + prompt_agent = meta["prompt_agent"] + system_prompt = None + prompt_source = None + if prompt_agent: + system_prompt = load_agent_prompt(prompt_agent, "") + prompt_source = "markdown" if has_external_prompt(prompt_agent) else "defaut_code" + model = config.get(meta["config_key"], meta["default_model"]) if meta["config_key"] else None + return { + "name": name, + "label": meta["label"], + "kind": "core", + "enabled": True, + "can_disable": False, + "model": model, + "tier": _tier_of(model), + "system_prompt": system_prompt, + "prompt_editable": prompt_agent is not None, + "prompt_source": prompt_source, + "is_custom": False, + "source": "config", + } + + +def _persistent_agent_dict(name: str, meta: dict, config: dict) -> dict: + pa = config.get("persistent_agents", {}) + model = pa.get(meta["model_key"]) + interval = pa.get(meta["interval_key"]) if meta["interval_key"] else None + return { + "name": name, + "label": meta["label"], + "kind": "persistent", + "enabled": bool(pa.get(meta["enabled_key"], False)), + "can_disable": True, + "model": model, + "tier": _tier_of(model), + "system_prompt": None, + "prompt_editable": False, + "prompt_source": None, + "is_custom": False, + "interval_minutes": interval, + "source": "config", + } + + +def _custom_agent_dict(entry: dict) -> dict: + return { + "name": entry["name"], + "label": entry.get("label", entry["name"]), + "kind": "custom", + "enabled": bool(entry.get("enabled", True)), + "can_disable": True, + "model": entry.get("tier", "automatique"), + "tier": _tier_of(entry.get("tier", "automatique")), + "system_prompt": entry.get("system_prompt"), + "prompt_editable": True, + "prompt_source": "config", + "is_custom": True, + "source": "config", + } + + +def get_config_custom_agents(config: dict | None = None) -> list[dict]: + """Agents custom de config.json (délégué au helper partagé, cf. #T213).""" + from core.custom_agents import load_custom_agent_entries + if config is None: + try: + config = _load_config() + except Exception: + return [] + return load_custom_agent_entries(config) + + +@router.get("/api/agents") +def list_agents(): + """Liste unifiée des agents : cœur + persistants + custom (config et workflow).""" + try: + config = _load_config() + except Exception as e: + raise HTTPException(status_code=500, detail=f"config.json illisible : {e}") + + agents = [_core_agent_dict(n, m, config) for n, m in CORE_AGENTS.items()] + agents += [_persistent_agent_dict(n, m, config) for n, m in PERSISTENT_AGENTS.items()] + agents += [_custom_agent_dict(e) for e in get_config_custom_agents(config)] + + # Agents custom dessinés dans l'éditeur de workflow (lecture seule ici : + # leur cycle de vie appartient à agents_workflows.json / WorkflowBuilder). + try: + from core.workflow_bridge import WorkflowBridge + known = {a["name"] for a in agents} + for custom in WorkflowBridge().get_custom_agents_config(): + if custom["name"] in known: + continue + agents.append({ + "name": custom["name"], + "label": custom.get("label", custom["name"]), + "kind": "custom", + "enabled": True, + "can_disable": False, + "model": custom.get("tier", "automatique"), + "tier": _tier_of(custom.get("tier", "automatique")), + "system_prompt": None, + "prompt_editable": False, + "prompt_source": None, + "is_custom": True, + "source": "workflow", + }) + except Exception as e: + logger.warning(f"[AGENTS] WorkflowBridge indisponible : {e}") + + return {"agents": agents} + + +@router.post("/api/agents") +def create_agent(body: AgentCreateBody): + """Crée un agent custom (config.json → réellement enregistré dans le moteur).""" + name = body.name.strip().lower() + if not _NAME_RE.match(name): + raise HTTPException( + status_code=422, + detail="Nom invalide : snake_case, 2-41 caractères, commence par une lettre.", + ) + if name in CORE_AGENTS or name in PERSISTENT_AGENTS or name == "router": + raise HTTPException(status_code=409, detail=f"'{name}' est un agent réservé du moteur.") + + created = { + "name": name, + "label": (body.label or name).strip(), + "tier": body.tier if body.tier in VALID_TIERS else body.tier.strip(), + "system_prompt": (body.system_prompt or "").strip() or None, + "enabled": True, + } + + def mutate(config: dict): + entries = config.setdefault("custom_agents", []) + if any(e.get("name") == name for e in entries if isinstance(e, dict)): + raise HTTPException(status_code=409, detail=f"L'agent custom '{name}' existe déjà.") + entries.append(created) + + _mutate_config(mutate) + logger.info(f"[AGENTS] Agent custom '{name}' créé (tier={created['tier']}).") + return _custom_agent_dict(created) + + +@router.put("/api/agents/{name}") +def update_agent(name: str, body: AgentPatchBody): + """ + Patch partiel d'un agent : + - cœur : model/tier → clé `_model` de config.json ; + system_prompt → fichier Markdown #T188 (si prompt_editable). + - persistant: model, enabled, interval_minutes → persistent_agents.*. + - custom : label, tier/model, system_prompt, enabled → custom_agents[]. + """ + new_model = body.model if body.model is not None else body.tier + + if name in CORE_AGENTS: + meta = CORE_AGENTS[name] + if new_model is not None: + if meta["config_key"] is None: + raise HTTPException( + status_code=422, + detail=f"'{name}' n'a pas de modèle configurable (résolu en interne).", + ) + config = _mutate_config(lambda c: c.__setitem__(meta["config_key"], new_model)) + else: + config = _load_config() + if body.system_prompt is not None: + if not meta["prompt_agent"]: + raise HTTPException( + status_code=422, + detail=f"Le prompt de '{name}' vit dans le code Python (non éditable ici).", + ) + path = save_agent_prompt(meta["prompt_agent"], body.system_prompt) + logger.info(f"[AGENTS] Prompt '{name}' sauvegardé → {path}") + if body.enabled is not None: + raise HTTPException(status_code=422, detail=f"'{name}' (agent cœur) ne se désactive pas.") + return _core_agent_dict(name, meta, config) + + if name in PERSISTENT_AGENTS: + meta = PERSISTENT_AGENTS[name] + + def mutate(config: dict): + pa = config.setdefault("persistent_agents", {}) + if new_model is not None: + pa[meta["model_key"]] = new_model + if body.enabled is not None: + pa[meta["enabled_key"]] = body.enabled + if body.interval_minutes is not None: + if not meta["interval_key"]: + raise HTTPException( + status_code=422, + detail=f"'{name}' est planifié par cron (dreamer_schedule), pas par intervalle.", + ) + pa[meta["interval_key"]] = body.interval_minutes + + config = _mutate_config(mutate) + return _persistent_agent_dict(name, meta, config) + + # ── Agent custom (config.json) ── + updated: dict = {} + + def mutate(config: dict): + entries = config.get("custom_agents", []) + for entry in entries: + if isinstance(entry, dict) and entry.get("name") == name: + if new_model is not None: + entry["tier"] = new_model + if body.label is not None: + entry["label"] = body.label.strip() + if body.system_prompt is not None: + entry["system_prompt"] = body.system_prompt.strip() or None + if body.enabled is not None: + entry["enabled"] = body.enabled + updated.update(entry) + return + raise HTTPException( + status_code=404, + detail=f"Agent '{name}' introuvable (les agents du workflow s'éditent dans l'éditeur).", + ) + + _mutate_config(mutate) + return _custom_agent_dict(updated) + + +@router.post("/api/agents/{name}/toggle") +def toggle_agent(name: str): + """Active/désactive un agent persistant ou custom. Les agents cœur : refus explicite.""" + if name in CORE_AGENTS: + raise HTTPException( + status_code=422, + detail=f"'{name}' est un agent cœur du pipeline : il ne peut pas être désactivé.", + ) + + result = {"enabled": False} + + if name in PERSISTENT_AGENTS: + meta = PERSISTENT_AGENTS[name] + + def mutate(config: dict): + pa = config.setdefault("persistent_agents", {}) + pa[meta["enabled_key"]] = not bool(pa.get(meta["enabled_key"], False)) + result["enabled"] = pa[meta["enabled_key"]] + + _mutate_config(mutate) + return result + + def mutate(config: dict): + for entry in config.get("custom_agents", []): + if isinstance(entry, dict) and entry.get("name") == name: + entry["enabled"] = not bool(entry.get("enabled", True)) + result["enabled"] = entry["enabled"] + return + raise HTTPException(status_code=404, detail=f"Agent '{name}' introuvable.") + + _mutate_config(mutate) + return result + + +@router.delete("/api/agents/{name}") +def delete_agent(name: str): + """Supprime un agent custom (config.json uniquement).""" + if name in CORE_AGENTS or name in PERSISTENT_AGENTS: + raise HTTPException(status_code=422, detail=f"'{name}' est un agent du moteur, non supprimable.") + + def mutate(config: dict): + entries = config.get("custom_agents", []) + remaining = [e for e in entries if not (isinstance(e, dict) and e.get("name") == name)] + if len(remaining) == len(entries): + raise HTTPException( + status_code=404, + detail=f"Agent '{name}' introuvable ici (ceux du workflow se suppriment dans l'éditeur).", + ) + config["custom_agents"] = remaining + + _mutate_config(mutate) + return {"status": "ok", "deleted": name} diff --git a/api/routes/ha.py b/api/routes/ha.py index 1d078c2..5353d50 100644 --- a/api/routes/ha.py +++ b/api/routes/ha.py @@ -1,4 +1,4 @@ -""" +""" api/routes/ha.py — Routes API Home Assistant du Moteur. Extrait de gui_server.py lors du refactoring Semaine 3. diff --git a/api/routes/prompt_tools.py b/api/routes/prompt_tools.py new file mode 100644 index 0000000..e8ed2dd --- /dev/null +++ b/api/routes/prompt_tools.py @@ -0,0 +1,64 @@ +""" +api/routes/prompt_tools.py — Branche le PromptEngineerAgent sur l'IHM (#T192). + +L'agent existait depuis le 07/07 mais n'était importé nulle part (code mort +confirmé par grep). Décision Phase 3 : le BRANCHER plutôt que le supprimer — +il devient le backend du bouton "Optimiser mon prompt" de la page Prompt de +l'IHM v2, sa raison d'être d'origine (demande d'Axel : agent de reformulation). + +POST /api/prompt/engineer : {prompt, context?, tier?} → {optimized_prompt}. +Le tier par défaut vient de config.json["prompt_engineer_model"] (clé gérée +par le CRUD /api/agents), repli "fort" (choix historique de l'agent). +""" + +import asyncio +import logging +import time + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["Prompt Tools"]) + + +class PromptEngineerBody(BaseModel): + prompt: str = Field(..., min_length=1, description="Demande brute à transformer en prompt expert") + context: str = Field("", description="Contexte optionnel à intégrer au prompt généré") + tier: str | None = Field(None, description="Tier (leger/moyen/fort/automatique) ou id de modèle") + + +@router.post("/api/prompt/engineer") +async def engineer_prompt(body: PromptEngineerBody): + """Transforme une demande brouillonne en prompt expert structuré (PromptEngineerAgent).""" + from agents.prompt_engineer_agent import PromptEngineerAgent + from core.llm_gateway import LLMGateway, load_config + from core.state import TaskPayload + + config = load_config() + provider_name = body.tier or config.get("prompt_engineer_model", "fort") + + agent = PromptEngineerAgent(llm_gateway=LLMGateway(), provider_name=provider_name) + payload = TaskPayload( + task_objective=body.prompt, + relevant_context=body.context, + metadata={"session_id": f"prompt_studio_{int(time.time())}"}, + ) + + try: + # L'agent gère déjà son propre timeout 60s ; filet global à 90s. + update = await asyncio.wait_for(agent.invoke(payload), timeout=90.0) + except TimeoutError: + raise HTTPException(status_code=504, detail="PromptEngineer : délai dépassé (90s).") + + if update.status != "success" or not update.result_data: + raise HTTPException( + status_code=502, + detail=f"PromptEngineer en échec : {update.error_message or 'réponse vide'}", + ) + + return { + "optimized_prompt": str(update.result_data).strip(), + "provider_used": provider_name, + } diff --git a/core/auditor_agent.py b/core/auditor_agent.py new file mode 100644 index 0000000..6ff0b9b --- /dev/null +++ b/core/auditor_agent.py @@ -0,0 +1,396 @@ +""" +core/auditor_agent.py — Auditeur de code autonome, repeuple le backlog seul. + +Service cron asyncio (même pattern que agents/dreamer_agent.py). Scanne le +code du dépôt lui-même (rotation d'une portée à la fois : core, agents, api, +memory, tools), envoie le corpus à un LLM via un prompt structuré forçant +un JSON de findings actionnables, dédoublonne contre le backlog actif +(core.backlog_db.get_active_task_titles), et insère les findings survivants +via core.backlog_db.add_task(). + +Réutilise la construction de corpus et l'hygiène de scan de secrets de +tools/vertex_audit.py (build_corpus/content_has_hardcoded_secret) plutôt que +de la dupliquer. + +Ne modifie AUCUN fichier et n'a AUCUN accès Git — l'auditeur ne fait que +lire le code et proposer des tâches ; seul DreamCoder (agents/dreamer_agent.py) +modifie le dépôt, sur des branches Git isolées avec approbation humaine +obligatoire avant merge (PUT /api/backlog/tasks/{id}). Rien ici ne contourne +cette porte. +""" + +import asyncio +import difflib +import glob +import json +import logging +import os +import time +from datetime import datetime +from pathlib import Path +from typing import Any + +logger = logging.getLogger("auditor_agent") + +_ENGINE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_STATE_PATH = os.path.join(_ENGINE_ROOT, "checkpoints", "auditor_state.json") +_REPORTS_DIR = os.path.join(_ENGINE_ROOT, "checkpoints", "auditor_reports") + +_DEFAULT_SCOPES = ["core", "agents", "api", "memory", "tools"] + +_SYSTEM_PROMPT = """Tu es un auditeur technique qui analyse un extrait de code source et en +extrait des tâches actionnables (bugs réels, dette technique concrète, incohérences). +Retourne UNIQUEMENT un JSON valide, sans balise markdown, avec cette structure exacte : +{ + "confidence": , + "findings": [ + {"title": "...", "description": "...", "file": "chemin/relatif.py", "priority": 1|2|3} + ] +} +priority: 1=urgent (bug réel/sécurité), 2=normal, 3=bas (cosmétique/mineur). +Ne propose que des findings concrets et actionnables, avec preuve dans le code fourni — +pas de généralités ni de suppositions sur du code que tu n'as pas vu. Si rien d'actionnable +n'est trouvé dans cet extrait, retourne findings=[].""" + + +# ────────────────────────────────────────────────────────────────── +# État persistant de l'auditeur (rotation de portée, dédup temporelle) +# ────────────────────────────────────────────────────────────────── + +auditor_state: dict[str, Any] = { + "enabled": False, + "running": False, + "last_run_at": None, + "last_report": None, + "last_error": None, + "total_runs": 0, +} + + +def _load_persistent_config() -> dict[str, Any]: + """Charge la section persistent_agents depuis config.json (même pattern que dreamer_agent).""" + try: + from core.llm_gateway import load_config + config = load_config() + return config.get("persistent_agents", {}) + except Exception as e: + logger.warning(f"[AUDITOR] Impossible de lire persistent_agents : {e}") + return {} + + +def _load_state() -> dict[str, Any]: + """État de rotation : {'scope_index': int, 'completed_count_baseline': int, 'last_run_at': float}.""" + if not os.path.exists(_STATE_PATH): + return {"scope_index": 0, "completed_count_baseline": 0, "last_run_at": 0} + try: + with open(_STATE_PATH, encoding="utf-8") as f: + return json.load(f) + except Exception as e: + logger.warning(f"[AUDITOR] État illisible, réinitialisation : {e}") + return {"scope_index": 0, "completed_count_baseline": 0, "last_run_at": 0} + + +def _save_state(state: dict[str, Any]) -> None: + os.makedirs(os.path.dirname(_STATE_PATH), exist_ok=True) + try: + with open(_STATE_PATH, "w", encoding="utf-8") as f: + json.dump(state, f, indent=2, ensure_ascii=False) + except Exception as e: + logger.error(f"[AUDITOR] Erreur sauvegarde état : {e}") + + +# ────────────────────────────────────────────────────────────────── +# Estimation de coût (pas de tracking natif hors pipeline de session) +# ────────────────────────────────────────────────────────────────── + +def _estimate_cost_usd(model_name: str, input_chars: int, output_chars: int) -> float: + """ + Estimation grossière (chars/4 ≈ tokens) du coût d'un appel LLM ad-hoc, + hors pipeline de session (donc non capté par core/token_tracker.py). + Utilise les tarifs réels du catalogue si connus, sinon 0.0 (modèles + locaux/gratuits ou non répertoriés). + """ + try: + from core.models_db import get_model_cost + pricing = get_model_cost(model_name) + cost_in = pricing.get("cost_input_per_m") or 0 + cost_out = pricing.get("cost_output_per_m") or 0 + input_tokens = input_chars / 4 + output_tokens = output_chars / 4 + return round((input_tokens / 1_000_000) * cost_in + (output_tokens / 1_000_000) * cost_out, 6) + except Exception as e: + logger.debug(f"[AUDITOR] Estimation de coût impossible pour '{model_name}' : {e}") + return 0.0 + + +# ────────────────────────────────────────────────────────────────── +# Extraction LLM structurée +# ────────────────────────────────────────────────────────────────── + +async def _extract_findings(corpus: str, tier: str, max_output_tokens: int = 2000) -> tuple[dict | None, float]: + """ + Appelle le LLM du tier donné (via ProviderScorer/LLMGateway, qualité + d'abord) avec le corpus de code et le prompt structuré. Retourne + (analyse JSON ou None, coût USD estimé). + """ + from core.llm_gateway import LLMGateway, load_config + + gateway = LLMGateway() + config = load_config() + model_name, provider = gateway.get_provider_for_tier(tier, config) + logger.info(f"[AUDITOR] Modèle résolu pour l'audit (tier={tier}) : {model_name}") + + user_prompt = f"## Extrait de code à auditer\n\n{corpus}" + response = await provider.generate_async( + system_prompt=_SYSTEM_PROMPT, + user_prompt=user_prompt, + max_tokens=max_output_tokens, + temperature=0.2, + ) + + cost = _estimate_cost_usd(model_name, len(_SYSTEM_PROMPT) + len(user_prompt), len(response or "")) + + text = (response or "").strip() + if text.startswith("```"): + lines = text.split("\n") + text = "\n".join(lines[1:-1]) + + try: + return json.loads(text), cost + except json.JSONDecodeError as je: + logger.warning(f"[AUDITOR] Réponse LLM non-JSON ({je}). Réponse brute : {text[:200]}...") + return None, cost + + +def _is_duplicate(title: str, active_titles: list[str], threshold: float) -> bool: + """Similarité texte simple (difflib), volontairement sans embeddings pour cette itération.""" + norm = title.strip().lower() + if not norm: + return True + return any( + difflib.SequenceMatcher(None, norm, t.strip().lower()).ratio() >= threshold + for t in active_titles + ) + + +# ────────────────────────────────────────────────────────────────── +# Cycle principal +# ────────────────────────────────────────────────────────────────── + +async def run_auditor_cycle(pa_config: dict) -> dict[str, Any]: + """ + Un cycle d'audit : construit le corpus de la portée courante (rotation), + extrait des findings structurés via LLM, dédoublonne, insère dans le + backlog. Avance la rotation de portée uniquement si le cycle aboutit + (pas de findings extraits = portée à retenter plus tard, pas sautée). + """ + from core.backlog_db import add_task, get_active_task_titles + from core.budget_guard import BudgetGuard + from tools.vertex_audit import build_corpus + + auditor_state["running"] = True + report: dict[str, Any] = { + "timestamp": datetime.now().isoformat(), + "scope": None, + "confidence": None, + "findings_proposed": 0, + "findings_skipped_duplicate": 0, + "cost_usd": 0.0, + "status": "ok", + } + + try: + bg = BudgetGuard() + weekly_cap = bg.config.get("auditor_weekly_budget_usd", 1.0) + spent = await bg.get_scoped_spend_usd("auditor", 7 * 86400) + if spent >= weekly_cap: + report["status"] = "budget_exhausted" + logger.warning(f"[AUDITOR] Plafond hebdomadaire atteint ({spent:.4f}/{weekly_cap} USD) — cycle sauté.") + return report + + state = _load_state() + scopes = pa_config.get("auditor_scopes", _DEFAULT_SCOPES) + if not scopes: + report["status"] = "no_scopes_configured" + return report + + scope_index = state.get("scope_index", 0) % len(scopes) + scope = scopes[scope_index] + report["scope"] = scope + + max_chars = pa_config.get("auditor_max_corpus_chars", 150_000) + corpus, est_tokens = build_corpus([Path(_ENGINE_ROOT)], {scope}, set()) + if not corpus.strip(): + logger.info(f"[AUDITOR] Portée '{scope}' vide ou introuvable — rotation vers la suivante.") + state["scope_index"] = (scope_index + 1) % len(scopes) + _save_state(state) + report["status"] = "empty_scope" + return report + + corpus = corpus[:max_chars] + logger.info(f"[AUDITOR] Portée '{scope}' — corpus {len(corpus):,} chars (~{est_tokens:,} tokens estimés avant troncature).") + + tier = pa_config.get("auditor_model_tier", "moyen") + escalated_tier = pa_config.get("auditor_escalation_tier", "fort") + confidence_threshold = pa_config.get("auditor_confidence_threshold", 6.0) + dedup_threshold = pa_config.get("auditor_dedup_similarity_threshold", 0.75) + + analysis, cost = await _extract_findings(corpus, tier) + report["cost_usd"] += cost + + if analysis and analysis.get("confidence", 10) < confidence_threshold: + logger.info( + f"[AUDITOR] Confiance basse ({analysis.get('confidence')}) sur '{scope}' — " + f"un seul ré-essai sur tier '{escalated_tier}'." + ) + escalated_analysis, escalated_cost = await _extract_findings(corpus, escalated_tier) + report["cost_usd"] += escalated_cost + if escalated_analysis: + analysis = escalated_analysis + + report["confidence"] = analysis.get("confidence") if analysis else None + + if analysis: + active_titles = await get_active_task_titles() + for finding in analysis.get("findings", []): + title = (finding.get("title") or "").strip() + if not title: + continue + if _is_duplicate(title, active_titles, dedup_threshold): + report["findings_skipped_duplicate"] += 1 + continue + + priority = finding.get("priority", 3) + priority = priority if priority in (1, 2, 3) else 3 + description = finding.get("description", "") + file_ref = finding.get("file", "") + if file_ref: + description = f"[{file_ref}] {description}" + + await add_task(title, description, priority=priority) + active_titles.append(title) # évite les doublons intra-cycle + report["findings_proposed"] += 1 + + # Enregistrer la dépense sous une source dédiée ('auditor'), séparée + # du budget quotidien de DreamCoder — voir get_scoped_spend_usd(). + if report["cost_usd"] > 0: + await bg.record_usage( + provider=f"auditor-{tier}", + tokens=0, + cost=report["cost_usd"], + model=tier, + sync_source="auditor", + ) + + # Rotation de portée uniquement si le cycle a bien produit une analyse + # (sinon on retente la même portée au prochain déclenchement). + if analysis is not None: + state["scope_index"] = (scope_index + 1) % len(scopes) + state["last_run_at"] = time.time() + _save_state(state) + + os.makedirs(_REPORTS_DIR, exist_ok=True) + report_path = os.path.join(_REPORTS_DIR, f"auditor_report_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.json") + with open(report_path, "w", encoding="utf-8") as f: + json.dump(report, f, indent=2, ensure_ascii=False) + + auditor_state["last_run_at"] = report["timestamp"] + auditor_state["last_report"] = report + auditor_state["total_runs"] += 1 + + except Exception as e: + logger.error(f"[AUDITOR] Erreur cycle : {e}", exc_info=True) + auditor_state["last_error"] = {"message": str(e), "timestamp": datetime.now().isoformat()} + report["status"] = "error" + report["error"] = str(e) + finally: + auditor_state["running"] = False + + return report + + +# ────────────────────────────────────────────────────────────────── +# Boucle cron asyncio (déclenchement par compteur de tâches ou planning) +# ────────────────────────────────────────────────────────────────── + +async def auditor_main_loop() -> None: + """ + Boucle cron asyncio pour l'auditeur. Vérifie toutes les 15 min si l'une + des conditions de déclenchement (auditor_trigger_mode) est remplie : + - 'task_count' : N tâches DreamCoder complétées depuis le dernier passage + - 'schedule' : créneau hebdomadaire fixe + - 'both' : l'un ou l'autre + Désactivé par défaut (auditor_enabled=false) — opt-in explicite requis. + """ + logger.info("[AUDITOR] 🕵️ Démarrage de la boucle de l'auditeur autonome") + await asyncio.sleep(20) # laisser le dreamer démarrer en premier + + while True: + pa_config = _load_persistent_config() + auditor_state["enabled"] = pa_config.get("auditor_enabled", False) + + if not pa_config.get("auditor_enabled", False): + await asyncio.sleep(900) + continue + + state = _load_state() + should_run = False + mode = pa_config.get("auditor_trigger_mode", "task_count") + + if mode in ("schedule", "both"): + now = datetime.now() + weekday = pa_config.get("auditor_schedule_weekday", 6) # 0=lundi..6=dimanche + sched_time = pa_config.get("auditor_schedule_time", "03:00") + try: + target_hour, target_minute = map(int, sched_time.split(":")) + in_window = ( + now.weekday() == weekday + and now.hour == target_hour + and target_minute <= now.minute < target_minute + 15 + ) + if in_window and (time.time() - state.get("last_run_at", 0)) > 6 * 3600: + should_run = True + except Exception as e: + logger.warning(f"[AUDITOR] auditor_schedule_time invalide ('{sched_time}') : {e}") + + if not should_run and mode in ("task_count", "both"): + try: + from core.backlog_db import get_task_stats + stats = await get_task_stats() + baseline = state.get("completed_count_baseline", 0) + threshold_n = pa_config.get("auditor_trigger_task_count", 10) + if stats.get("completed", 0) - baseline >= threshold_n: + should_run = True + except Exception as e: + logger.warning(f"[AUDITOR] Erreur lecture des stats backlog : {e}") + + if should_run: + logger.info("[AUDITOR] 🕵️ Déclenchement du cycle d'audit.") + try: + await run_auditor_cycle(pa_config) + except Exception as e: + logger.error(f"[AUDITOR] Erreur dans le cycle : {e}", exc_info=True) + finally: + # Reset du baseline même en cas de no-op, pour éviter un + # re-déclenchement immédiat en boucle serrée. + try: + from core.backlog_db import get_task_stats + stats = await get_task_stats() + st = _load_state() + st["completed_count_baseline"] = stats.get("completed", 0) + _save_state(st) + except Exception as e: + logger.warning(f"[AUDITOR] Erreur mise à jour baseline : {e}") + + await asyncio.sleep(900) # vérifie toutes les 15 min + + +def get_auditor_status() -> dict[str, Any]: + """Retourne l'état complet de l'auditeur (pour une future route API de supervision).""" + return {**auditor_state} + + +async def trigger_auditor_manual() -> dict[str, Any]: + """Déclenche manuellement un cycle de l'auditeur (test/debug).""" + pa_config = _load_persistent_config() + return await run_auditor_cycle(pa_config) diff --git a/core/auth.py b/core/auth.py index 3a2e1ba..456b8bc 100644 --- a/core/auth.py +++ b/core/auth.py @@ -16,15 +16,16 @@ async def execute_chat(body: ExecuteRequestBody, _=Depends(optional_auth)): Date : 2026-06-06 """ -import os -import hmac -import time import hashlib +import hmac +import logging +import os import secrets import threading -import logging -from fastapi import Depends, HTTPException, Request, status -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +import time + +from fastapi import Depends, HTTPException, Request, status, WebSocket +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer logger = logging.getLogger(__name__) @@ -87,7 +88,7 @@ def _get_api_key() -> str: env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".env") env_path = os.path.normpath(env_path) if os.path.exists(env_path): - with open(env_path, "r", encoding="utf-8") as f: + with open(env_path, encoding="utf-8") as f: for line in f: line = line.strip() if line.startswith("MOTEUR_API_KEY="): @@ -236,3 +237,66 @@ def invalidate_key_cache(): global _api_key_cache _api_key_cache = None logger.info("[AUTH] Cache MOTEUR_API_KEY invalidé.") + + +async def require_websocket_auth( + websocket: WebSocket, +) -> str: + """ + Dépendance FastAPI : authentification obligatoire pour les connexions WebSockets. + + Accepte : + - Un ticket éphémère à usage unique dans l'URL (?ticket=) + - Un token Bearer dans l'URL (?token=) pour rétrocompatibilité + - Un en-tête standard 'Authorization: Bearer ' (si envoyé par le client) + + Ferme la connexion avec le code 1008 (Policy Violation) et lève WebSocketDisconnect + si le token/ticket est absent ou invalide. + """ + import hmac + from fastapi import WebSocketDisconnect + + required_key = _get_api_key() + + # Fail-closed : pas de clé serveur → refus (jamais d'accès libre). + if not required_key: + logger.error( + "[AUTH] ❌ MOTEUR_API_KEY non configurée : refus fail-closed sur WebSocket." + ) + await websocket.close(code=1008) + raise WebSocketDisconnect(1008) + + # 1. Vérification via ticket éphémère (SSE/WS) + ticket = websocket.query_params.get("ticket", "") + if ticket: + if verify_and_consume_ticket(ticket): + return "ticket" + logger.warning("[AUTH] ❌ Ticket WebSocket invalide ou expiré.") + await websocket.close(code=1008) + raise WebSocketDisconnect(1008) + + # 2. Vérification via token dans la query string (?token=) + token = websocket.query_params.get("token", "") + + # 3. Alternative : vérification via header Authorization + if not token: + auth_header = websocket.headers.get("authorization", "") + if auth_header.startswith("Bearer "): + token = auth_header[7:] + + if not token: + logger.warning( + "[AUTH] ❌ Authentification WebSocket requise. Token ou ticket manquant." + ) + await websocket.close(code=1008) + raise WebSocketDisconnect(1008) + + if not hmac.compare_digest(token, required_key): + logger.warning( + f"[AUTH] ❌ Token WebSocket invalide reçu (empreinte {_fingerprint(token)})" + ) + await websocket.close(code=1008) + raise WebSocketDisconnect(1008) + + return token + diff --git a/core/billing_sync.py b/core/billing_sync.py new file mode 100644 index 0000000..0eb5aba --- /dev/null +++ b/core/billing_sync.py @@ -0,0 +1,62 @@ +""" +core/billing_sync.py — Pont vers api/services/billing_service.py. + +`api/routes/billing.py` importe historiquement `core.billing_sync` pour les +routes /api/billing/sync, /sync/status et /launch-chrome. Le refactoring A8 a +extrait la logique réelle (scraping headless GCP + Claude via +tools/billing_scraper.js) vers api/services/billing_service.py sans recréer +ce module pont : les 3 routes levaient un ImportError (→ 500) depuis lors. +Ce module réexpose la même API pour rebrancher les routes sans dupliquer +la logique de scraping. +""" + +import logging +import os + +from api.services.billing_service import billing_sync_state, run_billing_sync_flow + +logger = logging.getLogger(__name__) + + +async def run_billing_sync() -> dict: + """Lance la synchronisation de facturation (GCP + Claude) et attend le résultat.""" + await run_billing_sync_flow() + return dict(billing_sync_state) + + +def get_sync_status() -> dict: + """Retourne l'état courant (idle/running/needs_login/success/error) de la sync.""" + return dict(billing_sync_state) + + +async def launch_chrome_billing() -> dict: + """ + Force l'ouverture d'une fenêtre Chrome visible sur les pages de facturation + (GCP + Claude), utile pour une ré-authentification manuelle. + """ + import asyncio as _asyncio + + global billing_sync_state + node_cmd = ["node", "tools/billing_scraper.js", "--headless=false"] + cwd = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + billing_sync_state["status"] = "running" + billing_sync_state["message"] = "Ouverture de Chrome pour vérification manuelle…" + + proc = await _asyncio.create_subprocess_exec( + *node_cmd, + stdout=_asyncio.subprocess.PIPE, + stderr=_asyncio.subprocess.PIPE, + cwd=cwd, + ) + stdout, stderr = await proc.communicate() + + if proc.returncode == 0: + from api.services.billing_service import handle_scraper_success + await handle_scraper_success(stdout.decode("utf-8", errors="ignore").strip()) + else: + billing_sync_state["status"] = "error" + billing_sync_state["message"] = stderr.decode("utf-8", errors="ignore").strip()[:300] + logger.error(f"[BILLING] Échec lancement Chrome : {billing_sync_state['message']}") + + return dict(billing_sync_state) diff --git a/core/custom_agents.py b/core/custom_agents.py new file mode 100644 index 0000000..57b39e8 --- /dev/null +++ b/core/custom_agents.py @@ -0,0 +1,62 @@ +""" +core/custom_agents.py — Agents custom déclarés dans config.json (#T159). + +Source de vérité de la section `custom_agents` de config.json, partagée par : +- core/factory.py et services/pipeline_service.py (enregistrement réel dans + l'Engine — un seul helper pour ne pas recréer une divergence de sites, #T213) ; +- api/routes/agents_crud.py (CRUD IHM). + +Un agent custom = un clone d'ExecutorAgent (boucle ReAct + outils) avec son +nom, son tier et éventuellement son prompt système propre. Complémentaire des +agents custom du WorkflowBridge (dessinés dans l'éditeur de workflow), qui +gardent leur mécanisme historique. +""" + +import logging + +logger = logging.getLogger(__name__) + + +def load_custom_agent_entries(config: dict, only_enabled: bool = False) -> list[dict]: + """Entrées valides de config['custom_agents'] (dicts nommés uniquement).""" + entries = [ + e for e in config.get("custom_agents", []) + if isinstance(e, dict) and e.get("name") + ] + if only_enabled: + entries = [e for e in entries if e.get("enabled", True)] + return entries + + +def register_config_custom_agents(engine, gateway, tool_registry, config) -> int: + """ + Instancie et enregistre dans l'Engine les agents custom activés de config.json. + + Ignore silencieusement les noms déjà enregistrés (priorité aux agents cœur + et à ceux du WorkflowBridge, enregistrés avant). Retourne le nombre ajouté. + """ + from agents.executor import ExecutorAgent + + added = 0 + for entry in load_custom_agent_entries(config, only_enabled=True): + name = entry["name"] + if name in getattr(engine, "agents", {}): + continue + try: + agent = ExecutorAgent( + llm_gateway=gateway, + tool_registry=tool_registry, + provider_name=entry.get("tier", "automatique"), + ) + agent.name = name + agent.system_prompt = entry.get("system_prompt") or ( + f"Tu es l'agent custom '{name}' ({entry.get('label', name)}).\n" + f"Tu hérites de la boucle ReAct d'ExecutorAgent avec accès à tous les outils.\n" + f"Exécute la tâche qui t'est assignée de manière rigoureuse et pédagogue." + ) + engine.register_agent(agent) + added += 1 + logger.info(f"[CUSTOM_AGENTS] Agent '{name}' enregistré (tier={entry.get('tier', 'automatique')}).") + except Exception as e: + logger.error(f"[CUSTOM_AGENTS] Échec de l'enregistrement de '{name}' : {e}") + return added diff --git a/core/daemon_loop.py b/core/daemon_loop.py index 0c7343e..8b243ab 100644 --- a/core/daemon_loop.py +++ b/core/daemon_loop.py @@ -1,4 +1,4 @@ -""" +""" core/daemon_loop.py — Boucle de fond asyncio pour le Démon Sentinelle 24/7. Service asyncio autonome (PAS un BaseAgent) lancé au startup de gui_server.py. diff --git a/core/langfuse_bridge.py b/core/langfuse_bridge.py index f868f63..097e63b 100644 --- a/core/langfuse_bridge.py +++ b/core/langfuse_bridge.py @@ -1,4 +1,4 @@ -""" +""" core/langfuse_bridge.py — Axe 1 — Bridge Langfuse pour l'observabilité LLM avancée. Singleton qui envoie les traces (sessions, agents, appels LLM) vers une instance diff --git a/core/mcp_bridge.py b/core/mcp_bridge.py index 3761535..bb08f42 100644 --- a/core/mcp_bridge.py +++ b/core/mcp_bridge.py @@ -1,4 +1,4 @@ -import os +import os import sys import json import logging diff --git a/core/ml_router.py b/core/ml_router.py index e1ae213..4a9beb6 100644 --- a/core/ml_router.py +++ b/core/ml_router.py @@ -1,4 +1,4 @@ -""" +""" core/ml_router.py — Classifieur ML pour le routing des requêtes. Remplace le slow-path LLM par une LogisticRegression sklearn diff --git a/core/pricing.py b/core/pricing.py index 0c9b92c..89dc43f 100644 --- a/core/pricing.py +++ b/core/pricing.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ core/pricing.py — Barème de prix unifié (P1-2.4). @@ -18,9 +17,10 @@ JSON (ex. alias "gemini", "minimax", "claude") et le cas où le fichier manque. """ -import os +import datetime import json import logging +import os logger = logging.getLogger(__name__) @@ -102,7 +102,7 @@ def load_pricing_table() -> dict: return _cache["table"] try: - with open(_PRICING_FILE, "r", encoding="utf-8") as f: + with open(_PRICING_FILE, encoding="utf-8") as f: data = json.load(f) table = _build_table(data) _cache["table"] = table @@ -114,6 +114,26 @@ def load_pricing_table() -> dict: return {} +def is_deepseek_peak_hours(now_utc: datetime.datetime) -> bool: + """ + Détermine si l'instant donné en UTC correspond aux heures de pic de DeepSeek. + + À partir de mi-juillet 2026 (le 15 juillet inclusivement), les heures de pic sont : + - 1:00 AM à 4:00 AM UTC (9:00 AM à 12:00 noon UTC+8) + - 6:00 AM à 10:00 AM UTC (2:00 PM à 6:00 PM UTC+8) + """ + if now_utc.date() < datetime.date(2026, 7, 15): + return False + + t = now_utc.time() + peak1_start = datetime.time(1, 0) + peak1_end = datetime.time(4, 0) + peak2_start = datetime.time(6, 0) + peak2_end = datetime.time(10, 0) + + return (peak1_start <= t <= peak1_end) or (peak2_start <= t <= peak2_end) + + def get_model_pricing(model: str) -> dict: """ Retourne {"input": usd/token, "output": usd/token} pour `model`. @@ -133,13 +153,26 @@ def get_model_pricing(model: str) -> dict: return dict(_FREE) table = load_pricing_table() + res = None if m in table: - return table[m] - for key in sorted(table, key=len, reverse=True): - if key in m: - return table[key] - - for key, price in FALLBACK_PRICING.items(): - if key in m: - return price - return dict(_FREE) + res = dict(table[m]) + else: + for key in sorted(table, key=len, reverse=True): + if key in m: + res = dict(table[key]) + break + if res is None: + for key, price in FALLBACK_PRICING.items(): + if key in m: + res = dict(price) + break + if res is None: + res = dict(_FREE) + + # Tarification pic-creux pour DeepSeek à partir de mi-juillet 2026 + if "deepseek" in m and res: + now_utc = datetime.datetime.now(datetime.timezone.utc) + if is_deepseek_peak_hours(now_utc): + res = {k: v * 2.0 for k, v in res.items()} + + return res diff --git a/core/prompt_loader.py b/core/prompt_loader.py new file mode 100644 index 0000000..63b2922 --- /dev/null +++ b/core/prompt_loader.py @@ -0,0 +1,105 @@ +""" +core/prompt_loader.py — Prompts systèmes des agents externalisés en Markdown (#T188). + +Les prompts des agents (reviewer, planner, tool_maker, prompt_engineer) vivent +dans `contexte_ia/03_Software/prompts_agents/.md` plutôt qu'en dur dans +le Python : modifiables par l'IDE/IHM sans redéploiement de code. + +Contrat fail-safe : si le fichier Markdown est absent ou illisible (ex. prod +Deck déployée par overlay sans le dossier contexte_ia), l'agent retombe sur son +prompt par défaut codé en dur — le moteur ne casse JAMAIS pour un prompt manquant. + +Résolution du dossier : +1. Variable d'env `MOTEUR_PROMPTS_DIR` si définie (chemin absolu). +2. Sinon `/contexte_ia/03_Software/prompts_agents/` + (même convention de racine workspace que tools/system.py). + +Cache : contenu mémorisé par (chemin, mtime) — une édition du fichier est +prise en compte au prochain chargement sans redémarrage, sans relire le disque +à chaque requête si rien n'a changé. +""" + +import logging +import os +import threading + +logger = logging.getLogger(__name__) + +_CACHE: dict[str, tuple[float, str]] = {} +_CACHE_LOCK = threading.Lock() + + +def get_prompts_dir() -> str: + """Retourne le dossier des prompts agents (env MOTEUR_PROMPTS_DIR prioritaire).""" + env_dir = os.environ.get("MOTEUR_PROMPTS_DIR", "").strip() + if env_dir: + return env_dir + moteur_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + return os.path.join( + os.path.dirname(moteur_root), "contexte_ia", "03_Software", "prompts_agents" + ) + + +def prompt_file_path(agent_name: str) -> str: + """Chemin du fichier Markdown du prompt d'un agent (existant ou non).""" + # Nom de fichier strictement dérivé du nom d'agent (pas de traversée possible). + safe_name = "".join(c for c in agent_name if c.isalnum() or c in ("_", "-")) + return os.path.join(get_prompts_dir(), f"{safe_name}.md") + + +def load_agent_prompt(agent_name: str, default: str) -> str: + """ + Charge le prompt système d'un agent depuis son Markdown, sinon `default`. + + Le fichier est du Markdown brut : tout son contenu (strip) devient le + system prompt. Un fichier vide est traité comme absent (repli sur default). + """ + path = prompt_file_path(agent_name) + try: + mtime = os.path.getmtime(path) + except OSError: + return default + + with _CACHE_LOCK: + cached = _CACHE.get(path) + if cached and cached[0] == mtime: + return cached[1] or default + + try: + with open(path, encoding="utf-8") as f: + content = f.read().strip() + except OSError as e: + logger.warning(f"[PROMPTS] Lecture impossible {path} : {e} — prompt par défaut utilisé") + return default + + with _CACHE_LOCK: + _CACHE[path] = (mtime, content) + + if not content: + return default + logger.debug(f"[PROMPTS] Prompt '{agent_name}' chargé depuis {path}") + return content + + +def save_agent_prompt(agent_name: str, content: str) -> str: + """ + Écrit le prompt Markdown d'un agent (création du dossier si besoin). + + Retourne le chemin écrit. Utilisé par PUT /api/agents/{name} (IHM). + """ + path = prompt_file_path(agent_name) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8", newline="\n") as f: + f.write(content.strip() + "\n") + with _CACHE_LOCK: + _CACHE.pop(path, None) + return path + + +def has_external_prompt(agent_name: str) -> bool: + """True si un fichier Markdown non vide existe pour cet agent.""" + path = prompt_file_path(agent_name) + try: + return os.path.getsize(path) > 0 + except OSError: + return False diff --git a/core/provider_balances.py b/core/provider_balances.py new file mode 100644 index 0000000..32e7879 --- /dev/null +++ b/core/provider_balances.py @@ -0,0 +1,180 @@ +""" +core/provider_balances.py — Solde en direct auprès des providers qui exposent +une API officielle dédiée (lecture seule, clé déjà présente dans .env). + +Contrairement au scraping Chrome (GCP/Claude.ai, cf. tools/billing_scraper.js ++ core/billing_sync.py), ces providers documentent un endpoint HTTP simple : +pas de navigateur headless nécessaire, juste un GET authentifié. + +Sources vérifiées par recherche web + test live le 07/07/2026 (pas de simple +endpoint de solde documenté pour Mistral/Cohere/Cerebras/Zhipu à cette date — +ne pas en inventer un) : +- DeepSeek : https://api-docs.deepseek.com/api/get-user-balance +- OpenRouter : https://openrouter.ai/docs/faq (GET /api/v1/credits) +- MiniMax : GET /v1/token_plan/remains (testé en direct : le compte d'Axel + n'a pas de forfait "Token Plan" actif, facturation au solde pay-as-you-go — + MiniMax n'a PAS d'API de solde pour ce mode, uniquement pour le forfait). +- xAI : Management API (https://docs.x.ai/developers/rest-api-reference/management/billing), + GET /v1/billing/teams/{team_id}/prepaid/balance, clé "Management Key" séparée + de la clé API standard. Testé en direct 07/07/2026 : `total.val` est en + CENTS et SIGNE INVERSÉ par rapport au solde réel (confirmé par Axel : compte + à $10.00, API renvoyait total.val="-1000") → balance_usd = -val / 100. +""" + +import logging +import os + +import aiohttp + +logger = logging.getLogger(__name__) + + +async def fetch_deepseek_balance_usd() -> float | None: + """ + Solde DeepSeek réel via l'API officielle, ou None si clé absente/indisponible. + Ne renvoie une valeur que si le compte est facturé en USD (le endpoint peut + aussi renvoyer du CNY selon la région du compte) — sinon laisser le fallback + scrapé (core.token_tracker.real_billing) prendre le relais. + """ + api_key = os.environ.get("DEEPSEEK_API_KEY") + if not api_key: + return None + try: + async with aiohttp.ClientSession() as session: + async with session.get( + "https://api.deepseek.com/user/balance", + headers={"Authorization": f"Bearer {api_key}"}, + timeout=aiohttp.ClientTimeout(total=5), + ) as resp: + if resp.status != 200: + return None + data = await resp.json() + for info in data.get("balance_infos", []): + if info.get("currency") == "USD": + return float(info["total_balance"]) + return None + except Exception as e: + logger.warning(f"[BALANCE] DeepSeek indisponible : {e}") + return None + + +async def fetch_minimax_token_plan_status() -> dict | None: + """ + Statut du forfait "Token Plan" MiniMax via l'API officielle (GET + /v1/token_plan/remains, même clé que les appels moteur). Ne donne PAS un + solde pay-as-you-go (MiniMax n'expose aucune API pour ce mode) — sert + juste à distinguer les deux systèmes de facturation MiniMax (forfait + prépayé vs pay-as-you-go au solde), utile pour comprendre pourquoi aucun + chiffre n'est disponible automatiquement. + """ + api_key = os.environ.get("MINIMAX_API_KEY") + if not api_key: + return None + try: + async with aiohttp.ClientSession() as session: + async with session.get( + "https://api.minimax.io/v1/token_plan/remains", + headers={"Authorization": f"Bearer {api_key}"}, + timeout=aiohttp.ClientTimeout(total=5), + ) as resp: + if resp.status != 200: + return None + data = await resp.json() + has_plan = data.get("model_remains") is not None + return {"has_token_plan": has_plan, "model_remains": data.get("model_remains")} + except Exception as e: + logger.warning(f"[BALANCE] MiniMax token_plan indisponible : {e}") + return None + + +async def fetch_xai_balance_usd() -> float | None: + """ + Solde prépayé xAI réel via la Management API officielle (nécessite + XAI_MANAGEMENT_API_KEY + XAI_TEAM_ID dans .env — clé "Management Key" + distincte de XAI_API_KEY, créée dans console.x.ai avec accès "Billing" + en lecture seule uniquement). + + `total.val` de la réponse est en cents et son signe est inversé par + rapport au solde réel (confirmé empiriquement avec Axel le 07/07/2026 : + compte à $10.00, API renvoyait total.val="-1000") — d'où le `-val/100`. + """ + api_key = os.environ.get("XAI_MANAGEMENT_API_KEY") + team_id = os.environ.get("XAI_TEAM_ID") + if not api_key or not team_id: + return None + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"https://management-api.x.ai/v1/billing/teams/{team_id}/prepaid/balance", + headers={"Authorization": f"Bearer {api_key}"}, + timeout=aiohttp.ClientTimeout(total=5), + ) as resp: + if resp.status != 200: + return None + data = await resp.json() + total_val = data.get("total", {}).get("val") + if total_val is None: + return None + return round(-float(total_val) / 100, 2) + except Exception as e: + logger.warning(f"[BALANCE] xAI indisponible : {e}") + return None + + +async def fetch_openrouter_key_info() -> dict | None: + """ + Usage réel + limites de la clé OpenRouter via l'API officielle (GET + /api/v1/key) : limit (plafond de dépense configuré sur la clé, None si + aucun), limit_remaining, usage (all-time), usage_daily/weekly/monthly. + Plus riche que /credits (solde global) — utile pour une vraie page de + gestion de limites, pas juste un chiffre de solde. + """ + api_key = os.environ.get("OPENROUTER_API_KEY") + if not api_key: + return None + try: + async with aiohttp.ClientSession() as session: + async with session.get( + "https://openrouter.ai/api/v1/key", + headers={"Authorization": f"Bearer {api_key}"}, + timeout=aiohttp.ClientTimeout(total=5), + ) as resp: + if resp.status != 200: + return None + return (await resp.json()).get("data", {}) + except Exception as e: + logger.warning(f"[BALANCE] OpenRouter /key indisponible : {e}") + return None + + +async def fetch_openrouter_credits() -> dict | None: + """ + Crédits OpenRouter réels via l'API officielle : {total_credits, total_usage, + balance}. None si clé absente/indisponible (`total_credits - total_usage` + est une estimation ; le tableau de bord OpenRouter reste la source + d'autorité en cas de doute, cf. FAQ officielle). + """ + api_key = os.environ.get("OPENROUTER_API_KEY") + if not api_key: + return None + try: + async with aiohttp.ClientSession() as session: + async with session.get( + "https://openrouter.ai/api/v1/credits", + headers={"Authorization": f"Bearer {api_key}"}, + timeout=aiohttp.ClientTimeout(total=5), + ) as resp: + if resp.status != 200: + logger.warning(f"[BALANCE] OpenRouter HTTP {resp.status} : {(await resp.text())[:200]}") + return None + data = (await resp.json()).get("data", {}) + total_credits = float(data.get("total_credits", 0.0)) + total_usage = float(data.get("total_usage", 0.0)) + return { + "total_credits": total_credits, + "total_usage": total_usage, + "balance": round(total_credits - total_usage, 4), + } + except Exception as e: + logger.warning(f"[BALANCE] OpenRouter indisponible : {e}") + return None diff --git a/core/tab5_pusher.py b/core/tab5_pusher.py index 257c801..9f9cff8 100644 --- a/core/tab5_pusher.py +++ b/core/tab5_pusher.py @@ -1,4 +1,4 @@ -""" +""" core/tab5_pusher.py — Push d'alertes et suggestions vers le Tab5 M5Stack via HA. Communication moteur → Tab5 par entités Home Assistant : diff --git a/core/toolmaker_sandbox.py b/core/toolmaker_sandbox.py new file mode 100644 index 0000000..4e04df7 --- /dev/null +++ b/core/toolmaker_sandbox.py @@ -0,0 +1,217 @@ +""" +core/toolmaker_sandbox.py — Validation distante du code candidat ToolMaker (#T207). + +Remplace l'exécution locale `subprocess.run` (RCE : le code généré par le LLM +tournait avec les droits du process moteur sur le Deck) par un runner GitHub +Actions jetable : + +1. Création d'une branche éphémère `toolmaker/validate-` via l'API Git Data + (blob → tree → commit → ref : un seul événement push, donc un seul run) ; +2. Le workflow `.github/workflows/toolmaker_validate.yml` exécute le candidat + dans une VM GitHub isolée (aucun accès réseau au LAN Deck/HA, GITHUB_TOKEN + du job sans aucune permission) et échoue si `SANDBOX_OK` n'apparaît pas ; +3. Le verdict (conclusion du run) est interrogé par l'API REST Actions en + polling asyncio ; +4. La branche éphémère est supprimée dans tous les cas (finally). + +Choix SYNCHRONE (le flux attend le verdict, ~30 s à 2 min) plutôt qu'asynchrone +avec notification après coup : le ToolMaker n'est pas sur un chemin interactif +(déclenché par le SkillStore en tâche de fond), son `invoke()` attendait déjà +une boucle ReAct multi-appels LLM du même ordre de durée, et un mode asynchrone +exigerait de persister un état « en attente de validation » plus un mécanisme +de notification/reprise — de la machinerie sans bénéfice ici. Le polling est +fait avec `await asyncio.sleep`, il ne bloque jamais l'event loop. + +Sécurité (fail-closed) : sans PAT dédié (`MOTEUR_TOOLMAKER_PAT`), la validation +ÉCHOUE — il n'y a aucun repli vers une exécution locale. Le PAT doit être un +fine-grained token limité à CE dépôt (Contents: read/write, Actions: read), +distinct de `GITHUB_TOKEN` (clé LLM GitHub Models dans ce projet) et de tout +PAT existant plus large (cf. audit config Antigravity). +""" + +import asyncio +import logging +import os +import time +import uuid + +logger = logging.getLogger(__name__) + +GITHUB_API = "https://api.github.com" + +# Chemin du candidat dans la branche éphémère — doit correspondre au workflow. +CANDIDATE_PATH = ".toolmaker_sandbox/candidate_full.py" + + +def _get_pat() -> str | None: + """PAT dédié à la sandbox distante (fine-grained, scope minimal, ce dépôt).""" + return os.environ.get("MOTEUR_TOOLMAKER_PAT") or None + + +def _get_repo() -> str: + """Dépôt cible au format owner/repo.""" + return os.environ.get("MOTEUR_TOOLMAKER_REPO", "Axellum/moteur_agents") + + +async def _gh(client, method: str, path: str, **kwargs): + """Appel REST GitHub : lève httpx.HTTPStatusError si statut >= 400.""" + response = await client.request(method, f"{GITHUB_API}{path}", **kwargs) + response.raise_for_status() + return response + + +async def validate_candidate_remote( + full_code: str, + timeout_s: float | None = None, + poll_interval_s: float = 6.0, + _client=None, +) -> dict: + """ + Pousse le code candidat sur une branche éphémère, attend le verdict du + workflow `ToolMaker Validate`, puis supprime la branche. + + Args: + full_code: Code candidat complet (outil + bloc de test SANDBOX_OK). + timeout_s: Délai global d'attente du verdict (défaut : env + MOTEUR_TOOLMAKER_TIMEOUT_S ou 300 s). + poll_interval_s: Intervalle de polling de l'API Actions. + _client: Client httpx injectable (tests). + + Returns: + {"passed": bool, "error": str | None, "run_url": str | None} + """ + pat = _get_pat() + if not pat: + # Fail-closed : jamais de repli vers une exécution locale. + return { + "passed": False, + "error": ( + "MOTEUR_TOOLMAKER_PAT absent du .env — validation distante " + "impossible, outil rejeté (aucun repli local par sécurité). " + "Créer un fine-grained PAT limité à ce dépôt " + "(Contents: read/write, Actions: read)." + ), + "run_url": None, + } + + if timeout_s is None: + try: + timeout_s = float(os.environ.get("MOTEUR_TOOLMAKER_TIMEOUT_S", "300")) + except ValueError: + timeout_s = 300.0 + + repo = _get_repo() + branch = f"toolmaker/validate-{uuid.uuid4().hex[:12]}" + + import httpx + + headers = { + "Authorization": f"Bearer {pat}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + owns_client = _client is None + client = _client or httpx.AsyncClient(headers=headers, timeout=30.0) + branch_created = False + try: + # 1. Branche de base (défaut du dépôt) et son commit/tree. + repo_info = (await _gh(client, "GET", f"/repos/{repo}")).json() + default_branch = repo_info.get("default_branch", "master") + base_ref = ( + await _gh(client, "GET", f"/repos/{repo}/git/ref/heads/{default_branch}") + ).json() + base_sha = base_ref["object"]["sha"] + base_commit = ( + await _gh(client, "GET", f"/repos/{repo}/git/commits/{base_sha}") + ).json() + base_tree_sha = base_commit["tree"]["sha"] + + # 2. blob → tree → commit → ref : un seul push, donc un seul run. + blob = ( + await _gh( + client, "POST", f"/repos/{repo}/git/blobs", + json={"content": full_code, "encoding": "utf-8"}, + ) + ).json() + tree = ( + await _gh( + client, "POST", f"/repos/{repo}/git/trees", + json={ + "base_tree": base_tree_sha, + "tree": [{ + "path": CANDIDATE_PATH, + "mode": "100644", + "type": "blob", + "sha": blob["sha"], + }], + }, + ) + ).json() + commit = ( + await _gh( + client, "POST", f"/repos/{repo}/git/commits", + json={ + "message": f"toolmaker: candidat a valider ({branch})", + "tree": tree["sha"], + "parents": [base_sha], + }, + ) + ).json() + await _gh( + client, "POST", f"/repos/{repo}/git/refs", + json={"ref": f"refs/heads/{branch}", "sha": commit["sha"]}, + ) + branch_created = True + logger.info(f"[TOOLMAKER-SANDBOX] Branche éphémère poussée : {branch}") + + # 3. Polling du run déclenché par le push (seul workflow sur ces branches). + deadline = time.monotonic() + timeout_s + run_url = None + while time.monotonic() < deadline: + runs = ( + await _gh( + client, "GET", + f"/repos/{repo}/actions/runs", + params={"branch": branch, "event": "push", "per_page": 5}, + ) + ).json().get("workflow_runs", []) + if runs: + run = runs[0] + run_url = run.get("html_url") + if run.get("status") == "completed": + conclusion = run.get("conclusion") + if conclusion == "success": + logger.info( + f"[TOOLMAKER-SANDBOX] ✅ Verdict runner : SANDBOX_OK ({run_url})" + ) + return {"passed": True, "error": None, "run_url": run_url} + return { + "passed": False, + "error": f"Runner GitHub Actions : conclusion '{conclusion}'", + "run_url": run_url, + } + await asyncio.sleep(poll_interval_s) + + return { + "passed": False, + "error": f"Timeout {timeout_s:.0f}s : verdict du runner non obtenu", + "run_url": run_url, + } + + except Exception as e: + # Toute erreur API = rejet (fail-closed), jamais d'exécution locale. + logger.warning(f"[TOOLMAKER-SANDBOX] Erreur API GitHub : {e}") + return {"passed": False, "error": f"Erreur API GitHub : {e}", "run_url": None} + finally: + if branch_created: + try: + await _gh(client, "DELETE", f"/repos/{repo}/git/refs/heads/{branch}") + logger.info(f"[TOOLMAKER-SANDBOX] Branche éphémère supprimée : {branch}") + except Exception as e: + logger.warning( + f"[TOOLMAKER-SANDBOX] Échec de suppression de {branch} : {e} " + "(à nettoyer manuellement)" + ) + if owns_client: + await client.aclose() diff --git a/core/watchdog.py b/core/watchdog.py index 350599c..db68963 100644 --- a/core/watchdog.py +++ b/core/watchdog.py @@ -1,4 +1,4 @@ -""" +""" core/watchdog.py — Watchdog MQTT 24/7 pour la domotique. Architecture 3 niveaux : diff --git a/deploy/deploy_worker.ps1 b/deploy/deploy_worker.ps1 index cc9c121..0f61115 100644 --- a/deploy/deploy_worker.ps1 +++ b/deploy/deploy_worker.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS Deploie le Worker Standalone sur la Freebox VM via SSH/SCP. diff --git a/deploy/docker-compose-langfuse.yml b/deploy/docker-compose-langfuse.yml index 808a450..21f1832 100644 --- a/deploy/docker-compose-langfuse.yml +++ b/deploy/docker-compose-langfuse.yml @@ -1,4 +1,4 @@ -version: '3.8' +version: '3.8' services: postgres: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b17a2e6..c82c57c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,4 +1,4 @@ -# Architecture du vromvrom-engine — V11 +# Architecture du vromvrom-engine — V11 > **Dernière mise à jour** : 2026-06-12 > **Version code** : V11 (Circuit Breaker V12 intégré) diff --git a/ha_commands.json.example b/ha_commands.json.example index b161755..9d40f9d 100644 --- a/ha_commands.json.example +++ b/ha_commands.json.example @@ -1,16 +1,21 @@ { - "_comment": "Exemple de commandes vocales HA — copier vers ha_commands.json et adapter à votre installation.", + "_comment": "Phrases exactes après normalisation router (minuscules, sans accents finaux). entity_id vérifiés contre HA Freebox live 2026-07-14.", "commands": [ { "phrases": [ "allume le salon", "allume la lumiere du salon", "allume la lumière du salon", + "allume la lumiere salon", + "allumé le salon", + "allumee le salon", "mets la lumiere du salon", - "mets le salon" + "mets la lumière du salon", + "mets le salon", + "salon allume" ], "service": "light.turn_on", - "entity_id": "light.living_room" + "entity_id": "light.salon" }, { "phrases": [ @@ -18,46 +23,99 @@ "éteins le salon", "eteins la lumiere du salon", "éteins la lumière du salon", + "eteins les lumieres du salon", + "éteins les lumières du salon", + "eteindre la lumiere du salon", + "éteindre la lumière du salon", + "eteindre les lumieres du salon", + "coupe la lumiere du salon", + "coupe les lumieres du salon", "coupe le salon" ], "service": "light.turn_off", - "entity_id": "light.living_room" + "entity_id": "light.salon" }, { "phrases": [ "allume la chambre", - "allume la lumiere de la chambre" + "allume la lumiere de la chambre", + "mets la chambre" ], "service": "light.turn_on", - "entity_id": "light.bedroom" + "entity_id": "light.h6008_2" }, { "phrases": [ "eteins la chambre", "éteins la chambre", - "eteins la lumiere de la chambre" + "eteins la lumiere de la chambre", + "éteins la lumière de la chambre", + "eteins les lumieres de la chambre", + "éteins les lumières de la chambre" ], "service": "light.turn_off", - "entity_id": "light.bedroom" + "entity_id": "light.h6008_2" }, { "phrases": [ + "allume le chevet salon", + "allume la lumiere chevet salon" + ], + "service": "light.turn_on", + "entity_id": "light.h6008" + }, + { + "phrases": [ + "eteins le chevet salon", + "éteins le chevet salon" + ], + "service": "light.turn_off", + "entity_id": "light.h6008" + }, + { + "phrases": [ + "allume le spot pied", + "allume la lumiere pied" + ], + "service": "light.turn_on", + "entity_id": "light.h600c" + }, + { + "phrases": [ + "eteins le spot pied", + "éteins le spot pied" + ], + "service": "light.turn_off", + "entity_id": "light.h600c" + }, + { + "phrases": [ + "ouvre le volet serre", + "ouvre le volet de la serre", + "monte le volet serre", "ouvre le volet du salon", "ouvre les volets du salon", - "monte le volet du salon" + "monte le volet du salon", + "monte les volets du salon" ], - "service": "script.blind_action", + "service": "script.tab5_volet_action", "entity_id": "", "service_data": {"action": "open"} }, { "phrases": [ + "ferme le volet serre", + "ferme le volet de la serre", + "baisse le volet serre", "ferme le volet du salon", "ferme les volets du salon", "baisse le volet du salon", - "descend les volets du salon" + "descend le volet du salon", + "descends les volets du salon", + "descend les volets du salon", + "baisse les volets du salon" ], - "service": "script.blind_action", + "service": "script.tab5_volet_action", "entity_id": "", "service_data": {"action": "close"} }, @@ -65,28 +123,54 @@ "phrases": [ "stop", "stoppe", + "arrete", + "arrête", "arrete le volet", - "stop les volets du salon" + "arrête le volet", + "stop le volet", + "stop les volets", + "stop les volets du salon", + "arrete les volets du salon" ], - "service": "script.blind_action", + "service": "script.tab5_volet_action", "entity_id": "", "service_data": {"action": "stop"} }, { "phrases": [ "allume la clim du salon", - "allume la climatisation du salon" + "allume la climatisation du salon", + "demarre la clim salon" ], "service": "climate.turn_on", - "entity_id": "climate.living_room" + "entity_id": "climate.salon_daikinap71273_clim" }, { "phrases": [ "eteins la clim du salon", - "éteins la clim du salon" + "éteins la clim du salon", + "arrete la clim salon" ], "service": "climate.turn_off", - "entity_id": "climate.living_room" + "entity_id": "climate.salon_daikinap71273_clim" + }, + { + "phrases": [ + "active le micro", + "active le wake word", + "active okay nabu" + ], + "service": "switch.turn_on", + "entity_id": "switch.m5stack_tab5_home_assistant_hmi_tab5_wake_word_active" + }, + { + "phrases": [ + "desactive le micro", + "désactive le micro", + "coupe le wake word" + ], + "service": "switch.turn_off", + "entity_id": "switch.m5stack_tab5_home_assistant_hmi_tab5_wake_word_active" } ] } diff --git a/prepare_oss_export.ps1 b/prepare_oss_export.ps1 index 07a7a5a..4bcf551 100644 --- a/prepare_oss_export.ps1 +++ b/prepare_oss_export.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS Prépare le dossier OSS vromvrom-engine à partir du repo privé. Copie le code source en excluant les données personnelles et secrets. @@ -215,7 +215,9 @@ Write-Host "" Write-Host "Prochaines étapes :" -ForegroundColor Cyan Write-Host " 1. Vérifier le scan de sécurité ci-dessus" Write-Host " 2. cd H:\vromvrom-engine-oss" -Write-Host " 3. git init && git remote add origin " -Write-Host " 4. git add -p (revue patch par patch)" -Write-Host " 5. git commit -m 'feat: initial OSS release — moteur multi-agents V12'" -Write-Host " 6. git push -u origin main" +Write-Host " 3. python scripts/oss_secrets_scan.py (bloquant, même check que la CI)" +Write-Host " 4. git checkout -b sync/YYYY-MM-DD" +Write-Host " 5. git add -p (revue patch par patch) && git commit" +Write-Host " 6. git push -u origin HEAD && gh pr create" +Write-Host " 7. Attendre CI vert (secrets scan + pytest) puis merger la PR" +Write-Host " ⛔ Ne jamais pousser directement sur main (branche protégée)." diff --git a/pricing_strategy.json.example b/pricing_strategy.json.example new file mode 100644 index 0000000..b48087c --- /dev/null +++ b/pricing_strategy.json.example @@ -0,0 +1,559 @@ +{ + "subscriptions": [ + { + "name": "Claude Pro", + "cost_monthly_usd": 20.0, + "billing_period": "Mensuel", + "rolling_window_hours": 5, + "estimated_messages_limit": 45, + "hourly_token_limit": 1500000, + "monthly_token_limit": 35000000, + "models": [ + "claude-opus-4-8", + "claude-opus-4-7", + "claude-sonnet-4-6", + "claude-haiku-4-5", + "claude-opus-4-5", + "claude-sonnet-4-5" + ], + "advantages": "Modèle de développement de pointe. SWE-bench 88.2%, OSWorld 94.5%.", + "recommended_use": "Tâches complexes d'écriture et de refactoring de code (Tier Fort)." + }, + { + "name": "Gemini Advanced (Google One AI Pro)", + "cost_monthly_usd": 19.99, + "billing_period": "Mensuel", + "rolling_window_hours": 5, + "estimated_messages_limit": 50, + "hourly_token_limit": 4000000, + "monthly_token_limit": 100000000, + "models": [ + "gemini-3.5-flash-high-cli", + "gemini-3.5-flash-medium-cli", + "gemini-cli" + ], + "advantages": "Immense fenêtre (1M-2M), Antigravity IDE inclus, très rapide.", + "recommended_use": "Lecture gros fichiers, analyse globale, Tier Fort/Moyen." + } + ], + "apis": [ + { + "name": "Gemini Free Tier (AI Studio)", + "type": "Free Tier", + "rates": { + "gemini-2.5-flash": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.0 + }, + "gemini-2.5-flash-lite": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.0, + "note": "Free Tier AI Studio — ultra-rapide 350ms TTFT, 150 tok/s. Successeur 2.0 Flash Lite" + }, + "gemini-3-flash-preview": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.0, + "note": "Preview AI Studio — Gemini 3 Flash avant release stable, thinking inclus" + }, + "gemini-3-pro-image": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.0, + "note": "Free Tier AI Studio — génération images Pro 2026, haute fidélité" + }, + "gemini-3.1-flash-image": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.0, + "note": "Free Tier AI Studio — génération images native via Gemini 3.1 Flash" + }, + "gemini-3.1-flash-lite": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.0 + }, + "gemini-3.1-pro-preview": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.0 + }, + "gemini-3.5-flash": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.0 + }, + "gemini-robotics-er-1.6-preview": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.0, + "note": "Preview expérimental — contrôle robotique (Embodied Reasoning), usage non-production" + } + }, + "advantages": "5 clés Free Tier rotatives via KeyPool. Cache implicite gratuit." + }, + { + "name": "GitHub Models", + "type": "Free Tier", + "rates": { + "github/gpt-4o": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.0, + "note": "GitHub Models gratuit — GPT-4o via Azure AI, quota développeur, 10 RPM" + }, + "github/gpt-4o-mini": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.0, + "note": "GitHub Models gratuit — GPT-4o Mini via Azure AI, rapide, 10 RPM" + }, + "github/meta-llama-3.1-405b-instruct": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.0, + "note": "GitHub Models gratuit — LLaMA 3.1 405B, très puissant, quota limité" + }, + "github/meta-llama-3.3-70b-instruct": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.0, + "note": "GitHub Models gratuit — LLaMA 3.3 70B via Azure, MMLU 88%" + } + }, + "advantages": "Inférence gratuite pour développeurs via Azure AI." + }, + { + "name": "Cerebras API", + "type": "Pay-as-you-go", + "rates": { + "gpt-oss-120b": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.0, + "note": "Plan Gratuit (Cerebras)" + }, + "zai-glm-4.7": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.0, + "note": "Plan Gratuit (Cerebras)" + } + }, + "advantages": "Plan Gratuit (1M tokens/jour). Vitesse exceptionnelle." + }, + { + "name": "OpenRouter API", + "type": "Pay-as-you-go", + "rates": { + "anthropic/claude-3.5-sonnet": { + "input_cost_per_m": 3.0, + "output_cost_per_m": 15.0, + "note": "Payant" + }, + "anthropic/claude-sonnet-5": { + "input_cost_per_m": 2.0, + "output_cost_per_m": 10.0, + "note": "Payant" + }, + "google/gemma-4-26b-a4b-it:free": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.0, + "note": "OpenRouter gratuit — Gemma 4 MoE 26B (4B actifs), vision, instruction-tuned" + }, + "meta-llama/llama-3.2-3b-instruct:free": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.0, + "note": "Plan Gratuit (OpenRouter)" + }, + "meta-llama/llama-3.3-70b-instruct:free": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.0, + "note": "Plan Gratuit (OpenRouter)" + }, + "moonshotai/kimi-k2.7-code": { + "input_cost_per_m": 0.74, + "output_cost_per_m": 3.5, + "note": "Sorti 12/06/2026. Alternative moins chère disponible : moonshotai/kimi-k2-thinking (~$0.60/$2.50)." + }, + "nvidia/nemotron-3-ultra-550b-a55b:free": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.0, + "note": "OpenRouter gratuit — NVIDIA 550B MoE (55B actifs), thinking, MMLU 92%+. Quota limité" + }, + "openai/gpt-5.2": { + "input_cost_per_m": 1.75, + "output_cost_per_m": 14.0, + "note": "GPT-5.5 existe aussi sur OpenRouter (plus récent) mais pricing non confirmé au 03/07/2026 — à vérifier avant de basculer dessus." + }, + "openai/gpt-oss-20b:free": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.0, + "note": "OpenRouter gratuit — OpenAI open-weights 20B. Rapide, bon généraliste" + }, + "qwen/qwen3-coder:free": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.0, + "note": "OpenRouter gratuit — Qwen3 Coder, SWE-bench fort, thinking mode, code agentic" + }, + "qwen/qwen3.7-max": { + "input_cost_per_m": 1.25, + "output_cost_per_m": 3.75 + } + }, + "advantages": "Modèles gratuits et payants. Alternatives en cas de pannes de tiers." + }, + { + "name": "Anthropic API", + "type": "Pay-as-you-go", + "rates": { + "claude-fable-5": { + "input_cost_per_m": 10.0, + "output_cost_per_m": 50.0, + "note": "Thinking toujours actif (non désactivable). Nécessite rétention de données ≥30 jours (indisponible en ZDR). Tarif nettement supérieur à Opus — réserver aux tâches qui le justifient." + }, + "claude-sonnet-5": { + "input_cost_per_m": 2.0, + "output_cost_per_m": 10.0, + "note": "Tarif d'intro 2.0$/10.0$ par M tokens jusqu'au 31/08/2026 (puis 3.0$/15.0$). Thinking adaptatif actif par défaut." + } + }, + "advantages": "API native avec accès aux Batchs" + }, + { + "name": "Zhipu AI (Z.ai)", + "type": "Pay-as-you-go", + "rates": { + "glm-4.5": { + "input_cost_per_m": 0.28, + "output_cost_per_m": 1.1, + "input_cache_hit_cost_per_m": 0.05, + "note": "Idéal comme base économique pour des tâches simples." + }, + "glm-4.5-air": { + "input_cost_per_m": 0.2, + "output_cost_per_m": 1.1 + }, + "glm-4.6": { + "input_cost_per_m": 0.55, + "output_cost_per_m": 2.2 + }, + "glm-4.7": { + "input_cost_per_m": 0.55, + "output_cost_per_m": 2.2, + "input_cache_hit_cost_per_m": 0.11, + "note": "Très abordable, grand historique de stabilité." + }, + "glm-5": { + "input_cost_per_m": 1.0, + "output_cost_per_m": 3.2, + "note": "Dépassé par glm-5.1/glm-5.2. Prix à confirmer (sources divergentes au 03/07/2026: $1.00-1.40 input)." + }, + "glm-5-turbo": { + "input_cost_per_m": 0.96, + "output_cost_per_m": 3.58, + "input_cache_hit_cost_per_m": 0.25, + "note": "Rapport coût/performance imbattable, latence minimale, haute cadence." + }, + "glm-5.1": { + "input_cost_per_m": 1.0, + "output_cost_per_m": 3.2, + "note": "⚠️ Prix à vérifier avant usage intensif — sources divergentes au 03/07/2026 ($0.95-1.40 input / $3.15-4.40 output)." + }, + "glm-5.2": { + "input_cost_per_m": 1.1, + "output_cost_per_m": 3.86, + "input_cache_hit_cost_per_m": 0.28, + "note": "Contexte de 1M tokens, licence MIT open-source, excellentes performances en agentique complexe." + } + }, + "advantages": "Famille GLM — excellent en code/agentique et en chinois. ZHIPU_API_KEY active." + }, + { + "name": "Mistral AI API", + "type": "Pay-as-you-go", + "rates": { + "codestral-latest": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.0, + "note": "Plan Experiment (Gratuit)" + }, + "devstral-latest": { + "input_cost_per_m": 0.4, + "output_cost_per_m": 2.0, + "input_cache_hit_cost_per_m": 0.04, + "note": "Plan Experiment — agentic coding SOTA, SWE-bench 62%. Concurrent Sonnet pour code" + }, + "magistral-medium-latest": { + "input_cost_per_m": 2.0, + "output_cost_per_m": 5.0, + "input_cache_hit_cost_per_m": 0.2, + "note": "Payant — raisonnement CoT. Concurrent DeepSeek R1 et o3-mini. Thinking tokens inclus" + }, + "magistral-small-latest": { + "input_cost_per_m": 0.5, + "output_cost_per_m": 1.5, + "input_cache_hit_cost_per_m": 0.05, + "note": "Plan Experiment — raisonnement léger gratuit, très bon rapport qualité/coût" + }, + "ministral-14b-latest": { + "input_cost_per_m": 0.2, + "output_cost_per_m": 0.2, + "input_cache_hit_cost_per_m": 0.02, + "note": "Plan Experiment — edge 14B, faible coût $0.20/M symétrique, agents locaux" + }, + "mistral-large-latest": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.0, + "note": "Plan Experiment (Gratuit)" + }, + "mistral-medium-latest": { + "input_cost_per_m": 0.5, + "output_cost_per_m": 1.5, + "input_cache_hit_cost_per_m": 0.05, + "note": "Plan Experiment — Mistral Medium 3.5, polyvalent, vision, français" + }, + "mistral-ocr-latest": { + "input_cost_per_m": 0.25, + "output_cost_per_m": 0.25, + "note": "Plan Experiment — OCR/PDF natif, $0.25/M pages. Lit tableaux et formules" + }, + "open-mistral-nemo": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.0, + "note": "Plan Experiment (Gratuit)" + } + }, + "advantages": "Plan Experiment (Gratuit). Excellent en français. Tokenizer optimisé." + }, + { + "name": "Cohere API", + "type": "Pay-as-you-go", + "rates": { + "command-a-03-2025": { + "input_cost_per_m": 2.5, + "output_cost_per_m": 10.0, + "input_cache_hit_cost_per_m": 0.25, + "note": "Trial Key — nouveau flagship 2025, 256K contexte, agentic RAG. 20 RPM Trial" + }, + "command-a-plus-05-2026": { + "input_cost_per_m": 2.5, + "output_cost_per_m": 10.0, + "input_cache_hit_cost_per_m": 0.25, + "note": "Trial Key — MoE 25B actifs, mai 2026. Vision+raisonnement, 48 langues. 20 RPM Trial" + }, + "command-a-reasoning-08-2025": { + "input_cost_per_m": 2.5, + "output_cost_per_m": 10.0, + "input_cache_hit_cost_per_m": 0.25, + "note": "Trial Key — Command A + thinking CoT, août 2025. Concurrent Magistral" + }, + "command-a-vision-07-2025": { + "input_cost_per_m": 2.5, + "output_cost_per_m": 10.0, + "input_cache_hit_cost_per_m": 0.25, + "note": "Trial Key — Command A avec vision multimodale, juillet 2025" + }, + "command-r-08-2024": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.0, + "note": "Trial Key (Gratuit)" + }, + "command-r-plus-08-2024": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.0, + "note": "Trial Key (Gratuit)" + } + }, + "advantages": "Trial Key (Gratuit, 10 RPM). Champion du RAG et du Tool Calling." + }, + { + "name": "DeepSeek API", + "type": "Pay-as-you-go", + "rates": { + "deepseek-chat": { + "input_cost_per_m": 0.14, + "output_cost_per_m": 0.28, + "input_cache_hit_cost_per_m": 0.003, + "note": "Alias legacy" + }, + "deepseek-reasoner": { + "input_cost_per_m": 0.55, + "output_cost_per_m": 2.19, + "input_cache_hit_cost_per_m": 0.14, + "note": "Legacy R1" + }, + "deepseek-v4-flash": { + "input_cost_per_m": 0.14, + "output_cost_per_m": 0.28, + "input_cache_hit_cost_per_m": 0.003 + }, + "deepseek-v4-pro": { + "input_cost_per_m": 0.435, + "output_cost_per_m": 0.87, + "input_cache_hit_cost_per_m": 0.004 + } + }, + "advantages": "Ratio coût/intelligence imbattable. V4-Flash à $0.14/$0.28/M." + }, + { + "name": "DeepInfra API", + "type": "Pay-as-you-go", + "rates": { + "deepinfra/deepseek-r1": { + "input_cost_per_m": 0.55, + "output_cost_per_m": 2.19 + }, + "deepinfra/llama-3.3-70b-instruct": { + "input_cost_per_m": 0.23, + "output_cost_per_m": 0.23 + }, + "deepinfra/qwen-2.5-72b-instruct": { + "input_cost_per_m": 0.35, + "output_cost_per_m": 0.35 + } + }, + "advantages": "Hébergeur de modèles open-weights à bas coût. Llama 3.3, Qwen 2.5 et DeepSeek R1." + }, + { + "name": "xAI API (Grok)", + "type": "Pay-as-you-go", + "rates": { + "grok-4.20-0309-non-reasoning": { + "input_cost_per_m": 1.25, + "output_cost_per_m": 2.5 + }, + "grok-4.20-0309-reasoning": { + "input_cost_per_m": 1.25, + "output_cost_per_m": 2.5 + }, + "grok-4.20-multi-agent-0309": { + "input_cost_per_m": 1.25, + "output_cost_per_m": 2.5 + }, + "grok-4.3": { + "input_cost_per_m": 1.25, + "output_cost_per_m": 2.5 + }, + "grok-build-0.1": { + "input_cost_per_m": 1.0, + "output_cost_per_m": 2.0 + }, + "grok-imagine-image": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.07, + "note": "xAI — Génération image, $0.07/image. Basé sur Aurora (Aurora Image Gen)" + }, + "grok-imagine-video-1.5-preview": { + "input_cost_per_m": 0.0, + "output_cost_per_m": 0.5, + "note": "xAI Preview — génération vidéo ~$0.50+/vidéo. Non-production" + } + }, + "advantages": "Modèles Grok-2 et Grok-Beta. Excellentes capacités de raisonnement." + }, + { + "name": "MiniMax API", + "type": "Pay-as-you-go", + "rates": { + "MiniMax-M2.5": { + "input_cost_per_m": 0.15, + "output_cost_per_m": 1.2, + "input_cache_hit_cost_per_m": 0.015, + "note": "MiniMax — Modèle stable précédent, $0.15/$1.20/M. Bon rapport qualité/coût" + }, + "MiniMax-M2.7": { + "input_cost_per_m": 0.3, + "output_cost_per_m": 1.2, + "input_cache_hit_cost_per_m": 0.03, + "note": "MiniMax — 204K contexte, agentic+coding. $0.30/$1.20/M. Successeur M2.5" + }, + "MiniMax-M2.7-highspeed": { + "input_cost_per_m": 0.6, + "output_cost_per_m": 2.4, + "input_cache_hit_cost_per_m": 0.06, + "note": "MiniMax Highspeed — 2x plus rapide, 2x plus cher. $0.60/$2.40/M. Agents temps réel" + }, + "MiniMax-M3": { + "input_cost_per_m": 0.3, + "output_cost_per_m": 1.2, + "input_cache_hit_cost_per_m": 0.03, + "note": "MiniMax flagship — 1M contexte, multimodal text/image/video. $0.30/$1.20/M (≤512K)" + }, + "minimax-m3": { + "input_cost_per_m": 1.2, + "output_cost_per_m": 1.2 + } + }, + "advantages": "Modèle d'écriture et de dialogue, performant en multilingue." + }, + { + "name": "Gemini API (GCP Payant)", + "type": "Pay-as-you-go", + "rates": { + "gemini-2.5-flash-paid": { + "input_cost_per_m_eur": 0.256515, + "output_cost_per_m_eur": 2.137625, + "cached_input_per_m_eur": 0.025651 + }, + "gemini-2.5-pro-paid": { + "input_cost_per_m_eur": 1.068812, + "output_cost_per_m_eur": 8.5505, + "cached_input_per_m_eur": 0.106881 + }, + "gemini-3-pro-preview-paid": { + "input_cost_per_m_eur": 1.7101, + "output_cost_per_m_eur": 10.2606 + }, + "gemini-3-pro-short-paid": { + "input_cost_per_m_eur": 1.7101, + "output_cost_per_m_eur": 10.2606, + "note": "Alias court" + }, + "gemini-3.1-flash-lite-paid": { + "input_cost_per_m_eur": 0.213762, + "output_cost_per_m_eur": 1.282575, + "cached_input_per_m_eur": 0.021376 + }, + "gemini-3.1-pro-customtools-paid": { + "input_cost_per_m_eur": 1.7101, + "output_cost_per_m_eur": 10.2606 + }, + "gemini-3.1-pro-preview-paid": { + "input_cost_per_m_eur": 1.7101, + "output_cost_per_m_eur": 10.2606 + }, + "gemini-3.5-flash-paid": { + "input_cost_per_m_eur": 1.282575, + "output_cost_per_m_eur": 7.69545, + "cached_input_per_m_eur": 0.128257 + } + }, + "advantages": "Tarifs GCP en EUR. Search Grounding débloqué. Context Caching -90%." + }, + { + "name": "Anthropic GCP", + "type": "Pay-as-you-go", + "rates": { + "claude-opus-4-8-gcp": { + "input_cost_per_m": 5.0, + "output_cost_per_m": 25.0, + "note": "Most intelligent Opus model, deeper reasoning for enterprise workflows" + }, + "claude-sonnet-4-6-gcp": { + "input_cost_per_m": 3.0, + "output_cost_per_m": 15.0, + "note": "Frontier intelligence at scale, Agent Platform API, global endpoint" + } + }, + "advantages": null + } + ], + "local": { + "name": "LM Studio (Local)", + "cost_monthly_usd": 0.0, + "endpoints": [ + "http://192.168.1.x:1234/v1/chat/completions" + ], + "status": "ACTIF", + "models": [ + "deepseek-coder-v2-lite", + "gemma-4-26b-a4b", + "gemma-4-31b", + "gemma-4-e4b", + "nomic-embed-text-v1.5", + "qwen2.5-14b-instruct-1m", + "qwen2.5-coder-32b", + "qwen3-coder-next", + "qwen3.6-35b-a3b" + ], + "advantages": "RTX 5070 Ti (16GB VRAM), 9 modèles chargés. Coût zéro, confidentialité totale." + } +} \ No newline at end of file diff --git a/scripts/oss_secrets_scan.py b/scripts/oss_secrets_scan.py new file mode 100644 index 0000000..373486e --- /dev/null +++ b/scripts/oss_secrets_scan.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Scan anti-fuite pour le miroir public vromvrom-engine. + +Bloque le CI si des IP LAN réelles (192.168.0.x), tokens ou clés API +ressemblant à du vrai matériel apparaissent dans les sources trackées. + +Les placeholders documentés (192.168.1.x, changez-moi, AIza...votre_clé...) +sont autorisés. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +# Dossiers / fichiers hors périmètre (deps, caches, binaires). +SKIP_DIRS = { + ".git", + ".venv", + "venv", + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + "node_modules", + "chroma_db", + "chromadb_data", + "dist", + "build", + ".tox", +} + +SKIP_SUFFIXES = { + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".ico", + ".db", + ".sqlite3", + ".pyc", + ".pyo", + ".whl", + ".gz", + ".zip", +} + +# Extensions textuelles scannées. +TEXT_SUFFIXES = { + ".py", + ".md", + ".txt", + ".yml", + ".yaml", + ".toml", + ".json", + ".json5", + ".example", + ".env", + ".sh", + ".ps1", + ".js", + ".ts", + ".tsx", + ".css", + ".html", + ".ini", + ".cfg", + ".xml", +} + +# Lignes explicitement autorisées (placeholders / docs). +ALLOW_LINE_PATTERNS = [ + re.compile(r"192\.168\.1\.x"), + re.compile(r"\$\{HA_HOST:-192\.168\.1\.x\}"), + re.compile(r"changez-moi", re.I), + re.compile(r"AIza\.\.\.votre_clé", re.I), + re.compile(r"votre_clé", re.I), + re.compile(r"YOUR[_-]?API[_-]?KEY", re.I), + re.compile(r""), + re.compile(r"Bearer <"), + re.compile(r"sk-\.\.\."), + # Fixtures de tests (sanitizer) — pas de vraies clés. + re.compile(r"sk-abcdef[0-9A-Za-z]*"), +] + +# Règles bloquantes : (nom, regex, message). +RULES: list[tuple[str, re.Pattern[str], str]] = [ + ( + "lan_ip_0_x", + re.compile(r"192\.168\.0\.\d+"), + "IP LAN privée 192.168.0.x — anonymiser en 192.168.1.x / ${HA_HOST:-…}", + ), + ( + "openai_style_key", + re.compile(r"(? bool: + if path.suffix.lower() in TEXT_SUFFIXES: + return True + # Fichiers sans suffixe courants + return path.name in { + "Dockerfile", + "Dockerfile.webhook", + "Makefile", + "LICENSE", + ".gitignore", + ".dockerignore", + ".pre-commit-config.yaml", + } + + +def _line_allowed(line: str) -> bool: + return any(p.search(line) for p in ALLOW_LINE_PATTERNS) + + +def iter_source_files() -> list[Path]: + out: list[Path] = [] + for path in ROOT.rglob("*"): + if not path.is_file(): + continue + if any(part in SKIP_DIRS for part in path.parts): + continue + if path.suffix.lower() in SKIP_SUFFIXES: + continue + if not _is_text_file(path): + continue + out.append(path) + return sorted(out) + + +def scan() -> list[str]: + findings: list[str] = [] + for path in iter_source_files(): + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError as exc: + findings.append(f"{path.relative_to(ROOT)}: lecture impossible ({exc})") + continue + for lineno, line in enumerate(text.splitlines(), start=1): + if _line_allowed(line): + continue + for rule_id, pattern, message in RULES: + if pattern.search(line): + rel = path.relative_to(ROOT).as_posix() + findings.append(f"{rel}:{lineno}: [{rule_id}] {message}") + break + return findings + + +def main() -> int: + findings = scan() + if not findings: + print("✅ oss_secrets_scan : aucun secret / IP LAN réelle détecté(e).") + return 0 + print("⛔ oss_secrets_scan : fuites potentielles — NE PAS MERGER :", file=sys.stderr) + for item in findings: + print(f" - {item}", file=sys.stderr) + print(f"\n{len(findings)} alerte(s).", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/seed_models_db.py b/seed_models_db.py index 5eac061..3f31ac8 100644 --- a/seed_models_db.py +++ b/seed_models_db.py @@ -1,4 +1,4 @@ -""" +""" seed_models_db.py — Peuplement initial de models_registry.db. Script idempotent (INSERT OR REPLACE) qui peuple la BDD depuis : diff --git a/static/js/tabs/swarm.js b/static/js/tabs/swarm.js index a29ec29..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/test_failover.py b/tests/test_failover.py index 8c1df01..8856e28 100644 --- a/tests/test_failover.py +++ b/tests/test_failover.py @@ -1,4 +1,4 @@ -""" +""" tests/test_failover.py — Tests unitaires Phase 4 : Failover MQTT + SQLite Sync ══════════════════════════════════════════════════════════════════════════════ diff --git a/tests/unit/test_ollama_deck.py b/tests/unit/test_ollama_deck.py index a017c36..b665451 100644 --- a/tests/unit/test_ollama_deck.py +++ b/tests/unit/test_ollama_deck.py @@ -1,4 +1,4 @@ -""" +""" tests/unit/test_ollama_deck.py Tests unitaires pour OllamaDeckProvider et la méthode get_deck_provider() de LLMGateway. Les tests mockent requests pour ne pas dépendre du réseau. diff --git a/tests/unit/test_pure_helpers.py b/tests/unit/test_pure_helpers.py index 82d1df7..f0f7b01 100644 --- a/tests/unit/test_pure_helpers.py +++ b/tests/unit/test_pure_helpers.py @@ -62,9 +62,9 @@ def test_sanitizer_masks_api_key(): def test_sanitizer_masks_email_and_private_ip(): from tools.sanitizer import OutputSanitizer s = OutputSanitizer() - out = s.sanitize("contact test.user@example.com sur ${HA_HOST:-192.168.1.x}") + out = s.sanitize("contact test.user@example.com sur 192.168.1.50") assert "test.user@example.com" not in out - assert "${HA_HOST:-192.168.1.x}" not in out + assert "192.168.1.50" not in out def test_sanitizer_disabled_is_passthrough(): diff --git a/tools/context_self_healing.py b/tools/context_self_healing.py index 462f76a..77a3b18 100644 --- a/tools/context_self_healing.py +++ b/tools/context_self_healing.py @@ -1,4 +1,4 @@ -""" +""" context_self_healing.py — Module de validation automatique de la cohérence du contexte. Détecte les incohérences entre les fichiers Markdown de contexte (contexte_ia/) diff --git a/tools/failover_manager.py b/tools/failover_manager.py index b75a929..8aeff9d 100644 --- a/tools/failover_manager.py +++ b/tools/failover_manager.py @@ -1,4 +1,4 @@ -""" +""" tools/failover_manager.py — Antigravity Phase 4 : Gestionnaire de failover Deck (côté PC) ══════════════════════════════════════════════════════════════════════════════════════════ diff --git a/tools/ha_entity_ingest.py b/tools/ha_entity_ingest.py index 01e7700..b5eb7f0 100644 --- a/tools/ha_entity_ingest.py +++ b/tools/ha_entity_ingest.py @@ -1,4 +1,4 @@ -""" +""" ha_entity_ingest.py — Ingestion automatique des entités domotiques dans le graphe de connaissances. Ce script :