File size: 19,132 Bytes
28a08e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
"""
planner.py β€” Speculative Hybrid Planner (Gap-5: eliminazione bottleneck sequenziale)

Architettura:
  FASE 1  Quick-Start Draft   < 500ms  Cerebras gpt-oss-120b (o Groq 8B fallback)
           Genera 2-3 subtask immediati β†’ agente parte istantaneamente.
  FASE 2  Master Plan         background  DeepSeek-R1 via OpenRouter
           Piano architetturale completo; raffina i passi successivi.
  FASE 3  Parallel Sub-Graphs  β€”        Campo `parallel_groups` nel JSON
           Rami indipendenti (es. Backend vs Frontend) identificati esplicitamente.

CompatibilitΓ  backward: `create_plan()` restituisce sempre dict con "subtasks".
Flag `_speculative: True` β†’ piano quick-start; `_speculative: False` β†’ master plan.
"""
from __future__ import annotations

import asyncio
import json
import logging
import re
from models.ai_client import AIClient

_logger = logging.getLogger("agente_ai")

# ── Prompt master (DeepSeek-R1): piano architetturale completo ───────────────

PLANNER_SYSTEM = """Sei un planner AI avanzato. Dato un obiettivo, decomponilo in subtask concreti.

Rispondi SOLO con JSON valido nel formato:
{
  "goal": "obiettivo originale",
  "complexity": "low|medium|high",
  "data_model": [
    {"entity": "NomeEntitΓ ", "fields": ["id: str", "campo1: tipo", "campo2: tipo"]}
  ],
  "api_contract": [
    {"method": "GET|POST|PUT|DELETE", "path": "/api/risorsa", "body": {}, "response": {"campo": "tipo"}}
  ],
  "subtasks": [
    {
      "id": 1,
      "description": "cosa fare",
      "tool": "<tool>",
      "requires": [],
      "risk": "low|medium|high",
      "priority": "low|medium|high"
    }
  ],
  "parallel_groups": [[1,2],[3,4,5]],
  "estimated_steps": 3,
  "impacted_files": []
}

data_model: lista di entitΓ  dati con i loro campi tipizzati (SOLO per goal con entitΓ  persistenti).
  - Ogni entitΓ : {"entity": "Nome", "fields": ["campo: tipo", ...]}
  - Esempi di tipi: str, int, float, bool, datetime, list[str], dict.
  - Lascia [] per task senza entitΓ  dati (web search, domande, spiegazioni, singole funzioni).
  - P25-B5: definisci data_model PRIMA dei subtask di codice β€” Γ¨ il contratto condiviso tra tutti i subtask.
  - I subtask di codice DEVONO referenziare le entitΓ  definite qui, MAI inventare nomi diversi on-the-fly.

api_contract: endpoint REST/GraphQL con shape request+response (SOLO per goal con interfaccia API).
  - Ogni endpoint: {"method": "GET", "path": "/api/path", "body": {"campo": "tipo"}, "response": {"campo": "tipo"}}
  - Lascia [] per task senza endpoint API (script standalone, funzioni pure, task di analisi).
  - P25-B5: il primo subtask di codice backend DEVE implementare esattamente questo contratto.
  - MAI aggiungere endpoint non dichiarati qui senza aggiornare api_contract nel piano.

parallel_groups: lista di liste di id subtask che possono girare in PARALLELO tra loro.
  - Ogni lista interna = un gruppo di subtask eseguibili contemporaneamente (nessuna dipendenza reciproca).
  - Subtask con requires:[] vanno sempre in un gruppo parallelo.
  - Esempio: backend (id:1,2) e frontend (id:3,4) senza dipendenze reciproche β†’ [[1,2],[3,4]].
  - Se tutto Γ¨ sequenziale: [[1],[2],[3]].

impacted_files: lista di path file VFS che potrebbero essere impattati (vuota se non applicabile).

Tool disponibili:
  web_search     β€” cerca informazioni online in tempo reale
  code           β€” genera/modifica codice (Python, TS, JS, etc.)
  read_page      β€” legge una pagina web per URL
  memory         β€” accede a dati precedentemente memorizzati
  direct_response β€” risposta diretta senza tool esterni
  send_email     β€” invia email via Resend
  database_query β€” esegue query su database PostgreSQL/SQLite
  web_research   β€” ricerca approfondita multi-fonte con sintesi AI
  execute_sql    β€” esegue SQL su dati in-memory
  create_pdf     β€” genera un documento PDF
  call_api       β€” chiama un REST API esterno
  generate_image β€” genera un'immagine AI
  run_python     β€” esegue codice Python in sandbox sicura
  write_file     β€” scrive un file nel filesystem virtuale (risk: medium)
  read_file      β€” legge un file dal filesystem virtuale (risk: low)
  apply_patch    β€” applica una patch unificata a un file (risk: medium)
  execute_shell  β€” esegue un comando shell in sandbox (risk: high)
  directory_tree β€” elenca struttura ad albero di una directory (risk: low)
  file_search    β€” cerca pattern nei file con grep (risk: low)
  git_status     β€” mostra branch corrente e file modificati (risk: low)
  git_clone      β€” clona una repo remota (risk: medium)
  git_diff       β€” mostra modifiche in sospeso (risk: low)
  git_commit     β€” esegue add -A + commit (risk: medium)
  npm_install    β€” installa dipendenze node (risk: medium)
  npm_run        β€” esegue script node (risk: medium)
  pip_install    β€” installa pacchetti Python (risk: medium)
  type_check     β€” type check tsc o mypy (risk: low)
  scaffold_project β€” crea struttura progetto da template (risk: medium)
  delegate_task  β€” delega un sotto-obiettivo a un micro-agente indipendente (risk: low)


REGOLA TASK SEMPLICI (S-ROBUSTNESS): Se il task chiede una singola funzione TypeScript pura
  (sum, add, calculate, map, filter) anche se il prompt ha rumore/distrazioni/noise:
  β†’ piano con 1 SOLO subtask: tool=direct_response
  β†’ MAI run_python, type_check o npm_run per funzioni TypeScript di 1-3 righe

REGOLA DATA INTEGRITY (S-RECOVERY): Prima di pianificare analisi su dati numerici:
  - Controlla: conversion rate > 100%? conversioni > utenti? β†’ IMPOSSIBILE
  - Se dati impossibili β†’ piano con SOLO 1 subtask: tool=direct_response
    description: "segnala anomalia nei dati: incoerente/impossibile, non calcolare"
  - MAI pianificare run_python/execute_sql su dati statisticamente impossibili

REGOLA ASSOLUTA (S-GAP2): Per qualsiasi richiesta di creazione app/progetto/boilerplate,
DEVI verificare se esiste scaffold_project corrispondente. Se esiste β†’ PRIMO subtask.

REGOLA ORCHESTRATION (S-GAP9): Per task complessi (>5 passi), includi SEMPRE un subtask finale di "Verifica Integrazione e Test End-to-End".
Scomponi i rami Backend e Frontend in parallel_groups separati per massimizzare l'efficienza.

REGOLA RECOVERY & ROBUSTNESS (S-GAP12, S-GAP7): 
- Se l'obiettivo Γ¨ ambiguo o i dati sembrano incoerenti, il primo subtask DEVE essere "Analisi Critica e Validazione Requisiti" (tool: direct_response).
- Per ogni integrazione API, aggiungi un subtask di "Health Check / Verifica ConnettivitΓ " prima delle operazioni core.
- Se il task fallisce 2 volte, il piano deve includere un passo di "Debug e Analisi Log" (tool: read_file/execute_shell).

REGOLE GRAFO DI DIPENDENZE:
  - requires:[]  β†’ subtask eseguibile immediatamente in parallelo con altri requires:[]
  - requires:[N] β†’ subtask che dipende dall'output di subtask id N
  - priority:high: subtask bloccante; i dipendenti mettono il suo id in requires
  - priority:low:  subtask indipendente; eseguibile in parallelo
  - Identifica SEMPRE rami indipendenti (es. Backend vs Frontend, Read vs Write diversi file)
  - Aggiungi entrambi i rami in parallel_groups per massimizzare il parallelismo"""

