File size: 11,360 Bytes
24480a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
backend/api/brain_planner.py β€” Brain Planner (ARCH-I4.1)

Componente Planner del Brain: reasoning, planning, decomposition, reflection.
Produce WorkflowPlan (lista ordinata di PlanStep) senza sapere come eseguirli.

Il Brain Γ¨ ora separato in:
  BrainPlanner  β†’ "cosa fare e in che ordine" (questo file)
  BrainExecutor β†’ "come eseguire lo stato" (brain_executor.py)

Flusso:
  Kernel.submit_task(payload, capability="plan") β†’ BrainPlanner.plan(goal)
  β†’ WorkflowPlan(steps=[PlanStep,...])
  β†’ BrainExecutor.execute_plan(plan) β†’ [Kernel.submit_task per ogni step]

Invarianti ADR:
  S4:  Brain non conosce l'infrastruttura
  S9:  Planner ignora come Executor esegue
  S21: Brain dipende solo dal Kernel
  S27: ogni piano tracciato via plan_id
"""
from __future__ import annotations

import asyncio
import logging
import time
import uuid
from typing import Any, Literal

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field

from .auth_guard import AuthRole, require_role

_logger = logging.getLogger("api.brain_planner")

# ── Resolver guard ─────────────────────────────────────────────────────────────
try:
    from .capability_resolver import resolver as _resolver, ResolveRequest as _RReq
    _RESOLVER_AVAILABLE = True
except Exception:
    _resolver = None   # type: ignore[assignment]
    _RESOLVER_AVAILABLE = False

# ── Models ──────────────────────────────────────────────────────────────────────

class PlanStep(BaseModel):
    """Singolo step del piano β€” capability + payload + dipendenze."""
    step_id:      str            = Field(default_factory=lambda: str(uuid.uuid4()))
    capability:   str            = Field(..., description="Capability richiesta per questo step")
    description:  str            = Field("", description="Descrizione human-readable")
    payload:      dict[str, Any] = Field(default_factory=dict)
    depends_on:   list[str]      = Field(default_factory=list,
                                         description="step_id degli step da completare prima")
    timeout_s:    int            = Field(60)
    retry_max:    int            = Field(2)
    optional:     bool           = Field(False, description="Se True, fallimento non blocca il piano")
    requires_gpu: bool           = Field(False)
    metadata:     dict[str, Any] = Field(default_factory=dict)


class WorkflowPlan(BaseModel):
    """Piano di esecuzione prodotto dal Planner β€” input per il Workflow Engine."""
    plan_id:     str             = Field(default_factory=lambda: str(uuid.uuid4()))
    goal:        str             = Field(..., description="Obiettivo originale")
    strategy:    Literal["sequential", "parallel", "dag"] = Field(
                                   "sequential",
                                   description="sequential=step uno alla volta, "
                                               "parallel=tutti in parallelo, "
                                               "dag=dipendenze esplicite tra step")
    steps:       list[PlanStep]  = Field(default_factory=list)
    context:     dict[str, Any]  = Field(default_factory=dict,
                                         description="Contesto condiviso tra gli step")
    created_at:  float           = Field(default_factory=time.time)
    metadata:    dict[str, Any]  = Field(default_factory=dict)

    def validate_dag(self) -> list[str]:
        """Verifica che il DAG non abbia cicli. Ritorna errori (lista vuota = ok)."""
        step_ids = {s.step_id for s in self.steps}
        errors   = []
        for step in self.steps:
            for dep in step.depends_on:
                if dep not in step_ids:
                    errors.append(f"Step {step.step_id}: dipende da {dep} che non esiste nel piano")
        return errors


class PlanRequest(BaseModel):
    goal:           str            = Field(..., description="Obiettivo da pianificare")
    context:        dict[str, Any] = Field(default_factory=dict)
    strategy:       Literal["sequential", "parallel", "dag"] = "sequential"
    max_steps:      int            = Field(10, description="Numero massimo di step nel piano")
    session_id:     str | None     = None
    correlation_id: str            = Field(default_factory=lambda: str(uuid.uuid4()))
    hints:          list[str]      = Field(default_factory=list,
                                           description="Capability suggerite dal caller")


class ReflectRequest(BaseModel):
    """Richiesta di reflection su un piano eseguito (per migliorare piani futuri)."""
    plan_id:        str
    outcome:        Literal["success", "partial", "failure"]
    failed_steps:   list[str]      = Field(default_factory=list)
    notes:          str            = ""
    correlation_id: str            = Field(default_factory=lambda: str(uuid.uuid4()))


# ── BrainPlanner singleton ──────────────────────────────────────────────────────

class BrainPlanner:
    """
    Planner del Brain: data un goal, produce un WorkflowPlan.

    Strategia di planning (senza LLM, rule-based per ora):
      1. Decomposizione goal β†’ capability necessarie (via Resolver + hints)
      2. Ordinamento step (sequenziale per default, DAG se dipendenze esplicite)
      3. Validazione piano (no cicli, capabilities disponibili)
      4. Reflection store (memorizza piani e outcome per futuro miglioramento)

    Nota: la versione LLM-based (reasoning con modello) sarΓ  ARCH-I4.1 Phase 2.
    Questa implementazione Γ¨ rule-based e serve a definire il contratto.
    """

    def __init__(self) -> None:
        self._plans:      dict[str, WorkflowPlan]  = {}   # plan_id β†’ plan
        self._reflections: list[dict[str, Any]]    = []   # storia reflection
        self._lock        = asyncio.Lock()

    # ── Plan ──────────────────────────────────────────────────────────────────

    async def plan(self, req: PlanRequest) -> WorkflowPlan:
        """
        Produce un WorkflowPlan dal goal.

        Step logic (rule-based):
          - Se hints presenti: crea uno step per ogni hint
          - Altrimenti: step singolo con capability "llm" (Brain decide runtime)
          - Verifica che ogni capability sia risolvibile dal Resolver
        """
        capabilities = req.hints if req.hints else ["llm"]
        # Verifica capabilities via Resolver
        verified_caps = []
        for cap in capabilities[:req.max_steps]:
            if _RESOLVER_AVAILABLE and _resolver is not None:
                if _resolver.can_resolve(cap):
                    verified_caps.append(cap)
                else:
                    _logger.warning("[planner] capability '%s' non risolvibile β€” esclusa dal piano", cap)
            else:
                verified_caps.append(cap)   # no resolver: trust hints

        if not verified_caps:
            verified_caps = ["llm"]   # fallback sempre

        # Costruisci steps
        steps = []
        prev_id: str | None = None
        for cap in verified_caps:
            step = PlanStep(
                capability  = cap,
                description = f"Esegui capability '{cap}' per goal: {req.goal[:80]}",
                payload     = {"goal": req.goal, **req.context},
                depends_on  = [prev_id] if (req.strategy == "sequential" and prev_id) else [],
                timeout_s   = 120,
            )
            steps.append(step)
            prev_id = step.step_id

        plan = WorkflowPlan(
            goal     = req.goal,
            strategy = req.strategy,
            steps    = steps,
            context  = req.context,
            metadata = {"session_id": req.session_id, "correlation_id": req.correlation_id},
        )

        # Validate DAG
        errors = plan.validate_dag()
        if errors:
            raise ValueError("Piano DAG non valido: " + "; ".join(errors))

        async with self._lock:
            self._plans[plan.plan_id] = plan
        _logger.info("[planner] plan created plan_id=%s goal=%s steps=%d strategy=%s",
                     plan.plan_id, req.goal[:40], len(steps), req.strategy)
        return plan

    def get_plan(self, plan_id: str) -> WorkflowPlan | None:
        return self._plans.get(plan_id)

    # ── Reflect ───────────────────────────────────────────────────────────────

    async def reflect(self, req: ReflectRequest) -> dict:
        """Registra l'outcome di un piano β€” base per miglioramento futuro."""
        plan = self._plans.get(req.plan_id)
        entry = {
            "plan_id":     req.plan_id,
            "goal":        plan.goal if plan else "unknown",
            "outcome":     req.outcome,
            "failed_steps": req.failed_steps,
            "notes":       req.notes,
            "ts":          time.time(),
        }
        async with self._lock:
            self._reflections.append(entry)
            if len(self._reflections) > 500:   # cap
                self._reflections = self._reflections[-500:]
        _logger.info("[planner] reflect plan=%s outcome=%s", req.plan_id, req.outcome)
        return {"reflected": True, "plan_id": req.plan_id, "outcome": req.outcome}

    def status(self) -> dict:
        return {
            "total_plans":      len(self._plans),
            "total_reflections": len(self._reflections),
            "resolver_available": _RESOLVER_AVAILABLE,
        }


# ── Singleton ────────────────────────────────────────────────────────────────────
planner = BrainPlanner()

# ── HTTP Router ──────────────────────────────────────────────────────────────────
router = APIRouter(
    prefix="/api/brain",
    tags=["brain-planner"],
    dependencies=[Depends(require_role(AuthRole.MACHINE))],
)


@router.post("/plan", summary="Pianifica un goal β†’ WorkflowPlan")
async def route_plan(req: PlanRequest) -> WorkflowPlan:
    try:
        return await planner.plan(req)
    except ValueError as exc:
        raise HTTPException(400, str(exc))


@router.get("/plan/{plan_id}", summary="Recupera un piano esistente")
async def route_get_plan(plan_id: str) -> WorkflowPlan:
    p = planner.get_plan(plan_id)
    if not p:
        raise HTTPException(404, f"Piano '{plan_id}' non trovato")
    return p


@router.post("/reflect", summary="Reflection su un piano eseguito")
async def route_reflect(req: ReflectRequest) -> dict:
    return await planner.reflect(req)


@router.get("/planner/status", summary="Stato del BrainPlanner")
async def route_status() -> dict:
    return planner.status()