infralab 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. infralab/__init__.py +24 -0
  2. infralab/agents/__init__.py +1 -0
  3. infralab/agents/adapters.py +61 -0
  4. infralab/agents/agentic.py +350 -0
  5. infralab/agents/basic.py +354 -0
  6. infralab/agents/copilot.py +116 -0
  7. infralab/agents/legacy.py +1116 -0
  8. infralab/agents/protocol.py +375 -0
  9. infralab/agents/sandbox.py +448 -0
  10. infralab/agents/scripted.py +112 -0
  11. infralab/analysis.py +352 -0
  12. infralab/api.py +514 -0
  13. infralab/assets/__init__.py +14 -0
  14. infralab/assets/base.py +83 -0
  15. infralab/assets/bridges/__init__.py +44 -0
  16. infralab/assets/bridges/limit_states.py +102 -0
  17. infralab/assets/bridges/profiles.py +54 -0
  18. infralab/assets/buildings/__init__.py +54 -0
  19. infralab/assets/buildings/limit_states.py +161 -0
  20. infralab/assets/buildings/profiles.py +80 -0
  21. infralab/assets/registry.py +26 -0
  22. infralab/assets/retaining_walls/__init__.py +50 -0
  23. infralab/assets/retaining_walls/limit_states.py +122 -0
  24. infralab/assets/retaining_walls/profiles.py +59 -0
  25. infralab/assets/solar_trackers/__init__.py +52 -0
  26. infralab/assets/solar_trackers/limit_states.py +127 -0
  27. infralab/assets/solar_trackers/profiles.py +79 -0
  28. infralab/assets/wind_turbines/__init__.py +57 -0
  29. infralab/assets/wind_turbines/limit_states.py +162 -0
  30. infralab/assets/wind_turbines/profiles.py +88 -0
  31. infralab/benchmarks/__init__.py +0 -0
  32. infralab/benchmarks/regbench/configurations.yaml +602 -0
  33. infralab/benchmarks/regbench/degradation/markov_nbi.py +16 -0
  34. infralab/benchmarks/regbench/degradation/power.py +11 -0
  35. infralab/benchmarks/regbench/laws/D0.yaml +22 -0
  36. infralab/benchmarks/regbench/laws/D1.yaml +24 -0
  37. infralab/benchmarks/regbench/laws/D2.yaml +26 -0
  38. infralab/benchmarks/regbench/laws/D3.yaml +27 -0
  39. infralab/benchmarks/regbench/laws/G0.yaml +33 -0
  40. infralab/benchmarks/regbench/laws/G1.yaml +36 -0
  41. infralab/benchmarks/regbench/laws/G2.yaml +37 -0
  42. infralab/benchmarks/regbench/laws/G3.yaml +40 -0
  43. infralab/benchmarks/regbench/laws/J0.yaml +33 -0
  44. infralab/benchmarks/regbench/laws/J1.yaml +36 -0
  45. infralab/benchmarks/regbench/laws/K0.yaml +24 -0
  46. infralab/benchmarks/regbench/laws/N24.yaml +24 -0
  47. infralab/benchmarks/regbench/scenarios/aged-147.json +1 -0
  48. infralab/benchmarks/regbench/scenarios/aged-30.json +1 -0
  49. infralab/benchmarks/scenarios.json +1254 -0
  50. infralab/calibration_profiles.py +287 -0
  51. infralab/cli.py +73 -0
  52. infralab/codes/__init__.py +14 -0
  53. infralab/codes/base.py +45 -0
  54. infralab/codes/frameworks.py +81 -0
  55. infralab/codes/registry.py +24 -0
  56. infralab/core/__init__.py +0 -0
  57. infralab/core/degradation.py +57 -0
  58. infralab/core/environment.py +3614 -0
  59. infralab/core/hazards/__init__.py +0 -0
  60. infralab/core/hazards/earthquake.py +371 -0
  61. infralab/core/hazards/progressive_events.py +1071 -0
  62. infralab/core/hazards/regional_events.py +399 -0
  63. infralab/core/hazards/seismic_hazard.py +72 -0
  64. infralab/core/hazards/sudden_damage.py +633 -0
  65. infralab/core/inspection.py +54 -0
  66. infralab/core/reliability.py +687 -0
  67. infralab/data/__init__.py +0 -0
  68. infralab/data/excel_import.py +232 -0
  69. infralab/data/nbi.py +265 -0
  70. infralab/data/nbi_calibration.py +361 -0
  71. infralab/lab/__init__.py +3 -0
  72. infralab/lab/__main__.py +3 -0
  73. infralab/lab/arena.py +477 -0
  74. infralab/lab/build.py +66 -0
  75. infralab/lab/cli.py +79 -0
  76. infralab/lab/hydraulic_rate.py +90 -0
  77. infralab/lab/io.py +58 -0
  78. infralab/lab/leak_check.py +1047 -0
  79. infralab/lab/provenance.py +40 -0
  80. infralab/lab/runner.py +192 -0
  81. infralab/lab/spec.py +152 -0
  82. infralab/llm/__init__.py +2 -0
  83. infralab/llm/anthropic_backend.py +53 -0
  84. infralab/llm/openai_backend.py +58 -0
  85. infralab/llm/player.py +143 -0
  86. infralab/mcp_server.py +132 -0
  87. infralab/persistence.py +619 -0
  88. infralab/plugins/__init__.py +19 -0
  89. infralab/plugins/loader.py +127 -0
  90. infralab/plugins/registry.py +116 -0
  91. infralab/plugins/templates.py +88 -0
  92. infralab/regimes/__init__.py +16 -0
  93. infralab/regimes/base.py +350 -0
  94. infralab/regimes/builtin/hyp_risk_based_existing.yaml +21 -0
  95. infralab/regimes/builtin/it_lg2020_forced.yaml +19 -0
  96. infralab/regimes/builtin/it_lg2020_sanctioned.yaml +25 -0
  97. infralab/regimes/builtin/it_lg2020_signal.yaml +20 -0
  98. infralab/regimes/registry.py +66 -0
  99. infralab/settings/__init__.py +14 -0
  100. infralab/settings/config.py +856 -0
  101. infralab/study/__init__.py +12 -0
  102. infralab/study/battery.py +67 -0
  103. infralab/study/outcomes.py +72 -0
  104. infralab/study/questionnaire.py +122 -0
  105. infralab/study/stats.py +178 -0
  106. infralab/toolkit.py +155 -0
  107. infralab/ui/__init__.py +0 -0
  108. infralab/ui/auth.py +151 -0
  109. infralab/ui/gis_bim.py +709 -0
  110. infralab/ui/pages_legacy.py +2501 -0
  111. infralab/views.py +120 -0
  112. infralab-1.0.0.dist-info/METADATA +240 -0
  113. infralab-1.0.0.dist-info/RECORD +117 -0
  114. infralab-1.0.0.dist-info/WHEEL +5 -0
  115. infralab-1.0.0.dist-info/entry_points.txt +3 -0
  116. infralab-1.0.0.dist-info/licenses/LICENSE +21 -0
  117. infralab-1.0.0.dist-info/top_level.txt +1 -0
