from contextlib import asynccontextmanager import asyncio import hashlib import json import logging logger = logging.getLogger("uvicorn.error") from pathlib import Path from fastapi import FastAPI, Header, HTTPException, Request from fastapi.responses import HTMLResponse from pydantic import BaseModel, Field from .config import get_settings from .memory import Memory from .rag import index_knowledge from .graph import run_agent from .tools import full_status_report, infra_live_report, model_card, seeds_report from .ping import ping_report from .ops import ops_report from . import watch as watch_mod from . import alexa as alexa_mod memory = Memory() # Empreinte du répertoire knowledge : l'auto-reload (v1.5.0) réindexe # automatiquement quand la doc change — plus besoin de penser à /reload. _knowledge_sig: str = "" def _knowledge_fingerprint() -> str: """Empreinte stable des fichiers markdown de knowledge/ (noms + contenu).""" digest = hashlib.sha256() for path in sorted(Path("knowledge").rglob("*.md")): if path.name.startswith("._"): continue digest.update(path.name.encode()) digest.update(path.read_bytes()) return digest.hexdigest() async def _knowledge_watcher(interval: int = 30) -> None: """Compare l'empreinte toutes les 30 s ; réindexe au changement (v1.5.0). Une erreur ne tue jamais la boucle : le reload reste possible à la main.""" global _knowledge_sig while True: await asyncio.sleep(interval) try: signature = await asyncio.to_thread(_knowledge_fingerprint) if _knowledge_sig and signature != _knowledge_sig: count = await index_knowledge(memory) logger.info("knowledge/ modifié : réindexation auto de %d extraits", count) _knowledge_sig = signature except asyncio.CancelledError: raise except Exception: logger.exception("auto-reload knowledge : échec de ce tour") _watcher_task: asyncio.Task | None = None async def _heartbeat_loop(interval: int = 300) -> None: """Ping le check Healthchecks de l'API toutes les 5 min (symétrique du bot). Si l'API meurt, le check passe down en ~5-10 min (interval + grace) — l'auto-test horaire du bot ne le voit qu'au prochain tour (1 h max). URL via VEKTOR_HC_API_PING_URL, vide = désactivé.""" import httpx import os ping_url = os.environ.get("VEKTOR_HC_API_PING_URL", "").strip() if not ping_url: return while True: await asyncio.sleep(interval) try: async with httpx.AsyncClient(timeout=10) as client: await client.post(ping_url) except Exception: logger.warning("heartbeat API Healthchecks impossible") @asynccontextmanager async def lifespan(app: FastAPI): global _knowledge_sig await memory.connect() _knowledge_sig = _knowledge_fingerprint() await index_knowledge(memory) _watcher_task = asyncio.create_task(_knowledge_watcher()) heartbeat_task = asyncio.create_task(_heartbeat_loop()) yield heartbeat_task.cancel() if _watcher_task: _watcher_task.cancel() await memory.close() app = FastAPI(title="Vektor API", version="0.1.0", lifespan=lifespan) class ChatRequest(BaseModel): user_id: str = Field(min_length=1, max_length=128) channel: str = Field(pattern="^(telegram|alexa|web)$") text: str = Field(min_length=1, max_length=4000) conversation_id: str | None = None class ForgetRequest(BaseModel): user_id: str = Field(min_length=1, max_length=128) class ChatResponse(BaseModel): response: str conversation_id: str def require_token(token: str | None): if token != get_settings().vektor_api_token: raise HTTPException(status_code=401, detail="Unauthorized") @app.get("/health") async def health(): return {"status": "ok", "service": "vektor"} @app.post("/api/webhook/grafana") async def grafana_webhook_endpoint(request: Request): """Alertes Grafana -> auto-réparation sûre (selfheal). Sécurité : header X-Vektor-Selfheal obligatoire (secret partagé généré sur le VPS, jamais commité) — l'endpoint est sinon joignable depuis Internet via Caddy. L'action reste de toute façon bornée à la whitelist selfheal avec cooldown anti-tempête.""" import os expected = os.environ.get("VEKTOR_SELFHEAL_SECRET", "").strip() provided = request.headers.get("X-Vektor-Selfheal", "") if not expected or provided != expected: raise HTTPException(status_code=403, detail="secret selfheal manquant ou invalide") try: payload = await request.json() except Exception: raise HTTPException(status_code=400, detail="payload JSON invalide") from .selfheal import handle_grafana_webhook summary = await handle_grafana_webhook(payload) return { "acted": summary["acted"], "skipped_count": len(summary["skipped"]), "reports_generated": summary["reports_generated"], "reports_sent": summary["reports_sent"], } @app.post("/api/chat", response_model=ChatResponse) async def chat(request: ChatRequest, x_vektor_token: str | None = Header(default=None)): require_token(x_vektor_token) conversation_id = await memory.ensure_conversation( request.conversation_id, request.user_id, request.channel ) await memory.add_message(conversation_id, "user", request.text) history = await memory.history(conversation_id) # user_key = périmètre des confirmations d'actions (canal:user) response = await run_agent( memory, request.text, history, user_key=f"{request.channel}:{request.user_id}" ) await memory.add_message(conversation_id, "assistant", response) return ChatResponse(response=response, conversation_id=str(conversation_id)) @app.post("/api/alexa") async def alexa_endpoint(request: Request): body = await request.body() if len(body) > 512 * 1024: raise HTTPException(status_code=413, detail="Payload too large") try: await alexa_mod.verify_request( body, request.headers.get("Signature"), request.headers.get("SignatureCertChainUrl"), ) payload = json.loads(body) alexa_mod.check_timestamp_and_app(payload) except alexa_mod.AlexaVerificationError as exc: logger.warning("Requete Alexa rejetee: %s", exc) raise HTTPException(status_code=400, detail=str(exc)) except json.JSONDecodeError as exc: raise HTTPException(status_code=400, detail="JSON invalide") from exc request_type = payload.get("request", {}).get("type", "") session_attrs = payload.get("session", {}).get("attributes", {}) or {} session_id = payload.get("session", {}).get("sessionId") or "alexa-anon" user_id = ( payload.get("session", {}).get("user", {}).get("userId") or payload.get("context", {}).get("System", {}).get("user", {}).get("userId") or "alexa-anon" ) conv_id = session_attrs.get("conversation_id") if request_type in ("LaunchRequest", "SessionEndedRequest"): if request_type == "SessionEndedRequest": return alexa_mod.build_response("Au revoir !", True, session_attributes={}) return alexa_mod.build_response( "Vektor est là ! Demande-moi l'état du homelab, un service, ou pose ta question.", False, reprompt="Que veux-tu savoir ?", session_attributes=session_attrs, ) if request_type != "IntentRequest": return alexa_mod.build_response("Je n'ai pas compris ce type de demande.", True) intent_name = payload.get("request", {}).get("intent", {}).get("name", "") if intent_name in ("AMAZON.StopIntent", "AMAZON.CancelIntent"): return alexa_mod.build_response("À bientôt !", True) if intent_name == "AMAZON.HelpIntent": return alexa_mod.build_response( "Demande-moi l'état de Proxmox, vérifie si Jellyfin répond, " "ou pose une question générale.", False, reprompt="Que veux-tu savoir ?", session_attributes=session_attrs, ) if intent_name == "AMAZON.FallbackIntent": return alexa_mod.build_response( "Je n'ai pas saisi la demande. Essaie par exemple : quel est l'état de Proxmox ?", False, reprompt="Que veux-tu savoir ?", session_attributes=session_attrs, ) slots = payload.get("request", {}).get("intent", {}).get("slots", {}) or {} query = (slots.get("query", {}) or {}).get("value", "").strip() if not query: return alexa_mod.build_response( "Quel est le sujet de ta question ?", False, reprompt="Pose ta question.", session_attributes=session_attrs, ) conversation_id = await memory.ensure_conversation(conv_id, user_id, "alexa") await memory.add_message(conversation_id, "user", query) # Alexa : canal vocal -> AUCUNE action d'écriture (pas de confirmation # orale fiable). Les demandes d'action reçoivent la réponse de refus standard. live = await infra_live_report(query) if live: answer = live else: # Le LLM peut prendre 15-60 s : Alexa coupe à ~8 s. On diffuse des # messages intermédiaires (VoicePlayer.Speak) pour tenir la session. api_token = ( payload.get("context", {}) .get("System", {}) .get("apiAccessToken", "") ) request_id = payload.get("request", {}).get("requestId", "") api_endpoint = payload.get("context", {}).get("System", {}).get("apiEndpoint", "") progress = asyncio.create_task( alexa_mod.progressive_loop(api_token, request_id, api_endpoint) ) try: history = await memory.history(conversation_id) answer = await run_agent(memory, query, history) finally: progress.cancel() await memory.add_message(conversation_id, "assistant", answer) return alexa_mod.build_response( alexa_mod._clean_for_voice(answer), False, reprompt="Autre chose ?", session_attributes={**session_attrs, "conversation_id": str(conversation_id)}, ) @app.get("/api/model") async def model_endpoint(x_vektor_token: str | None = Header(default=None)): """Fiche technique réelle : modèle, hardware, latence. Aucun appel LLM.""" require_token(x_vektor_token) return {"card": await model_card()} @app.get("/api/seeds") async def seeds_endpoint(x_vektor_token: str | None = Header(default=None)): """Rapport seeds : qBittorrent live + staging récupérable. Lecture seule.""" require_token(x_vektor_token) return {"report": await seeds_report()} @app.get("/api/qb/status") async def qb_status_endpoint(x_vektor_token: str | None = Header(default=None)): """État qBittorrent + lectures Jellyfin en cours. Lecture seule.""" require_token(x_vektor_token) from .qbcontrol import jellyfin_playing_count, qb_state total, paused = await qb_state() playing = await jellyfin_playing_count() return {"total": total, "paused": paused, "jellyfin_playing": playing} @app.get("/api/qb/{verb}/proposal") async def qb_proposal_endpoint( verb: str, x_vektor_token: str | None = Header(default=None), user_id: str = "default", channel: str = "telegram", ): """Texte de proposition /pause|/resume + enregistrement du registre OUI. Aucune exécution ici : le « OUI » envoyé ensuite passe par le chemin normal du chat (graph -> confirm_pending -> canal d'actions SSH).""" if verb not in ("pause", "resume"): raise HTTPException(status_code=404, detail="verb inconnu") require_token(x_vektor_token) from .qbcontrol import proposal_for proposal = await proposal_for(verb, f"{channel}:{user_id}") return {"proposal": proposal} @app.post("/api/qb/{verb}/cancel") async def qb_cancel_endpoint( verb: str, x_vektor_token: str | None = Header(default=None), user_id: str = "default", channel: str = "telegram", ): """Annule la proposition en attente (bouton ❌ du bot).""" require_token(x_vektor_token) from . import actions actions._PENDING.pop(f"{channel}:{user_id}", None) return {"result": "annulée"} @app.post("/api/qb/{verb}") async def qb_action_endpoint( verb: str, x_vektor_token: str | None = Header(default=None), user_id: str = "default", channel: str = "telegram", ): """Confirmation pause|resume : n'exécute QUE l'action en attente. Miroir de confirm_pending (registre partagé avec le chat) : sans proposition enregistrée au préalable via /proposal, le POST refuse — pas d'exécution sans la double confirmation.""" if verb not in ("pause", "resume"): raise HTTPException(status_code=404, detail="verb inconnu") require_token(x_vektor_token) from . import actions user_key = f"{channel}:{user_id}" pending = actions._PENDING.get(user_key) if pending is None: return {"result": f"Aucune action en attente. Envoie /{verb} puis réponds OUI."} expected = f"qb_{verb}_103" if pending[0] != expected: return { "result": f"Une autre action est en attente ({pending[0]}) — " "confirme-la dans la conversation ou attends 2 minutes." } reply = await actions.confirm_pending("OUI", user_key) if reply is None: return {"result": "Confirmation refusée."} return {"result": reply} @app.get("/api/snap") async def snap_endpoint( ct: str = "103", x_vektor_token: str | None = Header(default=None), ): """Snapshot thin du CT demandé — classe AUTONOME (pas de OUI). Réversible par construction : le snapshot expire tout seul (7 jours, sweep PVE horaire). Aucun service n'est interrompu. Le CT est validé ici pour un message clair ; le script PVE re-valide de toute façon.""" require_token(x_vektor_token) if ct not in {"101", "102", "103", "104", "105", "106", "107"}: raise HTTPException(status_code=400, detail="CT inconnu (autorisés : 101-107)") from . import actions return {"report": await actions.execute_autonomous(f"lxc_snap_{ct}_pre-update")} @app.get("/api/snapls") async def snapls_endpoint(x_vektor_token: str | None = Header(default=None)): """Inventaire des snapshots à expiration automatique. Lecture seule.""" require_token(x_vektor_token) from . import actions return {"report": await actions.execute_autonomous("lxc_snapls")} @app.get("/api/ping") async def ping_endpoint(x_vektor_token: str | None = Header(default=None)): """Diagnostic du chemin LLM complet, maillon par maillon. Lecture seule.""" require_token(x_vektor_token) return {"report": await ping_report()} @app.post("/api/reload-doc") async def reload_doc_endpoint(x_vektor_token: str | None = Header(default=None)): """Ré-indexe knowledge/ (volume knowledge-live) sans redémarrer. replace_knowledge fait TRUNCATE + réinsertion en une transaction : un chat en cours pendant le reload ne voit jamais un index vide.""" require_token(x_vektor_token) try: count = await index_knowledge(memory) except Exception: logger.exception("reload-doc : réindexation échouée") raise HTTPException(status_code=500, detail="Réindexation échouée — index précédent conservé") return { "report": ( f"🔄 Documentation réindexée : {count} extraits chargés depuis knowledge/.\n" "Le prochain chat utilisera directement la nouvelle version — aucun redémarrage nécessaire." ) } @app.get("/api/status") async def status(x_vektor_token: str | None = Header(default=None)): require_token(x_vektor_token) report = await full_status_report() return {"report": report} @app.get("/api/backups") async def backups_endpoint(x_vektor_token: str | None = Header(default=None)): """Derniers backups vzdump vus par l'API Proxmox. Lecture seule.""" require_token(x_vektor_token) return {"report": await watch_mod.backups_report()} @app.get("/api/dns") async def dns_endpoint(x_vektor_token: str | None = Header(default=None)): """Sonde multi-transport (UDP wire/DoH) des résolveurs. Lecture seule.""" require_token(x_vektor_token) return {"report": await watch_mod.dns_report()} @app.get("/api/monitoring") async def monitoring_endpoint(x_vektor_token: str | None = Header(default=None)): """État des checks Healthchecks.io (token lecture dédié). Lecture seule.""" require_token(x_vektor_token) return {"report": await watch_mod.monitoring_report()} @app.get("/api/majlist") async def majlist_endpoint(x_vektor_token: str | None = Header(default=None)): """Inventaire des versions des conteneurs Docker (actuelle vs dispo). Lecture seule, mais pull les refs d'images : plusieurs minutes possibles.""" require_token(x_vektor_token) return {"report": await __import__("app.actions", fromlist=["_execute"])._execute("docker_updates_list")} @app.get("/api/morning") async def morning_endpoint(x_vektor_token: str | None = Header(default=None)): """Rapport matinal narratif (chiffres live, sans LLM).""" require_token(x_vektor_token) from .morning import morning_report return {"report": await morning_report()} @app.get("/api/ops") async def ops_endpoint(x_vektor_token: str | None = Header(default=None)): """Exploitation de Vektor : version déployée, conteneur, supervision. Lecture seule, sans LLM — répond même si le chemin d'inférence est cassé.""" require_token(x_vektor_token) return {"report": await ops_report()} @app.post("/api/forget") async def forget(request: ForgetRequest, x_vektor_token: str | None = Header(default=None)): require_token(x_vektor_token) deleted = await memory.forget_user(request.user_id) return {"deleted_conversations": deleted} # ── Canal web (roadmap V2 #4) ────────────────────────────────────────── # Le token sert ici de mot de passe client : la page le garde en # sessionStorage et l'envoie à chaque message. Aucun secret dans le dépôt. _WEB_PAGE = """
Entre le token d'accès (variable VEKTOR_API_TOKEN du serveur).