# Tech Spec — Claudio Opuscoli: Hackapizza 2.0

## 1. Project Structure

```
src/hackapizza/
├── config.py                  # Costanti, API keys, ricette target
├── main.py                    # Entrypoint: avvia SSE middleware + agenti
│
├── infra/                     # Infrastruttura (no logica di gioco)
│   ├── sse.py                 # SSE listener + event router
│   ├── brigata.py             # Middleware serving: ascolta preparation_complete, chiama serve_dish
│   ├── api_client.py          # Client HTTP per GET endpoints
│   ├── message_bus.py         # Message bus interno tra agenti
│   └── priority_inbox.py     # Priority queue inbox (usata dal Patron)
│
├── db/                        # Database layer
│   ├── schema.py              # Definizione tabelle + init DB
│   └── repository.py          # Operazioni CRUD per ogni tabella
│
├── agents/                    # Agenti LLM (brigata di cucina)
│   ├── base.py                # Classe base agente (wrappa datapizza.agents.Agent)
│   ├── prompts.py             # System prompt per ogni agente
│   ├── patron.py              # Orchestratore / Manager
│   ├── chef.py                # Menu planning + ricette
│   ├── magazzini.py           # Aste + mercato + scalping
│   ├── maitre.py              # Interpretazione ordini (serving phase)
│   ├── pr.py                  # Comunicazioni esterne + triage messaggi
│   └── evaluator.py           # Valutatore post-turno
│
└── models/                    # Data classes / tipi condivisi
    ├── messages.py            # Tipi messaggi interni tra agenti
    └── game.py                # Tipi di gioco (Recipe, MenuItem, MarketEntry, ecc.)
```

---

## 2. Stack tecnologico

| Componente | Pacchetto | Versione | Scopo |
|---|---|---|---|
| Runtime | Python | >= 3.12 | Linguaggio base |
| Async | asyncio (stdlib) | — | Concorrenza non bloccante |
| HTTP/SSE | aiohttp | latest | Connessione SSE + chiamate HTTP |
| Database | aiosqlite | latest | SQLite async |
| Framework agenti | datapizza-ai | latest | Costruzione agenti (obbligatorio) |
| LLM client | datapizza-ai-clients-openai-like | latest | Interfaccia verso Regolo.ai |
| LLM inference | Regolo.ai | — | Provider modelli (gpt-oss-120b, gpt-oss-20b) |
| Monitoring | Datapizza Monitoring | alpha | Tracing agenti |

### 2.1 Import principali datapizza-ai

```python
from datapizza.agents import Agent              # classe agente
from datapizza.clients.openai_like import OpenAILikeClient  # client OpenAI-compatible
from datapizza.tools import tool                # decoratore per definire tool
from datapizza.tools.mcp_client import MCPClient  # client MCP integrato
from datapizza.memory import Memory             # memoria conversazionale
from datapizza.type import ROLE, TextBlock      # tipi per messaggi
```

### 2.2 Modelli LLM per agente

| Agente | Modello | Motivazione |
|---|---|---|
| Patron | `gpt-oss-120b` | Decisioni strategiche, ragionamento complesso |
| Chef | `gpt-oss-120b` | Analisi ricette, ottimizzazione combinatoria menu |
| Magazzini | `gpt-oss-20b` | Decisioni numeriche strutturate, velocità > profondità |
| Maître | `gpt-oss-120b` | Interpretazione linguaggio naturale ordini clienti |
| Brigata | nessuno | Middleware deterministico, nessun LLM |
| PR | `gpt-oss-20b` | Triage messaggi e negoziazioni |
| Evaluator | `gpt-oss-120b` | Analisi qualitativa post-turno, raccomandazioni strategiche |

---

## 3. Data Model

### 3.1 Database SQLite — schema

Il DB conserva **solo** dati che il server non fornisce o che servono come memoria storica oltre il limite di 2 turni del server.

