commit b90621f6653bb93fe73acb13b67efff9aaad5c01 Author: M1n-0 Date: Mon Jan 19 22:02:50 2026 +0100 1st try of the backend diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..a818312 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,71 @@ +from fastapi import FastAPI +from pydantic import BaseModel +import json + +from llm import ollama_chat +from tools import web_search, electronics_ohm, run_command + +app = FastAPI() + +class ChatIn(BaseModel): + message: str + model: str = "llama3.1" # change si besoin + mode: str = "AUTO" # AUTO / DEV / ELEC / INFRA / WEB + +SYSTEM = """Tu es un assistant personnel pour Nino. +Tu dois être pratique, structuré, et orienté action. +Si une demande nécessite une recherche web, utilise l'outil web_search. +Si c'est de l'électronique, tu peux utiliser electronics_ohm ou demander les valeurs manquantes. +Si une commande système est utile, propose run_command mais explique ce que ça fait. +Réponds en français. +""" + +TOOLS_SPEC = """ +Outils disponibles (à appeler en JSON strict sur une seule ligne) : + +1) web_search: {"tool":"web_search","query":"...","max_results":5} +2) electronics_ohm: {"tool":"electronics_ohm","V":null,"I":0.02,"R":220} +3) run_command: {"tool":"run_command","cmd":"docker ps"} + +Si tu appelles un outil, n'écris QUE le JSON. +""" + +@app.post("/chat") +async def chat(inp: ChatIn): + messages = [ + {"role": "system", "content": SYSTEM}, + {"role": "system", "content": TOOLS_SPEC}, + {"role": "user", "content": inp.message}, + ] + + # 1) le modèle choisit soit de répondre, soit d'appeler un outil + first = (await ollama_chat(inp.model, messages)).strip() + + # 2) si JSON tool-call + if first.startswith("{") and '"tool"' in first: + call = json.loads(first) + tool = call["tool"] + + if tool == "web_search": + res = web_search(call["query"], call.get("max_results", 5)) + messages.append({"role": "assistant", "content": first}) + messages.append({"role": "tool", "content": json.dumps(res, ensure_ascii=False)}) + final = await ollama_chat(inp.model, messages) + return {"answer": final, "tool_used": "web_search", "tool_result": res} + + if tool == "electronics_ohm": + res = electronics_ohm(call.get("V"), call.get("I"), call.get("R")) + messages.append({"role": "assistant", "content": first}) + messages.append({"role": "tool", "content": json.dumps(res, ensure_ascii=False)}) + final = await ollama_chat(inp.model, messages) + return {"answer": final, "tool_used": "electronics_ohm", "tool_result": res} + + if tool == "run_command": + res = run_command(call["cmd"]) + messages.append({"role": "assistant", "content": first}) + messages.append({"role": "tool", "content": json.dumps(res, ensure_ascii=False)}) + final = await ollama_chat(inp.model, messages) + return {"answer": final, "tool_used": "run_command", "tool_result": res} + + # sinon réponse directe + return {"answer": first, "tool_used": None} diff --git a/backend/llm.py b/backend/llm.py new file mode 100644 index 0000000..40296ae --- /dev/null +++ b/backend/llm.py @@ -0,0 +1,13 @@ +import httpx + +OLLAMA_URL = "http://localhost:11434" + +async def ollama_chat(model: str, messages: list[dict]) -> str: + async with httpx.AsyncClient(timeout=120) as client: + r = await client.post( + f"{OLLAMA_URL}/api/chat", + json={"model": model, "messages": messages, "stream": False}, + ) + r.raise_for_status() + data = r.json() + return data["message"]["content"] diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..6acd8d1 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,5 @@ +fastapi +uvicorn +httpx +pydantic +duckduckgo-search \ No newline at end of file diff --git a/backend/tool.py b/backend/tool.py new file mode 100644 index 0000000..3ad95e1 --- /dev/null +++ b/backend/tool.py @@ -0,0 +1,36 @@ +from duckduckgo_search import DDGS +import subprocess +import shlex +import math + +def web_search(query: str, max_results: int = 5) -> list[dict]: + out = [] + with DDGS() as ddgs: + for r in ddgs.text(query, max_results=max_results): + out.append({"title": r.get("title"), "url": r.get("href"), "snippet": r.get("body")}) + return out + +def electronics_ohm(V: float | None, I: float | None, R: float | None) -> dict: + # résout V=I*R si une variable est None + if [V, I, R].count(None) != 1: + return {"error": "Donne exactement 2 valeurs (ex: V et R) et laisse l’autre à null."} + if V is None: + return {"V": I * R} + if I is None: + return {"I": V / R} + return {"R": V / I} + +SAFE_COMMANDS = {"git", "docker", "docker-compose", "python", "pip", "ls", "cat", "grep", "tail", "journalctl"} + +def run_command(cmd: str) -> dict: + # garde-fou minimal : n’autorise que certaines commandes + parts = shlex.split(cmd) + if not parts: + return {"error": "Commande vide."} + if parts[0] not in SAFE_COMMANDS: + return {"error": f"Commande interdite: {parts[0]} (liste: {sorted(SAFE_COMMANDS)})"} + try: + p = subprocess.run(parts, capture_output=True, text=True, timeout=25) + return {"returncode": p.returncode, "stdout": p.stdout[-4000:], "stderr": p.stderr[-4000:]} + except Exception as e: + return {"error": str(e)}