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.
- infralab/__init__.py +24 -0
- infralab/agents/__init__.py +1 -0
- infralab/agents/adapters.py +61 -0
- infralab/agents/agentic.py +350 -0
- infralab/agents/basic.py +354 -0
- infralab/agents/copilot.py +116 -0
- infralab/agents/legacy.py +1116 -0
- infralab/agents/protocol.py +375 -0
- infralab/agents/sandbox.py +448 -0
- infralab/agents/scripted.py +112 -0
- infralab/analysis.py +352 -0
- infralab/api.py +514 -0
- infralab/assets/__init__.py +14 -0
- infralab/assets/base.py +83 -0
- infralab/assets/bridges/__init__.py +44 -0
- infralab/assets/bridges/limit_states.py +102 -0
- infralab/assets/bridges/profiles.py +54 -0
- infralab/assets/buildings/__init__.py +54 -0
- infralab/assets/buildings/limit_states.py +161 -0
- infralab/assets/buildings/profiles.py +80 -0
- infralab/assets/registry.py +26 -0
- infralab/assets/retaining_walls/__init__.py +50 -0
- infralab/assets/retaining_walls/limit_states.py +122 -0
- infralab/assets/retaining_walls/profiles.py +59 -0
- infralab/assets/solar_trackers/__init__.py +52 -0
- infralab/assets/solar_trackers/limit_states.py +127 -0
- infralab/assets/solar_trackers/profiles.py +79 -0
- infralab/assets/wind_turbines/__init__.py +57 -0
- infralab/assets/wind_turbines/limit_states.py +162 -0
- infralab/assets/wind_turbines/profiles.py +88 -0
- infralab/benchmarks/__init__.py +0 -0
- infralab/benchmarks/regbench/configurations.yaml +602 -0
- infralab/benchmarks/regbench/degradation/markov_nbi.py +16 -0
- infralab/benchmarks/regbench/degradation/power.py +11 -0
- infralab/benchmarks/regbench/laws/D0.yaml +22 -0
- infralab/benchmarks/regbench/laws/D1.yaml +24 -0
- infralab/benchmarks/regbench/laws/D2.yaml +26 -0
- infralab/benchmarks/regbench/laws/D3.yaml +27 -0
- infralab/benchmarks/regbench/laws/G0.yaml +33 -0
- infralab/benchmarks/regbench/laws/G1.yaml +36 -0
- infralab/benchmarks/regbench/laws/G2.yaml +37 -0
- infralab/benchmarks/regbench/laws/G3.yaml +40 -0
- infralab/benchmarks/regbench/laws/J0.yaml +33 -0
- infralab/benchmarks/regbench/laws/J1.yaml +36 -0
- infralab/benchmarks/regbench/laws/K0.yaml +24 -0
- infralab/benchmarks/regbench/laws/N24.yaml +24 -0
- infralab/benchmarks/regbench/scenarios/aged-147.json +1 -0
- infralab/benchmarks/regbench/scenarios/aged-30.json +1 -0
- infralab/benchmarks/scenarios.json +1254 -0
- infralab/calibration_profiles.py +287 -0
- infralab/cli.py +73 -0
- infralab/codes/__init__.py +14 -0
- infralab/codes/base.py +45 -0
- infralab/codes/frameworks.py +81 -0
- infralab/codes/registry.py +24 -0
- infralab/core/__init__.py +0 -0
- infralab/core/degradation.py +57 -0
- infralab/core/environment.py +3614 -0
- infralab/core/hazards/__init__.py +0 -0
- infralab/core/hazards/earthquake.py +371 -0
- infralab/core/hazards/progressive_events.py +1071 -0
- infralab/core/hazards/regional_events.py +399 -0
- infralab/core/hazards/seismic_hazard.py +72 -0
- infralab/core/hazards/sudden_damage.py +633 -0
- infralab/core/inspection.py +54 -0
- infralab/core/reliability.py +687 -0
- infralab/data/__init__.py +0 -0
- infralab/data/excel_import.py +232 -0
- infralab/data/nbi.py +265 -0
- infralab/data/nbi_calibration.py +361 -0
- infralab/lab/__init__.py +3 -0
- infralab/lab/__main__.py +3 -0
- infralab/lab/arena.py +477 -0
- infralab/lab/build.py +66 -0
- infralab/lab/cli.py +79 -0
- infralab/lab/hydraulic_rate.py +90 -0
- infralab/lab/io.py +58 -0
- infralab/lab/leak_check.py +1047 -0
- infralab/lab/provenance.py +40 -0
- infralab/lab/runner.py +192 -0
- infralab/lab/spec.py +152 -0
- infralab/llm/__init__.py +2 -0
- infralab/llm/anthropic_backend.py +53 -0
- infralab/llm/openai_backend.py +58 -0
- infralab/llm/player.py +143 -0
- infralab/mcp_server.py +132 -0
- infralab/persistence.py +619 -0
- infralab/plugins/__init__.py +19 -0
- infralab/plugins/loader.py +127 -0
- infralab/plugins/registry.py +116 -0
- infralab/plugins/templates.py +88 -0
- infralab/regimes/__init__.py +16 -0
- infralab/regimes/base.py +350 -0
- infralab/regimes/builtin/hyp_risk_based_existing.yaml +21 -0
- infralab/regimes/builtin/it_lg2020_forced.yaml +19 -0
- infralab/regimes/builtin/it_lg2020_sanctioned.yaml +25 -0
- infralab/regimes/builtin/it_lg2020_signal.yaml +20 -0
- infralab/regimes/registry.py +66 -0
- infralab/settings/__init__.py +14 -0
- infralab/settings/config.py +856 -0
- infralab/study/__init__.py +12 -0
- infralab/study/battery.py +67 -0
- infralab/study/outcomes.py +72 -0
- infralab/study/questionnaire.py +122 -0
- infralab/study/stats.py +178 -0
- infralab/toolkit.py +155 -0
- infralab/ui/__init__.py +0 -0
- infralab/ui/auth.py +151 -0
- infralab/ui/gis_bim.py +709 -0
- infralab/ui/pages_legacy.py +2501 -0
- infralab/views.py +120 -0
- infralab-1.0.0.dist-info/METADATA +240 -0
- infralab-1.0.0.dist-info/RECORD +117 -0
- infralab-1.0.0.dist-info/WHEEL +5 -0
- infralab-1.0.0.dist-info/entry_points.txt +3 -0
- infralab-1.0.0.dist-info/licenses/LICENSE +21 -0
- 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))
|