Spaces:
Configuration error
Configuration error
GitHub Action commited on
Commit ·
e6fbc88
1
Parent(s): de4b6b2
sync: backend da GitHub 2026-05-18T11:59:53Z
Browse files- .gitignore +8 -0
- Dockerfile +48 -7
- README.md +42 -11
- agents/__init__.py +0 -0
- agents/code_agent.py +51 -0
- agents/critic.py +55 -0
- agents/executor.py +47 -0
- agents/planner.py +57 -0
- agents/reasoning_core.py +219 -0
- agents/unified_loop.py +136 -0
- api/__init__.py +0 -0
- api/coding.py +23 -0
- api/web.py +31 -0
- architecture.py +105 -0
- components.json +20 -0
- index.html +67 -0
- main.py +480 -10
- memory/__init__.py +0 -0
- memory/episodic.py +163 -0
- memory/hf_store.py +5 -0
- memory/manager.py +115 -0
- memory/reflection.py +81 -0
- memory/semantic.py +138 -0
- memory/sync.py +169 -0
- memory/working.py +40 -0
- models/__init__.py +0 -0
- models/ai_client.py +187 -0
- models/ollama_client.py +79 -0
- package.json +43 -0
- requirements.txt +4 -1
- start.py +6 -0
- tools/__init__.py +0 -0
- tools/content_cleaner.py +27 -0
- tools/diff_checker.py +20 -0
- tools/file_editor.py +33 -0
- tools/ranking.py +32 -0
- tools/registry.py +193 -0
- tools/repo_reader.py +29 -0
- tools/web_fetch.py +29 -0
- tools/web_search.py +58 -0
- tsconfig.json +23 -0
- vite.config.ts +83 -0
.gitignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
*.pyo
|
| 5 |
+
chroma_db/
|
| 6 |
+
*.egg-info/
|
| 7 |
+
.env
|
| 8 |
+
|
Dockerfile
CHANGED
|
@@ -1,14 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
FROM python:3.11-slim
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
| 6 |
WORKDIR /app
|
| 7 |
|
| 8 |
-
|
| 9 |
-
RUN
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
EXPOSE 7860
|
| 14 |
-
|
|
|
|
|
|
| 1 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 2 |
+
# Dockerfile — Backend FastAPI per HuggingFace Spaces
|
| 3 |
+
#
|
| 4 |
+
# HuggingFace Spaces: server sempre attivo, 16GB RAM, gratuito
|
| 5 |
+
# Supabase: database per memoria, file, conversazioni (esterno)
|
| 6 |
+
#
|
| 7 |
+
# Deploy:
|
| 8 |
+
# 1. Crea uno Space su https://huggingface.co/new-space (SDK: Docker)
|
| 9 |
+
# 2. Connetti il repo GitHub Baida98/AI
|
| 10 |
+
# 3. Aggiungi i secrets Supabase in Settings → Repository secrets:
|
| 11 |
+
# SUPABASE_URL, SUPABASE_KEY
|
| 12 |
+
# e i secret LLM opzionali:
|
| 13 |
+
# OPENROUTER_API_KEY, GROQ_API_KEY, GEMINI_API_KEY, HF_TOKEN
|
| 14 |
+
# 4. HF Spaces costruisce e avvia automaticamente questo Dockerfile
|
| 15 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 16 |
+
|
| 17 |
FROM python:3.11-slim
|
| 18 |
|
| 19 |
+
# Variabili HuggingFace Spaces
|
| 20 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 21 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 22 |
+
PORT=7860 \
|
| 23 |
+
FRONTEND_DIST=/app/backend/static
|
| 24 |
+
|
| 25 |
WORKDIR /app
|
| 26 |
|
| 27 |
+
# Dipendenze sistema (minimali)
|
| 28 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 29 |
+
build-essential curl git \
|
| 30 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 31 |
+
|
| 32 |
+
# Dipendenze Python
|
| 33 |
+
COPY backend/requirements.txt /app/backend/requirements.txt
|
| 34 |
+
RUN pip install --no-cache-dir -r /app/backend/requirements.txt
|
| 35 |
+
|
| 36 |
+
# Copia il backend
|
| 37 |
+
COPY backend/ /app/backend/
|
| 38 |
|
| 39 |
+
# Copia il frontend buildato (se presente)
|
| 40 |
+
# La build Vite va fatta prima del deploy e committata in backend/static/
|
| 41 |
+
# oppure buildala qui se il repo contiene i sorgenti frontend
|
| 42 |
+
# COPY artifacts/agente-ai/dist/ /app/backend/static/
|
| 43 |
|
| 44 |
+
# Crea user non-root (richiesto da HF Spaces)
|
| 45 |
+
RUN useradd -m -u 1000 user
|
| 46 |
+
USER user
|
| 47 |
+
ENV HOME=/home/user PATH=/home/user/.local/bin:$PATH
|
| 48 |
+
|
| 49 |
+
WORKDIR /home/user/app
|
| 50 |
+
COPY --chown=user backend/ /home/user/app/backend/
|
| 51 |
+
|
| 52 |
+
# HuggingFace Spaces usa la porta 7860
|
| 53 |
EXPOSE 7860
|
| 54 |
+
|
| 55 |
+
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
|
README.md
CHANGED
|
@@ -1,11 +1,42 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Agente AI — Backend locale
|
| 2 |
+
|
| 3 |
+
Backend Python FastAPI con Ollama + ChromaDB + 4-layer memory.
|
| 4 |
+
|
| 5 |
+
## Setup rapido
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
# 1. Installa Ollama: https://ollama.com
|
| 9 |
+
ollama serve
|
| 10 |
+
ollama pull qwen2.5-coder:7b # consigliato (~4GB)
|
| 11 |
+
|
| 12 |
+
# 2. Avvia il backend
|
| 13 |
+
cd backend && bash start.sh
|
| 14 |
+
```
|
| 15 |
+
|
| 16 |
+
Poi apri l'app: il frontend rileva automaticamente il backend locale.
|
| 17 |
+
|
| 18 |
+
## Architettura
|
| 19 |
+
|
| 20 |
+
Frontend React → Backend :8000 → Ollama :11434
|
| 21 |
+
→ ChromaDB (vector memory)
|
| 22 |
+
|
| 23 |
+
## 4 Layer di memoria
|
| 24 |
+
|
| 25 |
+
| Layer | Scopo | Storage |
|
| 26 |
+
|-----------|------------------------------|-----------|
|
| 27 |
+
| Working | Ultime 20 chat | RAM |
|
| 28 |
+
| Episodic | Task completati, errori, fix | SQLite |
|
| 29 |
+
| Semantic | Preferenze, concetti chiave | ChromaDB |
|
| 30 |
+
| Reflection| Pattern errori ricorrenti | JSON |
|
| 31 |
+
|
| 32 |
+
## Endpoints
|
| 33 |
+
|
| 34 |
+
- GET /health — stato Ollama + modelli disponibili
|
| 35 |
+
- POST /v1/chat/completions — streaming OpenAI-compatible
|
| 36 |
+
- POST /api/plan — pianifica task complesso
|
| 37 |
+
- POST /api/critique — valida output (critic model)
|
| 38 |
+
- POST /api/memory/search — ricerca semantica
|
| 39 |
+
- GET /api/memory/stats — statistiche memoria
|
| 40 |
+
- POST /api/reflect — reflection loop
|
| 41 |
+
- GET /api/tools — lista tool
|
| 42 |
+
|
agents/__init__.py
ADDED
|
File without changes
|
agents/code_agent.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""code_agent.py — Coding Agent: scan→analyze→plan→edit→validate→reflect"""
|
| 2 |
+
import asyncio, json, re
|
| 3 |
+
from models.ollama_client import OllamaClient
|
| 4 |
+
|
| 5 |
+
SYSTEM = """Sei un coding agent esperto. Analisi codice, bugfix, refactor.
|
| 6 |
+
Rispondi con JSON strutturato quando richiesto."""
|
| 7 |
+
|
| 8 |
+
class CodeAgent:
|
| 9 |
+
def __init__(self, ollama: OllamaClient): self.ollama = ollama
|
| 10 |
+
|
| 11 |
+
async def _llm_json(self, prompt:str, system:str=SYSTEM, max_tokens:int=1024)->dict:
|
| 12 |
+
msgs=[{"role":"system","content":system},{"role":"user","content":prompt}]
|
| 13 |
+
try:
|
| 14 |
+
raw=await self.ollama.chat(msgs,temperature=0.2,max_tokens=max_tokens)
|
| 15 |
+
m=re.search(r'\{[\s\S]+\}',raw)
|
| 16 |
+
return json.loads(m.group()) if m else {}
|
| 17 |
+
except: return {}
|
| 18 |
+
|
| 19 |
+
async def analyze_file(self, filepath:str, content:str, goal:str)->dict:
|
| 20 |
+
p=f"Analizza per: {goal}\nFile: {filepath}\nContenuto (prime 3000 char):\n{content[:3000]}\n\nRispondi JSON: {{issues:[], suggestions:[], complexity:'low|medium|high', priority:1-10, safe_to_edit:bool}}"
|
| 21 |
+
r=await self._llm_json(p)
|
| 22 |
+
return r or {"issues":[],"suggestions":[],"complexity":"unknown","priority":5,"safe_to_edit":True}
|
| 23 |
+
|
| 24 |
+
async def plan_edit(self, filepath:str, content:str, goal:str)->dict:
|
| 25 |
+
p=f"Pianifica modifiche per: {goal}\nFile: {filepath}\n{content[:2500]}\n\nJSON: {{steps:[{{description,type:'insert|replace|delete',old_code,new_code,risk:'low|medium|high'}}], requires_tests:bool, breaking_change:bool}}"
|
| 26 |
+
r=await self._llm_json(p,max_tokens=2048)
|
| 27 |
+
return r or {"steps":[],"requires_tests":False,"breaking_change":False,"_fallback":True}
|
| 28 |
+
|
| 29 |
+
async def generate_fix(self, filepath:str, content:str, issue:str)->str:
|
| 30 |
+
msgs=[{"role":"system","content":SYSTEM},
|
| 31 |
+
{"role":"user","content":f"Correggi: {issue}\nFile: {filepath}\n{content[:4000]}\n\nRestituisci SOLO il codice corretto."}]
|
| 32 |
+
return await self.ollama.chat(msgs,temperature=0.15,max_tokens=4096)
|
| 33 |
+
|
| 34 |
+
async def validate_edit(self, original:str, edited:str, goal:str)->dict:
|
| 35 |
+
p=f"Valida modifica per: {goal}\nPRIMA:{original[:1500]}\nDOPO:{edited[:1500]}\nJSON:{{valid:bool,achieves_goal:bool,regressions:[],confidence:0-1}}"
|
| 36 |
+
r=await self._llm_json(p,max_tokens=512)
|
| 37 |
+
return r or {"valid":True,"achieves_goal":True,"regressions":[],"confidence":0.7,"_fallback":True}
|
| 38 |
+
|
| 39 |
+
async def full_session(self, goal:str, files:list[dict])->dict:
|
| 40 |
+
session={"goal":goal,"analyzed":[],"edits":[]}
|
| 41 |
+
for f in files[:4]:
|
| 42 |
+
analysis=await self.analyze_file(f["path"],f["content"],goal)
|
| 43 |
+
if (analysis.get("priority") or 0)>3:
|
| 44 |
+
plan=await self.plan_edit(f["path"],f["content"],goal)
|
| 45 |
+
session["analyzed"].append({"path":f["path"],"analysis":analysis,"plan":plan})
|
| 46 |
+
if plan.get("steps"):
|
| 47 |
+
edited=await self.generate_fix(f["path"],f["content"],goal)
|
| 48 |
+
val=await self.validate_edit(f["content"],edited,goal)
|
| 49 |
+
session["edits"].append({"path":f["path"],"edited":edited,"validation":val})
|
| 50 |
+
session["summary"]=f"Analizzati {len(session['analyzed'])} file, {len(session['edits'])} modifiche."
|
| 51 |
+
return session
|
agents/critic.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
critic.py — Critic Model
|
| 3 |
+
Secondo passaggio: verifica output, trova errori, suggerisce miglioramenti.
|
| 4 |
+
"""
|
| 5 |
+
import json, re
|
| 6 |
+
from models.ollama_client import OllamaClient
|
| 7 |
+
|
| 8 |
+
CRITIC_SYSTEM = """Sei un critico AI. Valuta l'output dato e rispondi SOLO con JSON:
|
| 9 |
+
{
|
| 10 |
+
"quality": 0-10,
|
| 11 |
+
"issues": ["lista problemi trovati"],
|
| 12 |
+
"suggestions": ["lista miglioramenti"],
|
| 13 |
+
"is_complete": true/false,
|
| 14 |
+
"needs_retry": true/false,
|
| 15 |
+
"confidence": 0.0-1.0
|
| 16 |
+
}"""
|
| 17 |
+
|
| 18 |
+
class Critic:
|
| 19 |
+
def __init__(self, ollama: OllamaClient):
|
| 20 |
+
self.ollama = ollama
|
| 21 |
+
|
| 22 |
+
async def evaluate(self, task: str, output: str, model: str | None = None) -> dict:
|
| 23 |
+
messages = [
|
| 24 |
+
{"role": "system", "content": CRITIC_SYSTEM},
|
| 25 |
+
{"role": "user", "content": f"Task originale: {task}\n\nOutput da valutare:\n{output[:2000]}"},
|
| 26 |
+
]
|
| 27 |
+
try:
|
| 28 |
+
raw = await self.ollama.chat(messages, model=model, temperature=0.2, max_tokens=512)
|
| 29 |
+
json_match = re.search(r'\{[\s\S]+\}', raw)
|
| 30 |
+
if json_match:
|
| 31 |
+
result = json.loads(json_match.group())
|
| 32 |
+
result["_evaluated"] = True
|
| 33 |
+
return result
|
| 34 |
+
except Exception:
|
| 35 |
+
pass
|
| 36 |
+
|
| 37 |
+
# Fallback euristico
|
| 38 |
+
quality = 5
|
| 39 |
+
issues = []
|
| 40 |
+
if len(output) < 50:
|
| 41 |
+
quality -= 3; issues.append("Output troppo breve")
|
| 42 |
+
if "errore" in output.lower() or "error" in output.lower():
|
| 43 |
+
quality -= 2; issues.append("Potenziali errori nell'output")
|
| 44 |
+
if len(output) > 100:
|
| 45 |
+
quality += 2
|
| 46 |
+
|
| 47 |
+
return {
|
| 48 |
+
"quality": max(0, min(10, quality)),
|
| 49 |
+
"issues": issues,
|
| 50 |
+
"suggestions": ["Verifica la completezza della risposta"],
|
| 51 |
+
"is_complete": len(output) > 100,
|
| 52 |
+
"needs_retry": quality < 4,
|
| 53 |
+
"confidence": 0.5,
|
| 54 |
+
"_fallback": True,
|
| 55 |
+
}
|
agents/executor.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
executor.py — Tool Executor con retry e timeout.
|
| 3 |
+
"""
|
| 4 |
+
import asyncio
|
| 5 |
+
from models.ollama_client import OllamaClient
|
| 6 |
+
from memory.manager import MemoryManager
|
| 7 |
+
from tools.registry import TOOL_REGISTRY
|
| 8 |
+
|
| 9 |
+
class Executor:
|
| 10 |
+
def __init__(self, ollama: OllamaClient, memory: MemoryManager, max_retries: int = 2):
|
| 11 |
+
self.ollama = ollama
|
| 12 |
+
self.memory = memory
|
| 13 |
+
self.max_retries = max_retries
|
| 14 |
+
|
| 15 |
+
async def run_tool(self, tool_name: str, inputs: dict, timeout: float = 30.0) -> dict:
|
| 16 |
+
tool = TOOL_REGISTRY.get(tool_name)
|
| 17 |
+
if not tool:
|
| 18 |
+
return {"success": False, "error": f"Tool '{tool_name}' non trovato", "output": None}
|
| 19 |
+
|
| 20 |
+
# Validate required inputs
|
| 21 |
+
missing = [r for r in tool.get("required_inputs", []) if r not in inputs]
|
| 22 |
+
if missing:
|
| 23 |
+
return {"success": False, "error": f"Input mancanti: {missing}", "output": None}
|
| 24 |
+
|
| 25 |
+
for attempt in range(self.max_retries + 1):
|
| 26 |
+
try:
|
| 27 |
+
fn = tool.get("_fn")
|
| 28 |
+
if fn is None:
|
| 29 |
+
return {"success": False, "error": "Tool non ha funzione di esecuzione", "output": None}
|
| 30 |
+
result = await asyncio.wait_for(fn(**inputs), timeout=timeout)
|
| 31 |
+
await self.memory.save_episode("tool", f"{tool_name}: {str(inputs)[:100]}", str(result)[:500], True)
|
| 32 |
+
return {"success": True, "tool": tool_name, "output": result, "attempt": attempt + 1}
|
| 33 |
+
except asyncio.TimeoutError:
|
| 34 |
+
if attempt == self.max_retries:
|
| 35 |
+
err = f"Timeout dopo {timeout}s"
|
| 36 |
+
await self.memory.save_episode("tool", f"{tool_name}: {str(inputs)[:100]}", err, False)
|
| 37 |
+
await self.memory.reflect(f"{tool_name} timeout", err, False, err)
|
| 38 |
+
return {"success": False, "error": err, "output": None}
|
| 39 |
+
await asyncio.sleep(1.0 * (attempt + 1))
|
| 40 |
+
except Exception as e:
|
| 41 |
+
if attempt == self.max_retries:
|
| 42 |
+
err = str(e)
|
| 43 |
+
await self.memory.save_episode("tool", f"{tool_name}", err, False)
|
| 44 |
+
return {"success": False, "error": err, "output": None}
|
| 45 |
+
await asyncio.sleep(0.5)
|
| 46 |
+
|
| 47 |
+
return {"success": False, "error": "Max retries raggiunti", "output": None}
|
agents/planner.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
planner.py — Task Planner
|
| 3 |
+
Decompone obiettivi complessi in subtask con tool selection.
|
| 4 |
+
"""
|
| 5 |
+
import json, re
|
| 6 |
+
from models.ollama_client import OllamaClient
|
| 7 |
+
|
| 8 |
+
PLANNER_SYSTEM = """Sei un planner AI. Dato un obiettivo, decomponilo in subtask concreti.
|
| 9 |
+
|
| 10 |
+
Rispondi SOLO con JSON valido nel formato:
|
| 11 |
+
{
|
| 12 |
+
"goal": "obiettivo originale",
|
| 13 |
+
"complexity": "low|medium|high",
|
| 14 |
+
"subtasks": [
|
| 15 |
+
{
|
| 16 |
+
"id": 1,
|
| 17 |
+
"description": "cosa fare",
|
| 18 |
+
"tool": "web_search|code|read_page|memory|direct_response",
|
| 19 |
+
"requires": [],
|
| 20 |
+
"risk": "low|medium|high"
|
| 21 |
+
}
|
| 22 |
+
],
|
| 23 |
+
"estimated_steps": 3
|
| 24 |
+
}"""
|
| 25 |
+
|
| 26 |
+
class Planner:
|
| 27 |
+
def __init__(self, ollama: OllamaClient):
|
| 28 |
+
self.ollama = ollama
|
| 29 |
+
|
| 30 |
+
async def create_plan(self, goal: str, context: list | None = None, model: str | None = None) -> dict:
|
| 31 |
+
messages = [
|
| 32 |
+
{"role": "system", "content": PLANNER_SYSTEM},
|
| 33 |
+
{"role": "user", "content": f"Obiettivo: {goal}"}
|
| 34 |
+
]
|
| 35 |
+
if context:
|
| 36 |
+
ctx_str = "\n".join(m.get("content", "")[:100] for m in context[-3:])
|
| 37 |
+
messages[1]["content"] += f"\n\nContesto recente:\n{ctx_str}"
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
raw = await self.ollama.chat(messages, model=model, temperature=0.3, max_tokens=1024)
|
| 41 |
+
# Extract JSON from response
|
| 42 |
+
json_match = re.search(r'\{[\s\S]+\}', raw)
|
| 43 |
+
if json_match:
|
| 44 |
+
plan = json.loads(json_match.group())
|
| 45 |
+
plan["_raw"] = raw[:200]
|
| 46 |
+
return plan
|
| 47 |
+
except Exception as e:
|
| 48 |
+
pass
|
| 49 |
+
|
| 50 |
+
# Fallback: simple plan
|
| 51 |
+
return {
|
| 52 |
+
"goal": goal,
|
| 53 |
+
"complexity": "medium",
|
| 54 |
+
"subtasks": [{"id": 1, "description": goal, "tool": "direct_response", "requires": [], "risk": "low"}],
|
| 55 |
+
"estimated_steps": 1,
|
| 56 |
+
"_fallback": True,
|
| 57 |
+
}
|
agents/reasoning_core.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
reasoning_core.py — MobileMaxAgent Implementation
|
| 3 |
+
Cervello di livello massimo: Project Understanding + Strategy Engine + Auto-Debug Loop.
|
| 4 |
+
"""
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from typing import List, Dict, Any, Optional
|
| 7 |
+
import json, re
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass
|
| 11 |
+
class ReasoningResult:
|
| 12 |
+
action: str # "plan" | "fix" | "continue" | "stop" | "analyze" | "strategy"
|
| 13 |
+
steps: List[str]
|
| 14 |
+
patch: Optional[str] = None
|
| 15 |
+
reason: str = ""
|
| 16 |
+
confidence: float = 0.5
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class ReasoningState:
|
| 21 |
+
goal: str
|
| 22 |
+
context: str = ""
|
| 23 |
+
last_result: str = ""
|
| 24 |
+
errors: List[str] = field(default_factory=list)
|
| 25 |
+
completed_steps: List[str] = field(default_factory=list)
|
| 26 |
+
loop_count: int = 0
|
| 27 |
+
world_model: Optional[str] = None
|
| 28 |
+
strategy: Optional[str] = None
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class ReasoningCore:
|
| 32 |
+
"""
|
| 33 |
+
MobileMaxAgent — Evoluzione del ReasoningCore.
|
| 34 |
+
Gestisce l'intero ciclo di vita del progetto:
|
| 35 |
+
1. Analyze (Project Understanding)
|
| 36 |
+
2. Strategy (Global Decision Making)
|
| 37 |
+
3. Patch (Multi-file implementation)
|
| 38 |
+
4. Run & Debug (Auto-repair loop)
|
| 39 |
+
"""
|
| 40 |
+
MAX_LOOPS = 15
|
| 41 |
+
MIN_CONFIDENCE = 0.4
|
| 42 |
+
|
| 43 |
+
def __init__(self, ollama_client, planner=None, critic=None, executor=None):
|
| 44 |
+
self.llm = ollama_client
|
| 45 |
+
self.planner = planner
|
| 46 |
+
self.critic = critic
|
| 47 |
+
self.executor = executor
|
| 48 |
+
|
| 49 |
+
# ── 1. Project Understanding ────────────────────────────────────────────────
|
| 50 |
+
async def analyze_project(self, repo_context: str) -> str:
|
| 51 |
+
prompt = f"""Analyze full software system.
|
| 52 |
+
Return:
|
| 53 |
+
- architecture map
|
| 54 |
+
- dependencies
|
| 55 |
+
- risk zones
|
| 56 |
+
- entry points
|
| 57 |
+
CONTEXT:
|
| 58 |
+
{repo_context}
|
| 59 |
+
"""
|
| 60 |
+
return await self.llm.chat([{"role": "user", "content": prompt}], temperature=0.2)
|
| 61 |
+
|
| 62 |
+
# ── 2. Global Strategy (Devin Core) ─────────────────────────────────────────
|
| 63 |
+
async def develop_strategy(self, state: ReasoningState) -> str:
|
| 64 |
+
prompt = f"""You are an autonomous software engineer.
|
| 65 |
+
WORLD MODEL:
|
| 66 |
+
{state.world_model}
|
| 67 |
+
STATE:
|
| 68 |
+
- goal: {state.goal}
|
| 69 |
+
- errors: {state.errors}
|
| 70 |
+
- completed: {state.completed_steps}
|
| 71 |
+
Decide:
|
| 72 |
+
- what to change
|
| 73 |
+
- why
|
| 74 |
+
- impact
|
| 75 |
+
- risk level
|
| 76 |
+
"""
|
| 77 |
+
return await self.llm.chat([{"role": "user", "content": prompt}], temperature=0.3)
|
| 78 |
+
|
| 79 |
+
# ── 3. Error Intelligence ───────────────────────────────────────────────────
|
| 80 |
+
async def analyze_error(self, error: str) -> str:
|
| 81 |
+
prompt = f"""Map error to codebase.
|
| 82 |
+
ERROR:
|
| 83 |
+
{error}
|
| 84 |
+
Return:
|
| 85 |
+
- file
|
| 86 |
+
- root cause
|
| 87 |
+
- fix strategy
|
| 88 |
+
"""
|
| 89 |
+
return await self.llm.chat([{"role": "user", "content": prompt}], temperature=0.1)
|
| 90 |
+
|
| 91 |
+
# ── Prompt builder ──────────────────────────────────────────────────────────
|
| 92 |
+
def _build_prompt(self, state: ReasoningState) -> str:
|
| 93 |
+
errors_str = "\n".join(state.errors[-3:]) if state.errors else "nessuno"
|
| 94 |
+
steps_str = "\n".join(f"- {s}" for s in state.completed_steps[-5:]) if state.completed_steps else "nessuno"
|
| 95 |
+
|
| 96 |
+
return f"""Sei MobileMaxAgent, un sistema di ingegneria software autonoma.
|
| 97 |
+
Analizza lo stato e decidi l'azione successiva.
|
| 98 |
+
|
| 99 |
+
STATO:
|
| 100 |
+
- goal: {state.goal}
|
| 101 |
+
- world_model: {'Presente' if state.world_model else 'Mancante'}
|
| 102 |
+
- strategy: {'Definita' if state.strategy else 'Da definire'}
|
| 103 |
+
- last_result: {state.last_result[:300] if state.last_result else 'vuoto'}
|
| 104 |
+
- errors: {errors_str}
|
| 105 |
+
- loop_count: {state.loop_count}/{self.MAX_LOOPS}
|
| 106 |
+
|
| 107 |
+
Rispondi SOLO con JSON valido:
|
| 108 |
+
{{
|
| 109 |
+
"action": "analyze | strategy | plan | fix | continue | stop",
|
| 110 |
+
"steps": ["prossimo passo tecnico"],
|
| 111 |
+
"patch": "eventuale diff o codice",
|
| 112 |
+
"reason": "perché questa azione?",
|
| 113 |
+
"confidence": 0.0-1.0
|
| 114 |
+
}}
|
| 115 |
+
|
| 116 |
+
Regole:
|
| 117 |
+
1. Se manca world_model -> "analyze"
|
| 118 |
+
2. Se manca strategy -> "strategy"
|
| 119 |
+
3. Se strategy c'è ma serve piano -> "plan"
|
| 120 |
+
4. Se ci sono errori -> "fix"
|
| 121 |
+
5. Se tutto ok -> "continue" o "stop" se finito.
|
| 122 |
+
"""
|
| 123 |
+
|
| 124 |
+
def _parse(self, raw: str) -> ReasoningResult:
|
| 125 |
+
try:
|
| 126 |
+
m = re.search(r'\{[\s\S]+\}', raw)
|
| 127 |
+
data = json.loads(m.group()) if m else {}
|
| 128 |
+
except Exception:
|
| 129 |
+
return ReasoningResult(action="continue", steps=[], reason="Parsing error fallback", confidence=0.2)
|
| 130 |
+
|
| 131 |
+
return ReasoningResult(
|
| 132 |
+
action=data.get("action", "continue"),
|
| 133 |
+
steps=data.get("steps", []),
|
| 134 |
+
patch=data.get("patch"),
|
| 135 |
+
reason=data.get("reason", ""),
|
| 136 |
+
confidence=float(data.get("confidence", 0.5))
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
async def decide(self, state: ReasoningState) -> ReasoningResult:
|
| 140 |
+
if state.loop_count >= self.MAX_LOOPS:
|
| 141 |
+
return ReasoningResult(action="stop", steps=[], reason="Max loops reached", confidence=1.0)
|
| 142 |
+
|
| 143 |
+
prompt = self._build_prompt(state)
|
| 144 |
+
try:
|
| 145 |
+
raw = await self.llm.chat([{"role": "user", "content": prompt}], temperature=0.2)
|
| 146 |
+
return self._parse(raw)
|
| 147 |
+
except Exception as e:
|
| 148 |
+
return ReasoningResult(action="continue", steps=[], reason=f"LLM error: {e}", confidence=0.3)
|
| 149 |
+
|
| 150 |
+
async def run_loop(self, goal: str, context: str = "", on_step=None) -> Dict[str, Any]:
|
| 151 |
+
state = ReasoningState(goal=goal, context=context)
|
| 152 |
+
results = []
|
| 153 |
+
|
| 154 |
+
while state.loop_count < self.MAX_LOOPS:
|
| 155 |
+
decision = await self.decide(state)
|
| 156 |
+
|
| 157 |
+
if on_step:
|
| 158 |
+
await on_step({
|
| 159 |
+
"loop": state.loop_count,
|
| 160 |
+
"action": decision.action,
|
| 161 |
+
"reason": decision.reason,
|
| 162 |
+
"confidence": decision.confidence
|
| 163 |
+
})
|
| 164 |
+
|
| 165 |
+
if decision.action == "stop":
|
| 166 |
+
break
|
| 167 |
+
|
| 168 |
+
elif decision.action == "analyze":
|
| 169 |
+
state.world_model = await self.analyze_project(context or goal)
|
| 170 |
+
results.append({"action": "analyze", "output": "World model built"})
|
| 171 |
+
|
| 172 |
+
elif decision.action == "strategy":
|
| 173 |
+
state.strategy = await self.develop_strategy(state)
|
| 174 |
+
results.append({"action": "strategy", "output": state.strategy})
|
| 175 |
+
|
| 176 |
+
elif decision.action == "plan" and self.planner:
|
| 177 |
+
plan = await self.planner.create_plan(goal, context=state.strategy)
|
| 178 |
+
state.completed_steps.append("Piano creato")
|
| 179 |
+
state.last_result = "Piano generato"
|
| 180 |
+
results.append({"action": "plan", "result": plan})
|
| 181 |
+
|
| 182 |
+
elif decision.action == "fix":
|
| 183 |
+
if decision.patch:
|
| 184 |
+
# Se c'è una patch, l'executor la applica
|
| 185 |
+
if self.executor:
|
| 186 |
+
res = await self.executor.run_tool("file_editor", {"path": "patch.diff", "content": decision.patch})
|
| 187 |
+
state.last_result = str(res.get("output", ""))
|
| 188 |
+
state.errors = []
|
| 189 |
+
results.append({"action": "fix", "patch": "Applicata"})
|
| 190 |
+
else:
|
| 191 |
+
error_analysis = await self.analyze_error(str(state.errors))
|
| 192 |
+
state.last_result = error_analysis
|
| 193 |
+
results.append({"action": "error_analysis", "output": error_analysis})
|
| 194 |
+
|
| 195 |
+
elif decision.action == "continue":
|
| 196 |
+
if self.executor and decision.steps:
|
| 197 |
+
res = await self.executor.run_tool("direct_response", {"input": decision.steps[0]})
|
| 198 |
+
state.last_result = str(res.get("output", ""))
|
| 199 |
+
state.completed_steps.append(decision.steps[0])
|
| 200 |
+
results.append({"action": "continue", "steps": decision.steps})
|
| 201 |
+
|
| 202 |
+
# Auto-debug check con Critic
|
| 203 |
+
if self.critic and state.last_result and decision.action != "analyze":
|
| 204 |
+
critique = await self.critic.evaluate(goal, state.last_result)
|
| 205 |
+
if critique.get("needs_retry"):
|
| 206 |
+
state.errors.extend(critique.get("issues", []))
|
| 207 |
+
|
| 208 |
+
state.loop_count += 1
|
| 209 |
+
|
| 210 |
+
return {
|
| 211 |
+
"goal": goal,
|
| 212 |
+
"loops": state.loop_count,
|
| 213 |
+
"success": len(state.errors) == 0,
|
| 214 |
+
"results": results,
|
| 215 |
+
"final_state": {
|
| 216 |
+
"has_world_model": state.world_model is not None,
|
| 217 |
+
"has_strategy": state.strategy is not None
|
| 218 |
+
}
|
| 219 |
+
}
|
agents/unified_loop.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
unified_loop.py — Unified Agent Loop
|
| 3 |
+
|
| 4 |
+
Loop agente unico per backend cloud/HF Spaces. Usa smolagents quando installato e
|
| 5 |
+
configurato; in caso contrario mantiene un fallback deterministico su Planner,
|
| 6 |
+
ReasoningCore, Executor e Critic già presenti nel backend.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
import os
|
| 12 |
+
from dataclasses import dataclass, field
|
| 13 |
+
from typing import Any, Awaitable, Callable
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
StepCallback = Callable[[dict[str, Any]], Awaitable[None] | None]
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class UnifiedLoopState:
|
| 21 |
+
goal: str
|
| 22 |
+
context: str = ""
|
| 23 |
+
max_steps: int = 8
|
| 24 |
+
steps: list[dict[str, Any]] = field(default_factory=list)
|
| 25 |
+
errors: list[str] = field(default_factory=list)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class UnifiedAgentLoop:
|
| 29 |
+
"""Smolagents-first loop with safe fallback for environments without it."""
|
| 30 |
+
|
| 31 |
+
def __init__(self, llm_client: Any, planner: Any = None, executor: Any = None, critic: Any = None, memory: Any = None):
|
| 32 |
+
self.llm = llm_client
|
| 33 |
+
self.planner = planner
|
| 34 |
+
self.executor = executor
|
| 35 |
+
self.critic = critic
|
| 36 |
+
self.memory = memory
|
| 37 |
+
self._smol_agent = None
|
| 38 |
+
|
| 39 |
+
def _build_prompt(self, state: UnifiedLoopState) -> str:
|
| 40 |
+
memory_hint = ""
|
| 41 |
+
if self.memory:
|
| 42 |
+
memory_hint = "\nUsa la memoria disponibile per evitare ripetizioni e preservare preferenze utente."
|
| 43 |
+
return f"""Sei un agente operativo unificato.
|
| 44 |
+
Obiettivo: {state.goal}
|
| 45 |
+
Contesto: {state.context or 'nessuno'}
|
| 46 |
+
{memory_hint}
|
| 47 |
+
|
| 48 |
+
Produci una risposta o un piano operativo conciso. Se servono tool, indica passi verificabili.
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
def _load_smol_agent(self) -> Any | None:
|
| 52 |
+
if self._smol_agent is not None:
|
| 53 |
+
return self._smol_agent
|
| 54 |
+
try:
|
| 55 |
+
from smolagents import CodeAgent, LiteLLMModel # type: ignore
|
| 56 |
+
|
| 57 |
+
model_id = os.getenv("SMOLAGENTS_MODEL") or os.getenv("LLM_MODEL") or "gpt-4o-mini"
|
| 58 |
+
api_key = os.getenv("OPENAI_API_KEY") or os.getenv("GROQ_API_KEY") or os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACE_API_KEY")
|
| 59 |
+
if not api_key:
|
| 60 |
+
return None
|
| 61 |
+
model = LiteLLMModel(model_id=model_id, api_key=api_key)
|
| 62 |
+
self._smol_agent = CodeAgent(tools=[], model=model, max_steps=int(os.getenv("UNIFIED_LOOP_MAX_STEPS", "8")))
|
| 63 |
+
return self._smol_agent
|
| 64 |
+
except Exception:
|
| 65 |
+
return None
|
| 66 |
+
|
| 67 |
+
async def _run_smolagents(self, state: UnifiedLoopState, on_step: StepCallback | None) -> dict[str, Any] | None:
|
| 68 |
+
agent = self._load_smol_agent()
|
| 69 |
+
if agent is None:
|
| 70 |
+
return None
|
| 71 |
+
|
| 72 |
+
prompt = self._build_prompt(state)
|
| 73 |
+
if on_step:
|
| 74 |
+
await _maybe_await(on_step({"loop": 0, "action": "smolagents", "status": "started"}))
|
| 75 |
+
try:
|
| 76 |
+
result = await asyncio.to_thread(agent.run, prompt)
|
| 77 |
+
output = str(result)
|
| 78 |
+
state.steps.append({"action": "smolagents", "output": output})
|
| 79 |
+
if self.memory:
|
| 80 |
+
await self.memory.save_episode("unified_loop", state.goal, output[:1000], True, tags=["smolagents"])
|
| 81 |
+
if on_step:
|
| 82 |
+
await _maybe_await(on_step({"loop": 1, "action": "smolagents", "status": "done"}))
|
| 83 |
+
return {"success": True, "engine": "smolagents", "goal": state.goal, "steps": state.steps, "output": output}
|
| 84 |
+
except Exception as exc:
|
| 85 |
+
state.errors.append(str(exc))
|
| 86 |
+
if on_step:
|
| 87 |
+
await _maybe_await(on_step({"loop": 1, "action": "smolagents", "status": "error", "error": str(exc)}))
|
| 88 |
+
return None
|
| 89 |
+
|
| 90 |
+
async def _run_fallback(self, state: UnifiedLoopState, on_step: StepCallback | None) -> dict[str, Any]:
|
| 91 |
+
outputs: list[str] = []
|
| 92 |
+
|
| 93 |
+
if self.memory:
|
| 94 |
+
memory_ctx = await self.memory.get_context(state.goal)
|
| 95 |
+
if memory_ctx:
|
| 96 |
+
state.context = f"{state.context}\n\nMEMORIA:\n{memory_ctx}".strip()
|
| 97 |
+
|
| 98 |
+
if self.planner:
|
| 99 |
+
if on_step:
|
| 100 |
+
await _maybe_await(on_step({"loop": 0, "action": "plan", "status": "started"}))
|
| 101 |
+
plan = await self.planner.create_plan(state.goal, context=[{"role": "system", "content": state.context}])
|
| 102 |
+
state.steps.append({"action": "plan", "result": plan})
|
| 103 |
+
outputs.append(str(plan))
|
| 104 |
+
|
| 105 |
+
prompt = self._build_prompt(state)
|
| 106 |
+
if on_step:
|
| 107 |
+
await _maybe_await(on_step({"loop": 1, "action": "llm", "status": "started"}))
|
| 108 |
+
answer = await self.llm.chat([{"role": "user", "content": prompt}], temperature=0.2, max_tokens=2048)
|
| 109 |
+
state.steps.append({"action": "llm", "output": answer})
|
| 110 |
+
outputs.append(answer)
|
| 111 |
+
|
| 112 |
+
if self.critic:
|
| 113 |
+
critique = await self.critic.evaluate(state.goal, answer)
|
| 114 |
+
state.steps.append({"action": "critic", "result": critique})
|
| 115 |
+
if critique.get("needs_retry"):
|
| 116 |
+
state.errors.extend([str(x) for x in critique.get("issues", [])])
|
| 117 |
+
|
| 118 |
+
success = len(state.errors) == 0
|
| 119 |
+
final_output = "\n\n".join(outputs).strip()
|
| 120 |
+
if self.memory:
|
| 121 |
+
await self.memory.save_episode("unified_loop", state.goal, final_output[:1000], success, tags=["fallback"])
|
| 122 |
+
if on_step:
|
| 123 |
+
await _maybe_await(on_step({"loop": 2, "action": "fallback", "status": "done", "success": success}))
|
| 124 |
+
return {"success": success, "engine": "fallback", "goal": state.goal, "steps": state.steps, "errors": state.errors, "output": final_output}
|
| 125 |
+
|
| 126 |
+
async def run(self, goal: str, context: str = "", max_steps: int = 8, on_step: StepCallback | None = None) -> dict[str, Any]:
|
| 127 |
+
state = UnifiedLoopState(goal=goal, context=context, max_steps=max_steps)
|
| 128 |
+
smol_result = await self._run_smolagents(state, on_step)
|
| 129 |
+
if smol_result:
|
| 130 |
+
return smol_result
|
| 131 |
+
return await self._run_fallback(state, on_step)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
async def _maybe_await(value: Any) -> None:
|
| 135 |
+
if hasattr(value, "__await__"):
|
| 136 |
+
await value
|
api/__init__.py
ADDED
|
File without changes
|
api/coding.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""api/coding.py — Coding agent endpoints"""
|
| 2 |
+
from fastapi import APIRouter
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
from typing import Optional,List
|
| 5 |
+
from agents.code_agent import CodeAgent
|
| 6 |
+
|
| 7 |
+
router=APIRouter(prefix="/code",tags=["coding"])
|
| 8 |
+
_agent=None
|
| 9 |
+
def get_agent()->CodeAgent:
|
| 10 |
+
global _agent
|
| 11 |
+
if _agent is None: from main import ollama; _agent=CodeAgent(ollama)
|
| 12 |
+
return _agent
|
| 13 |
+
|
| 14 |
+
class AnalyzeReq(BaseModel): filepath:str; content:str; goal:str
|
| 15 |
+
class SessionReq(BaseModel): goal:str; files:List[dict]; model:Optional[str]=None
|
| 16 |
+
|
| 17 |
+
@router.post("/analyze")
|
| 18 |
+
async def analyze(req:AnalyzeReq):
|
| 19 |
+
return await get_agent().analyze_file(req.filepath,req.content,req.goal)
|
| 20 |
+
|
| 21 |
+
@router.post("/session")
|
| 22 |
+
async def session(req:SessionReq):
|
| 23 |
+
return await get_agent().full_session(req.goal,req.files)
|
api/web.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""api/web.py — Web search + fetch endpoints"""
|
| 2 |
+
from fastapi import APIRouter
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
from typing import Optional
|
| 5 |
+
from tools.web_search import web_search
|
| 6 |
+
from tools.web_fetch import fetch_page
|
| 7 |
+
from tools.ranking import rank_results, format_for_llm
|
| 8 |
+
from tools.content_cleaner import clean_and_structure
|
| 9 |
+
|
| 10 |
+
router=APIRouter(prefix="/web",tags=["web"])
|
| 11 |
+
|
| 12 |
+
class SearchReq(BaseModel):
|
| 13 |
+
query:str; focus:Optional[str]="general"; max_results:Optional[int]=6; format:Optional[str]="json"
|
| 14 |
+
|
| 15 |
+
class FetchReq(BaseModel):
|
| 16 |
+
url:str; query:Optional[str]=None; max_chars:Optional[int]=5000
|
| 17 |
+
|
| 18 |
+
@router.post("/search")
|
| 19 |
+
async def search(req:SearchReq):
|
| 20 |
+
raw=await web_search(req.query,req.focus or "general",req.max_results or 6)
|
| 21 |
+
ranked=rank_results(raw.get("results",[]),req.query,top_k=req.max_results or 6)
|
| 22 |
+
if req.format=="text": return {"text":format_for_llm(ranked,req.query),"query":req.query}
|
| 23 |
+
return {**raw,"results":ranked}
|
| 24 |
+
|
| 25 |
+
@router.post("/fetch")
|
| 26 |
+
async def fetch(req:FetchReq):
|
| 27 |
+
raw=await fetch_page(req.url,max_chars=req.max_chars or 5000)
|
| 28 |
+
if req.query and raw.get("content"):
|
| 29 |
+
cleaned=clean_and_structure(raw["content"],req.query)
|
| 30 |
+
raw["content"]=cleaned["content"]; raw["words"]=cleaned["words"]
|
| 31 |
+
return raw
|
architecture.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
architecture.py — Free distributed no-PC architecture profile
|
| 3 |
+
|
| 4 |
+
The selected zero-cost architecture is deliberately split into control,
|
| 5 |
+
execution, tools and memory layers:
|
| 6 |
+
|
| 7 |
+
- iPhone/mobile browser: control surface only.
|
| 8 |
+
- GitHub Actions: event-triggered Agent Kernel and lightweight execution.
|
| 9 |
+
- Hugging Face Spaces: backend tools/API runtime, not a heavy persistent VM.
|
| 10 |
+
- LiteLLM/OpenAI-compatible provider routing: free remote model pool.
|
| 11 |
+
- GitHub repository: primary memory, code, task history and run reports.
|
| 12 |
+
|
| 13 |
+
This keeps the system usable without Replit, without a local PC and without
|
| 14 |
+
paid infrastructure while making the real limits explicit.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
from dataclasses import dataclass, asdict
|
| 20 |
+
from typing import Any
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass(frozen=True)
|
| 24 |
+
class Capability:
|
| 25 |
+
name: str
|
| 26 |
+
status: str
|
| 27 |
+
reason: str
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
FREE_REMOTE_PLAN = {
|
| 31 |
+
"name": "github_actions_hf_spaces_free_agent",
|
| 32 |
+
"label": "GitHub Actions + HF Spaces Free Agent Kernel",
|
| 33 |
+
"mode": "total_free_no_local_pc",
|
| 34 |
+
"control_surface": "iPhone via GitHub Actions workflow_dispatch or web frontend",
|
| 35 |
+
"primary_runtime": "GitHub Actions for agent runs; Hugging Face Spaces for backend tools/API",
|
| 36 |
+
"tool_runtime": "Hugging Face Spaces Docker free tier",
|
| 37 |
+
"memory_primary": "GitHub repository under .agent/ plus code/history",
|
| 38 |
+
"memory_secondary": "SQLite/local backend memory when the Space is available",
|
| 39 |
+
"principle": "Distributed backend-first agent kernel with safe-by-default GitHub memory, provider fallback and mobile review.",
|
| 40 |
+
"model_priority": ["openrouter", "gemini", "groq", "huggingface", "openai_compatible"],
|
| 41 |
+
"execution": "GitHub Actions for bounded jobs; backend allowlisted tools only; browser sandbox disabled by default",
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _env_enabled(name: str, default: bool = False) -> bool:
|
| 46 |
+
raw = os.getenv(name)
|
| 47 |
+
if raw is None:
|
| 48 |
+
return default
|
| 49 |
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def get_architecture_profile() -> dict[str, Any]:
|
| 53 |
+
"""Return runtime capabilities for the free no-PC deployment plan."""
|
| 54 |
+
providers = {
|
| 55 |
+
"openrouter": bool(os.getenv("OPENROUTER_API_KEY")),
|
| 56 |
+
"gemini": bool(os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")),
|
| 57 |
+
"groq": bool(os.getenv("GROQ_API_KEY")),
|
| 58 |
+
"huggingface": bool(os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACE_API_KEY") or os.getenv("HUGGINGFACE_TOKEN")),
|
| 59 |
+
"openai_compatible": bool(os.getenv("OPENAI_API_KEY")),
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
github_actions_configured = bool(os.getenv("GITHUB_ACTIONS") or os.getenv("GITHUB_REPOSITORY"))
|
| 63 |
+
github_token_configured = bool(os.getenv("GITHUB_TOKEN") or os.getenv("GH_TOKEN"))
|
| 64 |
+
|
| 65 |
+
capabilities = [
|
| 66 |
+
Capability("mobile_control", "enabled", "GitHub Actions workflow_dispatch and the frontend can be used from iPhone."),
|
| 67 |
+
Capability("github_actions_agent_kernel", "enabled", "Bounded agent runs execute in GitHub Actions and persist reports under .agent/."),
|
| 68 |
+
Capability("github_memory", "enabled", "State, task history and run reports are committed to the repository for durable zero-cost memory."),
|
| 69 |
+
Capability("hf_spaces_tools", "enabled", "The FastAPI backend remains the lightweight tools/API runtime for the free Space."),
|
| 70 |
+
Capability("litellm_routing", "enabled", "OpenAI-compatible provider order supports OpenRouter, Gemini, Groq, Hugging Face and optional OpenAI-compatible endpoints."),
|
| 71 |
+
Capability("sqlite_memory", "secondary", "Backend SQLite/local memory remains useful but is not the primary no-PC source of truth."),
|
| 72 |
+
Capability("qdrant", "optional", "Use only if an external free Qdrant endpoint is configured; not required for the baseline."),
|
| 73 |
+
Capability("openhands", "external_optional", "Full OpenHands remains too heavy for the free baseline and should not run inside the free Space."),
|
| 74 |
+
Capability("docker_sandbox", "disabled_on_free_space", "Nested Docker is intentionally disabled on the free plan."),
|
| 75 |
+
Capability("browser_sandbox", "disabled_by_default", "Client-side code execution is not a real isolated workspace and is not part of the main path."),
|
| 76 |
+
]
|
| 77 |
+
|
| 78 |
+
return {
|
| 79 |
+
"plan": FREE_REMOTE_PLAN,
|
| 80 |
+
"providers": providers,
|
| 81 |
+
"capabilities": [asdict(c) for c in capabilities],
|
| 82 |
+
"limits": {
|
| 83 |
+
"requires_local_pc": False,
|
| 84 |
+
"requires_paid_vm": False,
|
| 85 |
+
"runs_heavy_local_models": False,
|
| 86 |
+
"supports_nested_docker": False,
|
| 87 |
+
"long_running_24_7_agent": False,
|
| 88 |
+
"bounded_actions_runtime": True,
|
| 89 |
+
},
|
| 90 |
+
"github_actions": {
|
| 91 |
+
"workflow": ".github/workflows/agent-kernel.yml",
|
| 92 |
+
"workflow_template": "docs/workflows/agent-kernel.yml",
|
| 93 |
+
"activation_note": "Copy the template to .github/workflows/agent-kernel.yml with GitHub UI or a token that has workflow permission.",
|
| 94 |
+
"script": "scripts/agent_kernel.py",
|
| 95 |
+
"memory_dir": ".agent/",
|
| 96 |
+
"configured_in_current_runtime": github_actions_configured,
|
| 97 |
+
"token_available_in_current_runtime": github_token_configured,
|
| 98 |
+
"trigger_modes": ["workflow_dispatch", "issue_label_agent-run"],
|
| 99 |
+
},
|
| 100 |
+
"feature_flags": {
|
| 101 |
+
"browser_sandbox_enabled": _env_enabled("VITE_ENABLE_BROWSER_SANDBOX", False),
|
| 102 |
+
"browser_llm_enabled": _env_enabled("VITE_ENABLE_BROWSER_LLM", False),
|
| 103 |
+
"qdrant_enabled": bool(os.getenv("QDRANT_URL")),
|
| 104 |
+
},
|
| 105 |
+
}
|
components.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://ui.shadcn.com/schema.json",
|
| 3 |
+
"style": "new-york",
|
| 4 |
+
"rsc": false,
|
| 5 |
+
"tsx": true,
|
| 6 |
+
"tailwind": {
|
| 7 |
+
"config": "",
|
| 8 |
+
"css": "src/index.css",
|
| 9 |
+
"baseColor": "neutral",
|
| 10 |
+
"cssVariables": true,
|
| 11 |
+
"prefix": ""
|
| 12 |
+
},
|
| 13 |
+
"aliases": {
|
| 14 |
+
"components": "@/components",
|
| 15 |
+
"utils": "@/lib/utils",
|
| 16 |
+
"ui": "@/components/ui",
|
| 17 |
+
"lib": "@/lib",
|
| 18 |
+
"hooks": "@/hooks"
|
| 19 |
+
}
|
| 20 |
+
}
|
index.html
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="it">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
|
| 6 |
+
<title>Agente AI Pro</title>
|
| 7 |
+
<meta name="description" content="Agente AI — assistente intelligente con strumenti autonomi: ricerca web, meteo, notizie, codice, memoria e molto altro." />
|
| 8 |
+
<meta name="theme-color" content="#0f0f1a" />
|
| 9 |
+
<meta property="og:title" content="Agente AI Pro" />
|
| 10 |
+
<meta property="og:description" content="Assistente AI autonomo con 14 strumenti integrati" />
|
| 11 |
+
<meta property="og:type" content="website" />
|
| 12 |
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 13 |
+
<meta name="description" content="Agente AI — assistente autonomo con web search, memoria, esecuzione codice e più provider AI." />
|
| 14 |
+
<meta name="theme-color" content="#0f0f1a" />
|
| 15 |
+
<meta property="og:title" content="Agente AI" />
|
| 16 |
+
<meta property="og:description" content="AI agent autonomo con web search, memoria e multi-AI Pro Mode." />
|
| 17 |
+
<meta property="og:type" content="website" />
|
| 18 |
+
<meta name="twitter:card" content="summary" />
|
| 19 |
+
<meta name="twitter:title" content="Agente AI" />
|
| 20 |
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
| 21 |
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
| 22 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 23 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 24 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 25 |
+
|
| 26 |
+
<!--
|
| 27 |
+
Register the COI Service Worker so that SharedArrayBuffer is available
|
| 28 |
+
on GitHub Pages (which doesn't allow custom COOP/COEP headers).
|
| 29 |
+
Required for WebLLM / WebGPU to work in-browser.
|
| 30 |
+
-->
|
| 31 |
+
<script>
|
| 32 |
+
if ("serviceWorker" in navigator) {
|
| 33 |
+
// Handle both root domain and subfolder (GitHub Pages)
|
| 34 |
+
const isGH = window.location.hostname.includes("github.io");
|
| 35 |
+
const pathParts = window.location.pathname.split("/").filter(Boolean);
|
| 36 |
+
const base = (isGH && pathParts.length > 0) ? "/" + pathParts[0] : "";
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
navigator.serviceWorker
|
| 40 |
+
.register(swPath)
|
| 41 |
+
.then(function (reg) {
|
| 42 |
+
// If the page is not yet cross-origin-isolated, reload once
|
| 43 |
+
// so the service worker can add the necessary headers.
|
| 44 |
+
if (!window.crossOriginIsolated) {
|
| 45 |
+
if (sessionStorage.getItem("__coi_reloaded__") === "1") {
|
| 46 |
+
console.warn("[COI] Already reloaded once; giving up to avoid loop. WebLLM disabled.");
|
| 47 |
+
} else {
|
| 48 |
+
sessionStorage.setItem("__coi_reloaded__", "1");
|
| 49 |
+
console.log("[COI] Reloading for cross-origin isolation…");
|
| 50 |
+
window.location.reload();
|
| 51 |
+
}
|
| 52 |
+
} else {
|
| 53 |
+
sessionStorage.removeItem("__coi_reloaded__");
|
| 54 |
+
console.log("[COI] crossOriginIsolated =", window.crossOriginIsolated);
|
| 55 |
+
}
|
| 56 |
+
})
|
| 57 |
+
.catch(function (err) {
|
| 58 |
+
console.warn("[COI] Service worker registration failed:", err);
|
| 59 |
+
});
|
| 60 |
+
}
|
| 61 |
+
</script>
|
| 62 |
+
</head>
|
| 63 |
+
<body>
|
| 64 |
+
<div id="root"></div>
|
| 65 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 66 |
+
</body>
|
| 67 |
+
</html>
|
main.py
CHANGED
|
@@ -1,20 +1,490 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
| 2 |
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
-
import
|
|
|
|
| 4 |
|
| 5 |
-
|
| 6 |
|
|
|
|
| 7 |
app.add_middleware(
|
| 8 |
CORSMiddleware,
|
| 9 |
-
allow_origins=[
|
| 10 |
-
allow_methods=[
|
| 11 |
-
allow_headers=[
|
| 12 |
)
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
async def health():
|
| 16 |
return {
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
| 20 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os, sys, json, asyncio, subprocess, tempfile, shlex, time, uuid
|
| 2 |
+
from typing import Optional, Any
|
| 3 |
+
from fastapi import FastAPI, HTTPException, Body
|
| 4 |
+
from fastapi.staticfiles import StaticFiles
|
| 5 |
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
+
from fastapi.responses import JSONResponse
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
|
| 9 |
+
print('BOOT: importing FastAPI...', flush=True)
|
| 10 |
|
| 11 |
+
app = FastAPI(title='Agente AI', version='3.1.0')
|
| 12 |
app.add_middleware(
|
| 13 |
CORSMiddleware,
|
| 14 |
+
allow_origins=['*'],
|
| 15 |
+
allow_methods=['*'],
|
| 16 |
+
allow_headers=['*'],
|
| 17 |
)
|
| 18 |
|
| 19 |
+
# ── Supabase (optional) ───────────────────────────────────────────────────────
|
| 20 |
+
_sb = None
|
| 21 |
+
SUPABASE_URL = os.getenv('SUPABASE_URL', '')
|
| 22 |
+
SUPABASE_KEY = os.getenv('SUPABASE_ANON_KEY') or os.getenv('SUPABASE_KEY', '')
|
| 23 |
+
|
| 24 |
+
if SUPABASE_URL and SUPABASE_KEY:
|
| 25 |
+
try:
|
| 26 |
+
from supabase import create_client
|
| 27 |
+
_sb = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 28 |
+
print('BOOT: Supabase connected ✓', flush=True)
|
| 29 |
+
except Exception as e:
|
| 30 |
+
print(f'BOOT: Supabase init failed: {e}', flush=True)
|
| 31 |
+
else:
|
| 32 |
+
print('BOOT: Supabase not configured (SUPABASE_URL / SUPABASE_KEY missing)', flush=True)
|
| 33 |
+
|
| 34 |
+
def sb():
|
| 35 |
+
if not _sb:
|
| 36 |
+
raise HTTPException(503, detail={
|
| 37 |
+
'error': 'supabase_not_configured',
|
| 38 |
+
'message': 'Imposta SUPABASE_URL e SUPABASE_KEY nelle variabili Railway/HuggingFace per abilitare la persistenza.',
|
| 39 |
+
})
|
| 40 |
+
return _sb
|
| 41 |
+
|
| 42 |
+
# ── Sensitive keys mask ───────────────────────────────────────────────────────
|
| 43 |
+
SENSITIVE = {
|
| 44 |
+
'OPENROUTER_API_KEY','OPENAI_API_KEY','GEMINI_API_KEY','GROQ_API_KEY',
|
| 45 |
+
'HF_TOKEN','HUGGINGFACE_API_KEY','GH_TOKEN','GITHUB_TOKEN',
|
| 46 |
+
'QDRANT_API_KEY','DATABASE_URL','SESSION_SECRET','SECRET_KEY',
|
| 47 |
+
'RAILWAY_TOKEN','SUPABASE_KEY','SUPABASE_ANON_KEY',
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
# ── Pydantic models ───────────────────────────────────────────────────────────
|
| 51 |
+
class ConversationIn(BaseModel):
|
| 52 |
+
id: str
|
| 53 |
+
title: str = 'Nuova conversazione'
|
| 54 |
+
created_at: int
|
| 55 |
+
updated_at: int
|
| 56 |
+
|
| 57 |
+
class MessageIn(BaseModel):
|
| 58 |
+
id: str
|
| 59 |
+
conversation_id: str
|
| 60 |
+
role: str
|
| 61 |
+
content: str
|
| 62 |
+
created_at: int
|
| 63 |
+
error: Optional[bool] = False
|
| 64 |
+
steps: Optional[Any] = None
|
| 65 |
+
agent_status: Optional[str] = None
|
| 66 |
+
|
| 67 |
+
class ShellCmd(BaseModel):
|
| 68 |
+
command: str
|
| 69 |
+
timeout: int = 15
|
| 70 |
+
|
| 71 |
+
class PipInstall(BaseModel):
|
| 72 |
+
packages: list[str]
|
| 73 |
+
|
| 74 |
+
class MemoryEntry(BaseModel):
|
| 75 |
+
key: str
|
| 76 |
+
value: str
|
| 77 |
+
category: str = 'general'
|
| 78 |
+
createdAt: int = 0
|
| 79 |
+
updatedAt: int = 0
|
| 80 |
+
|
| 81 |
+
class ReasonLoopIn(BaseModel):
|
| 82 |
+
goal: str
|
| 83 |
+
context: list[dict] = []
|
| 84 |
+
max_steps: int = 8
|
| 85 |
+
|
| 86 |
+
# ── In-memory fallback for agent memory (when Supabase not available) ─────────
|
| 87 |
+
_mem_fallback: dict[str, dict] = {}
|
| 88 |
+
|
| 89 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 90 |
+
# HEALTH / STATUS
|
| 91 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 92 |
+
|
| 93 |
+
@app.get('/health')
|
| 94 |
async def health():
|
| 95 |
return {
|
| 96 |
+
'status': 'ok',
|
| 97 |
+
'version': '3.1.0',
|
| 98 |
+
'supabase': _sb is not None,
|
| 99 |
+
'backend': 'HuggingFace Spaces / Railway',
|
| 100 |
}
|
| 101 |
+
|
| 102 |
+
@app.get('/api/status')
|
| 103 |
+
async def status():
|
| 104 |
+
safe_env = {k: '***' if k in SENSITIVE else v for k, v in os.environ.items()}
|
| 105 |
+
return {'status': 'running', 'env': safe_env, 'supabase': _sb is not None}
|
| 106 |
+
|
| 107 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 108 |
+
# CONVERSATIONS
|
| 109 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 110 |
+
|
| 111 |
+
@app.get('/api/conversations')
|
| 112 |
+
async def list_conversations():
|
| 113 |
+
data = sb().table('conversations').select('*').order('updated_at', desc=True).execute()
|
| 114 |
+
return {'conversations': data.data}
|
| 115 |
+
|
| 116 |
+
@app.post('/api/conversations')
|
| 117 |
+
async def upsert_conversation(conv: ConversationIn):
|
| 118 |
+
data = sb().table('conversations').upsert(conv.model_dump()).execute()
|
| 119 |
+
return {'conversation': data.data[0] if data.data else conv.model_dump()}
|
| 120 |
+
|
| 121 |
+
@app.put('/api/conversations/{conv_id}')
|
| 122 |
+
async def update_conversation(conv_id: str, body: dict = Body(...)):
|
| 123 |
+
body['id'] = conv_id
|
| 124 |
+
data = sb().table('conversations').upsert(body).execute()
|
| 125 |
+
return {'conversation': data.data[0] if data.data else body}
|
| 126 |
+
|
| 127 |
+
@app.delete('/api/conversations/{conv_id}')
|
| 128 |
+
async def delete_conversation(conv_id: str):
|
| 129 |
+
sb().table('messages').delete().eq('conversation_id', conv_id).execute()
|
| 130 |
+
sb().table('conversations').delete().eq('id', conv_id).execute()
|
| 131 |
+
return {'deleted': conv_id}
|
| 132 |
+
|
| 133 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 134 |
+
# MESSAGES
|
| 135 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 136 |
+
|
| 137 |
+
@app.get('/api/conversations/{conv_id}/messages')
|
| 138 |
+
async def list_messages(conv_id: str):
|
| 139 |
+
data = sb().table('messages').select('*').eq('conversation_id', conv_id).order('created_at').execute()
|
| 140 |
+
return {'messages': data.data}
|
| 141 |
+
|
| 142 |
+
@app.post('/api/conversations/{conv_id}/messages')
|
| 143 |
+
async def upsert_messages(conv_id: str, body: dict = Body(...)):
|
| 144 |
+
msgs = body.get('messages', [])
|
| 145 |
+
if not msgs:
|
| 146 |
+
return {'upserted': 0}
|
| 147 |
+
for m in msgs:
|
| 148 |
+
m['conversation_id'] = conv_id
|
| 149 |
+
if 'steps' in m and m['steps'] is not None:
|
| 150 |
+
m['steps'] = json.dumps(m['steps']) if not isinstance(m['steps'], str) else m['steps']
|
| 151 |
+
data = sb().table('messages').upsert(msgs).execute()
|
| 152 |
+
return {'upserted': len(data.data)}
|
| 153 |
+
|
| 154 |
+
@app.delete('/api/conversations/{conv_id}/messages')
|
| 155 |
+
async def clear_messages(conv_id: str):
|
| 156 |
+
sb().table('messages').delete().eq('conversation_id', conv_id).execute()
|
| 157 |
+
return {'cleared': conv_id}
|
| 158 |
+
|
| 159 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 160 |
+
# AGENT MEMORY — persistente su Supabase, fallback in-memory
|
| 161 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 162 |
+
|
| 163 |
+
@app.get('/api/memory/agent')
|
| 164 |
+
async def list_agent_memory():
|
| 165 |
+
if _sb:
|
| 166 |
+
try:
|
| 167 |
+
data = _sb.table('agent_memory').select('*').order('updated_at', desc=True).execute()
|
| 168 |
+
entries = [
|
| 169 |
+
{'key': r['key'], 'value': r['value'], 'category': r.get('category', 'general'),
|
| 170 |
+
'createdAt': r.get('created_at', 0), 'updatedAt': r.get('updated_at', 0)}
|
| 171 |
+
for r in (data.data or [])
|
| 172 |
+
]
|
| 173 |
+
return {'entries': entries}
|
| 174 |
+
except Exception as e:
|
| 175 |
+
print(f'[memory] Supabase list error: {e}', flush=True)
|
| 176 |
+
# Fallback in-memory
|
| 177 |
+
return {'entries': list(_mem_fallback.values())}
|
| 178 |
+
|
| 179 |
+
@app.get('/api/memory/agent/{key}')
|
| 180 |
+
async def get_agent_memory(key: str):
|
| 181 |
+
if _sb:
|
| 182 |
+
try:
|
| 183 |
+
data = _sb.table('agent_memory').select('*').eq('key', key).limit(1).execute()
|
| 184 |
+
if data.data:
|
| 185 |
+
return {'value': data.data[0]['value']}
|
| 186 |
+
except Exception as e:
|
| 187 |
+
print(f'[memory] Supabase get error: {e}', flush=True)
|
| 188 |
+
# Fallback in-memory
|
| 189 |
+
entry = _mem_fallback.get(key)
|
| 190 |
+
return {'value': entry['value'] if entry else None}
|
| 191 |
+
|
| 192 |
+
@app.post('/api/memory/agent')
|
| 193 |
+
async def set_agent_memory(entry: MemoryEntry):
|
| 194 |
+
now = int(time.time() * 1000)
|
| 195 |
+
row = {
|
| 196 |
+
'key': entry.key,
|
| 197 |
+
'value': entry.value,
|
| 198 |
+
'category': entry.category,
|
| 199 |
+
'created_at': entry.createdAt or now,
|
| 200 |
+
'updated_at': entry.updatedAt or now,
|
| 201 |
+
}
|
| 202 |
+
if _sb:
|
| 203 |
+
try:
|
| 204 |
+
_sb.table('agent_memory').upsert(row).execute()
|
| 205 |
+
except Exception as e:
|
| 206 |
+
print(f'[memory] Supabase set error: {e}', flush=True)
|
| 207 |
+
# Always update in-memory fallback
|
| 208 |
+
_mem_fallback[entry.key] = {
|
| 209 |
+
'key': entry.key, 'value': entry.value, 'category': entry.category,
|
| 210 |
+
'createdAt': row['created_at'], 'updatedAt': row['updated_at'],
|
| 211 |
+
}
|
| 212 |
+
return {'ok': True, 'key': entry.key}
|
| 213 |
+
|
| 214 |
+
@app.delete('/api/memory/agent/{key}')
|
| 215 |
+
async def delete_agent_memory(key: str):
|
| 216 |
+
if _sb:
|
| 217 |
+
try:
|
| 218 |
+
_sb.table('agent_memory').delete().eq('key', key).execute()
|
| 219 |
+
except Exception:
|
| 220 |
+
pass
|
| 221 |
+
_mem_fallback.pop(key, None)
|
| 222 |
+
return {'deleted': key}
|
| 223 |
+
|
| 224 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 225 |
+
# VIRTUAL FILE SYSTEM
|
| 226 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 227 |
+
|
| 228 |
+
@app.get('/api/files')
|
| 229 |
+
async def list_files(conversation_id: Optional[str] = None):
|
| 230 |
+
q = sb().table('vfs_files').select('id, path, language, conversation_id, updated_at')
|
| 231 |
+
if conversation_id:
|
| 232 |
+
q = q.eq('conversation_id', conversation_id)
|
| 233 |
+
data = q.order('path').execute()
|
| 234 |
+
return {'files': data.data}
|
| 235 |
+
|
| 236 |
+
@app.get('/api/files/{file_id}')
|
| 237 |
+
async def get_file(file_id: str):
|
| 238 |
+
data = sb().table('vfs_files').select('*').eq('id', file_id).single().execute()
|
| 239 |
+
return {'file': data.data}
|
| 240 |
+
|
| 241 |
+
@app.post('/api/files')
|
| 242 |
+
async def save_file(body: dict = Body(...)):
|
| 243 |
+
if 'id' not in body:
|
| 244 |
+
body['id'] = str(uuid.uuid4())
|
| 245 |
+
if 'updated_at' not in body:
|
| 246 |
+
body['updated_at'] = int(time.time() * 1000)
|
| 247 |
+
if 'created_at' not in body:
|
| 248 |
+
body['created_at'] = body['updated_at']
|
| 249 |
+
data = sb().table('vfs_files').upsert(body).execute()
|
| 250 |
+
return {'file': data.data[0] if data.data else body}
|
| 251 |
+
|
| 252 |
+
@app.delete('/api/files/{file_id}')
|
| 253 |
+
async def delete_file(file_id: str):
|
| 254 |
+
sb().table('vfs_files').delete().eq('id', file_id).execute()
|
| 255 |
+
return {'deleted': file_id}
|
| 256 |
+
|
| 257 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 258 |
+
# REASONING LOOP — delega al backend unificato per task complessi
|
| 259 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 260 |
+
|
| 261 |
+
@app.post('/api/reason/loop')
|
| 262 |
+
async def reason_loop(body: ReasonLoopIn):
|
| 263 |
+
"""
|
| 264 |
+
Loop di ragionamento backend per task complessi.
|
| 265 |
+
Usa UnifiedAgentLoop con smolagents se disponibile, altrimenti fallback deterministico.
|
| 266 |
+
"""
|
| 267 |
+
try:
|
| 268 |
+
from agents.unified_loop import UnifiedAgentLoop
|
| 269 |
+
from models.ai_client import AIClient
|
| 270 |
+
client = AIClient()
|
| 271 |
+
loop = UnifiedAgentLoop(llm_client=client)
|
| 272 |
+
result = await loop.run(goal=body.goal, context=body.context, max_steps=body.max_steps)
|
| 273 |
+
return {'ok': True, 'result': result, 'source': 'backend_loop'}
|
| 274 |
+
except Exception as e:
|
| 275 |
+
print(f'[reason/loop] Error: {e}', flush=True)
|
| 276 |
+
return {
|
| 277 |
+
'ok': False,
|
| 278 |
+
'result': f'Backend reasoning non disponibile: {e}. Il loop browser continua normalmente.',
|
| 279 |
+
'source': 'fallback',
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 283 |
+
# AGENT KERNEL — dispatch GitHub Actions da iPhone
|
| 284 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 285 |
+
|
| 286 |
+
@app.get('/api/agent-kernel/status')
|
| 287 |
+
async def agent_kernel_status():
|
| 288 |
+
gh_token = os.getenv('GITHUB_TOKEN') or os.getenv('GH_TOKEN', '')
|
| 289 |
+
return {
|
| 290 |
+
'dispatch_available': bool(gh_token),
|
| 291 |
+
'workflow_url': 'https://github.com/Baida98/AI/actions/workflows/agent-kernel.yml',
|
| 292 |
+
'mobile_url': 'https://github.com/Baida98/AI/actions',
|
| 293 |
+
'secrets_needed': ['OPENROUTER_API_KEY', 'GROQ_API_KEY', 'GEMINI_API_KEY', 'HF_TOKEN'],
|
| 294 |
+
'usage': 'Vai su GitHub Actions → Agent Kernel — no PC → Run workflow → inserisci il goal',
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
@app.post('/api/agent-kernel/dispatch')
|
| 298 |
+
async def agent_kernel_dispatch(body: dict = Body(...)):
|
| 299 |
+
"""Avvia GitHub Actions workflow dal backend (richiede GITHUB_TOKEN o GH_TOKEN nel backend)."""
|
| 300 |
+
gh_token = os.getenv('GITHUB_TOKEN') or os.getenv('GH_TOKEN', '')
|
| 301 |
+
if not gh_token:
|
| 302 |
+
raise HTTPException(503, detail={
|
| 303 |
+
'error': 'no_github_token',
|
| 304 |
+
'message': 'GITHUB_TOKEN non configurato nel backend. Usa GitHub Actions direttamente da mobile.',
|
| 305 |
+
})
|
| 306 |
+
goal = body.get('goal', '').strip()
|
| 307 |
+
mode = body.get('mode', 'plan')
|
| 308 |
+
if not goal:
|
| 309 |
+
raise HTTPException(400, detail='goal è obbligatorio')
|
| 310 |
+
|
| 311 |
+
import urllib.request, urllib.error
|
| 312 |
+
payload = json.dumps({
|
| 313 |
+
'ref': 'main',
|
| 314 |
+
'inputs': {'goal': goal, 'mode': mode, 'commit_memory': 'true'},
|
| 315 |
+
}).encode()
|
| 316 |
+
req = urllib.request.Request(
|
| 317 |
+
'https://api.github.com/repos/Baida98/AI/actions/workflows/agent-kernel.yml/dispatches',
|
| 318 |
+
data=payload,
|
| 319 |
+
headers={
|
| 320 |
+
'Authorization': f'Bearer {gh_token}',
|
| 321 |
+
'Accept': 'application/vnd.github+json',
|
| 322 |
+
'Content-Type': 'application/json',
|
| 323 |
+
'X-GitHub-Api-Version': '2022-11-28',
|
| 324 |
+
},
|
| 325 |
+
method='POST',
|
| 326 |
+
)
|
| 327 |
+
try:
|
| 328 |
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
| 329 |
+
return {'ok': True, 'status': resp.status, 'goal': goal, 'mode': mode}
|
| 330 |
+
except urllib.error.HTTPError as e:
|
| 331 |
+
raise HTTPException(e.code, detail=e.read().decode()[:500])
|
| 332 |
+
|
| 333 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 334 |
+
# SHELL EXECUTION (sandboxed)
|
| 335 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 336 |
+
BLOCKED_CMDS = {'rm -rf /', 'mkfs', ':(){:|:&};:', 'dd if=/dev/zero'}
|
| 337 |
+
|
| 338 |
+
@app.post('/api/execute-shell')
|
| 339 |
+
async def execute_shell(cmd: ShellCmd):
|
| 340 |
+
raw = cmd.command.strip()
|
| 341 |
+
for bad in BLOCKED_CMDS:
|
| 342 |
+
if bad in raw:
|
| 343 |
+
raise HTTPException(400, 'Command blocked for safety')
|
| 344 |
+
timeout = min(max(cmd.timeout, 1), 60)
|
| 345 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 346 |
+
try:
|
| 347 |
+
proc = await asyncio.create_subprocess_shell(
|
| 348 |
+
raw,
|
| 349 |
+
stdout=asyncio.subprocess.PIPE,
|
| 350 |
+
stderr=asyncio.subprocess.PIPE,
|
| 351 |
+
cwd=tmpdir,
|
| 352 |
+
env={**os.environ, 'HOME': tmpdir, 'TMPDIR': tmpdir},
|
| 353 |
+
)
|
| 354 |
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
| 355 |
+
return {
|
| 356 |
+
'stdout': stdout.decode('utf-8', errors='replace')[:8000],
|
| 357 |
+
'stderr': stderr.decode('utf-8', errors='replace')[:4000],
|
| 358 |
+
'exit_code': proc.returncode,
|
| 359 |
+
}
|
| 360 |
+
except asyncio.TimeoutError:
|
| 361 |
+
proc.kill()
|
| 362 |
+
return {'stdout': '', 'stderr': f'Timeout dopo {timeout}s', 'exit_code': -1}
|
| 363 |
+
except Exception as e:
|
| 364 |
+
return {'stdout': '', 'stderr': str(e), 'exit_code': -1}
|
| 365 |
+
|
| 366 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 367 |
+
# PIP INSTALL
|
| 368 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 369 |
+
|
| 370 |
+
@app.post('/api/pip-install')
|
| 371 |
+
async def pip_install(body: PipInstall):
|
| 372 |
+
pkgs = [p.strip() for p in body.packages if p.strip()]
|
| 373 |
+
if not pkgs:
|
| 374 |
+
raise HTTPException(400, 'No packages specified')
|
| 375 |
+
import re
|
| 376 |
+
safe = re.compile(r'^[a-zA-Z0-9_\-\[\]>=<\.]+$')
|
| 377 |
+
for p in pkgs:
|
| 378 |
+
if not safe.match(p):
|
| 379 |
+
raise HTTPException(400, f'Invalid package name: {p}')
|
| 380 |
+
try:
|
| 381 |
+
proc = await asyncio.create_subprocess_exec(
|
| 382 |
+
sys.executable, '-m', 'pip', 'install', '--quiet', '--user', *pkgs,
|
| 383 |
+
stdout=asyncio.subprocess.PIPE,
|
| 384 |
+
stderr=asyncio.subprocess.PIPE,
|
| 385 |
+
)
|
| 386 |
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=120)
|
| 387 |
+
return {
|
| 388 |
+
'installed': pkgs,
|
| 389 |
+
'stdout': stdout.decode('utf-8', errors='replace')[:4000],
|
| 390 |
+
'stderr': stderr.decode('utf-8', errors='replace')[:2000],
|
| 391 |
+
'exit_code': proc.returncode,
|
| 392 |
+
}
|
| 393 |
+
except asyncio.TimeoutError:
|
| 394 |
+
return {'error': 'Timeout during pip install (120s)', 'exit_code': -1}
|
| 395 |
+
except Exception as e:
|
| 396 |
+
return {'error': str(e), 'exit_code': -1}
|
| 397 |
+
|
| 398 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 399 |
+
# SUPABASE SCHEMA — esegui una volta nell'editor SQL di Supabase
|
| 400 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 401 |
+
#
|
| 402 |
+
# CREATE TABLE IF NOT EXISTS conversations (
|
| 403 |
+
# id TEXT PRIMARY KEY,
|
| 404 |
+
# user_id TEXT NOT NULL DEFAULT 'default',
|
| 405 |
+
# title TEXT NOT NULL DEFAULT 'Nuova conversazione',
|
| 406 |
+
# created_at BIGINT NOT NULL,
|
| 407 |
+
# updated_at BIGINT NOT NULL
|
| 408 |
+
# );
|
| 409 |
+
#
|
| 410 |
+
# CREATE TABLE IF NOT EXISTS messages (
|
| 411 |
+
# id TEXT PRIMARY KEY,
|
| 412 |
+
# user_id TEXT NOT NULL DEFAULT 'default',
|
| 413 |
+
# conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
| 414 |
+
# role TEXT NOT NULL CHECK (role IN ('user','assistant')),
|
| 415 |
+
# content TEXT NOT NULL,
|
| 416 |
+
# created_at BIGINT NOT NULL,
|
| 417 |
+
# error BOOLEAN DEFAULT FALSE,
|
| 418 |
+
# steps JSONB,
|
| 419 |
+
# agent_status TEXT
|
| 420 |
+
# );
|
| 421 |
+
# CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id);
|
| 422 |
+
# CREATE INDEX IF NOT EXISTS idx_conv_user ON conversations(user_id);
|
| 423 |
+
#
|
| 424 |
+
# CREATE TABLE IF NOT EXISTS vfs_files (
|
| 425 |
+
# id TEXT PRIMARY KEY,
|
| 426 |
+
# user_id TEXT NOT NULL DEFAULT 'default',
|
| 427 |
+
# conversation_id TEXT,
|
| 428 |
+
# path TEXT NOT NULL,
|
| 429 |
+
# content TEXT NOT NULL,
|
| 430 |
+
# language TEXT,
|
| 431 |
+
# created_at BIGINT NOT NULL,
|
| 432 |
+
# updated_at BIGINT NOT NULL
|
| 433 |
+
# );
|
| 434 |
+
# CREATE INDEX IF NOT EXISTS idx_vfs_conv ON vfs_files(conversation_id);
|
| 435 |
+
# CREATE INDEX IF NOT EXISTS idx_vfs_user ON vfs_files(user_id);
|
| 436 |
+
#
|
| 437 |
+
# CREATE TABLE IF NOT EXISTS agent_memory (
|
| 438 |
+
# key TEXT PRIMARY KEY,
|
| 439 |
+
# user_id TEXT NOT NULL DEFAULT 'default',
|
| 440 |
+
# value TEXT NOT NULL,
|
| 441 |
+
# category TEXT DEFAULT 'general',
|
| 442 |
+
# created_at BIGINT NOT NULL DEFAULT 0,
|
| 443 |
+
# updated_at BIGINT NOT NULL DEFAULT 0
|
| 444 |
+
# );
|
| 445 |
+
# CREATE INDEX IF NOT EXISTS idx_memory_user ON agent_memory(user_id);
|
| 446 |
+
#
|
| 447 |
+
# CREATE TABLE IF NOT EXISTS episodes (
|
| 448 |
+
# id BIGSERIAL PRIMARY KEY,
|
| 449 |
+
# user_id TEXT NOT NULL DEFAULT 'default',
|
| 450 |
+
# type TEXT NOT NULL,
|
| 451 |
+
# task TEXT NOT NULL,
|
| 452 |
+
# output TEXT,
|
| 453 |
+
# success BOOLEAN DEFAULT TRUE,
|
| 454 |
+
# ts TEXT NOT NULL,
|
| 455 |
+
# tags JSONB DEFAULT '[]'
|
| 456 |
+
# );
|
| 457 |
+
# CREATE INDEX IF NOT EXISTS idx_episodes_type ON episodes(type);
|
| 458 |
+
# CREATE INDEX IF NOT EXISTS idx_episodes_user ON episodes(user_id);
|
| 459 |
+
#
|
| 460 |
+
# CREATE TABLE IF NOT EXISTS semantic_memory (
|
| 461 |
+
# id TEXT PRIMARY KEY,
|
| 462 |
+
# content TEXT NOT NULL,
|
| 463 |
+
# metadata JSONB DEFAULT '{}'
|
| 464 |
+
# );
|
| 465 |
+
#
|
| 466 |
+
# -- Row Level Security (open per ora — nessun login richiesto)
|
| 467 |
+
# ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;
|
| 468 |
+
# ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
|
| 469 |
+
# ALTER TABLE vfs_files ENABLE ROW LEVEL SECURITY;
|
| 470 |
+
# ALTER TABLE agent_memory ENABLE ROW LEVEL SECURITY;
|
| 471 |
+
# ALTER TABLE episodes ENABLE ROW LEVEL SECURITY;
|
| 472 |
+
# ALTER TABLE semantic_memory ENABLE ROW LEVEL SECURITY;
|
| 473 |
+
# CREATE POLICY "public_all" ON conversations FOR ALL USING (true) WITH CHECK (true);
|
| 474 |
+
# CREATE POLICY "public_all" ON messages FOR ALL USING (true) WITH CHECK (true);
|
| 475 |
+
# CREATE POLICY "public_all" ON vfs_files FOR ALL USING (true) WITH CHECK (true);
|
| 476 |
+
# CREATE POLICY "public_all" ON agent_memory FOR ALL USING (true) WITH CHECK (true);
|
| 477 |
+
# CREATE POLICY "public_all" ON episodes FOR ALL USING (true) WITH CHECK (true);
|
| 478 |
+
# CREATE POLICY "public_all" ON semantic_memory FOR ALL USING (true) WITH CHECK (true);
|
| 479 |
+
|
| 480 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 481 |
+
# FRONTEND STATIC
|
| 482 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 483 |
+
STATIC_DIR = os.getenv('FRONTEND_DIST', '/app/backend/static')
|
| 484 |
+
if os.path.isdir(STATIC_DIR):
|
| 485 |
+
app.mount('/', StaticFiles(directory=STATIC_DIR, html=True), name='spa')
|
| 486 |
+
print(f'BOOT: serving frontend from {STATIC_DIR}', flush=True)
|
| 487 |
+
else:
|
| 488 |
+
print(f'BOOT: no frontend at {STATIC_DIR}', flush=True)
|
| 489 |
+
|
| 490 |
+
print('BOOT: main.py v3.1.0 ready ✓', flush=True)
|
memory/__init__.py
ADDED
|
File without changes
|
memory/episodic.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
episodic.py — Episodic Memory
|
| 3 |
+
Task completati, errori, fix — persiste su Supabase se disponibile, altrimenti SQLite locale.
|
| 4 |
+
Backend server: HuggingFace Spaces (FastAPI). Database: Supabase.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json, os, sqlite3
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
|
| 13 |
+
DB_PATH = Path("episodic_memory.db")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class Episode:
|
| 18 |
+
id: int
|
| 19 |
+
type: str
|
| 20 |
+
task: str
|
| 21 |
+
output: str
|
| 22 |
+
success: bool
|
| 23 |
+
ts: str
|
| 24 |
+
tags: list[str]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class EpisodicMemory:
|
| 28 |
+
def __init__(self):
|
| 29 |
+
self._db: sqlite3.Connection | None = None
|
| 30 |
+
self._sb = None
|
| 31 |
+
|
| 32 |
+
def _try_supabase(self):
|
| 33 |
+
url = os.getenv("SUPABASE_URL", "")
|
| 34 |
+
key = os.getenv("SUPABASE_ANON_KEY") or os.getenv("SUPABASE_KEY", "")
|
| 35 |
+
if not url or not key:
|
| 36 |
+
return None
|
| 37 |
+
try:
|
| 38 |
+
from supabase import create_client
|
| 39 |
+
return create_client(url, key)
|
| 40 |
+
except Exception as e:
|
| 41 |
+
print(f"EpisodicMemory: Supabase non disponibile: {e}", flush=True)
|
| 42 |
+
return None
|
| 43 |
+
|
| 44 |
+
def init(self):
|
| 45 |
+
self._sb = self._try_supabase()
|
| 46 |
+
if self._sb:
|
| 47 |
+
try:
|
| 48 |
+
self._sb.table("episodes").select("id").limit(1).execute()
|
| 49 |
+
print("EpisodicMemory: Supabase ✓", flush=True)
|
| 50 |
+
return
|
| 51 |
+
except Exception as e:
|
| 52 |
+
print(f"EpisodicMemory: Supabase table check fallita: {e}", flush=True)
|
| 53 |
+
self._sb = None
|
| 54 |
+
|
| 55 |
+
# Fallback SQLite locale (dev / HF Spaces senza Supabase configurato)
|
| 56 |
+
self._db = sqlite3.connect(DB_PATH, check_same_thread=False)
|
| 57 |
+
self._db.execute("""
|
| 58 |
+
CREATE TABLE IF NOT EXISTS episodes (
|
| 59 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 60 |
+
type TEXT NOT NULL,
|
| 61 |
+
task TEXT NOT NULL,
|
| 62 |
+
output TEXT,
|
| 63 |
+
success INTEGER DEFAULT 1,
|
| 64 |
+
ts TEXT NOT NULL,
|
| 65 |
+
tags TEXT DEFAULT '[]'
|
| 66 |
+
)
|
| 67 |
+
""")
|
| 68 |
+
self._db.execute("CREATE INDEX IF NOT EXISTS idx_type ON episodes(type)")
|
| 69 |
+
self._db.execute("CREATE INDEX IF NOT EXISTS idx_ts ON episodes(ts)")
|
| 70 |
+
self._db.commit()
|
| 71 |
+
print("EpisodicMemory: SQLite locale (fallback) ✓", flush=True)
|
| 72 |
+
|
| 73 |
+
# ── Write ─────────────────────────────────────────────────────────────────
|
| 74 |
+
|
| 75 |
+
def add(self, type_: str, task: str, output: str, success: bool,
|
| 76 |
+
tags: list[str] | None = None) -> int:
|
| 77 |
+
ts = datetime.now().isoformat()
|
| 78 |
+
row = {
|
| 79 |
+
"type": type_, "task": task[:500], "output": output[:2000],
|
| 80 |
+
"success": success, "ts": ts, "tags": json.dumps(tags or []),
|
| 81 |
+
}
|
| 82 |
+
if self._sb:
|
| 83 |
+
try:
|
| 84 |
+
res = self._sb.table("episodes").insert(row).execute()
|
| 85 |
+
return res.data[0].get("id", -1) if res.data else -1
|
| 86 |
+
except Exception:
|
| 87 |
+
pass
|
| 88 |
+
if not self._db:
|
| 89 |
+
return -1
|
| 90 |
+
cur = self._db.execute(
|
| 91 |
+
"INSERT INTO episodes (type,task,output,success,ts,tags) VALUES (?,?,?,?,?,?)",
|
| 92 |
+
(type_, task[:500], output[:2000], int(success), ts, json.dumps(tags or []))
|
| 93 |
+
)
|
| 94 |
+
self._db.commit()
|
| 95 |
+
return cur.lastrowid
|
| 96 |
+
|
| 97 |
+
# ── Read ──────────────────────────────────────────────────────────────────
|
| 98 |
+
|
| 99 |
+
def get_recent(self, n: int = 20, type_: str | None = None) -> list[Episode]:
|
| 100 |
+
if self._sb:
|
| 101 |
+
try:
|
| 102 |
+
q = self._sb.table("episodes").select("*").order("id", desc=True).limit(n)
|
| 103 |
+
if type_:
|
| 104 |
+
q = q.eq("type", type_)
|
| 105 |
+
rows = q.execute().data or []
|
| 106 |
+
return [self._from_sb(r) for r in rows]
|
| 107 |
+
except Exception:
|
| 108 |
+
pass
|
| 109 |
+
if not self._db:
|
| 110 |
+
return []
|
| 111 |
+
if type_:
|
| 112 |
+
rows = self._db.execute(
|
| 113 |
+
"SELECT * FROM episodes WHERE type=? ORDER BY id DESC LIMIT ?", (type_, n)
|
| 114 |
+
).fetchall()
|
| 115 |
+
else:
|
| 116 |
+
rows = self._db.execute(
|
| 117 |
+
"SELECT * FROM episodes ORDER BY id DESC LIMIT ?", (n,)
|
| 118 |
+
).fetchall()
|
| 119 |
+
return [Episode(r[0], r[1], r[2], r[3], bool(r[4]), r[5], json.loads(r[6])) for r in rows]
|
| 120 |
+
|
| 121 |
+
def search_text(self, query: str, n: int = 5) -> list[Episode]:
|
| 122 |
+
if self._sb:
|
| 123 |
+
try:
|
| 124 |
+
rows = self._sb.table("episodes").select("*").ilike("task", f"%{query}%").limit(n).execute().data or []
|
| 125 |
+
if not rows:
|
| 126 |
+
rows = self._sb.table("episodes").select("*").ilike("output", f"%{query}%").limit(n).execute().data or []
|
| 127 |
+
return [self._from_sb(r) for r in rows]
|
| 128 |
+
except Exception:
|
| 129 |
+
pass
|
| 130 |
+
if not self._db:
|
| 131 |
+
return []
|
| 132 |
+
rows = self._db.execute(
|
| 133 |
+
"SELECT * FROM episodes WHERE task LIKE ? OR output LIKE ? ORDER BY id DESC LIMIT ?",
|
| 134 |
+
(f"%{query}%", f"%{query}%", n)
|
| 135 |
+
).fetchall()
|
| 136 |
+
return [Episode(r[0], r[1], r[2], r[3], bool(r[4]), r[5], json.loads(r[6])) for r in rows]
|
| 137 |
+
|
| 138 |
+
def get_errors(self, n: int = 10) -> list[Episode]:
|
| 139 |
+
return self.get_recent(n, type_="error")
|
| 140 |
+
|
| 141 |
+
def stats(self) -> dict:
|
| 142 |
+
if self._sb:
|
| 143 |
+
try:
|
| 144 |
+
res = self._sb.table("episodes").select("id", count="exact").execute()
|
| 145 |
+
return {"total": res.count or 0, "backend": "supabase"}
|
| 146 |
+
except Exception:
|
| 147 |
+
pass
|
| 148 |
+
if not self._db:
|
| 149 |
+
return {"total": 0, "backend": "none"}
|
| 150 |
+
total = self._db.execute("SELECT COUNT(*) FROM episodes").fetchone()[0]
|
| 151 |
+
by_type = dict(self._db.execute("SELECT type, COUNT(*) FROM episodes GROUP BY type").fetchall())
|
| 152 |
+
return {"total": total, "by_type": by_type, "backend": "sqlite"}
|
| 153 |
+
|
| 154 |
+
@staticmethod
|
| 155 |
+
def _from_sb(r: dict) -> Episode:
|
| 156 |
+
return Episode(
|
| 157 |
+
r["id"], r["type"], r["task"], r.get("output", ""),
|
| 158 |
+
bool(r["success"]), r["ts"], json.loads(r.get("tags", "[]"))
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
def close(self):
|
| 162 |
+
if self._db:
|
| 163 |
+
self._db.close()
|
memory/hf_store.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# hf_store.py — NON USATO
|
| 2 |
+
# Questo file era un errore di progettazione.
|
| 3 |
+
# HuggingFace Spaces = server (esegue il backend Python).
|
| 4 |
+
# Supabase = database (salva dati, memoria, file).
|
| 5 |
+
# Vedi episodic.py e semantic.py per la corretta implementazione Supabase-first.
|
memory/manager.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
manager.py — Unified Memory Manager
|
| 3 |
+
Coordina i 4 layer: Working, Episodic, Semantic, Reflection.
|
| 4 |
+
"""
|
| 5 |
+
from .working import WorkingMemory
|
| 6 |
+
from .episodic import EpisodicMemory
|
| 7 |
+
from .semantic import SemanticMemory
|
| 8 |
+
from .reflection import ReflectionMemory
|
| 9 |
+
|
| 10 |
+
class MemoryManager:
|
| 11 |
+
def __init__(self):
|
| 12 |
+
self.working = WorkingMemory(max_entries=40)
|
| 13 |
+
self.episodic = EpisodicMemory()
|
| 14 |
+
self.semantic = SemanticMemory()
|
| 15 |
+
self.reflection = ReflectionMemory()
|
| 16 |
+
|
| 17 |
+
async def init(self):
|
| 18 |
+
self.episodic.init()
|
| 19 |
+
self.semantic.init()
|
| 20 |
+
|
| 21 |
+
async def close(self):
|
| 22 |
+
self.episodic.close()
|
| 23 |
+
|
| 24 |
+
async def get_context(self, query: str) -> str:
|
| 25 |
+
"""Assembla il contesto dai 4 layer per arricchire il prompt."""
|
| 26 |
+
parts = []
|
| 27 |
+
|
| 28 |
+
# 1. Working memory — conversazione recente
|
| 29 |
+
working_ctx = self.working.get_context_string(n=6)
|
| 30 |
+
if working_ctx:
|
| 31 |
+
parts.append(working_ctx)
|
| 32 |
+
|
| 33 |
+
# 2. Semantic memory — conoscenza rilevante
|
| 34 |
+
if self.semantic.available:
|
| 35 |
+
semantic_hits = self.semantic.search(query, n_results=4)
|
| 36 |
+
if semantic_hits:
|
| 37 |
+
lines = [f"- {h['content'][:200]}" for h in semantic_hits if h["similarity"] > 0.3]
|
| 38 |
+
if lines:
|
| 39 |
+
parts.append("Conoscenza rilevante:\n" + "\n".join(lines))
|
| 40 |
+
|
| 41 |
+
# 3. Reflection lessons — errori da evitare
|
| 42 |
+
lessons = self.reflection.get_relevant_lessons(query, n=2)
|
| 43 |
+
if lessons:
|
| 44 |
+
lesson_lines = []
|
| 45 |
+
for l in lessons:
|
| 46 |
+
if l["type"] == "failure":
|
| 47 |
+
lesson_lines.append(f"- EVITA: {l['avoid'][:100]}")
|
| 48 |
+
else:
|
| 49 |
+
lesson_lines.append(f"- STRATEGIA: {l['strategy'][:100]}")
|
| 50 |
+
if lesson_lines:
|
| 51 |
+
parts.append("Lezioni passate:\n" + "\n".join(lesson_lines))
|
| 52 |
+
|
| 53 |
+
return "\n\n".join(parts) if parts else ""
|
| 54 |
+
|
| 55 |
+
async def save_exchange(self, messages: list, response: str):
|
| 56 |
+
"""Salva uno scambio chat nella memoria."""
|
| 57 |
+
user_msg = next((m["content"] for m in reversed(messages) if m["role"] == "user"), "")
|
| 58 |
+
# Working: aggiungi utente + risposta
|
| 59 |
+
if user_msg:
|
| 60 |
+
self.working.add("user", user_msg)
|
| 61 |
+
self.working.add("assistant", response)
|
| 62 |
+
# Episodic: salva la coppia
|
| 63 |
+
self.episodic.add("chat", user_msg[:300], response[:500], True)
|
| 64 |
+
# Semantic: indicizza per similarity search futura
|
| 65 |
+
if self.semantic.available and user_msg and len(response) > 50:
|
| 66 |
+
combined = f"Q: {user_msg[:200]} A: {response[:400]}"
|
| 67 |
+
self.semantic.add(combined, {"type": "chat", "query": user_msg[:100]})
|
| 68 |
+
|
| 69 |
+
async def save_episode(self, type_: str, task: str, output: str, success: bool, tags: list | None = None):
|
| 70 |
+
self.episodic.add(type_, task, output, success, tags)
|
| 71 |
+
if self.semantic.available and task:
|
| 72 |
+
self.semantic.add(task, {"type": type_, "success": success})
|
| 73 |
+
|
| 74 |
+
async def search(self, query: str, n: int = 5, layer: str | None = None) -> list[dict]:
|
| 75 |
+
results = []
|
| 76 |
+
if layer in (None, "semantic") and self.semantic.available:
|
| 77 |
+
for h in self.semantic.search(query, n_results=n):
|
| 78 |
+
results.append({**h, "layer": "semantic"})
|
| 79 |
+
if layer in (None, "episodic"):
|
| 80 |
+
for ep in self.episodic.search_text(query, n=n):
|
| 81 |
+
results.append({"content": f"{ep.task} → {ep.output[:200]}", "layer": "episodic", "type": ep.type, "success": ep.success})
|
| 82 |
+
if layer == "reflection":
|
| 83 |
+
lessons = self.reflection.get_relevant_lessons(query, n=n)
|
| 84 |
+
results.extend([{**l, "layer": "reflection"} for l in lessons])
|
| 85 |
+
return results[:n]
|
| 86 |
+
|
| 87 |
+
async def reflect(self, task: str, output: str, success: bool, error: str | None = None) -> dict:
|
| 88 |
+
if success:
|
| 89 |
+
self.reflection.record_success(task, output[:300])
|
| 90 |
+
await self.save_episode("fix", task, output, True)
|
| 91 |
+
else:
|
| 92 |
+
self.reflection.record_failure(task, error or output[:300])
|
| 93 |
+
await self.save_episode("error", task, error or output[:300], False)
|
| 94 |
+
return {
|
| 95 |
+
"recorded": True,
|
| 96 |
+
"top_patterns": self.reflection.get_top_patterns(3),
|
| 97 |
+
"lessons": self.reflection.get_relevant_lessons(task, 2),
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
def stats(self) -> dict:
|
| 101 |
+
return {
|
| 102 |
+
"working": self.working.stats(),
|
| 103 |
+
"episodic": self.episodic.stats(),
|
| 104 |
+
"semantic": self.semantic.stats(),
|
| 105 |
+
"reflection": self.reflection.stats(),
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
async def clear(self, layer: str | None = None):
|
| 109 |
+
if layer in (None, "working"):
|
| 110 |
+
self.working.clear()
|
| 111 |
+
if layer in (None, "episodic"):
|
| 112 |
+
import sqlite3
|
| 113 |
+
if self.episodic._db:
|
| 114 |
+
self.episodic._db.execute("DELETE FROM episodes")
|
| 115 |
+
self.episodic._db.commit()
|
memory/reflection.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
reflection.py — Reflection Memory
|
| 3 |
+
Impara dagli errori, pattern di fallimento, strategie vincenti.
|
| 4 |
+
"""
|
| 5 |
+
import json
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
|
| 9 |
+
REFLECTION_PATH = Path("reflection_memory.json")
|
| 10 |
+
|
| 11 |
+
class ReflectionMemory:
|
| 12 |
+
def __init__(self):
|
| 13 |
+
self._data: dict = {"failures": [], "successes": [], "patterns": {}}
|
| 14 |
+
self._load()
|
| 15 |
+
|
| 16 |
+
def _load(self):
|
| 17 |
+
if REFLECTION_PATH.exists():
|
| 18 |
+
try:
|
| 19 |
+
self._data = json.loads(REFLECTION_PATH.read_text())
|
| 20 |
+
except Exception:
|
| 21 |
+
pass
|
| 22 |
+
|
| 23 |
+
def _save(self):
|
| 24 |
+
REFLECTION_PATH.write_text(json.dumps(self._data, indent=2, ensure_ascii=False))
|
| 25 |
+
|
| 26 |
+
def record_failure(self, task: str, error: str, attempted_strategy: str = ""):
|
| 27 |
+
entry = {
|
| 28 |
+
"ts": datetime.now().isoformat(),
|
| 29 |
+
"task": task[:300],
|
| 30 |
+
"error": error[:500],
|
| 31 |
+
"strategy": attempted_strategy,
|
| 32 |
+
}
|
| 33 |
+
self._data["failures"].append(entry)
|
| 34 |
+
# Track pattern frequency
|
| 35 |
+
key = error[:80].lower()
|
| 36 |
+
self._data["patterns"][key] = self._data["patterns"].get(key, 0) + 1
|
| 37 |
+
# Cap at 200 entries
|
| 38 |
+
self._data["failures"] = self._data["failures"][-200:]
|
| 39 |
+
self._save()
|
| 40 |
+
|
| 41 |
+
def record_success(self, task: str, strategy: str):
|
| 42 |
+
entry = {
|
| 43 |
+
"ts": datetime.now().isoformat(),
|
| 44 |
+
"task": task[:300],
|
| 45 |
+
"strategy": strategy[:300],
|
| 46 |
+
}
|
| 47 |
+
self._data["successes"].append(entry)
|
| 48 |
+
self._data["successes"] = self._data["successes"][-100:]
|
| 49 |
+
self._save()
|
| 50 |
+
|
| 51 |
+
def get_relevant_lessons(self, task: str, n: int = 3) -> list[dict]:
|
| 52 |
+
"""Find past failures/successes relevant to current task."""
|
| 53 |
+
task_lower = task.lower()
|
| 54 |
+
words = set(task_lower.split())
|
| 55 |
+
def score(entry):
|
| 56 |
+
text = (entry.get("task", "") + " " + entry.get("strategy", "") + entry.get("error", "")).lower()
|
| 57 |
+
return sum(1 for w in words if w in text and len(w) > 3)
|
| 58 |
+
|
| 59 |
+
failures = sorted(self._data["failures"], key=score, reverse=True)[:n]
|
| 60 |
+
successes = sorted(self._data["successes"], key=score, reverse=True)[:n]
|
| 61 |
+
lessons = []
|
| 62 |
+
for f in failures[:2]:
|
| 63 |
+
if score(f) > 0:
|
| 64 |
+
lessons.append({"type": "failure", "task": f["task"], "avoid": f["error"]})
|
| 65 |
+
for s in successes[:1]:
|
| 66 |
+
if score(s) > 0:
|
| 67 |
+
lessons.append({"type": "success", "task": s["task"], "strategy": s["strategy"]})
|
| 68 |
+
return lessons
|
| 69 |
+
|
| 70 |
+
def get_top_patterns(self, n: int = 5) -> list[dict]:
|
| 71 |
+
return sorted(
|
| 72 |
+
[{"pattern": k, "count": v} for k, v in self._data["patterns"].items()],
|
| 73 |
+
key=lambda x: x["count"], reverse=True
|
| 74 |
+
)[:n]
|
| 75 |
+
|
| 76 |
+
def stats(self) -> dict:
|
| 77 |
+
return {
|
| 78 |
+
"failures": len(self._data["failures"]),
|
| 79 |
+
"successes": len(self._data["successes"]),
|
| 80 |
+
"patterns": len(self._data["patterns"]),
|
| 81 |
+
}
|
memory/semantic.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
semantic.py — Semantic Memory
|
| 3 |
+
Preferenze, concetti, conoscenza persistente.
|
| 4 |
+
Database: Supabase (text search). Fallback: ChromaDB locale.
|
| 5 |
+
Backend server: HuggingFace Spaces (FastAPI).
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import os, uuid
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
CHROMA_PATH = Path("chroma_db")
|
| 14 |
+
COLLECTION_NAME = "agente_semantic"
|
| 15 |
+
EMBED_MODEL = "BAAI/bge-small-en-v1.5"
|
| 16 |
+
|
| 17 |
+
try:
|
| 18 |
+
import chromadb
|
| 19 |
+
from chromadb.utils import embedding_functions
|
| 20 |
+
CHROMA_AVAILABLE = True
|
| 21 |
+
except ImportError:
|
| 22 |
+
CHROMA_AVAILABLE = False
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class SemanticMemory:
|
| 26 |
+
def __init__(self):
|
| 27 |
+
self._client = None
|
| 28 |
+
self._collection = None
|
| 29 |
+
self._embed_fn = None
|
| 30 |
+
self._sb = None
|
| 31 |
+
self.available = False
|
| 32 |
+
|
| 33 |
+
def _try_supabase(self):
|
| 34 |
+
url = os.getenv("SUPABASE_URL", "")
|
| 35 |
+
key = os.getenv("SUPABASE_ANON_KEY") or os.getenv("SUPABASE_KEY", "")
|
| 36 |
+
if not url or not key:
|
| 37 |
+
return None
|
| 38 |
+
try:
|
| 39 |
+
from supabase import create_client
|
| 40 |
+
return create_client(url, key)
|
| 41 |
+
except Exception:
|
| 42 |
+
return None
|
| 43 |
+
|
| 44 |
+
def init(self):
|
| 45 |
+
self._sb = self._try_supabase()
|
| 46 |
+
if self._sb:
|
| 47 |
+
try:
|
| 48 |
+
self._sb.table("semantic_memory").select("id").limit(1).execute()
|
| 49 |
+
self.available = True
|
| 50 |
+
print("SemanticMemory: Supabase ✓", flush=True)
|
| 51 |
+
return
|
| 52 |
+
except Exception as e:
|
| 53 |
+
print(f"SemanticMemory: Supabase table mancante: {e}", flush=True)
|
| 54 |
+
self._sb = None
|
| 55 |
+
|
| 56 |
+
# Fallback ChromaDB locale (HF Spaces senza Supabase)
|
| 57 |
+
if not CHROMA_AVAILABLE:
|
| 58 |
+
print("WARN: chromadb non installato — SemanticMemory disabilitata", flush=True)
|
| 59 |
+
return
|
| 60 |
+
try:
|
| 61 |
+
self._embed_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
|
| 62 |
+
model_name=EMBED_MODEL
|
| 63 |
+
)
|
| 64 |
+
self._client = chromadb.PersistentClient(path=str(CHROMA_PATH))
|
| 65 |
+
self._collection = self._client.get_or_create_collection(
|
| 66 |
+
name=COLLECTION_NAME,
|
| 67 |
+
embedding_function=self._embed_fn,
|
| 68 |
+
metadata={"hnsw:space": "cosine"},
|
| 69 |
+
)
|
| 70 |
+
self.available = True
|
| 71 |
+
print("SemanticMemory: ChromaDB locale (fallback) ✓", flush=True)
|
| 72 |
+
except Exception as e:
|
| 73 |
+
print(f"WARN: SemanticMemory non disponibile: {e}", flush=True)
|
| 74 |
+
|
| 75 |
+
# ── Write ─────────────────────────────────────────────────────────────────
|
| 76 |
+
|
| 77 |
+
def add(self, text: str, metadata: dict | None = None, doc_id: str | None = None):
|
| 78 |
+
_id = doc_id or str(uuid.uuid4())
|
| 79 |
+
if self._sb:
|
| 80 |
+
try:
|
| 81 |
+
self._sb.table("semantic_memory").upsert({
|
| 82 |
+
"id": _id, "content": text[:2000], "metadata": metadata or {},
|
| 83 |
+
}).execute()
|
| 84 |
+
return
|
| 85 |
+
except Exception:
|
| 86 |
+
pass
|
| 87 |
+
if self._collection:
|
| 88 |
+
self._collection.upsert(
|
| 89 |
+
documents=[text],
|
| 90 |
+
metadatas=[metadata or {}],
|
| 91 |
+
ids=[_id],
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
# ── Read ──────────────────────────────────────────────────────────────────
|
| 95 |
+
|
| 96 |
+
def search(self, query: str, n_results: int = 5) -> list[dict]:
|
| 97 |
+
if self._sb:
|
| 98 |
+
try:
|
| 99 |
+
rows = (
|
| 100 |
+
self._sb.table("semantic_memory")
|
| 101 |
+
.select("*")
|
| 102 |
+
.ilike("content", f"%{query}%")
|
| 103 |
+
.limit(n_results)
|
| 104 |
+
.execute().data or []
|
| 105 |
+
)
|
| 106 |
+
return [{"content": r["content"], "metadata": r.get("metadata", {}),
|
| 107 |
+
"similarity": 0.8} for r in rows]
|
| 108 |
+
except Exception:
|
| 109 |
+
pass
|
| 110 |
+
if not self._collection:
|
| 111 |
+
return []
|
| 112 |
+
try:
|
| 113 |
+
results = self._collection.query(
|
| 114 |
+
query_texts=[query],
|
| 115 |
+
n_results=min(n_results, self._collection.count() or 1),
|
| 116 |
+
)
|
| 117 |
+
docs = results.get("documents", [[]])[0]
|
| 118 |
+
metas = results.get("metadatas", [[]])[0]
|
| 119 |
+
dists = results.get("distances", [[]])[0]
|
| 120 |
+
return [
|
| 121 |
+
{"content": doc, "metadata": meta, "similarity": round(1 - dist, 4)}
|
| 122 |
+
for doc, meta, dist in zip(docs, metas, dists)
|
| 123 |
+
]
|
| 124 |
+
except Exception:
|
| 125 |
+
return []
|
| 126 |
+
|
| 127 |
+
def count(self) -> int:
|
| 128 |
+
if self._sb:
|
| 129 |
+
try:
|
| 130 |
+
return self._sb.table("semantic_memory").select("id", count="exact").execute().count or 0
|
| 131 |
+
except Exception:
|
| 132 |
+
pass
|
| 133 |
+
return self._collection.count() if self._collection else 0
|
| 134 |
+
|
| 135 |
+
def stats(self) -> dict:
|
| 136 |
+
backend = "supabase" if self._sb else ("chroma" if self._collection else "none")
|
| 137 |
+
return {"available": self.available, "documents": self.count(),
|
| 138 |
+
"model": EMBED_MODEL, "backend": backend}
|
memory/sync.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
sync.py — Memory Sync Protocol
|
| 3 |
+
|
| 4 |
+
Sincronizza in modo idempotente la memoria locale del browser con il backend.
|
| 5 |
+
Il protocollo accetta batch di elementi firmati da source/id/versione e salva
|
| 6 |
+
il contenuto nei layer episodic/semantic senza richiedere dipendenze aggiuntive.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import hashlib
|
| 11 |
+
import time
|
| 12 |
+
from typing import Any, Iterable, Literal
|
| 13 |
+
|
| 14 |
+
from fastapi import APIRouter
|
| 15 |
+
from pydantic import BaseModel, Field
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
MemoryLayer = Literal["working", "episodic", "semantic", "reflection"]
|
| 19 |
+
MemoryType = Literal["fact", "preference", "summary", "skill", "episode", "reflection"]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class SyncMemoryItem(BaseModel):
|
| 23 |
+
id: str = Field(..., min_length=1)
|
| 24 |
+
content: str = Field(..., min_length=1)
|
| 25 |
+
type: MemoryType = "fact"
|
| 26 |
+
layer: MemoryLayer = "semantic"
|
| 27 |
+
topic: str | None = None
|
| 28 |
+
weight: float = 1.0
|
| 29 |
+
created_at: int | None = None
|
| 30 |
+
updated_at: int | None = None
|
| 31 |
+
source: str = "browser"
|
| 32 |
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class MemorySyncPushRequest(BaseModel):
|
| 36 |
+
device_id: str = "browser"
|
| 37 |
+
since: int | None = None
|
| 38 |
+
items: list[SyncMemoryItem] = Field(default_factory=list)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class MemorySyncPullRequest(BaseModel):
|
| 42 |
+
device_id: str = "browser"
|
| 43 |
+
query: str | None = None
|
| 44 |
+
since: int | None = None
|
| 45 |
+
limit: int = Field(default=50, ge=1, le=200)
|
| 46 |
+
layer: MemoryLayer | None = None
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class MemorySyncStatus(BaseModel):
|
| 50 |
+
ok: bool
|
| 51 |
+
protocol: str = "memory-sync-v1"
|
| 52 |
+
server_time: int
|
| 53 |
+
stats: dict[str, Any]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _now_ms() -> int:
|
| 57 |
+
return int(time.time() * 1000)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _fingerprint(item: SyncMemoryItem) -> str:
|
| 61 |
+
raw = f"{item.source}:{item.id}:{item.updated_at or item.created_at or 0}:{item.content}".encode("utf-8")
|
| 62 |
+
return hashlib.sha256(raw).hexdigest()[:16]
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _iter_local_items(memory: Any, layer: MemoryLayer | None, limit: int) -> Iterable[dict[str, Any]]:
|
| 66 |
+
"""Best-effort export from available memory layers."""
|
| 67 |
+
if layer in (None, "working") and hasattr(memory, "working"):
|
| 68 |
+
entries = getattr(memory.working, "entries", []) or []
|
| 69 |
+
for idx, entry in enumerate(entries[-limit:]):
|
| 70 |
+
if isinstance(entry, dict):
|
| 71 |
+
content = entry.get("content") or entry.get("text") or str(entry)
|
| 72 |
+
role = entry.get("role", "unknown")
|
| 73 |
+
else:
|
| 74 |
+
content = getattr(entry, "content", str(entry))
|
| 75 |
+
role = getattr(entry, "role", "unknown")
|
| 76 |
+
yield {
|
| 77 |
+
"id": f"working-{idx}",
|
| 78 |
+
"content": content,
|
| 79 |
+
"type": "episode",
|
| 80 |
+
"layer": "working",
|
| 81 |
+
"topic": role,
|
| 82 |
+
"weight": 1,
|
| 83 |
+
"updated_at": _now_ms(),
|
| 84 |
+
"source": "backend",
|
| 85 |
+
"metadata": {"role": role},
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def create_memory_sync_router(memory: Any) -> APIRouter:
|
| 90 |
+
router = APIRouter(prefix="/api/memory/sync", tags=["memory-sync"])
|
| 91 |
+
|
| 92 |
+
@router.get("/status", response_model=MemorySyncStatus)
|
| 93 |
+
async def sync_status() -> MemorySyncStatus:
|
| 94 |
+
return MemorySyncStatus(ok=True, server_time=_now_ms(), stats=memory.stats())
|
| 95 |
+
|
| 96 |
+
@router.post("/push")
|
| 97 |
+
async def sync_push(req: MemorySyncPushRequest) -> dict[str, Any]:
|
| 98 |
+
accepted: list[str] = []
|
| 99 |
+
skipped: list[dict[str, str]] = []
|
| 100 |
+
now = _now_ms()
|
| 101 |
+
|
| 102 |
+
for item in req.items:
|
| 103 |
+
if not item.content.strip():
|
| 104 |
+
skipped.append({"id": item.id, "reason": "empty-content"})
|
| 105 |
+
continue
|
| 106 |
+
|
| 107 |
+
item.metadata.setdefault("sync_id", item.id)
|
| 108 |
+
item.metadata.setdefault("device_id", req.device_id)
|
| 109 |
+
item.metadata.setdefault("fingerprint", _fingerprint(item))
|
| 110 |
+
item.metadata.setdefault("synced_at", now)
|
| 111 |
+
item.metadata.setdefault("source", item.source)
|
| 112 |
+
item.metadata.setdefault("topic", item.topic or "generale")
|
| 113 |
+
|
| 114 |
+
try:
|
| 115 |
+
if item.layer == "working" and hasattr(memory, "working"):
|
| 116 |
+
role = str(item.metadata.get("role") or item.type)
|
| 117 |
+
memory.working.add(role, item.content)
|
| 118 |
+
elif item.layer == "episodic":
|
| 119 |
+
await memory.save_episode(item.type, item.topic or item.id, item.content, True, tags=["sync", req.device_id])
|
| 120 |
+
else:
|
| 121 |
+
if getattr(memory.semantic, "available", False):
|
| 122 |
+
memory.semantic.add(item.content, {"type": item.type, **item.metadata})
|
| 123 |
+
else:
|
| 124 |
+
await memory.save_episode(item.type, item.topic or item.id, item.content, True, tags=["sync", req.device_id])
|
| 125 |
+
accepted.append(item.id)
|
| 126 |
+
except Exception as exc: # pragma: no cover - defensive endpoint boundary
|
| 127 |
+
skipped.append({"id": item.id, "reason": str(exc)[:180]})
|
| 128 |
+
|
| 129 |
+
return {
|
| 130 |
+
"ok": True,
|
| 131 |
+
"protocol": "memory-sync-v1",
|
| 132 |
+
"accepted": accepted,
|
| 133 |
+
"skipped": skipped,
|
| 134 |
+
"server_time": now,
|
| 135 |
+
"stats": memory.stats(),
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
@router.post("/pull")
|
| 139 |
+
async def sync_pull(req: MemorySyncPullRequest) -> dict[str, Any]:
|
| 140 |
+
query = (req.query or "").strip()
|
| 141 |
+
items: list[dict[str, Any]] = []
|
| 142 |
+
|
| 143 |
+
if query:
|
| 144 |
+
results = await memory.search(query, req.limit, layer=req.layer)
|
| 145 |
+
for idx, hit in enumerate(results):
|
| 146 |
+
content = str(hit.get("content", ""))
|
| 147 |
+
items.append({
|
| 148 |
+
"id": str(hit.get("id") or f"backend-search-{idx}"),
|
| 149 |
+
"content": content,
|
| 150 |
+
"type": str(hit.get("type") or "fact"),
|
| 151 |
+
"layer": str(hit.get("layer") or req.layer or "semantic"),
|
| 152 |
+
"topic": str(hit.get("topic") or "generale"),
|
| 153 |
+
"weight": float(hit.get("similarity") or 1),
|
| 154 |
+
"updated_at": _now_ms(),
|
| 155 |
+
"source": "backend",
|
| 156 |
+
"metadata": {k: v for k, v in hit.items() if k not in {"content"}},
|
| 157 |
+
})
|
| 158 |
+
else:
|
| 159 |
+
items.extend(list(_iter_local_items(memory, req.layer, req.limit)))
|
| 160 |
+
|
| 161 |
+
return {
|
| 162 |
+
"ok": True,
|
| 163 |
+
"protocol": "memory-sync-v1",
|
| 164 |
+
"items": items[: req.limit],
|
| 165 |
+
"server_time": _now_ms(),
|
| 166 |
+
"stats": memory.stats(),
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
return router
|
memory/working.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
working.py — Working Memory (short-term, in-RAM)
|
| 3 |
+
Ultime N conversazioni per contesto immediato.
|
| 4 |
+
"""
|
| 5 |
+
from collections import deque
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
|
| 9 |
+
@dataclass
|
| 10 |
+
class WorkingEntry:
|
| 11 |
+
role: str
|
| 12 |
+
content: str
|
| 13 |
+
ts: float = field(default_factory=lambda: datetime.now().timestamp())
|
| 14 |
+
|
| 15 |
+
class WorkingMemory:
|
| 16 |
+
def __init__(self, max_entries: int = 40):
|
| 17 |
+
self._buf: deque[WorkingEntry] = deque(maxlen=max_entries)
|
| 18 |
+
|
| 19 |
+
def add(self, role: str, content: str):
|
| 20 |
+
self._buf.append(WorkingEntry(role=role, content=content))
|
| 21 |
+
|
| 22 |
+
def get_recent(self, n: int = 10) -> list[dict]:
|
| 23 |
+
entries = list(self._buf)[-n:]
|
| 24 |
+
return [{"role": e.role, "content": e.content} for e in entries]
|
| 25 |
+
|
| 26 |
+
def get_context_string(self, n: int = 6) -> str:
|
| 27 |
+
recent = self.get_recent(n)
|
| 28 |
+
if not recent:
|
| 29 |
+
return ""
|
| 30 |
+
lines = []
|
| 31 |
+
for m in recent:
|
| 32 |
+
prefix = "Utente" if m["role"] == "user" else "AI"
|
| 33 |
+
lines.append(f"{prefix}: {m['content'][:200]}")
|
| 34 |
+
return "Conversazione recente:\n" + "\n".join(lines)
|
| 35 |
+
|
| 36 |
+
def clear(self):
|
| 37 |
+
self._buf.clear()
|
| 38 |
+
|
| 39 |
+
def stats(self) -> dict:
|
| 40 |
+
return {"entries": len(self._buf), "max": self._buf.maxlen}
|
models/__init__.py
ADDED
|
File without changes
|
models/ai_client.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ai_client.py — Cloud AI Client for free remote deployments
|
| 3 |
+
|
| 4 |
+
Backend-first model router for mobile/no-PC usage. It prefers free or low-cost
|
| 5 |
+
OpenAI-compatible providers when configured and falls back across providers before
|
| 6 |
+
returning an error. The iPhone remains only a control surface; all model calls run
|
| 7 |
+
from the deployed backend/HF Space.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
from typing import AsyncIterator, Optional
|
| 14 |
+
|
| 15 |
+
from openai import OpenAI
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass(frozen=True)
|
| 19 |
+
class ProviderConfig:
|
| 20 |
+
name: str
|
| 21 |
+
api_key: str
|
| 22 |
+
base_url: str
|
| 23 |
+
default_model: str
|
| 24 |
+
embedding_model: Optional[str] = None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class AIClient:
|
| 28 |
+
def __init__(self):
|
| 29 |
+
self.providers = self._discover_providers()
|
| 30 |
+
self.default_model = self.providers[0].default_model if self.providers else os.getenv("LLM_MODEL", "openrouter/auto")
|
| 31 |
+
self.provider_name = self.providers[0].name if self.providers else "unconfigured"
|
| 32 |
+
self.client = self._client_for(self.providers[0]) if self.providers else None
|
| 33 |
+
|
| 34 |
+
def _discover_providers(self) -> list[ProviderConfig]:
|
| 35 |
+
providers: list[ProviderConfig] = []
|
| 36 |
+
|
| 37 |
+
openrouter_key = os.getenv("OPENROUTER_API_KEY")
|
| 38 |
+
if openrouter_key:
|
| 39 |
+
providers.append(ProviderConfig(
|
| 40 |
+
name="openrouter",
|
| 41 |
+
api_key=openrouter_key,
|
| 42 |
+
base_url="https://openrouter.ai/api/v1",
|
| 43 |
+
default_model=os.getenv("OPENROUTER_MODEL", os.getenv("LLM_MODEL", "deepseek/deepseek-r1:free")),
|
| 44 |
+
))
|
| 45 |
+
|
| 46 |
+
gemini_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
|
| 47 |
+
if gemini_key:
|
| 48 |
+
providers.append(ProviderConfig(
|
| 49 |
+
name="gemini",
|
| 50 |
+
api_key=gemini_key,
|
| 51 |
+
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
|
| 52 |
+
default_model=os.getenv("GEMINI_MODEL", os.getenv("LLM_MODEL_GEMINI", "gemini-2.0-flash")),
|
| 53 |
+
))
|
| 54 |
+
|
| 55 |
+
groq_key = os.getenv("GROQ_API_KEY")
|
| 56 |
+
if groq_key:
|
| 57 |
+
providers.append(ProviderConfig(
|
| 58 |
+
name="groq",
|
| 59 |
+
api_key=groq_key,
|
| 60 |
+
base_url="https://api.groq.com/openai/v1",
|
| 61 |
+
default_model=os.getenv("GROQ_MODEL", os.getenv("LLM_MODEL_GROQ", "llama-3.1-8b-instant")),
|
| 62 |
+
))
|
| 63 |
+
|
| 64 |
+
hf_key = os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACE_API_KEY") or os.getenv("HUGGINGFACE_TOKEN")
|
| 65 |
+
if hf_key:
|
| 66 |
+
providers.append(ProviderConfig(
|
| 67 |
+
name="huggingface",
|
| 68 |
+
api_key=hf_key,
|
| 69 |
+
base_url=os.getenv("HF_OPENAI_BASE_URL", "https://router.huggingface.co/v1"),
|
| 70 |
+
default_model=os.getenv("HF_MODEL", os.getenv("LLM_MODEL_HF", "Qwen/Qwen2.5-Coder-32B-Instruct")),
|
| 71 |
+
))
|
| 72 |
+
|
| 73 |
+
openai_key = os.getenv("OPENAI_API_KEY")
|
| 74 |
+
if openai_key:
|
| 75 |
+
providers.append(ProviderConfig(
|
| 76 |
+
name="openai_compatible",
|
| 77 |
+
api_key=openai_key,
|
| 78 |
+
base_url=os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1"),
|
| 79 |
+
default_model=os.getenv("OPENAI_MODEL", os.getenv("LLM_MODEL", "gpt-4o-mini")),
|
| 80 |
+
embedding_model=os.getenv("EMBEDDING_MODEL", "text-embedding-3-small"),
|
| 81 |
+
))
|
| 82 |
+
|
| 83 |
+
return providers
|
| 84 |
+
|
| 85 |
+
def _client_for(self, provider: ProviderConfig) -> OpenAI:
|
| 86 |
+
return OpenAI(api_key=provider.api_key, base_url=provider.base_url)
|
| 87 |
+
|
| 88 |
+
def _model_for(self, provider: ProviderConfig, requested: Optional[str]) -> str:
|
| 89 |
+
return requested or provider.default_model
|
| 90 |
+
|
| 91 |
+
async def health(self) -> dict:
|
| 92 |
+
if not self.providers:
|
| 93 |
+
return {
|
| 94 |
+
"available": False,
|
| 95 |
+
"provider": "none",
|
| 96 |
+
"error": "No remote provider key configured. Set OPENROUTER_API_KEY, GEMINI_API_KEY, GROQ_API_KEY, HF_TOKEN or OPENAI_API_KEY.",
|
| 97 |
+
"models": [],
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
checks = []
|
| 101 |
+
for provider in self.providers:
|
| 102 |
+
try:
|
| 103 |
+
client = self._client_for(provider)
|
| 104 |
+
response = client.chat.completions.create(
|
| 105 |
+
model=provider.default_model,
|
| 106 |
+
messages=[{"role": "user", "content": "ping"}],
|
| 107 |
+
max_tokens=1,
|
| 108 |
+
temperature=0,
|
| 109 |
+
)
|
| 110 |
+
checks.append({"provider": provider.name, "available": True, "model": provider.default_model})
|
| 111 |
+
self.provider_name = provider.name
|
| 112 |
+
self.default_model = provider.default_model
|
| 113 |
+
self.client = client
|
| 114 |
+
return {
|
| 115 |
+
"available": True,
|
| 116 |
+
"provider": provider.name,
|
| 117 |
+
"models": [p.default_model for p in self.providers],
|
| 118 |
+
"default": provider.default_model,
|
| 119 |
+
"checks": checks,
|
| 120 |
+
}
|
| 121 |
+
except Exception as exc:
|
| 122 |
+
checks.append({"provider": provider.name, "available": False, "model": provider.default_model, "error": str(exc)})
|
| 123 |
+
|
| 124 |
+
return {
|
| 125 |
+
"available": False,
|
| 126 |
+
"provider": "configured_but_unavailable",
|
| 127 |
+
"models": [p.default_model for p in self.providers],
|
| 128 |
+
"checks": checks,
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
async def chat(self, messages: list, *, model=None, temperature=0.7, max_tokens=4096) -> str:
|
| 132 |
+
last_error: Exception | None = None
|
| 133 |
+
for provider in self.providers:
|
| 134 |
+
try:
|
| 135 |
+
client = self._client_for(provider)
|
| 136 |
+
response = client.chat.completions.create(
|
| 137 |
+
model=self._model_for(provider, model),
|
| 138 |
+
messages=messages,
|
| 139 |
+
temperature=temperature,
|
| 140 |
+
max_tokens=max_tokens,
|
| 141 |
+
stream=False,
|
| 142 |
+
)
|
| 143 |
+
self.provider_name = provider.name
|
| 144 |
+
self.default_model = self._model_for(provider, model)
|
| 145 |
+
self.client = client
|
| 146 |
+
return response.choices[0].message.content or ""
|
| 147 |
+
except Exception as exc:
|
| 148 |
+
last_error = exc
|
| 149 |
+
continue
|
| 150 |
+
raise RuntimeError(f"No configured model provider succeeded: {last_error}")
|
| 151 |
+
|
| 152 |
+
async def stream_chat(self, messages: list, *, model=None, temperature=0.7, max_tokens=4096) -> AsyncIterator[str]:
|
| 153 |
+
last_error: Exception | None = None
|
| 154 |
+
for provider in self.providers:
|
| 155 |
+
try:
|
| 156 |
+
client = self._client_for(provider)
|
| 157 |
+
response = client.chat.completions.create(
|
| 158 |
+
model=self._model_for(provider, model),
|
| 159 |
+
messages=messages,
|
| 160 |
+
temperature=temperature,
|
| 161 |
+
max_tokens=max_tokens,
|
| 162 |
+
stream=True,
|
| 163 |
+
)
|
| 164 |
+
self.provider_name = provider.name
|
| 165 |
+
self.default_model = self._model_for(provider, model)
|
| 166 |
+
self.client = client
|
| 167 |
+
for chunk in response:
|
| 168 |
+
if chunk.choices and chunk.choices[0].delta.content:
|
| 169 |
+
yield chunk.choices[0].delta.content
|
| 170 |
+
return
|
| 171 |
+
except Exception as exc:
|
| 172 |
+
last_error = exc
|
| 173 |
+
continue
|
| 174 |
+
raise RuntimeError(f"No configured streaming model provider succeeded: {last_error}")
|
| 175 |
+
|
| 176 |
+
async def embed(self, text: str, model: str = "text-embedding-3-small") -> list[float]:
|
| 177 |
+
for provider in self.providers:
|
| 178 |
+
embedding_model = provider.embedding_model or os.getenv("EMBEDDING_MODEL")
|
| 179 |
+
if not embedding_model:
|
| 180 |
+
continue
|
| 181 |
+
try:
|
| 182 |
+
client = self._client_for(provider)
|
| 183 |
+
response = client.embeddings.create(input=[text], model=embedding_model or model)
|
| 184 |
+
return response.data[0].embedding
|
| 185 |
+
except Exception:
|
| 186 |
+
continue
|
| 187 |
+
return []
|
models/ollama_client.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ollama_client.py — Client Ollama con auto-discovery modelli
|
| 3 |
+
"""
|
| 4 |
+
import httpx
|
| 5 |
+
from typing import Optional, AsyncIterator
|
| 6 |
+
|
| 7 |
+
OLLAMA_BASE = "http://localhost:11434"
|
| 8 |
+
DEFAULT_MODEL = "qwen2.5-coder:7b"
|
| 9 |
+
FALLBACK_MODELS = ["gemma3:4b", "phi4-mini", "llama3.2:3b", "mistral:7b", "llama3:8b"]
|
| 10 |
+
|
| 11 |
+
class OllamaClient:
|
| 12 |
+
def __init__(self, base_url: str = OLLAMA_BASE):
|
| 13 |
+
self.base = base_url.rstrip("/")
|
| 14 |
+
self.default_model = DEFAULT_MODEL
|
| 15 |
+
self._available_models: list[str] = []
|
| 16 |
+
|
| 17 |
+
async def health(self) -> dict:
|
| 18 |
+
try:
|
| 19 |
+
async with httpx.AsyncClient(timeout=3) as c:
|
| 20 |
+
r = await c.get(f"{self.base}/api/tags")
|
| 21 |
+
models = [m["name"] for m in r.json().get("models", [])]
|
| 22 |
+
self._available_models = models
|
| 23 |
+
# Pick best default from what's installed
|
| 24 |
+
for m in [DEFAULT_MODEL] + FALLBACK_MODELS:
|
| 25 |
+
if any(m in installed for installed in models):
|
| 26 |
+
self.default_model = m
|
| 27 |
+
break
|
| 28 |
+
return {"available": True, "models": models, "default": self.default_model}
|
| 29 |
+
except Exception as e:
|
| 30 |
+
return {"available": False, "models": [], "error": str(e)}
|
| 31 |
+
|
| 32 |
+
def _resolve_model(self, model: Optional[str]) -> str:
|
| 33 |
+
if model and model in self._available_models:
|
| 34 |
+
return model
|
| 35 |
+
return self.default_model
|
| 36 |
+
|
| 37 |
+
async def chat(self, messages: list, *, model=None, temperature=0.7, max_tokens=4096) -> str:
|
| 38 |
+
payload = {
|
| 39 |
+
"model": self._resolve_model(model),
|
| 40 |
+
"messages": messages,
|
| 41 |
+
"stream": False,
|
| 42 |
+
"options": {"temperature": temperature, "num_predict": max_tokens},
|
| 43 |
+
}
|
| 44 |
+
async with httpx.AsyncClient(timeout=120) as c:
|
| 45 |
+
r = await c.post(f"{self.base}/api/chat", json=payload)
|
| 46 |
+
r.raise_for_status()
|
| 47 |
+
return r.json().get("message", {}).get("content", "")
|
| 48 |
+
|
| 49 |
+
async def stream_chat(self, messages: list, *, model=None, temperature=0.7, max_tokens=4096) -> AsyncIterator[str]:
|
| 50 |
+
import json as _json
|
| 51 |
+
payload = {
|
| 52 |
+
"model": self._resolve_model(model),
|
| 53 |
+
"messages": messages,
|
| 54 |
+
"stream": True,
|
| 55 |
+
"options": {"temperature": temperature, "num_predict": max_tokens},
|
| 56 |
+
}
|
| 57 |
+
async with httpx.AsyncClient(timeout=180) as c:
|
| 58 |
+
async with c.stream("POST", f"{self.base}/api/chat", json=payload) as resp:
|
| 59 |
+
async for line in resp.aiter_lines():
|
| 60 |
+
if not line:
|
| 61 |
+
continue
|
| 62 |
+
try:
|
| 63 |
+
data = _json.loads(line)
|
| 64 |
+
chunk = data.get("message", {}).get("content", "")
|
| 65 |
+
if chunk:
|
| 66 |
+
yield chunk
|
| 67 |
+
if data.get("done"):
|
| 68 |
+
break
|
| 69 |
+
except Exception:
|
| 70 |
+
continue
|
| 71 |
+
|
| 72 |
+
async def embed(self, text: str, model: str = "mxbai-embed-large") -> list[float]:
|
| 73 |
+
try:
|
| 74 |
+
async with httpx.AsyncClient(timeout=30) as c:
|
| 75 |
+
r = await c.post(f"{self.base}/api/embed", json={"model": model, "input": text})
|
| 76 |
+
r.raise_for_status()
|
| 77 |
+
return r.json().get("embeddings", [[]])[0]
|
| 78 |
+
except Exception:
|
| 79 |
+
return []
|
package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "@workspace/agente-ai",
|
| 3 |
+
"version": "0.0.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite --config vite.config.ts --host 0.0.0.0",
|
| 8 |
+
"build": "vite build --config vite.config.ts",
|
| 9 |
+
"serve": "vite preview --config vite.config.ts --host 0.0.0.0",
|
| 10 |
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
| 11 |
+
"build:pages": "vite build --config vite.gh-pages.config.ts"
|
| 12 |
+
},
|
| 13 |
+
"devDependencies": {
|
| 14 |
+
"@replit/vite-plugin-cartographer": "catalog:",
|
| 15 |
+
"@replit/vite-plugin-dev-banner": "catalog:",
|
| 16 |
+
"@replit/vite-plugin-runtime-error-modal": "catalog:",
|
| 17 |
+
|
| 18 |
+
"@tailwindcss/vite": "catalog:",
|
| 19 |
+
"@types/node": "catalog:",
|
| 20 |
+
"@types/react": "catalog:",
|
| 21 |
+
"@types/react-dom": "catalog:",
|
| 22 |
+
"@vitejs/plugin-react": "catalog:",
|
| 23 |
+
|
| 24 |
+
"react": "catalog:",
|
| 25 |
+
"react-dom": "catalog:",
|
| 26 |
+
"tailwindcss": "catalog:",
|
| 27 |
+
"vite": "catalog:",
|
| 28 |
+
|
| 29 |
+
"@types/qrcode": "^1.5.5",
|
| 30 |
+
"highlight.js": "^11.11.1",
|
| 31 |
+
"jszip": "^3.10.1",
|
| 32 |
+
"katex": "^0.16.21",
|
| 33 |
+
"mammoth": "^1.9.0",
|
| 34 |
+
"mermaid": "^11.4.1",
|
| 35 |
+
"pdfjs-dist": "^4.10.38",
|
| 36 |
+
"qrcode": "^1.5.4",
|
| 37 |
+
"react-markdown": "^10.1.0",
|
| 38 |
+
"rehype-highlight": "^7.0.2",
|
| 39 |
+
"remark-gfm": "^4.0.1",
|
| 40 |
+
"tw-animate-css": "^1.4.0",
|
| 41 |
+
"xlsx": "^0.18.5"
|
| 42 |
+
}
|
| 43 |
+
}
|
requirements.txt
CHANGED
|
@@ -5,4 +5,7 @@ pydantic>=2.0.0
|
|
| 5 |
python-multipart>=0.0.9
|
| 6 |
aiofiles>=23.0.0
|
| 7 |
openai>=1.0.0
|
| 8 |
-
supabase>=2.0.0
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
python-multipart>=0.0.9
|
| 6 |
aiofiles>=23.0.0
|
| 7 |
openai>=1.0.0
|
| 8 |
+
supabase>=2.5.0,<3.0.0
|
| 9 |
+
smolagents[litellm]>=1.14.0
|
| 10 |
+
litellm>=1.40.0
|
| 11 |
+
huggingface_hub>=0.24.0
|
start.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os, sys
|
| 2 |
+
port = int(os.environ.get('PORT', '7860'))
|
| 3 |
+
print(f'START: PORT={port}', flush=True)
|
| 4 |
+
|
| 5 |
+
import uvicorn
|
| 6 |
+
uvicorn.run('main:app', host='0.0.0.0', port=port, log_level='info', access_log=True)
|
tools/__init__.py
ADDED
|
File without changes
|
tools/content_cleaner.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""content_cleaner.py — Pulisce e struttura il testo per il modello."""
|
| 2 |
+
import re
|
| 3 |
+
from typing import Optional
|
| 4 |
+
NOISE_PATTERNS=["cookie","accept all cookies","privacy policy","terms of service","all rights reserved","subscribe","follow us on","share this article"]
|
| 5 |
+
|
| 6 |
+
def remove_noise(text:str)->str:
|
| 7 |
+
lines=text.split("\n"); cleaned=[]
|
| 8 |
+
for line in lines:
|
| 9 |
+
ll=line.lower().strip()
|
| 10 |
+
if len(ll)<4: continue
|
| 11 |
+
if any(p in ll for p in NOISE_PATTERNS): continue
|
| 12 |
+
if line.count("|")>5: continue
|
| 13 |
+
cleaned.append(line)
|
| 14 |
+
return "\n".join(cleaned)
|
| 15 |
+
|
| 16 |
+
def extract_key_paragraphs(text:str,query:str,max_paragraphs:int=6)->list[str]:
|
| 17 |
+
q_words=set(w.lower() for w in re.split(r"\W+",query) if len(w)>3)
|
| 18 |
+
pars=[p.strip() for p in re.split(r"\n{2,}",text) if len(p.strip())>60]
|
| 19 |
+
def rel(p): pl=p.lower(); return sum(1 for w in q_words if w in pl)/max(1,len(q_words))
|
| 20 |
+
return sorted(pars,key=rel,reverse=True)[:max_paragraphs]
|
| 21 |
+
|
| 22 |
+
def clean_and_structure(text:str,query:Optional[str]=None,max_chars:int=4000)->dict:
|
| 23 |
+
text=remove_noise(text)
|
| 24 |
+
pars=extract_key_paragraphs(text,query) if query else [text]
|
| 25 |
+
structured="\n\n".join(pars)
|
| 26 |
+
if len(structured)>max_chars: structured=structured[:max_chars]+"\n[troncato]"
|
| 27 |
+
return {"content":structured,"chars":len(structured),"words":len(structured.split()),"query_used":query}
|
tools/diff_checker.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""diff_checker.py — Genera e valida diff tra versioni di file."""
|
| 2 |
+
import difflib, re
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
def generate_diff(original:str,modified:str,filename:str="file")->dict:
|
| 6 |
+
a=original.splitlines(keepends=True); b=modified.splitlines(keepends=True)
|
| 7 |
+
lines=list(difflib.unified_diff(a,b,fromfile=f"a/{filename}",tofile=f"b/{filename}",lineterm=""))
|
| 8 |
+
diff="\n".join(lines)
|
| 9 |
+
added=sum(1 for l in lines if l.startswith("+") and not l.startswith("+++"))
|
| 10 |
+
removed=sum(1 for l in lines if l.startswith("-") and not l.startswith("---"))
|
| 11 |
+
return {"diff":diff,"added":added,"removed":removed,"has_changes":len(lines)>0}
|
| 12 |
+
|
| 13 |
+
def validate_python(code:str)->dict:
|
| 14 |
+
import ast
|
| 15 |
+
try: ast.parse(code); return {"valid":True,"errors":[]}
|
| 16 |
+
except SyntaxError as e: return {"valid":False,"errors":[f"L{e.lineno}: {e.msg}"]}
|
| 17 |
+
|
| 18 |
+
def file_diff(path_a:str,path_b:str)->dict:
|
| 19 |
+
try: return generate_diff(Path(path_a).read_text(encoding="utf-8"),Path(path_b).read_text(encoding="utf-8"),Path(path_a).name)
|
| 20 |
+
except Exception as e: return {"error":str(e),"diff":""}
|
tools/file_editor.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""file_editor.py — Applica modifiche a file con backup automatico."""
|
| 2 |
+
import shutil
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
BACKUP_DIR=Path(".agent_backups")
|
| 6 |
+
|
| 7 |
+
def _backup(filepath:str)->str:
|
| 8 |
+
BACKUP_DIR.mkdir(exist_ok=True); src=Path(filepath)
|
| 9 |
+
if not src.exists(): return ""
|
| 10 |
+
ts=datetime.now().strftime("%Y%m%d_%H%M%S"); bk=BACKUP_DIR/f"{src.stem}_{ts}{src.suffix}"
|
| 11 |
+
shutil.copy2(src,bk); return str(bk)
|
| 12 |
+
|
| 13 |
+
def apply_replace(filepath:str,old_code:str,new_code:str,backup:bool=True)->dict:
|
| 14 |
+
try:
|
| 15 |
+
p=Path(filepath)
|
| 16 |
+
if not p.exists(): return {"success":False,"error":"File non trovato"}
|
| 17 |
+
c=p.read_text(encoding="utf-8")
|
| 18 |
+
if old_code not in c: return {"success":False,"error":"Blocco non trovato"}
|
| 19 |
+
bk=_backup(filepath) if backup else ""
|
| 20 |
+
p.write_text(c.replace(old_code,new_code,1),encoding="utf-8")
|
| 21 |
+
return {"success":True,"backup":bk}
|
| 22 |
+
except Exception as e: return {"success":False,"error":str(e)}
|
| 23 |
+
|
| 24 |
+
def write_file(filepath:str,content:str,backup:bool=True)->dict:
|
| 25 |
+
try:
|
| 26 |
+
p=Path(filepath); bk=_backup(filepath) if backup and p.exists() else ""
|
| 27 |
+
p.parent.mkdir(parents=True,exist_ok=True); p.write_text(content,encoding="utf-8")
|
| 28 |
+
return {"success":True,"backup":bk,"path":filepath}
|
| 29 |
+
except Exception as e: return {"success":False,"error":str(e)}
|
| 30 |
+
|
| 31 |
+
def list_backups()->list[dict]:
|
| 32 |
+
if not BACKUP_DIR.exists(): return []
|
| 33 |
+
return [{"path":str(b),"name":b.name,"size":b.stat().st_size} for b in sorted(BACKUP_DIR.iterdir(),reverse=True)[:20]]
|
tools/ranking.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ranking.py — Ordina risultati web per rilevanza + qualità."""
|
| 2 |
+
import re
|
| 3 |
+
from typing import Optional
|
| 4 |
+
|
| 5 |
+
def tfidf_score(query:str,document:str)->float:
|
| 6 |
+
terms=[w.lower() for w in re.split(r"\W+",query) if len(w)>2]
|
| 7 |
+
words=re.split(r"\W+",document.lower()); n=max(1,len(words)); score=0.0
|
| 8 |
+
for t in terms: score+=words.count(t)/n
|
| 9 |
+
return score
|
| 10 |
+
|
| 11 |
+
def quality_score(result:dict)->float:
|
| 12 |
+
w={"StackOverflow":0.95,"HackerNews":0.80,"DDG":0.70}
|
| 13 |
+
base=w.get(result.get("source",""),0.60)
|
| 14 |
+
bonus=min(0.2,len(result.get("snippet",""))/1000)+(0.1 if result.get("answered") else 0)
|
| 15 |
+
return min(1.0,base+bonus)
|
| 16 |
+
|
| 17 |
+
def rank_results(results:list[dict],query:str,top_k:int=6)->list[dict]:
|
| 18 |
+
for r in results:
|
| 19 |
+
r["_r"]=tfidf_score(query,f"{r.get('title','')} {r.get('snippet','')}")
|
| 20 |
+
r["_q"]=quality_score(r); r["_s"]=0.6*r["_r"]+0.4*r["_q"]
|
| 21 |
+
ranked=sorted(results,key=lambda x:x["_s"],reverse=True)[:top_k]
|
| 22 |
+
for r in ranked: [r.pop(k,None) for k in ["_r","_q","_s"]]
|
| 23 |
+
return ranked
|
| 24 |
+
|
| 25 |
+
def format_for_llm(results:list[dict],query:str)->str:
|
| 26 |
+
if not results: return f"Nessun risultato per: {query}"
|
| 27 |
+
lines=[f"Risultati: **{query}**\n"]
|
| 28 |
+
for i,r in enumerate(results,1):
|
| 29 |
+
lines.append(f"{i}. **{r.get('title','')[:80]}** [{r.get('source','Web')}]")
|
| 30 |
+
if r.get("snippet"): lines.append(f" {r['snippet'][:200]}")
|
| 31 |
+
if r.get("url"): lines.append(f" {r['url']}"); lines.append("")
|
| 32 |
+
return "\n".join(lines)
|
tools/registry.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
registry.py — Tool Registry
|
| 3 |
+
Ogni tool ha: goal, required_inputs, risk_level, fallbacks, _fn.
|
| 4 |
+
"""
|
| 5 |
+
import httpx
|
| 6 |
+
import asyncio
|
| 7 |
+
import subprocess
|
| 8 |
+
import tempfile
|
| 9 |
+
import os
|
| 10 |
+
import sys
|
| 11 |
+
|
| 12 |
+
# ─── Tool functions ────────────────────────────────────────
|
| 13 |
+
|
| 14 |
+
async def _web_search(query: str, max_results: int = 5) -> dict:
|
| 15 |
+
headers = {"User-Agent": "Mozilla/5.0 (compatible; AgentBot/1.0)"}
|
| 16 |
+
results = []
|
| 17 |
+
# 1. DuckDuckGo Instant Answers
|
| 18 |
+
try:
|
| 19 |
+
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as c:
|
| 20 |
+
r = await c.get(
|
| 21 |
+
f"https://api.duckduckgo.com/?q={httpx.QueryParams({'q': query}).get('q')}&format=json&no_html=1&skip_disambig=1",
|
| 22 |
+
headers=headers
|
| 23 |
+
)
|
| 24 |
+
data = r.json()
|
| 25 |
+
if data.get("AbstractText"):
|
| 26 |
+
results.append({
|
| 27 |
+
"title": data.get("Heading", query),
|
| 28 |
+
"snippet": data["AbstractText"][:400],
|
| 29 |
+
"url": data.get("AbstractURL", "")
|
| 30 |
+
})
|
| 31 |
+
for t in data.get("RelatedTopics", [])[:max_results]:
|
| 32 |
+
if isinstance(t, dict) and t.get("Text"):
|
| 33 |
+
results.append({
|
| 34 |
+
"title": t.get("Text", "")[:80],
|
| 35 |
+
"snippet": t.get("Text", "")[:300],
|
| 36 |
+
"url": t.get("FirstURL", "")
|
| 37 |
+
})
|
| 38 |
+
except Exception:
|
| 39 |
+
pass
|
| 40 |
+
|
| 41 |
+
# 2. Se non abbastanza risultati, prova SearXNG pubblico
|
| 42 |
+
if len(results) < 2:
|
| 43 |
+
try:
|
| 44 |
+
async with httpx.AsyncClient(timeout=10) as c:
|
| 45 |
+
r = await c.get(
|
| 46 |
+
f"https://searx.be/search?q={query}&format=json&categories=general",
|
| 47 |
+
headers=headers
|
| 48 |
+
)
|
| 49 |
+
data = r.json()
|
| 50 |
+
for item in data.get("results", [])[:max_results]:
|
| 51 |
+
results.append({
|
| 52 |
+
"title": item.get("title", ""),
|
| 53 |
+
"snippet": item.get("content", "")[:300],
|
| 54 |
+
"url": item.get("url", "")
|
| 55 |
+
})
|
| 56 |
+
except Exception:
|
| 57 |
+
pass
|
| 58 |
+
|
| 59 |
+
return {"query": query, "results": results[:max_results]}
|
| 60 |
+
|
| 61 |
+
async def _read_page(url: str) -> dict:
|
| 62 |
+
try:
|
| 63 |
+
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as c:
|
| 64 |
+
r = await c.get(url, headers={"User-Agent": "Mozilla/5.0"})
|
| 65 |
+
text = r.text
|
| 66 |
+
# Strip HTML tags
|
| 67 |
+
import re
|
| 68 |
+
clean = re.sub(r'<[^>]+>', ' ', text)
|
| 69 |
+
clean = re.sub(r'\s+', ' ', clean).strip()
|
| 70 |
+
return {"url": url, "content": clean[:5000], "status": r.status_code}
|
| 71 |
+
except Exception as e:
|
| 72 |
+
return {"url": url, "content": "", "error": str(e)}
|
| 73 |
+
|
| 74 |
+
async def _get_weather(city: str) -> dict:
|
| 75 |
+
try:
|
| 76 |
+
async with httpx.AsyncClient(timeout=8) as c:
|
| 77 |
+
geo = await c.get(f"https://geocoding-api.open-meteo.com/v1/search?name={city}&count=1&language=it&format=json")
|
| 78 |
+
results = geo.json().get("results", [])
|
| 79 |
+
if not results:
|
| 80 |
+
return {"error": f"Città '{city}' non trovata"}
|
| 81 |
+
loc = results[0]
|
| 82 |
+
weather = await c.get(
|
| 83 |
+
f"https://api.open-meteo.com/v1/forecast?latitude={loc['latitude']}&longitude={loc['longitude']}"
|
| 84 |
+
f"¤t=temperature_2m,weather_code,wind_speed_10m&timezone=auto"
|
| 85 |
+
)
|
| 86 |
+
w = weather.json().get("current", {})
|
| 87 |
+
return {"city": loc["name"], "country": loc.get("country", ""), "temp_c": w.get("temperature_2m"), "wind_kmh": w.get("wind_speed_10m"), "code": w.get("weather_code")}
|
| 88 |
+
except Exception as e:
|
| 89 |
+
return {"error": str(e)}
|
| 90 |
+
|
| 91 |
+
async def _calculate(expression: str) -> dict:
|
| 92 |
+
try:
|
| 93 |
+
import ast, operator
|
| 94 |
+
allowed = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.Pow: operator.pow, ast.USub: operator.neg}
|
| 95 |
+
def eval_node(node):
|
| 96 |
+
if isinstance(node, ast.Constant): return node.value
|
| 97 |
+
if isinstance(node, ast.BinOp): return allowed[type(node.op)](eval_node(node.left), eval_node(node.right))
|
| 98 |
+
if isinstance(node, ast.UnaryOp): return allowed[type(node.op)](eval_node(node.operand))
|
| 99 |
+
raise ValueError("Operazione non supportata")
|
| 100 |
+
tree = ast.parse(expression, mode='eval')
|
| 101 |
+
result = eval_node(tree.body)
|
| 102 |
+
return {"expression": expression, "result": result}
|
| 103 |
+
except Exception as e:
|
| 104 |
+
return {"expression": expression, "error": str(e)}
|
| 105 |
+
|
| 106 |
+
async def _run_python(code: str) -> dict:
|
| 107 |
+
"""Esegue codice Python reale sul server in sandbox con timeout."""
|
| 108 |
+
# Sandbox: blocca import pericolosi
|
| 109 |
+
forbidden = ["os.system", "subprocess", "shutil.rmtree", "__import__('os')", "open('/etc"]
|
| 110 |
+
for f in forbidden:
|
| 111 |
+
if f in code:
|
| 112 |
+
return {"error": f"Operazione non permessa: {f}", "stdout": "", "returncode": -1}
|
| 113 |
+
|
| 114 |
+
with tempfile.NamedTemporaryFile(suffix=".py", delete=False, mode="w", encoding="utf-8") as fp:
|
| 115 |
+
fp.write(code)
|
| 116 |
+
path = fp.name
|
| 117 |
+
try:
|
| 118 |
+
proc = await asyncio.create_subprocess_exec(
|
| 119 |
+
sys.executable, path,
|
| 120 |
+
stdout=asyncio.subprocess.PIPE,
|
| 121 |
+
stderr=asyncio.subprocess.PIPE,
|
| 122 |
+
)
|
| 123 |
+
try:
|
| 124 |
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15.0)
|
| 125 |
+
except asyncio.TimeoutError:
|
| 126 |
+
proc.kill()
|
| 127 |
+
return {"error": "Timeout 15s superato", "stdout": "", "returncode": -1}
|
| 128 |
+
return {
|
| 129 |
+
"stdout": stdout.decode("utf-8", errors="replace")[:3000],
|
| 130 |
+
"stderr": stderr.decode("utf-8", errors="replace")[:500],
|
| 131 |
+
"returncode": proc.returncode,
|
| 132 |
+
}
|
| 133 |
+
finally:
|
| 134 |
+
try:
|
| 135 |
+
os.unlink(path)
|
| 136 |
+
except Exception:
|
| 137 |
+
pass
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# ─── Registry ─────────────────────────────────────────────
|
| 141 |
+
|
| 142 |
+
TOOL_REGISTRY: dict[str, dict] = {
|
| 143 |
+
"web_search": {
|
| 144 |
+
"name": "web_search",
|
| 145 |
+
"goal": "Cerca informazioni aggiornate sul web",
|
| 146 |
+
"description": "Esegue ricerca DuckDuckGo e restituisce risultati rilevanti",
|
| 147 |
+
"required_inputs": ["query"],
|
| 148 |
+
"optional_inputs": {"max_results": 5},
|
| 149 |
+
"risk_level": "low",
|
| 150 |
+
"fallbacks": ["direct_response"],
|
| 151 |
+
"_fn": _web_search,
|
| 152 |
+
},
|
| 153 |
+
"read_page": {
|
| 154 |
+
"name": "read_page",
|
| 155 |
+
"goal": "Legge il contenuto di una pagina web",
|
| 156 |
+
"description": "Scarica e pulisce il testo di una URL",
|
| 157 |
+
"required_inputs": ["url"],
|
| 158 |
+
"optional_inputs": {},
|
| 159 |
+
"risk_level": "low",
|
| 160 |
+
"fallbacks": ["web_search"],
|
| 161 |
+
"_fn": _read_page,
|
| 162 |
+
},
|
| 163 |
+
"get_weather": {
|
| 164 |
+
"name": "get_weather",
|
| 165 |
+
"goal": "Ottieni meteo attuale per una città",
|
| 166 |
+
"description": "Usa Open-Meteo (gratuito, no API key) per meteo in tempo reale",
|
| 167 |
+
"required_inputs": ["city"],
|
| 168 |
+
"optional_inputs": {},
|
| 169 |
+
"risk_level": "low",
|
| 170 |
+
"fallbacks": [],
|
| 171 |
+
"_fn": _get_weather,
|
| 172 |
+
},
|
| 173 |
+
"calculate": {
|
| 174 |
+
"name": "calculate",
|
| 175 |
+
"goal": "Calcola espressioni matematiche in modo sicuro",
|
| 176 |
+
"description": "Valuta espressioni matematiche senza exec() — solo operatori sicuri",
|
| 177 |
+
"required_inputs": ["expression"],
|
| 178 |
+
"optional_inputs": {},
|
| 179 |
+
"risk_level": "low",
|
| 180 |
+
"fallbacks": [],
|
| 181 |
+
"_fn": _calculate,
|
| 182 |
+
},
|
| 183 |
+
"run_python": {
|
| 184 |
+
"name": "run_python",
|
| 185 |
+
"goal": "Esegui codice Python reale sul server",
|
| 186 |
+
"description": "Esegue Python vero: calcoli, data processing, file I/O, librerie stdlib. Timeout 15s.",
|
| 187 |
+
"required_inputs": ["code"],
|
| 188 |
+
"optional_inputs": {},
|
| 189 |
+
"risk_level": "medium",
|
| 190 |
+
"fallbacks": [],
|
| 191 |
+
"_fn": _run_python,
|
| 192 |
+
},
|
| 193 |
+
}
|
tools/repo_reader.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""repo_reader.py — Legge struttura e file di un repository locale."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
SKIP={'.git','node_modules','__pycache__','.venv','dist','build'}
|
| 4 |
+
TEXT={'.py','.ts','.tsx','.js','.jsx','.html','.css','.md','.json','.yaml','.yml','.toml','.txt','.sh'}
|
| 5 |
+
|
| 6 |
+
def read_tree(root:str,max_files:int=80)->dict:
|
| 7 |
+
rp=Path(root)
|
| 8 |
+
if not rp.exists(): return {"error":f"Path non trovato: {root}","files":[]}
|
| 9 |
+
files=[]
|
| 10 |
+
for p in sorted(rp.rglob("*")):
|
| 11 |
+
if len(files)>=max_files: break
|
| 12 |
+
if any(s in p.parts for s in SKIP): continue
|
| 13 |
+
if p.is_file():
|
| 14 |
+
rel=str(p.relative_to(rp)); sz=p.stat().st_size
|
| 15 |
+
files.append({"path":rel,"size":sz,"type":"text" if p.suffix.lower() in TEXT else "binary","ext":p.suffix})
|
| 16 |
+
return {"root":root,"total_files":len(files),"files":files}
|
| 17 |
+
|
| 18 |
+
def read_file(root:str,filepath:str)->dict:
|
| 19 |
+
full=Path(root)/filepath
|
| 20 |
+
if not full.exists(): return {"error":f"Non trovato: {filepath}","content":""}
|
| 21 |
+
if full.stat().st_size>50000: return {"error":"File troppo grande","content":""}
|
| 22 |
+
if full.suffix.lower() not in TEXT and full.suffix!="": return {"error":"File binario","content":""}
|
| 23 |
+
try:
|
| 24 |
+
c=full.read_text(encoding="utf-8",errors="replace")
|
| 25 |
+
return {"path":filepath,"content":c,"lines":c.count("\n")+1,"size":len(c)}
|
| 26 |
+
except Exception as e: return {"error":str(e),"content":""}
|
| 27 |
+
|
| 28 |
+
def read_files_batch(root:str,paths:list[str])->list[dict]:
|
| 29 |
+
return [read_file(root,p) for p in paths[:10]]
|
tools/web_fetch.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""web_fetch.py — Scarica + pulisce una pagina web. NON restituisce HTML grezzo."""
|
| 2 |
+
import httpx, re
|
| 3 |
+
from urllib.parse import urlparse
|
| 4 |
+
USER_AGENT="Mozilla/5.0 (compatible; AgenteAI/2.0)"
|
| 5 |
+
MAX_CONTENT=6000
|
| 6 |
+
|
| 7 |
+
def _clean_html(html:str)->str:
|
| 8 |
+
html=re.sub(r"<(script|style|nav|footer|header|noscript|aside)[^>]*>.*?</\1>"," ",html,flags=re.S|re.I)
|
| 9 |
+
text=re.sub(r"<[^>]+>"," ",html)
|
| 10 |
+
text=re.sub(r"[ \t]+"," ",text)
|
| 11 |
+
text=re.sub(r"\n{3,}","\n\n",text)
|
| 12 |
+
return text.strip()
|
| 13 |
+
|
| 14 |
+
def _extract_meta(html:str)->dict:
|
| 15 |
+
t=re.search(r"<title[^>]*>([^<]+)</title>",html,re.I)
|
| 16 |
+
d=re.search(r'<meta[^>]+name=["\'\']description["\'\'][^>]+content=["\'\']([^"\'\']+ )["\'\']',html,re.I)
|
| 17 |
+
return {"title":t.group(1).strip() if t else "","description":d.group(1).strip() if d else ""}
|
| 18 |
+
|
| 19 |
+
async def fetch_page(url:str, max_chars:int=MAX_CONTENT)->dict:
|
| 20 |
+
try:
|
| 21 |
+
parsed=urlparse(url)
|
| 22 |
+
if parsed.scheme not in ("http","https"): return {"url":url,"error":"Schema non supportato","content":""}
|
| 23 |
+
async with httpx.AsyncClient(timeout=15,follow_redirects=True,headers={"User-Agent":USER_AGENT}) as c:
|
| 24 |
+
r=await c.get(url)
|
| 25 |
+
html=r.text; meta=_extract_meta(html); text=_clean_html(html)
|
| 26 |
+
if len(text)>max_chars: text=text[:max_chars]+"\n[...troncato...]"
|
| 27 |
+
return {"url":url,"title":meta["title"],"description":meta["description"],"content":text,"status":r.status_code,"chars":len(text)}
|
| 28 |
+
except httpx.TimeoutException: return {"url":url,"error":"Timeout","content":""}
|
| 29 |
+
except Exception as e: return {"url":url,"error":str(e),"content":""}
|
tools/web_search.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""web_search.py — Pipeline: search → rank → cite"""
|
| 2 |
+
import httpx, re, asyncio
|
| 3 |
+
from urllib.parse import quote_plus
|
| 4 |
+
|
| 5 |
+
MAX_RESULTS = 8
|
| 6 |
+
|
| 7 |
+
async def search_ddg(query: str, max_results: int = MAX_RESULTS) -> list[dict]:
|
| 8 |
+
try:
|
| 9 |
+
async with httpx.AsyncClient(timeout=8, follow_redirects=True) as c:
|
| 10 |
+
r = await c.get("https://api.duckduckgo.com/",
|
| 11 |
+
params={"q": query, "format": "json", "no_html": "1", "skip_disambig": "1"})
|
| 12 |
+
d = r.json(); results = []
|
| 13 |
+
if d.get("AbstractText"):
|
| 14 |
+
results.append({"source":"DDG","title":d.get("Heading",query),"snippet":d["AbstractText"][:400],"url":d.get("AbstractURL",""),"score":1.0})
|
| 15 |
+
for t in d.get("RelatedTopics",[])[:max_results]:
|
| 16 |
+
if isinstance(t,dict) and t.get("Text") and t.get("FirstURL"):
|
| 17 |
+
results.append({"source":"DDG","title":t["Text"][:80],"snippet":t["Text"][:250],"url":t["FirstURL"],"score":0.7})
|
| 18 |
+
return results[:max_results]
|
| 19 |
+
except Exception as e:
|
| 20 |
+
return [{"source":"DDG","error":str(e),"title":"","snippet":"","url":"","score":0}]
|
| 21 |
+
|
| 22 |
+
async def search_hackernews(query: str, max_results: int = 5) -> list[dict]:
|
| 23 |
+
try:
|
| 24 |
+
async with httpx.AsyncClient(timeout=8) as c:
|
| 25 |
+
r = await c.get("https://hn.algolia.com/api/v1/search",
|
| 26 |
+
params={"query":query,"hitsPerPage":max_results,"tags":"story"})
|
| 27 |
+
return [{"source":"HackerNews","title":h.get("title","")[:100],
|
| 28 |
+
"snippet":(h.get("story_text") or "")[:300],
|
| 29 |
+
"url":h.get("url") or f"https://news.ycombinator.com/item?id={h.get('objectID','')}",
|
| 30 |
+
"score":min(1.0,(h.get("points",0) or 0)/500)} for h in r.json().get("hits",[]) if h.get("title")]
|
| 31 |
+
except: return []
|
| 32 |
+
|
| 33 |
+
async def search_stackoverflow(query: str, max_results: int = 4) -> list[dict]:
|
| 34 |
+
import re as _re
|
| 35 |
+
try:
|
| 36 |
+
async with httpx.AsyncClient(timeout=8) as c:
|
| 37 |
+
r = await c.get("https://api.stackexchange.com/2.3/search/advanced",
|
| 38 |
+
params={"q":query,"site":"stackoverflow","pagesize":max_results,"order":"desc","sort":"relevance","filter":"withbody"})
|
| 39 |
+
return [{"source":"StackOverflow","title":it.get("title","")[:100],
|
| 40 |
+
"snippet":_re.sub(r"<[^>]+>"," ",it.get("body",""))[:250],
|
| 41 |
+
"url":it.get("link",""),"score":min(1.0,(it.get("score",0) or 0)/100),
|
| 42 |
+
"answered":it.get("is_answered",False)} for it in r.json().get("items",[])]
|
| 43 |
+
except: return []
|
| 44 |
+
|
| 45 |
+
async def web_search(query:str, focus:str="general", max_results:int=6) -> dict:
|
| 46 |
+
tasks = [search_ddg(query, max_results)]
|
| 47 |
+
if focus in ("technical","code"): tasks.append(search_stackoverflow(query,4))
|
| 48 |
+
if focus in ("news","general","technical"): tasks.append(search_hackernews(query,4))
|
| 49 |
+
all_results=[]
|
| 50 |
+
for batch in await asyncio.gather(*tasks, return_exceptions=True):
|
| 51 |
+
if isinstance(batch,list): all_results.extend(batch)
|
| 52 |
+
seen,ranked=set(),[]
|
| 53 |
+
for r in sorted(all_results,key=lambda x:x.get("score",0),reverse=True):
|
| 54 |
+
url=r.get("url","")
|
| 55 |
+
if url and url not in seen: seen.add(url); ranked.append(r)
|
| 56 |
+
elif not url: ranked.append(r)
|
| 57 |
+
ranked=ranked[:max_results]
|
| 58 |
+
return {"query":query,"focus":focus,"total":len(ranked),"results":ranked,"sources":list({r["source"] for r in ranked})}
|
tsconfig.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"extends": "../../tsconfig.base.json",
|
| 3 |
+
"include": ["src/**/*"],
|
| 4 |
+
"exclude": [
|
| 5 |
+
"node_modules", "build", "dist", "**/*.test.ts",
|
| 6 |
+
"src/components/ui",
|
| 7 |
+
"src/hooks/use-toast.ts",
|
| 8 |
+
"src/pages/not-found.tsx",
|
| 9 |
+
"src/lib/utils.ts"
|
| 10 |
+
],
|
| 11 |
+
"compilerOptions": {
|
| 12 |
+
"noEmit": true,
|
| 13 |
+
"jsx": "preserve",
|
| 14 |
+
"lib": ["esnext", "dom", "dom.iterable"],
|
| 15 |
+
"resolveJsonModule": true,
|
| 16 |
+
"allowImportingTsExtensions": true,
|
| 17 |
+
"moduleResolution": "bundler",
|
| 18 |
+
"types": ["node", "vite/client"],
|
| 19 |
+
"paths": {
|
| 20 |
+
"@/*": ["./src/*"]
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
}
|
vite.config.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from "vite";
|
| 2 |
+
import path from "node:path";
|
| 3 |
+
|
| 4 |
+
export default defineConfig({
|
| 5 |
+
root: ".",
|
| 6 |
+
|
| 7 |
+
server: {
|
| 8 |
+
host: "0.0.0.0",
|
| 9 |
+
port: 5174,
|
| 10 |
+
strictPort: false,
|
| 11 |
+
open: false,
|
| 12 |
+
cors: true,
|
| 13 |
+
|
| 14 |
+
fs: {
|
| 15 |
+
strict: false,
|
| 16 |
+
allow: [
|
| 17 |
+
path.resolve(__dirname),
|
| 18 |
+
],
|
| 19 |
+
},
|
| 20 |
+
|
| 21 |
+
proxy: {
|
| 22 |
+
"/api": {
|
| 23 |
+
target: "http://localhost:8000",
|
| 24 |
+
changeOrigin: true,
|
| 25 |
+
secure: false,
|
| 26 |
+
},
|
| 27 |
+
|
| 28 |
+
"/ws": {
|
| 29 |
+
target: "ws://localhost:8000",
|
| 30 |
+
ws: true,
|
| 31 |
+
changeOrigin: true,
|
| 32 |
+
},
|
| 33 |
+
},
|
| 34 |
+
},
|
| 35 |
+
|
| 36 |
+
preview: {
|
| 37 |
+
host: "0.0.0.0",
|
| 38 |
+
port: 4174,
|
| 39 |
+
},
|
| 40 |
+
|
| 41 |
+
resolve: {
|
| 42 |
+
alias: {
|
| 43 |
+
"@": path.resolve(__dirname, "./src"),
|
| 44 |
+
"@backend": path.resolve(__dirname, "./"),
|
| 45 |
+
},
|
| 46 |
+
},
|
| 47 |
+
|
| 48 |
+
build: {
|
| 49 |
+
target: "es2022",
|
| 50 |
+
|
| 51 |
+
outDir: "dist",
|
| 52 |
+
|
| 53 |
+
emptyOutDir: true,
|
| 54 |
+
|
| 55 |
+
sourcemap: false,
|
| 56 |
+
|
| 57 |
+
minify: "esbuild",
|
| 58 |
+
|
| 59 |
+
chunkSizeWarningLimit: 1200,
|
| 60 |
+
|
| 61 |
+
rollupOptions: {
|
| 62 |
+
output: {
|
| 63 |
+
manualChunks: {
|
| 64 |
+
vendor: [
|
| 65 |
+
"react",
|
| 66 |
+
"react-dom",
|
| 67 |
+
],
|
| 68 |
+
},
|
| 69 |
+
},
|
| 70 |
+
},
|
| 71 |
+
},
|
| 72 |
+
|
| 73 |
+
optimizeDeps: {
|
| 74 |
+
exclude: [
|
| 75 |
+
"@xenova/transformers",
|
| 76 |
+
"@mlc-ai/web-llm",
|
| 77 |
+
],
|
| 78 |
+
},
|
| 79 |
+
|
| 80 |
+
define: {
|
| 81 |
+
__DEV__: JSON.stringify(process.env.NODE_ENV !== "production"),
|
| 82 |
+
},
|
| 83 |
+
});
|