packet-tracer-skill 0.1.0 → 0.2.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/CHANGELOG.md +33 -0
- package/CITATION.cff +15 -0
- package/CODE_OF_CONDUCT.md +26 -0
- package/CONTRIBUTING.md +120 -0
- package/README.md +164 -543
- package/SECURITY.md +36 -0
- package/SKILL.md +46 -5
- package/bin/packet-tracer-skill.js +503 -7
- package/docs/curated-donor-registry.md +51 -0
- package/docs/discovery-keywords.md +55 -0
- package/docs/github-discussions-setup.md +34 -0
- package/docs/github-metadata.md +33 -0
- package/docs/hero-demo-plan.md +42 -0
- package/docs/publish-preview-roadmap.md +31 -0
- package/docs/release-checklist.md +60 -0
- package/docs/release-notes-0.2.0.md +25 -0
- package/docs/runtime-truth.md +49 -0
- package/docs/screenshots/.gitkeep +1 -0
- package/docs/screenshots/packet-tracer-topology-cropped.png +0 -0
- package/examples/README.md +72 -0
- package/examples/complex_campus_master_edit_v4.inventory.json +94 -0
- package/examples/gallery.md +39 -0
- package/examples/home_iot_cli_edit_v1.inventory.json +58 -0
- package/examples/index.json +127 -0
- package/examples/screenshots/complex_campus_master_edit_v4.png +0 -0
- package/examples/screenshots/home_iot_cli_edit_v1.png +0 -0
- package/examples/screenshots/service_heavy_cli_edit_v1.png +0 -0
- package/examples/screenshots/service_heavy_cli_edit_v1_dhcp.png +0 -0
- package/examples/screenshots/service_heavy_cli_edit_v1_dns.png +0 -0
- package/examples/screenshots/service_heavy_cli_edit_v1_ftp.png +0 -0
- package/examples/service_heavy_cli_edit_v1.inventory.json +51 -0
- package/package.json +41 -4
- package/references/curated-donor-registry.json +71 -0
- package/references/scenario-fixture-corpus.json +53 -0
- package/scripts/__pycache__/build_examples_index.cpython-314.pyc +0 -0
- package/scripts/__pycache__/build_sample_catalog.cpython-314.pyc +0 -0
- package/scripts/__pycache__/coverage_matrix.cpython-314.pyc +0 -0
- package/scripts/__pycache__/donor_diagnostics.cpython-314.pyc +0 -0
- package/scripts/__pycache__/generate_pkt.cpython-314.pyc +0 -0
- package/scripts/__pycache__/install_skill.cpython-314.pyc +0 -0
- package/scripts/__pycache__/intent_parser.cpython-314.pyc +0 -0
- package/scripts/__pycache__/packet_tracer_env.cpython-314.pyc +0 -0
- package/scripts/__pycache__/pkt_builder.cpython-314.pyc +0 -0
- package/scripts/__pycache__/pkt_codec.cpython-314.pyc +0 -0
- package/scripts/__pycache__/pkt_editor.cpython-314.pyc +0 -0
- package/scripts/__pycache__/pkt_transformer.cpython-314.pyc +0 -0
- package/scripts/__pycache__/remote_search.cpython-314.pyc +0 -0
- package/scripts/__pycache__/runtime_doctor.cpython-314.pyc +0 -0
- package/scripts/__pycache__/sample_catalog.cpython-314.pyc +0 -0
- package/scripts/__pycache__/sample_selector.cpython-314.pyc +0 -0
- package/scripts/__pycache__/twofish_diagnostics.cpython-314.pyc +0 -0
- package/scripts/__pycache__/twofish_runtime.cpython-314.pyc +0 -0
- package/scripts/__pycache__/workspace_repair.cpython-314.pyc +0 -0
- package/scripts/build_examples_index.py +314 -0
- package/scripts/coverage_matrix.py +783 -0
- package/scripts/donor_diagnostics.py +6 -3
- package/scripts/generate_pkt.py +2605 -98
- package/scripts/intent_parser.py +173 -18
- package/scripts/packet_tracer_env.py +127 -14
- package/scripts/pkt_editor.py +312 -12
- package/scripts/pkt_transformer.py +195 -9
- package/scripts/remote_search.py +169 -0
- package/scripts/runtime_doctor.py +286 -0
- package/scripts/sample_catalog.py +643 -38
- package/scripts/sample_selector.py +251 -5
- package/scripts/setup.ps1 +2 -1
- package/scripts/twofish_diagnostics.py +20 -23
- package/scripts/twofish_runtime.py +101 -0
- package/scripts/vendor/README.md +13 -1
- package/scripts/vendor/__pycache__/twofish.cpython-314.pyc +0 -0
- package/scripts/vendor/twofish.py +11 -9
- package/scripts/workspace_repair.py +69 -2
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from intent_parser import IntentPlan
|
|
7
|
+
from sample_catalog import SampleDescriptor
|
|
8
|
+
|
|
9
|
+
MATURITY_LEVELS = [
|
|
10
|
+
"reference-known",
|
|
11
|
+
"inventory-supported",
|
|
12
|
+
"edit-supported",
|
|
13
|
+
"config-mutation-supported",
|
|
14
|
+
"safe-open-generate-supported",
|
|
15
|
+
"acceptance-verified",
|
|
16
|
+
]
|
|
17
|
+
MATURITY_RANK = {level: index for index, level in enumerate(MATURITY_LEVELS)}
|
|
18
|
+
|
|
19
|
+
DEVICE_FAMILY_MAP = {
|
|
20
|
+
"Router": "routers",
|
|
21
|
+
"Switch": "switches",
|
|
22
|
+
"MultiLayerSwitch": "multilayer switches",
|
|
23
|
+
"Server": "servers",
|
|
24
|
+
"PC": "end devices",
|
|
25
|
+
"Laptop": "end devices",
|
|
26
|
+
"Tablet": "end devices",
|
|
27
|
+
"Smartphone": "end devices",
|
|
28
|
+
"Printer": "end devices",
|
|
29
|
+
"LightWeightAccessPoint": "access points",
|
|
30
|
+
"WirelessRouter": "home/wireless routers",
|
|
31
|
+
"WirelessRouterNewGeneration": "home/wireless routers",
|
|
32
|
+
"HomeGateway": "home/wireless routers",
|
|
33
|
+
"WirelessLanController": "access points",
|
|
34
|
+
"Power Distribution Device": "pt-specific edge/utility devices",
|
|
35
|
+
"Cloud": "wan/cloud/dsl/cable devices",
|
|
36
|
+
"Cable Modem": "wan/cloud/dsl/cable devices",
|
|
37
|
+
"Dsl Modem": "wan/cloud/dsl/cable devices",
|
|
38
|
+
"Security Appliance": "security devices",
|
|
39
|
+
"ASA": "security devices",
|
|
40
|
+
"IoT": "iot devices",
|
|
41
|
+
"Board": "iot devices",
|
|
42
|
+
"Sensor": "iot devices",
|
|
43
|
+
"Actuator": "iot devices",
|
|
44
|
+
"MCUComponent": "iot devices",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
DEFAULT_FAMILY_LIMITATIONS = {
|
|
48
|
+
"access points": ["wireless client association requires donor-backed acceptance"],
|
|
49
|
+
"home/wireless routers": ["wireless client association and local NAT flows are acceptance-gated"],
|
|
50
|
+
"iot devices": ["iot registration/control is not acceptance-verified yet"],
|
|
51
|
+
"wan/cloud/dsl/cable devices": ["advanced wan/cloud patterns depend on donor availability"],
|
|
52
|
+
"security devices": ["advanced security devices remain donor-limited"],
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
CAPABILITY_PROVIDER_FAMILIES = {
|
|
56
|
+
"access_port": {"switches", "multilayer switches"},
|
|
57
|
+
"trunk": {"switches", "multilayer switches"},
|
|
58
|
+
"native_vlan": {"switches", "multilayer switches"},
|
|
59
|
+
"vlan": {"switches", "multilayer switches", "routers"},
|
|
60
|
+
"switching": {"switches", "multilayer switches"},
|
|
61
|
+
"router_on_a_stick": {"routers", "multilayer switches"},
|
|
62
|
+
"management": {"switches", "multilayer switches", "routers"},
|
|
63
|
+
"management_vlan": {"switches", "multilayer switches"},
|
|
64
|
+
"telnet": {"switches", "multilayer switches", "routers"},
|
|
65
|
+
"ssh": {"switches", "multilayer switches", "routers"},
|
|
66
|
+
"nat": {"routers", "home/wireless routers", "security devices"},
|
|
67
|
+
"pat": {"routers", "home/wireless routers", "security devices"},
|
|
68
|
+
"acl": {"routers", "multilayer switches", "switches", "security devices"},
|
|
69
|
+
"vpn": {"routers", "security devices", "wan/cloud/dsl/cable devices"},
|
|
70
|
+
"ipsec": {"routers", "security devices", "wan/cloud/dsl/cable devices"},
|
|
71
|
+
"gre": {"routers", "wan/cloud/dsl/cable devices"},
|
|
72
|
+
"ppp": {"routers", "wan/cloud/dsl/cable devices"},
|
|
73
|
+
"multilayer_switching": {"multilayer switches"},
|
|
74
|
+
"wan": {"wan/cloud/dsl/cable devices", "routers"},
|
|
75
|
+
"security_edge": {"security devices", "routers"},
|
|
76
|
+
"wireless": {"access points", "home/wireless routers", "end devices"},
|
|
77
|
+
"wireless_ap": {"access points", "home/wireless routers"},
|
|
78
|
+
"wireless_mutation": {"access points", "home/wireless routers"},
|
|
79
|
+
"wireless_client": {"end devices", "access points", "home/wireless routers"},
|
|
80
|
+
"wireless_client_association": {"end devices", "access points", "home/wireless routers"},
|
|
81
|
+
"end_device_mutation": {"end devices"},
|
|
82
|
+
"dhcp": {"routers", "servers", "home/wireless routers"},
|
|
83
|
+
"dhcp_pool": {"routers", "servers", "home/wireless routers"},
|
|
84
|
+
"router_dhcp": {"routers", "home/wireless routers"},
|
|
85
|
+
"server_dhcp": {"servers"},
|
|
86
|
+
"dns": {"servers", "routers"},
|
|
87
|
+
"server_dns": {"servers"},
|
|
88
|
+
"server_http": {"servers"},
|
|
89
|
+
"server_ftp": {"servers"},
|
|
90
|
+
"server_https": {"servers"},
|
|
91
|
+
"server_tftp": {"servers"},
|
|
92
|
+
"server_email": {"servers"},
|
|
93
|
+
"server_syslog": {"servers"},
|
|
94
|
+
"server_aaa": {"servers"},
|
|
95
|
+
"ntp": {"servers", "routers", "switches", "multilayer switches"},
|
|
96
|
+
"iot": {"iot devices"},
|
|
97
|
+
"iot_registration": {"iot devices"},
|
|
98
|
+
"iot_control": {"iot devices"},
|
|
99
|
+
"tablet": {"end devices"},
|
|
100
|
+
"printer": {"end devices"},
|
|
101
|
+
"verification": {"routers", "switches", "multilayer switches", "servers", "end devices", "access points"},
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class CapabilityMatrixEntry:
|
|
107
|
+
device_family: str
|
|
108
|
+
capability: str
|
|
109
|
+
maturity_level: str
|
|
110
|
+
accepted_donors: list[str]
|
|
111
|
+
known_limitations: list[str]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class CoverageGapReport:
|
|
116
|
+
requested_capabilities: list[str]
|
|
117
|
+
supported_capabilities: list[str]
|
|
118
|
+
unsupported_capabilities: list[str]
|
|
119
|
+
requested_device_families: list[str]
|
|
120
|
+
unsupported_device_families: list[str]
|
|
121
|
+
requires_curated_donor: list[str]
|
|
122
|
+
requires_manual_acceptance: list[str]
|
|
123
|
+
capability_statuses: list[dict[str, object]]
|
|
124
|
+
capability_parity: list[dict[str, object]]
|
|
125
|
+
scenario_family: str | None = None
|
|
126
|
+
scenario_generate_readiness: dict[str, object] = field(default_factory=dict)
|
|
127
|
+
recommended_next_actions: list[str] = field(default_factory=list)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class BlueprintPlan:
|
|
132
|
+
requested_devices: list[dict[str, object]]
|
|
133
|
+
requested_links: list[dict[str, object]]
|
|
134
|
+
required_capabilities: list[str]
|
|
135
|
+
required_donor_capacity: dict[str, int]
|
|
136
|
+
required_link_pairs: list[str]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class DonorGraphFit:
|
|
141
|
+
matched_pairs: list[str]
|
|
142
|
+
missing_pairs: list[str]
|
|
143
|
+
port_media_conflicts: list[str]
|
|
144
|
+
fit_score: int
|
|
145
|
+
layout_reuse_score: int
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _device_family_for_type(device_type: str) -> str:
|
|
149
|
+
return DEVICE_FAMILY_MAP.get(device_type, "pt-specific edge/utility devices")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _sample_maturity(sample: SampleDescriptor) -> str:
|
|
153
|
+
if sample.origin == "cisco-local" and sample.prototype_eligible and sample.version.startswith("9."):
|
|
154
|
+
structural = "acceptance-verified"
|
|
155
|
+
elif sample.origin in {"compat-donor", "external-curated"} and sample.donor_eligible and sample.prototype_eligible:
|
|
156
|
+
structural = "safe-open-generate-supported"
|
|
157
|
+
elif sample.capability_tags:
|
|
158
|
+
structural = "config-mutation-supported"
|
|
159
|
+
elif sample.devices:
|
|
160
|
+
structural = "inventory-supported"
|
|
161
|
+
else:
|
|
162
|
+
structural = "reference-known"
|
|
163
|
+
declared = sample.apply_safety_level if sample.apply_safety_level in MATURITY_RANK else "reference-known"
|
|
164
|
+
if MATURITY_RANK[declared] > MATURITY_RANK[structural]:
|
|
165
|
+
return declared
|
|
166
|
+
return structural
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _merge_limitations(existing: list[str], new_items: list[str]) -> list[str]:
|
|
170
|
+
merged = list(existing)
|
|
171
|
+
for item in new_items:
|
|
172
|
+
if item and item not in merged:
|
|
173
|
+
merged.append(item)
|
|
174
|
+
return merged
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _expanded_sample_capabilities(sample: SampleDescriptor, device_families: list[str]) -> list[str]:
|
|
178
|
+
capabilities = set(sample.capability_tags)
|
|
179
|
+
service_capability_map = {
|
|
180
|
+
"dhcp": {"dhcp", "server_dhcp"},
|
|
181
|
+
"dns": {"dns", "server_dns"},
|
|
182
|
+
"http": {"server_http"},
|
|
183
|
+
"https": {"server_https"},
|
|
184
|
+
"ftp": {"server_ftp"},
|
|
185
|
+
"tftp": {"server_tftp"},
|
|
186
|
+
"email": {"server_email"},
|
|
187
|
+
"syslog": {"server_syslog"},
|
|
188
|
+
"aaa": {"server_aaa"},
|
|
189
|
+
"ntp": {"ntp"},
|
|
190
|
+
}
|
|
191
|
+
for service in sample.service_support:
|
|
192
|
+
capabilities.add(service)
|
|
193
|
+
capabilities.update(service_capability_map.get(service, set()))
|
|
194
|
+
families = set(device_families)
|
|
195
|
+
topology_tags = set(sample.topology_tags)
|
|
196
|
+
normalized_counts = sample.normalized_device_counts()
|
|
197
|
+
iot_roles = set(sample.iot_roles)
|
|
198
|
+
|
|
199
|
+
if "vlan" in capabilities and families & {"switches", "multilayer switches"}:
|
|
200
|
+
capabilities.update({"trunk", "access_port"})
|
|
201
|
+
if (
|
|
202
|
+
"router_on_a_stick" in capabilities
|
|
203
|
+
or "router_on_a_stick" in topology_tags
|
|
204
|
+
or ("vlan" in capabilities and {"routers", "switches"} <= families)
|
|
205
|
+
):
|
|
206
|
+
capabilities.add("router_on_a_stick")
|
|
207
|
+
if "management_vlan" in capabilities:
|
|
208
|
+
capabilities.add("telnet")
|
|
209
|
+
if "telnet" in capabilities and families & {"switches", "multilayer switches"}:
|
|
210
|
+
capabilities.add("management_vlan")
|
|
211
|
+
if normalized_counts.get("Printer", 0) > 0 or ("end devices" in families and "host_server" in capabilities):
|
|
212
|
+
capabilities.add("printer")
|
|
213
|
+
if "end devices" in families:
|
|
214
|
+
capabilities.add("end_device_mutation")
|
|
215
|
+
if sample.wireless_mode_tags and {"access points", "home/wireless routers"} & families and "wireless_ap" in capabilities:
|
|
216
|
+
capabilities.add("wireless_mutation")
|
|
217
|
+
capabilities.add("wireless_client")
|
|
218
|
+
capabilities.add("wireless_client_association")
|
|
219
|
+
if iot_roles:
|
|
220
|
+
capabilities.add("iot")
|
|
221
|
+
if "thing" in iot_roles and ("server" in iot_roles or "gateway" in iot_roles):
|
|
222
|
+
capabilities.add("iot_registration")
|
|
223
|
+
if "thing" in iot_roles and ("gateway" in iot_roles or "server" in iot_roles):
|
|
224
|
+
capabilities.add("iot_control")
|
|
225
|
+
if sample.link_count > 0 and families & {"routers", "switches", "multilayer switches", "servers", "end devices", "access points"}:
|
|
226
|
+
capabilities.add("verification")
|
|
227
|
+
if "multilayer switches" in families:
|
|
228
|
+
capabilities.add("multilayer_switching")
|
|
229
|
+
if "wan/cloud/dsl/cable devices" in families:
|
|
230
|
+
capabilities.add("wan")
|
|
231
|
+
if "security devices" in families:
|
|
232
|
+
capabilities.add("security_edge")
|
|
233
|
+
if "vpn" in capabilities:
|
|
234
|
+
capabilities.update({"ipsec", "gre"})
|
|
235
|
+
return sorted(capabilities)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def build_capability_matrix(samples: list[SampleDescriptor]) -> list[CapabilityMatrixEntry]:
|
|
239
|
+
entries: dict[tuple[str, str], CapabilityMatrixEntry] = {}
|
|
240
|
+
for sample in samples:
|
|
241
|
+
maturity = _sample_maturity(sample)
|
|
242
|
+
device_families = sorted({_device_family_for_type(device.get("type", "")) for device in sample.devices if device.get("type")})
|
|
243
|
+
capabilities = _expanded_sample_capabilities(sample, device_families)
|
|
244
|
+
limitations: list[str] = []
|
|
245
|
+
if not sample.donor_eligible:
|
|
246
|
+
limitations.append("not donor-eligible")
|
|
247
|
+
if not sample.prototype_eligible:
|
|
248
|
+
limitations.append("reference-only")
|
|
249
|
+
for family in device_families or ["pt-specific edge/utility devices"]:
|
|
250
|
+
known_family_limitations = DEFAULT_FAMILY_LIMITATIONS.get(family, [])
|
|
251
|
+
for capability in capabilities or ["inventory"]:
|
|
252
|
+
key = (family, capability)
|
|
253
|
+
donor_label = sample.relative_path
|
|
254
|
+
if key not in entries:
|
|
255
|
+
entries[key] = CapabilityMatrixEntry(
|
|
256
|
+
device_family=family,
|
|
257
|
+
capability=capability,
|
|
258
|
+
maturity_level=maturity,
|
|
259
|
+
accepted_donors=[donor_label],
|
|
260
|
+
known_limitations=_merge_limitations([], [*known_family_limitations, *limitations]),
|
|
261
|
+
)
|
|
262
|
+
continue
|
|
263
|
+
current = entries[key]
|
|
264
|
+
if MATURITY_RANK[maturity] > MATURITY_RANK[current.maturity_level]:
|
|
265
|
+
current.maturity_level = maturity
|
|
266
|
+
if donor_label not in current.accepted_donors:
|
|
267
|
+
current.accepted_donors.append(donor_label)
|
|
268
|
+
current.known_limitations = _merge_limitations(current.known_limitations, [*known_family_limitations, *limitations])
|
|
269
|
+
return sorted(entries.values(), key=lambda item: (item.device_family, item.capability))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _requested_device_families(plan: IntentPlan) -> list[str]:
|
|
273
|
+
families = {_device_family_for_type(device_type) for device_type, count in plan.device_requirements.items() if count}
|
|
274
|
+
return sorted(families)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _scenario_family_for_plan(plan: IntentPlan, requested_families: list[str]) -> str | None:
|
|
278
|
+
capabilities = set(plan.capabilities)
|
|
279
|
+
requested_services = {str(service) for service in plan.service_requirements.get("services", []) if service}
|
|
280
|
+
if capabilities & {"iot", "iot_registration", "iot_control"} or {"iot devices", "home/wireless routers"} & set(requested_families):
|
|
281
|
+
return "home_iot"
|
|
282
|
+
if (
|
|
283
|
+
plan.network_style == "wan_security"
|
|
284
|
+
or capabilities & {"vpn", "ipsec", "gre", "ppp", "multilayer_switching", "security_edge"}
|
|
285
|
+
or {"wan/cloud/dsl/cable devices", "security devices"} & set(requested_families)
|
|
286
|
+
):
|
|
287
|
+
return "wan_security_edge"
|
|
288
|
+
if (
|
|
289
|
+
requested_services & {"dns", "dhcp", "http", "https", "ftp", "tftp", "email", "syslog", "aaa", "ntp"}
|
|
290
|
+
or capabilities & {"server_dns", "server_dhcp", "server_http", "server_https", "server_ftp", "server_tftp", "server_email", "server_syslog", "server_aaa"}
|
|
291
|
+
or "servers" in requested_families
|
|
292
|
+
) and not (plan.department_groups or {"switches", "multilayer switches"} & set(requested_families) and capabilities & {"vlan", "router_on_a_stick", "management_vlan", "telnet"}):
|
|
293
|
+
return "service_heavy"
|
|
294
|
+
if (
|
|
295
|
+
plan.department_groups
|
|
296
|
+
or {"switches", "multilayer switches"} & set(requested_families)
|
|
297
|
+
or capabilities & {"vlan", "router_on_a_stick", "management_vlan", "telnet", "acl", "nat", "pat"}
|
|
298
|
+
):
|
|
299
|
+
return "campus"
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
SCENARIO_CAPABILITY_SETS = {
|
|
304
|
+
"campus": {"router_on_a_stick", "trunk", "access_port", "management_vlan", "telnet", "verification", "acl", "nat"},
|
|
305
|
+
"service_heavy": {"server_dns", "server_dhcp", "server_http", "server_https", "server_ftp", "server_tftp", "server_email", "server_syslog", "server_aaa", "ntp"},
|
|
306
|
+
"home_iot": {"iot", "iot_registration", "iot_control", "wireless_ap", "wireless_mutation"},
|
|
307
|
+
"wan_security_edge": {"vpn", "ipsec", "gre", "ppp", "acl", "nat"},
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
CAPABILITY_REQUIRED_ARCHETYPES = {
|
|
311
|
+
"router_on_a_stick": ["campus/core"],
|
|
312
|
+
"management_vlan": ["campus/core"],
|
|
313
|
+
"telnet": ["campus/core"],
|
|
314
|
+
"server_dns": ["service-heavy"],
|
|
315
|
+
"server_dhcp": ["service-heavy"],
|
|
316
|
+
"server_http": ["service-heavy"],
|
|
317
|
+
"server_https": ["service-heavy"],
|
|
318
|
+
"server_ftp": ["service-heavy"],
|
|
319
|
+
"server_tftp": ["service-heavy"],
|
|
320
|
+
"server_email": ["service-heavy"],
|
|
321
|
+
"server_syslog": ["service-heavy"],
|
|
322
|
+
"server_aaa": ["service-heavy"],
|
|
323
|
+
"iot_registration": ["IoT/home gateway"],
|
|
324
|
+
"iot_control": ["IoT/home gateway"],
|
|
325
|
+
"wireless_mutation": ["wireless-heavy"],
|
|
326
|
+
"wireless_client_association": ["wireless-heavy", "IoT/home gateway"],
|
|
327
|
+
"vpn": ["WAN/security edge"],
|
|
328
|
+
"ipsec": ["WAN/security edge"],
|
|
329
|
+
"gre": ["WAN/security edge"],
|
|
330
|
+
"ppp": ["WAN/security edge"],
|
|
331
|
+
"multilayer_switching": ["WAN/security edge", "campus/core"],
|
|
332
|
+
"security_edge": ["WAN/security edge"],
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
CAPABILITY_REQUIRED_RUNTIME_FEATURES = {
|
|
336
|
+
"server_dns": ["workspace_validated", "server_runtime"],
|
|
337
|
+
"server_dhcp": ["workspace_validated", "server_runtime"],
|
|
338
|
+
"server_http": ["workspace_validated", "server_runtime"],
|
|
339
|
+
"server_https": ["workspace_validated", "server_runtime"],
|
|
340
|
+
"server_ftp": ["workspace_validated", "server_runtime"],
|
|
341
|
+
"server_tftp": ["workspace_validated", "server_runtime"],
|
|
342
|
+
"server_email": ["workspace_validated", "server_runtime"],
|
|
343
|
+
"server_syslog": ["workspace_validated", "server_runtime"],
|
|
344
|
+
"server_aaa": ["workspace_validated", "server_runtime"],
|
|
345
|
+
"wireless_mutation": ["workspace_validated", "wireless_runtime"],
|
|
346
|
+
"wireless_client_association": ["workspace_validated", "wireless_runtime"],
|
|
347
|
+
"iot_registration": ["workspace_validated", "iot_runtime"],
|
|
348
|
+
"iot_control": ["workspace_validated", "iot_runtime"],
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _generate_mismatch_reason(status: dict[str, object]) -> str | None:
|
|
353
|
+
if not bool(status.get("inventory_supported")):
|
|
354
|
+
return "unsupported"
|
|
355
|
+
if bool(status.get("edit_supported")) and not bool(status.get("safe_open_generate_supported")):
|
|
356
|
+
return "supported_in_edit_only"
|
|
357
|
+
if bool(status.get("safe_open_generate_supported")) and bool(status.get("requires_curated_donor")):
|
|
358
|
+
return "supported_but_donor_limited"
|
|
359
|
+
if bool(status.get("safe_open_generate_supported")) and bool(status.get("requires_manual_acceptance")) and not bool(status.get("acceptance_verified")):
|
|
360
|
+
return "supported_but_acceptance_gated"
|
|
361
|
+
if bool(status.get("safe_open_generate_supported")):
|
|
362
|
+
return None
|
|
363
|
+
return "unsupported"
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _best_maturity_level(status: dict[str, object]) -> str:
|
|
367
|
+
level = str(status.get("best_maturity_level") or "").strip()
|
|
368
|
+
if level in MATURITY_RANK:
|
|
369
|
+
return level
|
|
370
|
+
if bool(status.get("acceptance_verified")):
|
|
371
|
+
return "acceptance-verified"
|
|
372
|
+
if bool(status.get("safe_open_generate_supported")):
|
|
373
|
+
return "safe-open-generate-supported"
|
|
374
|
+
if bool(status.get("config_mutation_supported")):
|
|
375
|
+
return "config-mutation-supported"
|
|
376
|
+
if bool(status.get("edit_supported")):
|
|
377
|
+
return "edit-supported"
|
|
378
|
+
if bool(status.get("inventory_supported")):
|
|
379
|
+
return "inventory-supported"
|
|
380
|
+
return "reference-known"
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _recommended_next_action_for_capability(capability: str, mismatch_reason: str | None) -> str:
|
|
384
|
+
if mismatch_reason == "supported_in_edit_only":
|
|
385
|
+
return f"Use donor-backed edit flow for {capability} until safe-open generate coverage is raised."
|
|
386
|
+
if mismatch_reason == "supported_but_donor_limited":
|
|
387
|
+
return f"Provide or curate a donor whose skeleton explicitly covers {capability}."
|
|
388
|
+
if mismatch_reason == "supported_but_acceptance_gated":
|
|
389
|
+
return f"Keep {capability} in explain/edit flow until acceptance evidence is promoted."
|
|
390
|
+
return f"Import or curate donor coverage for {capability} before strict generate."
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _scenario_generate_readiness(
|
|
394
|
+
scenario_family: str | None,
|
|
395
|
+
capability_statuses: list[dict[str, object]],
|
|
396
|
+
unsupported_capabilities: list[str],
|
|
397
|
+
requires_curated_donor: list[str],
|
|
398
|
+
requires_manual_acceptance: list[str],
|
|
399
|
+
) -> dict[str, object]:
|
|
400
|
+
if not scenario_family:
|
|
401
|
+
return {
|
|
402
|
+
"family": None,
|
|
403
|
+
"status": "not_classified",
|
|
404
|
+
"critical_capabilities": [],
|
|
405
|
+
"missing_critical_capabilities": [],
|
|
406
|
+
"reasons": [],
|
|
407
|
+
}
|
|
408
|
+
critical_set = SCENARIO_CAPABILITY_SETS.get(scenario_family, set())
|
|
409
|
+
status_by_capability = {str(item.get("capability")): item for item in capability_statuses}
|
|
410
|
+
relevant_capabilities = [cap for cap in status_by_capability if cap in critical_set]
|
|
411
|
+
missing_critical = [cap for cap in unsupported_capabilities if cap in critical_set]
|
|
412
|
+
donor_limited_critical = [cap for cap in requires_curated_donor if cap in critical_set]
|
|
413
|
+
gated_critical = [cap for cap in requires_manual_acceptance if cap in critical_set and cap not in donor_limited_critical]
|
|
414
|
+
reasons: list[str] = []
|
|
415
|
+
if missing_critical:
|
|
416
|
+
reasons.append(f"missing critical capability coverage: {', '.join(missing_critical)}")
|
|
417
|
+
if donor_limited_critical:
|
|
418
|
+
reasons.append(f"critical capabilities depend on donor-limited safe-open coverage: {', '.join(donor_limited_critical)}")
|
|
419
|
+
if gated_critical:
|
|
420
|
+
reasons.append(f"critical capabilities remain acceptance-gated: {', '.join(gated_critical)}")
|
|
421
|
+
if missing_critical:
|
|
422
|
+
status = "unsupported"
|
|
423
|
+
elif donor_limited_critical:
|
|
424
|
+
status = "donor_limited"
|
|
425
|
+
elif gated_critical:
|
|
426
|
+
status = "acceptance_gated"
|
|
427
|
+
elif relevant_capabilities:
|
|
428
|
+
status = "ready"
|
|
429
|
+
else:
|
|
430
|
+
status = "partial"
|
|
431
|
+
reasons.append("scenario family is recognized, but no critical capability set was requested explicitly.")
|
|
432
|
+
return {
|
|
433
|
+
"family": scenario_family,
|
|
434
|
+
"status": status,
|
|
435
|
+
"critical_capabilities": relevant_capabilities,
|
|
436
|
+
"missing_critical_capabilities": missing_critical,
|
|
437
|
+
"reasons": reasons,
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _provider_families_for_capability(capability: str, requested_families: list[str]) -> set[str]:
|
|
442
|
+
mapped = CAPABILITY_PROVIDER_FAMILIES.get(capability)
|
|
443
|
+
if mapped:
|
|
444
|
+
return set(mapped)
|
|
445
|
+
return set(requested_families)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _normalize_media_name(value: str) -> str:
|
|
449
|
+
media = value.strip().lower().replace("_", "-").replace(" ", "-")
|
|
450
|
+
aliases = {
|
|
451
|
+
"estraightthrough": "straight-through",
|
|
452
|
+
"copper-straight-through": "straight-through",
|
|
453
|
+
"straight-through": "straight-through",
|
|
454
|
+
"ecrossover": "crossover",
|
|
455
|
+
"copper-crossover": "crossover",
|
|
456
|
+
"crossover": "crossover",
|
|
457
|
+
"fiber": "fiber",
|
|
458
|
+
"efiber": "fiber",
|
|
459
|
+
"serial": "serial",
|
|
460
|
+
"eserialdce": "serial-dce",
|
|
461
|
+
"serial-dce": "serial-dce",
|
|
462
|
+
"eserialdte": "serial-dte",
|
|
463
|
+
"serial-dte": "serial-dte",
|
|
464
|
+
}
|
|
465
|
+
compact = media.replace("-", "")
|
|
466
|
+
return aliases.get(media, aliases.get(compact, media))
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _pair_key(left: str, right: str) -> str:
|
|
470
|
+
return " <-> ".join(sorted((left, right)))
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def select_capability_matrix_hits(
|
|
474
|
+
plan: IntentPlan,
|
|
475
|
+
samples: list[SampleDescriptor],
|
|
476
|
+
) -> list[CapabilityMatrixEntry]:
|
|
477
|
+
matrix = build_capability_matrix(samples)
|
|
478
|
+
requested_capabilities = set(plan.capabilities)
|
|
479
|
+
if not requested_capabilities:
|
|
480
|
+
return []
|
|
481
|
+
requested_families = _requested_device_families(plan)
|
|
482
|
+
hits: list[CapabilityMatrixEntry] = []
|
|
483
|
+
seen: set[tuple[str, str]] = set()
|
|
484
|
+
for entry in matrix:
|
|
485
|
+
if entry.capability not in requested_capabilities:
|
|
486
|
+
continue
|
|
487
|
+
provider_families = _provider_families_for_capability(entry.capability, requested_families)
|
|
488
|
+
if provider_families and entry.device_family not in provider_families:
|
|
489
|
+
continue
|
|
490
|
+
key = (entry.device_family, entry.capability)
|
|
491
|
+
if key in seen:
|
|
492
|
+
continue
|
|
493
|
+
hits.append(entry)
|
|
494
|
+
seen.add(key)
|
|
495
|
+
return hits
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def build_blueprint_plan(plan: IntentPlan, blueprint: dict[str, object] | None = None) -> BlueprintPlan:
|
|
499
|
+
devices = [dict(device) for device in (blueprint or {}).get("devices", [])]
|
|
500
|
+
if not devices:
|
|
501
|
+
devices = [
|
|
502
|
+
{"type": device_type, "count": count}
|
|
503
|
+
for device_type, count in sorted(plan.device_requirements.items())
|
|
504
|
+
if count
|
|
505
|
+
]
|
|
506
|
+
links = [dict(link) for link in (blueprint or {}).get("links", [])]
|
|
507
|
+
if not links:
|
|
508
|
+
links = [dict(link) for link in plan.links]
|
|
509
|
+
required_pairs: list[str] = []
|
|
510
|
+
for link in links:
|
|
511
|
+
left = str(link.get("a", {}).get("dev", ""))
|
|
512
|
+
right = str(link.get("b", {}).get("dev", ""))
|
|
513
|
+
if left and right:
|
|
514
|
+
required_pairs.append(" <-> ".join(sorted((left, right))))
|
|
515
|
+
return BlueprintPlan(
|
|
516
|
+
requested_devices=devices,
|
|
517
|
+
requested_links=links,
|
|
518
|
+
required_capabilities=sorted(dict.fromkeys(plan.capabilities)),
|
|
519
|
+
required_donor_capacity={key: value for key, value in sorted(plan.device_requirements.items()) if value},
|
|
520
|
+
required_link_pairs=sorted(dict.fromkeys(required_pairs)),
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def build_coverage_gap_report(
|
|
525
|
+
plan: IntentPlan,
|
|
526
|
+
samples: list[SampleDescriptor],
|
|
527
|
+
*,
|
|
528
|
+
selected_donor: str | None = None,
|
|
529
|
+
) -> CoverageGapReport:
|
|
530
|
+
iot_acceptance_gated = {"iot", "iot_registration", "iot_control"}
|
|
531
|
+
matrix = build_capability_matrix(samples)
|
|
532
|
+
by_capability: dict[str, list[CapabilityMatrixEntry]] = {}
|
|
533
|
+
by_family: dict[str, list[CapabilityMatrixEntry]] = {}
|
|
534
|
+
for entry in matrix:
|
|
535
|
+
by_capability.setdefault(entry.capability, []).append(entry)
|
|
536
|
+
by_family.setdefault(entry.device_family, []).append(entry)
|
|
537
|
+
|
|
538
|
+
requested_families = _requested_device_families(plan)
|
|
539
|
+
requested_capabilities = sorted(dict.fromkeys(plan.capabilities))
|
|
540
|
+
supported_capabilities: list[str] = []
|
|
541
|
+
unsupported_capabilities: list[str] = []
|
|
542
|
+
requires_curated_donor: list[str] = []
|
|
543
|
+
requires_manual_acceptance: list[str] = []
|
|
544
|
+
capability_statuses: list[dict[str, object]] = []
|
|
545
|
+
for capability in requested_capabilities:
|
|
546
|
+
provider_families = _provider_families_for_capability(capability, requested_families)
|
|
547
|
+
matching_entries = [
|
|
548
|
+
entry
|
|
549
|
+
for entry in by_capability.get(capability, [])
|
|
550
|
+
if not provider_families or entry.device_family in provider_families
|
|
551
|
+
]
|
|
552
|
+
if matching_entries:
|
|
553
|
+
supported_capabilities.append(capability)
|
|
554
|
+
best_entry = max(matching_entries, key=lambda entry: MATURITY_RANK[entry.maturity_level])
|
|
555
|
+
acceptance_verified = any(entry.maturity_level == "acceptance-verified" for entry in matching_entries) and capability not in iot_acceptance_gated
|
|
556
|
+
if best_entry.maturity_level == "safe-open-generate-supported":
|
|
557
|
+
requires_curated_donor.append(capability)
|
|
558
|
+
if not acceptance_verified:
|
|
559
|
+
requires_manual_acceptance.append(capability)
|
|
560
|
+
capability_statuses.append(
|
|
561
|
+
{
|
|
562
|
+
"capability": capability,
|
|
563
|
+
"provider_families": sorted(provider_families),
|
|
564
|
+
"matching_device_families": sorted({entry.device_family for entry in matching_entries}),
|
|
565
|
+
"best_maturity_level": best_entry.maturity_level,
|
|
566
|
+
"inventory_supported": any(MATURITY_RANK[entry.maturity_level] >= MATURITY_RANK["inventory-supported"] for entry in matching_entries),
|
|
567
|
+
"edit_supported": any(MATURITY_RANK[entry.maturity_level] >= MATURITY_RANK["config-mutation-supported"] for entry in matching_entries),
|
|
568
|
+
"config_mutation_supported": any(MATURITY_RANK[entry.maturity_level] >= MATURITY_RANK["config-mutation-supported"] for entry in matching_entries),
|
|
569
|
+
"safe_open_generate_supported": any(MATURITY_RANK[entry.maturity_level] >= MATURITY_RANK["safe-open-generate-supported"] for entry in matching_entries),
|
|
570
|
+
"acceptance_verified": acceptance_verified,
|
|
571
|
+
"requires_curated_donor": best_entry.maturity_level == "safe-open-generate-supported",
|
|
572
|
+
"requires_manual_acceptance": not acceptance_verified,
|
|
573
|
+
"recommended_donors": list(dict.fromkeys(donor for entry in matching_entries for donor in entry.accepted_donors))[:10],
|
|
574
|
+
"known_limitations": list(dict.fromkeys(item for entry in matching_entries for item in entry.known_limitations)),
|
|
575
|
+
}
|
|
576
|
+
)
|
|
577
|
+
else:
|
|
578
|
+
unsupported_capabilities.append(capability)
|
|
579
|
+
capability_statuses.append(
|
|
580
|
+
{
|
|
581
|
+
"capability": capability,
|
|
582
|
+
"provider_families": sorted(provider_families),
|
|
583
|
+
"matching_device_families": [],
|
|
584
|
+
"best_maturity_level": "reference-known",
|
|
585
|
+
"inventory_supported": False,
|
|
586
|
+
"edit_supported": False,
|
|
587
|
+
"config_mutation_supported": False,
|
|
588
|
+
"safe_open_generate_supported": False,
|
|
589
|
+
"acceptance_verified": False,
|
|
590
|
+
"requires_curated_donor": False,
|
|
591
|
+
"requires_manual_acceptance": True,
|
|
592
|
+
"recommended_donors": [],
|
|
593
|
+
"known_limitations": ["no matching donor coverage found"],
|
|
594
|
+
}
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
unsupported_families = [family for family in requested_families if family not in by_family]
|
|
598
|
+
|
|
599
|
+
requires_curated_donor = sorted(dict.fromkeys(requires_curated_donor))
|
|
600
|
+
requires_manual_acceptance = sorted(dict.fromkeys(requires_manual_acceptance))
|
|
601
|
+
capability_parity = [
|
|
602
|
+
{
|
|
603
|
+
"capability": str(status.get("capability")),
|
|
604
|
+
"inventory_supported": bool(status.get("inventory_supported")),
|
|
605
|
+
"edit_supported": bool(status.get("edit_supported")),
|
|
606
|
+
"generate_supported": bool(status.get("safe_open_generate_supported")),
|
|
607
|
+
"acceptance_verified": bool(status.get("acceptance_verified")),
|
|
608
|
+
"best_maturity_level": _best_maturity_level(status),
|
|
609
|
+
"generate_mismatch_reason": _generate_mismatch_reason(status),
|
|
610
|
+
"required_donor_families": sorted(_provider_families_for_capability(str(status.get("capability")), requested_families)),
|
|
611
|
+
"required_archetypes": CAPABILITY_REQUIRED_ARCHETYPES.get(str(status.get("capability")), []),
|
|
612
|
+
"required_runtime_features": CAPABILITY_REQUIRED_RUNTIME_FEATURES.get(str(status.get("capability")), []),
|
|
613
|
+
"recommended_next_action": _recommended_next_action_for_capability(
|
|
614
|
+
str(status.get("capability")),
|
|
615
|
+
_generate_mismatch_reason(status),
|
|
616
|
+
),
|
|
617
|
+
}
|
|
618
|
+
for status in capability_statuses
|
|
619
|
+
]
|
|
620
|
+
scenario_family = _scenario_family_for_plan(plan, requested_families)
|
|
621
|
+
if selected_donor is None and plan.blocked_mutations:
|
|
622
|
+
requires_manual_acceptance = sorted(dict.fromkeys([*requires_manual_acceptance, *plan.blocked_mutations]))
|
|
623
|
+
scenario_generate_readiness = _scenario_generate_readiness(
|
|
624
|
+
scenario_family,
|
|
625
|
+
capability_statuses,
|
|
626
|
+
unsupported_capabilities,
|
|
627
|
+
requires_curated_donor,
|
|
628
|
+
requires_manual_acceptance,
|
|
629
|
+
)
|
|
630
|
+
recommended_next_actions: list[str] = []
|
|
631
|
+
if unsupported_capabilities or unsupported_families:
|
|
632
|
+
recommended_next_actions.append(
|
|
633
|
+
"Inspect the blueprint and import or provide a donor that contains the missing capabilities or device families."
|
|
634
|
+
)
|
|
635
|
+
if requires_manual_acceptance:
|
|
636
|
+
recommended_next_actions.append(
|
|
637
|
+
"Run explain-plan or validate-open-debug before enabling the acceptance-gated capability set."
|
|
638
|
+
)
|
|
639
|
+
if not requested_capabilities and not requested_families:
|
|
640
|
+
recommended_next_actions.append("Add explicit device, service, or topology requirements so donor ranking can be constrained.")
|
|
641
|
+
|
|
642
|
+
return CoverageGapReport(
|
|
643
|
+
requested_capabilities=requested_capabilities,
|
|
644
|
+
supported_capabilities=supported_capabilities,
|
|
645
|
+
unsupported_capabilities=unsupported_capabilities,
|
|
646
|
+
requested_device_families=requested_families,
|
|
647
|
+
unsupported_device_families=unsupported_families,
|
|
648
|
+
requires_curated_donor=requires_curated_donor,
|
|
649
|
+
requires_manual_acceptance=requires_manual_acceptance,
|
|
650
|
+
capability_statuses=capability_statuses,
|
|
651
|
+
capability_parity=capability_parity,
|
|
652
|
+
scenario_family=scenario_family,
|
|
653
|
+
scenario_generate_readiness=scenario_generate_readiness,
|
|
654
|
+
recommended_next_actions=recommended_next_actions,
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def build_inventory_capability_report(payload: dict[str, Any]) -> dict[str, object]:
|
|
659
|
+
device_counts = dict(payload.get("topology_summary", {}).get("device_counts", {}))
|
|
660
|
+
families = sorted({_device_family_for_type(device_type) for device_type in device_counts})
|
|
661
|
+
capabilities: set[str] = set()
|
|
662
|
+
if "multilayer switches" in families:
|
|
663
|
+
capabilities.add("multilayer_switching")
|
|
664
|
+
if "wan/cloud/dsl/cable devices" in families:
|
|
665
|
+
capabilities.add("wan")
|
|
666
|
+
if "security devices" in families:
|
|
667
|
+
capabilities.add("security_edge")
|
|
668
|
+
if payload.get("vlans"):
|
|
669
|
+
capabilities.update(["vlan", "trunk", "access_port"])
|
|
670
|
+
if payload.get("dhcp_pools"):
|
|
671
|
+
capabilities.update(["dhcp_pool", "router_dhcp", "server_dhcp"])
|
|
672
|
+
if payload.get("services"):
|
|
673
|
+
capabilities.update(
|
|
674
|
+
{
|
|
675
|
+
"dns" if "dns_server" in service else service
|
|
676
|
+
for service_names in payload["services"].values()
|
|
677
|
+
for service in service_names
|
|
678
|
+
}
|
|
679
|
+
)
|
|
680
|
+
if payload.get("wireless"):
|
|
681
|
+
capabilities.update(["wireless_ap", "wireless_mutation", "wireless_client"])
|
|
682
|
+
if any(family == "end devices" for family in families):
|
|
683
|
+
capabilities.add("wireless_client_association")
|
|
684
|
+
if payload.get("iot"):
|
|
685
|
+
capabilities.add("iot")
|
|
686
|
+
iot_roles = {str(entry.get("role", "")).strip() for entry in payload["iot"].values() if str(entry.get("role", "")).strip()}
|
|
687
|
+
if "thing" in iot_roles and ("gateway" in iot_roles or "server" in iot_roles):
|
|
688
|
+
capabilities.add("iot_registration")
|
|
689
|
+
if "thing" in iot_roles and ("gateway" in iot_roles or "server" in iot_roles):
|
|
690
|
+
capabilities.add("iot_control")
|
|
691
|
+
if any(family == "end devices" for family in families):
|
|
692
|
+
capabilities.add("end_device_mutation")
|
|
693
|
+
if payload.get("acl_names"):
|
|
694
|
+
capabilities.add("acl")
|
|
695
|
+
if payload.get("topology_summary", {}).get("network_style") == "wan_security":
|
|
696
|
+
capabilities.update({"vpn", "ipsec", "gre", "ppp"})
|
|
697
|
+
return {
|
|
698
|
+
"device_families": families,
|
|
699
|
+
"capabilities": sorted(capabilities),
|
|
700
|
+
"device_counts": device_counts,
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def build_donor_graph_fit(sample: SampleDescriptor, blueprint: dict[str, object] | None = None) -> DonorGraphFit:
|
|
705
|
+
expected_pairs: list[str] = []
|
|
706
|
+
expected_media: dict[str, str] = {}
|
|
707
|
+
expected_ports: dict[str, set[str]] = {}
|
|
708
|
+
blueprint_archetype = ""
|
|
709
|
+
blueprint_device_count = 0
|
|
710
|
+
if blueprint:
|
|
711
|
+
blueprint_archetype = str(blueprint.get("topology_archetype", "")).strip()
|
|
712
|
+
blueprint_device_count = len(list(blueprint.get("devices", [])))
|
|
713
|
+
for link in blueprint.get("links", []):
|
|
714
|
+
left = str(link.get("a", {}).get("dev", ""))
|
|
715
|
+
right = str(link.get("b", {}).get("dev", ""))
|
|
716
|
+
if left and right:
|
|
717
|
+
pair = _pair_key(left, right)
|
|
718
|
+
expected_pairs.append(pair)
|
|
719
|
+
media = _normalize_media_name(str(link.get("media", "")))
|
|
720
|
+
if media:
|
|
721
|
+
expected_media[pair] = media
|
|
722
|
+
ports = {
|
|
723
|
+
str(link.get("a", {}).get("port", "")).strip(),
|
|
724
|
+
str(link.get("b", {}).get("port", "")).strip(),
|
|
725
|
+
}
|
|
726
|
+
expected_ports[pair] = {port for port in ports if port}
|
|
727
|
+
sample_pairs: set[str] = set()
|
|
728
|
+
sample_media: dict[str, str] = {}
|
|
729
|
+
sample_ports: dict[str, set[str]] = {}
|
|
730
|
+
for link in sample.links:
|
|
731
|
+
left = str(link.get("from", "")).strip()
|
|
732
|
+
right = str(link.get("to", "")).strip()
|
|
733
|
+
if left and right:
|
|
734
|
+
pair = _pair_key(left, right)
|
|
735
|
+
sample_pairs.add(pair)
|
|
736
|
+
media = _normalize_media_name(str(link.get("media", "") or link.get("cable_type", "")))
|
|
737
|
+
if media and pair not in sample_media:
|
|
738
|
+
sample_media[pair] = media
|
|
739
|
+
ports = {str(port).strip() for port in link.get("ports", []) if str(port).strip()}
|
|
740
|
+
if ports:
|
|
741
|
+
sample_ports.setdefault(pair, set()).update(ports)
|
|
742
|
+
matched_pairs = [pair for pair in expected_pairs if pair in sample_pairs]
|
|
743
|
+
missing_pairs = [pair for pair in expected_pairs if pair not in sample_pairs]
|
|
744
|
+
port_media_conflicts: list[str] = []
|
|
745
|
+
for pair in matched_pairs:
|
|
746
|
+
expected_pair_media = expected_media.get(pair)
|
|
747
|
+
sample_pair_media = sample_media.get(pair)
|
|
748
|
+
if expected_pair_media and sample_pair_media and expected_pair_media != sample_pair_media:
|
|
749
|
+
port_media_conflicts.append(f"{pair} media mismatch ({expected_pair_media} != {sample_pair_media})")
|
|
750
|
+
expected_pair_ports = expected_ports.get(pair, set())
|
|
751
|
+
sample_pair_ports = sample_ports.get(pair, set())
|
|
752
|
+
if expected_pair_ports and sample_pair_ports and not expected_pair_ports.issubset(sample_pair_ports):
|
|
753
|
+
port_media_conflicts.append(
|
|
754
|
+
f"{pair} port mismatch (expected {sorted(expected_pair_ports)}, available {sorted(sample_pair_ports)})"
|
|
755
|
+
)
|
|
756
|
+
fit_score = max(0, len(matched_pairs) * 10 - len(missing_pairs) * 5 - len(port_media_conflicts) * 3)
|
|
757
|
+
topology_bonus = 0
|
|
758
|
+
if blueprint_archetype:
|
|
759
|
+
sample_topology = set(sample.topology_tags)
|
|
760
|
+
if blueprint_archetype in sample_topology:
|
|
761
|
+
topology_bonus += 18
|
|
762
|
+
elif blueprint_archetype == "chain" and sample_topology & {"department_lan", "core_access"}:
|
|
763
|
+
topology_bonus += 12
|
|
764
|
+
elif blueprint_archetype == "core_access" and sample_topology & {"department_lan", "router_on_a_stick"}:
|
|
765
|
+
topology_bonus += 10
|
|
766
|
+
elif blueprint_archetype == "wireless_branch" and sample_topology & {"wireless_edge", "small_office"}:
|
|
767
|
+
topology_bonus += 10
|
|
768
|
+
capacity_penalty = max(0, blueprint_device_count - sample.device_count) * 2
|
|
769
|
+
layout_reuse_score = max(
|
|
770
|
+
0,
|
|
771
|
+
len(matched_pairs) * 12 + topology_bonus - len(missing_pairs) * 6 - len(port_media_conflicts) * 4 - capacity_penalty,
|
|
772
|
+
)
|
|
773
|
+
return DonorGraphFit(
|
|
774
|
+
matched_pairs=matched_pairs,
|
|
775
|
+
missing_pairs=missing_pairs,
|
|
776
|
+
port_media_conflicts=port_media_conflicts,
|
|
777
|
+
fit_score=fit_score,
|
|
778
|
+
layout_reuse_score=layout_reuse_score,
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def asdict_list(items: list[Any]) -> list[dict[str, object]]:
|
|
783
|
+
return [asdict(item) for item in items]
|