# ── Prompt quick-start (Cerebras/Groq): 2-3 passi immediati ─────────────────

PLANNER_QUICK_SYSTEM = """Sei un planner rapido. Dato un obiettivo, genera SOLO i primi 2-3 passi immediati e concreti.

Rispondi SOLO con JSON valido:
{
  "goal": "obiettivo",
  "complexity": "low|medium|high",
  "subtasks": [
    {"id": 1, "description": "primo passo", "tool": "<tool>", "requires": [], "risk": "low", "priority": "high"},
    {"id": 2, "description": "secondo passo", "tool": "<tool>", "requires": [1], "risk": "low", "priority": "medium"}
  ],
  "parallel_groups": [[1],[2]],
  "estimated_steps": 2,
  "impacted_files": []
}

Regole:
  - MAX 3 subtask β€” solo le azioni piΓΉ immediate e ovvie
  - Scegli tool giusto: web_search/read_page per info, run_python per codice, write_file per file
  - Non pianificare l'intero progetto β€” solo il "prossimo passo" logico
  - requires:[] per passi indipendenti (possono partire subito in parallelo)"""


def _extract_json_balanced(raw: str) -> str | None:
    """P16-B3: depth-counting bilanciato β€” sostituisce regex greedy r'{[\s\S]+}'.
    Gestisce JSON annidati correttamente (piani con subtask oggetti complessi).
    """
    depth = 0
    start = -1
    for i, ch in enumerate(raw):
        if ch == '{':
            if depth == 0:
                start = i
            depth += 1
        elif ch == '}':
            depth -= 1
            if depth == 0 and start != -1:
                return raw[start:i + 1]
    return None