```sql
-- Storico completo aste (il server tiene solo ultimi 2 turni)
CREATE TABLE bid_history (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    turn_id INTEGER NOT NULL,
    restaurant_id TEXT NOT NULL,
    ingredient TEXT NOT NULL,
    bid INTEGER NOT NULL,
    quantity INTEGER NOT NULL,
    timestamp TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_bid_turn ON bid_history(turn_id);
CREATE INDEX idx_bid_ingredient ON bid_history(ingredient);

-- Log di ogni ordine ricevuto e relativo esito
CREATE TABLE service_log (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    turn_id INTEGER NOT NULL,
    client_id TEXT NOT NULL,
    client_name TEXT NOT NULL,
    order_text TEXT NOT NULL,
    interpreted_dish TEXT,
    served INTEGER NOT NULL DEFAULT 0,      -- 0=non servito, 1=servito
    outcome TEXT,                            -- 'success', 'intolerance', 'no_ingredients', ecc.
    timestamp TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_service_turn ON service_log(turn_id);
CREATE INDEX idx_service_client ON service_log(client_name);

-- Snapshot di fine turno per decisioni strategiche
CREATE TABLE turn_summary (
    turn_id INTEGER PRIMARY KEY,
    balance INTEGER NOT NULL,
    reputation INTEGER NOT NULL,
    dishes_served INTEGER NOT NULL DEFAULT 0,
    dishes_failed INTEGER NOT NULL DEFAULT 0,
    ingredients_wasted INTEGER NOT NULL DEFAULT 0,
    revenue INTEGER NOT NULL DEFAULT 0,
    cost INTEGER NOT NULL DEFAULT 0,
    menu_json TEXT,                          -- JSON del menu attivo nel turno
    notes TEXT,                              -- note strategiche del Patron
    timestamp TEXT NOT NULL DEFAULT (datetime('now'))
);

-- Transazioni completate sul mercato inter-ristoranti
CREATE TABLE market_history (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    turn_id INTEGER NOT NULL,
    counterpart_id TEXT NOT NULL,
    side TEXT NOT NULL CHECK (side IN ('BUY', 'SELL')),
    ingredient TEXT NOT NULL,
    quantity INTEGER NOT NULL,
    price INTEGER NOT NULL,
    timestamp TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_market_turn ON market_history(turn_id);

-- Conversazioni con altri ristoranti
CREATE TABLE messages_log (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    turn_id INTEGER NOT NULL,
    counterpart_id TEXT NOT NULL,
    counterpart_name TEXT NOT NULL,
    direction TEXT NOT NULL CHECK (direction IN ('IN', 'OUT')),
    text TEXT NOT NULL,
    timestamp TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_messages_turn ON messages_log(turn_id);

-- Intolleranze apprese per esperienza
CREATE TABLE known_intolerances (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    client_type TEXT NOT NULL,
    ingredient TEXT NOT NULL,
    confidence TEXT NOT NULL DEFAULT 'low' CHECK (confidence IN ('low', 'medium', 'high')),
    learned_at_turn INTEGER NOT NULL,
    source TEXT NOT NULL DEFAULT 'failure' CHECK (source IN ('failure', 'info', 'message')),
    UNIQUE(client_type, ingredient)
);
CREATE INDEX idx_intolerance_client ON known_intolerances(client_type);
```

### 3.2 Dati live (dal server, NON nel DB)

Questi dati si leggono sempre via API, mai duplicati:

| Dato | Endpoint | Nota |
|---|---|---|
| Ricette disponibili | `GET /recipes` | 287 ricette, statiche |
| Inventario attuale | `GET /restaurant/14` | campo `inventory` |
| Menu attuale | `GET /restaurant/14/menu` | campo `items` |
| Stato ristorante | `GET /restaurant/14` | balance, reputation, isOpen, kitchen |
| Tutti i ristoranti | `GET /restaurants` | overview pubblica |
| Mercato attivo | `GET /market/entries` | offerte correnti |
| Bid turno corrente | `GET /bid_history?turn_id=N` | solo ultimi 2 turni |
| Ordini turno | `GET /meals?turn_id=N&restaurant_id=14` | solo ultimi 2 turni |

### 3.3 Data classes (modelli Python)

```python
@dataclass
class Recipe:
    name: str
    preparation_time_ms: int
    ingredients: dict[str, int]    # nome → quantità
    prestige: int

@dataclass
class MenuItem:
    name: str
    price: int

@dataclass
class MarketEntry:
    id: int
    restaurant_id: str
    side: str                      # "BUY" | "SELL"
    ingredient_name: str
    quantity: int
    price: int                     # prezzo totale
    status: str                    # "OPEN" | "CLOSED"

@dataclass
class ClientOrder:
    client_id: str
    client_name: str
    order_text: str
    executed: bool

@dataclass
class RestaurantState:
    id: str
    name: str
    balance: int
    inventory: dict[str, int]
    reputation: int
    is_open: bool
    kitchen: list[str]
    menu_items: list[MenuItem]
```

