1st try of the backend
This commit is contained in:
71
backend/app.py
Normal file
71
backend/app.py
Normal file
@@ -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}
|
||||||
13
backend/llm.py
Normal file
13
backend/llm.py
Normal file
@@ -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"]
|
||||||
5
backend/requirements.txt
Normal file
5
backend/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
httpx
|
||||||
|
pydantic
|
||||||
|
duckduckgo-search
|
||||||
36
backend/tool.py
Normal file
36
backend/tool.py
Normal file
@@ -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)}
|
||||||
Reference in New Issue
Block a user