packet-tracer-skill 0.1.0
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.
- package/LICENSE +21 -0
- package/LICENSES/LICENSE.Twofish-BSD-3-Clause.txt +29 -0
- package/README.md +687 -0
- package/SKILL.md +221 -0
- package/bin/packet-tracer-skill.js +635 -0
- package/examples/blueprint_minimal.json +46 -0
- package/package.json +42 -0
- package/references/packettracer-sample-catalog.json +21410 -0
- package/references/packettracer-sample-catalog.md +1124 -0
- package/references/pkt-format.md +57 -0
- package/references/xml-skeleton-notes.md +44 -0
- package/requirements-dev.txt +1 -0
- package/requirements.txt +6 -0
- package/scripts/build_sample_catalog.py +65 -0
- package/scripts/donor_diagnostics.py +35 -0
- package/scripts/generate_pkt.py +1264 -0
- package/scripts/install_skill.py +71 -0
- package/scripts/intent_parser.py +712 -0
- package/scripts/packet_tracer_env.py +278 -0
- package/scripts/pkt_builder.py +15 -0
- package/scripts/pkt_codec.py +181 -0
- package/scripts/pkt_editor.py +752 -0
- package/scripts/pkt_transformer.py +541 -0
- package/scripts/sample_catalog.py +385 -0
- package/scripts/sample_selector.py +156 -0
- package/scripts/setup.ps1 +26 -0
- package/scripts/twofish_diagnostics.py +91 -0
- package/scripts/vendor/README.md +46 -0
- package/scripts/vendor/twofish.py +81 -0
- package/scripts/workspace_repair.py +441 -0
- package/templates/pt900/base_empty.xml +21 -0
- package/templates/pt900/device_library/pc.xml +20 -0
- package/templates/pt900/device_library/printer.xml +432 -0
- package/templates/pt900/device_library/router.xml +16 -0
- package/templates/pt900/device_library/switch.xml +38 -0
|
@@ -0,0 +1,1264 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import copy
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import xml.etree.ElementTree as ET
|
|
15
|
+
|
|
16
|
+
from intent_parser import IntentPlan, parse_intent
|
|
17
|
+
from packet_tracer_env import (
|
|
18
|
+
get_packet_tracer_compatibility_donor,
|
|
19
|
+
inspect_packet_tracer_compatibility_donor,
|
|
20
|
+
require_packet_tracer_exe,
|
|
21
|
+
)
|
|
22
|
+
from pkt_builder import build_packet_tracer_xml
|
|
23
|
+
from pkt_codec import decode_pkt_file, decode_pkt_modern, encode_pkt_modern
|
|
24
|
+
from pkt_editor import apply_plan_operations, decode_pkt_to_root, edit_pkt_file, inventory_devices, inventory_links, inventory_root
|
|
25
|
+
from pkt_transformer import transform_from_blueprint
|
|
26
|
+
from sample_catalog import ReferencePattern, load_catalog, load_reference_catalog
|
|
27
|
+
from sample_selector import rank_reference_samples, rank_samples, select_best_sample
|
|
28
|
+
from workspace_repair import inspect_donor_coherence, inspect_workspace_integrity, validate_donor_coherence, validate_workspace_integrity
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
32
|
+
try:
|
|
33
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PlanningError(RuntimeError):
|
|
39
|
+
def __init__(self, message: str, plan: IntentPlan) -> None:
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.plan = plan
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> dict[str, object]:
|
|
44
|
+
return {
|
|
45
|
+
"error": str(self),
|
|
46
|
+
"blocking_gaps": self.plan.blocking_gaps,
|
|
47
|
+
"parse_warnings": self.plan.parse_warnings,
|
|
48
|
+
"device_requirements": self.plan.device_requirements,
|
|
49
|
+
"vlan_ids": self.plan.vlan_ids,
|
|
50
|
+
"topology_requirements": self.plan.topology_requirements,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
STRICT_COMPATIBILITY_GAP = (
|
|
55
|
+
"Strict 9.0 generation requires a compatible local Packet Tracer 9.0 donor lab. "
|
|
56
|
+
"Set PACKET_TRACER_COMPAT_DONOR explicitly or let the repo auto-detect one."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _compat_donor_details() -> tuple[Path | None, str | None]:
|
|
61
|
+
details = inspect_packet_tracer_compatibility_donor()
|
|
62
|
+
return details.resolved_path, details.donor_version
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _apply_prompt_compatibility_requirements(plan: IntentPlan) -> IntentPlan:
|
|
66
|
+
prepared = prepare_generation_plan(plan)
|
|
67
|
+
if prepared.goal != "edit":
|
|
68
|
+
donor, _ = _compat_donor_details()
|
|
69
|
+
if donor is None and STRICT_COMPATIBILITY_GAP not in prepared.blocking_gaps:
|
|
70
|
+
prepared.blocking_gaps.append(STRICT_COMPATIBILITY_GAP)
|
|
71
|
+
return prepared
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _link_schema_summary(root: ET.Element) -> dict[str, object]:
|
|
75
|
+
cable = root.find(".//LINKS/LINK/CABLE")
|
|
76
|
+
if cable is None:
|
|
77
|
+
return {"link_schema_mode": "none", "link_schema_missing_fields": []}
|
|
78
|
+
from_ref = cable.findtext("FROM", default="")
|
|
79
|
+
mode = "save_ref_id" if from_ref.startswith("save-ref-id:") else ("numeric_index" if from_ref.isdigit() else "unknown")
|
|
80
|
+
required = ["FUNCTIONAL", "GEO_VIEW_COLOR", "IS_MANAGED_IN_RACK_VIEW"]
|
|
81
|
+
missing = [tag for tag in required if cable.find(tag) is None]
|
|
82
|
+
if mode == "save_ref_id":
|
|
83
|
+
mode = "save_ref_id_complete" if not missing else "save_ref_id_missing_fields"
|
|
84
|
+
return {"link_schema_mode": mode, "link_schema_missing_fields": missing}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class TopologyPlan:
|
|
89
|
+
topology_archetype: str
|
|
90
|
+
devices: list[dict[str, object]]
|
|
91
|
+
links: list[dict[str, object]]
|
|
92
|
+
layout: dict[str, dict[str, int]]
|
|
93
|
+
port_map: dict[str, list[str]]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class ConfigPlan:
|
|
98
|
+
switch_ops: list[dict[str, object]]
|
|
99
|
+
router_ops: list[dict[str, object]]
|
|
100
|
+
server_ops: list[dict[str, object]]
|
|
101
|
+
wireless_ops: list[dict[str, object]]
|
|
102
|
+
end_device_ops: list[dict[str, object]]
|
|
103
|
+
management_ops: list[dict[str, object]]
|
|
104
|
+
assumptions_used: list[str]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class DonorArchetypePlan:
|
|
109
|
+
compat_donor: str
|
|
110
|
+
donor_capacity: dict[str, object]
|
|
111
|
+
kept_devices: list[str]
|
|
112
|
+
pruned_devices: list[str]
|
|
113
|
+
renamed_devices: list[dict[str, str]]
|
|
114
|
+
mutation_groups: list[dict[str, object]]
|
|
115
|
+
layout_strategy: str
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _estimate_plan(topology_plan: TopologyPlan, config_plan: ConfigPlan) -> dict[str, object]:
|
|
119
|
+
device_count = len(topology_plan.devices)
|
|
120
|
+
link_count = len(topology_plan.links)
|
|
121
|
+
op_count = sum(
|
|
122
|
+
len(bucket)
|
|
123
|
+
for bucket in [
|
|
124
|
+
config_plan.switch_ops,
|
|
125
|
+
config_plan.router_ops,
|
|
126
|
+
config_plan.server_ops,
|
|
127
|
+
config_plan.wireless_ops,
|
|
128
|
+
config_plan.end_device_ops,
|
|
129
|
+
config_plan.management_ops,
|
|
130
|
+
]
|
|
131
|
+
)
|
|
132
|
+
complexity = "small"
|
|
133
|
+
if device_count >= 20 or link_count >= 18 or op_count >= 20:
|
|
134
|
+
complexity = "medium"
|
|
135
|
+
if device_count >= 40 or link_count >= 40 or op_count >= 40:
|
|
136
|
+
complexity = "large"
|
|
137
|
+
return {
|
|
138
|
+
"device_count": device_count,
|
|
139
|
+
"link_count": link_count,
|
|
140
|
+
"config_operation_count": op_count,
|
|
141
|
+
"complexity": complexity,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _preflight_validation(plan: IntentPlan, topology_plan: TopologyPlan, config_plan: ConfigPlan) -> dict[str, object]:
|
|
146
|
+
issues = list(plan.blocking_gaps)
|
|
147
|
+
warnings = list(plan.parse_warnings)
|
|
148
|
+
if topology_plan.topology_archetype == "chain" and len([device for device in topology_plan.devices if _device_kind(device) == "Switch"]) < 2:
|
|
149
|
+
warnings.append("Chain archetype selected with fewer than two switches.")
|
|
150
|
+
if "router_on_a_stick" in plan.capabilities and not any(op.get("op") == "set_subinterface" for op in config_plan.router_ops):
|
|
151
|
+
issues.append("Router-on-a-stick was requested but no router subinterfaces were planned.")
|
|
152
|
+
if "wireless_ap" in plan.capabilities and not any(op.get("op") == "set_ssid" for op in config_plan.wireless_ops):
|
|
153
|
+
warnings.append("Wireless access points are present but no SSID mutation was planned.")
|
|
154
|
+
if plan.device_requirements.get("Printer", 0) and not any(device.get("type") == "Printer" for device in topology_plan.devices):
|
|
155
|
+
issues.append("Prompt requested printers but topology plan did not allocate printer devices.")
|
|
156
|
+
status = "blocked" if issues else ("warning" if warnings else "ok")
|
|
157
|
+
return {
|
|
158
|
+
"status": status,
|
|
159
|
+
"issues": issues,
|
|
160
|
+
"warnings": warnings,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _autofix_summary(plan: IntentPlan, validation: dict[str, object]) -> dict[str, object]:
|
|
165
|
+
applied = list(plan.assumptions_used)
|
|
166
|
+
pending = list(validation.get("issues", []))
|
|
167
|
+
return {
|
|
168
|
+
"applied": applied,
|
|
169
|
+
"pending_manual_input": pending,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _default_name_for_type(device_type: str, index: int) -> str:
|
|
174
|
+
return {
|
|
175
|
+
"Router": f"R{index}",
|
|
176
|
+
"Switch": f"SW{index}",
|
|
177
|
+
"PC": f"PC{index}",
|
|
178
|
+
"Server": f"Server{index}",
|
|
179
|
+
"LightWeightAccessPoint": f"AP{index}",
|
|
180
|
+
"WirelessRouter": f"WRT{index}",
|
|
181
|
+
"Tablet": f"Tablet{index}",
|
|
182
|
+
"Laptop": f"Laptop{index}",
|
|
183
|
+
"Printer": f"Printer{index}",
|
|
184
|
+
"Smartphone": f"Phone{index}",
|
|
185
|
+
}.get(device_type, f"{device_type}{index}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _device_kind(device: dict[str, object]) -> str:
|
|
189
|
+
return str(device.get("type", ""))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _is_host_device(device: dict[str, object]) -> bool:
|
|
193
|
+
return _device_kind(device) in {"PC", "Server", "Printer", "Laptop"}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _is_wireless_client_device(device: dict[str, object]) -> bool:
|
|
197
|
+
return _device_kind(device) in {"Tablet", "Smartphone"}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _router_port(device: dict[str, object], index: int = 1) -> str:
|
|
201
|
+
model = str(device.get("model") or "")
|
|
202
|
+
if model.startswith("2901"):
|
|
203
|
+
return f"GigabitEthernet0/{index - 1}"
|
|
204
|
+
if model.startswith("ISR"):
|
|
205
|
+
return f"GigabitEthernet0/0/{index - 1}"
|
|
206
|
+
return f"FastEthernet0/{index - 1}"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _switch_uplink_port(device: dict[str, object], index: int) -> str:
|
|
210
|
+
model = str(device.get("model") or "")
|
|
211
|
+
if model.startswith("3650"):
|
|
212
|
+
return f"GigabitEthernet1/0/{index}"
|
|
213
|
+
return f"GigabitEthernet0/{index}"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _switch_access_port(index: int) -> str:
|
|
217
|
+
return f"FastEthernet0/{index}"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _host_port(device: dict[str, object]) -> str:
|
|
221
|
+
kind = _device_kind(device)
|
|
222
|
+
if kind in {"Tablet", "Smartphone"}:
|
|
223
|
+
return "Wireless0"
|
|
224
|
+
return "FastEthernet0"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _department_device_name(group_name: str, device_type: str, index: int) -> str:
|
|
228
|
+
suffix = {
|
|
229
|
+
"Switch": "SW",
|
|
230
|
+
"LightWeightAccessPoint": "AP",
|
|
231
|
+
"Printer": "PRN",
|
|
232
|
+
"PC": "PC",
|
|
233
|
+
"Tablet": "TAB",
|
|
234
|
+
"Laptop": "LAP",
|
|
235
|
+
"Server": "SRV",
|
|
236
|
+
"Smartphone": "PH",
|
|
237
|
+
}.get(device_type, device_type.upper())
|
|
238
|
+
return f"{group_name}-{suffix}{index}"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _choose_switch_model(index: int, total_switches: int, uplink_intent: str | None) -> str:
|
|
242
|
+
if get_packet_tracer_compatibility_donor() is not None:
|
|
243
|
+
return "2960-24TT"
|
|
244
|
+
if total_switches > 1 and index == 1:
|
|
245
|
+
return "3650-24PS"
|
|
246
|
+
if uplink_intent == "gigabit" and total_switches == 1:
|
|
247
|
+
return "2960-24TT"
|
|
248
|
+
return "2960-24TT"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _choose_router_model(plan: IntentPlan) -> str:
|
|
252
|
+
if get_packet_tracer_compatibility_donor() is not None:
|
|
253
|
+
return "ISR4331"
|
|
254
|
+
if plan.vlan_ids or plan.uplink_intent == "gigabit" or plan.device_requirements.get("Switch", 0):
|
|
255
|
+
return "2901"
|
|
256
|
+
return "1841"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _append_unique_op(bucket: list[dict[str, object]], operation: dict[str, object]) -> None:
|
|
260
|
+
if operation not in bucket:
|
|
261
|
+
bucket.append(operation)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _copy_plan(plan: IntentPlan) -> IntentPlan:
|
|
265
|
+
return copy.deepcopy(plan)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _choose_topology_archetype(plan: IntentPlan) -> str:
|
|
269
|
+
explicit = str(plan.topology_requirements.get("uplink_topology") or "")
|
|
270
|
+
if explicit == "chain":
|
|
271
|
+
return "chain"
|
|
272
|
+
if plan.department_groups:
|
|
273
|
+
return "chain"
|
|
274
|
+
if plan.network_style == "small_office":
|
|
275
|
+
return "small_office"
|
|
276
|
+
if explicit == "core_switch":
|
|
277
|
+
return "core_access"
|
|
278
|
+
if plan.device_requirements.get("Switch", 0) > 1:
|
|
279
|
+
return "core_access"
|
|
280
|
+
if "wireless_ap" in plan.capabilities and plan.device_requirements.get("Switch", 0) <= 1:
|
|
281
|
+
return "wireless_branch"
|
|
282
|
+
return "star"
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _topology_tags_for_plan(plan: IntentPlan, archetype: str) -> list[str]:
|
|
286
|
+
tags = [archetype]
|
|
287
|
+
if plan.department_groups:
|
|
288
|
+
tags.append("department_lan")
|
|
289
|
+
if plan.vlan_ids and plan.device_requirements.get("Router", 0):
|
|
290
|
+
tags.append("router_on_a_stick")
|
|
291
|
+
if any(cap in plan.capabilities for cap in ["dns", "server_dns", "server_http", "server_ftp"]):
|
|
292
|
+
tags.append("server_services")
|
|
293
|
+
if any(cap in plan.capabilities for cap in ["wireless_ap", "wireless_client"]):
|
|
294
|
+
tags.append("wireless_edge")
|
|
295
|
+
if "acl" in plan.capabilities:
|
|
296
|
+
tags.append("acl_policy")
|
|
297
|
+
return sorted(dict.fromkeys(tags))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _seed_devices_from_plan(plan: IntentPlan) -> list[dict[str, object]]:
|
|
301
|
+
devices = [dict(device) for device in plan.devices]
|
|
302
|
+
current_counts: dict[str, int] = {}
|
|
303
|
+
for device in devices:
|
|
304
|
+
dtype = _device_kind(device)
|
|
305
|
+
current_counts[dtype] = current_counts.get(dtype, 0) + 1
|
|
306
|
+
|
|
307
|
+
total_switches = plan.device_requirements.get("Switch", 0)
|
|
308
|
+
if plan.department_groups:
|
|
309
|
+
for index, group in enumerate(plan.department_groups, start=1):
|
|
310
|
+
switch_name = str(group.get("switch_name") or f"DEPT{index}-SW")
|
|
311
|
+
if not any(str(device.get("name")) == switch_name for device in devices):
|
|
312
|
+
devices.append(
|
|
313
|
+
{
|
|
314
|
+
"name": switch_name,
|
|
315
|
+
"type": "Switch",
|
|
316
|
+
"model": _choose_switch_model(index, max(total_switches, len(plan.department_groups)), plan.uplink_intent),
|
|
317
|
+
"group": group["name"],
|
|
318
|
+
"role": "department-switch",
|
|
319
|
+
}
|
|
320
|
+
)
|
|
321
|
+
for device_type, count in dict(group.get("devices") or {}).items():
|
|
322
|
+
for inner_index in range(1, int(count) + 1):
|
|
323
|
+
name = _department_device_name(str(group["name"]), device_type, inner_index)
|
|
324
|
+
if any(str(device.get("name")) == name for device in devices):
|
|
325
|
+
continue
|
|
326
|
+
entry: dict[str, object] = {"name": name, "type": device_type, "group": group["name"]}
|
|
327
|
+
if device_type == "LightWeightAccessPoint":
|
|
328
|
+
entry["model"] = "AccessPoint-PT" if get_packet_tracer_compatibility_donor() is not None else "LAP-PT"
|
|
329
|
+
devices.append(entry)
|
|
330
|
+
current_counts = {}
|
|
331
|
+
for device in devices:
|
|
332
|
+
dtype = _device_kind(device)
|
|
333
|
+
current_counts[dtype] = current_counts.get(dtype, 0) + 1
|
|
334
|
+
|
|
335
|
+
for device_type, count in plan.device_requirements.items():
|
|
336
|
+
existing = current_counts.get(device_type, 0)
|
|
337
|
+
for next_index in range(existing + 1, count + 1):
|
|
338
|
+
device: dict[str, object] = {
|
|
339
|
+
"name": _default_name_for_type(device_type, next_index),
|
|
340
|
+
"type": device_type,
|
|
341
|
+
}
|
|
342
|
+
if device_type == "Switch":
|
|
343
|
+
device["model"] = _choose_switch_model(next_index, count, plan.uplink_intent)
|
|
344
|
+
elif device_type == "Router":
|
|
345
|
+
device["model"] = _choose_router_model(plan)
|
|
346
|
+
devices.append(device)
|
|
347
|
+
current_counts[device_type] = count
|
|
348
|
+
|
|
349
|
+
archetype = _choose_topology_archetype(plan)
|
|
350
|
+
routers = [device for device in devices if _device_kind(device) == "Router"]
|
|
351
|
+
switches = [device for device in devices if _device_kind(device) == "Switch"]
|
|
352
|
+
hosts = [device for device in devices if _is_host_device(device)]
|
|
353
|
+
|
|
354
|
+
if archetype == "chain" and plan.department_groups:
|
|
355
|
+
if routers:
|
|
356
|
+
routers[0].setdefault("x", 180)
|
|
357
|
+
routers[0].setdefault("y", 120)
|
|
358
|
+
for index, group in enumerate(plan.department_groups):
|
|
359
|
+
base_x = 320 + index * 340
|
|
360
|
+
switch = next((device for device in switches if device.get("group") == group["name"]), None)
|
|
361
|
+
if switch is not None:
|
|
362
|
+
switch.setdefault("x", base_x)
|
|
363
|
+
switch.setdefault("y", 310)
|
|
364
|
+
group_devices = [device for device in devices if device.get("group") == group["name"] and device is not switch]
|
|
365
|
+
aps = [device for device in group_devices if _device_kind(device) == "LightWeightAccessPoint"]
|
|
366
|
+
printers = [device for device in group_devices if _device_kind(device) == "Printer"]
|
|
367
|
+
clients = [device for device in group_devices if _device_kind(device) in {"PC", "Tablet", "Laptop", "Smartphone", "Server"}]
|
|
368
|
+
for ap_index, ap in enumerate(aps):
|
|
369
|
+
ap.setdefault("x", base_x - 70 + ap_index * 120)
|
|
370
|
+
ap.setdefault("y", 120)
|
|
371
|
+
for printer_index, printer in enumerate(printers):
|
|
372
|
+
printer.setdefault("x", base_x - 80 + printer_index * 140)
|
|
373
|
+
printer.setdefault("y", 510)
|
|
374
|
+
for client_index, client in enumerate(clients):
|
|
375
|
+
row = client_index // 2
|
|
376
|
+
column = client_index % 2
|
|
377
|
+
client.setdefault("x", base_x - 120 + column * 160)
|
|
378
|
+
client.setdefault("y", 660 + row * 155)
|
|
379
|
+
else:
|
|
380
|
+
if routers:
|
|
381
|
+
routers[0].setdefault("x", 520)
|
|
382
|
+
routers[0].setdefault("y", 110)
|
|
383
|
+
for index, router in enumerate(routers[1:], start=1):
|
|
384
|
+
router.setdefault("x", 200 + index * 160)
|
|
385
|
+
router.setdefault("y", 110)
|
|
386
|
+
if switches:
|
|
387
|
+
switches[0].setdefault("x", 520)
|
|
388
|
+
switches[0].setdefault("y", 260)
|
|
389
|
+
for index, switch in enumerate(switches[1:], start=1):
|
|
390
|
+
switch.setdefault("x", 220 + (index - 1) * 220)
|
|
391
|
+
switch.setdefault("y", 420)
|
|
392
|
+
for index, host in enumerate(hosts):
|
|
393
|
+
host.setdefault("x", 180 + (index % 6) * 150)
|
|
394
|
+
host.setdefault("y", 610 + (index // 6) * 120)
|
|
395
|
+
for index, device in enumerate(devices):
|
|
396
|
+
device.setdefault("x", 200 + (index % 5) * 150)
|
|
397
|
+
device.setdefault("y", 180 + (index // 5) * 130)
|
|
398
|
+
return devices
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _plan_configs(plan: IntentPlan, devices: list[dict[str, object]]) -> dict[str, object]:
|
|
402
|
+
configs: dict[str, object] = {}
|
|
403
|
+
if plan.topology_requirements.get("needs_dhcp_pool"):
|
|
404
|
+
for device in devices:
|
|
405
|
+
if _device_kind(device) == "Router":
|
|
406
|
+
port = _router_port(device, 1)
|
|
407
|
+
configs[device["name"]] = [
|
|
408
|
+
f"hostname {device['name']}",
|
|
409
|
+
f"interface {port}",
|
|
410
|
+
" ip address 192.168.1.1 255.255.255.0",
|
|
411
|
+
" no shutdown",
|
|
412
|
+
"ip dhcp pool AUTOPOOL",
|
|
413
|
+
" network 192.168.1.0 255.255.255.0",
|
|
414
|
+
" default-router 192.168.1.1",
|
|
415
|
+
"end",
|
|
416
|
+
]
|
|
417
|
+
break
|
|
418
|
+
return configs
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _synthesize_links(plan: IntentPlan, devices: list[dict[str, object]]) -> list[dict[str, object]]:
|
|
422
|
+
if plan.links:
|
|
423
|
+
return list(plan.links)
|
|
424
|
+
|
|
425
|
+
archetype = _choose_topology_archetype(plan)
|
|
426
|
+
routers = [device for device in devices if _device_kind(device) == "Router"]
|
|
427
|
+
switches = [device for device in devices if _device_kind(device) == "Switch"]
|
|
428
|
+
hosts = [device for device in devices if _is_host_device(device)]
|
|
429
|
+
if not switches:
|
|
430
|
+
return []
|
|
431
|
+
|
|
432
|
+
if archetype == "chain":
|
|
433
|
+
links: list[dict[str, object]] = []
|
|
434
|
+
router = routers[0] if routers else None
|
|
435
|
+
ordered_switches = switches
|
|
436
|
+
if plan.department_groups:
|
|
437
|
+
ordered_switches = []
|
|
438
|
+
for group in plan.department_groups:
|
|
439
|
+
switch = next((device for device in switches if device.get("group") == group["name"]), None)
|
|
440
|
+
if switch is not None:
|
|
441
|
+
ordered_switches.append(switch)
|
|
442
|
+
if router and ordered_switches:
|
|
443
|
+
links.append(
|
|
444
|
+
{
|
|
445
|
+
"a": {"dev": ordered_switches[0]["name"], "port": _switch_uplink_port(ordered_switches[0], 1)},
|
|
446
|
+
"b": {"dev": router["name"], "port": _router_port(router, 1)},
|
|
447
|
+
"media": "straight-through",
|
|
448
|
+
}
|
|
449
|
+
)
|
|
450
|
+
for index in range(len(ordered_switches) - 1):
|
|
451
|
+
links.append(
|
|
452
|
+
{
|
|
453
|
+
"a": {"dev": ordered_switches[index]["name"], "port": _switch_uplink_port(ordered_switches[index], 2)},
|
|
454
|
+
"b": {"dev": ordered_switches[index + 1]["name"], "port": _switch_uplink_port(ordered_switches[index + 1], 1)},
|
|
455
|
+
"media": "straight-through",
|
|
456
|
+
}
|
|
457
|
+
)
|
|
458
|
+
access_port_index: dict[str, int] = {str(device["name"]): 1 for device in ordered_switches}
|
|
459
|
+
for device in devices:
|
|
460
|
+
if _is_wireless_client_device(device):
|
|
461
|
+
continue
|
|
462
|
+
if _device_kind(device) not in {"PC", "Server", "Printer", "Laptop", "LightWeightAccessPoint"}:
|
|
463
|
+
continue
|
|
464
|
+
group_name = str(device.get("group") or "")
|
|
465
|
+
switch = next((item for item in ordered_switches if str(item.get("group") or "") == group_name), ordered_switches[0] if ordered_switches else None)
|
|
466
|
+
if switch is None:
|
|
467
|
+
continue
|
|
468
|
+
switch_name = str(switch["name"])
|
|
469
|
+
port_index = access_port_index[switch_name]
|
|
470
|
+
access_port_index[switch_name] += 1
|
|
471
|
+
links.append(
|
|
472
|
+
{
|
|
473
|
+
"a": {"dev": device["name"], "port": _host_port(device)},
|
|
474
|
+
"b": {"dev": switch_name, "port": _switch_access_port(port_index)},
|
|
475
|
+
"media": "straight-through",
|
|
476
|
+
}
|
|
477
|
+
)
|
|
478
|
+
return links
|
|
479
|
+
|
|
480
|
+
core_switch = switches[0]
|
|
481
|
+
access_switches = switches[1:] or [core_switch]
|
|
482
|
+
links: list[dict[str, object]] = []
|
|
483
|
+
uplink_media = "straight-through"
|
|
484
|
+
|
|
485
|
+
if routers:
|
|
486
|
+
router = routers[0]
|
|
487
|
+
links.append(
|
|
488
|
+
{
|
|
489
|
+
"a": {"dev": core_switch["name"], "port": _switch_uplink_port(core_switch, len(access_switches) + 1 if switches[1:] else 1)},
|
|
490
|
+
"b": {"dev": router["name"], "port": _router_port(router, 1)},
|
|
491
|
+
"media": uplink_media,
|
|
492
|
+
}
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
if switches[1:]:
|
|
496
|
+
for index, switch in enumerate(switches[1:], start=1):
|
|
497
|
+
links.append(
|
|
498
|
+
{
|
|
499
|
+
"a": {"dev": core_switch["name"], "port": _switch_uplink_port(core_switch, index)},
|
|
500
|
+
"b": {"dev": switch["name"], "port": _switch_uplink_port(switch, 1)},
|
|
501
|
+
"media": uplink_media,
|
|
502
|
+
}
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
host_port_index: dict[str, int] = {str(device["name"]): 1 for device in access_switches}
|
|
506
|
+
for index, host in enumerate(hosts):
|
|
507
|
+
target_switch = access_switches[index % len(access_switches)]
|
|
508
|
+
switch_name = str(target_switch["name"])
|
|
509
|
+
access_index = host_port_index[switch_name]
|
|
510
|
+
host_port_index[switch_name] += 1
|
|
511
|
+
links.append(
|
|
512
|
+
{
|
|
513
|
+
"a": {"dev": host["name"], "port": _host_port(host)},
|
|
514
|
+
"b": {"dev": switch_name, "port": _switch_access_port(access_index)},
|
|
515
|
+
"media": "straight-through",
|
|
516
|
+
}
|
|
517
|
+
)
|
|
518
|
+
return links
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _synthesize_vlan_and_link_ops(plan: IntentPlan, devices: list[dict[str, object]], links: list[dict[str, object]]) -> None:
|
|
522
|
+
if not plan.vlan_ids:
|
|
523
|
+
return
|
|
524
|
+
|
|
525
|
+
switches = [device for device in devices if _device_kind(device) == "Switch"]
|
|
526
|
+
routers = [device for device in devices if _device_kind(device) == "Router"]
|
|
527
|
+
allowed = list(plan.vlan_ids)
|
|
528
|
+
core_switch = switches[0] if switches else None
|
|
529
|
+
|
|
530
|
+
for switch in switches:
|
|
531
|
+
for vlan_id in plan.vlan_ids:
|
|
532
|
+
_append_unique_op(plan.switch_ops, {"op": "set_vlan", "device": switch["name"], "vlan": vlan_id, "name": f"VLAN{vlan_id}"})
|
|
533
|
+
|
|
534
|
+
if core_switch is not None:
|
|
535
|
+
switch_names = {str(switch["name"]) for switch in switches}
|
|
536
|
+
for link in links:
|
|
537
|
+
left_name = str(link["a"]["dev"])
|
|
538
|
+
right_name = str(link["b"]["dev"])
|
|
539
|
+
left_port = str(link["a"]["port"])
|
|
540
|
+
right_port = str(link["b"]["port"])
|
|
541
|
+
if {left_name, right_name} & switch_names:
|
|
542
|
+
if "GigabitEthernet" in left_port:
|
|
543
|
+
if left_name in switch_names:
|
|
544
|
+
_append_unique_op(plan.switch_ops, {"op": "set_trunk_port", "device": left_name, "port": left_port, "allowed": allowed, "native": None})
|
|
545
|
+
if "GigabitEthernet" in right_port:
|
|
546
|
+
if right_name in switch_names:
|
|
547
|
+
_append_unique_op(plan.switch_ops, {"op": "set_trunk_port", "device": right_name, "port": right_port, "allowed": allowed, "native": None})
|
|
548
|
+
|
|
549
|
+
if routers:
|
|
550
|
+
router = routers[0]
|
|
551
|
+
base_port = _router_port(router, 1)
|
|
552
|
+
for vlan_id in plan.vlan_ids:
|
|
553
|
+
_append_unique_op(
|
|
554
|
+
plan.router_ops,
|
|
555
|
+
{
|
|
556
|
+
"op": "set_subinterface",
|
|
557
|
+
"device": router["name"],
|
|
558
|
+
"subinterface": f"{base_port}.{vlan_id}",
|
|
559
|
+
"vlan": vlan_id,
|
|
560
|
+
"ip": f"192.168.{vlan_id}.1",
|
|
561
|
+
"prefix": 24,
|
|
562
|
+
},
|
|
563
|
+
)
|
|
564
|
+
if plan.topology_requirements.get("needs_dhcp_pool"):
|
|
565
|
+
_append_unique_op(
|
|
566
|
+
plan.router_ops,
|
|
567
|
+
{
|
|
568
|
+
"op": "set_router_dhcp_pool",
|
|
569
|
+
"device": router["name"],
|
|
570
|
+
"name": f"VLAN{vlan_id}",
|
|
571
|
+
"network": f"192.168.{vlan_id}.0",
|
|
572
|
+
"prefix": 24,
|
|
573
|
+
"gateway": f"192.168.{vlan_id}.1",
|
|
574
|
+
"dns": None,
|
|
575
|
+
"start": f"192.168.{vlan_id}.100",
|
|
576
|
+
"max_users": 100,
|
|
577
|
+
},
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
if plan.host_vlan_assignment and not plan.department_groups:
|
|
581
|
+
access_port_links = [link for link in links if "FastEthernet0/" in str(link["b"]["port"]) and _device_kind(next(device for device in devices if device["name"] == link["b"]["dev"])) == "Switch"]
|
|
582
|
+
vlan_queue: list[int] = []
|
|
583
|
+
for vlan_id, count in sorted(plan.host_vlan_assignment.items()):
|
|
584
|
+
vlan_queue.extend([vlan_id] * count)
|
|
585
|
+
for link, vlan_id in zip(access_port_links, vlan_queue):
|
|
586
|
+
_append_unique_op(
|
|
587
|
+
plan.switch_ops,
|
|
588
|
+
{
|
|
589
|
+
"op": "set_access_port",
|
|
590
|
+
"device": str(link["b"]["dev"]),
|
|
591
|
+
"port": str(link["b"]["port"]),
|
|
592
|
+
"vlan": vlan_id,
|
|
593
|
+
},
|
|
594
|
+
)
|
|
595
|
+
if plan.department_groups:
|
|
596
|
+
switch_by_group = {str(device.get("group") or ""): str(device["name"]) for device in switches if device.get("group")}
|
|
597
|
+
for group in plan.department_groups:
|
|
598
|
+
vlan_id = group.get("vlan_id")
|
|
599
|
+
switch_name = switch_by_group.get(str(group["name"]))
|
|
600
|
+
if not vlan_id or not switch_name:
|
|
601
|
+
continue
|
|
602
|
+
for link in links:
|
|
603
|
+
if str(link["b"]["dev"]) != switch_name or "FastEthernet0/" not in str(link["b"]["port"]):
|
|
604
|
+
continue
|
|
605
|
+
if str(link["a"]["dev"]).startswith(str(group["name"])):
|
|
606
|
+
_append_unique_op(
|
|
607
|
+
plan.switch_ops,
|
|
608
|
+
{
|
|
609
|
+
"op": "set_access_port",
|
|
610
|
+
"device": switch_name,
|
|
611
|
+
"port": str(link["b"]["port"]),
|
|
612
|
+
"vlan": int(vlan_id),
|
|
613
|
+
},
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _build_topology_plan(plan: IntentPlan, devices: list[dict[str, object]], links: list[dict[str, object]]) -> TopologyPlan:
|
|
618
|
+
archetype = _choose_topology_archetype(plan)
|
|
619
|
+
layout = {str(device["name"]): {"x": int(device.get("x", 0)), "y": int(device.get("y", 0))} for device in devices}
|
|
620
|
+
port_map: dict[str, list[str]] = {}
|
|
621
|
+
for link in links:
|
|
622
|
+
for endpoint in ["a", "b"]:
|
|
623
|
+
device_name = str(link[endpoint]["dev"])
|
|
624
|
+
port_map.setdefault(device_name, []).append(str(link[endpoint]["port"]))
|
|
625
|
+
return TopologyPlan(
|
|
626
|
+
topology_archetype=archetype,
|
|
627
|
+
devices=devices,
|
|
628
|
+
links=links,
|
|
629
|
+
layout=layout,
|
|
630
|
+
port_map=port_map,
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _build_config_plan(plan: IntentPlan) -> ConfigPlan:
|
|
635
|
+
return ConfigPlan(
|
|
636
|
+
switch_ops=plan.switch_ops,
|
|
637
|
+
router_ops=plan.router_ops,
|
|
638
|
+
server_ops=plan.server_ops,
|
|
639
|
+
wireless_ops=plan.wireless_ops,
|
|
640
|
+
end_device_ops=plan.end_device_ops,
|
|
641
|
+
management_ops=plan.management_ops,
|
|
642
|
+
assumptions_used=plan.assumptions_used,
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _name_sort_key(name: str) -> tuple[object, ...]:
|
|
647
|
+
parts = re.split(r"(\d+)", name)
|
|
648
|
+
key: list[object] = []
|
|
649
|
+
for part in parts:
|
|
650
|
+
if not part:
|
|
651
|
+
continue
|
|
652
|
+
key.append(int(part) if part.isdigit() else part.lower())
|
|
653
|
+
return tuple(key)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _donor_group_prefix(name: str, device_type: str) -> str | None:
|
|
657
|
+
if device_type == "Switch":
|
|
658
|
+
for suffix in ["-SWITCH", "-SW"]:
|
|
659
|
+
if name.upper().endswith(suffix):
|
|
660
|
+
return name[: -len(suffix)]
|
|
661
|
+
if "-" in name and device_type not in {"Router", "Power Distribution Device"}:
|
|
662
|
+
return name.split("-", 1)[0]
|
|
663
|
+
return None
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _collect_donor_groups(root: ET.Element) -> list[dict[str, object]]:
|
|
667
|
+
devices = inventory_devices(root)
|
|
668
|
+
groups: list[dict[str, object]] = []
|
|
669
|
+
for device in devices:
|
|
670
|
+
if device["type"] != "Switch":
|
|
671
|
+
continue
|
|
672
|
+
prefix = _donor_group_prefix(device["name"], device["type"])
|
|
673
|
+
if not prefix:
|
|
674
|
+
continue
|
|
675
|
+
members = [
|
|
676
|
+
candidate
|
|
677
|
+
for candidate in devices
|
|
678
|
+
if candidate["name"] != device["name"]
|
|
679
|
+
and _donor_group_prefix(candidate["name"], candidate["type"]) == prefix
|
|
680
|
+
]
|
|
681
|
+
members_by_type: dict[str, list[dict[str, str]]] = {}
|
|
682
|
+
for member in members:
|
|
683
|
+
members_by_type.setdefault(member["type"], []).append(member)
|
|
684
|
+
for bucket in members_by_type.values():
|
|
685
|
+
bucket.sort(key=lambda item: _name_sort_key(item["name"]))
|
|
686
|
+
groups.append(
|
|
687
|
+
{
|
|
688
|
+
"group_name": prefix,
|
|
689
|
+
"switch": device,
|
|
690
|
+
"members": members,
|
|
691
|
+
"members_by_type": members_by_type,
|
|
692
|
+
}
|
|
693
|
+
)
|
|
694
|
+
return groups
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _target_groups_from_blueprint(plan: IntentPlan, blueprint: dict[str, object]) -> list[dict[str, object]]:
|
|
698
|
+
devices = [dict(device) for device in blueprint.get("devices", [])]
|
|
699
|
+
links = [dict(link) for link in blueprint.get("links", [])]
|
|
700
|
+
switches = [device for device in devices if _device_kind(device) == "Switch"]
|
|
701
|
+
if plan.department_groups:
|
|
702
|
+
result: list[dict[str, object]] = []
|
|
703
|
+
for group in plan.department_groups:
|
|
704
|
+
group_name = str(group["name"])
|
|
705
|
+
switch = next((device for device in switches if str(device.get("group") or "") == group_name), None)
|
|
706
|
+
if switch is None:
|
|
707
|
+
continue
|
|
708
|
+
members = [device for device in devices if str(device.get("group") or "") == group_name and _device_kind(device) != "Switch"]
|
|
709
|
+
result.append({"group_name": group_name, "switch": switch, "members": members})
|
|
710
|
+
return result
|
|
711
|
+
groups: list[dict[str, object]] = []
|
|
712
|
+
by_name = {str(device["name"]): device for device in devices}
|
|
713
|
+
switch_map = {str(device["name"]): {"group_name": str(device["name"]), "switch": device, "members": []} for device in switches}
|
|
714
|
+
host_assignment: dict[str, str] = {}
|
|
715
|
+
for link in links:
|
|
716
|
+
left_name = str(link["a"]["dev"])
|
|
717
|
+
right_name = str(link["b"]["dev"])
|
|
718
|
+
left_type = _device_kind(by_name.get(left_name, {}))
|
|
719
|
+
right_type = _device_kind(by_name.get(right_name, {}))
|
|
720
|
+
if left_type == "Switch" and right_type != "Switch":
|
|
721
|
+
host_assignment[right_name] = left_name
|
|
722
|
+
elif right_type == "Switch" and left_type != "Switch":
|
|
723
|
+
host_assignment[left_name] = right_name
|
|
724
|
+
switch_names = list(switch_map)
|
|
725
|
+
fallback_index = 0
|
|
726
|
+
for device in devices:
|
|
727
|
+
if _device_kind(device) == "Switch":
|
|
728
|
+
continue
|
|
729
|
+
assigned_switch = host_assignment.get(str(device["name"]))
|
|
730
|
+
if assigned_switch is None and switch_names:
|
|
731
|
+
assigned_switch = switch_names[fallback_index % len(switch_names)]
|
|
732
|
+
fallback_index += 1
|
|
733
|
+
if assigned_switch and assigned_switch in switch_map:
|
|
734
|
+
switch_map[assigned_switch]["members"].append(device)
|
|
735
|
+
for switch in switches:
|
|
736
|
+
groups.append(switch_map[str(switch["name"])])
|
|
737
|
+
return groups
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _donor_capacity(root: ET.Element, donor_groups: list[dict[str, object]]) -> dict[str, object]:
|
|
741
|
+
counts: dict[str, int] = {}
|
|
742
|
+
for device in inventory_devices(root):
|
|
743
|
+
counts[device["type"]] = counts.get(device["type"], 0) + 1
|
|
744
|
+
group_counts: list[dict[str, object]] = []
|
|
745
|
+
for group in donor_groups:
|
|
746
|
+
member_counts: dict[str, int] = {}
|
|
747
|
+
for member in group["members"]:
|
|
748
|
+
member_counts[member["type"]] = member_counts.get(member["type"], 0) + 1
|
|
749
|
+
group_counts.append(
|
|
750
|
+
{
|
|
751
|
+
"group_name": group["group_name"],
|
|
752
|
+
"switch": group["switch"]["name"],
|
|
753
|
+
"members": member_counts,
|
|
754
|
+
}
|
|
755
|
+
)
|
|
756
|
+
return {"device_counts": counts, "group_count": len(donor_groups), "groups": group_counts}
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _sanitize_runtime_sections(root: ET.Element) -> None:
|
|
760
|
+
scenario_set = root.find("./SCENARIOSET")
|
|
761
|
+
if scenario_set is not None:
|
|
762
|
+
scenario_set.clear()
|
|
763
|
+
scenario = ET.SubElement(scenario_set, "SCENARIO")
|
|
764
|
+
name = ET.SubElement(scenario, "NAME")
|
|
765
|
+
name.set("translate", "true")
|
|
766
|
+
name.text = "Scenario 0"
|
|
767
|
+
description = ET.SubElement(scenario, "DESCRIPTION")
|
|
768
|
+
description.set("translate", "true")
|
|
769
|
+
command_logs = root.find("./COMMAND_LOGS")
|
|
770
|
+
if command_logs is not None:
|
|
771
|
+
command_logs.clear()
|
|
772
|
+
ceps = root.find("./CEPS")
|
|
773
|
+
if ceps is not None:
|
|
774
|
+
ceps.clear()
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _unexpected_workspace_issues(donor_root: ET.Element, generated_root: ET.Element) -> list[str]:
|
|
778
|
+
donor_result = inspect_workspace_integrity(donor_root)
|
|
779
|
+
generated_result = inspect_workspace_integrity(generated_root)
|
|
780
|
+
donor_issue_set = set(donor_result.blocking_issues)
|
|
781
|
+
return [issue for issue in generated_result.blocking_issues if issue not in donor_issue_set]
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _build_donor_prune_plan(plan: IntentPlan, blueprint: dict[str, object]) -> tuple[IntentPlan, DonorArchetypePlan]:
|
|
785
|
+
compat_donor, _ = _compat_donor_details()
|
|
786
|
+
if compat_donor is None:
|
|
787
|
+
raise PlanningError("Prompt plan is incomplete; generation was skipped.", plan)
|
|
788
|
+
donor_root = decode_pkt_to_root(compat_donor)
|
|
789
|
+
donor_groups = _collect_donor_groups(donor_root)
|
|
790
|
+
target_groups = _target_groups_from_blueprint(plan, blueprint)
|
|
791
|
+
adapted_plan = copy.deepcopy(plan)
|
|
792
|
+
adapted_plan.edit_operations = []
|
|
793
|
+
donor_devices = inventory_devices(donor_root)
|
|
794
|
+
donor_links = inventory_links(donor_root)
|
|
795
|
+
donor_capacity = _donor_capacity(donor_root, donor_groups)
|
|
796
|
+
if len(target_groups) > len(donor_groups):
|
|
797
|
+
gap = f"Compatibility donor supports only {len(donor_groups)} switch groups; requested {len(target_groups)}."
|
|
798
|
+
if gap not in adapted_plan.blocking_gaps:
|
|
799
|
+
adapted_plan.blocking_gaps.append(gap)
|
|
800
|
+
raise PlanningError("Prompt plan is incomplete; generation was skipped.", adapted_plan)
|
|
801
|
+
|
|
802
|
+
kept_devices: set[str] = set()
|
|
803
|
+
parked_devices: list[str] = []
|
|
804
|
+
renamed_devices: list[dict[str, str]] = []
|
|
805
|
+
mutation_groups: list[dict[str, object]] = []
|
|
806
|
+
rename_map: dict[str, str] = {}
|
|
807
|
+
|
|
808
|
+
def keep_name(old_name: str, new_name: str | None = None, x: int | None = None, y: int | None = None) -> None:
|
|
809
|
+
kept_devices.add(old_name)
|
|
810
|
+
final_name = new_name or old_name
|
|
811
|
+
rename_map[old_name] = final_name
|
|
812
|
+
if old_name != final_name:
|
|
813
|
+
adapted_plan.edit_operations.append({"op": "rename_device", "device": old_name, "new_name": final_name})
|
|
814
|
+
renamed_devices.append({"from": old_name, "to": final_name})
|
|
815
|
+
if x is not None and y is not None:
|
|
816
|
+
adapted_plan.edit_operations.append({"op": "reflow_layout", "device": final_name, "x": int(x), "y": int(y)})
|
|
817
|
+
|
|
818
|
+
park_cursor = {"index": 0}
|
|
819
|
+
|
|
820
|
+
def park_device(
|
|
821
|
+
old_name: str,
|
|
822
|
+
anchor_x: int | None = None,
|
|
823
|
+
anchor_y: int | None = None,
|
|
824
|
+
local_index: int | None = None,
|
|
825
|
+
parked_name: str | None = None,
|
|
826
|
+
) -> None:
|
|
827
|
+
if old_name in kept_devices:
|
|
828
|
+
return
|
|
829
|
+
kept_devices.add(old_name)
|
|
830
|
+
final_name = parked_name or old_name
|
|
831
|
+
rename_map[old_name] = final_name
|
|
832
|
+
if old_name != final_name:
|
|
833
|
+
adapted_plan.edit_operations.append({"op": "rename_device", "device": old_name, "new_name": final_name})
|
|
834
|
+
renamed_devices.append({"from": old_name, "to": final_name})
|
|
835
|
+
if local_index is None:
|
|
836
|
+
park_index = park_cursor["index"]
|
|
837
|
+
park_cursor["index"] += 1
|
|
838
|
+
else:
|
|
839
|
+
park_index = local_index
|
|
840
|
+
parked_devices.append(final_name)
|
|
841
|
+
if anchor_x is None:
|
|
842
|
+
anchor_x = 1820
|
|
843
|
+
if anchor_y is None:
|
|
844
|
+
anchor_y = 180
|
|
845
|
+
col = park_index % 3
|
|
846
|
+
row = park_index // 3
|
|
847
|
+
adapted_plan.edit_operations.append(
|
|
848
|
+
{
|
|
849
|
+
"op": "reflow_layout",
|
|
850
|
+
"device": final_name,
|
|
851
|
+
"x": int(anchor_x + (-130 + col * 130)),
|
|
852
|
+
"y": int(anchor_y + row * 115),
|
|
853
|
+
}
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
target_router = next((device for device in blueprint.get("devices", []) if _device_kind(device) == "Router"), None)
|
|
857
|
+
donor_router = next((device for device in donor_devices if device["type"] == "Router"), None)
|
|
858
|
+
if target_router is not None:
|
|
859
|
+
if donor_router is None:
|
|
860
|
+
gap = "Compatibility donor does not contain a router prototype for prompt generation."
|
|
861
|
+
if gap not in adapted_plan.blocking_gaps:
|
|
862
|
+
adapted_plan.blocking_gaps.append(gap)
|
|
863
|
+
raise PlanningError("Prompt plan is incomplete; generation was skipped.", adapted_plan)
|
|
864
|
+
keep_name(str(donor_router["name"]), str(target_router["name"]), int(target_router.get("x", 0)), int(target_router.get("y", 0)))
|
|
865
|
+
elif donor_router is not None:
|
|
866
|
+
park_device(str(donor_router["name"]))
|
|
867
|
+
|
|
868
|
+
for donor_group, target_group in zip(donor_groups, target_groups):
|
|
869
|
+
group_kept: list[str] = []
|
|
870
|
+
group_park_index = 0
|
|
871
|
+
donor_switch = donor_group["switch"]
|
|
872
|
+
target_switch = target_group["switch"]
|
|
873
|
+
switch_x = int(target_switch.get("x", 0))
|
|
874
|
+
switch_y = int(target_switch.get("y", 0))
|
|
875
|
+
park_anchor_x = switch_x
|
|
876
|
+
park_anchor_y = switch_y + 650
|
|
877
|
+
keep_name(str(donor_switch["name"]), str(target_switch["name"]), int(target_switch.get("x", 0)), int(target_switch.get("y", 0)))
|
|
878
|
+
group_kept.append(str(target_switch["name"]))
|
|
879
|
+
target_members_by_type: dict[str, list[dict[str, object]]] = {}
|
|
880
|
+
for member in target_group["members"]:
|
|
881
|
+
target_members_by_type.setdefault(_device_kind(member), []).append(member)
|
|
882
|
+
for members in target_members_by_type.values():
|
|
883
|
+
members.sort(key=lambda item: _name_sort_key(str(item["name"])))
|
|
884
|
+
donor_members_by_type = donor_group["members_by_type"]
|
|
885
|
+
for device_type, wanted in target_members_by_type.items():
|
|
886
|
+
available = donor_members_by_type.get(device_type, [])
|
|
887
|
+
if len(wanted) > len(available):
|
|
888
|
+
gap = (
|
|
889
|
+
f"Compatibility donor group {donor_group['group_name']} has only {len(available)} {device_type} device(s); "
|
|
890
|
+
f"requested {len(wanted)} for {target_group['group_name']}."
|
|
891
|
+
)
|
|
892
|
+
if gap not in adapted_plan.blocking_gaps:
|
|
893
|
+
adapted_plan.blocking_gaps.append(gap)
|
|
894
|
+
raise PlanningError("Prompt plan is incomplete; generation was skipped.", adapted_plan)
|
|
895
|
+
for donor_member, target_member in zip(available, wanted):
|
|
896
|
+
keep_name(
|
|
897
|
+
str(donor_member["name"]),
|
|
898
|
+
str(target_member["name"]),
|
|
899
|
+
int(target_member.get("x", 0)),
|
|
900
|
+
int(target_member.get("y", 0)),
|
|
901
|
+
)
|
|
902
|
+
group_kept.append(str(target_member["name"]))
|
|
903
|
+
for spare_offset, donor_member in enumerate(available[len(wanted) :], start=1):
|
|
904
|
+
spare_name = f"{target_group['group_name']}-SPARE-{device_type.upper()}{spare_offset}"
|
|
905
|
+
park_device(str(donor_member["name"]), park_anchor_x, park_anchor_y, group_park_index, spare_name)
|
|
906
|
+
group_park_index += 1
|
|
907
|
+
for device_type, available in donor_members_by_type.items():
|
|
908
|
+
if device_type in target_members_by_type:
|
|
909
|
+
continue
|
|
910
|
+
for spare_offset, donor_member in enumerate(available, start=1):
|
|
911
|
+
spare_name = f"{target_group['group_name']}-SPARE-{device_type.upper()}{spare_offset}"
|
|
912
|
+
park_device(str(donor_member["name"]), park_anchor_x, park_anchor_y, group_park_index, spare_name)
|
|
913
|
+
group_park_index += 1
|
|
914
|
+
mutation_groups.append(
|
|
915
|
+
{
|
|
916
|
+
"donor_group": donor_group["group_name"],
|
|
917
|
+
"target_group": target_group["group_name"],
|
|
918
|
+
"kept_devices": group_kept,
|
|
919
|
+
}
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
for donor_group in donor_groups[len(target_groups) :]:
|
|
923
|
+
names = [str(donor_group["switch"]["name"]), *[str(member["name"]) for member in donor_group["members"]]]
|
|
924
|
+
donor_switch = donor_group["switch"]
|
|
925
|
+
switch_x = int(donor_switch.get("x", 0))
|
|
926
|
+
switch_y = int(donor_switch.get("y", 0))
|
|
927
|
+
group_park_index = 0
|
|
928
|
+
for name in names:
|
|
929
|
+
park_device(name, switch_x, switch_y + 650, group_park_index)
|
|
930
|
+
group_park_index += 1
|
|
931
|
+
mutation_groups.append({"donor_group": donor_group["group_name"], "target_group": None, "parked_devices": names})
|
|
932
|
+
|
|
933
|
+
archetype_plan = DonorArchetypePlan(
|
|
934
|
+
compat_donor=str(compat_donor),
|
|
935
|
+
donor_capacity=donor_capacity,
|
|
936
|
+
kept_devices=sorted(rename_map.values(), key=_name_sort_key),
|
|
937
|
+
pruned_devices=sorted(dict.fromkeys(parked_devices), key=_name_sort_key),
|
|
938
|
+
renamed_devices=renamed_devices,
|
|
939
|
+
mutation_groups=mutation_groups,
|
|
940
|
+
layout_strategy="donor_preserve_park_unused",
|
|
941
|
+
)
|
|
942
|
+
return adapted_plan, archetype_plan
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def prepare_generation_plan(plan: IntentPlan) -> IntentPlan:
|
|
946
|
+
enriched = _copy_plan(plan)
|
|
947
|
+
if enriched.goal == "edit":
|
|
948
|
+
return enriched
|
|
949
|
+
|
|
950
|
+
if enriched.department_groups and not enriched.device_requirements.get("Router", 0):
|
|
951
|
+
enriched.device_requirements["Router"] = 1
|
|
952
|
+
enriched.assumptions_used.append("Added one router for department-based topology.")
|
|
953
|
+
if enriched.department_groups and not enriched.vlan_ids:
|
|
954
|
+
enriched.vlan_ids = [10 * (index + 1) for index in range(len(enriched.department_groups))]
|
|
955
|
+
enriched.topology_requirements["vlan_ids"] = enriched.vlan_ids
|
|
956
|
+
for index, group in enumerate(enriched.department_groups):
|
|
957
|
+
group["vlan_id"] = enriched.vlan_ids[index]
|
|
958
|
+
pc_count = int(group.get("devices", {}).get("PC", 0))
|
|
959
|
+
if pc_count:
|
|
960
|
+
enriched.host_vlan_assignment[enriched.vlan_ids[index]] = pc_count
|
|
961
|
+
enriched.assumptions_used.append("Generated default VLAN IDs in 10-step increments for each department.")
|
|
962
|
+
if enriched.device_requirements.get("Switch", 0) > 1:
|
|
963
|
+
enriched.topology_requirements.setdefault("uplink_topology", "core_switch")
|
|
964
|
+
if enriched.department_groups:
|
|
965
|
+
enriched.topology_requirements["uplink_topology"] = "chain"
|
|
966
|
+
if enriched.device_requirements.get("Switch", 0) and not enriched.host_link_intent and enriched.device_requirements.get("PC", 0):
|
|
967
|
+
enriched.host_link_intent = "fastethernet"
|
|
968
|
+
enriched.topology_requirements.setdefault("host_link_intent", "fastethernet")
|
|
969
|
+
enriched.assumptions_used.append("Defaulted host links to FastEthernet.")
|
|
970
|
+
if enriched.department_groups and any(
|
|
971
|
+
any(device_type in {"Tablet", "Smartphone"} for device_type in dict(group.get("devices") or {}))
|
|
972
|
+
for group in enriched.department_groups
|
|
973
|
+
):
|
|
974
|
+
assumption = "Tablets and smartphones are treated as wireless clients and are not auto-wired."
|
|
975
|
+
if assumption not in enriched.assumptions_used:
|
|
976
|
+
enriched.assumptions_used.append(assumption)
|
|
977
|
+
if enriched.device_requirements.get("Switch", 0) > 1 and not enriched.uplink_intent:
|
|
978
|
+
enriched.uplink_intent = "gigabit"
|
|
979
|
+
enriched.topology_requirements.setdefault("uplink_intent", "gigabit")
|
|
980
|
+
enriched.assumptions_used.append("Defaulted switch uplinks to GigabitEthernet.")
|
|
981
|
+
|
|
982
|
+
if enriched.vlan_ids and enriched.device_requirements.get("PC", 0) and not enriched.host_vlan_assignment and not any(op["op"] == "set_access_port" for op in enriched.switch_ops):
|
|
983
|
+
gap = "Host-to-VLAN assignment is missing. Specify how many PCs belong to each VLAN."
|
|
984
|
+
if gap not in enriched.blocking_gaps:
|
|
985
|
+
enriched.blocking_gaps.append(gap)
|
|
986
|
+
|
|
987
|
+
if any(cap in enriched.capabilities for cap in ["vlan", "trunk"]) or enriched.vlan_ids:
|
|
988
|
+
for capability in ["vlan", "trunk", "access_port"]:
|
|
989
|
+
if capability not in enriched.capabilities:
|
|
990
|
+
enriched.capabilities.append(capability)
|
|
991
|
+
if enriched.vlan_ids and enriched.device_requirements.get("Router", 0):
|
|
992
|
+
for capability in ["router_on_a_stick"]:
|
|
993
|
+
if capability not in enriched.capabilities:
|
|
994
|
+
enriched.capabilities.append(capability)
|
|
995
|
+
|
|
996
|
+
return enriched
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def build_prompt_blueprint(plan: IntentPlan) -> tuple[dict[str, object], IntentPlan]:
|
|
1000
|
+
prepared = _apply_prompt_compatibility_requirements(plan)
|
|
1001
|
+
if prepared.blocking_gaps:
|
|
1002
|
+
raise PlanningError("Prompt plan is incomplete; generation was skipped.", prepared)
|
|
1003
|
+
|
|
1004
|
+
devices = _seed_devices_from_plan(prepared)
|
|
1005
|
+
links = _synthesize_links(prepared, devices)
|
|
1006
|
+
prepared.links = links
|
|
1007
|
+
_synthesize_vlan_and_link_ops(prepared, devices, links)
|
|
1008
|
+
prepared.capabilities = sorted(dict.fromkeys(prepared.capabilities))
|
|
1009
|
+
topology_plan = _build_topology_plan(prepared, devices, links)
|
|
1010
|
+
config_plan = _build_config_plan(prepared)
|
|
1011
|
+
|
|
1012
|
+
blueprint = {
|
|
1013
|
+
"name": "Generated from prompt",
|
|
1014
|
+
"capabilities": prepared.capabilities,
|
|
1015
|
+
"devices": devices,
|
|
1016
|
+
"links": links,
|
|
1017
|
+
"configs": _plan_configs(prepared, devices),
|
|
1018
|
+
"topology_archetype": topology_plan.topology_archetype,
|
|
1019
|
+
"topology_plan": asdict(topology_plan),
|
|
1020
|
+
"config_plan": asdict(config_plan),
|
|
1021
|
+
"workspace_mode": "logical_only_safe",
|
|
1022
|
+
}
|
|
1023
|
+
return blueprint, prepared
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def generate_from_blueprint(blueprint_path: Path, output_path: Path, xml_out_path: Path | None = None) -> None:
|
|
1027
|
+
blueprint = json.loads(blueprint_path.read_text(encoding="utf-8"))
|
|
1028
|
+
xml_bytes = build_packet_tracer_xml(blueprint)
|
|
1029
|
+
if xml_out_path is not None:
|
|
1030
|
+
xml_out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1031
|
+
xml_out_path.write_bytes(xml_bytes)
|
|
1032
|
+
pkt_bytes = encode_pkt_modern(xml_bytes)
|
|
1033
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1034
|
+
output_path.write_bytes(pkt_bytes)
|
|
1035
|
+
print(f"PKT file created: {output_path}")
|
|
1036
|
+
print(f"XML bytes: {len(xml_bytes)}")
|
|
1037
|
+
print(f"PKT bytes: {len(pkt_bytes)}")
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def generate_from_prompt(prompt: str, output_path: Path, xml_out_path: Path | None = None, reference_roots: list[Path] | None = None) -> None:
|
|
1041
|
+
raw_plan = parse_intent(prompt)
|
|
1042
|
+
if raw_plan.goal == "edit" and raw_plan.pkt_path:
|
|
1043
|
+
edit_pkt_file(raw_plan.pkt_path, raw_plan, output_path, xml_out_path)
|
|
1044
|
+
print(f"Edited PKT file created: {output_path}")
|
|
1045
|
+
return
|
|
1046
|
+
|
|
1047
|
+
blueprint, prepared_plan = build_prompt_blueprint(raw_plan)
|
|
1048
|
+
adapted_plan, donor_archetype = _build_donor_prune_plan(prepared_plan, blueprint)
|
|
1049
|
+
donor_root = decode_pkt_to_root(donor_archetype.compat_donor)
|
|
1050
|
+
root = apply_plan_operations(donor_root, adapted_plan)
|
|
1051
|
+
_sanitize_runtime_sections(root)
|
|
1052
|
+
unexpected_workspace_issues = _unexpected_workspace_issues(donor_root, root)
|
|
1053
|
+
if unexpected_workspace_issues:
|
|
1054
|
+
raise ValueError("; ".join(unexpected_workspace_issues))
|
|
1055
|
+
validate_donor_coherence(donor_root, root)
|
|
1056
|
+
xml_bytes = ET.tostring(root, encoding="utf-8", xml_declaration=False)
|
|
1057
|
+
if xml_out_path is not None:
|
|
1058
|
+
xml_out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1059
|
+
xml_out_path.write_bytes(xml_bytes)
|
|
1060
|
+
pkt_bytes = encode_pkt_modern(xml_bytes)
|
|
1061
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1062
|
+
output_path.write_bytes(pkt_bytes)
|
|
1063
|
+
print(f"Selected donor: {donor_archetype.compat_donor}")
|
|
1064
|
+
compat_donor, compat_donor_version = _compat_donor_details()
|
|
1065
|
+
if compat_donor is not None:
|
|
1066
|
+
print(f"Compatibility donor: {compat_donor} ({compat_donor_version or 'unknown'})")
|
|
1067
|
+
if reference_roots:
|
|
1068
|
+
references = load_reference_catalog(reference_roots)
|
|
1069
|
+
print(f"Loaded reference-only samples: {len(references)}")
|
|
1070
|
+
print(f"PKT file created: {output_path}")
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def explain_plan(prompt: str, reference_roots: list[Path] | None = None) -> None:
|
|
1074
|
+
plan = _apply_prompt_compatibility_requirements(parse_intent(prompt))
|
|
1075
|
+
donor_details = inspect_packet_tracer_compatibility_donor()
|
|
1076
|
+
compat_donor, compat_donor_version = donor_details.resolved_path, donor_details.donor_version
|
|
1077
|
+
result: dict[str, object] = {
|
|
1078
|
+
"intent_plan": plan.to_dict(),
|
|
1079
|
+
"compatibility_mode": "donor_prune_strict_9_0",
|
|
1080
|
+
"compat_donor": str(compat_donor) if compat_donor is not None else None,
|
|
1081
|
+
"compat_donor_version": compat_donor_version,
|
|
1082
|
+
"compat_donor_source": donor_details.donor_source,
|
|
1083
|
+
"target_version": donor_details.target_version,
|
|
1084
|
+
"blocking_reason": donor_details.blocking_reason or None,
|
|
1085
|
+
"donor_candidates": [
|
|
1086
|
+
{"source": source, "path": str(path)}
|
|
1087
|
+
for source, path in donor_details.candidate_paths[:10]
|
|
1088
|
+
],
|
|
1089
|
+
}
|
|
1090
|
+
if not plan.blocking_gaps and plan.goal != "edit":
|
|
1091
|
+
blueprint, prepared = build_prompt_blueprint(plan)
|
|
1092
|
+
topology_plan = TopologyPlan(**blueprint.get("topology_plan", {}))
|
|
1093
|
+
config_plan = ConfigPlan(**blueprint.get("config_plan", {}))
|
|
1094
|
+
topology_tags = _topology_tags_for_plan(prepared, str(blueprint.get("topology_archetype", "general")))
|
|
1095
|
+
ranked = rank_samples(load_catalog(), prepared.capabilities, prepared.device_requirements, topology_tags=topology_tags, prototype_only=True)
|
|
1096
|
+
validation = _preflight_validation(prepared, topology_plan, config_plan)
|
|
1097
|
+
selected_donor = None
|
|
1098
|
+
donor_capacity = None
|
|
1099
|
+
prune_plan = None
|
|
1100
|
+
try:
|
|
1101
|
+
adapted_plan, donor_archetype = _build_donor_prune_plan(prepared, blueprint)
|
|
1102
|
+
selected_donor = donor_archetype.compat_donor
|
|
1103
|
+
donor_capacity = donor_archetype.donor_capacity
|
|
1104
|
+
prune_plan = asdict(donor_archetype)
|
|
1105
|
+
donor_root = decode_pkt_to_root(donor_archetype.compat_donor)
|
|
1106
|
+
candidate_root = apply_plan_operations(donor_root, adapted_plan)
|
|
1107
|
+
_sanitize_runtime_sections(candidate_root)
|
|
1108
|
+
workspace_result = inspect_workspace_integrity(candidate_root)
|
|
1109
|
+
workspace_result.blocking_issues = _unexpected_workspace_issues(donor_root, candidate_root)
|
|
1110
|
+
workspace_result.logical_status = "invalid" if workspace_result.blocking_issues else "ok"
|
|
1111
|
+
coherence_result = inspect_donor_coherence(donor_root, candidate_root)
|
|
1112
|
+
result["validation_report"] = {
|
|
1113
|
+
"workspace_mode": workspace_result.workspace_mode,
|
|
1114
|
+
"logical_status": workspace_result.logical_status,
|
|
1115
|
+
"physical_status": workspace_result.physical_status,
|
|
1116
|
+
"device_metadata_status": coherence_result.device_metadata_status,
|
|
1117
|
+
"scenario_status": coherence_result.scenario_status,
|
|
1118
|
+
"physical_runtime_status": coherence_result.physical_runtime_status,
|
|
1119
|
+
"blocking_issues": [*workspace_result.blocking_issues, *coherence_result.blocking_issues],
|
|
1120
|
+
}
|
|
1121
|
+
except PlanningError as exc:
|
|
1122
|
+
result["intent_plan"] = exc.plan.to_dict()
|
|
1123
|
+
except ValueError as exc:
|
|
1124
|
+
result["validation_report"] = {"status": "invalid", "blocking_issues": str(exc).split("; ")}
|
|
1125
|
+
result["topology_plan"] = blueprint.get("topology_plan")
|
|
1126
|
+
result["config_plan"] = blueprint.get("config_plan")
|
|
1127
|
+
result["estimate_plan"] = _estimate_plan(topology_plan, config_plan)
|
|
1128
|
+
result["preflight_validation"] = validation
|
|
1129
|
+
result["autofix_summary"] = _autofix_summary(prepared, validation)
|
|
1130
|
+
result["assumptions_used"] = prepared.assumptions_used
|
|
1131
|
+
result["workspace_mode"] = blueprint.get("workspace_mode", "logical_only_safe")
|
|
1132
|
+
result["selected_donor"] = selected_donor
|
|
1133
|
+
result["donor_capacity"] = donor_capacity
|
|
1134
|
+
result["prune_plan"] = prune_plan
|
|
1135
|
+
candidates = [
|
|
1136
|
+
{
|
|
1137
|
+
"relative_path": candidate.sample.relative_path,
|
|
1138
|
+
"origin": candidate.sample.origin,
|
|
1139
|
+
"total_score": candidate.total_score,
|
|
1140
|
+
"capability_score": candidate.capability_score,
|
|
1141
|
+
"topology_score": candidate.topology_score,
|
|
1142
|
+
"reasons": candidate.reasons[:8],
|
|
1143
|
+
}
|
|
1144
|
+
for candidate in ranked[:5]
|
|
1145
|
+
]
|
|
1146
|
+
result["cisco_sample_candidates"] = candidates
|
|
1147
|
+
result["sample_candidates"] = candidates
|
|
1148
|
+
if reference_roots:
|
|
1149
|
+
reference_catalog = load_reference_catalog(reference_roots)
|
|
1150
|
+
reference_ranked = rank_reference_samples(
|
|
1151
|
+
reference_catalog,
|
|
1152
|
+
plan.capabilities,
|
|
1153
|
+
plan.device_requirements,
|
|
1154
|
+
topology_tags=_topology_tags_for_plan(plan, str(result.get("topology_plan", {}).get("topology_archetype", "general"))) if result.get("topology_plan") else None,
|
|
1155
|
+
)
|
|
1156
|
+
patterns = []
|
|
1157
|
+
for candidate in reference_ranked[:10]:
|
|
1158
|
+
pattern = ReferencePattern(
|
|
1159
|
+
relative_path=candidate.sample.relative_path,
|
|
1160
|
+
origin=candidate.sample.origin,
|
|
1161
|
+
capability_tags=candidate.sample.capability_tags,
|
|
1162
|
+
topology_tags=candidate.sample.topology_tags,
|
|
1163
|
+
device_summary=candidate.sample.normalized_device_counts(),
|
|
1164
|
+
)
|
|
1165
|
+
pattern_dict = asdict(pattern)
|
|
1166
|
+
pattern_dict["score"] = candidate.total_score
|
|
1167
|
+
pattern_dict["reasons"] = candidate.reasons[:8]
|
|
1168
|
+
patterns.append(pattern_dict)
|
|
1169
|
+
result["external_reference_patterns"] = patterns
|
|
1170
|
+
result["reference_patterns"] = patterns
|
|
1171
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def inventory_pkt(pkt_path: Path) -> None:
|
|
1175
|
+
root = ET.fromstring(decode_pkt_modern(pkt_path.read_bytes()))
|
|
1176
|
+
payload = inventory_root(root)
|
|
1177
|
+
workspace = inspect_workspace_integrity(root)
|
|
1178
|
+
donor_details = inspect_packet_tracer_compatibility_donor()
|
|
1179
|
+
compat_donor, compat_donor_version = donor_details.resolved_path, donor_details.donor_version
|
|
1180
|
+
payload["workspace_validation"] = {
|
|
1181
|
+
"workspace_mode": workspace.workspace_mode,
|
|
1182
|
+
"logical_status": workspace.logical_status,
|
|
1183
|
+
"physical_status": workspace.physical_status,
|
|
1184
|
+
"blocking_issues": workspace.blocking_issues,
|
|
1185
|
+
}
|
|
1186
|
+
payload["compatibility_mode"] = "strict_9_0"
|
|
1187
|
+
payload["compat_donor"] = str(compat_donor) if compat_donor is not None else None
|
|
1188
|
+
payload["compat_donor_version"] = compat_donor_version
|
|
1189
|
+
payload["compat_donor_source"] = donor_details.donor_source
|
|
1190
|
+
payload["target_version"] = donor_details.target_version
|
|
1191
|
+
payload["blocking_reason"] = donor_details.blocking_reason or None
|
|
1192
|
+
payload["donor_candidates"] = [
|
|
1193
|
+
{"source": source, "path": str(path)}
|
|
1194
|
+
for source, path in donor_details.candidate_paths[:10]
|
|
1195
|
+
]
|
|
1196
|
+
payload["pkt_version"] = root.findtext("./VERSION")
|
|
1197
|
+
if compat_donor is not None:
|
|
1198
|
+
donor_root = decode_pkt_to_root(compat_donor)
|
|
1199
|
+
coherence = inspect_donor_coherence(donor_root, root)
|
|
1200
|
+
payload["validation_report"] = {
|
|
1201
|
+
"device_metadata_status": coherence.device_metadata_status,
|
|
1202
|
+
"scenario_status": coherence.scenario_status,
|
|
1203
|
+
"physical_runtime_status": coherence.physical_runtime_status,
|
|
1204
|
+
"blocking_issues": coherence.blocking_issues,
|
|
1205
|
+
}
|
|
1206
|
+
payload.update(_link_schema_summary(root))
|
|
1207
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
def validate_open(pkt_path: Path) -> None:
|
|
1211
|
+
packet_tracer_exe = require_packet_tracer_exe()
|
|
1212
|
+
process = subprocess.Popen([str(packet_tracer_exe), str(pkt_path)])
|
|
1213
|
+
print(json.dumps({"status": "launched", "pid": process.pid, "pkt": str(pkt_path)}, ensure_ascii=False))
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def main() -> None:
|
|
1217
|
+
parser = argparse.ArgumentParser(description="Generate or inspect Cisco Packet Tracer 9.0 .pkt files")
|
|
1218
|
+
parser.add_argument("--blueprint", help="Path to the topology blueprint JSON")
|
|
1219
|
+
parser.add_argument("--prompt", help="Natural language topology or edit request")
|
|
1220
|
+
parser.add_argument("--output", help="Path to the output .pkt file")
|
|
1221
|
+
parser.add_argument("--xml-out", help="Optional XML output path for generated or decoded XML")
|
|
1222
|
+
parser.add_argument("--decode", help="Decode an existing .pkt file")
|
|
1223
|
+
parser.add_argument("--inventory", help="Print device/link/service inventory for an existing .pkt file")
|
|
1224
|
+
parser.add_argument("--explain-plan", help="Print the parsed prompt plan as JSON")
|
|
1225
|
+
parser.add_argument("--validate-open", help="Launch Packet Tracer with the given .pkt file")
|
|
1226
|
+
parser.add_argument("--compat-donor", help="Explicit Packet Tracer 9.0 donor .pkt path for strict compatibility mode")
|
|
1227
|
+
parser.add_argument("--reference-root", action="append", help="Optional local folder of imported external sample .pkt files")
|
|
1228
|
+
args = parser.parse_args()
|
|
1229
|
+
if args.compat_donor:
|
|
1230
|
+
os.environ["PACKET_TRACER_COMPAT_DONOR"] = args.compat_donor
|
|
1231
|
+
reference_roots = [Path(path) for path in (args.reference_root or [])]
|
|
1232
|
+
|
|
1233
|
+
if args.explain_plan:
|
|
1234
|
+
explain_plan(args.explain_plan, reference_roots)
|
|
1235
|
+
return
|
|
1236
|
+
if args.inventory:
|
|
1237
|
+
inventory_pkt(Path(args.inventory))
|
|
1238
|
+
return
|
|
1239
|
+
if args.decode:
|
|
1240
|
+
if not args.xml_out:
|
|
1241
|
+
parser.error("--decode requires --xml-out")
|
|
1242
|
+
decode_pkt_file(args.decode, args.xml_out)
|
|
1243
|
+
print(f"Decoded XML written to {args.xml_out}")
|
|
1244
|
+
return
|
|
1245
|
+
if args.validate_open:
|
|
1246
|
+
validate_open(Path(args.validate_open))
|
|
1247
|
+
return
|
|
1248
|
+
|
|
1249
|
+
if not args.output:
|
|
1250
|
+
parser.error("generation requires --output")
|
|
1251
|
+
if args.prompt:
|
|
1252
|
+
try:
|
|
1253
|
+
generate_from_prompt(args.prompt, Path(args.output), Path(args.xml_out) if args.xml_out else None, reference_roots)
|
|
1254
|
+
except PlanningError as exc:
|
|
1255
|
+
print(json.dumps(exc.to_dict(), indent=2, ensure_ascii=False))
|
|
1256
|
+
raise SystemExit(2) from exc
|
|
1257
|
+
return
|
|
1258
|
+
if not args.blueprint:
|
|
1259
|
+
parser.error("generation requires either --blueprint or --prompt")
|
|
1260
|
+
generate_from_blueprint(Path(args.blueprint), Path(args.output), Path(args.xml_out) if args.xml_out else None)
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
if __name__ == "__main__":
|
|
1264
|
+
main()
|