---

## 4. Componenti infrastrutturali

### 4.1 SSE Middleware (`infra/sse.py`)

Processo asyncio permanente. Unica connessione SSE verso `GET /events/14`.

**Responsabilità**:
- Mantenere la connessione SSE attiva (riconnessione automatica con backoff)
- Parsare eventi JSON
- Routing deterministico verso le inbox degli agenti
- Arricchimento `client_spawned` con `client_id` (via `GET /meals`)

**Tabella di routing**:

| Evento SSE | Destinatario | Arricchimento |
|---|---|---|
| `game_started` | Patron | — |
| `game_phase_changed` | Patron | — |
| `game_reset` | Patron | — |
| `client_spawned` | Maître | `client_id` da `GET /meals` |
| `preparation_complete` | Brigata (`infra/brigata.py`) | — |
| `new_message` | PR | — |
| `message` (broadcast) | PR | — |
| `heartbeat` | nessuno (log opzionale) | — |

**Riconnessione SSE**:
```
Connessione persa → attesa 1s → retry
Secondo fallimento → attesa 3s → retry
Terzo fallimento → attesa 5s → retry
Poi continua con 5s interval
```

### 4.2 Message Bus (`infra/message_bus.py`)

Sistema di comunicazione interna peer-to-peer tra agenti.

**Interfaccia**:
```python
class MessageBus:
    async def register(self, agent_name: str) -> asyncio.Queue
    async def send(self, to: str, message: InternalMessage) -> None
    async def broadcast_internal(self, message: InternalMessage) -> None
```

### 4.2.1 Priority Inbox (`infra/priority_inbox.py`)

Drop-in replacement per `asyncio.Queue` usata dal Patron. Dequeue per priorità usando `asyncio.PriorityQueue` con tuple `(priority, sequence, message)`.

**Priorità**:
| Priority | Tipi messaggio |
|----------|----------------|
| 0 (max) | `sse_event`, `phase_change`, `turn_started` |
| 1 | `menu_ready`, `phase_directive`, `ingredient_request`, `ingredient_report` |
| 5 (default) | Tutto il resto |
| 9 (min) | `strategic_escalation` |

**Interfaccia** (duck-types `asyncio.Queue`):
```python
class PriorityInbox:
    async def put(self, msg: InternalMessage) -> None
    async def get(self) -> InternalMessage
    def get_nowait(self) -> InternalMessage
    def empty(self) -> bool
```

**Motivazione**: durante il turno 42, il PR ha inondato il Patron con `strategic_escalation` (una per ogni messaggio in arrivo). Ogni escalation richiedeva un `ask_llm` bloccante, impedendo al Patron di processare il `game_phase_changed(closed_bid)` in tempo. Con la PriorityInbox, gli eventi SSE e i cambi fase saltano in testa alla coda.

**Tipi di messaggio interni** (`models/messages.py`):
```python
@dataclass
class InternalMessage:
    sender: str               # "patron", "chef", "magazzini", "maitre", "pr", "evaluator"
    type: str                 # tipo messaggio (vedi sotto)
    content: dict[str, Any]
    timestamp: float

# Tipi previsti:
# "ingredient_request"     Chef → Magazzini: lista ingredienti necessari
# "ingredient_report"      Magazzini → Chef: risultato asta
# "trade_proposal"         PR → Magazzini: proposta scambio da team esterno (post-triage)
# "trade_decision"         Magazzini → PR: risposta su proposta scambio
# "capture_market_entry"   PR → Magazzini: "l'accordo è fatto, cattura l'offerta sul mercato"
# "strategic_escalation"   qualsiasi → Patron: decisione strategica
# "phase_directive"        Patron → qualsiasi: istruzioni per la fase
```

### 4.3 MCP Client (`infra/mcp_client.py`)

Usa il `MCPClient` nativo di `datapizza-ai` per connettersi al server MCP di gioco e ottenere i tool come oggetti passabili direttamente agli agenti.

**Inizializzazione e discovery dei tool**:
```python
from datapizza.tools.mcp_client import MCPClient

# Connessione al server MCP di gioco
game_mcp = MCPClient(url=f"{BASE_URL}/mcp")
game_tools = game_mcp.list_tools()
# game_tools è una lista di Tool objects pronti per essere passati ad Agent(tools=[...])
```