infralab/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """InfraLab — laboratorio virtuale per la gestione di asset infrastrutturali.
2
+
3
+ Pacchetto nato dal refactoring di BridgeManager: motore di affidabilità
4
+ FOSM/Bayesiano generico, simulatore a turni con degrado stocastico, agenti
5
+ decisionali (euristici, ottimizzazione, LLM) e UI Streamlit per esperimenti
6
+ con soggetti umani e campagne computazionali.
7
+ """
8
+
9
+ __version__ = "1.0.0"
10
+
11
+ # API pubblica della libreria (infralab.api): import pigri, cosi' `import infralab` resta leggero.
12
+ _API = ("make", "run", "actions", "compare_laws", "scenarios", "load_scenario", "laws", "describe_law", "configurations",
13
+ "configuration", "degradation_laws", "resolve_degradation", "policies", "basic_policies", "policy_config", "child_seeds", "Env")
14
+
15
+
16
+ def __getattr__(name):
17
+ if name in _API:
18
+ from infralab import api
19
+ return getattr(api, name)
20
+ raise AttributeError(f"module 'infralab' has no attribute {name!r}")
21
+
22
+
23
+ def __dir__():
24
+ return sorted(list(globals()) + list(_API))
@@ -0,0 +1 @@
1
+ from infralab.agents.legacy import * # noqa: F401,F403
@@ -0,0 +1,61 @@
1
+ """Adapter tra gli agenti legacy (AgentInterface.decide(env)) e il protocollo gym-like.
2
+
3
+ Gli agenti storici decidono leggendo l'intero BridgeEnv; il protocollo passa
4
+ l'osservazione e mette l'ambiente in info["env"] al reset. L'adapter conserva
5
+ quel riferimento e delega a decide().
6
+
7
+ Con policy_sandbox=True l'adapter (lato harness) resta l'unico a tenere il motore:
8
+ alla policy passa, a ogni decisione, un PlanningHandle nuovo (infralab.agents.sandbox).
9
+ Un accesso vietato (SandboxViolation) non ferma la partita: la decisione del
10
+ trimestre diventa "nessuna azione" e l'accesso e' registrato in `sandbox_violations`.
11
+ """
12
+ from typing import Any, Dict, List
13
+
14
+ from infralab.agents.legacy import AgentInterface, agent_factory
15
+ from infralab.agents.sandbox import PlanningHandle, SandboxViolation, sandbox_enabled
16
+
17
+
18
+ class LegacyAgentAdapter:
19
+ """Avvolge un AgentInterface legacy nel protocollo Agent (reset/act)."""
20
+
21
+ def __init__(self, legacy_agent: AgentInterface, **decide_kwargs: Any):
22
+ self.legacy_agent = legacy_agent
23
+ self.decide_kwargs = decide_kwargs
24
+ self._env = None
25
+ self.last_rationale: Any = None
26
+ # Diagnostica del sandbox: [{"quarter", "access"}] (vuota senza sandbox).
27
+ self.sandbox_violations: List[Dict[str, Any]] = []
28
+
29
+ @property
30
+ def name(self) -> str:
31
+ return self.legacy_agent.name
32
+
33
+ def reset(self, observation: Dict[str, Any], info: Dict[str, Any]) -> None:
34
+ self._env = info.get("env")
35
+ if self._env is None:
36
+ raise ValueError("LegacyAgentAdapter richiede info['env'] al reset.")
37
+
38
+ def act(self, observation: Dict[str, Any]) -> Dict[str, Any]:
39
+ if self._env is None:
40
+ raise RuntimeError("Chiamare reset() prima di act().")
41
+ if not sandbox_enabled(self._env):
42
+ plan, rationale = self.legacy_agent.decide(self._env, **self.decide_kwargs)
43
+ self.last_rationale = rationale
44
+ return plan or {}
45
+ handle = PlanningHandle.build(self._env, self.name)
46
+ try:
47
+ plan, rationale = self.legacy_agent.decide(handle, **self.decide_kwargs)
48
+ except SandboxViolation as e:
49
+ plan, rationale = {}, ("sandbox_violation", str(e))
50
+ for access in handle.violations:
51
+ self.sandbox_violations.append({"quarter": handle.quarter, "access": access})
52
+ self.last_rationale = rationale
53
+ return plan if isinstance(plan, dict) else {}
54
+
55
+
56
+ def make_agent(name: str, config: Dict[str, Any], **decide_kwargs: Any) -> LegacyAgentAdapter:
57
+ """Costruisce un agente protocol-compatibile dalla config legacy.
58
+
59
+ config: {"type": "Euristico" | "LLM" | "GA Orizzonte Variabile" | ...}
60
+ """
61
+ return LegacyAgentAdapter(agent_factory(name, config), **decide_kwargs)
@@ -0,0 +1,350 @@
1
+ """Agente LLM *agentico*: decide scrivendo ed eseguendo codice sulla simulazione.
2
+
3
+ Differenza rispetto a LLMAgent (risposta diretta): questo agente entra in un
4
+ loop ReAct con un ambiente di esecuzione Python sandboxato in cui può
5
+ - ispezionare lo stato (`state()`, `feasible_actions()`),
6
+ - CLONARE la simulazione e fare rollout dei propri piani (`simulate(...)`),
7
+ - costruirsi ottimizzazioni proprie (ricerche greedy, enumerazioni, euristiche)
8
+ prima di impegnarsi in un piano. Il rollout avviene su una copia serializzata
9
+ dell'ambiente (`to_dict`/`from_dict`): l'ambiente reale non viene mai toccato.
10
+
11
+ Protocollo di risposta del modello (SOLO JSON):
12
+ {"action": "run_code", "code": "<python>"} -> esegue e restituisce l'output
13
+ {"action": "final_plan", "plan": {...}, "explanation": "..."}
14
+ Budget: al massimo `max_rounds` esecuzioni di codice per decisione; timeout per
15
+ esecuzione; output troncato. Al termine del budget viene forzato il piano.
16
+
17
+ Sicurezza (sandbox best-effort per un contesto di ricerca):
18
+ - builtins whitelist (niente open/exec/eval), import consentiti solo da
19
+ ALLOWED_IMPORTS (math, statistics, itertools, random, json, heapq),
20
+ - nessun accesso a file/rete, print catturato, esecuzione in thread con timeout.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import builtins as _builtins
25
+ import io
26
+ import json
27
+ import threading
28
+ from typing import Any, Callable, Dict, List, Optional, Tuple
29
+
30
+ from infralab.agents.legacy import LLMAgent, NumpyJSONEncoder
31
+
32
+ ALLOWED_IMPORTS = {"math", "statistics", "itertools", "random", "json", "heapq", "functools", "collections"}
33
+
34
+ _SAFE_BUILTIN_NAMES = [
35
+ "abs", "all", "any", "bool", "dict", "divmod", "enumerate", "filter", "float",
36
+ "int", "isinstance", "len", "list", "map", "max", "min", "pow", "range",
37
+ "repr", "reversed", "round", "set", "sorted", "str", "sum", "tuple", "zip",
38
+ "ValueError", "KeyError", "TypeError", "Exception", "StopIteration",
39
+ ]
40
+
41
+ MAX_OUTPUT_CHARS = 4000
42
+ CODE_TIMEOUT_S = 15.0
43
+
44
+
45
+ def _safe_import(name, globals=None, locals=None, fromlist=(), level=0):
46
+ root = name.split(".")[0]
47
+ if root not in ALLOWED_IMPORTS:
48
+ raise ImportError(f"Import non consentito nella sandbox: {name!r} (consentiti: {sorted(ALLOWED_IMPORTS)})")
49
+ return __import__(name, globals, locals, fromlist, level)
50
+
51
+
52
+ class CodeSandbox:
53
+ """Esegue codice Python con builtins ristretti e tool di simulazione."""
54
+
55
+ def __init__(self, tools: Dict[str, Callable]):
56
+ safe = {n: getattr(_builtins, n) for n in _SAFE_BUILTIN_NAMES}
57
+ safe["__import__"] = _safe_import
58
+ self._base_globals: Dict[str, Any] = {"__builtins__": safe, **tools}
59
+
60
+ def run(self, code: str, timeout_s: float = CODE_TIMEOUT_S) -> str:
61
+ buffer = io.StringIO()
62
+
63
+ def _print(*args, **kwargs):
64
+ kwargs.pop("file", None)
65
+ print(*args, file=buffer, **kwargs)
66
+
67
+ env = dict(self._base_globals)
68
+ env["print"] = _print
69
+ result: Dict[str, Any] = {}
70
+
71
+ def _target():
72
+ try:
73
+ exec(compile(code, "<agent_code>", "exec"), env) # noqa: S102 - sandboxata
74
+ result["ok"] = True
75
+ except Exception as e: # noqa: BLE001 - l'errore torna al modello
76
+ result["error"] = f"{type(e).__name__}: {e}"
77
+
78
+ thread = threading.Thread(target=_target, daemon=True)
79
+ thread.start()
80
+ thread.join(timeout_s)
81
+ if thread.is_alive():
82
+ return f"[TIMEOUT dopo {timeout_s}s — semplifica il codice]"
83
+ out = buffer.getvalue()
84
+ if "error" in result:
85
+ out += f"\n[ERRORE] {result['error']}"
86
+ if not out.strip():
87
+ out = "[nessun output — usa print(...) per vedere i risultati]"
88
+ if len(out) > MAX_OUTPUT_CHARS:
89
+ out = out[:MAX_OUTPUT_CHARS] + f"\n[output troncato a {MAX_OUTPUT_CHARS} caratteri]"
90
+ return out
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Tool di simulazione (operano su CLONI dell'ambiente)
95
+ # ---------------------------------------------------------------------------
96
+
97
+ def make_sandboxed_tools(handle, agent_name: str) -> Dict[str, Callable]:
98
+ """Tool con policy_sandbox: `handle` e' un PlanningHandle (nessun motore).
99
+
100
+ state() e feasible_actions() leggono l'osservazione sanitizzata; simulate() fa il
101
+ rollout su una copia del mondo del gestore (belief_clone) con futuro casuale
102
+ proprio: non e' chiaroveggente sui sorteggi reali. Le chiusure dei tool tengono
103
+ solo il handle, che non contiene riferimenti al motore."""
104
+ from infralab.agents.scripted import compact_observation
105
+
106
+ def state() -> Dict[str, Any]:
107
+ """Stato compatto: per asset -> condizioni, beta, budget, azioni fattibili (stime del gestore)."""
108
+ c = compact_observation(handle.get_agent_observations(agent_name), digits=(3, 2, 0))
109
+ return {"quarter": c["quarter"], "budget": round(c["budget"]), "emissions_tco2": round(c["emissions_tco2"], 2),
110
+ "assets": [{"id": a["id"], "name": a["name"], "min_condition": a["condition_min"], "beta": a["beta"],
111
+ "annual_pf": a["annual_pf"], "components": a["components"],
112
+ "feasible_actions": {k: round(v) for k, v in a["feasible_actions"].items()}}
113
+ for a in c["assets"]]}
114
+
115
+ def feasible_actions() -> Dict[str, List[str]]:
116
+ """{asset_id: [azioni fattibili adesso]}"""
117
+ return {k: [a for a, i in v.items() if i.get("feasible")] for k, v in handle.feasible_actions().items()}
118
+
119
+ def simulate(plan: Optional[Dict[str, str]] = None, quarters: int = 4,
120
+ plans_by_quarter: Optional[List[Dict[str, str]]] = None) -> Dict[str, Any]:
121
+ """Rollout su una COPIA del mondo del gestore (credenza, futuro casuale proprio)."""
122
+ return _rollout_kpis(handle.planning_clone(), agent_name, plan, quarters, plans_by_quarter)
123
+
124
+ return {"state": state, "feasible_actions": feasible_actions, "simulate": simulate}
125
+
126
+
127
+ def _rollout_kpis(clone, agent_name: str, plan, quarters, plans_by_quarter) -> Dict[str, Any]:
128
+ quarters = max(1, min(int(quarters), 24))
129
+ schedule = plans_by_quarter if plans_by_quarter is not None else [plan or {}]
130
+ for q in range(quarters):
131
+ step_plan = schedule[q] if q < len(schedule) else {}
132
+ clone.step({agent_name: {"maintenance": {str(k): v for k, v in (step_plan or {}).items()}}})
133
+ my_assets = [b for b in clone.bridges.values() if b.manager == agent_name]
134
+ betas = [b.reliability_index for b in my_assets if b.reliability_index is not None]
135
+ return {
136
+ "quarters_simulated": quarters,
137
+ "final_budget": round(clone.budgets.get(agent_name, 0.0)),
138
+ "min_beta": round(min(betas), 3) if betas else None,
139
+ "mean_condition": round(sum(b.min_condition for b in my_assets) / max(1, len(my_assets)), 3),
140
+ "emissions_tco2": round(getattr(clone, "emissions_tco2", {}).get(agent_name, 0.0), 2),
141
+ "still_active": agent_name in clone.active_agents,
142
+ }
143
+
144
+
145
+ def make_simulation_tools(env, agent_name: str) -> Dict[str, Callable]:
146
+ """Costruisce i tool esposti nella sandbox per un dato ambiente/agente."""
147
+ from infralab.agents.sandbox import is_sandboxed
148
+ if is_sandboxed(env):
149
+ return make_sandboxed_tools(env, agent_name)
150
+ from infralab.core.environment import BridgeEnv
151
+
152
+ snapshot = env.to_dict() # stato congelato al momento della decisione
153
+
154
+ def _clone():
155
+ return BridgeEnv.from_dict(json.loads(json.dumps(snapshot, cls=NumpyJSONEncoder)))
156
+
157
+ def state() -> Dict[str, Any]:
158
+ """Stato compatto: per asset -> condizioni, beta, budget, azioni fattibili."""
159
+ assets = []
160
+ for b in env.bridges.values():
161
+ if b.manager != agent_name:
162
+ continue
163
+ feas = env.get_feasible_actions_for_bridge(agent_name, b)
164
+ assets.append({
165
+ "id": b.id,
166
+ "name": b.name,
167
+ "min_condition": round(b.min_condition, 3),
168
+ "beta": None if b.reliability_index is None else round(b.reliability_index, 2),
169
+ "annual_pf": b.annual_failure_probability,
170
+ "components": {n: round(c.current_condition, 3) for n, c in b.components.items()},
171
+ "feasible_actions": {a: round(i["cost"]) for a, i in feas.items() if i["feasible"]},
172
+ })
173
+ return {"quarter": env.quarter, "budget": round(env.budgets.get(agent_name, 0.0)),
174
+ "emissions_tco2": round(getattr(env, "emissions_tco2", {}).get(agent_name, 0.0), 2),
175
+ "assets": assets}
176
+
177
+ def feasible_actions() -> Dict[str, List[str]]:
178
+ """{asset_id: [azioni fattibili adesso]}"""
179
+ out = {}
180
+ for b in env.bridges.values():
181
+ if b.manager != agent_name:
182
+ continue
183
+ feas = env.get_feasible_actions_for_bridge(agent_name, b)
184
+ out[str(b.id)] = [a for a, i in feas.items() if i["feasible"]]
185
+ return out
186
+
187
+ def simulate(plan: Optional[Dict[str, str]] = None, quarters: int = 4,
188
+ plans_by_quarter: Optional[List[Dict[str, str]]] = None) -> Dict[str, Any]:
189
+ """Rollout su un CLONE: applica `plan` al primo trimestre (o la lista
190
+ `plans_by_quarter`) poi avanza senza azioni. Restituisce i KPI finali.
191
+ Non modifica la partita reale."""
192
+ clone = _clone()
193
+ quarters = max(1, min(int(quarters), 24))
194
+ schedule = plans_by_quarter if plans_by_quarter is not None else [plan or {}]
195
+ for q in range(quarters):
196
+ step_plan = schedule[q] if q < len(schedule) else {}
197
+ clone.step({agent_name: {"maintenance": {str(k): v for k, v in (step_plan or {}).items()}}})
198
+ my_assets = [b for b in clone.bridges.values() if b.manager == agent_name]
199
+ betas = [b.reliability_index for b in my_assets if b.reliability_index is not None]
200
+ return {
201
+ "quarters_simulated": quarters,
202
+ "final_budget": round(clone.budgets.get(agent_name, 0.0)),
203
+ "min_beta": round(min(betas), 3) if betas else None,
204
+ "mean_condition": round(sum(b.min_condition for b in my_assets) / max(1, len(my_assets)), 3),
205
+ "emissions_tco2": round(getattr(clone, "emissions_tco2", {}).get(agent_name, 0.0), 2),
206
+ "still_active": agent_name in clone.active_agents,
207
+ }
208
+
209
+ return {"state": state, "feasible_actions": feasible_actions, "simulate": simulate}
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # Agente
214
+ # ---------------------------------------------------------------------------
215
+
216
+ AGENTIC_SYSTEM = "Rispondi SOLO con un oggetto JSON valido, senza testo fuori dal JSON."
217
+
218
+ AGENTIC_INSTRUCTIONS = """Sei un asset manager AGENTICO con profilo '{profile}'. Trimestre {quarter}.
219
+ Puoi SCRIVERE ED ESEGUIRE CODICE PYTHON per analizzare la rete e testare piani
220
+ su una copia della simulazione prima di decidere. Strumenti disponibili nel codice:
221
+
222
+ - state() -> dict: stato dei tuoi asset (condizioni, beta, budget, azioni fattibili con costo)
223
+ - feasible_actions() -> dict: {{asset_id: [azioni fattibili]}}
224
+ - simulate(plan=None, quarters=4, plans_by_quarter=None) -> dict:
225
+ rollout su un CLONE (non tocca la partita reale). plan = {{asset_id: azione}}
226
+ applicato al primo trimestre. Ritorna final_budget, min_beta, mean_condition,
227
+ emissions_tco2, still_active.
228
+ - import consentiti: math, statistics, itertools, random, json, heapq, functools, collections
229
+ - usa print(...) per vedere i risultati.
230
+
231
+ Strategia consigliata: ispeziona lo stato, genera piani candidati, valutali con
232
+ simulate() (es. confronto min_beta/budget/emissioni), scegli il migliore.
233
+ Hai al massimo {rounds_left} esecuzioni di codice rimaste.
234
+
235
+ RISPONDI SOLO JSON, in uno dei due formati:
236
+ {{"action": "run_code", "code": "..."}}
237
+ {{"action": "final_plan", "plan": {{"maintenance": {{"ID_ASSET": "azione"}}}}, "explanation": "..."}}
238
+ """
239
+
240
+
241
+ class AgenticLLMAgent(LLMAgent):
242
+ """LLM con loop agentico: codice + rollout su cloni, poi piano finale.
243
+
244
+ `llm_callable` (test/ricerca): funzione (system, prompt) -> str che
245
+ sostituisce la chiamata al provider — permette policy scriptate e test
246
+ deterministici dell'intero harness senza rete.
247
+ """
248
+
249
+ def __init__(self, name: str, profile: str, provider: str, model: str | None = None,
250
+ max_rounds: int = 3, llm_callable: Optional[Callable[[str, str], str]] = None,
251
+ hf_token: Optional[str] = None):
252
+ super().__init__(name, profile, provider, model=model, hf_token=hf_token)
253
+ self.max_rounds = max_rounds
254
+ self.llm_callable = llm_callable
255
+
256
+ # -- chiamata al modello -------------------------------------------------
257
+ def _chat(self, prompt: str) -> str:
258
+ if self.llm_callable is not None:
259
+ return self.llm_callable(AGENTIC_SYSTEM, prompt)
260
+ import os
261
+
262
+ import openai as _openai
263
+ if self.provider == "OpenAI":
264
+ client = _openai.OpenAI()
265
+ model = self.openai_model
266
+ elif self.provider == "Locale":
267
+ client = _openai.OpenAI(base_url=self.local_base_url,
268
+ api_key=os.environ.get("INFRALAB_LLM_API_KEY", "not-needed"))
269
+ model = self.local_model
270
+ # Qwen3: disattiva il thinking-mode (soft switch) — altrimenti la
271
+ # generazione può richiedere minuti per risposta su hardware locale.
272
+ if model.startswith("qwen3"):
273
+ prompt = prompt + "\n/no_think"
274
+ elif self.provider == "HuggingFace":
275
+ from huggingface_hub import InferenceClient
276
+ token = self.hf_token or os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACEHUB_API_TOKEN")
277
+ client = InferenceClient(token=token)
278
+ response = client.chat.completions.create(
279
+ model=self.hf_model,
280
+ messages=[{"role": "system", "content": AGENTIC_SYSTEM},
281
+ {"role": "user", "content": prompt}],
282
+ temperature=0.3,
283
+ max_tokens=2000,
284
+ )
285
+ return response.choices[0].message.content or ""
286
+ elif self.provider == "Google":
287
+ import google.generativeai as genai
288
+ response = genai.GenerativeModel(self.google_model).generate_content(
289
+ AGENTIC_SYSTEM + "\n\n" + prompt)
290
+ return response.text or ""
291
+ else:
292
+ raise ValueError(f"Provider non supportato: {self.provider}")
293
+ response = client.chat.completions.create(
294
+ model=model,
295
+ messages=[{"role": "system", "content": AGENTIC_SYSTEM},
296
+ {"role": "user", "content": prompt}],
297
+ temperature=0.3,
298
+ )
299
+ return response.choices[0].message.content or ""
300
+
301
+ # -- loop agentico -------------------------------------------------------
302
+ def decide(self, env, **kwargs) -> Tuple[Dict[str, Any], Any]:
303
+ tools = make_simulation_tools(env, self.name)
304
+ sandbox = CodeSandbox(tools)
305
+ obs_state = tools["state"]()
306
+ transcript: List[Dict[str, str]] = []
307
+
308
+ context = (AGENTIC_INSTRUCTIONS.format(profile=self.profile, quarter=env.quarter,
309
+ rounds_left=self.max_rounds)
310
+ + "\nSTATO ATTUALE:\n" + json.dumps(obs_state, indent=1, cls=NumpyJSONEncoder))
311
+
312
+ prompt = context
313
+ code_runs = 0
314
+ # Chiamate LLM totali: esecuzioni di codice + margine per retry JSON e
315
+ # per la risposta finale forzata dopo l'esaurimento del budget.
316
+ max_llm_calls = self.max_rounds + 3
317
+ for _ in range(max_llm_calls):
318
+ try:
319
+ raw = self._chat(prompt)
320
+ except Exception as e: # errore provider: nessuna azione, log
321
+ return {}, ("", f"Errore LLM agentico: {e}")
322
+ try:
323
+ data = self._extract_json(raw)
324
+ except Exception:
325
+ transcript.append({"role": "model", "content": raw[:500]})
326
+ prompt = context + "\n\nLa tua ultima risposta non era JSON valido. Rispondi SOLO JSON."
327
+ continue
328
+
329
+ action = data.get("action")
330
+ if action == "final_plan":
331
+ transcript.append({"role": "model", "content": json.dumps(data)[:800]})
332
+ return data.get("plan", {}), (context, (data, transcript))
333
+
334
+ if action == "run_code" and code_runs < self.max_rounds:
335
+ code = data.get("code", "")
336
+ code_runs += 1
337
+ output = sandbox.run(code)
338
+ transcript.append({"role": "model", "content": code[:800]})
339
+ transcript.append({"role": "sandbox", "content": output[:800]})
340
+ rounds_left = self.max_rounds - code_runs
341
+ prompt = (context
342
+ + f"\n\nESECUZIONE #{code_runs} — codice:\n{code}\n\nOUTPUT:\n{output}"
343
+ + f"\n\nEsecuzioni rimaste: {rounds_left}. "
344
+ "Se hai abbastanza informazioni, rispondi con final_plan.")
345
+ continue
346
+
347
+ # Budget esaurito o azione ignota: forza il piano alla prossima chiamata.
348
+ prompt = context + "\n\nBudget di esecuzioni esaurito: rispondi ORA con final_plan (SOLO JSON)."
349
+
350
+ return {}, (context, ("Budget di round esaurito senza piano", transcript))