def _parse_plan(raw: str) -> dict | None:
    """Estrae e valida il JSON del piano dalla risposta LLM."""
    if not raw:
        return None
    json_match = _extract_json_balanced(raw)
    if not json_match:
        return None
    try:
        plan = json.loads(json_match)
        if not plan.get("subtasks"):
            return None
        return plan
    except (json.JSONDecodeError, ValueError):
        return None


class Planner:
    def __init__(self, llm_client: AIClient | None = None):
        if llm_client is not None:
            self.llm = llm_client
        else:
            # Master planner: DeepSeek-R1 per deep reasoning
            try:
                from models.role_router import RoleRouter, Role
                self.llm = RoleRouter.get_client(Role.ARCHITECT)
            except Exception:
                self.llm = AIClient()

    @classmethod
    def from_ollama(cls, ollama=None) -> "Planner":
        return cls()

    def _get_fast_llm(self) -> AIClient:
        """Gap-5: Cerebras gpt-oss-120b (2000+ tok/s) per quick-start draft.
        Fallback: Groq llama-3.1-8b-instant se CEREBRAS_API_KEY assente."""
        try:
            from models.role_router import RoleRouter, Role
            return RoleRouter.get_client(Role.REASONER)  # Cerebras 120B
        except Exception as _exc:
            _logger.debug("[planner] silenced %s", type(_exc).__name__)  # noqa: BLE001
        try:
            from models.role_router import RoleRouter, Role
            return RoleRouter.get_client(Role.FAST)  # Groq 8B fallback
        except Exception:
            return AIClient()

    def _build_messages(self, system: str, goal: str,
                        context: list | None = None) -> list[dict]:
        msgs = [
            {"role": "system", "content": system},
            {"role": "user",   "content": f"Obiettivo: {goal}"},
        ]
        if context:
            ctx_str = "\n".join(m.get("content", "")[:500] for m in context[-5:])
            msgs[1]["content"] += f"\n\nContesto recente:\n{ctx_str}"
        return msgs

    async def create_plan(self, goal: str,
                          context: list | None = None,
                          model: str | None = None) -> dict:
        """
        Gap-5: Speculative Hybrid Planning.

        Lancia in parallelo:
          1. Quick-Start (Cerebras/Groq) β€” risponde in < 500ms con 2-3 subtask immediati
          2. Master Plan (DeepSeek-R1)   β€” risponde in 8-15s con piano architetturale completo

        Logica:
          - attende max QUICK_TIMEOUT per il quick-start
          - se arriva → restituisce subito (flag _speculative=True) così l'agente parte
          - se DeepSeek-R1 arriva prima β†’ piano completo (flag _speculative=False)
          - se entrambi timeout β†’ fallback euristico
        """
        QUICK_TIMEOUT  = 1.2   # secondi: soglia "fast-first" win
        MASTER_TIMEOUT = 30.0  # secondi: timeout totale DeepSeek-R1

        msgs_quick  = self._build_messages(PLANNER_QUICK_SYSTEM,  goal, context)
        msgs_master = self._build_messages(PLANNER_SYSTEM, goal, context)

        fast_llm = self._get_fast_llm()

        async def _call_quick() -> dict | None:
            try:
                raw = await asyncio.wait_for(
                    fast_llm.chat(msgs_quick, temperature=0.2, max_tokens=512),
                    timeout=QUICK_TIMEOUT,
                )
                plan = _parse_plan(raw)
                if plan:
                    plan["_speculative"] = True
                    plan["_raw"] = raw[:400]
                return plan
            except Exception:
                return None

        async def _call_master() -> dict | None:
            for _attempt in range(3):
                try:
                    raw = await asyncio.wait_for(
                        self.llm.chat(msgs_master, temperature=0.3, max_tokens=2048),
                        timeout=MASTER_TIMEOUT,
                    )
                    plan = _parse_plan(raw)
                    if plan:
                        plan["_speculative"] = False
                        plan["_raw"] = raw[:400]
                        return plan
                except (asyncio.TimeoutError, TimeoutError):
                    if _attempt < 2:
                        await asyncio.sleep(1.0 * (2 ** _attempt))
                        continue
                    break
                except Exception:
                    break
            return None

        # ── Speculative dual-fire ────────────────────────────────────────────
        # Entrambi i modelli partono simultaneamente.
        # asyncio.wait(FIRST_COMPLETED) con soglia QUICK_TIMEOUT:
        #   - Se quick-start risponde prima β†’ agente parte subito (< 1s)
        #   - Master plan continua in background; il loop lo ignora (non ha callback)
        #   - Se master arriva per primo (es. R1 cold-start veloce) β†’ piano completo
        quick_task  = asyncio.create_task(_call_quick())
        master_task = asyncio.create_task(_call_master())

        done, pending = await asyncio.wait(
            {quick_task, master_task},
            timeout=QUICK_TIMEOUT,
            return_when=asyncio.FIRST_COMPLETED,
        )

        # Caso 1: quick-start ha risposto entro QUICK_TIMEOUT
        if quick_task in done:
            quick_plan = quick_task.result()
            if quick_plan:
                # P25-B3: per goal complessi (app/progetto/sistema), ignora quick-plan con ≀3 subtask
                # e attendi il master plan architetturale. Il quick-plan 2-3 step causa rework
                # sistematico su task multi-file che richiedono schema + contratto API.
                _COMPLEX_APP_RE = re.compile(
                    r'\b(crea\s+(?:una\s+)?(?:app|applicazione|sistema|sito|progetto|piattaforma|servizio)|'
                    r'build\s+(?:a\s+)?(?:app|system|platform|service|website)|'
                    r'sviluppa|realizza|implementa\s+(?:un[ao]\s+)?(?:sistema|app|servizio)|'
                    r'full.?stack|backend\s+e\s+frontend|frontend\s+e\s+backend)\b',
                    re.IGNORECASE,
                )
                _is_complex_app = bool(_COMPLEX_APP_RE.search(goal)) and len(goal) > 60
                _n_quick_subtasks = len(quick_plan.get("subtasks", []))
                if _is_complex_app and _n_quick_subtasks <= 3:
                    _logger.info(
                        "P25-B3: goal complesso rilevato (%d subtask quick) β€” attendo master plan",
                        _n_quick_subtasks,
                    )
                    # Non restituire il quick-plan; lascia cadere al Caso 2 (wait master)
                else:
                    _logger.info(
                        "Gap-5 speculative: quick-start plan (%d subtask), master in background",
                        _n_quick_subtasks,
                    )
                    # Master task continua in background; risultato non bloccante
                    master_task.add_done_callback(
                        lambda t: (
                            _logger.info(
                                "Gap-5 master plan ready (%d subtask) β€” successiva chiamata beneficerΓ ",
                                len((t.result() or {}).get("subtasks", [])) if not t.cancelled() and t.exception() is None else 0,
                            )
                            if not t.cancelled() and t.exception() is None
                            else None
                        )
                    )
                    return quick_plan

        # Caso 2: nessuno ha risposto in QUICK_TIMEOUT β€” aspetta il master plan
        if master_task in done:
            master_plan = master_task.result()
            if master_plan:
                quick_task.cancel()
                return master_plan

        # Caso 3: nessuno ancora pronto β€” aspetta fino a MASTER_TIMEOUT
        remaining = {t for t in {quick_task, master_task} if not t.done()}
        if remaining:
            done2, _ = await asyncio.wait(remaining, timeout=MASTER_TIMEOUT - QUICK_TIMEOUT)
            for t in done2:
                if t.exception() is None and not t.cancelled():
                    result = t.result()
                    if result:
                        for other in remaining - {t}:
                            other.cancel()
                        return result

        # Cancella task pendenti
        for t in {quick_task, master_task}:
            if not t.done():
                t.cancel()

        # ── Fallback euristico (piano semplice) ──────────────────────────────
        _logger.warning("Gap-5 planner: tutti i modelli in timeout per goal: %s", goal[:80])
        _g = goal.lower()
        _ft, _fr = "direct_response", "low"
        if re.search(r"https?://", _g):                                              _ft = "read_page"
        elif re.search(r"\b(cerca|search|notizie|news)\b", _g):                      _ft = "web_search"
        elif re.search(r"\b(git|branch|commit|diff)\b", _g):                         _ft = "git_status"
        elif re.search(r"\b(struttura|directory|tree|elenca)\b", _g):                _ft = "directory_tree"
        elif re.search(r"\b(grep|occorrenze|cerca.*codice)\b", _g):                  _ft = "file_search"
        elif re.search(r"\b(npm|pnpm|yarn)\b", _g):                                  _ft, _fr = "npm_run", "medium"
        elif re.search(r"\b(pip |pip3 |installa pacchett)\b", _g):                   _ft, _fr = "pip_install", "medium"
        elif re.search(r"\b(codice|python|script)\b", _g):                           _ft, _fr = "run_python", "medium"
        elif re.search(r"\b(scaffold|bootstrap)\b|crea.*app|crea.*progetto|nuovo.*progetto", _g): _ft, _fr = "scaffold_project", "medium"
        elif re.search(r"\b(genera|crea).*immagine\b", _g):                          _ft, _fr = "generate_image", "medium"
        return {
            "goal": goal, "complexity": "medium",
            "subtasks": [{"id": 1, "description": goal, "tool": _ft,
                          "requires": [], "risk": _fr, "priority": "high"}],
            "parallel_groups": [[1]],
            "estimated_steps": 1, "impacted_files": [], "_fallback": True,
        }