**Headers richiesti** (configurati a livello di sessione HTTP o nell'URL):
```
x-api-key: <API_KEY>
Content-Type: application/json
Accept: application/json, text/event-stream
```

**Filtraggio tool per agente**: i tool MCP restituiti da `list_tools()` vengono filtrati per nome e distribuiti a ogni agente in base al suo ruolo (vedi sezione 5). Esempio:

```python
# Filtra solo i tool necessari per il Patron
patron_game_tools = [t for t in game_tools if t.name in ("update_restaurant_is_open",)]
chef_game_tools = [t for t in game_tools if t.name in ("save_menu",)]
magazzini_game_tools = [t for t in game_tools if t.name in (
    "closed_bid", "create_market_entry", "execute_transaction", "delete_market_entry"
)]
```

**Gestione errori**: il framework wrappa le risposte MCP automaticamente. Errori e `isError` vengono normalizzati nel risultato del tool.

### 4.4 API Client (`infra/api_client.py`)

Client per i GET endpoints HTTP.

**Interfaccia**:
```python
class APIClient:
    async def get_recipes(self) -> list[Recipe]
    async def get_restaurant(self) -> RestaurantState
    async def get_menu(self) -> list[MenuItem]
    async def get_meals(self, turn_id: int) -> list[ClientOrder]
    async def get_bid_history(self, turn_id: int) -> list[dict]
    async def get_market_entries(self) -> list[MarketEntry]
```

Tutte le chiamate includono header `x-api-key` e gestiscono errori HTTP (401, 403, 404, 429).

## 5. Agenti — specifiche tecniche

### 5.1 Base Agent (`agents/base.py`)

Ogni agente wrappa un'istanza `datapizza.agents.Agent` con un `OpenAILikeClient` che punta a Regolo.ai. Il pattern è: un **event loop asyncio** custom ascolta la inbox, e quando serve ragionamento LLM invoca `agent.run(prompt)` con i tool appropriati.

```python
from datapizza.agents import Agent
from datapizza.clients.openai_like import OpenAILikeClient
from datapizza.tools import tool

class BaseAgent:
    name: str
    inbox: asyncio.Queue          # messaggi interni dal MessageBus
    agent: Agent                  # istanza datapizza-ai Agent
    api: APIClient                # GET endpoints
    db: Repository                # accesso DB
    bus: MessageBus               # comunicazione tra agenti

    def __init__(self, name: str, model: str, system_prompt: str,
                 game_tools: list, **kwargs):
        client = OpenAILikeClient(
            api_key=REGOLO_API_KEY,
            model=model,                          # "gpt-oss-120b" o "gpt-oss-20b"
            base_url=REGOLO_BASE_URL,             # "https://api.regolo.ai/v1"
            system_prompt=system_prompt,
        )
        self.agent = Agent(
            name=name,
            client=client,
            tools=game_tools,                     # tool MCP di gioco
        )

    async def run(self) -> None:
        """Loop principale: ascolta inbox, dispatcha a handle_message."""
        while True:
            msg = await self.inbox.get()
            await self.handle_message(msg)

    async def handle_message(self, msg: InternalMessage) -> None:
        """Override per ogni agente: costruisce prompt e invoca l'LLM."""
        raise NotImplementedError

    async def ask_llm(self, prompt: str, _retries: int = 2) -> str:
        """Invoca l'agente datapizza-ai con il prompt. Timeout 30s per call."""
        response = await asyncio.wait_for(self.agent.a_run(prompt), timeout=30)
        return response.text
```

**Pattern chiave**:
- `Agent(tools=[...])` riceve solo i tool MCP di gioco (da `MCPClient.list_tools()`)
- L'accesso al DB avviene tramite chiamate dirette a `self.db.*()` (Repository) nel codice Python, prima di invocare l'LLM
- `agent.run(prompt)` → il framework gestisce autonomamente il ciclo tool-call / risposta
- `agent.can_call([other_agents])` permette orchestrazione multi-agente nativa (usato dal Patron)

### 5.2 Patron (`agents/patron.py`)

**Inbox**: riceve eventi di fase dal SSE middleware.

**Tool di gioco**: `update_restaurant_is_open`

**Accesso DB diretto**: `self.db.read_turn_summary()` — i dati vengono letti in Python e passati nel prompt.

**Orchestrazione multi-agente**: il Patron usa `can_call()` per invocare Chef ed Evaluator come sub-agenti quando serve ragionamento coordinato.

```python
patron_agent = Agent(
    name="patron",
    client=OpenAILikeClient(api_key=REGOLO_API_KEY, model=MODEL_LARGE, base_url=REGOLO_BASE_URL,
                             system_prompt=PATRON_SYSTEM_PROMPT),
    tools=patron_game_tools,
)
patron_agent.can_call([chef_agent, evaluator_agent])
```

**Logica per fase**:

| Fase | Azione |
|---|---|
| `game_started` | Legge `turn_summary` precedente, decide strategia turno |
| `speaking` | Invia `phase_directive` a Chef e PR |
| `closed_bid` | Invia `phase_directive` a Magazzini con budget cap |
| `waiting` | Aspetta report da Chef, decide se aprire (`update_restaurant_is_open`) |
| `serving` | Nessuna azione (Maître + Brigata autonomi) |
| `stopped` | Attiva Evaluator, poi legge turn_summary con raccomandazioni |

### 5.3 Chef (`agents/chef.py`)

**Inbox**: riceve direttive dal Patron e report dal Magazzini.

**Tool di gioco**: `save_menu`

**Tool DB**: `read_bid_history`, `read_turn_summary`, `read_service_log`, `read_known_intolerances`

**Flusso speaking**:
1. `GET /recipes` → tutte le ricette
2. `GET /restaurant/14` → inventario attuale
3. Legge `bid_history` e `service_log` dal DB
4. LLM seleziona 3-4 ricette ottimali (prestige alto, pochi ingredienti, tempo basso)
5. Invia `ingredient_request` al Magazzini

**Flusso waiting** (post-asta):
1. Riceve `ingredient_report` dal Magazzini
2. `GET /restaurant/14` → inventario aggiornato
3. LLM ricalibra menu in base a ingredienti disponibili
4. Chiama `save_menu` con il menu finale

### 5.4 Magazzini (`agents/magazzini.py`)

**Inbox**: riceve richieste dal Chef, proposte dal PR, direttive dal Patron.

**Tool di gioco**: `closed_bid`, `create_market_entry`, `execute_transaction`, `delete_market_entry`

**Tool DB**: `read_bid_history`, `write_bid_history`, `read_market_history`, `write_market_history`

**Flusso closed_bid**:
1. Riceve `ingredient_request` dal Chef
2. Legge `bid_history` dal DB → calcola media prezzi per ingrediente
3. `GET /restaurant/14` → legge balance attuale
4. LLM decide: per ogni ingrediente → quantità e prezzo bid
5. Valuta anche ingredienti da comprare per scalping (basso costo, alta domanda)
6. Chiama `closed_bid`
7. Salva i propri bid nel DB (`bid_history`)

**Flusso post-asta (waiting)**:
1. `GET /restaurant/14` → verifica inventario ottenuto
2. Confronta con richiesta Chef → identifica ingredienti mancanti
3. `GET /market/entries` → cerca opportunità di acquisto (polling attivo)
4. Se conviene: `execute_transaction`
5. Se ha surplus non necessario per le ricette: `create_market_entry` (SELL) a prezzo maggiorato
6. Invia `ingredient_report` al Chef
7. Salva bid history di tutti (da `GET /bid_history`) nel DB

**Cattura offerte da accordi P2P**:
1. Il PR comunica `capture_market_entry`: "Team X ha pubblicato l'offerta"
2. Magazzini avvia polling frequente su `GET /market/entries`
3. Appena trova l'offerta → `execute_transaction` immediatamente
4. Il mercato è pubblico: un terzo team può intercettare prima di noi

**Gestione proposte dal PR**:
1. Riceve `trade_proposal` dal PR (già filtrata dal triage)
2. Verifica se l'ingrediente serve e se il prezzo è ragionevole (vs storico DB)
3. Risponde con `trade_decision` al PR

**Scalping**:
1. Consulta `bid_history` per identificare ingredienti con domanda alta e prezzo in crescita
2. Se il budget lo permette, compra all'asta a basso costo
3. Pubblica sul mercato a prezzo maggiorato con `create_market_entry` (SELL)

### 5.5 Maître (`agents/maitre.py`)

**Inbox**: riceve `client_spawned` (arricchito con `client_id`) dal SSE middleware.

**Tool di gioco**: `prepare_dish` (ritorna 200 OK immediato, NON bloccante)

**Tool DB**: `read_service_log`, `read_known_intolerances`

**Stato volatile condiviso con Brigata** (resettato ogni turno):
```python
pending_orders: dict[str, PendingOrder]   # client_id → ordine in corso
```

**Flusso** (gestisce N clienti in parallelo, come "chat aperte"):
```
Per ogni client_spawned:
  ├─ LLM interpreta orderText → identifica piatto dal menu
  ├─ controlla known_intolerances per quel client_type
  ├─ se OK: chiama prepare_dish(dish_name) → HTTP 200 immediato
  ├─ registra in pending_orders {client_id, dish, status: "preparing"}
  └─ NON aspetta preparation_complete: passa subito al prossimo cliente
```

Il Maître non chiama mai `serve_dish` — questo è responsabilità della Brigata.

### 5.6 Brigata (`infra/brigata.py`) — NON è un agente LLM

**Inbox**: riceve `preparation_complete` dal SSE middleware.

**Tool di gioco**: `serve_dish`

**Tool DB**: `write_service_log`, `write_known_intolerances`

Middleware deterministico, nessuna chiamata LLM. Condivide `pending_orders` con il Maître.

**Flusso**:
```
Per ogni preparation_complete {dish}:
  ├─ cerca in pending_orders il primo client con dish match e status "preparing"
  ├─ chiama serve_dish(dish_name, client_id)
  ├─ aggiorna pending_orders → status: "served"
  ├─ logga in service_log (esito, tempi)
  └─ se serve_dish fallisce per intolleranza:
       └─ scrive in known_intolerances (client_type → ingrediente)
```

**Apprendimento intolleranze**:
- Se `serve_dish` ritorna errore con indicazione di intolleranza → scrive in `known_intolerances`
- La confidence sale: `low` al primo fallimento, `medium` al secondo, `high` al terzo

### 5.7 PR (`agents/pr.py`)

**Inbox**: riceve `new_message` e `message` (broadcast) dal SSE middleware.

**Tool di gioco**: `send_message`

**Tool DB**: `read_messages_log`, `write_messages_log`

**Triage messaggi in entrata** (funzione chiave):
1. Logga in `messages_log`
2. LLM valuta il messaggio:
   - L'ingrediente proposto è già impegnato nelle nostre ricette? → rifiuta
   - Il prezzo è plausibile rispetto allo storico? → se no, possibile truffa/spam
   - Il team ha track record affidabile? (consulta `messages_log`) → se inaffidabile, cautela
3. Solo se il triage passa → invia `trade_proposal` al Magazzini
4. Se è proposta strategica → invia `strategic_escalation` al Patron

**Flusso scambio P2P**:
1. Riceve proposta dal team esterno
2. Triage → se passa → `trade_proposal` al Magazzini
3. Magazzini risponde con `trade_decision`
4. Se "accetta" → PR dice all'altro team di pubblicare sul mercato
5. PR invia `capture_market_entry` al Magazzini → polling e cattura

**Comportamento proattivo** (durante `speaking`):
1. Il Patron gli indica la strategia
2. LLM decide se contattare team specifici (es. per cercare ingredienti mancanti)
3. Chiama `send_message`

### 5.8 Evaluator (`agents/evaluator.py`)

**Inbox**: attivato dal Patron a fine turno (`stopped`).

**Tool di gioco**: nessuno (solo lettura via API)

**Tool DB**: `read_service_log`, `read_bid_history`, `read_market_history`, `write_turn_summary`

**Cosa analizza**:
- Per ogni ordine: richiesta iniziale → piatto interpretato → tempo → costo ingredienti → prezzo vendita → esito
- Margine effettivo per piatto
- Piatti più richiesti vs piatti mai ordinati
- Efficacia strategia d'asta: quanto speso vs quanto guadagnato
- Intolleranze scoperte nel turno
- Performance scalping (se attivo)

**Output**: scrive `turn_summary` con:
- Metriche quantitative (balance, reputation, dishes_served, dishes_failed, revenue, cost)
- Campo `notes` con raccomandazioni strategiche in linguaggio naturale per il Patron e lo Chef

---

## 6. Flusso di un turno — sequenza tecnica

```
[SSE] game_started {turn_id: N}
  │
  └─► [Patron] inizializza turno
        ├── db.read_turn_summary(N-1)  ← contiene note Evaluator
        └── prepara contesto strategico

[SSE] game_phase_changed {phase: "speaking"}
  │
  └─► [Patron]
        ├── bus.send("chef", phase_directive + note Evaluator)
        └── bus.send("pr", phase_directive)
              │
              ├─► [Chef]
              │     ├── api.get_recipes()
              │     ├── api.get_restaurant()
              │     ├── db.read_bid_history()
              │     ├── db.read_service_log()
              │     ├── LLM → seleziona ricette (menu determina il target)
              │     └── bus.send("magazzini", ingredient_request)
              │
              ├─► [Magazzini]
              │     └── valuta opportunità scalping se fondi extra
              │
              └─► [PR]
                    ├── LLM → decide chi contattare
                    └── mcp.send_message(...)

[SSE] game_phase_changed {phase: "closed_bid"}
  │
  └─► [Patron]
        └── bus.send("magazzini", phase_directive + budget)
              │
              └─► [Magazzini]
                    ├── legge ingredient_request dalla inbox
                    ├── db.read_bid_history()
                    ├── api.get_restaurant() → balance
                    ├── LLM → calibra bid (include scalping se opportuno)
                    ├── mcp.closed_bid(bids)
                    └── db.write_bid_history(own_bids)

[SSE] game_phase_changed {phase: "waiting"}
  │
  └─► [Patron]
        └── bus.send("magazzini", "report_inventory")
              │
              └─► [Magazzini]
                    ├── api.get_restaurant() → inventario
                    ├── api.get_bid_history(N) → tutti i bid
                    ├── db.write_bid_history(all_bids)
                    ├── api.get_market_entries() → polling attivo
                    ├── if missing: mcp.execute_transaction(...)
                    ├── if surplus not needed: mcp.create_market_entry(SELL, ...)
                    └── bus.send("chef", ingredient_report)
                          │
                          └─► [Chef]
                                ├── api.get_restaurant() → inventario finale
                                ├── LLM → ricalibra menu (menu determina target)
                                ├── mcp.save_menu(items)
                                └── bus.send("patron", "menu_ready")
                                      │
                                      └─► [Patron]
                                            └── mcp.update_restaurant_is_open(true)

[SSE] game_phase_changed {phase: "serving"}
  │
  └─► [Patron] nessuna azione (Maître + Brigata autonomi)

  [SSE] client_spawned {clientName, orderText}
    │
    └─► [SSE middleware] arricchisce con client_id (GET /meals)
          │
          └─► [Maître]  (gestisce N clienti in parallelo)
                ├── LLM → interpreta ordine
                ├── db.read_known_intolerances(client_type)
                ├── mcp.prepare_dish(dish_name)     ← HTTP 200 immediato
                └── aggiorna pending_orders → passa al prossimo cliente

  [SSE] preparation_complete {dish}
    │
    └─► [Brigata]  (deterministico, no LLM)
          ├── trova client in pending_orders
          ├── mcp.serve_dish(dish, client_id)
          ├── db.write_service_log(...)
          └── se errore intolleranza: db.write_known_intolerances(...)

  [Magazzini continua polling mercato durante serving]

[SSE] game_phase_changed {phase: "stopped"}
  │
  └─► [Patron]
        └── bus.send("evaluator", "analyze_turn")
              │
              └─► [Evaluator]
                    ├── db.read_service_log(N)
                    ├── db.read_bid_history(N)
                    ├── db.read_market_history(N)
                    ├── api.get_restaurant() → stato finale
                    ├── LLM → analisi qualitativa + raccomandazioni
                    └── db.write_turn_summary(N) con notes strategiche

  [Magazzini] db.write_bid_history(all_bids)
```

---

## 7. Accesso DB

L'accesso al database avviene tramite chiamate dirette a `Repository` (`db/repository.py`) nel codice Python degli agenti. I dati vengono letti prima di invocare l'LLM e inseriti nel prompt come contesto. L'LLM non ha tool DB — interagisce solo con i tool MCP di gioco.

---

## 8. Resilienza — specifiche tecniche

### 8.1 Timeout LLM

Ogni chiamata `ask_llm` in `BaseAgent` è wrappata con `asyncio.wait_for(..., timeout=30)`. Se supera 30 secondi, la chiamata viene cancellata, un warning è loggato e viene restituita stringa vuota. Questo previene che una risposta LLM lenta (es. durante un'escalation) blocchi il processing di messaggi critici di fase.

### 8.2 Fallback deterministici

| Situazione | Fallback |
|---|---|
| LLM non riesce a interpretare ordine (3 retry) | Maître serve il piatto più venduto del turno |
| LLM non riesce a decidere bid (3 retry) | Magazzini usa media storica + 20% |
| LLM non riesce a comporre menu (3 retry) | Chef usa menu del turno precedente |
| Nessun ingrediente ottenuto | Patron chiude ristorante per il turno |
| Connessione SSE persa | Riconnessione automatica con backoff 1s/3s/5s/5s... |
| Rate limit 429 | Backoff esponenziale, max 3 retry |

### 8.3 Chiusura protettiva

Se a fine `waiting` il menu ha 0 piatti assemblabili:
1. Patron chiama `update_restaurant_is_open(false)`
2. Logga nel `turn_summary` con notes="chiusura protettiva"
3. Al turno successivo riapre normalmente

---

## 9. Configurazione e Environment

### 9.1 File `.env`

```
HACKAPIZZA_API_KEY=<redacted>
REGOLO_API_KEY=<redacted>
```

### 9.2 Costanti (`config.py`)

```python
TEAM_ID = 14
TEAM_NAME = "Claudio Opuscoli"
BASE_URL = "https://hackapizza.datapizza.tech"
REGOLO_BASE_URL = "https://api.regolo.ai/v1"
MODEL_LARGE = "gpt-oss-120b"
MODEL_SMALL = "gpt-oss-20b"
DB_PATH = "hackapizza.db"
```

### 9.3 Dipendenze (`pyproject.toml`)

```toml
dependencies = [
    "aiohttp",
    "aiosqlite",
    "datapizza-ai",
    "datapizza-ai-clients-openai-like",
    "python-dotenv",
]
```

---

## 10. Entrypoint (`main.py`)

```
1.  Carica config e .env
2.  Inizializza DB (schema.py → crea tabelle se non esistono)
3.  Inizializza client condivisi:
    - APIClient (aiohttp per GET endpoints)
    - MCPClient (datapizza.tools.mcp_client per tool di gioco)
      game_mcp = MCPClient(url=f"{BASE_URL}/mcp")
      game_tools = game_mcp.list_tools()
4.  Crea OpenAILikeClient per ogni modello:
    - client_large = OpenAILikeClient(api_key=REGOLO_API_KEY, model=MODEL_LARGE, base_url=REGOLO_BASE_URL, ...)
    - client_small = OpenAILikeClient(api_key=REGOLO_API_KEY, model=MODEL_SMALL, base_url=REGOLO_BASE_URL, ...)
5.  Inizializza agenti come Agent(name=..., client=..., tools=[game_tools_filtrati]):
    - Patron, Chef, Magazzini, Maître, PR, Evaluator
6.  Configura orchestrazione multi-agente:
    - patron_agent.can_call([chef_agent, evaluator_agent])
7.  Inizializza MessageBus e registra agenti
8.  Avvia SSE middleware come task asyncio
9.  Avvia agent event loop per ogni agente come task asyncio
10. asyncio.gather() — tutto gira concorrentemente
11. Graceful shutdown su SIGINT/SIGTERM
```

---

## 11. Monitoring e tracing

Integrazione con **Datapizza Monitoring** (alpha):
- Ogni chiamata LLM tracciata
- Ogni tool call tracciata
- Turn-level metrics: tempo risposta, token usati, piatti serviti
- Invitation code: `hackapizza2`
- URL: `datapizza-monitoring.datapizza.tech`

---

## 12. Vincoli e limiti noti

| Vincolo | Impatto |
|---|---|
| Una sola connessione SSE per ristorante | Il middleware è singleton |
| `bid_history` server: solo ultimi 2 turni | Salvare sempre nel DB locale |
| `meals` server: solo ultimi 2 turni | Salvare in `service_log` |
| Rate limit (429) | Retry con backoff, minimizzare chiamate ridondanti |
| Ingredienti scadono a fine turno | Mai comprare più di quello che si può cucinare |
| `save_menu` non disponibile in `serving` | Menu deve essere finalizzato in `waiting` |
| In `serving` si può solo chiudere (non aprire) | Decisione apertura va presa in `waiting` |
| Prezzo massimo menu: 1000 | Cap pricing |
| Golden Run: nessun intervento umano | Il sistema deve essere completamente autonomo |
