deploy: disable Qwen reasoning budget for concise responses

#4
by Baida07 - opened
This view is limited to 50 files because it contains too many changes. See the raw diff here.
Files changed (50) hide show
  1. .env.example +9 -2
  2. agents/engineering_state.py +255 -0
  3. agents/goal_verifier.py +3 -3
  4. agents/planner.py +1 -1
  5. agents/unified_loop.py +211 -19
  6. agents/unified_loop_helpers.py +13 -1
  7. agents/unified_loop_llm.py +13 -6
  8. agents/unified_loop_prompts.py +7 -4
  9. agents/unified_loop_tools.py +50 -6
  10. api/admin_state.py +75 -0
  11. api/agent.py +19 -10
  12. api/agent_checkpoint.py +1 -1
  13. api/agent_memory.py +1 -0
  14. api/auth_guard.py +126 -5
  15. api/benchmark.py +1 -1
  16. api/benchmark_handler.py +92 -43
  17. api/browser.py +7 -7
  18. api/exec.py +4 -5
  19. api/me_tasks.py +117 -0
  20. api/persistence.py +108 -8
  21. api/private_state.py +382 -0
  22. api/providers.py +83 -31
  23. api/public_status.py +72 -0
  24. api/research.py +1 -1
  25. api/scheduler.py +35 -9
  26. api/speculative.py +3 -3
  27. api/startup_migration.py +12 -0
  28. api/state.py +98 -59
  29. api/telegram_webhook.py +167 -331
  30. api/terminal.py +49 -0
  31. api/vault.py +11 -11
  32. api/version.py +3 -0
  33. api/vision.py +112 -87
  34. benchmarks/__init__.py +41 -0
  35. benchmarks/model_watch_adapter.py +352 -0
  36. benchmarks/shadow_telemetry.py +125 -0
  37. benchmarks/validators.py +351 -0
  38. main.py +33 -1
  39. memory/manager.py +33 -0
  40. memory/semantic.py +6 -5
  41. memory/sync.py +7 -5
  42. models/ai_client.py +326 -58
  43. models/role_router.py +179 -88
  44. tests/test_ai_client_provider_unavailability.py +95 -0
  45. tests/test_ai_client_schema_compatibility.py +83 -0
  46. tests/test_auth_scheduler_regressions.py +111 -0
  47. tests/test_benchmark_validators.py +232 -0
  48. tests/test_coding_output_contract.py +27 -0
  49. tests/test_cognitive_gaps.py +8 -3
  50. tests/test_engineering_state.py +92 -0
.env.example CHANGED
@@ -12,13 +12,17 @@ VAULT_KEY= # AES-256 Hex
12
  NOTIFY_TOKEN= # Notifiche Interne
13
 
14
  # ── 2. Quadrante A (BRAIN - Primary) ─────────────────────────
15
- BACKEND_URL=https://baida-a-terminal.hf.space
16
  RAILWAY_TOKEN=
17
  RAILWAY_PROJECT_ID=YOUR_RAILWAY_PROJECT_ID_A
18
  SUPABASE_URL=
19
  SUPABASE_SERVICE_ROLE_KEY=
20
  GITHUB_TOKEN=
 
21
  HF_TOKEN=
 
 
 
22
 
23
  # ── 3. Quadrante B (HANDS - Collab/Failover) ─────────────────
24
  RAILWAY_TOKEN_B=
@@ -47,6 +51,9 @@ RAILWAY_PROJECT_ID_E=YOUR_RAILWAY_PROJECT_ID_E
47
  # Configurare nei Secrets del provider hosting (HF/Railway)
48
  GROQ_API_KEY=
49
  OPENROUTER_API_KEY=
 
 
 
50
  GEMINI_API_KEY=
51
  NVIDIA_API_KEY=
52
 
@@ -61,5 +68,5 @@ UPSTASH_REDIS_REST_TOKEN=
61
  # ── 9. Feature Flags ─────────────────────────────────────────
62
  VITE_ENABLE_BROWSER_SANDBOX=false
63
  UNIFIED_LOOP_MAX_STEPS=8
64
- LLM_MODEL=google/gemini-2.0-flash-exp:free
65
 
 
12
  NOTIFY_TOKEN= # Notifiche Interne
13
 
14
  # ── 2. Quadrante A (BRAIN - Primary) ─────────────────────────
15
+ BACKEND_URL=https://baida07-terminal.hf.space
16
  RAILWAY_TOKEN=
17
  RAILWAY_PROJECT_ID=YOUR_RAILWAY_PROJECT_ID_A
18
  SUPABASE_URL=
19
  SUPABASE_SERVICE_ROLE_KEY=
20
  GITHUB_TOKEN=
21
+ # Hugging Face Router: endpoint OpenAI-compatible per inferenza.
22
  HF_TOKEN=
23
+ HF_MODEL=Qwen/Qwen2.5-Coder-32B-Instruct
24
+ # Pool opzionale: [{"profile":"primary","api_key":"...","model":"openai/gpt-oss-120b:fastest"}]
25
+ HF_ROUTER_PROFILES_JSON=
26
 
27
  # ── 3. Quadrante B (HANDS - Collab/Failover) ─────────────────
28
  RAILWAY_TOKEN_B=
 
51
  # Configurare nei Secrets del provider hosting (HF/Railway)
52
  GROQ_API_KEY=
53
  OPENROUTER_API_KEY=
54
+ # Pool opzionale: JSON senza loggare le chiavi. Ogni profilo deve avere profile e api_key.
55
+ # Esempio: OPENROUTER_PROFILES_JSON=[{"profile":"primary","api_key":"..."},{"profile":"backup","api_key":"..."}]
56
+ OPENROUTER_PROFILES_JSON=
57
  GEMINI_API_KEY=
58
  NVIDIA_API_KEY=
59
 
 
68
  # ── 9. Feature Flags ─────────────────────────────────────────
69
  VITE_ENABLE_BROWSER_SANDBOX=false
70
  UNIFIED_LOOP_MAX_STEPS=8
71
+ LLM_MODEL=openai/gpt-oss-20b:free
72
 
agents/engineering_state.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Versioned, bounded engineering lifecycle state for the unified agent loop.
2
+
3
+ The module is deliberately dependency-free. It mirrors the legacy lifecycle without
4
+ being authoritative for recovery when the rollout mode is enabled, and it never stores
5
+ raw prompts, credentials, or arbitrary tool output.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import os
11
+ import re
12
+ import time
13
+ from dataclasses import dataclass, field
14
+ from enum import Enum
15
+ from typing import Any, Mapping
16
+
17
+ SCHEMA_VERSION = 1
18
+ MAX_HISTORY = 64
19
+ MAX_DIAGNOSTICS = 24
20
+ MAX_PREVIEW_CHARS = 256
21
+ MAX_ID_CHARS = 180
22
+
23
+ _SECRET_PATTERNS = (
24
+ re.compile(r"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]{8,}"),
25
+ re.compile(r"(?i)(api[_-]?key\s*[:=]\s*)[^\s,;]+"),
26
+ re.compile(r"(?i)(token\s*[:=]\s*)[^\s,;]+"),
27
+ re.compile(r"(?i)\b(?:ghp|gho|github_pat|hf|sk|xoxb|xapp|r8)_[A-Za-z0-9_-]{8,}\b"),
28
+ re.compile(r"\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b"),
29
+ )
30
+
31
+
32
+ class EngineeringStateMode(str, Enum):
33
+ OFF = "off"
34
+ SHADOW = "shadow"
35
+ CANARY = "canary"
36
+ AUTHORITATIVE = "authoritative"
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class EngineeringStateConfig:
41
+ """Conservative rollout configuration read once per run."""
42
+
43
+ mode: EngineeringStateMode = EngineeringStateMode.OFF
44
+ canary_rate: float = 0.0
45
+
46
+ @classmethod
47
+ def from_env(cls) -> "EngineeringStateConfig":
48
+ raw_mode = os.getenv("ENGINEERING_STATE_MODE", "authoritative").strip().lower() # P1 default; off remains an explicit rollback mode
49
+ try:
50
+ mode = EngineeringStateMode(raw_mode)
51
+ except ValueError:
52
+ mode = EngineeringStateMode.OFF
53
+ try:
54
+ rate = float(os.getenv("ENGINEERING_STATE_CANARY_RATE", "0"))
55
+ except (TypeError, ValueError):
56
+ rate = 0.0
57
+ return cls(mode=mode, canary_rate=max(0.0, min(rate, 1.0)))
58
+
59
+ @property
60
+ def enabled(self) -> bool:
61
+ return self.mode is not EngineeringStateMode.OFF
62
+
63
+ def selects_canary(self, run_id: str, session_id: str) -> bool:
64
+ if self.mode is not EngineeringStateMode.CANARY or not session_id:
65
+ return False
66
+ if self.canary_rate >= 1.0:
67
+ return True
68
+ if self.canary_rate <= 0.0:
69
+ return False
70
+ digest = hashlib.sha256(f"{run_id}:{session_id}".encode()).digest()
71
+ bucket = int.from_bytes(digest[:8], "big") / float(2**64)
72
+ return bucket < self.canary_rate
73
+
74
+
75
+ def _bounded_id(value: str | None) -> str:
76
+ return re.sub(r"[^A-Za-z0-9_.:/-]", "_", str(value or ""))[:MAX_ID_CHARS]
77
+
78
+
79
+ def redact_text(value: object, max_chars: int = MAX_PREVIEW_CHARS) -> str:
80
+ """Redact common credential forms before anything reaches a checkpoint."""
81
+ text = str(value or "")[: max_chars * 4]
82
+ for pattern in _SECRET_PATTERNS:
83
+ if pattern.groups:
84
+ text = pattern.sub(lambda match: f"{match.group(1)}[REDACTED]", text)
85
+ else:
86
+ text = pattern.sub("[REDACTED]", text)
87
+ return text[:max_chars]
88
+
89
+
90
+ _ALLOWED_TRANSITIONS: dict[str, frozenset[str]] = {
91
+ "IDLE": frozenset({"CLASSIFYING", "FAILED"}),
92
+ "CLASSIFYING": frozenset({"TOOL_EXECUTING", "THINKING", "COMPLETED", "FAILED"}),
93
+ "TOOL_EXECUTING": frozenset({"THINKING", "COMPLETED", "FAILED"}),
94
+ "THINKING": frozenset({"COMPLETED", "FAILED"}),
95
+ "FAILED": frozenset({"IDLE"}),
96
+ "COMPLETED": frozenset({"IDLE", "FAILED"}),
97
+ }
98
+
99
+
100
+ @dataclass
101
+ class EngineeringState:
102
+ """Bounded state envelope that can be persisted and safely restored."""
103
+
104
+ run_id: str
105
+ session_id: str
106
+ checkpoint_id: str
107
+ goal_digest: str
108
+ goal_preview: str
109
+ current_state: str = "IDLE"
110
+ history: list[dict[str, Any]] = field(default_factory=list)
111
+ diagnostics: list[str] = field(default_factory=list)
112
+ revision: int = 0
113
+ sequence: int = 0
114
+ created_at_ms: int = field(default_factory=lambda: int(time.time() * 1000))
115
+ updated_at_ms: int = field(default_factory=lambda: int(time.time() * 1000))
116
+
117
+ @classmethod
118
+ def start(
119
+ cls,
120
+ goal: str,
121
+ *,
122
+ run_id: str,
123
+ session_id: str = "",
124
+ checkpoint_id: str | None = None,
125
+ now_ms: int | None = None,
126
+ ) -> "EngineeringState":
127
+ now = int(time.time() * 1000) if now_ms is None else int(now_ms)
128
+ normalized_goal = str(goal or "")
129
+ return cls(
130
+ run_id=_bounded_id(run_id),
131
+ session_id=_bounded_id(session_id),
132
+ checkpoint_id=_bounded_id(checkpoint_id or session_id or run_id),
133
+ goal_digest=hashlib.sha256(normalized_goal.encode("utf-8", "replace")).hexdigest(),
134
+ goal_preview=redact_text(normalized_goal),
135
+ created_at_ms=now,
136
+ updated_at_ms=now,
137
+ )
138
+
139
+ @property
140
+ def status(self) -> str:
141
+ if self.current_state == "COMPLETED":
142
+ return "completed"
143
+ if self.current_state == "FAILED":
144
+ return "failed"
145
+ return "active"
146
+
147
+ def transition(self, next_state: str, *, now_ms: int | None = None) -> bool:
148
+ """Apply an idempotent transition; reject illegal transitions deterministically."""
149
+ target = str(next_state)
150
+ if target == self.current_state:
151
+ return False
152
+ allowed = _ALLOWED_TRANSITIONS.get(self.current_state, frozenset())
153
+ if target not in allowed:
154
+ raise ValueError(f"Invalid EngineeringState transition: {self.current_state} -> {target}")
155
+ now = int(time.time() * 1000) if now_ms is None else int(now_ms)
156
+ self.sequence += 1
157
+ self.revision += 1
158
+ self.history.append({
159
+ "sequence": self.sequence,
160
+ "from_state": self.current_state,
161
+ "to_state": target,
162
+ "at_ms": now,
163
+ })
164
+ if len(self.history) > MAX_HISTORY:
165
+ del self.history[:-MAX_HISTORY]
166
+ self.current_state = target
167
+ self.updated_at_ms = now
168
+ return True
169
+
170
+ def prepare_for_resume(self) -> None:
171
+ """Normalize a restored snapshot before a new loop execution."""
172
+ if self.current_state != "IDLE":
173
+ self.current_state = "IDLE"
174
+ self.revision += 1
175
+ self.updated_at_ms = int(time.time() * 1000)
176
+ self.diagnostic("resume normalized state to IDLE")
177
+
178
+ def diagnostic(self, message: str) -> None:
179
+ value = redact_text(message, 180)
180
+ if not value or value in self.diagnostics:
181
+ return
182
+ self.diagnostics.append(value)
183
+ if len(self.diagnostics) > MAX_DIAGNOSTICS:
184
+ del self.diagnostics[:-MAX_DIAGNOSTICS]
185
+ self.revision += 1
186
+ self.updated_at_ms = int(time.time() * 1000)
187
+
188
+ def snapshot(self) -> dict[str, Any]:
189
+ """Return a bounded JSON-compatible envelope; never expose the raw goal."""
190
+ return {
191
+ "schema_version": SCHEMA_VERSION,
192
+ "run_id": self.run_id,
193
+ "session_id": self.session_id,
194
+ "checkpoint_id": self.checkpoint_id,
195
+ "goal_digest": self.goal_digest,
196
+ "goal_preview": self.goal_preview,
197
+ "status": self.status,
198
+ "current_state": self.current_state,
199
+ "revision": self.revision,
200
+ "sequence": self.sequence,
201
+ "history": list(self.history[-MAX_HISTORY:]),
202
+ "diagnostics": list(self.diagnostics[-MAX_DIAGNOSTICS:]),
203
+ "created_at_ms": self.created_at_ms,
204
+ "updated_at_ms": self.updated_at_ms,
205
+ }
206
+
207
+ def projection(self) -> dict[str, Any]:
208
+ """Small read-only view safe for API/SSE consumers."""
209
+ return {
210
+ "schema_version": SCHEMA_VERSION,
211
+ "status": self.status,
212
+ "current_state": self.current_state,
213
+ "revision": self.revision,
214
+ "sequence": self.sequence,
215
+ "checkpoint_id": self.checkpoint_id,
216
+ "history": [dict(item) for item in self.history[-16:]],
217
+ "diagnostics": list(self.diagnostics[-MAX_DIAGNOSTICS:]),
218
+ }
219
+
220
+ @classmethod
221
+ def from_snapshot(cls, payload: Mapping[str, Any]) -> "EngineeringState":
222
+ if not isinstance(payload, Mapping):
223
+ raise ValueError("engineering state must be an object")
224
+ if int(payload.get("schema_version", -1)) != SCHEMA_VERSION:
225
+ raise ValueError("unsupported engineering state schema")
226
+ history = payload.get("history", [])
227
+ diagnostics = payload.get("diagnostics", [])
228
+ if not isinstance(history, list) or len(history) > MAX_HISTORY:
229
+ raise ValueError("invalid engineering state history")
230
+ if not isinstance(diagnostics, list) or len(diagnostics) > MAX_DIAGNOSTICS:
231
+ raise ValueError("invalid engineering state diagnostics")
232
+ current = str(payload.get("current_state", ""))
233
+ if current not in _ALLOWED_TRANSITIONS:
234
+ raise ValueError("invalid engineering state current state")
235
+ revision = int(payload.get("revision", -1))
236
+ sequence = int(payload.get("sequence", -1))
237
+ if revision < 0 or sequence < 0 or revision < sequence:
238
+ raise ValueError("invalid engineering state revision")
239
+ state = cls(
240
+ run_id=_bounded_id(str(payload.get("run_id", ""))),
241
+ session_id=_bounded_id(str(payload.get("session_id", ""))),
242
+ checkpoint_id=_bounded_id(str(payload.get("checkpoint_id", ""))),
243
+ goal_digest=str(payload.get("goal_digest", "")),
244
+ goal_preview=redact_text(payload.get("goal_preview", "")),
245
+ current_state=current,
246
+ history=[dict(item) for item in history if isinstance(item, Mapping)],
247
+ diagnostics=[redact_text(item, 180) for item in diagnostics],
248
+ revision=revision,
249
+ sequence=sequence,
250
+ created_at_ms=int(payload.get("created_at_ms", 0)),
251
+ updated_at_ms=int(payload.get("updated_at_ms", 0)),
252
+ )
253
+ if len(state.goal_digest) != 64 or not re.fullmatch(r"[0-9a-f]{64}", state.goal_digest):
254
+ raise ValueError("invalid engineering state goal digest")
255
+ return state
agents/goal_verifier.py CHANGED
@@ -40,7 +40,7 @@ class GoalVerificationStatus(str, Enum):
40
  FAIL = "FAIL"
41
  UNKNOWN = "UNKNOWN"
42
 
43
- RETRY_THRESHOLD = 0.35
44
  MAX_GOAL_CHARS = 400
45
  MAX_ANS_CHARS = 1500
46
  MAX_HINT_CHARS = 150
@@ -203,9 +203,9 @@ class GoalVerifier:
203
  if cls._EXPLANATION_RE.search(g[:500]) and not cls._CODE_RE.search(g[:500]):
204
  return 0.25
205
  if _COMPLEX_CODE_RE.search(g[:500]):
206
- return 0.55
207
  if cls._CODE_RE.search(g[:500]):
208
- return 0.42
209
  return RETRY_THRESHOLD
210
 
211
  def __init__(self, llm: Any) -> None:
 
40
  FAIL = "FAIL"
41
  UNKNOWN = "UNKNOWN"
42
 
43
+ RETRY_THRESHOLD = 0.30 # S-BENCH-FIX: meno punitivo su near-misses
44
  MAX_GOAL_CHARS = 400
45
  MAX_ANS_CHARS = 1500
46
  MAX_HINT_CHARS = 150
 
203
  if cls._EXPLANATION_RE.search(g[:500]) and not cls._CODE_RE.search(g[:500]):
204
  return 0.25
205
  if _COMPLEX_CODE_RE.search(g[:500]):
206
+ return 0.48 # S-BENCH-FIX: 0.55 -> 0.48 bilanciamento rigore
207
  if cls._CODE_RE.search(g[:500]):
208
+ return 0.38 # S-BENCH-FIX: 0.42 -> 0.38
209
  return RETRY_THRESHOLD
210
 
211
  def __init__(self, llm: Any) -> None:
agents/planner.py CHANGED
@@ -210,7 +210,7 @@ class Planner:
210
 
211
  def _get_fast_llm(self) -> AIClient:
212
  """Gap-5: Cerebras gpt-oss-120b (2000+ tok/s) per quick-start draft.
213
- Fallback: Groq llama-3.1-8b-instant se CEREBRAS_API_KEY assente."""
214
  try:
215
  from models.role_router import RoleRouter, Role
216
  return RoleRouter.get_client(Role.REASONER) # Cerebras 120B
 
210
 
211
  def _get_fast_llm(self) -> AIClient:
212
  """Gap-5: Cerebras gpt-oss-120b (2000+ tok/s) per quick-start draft.
213
+ Fallback: Groq openai/gpt-oss-20b se CEREBRAS_API_KEY assente."""
214
  try:
215
  from models.role_router import RoleRouter, Role
216
  return RoleRouter.get_client(Role.REASONER) # Cerebras 120B
agents/unified_loop.py CHANGED
@@ -63,6 +63,48 @@ from agents.unified_loop_types import (
63
  # I4.5: active state is scoped to the current asyncio task, not the loop instance.
64
  # This lets the public guard close unexpected exceptions without sharing state across runs.
65
  _ACTIVE_LOOP_STATE: ContextVar[UnifiedLoopState | None] = ContextVar("active_loop_state", default=None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
  # S404: Error Classifier — import lazy per evitare circular import issues
68
  def _get_classifier():
@@ -143,15 +185,31 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
143
  """Validate and publish one per-run state transition."""
144
  previous = state.state_machine.current
145
  state.state_machine.transition(next_state)
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  if previous == next_state or on_step is None:
147
  return
148
  try:
149
- await _maybe_await(on_step({
150
  "action": "state_transition",
151
  "status": "done",
152
  "from_state": previous.value,
153
  "to_state": next_state.value,
154
- }))
 
 
 
155
  except Exception as _state_callback_error:
156
  _logger.debug("[unified_loop] state callback silenced: %s", _state_callback_error)
157
 
@@ -545,7 +603,12 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
545
  "explanation": "Il pianificatore ha impiegato troppo — procedo senza piano",
546
  "visibility": "progress",
547
  }))
548
- if plan is not None:
 
 
 
 
 
549
  state.steps.append({"action": "plan", "result": plan})
550
  try:
551
  from api.state import record_timing as _rtc_pl
@@ -952,6 +1015,15 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
952
  tool_key_pair = _TOOL_MAP.get(_s_tool, (None, None))
953
  reg_name, inp_builder = tool_key_pair
954
  if reg_name and inp_builder is not None:
 
 
 
 
 
 
 
 
 
955
  _pending_exec.append((subtask, reg_name, inp_builder))
956
  elif _s_tool:
957
  # COG-4: tool non in _TOOL_MAP — tenta generazione dinamica
@@ -1687,16 +1759,16 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
1687
  _logger.info("GAP-NEW-2: TDD fail iniettato in exec_warn (%d chars)", len(self._tdd_fail_inject))
1688
  self._tdd_fail_inject = None
1689
  # GAP-4: StrategicHealer — analisi LLM pattern di fallimento (integra GAP-SELFHEAL v2)
1690
- if exec_errors and getattr(self, '_strategic_healer', None):
1691
  try:
1692
  _sh_ctx_str = "\n".join(str(w) for w in exec_warn[-10:] if isinstance(w, str))
1693
- _sh_decision = await self._strategic_healer.analyze_and_decide(exec_errors, _sh_ctx_str)
1694
  if _sh_decision and getattr(_sh_decision, 'strategy_prompt', None):
1695
  exec_warn.insert(0, _sh_decision.strategy_prompt)
1696
  _logger.info("GAP-4: StrategicHealer strategy iniettata in exec_warn")
1697
  if _sh_decision and getattr(_sh_decision, 'should_stop', False):
1698
  _logger.info("GAP-4: StrategicHealer → should_stop, interruzione fallback")
1699
- return # _run_fallback: should_stop esci dal fallback (non c'è loop da rompere)
1700
  except Exception as _sh_loop_err:
1701
  _logger.debug("GAP-4: StrategicHealer loop silenced — %s", _sh_loop_err)
1702
  # GAP-SELFHEAL v2: dual-mode fingerprinting — raw + error-class extraction.
@@ -2159,6 +2231,58 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
2159
  _rec_timing("coder_ms", _llm_elapsed) # Sprint 5 ITEM 14: phase timing
2160
  except Exception as _exc:
2161
  _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2162
  # P16-B4: segnala truncation SSE se finish_reason == "length"
2163
  _fr = getattr(_active_llm, '_last_finish_reason', 'stop')
2164
  if _fr == 'length' and on_step:
@@ -2914,7 +3038,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
2914
  except Exception:
2915
  pass
2916
  # S455-P10: task supervisionato — done_callback logga eccezioni silenziate
2917
- asyncio.create_task(_reverify_task())
2918
  _rv_t.add_done_callback(
2919
  lambda t: t.exception() if not t.cancelled() and not t.exception() is None else None
2920
  )
@@ -3389,6 +3513,8 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
3389
  session_id: str = "") -> dict[str, Any]:
3390
  """Run the loop and close unexpected exceptions as a controlled FAILED state."""
3391
  previous_state = _ACTIVE_LOOP_STATE.get()
 
 
3392
  try:
3393
  return await self._run_impl(goal, context, max_steps, on_step, session_id)
3394
  except Exception as _run_error:
@@ -3406,17 +3532,14 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
3406
  state.errors.append(error_text)
3407
  previous = state.state_machine.current
3408
  if previous != AgentState.FAILED:
3409
- state.state_machine.transition(AgentState.FAILED)
3410
- if on_step is not None:
3411
- try:
3412
- await _maybe_await(on_step({
3413
- "action": "state_transition",
3414
- "status": "done",
3415
- "from_state": previous.value,
3416
- "to_state": AgentState.FAILED.value,
3417
- }))
3418
- except Exception as _state_callback_error:
3419
- _logger.debug("[unified_loop] failure callback silenced: %s", _state_callback_error)
3420
  return {
3421
  "success": False,
3422
  "goal": state.goal,
@@ -3427,6 +3550,8 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
3427
  }
3428
  finally:
3429
  _ACTIVE_LOOP_STATE.set(previous_state)
 
 
3430
 
3431
  async def _run_impl(self, goal: str, context: str = "", max_steps: int = 8,
3432
  on_step: StepCallback | None = None,
@@ -3489,16 +3614,83 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
3489
  max_steps = 12
3490
 
3491
  state = UnifiedLoopState(goal=goal, context=context, max_steps=max_steps, session_id=session_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3492
  _ACTIVE_LOOP_STATE.set(state)
3493
  await self._transition_state(state, AgentState.CLASSIFYING, on_step)
3494
 
3495
  def _with_state(result: dict[str, Any]) -> dict[str, Any]:
3496
  result.update(state.state_machine.snapshot())
 
 
3497
  return result
3498
 
 
 
 
 
 
 
 
 
 
 
 
3499
  async def _finish(result: dict[str, Any]) -> dict[str, Any]:
3500
  next_state = AgentState.COMPLETED if result.get("success", True) else AgentState.FAILED
3501
- await self._transition_state(state, next_state, on_step)
 
 
 
 
3502
  return _with_state(result)
3503
 
3504
  # GAP-4: StrategicHealer — init + load past failures (LLM-based self-healing cognitivo)
 
63
  # I4.5: active state is scoped to the current asyncio task, not the loop instance.
64
  # This lets the public guard close unexpected exceptions without sharing state across runs.
65
  _ACTIVE_LOOP_STATE: ContextVar[UnifiedLoopState | None] = ContextVar("active_loop_state", default=None)
66
+ # P0: EngineeringState is a shadow/canary projection of the legacy lifecycle.
67
+ # Context-local storage keeps parallel runs isolated even when one loop instance is reused.
68
+ from agents.engineering_state import EngineeringState, EngineeringStateConfig, EngineeringStateMode
69
+
70
+ _ACTIVE_ENGINEERING_STATE: ContextVar[EngineeringState | None] = ContextVar(
71
+ "active_engineering_state", default=None
72
+ )
73
+ _ACTIVE_ENGINEERING_MODE: ContextVar[EngineeringStateMode | None] = ContextVar(
74
+ "active_engineering_mode", default=None
75
+ )
76
+
77
+
78
+ def _schedule_engineering_persist(engineering_state: EngineeringState) -> None:
79
+ """Persist a snapshot without blocking the loop or making observability fatal."""
80
+ snapshot = engineering_state.snapshot()
81
+
82
+ async def _persist() -> None:
83
+ try:
84
+ from api.persistence import sb_save_engineering_state
85
+ await sb_save_engineering_state(snapshot["checkpoint_id"], snapshot)
86
+ except Exception as exc: # shadow state must never break the user task
87
+ _logger.debug("[engineering-state] persist silenced: %s", type(exc).__name__)
88
+
89
+ try:
90
+ task = asyncio.create_task(_persist())
91
+ task.add_done_callback(lambda done: done.exception() if not done.cancelled() else None)
92
+ except RuntimeError:
93
+ # No running event loop during defensive/test-only calls.
94
+ return
95
+
96
+
97
+ async def _flush_engineering_persist(engineering_state: EngineeringState | None) -> None:
98
+ """Flush the terminal snapshot before returning a run result."""
99
+ if engineering_state is None:
100
+ return
101
+ snapshot = engineering_state.snapshot()
102
+ try:
103
+ from api.persistence import sb_save_engineering_state
104
+ await sb_save_engineering_state(snapshot["checkpoint_id"], snapshot, force=True)
105
+ except Exception as exc: # persistence must not turn a completed task into a crash
106
+ engineering_state.diagnostic(f"final persist failed: {type(exc).__name__}")
107
+ _logger.debug("[engineering-state] final persist silenced: %s", type(exc).__name__)
108
 
109
  # S404: Error Classifier — import lazy per evitare circular import issues
110
  def _get_classifier():
 
185
  """Validate and publish one per-run state transition."""
186
  previous = state.state_machine.current
187
  state.state_machine.transition(next_state)
188
+
189
+ # P0 adapter: mirror every legacy transition into the versioned state.
190
+ engineering_state = _ACTIVE_ENGINEERING_STATE.get()
191
+ if engineering_state is not None:
192
+ try:
193
+ engineering_state.transition(next_state.value)
194
+ _schedule_engineering_persist(engineering_state)
195
+ except Exception as exc:
196
+ engineering_state.diagnostic(f"transition adapter: {type(exc).__name__}")
197
+ if _ACTIVE_ENGINEERING_MODE.get() == EngineeringStateMode.AUTHORITATIVE:
198
+ raise
199
+ _logger.debug("[engineering-state] transition silenced: %s", type(exc).__name__)
200
+
201
  if previous == next_state or on_step is None:
202
  return
203
  try:
204
+ event = {
205
  "action": "state_transition",
206
  "status": "done",
207
  "from_state": previous.value,
208
  "to_state": next_state.value,
209
+ }
210
+ if engineering_state is not None:
211
+ event["engineering_state"] = engineering_state.projection()
212
+ await _maybe_await(on_step(event))
213
  except Exception as _state_callback_error:
214
  _logger.debug("[unified_loop] state callback silenced: %s", _state_callback_error)
215
 
 
603
  "explanation": "Il pianificatore ha impiegato troppo — procedo senza piano",
604
  "visibility": "progress",
605
  }))
606
+ # P1-RECOVERY: check if plan already exists in steps
607
+ existing_plan_step = next((s for s in state.steps if s.get("action") == "plan"), None)
608
+ if existing_plan_step:
609
+ plan = existing_plan_step.get("result")
610
+ _logger.info("[P1-RECOVERY] Plan restored from steps")
611
+ elif plan is not None:
612
  state.steps.append({"action": "plan", "result": plan})
613
  try:
614
  from api.state import record_timing as _rtc_pl
 
1015
  tool_key_pair = _TOOL_MAP.get(_s_tool, (None, None))
1016
  reg_name, inp_builder = tool_key_pair
1017
  if reg_name and inp_builder is not None:
1018
+ # P1-RECOVERY: skip subtasks already completed in state.steps
1019
+ _st_id = subtask.get("id")
1020
+ _done_step = next((s for s in state.steps if s.get("subtask_id") == _st_id), None)
1021
+ if _done_step:
1022
+ _logger.info("[P1-RECOVERY] Skipping already completed subtask #%s", _st_id)
1023
+ # Ripristiniamo l'output nel buffer per i dipendenti
1024
+ _existing_out = _done_step.get("output", "")
1025
+ _subtask_outputs[str(_st_id)] = _existing_out
1026
+ continue
1027
  _pending_exec.append((subtask, reg_name, inp_builder))
1028
  elif _s_tool:
1029
  # COG-4: tool non in _TOOL_MAP — tenta generazione dinamica
 
1759
  _logger.info("GAP-NEW-2: TDD fail iniettato in exec_warn (%d chars)", len(self._tdd_fail_inject))
1760
  self._tdd_fail_inject = None
1761
  # GAP-4: StrategicHealer — analisi LLM pattern di fallimento (integra GAP-SELFHEAL v2)
1762
+ if _tool_exec_errors and getattr(self, '_strategic_healer', None):
1763
  try:
1764
  _sh_ctx_str = "\n".join(str(w) for w in exec_warn[-10:] if isinstance(w, str))
1765
+ _sh_decision = await self._strategic_healer.analyze_and_decide(_tool_exec_errors, _sh_ctx_str)
1766
  if _sh_decision and getattr(_sh_decision, 'strategy_prompt', None):
1767
  exec_warn.insert(0, _sh_decision.strategy_prompt)
1768
  _logger.info("GAP-4: StrategicHealer strategy iniettata in exec_warn")
1769
  if _sh_decision and getattr(_sh_decision, 'should_stop', False):
1770
  _logger.info("GAP-4: StrategicHealer → should_stop, interruzione fallback")
1771
+ return {"success": False, "output": "", "error": "StrategicHealer ha interrotto il fallback dopo errori di esecuzione"}
1772
  except Exception as _sh_loop_err:
1773
  _logger.debug("GAP-4: StrategicHealer loop silenced — %s", _sh_loop_err)
1774
  # GAP-SELFHEAL v2: dual-mode fingerprinting — raw + error-class extraction.
 
2231
  _rec_timing("coder_ms", _llm_elapsed) # Sprint 5 ITEM 14: phase timing
2232
  except Exception as _exc:
2233
  _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
2234
+ # BENCH-SHADOW: validator osservazionale MMLU/coding. Fail-open: non
2235
+ # modifica answer, retry, provider routing o scoring.
2236
+ try:
2237
+ from benchmarks.shadow_telemetry import validate_and_record_shadow
2238
+ validate_and_record_shadow(
2239
+ goal=state.goal,
2240
+ answer=answer,
2241
+ metadata={
2242
+ "provider": getattr(_active_llm, "provider", None),
2243
+ "model": getattr(_active_llm, "model", None),
2244
+ "profile": getattr(_active_llm, "profile", None),
2245
+ "attempt": _llm_try,
2246
+ "latency_ms": round(_llm_elapsed, 2),
2247
+ "source": "unified_loop",
2248
+ },
2249
+ )
2250
+ except Exception as _exc:
2251
+ _logger.debug("[unified_loop] shadow telemetry silenced %s", type(_exc).__name__)
2252
+
2253
+ # BENCH-CODE-RETRY: retry strutturato solo per output TypeScript
2254
+ # non estraibile/non conforme. Non aggiunge tentativi oltre il budget
2255
+ # esistente e non scatta su goal non-coding.
2256
+ if not _is_last:
2257
+ try:
2258
+ from benchmarks.validators import validate_coding_retry
2259
+ _code_validation = validate_coding_retry(
2260
+ state.goal,
2261
+ answer,
2262
+ is_last_attempt=_is_last,
2263
+ )
2264
+ if _code_validation is not None:
2265
+ state.steps.append({
2266
+ "action": f"typescript_contract_retry_{_llm_try}",
2267
+ "failure_code": _code_validation.failure_code,
2268
+ })
2269
+ _code_repair = (
2270
+ "CONTRATTO TYPESCRIPT FALLITO: "
2271
+ f"{_code_validation.failure_code}.\n"
2272
+ "Ripeti ora la risposta da zero. Restituisci ESATTAMENTE un solo blocco "
2273
+ "```typescript ... ``` non vuoto, completo e compilabile. "
2274
+ "Mantieni la firma e tutti i simboli richiesti dal task. "
2275
+ "Non usare pseudocodice, Python, testo al posto del codice, TODO o placeholder."
2276
+ )
2277
+ messages = [
2278
+ messages[0],
2279
+ {"role": "system", "content": _code_repair},
2280
+ *messages[1:],
2281
+ ]
2282
+ _error_severity = "syntax"
2283
+ continue
2284
+ except Exception as _exc:
2285
+ _logger.debug("[unified_loop] coding validator retry silenced %s", type(_exc).__name__)
2286
  # P16-B4: segnala truncation SSE se finish_reason == "length"
2287
  _fr = getattr(_active_llm, '_last_finish_reason', 'stop')
2288
  if _fr == 'length' and on_step:
 
3038
  except Exception:
3039
  pass
3040
  # S455-P10: task supervisionato — done_callback logga eccezioni silenziate
3041
+ _rv_t = asyncio.create_task(_reverify_task())
3042
  _rv_t.add_done_callback(
3043
  lambda t: t.exception() if not t.cancelled() and not t.exception() is None else None
3044
  )
 
3513
  session_id: str = "") -> dict[str, Any]:
3514
  """Run the loop and close unexpected exceptions as a controlled FAILED state."""
3515
  previous_state = _ACTIVE_LOOP_STATE.get()
3516
+ previous_engineering_state = _ACTIVE_ENGINEERING_STATE.get()
3517
+ previous_engineering_mode = _ACTIVE_ENGINEERING_MODE.get()
3518
  try:
3519
  return await self._run_impl(goal, context, max_steps, on_step, session_id)
3520
  except Exception as _run_error:
 
3532
  state.errors.append(error_text)
3533
  previous = state.state_machine.current
3534
  if previous != AgentState.FAILED:
3535
+ try:
3536
+ await self._transition_state(state, AgentState.FAILED, on_step)
3537
+ except Exception as _state_transition_error:
3538
+ _logger.debug(
3539
+ "[unified_loop] failure transition silenced: %s",
3540
+ _state_transition_error,
3541
+ )
3542
+ await _flush_engineering_persist(_ACTIVE_ENGINEERING_STATE.get())
 
 
 
3543
  return {
3544
  "success": False,
3545
  "goal": state.goal,
 
3550
  }
3551
  finally:
3552
  _ACTIVE_LOOP_STATE.set(previous_state)
3553
+ _ACTIVE_ENGINEERING_STATE.set(previous_engineering_state)
3554
+ _ACTIVE_ENGINEERING_MODE.set(previous_engineering_mode)
3555
 
3556
  async def _run_impl(self, goal: str, context: str = "", max_steps: int = 8,
3557
  on_step: StepCallback | None = None,
 
3614
  max_steps = 12
3615
 
3616
  state = UnifiedLoopState(goal=goal, context=context, max_steps=max_steps, session_id=session_id)
3617
+
3618
+ # P1: EngineeringState is the recovery authority unless explicitly disabled.
3619
+ engineering_config = EngineeringStateConfig.from_env()
3620
+ _effective_mode = engineering_config.mode
3621
+ _ACTIVE_ENGINEERING_MODE.set(_effective_mode)
3622
+
3623
+ engineering_state: EngineeringState | None = None
3624
+ recovery_status = "disabled"
3625
+ if _effective_mode != EngineeringStateMode.OFF:
3626
+ engineering_state = EngineeringState.start(
3627
+ goal,
3628
+ run_id=self._run_task_id,
3629
+ session_id=session_id,
3630
+ checkpoint_id=session_id or self._run_task_id,
3631
+ )
3632
+ _ACTIVE_ENGINEERING_STATE.set(engineering_state)
3633
+ recovery_status = "started"
3634
+
3635
+ # RECOV-P1.1/P1.2: load and validate EngineeringState before the first transition.
3636
+ if _effective_mode.value in {"canary", "authoritative"} and engineering_state.checkpoint_id:
3637
+ try:
3638
+ from api.persistence import sb_get_checkpoint
3639
+ legacy_checkpoint = await sb_get_checkpoint(engineering_state.checkpoint_id)
3640
+ candidate = (legacy_checkpoint or {}).get("engineering_state")
3641
+ if candidate:
3642
+ restored = EngineeringState.from_snapshot(candidate)
3643
+ if restored.session_id != engineering_state.session_id or restored.goal_digest != engineering_state.goal_digest:
3644
+ engineering_state.diagnostic("restore conflict: identity mismatch")
3645
+ recovery_status = "conflict"
3646
+ elif _effective_mode == EngineeringStateMode.AUTHORITATIVE:
3647
+ engineering_state = restored
3648
+ engineering_state.prepare_for_resume()
3649
+ _ACTIVE_ENGINEERING_STATE.set(engineering_state)
3650
+ if legacy_checkpoint:
3651
+ checkpoint_steps = legacy_checkpoint.get("steps")
3652
+ checkpoint_errors = legacy_checkpoint.get("errors")
3653
+ state.steps = list(checkpoint_steps)[-64:] if isinstance(checkpoint_steps, list) else []
3654
+ state.errors = [str(item)[:512] for item in checkpoint_errors][-24:] if isinstance(checkpoint_errors, list) else []
3655
+ recovery_status = "restored"
3656
+ _logger.info("[P1-RECOVERY] authoritative checkpoint restored revision=%d", restored.revision)
3657
+ else:
3658
+ engineering_state.diagnostic("restore validated read-only")
3659
+ recovery_status = "validated"
3660
+ else:
3661
+ recovery_status = "checkpoint_missing"
3662
+ except Exception as restore_error:
3663
+ engineering_state.diagnostic(f"restore rejected: {type(restore_error).__name__}")
3664
+ recovery_status = "rejected"
3665
+ _logger.debug("[engineering-state] restore silenced: %s", type(restore_error).__name__)
3666
+
3667
  _ACTIVE_LOOP_STATE.set(state)
3668
  await self._transition_state(state, AgentState.CLASSIFYING, on_step)
3669
 
3670
  def _with_state(result: dict[str, Any]) -> dict[str, Any]:
3671
  result.update(state.state_machine.snapshot())
3672
+ if engineering_state is not None:
3673
+ result["engineering_state"] = engineering_state.projection()
3674
  return result
3675
 
3676
+ if engineering_state is not None and on_step is not None:
3677
+ try:
3678
+ await _maybe_await(on_step({
3679
+ "action": "engineering_state",
3680
+ "status": recovery_status,
3681
+ "mode": _effective_mode.value,
3682
+ "engineering_state": engineering_state.projection(),
3683
+ }))
3684
+ except Exception as recovery_event_error:
3685
+ _logger.debug("[engineering-state] recovery event silenced: %s", type(recovery_event_error).__name__)
3686
+
3687
  async def _finish(result: dict[str, Any]) -> dict[str, Any]:
3688
  next_state = AgentState.COMPLETED if result.get("success", True) else AgentState.FAILED
3689
+ try:
3690
+ await self._transition_state(state, next_state, on_step)
3691
+ finally:
3692
+ # P1 contract: persist the terminal state before returning to the caller.
3693
+ await _flush_engineering_persist(_ACTIVE_ENGINEERING_STATE.get())
3694
  return _with_state(result)
3695
 
3696
  # GAP-4: StrategicHealer — init + load past failures (LLM-based self-healing cognitivo)
agents/unified_loop_helpers.py CHANGED
@@ -27,7 +27,19 @@ import logging
27
  _logger = logging.getLogger("agents.unified_loop_helpers")
28
 
29
  # Import tipi condivisi — zero circular (unified_loop_types ha solo stdlib)
30
- from agents.unified_loop_types import StepCallback, UnifiedLoopState
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
 
33
  class HelpersMixin:
 
27
  _logger = logging.getLogger("agents.unified_loop_helpers")
28
 
29
  # Import tipi condivisi — zero circular (unified_loop_types ha solo stdlib)
30
+ from agents.unified_loop_types import (
31
+ StepCallback,
32
+ UnifiedLoopState,
33
+ _LANG_INSTRUCTIONS,
34
+ _detect_user_lang,
35
+ _maybe_await,
36
+ )
37
+
38
+
39
+ def _get_classifier():
40
+ """Load the error classifier lazily, avoiding import cycles."""
41
+ from agents.error_classifier import classify_error, format_for_context
42
+ return classify_error, format_for_context
43
 
44
 
45
  class HelpersMixin:
agents/unified_loop_llm.py CHANGED
@@ -65,7 +65,7 @@ class LLMSelectionMixin:
65
  return self._coder_llm
66
 
67
  def _get_fast_llm(self) -> Any:
68
- """S-FAST: return Role.FAST client (Groq llama-3.1-8b-instant) per query semplici.
69
  Caricato lazy e cachato in self._fast_llm — zero overhead dopo il primo accesso.
70
  Fallback silenzioso su self.llm se GROQ_API_KEY mancante o RoleRouter non disponibile."""
71
  if self._fast_llm is None:
@@ -284,11 +284,18 @@ class LLMSelectionMixin:
284
  _FORMAT_DIRECTIVE_CODE = (
285
  "FORMATO RISPOSTA OBBLIGATORIO — CODICE:\n"
286
  "• Usa SEMPRE blocchi markdown con linguaggio specificato (```python, ```typescript, ecc.)\n"
287
- "• Struttura: breve spiegazione → blocco codice completo → come usarlo\n"
288
- "• Ogni blocco deve essere autonomo ed eseguibile senza modifiche\n"
289
- "• Aggiungi commenti inline per la logica non ovvia\n"
290
- "• Se multi-file: mostra ogni file in un blocco separato con il nome come titolo\n"
291
- "• Formato titolo file OBBLIGATORIO: ### src/nomefile.tsx (H3 - risparmia spazio verticale su mobile)"
 
 
 
 
 
 
 
292
  )
293
  _FORMAT_DIRECTIVE_MARKDOWN = (
294
  "FORMATO RISPOSTA OBBLIGATORIO — STRUTTURATO:\n"
 
65
  return self._coder_llm
66
 
67
  def _get_fast_llm(self) -> Any:
68
+ """S-FAST: return Role.FAST client (Groq openai/gpt-oss-20b) per query semplici.
69
  Caricato lazy e cachato in self._fast_llm — zero overhead dopo il primo accesso.
70
  Fallback silenzioso su self.llm se GROQ_API_KEY mancante o RoleRouter non disponibile."""
71
  if self._fast_llm is None:
 
284
  _FORMAT_DIRECTIVE_CODE = (
285
  "FORMATO RISPOSTA OBBLIGATORIO — CODICE:\n"
286
  "• Usa SEMPRE blocchi markdown con linguaggio specificato (```python, ```typescript, ecc.)\n"
287
+ "• Per una richiesta di singolo snippet, emetti ESATTAMENTE un blocco nel linguaggio richiesto; "
288
+ "non sostituirlo con pseudocodice, analisi o un blocco generico.\n"
289
+ "• Il blocco deve contenere la soluzione completa, autonoma ed eseguibile senza modifiche; "
290
+ "mantieni gli export e la firma richiesti.\n"
291
+ "• Prima di rispondere applica il CONTROLLO FINALE: codice compilabile, nessun placeholder/TODO, "
292
+ "nessun simbolo non definito, tipi espliciti.\n"
293
+ "• Per codice async con handler indipendenti: includi `async`, `await` e `try/catch` oppure "
294
+ "`Promise.allSettled` per isolare ogni errore.\n"
295
+ "• Per correzioni React useEffect: preserva la struttura, usa AbortController o una guardia di annullamento "
296
+ "e restituisci sempre cleanup (`return () => ...`).\n"
297
+ "• Aggiungi commenti inline solo per la logica non ovvia. Se multi-file: mostra ogni file in un blocco separato "
298
+ "con il nome come titolo; formato titolo: ### src/nomefile.tsx."
299
  )
300
  _FORMAT_DIRECTIVE_MARKDOWN = (
301
  "FORMATO RISPOSTA OBBLIGATORIO — STRUTTURATO:\n"
agents/unified_loop_prompts.py CHANGED
@@ -47,8 +47,9 @@ class PromptBuilderMixin:
47
  "8b. Per domande a scelta multipla (A/B/C/D): inizia la risposta con "
48
  "\'Risposta: X\' dove X è la lettera scelta, poi spiega il ragionamento.\n"
49
  "8c. OBBLIGO TypeScript: ogni snippet di codice TypeScript DEVE essere in blocchi "
50
- "\'\'\'typescript\'\'\'...\'\'\'typescript. Mai inline, mai in blocchi generici. "
51
- "Il codice deve compilare: nessun placeholder, nessun TODO, tipi espliciti.\n"
 
52
  "9. Per decisioni architetturali: dai 3 opzioni con pro/contro e raccomandazione\n"
53
  "10. NON inventare mai informazioni su te stesso: token usati, context window, "
54
  "versione, architettura, parametri interni. Se non lo sai con certezza, "
@@ -305,7 +306,7 @@ class PromptBuilderMixin:
305
 
306
  # ── S200: Context-aware rule injection ──────────────────────────────────────
307
  # Seleziona solo le regole rilevanti per il task corrente.
308
- # Con llama-3.1-8b-instant (8K context), mettere tutto nel system prompt
309
  # causa troncamento silenzioso — le regole non vengono mai lette.
310
  # Soluzione: iniettare 2-4 regole contestuali ALLA FINE del user message
311
  # (posizione con massima attenzione del modello = "recency bias").
@@ -440,7 +441,8 @@ class PromptBuilderMixin:
440
  "VIETATO cambiare il comportamento delle parti non menzionate. "
441
  "Approccio corretto: (1) identifica esattamente cosa e' rotto, "
442
  "(2) scrivi SOLO il diff minimo necessario, "
443
- "(3) verifica che il resto del codice rimanga invariato. "
 
444
  "Usa apply_patch invece di write_file per qualsiasi modifica < 50% del file. "
445
  "NON riscrivere funzioni, classi o moduli interi — applica il fix minimo."
446
  ),
@@ -1828,3 +1830,4 @@ _CONTEXT_RULES_ADVANCED = [
1828
  ]
1829
 
1830
 
 
 
47
  "8b. Per domande a scelta multipla (A/B/C/D): inizia la risposta con "
48
  "\'Risposta: X\' dove X è la lettera scelta, poi spiega il ragionamento.\n"
49
  "8c. OBBLIGO TypeScript: ogni snippet di codice TypeScript DEVE essere in blocchi "
50
+ "```typescript```...```typescript. Mai inline, mai in blocchi generici. "
51
+ "Il codice deve compilare: nessun placeholder, nessun TODO, tipi espliciti. In caso di REFACTORING: sostituisci SEMPRE nomi di variabili a lettera singola (p, m, v) con nomi semantici e descrittivi, e usa interfacce o tipi per ogni oggetto complesso.\n"
52
+ "8d. REASONING: Per problemi complessi, scomponi il problema in sotto-task logici. Verifica la coerenza dei risultati intermedi prima di procedere al calcolo finale.\n"
53
  "9. Per decisioni architetturali: dai 3 opzioni con pro/contro e raccomandazione\n"
54
  "10. NON inventare mai informazioni su te stesso: token usati, context window, "
55
  "versione, architettura, parametri interni. Se non lo sai con certezza, "
 
306
 
307
  # ── S200: Context-aware rule injection ──────────────────────────────────────
308
  # Seleziona solo le regole rilevanti per il task corrente.
309
+ # Con openai/gpt-oss-20b (8K context), mettere tutto nel system prompt
310
  # causa troncamento silenzioso — le regole non vengono mai lette.
311
  # Soluzione: iniettare 2-4 regole contestuali ALLA FINE del user message
312
  # (posizione con massima attenzione del modello = "recency bias").
 
441
  "VIETATO cambiare il comportamento delle parti non menzionate. "
442
  "Approccio corretto: (1) identifica esattamente cosa e' rotto, "
443
  "(2) scrivi SOLO il diff minimo necessario, "
444
+ "(3) preserva import/export, API pubbliche e side effect non coinvolti, "
445
+ "(4) verifica che il resto del codice rimanga invariato. "
446
  "Usa apply_patch invece di write_file per qualsiasi modifica < 50% del file. "
447
  "NON riscrivere funzioni, classi o moduli interi — applica il fix minimo."
448
  ),
 
1830
  ]
1831
 
1832
 
1833
+
agents/unified_loop_tools.py CHANGED
@@ -538,10 +538,50 @@ class DirectToolsMixin:
538
  elif ": errore" in r_str or ": timeout" in r_str:
539
  n_errors += 1
540
  return ("\n\n".join(results), n_called, n_success, n_errors)
541
- def _validate_claims(self, response: str, results_str: str) -> bool:
542
- """S428: Anti-hallucination layer. Valida i claim della risposta contro i dati reali."""
543
- if not results_str or "[ERRORE]" in results_str: return True
544
- return True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
545
  _TOOL_NEEDED_RE = re.compile(
546
  r"\b(meteo|temperatura|weather|forecast|cerca|search|trova|find|googla|google|"
547
  r"immagine|foto|photo|image|disegna|draw|genera|create|calcola|calculate|math|"
@@ -550,10 +590,14 @@ class DirectToolsMixin:
550
  re.IGNORECASE,
551
  )
552
  def _needs_tools(self, goal: str) -> bool:
553
- if len(goal) > 100: return True
 
554
  if bool(self._TOOL_NEEDED_RE.search(goal)): return True
555
- tech_keywords = ['file', 'directory', 'folder', 'script', 'api', 'json', 'data', 'analisi', 'fix', 'bug']
 
556
  if any(kw in goal.lower() for kw in tech_keywords): return True
 
 
557
  return False
558
  _SIMPLE_CONV_RE = re.compile(
559
  r"^(?:ciao|salve|hey\b|hi\b|hello\b|buongiorno|buonasera|buonanotte|"
 
538
  elif ": errore" in r_str or ": timeout" in r_str:
539
  n_errors += 1
540
  return ("\n\n".join(results), n_called, n_success, n_errors)
541
+ # ── Claim Validation (S428 Sprint1-Fix3) ─────────────────────────────────
542
+ # A failed live tool must never be represented as a successful live lookup.
543
+ _FALSE_CLAIM_RE = re.compile(
544
+ r"\b(ho\s+trovato(?:\s+che)?|ho\s+recuperato|ho\s+cercato\s+e\s+trovato|"
545
+ r"dai\s+risultati(?:\s+della\s+ricerca)?|stando\s+ai\s+risultati|"
546
+ r"i\s+risultati\s+(?:mostrano|indicano|confermano)|"
547
+ r"la\s+ricerca\s+ha\s+(?:trovato|restituito)|"
548
+ r"secondo\s+i\s+risultati|dalle\s+mie\s+ricerche|"
549
+ r"I\s+found|the\s+results?\s+show|based\s+on\s+(?:the\s+)?results?|"
550
+ r"according\s+to\s+(?:the\s+)?(?:search\s+)?results?)\b",
551
+ re.IGNORECASE,
552
+ )
553
+ _REALTIME_GOAL_RE = re.compile(
554
+ r"\b(notizie|news|ultime\s+notizie|cerca|ricerca\s+web|"
555
+ r"weather|meteo|previsioni|temperatura|"
556
+ r"bitcoin|ethereum|cambio\s+valuta|tasso|crypto|"
557
+ r"versione\s+(?:attuale|corrente|recente)|aggiornamenti\s+su|release)\b",
558
+ re.IGNORECASE,
559
+ )
560
+
561
+ @staticmethod
562
+ def _validate_claims(
563
+ response: str,
564
+ n_success: int,
565
+ n_errors: int,
566
+ goal: str,
567
+ false_claim_re: "re.Pattern[str]",
568
+ realtime_goal_re: "re.Pattern[str]",
569
+ ) -> str:
570
+ """Add transparency when failed live tools are presented as successful."""
571
+ if n_success > 0 or n_errors == 0:
572
+ return response
573
+ if not realtime_goal_re.search(goal):
574
+ return response
575
+ if not false_claim_re.search(response):
576
+ return response
577
+ disclaimer = (
578
+ "\n\n---\n"
579
+ "**Nota tecnica**: i servizi di ricerca in tempo reale non erano "
580
+ "raggiungibili durante questa risposta. Le informazioni sopra provengono "
581
+ "dal mio training e potrebbero non essere aggiornate. "
582
+ "Per dati live consulta una fonte ufficiale."
583
+ )
584
+ return response + disclaimer
585
  _TOOL_NEEDED_RE = re.compile(
586
  r"\b(meteo|temperatura|weather|forecast|cerca|search|trova|find|googla|google|"
587
  r"immagine|foto|photo|image|disegna|draw|genera|create|calcola|calculate|math|"
 
590
  re.IGNORECASE,
591
  )
592
  def _needs_tools(self, goal: str) -> bool:
593
+ # S-BENCH-FIX: abbassata soglia a 50 per catturare task di benchmark complessi
594
+ if len(goal) > 50: return True
595
  if bool(self._TOOL_NEEDED_RE.search(goal)): return True
596
+ # Aggiunto 'benchmark', 'test', 'codice' per forzare tool su task tecnici
597
+ tech_keywords = ['file', 'directory', 'folder', 'script', 'api', 'json', 'data', 'analisi', 'fix', 'bug', 'benchmark', 'test', 'codice']
598
  if any(kw in goal.lower() for kw in tech_keywords): return True
599
+ # Se sembra un goal di codice, attiva i tool
600
+ if bool(self._CODE_GOAL_RE.search(goal)): return True
601
  return False
602
  _SIMPLE_CONV_RE = re.compile(
603
  r"^(?:ciao|salve|hey\b|hi\b|hello\b|buongiorno|buonasera|buonanotte|"
api/admin_state.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stato operativo amministrativo protetto da JWT Supabase admin."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime, timedelta, timezone
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, Depends, Query
8
+
9
+ from .auth_guard import require_admin_user
10
+ from .private_state import _MAX_TASK_PAGE, _as_epoch_ms, _call, _json_object
11
+
12
+ router = APIRouter(
13
+ prefix="/api/admin/state",
14
+ tags=["admin"],
15
+ dependencies=[Depends(require_admin_user)],
16
+ )
17
+
18
+
19
+ @router.get("/sessions")
20
+ async def admin_sessions(
21
+ max_age_ms: int = Query(default=300_000, ge=10_000, le=3_600_000),
22
+ limit: int = Query(default=100, ge=1, le=200),
23
+ ) -> dict[str, object]:
24
+ cutoff = (datetime.now(timezone.utc) - timedelta(milliseconds=max_age_ms)).isoformat()
25
+
26
+ def operation(client: Any):
27
+ return client.table("agent_tasks").select("task_id,context,updated_at").eq("status", "__session__").gte("updated_at", cutoff).order("updated_at", desc=True).limit(limit).execute()
28
+
29
+ result = await _call(operation)
30
+ sessions = []
31
+ for row in result.data or []:
32
+ context = _json_object(row.get("context"))
33
+ session_id = str(context.get("sessionId") or row.get("task_id") or "").strip()
34
+ if not session_id:
35
+ continue
36
+ claimed = context.get("claimedFiles")
37
+ sessions.append({
38
+ "session_id": session_id,
39
+ "session_name": str(context.get("sessionName") or session_id)[:160],
40
+ "sprint": str(context["sprint"])[:120] if context.get("sprint") else None,
41
+ "claimed_files": [str(item)[:300] for item in claimed[:100]] if isinstance(claimed, list) else [],
42
+ "last_heartbeat": _as_epoch_ms(context.get("lastHeartbeat")) or _as_epoch_ms(row.get("updated_at")),
43
+ "current_task": str(context["currentTask"])[:500] if context.get("currentTask") else None,
44
+ })
45
+ return {"sessions": sessions}
46
+
47
+
48
+ @router.get("/tasks")
49
+ async def admin_tasks(
50
+ limit: int = Query(default=20, ge=1, le=_MAX_TASK_PAGE),
51
+ offset: int = Query(default=0, ge=0, le=10_000),
52
+ status: str | None = Query(default=None, max_length=64),
53
+ ) -> dict[str, object]:
54
+ normalized_status = status.strip().upper() if status else ""
55
+
56
+ def operation(client: Any):
57
+ query = client.table("agent_tasks").select("task_id,goal,status,updated_at").neq("status", "__session__").neq("status", "__config__")
58
+ if normalized_status:
59
+ query = query.eq("status", normalized_status)
60
+ page = query.order("updated_at", desc=True).range(offset, offset + limit - 1).execute()
61
+ all_statuses = client.table("agent_tasks").select("status").neq("status", "__session__").neq("status", "__config__").limit(2_000).execute()
62
+ return page, all_statuses
63
+
64
+ page, all_statuses = await _call(operation)
65
+ counts: dict[str, int] = {}
66
+ for row in all_statuses.data or []:
67
+ key = str(row.get("status") or "UNKNOWN").upper()
68
+ counts[key] = counts.get(key, 0) + 1
69
+ tasks = [{
70
+ "task_id": str(row.get("task_id") or ""),
71
+ "goal": str(row.get("goal") or "")[:1_000],
72
+ "status": str(row.get("status") or "UNKNOWN"),
73
+ "updated_at": _as_epoch_ms(row.get("updated_at")),
74
+ } for row in page.data or []]
75
+ return {"tasks": tasks, "counts": counts, "offset": offset, "limit": limit}
api/agent.py CHANGED
@@ -522,12 +522,12 @@ async def agent_kernel_dispatch(body: AgentKernelDispatchIn, role: AuthRole = De
522
  'goal': goal,
523
  'mode': mode,
524
  'dispatch_id': _dispatch_id,
 
525
  },
526
  priority='HIGH',
527
- metadata={'workflow': 'agent-kernel.yml'},
528
  )).add_done_callback(_log_task_exc)
529
  asyncio.create_task(_kernel.publish_event(
530
- event_type='agent.kernel.dispatched',
531
  payload={'goal': goal[:200], 'mode': mode},
532
  )).add_done_callback(_log_task_exc)
533
  import httpx as _httpx
@@ -579,10 +579,10 @@ async def _create_task_internal(task_id: str, goal: str, job: dict) -> dict:
579
  "goal": goal,
580
  "max_steps": job.get("max_steps", 20),
581
  "source": "job_queue",
 
582
  },
583
  priority="NORMAL",
584
  session_id=job.get("session_id", ""),
585
- metadata={"job_queue": True},
586
  )).add_done_callback(_log_task_exc)
587
  return {"taskId": task_id, "status": "QUEUED"}
588
 
@@ -649,13 +649,13 @@ async def create_agent_task(body: AgentTaskIn, role: AuthRole = Depends(require_
649
  'max_steps': body.max_steps,
650
  'persona': body.persona,
651
  'source': 'agent_api',
 
652
  },
653
  priority='NORMAL',
654
  session_id=body.session_id,
655
- metadata={'agent_api': True},
656
  )).add_done_callback(_log_task_exc)
657
  asyncio.create_task(_kernel.publish_event(
658
- event_type='task.created',
659
  payload={'task_id': task_id, 'goal': body.goal[:200], 'status': 'QUEUED'},
660
  )).add_done_callback(_log_task_exc)
661
  return {'taskId': task_id, 'status': 'QUEUED'}
@@ -939,7 +939,7 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
939
  # ARCH-K2.2: pubblica lifecycle event via Kernel
940
  if _KERNEL_AVAILABLE and _kernel is not None:
941
  asyncio.create_task(_kernel.publish_event(
942
- event_type='task.running',
943
  payload={'task_id': task_id, 'status': 'RUNNING'},
944
  )).add_done_callback(_log_task_exc)
945
  _prune_agent_tasks()
@@ -1067,6 +1067,15 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
1067
  if _action == 'text_chunk':
1068
  _sse('text_chunk', {'taskId': task_id, 'token': _ss(step_data.get('token', ''))})
1069
  return
 
 
 
 
 
 
 
 
 
1070
 
1071
  # S363-Blueprint: Narrative Streaming — explanation lookup for ALL step_done events
1072
  # S376: _STEP_NARRATIONS espanso — aggiunge 12 tool mancanti
@@ -1286,7 +1295,7 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
1286
  # ARCH-K2.2: pubblica lifecycle event via Kernel
1287
  if _KERNEL_AVAILABLE and _kernel is not None:
1288
  asyncio.create_task(_kernel.publish_event(
1289
- event_type='task.completed',
1290
  payload={'task_id': task_id, 'status': 'SUCCESS'},
1291
  )).add_done_callback(_log_task_exc)
1292
  _result_text = str(result.get('output', result) if isinstance(result, dict) else result)
@@ -1309,7 +1318,7 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
1309
  # ARCH-K2.2: pubblica lifecycle event via Kernel
1310
  if _KERNEL_AVAILABLE and _kernel is not None:
1311
  asyncio.create_task(_kernel.publish_event(
1312
- event_type='task.cancelled',
1313
  payload={'task_id': task_id, 'status': 'CANCELLED'},
1314
  )).add_done_callback(_log_task_exc)
1315
  _sse('task_cancelled', {'taskId': task_id})
@@ -1330,7 +1339,7 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
1330
  # ARCH-K2.2: pubblica lifecycle event via Kernel
1331
  if _KERNEL_AVAILABLE and _kernel is not None:
1332
  asyncio.create_task(_kernel.publish_event(
1333
- event_type='task.failed',
1334
  payload={'task_id': task_id, 'status': 'ERROR', 'error': str(err)[:500]},
1335
  )).add_done_callback(_log_task_exc)
1336
  _logger.error('[agent/stream] %s error: %s', task_id, err, exc_info=True)
@@ -1409,7 +1418,7 @@ async def save_checkpoint(task_id: str, body: CheckpointIn, role: AuthRole = Dep
1409
  'extra': body.extra,
1410
  'savedAt': int(time.time() * 1000),
1411
  }
1412
- asyncio.create_task(sb_save_checkpoint(task_id, _task_checkpoints[task_id])).add_done_callback(_log_task_exc)
1413
  return {'saved': True, 'taskId': task_id, 'step': body.step}
1414
 
1415
 
 
522
  'goal': goal,
523
  'mode': mode,
524
  'dispatch_id': _dispatch_id,
525
+ 'metadata': {'workflow': 'agent-kernel.yml'},
526
  },
527
  priority='HIGH',
 
528
  )).add_done_callback(_log_task_exc)
529
  asyncio.create_task(_kernel.publish_event(
530
+ topic='agent.kernel.dispatched',
531
  payload={'goal': goal[:200], 'mode': mode},
532
  )).add_done_callback(_log_task_exc)
533
  import httpx as _httpx
 
579
  "goal": goal,
580
  "max_steps": job.get("max_steps", 20),
581
  "source": "job_queue",
582
+ "metadata": {"job_queue": True},
583
  },
584
  priority="NORMAL",
585
  session_id=job.get("session_id", ""),
 
586
  )).add_done_callback(_log_task_exc)
587
  return {"taskId": task_id, "status": "QUEUED"}
588
 
 
649
  'max_steps': body.max_steps,
650
  'persona': body.persona,
651
  'source': 'agent_api',
652
+ 'metadata': {'agent_api': True},
653
  },
654
  priority='NORMAL',
655
  session_id=body.session_id,
 
656
  )).add_done_callback(_log_task_exc)
657
  asyncio.create_task(_kernel.publish_event(
658
+ topic='task.created',
659
  payload={'task_id': task_id, 'goal': body.goal[:200], 'status': 'QUEUED'},
660
  )).add_done_callback(_log_task_exc)
661
  return {'taskId': task_id, 'status': 'QUEUED'}
 
939
  # ARCH-K2.2: pubblica lifecycle event via Kernel
940
  if _KERNEL_AVAILABLE and _kernel is not None:
941
  asyncio.create_task(_kernel.publish_event(
942
+ topic='task.running',
943
  payload={'task_id': task_id, 'status': 'RUNNING'},
944
  )).add_done_callback(_log_task_exc)
945
  _prune_agent_tasks()
 
1067
  if _action == 'text_chunk':
1068
  _sse('text_chunk', {'taskId': task_id, 'token': _ss(step_data.get('token', ''))})
1069
  return
1070
+ # RECOV-P1: engineering_state event — forward projection to frontend
1071
+ if _action == 'engineering_state':
1072
+ _sse('engineering_state', {
1073
+ 'taskId': task_id,
1074
+ 'status': step_data.get('status'),
1075
+ 'mode': step_data.get('mode'),
1076
+ 'engineering_state': step_data.get('engineering_state'),
1077
+ })
1078
+ return
1079
 
1080
  # S363-Blueprint: Narrative Streaming — explanation lookup for ALL step_done events
1081
  # S376: _STEP_NARRATIONS espanso — aggiunge 12 tool mancanti
 
1295
  # ARCH-K2.2: pubblica lifecycle event via Kernel
1296
  if _KERNEL_AVAILABLE and _kernel is not None:
1297
  asyncio.create_task(_kernel.publish_event(
1298
+ topic='task.completed',
1299
  payload={'task_id': task_id, 'status': 'SUCCESS'},
1300
  )).add_done_callback(_log_task_exc)
1301
  _result_text = str(result.get('output', result) if isinstance(result, dict) else result)
 
1318
  # ARCH-K2.2: pubblica lifecycle event via Kernel
1319
  if _KERNEL_AVAILABLE and _kernel is not None:
1320
  asyncio.create_task(_kernel.publish_event(
1321
+ topic='task.cancelled',
1322
  payload={'task_id': task_id, 'status': 'CANCELLED'},
1323
  )).add_done_callback(_log_task_exc)
1324
  _sse('task_cancelled', {'taskId': task_id})
 
1339
  # ARCH-K2.2: pubblica lifecycle event via Kernel
1340
  if _KERNEL_AVAILABLE and _kernel is not None:
1341
  asyncio.create_task(_kernel.publish_event(
1342
+ topic='task.failed',
1343
  payload={'task_id': task_id, 'status': 'ERROR', 'error': str(err)[:500]},
1344
  )).add_done_callback(_log_task_exc)
1345
  _logger.error('[agent/stream] %s error: %s', task_id, err, exc_info=True)
 
1418
  'extra': body.extra,
1419
  'savedAt': int(time.time() * 1000),
1420
  }
1421
+ asyncio.create_task(sb_save_checkpoint(task_id, body.step, _task_checkpoints[task_id])).add_done_callback(_log_task_exc)
1422
  return {'saved': True, 'taskId': task_id, 'step': body.step}
1423
 
1424
 
api/agent_checkpoint.py CHANGED
@@ -95,7 +95,7 @@ async def save_checkpoint_alias(body: CheckpointBody):
95
  }
96
  _task_checkpoints[task_id] = cp
97
  # Persist su Supabase — fire-and-forget (stesso pattern di agent.py)
98
- asyncio.create_task(sb_save_checkpoint(task_id, cp))
99
  return {"saved": True, "taskId": task_id, "step": body.step}
100
 
101
 
 
95
  }
96
  _task_checkpoints[task_id] = cp
97
  # Persist su Supabase — fire-and-forget (stesso pattern di agent.py)
98
+ asyncio.create_task(sb_save_checkpoint(task_id, body.step, cp))
99
  return {"saved": True, "taskId": task_id, "step": body.step}
100
 
101
 
api/agent_memory.py CHANGED
@@ -4,6 +4,7 @@ GAP-MEM-FIX: aggiunta riconciliazione _mem_fallback → Supabase.
4
  GAP-SENSITIVE-FIX: implementato masking per le chiavi definite in SENSITIVE.
5
  """
6
  import time, asyncio
 
7
  from fastapi import APIRouter, Depends
8
  from .auth_guard import require_role, AuthRole
9
  from pydantic import BaseModel
 
4
  GAP-SENSITIVE-FIX: implementato masking per le chiavi definite in SENSITIVE.
5
  """
6
  import time, asyncio
7
+ from typing import Any
8
  from fastapi import APIRouter, Depends
9
  from .auth_guard import require_role, AuthRole
10
  from pydantic import BaseModel
api/auth_guard.py CHANGED
@@ -33,7 +33,7 @@ from __future__ import annotations
33
  import logging
34
  import os
35
  from enum import IntEnum
36
- from typing import Optional
37
 
38
  from fastapi import Depends, Header, HTTPException, Request
39
 
@@ -96,6 +96,30 @@ _RATE_LIMITS: dict[int, int] = {
96
  _RATE_WINDOW_S = 60 # finestra sliding 60s
97
  _rate_store: dict[str, _col.deque] = {} # token_hash → deque di timestamps
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
 
100
  def _rate_key(role: int, token_header: str | None, client_ip: str | None = None) -> str:
101
  """Chiave rate limiter: hash(role + discriminante) — non espone token né IP in chiaro.
@@ -123,6 +147,7 @@ def _inmem_rate_check(key: str, limit: int, window_s: float) -> tuple[bool, int]
123
  """
124
  now = _rl_time.monotonic()
125
  window_start = now - window_s
 
126
 
127
  if key not in _rate_store:
128
  _rate_store[key] = _col.deque()
@@ -162,6 +187,66 @@ def _check_rate_limit(
162
  return _inmem_rate_check(key, limit, int(_RATE_WINDOW_S))
163
 
164
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  class AuthRole(IntEnum):
166
  """Gerarchia ruoli: USER < MACHINE < OPERATOR < ADMIN."""
167
  USER = 0
@@ -176,6 +261,7 @@ def _get_token(env_var: str) -> str:
176
 
177
  async def _resolve_role(
178
  x_internal_token: Optional[str] = Header(None, alias="X-Internal-Token"),
 
179
  x_operator_token: Optional[str] = Header(None, alias="X-Operator-Token"),
180
  x_admin_token: Optional[str] = Header(None, alias="X-Admin-Token"),
181
  ) -> AuthRole:
@@ -193,9 +279,11 @@ async def _resolve_role(
193
  logger.debug("auth: OPERATOR role granted")
194
  return AuthRole.OPERATOR
195
 
196
- # MACHINE (INTERNAL_TOKEN, già generato al boot da main.py)
197
- int_tok = _get_token("INTERNAL_TOKEN")
198
- if int_tok and x_internal_token and _sec_comp.compare_digest(x_internal_token, int_tok):
 
 
199
  logger.debug("auth: MACHINE role granted")
200
  return AuthRole.MACHINE
201
 
@@ -203,6 +291,38 @@ async def _resolve_role(
203
  return AuthRole.USER
204
 
205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  def require_role(min_role: AuthRole):
207
  """
208
  FastAPI Depends factory per autorizzazione granulare.
@@ -223,7 +343,8 @@ def require_role(min_role: AuthRole):
223
  _token_hdr = (
224
  request.headers.get('X-Admin-Token') or
225
  request.headers.get('X-Operator-Token') or
226
- request.headers.get('X-Internal-Token')
 
227
  )
228
  # GAP-AUTH-FIX: estrai IP reale (Railway/HF dietro proxy → X-Forwarded-For)
229
  _client_ip: str | None = (
 
33
  import logging
34
  import os
35
  from enum import IntEnum
36
+ from typing import Optional, Any
37
 
38
  from fastapi import Depends, Header, HTTPException, Request
39
 
 
96
  _RATE_WINDOW_S = 60 # finestra sliding 60s
97
  _rate_store: dict[str, _col.deque] = {} # token_hash → deque di timestamps
98
 
99
+ # Lo store è usato anche quando Redis non è disponibile. Un client una tantum
100
+ # lasciava una deque vuota nel dict per l'intera vita del processo. Eseguiamo uno
101
+ # sweep ammortizzato: il lavoro resta O(1) per la quasi totalità delle richieste
102
+ # e il numero di chiavi inattive rimane limitato al traffico tra due sweep.
103
+ _RATE_STORE_SWEEP_EVERY = 128
104
+ _rate_store_checks = 0
105
+
106
+
107
+ def _prune_expired_rate_keys(now: float, window_s: float) -> None:
108
+ """Rimuove bucket in-memory senza timestamp ancora nella finestra corrente."""
109
+ global _rate_store_checks
110
+ _rate_store_checks += 1
111
+ if _rate_store_checks % _RATE_STORE_SWEEP_EVERY:
112
+ return
113
+
114
+ window_start = now - window_s
115
+ stale_keys = [
116
+ stored_key
117
+ for stored_key, timestamps in _rate_store.items()
118
+ if not timestamps or timestamps[-1] < window_start
119
+ ]
120
+ for stored_key in stale_keys:
121
+ _rate_store.pop(stored_key, None)
122
+
123
 
124
  def _rate_key(role: int, token_header: str | None, client_ip: str | None = None) -> str:
125
  """Chiave rate limiter: hash(role + discriminante) — non espone token né IP in chiaro.
 
147
  """
148
  now = _rl_time.monotonic()
149
  window_start = now - window_s
150
+ _prune_expired_rate_keys(now, window_s)
151
 
152
  if key not in _rate_store:
153
  _rate_store[key] = _col.deque()
 
187
  return _inmem_rate_check(key, limit, int(_RATE_WINDOW_S))
188
 
189
 
190
+ async def require_supabase_user(request: Request) -> dict[str, Any]:
191
+ """Valida il Bearer JWT tramite Supabase Auth e restituisce il profilo minimo.
192
+
193
+ La chiave Supabase resta server-side; il JWT arriva esclusivamente nell'header
194
+ Authorization del chiamante e non viene scritto nei log.
195
+ """
196
+ import httpx
197
+
198
+ authorization = request.headers.get("Authorization", "")
199
+ if not authorization.lower().startswith("bearer "):
200
+ raise HTTPException(status_code=401, detail="Bearer token richiesto")
201
+ jwt = authorization[7:].strip()
202
+ if not jwt:
203
+ raise HTTPException(status_code=401, detail="Bearer token non valido")
204
+
205
+ supabase_url = os.getenv("SUPABASE_URL", "").rstrip("/")
206
+ api_key = os.getenv("SUPABASE_SERVICE_ROLE_KEY") or os.getenv("SUPABASE_KEY", "")
207
+ if not supabase_url or not api_key:
208
+ raise HTTPException(status_code=503, detail="Autenticazione Supabase non configurata")
209
+
210
+ try:
211
+ async with httpx.AsyncClient(timeout=5) as client:
212
+ response = await client.get(
213
+ f"{supabase_url}/auth/v1/user",
214
+ headers={
215
+ "apikey": api_key,
216
+ "Authorization": f"Bearer {jwt}",
217
+ "Accept": "application/json",
218
+ },
219
+ )
220
+ except httpx.HTTPError as exc:
221
+ logger.warning("supabase user validation unavailable: %s", type(exc).__name__)
222
+ raise HTTPException(status_code=503, detail="Autenticazione temporaneamente non disponibile") from exc
223
+
224
+ if response.status_code != 200:
225
+ raise HTTPException(status_code=401, detail="Sessione Supabase non valida o scaduta")
226
+ try:
227
+ user = response.json()
228
+ except ValueError as exc:
229
+ raise HTTPException(status_code=401, detail="Risposta autenticazione non valida") from exc
230
+ if not isinstance(user, dict) or not user.get("id"):
231
+ raise HTTPException(status_code=401, detail="Utente Supabase non valido")
232
+ return user
233
+
234
+
235
+ async def require_admin_user(request: Request) -> dict[str, Any]:
236
+ """Richiede un JWT Supabase con app_metadata.role=admin.
237
+
238
+ app_metadata è server-controlled; user_metadata non viene mai considerato
239
+ per autorizzare l’area amministrativa.
240
+ """
241
+ user = await require_supabase_user(request)
242
+ app_metadata = user.get("app_metadata") or {}
243
+ roles = app_metadata.get("roles") or []
244
+ is_admin = app_metadata.get("role") == "admin" or "admin" in roles
245
+ if not is_admin:
246
+ raise HTTPException(status_code=403, detail="Membership amministrativa richiesta")
247
+ return user
248
+
249
+
250
  class AuthRole(IntEnum):
251
  """Gerarchia ruoli: USER < MACHINE < OPERATOR < ADMIN."""
252
  USER = 0
 
261
 
262
  async def _resolve_role(
263
  x_internal_token: Optional[str] = Header(None, alias="X-Internal-Token"),
264
+ x_machine_token: Optional[str] = Header(None, alias="X-Machine-Token"),
265
  x_operator_token: Optional[str] = Header(None, alias="X-Operator-Token"),
266
  x_admin_token: Optional[str] = Header(None, alias="X-Admin-Token"),
267
  ) -> AuthRole:
 
279
  logger.debug("auth: OPERATOR role granted")
280
  return AuthRole.OPERATOR
281
 
282
+ # MACHINE: supporta entrambi gli header per compatibilità tra runner e backend.
283
+ # Il valore resta confrontato esclusivamente con il secret server-side.
284
+ int_tok = _get_token("INTERNAL_TOKEN") or _get_token("MACHINE_TOKEN")
285
+ machine_header = x_internal_token or x_machine_token
286
+ if int_tok and machine_header and _sec_comp.compare_digest(machine_header, int_tok):
287
  logger.debug("auth: MACHINE role granted")
288
  return AuthRole.MACHINE
289
 
 
291
  return AuthRole.USER
292
 
293
 
294
+ async def require_private_state_machine(
295
+ request: 'Request',
296
+ x_internal_token: Optional[str] = Header(None, alias="X-Internal-Token"),
297
+ ) -> AuthRole:
298
+ """Autorizza esclusivamente il proxy Pages dello stato privato.
299
+
300
+ Usa un token dedicato per non ruotare o esporre ``INTERNAL_TOKEN``, da cui
301
+ dipendono le integrazioni legacy del master B. Il token non conferisce un
302
+ ruolo più ampio del canale MACHINE e resta soggetto allo stesso rate limit.
303
+ """
304
+ import secrets as _sec_comp
305
+ private_token = _get_token("PRIVATE_STATE_INTERNAL_TOKEN")
306
+ if not private_token:
307
+ raise HTTPException(status_code=503, detail="Canale stato privato non configurato")
308
+ if not x_internal_token or not _sec_comp.compare_digest(x_internal_token, private_token):
309
+ raise HTTPException(status_code=403, detail="Permessi insufficienti per lo stato privato")
310
+
311
+ client_ip = (
312
+ request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
313
+ or request.headers.get('X-Real-IP', '')
314
+ or (request.client.host if request.client else None)
315
+ ) or None
316
+ allowed, retry_after = _check_rate_limit(int(AuthRole.MACHINE), x_internal_token, client_ip)
317
+ if not allowed:
318
+ raise HTTPException(
319
+ status_code=429,
320
+ detail="Rate limit stato privato superato",
321
+ headers={'Retry-After': str(retry_after)},
322
+ )
323
+ return AuthRole.MACHINE
324
+
325
+
326
  def require_role(min_role: AuthRole):
327
  """
328
  FastAPI Depends factory per autorizzazione granulare.
 
343
  _token_hdr = (
344
  request.headers.get('X-Admin-Token') or
345
  request.headers.get('X-Operator-Token') or
346
+ request.headers.get('X-Internal-Token') or
347
+ request.headers.get('X-Machine-Token')
348
  )
349
  # GAP-AUTH-FIX: estrai IP reale (Railway/HF dietro proxy → X-Forwarded-For)
350
  _client_ip: str | None = (
api/benchmark.py CHANGED
@@ -373,7 +373,7 @@ async def run_benchmark(
373
  #
374
  # Per ogni categoria agente (DA / ORCH / MC / REC):
375
  # 1. Inietta la context rule via UnifiedLoopPrompts._pick_context_rules()
376
- # 2. Chiama il LLM (ARCHITECT = llama-3.3-70b-versatile) a temperatura 0.3
377
  # 3. Valuta la risposta con checker regex (stessa logica di benchmark-extended.mjs)
378
  # 4. Produce score 0-100 per categoria + media totale
379
  #
 
373
  #
374
  # Per ogni categoria agente (DA / ORCH / MC / REC):
375
  # 1. Inietta la context rule via UnifiedLoopPrompts._pick_context_rules()
376
+ # 2. Chiama il LLM (ARCHITECT = openai/gpt-oss-120b) a temperatura 0.3
377
  # 3. Valuta la risposta con checker regex (stessa logica di benchmark-extended.mjs)
378
  # 4. Produce score 0-100 per categoria + media totale
379
  #
api/benchmark_handler.py CHANGED
@@ -20,64 +20,89 @@ from typing import Any
20
  logger = logging.getLogger("agente_ai.benchmark_handler")
21
 
22
  # ── Percorsi server Railway ────────────────────────────────────────────────────
23
- _REPO_ROOT = os.getenv("REPO_ROOT", "/home/ubuntu/Baida98_AI")
24
-
25
- # v7 (GAP-BENCH-2)
26
- _BENCH_SCRIPT = os.path.join(_REPO_ROOT, "scripts", "benchmark-extended.mjs")
27
- _REPORT_V7 = "/tmp/agente-ai/benchmark-v7-latest.json"
28
- _BENCH_TIMEOUT = float(os.getenv("BENCH_TIMEOUT_SECS", "720")) # 12 min (era 360s)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  # v6.2 — usato come fallback in get_smart_summary per compatibilità
31
  _REPORT_V6 = os.path.join(_REPO_ROOT, "benchmark-stress-report.json")
32
 
33
 
34
- async def run_benchmark_task(chat_id: int, send_reply_fn) -> None:
35
- """Esegue benchmark-extended.mjs v7 con --json e invia risultati via Telegram.
 
 
 
 
36
 
37
- Flag --json scrive /tmp/agente-ai/benchmark-v7-latest.json.
38
- Variabili env richieste (Railway): GROQ_API_KEY, INTERNAL_TOKEN.
39
- """
40
- await send_reply_fn(
41
- chat_id,
42
- "🚀 <b>Avvio Benchmark Extended v7…</b>\n"
43
- "<i>10+ categorie · HF datasets · ref vs Replit/Cursor/Devin/Manus · ~10-12 min.</i>",
44
- )
 
 
 
 
 
 
 
 
 
 
 
 
45
  env = {
46
  **os.environ,
47
- "GROQ_API_KEY": os.getenv("GROQ_API_KEY", ""),
48
- "NVIDIA_API_KEY": os.getenv("NVIDIA_API_KEY", ""),
49
  "INTERNAL_TOKEN": os.getenv("INTERNAL_TOKEN", ""),
 
50
  }
51
  process: asyncio.subprocess.Process | None = None
52
  try:
53
  process = await asyncio.create_subprocess_exec(
54
- "node", _BENCH_SCRIPT, "--json",
55
  stdout=asyncio.subprocess.PIPE,
56
  stderr=asyncio.subprocess.PIPE,
57
  env=env,
58
  )
59
- stdout, stderr = await asyncio.wait_for(
60
- process.communicate(), timeout=_BENCH_TIMEOUT
61
- )
62
  if process.returncode != 0:
63
  err = stderr.decode(errors="replace")[:400]
64
- logger.error("Benchmark v7 failed rc=%d: %s", process.returncode, err)
65
- await send_reply_fn(chat_id, f"❌ <b>Errore benchmark v7:</b>\n<code>{err}</code>")
66
  return
67
  except asyncio.TimeoutError:
68
- # FIX-1: kill del processo figlio prima di notificare
69
  if process is not None:
70
  try:
71
  process.kill()
72
  await process.wait()
73
  except Exception:
74
  pass
75
- logger.warning("Benchmark v7 timeout (>%.0fs) — process killed", _BENCH_TIMEOUT)
76
- await send_reply_fn(
77
- chat_id,
78
- f"⏱ <b>Timeout benchmark v7</b> (>{int(_BENCH_TIMEOUT // 60)} min) — "
79
- "processo terminato, controlla log Railway.",
80
- )
81
  return
82
  except Exception as exc:
83
  if process is not None:
@@ -86,26 +111,30 @@ async def run_benchmark_task(chat_id: int, send_reply_fn) -> None:
86
  await process.wait()
87
  except Exception:
88
  pass
89
- logger.error("run_benchmark_task v7 error: %s", exc, exc_info=True)
90
- await send_reply_fn(chat_id, f"💥 <b>Errore critico:</b> <code>{exc}</code>")
91
  return
92
 
93
- report_exists = await asyncio.to_thread(os.path.exists, _REPORT_V7)
94
  if not report_exists:
95
- await send_reply_fn(chat_id, "⚠️ <b>Benchmark terminato ma report v7 non trovato.</b>")
96
  return
97
-
98
  try:
99
- # FIX-3: lettura file in thread non blocca l'event loop
100
- report: dict[str, Any] = await asyncio.to_thread(_read_json, _REPORT_V7)
101
  except Exception as exc:
102
- await send_reply_fn(chat_id, f"⚠️ <b>Report v7 non leggibile:</b> <code>{exc}</code>")
103
  return
104
 
105
- await send_reply_fn(chat_id, _format_v7_report(report))
 
 
 
 
 
 
106
 
107
 
108
- def _format_v7_report(report: dict[str, Any]) -> str:
109
  """Formatta il report v7 per Telegram HTML."""
110
  s = report.get("summary", {})
111
  ts = (report.get("timestamp") or "")[:16].replace("T", " ")
@@ -123,7 +152,8 @@ def _format_v7_report(report: dict[str, Any]) -> str:
123
  lines: list[str] = [
124
  f"🏆 <b>Benchmark {ver} completato!</b>\n\n"
125
  f"📊 <b>Score agente:</b> <code>{avg}/100</code>\n"
126
- f"📅 <b>Run:</b> <code>{ts}</code>\n\n"
 
127
  "📈 <b>Confronto vs riferimenti:</b>\n"
128
  f" • Replit: <code>{repl}/100</code>\n"
129
  f" • Cursor: <code>{curs}/100</code>\n"
@@ -136,15 +166,24 @@ def _format_v7_report(report: dict[str, Any]) -> str:
136
  if canary:
137
  lines.append(f"⚠️ <b>Canary leak:</b> {canary} task\n")
138
 
139
- # Score per categoria
 
140
  tasks = report.get("tasks", [])
141
  if tasks:
 
142
  by_cat: dict[str, list[float]] = {}
143
  for t in tasks:
144
  cat = t.get("cat", "?")
145
  sc = t.get("score")
146
  if isinstance(sc, (int, float)):
147
  by_cat.setdefault(cat, []).append(float(sc))
 
 
 
 
 
 
 
148
  if by_cat:
149
  lines.append("\n📂 <b>Per categoria:</b>\n")
150
  for cat, scores in sorted(by_cat.items()):
@@ -152,6 +191,16 @@ def _format_v7_report(report: dict[str, Any]) -> str:
152
  icon = "🟢" if avg_cat >= 70 else "🟡" if avg_cat >= 50 else "🔴"
153
  lines.append(f" {icon} <code>{avg_cat:5.1f}</code> {cat}\n")
154
 
 
 
 
 
 
 
 
 
 
 
155
  # Gap cards (prime 3)
156
  gap_cards = report.get("gapCards", [])
157
  if gap_cards:
 
20
  logger = logging.getLogger("agente_ai.benchmark_handler")
21
 
22
  # ── Percorsi server Railway ────────────────────────────────────────────────────
23
+ # Lo Space HF esegue il backend in /app; Railway può impostare REPO_ROOT.
24
+ _REPO_ROOT = os.getenv("REPO_ROOT", "/app")
25
+
26
+ # Extended v5: 20 categorie. Gli Space possono montare il repository in
27
+ # /home/user/app anche quando il Dockerfile dichiara WORKDIR=/app.
28
+ _BENCH_SCRIPT_CANDIDATES = (
29
+ os.getenv("BENCHMARK_RUNNER_PATH", "").strip(),
30
+ os.path.join(_REPO_ROOT, "benchmark-extended.mjs"),
31
+ "/home/user/app/benchmark-extended.mjs",
32
+ "/app/benchmark-extended.mjs",
33
+ )
34
+ _BENCH_SCRIPT = next(
35
+ (candidate for candidate in _BENCH_SCRIPT_CANDIDATES if candidate and os.path.isfile(candidate)),
36
+ os.path.join(_REPO_ROOT, "benchmark-extended.mjs"),
37
+ )
38
+ _REPORT_V7 = "/tmp/agente-ai/benchmark-v5-latest.json"
39
+ _REPORT_V7_WEAK = "/tmp/agente-ai/benchmark-v5-weak-latest.json"
40
+ _WEAK_CATEGORIES = (
41
+ "sql", "context_window", "reasoning", "data_analysis", "research_synthesis",
42
+ "mmlu", "technical_writing", "code_correct", "feature", "security",
43
+ )
44
+ # 20 task seriali possono richiedere più di 12 minuti con provider gratuiti.
45
+ _BENCH_TIMEOUT = float(os.getenv("BENCH_TIMEOUT_SECS", "3600"))
46
 
47
  # v6.2 — usato come fallback in get_smart_summary per compatibilità
48
  _REPORT_V6 = os.path.join(_REPO_ROOT, "benchmark-stress-report.json")
49
 
50
 
51
+ async def run_benchmark_task(chat_id: int, send_reply_fn, mode: str = "full") -> None:
52
+ """Esegue il benchmark Extended v5 su tutte le 20 categorie via API task moderna."""
53
+ if not await asyncio.to_thread(os.path.isfile, _BENCH_SCRIPT):
54
+ await send_reply_fn(chat_id, "❌ <b>Runner benchmark esteso non disponibile.</b>\n"
55
+ "Il deployment non ha incluso <code>benchmark-extended.mjs</code>.")
56
+ return
57
 
58
+ is_weak_run = mode == "weak"
59
+ if is_weak_run:
60
+ report_path = _REPORT_V7_WEAK
61
+ flags = [
62
+ f"--categories={','.join(_WEAK_CATEGORIES)}", "--json",
63
+ f"--output={report_path}", "--gap-analysis",
64
+ ]
65
+ await send_reply_fn(
66
+ chat_id,
67
+ "🎯 <b>Benchmark Extended v5 mirato avviato</b>\n"
68
+ "<i>10 categorie più deboli della baseline 39,1 · seed 1337 · task API moderna.</i>",
69
+ )
70
+ else:
71
+ report_path = _REPORT_V7
72
+ flags = ["--full", "--json", f"--output={report_path}", "--gap-analysis"]
73
+ await send_reply_fn(
74
+ chat_id,
75
+ "🚀 <b>Benchmark Extended v5 avviato</b>\n"
76
+ "<i>20/20 categorie · seed 1337 · task API moderna · durata variabile fino a ~60 min.</i>",
77
+ )
78
  env = {
79
  **os.environ,
 
 
80
  "INTERNAL_TOKEN": os.getenv("INTERNAL_TOKEN", ""),
81
+ "BENCHMARK_BASE_URL": os.getenv("BENCHMARK_BASE_URL", "http://127.0.0.1:7860"),
82
  }
83
  process: asyncio.subprocess.Process | None = None
84
  try:
85
  process = await asyncio.create_subprocess_exec(
86
+ "node", _BENCH_SCRIPT, *flags,
87
  stdout=asyncio.subprocess.PIPE,
88
  stderr=asyncio.subprocess.PIPE,
89
  env=env,
90
  )
91
+ _stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=_BENCH_TIMEOUT)
 
 
92
  if process.returncode != 0:
93
  err = stderr.decode(errors="replace")[:400]
94
+ logger.error("Extended benchmark failed rc=%d: %s", process.returncode, err)
95
+ await send_reply_fn(chat_id, f"❌ <b>Errore benchmark Extended:</b>\n<code>{err}</code>")
96
  return
97
  except asyncio.TimeoutError:
 
98
  if process is not None:
99
  try:
100
  process.kill()
101
  await process.wait()
102
  except Exception:
103
  pass
104
+ logger.warning("Extended benchmark timeout (>%.0fs) — process killed", _BENCH_TIMEOUT)
105
+ await send_reply_fn(chat_id, f"⏱ <b>Timeout benchmark Extended</b> (>{int(_BENCH_TIMEOUT // 60)} min) — processo terminato.")
 
 
 
 
106
  return
107
  except Exception as exc:
108
  if process is not None:
 
111
  await process.wait()
112
  except Exception:
113
  pass
114
+ logger.exception("run_benchmark_task extended error")
115
+ await send_reply_fn(chat_id, f"💥 <b>Errore critico benchmark:</b> <code>{exc}</code>")
116
  return
117
 
118
+ report_exists = await asyncio.to_thread(os.path.exists, report_path)
119
  if not report_exists:
120
+ await send_reply_fn(chat_id, "⚠️ <b>Benchmark Extended terminato ma report non trovato.</b>")
121
  return
 
122
  try:
123
+ report: dict[str, Any] = await asyncio.to_thread(_read_json, report_path)
 
124
  except Exception as exc:
125
+ await send_reply_fn(chat_id, f"⚠️ <b>Report Extended non leggibile:</b> <code>{exc}</code>")
126
  return
127
 
128
+ categories = {str(task.get("cat", "")) for task in report.get("tasks", []) if task.get("cat")}
129
+ expected_categories = len(_WEAK_CATEGORIES) if is_weak_run else 20
130
+ if len(categories) != expected_categories:
131
+ await send_reply_fn(chat_id, f"⚠️ <b>Run incompleta:</b> <code>{len(categories)}/{expected_categories}</code> categorie nel report."
132
+ " Nessun risultato incompleto viene presentato come benchmark completo.")
133
+ return
134
+ await send_reply_fn(chat_id, _format_v7_report(report, expected_categories=expected_categories, run_label="mirato · categorie deboli" if is_weak_run else None))
135
 
136
 
137
+ def _format_v7_report(report: dict[str, Any], *, expected_categories: int = 20, run_label: str | None = None) -> str:
138
  """Formatta il report v7 per Telegram HTML."""
139
  s = report.get("summary", {})
140
  ts = (report.get("timestamp") or "")[:16].replace("T", " ")
 
152
  lines: list[str] = [
153
  f"🏆 <b>Benchmark {ver} completato!</b>\n\n"
154
  f"📊 <b>Score agente:</b> <code>{avg}/100</code>\n"
155
+ f"📅 <b>Run:</b> <code>{ts}</code>\n"
156
+ + (f"🎯 <b>Modalità:</b> <code>{run_label}</code>\n" if run_label else "") + "\n"
157
  "📈 <b>Confronto vs riferimenti:</b>\n"
158
  f" • Replit: <code>{repl}/100</code>\n"
159
  f" • Cursor: <code>{curs}/100</code>\n"
 
166
  if canary:
167
  lines.append(f"⚠️ <b>Canary leak:</b> {canary} task\n")
168
 
169
+ # Score per categoria. Una categoria in timeout resta tentata ma non entra
170
+ # nella media: non va trasformata silenziosamente in uno score pari a zero.
171
  tasks = report.get("tasks", [])
172
  if tasks:
173
+ attempted_categories = {str(t.get("cat")) for t in tasks if t.get("cat")}
174
  by_cat: dict[str, list[float]] = {}
175
  for t in tasks:
176
  cat = t.get("cat", "?")
177
  sc = t.get("score")
178
  if isinstance(sc, (int, float)):
179
  by_cat.setdefault(cat, []).append(float(sc))
180
+ attempted = s.get("attemptedTaskCount", len(tasks))
181
+ scored = s.get("scoredTaskCount", sum(len(v) for v in by_cat.values()))
182
+ skipped = s.get("skippedTaskCount", max(0, attempted - scored))
183
+ lines.append(
184
+ f"🧪 <b>Copertura:</b> <code>{len(attempted_categories)}/{expected_categories} categorie tentate · "
185
+ f"{scored} valutabili · {skipped} non valutabili</code>\n"
186
+ )
187
  if by_cat:
188
  lines.append("\n📂 <b>Per categoria:</b>\n")
189
  for cat, scores in sorted(by_cat.items()):
 
191
  icon = "🟢" if avg_cat >= 70 else "🟡" if avg_cat >= 50 else "🔴"
192
  lines.append(f" {icon} <code>{avg_cat:5.1f}</code> {cat}\n")
193
 
194
+ failures = report.get("taskFailures", [])
195
+ if failures:
196
+ lines.append("\n⚠️ <b>Categorie non valutabili:</b>\n")
197
+ for failure in failures[:3]:
198
+ cat = failure.get("cat", "?")
199
+ reason = str(failure.get("reason", "errore non specificato"))[:100]
200
+ lines.append(f" • <code>{cat}</code> — {reason}\n")
201
+ if len(failures) > 3:
202
+ lines.append(f" <i>...e altre {len(failures) - 3}.</i>\n")
203
+
204
  # Gap cards (prime 3)
205
  gap_cards = report.get("gapCards", [])
206
  if gap_cards:
api/browser.py CHANGED
@@ -260,16 +260,16 @@ def _trim_ax_tree(node: dict, depth: int) -> dict:
260
  Mantiene: role, name, description, value, checked, expanded, required.
261
  Scarta: proprietà interne Playwright (nodeId, backendDOMNodeId, ignoredReasons).
262
  """
263
- KEEP = frozenset({role, name, description, value, checked,
264
- expanded, required, haspopup, level, pressed,
265
- selected, multiselectable, orientation})
266
  result: dict = {k: v for k, v in node.items() if k in KEEP and v not in (None, False, )}
267
- if depth > 0 and node.get(children):
268
- trimmed = [_trim_ax_tree(c, depth - 1) for c in node[children]]
269
  # Filtra nodi completamente vuoti (solo role senza nome né figli)
270
- trimmed = [c for c in trimmed if len(c) > 1 or c.get(children)]
271
  if trimmed:
272
- result[children] = trimmed
273
  return result
274
 
275
 
 
260
  Mantiene: role, name, description, value, checked, expanded, required.
261
  Scarta: proprietà interne Playwright (nodeId, backendDOMNodeId, ignoredReasons).
262
  """
263
+ KEEP = frozenset({"role", "name", "description", "value", "checked",
264
+ "expanded", "required", "haspopup", "level", "pressed",
265
+ "selected", "multiselectable", "orientation"})
266
  result: dict = {k: v for k, v in node.items() if k in KEEP and v not in (None, False, )}
267
+ if depth > 0 and node.get("children"):
268
+ trimmed = [_trim_ax_tree(c, depth - 1) for c in node["children"]]
269
  # Filtra nodi completamente vuoti (solo role senza nome né figli)
270
+ trimmed = [c for c in trimmed if len(c) > 1 or c.get("children")]
271
  if trimmed:
272
+ result["children"] = trimmed
273
  return result
274
 
275
 
api/exec.py CHANGED
@@ -568,15 +568,14 @@ async def llm_fix_code(
568
  _FIX_CHAIN = []
569
  groq_key = os.getenv('GROQ_API_KEY', '')
570
  if groq_key:
571
- _FIX_CHAIN.append(('https://api.groq.com/openai/v1', groq_key, 'llama-3.3-70b-versatile'))
572
- _FIX_CHAIN.append(('https://api.groq.com/openai/v1', groq_key, 'llama-3.1-8b-instant'))
573
 
574
  or_key = os.getenv('OPENROUTER_API_KEY', '')
575
  if or_key:
576
  for m in [
577
- 'meta-llama/llama-3.1-8b-instruct:free',
578
- 'google/gemini-2.0-flash-exp:free',
579
- 'qwen/qwen-2.5-coder-7b-instruct:free',
580
  ]:
581
  _FIX_CHAIN.append(('https://openrouter.ai/api/v1', or_key, m))
582
 
 
568
  _FIX_CHAIN = []
569
  groq_key = os.getenv('GROQ_API_KEY', '')
570
  if groq_key:
571
+ _FIX_CHAIN.append(('https://api.groq.com/openai/v1', groq_key, 'qwen/qwen3.6-27b'))
 
572
 
573
  or_key = os.getenv('OPENROUTER_API_KEY', '')
574
  if or_key:
575
  for m in [
576
+ 'openrouter/free',
577
+ 'qwen/qwen3-coder:free',
578
+ 'meta-llama/llama-3.3-70b-instruct:free',
579
  ]:
580
  _FIX_CHAIN.append(('https://openrouter.ai/api/v1', or_key, m))
581
 
api/me_tasks.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Task personali del prodotto pubblico.
2
+
3
+ Tutte le query applicano owner_id derivato dal JWT Supabase verificato. Il client
4
+ non può scegliere o sostituire il proprietario nel body o nella query.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import logging
10
+ from typing import Any
11
+ from uuid import UUID
12
+
13
+ from fastapi import APIRouter, Depends, HTTPException, Query
14
+ from pydantic import BaseModel, Field
15
+
16
+ from .auth_guard import require_supabase_user
17
+ from .state import sb
18
+
19
+ _logger = logging.getLogger("agente_ai.api.me_tasks")
20
+ router = APIRouter(prefix="/api/me/tasks", tags=["me"])
21
+
22
+
23
+ class TaskCreate(BaseModel):
24
+ goal: str = Field(min_length=1, max_length=10_000)
25
+
26
+
27
+ class TaskUpdate(BaseModel):
28
+ status: str = Field(pattern="^(queued|in_progress|done|failed|cancelled)$")
29
+
30
+
31
+ _ALLOWED = "id,goal,status,created_at,updated_at"
32
+
33
+
34
+ def _owner(user: dict[str, Any]) -> str:
35
+ return str(user["id"])
36
+
37
+
38
+ def _client():
39
+ client = sb()
40
+ if client is None:
41
+ raise HTTPException(status_code=503, detail="Database non configurato")
42
+ return client
43
+
44
+
45
+ @router.get("")
46
+ async def list_my_tasks(
47
+ user: dict[str, Any] = Depends(require_supabase_user),
48
+ limit: int = Query(50, ge=1, le=100),
49
+ offset: int = Query(0, ge=0),
50
+ ) -> dict[str, Any]:
51
+ client = _client()
52
+ owner_id = _owner(user)
53
+
54
+ def operation():
55
+ return client.table("user_agent_tasks").select(_ALLOWED).eq("owner_id", owner_id).order("updated_at", desc=True).range(offset, offset + limit - 1).execute()
56
+
57
+ try:
58
+ result = await asyncio.to_thread(operation)
59
+ return {"tasks": result.data or [], "offset": offset, "limit": limit}
60
+ except Exception as exc:
61
+ _logger.warning("list own tasks failed: %s", type(exc).__name__)
62
+ raise HTTPException(status_code=503, detail="Task personali temporaneamente non disponibili") from exc
63
+
64
+
65
+ @router.post("", status_code=201)
66
+ async def create_my_task(
67
+ body: TaskCreate,
68
+ user: dict[str, Any] = Depends(require_supabase_user),
69
+ ) -> dict[str, Any]:
70
+ client = _client()
71
+ owner_id = _owner(user)
72
+
73
+ def operation():
74
+ return client.table("user_agent_tasks").insert({"owner_id": owner_id, "goal": body.goal.strip(), "status": "queued"}).select(_ALLOWED).single().execute()
75
+
76
+ try:
77
+ result = await asyncio.to_thread(operation)
78
+ if not result.data:
79
+ raise HTTPException(status_code=502, detail="Task personale non creato")
80
+ return result.data
81
+ except HTTPException:
82
+ raise
83
+ except Exception as exc:
84
+ _logger.warning("create own task failed: %s", type(exc).__name__)
85
+ raise HTTPException(status_code=503, detail="Task personale temporaneamente non disponibile") from exc
86
+
87
+
88
+ @router.patch("/{task_id}")
89
+ async def update_my_task(
90
+ task_id: UUID,
91
+ body: TaskUpdate,
92
+ user: dict[str, Any] = Depends(require_supabase_user),
93
+ ) -> dict[str, Any]:
94
+ client = _client()
95
+ owner_id = _owner(user)
96
+
97
+ def operation():
98
+ return client.table("user_agent_tasks").update({"status": body.status}).eq("id", str(task_id)).eq("owner_id", owner_id).select(_ALLOWED).maybe_single().execute()
99
+
100
+ try:
101
+ result = await asyncio.to_thread(operation)
102
+ if not result.data:
103
+ raise HTTPException(status_code=404, detail="Task personale non trovato")
104
+ return result.data
105
+ except HTTPException:
106
+ raise
107
+ except Exception as exc:
108
+ _logger.warning("update own task failed: %s", type(exc).__name__)
109
+ raise HTTPException(status_code=503, detail="Task personale temporaneamente non disponibile") from exc
110
+
111
+
112
+ @router.post("/{task_id}/cancel")
113
+ async def cancel_my_task(
114
+ task_id: UUID,
115
+ user: dict[str, Any] = Depends(require_supabase_user),
116
+ ) -> dict[str, Any]:
117
+ return await update_my_task(task_id, TaskUpdate(status="cancelled"), user)
api/persistence.py CHANGED
@@ -17,7 +17,7 @@ Required Supabase tables (run backend/migrations/s359_task_persistence.sql once)
17
  """
18
  import asyncio, time, json
19
  from .state import safe_json_dumps as _sjd # B11-FIX: surrogate-safe drop-in
20
- from typing import Optional
21
 
22
  import logging
23
  _logger = logging.getLogger("api.persistence")
@@ -27,6 +27,27 @@ MAX_EVENTS = 500 # max SSE frames persisted per task
27
  _MAX_RETRY = 2 # GAP-P40D-FIX: tentativi massimi per write Supabase
28
  _RETRY_SLEEP = 0.3 # GAP-P40D-FIX: sleep tra tentativi (secondi)
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  # ── Write helpers (fire-and-forget, never raise) ───────────────────────────────
32
 
@@ -177,22 +198,101 @@ async def sb_list_tasks(limit: int = 50) -> list[dict]:
177
  # ── Checkpoint helpers (S359: task state snapshots) ───────────────────────────
178
 
179
  async def sb_save_checkpoint(task_id: str, step: int, checkpoint_data: dict) -> None:
180
- """Save a mid-task checkpoint for potential resume."""
181
  from .state import _sb
182
  if not _sb:
183
  return
184
  now = int(time.time() * 1000)
185
  try:
186
- await asyncio.to_thread(
187
- lambda: _sb.table('agent_tasks')
188
- .update({'checkpoint': _sjd(checkpoint_data)[:16000], 'updated_at': now})
189
- .eq('task_id', task_id)
190
- .execute()
191
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  except Exception as e:
193
  _logger.debug('[persist] save_checkpoint %s#%d: %s', task_id, step, e)
194
 
195
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  async def sb_get_checkpoint(task_id: str) -> Optional[dict]:
197
  """Retrieve latest checkpoint for a task."""
198
  from .state import _sb
 
17
  """
18
  import asyncio, time, json
19
  from .state import safe_json_dumps as _sjd # B11-FIX: surrogate-safe drop-in
20
+ from typing import Optional, Any
21
 
22
  import logging
23
  _logger = logging.getLogger("api.persistence")
 
27
  _MAX_RETRY = 2 # GAP-P40D-FIX: tentativi massimi per write Supabase
28
  _RETRY_SLEEP = 0.3 # GAP-P40D-FIX: sleep tra tentativi (secondi)
29
 
30
+ # P0: per-run locks serialize compatible envelope updates in one worker.
31
+ _ENGINEERING_LOCKS: dict[str, asyncio.Lock] = {}
32
+ _ENGINEERING_LOCK_LAST_USED: dict[str, float] = {}
33
+ _ENGINEERING_LOCK_MAX = 256
34
+
35
+
36
+ def _engineering_lock(task_id: str) -> asyncio.Lock:
37
+ lock = _ENGINEERING_LOCKS.get(task_id)
38
+ if lock is None:
39
+ lock = asyncio.Lock()
40
+ _ENGINEERING_LOCKS[task_id] = lock
41
+ _ENGINEERING_LOCK_LAST_USED[task_id] = time.monotonic()
42
+ if len(_ENGINEERING_LOCKS) > _ENGINEERING_LOCK_MAX:
43
+ for stale_id, _ in sorted(_ENGINEERING_LOCK_LAST_USED.items(), key=lambda item: item[1]):
44
+ stale_lock = _ENGINEERING_LOCKS.get(stale_id)
45
+ if stale_lock is not None and not stale_lock.locked() and stale_id != task_id:
46
+ _ENGINEERING_LOCKS.pop(stale_id, None)
47
+ _ENGINEERING_LOCK_LAST_USED.pop(stale_id, None)
48
+ break
49
+ return lock
50
+
51
 
52
  # ── Write helpers (fire-and-forget, never raise) ───────────────────────────────
53
 
 
198
  # ── Checkpoint helpers (S359: task state snapshots) ───────────────────────────
199
 
200
  async def sb_save_checkpoint(task_id: str, step: int, checkpoint_data: dict) -> None:
201
+ """Save a legacy checkpoint while preserving a valid EngineeringState envelope."""
202
  from .state import _sb
203
  if not _sb:
204
  return
205
  now = int(time.time() * 1000)
206
  try:
207
+ payload = dict(checkpoint_data) if isinstance(checkpoint_data, dict) else {}
208
+ # A legacy save must not erase the shadow/canary envelope written by the
209
+ # adapter. Read/merge under the same per-task lock used by its writer.
210
+ lock = _engineering_lock(task_id)
211
+ async with lock:
212
+ current = await sb_get_checkpoint(task_id)
213
+ current_engineering = current.get('engineering_state') if isinstance(current, dict) else None
214
+ if isinstance(current_engineering, dict) and 'engineering_state' not in payload:
215
+ payload['engineering_state'] = current_engineering
216
+ serialized = _sjd(payload)
217
+ if len(serialized) > 16000:
218
+ _logger.debug('[persist] save_checkpoint %s#%d skipped: payload exceeds size limit', task_id, step)
219
+ return
220
+ await asyncio.to_thread(
221
+ lambda: _sb.table('agent_tasks')
222
+ .update({'checkpoint': serialized, 'updated_at': now})
223
+ .eq('task_id', task_id)
224
+ .execute()
225
+ )
226
  except Exception as e:
227
  _logger.debug('[persist] save_checkpoint %s#%d: %s', task_id, step, e)
228
 
229
 
230
+ _ENGINEERING_DEBOUNCE_CACHE: dict[str, dict[str, Any]] = {}
231
+ _ENGINEERING_LAST_FLUSH_TS: dict[str, float] = {}
232
+ DEBOUNCE_INTERVAL_SEC = 2.0
233
+
234
+ async def sb_save_engineering_state(task_id: str, envelope: dict, force: bool = False) -> None:
235
+ """Merge a validated EngineeringState envelope with debouncing and monotone revision check."""
236
+ from .state import _sb
237
+ if not _sb or not task_id:
238
+ return
239
+ try:
240
+ from agents.engineering_state import EngineeringState
241
+ validated = EngineeringState.from_snapshot(envelope).snapshot()
242
+ except Exception as exc:
243
+ _logger.debug('[persist] engineering state rejected: %s', type(exc).__name__)
244
+ return
245
+
246
+ lock = _engineering_lock(task_id)
247
+ async with lock:
248
+ current = await sb_get_checkpoint(task_id)
249
+ current = current if isinstance(current, dict) else {}
250
+ current_engineering = current.get('engineering_state')
251
+ try:
252
+ current_revision = int(current_engineering.get('revision', -1)) if isinstance(current_engineering, dict) else -1
253
+ except (TypeError, ValueError):
254
+ current_revision = -1
255
+ incoming_revision = int(validated.get('revision', -1))
256
+ if current_revision > incoming_revision:
257
+ _logger.debug('[persist] engineering state conflict %s: remote revision %d > %d', task_id, current_revision, incoming_revision)
258
+ return
259
+ now_t = time.time()
260
+ _ENGINEERING_DEBOUNCE_CACHE[task_id] = validated
261
+ if not force and task_id in _ENGINEERING_LAST_FLUSH_TS:
262
+ if now_t - _ENGINEERING_LAST_FLUSH_TS[task_id] < DEBOUNCE_INTERVAL_SEC:
263
+ return
264
+
265
+ _ENGINEERING_LAST_FLUSH_TS[task_id] = now_t
266
+ to_flush = _ENGINEERING_DEBOUNCE_CACHE.get(task_id, validated)
267
+
268
+ async with lock:
269
+ current = await sb_get_checkpoint(task_id)
270
+ current = current if isinstance(current, dict) else {}
271
+ current_engineering = current.get('engineering_state')
272
+ try:
273
+ current_revision = int(current_engineering.get('revision', -1)) if isinstance(current_engineering, dict) else -1
274
+ except (TypeError, ValueError):
275
+ current_revision = -1
276
+ incoming_revision = int(to_flush.get('revision', -1))
277
+ if current_revision > incoming_revision and not force:
278
+ return
279
+ merged = dict(current)
280
+ merged['engineering_state'] = to_flush
281
+ serialized = _sjd(merged)
282
+ if len(serialized) > 16000:
283
+ return
284
+ now = int(time.time() * 1000)
285
+ try:
286
+ await asyncio.to_thread(
287
+ lambda: _sb.table('agent_tasks')
288
+ .update({'checkpoint': serialized, 'updated_at': now})
289
+ .eq('task_id', task_id)
290
+ .execute()
291
+ )
292
+ except Exception as exc:
293
+ _logger.debug('[persist] save_engineering_state %s: %s', task_id, exc)
294
+
295
+
296
  async def sb_get_checkpoint(task_id: str) -> Optional[dict]:
297
  """Retrieve latest checkpoint for a task."""
298
  from .state import _sb
api/private_state.py ADDED
@@ -0,0 +1,382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """API private per il cutover browser → backend delle tabelle Supabase sensibili.
2
+
3
+ Questi endpoint sono destinati esclusivamente alle Pages Functions, che inoltrano
4
+ ``X-Internal-Token`` al master B. Nessun client browser riceve una service-role key
5
+ o accede direttamente alle tabelle private.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import math
13
+ import re
14
+ import time
15
+ from datetime import datetime, timedelta, timezone
16
+ from typing import Any
17
+
18
+ from fastapi import APIRouter, Depends, HTTPException, Query
19
+ from pydantic import BaseModel, Field, field_validator
20
+
21
+ from .auth_guard import require_private_state_machine
22
+ from .state import get_supabase
23
+
24
+ _logger = logging.getLogger("agente_ai.api.private_state")
25
+ router = APIRouter(
26
+ prefix="/api/private-state",
27
+ tags=["private-state"],
28
+ dependencies=[Depends(require_private_state_machine)],
29
+ )
30
+
31
+ _RAG_LANGUAGE = "rag_chunk"
32
+ _RAG_PREFIX = "__rag_chunk"
33
+ _MAX_RAG_CHUNKS = 100
34
+ _MAX_RAG_CONTENT_CHARS = 10_000
35
+ _MAX_EMBEDDING_DIMENSIONS = 4_096
36
+ _MAX_TASK_PAGE = 100
37
+
38
+
39
+ def _db() -> Any:
40
+ """Restituisce il client service-role del backend o un errore non sensibile."""
41
+ client = get_supabase()
42
+ if client is None:
43
+ raise HTTPException(status_code=503, detail="Archivio privato temporaneamente non disponibile")
44
+ return client
45
+
46
+
47
+ async def _call(operation):
48
+ """Esegue il client sincrono Supabase senza bloccare l'event loop FastAPI."""
49
+ try:
50
+ return await asyncio.to_thread(operation, _db())
51
+ except HTTPException:
52
+ raise
53
+ except Exception as exc: # Non esporre dettagli backend, query o dati al browser.
54
+ _logger.warning("[private-state] database operation failed: %s", type(exc).__name__)
55
+ raise HTTPException(status_code=502, detail="Operazione sullo stato privato non riuscita") from exc
56
+
57
+
58
+ def _json_object(value: object) -> dict[str, Any]:
59
+ if isinstance(value, dict):
60
+ return value
61
+ if isinstance(value, str):
62
+ try:
63
+ parsed = json.loads(value)
64
+ return parsed if isinstance(parsed, dict) else {}
65
+ except (TypeError, ValueError):
66
+ return {}
67
+ return {}
68
+
69
+
70
+ def _as_epoch_ms(value: object) -> int:
71
+ """Normalizza i valori `timestamptz` PostgREST in millisecondi browser-safe."""
72
+ if isinstance(value, (int, float)):
73
+ return int(value)
74
+ if isinstance(value, datetime):
75
+ moment = value
76
+ elif isinstance(value, str):
77
+ try:
78
+ moment = datetime.fromisoformat(value.replace("Z", "+00:00"))
79
+ except ValueError:
80
+ return 0
81
+ else:
82
+ return 0
83
+ if moment.tzinfo is None:
84
+ moment = moment.replace(tzinfo=timezone.utc)
85
+ return int(moment.timestamp() * 1_000)
86
+
87
+
88
+ def _finite_vector(values: list[float]) -> list[float]:
89
+ if not values or len(values) > _MAX_EMBEDDING_DIMENSIONS:
90
+ raise ValueError("dimensione embedding non valida")
91
+ if any(not math.isfinite(value) for value in values):
92
+ raise ValueError("embedding contiene valori non finiti")
93
+ return values
94
+
95
+
96
+ class TelegramConfigIn(BaseModel):
97
+ bot_token: str = Field(min_length=1, max_length=512)
98
+ chat_id: str = Field(min_length=1, max_length=128)
99
+
100
+
101
+ class SkillPatternIn(BaseModel):
102
+ id: str = Field(min_length=1, max_length=128)
103
+ task_signature: str = Field(min_length=1, max_length=200)
104
+ tool_sequence: list[str] = Field(min_length=1, max_length=8)
105
+ success_count: int = Field(ge=0, le=1_000_000)
106
+ total_count: int = Field(ge=1, le=1_000_000)
107
+ last_used: int = Field(ge=0)
108
+ confidence: float = Field(ge=0, le=1)
109
+
110
+ @field_validator("tool_sequence")
111
+ @classmethod
112
+ def validate_tools(cls, tools: list[str]) -> list[str]:
113
+ clean = [tool.strip()[:120] for tool in tools if isinstance(tool, str) and tool.strip()]
114
+ if not clean:
115
+ raise ValueError("tool_sequence non valida")
116
+ return clean
117
+
118
+
119
+ class RagChunkIn(BaseModel):
120
+ id: str = Field(min_length=1, max_length=128)
121
+ path: str = Field(min_length=1, max_length=256)
122
+ content: str = Field(min_length=51, max_length=_MAX_RAG_CONTENT_CHARS)
123
+ embedding: list[float] | None = Field(default=None, max_length=_MAX_EMBEDDING_DIMENSIONS)
124
+
125
+ @field_validator("embedding")
126
+ @classmethod
127
+ def validate_embedding(cls, value: list[float] | None) -> list[float] | None:
128
+ return _finite_vector(value) if value is not None else None
129
+
130
+
131
+ class RagIndexIn(BaseModel):
132
+ file_id: str = Field(min_length=1, max_length=40)
133
+ chunks: list[RagChunkIn] = Field(min_length=1, max_length=_MAX_RAG_CHUNKS)
134
+
135
+ @field_validator("file_id")
136
+ @classmethod
137
+ def validate_file_id(cls, value: str) -> str:
138
+ if not re.fullmatch(r"[a-z0-9_]+", value):
139
+ raise ValueError("file_id non valido")
140
+ return value
141
+
142
+
143
+ class RagSearchIn(BaseModel):
144
+ query_embedding: list[float] | None = Field(default=None, max_length=_MAX_EMBEDDING_DIMENSIONS)
145
+ query: str = Field(default="", max_length=2_000)
146
+ similarity_threshold: float = Field(default=0.22, ge=-1, le=1)
147
+ match_count: int = Field(default=5, ge=1, le=10)
148
+
149
+ @field_validator("query_embedding")
150
+ @classmethod
151
+ def validate_query_embedding(cls, value: list[float] | None) -> list[float] | None:
152
+ return _finite_vector(value) if value is not None else None
153
+
154
+ @field_validator("query")
155
+ @classmethod
156
+ def validate_query(cls, value: str) -> str:
157
+ if not value.strip() and value == "":
158
+ return ""
159
+ return value.strip()
160
+
161
+
162
+ @router.get("/sessions")
163
+ async def list_sessions(
164
+ max_age_ms: int = Query(default=300_000, ge=10_000, le=3_600_000),
165
+ limit: int = Query(default=100, ge=1, le=200),
166
+ ) -> dict[str, object]:
167
+ """Restituisce esclusivamente i metadati delle sessioni agente ancora vive."""
168
+ cutoff = (datetime.now(timezone.utc) - timedelta(milliseconds=max_age_ms)).isoformat()
169
+
170
+ def operation(client: Any):
171
+ return client.table("agent_tasks").select("task_id,context,updated_at") \
172
+ .eq("status", "__session__").gte("updated_at", cutoff) \
173
+ .order("updated_at", desc=True).limit(limit).execute()
174
+
175
+ result = await _call(operation)
176
+ sessions: list[dict[str, object]] = []
177
+ for row in result.data or []:
178
+ context = _json_object(row.get("context"))
179
+ session_id = str(context.get("sessionId") or row.get("task_id") or "").strip()
180
+ if not session_id:
181
+ continue
182
+ claimed = context.get("claimedFiles")
183
+ sessions.append({
184
+ "session_id": session_id,
185
+ "session_name": str(context.get("sessionName") or session_id)[:160],
186
+ "sprint": str(context["sprint"])[:120] if context.get("sprint") else None,
187
+ "claimed_files": [str(item)[:300] for item in claimed[:100]] if isinstance(claimed, list) else [],
188
+ "last_heartbeat": _as_epoch_ms(context.get("lastHeartbeat")) or _as_epoch_ms(row.get("updated_at")),
189
+ "current_task": str(context["currentTask"])[:500] if context.get("currentTask") else None,
190
+ })
191
+ return {"sessions": sessions}
192
+
193
+
194
+ @router.get("/tasks")
195
+ async def list_tasks(
196
+ limit: int = Query(default=20, ge=1, le=_MAX_TASK_PAGE),
197
+ offset: int = Query(default=0, ge=0, le=10_000),
198
+ status: str | None = Query(default=None, max_length=64),
199
+ ) -> dict[str, object]:
200
+ """Lista task non di configurazione per il TMA, con conteggi per stato."""
201
+ normalized_status = status.strip().upper() if status else ""
202
+
203
+ def operation(client: Any):
204
+ query = client.table("agent_tasks").select("task_id,goal,status,updated_at") \
205
+ .neq("status", "__session__").neq("status", "__config__")
206
+ if normalized_status:
207
+ query = query.eq("status", normalized_status)
208
+ page = query.order("updated_at", desc=True).range(offset, offset + limit - 1).execute()
209
+ all_statuses = client.table("agent_tasks").select("status") \
210
+ .neq("status", "__session__").neq("status", "__config__").limit(2_000).execute()
211
+ return page, all_statuses
212
+
213
+ page, all_statuses = await _call(operation)
214
+ counts: dict[str, int] = {}
215
+ for row in all_statuses.data or []:
216
+ key = str(row.get("status") or "UNKNOWN").upper()
217
+ counts[key] = counts.get(key, 0) + 1
218
+ tasks = [
219
+ {
220
+ "task_id": str(row.get("task_id") or ""),
221
+ "goal": str(row.get("goal") or "")[:1_000],
222
+ "status": str(row.get("status") or "UNKNOWN"),
223
+ "updated_at": _as_epoch_ms(row.get("updated_at")),
224
+ }
225
+ for row in page.data or []
226
+ ]
227
+ return {"tasks": tasks, "counts": counts, "offset": offset, "limit": limit}
228
+
229
+
230
+ @router.post("/telegram-config")
231
+ async def save_telegram_config(payload: TelegramConfigIn) -> dict[str, bool]:
232
+ """Salva la configurazione Telegram nel record privato del daemon."""
233
+ now = datetime.now(timezone.utc).isoformat()
234
+ row = {
235
+ "task_id": "__telegram_config__",
236
+ "goal": "__telegram_config__",
237
+ "status": "__config__",
238
+ "max_steps": 0,
239
+ "context": json.dumps({"botToken": payload.bot_token, "chatId": payload.chat_id}),
240
+ "updated_at": now,
241
+ }
242
+
243
+ def operation(client: Any):
244
+ return client.table("agent_tasks").upsert(row, on_conflict="task_id").execute()
245
+
246
+ await _call(operation)
247
+ return {"ok": True}
248
+
249
+
250
+ @router.get("/skill-patterns")
251
+ async def list_skill_patterns(limit: int = Query(default=100, ge=1, le=100)) -> dict[str, object]:
252
+ """Carica pattern cloud per il merge con lo storage locale Dexie."""
253
+
254
+ def operation(client: Any):
255
+ return client.table("skill_patterns").select(
256
+ "id,task_signature,tool_sequence,success_count,total_count,last_used,confidence"
257
+ ).order("confidence", desc=True).limit(limit).execute()
258
+
259
+ result = await _call(operation)
260
+ return {"patterns": result.data or []}
261
+
262
+
263
+ @router.put("/skill-patterns/{pattern_id}")
264
+ async def upsert_skill_pattern(pattern_id: str, payload: SkillPatternIn) -> dict[str, bool]:
265
+ """Sincronizza un pattern già validato dal layer locale del browser."""
266
+ if pattern_id != payload.id:
267
+ raise HTTPException(status_code=400, detail="Identificatore pattern non coerente")
268
+
269
+ row = payload.model_dump()
270
+
271
+ def operation(client: Any):
272
+ return client.table("skill_patterns").upsert(row, on_conflict="id").execute()
273
+
274
+ await _call(operation)
275
+ return {"ok": True}
276
+
277
+
278
+ @router.post("/rag/index")
279
+ async def index_rag(payload: RagIndexIn) -> dict[str, int]:
280
+ """Sostituisce i chunk RAG di un file senza esporre `vfs_files` al browser."""
281
+ prefix = f"{_RAG_PREFIX}/{payload.file_id}/"
282
+ rows: list[dict[str, object]] = []
283
+ now = int(time.time() * 1000)
284
+ for chunk in payload.chunks:
285
+ if not chunk.path.startswith(prefix) or not chunk.id.startswith(f"rag-{payload.file_id}-"):
286
+ raise HTTPException(status_code=400, detail="Chunk RAG non coerente con il file")
287
+ row: dict[str, object] = {
288
+ "id": chunk.id,
289
+ "user_id": "default",
290
+ "path": chunk.path,
291
+ "content": chunk.content,
292
+ "language": _RAG_LANGUAGE,
293
+ "created_at": now,
294
+ "updated_at": now,
295
+ }
296
+ if chunk.embedding:
297
+ row["embedding"] = "[" + ",".join(str(value) for value in chunk.embedding) + "]"
298
+ row["embedding_vec"] = chunk.embedding
299
+ rows.append(row)
300
+
301
+ def operation(client: Any):
302
+ client.table("vfs_files").delete().eq("language", _RAG_LANGUAGE).like("path", prefix + "%").execute()
303
+ return client.table("vfs_files").upsert(rows).execute()
304
+
305
+ await _call(operation)
306
+ return {"indexed": len(rows)}
307
+
308
+
309
+ def _parse_vector(value: object) -> list[float] | None:
310
+ if isinstance(value, list):
311
+ try:
312
+ return [float(item) for item in value]
313
+ except (TypeError, ValueError):
314
+ return None
315
+ if isinstance(value, str):
316
+ try:
317
+ parsed = json.loads(value)
318
+ return [float(item) for item in parsed] if isinstance(parsed, list) else None
319
+ except (TypeError, ValueError):
320
+ return None
321
+ return None
322
+
323
+
324
+ def _cosine_similarity(left: list[float], right: list[float]) -> float:
325
+ if len(left) != len(right) or not left:
326
+ return 0.0
327
+ numerator = sum(a * b for a, b in zip(left, right))
328
+ left_norm = math.sqrt(sum(a * a for a in left))
329
+ right_norm = math.sqrt(sum(b * b for b in right))
330
+ return numerator / (left_norm * right_norm) if left_norm and right_norm else 0.0
331
+
332
+
333
+ @router.post("/rag/search")
334
+ async def search_rag(payload: RagSearchIn) -> dict[str, object]:
335
+ """Ricerca pgvector con fallback server-side alla similarità coseno in memoria."""
336
+
337
+ def operation(client: Any):
338
+ if payload.query_embedding:
339
+ try:
340
+ rpc = client.rpc("match_rag_chunks", {
341
+ "query_embedding": payload.query_embedding,
342
+ "similarity_threshold": payload.similarity_threshold,
343
+ "match_count": payload.match_count,
344
+ }).execute()
345
+ if isinstance(rpc.data, list):
346
+ return {"mode": "pgvector", "rows": rpc.data}
347
+ except Exception as exc:
348
+ _logger.info("[private-state] rag RPC unavailable, using fallback: %s", type(exc).__name__)
349
+
350
+ result = client.table("vfs_files").select("content,embedding,path") \
351
+ .eq("language", _RAG_LANGUAGE).limit(300).execute()
352
+ query_words = {word for word in re.split(r"\W+", payload.query.lower()) if len(word) > 3}
353
+ scored: list[dict[str, object]] = []
354
+ for row in result.data or []:
355
+ content = str(row.get("content") or "")
356
+ embedding = _parse_vector(row.get("embedding"))
357
+ if payload.query_embedding and embedding:
358
+ score = _cosine_similarity(payload.query_embedding, embedding)
359
+ else:
360
+ lower = content.lower()
361
+ hits = sum(1 for word in query_words if word in lower)
362
+ score = hits / len(query_words) if query_words else 0.0
363
+ if score >= payload.similarity_threshold:
364
+ scored.append({
365
+ "content": content,
366
+ "similarity": score,
367
+ "path": str(row.get("path") or ""),
368
+ })
369
+ scored.sort(key=lambda item: float(item["similarity"]), reverse=True)
370
+ return {"mode": "cosine_fallback", "rows": scored[:payload.match_count]}
371
+
372
+ result = await _call(operation)
373
+ rows = [
374
+ {
375
+ "content": str(row.get("content") or ""),
376
+ "similarity": float(row.get("similarity") or 0),
377
+ "path": str(row.get("path") or ""),
378
+ }
379
+ for row in result["rows"]
380
+ if isinstance(row, dict)
381
+ ]
382
+ return {"results": rows, "mode": result["mode"]}
api/providers.py CHANGED
@@ -1,9 +1,11 @@
1
  """backend/api/providers.py — Health, tools, status, AI health, heartbeat (S354)."""
2
  import os, asyncio, time, logging
 
3
  from fastapi import APIRouter, Request
4
  from fastapi import Depends
5
  from .auth_guard import require_role, AuthRole
6
  from .state import _sb, SENSITIVE, _ai_health_cache, _AI_HEALTH_TTL, _heartbeat_state, _TIMING_STORE, _REPAIR_STATS
 
7
 
8
  router = APIRouter()
9
  _logger = logging.getLogger('agente_ai')
@@ -28,7 +30,7 @@ _heartbeat_task: asyncio.Task | None = None
28
  async def health():
29
  return {
30
  'status': 'ok',
31
- 'version': '3.4.2',
32
  'supabase': _sb is not None,
33
  'backend': 'HuggingFace Spaces / Railway',
34
  }
@@ -221,6 +223,44 @@ async def ai_provider_health(role: AuthRole = Depends(require_role(AuthRole.MACH
221
  from models.ai_client import AIClient
222
  client = AIClient()
223
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  async def _probe(provider) -> dict:
225
  t0 = time.monotonic()
226
  try:
@@ -236,12 +276,15 @@ async def ai_provider_health(role: AuthRole = Depends(require_role(AuthRole.MACH
236
  timeout=8.0,
237
  )
238
  ms = round((time.monotonic() - t0) * 1000)
239
- return {"name": provider.name, "ok": True, "status": "ok", "latency_ms": ms,
240
- "model": provider.default_model.split("/")[-1][:28]}
 
 
241
  except Exception as exc:
242
  ms = round((time.monotonic() - t0) * 1000)
243
- return {"name": provider.name, "ok": False, "status": "error", "latency_ms": ms,
244
- "error": str(exc)[:300], "model": provider.default_model.split("/")[-1][:28]} # S606: 200→300
 
245
 
246
  results = list(await asyncio.gather(*[_probe(p) for p in client.providers]))
247
  payload = {"providers": results, "tested_at": int(time.time() * 1000)}
@@ -427,15 +470,17 @@ async def debug_timing(role: AuthRole = Depends(require_role(AuthRole.MACHINE)))
427
  @router.get("/api/providers/heartbeat")
428
  async def providers_heartbeat(role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
429
  now = int(time.time())
 
 
430
  return {
431
- "status": _heartbeat_state["status"],
432
- "best_provider": _heartbeat_state["best_provider"],
433
- "best_latency_ms": _heartbeat_state["best_latency_ms"],
434
- "providers": _heartbeat_state["providers"],
435
- "last_run_at": _heartbeat_state["last_run_at"],
436
- "next_run_at": _heartbeat_state["next_run_at"],
437
- "runs": _heartbeat_state["runs"],
438
- "error": _heartbeat_state["error"],
439
  "interval_s": _HEARTBEAT_INTERVAL_S,
440
  "server_time": now,
441
  }
@@ -787,7 +832,11 @@ async def health_full(role: AuthRole = Depends(require_role(AuthRole.MACHINE))):
787
  return {"ok": False, "error": str(exc)[:100]}
788
 
789
  # ── Esegui tutti i check in parallelo ─────────────────────────────────────
790
- from .state import _sb as _sb_h, _sb2 as _sb2_h, _sb_fallback as _sbf_h
 
 
 
 
791
 
792
  (
793
  c_sb1,
@@ -837,10 +886,10 @@ async def health_full(role: AuthRole = Depends(require_role(AuthRole.MACHINE))):
837
  supabase_any_ok = c_sb1["ok"] or c_sb2["ok"] or c_sbf["ok"]
838
  critical_ok = supabase_any_ok and c_env["ok"]
839
 
840
- # Non-critical: tutto il resto
841
  non_critical_failed = [
842
  name for name, c in checks.items()
843
- if name != "env_config" and not c.get("ok")
844
  ]
845
 
846
  if not critical_ok: overall = "critical"
@@ -867,7 +916,7 @@ async def health_full(role: AuthRole = Depends(require_role(AuthRole.MACHINE))):
867
 
868
 
869
  # ── S19-FIX: Endpoint per aggiornare modelli deprecati nella flotta ───────────
870
- @router.post("/api/providers/update-models")
871
  async def update_provider_models(role: AuthRole = Depends(require_role(AuthRole.MACHINE))):
872
  """S19: Aggiorna i modelli deprecati nella tabella ai_providers.
873
  Idempotente — sicuro da chiamare più volte.
@@ -876,37 +925,40 @@ async def update_provider_models(role: AuthRole = Depends(require_role(AuthRole.
876
  if _sb is None:
877
  return {"ok": False, "error": "Supabase non configurato", "updated": 0}
878
 
879
- # Mappa: modello_vecchio -> modello_nuovo
 
 
 
880
  MODEL_FIXES = [
881
- ("llama-3.1-70b-versatile", "llama-3.3-70b-versatile"),
882
- ("llama3.1-70b", "llama-4-scout"),
883
- ("llama-3.1-405b-instruct", "meta/llama-3.3-70b-instruct"),
884
- ("llama-3.1-405b", "meta-llama/llama-4-scout:free"),
885
- ("llama3-70b", "DeepSeek-V3.2"),
886
- ("gemini-1.5-flash", "gemini-2.5-flash-lite"),
887
- ("gemini-1.5-pro", "gemini-2.5-flash-lite"),
888
- ("gpt-oss-120b", "llama-4-scout"),
889
- ("claude-3.5-sonnet", "meta-llama/llama-4-scout:free"),
890
  ]
891
 
892
  import asyncio as _aio
893
  total_updated = 0
894
  results = []
895
 
896
- for old_model, new_model in MODEL_FIXES:
897
  try:
898
  r = await _aio.to_thread(
899
- lambda om=old_model, nm=new_model: _sb.table("ai_providers")
900
  .update({"default_model": nm})
 
901
  .eq("default_model", om)
902
  .execute()
903
  )
904
  n = len(r.data) if r.data else 0
905
  total_updated += n
906
  if n > 0:
907
- results.append({"old": old_model, "new": new_model, "rows": n})
908
  except Exception as exc:
909
- results.append({"old": old_model, "new": new_model, "error": str(exc)[:100]})
910
 
911
  # Disattiva provider E2B (non sono LLM provider)
912
  try:
 
1
  """backend/api/providers.py — Health, tools, status, AI health, heartbeat (S354)."""
2
  import os, asyncio, time, logging
3
+ import requests
4
  from fastapi import APIRouter, Request
5
  from fastapi import Depends
6
  from .auth_guard import require_role, AuthRole
7
  from .state import _sb, SENSITIVE, _ai_health_cache, _AI_HEALTH_TTL, _heartbeat_state, _TIMING_STORE, _REPAIR_STATS
8
+ from .version import RUNTIME_VERSION
9
 
10
  router = APIRouter()
11
  _logger = logging.getLogger('agente_ai')
 
30
  async def health():
31
  return {
32
  'status': 'ok',
33
+ 'version': RUNTIME_VERSION,
34
  'supabase': _sb is not None,
35
  'backend': 'HuggingFace Spaces / Railway',
36
  }
 
223
  from models.ai_client import AIClient
224
  client = AIClient()
225
 
226
+ def _classify_probe_error(exc: Exception) -> str:
227
+ message = str(exc).lower()
228
+ if "429" in message or "rate limit" in message or "quota" in message:
229
+ return "rate_limit_or_quota"
230
+ if "402" in message or "payment" in message or "credit" in message:
231
+ return "credits_exhausted"
232
+ if "401" in message or "403" in message or "unauthorized" in message or "forbidden" in message:
233
+ return "authentication_or_permission"
234
+ if "timeout" in message or "timed out" in message:
235
+ return "timeout"
236
+ if "404" in message or "not found" in message:
237
+ return "model_or_endpoint_not_found"
238
+ return "upstream_error"
239
+
240
+ async def _openrouter_key_limits(provider) -> dict:
241
+ if provider.name != "openrouter":
242
+ return {}
243
+ try:
244
+ response = await asyncio.to_thread(
245
+ requests.get,
246
+ "https://openrouter.ai/api/v1/key",
247
+ headers={"Authorization": f"Bearer {provider.api_key}"},
248
+ timeout=8,
249
+ )
250
+ body = response.json() if response.content else {}
251
+ data = body.get("data") if isinstance(body, dict) else {}
252
+ if response.status_code >= 400:
253
+ return {"key_status": response.status_code, "key_error_class": _classify_probe_error(RuntimeError(f"HTTP {response.status_code}"))}
254
+ return {
255
+ "key_status": response.status_code,
256
+ "limit_remaining": data.get("limit_remaining"),
257
+ "limit_reset": data.get("limit_reset"),
258
+ "is_free_tier": data.get("is_free_tier"),
259
+ "usage_daily": data.get("usage_daily"),
260
+ }
261
+ except Exception as exc:
262
+ return {"key_error_class": _classify_probe_error(exc)}
263
+
264
  async def _probe(provider) -> dict:
265
  t0 = time.monotonic()
266
  try:
 
276
  timeout=8.0,
277
  )
278
  ms = round((time.monotonic() - t0) * 1000)
279
+ result = {"name": provider.name, "profile": provider.profile, "ok": True, "status": "ok", "latency_ms": ms,
280
+ "model": provider.default_model.split("/")[-1][:40]}
281
+ result.update(await _openrouter_key_limits(provider))
282
+ return result
283
  except Exception as exc:
284
  ms = round((time.monotonic() - t0) * 1000)
285
+ return {"name": provider.name, "profile": provider.profile, "ok": False, "status": "error", "latency_ms": ms,
286
+ "error_class": _classify_probe_error(exc),
287
+ "error": str(exc)[:300], "model": provider.default_model.split("/")[-1][:40], **(await _openrouter_key_limits(provider))}
288
 
289
  results = list(await asyncio.gather(*[_probe(p) for p in client.providers]))
290
  payload = {"providers": results, "tested_at": int(time.time() * 1000)}
 
470
  @router.get("/api/providers/heartbeat")
471
  async def providers_heartbeat(role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
472
  now = int(time.time())
473
+ # Il primo ciclo async potrebbe non essere ancora partito: la route di health
474
+ # deve restituire uno snapshot coerente, non propagare un KeyError come HTTP 500.
475
  return {
476
+ "status": _heartbeat_state.get("status", "idle"),
477
+ "best_provider": _heartbeat_state.get("best_provider"),
478
+ "best_latency_ms": _heartbeat_state.get("best_latency_ms"),
479
+ "providers": _heartbeat_state.get("providers", []),
480
+ "last_run_at": _heartbeat_state.get("last_run_at"),
481
+ "next_run_at": _heartbeat_state.get("next_run_at"),
482
+ "runs": _heartbeat_state.get("runs", 0),
483
+ "error": _heartbeat_state.get("error"),
484
  "interval_s": _HEARTBEAT_INTERVAL_S,
485
  "server_time": now,
486
  }
 
832
  return {"ok": False, "error": str(exc)[:100]}
833
 
834
  # ── Esegui tutti i check in parallelo ─────────────────────────────────────
835
+ from .state import _sb as _sb_h, _clients as _sb_clients_h
836
+ # FIX-HEALTH-FULL: _sb2 e _sb_fallback non esistono in state.py.
837
+ # Estraiamo i client dal pool _clients (A=primary, B=secondary, C=fallback).
838
+ _sb2_h = _sb_clients_h[1]["client"] if len(_sb_clients_h) > 1 else None
839
+ _sbf_h = _sb_clients_h[2]["client"] if len(_sb_clients_h) > 2 else None
840
 
841
  (
842
  c_sb1,
 
886
  supabase_any_ok = c_sb1["ok"] or c_sb2["ok"] or c_sbf["ok"]
887
  critical_ok = supabase_any_ok and c_env["ok"]
888
 
889
+ # Non-critical: tutto il resto (GAP-UX-FIX: ignora redis/telegram non configurati)
890
  non_critical_failed = [
891
  name for name, c in checks.items()
892
+ if name not in ["env_config", "redis", "telegram"] and not c.get("ok")
893
  ]
894
 
895
  if not critical_ok: overall = "critical"
 
916
 
917
 
918
  # ── S19-FIX: Endpoint per aggiornare modelli deprecati nella flotta ───────────
919
+ @router.post("/update-models")
920
  async def update_provider_models(role: AuthRole = Depends(require_role(AuthRole.MACHINE))):
921
  """S19: Aggiorna i modelli deprecati nella tabella ai_providers.
922
  Idempotente — sicuro da chiamare più volte.
 
925
  if _sb is None:
926
  return {"ok": False, "error": "Supabase non configurato", "updated": 0}
927
 
928
+ # Mappa provider-specifica: (provider, modello_vecchio, modello_nuovo).
929
+ # Lo stesso ID modello può essere valido su un provider e non su un altro:
930
+ # filtrare per `name` evita di applicare un formato incompatibile alla riga sbagliata.
931
+ # GPT-OSS 120B non compare come vecchio valore perché è già un modello supportato.
932
  MODEL_FIXES = [
933
+ ("groq", "llama-3.1-70b-versatile", "qwen/qwen3.6-27b"),
934
+ ("cerebras", "llama3.1-70b", "gpt-oss-120b"),
935
+ ("nvidia", "llama-3.1-405b-instruct", "meta/llama-3.3-70b-instruct"),
936
+ ("openrouter", "llama-3.1-405b", "openrouter/free"),
937
+ ("sambanova", "llama3-70b", "DeepSeek-V3.2"),
938
+ ("gemini", "gemini-1.5-flash", "gemini-3.5-flash-lite"),
939
+ ("gemini", "gemini-1.5-pro", "gemini-3.6-flash"),
940
+ ("openrouter", "claude-3.5-sonnet", "openrouter/free"),
 
941
  ]
942
 
943
  import asyncio as _aio
944
  total_updated = 0
945
  results = []
946
 
947
+ for provider_name, old_model, new_model in MODEL_FIXES:
948
  try:
949
  r = await _aio.to_thread(
950
+ lambda pn=provider_name, om=old_model, nm=new_model: _sb.table("ai_providers")
951
  .update({"default_model": nm})
952
+ .eq("name", pn)
953
  .eq("default_model", om)
954
  .execute()
955
  )
956
  n = len(r.data) if r.data else 0
957
  total_updated += n
958
  if n > 0:
959
+ results.append({"provider": provider_name, "old": old_model, "new": new_model, "rows": n})
960
  except Exception as exc:
961
+ results.append({"provider": provider_name, "old": old_model, "new": new_model, "error": str(exc)[:100]})
962
 
963
  # Disattiva provider E2B (non sono LLM provider)
964
  try:
api/public_status.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DTO pubblico e sanificato dello stato del servizio.
2
+
3
+ Questa route non legge agent_tasks, sessioni operative o log. La tabella
4
+ public_dashboard_snapshot viene aggiornata dal backend con service_role e letta
5
+ qui tramite una whitelist di campi.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ from typing import Any
12
+
13
+ from fastapi import APIRouter
14
+
15
+ from .state import sb
16
+
17
+ _logger = logging.getLogger("agente_ai.api.public_status")
18
+ router = APIRouter(prefix="/api/public", tags=["public"])
19
+
20
+ _PUBLIC_FIELDS = (
21
+ "singleton,service_status,active_sessions,queued_tasks,in_progress_tasks,"
22
+ "app_version,updated_at"
23
+ )
24
+
25
+
26
+ @router.get("/status")
27
+ async def public_status() -> dict[str, Any]:
28
+ """Restituisce esclusivamente lo snapshot deliberatamente pubblico."""
29
+ client = sb()
30
+ if client is None:
31
+ return _degraded_snapshot("database_unavailable")
32
+
33
+ def operation():
34
+ return client.table("public_dashboard_snapshot").select(_PUBLIC_FIELDS).eq("singleton", True).limit(1).execute()
35
+
36
+ try:
37
+ result = await asyncio.to_thread(operation)
38
+ except Exception as exc:
39
+ _logger.warning("public status snapshot unavailable: %s", type(exc).__name__)
40
+ return _degraded_snapshot("snapshot_unavailable")
41
+
42
+ row = (result.data or [None])[0]
43
+ if not row:
44
+ return _degraded_snapshot("snapshot_not_initialized")
45
+
46
+ return {
47
+ "service_status": str(row.get("service_status") or "unknown"),
48
+ "active_sessions": int(row.get("active_sessions") or 0),
49
+ "queued_tasks": int(row.get("queued_tasks") or 0),
50
+ "in_progress_tasks": int(row.get("in_progress_tasks") or 0),
51
+ "app_version": row.get("app_version"),
52
+ "updated_at": row.get("updated_at"),
53
+ }
54
+
55
+
56
+ def _degraded_snapshot(reason: str) -> dict[str, Any]:
57
+ """Safe public response while the operational snapshot is unavailable.
58
+
59
+ The public endpoint is used by lightweight status surfaces. Returning a
60
+ deliberate degraded state keeps those surfaces functional without
61
+ exposing database errors, internal topology, or operational records.
62
+ """
63
+ return {
64
+ "service_status": "degraded",
65
+ "active_sessions": 0,
66
+ "queued_tasks": 0,
67
+ "in_progress_tasks": 0,
68
+ "app_version": None,
69
+ "updated_at": None,
70
+ "degraded": True,
71
+ "reason": reason,
72
+ }
api/research.py CHANGED
@@ -283,7 +283,7 @@ async def _synthesize(topic: str, sources: list[dict]) -> str:
283
  "https://api.groq.com/openai/v1/chat/completions",
284
  headers={"Authorization": f"Bearer {groq_key}", "Content-Type": "application/json"},
285
  json={
286
- "model": "llama-3.1-8b-instant",
287
  "max_tokens": 700,
288
  "messages": [
289
  {"role": "system", "content": "Sei un assistente che sintetizza informazioni web. Rispondi sempre in italiano. Sii conciso e preciso."},
 
283
  "https://api.groq.com/openai/v1/chat/completions",
284
  headers={"Authorization": f"Bearer {groq_key}", "Content-Type": "application/json"},
285
  json={
286
+ "model": "openai/gpt-oss-20b",
287
  "max_tokens": 700,
288
  "messages": [
289
  {"role": "system", "content": "Sei un assistente che sintetizza informazioni web. Rispondi sempre in italiano. Sii conciso e preciso."},
api/scheduler.py CHANGED
@@ -26,6 +26,7 @@ Route:
26
  import asyncio
27
  import datetime
28
  import json
 
29
  from .state import safe_json_dumps
30
  import os
31
  import time
@@ -198,6 +199,23 @@ def _is_due(task: dict, now_ms: int) -> bool:
198
  return False
199
 
200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  def _advance_trigger(trigger: dict, now_ms: int) -> dict:
202
  t = dict(trigger)
203
  tt = t.get("type")
@@ -206,14 +224,17 @@ def _advance_trigger(trigger: dict, now_ms: int) -> dict:
206
  elif tt == "daily":
207
  hour = t.get("hour", 9)
208
  minute = t.get("minute", 0)
209
- nxt = datetime.datetime.now().replace(
210
- hour=hour, minute=minute, second=0, microsecond=0
211
- )
212
- nxt_ms = int(nxt.timestamp() * 1000)
213
- if nxt_ms <= now_ms:
214
- nxt = nxt + datetime.timedelta(days=1)
215
- nxt_ms = int(nxt.timestamp() * 1000)
216
- t["nextRun"] = nxt_ms
 
 
 
217
  # once / on_open: nessun avanzamento
218
  return t
219
 
@@ -256,7 +277,12 @@ async def _run_goal(goal: str, conversation_id: Optional[str] = None, risk: str
256
  loop.run(goal=goal, context="", max_steps=8),
257
  timeout=_timeout_s,
258
  )
259
- output = result.get("output", "") if isinstance(result, dict) else str(result)
 
 
 
 
 
260
  return str(output)[:1000]
261
 
262
  except asyncio.TimeoutError:
 
26
  import asyncio
27
  import datetime
28
  import json
29
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
30
  from .state import safe_json_dumps
31
  import os
32
  import time
 
199
  return False
200
 
201
 
202
+ def _daily_timezone(trigger: dict) -> ZoneInfo | None:
203
+ """Ritorna il fuso IANA salvato dal browser, se disponibile e valido.
204
+
205
+ I task daily creati prima dell'introduzione del campo ``timeZone`` restano
206
+ compatibili: l'assenza o un valore non valido mantiene il calcolo nel fuso
207
+ locale del server invece di bloccare la pianificazione.
208
+ """
209
+ time_zone = trigger.get("timeZone")
210
+ if not isinstance(time_zone, str) or not time_zone:
211
+ return None
212
+ try:
213
+ return ZoneInfo(time_zone)
214
+ except ZoneInfoNotFoundError:
215
+ logger.warning("Scheduler: timezone daily non valida (%r), fallback server-local", time_zone)
216
+ return None
217
+
218
+
219
  def _advance_trigger(trigger: dict, now_ms: int) -> dict:
220
  t = dict(trigger)
221
  tt = t.get("type")
 
224
  elif tt == "daily":
225
  hour = t.get("hour", 9)
226
  minute = t.get("minute", 0)
227
+ time_zone = _daily_timezone(t)
228
+ # Usa il timestamp dell'esecuzione, non l'orologio nel momento in cui
229
+ # il task termina: preserva la semantica esistente anche per task lunghi.
230
+ now = datetime.datetime.fromtimestamp(
231
+ now_ms / 1000,
232
+ tz=time_zone,
233
+ ) if time_zone else datetime.datetime.fromtimestamp(now_ms / 1000)
234
+ nxt = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
235
+ if nxt <= now:
236
+ nxt = nxt + datetime.timedelta(days=1)
237
+ t["nextRun"] = int(nxt.timestamp() * 1000)
238
  # once / on_open: nessun avanzamento
239
  return t
240
 
 
277
  loop.run(goal=goal, context="", max_steps=8),
278
  timeout=_timeout_s,
279
  )
280
+ if isinstance(result, dict):
281
+ # Preserve structured loop outcomes; never turn controlled failures into empty strings.
282
+ output = next((result.get(key) for key in ("output", "answer", "explanation", "error")
283
+ if result.get(key)), "")
284
+ else:
285
+ output = str(result)
286
  return str(output)[:1000]
287
 
288
  except asyncio.TimeoutError:
api/speculative.py CHANGED
@@ -2,7 +2,7 @@
2
  backend/api/speculative.py — Speculative Tool Firing (S361)
3
 
4
  Pre-fires tool calls in parallel while the main model is processing.
5
- Uses Groq llama-3.1-8b-instant for ultra-fast intent extraction (~200-300ms).
6
  Results stored in a per-goal cache, consumed by _run_direct_tools before actual execution.
7
 
8
  Architecture:
@@ -141,7 +141,7 @@ def _get_spec_groq_client() -> Any:
141
 
142
  async def _extract_tools_fast(goal: str) -> list[dict]:
143
  """
144
- Usa Groq llama-3.1-8b-instant per estrarre tool calls in ~300ms.
145
  Fallback silenzioso → [] se timeout, errore o key assente.
146
  """
147
  if not os.getenv("GROQ_API_KEY"):
@@ -154,7 +154,7 @@ async def _extract_tools_fast(goal: str) -> list[dict]:
154
  resp = await asyncio.wait_for(
155
  asyncio.to_thread(
156
  client.chat.completions.create,
157
- model="llama-3.1-8b-instant",
158
  messages=[{"role": "user", "content": prompt}],
159
  temperature=0.0,
160
  max_tokens=400, # S587: 256→400 — JSON array da goal[:500] supera 256 tok
 
2
  backend/api/speculative.py — Speculative Tool Firing (S361)
3
 
4
  Pre-fires tool calls in parallel while the main model is processing.
5
+ Uses Groq openai/gpt-oss-20b for ultra-fast intent extraction (~200-300ms).
6
  Results stored in a per-goal cache, consumed by _run_direct_tools before actual execution.
7
 
8
  Architecture:
 
141
 
142
  async def _extract_tools_fast(goal: str) -> list[dict]:
143
  """
144
+ Usa Groq openai/gpt-oss-20b per estrarre tool calls in ~300ms.
145
  Fallback silenzioso → [] se timeout, errore o key assente.
146
  """
147
  if not os.getenv("GROQ_API_KEY"):
 
154
  resp = await asyncio.wait_for(
155
  asyncio.to_thread(
156
  client.chat.completions.create,
157
+ model="openai/gpt-oss-20b",
158
  messages=[{"role": "user", "content": prompt}],
159
  temperature=0.0,
160
  max_tokens=400, # S587: 256→400 — JSON array da goal[:500] supera 256 tok
api/startup_migration.py CHANGED
@@ -36,6 +36,18 @@ _SENSITIVE_TABLES = [
36
 
37
  _RLS_FIX_SQL = """
38
  -- ARCH-F1.5 + SEC-RLS-FIX: RLS GRANT fix — idempotente, sicuro da ri-eseguire.
 
 
 
 
 
 
 
 
 
 
 
 
39
  GRANT USAGE ON SCHEMA public TO anon;
40
  GRANT USAGE ON SCHEMA public TO authenticated;
41
 
 
36
 
37
  _RLS_FIX_SQL = """
38
  -- ARCH-F1.5 + SEC-RLS-FIX: RLS GRANT fix — idempotente, sicuro da ri-eseguire.
39
+ -- Compatibility fix: older Supabase projects created vfs_files without the
40
+ -- conversation namespace used by the VFS router. Keep this safe on every boot.
41
+ DO $$
42
+ BEGIN
43
+ IF to_regclass('public.vfs_files') IS NOT NULL THEN
44
+ ALTER TABLE public.vfs_files
45
+ ADD COLUMN IF NOT EXISTS conversation_id TEXT NOT NULL DEFAULT '';
46
+ CREATE INDEX IF NOT EXISTS vfs_files_conversation_idx
47
+ ON public.vfs_files (conversation_id);
48
+ END IF;
49
+ END $$;
50
+
51
  GRANT USAGE ON SCHEMA public TO anon;
52
  GRANT USAGE ON SCHEMA public TO authenticated;
53
 
api/state.py CHANGED
@@ -5,9 +5,10 @@ TTL constants, prune helpers. Extracted from main.py — zero behaviour change.
5
  """
6
  import os, time, asyncio as _asyncio_mod, json as _json, re as _re
7
  import logging
8
- from typing import Optional, Any
9
- from fastapi import HTTPException, APIRouter, Request
10
  from pydantic import BaseModel, field_validator
 
11
 
12
  _logger = logging.getLogger("api.state")
13
 
@@ -32,7 +33,6 @@ _current_client_idx = 0
32
 
33
  try:
34
  from supabase import create_client
35
-
36
  # S-FIX: Preferisce SERVICE_ROLE_KEY per bypassare RLS nelle operazioni di sistema
37
  def _get_key(p):
38
  return os.getenv(f"SUPABASE_SERVICE_ROLE_KEY_{p}") or os.getenv(f"SUPABASE_SERVICE_ROLE_{p}") or \
@@ -46,7 +46,6 @@ try:
46
  {"id": "D", "url": os.getenv("SUPABASE_URL_4") or os.getenv("SUPABASE_URL_D"), "key": _get_key("D")},
47
  {"id": "E", "url": os.getenv("SUPABASE_URL_5") or os.getenv("SUPABASE_URL_E"), "key": _get_key("E")},
48
  ]
49
-
50
  for cfg in PROJECT_CONFIGS:
51
  if cfg["url"] and cfg["key"]:
52
  try:
@@ -70,8 +69,43 @@ def _get_sb() -> Any:
70
  return entry["client"]
71
  return _clients[0]["client"] if _clients else None
72
 
 
 
 
 
 
 
 
 
73
  _sb = _get_sb()
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  # ── SENSITIVE keys set (Z-GAP-4) ──────────────────────────────────────────────
76
  SENSITIVE = {
77
  'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'GEMINI_API_KEY', 'GROQ_API_KEY',
@@ -85,6 +119,9 @@ SENSITIVE = {
85
  'VAULT_KEY', 'INTERNAL_TOKEN', 'DEPLOY_SECRET', 'WEBHOOK_TOKEN',
86
  'TERMINAL_SECRET', 'EXEC_TOKEN', 'VITE_INTERNAL_TOKEN', 'VITE_TERMINAL_SECRET',
87
  'VITE_OPENROUTER_API_KEY', 'VITE_HF_TOKEN', 'VITE_GROQ_API_KEY',
 
 
 
88
  'GH_PAGES_TOKEN', 'VERCEL_TOKEN',
89
  }
90
 
@@ -104,6 +141,10 @@ _AGENT_TASK_MAX = 200
104
  _ai_health_cache: dict = {"data": None, "at": 0.0}
105
  _AI_HEALTH_TTL = 60.0
106
  _heartbeat_state: dict = {
 
 
 
 
107
  "last_run_at": None,
108
  "next_run_at": None,
109
  "best_provider": None,
@@ -112,9 +153,25 @@ _heartbeat_state: dict = {
112
  "runs": 0,
113
  }
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  # ── Singleton Getters ─────────────────────────────────────────────────────────
116
  def get_supabase() -> Optional[Any]:
117
- """Ritorna il client Supabase primario."""
118
  return _sb
119
 
120
  _mem_manager: Any = None
@@ -123,12 +180,16 @@ def _get_mem_manager() -> Any:
123
  global _mem_manager, _mem_manager_inited
124
  if _mem_manager_inited: return _mem_manager
125
  try:
126
- from memory.memory_manager import MemoryManager
 
127
  try:
128
- _mem_manager = MemoryManager()
129
  _mem_manager_inited = True
130
- except RuntimeError: pass
131
- except Exception: _mem_manager = None
 
 
 
132
  return _mem_manager
133
 
134
  _executor: Any = None
@@ -151,6 +212,9 @@ def _get_ai_client() -> Any:
151
  except Exception: _ai_client = None
152
  return _ai_client
153
 
 
 
 
154
  _planner: Any = None
155
  def _get_planner() -> Any:
156
  global _planner
@@ -158,30 +222,40 @@ def _get_planner() -> Any:
158
  try:
159
  from agents.planner import Planner
160
  _planner = Planner(llm_client=_get_ai_client())
161
- except Exception: _planner = None
 
162
  return _planner
163
 
164
  # ── Prune helpers ─────────────────────────────────────────────────────────────
165
  def _prune_checkpoints() -> None:
166
  now = int(time.time() * 1000)
167
- expired = [k for k, v in list(_task_checkpoints.items()) if now - v.get('savedAt', 0) > _CHECKPOINT_TTL_MS]
168
- for k in expired: _task_checkpoints.pop(k, None)
 
 
169
  if len(_task_checkpoints) > _CHECKPOINT_MAX:
170
- oldest = sorted(list(_task_checkpoints.items()), key=lambda x: x[1].get('savedAt', 0))
171
- for k, _ in oldest[:len(_task_checkpoints) - _CHECKPOINT_MAX]: _task_checkpoints.pop(k, None)
 
172
 
173
  def _prune_agent_tasks() -> None:
174
  now = int(time.time() * 1000)
175
- expired = [k for k, v in list(_agent_tasks.items()) if v.get('status') in ('SUCCESS', 'ERROR', 'CANCELLED') and now - v.get('created_at', 0) > _AGENT_TASK_TTL_MS]
176
- for k in expired: _agent_tasks.pop(k, None)
 
 
 
177
  if len(_agent_tasks) > _AGENT_TASK_MAX:
178
- oldest = sorted(list(_agent_tasks.items()), key=lambda x: x[1].get('created_at', 0))
179
- for k, _ in oldest[:len(_agent_tasks) - _AGENT_TASK_MAX]: _agent_tasks.pop(k, None)
 
180
 
181
  def _prune_loop_registry() -> None:
182
  now = time.time()
183
- stale = [k for k, v in list(_loop_registry.items()) if v.get('done') and now - v.get('finished_at', 0.0) > _LOOP_REGISTRY_TTL_S]
184
- for k in stale: _loop_registry.pop(k, None)
 
 
185
 
186
  # ── Shared Pydantic models ────────────────────────────────────────────────────
187
  class ReasonLoopIn(BaseModel):
@@ -196,7 +270,8 @@ class ReasonLoopIn(BaseModel):
196
  @field_validator('goal', mode='before')
197
  @classmethod
198
  def validate_goal(cls, v: object) -> str:
199
- if not isinstance(v, str) or not v.strip(): raise ValueError('goal must be a non-empty string')
 
200
  return v.strip()
201
 
202
  @field_validator('context', 'learning_hints', mode='before')
@@ -224,7 +299,8 @@ class AgentTaskIn(BaseModel):
224
  @field_validator('goal', mode='before')
225
  @classmethod
226
  def validate_goal(cls, v: object) -> str:
227
- if not isinstance(v, str) or not v.strip(): raise ValueError('goal must be a non-empty string')
 
228
  return v.strip()
229
 
230
  @field_validator('context', 'learning_hints', mode='before')
@@ -232,40 +308,3 @@ class AgentTaskIn(BaseModel):
232
  def coerce_list(cls, v: object) -> list:
233
  return v if isinstance(v, list) else []
234
 
235
- @router.get("/health")
236
- async def health_check(request: Request):
237
- """
238
- Z-GAP-3: Healthcheck endpoint per monitoraggio deploy (TMA/HF).
239
- Verifica lo stato del server e la connettività al database.
240
- """
241
- health = {
242
- "status": "ok",
243
- "timestamp": time.time(),
244
- "version": "1.5.5",
245
- "database": "unknown",
246
- "pool_size": len(_clients)
247
- }
248
- try:
249
- if _sb:
250
- # S-FIX: Verifica reale con try-except per gestire errori PostgREST malformati
251
- try:
252
- # S-FIX: select('key') è più leggero di select('count') per health check
253
- res = _sb.table("agent_memory").select("key").limit(1).execute()
254
- health["database"] = "connected"
255
- except Exception as inner_e:
256
- # Se il client corrente fallisce, lo marchiamo per il pool
257
- for entry in _clients:
258
- if entry["client"] == _sb:
259
- entry["status"] = "failed"
260
- break
261
- raise inner_e
262
- else:
263
- health["database"] = "disconnected"
264
- except Exception as e:
265
- health["status"] = "degraded"
266
- # S-FIX: Estrae il messaggio di errore in modo più pulito
267
- err_msg = str(e)
268
- if "JSON could not be generated" in err_msg:
269
- err_msg = "PostgREST JSON error (likely RLS or schema mismatch)"
270
- health["database"] = f"error: {err_msg[:100]}"
271
- return health
 
5
  """
6
  import os, time, asyncio as _asyncio_mod, json as _json, re as _re
7
  import logging
8
+ from typing import Optional, Any, AsyncIterator, List, Tuple
9
+ from fastapi import HTTPException, APIRouter, Request, Body
10
  from pydantic import BaseModel, field_validator
11
+ from .version import RUNTIME_VERSION
12
 
13
  _logger = logging.getLogger("api.state")
14
 
 
33
 
34
  try:
35
  from supabase import create_client
 
36
  # S-FIX: Preferisce SERVICE_ROLE_KEY per bypassare RLS nelle operazioni di sistema
37
  def _get_key(p):
38
  return os.getenv(f"SUPABASE_SERVICE_ROLE_KEY_{p}") or os.getenv(f"SUPABASE_SERVICE_ROLE_{p}") or \
 
46
  {"id": "D", "url": os.getenv("SUPABASE_URL_4") or os.getenv("SUPABASE_URL_D"), "key": _get_key("D")},
47
  {"id": "E", "url": os.getenv("SUPABASE_URL_5") or os.getenv("SUPABASE_URL_E"), "key": _get_key("E")},
48
  ]
 
49
  for cfg in PROJECT_CONFIGS:
50
  if cfg["url"] and cfg["key"]:
51
  try:
 
69
  return entry["client"]
70
  return _clients[0]["client"] if _clients else None
71
 
72
+ def sb() -> Any:
73
+ """Return the current Supabase client for router compatibility.
74
+
75
+ Routers use this public accessor so pool rotation and failed-client
76
+ avoidance remain centralized in ``_get_sb``.
77
+ """
78
+ return _get_sb()
79
+
80
  _sb = _get_sb()
81
 
82
+ @router.get("/health")
83
+ async def health_check(request: Request):
84
+ health = {
85
+ "status": "ok",
86
+ "timestamp": time.time(),
87
+ "version": RUNTIME_VERSION,
88
+ "database": "unknown",
89
+ "pool_size": len(_clients)
90
+ }
91
+ try:
92
+ if _sb:
93
+ try:
94
+ res = _sb.table("agent_memory").select("key").limit(1).execute()
95
+ health["database"] = "connected"
96
+ except Exception as inner_e:
97
+ for entry in _clients:
98
+ if entry["client"] == _sb:
99
+ entry["status"] = "failed"
100
+ break
101
+ raise inner_e
102
+ else:
103
+ health["database"] = "disconnected"
104
+ except Exception as e:
105
+ health["status"] = "degraded"
106
+ health["database"] = f"RAW_ERROR: {str(e)}"
107
+ return health
108
+
109
  # ── SENSITIVE keys set (Z-GAP-4) ──────────────────────────────────────────────
110
  SENSITIVE = {
111
  'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'GEMINI_API_KEY', 'GROQ_API_KEY',
 
119
  'VAULT_KEY', 'INTERNAL_TOKEN', 'DEPLOY_SECRET', 'WEBHOOK_TOKEN',
120
  'TERMINAL_SECRET', 'EXEC_TOKEN', 'VITE_INTERNAL_TOKEN', 'VITE_TERMINAL_SECRET',
121
  'VITE_OPENROUTER_API_KEY', 'VITE_HF_TOKEN', 'VITE_GROQ_API_KEY',
122
+ 'OPENROUTER_PROFILES_JSON', 'GROQ_PROFILES_JSON', 'CEREBRAS_PROFILES_JSON',
123
+ 'SAMBANOVA_PROFILES_JSON', 'GEMINI_PROFILES_JSON', 'NVIDIA_PROFILES_JSON',
124
+ 'HF_ROUTER_PROFILES_JSON', 'HF_MODEL',
125
  'GH_PAGES_TOKEN', 'VERCEL_TOKEN',
126
  }
127
 
 
141
  _ai_health_cache: dict = {"data": None, "at": 0.0}
142
  _AI_HEALTH_TTL = 60.0
143
  _heartbeat_state: dict = {
144
+ # Stato completo disponibile già al boot: le route di osservabilità non
145
+ # devono dipendere dal primo ciclo async per avere le chiavi di risposta.
146
+ "status": "idle",
147
+ "error": None,
148
  "last_run_at": None,
149
  "next_run_at": None,
150
  "best_provider": None,
 
153
  "runs": 0,
154
  }
155
 
156
+ # ── Telemetry & Timing ────────────────────────────────────────────────────────
157
+ # Shared by the agent loop and the provider diagnostics endpoint. Keep this
158
+ # bounded so long-running workers cannot grow without limit.
159
+ _TIMING_STORE: dict[str, list[float]] = {}
160
+ _REPAIR_STATS: dict[str, int] = {}
161
+
162
+ def record_timing(key: str, duration_ms: float) -> None:
163
+ """Record a bounded latency sample for agent/provider diagnostics."""
164
+ samples = _TIMING_STORE.setdefault(key, [])
165
+ samples.append(duration_ms)
166
+ if len(samples) > 100:
167
+ samples.pop(0)
168
+
169
+ def increment_stat(key: str, delta: int = 1) -> None:
170
+ """Increment an aggregated agent quality/recovery counter."""
171
+ _REPAIR_STATS[key] = _REPAIR_STATS.get(key, 0) + delta
172
+
173
  # ── Singleton Getters ─────────────────────────────────────────────────────────
174
  def get_supabase() -> Optional[Any]:
 
175
  return _sb
176
 
177
  _mem_manager: Any = None
 
180
  global _mem_manager, _mem_manager_inited
181
  if _mem_manager_inited: return _mem_manager
182
  try:
183
+ from memory.manager import MemoryManager
184
+ _mem_manager = MemoryManager(sb_client=_get_sb())
185
  try:
186
+ _asyncio_mod.create_task(_mem_manager.init())
187
  _mem_manager_inited = True
188
+ except RuntimeError:
189
+ # No running event loop during import; the async getter initializes it.
190
+ pass
191
+ except Exception:
192
+ _mem_manager = None
193
  return _mem_manager
194
 
195
  _executor: Any = None
 
212
  except Exception: _ai_client = None
213
  return _ai_client
214
 
215
+ async def _get_mem_manager_async() -> Any:
216
+ return _get_mem_manager()
217
+
218
  _planner: Any = None
219
  def _get_planner() -> Any:
220
  global _planner
 
222
  try:
223
  from agents.planner import Planner
224
  _planner = Planner(llm_client=_get_ai_client())
225
+ except Exception:
226
+ _planner = None
227
  return _planner
228
 
229
  # ── Prune helpers ─────────────────────────────────────────────────────────────
230
  def _prune_checkpoints() -> None:
231
  now = int(time.time() * 1000)
232
+ expired = [k for k, v in list(_task_checkpoints.items())
233
+ if now - v.get('savedAt', 0) > _CHECKPOINT_TTL_MS]
234
+ for k in expired:
235
+ _task_checkpoints.pop(k, None)
236
  if len(_task_checkpoints) > _CHECKPOINT_MAX:
237
+ oldest = sorted(_task_checkpoints.items(), key=lambda x: x[1].get('savedAt', 0))
238
+ for k, _ in oldest[:len(_task_checkpoints) - _CHECKPOINT_MAX]:
239
+ _task_checkpoints.pop(k, None)
240
 
241
  def _prune_agent_tasks() -> None:
242
  now = int(time.time() * 1000)
243
+ expired = [k for k, v in list(_agent_tasks.items())
244
+ if v.get('status') in ('SUCCESS', 'ERROR', 'CANCELLED')
245
+ and now - v.get('created_at', 0) > _AGENT_TASK_TTL_MS]
246
+ for k in expired:
247
+ _agent_tasks.pop(k, None)
248
  if len(_agent_tasks) > _AGENT_TASK_MAX:
249
+ oldest = sorted(_agent_tasks.items(), key=lambda x: x[1].get('created_at', 0))
250
+ for k, _ in oldest[:len(_agent_tasks) - _AGENT_TASK_MAX]:
251
+ _agent_tasks.pop(k, None)
252
 
253
  def _prune_loop_registry() -> None:
254
  now = time.time()
255
+ stale = [k for k, v in list(_loop_registry.items())
256
+ if v.get('done') and now - v.get('finished_at', 0.0) > _LOOP_REGISTRY_TTL_S]
257
+ for k in stale:
258
+ _loop_registry.pop(k, None)
259
 
260
  # ── Shared Pydantic models ────────────────────────────────────────────────────
261
  class ReasonLoopIn(BaseModel):
 
270
  @field_validator('goal', mode='before')
271
  @classmethod
272
  def validate_goal(cls, v: object) -> str:
273
+ if not isinstance(v, str) or not v.strip():
274
+ raise ValueError('goal must be a non-empty string')
275
  return v.strip()
276
 
277
  @field_validator('context', 'learning_hints', mode='before')
 
299
  @field_validator('goal', mode='before')
300
  @classmethod
301
  def validate_goal(cls, v: object) -> str:
302
+ if not isinstance(v, str) or not v.strip():
303
+ raise ValueError('goal must be a non-empty string')
304
  return v.strip()
305
 
306
  @field_validator('context', 'learning_hints', mode='before')
 
308
  def coerce_list(cls, v: object) -> list:
309
  return v if isinstance(v, list) else []
310
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
api/telegram_webhook.py CHANGED
@@ -44,12 +44,63 @@ def _get_bot_token() -> str:
44
  return os.getenv("TELEGRAM_BOT_TOKEN", "").strip()
45
 
46
 
47
- async def _tg_reply(chat_id: str | int, text: str, token: str | None = None,
48
- keyboard: dict | None = None) -> None:
49
- """Invia risposta al chat_id con HTML + opzionale inline keyboard."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  bot_token = token or _get_bot_token()
51
  if not bot_token:
52
- return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  payload: dict = {
54
  "chat_id": chat_id,
55
  "text": text,
@@ -58,39 +109,24 @@ async def _tg_reply(chat_id: str | int, text: str, token: str | None = None,
58
  }
59
  if keyboard:
60
  payload["reply_markup"] = keyboard
61
- try:
62
- import httpx
63
- async with httpx.AsyncClient(timeout=8.0) as c:
64
- await c.post(
65
- f"https://api.telegram.org/bot{bot_token}/sendMessage",
66
- json=payload,
67
- )
68
- except Exception as exc:
69
- _logger.warning("tg_reply error: %s", exc)
70
 
71
 
72
  async def _tg_answer_callback(callback_query_id: str, text: str = "", token: str | None = None) -> None:
73
- """Risponde a un callback_query (obbligatorio per chiudere il loading sui buttons)."""
74
- bot_token = token or _get_bot_token()
75
- if not bot_token:
76
  return
77
- try:
78
- import httpx
79
- async with httpx.AsyncClient(timeout=5.0) as c:
80
- await c.post(
81
- f"https://api.telegram.org/bot{bot_token}/answerCallbackQuery",
82
- json={"callback_query_id": callback_query_id, "text": text, "show_alert": False},
83
- )
84
- except Exception as exc:
85
- _logger.debug("answer_callback error: %s", exc)
86
 
87
 
88
  async def _tg_send(chat_id: str | int, text: str, token: str | None = None,
89
  keyboard: dict | None = None) -> str | None:
90
- """Invia messaggio e ritorna il message_id (per editMessageText streaming)."""
91
- bot_token = token or _get_bot_token()
92
- if not bot_token:
93
- return None
94
  payload: dict = {
95
  "chat_id": chat_id,
96
  "text": text,
@@ -99,26 +135,15 @@ async def _tg_send(chat_id: str | int, text: str, token: str | None = None,
99
  }
100
  if keyboard:
101
  payload["reply_markup"] = keyboard
102
- try:
103
- import httpx
104
- async with httpx.AsyncClient(timeout=8.0) as c:
105
- r = await c.post(
106
- f"https://api.telegram.org/bot{bot_token}/sendMessage",
107
- json=payload,
108
- )
109
- j = r.json()
110
- return str(j.get("result", {}).get("message_id", "")) if j.get("ok") else None
111
- except Exception as exc:
112
- _logger.warning("tg_send error: %s", exc)
113
- return None
114
 
115
 
116
  async def _tg_edit(chat_id: str | int, message_id: str, text: str,
117
  token: str | None = None, keyboard: dict | None = None) -> bool:
118
- """Aggiorna messaggio esistente — streaming live via editMessageText.
119
- Ritorna True se successo. Rate-limit: max 20 edit/min per chat Telegram."""
120
- bot_token = token or _get_bot_token()
121
- if not bot_token or not message_id:
122
  return False
123
  payload: dict = {
124
  "chat_id": chat_id,
@@ -129,17 +154,7 @@ async def _tg_edit(chat_id: str | int, message_id: str, text: str,
129
  }
130
  if keyboard:
131
  payload["reply_markup"] = keyboard
132
- try:
133
- import httpx
134
- async with httpx.AsyncClient(timeout=8.0) as c:
135
- r = await c.post(
136
- f"https://api.telegram.org/bot{bot_token}/editMessageText",
137
- json=payload,
138
- )
139
- return r.json().get("ok", False)
140
- except Exception as exc:
141
- _logger.debug("tg_edit error: %s", exc)
142
- return False
143
 
144
 
145
  async def _tg_photo(
@@ -149,78 +164,66 @@ async def _tg_photo(
149
  token: str | None = None,
150
  keyboard: dict | None = None,
151
  ) -> None:
152
- """Invia foto/chart via sendPhoto Telegram.
153
-
154
- Strategia anti URL-lungo:
155
- 1. POST a quickchart.io → scarica PNG bytes → multipart sendPhoto (no limite URL).
156
- 2. Fallback: invia URL direttamente (funziona se URL < ~2000 chars).
157
- """
158
  bot_token = token or _get_bot_token()
159
  if not bot_token:
160
  return
161
  caption_safe = (caption or "")[:1024]
 
 
 
 
 
 
 
 
 
162
 
163
  import httpx as _hx_p, json as _j_p, urllib.parse as _ul_p, re as _re_p
164
-
165
  png_bytes: bytes | None = None
166
  if "quickchart.io/chart" in photo_url:
167
  try:
168
- m = _re_p.search(r"[?&]c=([^&]+)", photo_url)
169
- if m:
170
- cfg_dict = _j_p.loads(_ul_p.unquote(m.group(1)))
171
- async with _hx_p.AsyncClient(timeout=20.0) as c:
172
- qr = await c.post(
173
  "https://quickchart.io/chart",
174
  json={"chart": cfg_dict, "width": 720, "height": 420,
175
  "backgroundColor": "white", "format": "png"},
176
  )
177
- if qr.status_code == 200 and qr.headers.get("content-type", "").startswith("image/"):
178
- png_bytes = qr.content
179
- _logger.debug("tg_photo: quickchart POST ok, %d bytes", len(png_bytes))
180
  except Exception as exc:
181
- _logger.debug("tg_photo: quickchart POST fallback: %s", exc)
182
 
183
  try:
184
- import httpx as _hx_s
185
- async with _hx_s.AsyncClient(timeout=15.0) as c:
186
  if png_bytes:
187
- import json as _j_s
188
  data: dict = {"chat_id": str(chat_id), "parse_mode": "HTML"}
189
  if caption_safe:
190
  data["caption"] = caption_safe
191
  if keyboard:
192
- data["reply_markup"] = _j_s.dumps(keyboard)
193
- files = {"photo": ("chart.png", png_bytes, "image/png")}
194
- await c.post(f"https://api.telegram.org/bot{bot_token}/sendPhoto",
195
- data=data, files=files)
 
 
196
  else:
197
- payload: dict = {"chat_id": chat_id, "photo": photo_url, "parse_mode": "HTML"}
198
  if caption_safe:
199
  payload["caption"] = caption_safe
200
  if keyboard:
201
  payload["reply_markup"] = keyboard
202
- await c.post(f"https://api.telegram.org/bot{bot_token}/sendPhoto", json=payload)
203
  except Exception as exc:
204
  _logger.warning("tg_photo error: %s", exc)
205
 
206
 
207
  async def _tg_typing(chat_id: str | int, action: str = "typing", token: str | None = None) -> None:
208
- """Invia sendChatAction mostra '⌨️ digitando…' prima di operazioni pesanti.
209
-
210
- Dura 5 secondi o fino al prossimo messaggio del bot.
211
- Azioni: typing, upload_photo, upload_document, find_location, record_video_note.
212
- """
213
- bot_token = token or _get_bot_token()
214
- if not bot_token:
215
- return
216
- try:
217
- async with httpx.AsyncClient(timeout=3.0) as c:
218
- await c.post(
219
- f"https://api.telegram.org/bot{bot_token}/sendChatAction",
220
- json={"chat_id": chat_id, "action": action},
221
- )
222
- except Exception:
223
- pass
224
 
225
 
226
  async def _tg_react(
@@ -229,26 +232,19 @@ async def _tg_react(
229
  emoji: str = "👍",
230
  token: str | None = None,
231
  ) -> None:
232
- """Aggiunge reazione emoji a un messaggio (Bot API 7.1+, Feb 2024).
233
-
234
- Emoji supportate: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🎉 🤩 🏆 ✅ 💯 ⚡ 🚀 🎯
235
- """
236
- bot_token = token or _get_bot_token()
237
- if not bot_token or not message_id:
238
  return
239
- try:
240
- async with httpx.AsyncClient(timeout=3.0) as c:
241
- await c.post(
242
- f"https://api.telegram.org/bot{bot_token}/setMessageReaction",
243
- json={
244
- "chat_id": chat_id,
245
- "message_id": int(message_id),
246
- "reaction": [{"type": "emoji", "emoji": emoji}],
247
- "is_big": False,
248
- },
249
- )
250
- except Exception:
251
- pass
252
 
253
 
254
  def _fmt_elapsed(created_at_ms: int) -> str:
@@ -1394,8 +1390,9 @@ async def _cmd_score(chat_id: int) -> None:
1394
  """🏆 Score card dettagliata — chart + ranking 4 competitor + nodes + gaps + runtime telemetry."""
1395
  import httpx as _hx_sc, base64 as _b64_sc, json as _j_sc, urllib.parse as _ul_sc
1396
  gh_token = os.getenv("GITHUB_TOKEN", "").strip()
1397
- rw_url = os.getenv("RAILWAY_URL", "https://baida-a-terminal.hf.space").rstrip("/")
1398
- await _tg_reply(chat_id, "⏳ <b>Score</b> — carico report + metriche runtime…")
 
1399
 
1400
  report: dict | None = None
1401
  if gh_token:
@@ -1443,7 +1440,8 @@ async def _cmd_score(chat_id: int) -> None:
1443
  rt_repair: dict = {}
1444
  try:
1445
  async with _hx_sc.AsyncClient(timeout=5.0) as _c:
1446
- _tr = await _c.get(f"{rw_url}/api/telemetry")
 
1447
  if _tr.status_code == 200:
1448
  _td = _tr.json()
1449
  rt_timing = _td.get("timing", {})
@@ -1490,7 +1488,8 @@ async def _cmd_score(chat_id: int) -> None:
1490
  d_dev = round(avg_ai - avg_dev); s_dev = ("+" if d_dev >= 0 else "") + str(d_dev)
1491
  d_mns = round(avg_ai - avg_mns); s_mns = ("+" if d_mns >= 0 else "") + str(d_mns)
1492
  d_cur = round(avg_ai - avg_cur); s_cur = ("+" if d_cur >= 0 else "") + str(d_cur)
1493
- caption = f"🏆 <b>Score</b> — {ts} UTC <code>v{ver}</code>\n"
 
1494
  caption += f"<code>{bar_g}</code> <b>{avg_ai}%</b> {verdict}\n\n"
1495
  caption += f"<code>{'Modello':<10} {'Score':>5} {'Δ':>4} Wins</code>\n"
1496
  caption += f"<code>{'Agente AI':<10} {str(avg_ai)+'%':>5} {'─':>4} ─</code>\n"
@@ -1511,13 +1510,13 @@ async def _cmd_score(chat_id: int) -> None:
1511
  await _tg_photo(chat_id, chart_url, caption=caption[:1024], keyboard=_BENCH_ACTION_KB)
1512
 
1513
  # ── Messaggio 2 — dettaglio completo ─────────────────────────
1514
- det = "📊 <b>Score — Dettaglio</b>\n\n"
1515
 
1516
  # Orchestration nodes
1517
  NODE_ICONS = {"planner":"🧠","executor":"⚙️","reasoner":"🔬",
1518
  "recovery_manager":"🛡","robustness_layer":"🔒","memory_module":"💾"}
1519
  if nodes:
1520
- det += "<b>⚡ Orchestration Nodes:</b>\n<code>"
1521
  for nk, nv in nodes.items():
1522
  sr = str(nv.get("success_rate", "?"))
1523
  lat = nv.get("avg_latency_s")
@@ -1545,7 +1544,7 @@ async def _cmd_score(chat_id: int) -> None:
1545
  det += f" {k[:20]:<20} {v}\n"
1546
  det += "</code>\n"
1547
  else:
1548
- det += "<i>ℹ️ Telemetria runtime non disponibile (Railway idle)</i>\n"
1549
 
1550
  # Top 3 best + Top 3 worst
1551
  sorted_tasks = sorted([t for t in tasks if t.get("score") is not None], key=lambda t: -t["score"])
@@ -1589,209 +1588,47 @@ async def _cmd_score(chat_id: int) -> None:
1589
  await _tg_reply(chat_id, det[_TG_MAX:_TG_MAX*2][:_TG_MAX], keyboard=_BENCH_ACTION_KB)
1590
 
1591
 
1592
- async def _cmd_bench(chat_id: int, mode: str = "default") -> None:
1593
- """📊 Benchmark via bench.yml (benchmark-extended.mjs) + quickchart.io.
1594
-
1595
- GAP-TGB: workflow_dispatch su bench.yml — usa benchmark-extended.mjs
1596
- (20 categorie, seed canonico 1337, tutte le fix v5).
1597
- Risultati inviati via Telegram da ab-bench.mjs --notify al completamento.
1598
- """
1599
- gh_token = os.getenv("GITHUB_TOKEN", "").strip()
1600
-
1601
- # ── Tenta fetch ultimo run completato da GitHub Actions artifact ─────────
1602
- last_report: dict | None = None
1603
- if gh_token:
1604
- try:
1605
- import httpx as _hx
1606
- async with _hx.AsyncClient(timeout=8.0) as _c:
1607
- _r = await _c.get(
1608
- "https://api.github.com/repos/Baida98/AI/actions/workflows/bench.yml/runs"
1609
- "?status=completed&per_page=1",
1610
- headers={"Authorization": f"Bearer {gh_token}",
1611
- "Accept": "application/vnd.github.v3+json",
1612
- "User-Agent": "AgenteAI-Bot"},
1613
- )
1614
- if _r.status_code == 200:
1615
- _runs = _r.json().get("workflow_runs", [])
1616
- if _runs:
1617
- last_report = {
1618
- "run_id": _runs[0]["id"],
1619
- "run_url": _runs[0]["html_url"],
1620
- "conclusion":_runs[0].get("conclusion","?"),
1621
- "updated": _runs[0].get("updated_at",""),
1622
- }
1623
- except Exception as _exc:
1624
- _logger.debug("bench fetch last run: %s", _exc)
1625
-
1626
- # ── Trigger nuovo run via workflow_dispatch ───────────────────────────────
1627
- run_url = "https://github.com/Baida98/AI/actions/workflows/bench.yml"
1628
- if gh_token:
1629
- try:
1630
- import httpx as _hx
1631
- async with _hx.AsyncClient(timeout=10.0) as _c:
1632
- _r = await _c.post(
1633
- "https://api.github.com/repos/Baida98/AI/actions/workflows/bench.yml/dispatches",
1634
- json={"ref": "main", "inputs": {
1635
- "mode": mode,
1636
- "run_improve": "false",
1637
- "force_update_baseline": "false",
1638
- }},
1639
- headers={"Authorization": f"Bearer {gh_token}",
1640
- "Accept": "application/vnd.github.v3+json",
1641
- "User-Agent": "AgenteAI-Bot"},
1642
- )
1643
- if _r.status_code == 204:
1644
- _logger.info("[bench] workflow_dispatch OK (mode=%s)", mode)
1645
- # Attendi 2s e leggi il run ID appena creato
1646
- await asyncio.sleep(2.0)
1647
- async with _hx.AsyncClient(timeout=8.0) as _c2:
1648
- _r2 = await _c2.get(
1649
- "https://api.github.com/repos/Baida98/AI/actions/workflows/"
1650
- "bench.yml/runs?per_page=1",
1651
- headers={"Authorization": f"Bearer {gh_token}",
1652
- "Accept": "application/vnd.github.v3+json",
1653
- "User-Agent": "AgenteAI-Bot"},
1654
- )
1655
- if _r2.status_code == 200:
1656
- _rr = _r2.json().get("workflow_runs", [])
1657
- if _rr:
1658
- run_url = _rr[0]["html_url"]
1659
- else:
1660
- _logger.warning("[bench] workflow_dispatch status=%d", _r.status_code)
1661
- except Exception as _exc:
1662
- _logger.warning("[bench] workflow_dispatch error: %s", _exc)
1663
-
1664
- # ── Costruisci messaggio con quickchart dell'ultimo run (se disponibile) ──
1665
- _BENCH_CACHE[chat_id] = {"mode": mode, "run_url": run_url}
1666
-
1667
- # ── Fetch benchmark-report.json dal repo per quickchart reale ──────────────
1668
- bench_report: dict | None = None
1669
- if gh_token:
1670
- try:
1671
- import httpx as _hx, base64 as _b64, json as _json
1672
- async with _hx.AsyncClient(timeout=8.0) as _c:
1673
- _br = await _c.get(
1674
- "https://api.github.com/repos/Baida98/AI/contents/benchmark-report.json?ref=main",
1675
- headers={"Authorization": f"Bearer {gh_token}",
1676
- "Accept": "application/vnd.github.v3+json",
1677
- "User-Agent": "AgenteAI-Bot"},
1678
- )
1679
- if _br.status_code == 200:
1680
- _content = _b64.b64decode(_br.json()["content"]).decode()
1681
- bench_report = _json.loads(_content)
1682
- except Exception as _exc:
1683
- _logger.debug("bench fetch benchmark-report.json: %s", _exc)
1684
-
1685
- chart_url: str | None = None
1686
-
1687
- def _build_quickchart(report: dict) -> str:
1688
- """Costruisce URL quickchart.io da benchmark-report.json."""
1689
- import json as _j, urllib.parse as _ul
1690
- tasks = report.get("tasks", [])
1691
- summary = report.get("summary", {})
1692
- avg_ai = summary.get("avgScore", 0)
1693
- avg_rpl = summary.get("avgReplit", 57.9)
1694
- avg_mns = summary.get("avgManus", 71.2)
1695
- cat_map: dict[str, list[float]] = {}
1696
- for t in tasks:
1697
- cat = (t.get("cat") or "other").replace("_", " ")[:14]
1698
- cat_map.setdefault(cat, []).append(t.get("score", 0))
1699
- if not cat_map:
1700
- return ""
1701
- labels = list(cat_map.keys())
1702
- scores = [round(sum(v)/len(v)) for v in cat_map.values()]
1703
- colors = ["#4CAF50" if s >= avg_rpl else "#FF9800" if s >= 50 else "#F44336" for s in scores]
1704
- cfg = {
1705
- "type": "horizontalBar",
1706
- "data": {
1707
- "labels": labels,
1708
- "datasets": [
1709
- {"label": "Agente AI", "data": scores,
1710
- "backgroundColor": colors, "borderColor": colors, "borderWidth": 1},
1711
- {"label": f"Replit {avg_rpl}",
1712
- "data": [avg_rpl]*len(labels),
1713
- "type": "line", "borderColor": "#2196F3", "borderDash": [5,3],
1714
- "pointRadius": 0, "fill": False, "borderWidth": 2},
1715
- {"label": f"Manus {avg_mns}",
1716
- "data": [avg_mns]*len(labels),
1717
- "type": "line", "borderColor": "#9C27B0", "borderDash": [5,3],
1718
- "pointRadius": 0, "fill": False, "borderWidth": 2},
1719
- ],
1720
- },
1721
- "options": {
1722
- "title": {"display": True,
1723
- "text": f"Agente AI {avg_ai}% | Replit {avg_rpl}% | Manus {avg_mns}%"},
1724
- "scales": {"xAxes": [{"ticks": {"min": 0, "max": 100, "stepSize": 20}}]},
1725
- "legend": {"display": True, "position": "bottom"},
1726
- "plugins": {"datalabels": {"display": False}},
1727
- },
1728
- }
1729
- return ("https://quickchart.io/chart?c=" +
1730
- _ul.quote(_j.dumps(cfg, separators=(",",":"))) +
1731
- "&width=720&height=420&backgroundColor=white")
1732
-
1733
- if bench_report:
1734
- chart_url = _build_quickchart(bench_report)
1735
-
1736
- summary = (bench_report or {}).get("summary", {})
1737
- avg_ai = summary.get("avgScore")
1738
- avg_rpl = summary.get("avgReplit")
1739
- avg_mns = summary.get("avgManus")
1740
- # ── Tabella ASCII con barre per caption Telegram ──────────────────────────
1741
- def _text_table_bench(report: dict, rpl: float) -> str:
1742
- tasks = report.get("tasks", [])
1743
- cat_map: dict[str, list[float]] = {}
1744
- for t in tasks:
1745
- cat = (t.get("cat") or "other").replace("_", " ")[:12]
1746
- cat_map.setdefault(cat, []).append(float(t.get("score", 0)))
1747
- if not cat_map:
1748
- return ""
1749
- rows = []
1750
- for cat, vals in sorted(cat_map.items(), key=lambda x: -sum(x[1]) / len(x[1])):
1751
- sc = round(sum(vals) / len(vals))
1752
- bar = "█" * round(sc / 10) + "░" * (10 - round(sc / 10))
1753
- delta_rpl = sc - rpl
1754
- vs = ("+" if delta_rpl >= 0 else "") + str(round(delta_rpl)) + "vsRpl"
1755
- rows.append(f"{cat:<12} {bar} {sc:>3}% {vs}")
1756
- return "\n".join(rows)
1757
-
1758
- text_table = ""
1759
- if bench_report and avg_rpl is not None:
1760
- text_table = _text_table_bench(bench_report, float(avg_rpl))
1761
-
1762
- score_line = ""
1763
- if avg_ai is not None:
1764
- score_line = (
1765
- f"\n📈 <b>Score:</b> AI <b>{avg_ai}%</b>"
1766
- + (f" | Replit {avg_rpl}%" if avg_rpl else "")
1767
- + (f" | Manus {avg_mns}%" if avg_mns else "")
1768
- + "\n"
1769
- )
1770
 
1771
- def _build_bench_caption(header: str) -> str:
1772
- tbl = ("\n<code>" + text_table + "</code>") if text_table else ""
1773
- link = f'\n🔗 <a href="{html.escape(run_url)}">GitHub Actions</a>'
1774
- full = header + score_line + tbl + link
1775
- if len(full) > 1020 and text_table:
1776
- avail = max(0, 1020 - len(header) - len(score_line) - len(link) - 14)
1777
- tbl = "\n<code>" + text_table[:avail] + "…</code>"
1778
- full = header + score_line + tbl + link
1779
- return full[:1024]
1780
-
1781
- if last_report:
1782
- _conclusion = last_report.get("conclusion", "?")
1783
- _em = "✅" if _conclusion == "success" else ("❌" if _conclusion == "failure" else "⚠️")
1784
- _upd = last_report.get("updated", "")[:16].replace("T", " ")
1785
- header = f"📊 <b>Benchmark avviato</b> — {_em} {_conclusion}\n🕐 {_upd} UTC"
1786
- else:
1787
- header = "📊 <b>Benchmark avviato</b> (benchmark-extended.mjs)"
1788
 
1789
- caption = _build_bench_caption(header)
 
 
1790
 
1791
- if chart_url:
1792
- await _tg_photo(chat_id, chart_url, caption=caption, keyboard=_BENCH_ACTION_KB)
1793
- else:
1794
- await _tg_reply(chat_id, caption, keyboard=_BENCH_ACTION_KB)
 
 
1795
 
1796
 
1797
 
@@ -1901,13 +1738,12 @@ async def _handle_inline(iq: dict, token: str) -> None:
1901
  "description":f"/autofix {q60}",
1902
  "input_message_content":{"message_text":f"/autofix {query}"}},
1903
  ]
1904
- try:
1905
- import httpx as _hx
1906
- async with _hx.AsyncClient(timeout=5.0) as c:
1907
- await c.post(f"https://api.telegram.org/bot{bot_token}/answerInlineQuery",
1908
- json={"inline_query_id":iq_id,"results":results,"cache_time":30,"is_personal":True})
1909
- except Exception as exc:
1910
- _logger.debug("inline answer error: %s", exc)
1911
 
1912
 
1913
  async def _handle_callback(callback_query: dict, token: str) -> None:
@@ -2223,7 +2059,7 @@ async def telegram_webhook(request: Request) -> dict:
2223
  await _tg_reply(chat_id, "🧠 Uso: <code>/ask &lt;domanda&gt;</code>", keyboard=_MAIN_KB)
2224
  elif cmd == "/bench":
2225
  _mode = text[len(cmd):].strip() or "default"
2226
- if _mode not in ("default","full","coding-only","noncode-only","agentic-only"):
2227
  _mode = "default"
2228
  _t=asyncio.create_task(_cmd_bench(chat_id, _mode)); _t.add_done_callback(_log_tg_exc)
2229
  elif cmd == "/score":
 
44
  return os.getenv("TELEGRAM_BOT_TOKEN", "").strip()
45
 
46
 
47
+ def _get_reply_gateway() -> tuple[str, str]:
48
+ """Restituisce il gateway Pages autenticato, se configurato."""
49
+ return (
50
+ os.getenv("TELEGRAM_REPLY_PROXY_URL", "").strip(),
51
+ os.getenv("TELEGRAM_REPLY_PROXY_SECRET", "").strip(),
52
+ )
53
+
54
+
55
+ async def _tg_api_call(
56
+ method: str,
57
+ payload: dict,
58
+ token: str | None = None,
59
+ *,
60
+ timeout: httpx.Timeout | float | None = None,
61
+ ) -> dict:
62
+ """Invia un metodo Bot API tramite il gateway Pages quando disponibile.
63
+
64
+ Hugging Face può bloccare l'egress TCP verso Telegram. Il gateway mantiene il
65
+ token del bot fuori dal runtime e inoltra solo metodi strettamente consentiti.
66
+ """
67
  bot_token = token or _get_bot_token()
68
  if not bot_token:
69
+ _logger.warning("tg_api %s skipped: TELEGRAM_BOT_TOKEN missing", method)
70
+ return {}
71
+
72
+ gateway_url, gateway_secret = _get_reply_gateway()
73
+ if gateway_url and gateway_secret:
74
+ request_url = gateway_url
75
+ request_headers = {"Authorization": f"Bearer {gateway_secret}"}
76
+ request_payload = {"method": method, **payload}
77
+ else:
78
+ request_url = f"https://api.telegram.org/bot{bot_token}/{method}"
79
+ request_headers = {}
80
+ request_payload = payload
81
+
82
+ try:
83
+ client_timeout = timeout or httpx.Timeout(connect=5.0, read=15.0, write=10.0, pool=5.0)
84
+ async with httpx.AsyncClient(timeout=client_timeout, trust_env=False) as client:
85
+ response = await client.post(request_url, headers=request_headers, json=request_payload)
86
+ try:
87
+ data = response.json()
88
+ except ValueError:
89
+ data = {}
90
+ if response.status_code >= 400 or not data.get("ok", False):
91
+ detail = str(data.get("description") or data.get("error") or response.text[:160] or "unknown")
92
+ _logger.warning("tg_api rejected: method=%s status=%s detail=%s", method, response.status_code, detail)
93
+ return {}
94
+ return data
95
+ except Exception as exc:
96
+ detail = str(exc) or repr(exc)
97
+ _logger.warning("tg_api error: method=%s %s: %s", method, type(exc).__name__, detail)
98
+ return {}
99
+
100
+
101
+ async def _tg_reply(chat_id: str | int, text: str, token: str | None = None,
102
+ keyboard: dict | None = None) -> None:
103
+ """Invia una risposta con HTML e opzionale inline keyboard."""
104
  payload: dict = {
105
  "chat_id": chat_id,
106
  "text": text,
 
109
  }
110
  if keyboard:
111
  payload["reply_markup"] = keyboard
112
+ await _tg_api_call("sendMessage", payload, token)
 
 
 
 
 
 
 
 
113
 
114
 
115
  async def _tg_answer_callback(callback_query_id: str, text: str = "", token: str | None = None) -> None:
116
+ """Chiude il caricamento dei pulsanti inline tramite il gateway."""
117
+ if not callback_query_id:
 
118
  return
119
+ await _tg_api_call(
120
+ "answerCallbackQuery",
121
+ {"callback_query_id": callback_query_id, "text": text, "show_alert": False},
122
+ token,
123
+ timeout=5.0,
124
+ )
 
 
 
125
 
126
 
127
  async def _tg_send(chat_id: str | int, text: str, token: str | None = None,
128
  keyboard: dict | None = None) -> str | None:
129
+ """Invia un messaggio e restituisce l'identificativo per gli edit streaming."""
 
 
 
130
  payload: dict = {
131
  "chat_id": chat_id,
132
  "text": text,
 
135
  }
136
  if keyboard:
137
  payload["reply_markup"] = keyboard
138
+ data = await _tg_api_call("sendMessage", payload, token, timeout=8.0)
139
+ message_id = (data.get("result") or {}).get("message_id")
140
+ return str(message_id) if message_id is not None else None
 
 
 
 
 
 
 
 
 
141
 
142
 
143
  async def _tg_edit(chat_id: str | int, message_id: str, text: str,
144
  token: str | None = None, keyboard: dict | None = None) -> bool:
145
+ """Aggiorna un messaggio streaming attraverso il gateway."""
146
+ if not message_id:
 
 
147
  return False
148
  payload: dict = {
149
  "chat_id": chat_id,
 
154
  }
155
  if keyboard:
156
  payload["reply_markup"] = keyboard
157
+ return bool(await _tg_api_call("editMessageText", payload, token, timeout=8.0))
 
 
 
 
 
 
 
 
 
 
158
 
159
 
160
  async def _tg_photo(
 
164
  token: str | None = None,
165
  keyboard: dict | None = None,
166
  ) -> None:
167
+ """Invia grafici tramite gateway; conserva il fallback multipart per ambienti legacy."""
 
 
 
 
 
168
  bot_token = token or _get_bot_token()
169
  if not bot_token:
170
  return
171
  caption_safe = (caption or "")[:1024]
172
+ gateway_url, gateway_secret = _get_reply_gateway()
173
+ if gateway_url and gateway_secret:
174
+ payload: dict = {"chat_id": chat_id, "photo": photo_url, "parse_mode": "HTML"}
175
+ if caption_safe:
176
+ payload["caption"] = caption_safe
177
+ if keyboard:
178
+ payload["reply_markup"] = keyboard
179
+ await _tg_api_call("sendPhoto", payload, bot_token, timeout=20.0)
180
+ return
181
 
182
  import httpx as _hx_p, json as _j_p, urllib.parse as _ul_p, re as _re_p
 
183
  png_bytes: bytes | None = None
184
  if "quickchart.io/chart" in photo_url:
185
  try:
186
+ match = _re_p.search(r"[?&]c=([^&]+)", photo_url)
187
+ if match:
188
+ cfg_dict = _j_p.loads(_ul_p.unquote(match.group(1)))
189
+ async with _hx_p.AsyncClient(timeout=20.0, trust_env=False) as client:
190
+ response = await client.post(
191
  "https://quickchart.io/chart",
192
  json={"chart": cfg_dict, "width": 720, "height": 420,
193
  "backgroundColor": "white", "format": "png"},
194
  )
195
+ if response.status_code == 200 and response.headers.get("content-type", "").startswith("image/"):
196
+ png_bytes = response.content
 
197
  except Exception as exc:
198
+ _logger.debug("tg_photo quickchart fallback: %s", exc)
199
 
200
  try:
201
+ async with httpx.AsyncClient(timeout=15.0, trust_env=False) as client:
 
202
  if png_bytes:
203
+ import json as _json
204
  data: dict = {"chat_id": str(chat_id), "parse_mode": "HTML"}
205
  if caption_safe:
206
  data["caption"] = caption_safe
207
  if keyboard:
208
+ data["reply_markup"] = _json.dumps(keyboard)
209
+ await client.post(
210
+ f"https://api.telegram.org/bot{bot_token}/sendPhoto",
211
+ data=data,
212
+ files={"photo": ("chart.png", png_bytes, "image/png")},
213
+ )
214
  else:
215
+ payload = {"chat_id": chat_id, "photo": photo_url, "parse_mode": "HTML"}
216
  if caption_safe:
217
  payload["caption"] = caption_safe
218
  if keyboard:
219
  payload["reply_markup"] = keyboard
220
+ await client.post(f"https://api.telegram.org/bot{bot_token}/sendPhoto", json=payload)
221
  except Exception as exc:
222
  _logger.warning("tg_photo error: %s", exc)
223
 
224
 
225
  async def _tg_typing(chat_id: str | int, action: str = "typing", token: str | None = None) -> None:
226
+ await _tg_api_call("sendChatAction", {"chat_id": chat_id, "action": action}, token, timeout=5.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
 
228
 
229
  async def _tg_react(
 
232
  emoji: str = "👍",
233
  token: str | None = None,
234
  ) -> None:
235
+ if not message_id:
 
 
 
 
 
236
  return
237
+ await _tg_api_call(
238
+ "setMessageReaction",
239
+ {
240
+ "chat_id": chat_id,
241
+ "message_id": int(message_id),
242
+ "reaction": [{"type": "emoji", "emoji": emoji}],
243
+ "is_big": False,
244
+ },
245
+ token,
246
+ timeout=5.0,
247
+ )
 
 
248
 
249
 
250
  def _fmt_elapsed(created_at_ms: int) -> str:
 
1390
  """🏆 Score card dettagliata — chart + ranking 4 competitor + nodes + gaps + runtime telemetry."""
1391
  import httpx as _hx_sc, base64 as _b64_sc, json as _j_sc, urllib.parse as _ul_sc
1392
  gh_token = os.getenv("GITHUB_TOKEN", "").strip()
1393
+ runtime_url = (os.getenv("TELEMETRY_URL") or os.getenv("BACKEND_URL") or os.getenv("RAILWAY_URL") or "https://baida07-terminal.hf.space").rstrip("/")
1394
+ machine_token = os.getenv("INTERNAL_TOKEN", "").strip()
1395
+ await _tg_reply(chat_id, "⏳ <b>Score</b> — carico benchmark archiviato + telemetria runtime…")
1396
 
1397
  report: dict | None = None
1398
  if gh_token:
 
1440
  rt_repair: dict = {}
1441
  try:
1442
  async with _hx_sc.AsyncClient(timeout=5.0) as _c:
1443
+ _headers = {"X-Machine-Token": machine_token} if machine_token else {}
1444
+ _tr = await _c.get(f"{runtime_url}/api/telemetry", headers=_headers)
1445
  if _tr.status_code == 200:
1446
  _td = _tr.json()
1447
  rt_timing = _td.get("timing", {})
 
1488
  d_dev = round(avg_ai - avg_dev); s_dev = ("+" if d_dev >= 0 else "") + str(d_dev)
1489
  d_mns = round(avg_ai - avg_mns); s_mns = ("+" if d_mns >= 0 else "") + str(d_mns)
1490
  d_cur = round(avg_ai - avg_cur); s_cur = ("+" if d_cur >= 0 else "") + str(d_cur)
1491
+ caption = f"🏆 <b>Score snapshot</b> — {ts} UTC <code>v{ver}</code>\n"
1492
+ caption += "<i>Report archiviato: non è una valutazione live del runtime.</i>\n"
1493
  caption += f"<code>{bar_g}</code> <b>{avg_ai}%</b> {verdict}\n\n"
1494
  caption += f"<code>{'Modello':<10} {'Score':>5} {'Δ':>4} Wins</code>\n"
1495
  caption += f"<code>{'Agente AI':<10} {str(avg_ai)+'%':>5} {'─':>4} ─</code>\n"
 
1510
  await _tg_photo(chat_id, chart_url, caption=caption[:1024], keyboard=_BENCH_ACTION_KB)
1511
 
1512
  # ── Messaggio 2 — dettaglio completo ─────────────────────────
1513
+ det = "📊 <b>Score — Dettaglio</b>\n<i>Benchmark archiviato del " + (ts or "timestamp non disponibile") + " UTC; non rappresenta una misura live.</i>\n\n"
1514
 
1515
  # Orchestration nodes
1516
  NODE_ICONS = {"planner":"🧠","executor":"⚙️","reasoner":"🔬",
1517
  "recovery_manager":"🛡","robustness_layer":"🔒","memory_module":"💾"}
1518
  if nodes:
1519
+ det += "<b>⚡ Proxy benchmark per nodo (non telemetria live):</b>\n<code>"
1520
  for nk, nv in nodes.items():
1521
  sr = str(nv.get("success_rate", "?"))
1522
  lat = nv.get("avg_latency_s")
 
1544
  det += f" {k[:20]:<20} {v}\n"
1545
  det += "</code>\n"
1546
  else:
1547
+ det += "<i>ℹ️ Telemetria runtime non disponibile o non autorizzata.</i>\n"
1548
 
1549
  # Top 3 best + Top 3 worst
1550
  sorted_tasks = sorted([t for t in tasks if t.get("score") is not None], key=lambda t: -t["score"])
 
1588
  await _tg_reply(chat_id, det[_TG_MAX:_TG_MAX*2][:_TG_MAX], keyboard=_BENCH_ACTION_KB)
1589
 
1590
 
1591
+ def _format_live_quality_benchmark(report: dict) -> str:
1592
+ """Formatta solo risultati prodotti dal benchmark quality corrente."""
1593
+ timestamp = str(report.get("timestamp") or "")[:19].replace("T", " ")
1594
+ score = report.get("total_score", "N/A")
1595
+ results = report.get("results") if isinstance(report.get("results"), list) else []
1596
+ errors = report.get("errors") if isinstance(report.get("errors"), list) else []
1597
+ outcome = "" if report.get("ok") else "⚠️"
1598
+ lines = [
1599
+ f"📊 <b>Benchmark live Quality</b> — {outcome}",
1600
+ f"🕐 <code>{html.escape(timestamp or 'ora non disponibile')}</code>",
1601
+ f"📈 <b>Score live:</b> <code>{html.escape(str(score))}/100</code>",
1602
+ f"🧪 Categorie eseguite: <code>{len(results)}</code>",
1603
+ ]
1604
+ if results:
1605
+ lines.append("\n<b>Risultati della run corrente:</b>")
1606
+ for item in results[:12]:
1607
+ label = html.escape(str(item.get("label") or item.get("id") or "categoria")[:42])
1608
+ value = item.get("score", "N/A")
1609
+ try:
1610
+ icon = "🟢" if float(value) >= 75 else "🟡" if float(value) >= 50 else "🔴"
1611
+ except (TypeError, ValueError):
1612
+ icon = "⚪"
1613
+ lines.append(f"{icon} <code>{str(value):>5}</code> {label}")
1614
+ if errors:
1615
+ lines.append(f"\n⚠️ <b>Errori della run:</b> <code>{len(errors)}</code>")
1616
+ for error in errors[:2]:
1617
+ lines.append("<i>" + html.escape(str(error)[:180]) + "</i>")
1618
+ lines.append("\n<i>Misura live del backend: nessun report storico o workflow GitHub è stato usato.</i>")
1619
+ return "\n".join(lines)[:3900]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1620
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1621
 
1622
+ async def _cmd_bench(chat_id: int, mode: str = "default") -> None:
1623
+ """Avvia il benchmark Extended su tutte le 20 categorie in background."""
1624
+ from .benchmark_handler import run_benchmark_task
1625
 
1626
+ normalized_mode = "weak" if mode == "weak" else "full"
1627
+ _BENCH_CACHE[chat_id] = {"mode": "extended-weak" if normalized_mode == "weak" else "extended-20", "run_url": "", "started_at": time.time()}
1628
+ task = asyncio.create_task(run_benchmark_task(chat_id, _tg_reply, mode=normalized_mode))
1629
+ task.add_done_callback(lambda completed: _logger.error(
1630
+ "[bench-extended] background task failed: %s", completed.exception()
1631
+ ) if not completed.cancelled() and completed.exception() else None)
1632
 
1633
 
1634
 
 
1738
  "description":f"/autofix {q60}",
1739
  "input_message_content":{"message_text":f"/autofix {query}"}},
1740
  ]
1741
+ await _tg_api_call(
1742
+ "answerInlineQuery",
1743
+ {"inline_query_id": iq_id, "results": results, "cache_time": 30, "is_personal": True},
1744
+ bot_token,
1745
+ timeout=5.0,
1746
+ )
 
1747
 
1748
 
1749
  async def _handle_callback(callback_query: dict, token: str) -> None:
 
2059
  await _tg_reply(chat_id, "🧠 Uso: <code>/ask &lt;domanda&gt;</code>", keyboard=_MAIN_KB)
2060
  elif cmd == "/bench":
2061
  _mode = text[len(cmd):].strip() or "default"
2062
+ if _mode not in ("default","full","coding-only","noncode-only","agentic-only","weak"):
2063
  _mode = "default"
2064
  _t=asyncio.create_task(_cmd_bench(chat_id, _mode)); _t.add_done_callback(_log_tg_exc)
2065
  elif cmd == "/score":
api/terminal.py CHANGED
@@ -1,5 +1,6 @@
1
  """backend/api/terminal.py — WebSocket PTY terminal (S354 + S754-B + S755)."""
2
  import os, asyncio, pty, fcntl, struct, termios, json, shlex, time, logging
 
3
  from pathlib import Path
4
  from fastapi import APIRouter, WebSocket, WebSocketDisconnect
5
  from fastapi import Depends
@@ -8,6 +9,31 @@ from .auth_guard import require_role, AuthRole
8
  router = APIRouter()
9
  _logger = logging.getLogger("terminal")
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  # ── Startup script (S755) ─────────────────────────────────────────────────────
12
  # Scritto in /data/.bashrc_agente e sourciate da bash via --rcfile.
13
  # Configura venv Python + npm persistenti, Playwright, workspace, aliases, prompt.
@@ -239,6 +265,25 @@ async def terminal_packages(role: AuthRole = Depends(require_role(AuthRole.MACHI
239
  'generated_at': int(time.time()),
240
  }
241
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  @router.websocket('/ws/terminal')
243
  async def terminal_ws(ws: WebSocket):
244
  """
@@ -263,6 +308,8 @@ async def terminal_ws(ws: WebSocket):
263
  await ws.close(code=4403)
264
  return
265
  await ws.accept()
 
 
266
  loop = asyncio.get_event_loop()
267
 
268
  # S755: assicura che /data/.bashrc_agente esista e sia aggiornato
@@ -327,6 +374,7 @@ async def terminal_ws(ws: WebSocket):
327
  try:
328
  data = await loop.run_in_executor(None, lambda: os.read(master_fd, 4096))
329
  if data:
 
330
  await ws.send_bytes(data)
331
  # S754-B: salvataggio periodico ogni 60s durante attività
332
  _now = time.monotonic()
@@ -361,6 +409,7 @@ async def terminal_ws(ws: WebSocket):
361
  try:
362
  await asyncio.gather(_reader(), _writer())
363
  finally:
 
364
  closed.set()
365
  # S754-B: salva lo stato prima di terminare il processo.
366
  # La sessione tmux è ancora viva qui (proc è il CLIENT tmux, non il SERVER).
 
1
  """backend/api/terminal.py — WebSocket PTY terminal (S354 + S754-B + S755)."""
2
  import os, asyncio, pty, fcntl, struct, termios, json, shlex, time, logging
3
+ from collections import defaultdict, deque
4
  from pathlib import Path
5
  from fastapi import APIRouter, WebSocket, WebSocketDisconnect
6
  from fastapi import Depends
 
9
  router = APIRouter()
10
  _logger = logging.getLogger("terminal")
11
 
12
+ # Recent PTY output used by the authenticated auto-repair diagnostic.
13
+ # Buffers are intentionally process-local and bounded: they are diagnostics,
14
+ # not a second persistence channel for terminal sessions.
15
+ _BUFFER_MAX_CHUNKS = 200
16
+ _BUFFER_MAX_CHARS = 20_000
17
+ _terminal_buffers: dict[str, deque[str]] = defaultdict(
18
+ lambda: deque(maxlen=_BUFFER_MAX_CHUNKS)
19
+ )
20
+ _terminal_active: set[str] = set()
21
+
22
+
23
+ def _session_id(value: str | None) -> str:
24
+ """Normalize the client-provided diagnostic key without trusting it."""
25
+ value = (value or "default").strip()
26
+ return value[:128] or "default"
27
+
28
+
29
+ def _append_buffer(session_id: str, data: bytes) -> None:
30
+ text = data.decode("utf-8", errors="replace")
31
+ if text:
32
+ _terminal_buffers[session_id].append(text)
33
+ # Keep the joined diagnostic bounded even when chunks are large.
34
+ while sum(len(chunk) for chunk in _terminal_buffers[session_id]) > _BUFFER_MAX_CHARS:
35
+ _terminal_buffers[session_id].popleft()
36
+
37
  # ── Startup script (S755) ─────────────────────────────────────────────────────
38
  # Scritto in /data/.bashrc_agente e sourciate da bash via --rcfile.
39
  # Configura venv Python + npm persistenti, Playwright, workspace, aliases, prompt.
 
265
  'generated_at': int(time.time()),
266
  }
267
 
268
+
269
+ @router.get('/api/terminal/buffer/{session_id}')
270
+ async def terminal_buffer(
271
+ session_id: str,
272
+ role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
273
+ ):
274
+ """Return a bounded recent PTY diagnostic buffer.
275
+
276
+ The route is machine-authenticated because terminal output can contain
277
+ project data. It deliberately exposes no tmux metadata or environment.
278
+ """
279
+ sid = _session_id(session_id)
280
+ return {
281
+ "buffer": "".join(_terminal_buffers.get(sid, ())),
282
+ "active": sid in _terminal_active,
283
+ "session_id": sid,
284
+ }
285
+
286
+
287
  @router.websocket('/ws/terminal')
288
  async def terminal_ws(ws: WebSocket):
289
  """
 
308
  await ws.close(code=4403)
309
  return
310
  await ws.accept()
311
+ _sid = _session_id(ws.query_params.get("session_id"))
312
+ _terminal_active.add(_sid)
313
  loop = asyncio.get_event_loop()
314
 
315
  # S755: assicura che /data/.bashrc_agente esista e sia aggiornato
 
374
  try:
375
  data = await loop.run_in_executor(None, lambda: os.read(master_fd, 4096))
376
  if data:
377
+ _append_buffer(_sid, data)
378
  await ws.send_bytes(data)
379
  # S754-B: salvataggio periodico ogni 60s durante attività
380
  _now = time.monotonic()
 
409
  try:
410
  await asyncio.gather(_reader(), _writer())
411
  finally:
412
+ _terminal_active.discard(_sid)
413
  closed.set()
414
  # S754-B: salva lo stato prima di terminare il processo.
415
  # La sessione tmux è ancora viva qui (proc è il CLIENT tmux, non il SERVER).
api/vault.py CHANGED
@@ -95,23 +95,21 @@ def _vault_encrypt(plaintext: str) -> str:
95
 
96
 
97
  def _vault_decrypt(ciphertext: str) -> str:
98
- """Decrittografia: tenta Fernet; il fallback XOR è permesso solo per la migrazione di vecchi segreti."""
99
  if _fernet_instance:
100
  try:
101
  return _fernet_instance.decrypt(ciphertext.encode('ascii')).decode('utf-8')
102
  except Exception:
103
- _vault_logger.warning('Vault: rilevato segreto legacy (XOR) — si consiglia di risalvarlo per migrare a Fernet')
104
-
105
  if os.getenv('ENV', 'production') == 'development':
106
  return _vault_decrypt_xor(ciphertext)
107
-
108
- # In produzione, se Fernet fallisce e non siamo in dev, blocchiamo i segreti non sicuri
109
- # a meno che non sia strettamente necessario per la migrazione.
110
- try:
111
- return _vault_decrypt_xor(ciphertext)
112
- except Exception as e:
113
- _vault_logger.error(f'Vault: errore decrittografia segreto: {e}')
114
- raise HTTPException(status_code=500, detail='Vault decryption error: invalid key or corrupted data')
115
 
116
 
117
  # ── XOR legacy (usato solo come fallback per migrazione segreti esistenti) ────
@@ -246,6 +244,8 @@ async def vault_get_token(
246
  raise HTTPException(status_code=404, detail=f"Chiave '{key}' non trovata nel vault")
247
  try:
248
  return {'key': key, 'value': _vault_decrypt(data[key])}
 
 
249
  except Exception as e:
250
  raise HTTPException(status_code=500, detail=f'Decryption error: {e}')
251
 
 
95
 
96
 
97
  def _vault_decrypt(ciphertext: str) -> str:
98
+ """Decrittografa Fernet; il formato XOR legacy è ammesso solo in sviluppo per migrazione."""
99
  if _fernet_instance:
100
  try:
101
  return _fernet_instance.decrypt(ciphertext.encode('ascii')).decode('utf-8')
102
  except Exception:
103
+ _vault_logger.warning('Vault: ciphertext non-Fernet rilevato')
104
+
105
  if os.getenv('ENV', 'production') == 'development':
106
  return _vault_decrypt_xor(ciphertext)
107
+
108
+ _vault_logger.error('Vault: ciphertext legacy rifiutato in produzione')
109
+ raise HTTPException(
110
+ status_code=422,
111
+ detail='Vault decryption error: legacy ciphertext is not accepted in production',
112
+ )
 
 
113
 
114
 
115
  # ── XOR legacy (usato solo come fallback per migrazione segreti esistenti) ────
 
244
  raise HTTPException(status_code=404, detail=f"Chiave '{key}' non trovata nel vault")
245
  try:
246
  return {'key': key, 'value': _vault_decrypt(data[key])}
247
+ except HTTPException:
248
+ raise
249
  except Exception as e:
250
  raise HTTPException(status_code=500, detail=f'Decryption error: {e}')
251
 
api/version.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """Canonical runtime version shared by backend health endpoints."""
2
+
3
+ RUNTIME_VERSION = "3.4.2"
api/vision.py CHANGED
@@ -3,21 +3,26 @@ vision.py — Generazione e analisi immagini + ricerca immagini.
3
 
4
  Endpoints:
5
  POST /api/vision/generate — FLUX.1-schnell (HF Inference API)
6
- POST /api/vision/analyze — Groq llama-3.2-vision / GPT-4o-mini / BLIP fallback
7
  GET /api/vision/search — Pexels > Pixabay > Unsplash Source (zero API key)
8
 
9
  Problematiche HF Inference API:
10
  - 503 "loading": cold-start fino a 60s → retry con backoff
11
  - Output generate: raw bytes PNG (non JSON)
12
- - BLIP: captioning solo, non risponde a domande aperte
13
  - Rate limit senza HF_TOKEN: ~10 req/hr per IP
14
 
15
  Fallback chain analyze_image:
16
  1. Groq llama-3.2-11b-vision-preview (free tier, veloce, richiede GROQ_API_KEY)
17
- 2. GPT-4o-mini vision (richiede OPENAI_API_KEY)
18
- 3. BLIP-large captioning (HF Inference, libero ma solo didascalia)
 
 
 
 
19
  """
20
- import asyncio, base64, os, httpx, logging
 
21
  from fastapi import APIRouter, Depends
22
  from .auth_guard import require_role, AuthRole
23
  from pydantic import BaseModel
@@ -25,17 +30,22 @@ from pydantic import BaseModel
25
  router = APIRouter(prefix="/api/vision", tags=["vision"], dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
26
  _logger = logging.getLogger("vision")
27
 
28
- _HF_API = "https://api-inference.huggingface.co"
 
29
  _USER_AGENT = "Mozilla/5.0 (compatible; AgenteAI/3.0)"
30
 
31
  _MODEL_MAP: dict[str, str] = {
32
- "FLUX.1-schnell": "black-forest-labs/FLUX.1-schnell",
33
- "FLUX.1-dev": "black-forest-labs/FLUX.1-dev",
34
- "sdxl": "stabilityai/stable-diffusion-xl-base-1.0",
35
- "flux": "black-forest-labs/FLUX.1-schnell",
36
- "flux-schnell": "black-forest-labs/FLUX.1-schnell",
37
  }
38
 
 
 
 
 
39
 
40
  def _hf_headers(content_type: str = "application/json") -> dict:
41
  token = os.getenv("HF_TOKEN", "")
@@ -63,6 +73,13 @@ class AnalyzeImageRequest(BaseModel):
63
  question: str = "Descrivi questa immagine in dettaglio in italiano."
64
 
65
 
 
 
 
 
 
 
 
66
  # ─── /generate ────────────────────────────────────────────────────────────────
67
 
68
  @router.post("/generate")
@@ -76,53 +93,64 @@ async def generate_image(req: GenerateImageRequest):
76
  - HF restituisce raw bytes PNG — non JSON.
77
  - steps ottimali FLUX.1-schnell: 4 (veloce) – 8 (qualità).
78
  """
79
- model_id = _MODEL_MAP.get(req.model, "black-forest-labs/FLUX.1-schnell")
80
- url = f"{_HF_API}/models/{model_id}"
81
-
82
- payload: dict = {"inputs": req.prompt.strip()[:400]}
83
- params: dict = {"num_inference_steps": min(max(req.steps, 1), 8)}
84
- if req.width != 512: params["width"] = min(max(req.width, 256), 1024)
85
- if req.height != 512: params["height"] = min(max(req.height, 256), 1024)
86
- if req.negative_prompt:
87
- params["negative_prompt"] = req.negative_prompt[:200]
88
- payload["parameters"] = params
89
-
90
- async with httpx.AsyncClient(timeout=90) as client:
91
- for attempt in range(2):
92
- try:
93
- r = await client.post(url, headers=_hf_headers(), json=payload)
 
94
 
95
- if r.status_code == 200:
96
- b64 = base64.b64encode(r.content).decode()
97
- return {
98
- "ok": True, "image_b64": b64, "mime": "image/png",
99
- "model": req.model, "prompt": req.prompt[:100],
100
- }
101
-
102
- if r.status_code == 503 and attempt == 0:
103
- try:
104
- wait = min(float(r.json().get("estimated_time", 20)), 45)
105
- except Exception:
106
- wait = 20
107
- _logger.info("HF model loading, waiting %.0fs…", wait)
108
- await asyncio.sleep(wait)
109
- continue
110
-
111
- try:
112
- err = r.json().get("error", r.text[:200])
113
- except Exception:
114
- err = r.text[:200]
115
- return {
116
- "ok": False, "error": f"HF API {r.status_code}: {err}",
117
- "hint": "Aggiungi HF_TOKEN nelle variabili d'ambiente per più richieste/ora.",
118
- }
119
-
120
- except httpx.TimeoutException:
121
- return {"ok": False, "error": "Timeout 90s — modello in cold-start. Riprova tra 30s."}
122
- except Exception as e:
123
- return {"ok": False, "error": str(e)[:300]}
124
-
125
- return {"ok": False, "error": "Impossibile generare dopo 2 tentativi."}
 
 
 
 
 
 
 
 
 
 
126
 
127
 
128
  # ─── /analyze ─────────────────────────────────────────────────────────────────
@@ -134,8 +162,8 @@ async def analyze_image(req: AnalyzeImageRequest):
134
 
135
  Chain:
136
  1. Groq llama-3.2-11b-vision (free tier, 30 img/min)
137
- 2. GPT-4o-mini vision
138
- 3. BLIP-large captioning (HF, puro captioning senza Q&A)
139
  """
140
  # Scarica immagine se URL
141
  image_b64 = req.base64_image
@@ -185,8 +213,8 @@ async def analyze_image(req: AnalyzeImageRequest):
185
  _logger.debug("analyze_image: groq vision failed (%s)", type(_e).__name__)
186
 
187
  # 2. Gemini Vision (free tier — GEMINI_API_KEY da aistudio.google.com)
188
- # GAP-TOOL-2-fix: Gemini 1.5 Flash supporta vision, è gratuito su AI Studio, non richiede dominio.
189
- # Inserito prima di GPT-4o-mini (paid) come primo fallback gratuito di Groq.
190
  _gemini_key = os.getenv("GEMINI_API_KEY", "")
191
  if _gemini_key:
192
  try:
@@ -214,32 +242,32 @@ async def analyze_image(req: AnalyzeImageRequest):
214
  except Exception as _e:
215
  _logger.debug("analyze_image: gemini vision failed (%s)", type(_e).__name__)
216
 
217
- # 3. OpenAI GPT-4o-mini vision
218
- _openai_key = os.getenv("OPENAI_API_KEY", "")
219
- _openai_base = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1").rstrip("/")
220
- if _openai_key:
221
- try:
222
- async with httpx.AsyncClient(timeout=30) as c:
223
- r = await c.post(
224
- f"{_openai_base}/chat/completions",
225
- headers={"Authorization": f"Bearer {_openai_key}", "Content-Type": "application/json"},
226
- json={"model": "gpt-4o-mini", "max_tokens": 600, "messages": vision_body_msgs},
227
- )
228
- if r.status_code == 200:
229
- # S750-GAP-H: guard choices[] provider può ritornare {"error":...}
230
- _chs2 = r.json().get("choices") or []
231
- _desc2 = (_chs2[0].get("message",{}).get("content") or "") if _chs2 else ""
232
- if _desc2:
233
- return {"ok": True, "description": _desc2, "provider": "gpt-4o-mini"}
234
- except Exception as _e:
235
- _logger.debug("analyze_image: openai vision failed (%s)", type(_e).__name__)
236
 
237
- # 4. HF BLIP-large (captioning only — ultimo fallback)
238
  try:
239
  img_bytes = base64.b64decode(image_b64)
240
  async with httpx.AsyncClient(timeout=30) as c:
241
  r = await c.post(
242
- f"{_HF_API}/models/Salesforce/blip-image-captioning-large",
243
  headers={k: v for k, v in _hf_headers("application/octet-stream").items()},
244
  content=img_bytes,
245
  )
@@ -247,9 +275,7 @@ async def analyze_image(req: AnalyzeImageRequest):
247
  results = r.json()
248
  caption = (results[0].get("generated_text", "") if isinstance(results, list) and results else "")
249
  if caption:
250
- note = ("\n\n_BLIP fornisce solo didascalia base. Per Q&A su immagini, "
251
- "aggiungi GROQ_API_KEY (gratuito su console.groq.com)._")
252
- return {"ok": True, "description": caption + note, "provider": "blip-large"}
253
  elif r.status_code == 503:
254
  return {"ok": False, "error": "BLIP in avvio (cold-start ~30s). Riprova tra qualche secondo.",
255
  "hint": "Aggiungi GROQ_API_KEY per analisi rapida e senza limiti di cold-start."}
@@ -258,8 +284,7 @@ async def analyze_image(req: AnalyzeImageRequest):
258
 
259
  return {
260
  "ok": False, "error": "Analisi immagini non disponibile.",
261
- "hint": ("Aggiungi GROQ_API_KEY (free su console.groq.com) o OPENAI_API_KEY "
262
- "nelle variabili del tuo HF Space."),
263
  }
264
 
265
 
 
3
 
4
  Endpoints:
5
  POST /api/vision/generate — FLUX.1-schnell (HF Inference API)
6
+ POST /api/vision/analyze — Groq llama-3.2-vision / Gemini Vision / HF VQA + BLIP fallback
7
  GET /api/vision/search — Pexels > Pixabay > Unsplash Source (zero API key)
8
 
9
  Problematiche HF Inference API:
10
  - 503 "loading": cold-start fino a 60s → retry con backoff
11
  - Output generate: raw bytes PNG (non JSON)
12
+ - BLIP VQA risponde a domande semplici; BLIP captioning fornisce una didascalia di fallback
13
  - Rate limit senza HF_TOKEN: ~10 req/hr per IP
14
 
15
  Fallback chain analyze_image:
16
  1. Groq llama-3.2-11b-vision-preview (free tier, veloce, richiede GROQ_API_KEY)
17
+ 2. Gemini 2.5 Flash Vision (free tier, richiede GEMINI_API_KEY)
18
+ 3. HF BLIP VQA + captioning (richiede solo HF_TOKEN)
19
+
20
+ Image generation and editing use exclusively Hugging Face Inference API:
21
+ - Stable Diffusion 3 Medium for text-to-image generation via HF Inference Providers
22
+ - FLUX.1-Kontext-dev for prompt-guided image editing
23
  """
24
+ import asyncio, base64, io, os, httpx, logging
25
+ from huggingface_hub import InferenceClient
26
  from fastapi import APIRouter, Depends
27
  from .auth_guard import require_role, AuthRole
28
  from pydantic import BaseModel
 
30
  router = APIRouter(prefix="/api/vision", tags=["vision"], dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
31
  _logger = logging.getLogger("vision")
32
 
33
+ # Router Inference Providers: l’host api-inference legacy non è più disponibile.
34
+ _HF_API = "https://router.huggingface.co/hf-inference"
35
  _USER_AGENT = "Mozilla/5.0 (compatible; AgenteAI/3.0)"
36
 
37
  _MODEL_MAP: dict[str, str] = {
38
+ "FLUX.1-schnell": "stabilityai/stable-diffusion-3-medium-diffusers",
39
+ "FLUX.1-dev": "stabilityai/stable-diffusion-3-medium-diffusers",
40
+ "sdxl": "stabilityai/stable-diffusion-3-medium-diffusers",
41
+ "flux": "stabilityai/stable-diffusion-3-medium-diffusers",
42
+ "flux-schnell": "stabilityai/stable-diffusion-3-medium-diffusers",
43
  }
44
 
45
+ _EDIT_MODEL = "timbrooks/instruct-pix2pix"
46
+ _HF_VQA_MODEL = "Salesforce/blip-vqa-base"
47
+ _HF_CAPTION_MODEL = "Salesforce/blip-image-captioning-large"
48
+
49
 
50
  def _hf_headers(content_type: str = "application/json") -> dict:
51
  token = os.getenv("HF_TOKEN", "")
 
73
  question: str = "Descrivi questa immagine in dettaglio in italiano."
74
 
75
 
76
+ class EditImageRequest(BaseModel):
77
+ prompt: str
78
+ base64_image: str
79
+ negative_prompt: str = ""
80
+ steps: int = 5
81
+
82
+
83
  # ─── /generate ────────────────────────────────────────────────────────────────
84
 
85
  @router.post("/generate")
 
93
  - HF restituisce raw bytes PNG — non JSON.
94
  - steps ottimali FLUX.1-schnell: 4 (veloce) – 8 (qualità).
95
  """
96
+ model_id = _MODEL_MAP.get(req.model, "stabilityai/stable-diffusion-3-medium-diffusers")
97
+ prompt = req.prompt.strip()[:400]
98
+ steps = min(max(req.steps, 1), 8)
99
+ width = min(max(req.width, 256), 1024)
100
+ height = min(max(req.height, 256), 1024)
101
+
102
+ def _run_generation():
103
+ client = InferenceClient(token=os.getenv("HF_TOKEN"), provider="auto", timeout=90)
104
+ return client.text_to_image(
105
+ prompt=prompt,
106
+ model=model_id,
107
+ negative_prompt=req.negative_prompt[:200] if req.negative_prompt else None,
108
+ num_inference_steps=steps,
109
+ width=width,
110
+ height=height,
111
+ )
112
 
113
+ try:
114
+ generated = await asyncio.to_thread(_run_generation)
115
+ output = io.BytesIO()
116
+ generated.save(output, format="PNG")
117
+ return {"ok": True, "image_b64": base64.b64encode(output.getvalue()).decode(), "mime": "image/png", "model": model_id, "prompt": req.prompt[:100]}
118
+ except TimeoutError:
119
+ return {"ok": False, "error": "Timeout 90s — modello HF in cold-start. Riprova tra 30s."}
120
+ except Exception as e:
121
+ _logger.warning("HF image generation failed: %s", type(e).__name__)
122
+ return {"ok": False, "error": f"HF image generation unavailable: {str(e)[:300]}"}
123
+
124
+
125
+ # ─── /edit ────────────────────────────────────────────────────────────────────
126
+
127
+ @router.post("/edit")
128
+ async def edit_image(req: EditImageRequest):
129
+ """Modifica un’immagine con un provider Hugging Face selezionato automaticamente."""
130
+ try:
131
+ source = base64.b64decode(req.base64_image)
132
+ prompt = req.prompt.strip()[:400]
133
+ steps = min(max(req.steps, 1), 8)
134
+
135
+ def _run_edit():
136
+ client = InferenceClient(token=os.getenv("HF_TOKEN"), provider="auto", timeout=120)
137
+ return client.image_to_image(
138
+ image=source,
139
+ prompt=prompt,
140
+ model="black-forest-labs/FLUX.1-Kontext-dev",
141
+ negative_prompt=req.negative_prompt[:200] if req.negative_prompt else None,
142
+ num_inference_steps=steps,
143
+ )
144
+
145
+ edited = await asyncio.to_thread(_run_edit)
146
+ output = io.BytesIO()
147
+ edited.save(output, format="PNG")
148
+ return {"ok": True, "image_b64": base64.b64encode(output.getvalue()).decode(), "mime": "image/png", "model": "FLUX.1-Kontext-dev"}
149
+ except TimeoutError:
150
+ return {"ok": False, "error": "Timeout 120s — modello image-to-image in cold-start."}
151
+ except Exception as e:
152
+ _logger.warning("HF image edit failed: %s", type(e).__name__)
153
+ return {"ok": False, "error": f"HF image edit unavailable: {str(e)[:300]}"}
154
 
155
 
156
  # ─── /analyze ─────────────────────────────────────────────────────────────────
 
162
 
163
  Chain:
164
  1. Groq llama-3.2-11b-vision (free tier, 30 img/min)
165
+ 2. Gemini 2.5 Flash Vision (free tier)
166
+ 3. HF BLIP VQA, poi BLIP-large captioning
167
  """
168
  # Scarica immagine se URL
169
  image_b64 = req.base64_image
 
213
  _logger.debug("analyze_image: groq vision failed (%s)", type(_e).__name__)
214
 
215
  # 2. Gemini Vision (free tier — GEMINI_API_KEY da aistudio.google.com)
216
+ # Gemini 2.5 Flash supporta vision ed è disponibile nel tier gratuito AI Studio.
217
+ # Viene usato come fallback gratuito dopo Groq.
218
  _gemini_key = os.getenv("GEMINI_API_KEY", "")
219
  if _gemini_key:
220
  try:
 
242
  except Exception as _e:
243
  _logger.debug("analyze_image: gemini vision failed (%s)", type(_e).__name__)
244
 
245
+ # 3. Hugging Face BLIP VQA (Q&A) e captioning (fallback senza provider a pagamento)
246
+ try:
247
+ async with httpx.AsyncClient(timeout=45) as c:
248
+ vqa = await c.post(
249
+ f"{_HF_API}/models/{_HF_VQA_MODEL}",
250
+ headers=_hf_headers(),
251
+ json={"inputs": {"image": image_b64, "question": question}},
252
+ )
253
+ if vqa.status_code == 200:
254
+ results = vqa.json()
255
+ answer = ""
256
+ if isinstance(results, list) and results:
257
+ answer = str(results[0].get("answer", "") or results[0].get("generated_text", ""))
258
+ elif isinstance(results, dict):
259
+ answer = str(results.get("answer", "") or results.get("generated_text", ""))
260
+ if answer.strip():
261
+ return {"ok": True, "description": answer.strip(), "provider": "blip-vqa"}
262
+ except Exception as _e:
263
+ _logger.debug("analyze_image: HF VQA failed (%s)", type(_e).__name__)
264
 
265
+ # 4. HF BLIP-large captioning (ultimo fallback)
266
  try:
267
  img_bytes = base64.b64decode(image_b64)
268
  async with httpx.AsyncClient(timeout=30) as c:
269
  r = await c.post(
270
+ f"{_HF_API}/models/{_HF_CAPTION_MODEL}",
271
  headers={k: v for k, v in _hf_headers("application/octet-stream").items()},
272
  content=img_bytes,
273
  )
 
275
  results = r.json()
276
  caption = (results[0].get("generated_text", "") if isinstance(results, list) and results else "")
277
  if caption:
278
+ return {"ok": True, "description": caption, "provider": "blip-large"}
 
 
279
  elif r.status_code == 503:
280
  return {"ok": False, "error": "BLIP in avvio (cold-start ~30s). Riprova tra qualche secondo.",
281
  "hint": "Aggiungi GROQ_API_KEY per analisi rapida e senza limiti di cold-start."}
 
284
 
285
  return {
286
  "ok": False, "error": "Analisi immagini non disponibile.",
287
+ "hint": "Configura HF_TOKEN per il fallback Hugging Face oppure un provider gratuito Groq/Gemini.",
 
288
  }
289
 
290
 
benchmarks/__init__.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic benchmark validators and observe-only model discovery."""
2
+
3
+ from .model_watch_adapter import (
4
+ CatalogResult,
5
+ CatalogStatus,
6
+ GeminiModelsAdapter,
7
+ ObserveOnlyModelsAdapter,
8
+ ProfileScan,
9
+ ProviderProfile,
10
+ scan_profiles,
11
+ )
12
+ from .shadow_telemetry import (
13
+ shadow_enabled,
14
+ validate_and_record_shadow,
15
+ )
16
+ from .validators import (
17
+ ValidationResult,
18
+ validate_coding_output,
19
+ validate_coding_retry,
20
+ validate_mmlu_output,
21
+ validate_reasoning_output,
22
+ validate_reasoning_retry,
23
+ )
24
+
25
+ __all__ = [
26
+ "CatalogResult",
27
+ "CatalogStatus",
28
+ "GeminiModelsAdapter",
29
+ "ObserveOnlyModelsAdapter",
30
+ "ProfileScan",
31
+ "ProviderProfile",
32
+ "scan_profiles",
33
+ "ValidationResult",
34
+ "shadow_enabled",
35
+ "validate_and_record_shadow",
36
+ "validate_coding_output",
37
+ "validate_coding_retry",
38
+ "validate_mmlu_output",
39
+ "validate_reasoning_output",
40
+ "validate_reasoning_retry",
41
+ ]
benchmarks/model_watch_adapter.py ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Observe-only model catalog adapters.
2
+
3
+ This module performs discovery only. It never updates ai_providers, selects a
4
+ fallback, or persists credentials. Callers can use CatalogResult as an audit
5
+ record and decide separately whether a later approval/apply phase is allowed.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field, replace
10
+ import asyncio
11
+ from enum import Enum
12
+ import json
13
+ import os
14
+ import re
15
+ from typing import Any, Awaitable, Callable, Mapping, Optional
16
+
17
+ import httpx
18
+
19
+
20
+ class CatalogStatus(str, Enum):
21
+ AVAILABLE = "available"
22
+ UNAUTHORIZED = "unauthorized"
23
+ FORBIDDEN = "forbidden"
24
+ RATE_LIMITED = "rate_limited"
25
+ PROVIDER_ERROR = "provider_error"
26
+ TIMEOUT = "timeout"
27
+ NETWORK_ERROR = "network_error"
28
+ MALFORMED = "malformed"
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class ModelWatchConfig:
33
+ """Safety gate for optional model updates.
34
+
35
+ Discovery remains observe-only by default. Auto-apply is enabled only when
36
+ the explicit flag and approval marker are both present; callers must also
37
+ provide an allowlist of provider/old/new model triples.
38
+ """
39
+
40
+ auto_apply_enabled: bool = False
41
+ approval_marker: str = ""
42
+ required_approval_marker: str = "I_UNDERSTAND_MODEL_UPDATES"
43
+ approved_updates: tuple[tuple[str, str, str], ...] = ()
44
+
45
+ @classmethod
46
+ def from_env(cls) -> "ModelWatchConfig":
47
+ raw_updates = os.getenv("MODEL_AUTO_APPLY_ALLOWLIST", "")
48
+ updates: list[tuple[str, str, str]] = []
49
+ for item in raw_updates.split(","):
50
+ parts = tuple(part.strip() for part in item.split("|"))
51
+ if len(parts) == 3 and all(parts):
52
+ updates.append(parts) # type: ignore[arg-type]
53
+ return cls(
54
+ auto_apply_enabled=os.getenv("MODEL_AUTO_APPLY_ENABLED", "0").lower() in {"1", "true", "yes"},
55
+ approval_marker=os.getenv("MODEL_AUTO_APPLY_APPROVAL", ""),
56
+ approved_updates=tuple(updates),
57
+ )
58
+
59
+ @property
60
+ def can_auto_apply(self) -> bool:
61
+ return (
62
+ self.auto_apply_enabled
63
+ and self.approval_marker == self.required_approval_marker
64
+ and bool(self.approved_updates)
65
+ )
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class ProviderProfile:
70
+ provider: str
71
+ profile: str
72
+ base_url: str
73
+ api_key: str
74
+ default_model: str
75
+ auth_mode: str = "bearer" # bearer | query_key
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class CatalogResult:
80
+ provider: str
81
+ profile: str
82
+ status: CatalogStatus
83
+ http_status: Optional[int] = None
84
+ models: tuple[str, ...] = ()
85
+ default_available: Optional[bool] = None
86
+ retry_after_seconds: Optional[int] = None
87
+ detail: str = ""
88
+ checked_url: str = ""
89
+ metadata: Mapping[str, Any] = field(default_factory=dict)
90
+
91
+ @property
92
+ def should_auto_apply(self) -> bool:
93
+ """Observe-only invariant: this adapter can never authorize a write."""
94
+ return False
95
+
96
+ def as_audit_record(self) -> dict[str, Any]:
97
+ return {
98
+ "provider": self.provider,
99
+ "profile": self.profile,
100
+ "status": self.status.value,
101
+ "http_status": self.http_status,
102
+ "model_count": len(self.models),
103
+ "default_available": self.default_available,
104
+ "retry_after_seconds": self.retry_after_seconds,
105
+ "detail": self.detail[:240],
106
+ "checked_url": self.checked_url,
107
+ "metadata": dict(self.metadata),
108
+ }
109
+
110
+
111
+ _RETRY_AFTER_SECONDS = re.compile(r"^\s*(\d+)\s*$")
112
+
113
+
114
+ def models_url(base_url: str) -> str:
115
+ """Normalize common OpenAI-compatible base URLs to a models endpoint."""
116
+ value = base_url.rstrip("/")
117
+ for suffix in ("/chat/completions", "/completions"):
118
+ if value.endswith(suffix):
119
+ value = value[: -len(suffix)]
120
+ if not value.endswith("/models"):
121
+ value += "/models"
122
+ return value
123
+
124
+
125
+ def _retry_after(headers: Mapping[str, str]) -> Optional[int]:
126
+ raw = headers.get("retry-after") or headers.get("Retry-After")
127
+ if not raw:
128
+ return None
129
+ match = _RETRY_AFTER_SECONDS.match(raw)
130
+ return int(match.group(1)) if match else None
131
+
132
+
133
+ def _safe_detail(response: httpx.Response) -> str:
134
+ """Return bounded provider detail without authorization headers or secrets."""
135
+ try:
136
+ payload = response.json()
137
+ if isinstance(payload, Mapping):
138
+ for key in ("error", "message", "detail", "code"):
139
+ value = payload.get(key)
140
+ if value is not None:
141
+ return str(value)[:240]
142
+ return json.dumps(payload, ensure_ascii=True)[:240]
143
+ except Exception:
144
+ return response.text[:240]
145
+
146
+
147
+ def _models_from_gemini_payload(payload: Any) -> tuple[str, ...] | None:
148
+ """Parse Gemini's native {models: [{name: 'models/<id>'}]} payload."""
149
+ if not isinstance(payload, Mapping) or not isinstance(payload.get("models"), list):
150
+ return None
151
+ models: list[str] = []
152
+ for item in payload["models"]:
153
+ if not isinstance(item, Mapping):
154
+ continue
155
+ name = item.get("name") or item.get("baseModelId")
156
+ if isinstance(name, str) and name.strip():
157
+ normalized = name.strip()
158
+ if normalized.startswith("models/"):
159
+ normalized = normalized[len("models/"):]
160
+ models.append(normalized)
161
+ return tuple(dict.fromkeys(models))
162
+
163
+
164
+ def _models_from_payload(payload: Any) -> tuple[str, ...] | None:
165
+ if isinstance(payload, Mapping):
166
+ items = payload.get("data")
167
+ else:
168
+ items = payload
169
+ if not isinstance(items, list):
170
+ return None
171
+ models: list[str] = []
172
+ for item in items:
173
+ if isinstance(item, Mapping) and isinstance(item.get("id"), str) and item["id"].strip():
174
+ models.append(item["id"].strip())
175
+ return tuple(dict.fromkeys(models))
176
+
177
+
178
+ @dataclass(frozen=True)
179
+ class ProfileScan:
180
+ """Results plus profiles skipped because their provider returned 429."""
181
+ results: tuple[CatalogResult, ...]
182
+ skipped_rate_limited: tuple[CatalogResult, ...] = ()
183
+
184
+
185
+ class ObserveOnlyModelsAdapter:
186
+ """Fetch a provider catalog and classify the result; never mutates state."""
187
+
188
+ def __init__(
189
+ self,
190
+ *,
191
+ timeout_seconds: float = 8.0,
192
+ client: httpx.AsyncClient | None = None,
193
+ config: ModelWatchConfig | None = None,
194
+ ):
195
+ self.timeout_seconds = timeout_seconds
196
+ self._client = client
197
+ self.config = config or ModelWatchConfig.from_env()
198
+
199
+ @property
200
+ def can_auto_apply(self) -> bool:
201
+ """True only when every explicit safety gate is satisfied."""
202
+ return self.config.can_auto_apply
203
+
204
+ async def apply_updates(
205
+ self,
206
+ updates: list[tuple[str, str, str]],
207
+ apply_callback: Callable[[str, str, str], Awaitable[None]],
208
+ ) -> dict[str, Any]:
209
+ """Apply only allowlisted updates through a caller-owned callback.
210
+
211
+ The adapter never receives a database client and cannot mutate state on
212
+ its own. With the default config this returns a dry-run result.
213
+ """
214
+ if not self.can_auto_apply:
215
+ return {"applied": False, "dry_run": True, "reason": "auto_apply_disabled"}
216
+ approved = set(self.config.approved_updates)
217
+ applied = 0
218
+ skipped = 0
219
+ for provider, old_model, new_model in updates:
220
+ if (provider, old_model, new_model) not in approved:
221
+ skipped += 1
222
+ continue
223
+ await apply_callback(provider, old_model, new_model)
224
+ applied += 1
225
+ return {"applied": applied > 0, "dry_run": False, "applied_count": applied, "skipped_count": skipped}
226
+
227
+ async def list_models(self, profile: ProviderProfile) -> CatalogResult:
228
+ url = models_url(profile.base_url)
229
+ headers = {"Accept": "application/json"}
230
+ params: dict[str, str] = {}
231
+ if profile.auth_mode == "query_key":
232
+ params["key"] = profile.api_key
233
+ elif profile.auth_mode != "none":
234
+ headers["Authorization"] = f"Bearer {profile.api_key}"
235
+
236
+ owns_client = self._client is None
237
+ client = self._client or httpx.AsyncClient(timeout=self.timeout_seconds)
238
+ try:
239
+ response = await client.get(url, headers=headers, params=params)
240
+ status = response.status_code
241
+ if status == 401:
242
+ return self._result(profile, url, CatalogStatus.UNAUTHORIZED, response)
243
+ if status == 403:
244
+ return self._result(profile, url, CatalogStatus.FORBIDDEN, response)
245
+ if status == 429:
246
+ return self._result(profile, url, CatalogStatus.RATE_LIMITED, response)
247
+ if 500 <= status <= 599:
248
+ return self._result(profile, url, CatalogStatus.PROVIDER_ERROR, response)
249
+ if status != 200:
250
+ return self._result(profile, url, CatalogStatus.NETWORK_ERROR, response)
251
+ try:
252
+ payload = response.json()
253
+ except (ValueError, json.JSONDecodeError):
254
+ return self._result(profile, url, CatalogStatus.MALFORMED, response, detail="invalid JSON")
255
+ models = _models_from_payload(payload)
256
+ if models is None:
257
+ return self._result(profile, url, CatalogStatus.MALFORMED, response, detail="missing data list")
258
+ return CatalogResult(
259
+ provider=profile.provider,
260
+ profile=profile.profile,
261
+ status=CatalogStatus.AVAILABLE,
262
+ http_status=status,
263
+ models=models,
264
+ default_available=profile.default_model in models,
265
+ checked_url=url,
266
+ detail="catalog fetched",
267
+ )
268
+ except httpx.TimeoutException as exc:
269
+ return CatalogResult(profile.provider, profile.profile, CatalogStatus.TIMEOUT, detail=str(exc)[:240], checked_url=url)
270
+ except httpx.RequestError as exc:
271
+ return CatalogResult(profile.provider, profile.profile, CatalogStatus.NETWORK_ERROR, detail=str(exc)[:240], checked_url=url)
272
+ finally:
273
+ if owns_client:
274
+ await client.aclose()
275
+
276
+ @staticmethod
277
+ def _result(profile: ProviderProfile, url: str, status: CatalogStatus, response: httpx.Response, *, detail: str = "") -> CatalogResult:
278
+ return CatalogResult(
279
+ provider=profile.provider,
280
+ profile=profile.profile,
281
+ status=status,
282
+ http_status=response.status_code,
283
+ retry_after_seconds=_retry_after(response.headers) if status == CatalogStatus.RATE_LIMITED else None,
284
+ detail=detail or _safe_detail(response),
285
+ checked_url=url,
286
+ )
287
+
288
+
289
+ class GeminiModelsAdapter(ObserveOnlyModelsAdapter):
290
+ """Observe-only adapter for Gemini's native ``models`` catalog."""
291
+
292
+ async def list_models(self, profile: ProviderProfile) -> CatalogResult:
293
+ url = models_url(profile.base_url)
294
+ headers = {"Accept": "application/json"}
295
+ params = {"key": profile.api_key} if profile.auth_mode != "none" else {}
296
+ owns_client = self._client is None
297
+ client = self._client or httpx.AsyncClient(timeout=self.timeout_seconds)
298
+ try:
299
+ response = await client.get(url, headers=headers, params=params)
300
+ status = response.status_code
301
+ if status == 401:
302
+ return self._result(profile, url, CatalogStatus.UNAUTHORIZED, response)
303
+ if status == 403:
304
+ return self._result(profile, url, CatalogStatus.FORBIDDEN, response)
305
+ if status == 429:
306
+ return self._result(profile, url, CatalogStatus.RATE_LIMITED, response)
307
+ if 500 <= status <= 599:
308
+ return self._result(profile, url, CatalogStatus.PROVIDER_ERROR, response)
309
+ if status != 200:
310
+ return self._result(profile, url, CatalogStatus.NETWORK_ERROR, response)
311
+ try:
312
+ payload = response.json()
313
+ except (ValueError, json.JSONDecodeError):
314
+ return self._result(profile, url, CatalogStatus.MALFORMED, response, detail="invalid JSON")
315
+ models = _models_from_gemini_payload(payload)
316
+ if models is None:
317
+ return self._result(profile, url, CatalogStatus.MALFORMED, response, detail="missing models list")
318
+ return CatalogResult(
319
+ provider=profile.provider,
320
+ profile=profile.profile,
321
+ status=CatalogStatus.AVAILABLE,
322
+ http_status=status,
323
+ models=models,
324
+ default_available=profile.default_model in models,
325
+ checked_url=url,
326
+ detail="Gemini catalog fetched",
327
+ metadata={"catalog_format": "gemini_native"},
328
+ )
329
+ except httpx.TimeoutException as exc:
330
+ return CatalogResult(profile.provider, profile.profile, CatalogStatus.TIMEOUT, detail=str(exc)[:240], checked_url=url)
331
+ except httpx.RequestError as exc:
332
+ return CatalogResult(profile.provider, profile.profile, CatalogStatus.NETWORK_ERROR, detail=str(exc)[:240], checked_url=url)
333
+ finally:
334
+ if owns_client:
335
+ await client.aclose()
336
+
337
+
338
+ async def scan_profiles(
339
+ profiles: list[ProviderProfile],
340
+ *,
341
+ adapter: ObserveOnlyModelsAdapter | None = None,
342
+ ) -> ProfileScan:
343
+ """Scan a mixed pool and isolate 429 profiles without blocking healthy ones."""
344
+ adapter = adapter or ObserveOnlyModelsAdapter()
345
+ results = await asyncio.gather(*(adapter.list_models(profile) for profile in profiles))
346
+ skipped = tuple(
347
+ replace(result, metadata={"skipped": True, "skip_reason": "rate_limited"})
348
+ for result in results
349
+ if result.status == CatalogStatus.RATE_LIMITED
350
+ )
351
+ active = tuple(result for result in results if result.status != CatalogStatus.RATE_LIMITED)
352
+ return ProfileScan(results=active, skipped_rate_limited=skipped)
benchmarks/shadow_telemetry.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fail-open shadow telemetry for benchmark output validators.
2
+
3
+ Shadow mode records validator outcomes only. It never changes the answer, retry
4
+ budget, provider selection, or benchmark score. Raw model output is deliberately
5
+ not persisted; only length and normalized validator evidence are stored.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime, timezone
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ import threading
15
+ from typing import Any, Mapping, Optional
16
+
17
+ from .validators import ValidationResult, validate_coding_output, validate_mmlu_output
18
+
19
+
20
+ _ENABLED_VALUES = frozenset({"1", "true", "yes", "on"})
21
+ _WRITE_LOCK = threading.Lock()
22
+
23
+
24
+ def shadow_enabled() -> bool:
25
+ return os.getenv("BENCHMARK_SHADOW_MODE", "0").strip().lower() in _ENABLED_VALUES
26
+
27
+
28
+ def infer_benchmark_category(goal: Any) -> Optional[str]:
29
+ """Infer only the two supported benchmark categories from explicit markers."""
30
+
31
+ text = str(goal or "")
32
+ lowered = text.lower()
33
+ if "mmlu" in lowered or "scelta multipla" in lowered or "a/b/c/d" in lowered:
34
+ return "mmlu"
35
+ if "code_correct" in lowered or "typescript" in lowered or "```typescript" in lowered:
36
+ return "coding"
37
+ return None
38
+
39
+
40
+ def _safe_metadata(metadata: Optional[Mapping[str, Any]]) -> dict[str, Any]:
41
+ allowed = {
42
+ "provider",
43
+ "model",
44
+ "profile",
45
+ "attempt",
46
+ "latency_ms",
47
+ "first_token_ms",
48
+ "task_id",
49
+ "source",
50
+ }
51
+ safe: dict[str, Any] = {}
52
+ for key in allowed:
53
+ value = (metadata or {}).get(key)
54
+ if value is None:
55
+ continue
56
+ if isinstance(value, (str, int, float, bool)):
57
+ safe[key] = value
58
+ else:
59
+ safe[key] = str(value)[:120]
60
+ return safe
61
+
62
+
63
+ def _evidence_for_log(result: ValidationResult) -> dict[str, Any]:
64
+ evidence: dict[str, Any] = {}
65
+ for key, value in result.evidence.items():
66
+ if key == "source_length":
67
+ evidence[key] = value
68
+ elif key in {"candidates", "distinct_candidates", "required_symbols", "missing_symbols", "declarations", "fence_count", "languages", "extraction", "significant_lines", "correct", "expected", "has_import_or_export", "has_syntax_tokens"}:
69
+ evidence[key] = value
70
+ return evidence
71
+
72
+
73
+ def _log_path() -> Path:
74
+ return Path(os.getenv("BENCHMARK_SHADOW_LOG_PATH", "/tmp/baida98-benchmark-shadow.jsonl"))
75
+
76
+
77
+ def _append_event(event: dict[str, Any]) -> None:
78
+ path = _log_path()
79
+ path.parent.mkdir(parents=True, exist_ok=True)
80
+ with _WRITE_LOCK:
81
+ with path.open("a", encoding="utf-8") as handle:
82
+ handle.write(json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n")
83
+
84
+
85
+ def validate_and_record_shadow(
86
+ *,
87
+ goal: Any,
88
+ answer: Any,
89
+ metadata: Optional[Mapping[str, Any]] = None,
90
+ ) -> Optional[ValidationResult]:
91
+ """Validate and record a supported benchmark response in fail-open shadow mode."""
92
+
93
+ if not shadow_enabled():
94
+ return None
95
+
96
+ category = infer_benchmark_category(goal)
97
+ if category is None:
98
+ return None
99
+
100
+ if category == "mmlu":
101
+ result = validate_mmlu_output(answer)
102
+ validator = "mmlu_v1"
103
+ else:
104
+ result = validate_coding_output(answer)
105
+ validator = "coding_v1"
106
+
107
+ text = answer if isinstance(answer, str) else str(answer or "")
108
+ event = {
109
+ "schema_version": 1,
110
+ "event": "benchmark_shadow_validation",
111
+ "timestamp": datetime.now(timezone.utc).isoformat(),
112
+ "category": category,
113
+ "validator": validator,
114
+ "valid": result.valid,
115
+ "failure_code": result.failure_code,
116
+ "response_chars": len(text),
117
+ "evidence": _evidence_for_log(result),
118
+ "metadata": _safe_metadata(metadata),
119
+ }
120
+ try:
121
+ _append_event(event)
122
+ except Exception:
123
+ # Shadow telemetry must never break the agent loop.
124
+ return result
125
+ return result
benchmarks/validators.py ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic validators for benchmark outputs.
2
+
3
+ The validators in this module deliberately do not call an LLM or a provider. They
4
+ only normalize an output when the evidence is unambiguous and otherwise return a
5
+ stable failure code that the retry layer can act on.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ import json
12
+ import re
13
+ from typing import Any, Iterable, Mapping, Optional
14
+
15
+
16
+ _MMLU_LETTERS = frozenset("ABCD")
17
+ _MMLU_EXPLICIT = re.compile(
18
+ r"\b(?:answer|答案|risposta|final(?:\s+answer)?|choice|scelta)\b\s*"
19
+ r"(?:is|=|:)\s*[*`_\[\(]*([A-D])[*`_\]\)]*",
20
+ re.IGNORECASE,
21
+ )
22
+ _MMLU_MARKED = re.compile(r"(?:^|\n|\s)(?:\(?([A-D])\)?)[\.:\)](?:\s|$)", re.IGNORECASE)
23
+ _MMLU_ISOLATED = re.compile(r"(?<![A-Za-z])([A-D])(?![A-Za-z])", re.IGNORECASE)
24
+
25
+ _CODE_FENCE = re.compile(
26
+ r"```\s*([A-Za-z0-9_+#.-]*)\s*\n?(.*?)```", re.IGNORECASE | re.DOTALL
27
+ )
28
+ _CODE_JSON_KEYS = ("code", "typescript", "source", "implementation")
29
+ _TS_DECLARATION = re.compile(
30
+ r"\b(?:export\s+)?(?:async\s+)?(?:function|class|interface|type|const|let|var)\s+([A-Za-z_$][\w$]*)",
31
+ re.MULTILINE,
32
+ )
33
+ _TS_IMPORT_EXPORT = re.compile(r"\b(?:import|export)\b")
34
+ _TS_SYNTAX_TOKENS = re.compile(r"[{}();]|=>|:\s*[A-Za-z_$][\w$<>,\[\]| ]*")
35
+ _PLACEHOLDER = re.compile(r"\b(?:TODO|TBD|your implementation|implement here)\b", re.IGNORECASE)
36
+ _REASONING_EXPLICIT = re.compile(
37
+ r"(?:####|final\s+answer|answer|risposta|risultato|result|total|totale)\s*[:=]?\s*"
38
+ r"(-?\d[\d,]*(?:\.\d+)?)",
39
+ re.IGNORECASE,
40
+ )
41
+ _REASONING_BOLD = re.compile(r"\*\*\s*(-?\d[\d,]*(?:\.\d+)?)\s*\*\*")
42
+ _REASONING_LINE_NUMBER = re.compile(r"(?m)^\s*(-?\d[\d,]*(?:\.\d+)?)\s*$")
43
+ _REASONING_FAILURES = frozenset({"answer_missing", "wrong_numeric_answer", "calculation_conflict"})
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class ValidationResult:
48
+ """Stable validator result consumed by shadow mode and retry logic."""
49
+
50
+ valid: bool
51
+ normalized: Optional[str] = None
52
+ failure_code: Optional[str] = None
53
+ evidence: dict[str, Any] = field(default_factory=dict)
54
+ repair_hint: Optional[str] = None
55
+
56
+ def as_dict(self) -> dict[str, Any]:
57
+ return {
58
+ "valid": self.valid,
59
+ "normalized": self.normalized,
60
+ "failure_code": self.failure_code,
61
+ "evidence": self.evidence,
62
+ "repair_hint": self.repair_hint,
63
+ }
64
+
65
+
66
+ def _failure(code: str, *, evidence: Optional[dict[str, Any]] = None, hint: str = "") -> ValidationResult:
67
+ return ValidationResult(
68
+ valid=False,
69
+ failure_code=code,
70
+ evidence=evidence or {},
71
+ repair_hint=hint or None,
72
+ )
73
+
74
+
75
+ def _success(normalized: str, *, evidence: Optional[dict[str, Any]] = None) -> ValidationResult:
76
+ return ValidationResult(valid=True, normalized=normalized, evidence=evidence or {})
77
+
78
+
79
+ def _clean_text(raw: Any) -> str:
80
+ if raw is None:
81
+ return ""
82
+ if isinstance(raw, str):
83
+ return raw.strip()
84
+ return str(raw).strip()
85
+
86
+
87
+ def _mmlu_candidates(text: str) -> list[str]:
88
+ """Return candidates in evidence order, preserving duplicates for ambiguity checks."""
89
+ explicit = [m.group(1).upper() for m in _MMLU_EXPLICIT.finditer(text)]
90
+ if explicit:
91
+ return explicit
92
+ marked = [m.group(1).upper() for m in _MMLU_MARKED.finditer(text)]
93
+ if marked:
94
+ return marked
95
+ return [m.group(1).upper() for m in _MMLU_ISOLATED.finditer(text)]
96
+
97
+
98
+ def validate_mmlu_output(raw: Any, *, expected: Optional[str] = None) -> ValidationResult:
99
+ """Validate a multiple-choice answer without guessing from explanation prose.
100
+
101
+ Accepted outputs contain one unambiguous A/B/C/D choice. Explicit labels such
102
+ as ``ANSWER: C`` have priority over marked choices and isolated letters. If
103
+ multiple distinct candidates are present, the result is ambiguous and fails.
104
+ ``expected`` is optional and is only used to expose correctness in evidence; it
105
+ never changes the parsing result.
106
+ """
107
+
108
+ text = _clean_text(raw)
109
+ if not text:
110
+ return _failure(
111
+ "answer_missing",
112
+ hint="Return exactly one canonical choice using ANSWER: A, B, C, or D.",
113
+ )
114
+
115
+ candidates = _mmlu_candidates(text)
116
+ distinct = sorted(set(candidates))
117
+ evidence: dict[str, Any] = {
118
+ "candidates": candidates,
119
+ "distinct_candidates": distinct,
120
+ "source_length": len(text),
121
+ }
122
+ if expected is not None:
123
+ normalized_expected = str(expected).strip().upper()
124
+ evidence["expected"] = normalized_expected
125
+ if normalized_expected in _MMLU_LETTERS:
126
+ evidence["correct"] = len(distinct) == 1 and distinct[0] == normalized_expected
127
+
128
+ if not candidates:
129
+ return _failure(
130
+ "answer_missing",
131
+ evidence=evidence,
132
+ hint="Return exactly one canonical choice using ANSWER: A, B, C, or D.",
133
+ )
134
+ if len(distinct) != 1 or distinct[0] not in _MMLU_LETTERS:
135
+ return _failure(
136
+ "answer_ambiguous",
137
+ evidence=evidence,
138
+ hint="Remove competing choices and return one letter: A, B, C, or D.",
139
+ )
140
+
141
+ return _success(distinct[0], evidence=evidence)
142
+
143
+
144
+ def _extract_code(raw: Any) -> tuple[Optional[str], str, dict[str, Any]]:
145
+ """Extract code from a TypeScript fence or a JSON envelope."""
146
+
147
+ text = _clean_text(raw)
148
+ if not text:
149
+ return None, "none", {"source_length": 0}
150
+
151
+ try:
152
+ decoded = json.loads(text)
153
+ except (TypeError, json.JSONDecodeError):
154
+ decoded = None
155
+ if isinstance(decoded, Mapping):
156
+ for key in _CODE_JSON_KEYS:
157
+ value = decoded.get(key)
158
+ if isinstance(value, str) and value.strip():
159
+ return value.strip(), f"json:{key}", {"source_length": len(text)}
160
+
161
+ fences = _CODE_FENCE.findall(text)
162
+ if fences:
163
+ typed = [body.strip() for language, body in fences if language.lower() in {"ts", "typescript"}]
164
+ if typed:
165
+ return max(typed, key=len), "fence:typescript", {"fence_count": len(fences)}
166
+ return None, "fence:wrong-language", {"languages": [language.lower() for language, _ in fences]}
167
+
168
+ return None, "none", {"source_length": len(text)}
169
+
170
+
171
+ def _normalize_symbols(required_symbols: Iterable[str]) -> list[str]:
172
+ return [symbol.strip() for symbol in required_symbols if str(symbol).strip()]
173
+
174
+
175
+ def _parse_numeric_token(value: str) -> int | float:
176
+ normalized = value.replace(",", "").strip()
177
+ number = float(normalized) if "." in normalized else int(normalized)
178
+ return number
179
+
180
+
181
+ def _reasoning_candidates(text: str) -> tuple[list[int | float], str]:
182
+ """Extract answer candidates conservatively, preferring explicit final markers."""
183
+ explicit = [_parse_numeric_token(match.group(1)) for match in _REASONING_EXPLICIT.finditer(text)]
184
+ if explicit:
185
+ return explicit, "explicit"
186
+ bold = [_parse_numeric_token(match.group(1)) for match in _REASONING_BOLD.finditer(text)]
187
+ if bold:
188
+ return bold, "bold"
189
+ lines = [_parse_numeric_token(match.group(1)) for match in _REASONING_LINE_NUMBER.finditer(text)]
190
+ if lines:
191
+ return lines[-1:], "final_line"
192
+ return [], "none"
193
+
194
+
195
+ def validate_reasoning_output(raw: Any, *, expected: Optional[int | float] = None) -> ValidationResult:
196
+ """Validate a numeric reasoning answer without calling an LLM.
197
+
198
+ Explicit final markers have priority over intermediate arithmetic. Multiple
199
+ distinct explicit answers are classified as a conflict rather than guessed.
200
+ """
201
+ text = _clean_text(raw)
202
+ if not text:
203
+ return _failure(
204
+ "answer_missing",
205
+ hint="Show the calculation and finish with #### N, where N is the final integer.",
206
+ )
207
+
208
+ candidates, source = _reasoning_candidates(text)
209
+ distinct = list(dict.fromkeys(candidates))
210
+ evidence: dict[str, Any] = {
211
+ "candidates": candidates,
212
+ "distinct_candidates": distinct,
213
+ "source": source,
214
+ "source_length": len(text),
215
+ }
216
+ if expected is not None:
217
+ try:
218
+ normalized_expected = _parse_numeric_token(str(expected))
219
+ evidence["expected"] = normalized_expected
220
+ except ValueError:
221
+ normalized_expected = expected
222
+
223
+ if not candidates:
224
+ return _failure(
225
+ "answer_missing",
226
+ evidence=evidence,
227
+ hint="Show the calculation and finish with #### N, where N is the final integer.",
228
+ )
229
+ if len(distinct) > 1:
230
+ return _failure(
231
+ "calculation_conflict",
232
+ evidence=evidence,
233
+ hint="Recalculate the final value and provide exactly one final numeric answer.",
234
+ )
235
+
236
+ normalized = distinct[0]
237
+ if expected is not None and normalized != normalized_expected:
238
+ evidence["correct"] = False
239
+ return _failure(
240
+ "wrong_numeric_answer",
241
+ evidence=evidence,
242
+ hint="Recheck every arithmetic step and return the corrected final number.",
243
+ )
244
+
245
+ evidence["correct"] = True if expected is not None else None
246
+ return _success(str(normalized), evidence=evidence)
247
+
248
+
249
+ def validate_reasoning_retry(
250
+ goal: Any,
251
+ raw: Any,
252
+ *,
253
+ expected: Optional[int | float] = None,
254
+ is_last_attempt: bool,
255
+ ) -> Optional[ValidationResult]:
256
+ """Return a reasoning failure only when a non-final numeric retry is warranted."""
257
+ if is_last_attempt or not any(marker in str(goal or "").lower() for marker in ("reasoning", "gsm8k", "risolvi il problema matematico")):
258
+ return None
259
+ result = validate_reasoning_output(raw, expected=expected)
260
+ return result if result.failure_code in _REASONING_FAILURES else None
261
+
262
+
263
+ _CODING_RETRY_FAILURES = frozenset({
264
+ "code_missing",
265
+ "code_wrong_language",
266
+ "code_empty",
267
+ "code_placeholder",
268
+ "required_symbol_missing",
269
+ "code_syntax_suspect",
270
+ })
271
+
272
+
273
+ def is_typescript_goal(goal: Any) -> bool:
274
+ lowered = str(goal or "").lower()
275
+ return any(marker in lowered for marker in ("code_correct", "typescript", "```ts", "```typescript"))
276
+
277
+
278
+ def validate_coding_retry(goal: Any, raw: Any, *, is_last_attempt: bool) -> Optional[ValidationResult]:
279
+ """Return the failed result only when a non-final TypeScript retry is warranted."""
280
+
281
+ if is_last_attempt or not is_typescript_goal(goal):
282
+ return None
283
+ result = validate_coding_output(raw)
284
+ return result if result.failure_code in _CODING_RETRY_FAILURES else None
285
+
286
+
287
+ def validate_coding_output(
288
+ raw: Any,
289
+ *,
290
+ required_symbols: Iterable[str] = (),
291
+ min_significant_lines: int = 1,
292
+ reject_placeholders: bool = True,
293
+ ) -> ValidationResult:
294
+ """Validate extraction and minimum structural quality of TypeScript output.
295
+
296
+ This is intentionally a contract validator, not a compiler. Syntax checks are
297
+ conservative and deterministic; full compilation remains a separate isolated
298
+ integration test because it depends on the repository's TypeScript toolchain.
299
+ """
300
+
301
+ code, source, extraction_evidence = _extract_code(raw)
302
+ if code is None:
303
+ failure = "code_wrong_language" if source == "fence:wrong-language" else "code_missing"
304
+ return _failure(
305
+ failure,
306
+ evidence=extraction_evidence | {"extraction": source},
307
+ hint="Return exactly one non-empty ```typescript code block.",
308
+ )
309
+
310
+ significant_lines = [line for line in code.splitlines() if line.strip() and not line.strip().startswith("//")]
311
+ evidence: dict[str, Any] = extraction_evidence | {
312
+ "extraction": source,
313
+ "significant_lines": len(significant_lines),
314
+ "has_import_or_export": bool(_TS_IMPORT_EXPORT.search(code)),
315
+ "has_syntax_tokens": bool(_TS_SYNTAX_TOKENS.search(code)),
316
+ }
317
+
318
+ if not significant_lines or len(significant_lines) < max(1, min_significant_lines):
319
+ return _failure(
320
+ "code_empty",
321
+ evidence=evidence,
322
+ hint="Provide a complete non-empty TypeScript implementation.",
323
+ )
324
+ if reject_placeholders and _PLACEHOLDER.search(code):
325
+ return _failure(
326
+ "code_placeholder",
327
+ evidence=evidence,
328
+ hint="Replace TODO/TBD placeholders with executable TypeScript.",
329
+ )
330
+
331
+ declarations = {match.group(1) for match in _TS_DECLARATION.finditer(code)}
332
+ required = _normalize_symbols(required_symbols)
333
+ missing = [symbol for symbol in required if symbol not in declarations and not re.search(rf"\b{re.escape(symbol)}\b", code)]
334
+ evidence["declarations"] = sorted(declarations)
335
+ evidence["required_symbols"] = required
336
+ evidence["missing_symbols"] = missing
337
+ if missing:
338
+ return _failure(
339
+ "required_symbol_missing",
340
+ evidence=evidence,
341
+ hint=f"Implement and expose the required symbols: {', '.join(missing)}.",
342
+ )
343
+
344
+ if not _TS_SYNTAX_TOKENS.search(code):
345
+ return _failure(
346
+ "code_syntax_suspect",
347
+ evidence=evidence,
348
+ hint="Return syntactically structured TypeScript with declarations and delimiters.",
349
+ )
350
+
351
+ return _success(code, evidence=evidence)
main.py CHANGED
@@ -9,6 +9,7 @@ import argparse
9
  from fastapi import FastAPI
10
  from fastapi.middleware.cors import CORSMiddleware
11
  from fastapi.staticfiles import StaticFiles
 
12
 
13
  # Configurazione Logging
14
  logging.basicConfig(
@@ -21,7 +22,7 @@ _logger = logging.getLogger("agente_ai.main")
21
  app = FastAPI(
22
  title="Agente AI API",
23
  description="Backend per l'orchestrazione di agenti autonomi e tool-use.",
24
- version="1.5.3",
25
  )
26
 
27
  # CORS
@@ -56,6 +57,9 @@ async def _run_auto_migration():
56
  'VAULT_KEY', 'INTERNAL_TOKEN', 'DEPLOY_SECRET', 'WEBHOOK_TOKEN',
57
  'TERMINAL_SECRET', 'EXEC_TOKEN', 'VITE_INTERNAL_TOKEN', 'VITE_TERMINAL_SECRET',
58
  'VITE_OPENROUTER_API_KEY', 'VITE_HF_TOKEN', 'VITE_GROQ_API_KEY',
 
 
 
59
  'GH_PAGES_TOKEN', 'VERCEL_TOKEN'
60
  ]
61
 
@@ -139,7 +143,11 @@ _ROUTER_MAP = {
139
  "marketplace": "marketplace",
140
  "plugins": "plugins",
141
  "skills": "skills",
 
142
  "auth": "auth_managed",
 
 
 
143
  # ── Aggiunti ROUTER-COMPLETE (29 moduli orfani rimontati) ─────────────────
144
  "agent_checkpoint": "agent_checkpoint",
145
  "agent_telemetry": "agent_telemetry",
@@ -186,6 +194,18 @@ for prefix, module_name in _ROUTER_MAP.items():
186
  except Exception as e:
187
  _logger.error(f"❌ Errore montaggio rotta {prefix}: {e}")
188
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  # ── CLI Task Execution ────────────────────────────────────────────────────────
190
  async def run_cli_task(task_description: str):
191
  _logger.info(f"CLI: Avvio task richiesto: {task_description[:50]}...")
@@ -206,7 +226,19 @@ async def run_cli_task(task_description: str):
206
  @app.on_event("startup")
207
  async def startup_event():
208
  _logger.info("Server starting up...")
 
 
 
 
 
 
209
  asyncio.create_task(_run_auto_migration())
 
 
 
 
 
 
210
  if not any(arg in sys.argv for arg in ["--task", "-t"]):
211
  try:
212
  from api.job_queue import start_job_queue_consumer
 
9
  from fastapi import FastAPI
10
  from fastapi.middleware.cors import CORSMiddleware
11
  from fastapi.staticfiles import StaticFiles
12
+ from api.version import RUNTIME_VERSION
13
 
14
  # Configurazione Logging
15
  logging.basicConfig(
 
22
  app = FastAPI(
23
  title="Agente AI API",
24
  description="Backend per l'orchestrazione di agenti autonomi e tool-use.",
25
+ version=RUNTIME_VERSION,
26
  )
27
 
28
  # CORS
 
57
  'VAULT_KEY', 'INTERNAL_TOKEN', 'DEPLOY_SECRET', 'WEBHOOK_TOKEN',
58
  'TERMINAL_SECRET', 'EXEC_TOKEN', 'VITE_INTERNAL_TOKEN', 'VITE_TERMINAL_SECRET',
59
  'VITE_OPENROUTER_API_KEY', 'VITE_HF_TOKEN', 'VITE_GROQ_API_KEY',
60
+ 'OPENROUTER_PROFILES_JSON', 'GROQ_PROFILES_JSON', 'CEREBRAS_PROFILES_JSON',
61
+ 'SAMBANOVA_PROFILES_JSON', 'GEMINI_PROFILES_JSON', 'NVIDIA_PROFILES_JSON',
62
+ 'HF_ROUTER_PROFILES_JSON', 'HF_MODEL',
63
  'GH_PAGES_TOKEN', 'VERCEL_TOKEN'
64
  ]
65
 
 
143
  "marketplace": "marketplace",
144
  "plugins": "plugins",
145
  "skills": "skills",
146
+ "private_state": "private_state",
147
  "auth": "auth_managed",
148
+ "public_status": "public_status",
149
+ "me_tasks": "me_tasks",
150
+ "admin_state": "admin_state",
151
  # ── Aggiunti ROUTER-COMPLETE (29 moduli orfani rimontati) ─────────────────
152
  "agent_checkpoint": "agent_checkpoint",
153
  "agent_telemetry": "agent_telemetry",
 
194
  except Exception as e:
195
  _logger.error(f"❌ Errore montaggio rotta {prefix}: {e}")
196
 
197
+ # ── Memory sync protocol ─────────────────────────────────────────────────────
198
+ # È una factory parametrica, quindi non può stare in _ROUTER_MAP. Riutilizza il
199
+ # singleton lazy di state.py per evitare una seconda istanza di MemoryManager.
200
+ try:
201
+ from memory.sync import create_memory_sync_router
202
+ from api.state import _get_mem_manager
203
+ _sync_router = create_memory_sync_router(_get_mem_manager())
204
+ app.include_router(_sync_router)
205
+ _logger.info("✅ Route montata: /api/memory/sync (da memory.sync)")
206
+ except Exception as e:
207
+ _logger.error(f"❌ Errore montaggio memory sync router: {e}")
208
+
209
  # ── CLI Task Execution ────────────────────────────────────────────────────────
210
  async def run_cli_task(task_description: str):
211
  _logger.info(f"CLI: Avvio task richiesto: {task_description[:50]}...")
 
226
  @app.on_event("startup")
227
  async def startup_event():
228
  _logger.info("Server starting up...")
229
+ try:
230
+ from api.startup_migration import apply_rls_fix_sync
231
+ apply_rls_fix_sync()
232
+ _logger.info("✅ BOOT: apply_rls_fix_sync() eseguito con successo.")
233
+ except Exception as e:
234
+ _logger.warning(f"⚠️ BOOT: apply_rls_fix_sync() fallito (non bloccante): {e}")
235
  asyncio.create_task(_run_auto_migration())
236
+ try:
237
+ from api.providers import start_heartbeat
238
+ start_heartbeat()
239
+ _logger.info("✅ BOOT: provider heartbeat avviato.")
240
+ except Exception as e:
241
+ _logger.warning(f"⚠️ BOOT: avvio provider heartbeat fallito (non bloccante): {e}")
242
  if not any(arg in sys.argv for arg in ["--task", "-t"]):
243
  try:
244
  from api.job_queue import start_job_queue_consumer
memory/manager.py CHANGED
@@ -53,6 +53,39 @@ class MemoryManager:
53
  results.extend([{**l, "layer": "reflection"} for l in lessons])
54
  return results[:n]
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  async def reflect(self, task: str, output: str, success: bool, error: str | None = None) -> dict:
57
  if success:
58
  self.reflection.record_success(task, output[:500])
 
53
  results.extend([{**l, "layer": "reflection"} for l in lessons])
54
  return results[:n]
55
 
56
+ async def get_context(self, query: str, code_length: int = 0, n: int = 5) -> str:
57
+ """Return a bounded text context for consumers such as UnifiedAgentLoop.
58
+
59
+ The loop needs a context-shaped view, while the public manager API exposes
60
+ structured search results. Keep this adapter here so callers do not reach
61
+ into individual memory layers or depend on their implementation details.
62
+ """
63
+ if not query:
64
+ return ""
65
+
66
+ hits = await self.search(query, n=n)
67
+ if not hits:
68
+ return ""
69
+
70
+ # Leave room for the current prompt/context; never inject an unbounded
71
+ # memory payload into a long-running agent loop.
72
+ max_chars = max(1000, min(4000, 4000 - max(0, code_length)))
73
+ parts: list[str] = []
74
+ used = 0
75
+ for hit in hits:
76
+ content = str(hit.get("content", "")).strip()
77
+ if not content:
78
+ continue
79
+ layer = str(hit.get("layer", "memory"))
80
+ block = f"[{layer}] {content}"
81
+ remaining = max_chars - used
82
+ if remaining <= 0:
83
+ break
84
+ parts.append(block[:remaining])
85
+ used += len(parts[-1]) + 1
86
+
87
+ return "\n".join(parts).strip()
88
+
89
  async def reflect(self, task: str, output: str, success: bool, error: str | None = None) -> dict:
90
  if success:
91
  self.reflection.record_success(task, output[:500])
memory/semantic.py CHANGED
@@ -106,11 +106,11 @@ class _EmbedCache:
106
 
107
 
108
  class SemanticMemory:
109
- def __init__(self):
110
- self._client = None # chromadb fallback
111
  self._collection = None
112
  self._embed_fn = None
113
- self._sb = None # Supabase client
114
  self._hf_client = None # HuggingFace InferenceClient (lazy)
115
  self._pgvector = False # S569: True quando match_semantic_memory RPC disponibile
116
  self._embed_cache = _EmbedCache() # S570: LRU 256 entry, TTL 10 min
@@ -127,8 +127,9 @@ class SemanticMemory:
127
  except Exception:
128
  return None
129
 
130
- def init(self):
131
- self._sb = self._try_supabase()
 
132
  if self._sb:
133
  try:
134
  self._sb.table("semantic_memory").select("id").limit(1).execute()
 
106
 
107
 
108
  class SemanticMemory:
109
+ def __init__(self, sb_client=None, chroma_client=None):
110
+ self._client = chroma_client # chromadb fallback
111
  self._collection = None
112
  self._embed_fn = None
113
+ self._sb = sb_client # Supabase client (injected when available)
114
  self._hf_client = None # HuggingFace InferenceClient (lazy)
115
  self._pgvector = False # S569: True quando match_semantic_memory RPC disponibile
116
  self._embed_cache = _EmbedCache() # S570: LRU 256 entry, TTL 10 min
 
127
  except Exception:
128
  return None
129
 
130
+ async def init(self):
131
+ if self._sb is None:
132
+ self._sb = self._try_supabase()
133
  if self._sb:
134
  try:
135
  self._sb.table("semantic_memory").select("id").limit(1).execute()
memory/sync.py CHANGED
@@ -58,6 +58,12 @@ class MemorySyncStatus(BaseModel):
58
  stats: dict[str, Any]
59
 
60
 
 
 
 
 
 
 
61
  # ── GAP-VAULT-AUTH: autenticazione Bearer ─────────────────────────────────────
62
  _SYNC_ADMIN_TOKEN = os.getenv('VAULT_ADMIN_TOKEN', '') # stessa variabile del vault
63
 
@@ -218,13 +224,9 @@ def create_memory_sync_router(memory: Any) -> APIRouter:
218
  "server_time": _now_ms(),
219
  }
220
 
221
- class _MemoryImportRequest(BaseModel):
222
- records: list[dict[str, Any]] = Field(default_factory=list)
223
- overwrite: bool = False
224
-
225
  @router.post("/import")
226
  async def memory_import(
227
- req: _MemoryImportRequest,
228
  _auth: None = Depends(_require_sync_auth),
229
  ) -> dict[str, Any]:
230
  """Importa records nella semantic memory. Richiede Bearer VAULT_ADMIN_TOKEN."""
 
58
  stats: dict[str, Any]
59
 
60
 
61
+ class MemoryImportRequest(BaseModel):
62
+ """Payload di import definito a livello modulo per lo schema OpenAPI."""
63
+ records: list[dict[str, Any]] = Field(default_factory=list)
64
+ overwrite: bool = False
65
+
66
+
67
  # ── GAP-VAULT-AUTH: autenticazione Bearer ─────────────────────────────────────
68
  _SYNC_ADMIN_TOKEN = os.getenv('VAULT_ADMIN_TOKEN', '') # stessa variabile del vault
69
 
 
224
  "server_time": _now_ms(),
225
  }
226
 
 
 
 
 
227
  @router.post("/import")
228
  async def memory_import(
229
+ req: MemoryImportRequest,
230
  _auth: None = Depends(_require_sync_auth),
231
  ) -> dict[str, Any]:
232
  """Importa records nella semantic memory. Richiede Bearer VAULT_ADMIN_TOKEN."""
models/ai_client.py CHANGED
@@ -14,7 +14,9 @@ sempre vuoto in produzione (ogni chiamata falliva silenziosamente con
14
  from __future__ import annotations
15
 
16
  import asyncio
 
17
  import os
 
18
  import time as _time_mod
19
  from dataclasses import dataclass
20
  from typing import AsyncIterator, Optional, List, Tuple
@@ -24,6 +26,20 @@ from api.semantic_cache import get_cached_response, set_cached_response
24
  import logging
25
  _logger = logging.getLogger("agente_ai")
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  @dataclass(frozen=True)
28
  class ProviderConfig:
29
  id: int = 0
@@ -35,18 +51,23 @@ class ProviderConfig:
35
  purpose: str = "reasoning"
36
  profile: str = "general"
37
 
 
 
 
 
 
38
  # Definizione statica dei provider LLM realmente attivi nel progetto.
39
  # base_url punta sempre all'endpoint OpenAI-compatible ufficiale del provider
40
  # (nessun proxy CF Worker qui: questo client gira lato backend Python, non browser).
41
  _PROVIDER_DEFS = [
42
  # tier 0 — free tier veloce e affidabile
43
- {"name": "groq", "env_key": "GROQ_API_KEY", "base_url": "https://api.groq.com/openai/v1", "model_env": "GROQ_MODEL", "default_model": "llama-3.3-70b-versatile", "tier": 0, "purpose": "reasoning"},
44
- {"name": "cerebras", "env_key": "CEREBRAS_API_KEY", "base_url": "https://api.cerebras.ai/v1", "model_env": "CEREBRAS_MODEL", "default_model": "llama-4-scout", "tier": 0, "purpose": "reasoning"},
45
- {"name": "sambanova", "env_key": "SAMBANOVA_API_KEY", "base_url": "https://api.sambanova.ai/v1", "model_env": "SAMBANOVA_MODEL", "default_model": "DeepSeek-V3.2", "tier": 0, "purpose": "reasoning"},
46
  # tier 1 — free tier con rate limit più stretti
47
- {"name": "openrouter", "env_key": "OPENROUTER_API_KEY", "base_url": "https://openrouter.ai/api/v1", "model_env": "OPENROUTER_MODEL","default_model": "meta-llama/llama-4-scout:free", "tier": 1, "purpose": "coding"},
48
  {"name": "hf_router", "env_key": "HF_TOKEN", "base_url": "https://router.huggingface.co/v1", "model_env": "HF_MODEL", "default_model": "Qwen/Qwen2.5-Coder-32B-Instruct", "tier": 1, "purpose": "coding"},
49
- {"name": "gemini", "env_key": "GEMINI_API_KEY", "base_url": "https://generativelanguage.googleapis.com/v1beta/openai/", "model_env": "GEMINI_MODEL", "default_model": "gemini-2.0-flash-exp", "tier": 1, "purpose": "memory"},
50
  # tier 2 — fallback opzionale (spesso a pagamento o quota limitata)
51
  {"name": "nvidia", "env_key": "NVIDIA_API_KEY", "base_url": "https://integrate.api.nvidia.com/v1", "model_env": "NVIDIA_MODEL", "default_model": "nvidia/nemotron-3-ultra-550b-a55b", "tier": 2, "purpose": "audit"},
52
  ]
@@ -55,17 +76,116 @@ _PROVIDER_DEFS = [
55
  class AIClient:
56
  def __init__(self) -> None:
57
  self.providers = self._load_providers()
58
- self._client_cache: dict[str, OpenAI] = {}
59
- # S-DUAL-10: Indice per round-robin tra provider dello stesso purpose
60
  self._rr_indices: dict[str, int] = {}
 
 
 
61
 
62
  def _load_providers(self) -> list[ProviderConfig]:
63
  """Carica la flotta: prova Supabase (tabella `ai_providers`, source of
64
  truth dichiarata in supabase/migrations/20260711_ai_providers_fleet.sql),
65
  fallback sui provider reali via env se Supabase non è raggiungibile/vuoto
66
  (es. progetto sospeso per fatturazione, tabella non ancora popolata)."""
67
- providers = self._try_load_from_supabase()
68
- return providers if providers else self._discover_providers_from_env()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
  def _try_load_from_supabase(self) -> list[ProviderConfig]:
71
  url = os.getenv("SUPABASE_URL", "")
@@ -75,20 +195,31 @@ class AIClient:
75
  try:
76
  from supabase import create_client
77
  sb = create_client(url, key)
78
- res = (
79
- sb.table("ai_providers")
80
- .select("id,name,api_key,base_url,default_model,tier,purpose")
81
- .eq("is_active", True)
82
- .order("tier", desc=False)
83
- .order("success_count", desc=True)
84
- .execute()
85
- )
 
 
 
 
 
 
 
 
86
  rows = res.data or []
87
  return [
88
  ProviderConfig(
89
  id=row["id"], name=row["name"], api_key=row["api_key"],
90
- base_url=row["base_url"], default_model=row["default_model"],
91
- tier=row["tier"], purpose=row["purpose"], profile="general",
 
 
 
92
  )
93
  for row in rows
94
  ]
@@ -102,6 +233,7 @@ class AIClient:
102
  è impostata — nessun placeholder, nessun nodo fantasma."""
103
  providers = []
104
  for i, d in enumerate(_PROVIDER_DEFS):
 
105
  api_key = os.getenv(d["env_key"], "")
106
  if not api_key:
107
  continue
@@ -113,39 +245,122 @@ class AIClient:
113
  default_model=os.getenv(d["model_env"], d["default_model"]),
114
  tier=d["tier"],
115
  purpose=d["purpose"],
116
- profile="general",
117
  ))
118
  if not providers:
119
  _logger.error("AIClient: nessuna API key provider configurata (Groq/OpenRouter/Cerebras/SambaNova/Gemini/NVIDIA/HF_TOKEN tutte assenti)")
120
  return providers
121
 
122
  def _client_for(self, provider: ProviderConfig) -> OpenAI:
123
- if provider.name not in self._client_cache:
124
- self._client_cache[provider.name] = OpenAI(
125
- api_key=provider.api_key,
126
- base_url=provider.base_url,
127
- timeout=20,
 
 
128
  max_retries=0
129
  )
130
- return self._client_cache[provider.name]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
 
132
  async def _fetch_one(self, provider: ProviderConfig, messages: list, temperature: float, max_tokens: int) -> Tuple[ProviderConfig, str, float]:
133
- client = self._client_for(provider)
134
  start = _time_mod.monotonic()
135
  try:
 
136
  response = await asyncio.wait_for(
137
  asyncio.to_thread(
138
  client.chat.completions.create,
139
  model=provider.default_model,
140
  messages=messages,
141
  temperature=temperature,
142
- max_tokens=max_tokens
 
143
  ),
144
- timeout=15
 
 
 
145
  )
 
146
  return provider, response.choices[0].message.content or "", _time_mod.monotonic() - start
147
  except Exception as e:
148
- _logger.warning(f"Provider {provider.name} fallito: {e}")
 
149
  return provider, f"ERROR: {str(e)}", 0.0
150
 
151
  def _get_round_robin_provider(self, purpose: str) -> Optional[ProviderConfig]:
@@ -188,24 +403,51 @@ class AIClient:
188
  pool = self.providers[:4]
189
 
190
  if not pool:
191
- return "🔴 Nessun provider LLM configurato (verifica le API key nei secrets del backend)."
192
-
193
- # 3. Esecuzione parallela (Ensemble Intelligence)
194
- tasks = [self._fetch_one(p, messages, temperature, max_tokens) for p in pool]
195
- results = await asyncio.gather(*tasks)
196
-
197
- best_r = self._judge_best_response(results, primary_purpose)
198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  # S-CACHE-1: Popolamento cache asincrono
200
  if not best_r.startswith("🔴"):
201
  asyncio.create_task(set_cached_response(messages, best_r))
202
-
203
  return best_r
204
 
205
  def _judge_best_response(self, results: List[Tuple[ProviderConfig, str, float]], target_purpose: str) -> str:
206
  valid = [(p, r, t) for p, r, t in results if not r.startswith("ERROR:") and len(r) > 10]
207
- if not valid:
208
- return "🔴 Tutti i provider configurati hanno fallito o sono saturi. Riprovo con provider esterni..."
209
 
210
  def score(item):
211
  p, r, t = item
@@ -226,38 +468,64 @@ class AIClient:
226
  # Nessun provider configurato: feedback immediato all'utente invece di
227
  # cadere silenziosamente nel loop vuoto e dare un messaggio generico.
228
  if not self.providers:
229
- yield (
230
- "⚠️ Nessun provider LLM configurato. "
231
- "Imposta almeno una delle seguenti variabili d'ambiente: "
232
- "GROQ_API_KEY, CEREBRAS_API_KEY, SAMBANOVA_API_KEY, "
233
- "OPENROUTER_API_KEY, HF_TOKEN, GEMINI_API_KEY."
234
- )
235
- return
236
 
237
- # Nello streaming proviamo i provider in ordine di tier e performance
238
- for provider in self.providers:
239
- client = self._client_for(provider)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  try:
 
241
  stream = await asyncio.to_thread(
242
  client.chat.completions.create,
243
- model=provider.default_model,
244
- messages=messages,
245
- temperature=temperature,
246
- max_tokens=max_tokens,
247
  stream=True,
 
248
  )
249
  iterator = iter(stream)
250
  while True:
251
  chunk = await asyncio.to_thread(next, iterator, None)
252
- if chunk is None: break
 
253
  if chunk.choices and chunk.choices[0].delta.content:
 
254
  yield chunk.choices[0].delta.content
 
255
  return
256
  except Exception as e:
257
- _logger.warning(f"Streaming fallito su {provider.name}: {e}")
 
 
 
 
 
 
 
 
258
  continue
259
-
260
- yield "🔴 Errore critico: tutti i provider configurati sono falliti o non disponibili."
261
 
262
 
263
 
 
14
  from __future__ import annotations
15
 
16
  import asyncio
17
+ import json
18
  import os
19
+ import re
20
  import time as _time_mod
21
  from dataclasses import dataclass
22
  from typing import AsyncIterator, Optional, List, Tuple
 
26
  import logging
27
  _logger = logging.getLogger("agente_ai")
28
 
29
+ class ProviderUnavailableError(RuntimeError):
30
+ """Raised when no configured LLM provider can produce a response.
31
+
32
+ The error deliberately includes provider names only, never credentials or
33
+ raw upstream payloads, so callers can distinguish infrastructure failure
34
+ from a model answer without leaking sensitive data.
35
+ """
36
+
37
+ def __init__(self, providers: list[str] | tuple[str, ...]) -> None:
38
+ self.providers = tuple(providers)
39
+ detail = ", ".join(self.providers) if self.providers else "none"
40
+ super().__init__(f"provider_unavailable: {detail}")
41
+
42
+
43
  @dataclass(frozen=True)
44
  class ProviderConfig:
45
  id: int = 0
 
51
  purpose: str = "reasoning"
52
  profile: str = "general"
53
 
54
+ @property
55
+ def identity(self) -> tuple[str, str, str]:
56
+ """Stable identity: different profiles must never share a client cache entry."""
57
+ return (self.name, self.profile, self.base_url)
58
+
59
  # Definizione statica dei provider LLM realmente attivi nel progetto.
60
  # base_url punta sempre all'endpoint OpenAI-compatible ufficiale del provider
61
  # (nessun proxy CF Worker qui: questo client gira lato backend Python, non browser).
62
  _PROVIDER_DEFS = [
63
  # tier 0 — free tier veloce e affidabile
64
+ {"name": "groq", "env_key": "GROQ_API_KEY", "base_url": "https://api.groq.com/openai/v1", "model_env": "GROQ_MODEL", "default_model": "qwen/qwen3.6-27b", "tier": 0, "purpose": "reasoning"},
65
+ {"name": "cerebras", "env_key": "CEREBRAS_API_KEY", "base_url": "https://api.cerebras.ai/v1", "model_env": "CEREBRAS_MODEL", "default_model": "gpt-oss-120b", "tier": 0, "purpose": "reasoning"},
66
+ {"name": "sambanova", "env_key": "SAMBANOVA_API_KEY", "base_url": "https://api.sambanova.ai/v1", "model_env": "SAMBANOVA_MODEL", "default_model": "DeepSeek-V3.1", "tier": 0, "purpose": "reasoning"},
67
  # tier 1 — free tier con rate limit più stretti
68
+ {"name": "openrouter", "env_key": "OPENROUTER_API_KEY", "base_url": "https://openrouter.ai/api/v1", "model_env": "OPENROUTER_MODEL","default_model": "openrouter/free", "tier": 1, "purpose": "coding"},
69
  {"name": "hf_router", "env_key": "HF_TOKEN", "base_url": "https://router.huggingface.co/v1", "model_env": "HF_MODEL", "default_model": "Qwen/Qwen2.5-Coder-32B-Instruct", "tier": 1, "purpose": "coding"},
70
+ {"name": "gemini", "env_key": "GEMINI_API_KEY", "base_url": "https://generativelanguage.googleapis.com/v1beta/openai/", "model_env": "GEMINI_MODEL", "default_model": "gemini-3.6-flash", "tier": 1, "purpose": "memory"},
71
  # tier 2 — fallback opzionale (spesso a pagamento o quota limitata)
72
  {"name": "nvidia", "env_key": "NVIDIA_API_KEY", "base_url": "https://integrate.api.nvidia.com/v1", "model_env": "NVIDIA_MODEL", "default_model": "nvidia/nemotron-3-ultra-550b-a55b", "tier": 2, "purpose": "audit"},
73
  ]
 
76
  class AIClient:
77
  def __init__(self) -> None:
78
  self.providers = self._load_providers()
79
+ self._client_cache: dict[tuple[str, str, str], OpenAI] = {}
80
+ # Round-robin e circuit breaker sono indicizzati per purpose e profilo.
81
  self._rr_indices: dict[str, int] = {}
82
+ self._breaker: dict[tuple[str, str, str], dict[str, float | int]] = {}
83
+ self._breaker_threshold = 2
84
+ self._breaker_cooldown_s = 60.0
85
 
86
  def _load_providers(self) -> list[ProviderConfig]:
87
  """Carica la flotta: prova Supabase (tabella `ai_providers`, source of
88
  truth dichiarata in supabase/migrations/20260711_ai_providers_fleet.sql),
89
  fallback sui provider reali via env se Supabase non è raggiungibile/vuoto
90
  (es. progetto sospeso per fatturazione, tabella non ancora popolata)."""
91
+ database_providers = self._try_load_from_supabase()
92
+ environment_profiles = [
93
+ profile
94
+ for definition in _PROVIDER_DEFS
95
+ for profile in self._profile_rows_from_env(definition)
96
+ ]
97
+ if environment_profiles:
98
+ profiled_names = {profile.name for profile in environment_profiles}
99
+ # Explicit profile pools override a same-provider Supabase credential;
100
+ # DB providers not covered by a pool remain available as fallbacks.
101
+ database_providers = [
102
+ provider for provider in database_providers
103
+ if provider.name not in profiled_names
104
+ ]
105
+ return environment_profiles + database_providers
106
+ return database_providers or self._discover_providers_from_env()
107
+
108
+ @staticmethod
109
+ def _runtime_model_override(row: dict) -> str:
110
+ """Use an explicit model environment override for a known provider endpoint.
111
+
112
+ Supabase remains the source for provider credentials and ordering; runtime
113
+ model-selection variables deliberately win so emergency model migrations
114
+ do not require reading or mutating provider secrets in the database.
115
+ """
116
+ database_model = str(row.get("default_model", "")).strip()
117
+ row_base_url = str(row.get("base_url", "")).rstrip("/")
118
+ for definition in _PROVIDER_DEFS:
119
+ if row_base_url == definition["base_url"].rstrip("/"):
120
+ # Explicit runtime configuration always wins over persisted DB values.
121
+ configured_model = os.getenv(definition["model_env"], "").strip()
122
+ if configured_model:
123
+ return configured_model
124
+ # Supabase may retain a model retired by the provider. Do not let a
125
+ # stale row override the tested repository default after deployment.
126
+ if (
127
+ definition["name"] == "groq"
128
+ and database_model in {
129
+ "llama-3.3-70b-versatile",
130
+ "llama-3.1-70b-versatile",
131
+ "llama-3.1-8b-instant",
132
+ }
133
+ ):
134
+ return definition["default_model"]
135
+ return database_model or definition["default_model"]
136
+ return database_model
137
+
138
+ @staticmethod
139
+ def _is_legacy_schema_error(exc: Exception) -> bool:
140
+ """Riconosce il layout `ai_providers` precedente alla flotta canonica.
141
+
142
+ Quel layout espone `model_name`, `priority` e `provider_type`, ma
143
+ contiene record storici e modelli deprecati. Fino alla migrazione non va
144
+ promosso a source of truth: il fallback ambiente aggiornato è più sicuro.
145
+ """
146
+ message = str(exc).lower()
147
+ return (
148
+ "column ai_providers." in message
149
+ and "does not exist" in message
150
+ and any(column in message for column in (
151
+ "default_model", "tier", "purpose", "success_count",
152
+ ))
153
+ )
154
+
155
+ @staticmethod
156
+ def _profile_rows_from_env(definition: dict) -> list[ProviderConfig]:
157
+ """Load optional per-provider profiles without logging secret values.
158
+
159
+ Format: ``<PROVIDER>_PROFILES_JSON=[{"profile":"p1","api_key":"...", "model":"..."}]``.
160
+ The legacy single-key variable remains supported and is loaded after profiles.
161
+ """
162
+ env_name = f"{definition['name'].upper()}_PROFILES_JSON"
163
+ raw = os.getenv(env_name, "").strip()
164
+ if not raw:
165
+ return []
166
+ try:
167
+ rows = json.loads(raw)
168
+ except json.JSONDecodeError:
169
+ _logger.warning("AIClient: %s non valido, profili ignorati", env_name)
170
+ return []
171
+ if not isinstance(rows, list):
172
+ _logger.warning("AIClient: %s deve essere un array JSON", env_name)
173
+ return []
174
+ result: list[ProviderConfig] = []
175
+ for index, row in enumerate(rows):
176
+ if not isinstance(row, dict) or not row.get("api_key"):
177
+ continue
178
+ result.append(ProviderConfig(
179
+ id=-(index + 1),
180
+ name=definition["name"],
181
+ api_key=str(row["api_key"]),
182
+ base_url=str(row.get("base_url") or definition["base_url"]),
183
+ default_model=str(row.get("model") or os.getenv(definition["model_env"], definition["default_model"])),
184
+ tier=definition["tier"],
185
+ purpose=str(row.get("purpose") or definition["purpose"]),
186
+ profile=str(row.get("profile") or f"profile-{index + 1}"),
187
+ ))
188
+ return result
189
 
190
  def _try_load_from_supabase(self) -> list[ProviderConfig]:
191
  url = os.getenv("SUPABASE_URL", "")
 
195
  try:
196
  from supabase import create_client
197
  sb = create_client(url, key)
198
+ try:
199
+ res = (
200
+ sb.table("ai_providers")
201
+ .select("id,name,api_key,base_url,default_model,tier,purpose")
202
+ .eq("is_active", True)
203
+ .order("tier", desc=False)
204
+ .order("success_count", desc=True)
205
+ .execute()
206
+ )
207
+ except Exception as exc:
208
+ if self._is_legacy_schema_error(exc):
209
+ # Non usare il layout storico: contiene provider fittizi e
210
+ # modelli superati. La migrazione normalizzerà la tabella;
211
+ # nel frattempo il caller seleziona il fallback env corrente.
212
+ return []
213
+ raise
214
  rows = res.data or []
215
  return [
216
  ProviderConfig(
217
  id=row["id"], name=row["name"], api_key=row["api_key"],
218
+ base_url=row["base_url"], default_model=self._runtime_model_override(row),
219
+ tier=row["tier"], purpose=row["purpose"],
220
+ # Legacy schema has no profile column: the row id is still a
221
+ # stable profile identity and prevents client-cache collisions.
222
+ profile=f"db-{row['id']}",
223
  )
224
  for row in rows
225
  ]
 
233
  è impostata — nessun placeholder, nessun nodo fantasma."""
234
  providers = []
235
  for i, d in enumerate(_PROVIDER_DEFS):
236
+ providers.extend(self._profile_rows_from_env(d))
237
  api_key = os.getenv(d["env_key"], "")
238
  if not api_key:
239
  continue
 
245
  default_model=os.getenv(d["model_env"], d["default_model"]),
246
  tier=d["tier"],
247
  purpose=d["purpose"],
248
+ profile="legacy",
249
  ))
250
  if not providers:
251
  _logger.error("AIClient: nessuna API key provider configurata (Groq/OpenRouter/Cerebras/SambaNova/Gemini/NVIDIA/HF_TOKEN tutte assenti)")
252
  return providers
253
 
254
  def _client_for(self, provider: ProviderConfig) -> OpenAI:
255
+ if provider.identity not in self._client_cache:
256
+ self._client_cache[provider.identity] = OpenAI(
257
+ api_key=provider.api_key,
258
+ base_url=provider.base_url,
259
+ # I task coding possono richiedere più di 20 s prima del primo
260
+ # chunk dal fallback gratuito; il budget esterno resta finito.
261
+ timeout=45,
262
  max_retries=0
263
  )
264
+ return self._client_cache[provider.identity]
265
+
266
+ def _is_available(self, provider: ProviderConfig) -> bool:
267
+ state = self._breaker.get(provider.identity)
268
+ return not state or float(state.get("open_until", 0.0)) <= _time_mod.monotonic()
269
+
270
+ def _record_success(self, provider: ProviderConfig) -> None:
271
+ self._breaker.pop(provider.identity, None)
272
+
273
+ def _record_failure(self, provider: ProviderConfig, exc: Exception) -> None:
274
+ message = str(exc).lower()
275
+ if not any(token in message for token in ("401", "403", "429", "500", "502", "503", "504", "rate limit", "quota")):
276
+ return
277
+ state = self._breaker.setdefault(provider.identity, {"failures": 0, "open_until": 0.0})
278
+ failures = int(state.get("failures", 0)) + 1
279
+ severe = any(token in message for token in ("401", "403"))
280
+ quota_limited = any(token in message for token in ("429", "rate limit", "quota"))
281
+ # A quota/rate-limit response is deterministic: retrying the same
282
+ # profile immediately only creates a storm. Open that profile on the
283
+ # first signal and let the provider pool move to another provider.
284
+ threshold = 1 if severe or quota_limited else self._breaker_threshold
285
+ if failures >= threshold:
286
+ cooldown = 900.0 if severe else self._rate_limit_cooldown_seconds(message) if quota_limited else self._breaker_cooldown_s
287
+ state["open_until"] = _time_mod.monotonic() + cooldown
288
+ state["failures"] = failures
289
+
290
+ @staticmethod
291
+ def _rate_limit_cooldown_seconds(message: str) -> float:
292
+ """Return a provider reset-aware cooldown, never shorter than 15 min."""
293
+ reset_match = re.search(r"x-ratelimit-reset[^0-9]*(\d{10,13})", message, re.IGNORECASE)
294
+ if reset_match:
295
+ reset_value = float(reset_match.group(1))
296
+ reset_epoch = reset_value / 1000.0 if reset_value > 10_000_000_000 else reset_value
297
+ return max(900.0, reset_epoch - _time_mod.time())
298
+ return 900.0
299
+
300
+ def _execution_pool(self, providers: list[ProviderConfig], purpose: str) -> list[ProviderConfig]:
301
+ """Return one rotated, healthy profile per provider endpoint group."""
302
+ groups: dict[tuple[str, str], list[ProviderConfig]] = {}
303
+ for provider in providers:
304
+ if not self._is_available(provider):
305
+ continue
306
+ groups.setdefault((provider.name, provider.base_url), []).append(provider)
307
+ selected: list[ProviderConfig] = []
308
+ for group_key, profiles in groups.items():
309
+ index_key = f"{purpose}:{group_key[0]}:{group_key[1]}"
310
+ start = self._rr_indices.get(index_key, 0)
311
+ selected.append(profiles[start % len(profiles)])
312
+ self._rr_indices[index_key] = start + 1
313
+ return selected
314
+
315
+ def _inter_provider_fallback_pool(
316
+ self,
317
+ purpose: str,
318
+ excluded: set[str] | None = None,
319
+ providers: list[ProviderConfig] | None = None,
320
+ ) -> list[ProviderConfig]:
321
+ """Select one healthy profile per provider, prioritizing the target purpose.
322
+
323
+ A provider whose complete profile group is open in the circuit breaker is
324
+ absent from this list; the next healthy provider becomes the automatic
325
+ fallback. This prevents retry storms against an exhausted pool.
326
+ """
327
+ excluded = excluded or set()
328
+ source = self.providers if providers is None else providers
329
+ candidates = [
330
+ provider for provider in source
331
+ if provider.name not in excluded and self._is_available(provider)
332
+ ]
333
+ candidates.sort(key=lambda provider: (
334
+ 0 if provider.purpose == purpose else 1,
335
+ provider.tier,
336
+ provider.name,
337
+ provider.profile,
338
+ ))
339
+ return self._execution_pool(candidates, f"fallback:{purpose}")
340
 
341
  async def _fetch_one(self, provider: ProviderConfig, messages: list, temperature: float, max_tokens: int) -> Tuple[ProviderConfig, str, float]:
 
342
  start = _time_mod.monotonic()
343
  try:
344
+ client = self._client_for(provider)
345
  response = await asyncio.wait_for(
346
  asyncio.to_thread(
347
  client.chat.completions.create,
348
  model=provider.default_model,
349
  messages=messages,
350
  temperature=temperature,
351
+ max_tokens=max_tokens,
352
+ **({"reasoning_effort": "none"} if provider.name == "groq" and provider.default_model == "qwen/qwen3.6-27b" else {})
353
  ),
354
+ # Il fallback non-streaming deve avere lo stesso budget del client:
355
+ # 15s scartava provider sani su richieste coding che richiedono
356
+ # più tempo per produrre una risposta completa dopo uno stream interrotto.
357
+ timeout=45
358
  )
359
+ self._record_success(provider)
360
  return provider, response.choices[0].message.content or "", _time_mod.monotonic() - start
361
  except Exception as e:
362
+ self._record_failure(provider, e)
363
+ _logger.warning(f"Provider {provider.name}/{provider.profile} fallito: {e}")
364
  return provider, f"ERROR: {str(e)}", 0.0
365
 
366
  def _get_round_robin_provider(self, purpose: str) -> Optional[ProviderConfig]:
 
403
  pool = self.providers[:4]
404
 
405
  if not pool:
406
+ raise ProviderUnavailableError([])
 
 
 
 
 
 
407
 
408
+ # 3. Un solo profilo per endpoint e richiesta: round-robin evita che
409
+ # profili condividano quota e client, mentre provider diversi restano
410
+ # disponibili come ensemble/fallback.
411
+ pool = self._execution_pool(pool, primary_purpose)
412
+ results = []
413
+ if pool:
414
+ tasks = [self._fetch_one(p, messages, temperature, max_tokens) for p in pool]
415
+ results = await asyncio.gather(*tasks)
416
+
417
+ valid = [result for result in results if not result[1].startswith("ERROR:") and len(result[1]) > 10]
418
+ if valid:
419
+ best_r = self._judge_best_response(results, primary_purpose)
420
+ else:
421
+ # Il pool primario è interamente in rate limit, errore auth o timeout:
422
+ # prova un solo profilo per ogni provider sano, in ordine di purpose/tier.
423
+ excluded = {provider.name for provider, _response, _latency in results}
424
+ fallback_pool = self._inter_provider_fallback_pool(primary_purpose, excluded)
425
+ fallback_results = []
426
+ for fallback in fallback_pool:
427
+ result = await self._fetch_one(fallback, messages, temperature, max_tokens)
428
+ fallback_results.append(result)
429
+ if not result[1].startswith("ERROR:") and len(result[1]) > 10:
430
+ _logger.info(
431
+ "[fleet] inter-provider fallback succeeded on %s/%s",
432
+ fallback.name,
433
+ fallback.profile,
434
+ )
435
+ best_r = result[1]
436
+ break
437
+ else:
438
+ failed_names = [provider.name for provider, _response, _latency in results + fallback_results]
439
+ raise ProviderUnavailableError(failed_names)
440
+
441
  # S-CACHE-1: Popolamento cache asincrono
442
  if not best_r.startswith("🔴"):
443
  asyncio.create_task(set_cached_response(messages, best_r))
444
+
445
  return best_r
446
 
447
  def _judge_best_response(self, results: List[Tuple[ProviderConfig, str, float]], target_purpose: str) -> str:
448
  valid = [(p, r, t) for p, r, t in results if not r.startswith("ERROR:") and len(r) > 10]
449
+ if not valid:
450
+ raise ProviderUnavailableError([p.name for p, _r, _t in results])
451
 
452
  def score(item):
453
  p, r, t = item
 
468
  # Nessun provider configurato: feedback immediato all'utente invece di
469
  # cadere silenziosamente nel loop vuoto e dare un messaggio generico.
470
  if not self.providers:
471
+ raise ProviderUnavailableError([])
 
 
 
 
 
 
472
 
473
+ # Un client di ruolo può contenere un solo provider specializzato.
474
+ # Dopo il suo primario, integra la flotta runtime non duplicata: un limite
475
+ # temporaneo di quel provider non deve rendere indisponibile l'intero task.
476
+ providers = list(self.providers)
477
+ try:
478
+ for fallback in self._load_providers():
479
+ if not any(
480
+ current.name == fallback.name
481
+ and current.base_url == fallback.base_url
482
+ for current in providers
483
+ ):
484
+ providers.append(fallback)
485
+ except Exception as exc:
486
+ _logger.debug("Streaming fleet expansion skipped: %s", type(exc).__name__)
487
+
488
+ # Un profilo sano per provider: se l’intero pool primario è in rate
489
+ # limit, il fallback passa automaticamente al provider successivo.
490
+ providers = self._inter_provider_fallback_pool("stream", providers=providers)
491
+ attempted: list[str] = []
492
+ for provider in providers:
493
+ attempted.append(provider.name)
494
+ emitted = False
495
  try:
496
+ client = self._client_for(provider)
497
  stream = await asyncio.to_thread(
498
  client.chat.completions.create,
499
+ model=provider.default_model,
500
+ messages=messages,
501
+ temperature=temperature,
502
+ max_tokens=max_tokens,
503
  stream=True,
504
+ **({"reasoning_effort": "none"} if provider.name == "groq" and provider.default_model == "qwen/qwen3.6-27b" else {}),
505
  )
506
  iterator = iter(stream)
507
  while True:
508
  chunk = await asyncio.to_thread(next, iterator, None)
509
+ if chunk is None:
510
+ break
511
  if chunk.choices and chunk.choices[0].delta.content:
512
+ emitted = True
513
  yield chunk.choices[0].delta.content
514
+ self._record_success(provider)
515
  return
516
  except Exception as e:
517
+ self._record_failure(provider, e)
518
+ _logger.warning(
519
+ "Streaming fallito su %s/%s (emitted=%s): %s",
520
+ provider.name, provider.profile, emitted, e,
521
+ )
522
+ # Retry solo prima del primo chunk: dopo output parziale un
523
+ # retry produrrebbe testo duplicato o una risposta incoerente.
524
+ if emitted:
525
+ raise
526
  continue
527
+
528
+ raise ProviderUnavailableError(attempted)
529
 
530
 
531
 
models/role_router.py CHANGED
@@ -3,27 +3,27 @@ role_router.py — Multi-model role routing (S362, aggiornato 2026-06-14 benchma
3
 
4
  BENCHMARK RESULTS 2026-06-14 FINALE (14 modelli × 3 test, max_tokens corretti):
5
  100% qualità (ordinati per TTFT):
6
- #1 Groq / openai/gpt-oss-20b — 170ms 100% ← FASTEST
7
  #2 Cerebras / gpt-oss-120b — 207ms 100% ← REASONING (max_tokens≥500)
8
- #3 Groq / openai/gpt-oss-120b — 235ms 100%
9
  #4 Cerebras / zai-glm-4.7 — 254ms 100%
10
  #5 Groq / compound-mini — 341ms 100%
11
  #6 SambaNova / DeepSeek-V3.1 — 482ms 100%
12
  #7 SambaNova / gemma-4-31B — 2132ms 100%
13
- #8 OpenRouter / gpt-oss-120b:free — 2160ms 100%
14
 
15
  Role assignments 2026-06-14 FINALE:
16
- FAST → Groq openai/gpt-oss-20b (170ms, 100%) ← #1 assoluto
17
  ARCHITECT → Groq llama-4-scout-17b 10M ctx (244ms, 67% — best per contesto lungo)
18
- CODER → Groq openai/gpt-oss-120b (235ms, 100%) ← #3 qualità
19
- TESTER → Groq openai/gpt-oss-20b
20
- CONTEXT → Groq openai/gpt-oss-20b
21
  RESEARCHER → Gemini 2.5-flash (599ms, 67% — math prompt-sensitive)
22
  REASONER → Cerebras gpt-oss-120b (207ms, 100%, reasoning model → max_tokens≥500)
23
  SAMBANOVA → SambaNova DeepSeek-V3.1 (482ms, 100%)
24
- DEFAULT → AIClient() primary (openai/gpt-oss-120b o primo disponibile)
25
 
26
- OpenRouter tenuto come fallback secondario (gpt-oss-120b:free = 1645ms ma 100% qualità).
27
  """
28
  from __future__ import annotations
29
 
@@ -36,14 +36,14 @@ _logger = logging.getLogger("models.role_router")
36
 
37
 
38
  class Role(str, Enum):
39
- FAST = "fast" # greetings, math semplice, identity — openai/gpt-oss-20b
40
- ARCHITECT = "architect" # planning, ragionamento complesso — llama-4-scout (10M ctx)
41
- CODER = "coder" # coding, debug — llama-3.3-70b-versatile
42
- TESTER = "tester" # test gen, debug hints — llama-3.3-70b-versatile
43
- CONTEXT = "context" # summarization, context compression — llama-3.3-70b-versatile
44
  DEFAULT = "default" # AIClient() primary
45
- RESEARCHER = "researcher" # web research + document synthesis — gemini-2.0-flash-exp
46
- REASONER = "reasoner" # throughput massimo — Cerebras llama-4-scout (2000+ tok/s)
47
  SAMBANOVA = "sambanova"
48
  NVIDIA = "nvidia" # NVIDIA NIM — nemotron-3-ultra-550b (1M ctx) # DeepSeek-V3.2 via SambaNova (404ms, 100% qualità benchmark)
49
 
@@ -83,11 +83,37 @@ class RoleRouter:
83
 
84
  # ── Role-specific builders ─────────────────────────────────────────────────
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  @staticmethod
87
  def _fast_client() -> Any:
88
- """Groq llama-3.3-70b-versatile 344ms TTFT, 100% benchmark qualità.
89
  Usato per: greetings, calcoli semplici, identity, domande 1-liner."""
90
  from models.ai_client import AIClient, ProviderConfig
 
 
 
 
91
  groq_key = os.getenv("GROQ_API_KEY")
92
  if not groq_key:
93
  return RoleRouter._tester_client()
@@ -96,7 +122,7 @@ class RoleRouter:
96
  name="groq-fast",
97
  api_key=groq_key,
98
  base_url="https://api.groq.com/openai/v1",
99
- default_model=os.getenv("GROQ_FAST_MODEL", "llama-3.3-70b-versatile"),
100
  )
101
  rest = [p for p in client.providers if p.name not in ("groq", "groq-fast", "groq-tester")]
102
  client.providers = [fast, *rest]
@@ -107,9 +133,13 @@ class RoleRouter:
107
 
108
  @staticmethod
109
  def _architect_client() -> Any:
110
- """NVIDIA NIM deepseek-v4-flash (1M ctx) come primario — massima potenza per architettura.
111
- Fallback 1: Groq llama-4-scout (10M ctx, 480ms). Fallback 2: OpenRouter llama-4-scout:free."""
112
  from models.ai_client import AIClient, ProviderConfig
 
 
 
 
113
  nvidia_key = os.getenv("NVIDIA_API_KEY")
114
  if nvidia_key:
115
  client = AIClient()
@@ -125,7 +155,7 @@ class RoleRouter:
125
  client.default_model = nvidia.default_model
126
  client.client = client._client_for(nvidia)
127
  return client
128
- # Fallback 1: Groq llama-4-scout (10M ctx, 480ms)
129
  groq_key = os.getenv("GROQ_API_KEY")
130
  if groq_key:
131
  client = AIClient()
@@ -133,7 +163,7 @@ class RoleRouter:
133
  name="groq-architect",
134
  api_key=groq_key,
135
  base_url="https://api.groq.com/openai/v1",
136
- default_model=os.getenv("ARCHITECT_MODEL", "llama-4-scout"),
137
  )
138
  rest = [p for p in client.providers if p.name not in ("groq", "groq-architect")]
139
  client.providers = [architect, *rest]
@@ -141,32 +171,37 @@ class RoleRouter:
141
  client.default_model = architect.default_model
142
  client.client = client._client_for(architect)
143
  return client
144
- # Fallback: OpenRouter meta-llama/llama-4-scout:free (1645ms ma 100% qualità)
145
- openrouter_key = os.getenv("OPENROUTER_API_KEY")
146
- if openrouter_key:
147
  client = AIClient()
148
- fallback = ProviderConfig(
149
- name="openrouter-architect",
150
- api_key=openrouter_key,
151
- base_url="https://openrouter.ai/api/v1",
152
- default_model="meta-llama/llama-4-scout:free",
153
- )
154
- rest = [p for p in client.providers if not p.name.startswith("openrouter")]
155
- client.providers = [fallback, *rest]
156
- client.provider_name = fallback.name
157
- client.default_model = fallback.default_model
158
- client.client = client._client_for(fallback)
 
 
159
  return client
160
  return AIClient()
161
 
162
  @staticmethod
163
  def _coder_client() -> Any:
164
- """Groq llama-3.3-70b-versatile 358ms TTFT, 100% benchmark qualità.
165
- AGGIORNATO 2026-08-04: era Groq openai/gpt-oss-120b.
166
- Fallback: OpenRouter llama-4-scout:free se GROQ_API_KEY mancante."""
167
  from models.ai_client import AIClient, ProviderConfig
 
 
 
 
168
  groq_key = os.getenv("GROQ_API_KEY")
169
- model = os.getenv("CODER_MODEL", "llama-3.3-70b-versatile")
 
170
  if groq_key:
171
  client = AIClient()
172
  coder = ProviderConfig(
@@ -174,74 +209,117 @@ class RoleRouter:
174
  api_key=groq_key,
175
  base_url="https://api.groq.com/openai/v1",
176
  default_model=model,
 
177
  )
178
- rest = [p for p in client.providers if p.name not in ("groq", "groq-coder")]
179
- client.providers = [coder, *rest]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  client.provider_name = coder.name
181
  client.default_model = coder.default_model
182
  client.client = client._client_for(coder)
183
  return client
184
- openrouter_key = os.getenv("OPENROUTER_API_KEY")
185
- if openrouter_key:
186
  client = AIClient()
187
- fallback = ProviderConfig(
188
- name="openrouter-coder",
189
- api_key=openrouter_key,
190
- base_url="https://openrouter.ai/api/v1",
191
- default_model="meta-llama/llama-4-scout:free",
192
- )
193
- rest = [p for p in client.providers if not p.name.startswith("openrouter")]
194
- client.providers = [fallback, *rest]
195
- client.provider_name = fallback.name
196
- client.default_model = fallback.default_model
197
- client.client = client._client_for(fallback)
 
 
198
  return client
199
  return AIClient()
200
 
201
  @staticmethod
202
  def _researcher_client() -> Any:
203
- """Gemini 2.0-flash-exp TTFT 910ms, ottima per research/synthesis/doc analysis."""
 
 
 
 
 
204
  from models.ai_client import AIClient, ProviderConfig
205
- gemini_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
 
 
 
206
  groq_key = os.getenv("GROQ_API_KEY")
207
-
208
- if gemini_key:
209
- client = AIClient()
210
  researcher = ProviderConfig(
211
- name="gemini-researcher",
212
- api_key=gemini_key,
213
- base_url="https://generativelanguage.googleapis.com/v1beta/openai",
214
- default_model=os.getenv("GEMINI_MODEL", "gemini-2.0-flash-exp"),
 
 
 
215
  )
216
- rest = [p for p in client.providers if not p.name.startswith("gemini")]
 
 
 
217
  client.providers = [researcher, *rest]
218
  client.provider_name = researcher.name
219
  client.default_model = researcher.default_model
220
  client.client = client._client_for(researcher)
221
  return client
222
- elif groq_key:
223
- client = AIClient()
224
- groq_compound = ProviderConfig(
225
- name="groq-compound-researcher",
226
- api_key=groq_key,
227
- base_url="https://api.groq.com/openai/v1",
228
- default_model=os.getenv("GROQ_COMPOUND_MODEL", "groq/compound"),
229
- )
230
- rest = [p for p in client.providers if p.name not in ("groq", "groq-compound-researcher")]
231
- client.providers = [groq_compound, *rest]
232
- client.provider_name = groq_compound.name
233
- client.default_model = groq_compound.default_model
234
- client.client = client._client_for(groq_compound)
235
- return client
236
  return AIClient()
237
 
238
  @staticmethod
239
  def _reasoner_client() -> Any:
240
- """Cerebras llama-4-scout 207ms TTFT, 100% qualità (bench 2026-08-04).
241
- REASONING MODEL: genera "reasoning" field prima del "content".
242
- Richiede max_tokens≥500 per output non-vuoto su task non-triviali.
243
- Fallback: _coder_client (Groq 70B) se CEREBRAS_API_KEY mancante."""
 
244
  from models.ai_client import AIClient, ProviderConfig
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  cerebras_key = os.getenv("CEREBRAS_API_KEY")
246
  if not cerebras_key:
247
  return RoleRouter._coder_client()
@@ -250,9 +328,9 @@ class RoleRouter:
250
  name="cerebras-reasoner",
251
  api_key=cerebras_key,
252
  base_url="https://api.cerebras.ai/v1",
253
- default_model=os.getenv("CEREBRAS_MODEL", "llama-4-scout"),
254
  )
255
- rest = [p for p in client.providers if not p.name.startswith("cerebras")]
256
  client.providers = [reasoner, *rest]
257
  client.provider_name = reasoner.name
258
  client.default_model = reasoner.default_model
@@ -265,6 +343,10 @@ class RoleRouter:
265
  gemma-4-31B-it: 100% ma 2132ms. Meta-Llama: rate-limited. gpt-oss-120b: ERR.
266
  Fallback: _architect_client (Groq) se SAMBANOVA_API_KEY mancante."""
267
  from models.ai_client import AIClient, ProviderConfig
 
 
 
 
268
  sn_key = os.getenv("SAMBANOVA_API_KEY")
269
  if not sn_key:
270
  return RoleRouter._architect_client()
@@ -287,6 +369,10 @@ class RoleRouter:
287
  """NVIDIA NIM nemotron-3-ultra-550b-a55b — 550B params, 1M ctx, API OpenAI-compat.
288
  Fallback: _architect_client (Groq) se NVIDIA_API_KEY mancante."""
289
  from models.ai_client import AIClient, ProviderConfig
 
 
 
 
290
  nvidia_key = os.getenv("NVIDIA_API_KEY")
291
  if not nvidia_key:
292
  return RoleRouter._architect_client()
@@ -306,17 +392,22 @@ class RoleRouter:
306
 
307
  @staticmethod
308
  def _tester_client() -> Any:
309
- """Groq llama-3.3-70b-versatile fast, sufficiente per test gen e debug hints."""
310
  from models.ai_client import AIClient, ProviderConfig
 
 
 
 
311
  groq_key = os.getenv("GROQ_API_KEY")
312
  if not groq_key:
313
- return AIClient()
314
  client = AIClient()
315
  tester = ProviderConfig(
316
  name="groq-tester",
317
  api_key=groq_key,
318
  base_url="https://api.groq.com/openai/v1",
319
- default_model=os.getenv("GROQ_FAST_MODEL", "llama-3.3-70b-versatile"),
 
320
  )
321
  rest = [p for p in client.providers if p.name not in ("groq", "groq-tester")]
322
  client.providers = [tester, *rest]
 
3
 
4
  BENCHMARK RESULTS 2026-06-14 FINALE (14 modelli × 3 test, max_tokens corretti):
5
  100% qualità (ordinati per TTFT):
6
+ #1 Groq / qwen/qwen3.6-27b — 170ms 100% ← FASTEST
7
  #2 Cerebras / gpt-oss-120b — 207ms 100% ← REASONING (max_tokens≥500)
8
+ #3 Groq / qwen/qwen3.6-27b — 235ms 100%
9
  #4 Cerebras / zai-glm-4.7 — 254ms 100%
10
  #5 Groq / compound-mini — 341ms 100%
11
  #6 SambaNova / DeepSeek-V3.1 — 482ms 100%
12
  #7 SambaNova / gemma-4-31B — 2132ms 100%
13
+ #8 OpenRouter / openrouter/free — 2160ms 100%
14
 
15
  Role assignments 2026-06-14 FINALE:
16
+ FAST → Groq qwen/qwen3.6-27b (170ms, 100%) ← #1 assoluto
17
  ARCHITECT → Groq llama-4-scout-17b 10M ctx (244ms, 67% — best per contesto lungo)
18
+ CODER → Groq qwen/qwen3.6-27b (235ms, 100%) ← #3 qualità
19
+ TESTER → Groq qwen/qwen3.6-27b
20
+ CONTEXT → Groq qwen/qwen3.6-27b
21
  RESEARCHER → Gemini 2.5-flash (599ms, 67% — math prompt-sensitive)
22
  REASONER → Cerebras gpt-oss-120b (207ms, 100%, reasoning model → max_tokens≥500)
23
  SAMBANOVA → SambaNova DeepSeek-V3.1 (482ms, 100%)
24
+ DEFAULT → AIClient() primary (qwen/qwen3.6-27b o primo disponibile)
25
 
26
+ OpenRouter tenuto come fallback secondario (openrouter/free = 1645ms ma 100% qualità).
27
  """
28
  from __future__ import annotations
29
 
 
36
 
37
 
38
  class Role(str, Enum):
39
+ FAST = "fast" # greetings, math semplice, identity — qwen/qwen3.6-27b
40
+ ARCHITECT = "architect" # planning, ragionamento complesso — GPT-OSS 120B
41
+ CODER = "coder" # coding, debug — qwen/qwen3.6-27b
42
+ TESTER = "tester" # test gen, debug hints — qwen/qwen3.6-27b
43
+ CONTEXT = "context" # summarization, context compression — qwen/qwen3.6-27b
44
  DEFAULT = "default" # AIClient() primary
45
+ RESEARCHER = "researcher" # web research + document synthesis — GPT-OSS 120B
46
+ REASONER = "reasoner" # throughput massimo — Cerebras GPT-OSS 120B
47
  SAMBANOVA = "sambanova"
48
  NVIDIA = "nvidia" # NVIDIA NIM — nemotron-3-ultra-550b (1M ctx) # DeepSeek-V3.2 via SambaNova (404ms, 100% qualità benchmark)
49
 
 
83
 
84
  # ── Role-specific builders ─────────────────────────────────────────────────
85
 
86
+ @staticmethod
87
+ def _prioritize_profile_pool(client: Any, provider_name: str) -> Any:
88
+ """Promote all configured profiles for one provider without collapsing them."""
89
+ profiles = [p for p in client.providers if p.name == provider_name]
90
+ if not profiles:
91
+ return None
92
+ client.providers = profiles + [p for p in client.providers if p.name != provider_name]
93
+ client.provider_name = profiles[0].name
94
+ client.default_model = profiles[0].default_model
95
+ client.client = client._client_for(profiles[0])
96
+ return client
97
+
98
+ @staticmethod
99
+ def _profiled_client(client: Any, provider_names: tuple[str, ...]) -> Any:
100
+ for provider_name in provider_names:
101
+ env_name = f"{provider_name.upper()}_PROFILES_JSON"
102
+ if os.getenv(env_name):
103
+ profiled = RoleRouter._prioritize_profile_pool(client, provider_name)
104
+ if profiled:
105
+ return profiled
106
+ return None
107
+
108
  @staticmethod
109
  def _fast_client() -> Any:
110
+ """Groq GPT-OSS 20B per query brevi e a bassa latenza.
111
  Usato per: greetings, calcoli semplici, identity, domande 1-liner."""
112
  from models.ai_client import AIClient, ProviderConfig
113
+ client = AIClient()
114
+ profiled = RoleRouter._profiled_client(client, ("groq",))
115
+ if profiled:
116
+ return profiled
117
  groq_key = os.getenv("GROQ_API_KEY")
118
  if not groq_key:
119
  return RoleRouter._tester_client()
 
122
  name="groq-fast",
123
  api_key=groq_key,
124
  base_url="https://api.groq.com/openai/v1",
125
+ default_model=os.getenv("GROQ_FAST_MODEL", "qwen/qwen3.6-27b"),
126
  )
127
  rest = [p for p in client.providers if p.name not in ("groq", "groq-fast", "groq-tester")]
128
  client.providers = [fast, *rest]
 
133
 
134
  @staticmethod
135
  def _architect_client() -> Any:
136
+ """NVIDIA NIM come primario per architettura.
137
+ Fallback 1: Groq GPT-OSS 120B. Fallback 2: OpenRouter GPT-OSS 20B gratuito."""
138
  from models.ai_client import AIClient, ProviderConfig
139
+ client = AIClient()
140
+ profiled = RoleRouter._profiled_client(client, ("nvidia", "groq", "openrouter"))
141
+ if profiled:
142
+ return profiled
143
  nvidia_key = os.getenv("NVIDIA_API_KEY")
144
  if nvidia_key:
145
  client = AIClient()
 
155
  client.default_model = nvidia.default_model
156
  client.client = client._client_for(nvidia)
157
  return client
158
+ # Fallback 1: Groq GPT-OSS 120B, modello production supportato.
159
  groq_key = os.getenv("GROQ_API_KEY")
160
  if groq_key:
161
  client = AIClient()
 
163
  name="groq-architect",
164
  api_key=groq_key,
165
  base_url="https://api.groq.com/openai/v1",
166
+ default_model=os.getenv("ARCHITECT_MODEL", "qwen/qwen3.6-27b"),
167
  )
168
  rest = [p for p in client.providers if p.name not in ("groq", "groq-architect")]
169
  client.providers = [architect, *rest]
 
171
  client.default_model = architect.default_model
172
  client.client = client._client_for(architect)
173
  return client
174
+ # Fallback OpenRouter: usa il pool multi-profilo, se configurato.
175
+ if os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENROUTER_PROFILES_JSON"):
 
176
  client = AIClient()
177
+ profiles = [p for p in client.providers if p.name == "openrouter"]
178
+ if not profiles and os.getenv("OPENROUTER_API_KEY"):
179
+ profiles = [ProviderConfig(
180
+ name="openrouter", api_key=os.getenv("OPENROUTER_API_KEY", ""),
181
+ base_url="https://openrouter.ai/api/v1",
182
+ default_model=os.getenv("OPENROUTER_MODEL", "openrouter/free"),
183
+ profile="legacy",
184
+ )]
185
+ if profiles:
186
+ client.providers = profiles + [p for p in client.providers if p.name != "openrouter"]
187
+ client.provider_name = profiles[0].name
188
+ client.default_model = profiles[0].default_model
189
+ client.client = client._client_for(profiles[0])
190
  return client
191
  return AIClient()
192
 
193
  @staticmethod
194
  def _coder_client() -> Any:
195
+ """Groq GPT-OSS 120B per coding e debug.
196
+ Fallback: provider ordinari del router se GROQ_API_KEY manca."""
 
197
  from models.ai_client import AIClient, ProviderConfig
198
+ client = AIClient()
199
+ profiled = RoleRouter._profiled_client(client, ("groq", "nvidia", "openrouter"))
200
+ if profiled:
201
+ return profiled
202
  groq_key = os.getenv("GROQ_API_KEY")
203
+ nvidia_key = os.getenv("NVIDIA_API_KEY")
204
+ model = os.getenv("CODER_MODEL", "qwen/qwen3.6-27b")
205
  if groq_key:
206
  client = AIClient()
207
  coder = ProviderConfig(
 
209
  api_key=groq_key,
210
  base_url="https://api.groq.com/openai/v1",
211
  default_model=model,
212
+ purpose="coding",
213
  )
214
+ dedicated_fallbacks: list[ProviderConfig] = []
215
+ if nvidia_key:
216
+ dedicated_fallbacks.append(
217
+ ProviderConfig(
218
+ name="nvidia-coder",
219
+ api_key=nvidia_key,
220
+ base_url="https://integrate.api.nvidia.com/v1",
221
+ default_model=os.getenv(
222
+ "NVIDIA_MODEL", "nvidia/nemotron-3-ultra-550b-a55b"
223
+ ),
224
+ purpose="coding",
225
+ )
226
+ )
227
+ rest = [
228
+ provider for provider in client.providers
229
+ if provider.name not in ("groq", "groq-coder", "nvidia", "nvidia-coder")
230
+ ]
231
+ client.providers = [coder, *dedicated_fallbacks, *rest]
232
  client.provider_name = coder.name
233
  client.default_model = coder.default_model
234
  client.client = client._client_for(coder)
235
  return client
236
+ # Fallback OpenRouter: usa il pool multi-profilo, se configurato.
237
+ if os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENROUTER_PROFILES_JSON"):
238
  client = AIClient()
239
+ profiles = [p for p in client.providers if p.name == "openrouter"]
240
+ if not profiles and os.getenv("OPENROUTER_API_KEY"):
241
+ profiles = [ProviderConfig(
242
+ name="openrouter", api_key=os.getenv("OPENROUTER_API_KEY", ""),
243
+ base_url="https://openrouter.ai/api/v1",
244
+ default_model=os.getenv("OPENROUTER_MODEL", "openrouter/free"),
245
+ profile="legacy",
246
+ )]
247
+ if profiles:
248
+ client.providers = profiles + [p for p in client.providers if p.name != "openrouter"]
249
+ client.provider_name = profiles[0].name
250
+ client.default_model = profiles[0].default_model
251
+ client.client = client._client_for(profiles[0])
252
  return client
253
  return AIClient()
254
 
255
  @staticmethod
256
  def _researcher_client() -> Any:
257
+ """Groq GPT-OSS 120B per analisi e sintesi; la flotta restante è fallback.
258
+
259
+ Gemini può essere configurato ma ha una quota indipendente e più stretta:
260
+ non deve quindi bloccare i task della persona analyst/researcher quando
261
+ Groq è sano. L'ordine conserva tutti i provider ordinari dopo Groq.
262
+ """
263
  from models.ai_client import AIClient, ProviderConfig
264
+ client = AIClient()
265
+ profiled = RoleRouter._profiled_client(client, ("groq", "gemini", "openrouter"))
266
+ if profiled:
267
+ return profiled
268
  groq_key = os.getenv("GROQ_API_KEY")
269
+ if groq_key:
 
 
270
  researcher = ProviderConfig(
271
+ name="groq-researcher",
272
+ api_key=groq_key,
273
+ base_url="https://api.groq.com/openai/v1",
274
+ default_model=os.getenv(
275
+ "GROQ_RESEARCH_MODEL",
276
+ os.getenv("GROQ_MODEL", "qwen/qwen3.6-27b"),
277
+ ),
278
  )
279
+ rest = [
280
+ provider for provider in client.providers
281
+ if provider.name not in ("groq", "groq-researcher")
282
+ ]
283
  client.providers = [researcher, *rest]
284
  client.provider_name = researcher.name
285
  client.default_model = researcher.default_model
286
  client.client = client._client_for(researcher)
287
  return client
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  return AIClient()
289
 
290
  @staticmethod
291
  def _reasoner_client() -> Any:
292
+ """Priorità a Groq GPT-OSS per reasoning/MMLU, con flotta runtime come fallback.
293
+
294
+ Gemini è soggetto a quote RPM e non deve essere il percorso iniziale per
295
+ risposte deterministiche a scelta multipla. Cerebras resta un fallback
296
+ compatibile quando Groq non è configurato."""
297
  from models.ai_client import AIClient, ProviderConfig
298
+ client = AIClient()
299
+ profiled = RoleRouter._profiled_client(client, ("groq", "cerebras", "gemini", "openrouter"))
300
+ if profiled:
301
+ return profiled
302
+ groq_key = os.getenv("GROQ_API_KEY")
303
+ if groq_key:
304
+ reasoner = ProviderConfig(
305
+ name="groq-reasoner",
306
+ api_key=groq_key,
307
+ base_url="https://api.groq.com/openai/v1",
308
+ default_model=os.getenv(
309
+ "GROQ_REASONER_MODEL",
310
+ os.getenv("GROQ_MODEL", "qwen/qwen3.6-27b"),
311
+ ),
312
+ )
313
+ rest = [
314
+ provider for provider in client.providers
315
+ if provider.name not in ("groq", "groq-reasoner")
316
+ ]
317
+ client.providers = [reasoner, *rest]
318
+ client.provider_name = reasoner.name
319
+ client.default_model = reasoner.default_model
320
+ client.client = client._client_for(reasoner)
321
+ return client
322
+
323
  cerebras_key = os.getenv("CEREBRAS_API_KEY")
324
  if not cerebras_key:
325
  return RoleRouter._coder_client()
 
328
  name="cerebras-reasoner",
329
  api_key=cerebras_key,
330
  base_url="https://api.cerebras.ai/v1",
331
+ default_model=os.getenv("CEREBRAS_MODEL", "gpt-oss-120b"),
332
  )
333
+ rest = [provider for provider in client.providers if not provider.name.startswith("cerebras")]
334
  client.providers = [reasoner, *rest]
335
  client.provider_name = reasoner.name
336
  client.default_model = reasoner.default_model
 
343
  gemma-4-31B-it: 100% ma 2132ms. Meta-Llama: rate-limited. gpt-oss-120b: ERR.
344
  Fallback: _architect_client (Groq) se SAMBANOVA_API_KEY mancante."""
345
  from models.ai_client import AIClient, ProviderConfig
346
+ client = AIClient()
347
+ profiled = RoleRouter._profiled_client(client, ("sambanova",))
348
+ if profiled:
349
+ return profiled
350
  sn_key = os.getenv("SAMBANOVA_API_KEY")
351
  if not sn_key:
352
  return RoleRouter._architect_client()
 
369
  """NVIDIA NIM nemotron-3-ultra-550b-a55b — 550B params, 1M ctx, API OpenAI-compat.
370
  Fallback: _architect_client (Groq) se NVIDIA_API_KEY mancante."""
371
  from models.ai_client import AIClient, ProviderConfig
372
+ client = AIClient()
373
+ profiled = RoleRouter._profiled_client(client, ("nvidia",))
374
+ if profiled:
375
+ return profiled
376
  nvidia_key = os.getenv("NVIDIA_API_KEY")
377
  if not nvidia_key:
378
  return RoleRouter._architect_client()
 
392
 
393
  @staticmethod
394
  def _tester_client() -> Any:
395
+ """Groq GPT-OSS 20B per test rapidi e debug hints."""
396
  from models.ai_client import AIClient, ProviderConfig
397
+ client = AIClient()
398
+ profiled = RoleRouter._profiled_client(client, ("groq",))
399
+ if profiled:
400
+ return profiled
401
  groq_key = os.getenv("GROQ_API_KEY")
402
  if not groq_key:
403
+ return client
404
  client = AIClient()
405
  tester = ProviderConfig(
406
  name="groq-tester",
407
  api_key=groq_key,
408
  base_url="https://api.groq.com/openai/v1",
409
+ default_model=os.getenv("GROQ_FAST_MODEL", "qwen/qwen3.6-27b"),
410
+ purpose="coding",
411
  )
412
  rest = [p for p in client.providers if p.name not in ("groq", "groq-tester")]
413
  client.providers = [tester, *rest]
tests/test_ai_client_provider_unavailability.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import unittest
3
+ from unittest.mock import patch
4
+
5
+ from models.ai_client import AIClient, ProviderConfig, ProviderUnavailableError
6
+
7
+
8
+ class _FailingCompletions:
9
+ def create(self, **_kwargs):
10
+ raise RuntimeError("quota exhausted")
11
+
12
+
13
+ class _FailingChat:
14
+ completions = _FailingCompletions()
15
+
16
+
17
+ class _FailingClient:
18
+ chat = _FailingChat()
19
+
20
+
21
+ class _ClientWithFailingProviders(AIClient):
22
+ def __init__(self):
23
+ self.providers = [
24
+ ProviderConfig(name="primary", api_key="x", base_url="https://example.invalid", default_model="model-a"),
25
+ ProviderConfig(name="fallback", api_key="y", base_url="https://example.invalid", default_model="model-b"),
26
+ ]
27
+ self._client_cache = {}
28
+ self._rr_indices = {}
29
+ # Stato minimo richiesto dai percorsi chat/stream dopo l’introduzione
30
+ # del circuit breaker per profilo. Non chiama AIClient.__init__ e non
31
+ # carica provider o segreti dall’ambiente.
32
+ self._breaker = {}
33
+ self._breaker_threshold = 2
34
+ self._breaker_cooldown_s = 60.0
35
+
36
+ def _client_for(self, _provider):
37
+ return _FailingClient()
38
+
39
+
40
+ class ProviderUnavailableTests(unittest.IsolatedAsyncioTestCase):
41
+ async def test_chat_raises_structured_error_when_every_provider_fails(self):
42
+ client = _ClientWithFailingProviders()
43
+
44
+ with self.assertRaises(ProviderUnavailableError) as raised:
45
+ await client.chat([{"role": "user", "content": "hello"}], max_tokens=8)
46
+
47
+ self.assertCountEqual(raised.exception.providers, ("primary", "fallback"))
48
+ self.assertNotIn("api_key", str(raised.exception).lower())
49
+
50
+ async def test_stream_chat_raises_structured_error_when_every_provider_fails(self):
51
+ client = _ClientWithFailingProviders()
52
+
53
+ with self.assertRaises(ProviderUnavailableError) as raised:
54
+ async for _ in client.stream_chat([{"role": "user", "content": "hello"}], max_tokens=8):
55
+ pass
56
+
57
+ self.assertCountEqual(raised.exception.providers, ("primary", "fallback"))
58
+ self.assertNotIn("api_key", str(raised.exception).lower())
59
+
60
+ async def test_stream_chat_expands_a_role_specific_provider_pool(self):
61
+ client = _ClientWithFailingProviders()
62
+ client.providers = [
63
+ ProviderConfig(name="gemini-role", api_key="x", base_url="https://example.invalid", default_model="gemini")
64
+ ]
65
+ runtime_fallback = ProviderConfig(
66
+ name="nvidia", api_key="y", base_url="https://fallback.invalid", default_model="nemotron"
67
+ )
68
+
69
+ with patch.object(client, "_load_providers", return_value=[runtime_fallback]):
70
+ with self.assertRaises(ProviderUnavailableError) as raised:
71
+ async for _ in client.stream_chat([{"role": "user", "content": "hello"}], max_tokens=8):
72
+ pass
73
+
74
+ self.assertCountEqual(raised.exception.providers, ("gemini-role", "nvidia"))
75
+
76
+
77
+ class RuntimeModelOverrideTests(unittest.TestCase):
78
+ def test_groq_runtime_model_overrides_database_model(self):
79
+ row = {
80
+ "base_url": "https://api.groq.com/openai/v1",
81
+ "default_model": "llama-3.3-70b-versatile",
82
+ }
83
+ with patch.dict("os.environ", {"GROQ_MODEL": "openai/gpt-oss-120b"}, clear=False):
84
+ self.assertEqual(
85
+ AIClient._runtime_model_override(row),
86
+ "openai/gpt-oss-120b",
87
+ )
88
+
89
+ def test_unknown_provider_keeps_database_model(self):
90
+ row = {"base_url": "https://example.invalid/v1", "default_model": "custom-model"}
91
+ self.assertEqual(AIClient._runtime_model_override(row), "custom-model")
92
+
93
+
94
+ if __name__ == "__main__":
95
+ unittest.main()
tests/test_ai_client_schema_compatibility.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import types
4
+ import unittest
5
+ from unittest.mock import patch
6
+
7
+ from models.ai_client import AIClient
8
+
9
+
10
+ class _LegacySchemaError(Exception):
11
+ pass
12
+
13
+
14
+ class _Result:
15
+ def __init__(self, data):
16
+ self.data = data
17
+
18
+
19
+ class _LegacyQuery:
20
+ def __init__(self, rows):
21
+ self.rows = rows
22
+ self.selects = []
23
+
24
+ def select(self, columns):
25
+ self.selects.append(columns)
26
+ if "default_model" in columns:
27
+ raise _LegacySchemaError("column ai_providers.default_model does not exist")
28
+ return self
29
+
30
+ def eq(self, *_args, **_kwargs):
31
+ return self
32
+
33
+ def order(self, *_args, **_kwargs):
34
+ return self
35
+
36
+ def execute(self):
37
+ return _Result(self.rows)
38
+
39
+
40
+ class _LegacySupabase:
41
+ def __init__(self, rows):
42
+ self.query = _LegacyQuery(rows)
43
+
44
+ def table(self, name):
45
+ assert name == "ai_providers"
46
+ return self.query
47
+
48
+
49
+ class LegacySchemaCompatibilityTests(unittest.TestCase):
50
+ def test_detects_only_known_legacy_missing_columns(self):
51
+ self.assertTrue(
52
+ AIClient._is_legacy_schema_error(
53
+ _LegacySchemaError("column ai_providers.default_model does not exist")
54
+ )
55
+ )
56
+ self.assertFalse(
57
+ AIClient._is_legacy_schema_error(
58
+ _LegacySchemaError("column ai_providers.api_key does not exist")
59
+ )
60
+ )
61
+
62
+ def test_legacy_schema_returns_empty_so_current_environment_fallback_stays_authoritative(self):
63
+ legacy_supabase = _LegacySupabase([])
64
+ fake_supabase = types.SimpleNamespace(
65
+ create_client=lambda _url, _key: legacy_supabase,
66
+ )
67
+ client = AIClient.__new__(AIClient)
68
+
69
+ with patch.dict(
70
+ os.environ,
71
+ {"SUPABASE_URL": "https://example.supabase.co", "SUPABASE_SERVICE_ROLE_KEY": "test"},
72
+ clear=True,
73
+ ), patch.dict(sys.modules, {"supabase": fake_supabase}):
74
+ providers = client._try_load_from_supabase()
75
+
76
+ self.assertEqual(legacy_supabase.query.selects, [
77
+ "id,name,api_key,base_url,default_model,tier,purpose",
78
+ ])
79
+ self.assertEqual(providers, [])
80
+
81
+
82
+ if __name__ == "__main__":
83
+ unittest.main()
tests/test_auth_scheduler_regressions.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regressioni auth/scheduler: cleanup rate limiter e timezone daily.
2
+
3
+ Esegui con: python3 -m unittest backend.tests.test_auth_scheduler_regressions -v
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import sys
9
+ import unittest
10
+ from collections import deque
11
+ from datetime import datetime, timezone
12
+ from unittest.mock import patch
13
+
14
+ _BACKEND = os.path.join(os.path.dirname(__file__), "..")
15
+ if _BACKEND not in sys.path:
16
+ sys.path.insert(0, _BACKEND)
17
+
18
+
19
+ class TestInMemoryRateStoreCleanup(unittest.TestCase):
20
+ """AUTH-RATE-LEAK: bucket inattivi non devono restare nel processo."""
21
+
22
+ def setUp(self) -> None:
23
+ try:
24
+ import api.auth_guard as auth_guard
25
+ except ImportError as exc:
26
+ self.skipTest(str(exc))
27
+ self.auth_guard = auth_guard
28
+ auth_guard._rate_store.clear()
29
+ auth_guard._rate_store_checks = 0
30
+
31
+ def tearDown(self) -> None:
32
+ self.auth_guard._rate_store.clear()
33
+ self.auth_guard._rate_store_checks = 0
34
+
35
+ def test_periodic_sweep_removes_expired_empty_bucket(self) -> None:
36
+ self.auth_guard._rate_store["expired-client"] = deque([1.0])
37
+ self.auth_guard._rate_store_checks = self.auth_guard._RATE_STORE_SWEEP_EVERY - 1
38
+
39
+ with patch.object(self.auth_guard._rl_time, "monotonic", return_value=120.0):
40
+ allowed, retry_after = self.auth_guard._inmem_rate_check(
41
+ "active-client", limit=10, window_s=60
42
+ )
43
+
44
+ self.assertTrue(allowed)
45
+ self.assertEqual(retry_after, 0)
46
+ self.assertNotIn(
47
+ "expired-client",
48
+ self.auth_guard._rate_store,
49
+ "AUTH-RATE-LEAK: il bucket inattivo resta nello store dopo lo sweep",
50
+ )
51
+ self.assertIn("active-client", self.auth_guard._rate_store)
52
+
53
+ def test_current_request_survives_its_own_sweep(self) -> None:
54
+ self.auth_guard._rate_store_checks = self.auth_guard._RATE_STORE_SWEEP_EVERY - 1
55
+
56
+ with patch.object(self.auth_guard._rl_time, "monotonic", return_value=120.0):
57
+ allowed, _ = self.auth_guard._inmem_rate_check(
58
+ "current-client", limit=1, window_s=60
59
+ )
60
+
61
+ self.assertTrue(allowed)
62
+ self.assertIn("current-client", self.auth_guard._rate_store)
63
+
64
+
65
+ class TestDailyTriggerTimezone(unittest.TestCase):
66
+ """SCHED-TZ-DRIFT: il backend deve conservare l'ora civile scelta dal browser."""
67
+
68
+ def setUp(self) -> None:
69
+ try:
70
+ import api.scheduler as scheduler
71
+ except ImportError as exc:
72
+ self.skipTest(str(exc))
73
+ self.scheduler = scheduler
74
+
75
+ def _advance(self, iso_now: str) -> datetime:
76
+ now = datetime.fromisoformat(iso_now)
77
+ result = self.scheduler._advance_trigger(
78
+ {
79
+ "type": "daily",
80
+ "hour": 9,
81
+ "minute": 0,
82
+ "nextRun": int(now.timestamp() * 1000),
83
+ "timeZone": "Europe/Rome",
84
+ },
85
+ int(now.timestamp() * 1000),
86
+ )
87
+ return datetime.fromtimestamp(result["nextRun"] / 1000, tz=timezone.utc)
88
+
89
+ def test_daily_uses_browser_timezone_not_utc_server_timezone(self) -> None:
90
+ # 09:00 CEST è 07:00 UTC. Essendo già l'orario pianificato, il run successivo
91
+ # deve restare alle 09:00 civili del giorno seguente (07:00 UTC), non 09:00 UTC.
92
+ actual = self._advance("2026-06-01T07:00:00+00:00")
93
+ self.assertEqual(actual, datetime(2026, 6, 2, 7, 0, tzinfo=timezone.utc))
94
+
95
+ def test_daily_preserves_wall_clock_across_dst_transition(self) -> None:
96
+ # Il giorno dopo l'Europa passa da CET (UTC+1) a CEST (UTC+2): l'ora civile
97
+ # deve rimanere 09:00, quindi l'epoch UTC passa correttamente da 08:00 a 07:00.
98
+ actual = self._advance("2026-03-28T08:00:00+00:00")
99
+ self.assertEqual(actual, datetime(2026, 3, 29, 7, 0, tzinfo=timezone.utc))
100
+
101
+ def test_legacy_daily_trigger_without_timezone_remains_schedulable(self) -> None:
102
+ now = datetime(2026, 6, 1, 7, 0, tzinfo=timezone.utc)
103
+ result = self.scheduler._advance_trigger(
104
+ {"type": "daily", "hour": 9, "minute": 0, "nextRun": int(now.timestamp() * 1000)},
105
+ int(now.timestamp() * 1000),
106
+ )
107
+ self.assertGreater(result["nextRun"], int(now.timestamp() * 1000))
108
+
109
+
110
+ if __name__ == "__main__":
111
+ unittest.main()
tests/test_benchmark_validators.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+
3
+ from benchmarks.validators import (
4
+ validate_coding_output,
5
+ validate_coding_retry,
6
+ validate_mmlu_output,
7
+ validate_reasoning_output,
8
+ validate_reasoning_retry,
9
+ )
10
+
11
+
12
+ class MMLUValidatorTests(unittest.TestCase):
13
+ def test_accepts_explicit_answer_with_explanation(self):
14
+ result = validate_mmlu_output(
15
+ "ANSWER: C\nPerché la complessità nel caso peggiore è quadratica.",
16
+ expected="C",
17
+ )
18
+ self.assertTrue(result.valid)
19
+ self.assertEqual(result.normalized, "C")
20
+ self.assertTrue(result.evidence["correct"])
21
+
22
+ def test_accepts_marked_choice(self):
23
+ result = validate_mmlu_output("La scelta corretta è (B). La stack segue LIFO.")
24
+ self.assertTrue(result.valid)
25
+ self.assertEqual(result.normalized, "B")
26
+
27
+ def test_accepts_single_isolated_letter(self):
28
+ result = validate_mmlu_output("D")
29
+ self.assertTrue(result.valid)
30
+ self.assertEqual(result.normalized, "D")
31
+
32
+ def test_accepts_final_answer_contract_used_by_retry(self):
33
+ result = validate_mmlu_output("Final answer: D\nThe two values overflow because both are negative.")
34
+ self.assertTrue(result.valid)
35
+ self.assertEqual(result.normalized, "D")
36
+
37
+ def test_accepts_runner_bold_contract(self):
38
+ result = validate_mmlu_output("**(B)** — risposta scelta")
39
+ self.assertTrue(result.valid)
40
+ self.assertEqual(result.normalized, "B")
41
+
42
+ def test_explanation_letters_do_not_override_explicit_answer(self):
43
+ result = validate_mmlu_output("ANSWER: A. Le opzioni B, C e D sono errate.")
44
+ self.assertTrue(result.valid)
45
+ self.assertEqual(result.normalized, "A")
46
+
47
+ def test_rejects_missing_answer(self):
48
+ result = validate_mmlu_output("La spiegazione descrive il concetto ma non seleziona un'opzione.")
49
+ self.assertFalse(result.valid)
50
+ self.assertEqual(result.failure_code, "answer_missing")
51
+
52
+ def test_rejects_conflicting_explicit_answers(self):
53
+ result = validate_mmlu_output("ANSWER: A\nFinal answer: C")
54
+ self.assertFalse(result.valid)
55
+ self.assertEqual(result.failure_code, "answer_ambiguous")
56
+ self.assertEqual(result.evidence["distinct_candidates"], ["A", "C"])
57
+
58
+ def test_rejects_empty_output(self):
59
+ result = validate_mmlu_output(None)
60
+ self.assertFalse(result.valid)
61
+ self.assertEqual(result.failure_code, "answer_missing")
62
+
63
+ def test_expected_answer_only_affects_evidence(self):
64
+ result = validate_mmlu_output("ANSWER: B", expected="C")
65
+ self.assertTrue(result.valid)
66
+ self.assertFalse(result.evidence["correct"])
67
+ self.assertEqual(result.normalized, "B")
68
+
69
+
70
+ class CodingValidatorTests(unittest.TestCase):
71
+ def test_accepts_typescript_fence_and_required_symbol(self):
72
+ output = """Ecco l'implementazione:
73
+ ```typescript
74
+ export function reverseWords(value: string): string {
75
+ return value.trim().split(/\\s+/).reverse().join(' ');
76
+ }
77
+ ```
78
+ """
79
+ result = validate_coding_output(output, required_symbols=["reverseWords"], min_significant_lines=3)
80
+ self.assertTrue(result.valid)
81
+ self.assertIn("reverseWords", result.normalized)
82
+ self.assertEqual(result.evidence["missing_symbols"], [])
83
+
84
+ def test_accepts_json_envelope(self):
85
+ output = '{"language":"typescript","code":"export const add = (a: number, b: number): number => a + b;"}'
86
+ result = validate_coding_output(output, required_symbols=["add"])
87
+ self.assertTrue(result.valid)
88
+ self.assertEqual(result.evidence["extraction"], "json:code")
89
+
90
+ def test_rejects_empty_fence(self):
91
+ result = validate_coding_output("```typescript\n\n```")
92
+ self.assertFalse(result.valid)
93
+ self.assertEqual(result.failure_code, "code_empty")
94
+
95
+ def test_rejects_missing_code_block(self):
96
+ result = validate_coding_output("La soluzione è implementata nel testo seguente, ma il codice non è incluso.")
97
+ self.assertFalse(result.valid)
98
+ self.assertEqual(result.failure_code, "code_missing")
99
+
100
+ def test_rejects_wrong_language_fence(self):
101
+ result = validate_coding_output("```python\ndef add(a, b): return a + b\n```")
102
+ self.assertFalse(result.valid)
103
+ self.assertEqual(result.failure_code, "code_wrong_language")
104
+
105
+ def test_rejects_required_symbol_missing(self):
106
+ result = validate_coding_output(
107
+ "```ts\nexport function subtract(a: number, b: number): number { return a - b; }\n```",
108
+ required_symbols=["add"],
109
+ )
110
+ self.assertFalse(result.valid)
111
+ self.assertEqual(result.failure_code, "required_symbol_missing")
112
+ self.assertEqual(result.evidence["missing_symbols"], ["add"])
113
+
114
+ def test_repaired_typescript_output_passes_contract(self):
115
+ result = validate_coding_output(
116
+ "```typescript\nexport function add(a: number, b: number): number {\n return a + b;\n}\n```",
117
+ required_symbols=["add"],
118
+ min_significant_lines=3,
119
+ )
120
+ self.assertTrue(result.valid)
121
+ self.assertIsNone(result.failure_code)
122
+
123
+ def test_retry_is_requested_for_missing_typescript_before_last_attempt(self):
124
+ result = validate_coding_retry(
125
+ "code_correct: implementa TypeScript",
126
+ "La spiegazione non contiene codice.",
127
+ is_last_attempt=False,
128
+ )
129
+ self.assertIsNotNone(result)
130
+ self.assertEqual(result.failure_code, "code_missing")
131
+
132
+ def test_retry_is_not_requested_on_last_attempt(self):
133
+ result = validate_coding_retry(
134
+ "code_correct: implementa TypeScript",
135
+ "La spiegazione non contiene codice.",
136
+ is_last_attempt=True,
137
+ )
138
+ self.assertIsNone(result)
139
+
140
+ def test_retry_is_not_requested_for_non_coding_goal(self):
141
+ result = validate_coding_retry(
142
+ "Scrivi una spiegazione concettuale",
143
+ "La spiegazione non contiene codice.",
144
+ is_last_attempt=False,
145
+ )
146
+ self.assertIsNone(result)
147
+
148
+ def test_retry_is_not_requested_for_valid_typescript(self):
149
+ result = validate_coding_retry(
150
+ "code_correct: implementa TypeScript",
151
+ "```typescript\nexport const add = (a: number, b: number): number => a + b;\n```",
152
+ is_last_attempt=False,
153
+ )
154
+ self.assertIsNone(result)
155
+
156
+ def test_rejects_placeholder_implementation(self):
157
+ result = validate_coding_output(
158
+ "```typescript\nexport function add(a: number, b: number): number {\n // TODO implement here\n return 0;\n}\n```",
159
+ required_symbols=["add"],
160
+ )
161
+ self.assertFalse(result.valid)
162
+ self.assertEqual(result.failure_code, "code_placeholder")
163
+
164
+ def test_rejects_non_typescript_prose_inside_fence(self):
165
+ result = validate_coding_output("```typescript\nThis is only explanatory prose.\n```")
166
+ self.assertFalse(result.valid)
167
+ self.assertEqual(result.failure_code, "code_syntax_suspect")
168
+
169
+
170
+ class ReasoningValidatorTests(unittest.TestCase):
171
+ def test_accepts_gsm8k_contract_with_thousands_separator(self):
172
+ result = validate_reasoning_output(
173
+ "Somma i valori: 100 + 125 = 225.\n#### 225",
174
+ expected=225,
175
+ )
176
+ self.assertTrue(result.valid)
177
+ self.assertEqual(result.normalized, "225")
178
+ self.assertTrue(result.evidence["correct"])
179
+
180
+ def test_accepts_labeled_final_answer(self):
181
+ result = validate_reasoning_output("I passaggi portano al totale. Final answer: 2,250", expected=2250)
182
+ self.assertTrue(result.valid)
183
+ self.assertEqual(result.normalized, "2250")
184
+
185
+ def test_classifies_wrong_numeric_answer(self):
186
+ result = validate_reasoning_output("Calcolo completo. #### 250", expected=225)
187
+ self.assertFalse(result.valid)
188
+ self.assertEqual(result.failure_code, "wrong_numeric_answer")
189
+ self.assertFalse(result.evidence["correct"])
190
+
191
+ def test_classifies_missing_numeric_answer(self):
192
+ result = validate_reasoning_output("La spiegazione termina senza un numero finale.", expected=225)
193
+ self.assertFalse(result.valid)
194
+ self.assertEqual(result.failure_code, "answer_missing")
195
+
196
+ def test_classifies_conflicting_explicit_answers(self):
197
+ result = validate_reasoning_output("#### 250\nFinal answer: 225", expected=225)
198
+ self.assertFalse(result.valid)
199
+ self.assertEqual(result.failure_code, "calculation_conflict")
200
+ self.assertEqual(result.evidence["distinct_candidates"], [250, 225])
201
+
202
+ def test_reasoning_retry_is_requested_for_wrong_answer_before_last_attempt(self):
203
+ result = validate_reasoning_retry(
204
+ "reasoning GSM8K: risolvi il problema",
205
+ "#### 250",
206
+ expected=225,
207
+ is_last_attempt=False,
208
+ )
209
+ self.assertIsNotNone(result)
210
+ self.assertEqual(result.failure_code, "wrong_numeric_answer")
211
+
212
+ def test_reasoning_retry_is_not_requested_on_last_attempt(self):
213
+ result = validate_reasoning_retry(
214
+ "reasoning GSM8K: risolvi il problema",
215
+ "#### 250",
216
+ expected=225,
217
+ is_last_attempt=True,
218
+ )
219
+ self.assertIsNone(result)
220
+
221
+ def test_reasoning_retry_is_not_requested_for_non_reasoning_goal(self):
222
+ result = validate_reasoning_retry(
223
+ "Implementa un componente TypeScript",
224
+ "#### 250",
225
+ expected=225,
226
+ is_last_attempt=False,
227
+ )
228
+ self.assertIsNone(result)
229
+
230
+
231
+ if __name__ == "__main__":
232
+ unittest.main()
tests/test_coding_output_contract.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+
3
+ from agents.unified_loop_llm import LLMSelectionMixin
4
+
5
+
6
+ class CodingOutputContractTests(unittest.TestCase):
7
+ def test_code_directive_requires_extractable_single_snippet(self):
8
+ directive = LLMSelectionMixin._FORMAT_DIRECTIVE_CODE
9
+
10
+ self.assertIn("ESATTAMENTE un blocco", directive)
11
+ self.assertIn("linguaggio richiesto", directive)
12
+ self.assertIn("compilabile", directive)
13
+ self.assertIn("export", directive)
14
+
15
+ def test_code_directive_covers_async_and_react_safety(self):
16
+ directive = LLMSelectionMixin._FORMAT_DIRECTIVE_CODE
17
+
18
+ self.assertIn("async", directive)
19
+ self.assertIn("await", directive)
20
+ self.assertIn("try/catch", directive)
21
+ self.assertIn("Promise.allSettled", directive)
22
+ self.assertIn("AbortController", directive)
23
+ self.assertIn("return () =>", directive)
24
+
25
+
26
+ if __name__ == "__main__":
27
+ unittest.main()
tests/test_cognitive_gaps.py CHANGED
@@ -24,7 +24,8 @@ if _BACKEND not in sys.path:
24
  sys.path.insert(0, _BACKEND)
25
 
26
  def _run(coro):
27
- return asyncio.get_event_loop().run_until_complete(coro)
 
28
 
29
 
30
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -642,7 +643,9 @@ class TestCOG5WiringInUnifiedLoop(unittest.TestCase):
642
  """COG-5 wiring: in caso di drift, il messaggio viene aggiunto a exec_warn."""
643
  idx = self.src.find("goal_drift_detector")
644
  self.assertGreater(idx, 0)
645
- block = self.src[idx: idx + 800]
 
 
646
  self.assertIn("exec_warn.append", block)
647
 
648
  def test_cog5_is_non_blocking(self):
@@ -650,7 +653,9 @@ class TestCOG5WiringInUnifiedLoop(unittest.TestCase):
650
  idx = self.src.find("goal_drift_detector")
651
  self.assertGreater(idx, 0)
652
  # La try/except deve precedere l'import
653
- pre_block = self.src[max(0, idx - 200): idx + 800]
 
 
654
  self.assertIn("except Exception as _cog5_err", pre_block)
655
 
656
  def test_cog5_marker_in_source(self):
 
24
  sys.path.insert(0, _BACKEND)
25
 
26
  def _run(coro):
27
+ """Esegue una coroutine anche quando Python non ha un event loop corrente."""
28
+ return asyncio.run(coro)
29
 
30
 
31
  # ═══════════════════════════════════════════════════════════════════════════════
 
643
  """COG-5 wiring: in caso di drift, il messaggio viene aggiunto a exec_warn."""
644
  idx = self.src.find("goal_drift_detector")
645
  self.assertGreater(idx, 0)
646
+ # Il blocco COG-5 può crescere con il logging diagnostico: non usare
647
+ # una finestra corta che tronca l'append effettivo.
648
+ block = self.src[idx: idx + 2200]
649
  self.assertIn("exec_warn.append", block)
650
 
651
  def test_cog5_is_non_blocking(self):
 
653
  idx = self.src.find("goal_drift_detector")
654
  self.assertGreater(idx, 0)
655
  # La try/except deve precedere l'import
656
+ # L'import e il relativo guard devono restare nello stesso blocco COG-5;
657
+ # la finestra include anche il logging aggiunto dopo il fix originale.
658
+ pre_block = self.src[max(0, idx - 500): idx + 2200]
659
  self.assertIn("except Exception as _cog5_err", pre_block)
660
 
661
  def test_cog5_marker_in_source(self):
tests/test_engineering_state.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Focused P0 tests for EngineeringState's safety and rollout contract."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import sys
6
+ import unittest
7
+ from unittest.mock import patch
8
+
9
+ _BACKEND = os.path.join(os.path.dirname(__file__), "..")
10
+ if _BACKEND not in sys.path:
11
+ sys.path.insert(0, _BACKEND)
12
+
13
+ from agents.engineering_state import ( # noqa: E402
14
+ EngineeringState,
15
+ EngineeringStateConfig,
16
+ EngineeringStateMode,
17
+ SCHEMA_VERSION,
18
+ redact_text,
19
+ )
20
+
21
+
22
+ class TestEngineeringState(unittest.TestCase):
23
+ def test_default_rollout_is_authoritative_and_invalid_mode_fails_closed(self) -> None:
24
+ with patch.dict(os.environ, {}, clear=True):
25
+ self.assertEqual(EngineeringStateConfig.from_env().mode, EngineeringStateMode.AUTHORITATIVE)
26
+ with patch.dict(os.environ, {"ENGINEERING_STATE_MODE": "unsafe"}, clear=False):
27
+ self.assertEqual(EngineeringStateConfig.from_env().mode, EngineeringStateMode.OFF)
28
+
29
+ def test_redaction_removes_common_credentials(self) -> None:
30
+ value = "Authorization: Bearer abcdefghijkl token=ghp_1234567890abcdef hf_1234567890"
31
+ result = redact_text(value)
32
+ self.assertNotIn("abcdefghijkl", result)
33
+ self.assertNotIn("ghp_1234567890abcdef", result)
34
+ self.assertNotIn("hf_1234567890", result)
35
+ self.assertIn("[REDACTED]", result)
36
+
37
+ def test_transitions_are_validated_and_idempotent(self) -> None:
38
+ state = EngineeringState.start("build a safe agent", run_id="run-1", now_ms=100)
39
+ self.assertTrue(state.transition("CLASSIFYING", now_ms=101))
40
+ self.assertFalse(state.transition("CLASSIFYING", now_ms=102))
41
+ with self.assertRaises(ValueError):
42
+ state.transition("IDLE", now_ms=103)
43
+ self.assertEqual(state.revision, 1)
44
+ self.assertEqual(state.sequence, 1)
45
+
46
+ def test_round_trip_is_bounded_and_does_not_store_raw_goal(self) -> None:
47
+ goal = "use token=super-secret-value to build this agent"
48
+ state = EngineeringState.start(goal, run_id="run-2", session_id="session-2", now_ms=100)
49
+ for target in ("CLASSIFYING", "THINKING", "COMPLETED"):
50
+ state.transition(target, now_ms=101)
51
+ snapshot = state.snapshot()
52
+ restored = EngineeringState.from_snapshot(snapshot)
53
+ self.assertEqual(restored.snapshot(), snapshot)
54
+ self.assertEqual(snapshot["schema_version"], SCHEMA_VERSION)
55
+ self.assertNotIn("super-secret-value", str(snapshot))
56
+ self.assertLessEqual(len(snapshot["history"]), 64)
57
+
58
+ def test_corrupt_schema_and_revision_are_rejected(self) -> None:
59
+ state = EngineeringState.start("goal", run_id="run-3")
60
+ snapshot = state.snapshot()
61
+ snapshot["schema_version"] = 999
62
+ with self.assertRaises(ValueError):
63
+ EngineeringState.from_snapshot(snapshot)
64
+ snapshot = state.snapshot()
65
+ snapshot["revision"] = -1
66
+ with self.assertRaises(ValueError):
67
+ EngineeringState.from_snapshot(snapshot)
68
+
69
+ def test_canary_selection_is_deterministic_and_requires_session(self) -> None:
70
+ config = EngineeringStateConfig(EngineeringStateMode.CANARY, 0.5)
71
+ self.assertFalse(config.selects_canary("run", ""))
72
+ self.assertEqual(
73
+ config.selects_canary("run", "session"),
74
+ config.selects_canary("run", "session"),
75
+ )
76
+
77
+ def test_resume_normalizes_terminal_state_and_preserves_history(self) -> None:
78
+ state = EngineeringState.start("resume this task", run_id="run-4", session_id="session-4")
79
+ for target in ("CLASSIFYING", "THINKING", "COMPLETED"):
80
+ state.transition(target)
81
+ history_before_resume = list(state.history)
82
+
83
+ state.prepare_for_resume()
84
+
85
+ self.assertEqual(state.current_state, "IDLE")
86
+ self.assertEqual(state.status, "active")
87
+ self.assertEqual(state.history[:len(history_before_resume)], history_before_resume)
88
+ self.assertTrue(any("resume normalized state to IDLE" in item for item in state.diagnostics))
89
+
90
+
91
+ if __name__ == "__main__":
92
+ unittest.main(verbosity=2)