packet-tracer-skill 0.2.3 → 0.3.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +424 -73
  2. package/README.md +557 -442
  3. package/SKILL.md +337 -262
  4. package/bin/packet-tracer-skill.js +29 -2
  5. package/docs/github-launch-ops-0.2.3.md +37 -0
  6. package/docs/github-metadata.md +6 -4
  7. package/docs/hero-demo-plan.md +1 -1
  8. package/docs/home-iot-donor-proof.md +4 -4
  9. package/docs/l2-security-qos-proof.md +1 -1
  10. package/docs/packet-tracer-feature-gap-atlas.md +4 -4
  11. package/docs/post-launch-follow-up.md +9 -5
  12. package/docs/proof-readiness-dashboard.md +69 -0
  13. package/docs/publish-preview-roadmap.md +6 -5
  14. package/docs/release-checklist.md +17 -8
  15. package/docs/release-notes-0.2.4.md +20 -0
  16. package/docs/runtime-truth.md +33 -8
  17. package/docs/security-edge-deepening-proof.md +1 -1
  18. package/examples/README.md +98 -69
  19. package/examples/complex_campus_master_edit_v4.inventory.json +12 -2
  20. package/examples/gallery.md +94 -6
  21. package/examples/home_iot_cli_edit_v1.inventory.json +11 -2
  22. package/examples/index.json +932 -4
  23. package/examples/local-sample-evidence.json +24 -0
  24. package/examples/proof-cards.json +117 -0
  25. package/examples/service_heavy_cli_edit_v1.inventory.json +11 -2
  26. package/package.json +60 -53
  27. package/pytest.ini +9 -0
  28. package/references/packettracer-sample-catalog.json +45287 -4525
  29. package/references/packettracer-sample-catalog.md +599 -259
  30. package/references/proof-readiness-candidates.json +352 -0
  31. package/scripts/build_examples_index.py +228 -35
  32. package/scripts/build_sample_catalog.py +24 -44
  33. package/scripts/corpus_runner.py +430 -0
  34. package/scripts/coverage_matrix.py +1842 -1812
  35. package/scripts/donor_cache.py +354 -0
  36. package/scripts/donor_diagnostics.py +3 -1
  37. package/scripts/generate_pkt.py +8762 -4228
  38. package/scripts/intent_parser.py +2242 -1657
  39. package/scripts/local_donors.py +340 -0
  40. package/scripts/packet_tracer_env.py +846 -391
  41. package/scripts/pkt_annotate.py +218 -0
  42. package/scripts/pkt_codec.py +420 -181
  43. package/scripts/pkt_editor.py +2405 -1703
  44. package/scripts/pkt_transformer.py +1072 -727
  45. package/scripts/pkt_verify.py +461 -0
  46. package/scripts/runtime_doctor.py +80 -29
  47. package/scripts/sample_catalog.py +1372 -1250
  48. package/scripts/twofish_diagnostics.py +48 -31
  49. package/scripts/usage_ledger.py +218 -0
  50. package/scripts/vendor/README.md +44 -37
  51. package/scripts/vendor/twofish_pure.py +321 -0
  52. package/scripts/workspace_repair.py +548 -508
  53. package/templates/pt900/donors/README.md +15 -0
@@ -1,1657 +1,2242 @@
1
- from __future__ import annotations
2
-
3
- import re
4
- import unicodedata
5
- from dataclasses import asdict, dataclass, field
6
-
7
-
8
- CAPABILITY_PATTERNS = {
9
- "vlan": [r"\bvlan(?:larda|lar|da|de|a|e)?\b", r"\btrunk\b", r"\baccess port\b", r"\brouter-on-a-stick\b", r"\bdot1q\b"],
10
- "trunk": [r"\btrunk\b", r"\ballowed vlan\b", r"\bnative vlan\b"],
11
- "access_port": [r"\baccess port\b", r"\baccess-port\b"],
12
- "router_on_a_stick": [r"\brouter-on-a-stick\b", r"\bsubinterface\b", r"\bdot1q\b"],
13
- "dhcp_pool": [r"\bdhcp\b", r"\bdhcp pool\b"],
14
- "router_dhcp": [r"\bdhcp\b", r"\bdhcp pool\b", r"\bdefault-router\b"],
15
- "server_dhcp": [r"\bserver dhcp\b", r"\bdhcp server\b"],
16
- "dns": [r"\bdns\b", r"\ba record\b", r"\bcname\b"],
17
- "server_dns": [r"\bdns\b", r"\ba record\b", r"\bcname\b"],
18
- "server_http": [r"\bhttp\b", r"\bweb\b"],
19
- "server_ftp": [r"\bftp\b", r"\btftp\b"],
20
- "server_https": [r"\bhttps\b"],
21
- "server_tftp": [r"\btftp\b"],
22
- "server_email": [r"\bemail\b", r"\bsmtp\b", r"\bpop3\b"],
23
- "server_syslog": [r"\bsyslog\b"],
24
- "server_aaa": [r"\baaa\b", r"\bacs\b", r"\bradius\b"],
25
- "iot": [r"\biot\b", r"\bhome gateway\b", r"\bsmart home\b", r"\bsensor\b", r"\bactuator\b", r"\bmcu\b"],
26
- "iot_registration": [r"\biot registration\b", r"\bregister\b", r"\bregistration server\b"],
27
- "iot_control": [r"\biot control\b", r"\bcontrol\b", r"\btrigger\b", r"\bturn on\b", r"\bturn off\b", r"\block\b", r"\bunlock\b"],
28
- "management_vlan": [r"\bmanagement vlan\b", r"\bvlan 99\b", r"\bdefault-gateway\b"],
29
- "telnet": [r"\btelnet\b", r"\bvty\b", r"\btransport input telnet\b"],
30
- "wireless_ap": [r"\bssid\b", r"\bwifi\b", r"\bwpa\b", r"\bwpa2\b", r"\bwireless\b", r"\baccess point\b", r"\bap\b"],
31
- "wireless_mutation": [r"\bset\s+[A-Za-z0-9_ -]+?\s+ssid\b", r"\bsecurity\s+(?:wpa|wpa2|wpa-enterprise|wpa2-enterprise|wep|open|802\.1x)\b", r"\bpassphrase\b", r"\bchannel\s+\d+\b"],
32
- "wireless_client": [r"\btablet\b", r"\blaptop\b", r"\bsmartphone\b", r"\bassociate\b", r"\bjoin\b"],
33
- "wireless_client_association": [r"\bassociate\b", r"\bjoin\b"],
34
- "tablet": [r"\btablet\b"],
35
- "printer": [r"\bprinter\b"],
36
- "end_device_mutation": [r"\bset\s+[A-Za-z0-9_-]+\s+ip\b", r"\bset\s+[A-Za-z0-9_-]+\s+ipv4\s+dhcp\b", r"\bset\s+[A-Za-z0-9_-]+\s+dns\b"],
37
- "verification": [r"\bshow vlan brief\b", r"\bshow interfaces trunk\b", r"\bshow ip interface brief\b", r"\bshow ip dhcp binding\b", r"\bping\b", r"\btelnet\b"],
38
- "ospf": [r"\bospf\b"],
39
- "eigrp": [r"\beigrp\b"],
40
- "rip": [r"\brip\b"],
41
- "ospfv2": [r"\bospfv2\b", r"\bospf v2\b", r"\bsingle-area ospf\b", r"\brouter ospf\b"],
42
- "eigrp_ipv4": [r"\beigrp ipv4\b", r"\bipv4 eigrp\b", r"\brouter eigrp\b"],
43
- "ripv2": [r"\bripv2\b", r"\brip v2\b", r"\brip version 2\b", r"\brouter rip\b"],
44
- "static_route": [r"\bstatic route\b", r"\bstatic-route\b", r"\bip route\b"],
45
- "default_route": [r"\bdefault route\b", r"\bdefault-route\b", r"\b0\.0\.0\.0/0\b"],
46
- "dhcp_relay": [r"\bdhcp relay\b", r"\bdhcp-relay\b", r"\bhelper-address\b", r"\bip helper\b"],
47
- "nat_static": [r"\bstatic nat\b", r"\bnat static\b"],
48
- "nat_dynamic": [r"\bdynamic nat\b", r"\bnat dynamic\b"],
49
- "pat": [r"\bpat\b", r"\boverload\b"],
50
- "ssh_ios": [r"\bssh\b", r"\bip ssh\b", r"\bcrypto key\b"],
51
- "ntp_ios": [r"\bntp\b", r"\bntp server\b"],
52
- "syslog_ios": [r"\bsyslog\b", r"\blogging host\b"],
53
- "ipv6_slaac": [r"\bslaac\b", r"\bipv6\s+slaac\b"],
54
- "dhcpv6_stateful": [r"\bdhcpv6\b", r"\bstateful dhcpv6\b", r"\bipv6 stateful\b"],
55
- "dhcpv6_stateless": [r"\bstateless dhcpv6\b", r"\bipv6 stateless\b"],
56
- "ipv6_prefix_delegation": [r"\bprefix delegation\b", r"\bipv6 prefix\b"],
57
- "ipv6_dns_aaaa": [r"\baaaa\b", r"\bipv6 dns\b"],
58
- "ipv6_tunneling": [r"\bipv6ip\b", r"\bipv6 tunnel\b", r"\bipv6 tunneling\b"],
59
- "isatap": [r"\bisatap\b"],
60
- "ospfv3": [r"\bospfv3\b", r"\bipv6 ospf\b"],
61
- "eigrp_ipv6": [r"\bipv6 eigrp\b", r"\beigrp ipv6\b"],
62
- "ripng": [r"\bripng\b", r"\bipv6 rip\b"],
63
- "hsrp": [r"\bhsrp\b"],
64
- "nat": [r"\bnat\b"],
65
- "acl": [r"\bacl\b", r"\baccess-list\b"],
66
- "dhcp_snooping": [r"\bdhcp snooping\b"],
67
- "dai": [r"\bdai\b", r"\bdynamic arp inspection\b"],
68
- "dot1x": [r"\bdot1x\b", r"\b802\.1x\b", r"\bnac\b"],
69
- "lldp": [r"\blldp\b"],
70
- "rep": [r"\brep\b"],
71
- "snmp": [r"\bsnmp\b"],
72
- "netflow": [r"\bnetflow\b"],
73
- "span": [r"\bmonitor\s+session\b", r"\bspan\b", r"\brspan\b"],
74
- "qos": [r"\bqos policy\b", r"\bqos class\b", r"\bclass-map\b", r"\bpolicy-map\b", r"\bquality of service\b"],
75
- "port_security": [r"\bport security\b", r"\bport-security\b"],
76
- "bgp": [r"\bbgp\b", r"\brouter bgp\b", r"\bremote-as\b"],
77
- "stp": [r"\bstp\b", r"\bspanning tree\b", r"\bspanning-tree\b", r"\bpvst\b"],
78
- "rstp": [r"\brstp\b", r"\brapid-pvst\b", r"\brapid pvst\b", r"\brapid spanning\b"],
79
- "etherchannel": [r"\betherchannel\b", r"\bport-channel\b", r"\bchannel-group\b"],
80
- "lacp": [r"\blacp\b"],
81
- "pagp": [r"\bpagp\b"],
82
- "vtp": [r"\bvtp\b"],
83
- "dtp": [r"\bdtp\b", r"\bdynamic desirable\b", r"\bdynamic auto\b"],
84
- "vpn": [r"\bvpn\b", r"\bsite-to-site\b", r"\bipsec\b", r"\btunnel\b"],
85
- "ipsec": [r"\bipsec\b", r"\bike\b", r"\bphase 1\b", r"\bphase 2\b"],
86
- "gre": [r"\bgre\b", r"\bgre tunnel\b"],
87
- "ppp": [r"\bppp\b", r"\bchap\b", r"\bpap\b"],
88
- "security_edge": [r"\bsecurity edge\b", r"\bsecurity appliance\b", r"\bfirewall\b", r"\basa\b"],
89
- "asa_acl_nat": [r"\basa acl\b", r"\basa nat\b", r"\basa acl nat\b"],
90
- "asa_service_policy": [r"\basa service policy\b", r"\bservice policy\b"],
91
- "clientless_vpn": [r"\bclientless vpn\b"],
92
- "cbac": [r"\bcbac\b"],
93
- "zfw": [r"\bzfw\b", r"\bzone based firewall\b", r"\bzone-based firewall\b"],
94
- "sniffer": [r"\bsniffer\b"],
95
- "multilayer_switching": [r"\bmultilayer switch\b", r"\blayer 3 switch\b", r"\b3560\b", r"\bsvi\b"],
96
- "wlc": [r"\bwlc\b", r"\bwireless lan controller\b"],
97
- "wpa_enterprise": [r"\bwpa enterprise\b", r"\bwpa2 enterprise\b", r"\bwpa-enterprise\b", r"\bwpa2-enterprise\b", r"\b802\.1x wireless\b"],
98
- "wep": [r"\bwep\b"],
99
- "guest_wifi": [r"\bguest wifi\b", r"\bguest wlan\b"],
100
- "beamforming": [r"\bbeamforming\b"],
101
- "meraki": [r"\bmeraki\b"],
102
- "cellular_5g": [r"\b5g\b", r"\bcellular\b", r"\bcell tower\b"],
103
- "bluetooth": [r"\bbluetooth\b"],
104
- "network_controller": [r"\bnetwork controller\b", r"\bnetcon\b"],
105
- "python_programming": [r"\bpython programming\b", r"\bpython app\b"],
106
- "javascript_programming": [r"\bjavascript\b", r"\bjs app\b"],
107
- "blockly_programming": [r"\bblockly\b"],
108
- "tcp_udp_app": [r"\btcp test app\b", r"\budp test app\b", r"\btcp app\b", r"\budp app\b"],
109
- "vm_iox": [r"\biox\b", r"\bvm\b", r"\bvirtual machine\b"],
110
- "voip": [r"\bvoip\b"],
111
- "ip_phone": [r"\bip phone\b", r"\bphone\b"],
112
- "call_manager": [r"\bcall manager\b", r"\btelephony\b"],
113
- "linksys_voice": [r"\blinksys voice\b"],
114
- "mqtt": [r"\bmqtt\b"],
115
- "real_http": [r"\breal http\b"],
116
- "real_websocket": [r"\bwebsocket\b", r"\breal websocket\b"],
117
- "visual_scripting": [r"\bvisual scripting\b"],
118
- "ptp": [r"\bptp\b", r"\bprecision time protocol\b"],
119
- "profinet": [r"\bprofinet\b"],
120
- "l2nat": [r"\bl2nat\b", r"\bl2 nat\b"],
121
- "cyberobserver": [r"\bcyberobserver\b"],
122
- "industrial_firewall": [r"\bindustrial firewall\b", r"\bisa3000\b"],
123
- "coaxial": [r"\bcoaxial\b"],
124
- "cable_dsl": [r"\bcable modem\b", r"\bdsl\b", r"\bdsl modem\b"],
125
- "central_office": [r"\bcentral office\b"],
126
- "cell_tower": [r"\bcell tower\b"],
127
- "power_distribution": [r"\bpower distribution\b"],
128
- "hot_swappable": [r"\bhot swappable\b", r"\bhot-swappable\b"],
129
- "ios_license": [r"\bios license\b", r"\blicense\b", r"\bios15\b", r"\bios 15\b"],
130
- }
131
-
132
- DEVICE_SYNONYMS = {
133
- "router": "Router",
134
- "switch": "Switch",
135
- "pc": "PC",
136
- "server": "Server",
137
- "access-point": "LightWeightAccessPoint",
138
- "accesspoint": "LightWeightAccessPoint",
139
- "ap": "LightWeightAccessPoint",
140
- "wireless-router": "WirelessRouter",
141
- "wirelessrouter": "WirelessRouter",
142
- "home-router": "WirelessRouter",
143
- "homerouter": "WirelessRouter",
144
- "tablet": "Tablet",
145
- "laptop": "Laptop",
146
- "smartphone": "Smartphone",
147
- "printer": "Printer",
148
- "multilayer-switch": "MultiLayerSwitch",
149
- "multilayerswitch": "MultiLayerSwitch",
150
- "layer3-switch": "MultiLayerSwitch",
151
- "layer-3-switch": "MultiLayerSwitch",
152
- "cloud": "Cloud",
153
- "cable-modem": "Cable Modem",
154
- "cablemodem": "Cable Modem",
155
- "dsl-modem": "Dsl Modem",
156
- "dslmodem": "Dsl Modem",
157
- "security-appliance": "Security Appliance",
158
- "securityappliance": "Security Appliance",
159
- "asa": "ASA",
160
- }
161
-
162
- NATURAL_DEVICE_ALIASES = {
163
- "Router": ["router", "routerler", "routerlerin"],
164
- "Switch": ["switch", "switchler", "switchlerin"],
165
- "PC": ["pc", "pcs", "computer", "computers", "komputer", "komputerler", "kompyuter", "kompyuterler"],
166
- "Server": ["server", "serverler"],
167
- "WirelessRouter": ["wireless router", "wireless-router", "wirelessrouter", "home router", "home-router", "wrt"],
168
- "Tablet": ["tablet", "tabletler"],
169
- "Laptop": ["laptop", "laptoplar"],
170
- "Printer": ["printer", "printerler"],
171
- "LightWeightAccessPoint": ["ap", "aps", "accesspoint", "access-point", "access point", "apler"],
172
- "MultiLayerSwitch": ["multilayer switch", "multilayer-switch", "multilayerswitch", "layer 3 switch", "layer3 switch", "3560"],
173
- "Cloud": ["cloud", "wan cloud"],
174
- "Cable Modem": ["cable modem", "cable-modem", "cablemodem"],
175
- "Dsl Modem": ["dsl modem", "dsl-modem", "dslmodem"],
176
- "ASA": ["asa", "security appliance", "security-appliance", "firewall"],
177
- }
178
-
179
- NETWORK_STYLE_PATTERNS = {
180
- "campus": [r"\bcampus\b", r"\bkampus\b", r"\bsobeli\b", r"\bdepart", r"\bdepartment\b"],
181
- "branch": [r"\bbranch\b", r"\bfilial\b"],
182
- "ipv6_routing": [r"\bipv6\b", r"\bslaac\b", r"\bdhcpv6\b", r"\bospfv3\b", r"\bhsrp\b"],
183
- "ipv4_routing_management": [r"\bospfv2\b", r"\bospf v2\b", r"\beigrp ipv4\b", r"\bripv2\b", r"\brip version 2\b", r"\bstatic route\b", r"\bdefault route\b", r"\bdhcp relay\b", r"\bnat\b", r"\bpat\b", r"\bip ssh\b", r"\bntp server\b", r"\blogging host\b"],
184
- "l2_security_monitoring": [r"\bdhcp snooping\b", r"\bdai\b", r"\bdot1x\b", r"\b802\.1x\b", r"\bsnmp\b", r"\bnetflow\b", r"\bspan\b", r"\brspan\b", r"\bquality of service\b", r"\blldp\b", r"\brep\b", r"\bport security\b", r"\bport-security\b"],
185
- "l2_resiliency_routing": [r"\bbgp\b", r"\bstp\b", r"\brstp\b", r"\bspanning tree\b", r"\bspanning-tree\b", r"\betherchannel\b", r"\bport-channel\b", r"\bchannel-group\b", r"\blacp\b", r"\bpagp\b", r"\bvtp\b", r"\bdtp\b"],
186
- "wireless_advanced": [r"\bwlc\b", r"\bwireless lan controller\b", r"\bmeraki\b", r"\bbluetooth\b", r"\b5g\b", r"\bcellular\b", r"\bwpa enterprise\b", r"\bwpa2 enterprise\b", r"\bwep\b", r"\bguest wifi\b", r"\bguest wlan\b", r"\bbeamforming\b"],
187
- "automation_controller": [r"\bnetwork controller\b", r"\bnetcon\b", r"\bblockly\b", r"\bjavascript\b", r"\bpython programming\b", r"\biox\b"],
188
- "voice_collaboration": [r"\bvoip\b", r"\bip phone\b", r"\bcall manager\b", r"\btelephony\b"],
189
- "industrial_iot": [r"\bmqtt\b", r"\bwebsocket\b", r"\bprofinet\b", r"\bl2nat\b", r"\bptp\b", r"\bcyberobserver\b", r"\bindustrial\b"],
190
- "physical_media_device": [r"\bcoaxial\b", r"\bcable modem\b", r"\bdsl modem\b", r"\bcentral office\b", r"\bcell tower\b", r"\bpower distribution\b"],
191
- "small_office": [r"\bsmall office\b", r"\bhome\b", r"\bofis\b"],
192
- "wireless_branch": [r"\bwireless\b", r"\bwifi\b", r"\bssid\b"],
193
- "wan_security": [r"\bwan\b", r"\bgre\b", r"\bppp\b", r"\bipsec\b", r"\bvpn\b", r"\bsecurity edge\b", r"\bfirewall\b", r"\basa\b"],
194
- }
195
-
196
- PER_DEPARTMENT_DEVICE_ALIASES = {
197
- "PC": ["pc", "komputer", "computer"],
198
- "Printer": ["printer"],
199
- "LightWeightAccessPoint": ["ap", "access point", "accesspoint"],
200
- "Tablet": ["tablet"],
201
- "Laptop": ["laptop"],
202
- "Server": ["server"],
203
- }
204
-
205
- TOPOLOGY_HINT_WORDS = [
206
- "switch",
207
- "switchler",
208
- "router",
209
- "komputer",
210
- "pc",
211
- "server",
212
- "printer",
213
- "ap",
214
- "tablet",
215
- "qosul",
216
- "qo",
217
- "gig",
218
- "fastethernet",
219
- "vlan",
220
- ]
221
-
222
- SECURITY_TO_AUTH = {
223
- "open": ("0", "0"),
224
- "wep": ("1", "1"),
225
- "wpa-psk": ("3", "3"),
226
- "wpa2-psk": ("4", "4"),
227
- "wpa": ("2", "2"),
228
- "wpa2": ("4", "4"),
229
- "802.1x": ("5", "5"),
230
- "wpa-enterprise": ("5", "5"),
231
- "wpa2-enterprise": ("5", "5"),
232
- }
233
-
234
- TRANSLITERATION_TABLE = str.maketrans(
235
- {
236
- "ə": "e",
237
- "Ə": "e",
238
- "ş": "s",
239
- "Ş": "s",
240
- "ı": "i",
241
- "İ": "i",
242
- "ç": "c",
243
- "Ç": "c",
244
- "ö": "o",
245
- "Ö": "o",
246
- "ü": "u",
247
- "Ü": "u",
248
- "ğ": "g",
249
- "Ğ": "g",
250
- "â": "a",
251
- "Â": "a",
252
- "ê": "e",
253
- "Ê": "e",
254
- }
255
- )
256
-
257
-
258
- def _command_segments(prompt: str) -> list[str]:
259
- parts = re.split(
260
- r"(?=(?<!-)\b(?:device|connect|link|set|enable|associate|change|update|rename|apply)\b)",
261
- prompt,
262
- flags=re.IGNORECASE,
263
- )
264
- return [part.strip() for part in parts if part.strip()]
265
-
266
-
267
- def _normalize_prompt(prompt: str) -> str:
268
- text = prompt.translate(TRANSLITERATION_TABLE)
269
- for source, target in {
270
- "É™": "e",
271
- "Ə": "e",
272
- "ÅŸ": "s",
273
- "Ş": "s",
274
- "ı": "i",
275
- "İ": "i",
276
- "ç": "c",
277
- "Ç": "c",
278
- "ö": "o",
279
- "Ö": "o",
280
- "ü": "u",
281
- "Ü": "u",
282
- "ÄŸ": "g",
283
- "Ğ": "g",
284
- }.items():
285
- text = text.replace(source, target)
286
- text = unicodedata.normalize("NFKD", text)
287
- text = text.encode("ascii", "ignore").decode("ascii")
288
- text = text.lower()
289
- text = re.sub(r"[^a-z0-9./\\,:_-]+", " ", text)
290
- text = re.sub(r"\s+", " ", text).strip()
291
- return text
292
-
293
-
294
- @dataclass
295
- class IntentPlan:
296
- goal: str
297
- prompt: str
298
- pkt_path: str | None = None
299
- capabilities: list[str] = field(default_factory=list)
300
- network_style: str | None = None
301
- wireless_mode: str | None = None
302
- device_requirements: dict[str, int] = field(default_factory=dict)
303
- device_counts: dict[str, int] = field(default_factory=dict)
304
- department_groups: list[dict[str, object]] = field(default_factory=list)
305
- service_requirements: dict[str, object] = field(default_factory=dict)
306
- topology_requirements: dict[str, object] = field(default_factory=dict)
307
- vlan_ids: list[int] = field(default_factory=list)
308
- uplink_intent: str | None = None
309
- host_link_intent: str | None = None
310
- host_vlan_assignment: dict[int, int] = field(default_factory=dict)
311
- assumptions_used: list[str] = field(default_factory=list)
312
- confidence_score: float = 0.0
313
- parse_warnings: list[str] = field(default_factory=list)
314
- blocking_gaps: list[str] = field(default_factory=list)
315
- compatibility_profile: dict[str, object] = field(default_factory=dict)
316
- unsafe_mutations_requested: list[str] = field(default_factory=list)
317
- blocked_mutations: list[str] = field(default_factory=list)
318
- acceptance_stage_plan: list[dict[str, object]] = field(default_factory=list)
319
- capability_matrix_hits: list[dict[str, object]] = field(default_factory=list)
320
- unsupported_capabilities: list[str] = field(default_factory=list)
321
- coverage_gap_report: dict[str, object] = field(default_factory=dict)
322
- blueprint_plan: dict[str, object] = field(default_factory=dict)
323
- remote_search_results: list[dict[str, object]] = field(default_factory=list)
324
- edit_operations: list[dict[str, object]] = field(default_factory=list)
325
- devices: list[dict[str, object]] = field(default_factory=list)
326
- links: list[dict[str, object]] = field(default_factory=list)
327
- switch_ops: list[dict[str, object]] = field(default_factory=list)
328
- router_ops: list[dict[str, object]] = field(default_factory=list)
329
- server_ops: list[dict[str, object]] = field(default_factory=list)
330
- wireless_ops: list[dict[str, object]] = field(default_factory=list)
331
- end_device_ops: list[dict[str, object]] = field(default_factory=list)
332
- management_ops: list[dict[str, object]] = field(default_factory=list)
333
- verification_ops: list[dict[str, object]] = field(default_factory=list)
334
- iot_ops: list[dict[str, object]] = field(default_factory=list)
335
- programming_ops: list[dict[str, object]] = field(default_factory=list)
336
-
337
- def all_operations(self) -> list[dict[str, object]]:
338
- merged: list[dict[str, object]] = []
339
- for bucket in [
340
- self.edit_operations,
341
- self.switch_ops,
342
- self.router_ops,
343
- self.server_ops,
344
- self.wireless_ops,
345
- self.end_device_ops,
346
- self.management_ops,
347
- self.verification_ops,
348
- self.iot_ops,
349
- self.programming_ops,
350
- ]:
351
- merged.extend(bucket)
352
- return merged
353
-
354
- def to_dict(self) -> dict[str, object]:
355
- data = asdict(self)
356
- data["all_operations"] = self.all_operations()
357
- return data
358
-
359
-
360
- def _normalize_device_type(raw_type: str) -> str:
361
- return DEVICE_SYNONYMS.get(raw_type.strip().lower(), raw_type.strip())
362
-
363
-
364
- def _extract_natural_device_counts(normalized_prompt: str) -> dict[str, int]:
365
- counts: dict[str, int] = {}
366
- for device_type, aliases in NATURAL_DEVICE_ALIASES.items():
367
- alias_pattern = "|".join(re.escape(alias) for alias in aliases)
368
- patterns = [
369
- re.compile(rf"(?<![,/])\b(\d+)\s*(?:dene|dene?|eded|eded|tane)?\s*(?:{alias_pattern})\b"),
370
- re.compile(rf"\b(?:{alias_pattern})\s+(\d+)\b"),
371
- ]
372
- values: list[int] = []
373
- for pattern in patterns:
374
- values.extend(int(value) for value in pattern.findall(normalized_prompt))
375
- if values:
376
- counts[device_type] = max(values)
377
- return counts
378
-
379
-
380
- def _extract_vlan_ids(normalized_prompt: str) -> list[int]:
381
- vlan_ids: list[int] = []
382
- for match in re.findall(r"\bvlan(?:larda|lar|da|de|a|e)?\s+([0-9,\s/veand]+)", normalized_prompt):
383
- vlan_ids.extend(int(value) for value in re.findall(r"\d+", match))
384
- return sorted(dict.fromkeys(vlan_ids))
385
-
386
-
387
- def _extract_host_vlan_assignment(normalized_prompt: str) -> dict[int, int]:
388
- assignments: dict[int, int] = {}
389
- patterns = [
390
- re.compile(r"\bvlan\s+(\d+)\s*(?:da|de|a|e)?\s+(\d+)\s+(?:pc|komputer|computer)\b"),
391
- re.compile(r"\b(\d+)\s+(?:pc|komputer|computer)\s+vlan(?:da|de|a|e)?\s+(\d+)\b"),
392
- ]
393
- for pattern_index, pattern in enumerate(patterns):
394
- for first, second in pattern.findall(normalized_prompt):
395
- if pattern_index == 0:
396
- vlan_id, count = int(first), int(second)
397
- else:
398
- count, vlan_id = int(first), int(second)
399
- assignments[vlan_id] = assignments.get(vlan_id, 0) + count
400
- return assignments
401
-
402
-
403
- def _extract_uplink_intent(normalized_prompt: str) -> str | None:
404
- if any(token in normalized_prompt for token in ["gig port", "gigabit", " gig ", "gi0", "routerle aralarinda gig", "switchlerin oz aralarinda"]):
405
- return "gigabit"
406
- return None
407
-
408
-
409
- def _extract_host_link_intent(normalized_prompt: str) -> str | None:
410
- if any(token in normalized_prompt for token in ["fa port", "fastethernet", " fa ", "pc ler ise fa", "komputerler ise fa"]):
411
- return "fastethernet"
412
- return None
413
-
414
-
415
- def _extract_network_style(normalized_prompt: str) -> str | None:
416
- for style, patterns in NETWORK_STYLE_PATTERNS.items():
417
- if any(re.search(pattern, normalized_prompt) for pattern in patterns):
418
- return style
419
- return None
420
-
421
-
422
- def _extract_department_count(normalized_prompt: str) -> int:
423
- patterns = [
424
- re.compile(r"\b(\d+)\s+(?:sobeli|sobe|department|departamentli)\b"),
425
- re.compile(r"\b(\d+)\s+(?:depart(?:ment)?|bolme)\b"),
426
- ]
427
- for pattern in patterns:
428
- match = pattern.search(normalized_prompt)
429
- if match:
430
- return int(match.group(1))
431
- return 0
432
-
433
-
434
- def _extract_per_department_devices(normalized_prompt: str) -> dict[str, int]:
435
- counts: dict[str, int] = {}
436
- group_segment = ""
437
- segment_match = re.search(
438
- r"\bher\s+(?:sobede|bolmede|departmentda)\s+(.+?)(?=\b(?:router|dhcp|vlan|ssid|acl|telnet|ospf|eigrp|rip|nat)\b|$)",
439
- normalized_prompt,
440
- )
441
- if segment_match:
442
- group_segment = segment_match.group(1)
443
- for device_type, aliases in PER_DEPARTMENT_DEVICE_ALIASES.items():
444
- alias_pattern = "|".join(re.escape(alias) for alias in aliases)
445
- patterns = [
446
- re.compile(rf"\bher\s+(?:sobede|bolmede|departmentda)\s+(\d+)\s+(?:dene\s+)?(?:{alias_pattern})\b"),
447
- re.compile(rf"\beach\s+(?:department|group)\s+(\d+)\s+(?:{alias_pattern})\b"),
448
- re.compile(rf"\b(\d+)\s+(?:dene\s+)?(?:{alias_pattern})\b"),
449
- ]
450
- values: list[int] = []
451
- for pattern in patterns:
452
- target_text = group_segment if pattern.pattern.startswith(r"\b(\d+)") and group_segment else normalized_prompt
453
- values.extend(int(value) for value in pattern.findall(target_text))
454
- if values:
455
- counts[device_type] = max(values)
456
- return counts
457
-
458
-
459
- def _build_department_groups(
460
- normalized_prompt: str,
461
- department_count: int,
462
- per_department_devices: dict[str, int],
463
- vlan_ids: list[int],
464
- ) -> list[dict[str, object]]:
465
- if department_count <= 0:
466
- return []
467
- groups: list[dict[str, object]] = []
468
- for index in range(department_count):
469
- group_name = f"DEPT{index + 1}"
470
- groups.append(
471
- {
472
- "name": group_name,
473
- "switch_name": f"{group_name}-SW",
474
- "vlan_id": vlan_ids[index] if index < len(vlan_ids) else None,
475
- "devices": dict(per_department_devices),
476
- }
477
- )
478
- return groups
479
-
480
-
481
- def _extract_service_requirements(capabilities: list[str], prompt: str) -> dict[str, object]:
482
- lowered = prompt.lower()
483
- requirements: dict[str, object] = {
484
- "routing": next((cap for cap in ["ospf", "eigrp", "rip"] if cap in capabilities), None),
485
- "services": [],
486
- "security": [],
487
- }
488
- for service in ["dhcp", "dns", "http", "https", "ftp", "tftp", "ntp", "email", "syslog", "aaa"]:
489
- if service in lowered:
490
- requirements["services"].append(service)
491
- for security in ["acl", "telnet", "wpa2", "wpa", "wep", "nat", "vpn", "ipsec", "gre", "ppp", "security_edge", "ssh"]:
492
- if security in lowered:
493
- requirements["security"].append(security)
494
- return requirements
495
-
496
-
497
- def _extract_wireless_mode(
498
- normalized_prompt: str,
499
- capabilities: list[str],
500
- device_requirements: dict[str, int],
501
- network_style: str | None,
502
- ) -> str | None:
503
- has_wireless = any(cap in capabilities for cap in ["wireless_ap", "wireless_client"])
504
- if not has_wireless and not device_requirements.get("WirelessRouter", 0):
505
- return None
506
- mentions_home_router = any(token in normalized_prompt for token in ["home router", "wireless router", "wirelessrouter", "wrt"])
507
- campus_like = network_style in {"campus", "branch", "wireless_branch"} or "department" in normalized_prompt or "sobeli" in normalized_prompt
508
- if mentions_home_router and campus_like:
509
- return "mixed"
510
- if mentions_home_router or "nat" in capabilities or network_style == "small_office" or device_requirements.get("WirelessRouter", 0):
511
- return "home_router_edge"
512
- return "ap_bridge"
513
-
514
-
515
- def _estimate_confidence(
516
- device_requirements: dict[str, int],
517
- capabilities: list[str],
518
- parse_warnings: list[str],
519
- blocking_gaps: list[str],
520
- explicit_devices: list[dict[str, object]],
521
- links: list[dict[str, object]],
522
- ) -> float:
523
- score = 0.2
524
- if device_requirements or explicit_devices:
525
- score += 0.25
526
- if capabilities:
527
- score += 0.2
528
- if links:
529
- score += 0.15
530
- score -= min(len(parse_warnings) * 0.08, 0.16)
531
- score -= min(len(blocking_gaps) * 0.2, 0.4)
532
- return max(0.0, min(1.0, round(score, 2)))
533
-
534
-
535
- def _extract_explicit_devices(prompt: str) -> list[dict[str, object]]:
536
- devices: list[dict[str, object]] = []
537
- pattern = re.compile(
538
- r"(?:device\s+)?([A-Za-z][A-Za-z0-9_-]*)\s+type\s+([A-Za-z-]+)(?:\s+model\s+([A-Za-z0-9._-]+))?",
539
- flags=re.IGNORECASE,
540
- )
541
- for segment in _command_segments(prompt):
542
- for name, raw_type, model in pattern.findall(segment):
543
- devices.append({"name": name, "type": _normalize_device_type(raw_type), "model": model or None})
544
- return devices
545
-
546
-
547
- def _extract_explicit_links(prompt: str) -> list[dict[str, object]]:
548
- links: list[dict[str, object]] = []
549
- pattern = re.compile(
550
- r"(?:connect|link)\s+([A-Za-z][A-Za-z0-9_-]*):([A-Za-z0-9/._-]+)\s+(?:to|->)\s+([A-Za-z][A-Za-z0-9_-]*):([A-Za-z0-9/._-]+)"
551
- r"(?:\s+with\s+([A-Za-z0-9._-]+))?",
552
- flags=re.IGNORECASE,
553
- )
554
- for segment in _command_segments(prompt):
555
- for left_dev, left_port, right_dev, right_port, cable in pattern.findall(segment):
556
- links.append(
557
- {
558
- "a": {"dev": left_dev, "port": left_port},
559
- "b": {"dev": right_dev, "port": right_port},
560
- "media": (cable or "copper").lower(),
561
- }
562
- )
563
- deduped: list[dict[str, object]] = []
564
- seen: set[tuple[str, str, str, str, str]] = set()
565
- for link in links:
566
- key = (
567
- str(link["a"]["dev"]),
568
- str(link["a"]["port"]),
569
- str(link["b"]["dev"]),
570
- str(link["b"]["port"]),
571
- str(link["media"]),
572
- )
573
- if key not in seen:
574
- seen.add(key)
575
- deduped.append(link)
576
- return deduped
577
-
578
-
579
- def _extract_link_edit_operations(links: list[dict[str, object]]) -> list[dict[str, object]]:
580
- return [{"op": "set_link", **link} for link in links]
581
-
582
-
583
- def _extract_switch_ops(prompt: str) -> list[dict[str, object]]:
584
- ops: list[dict[str, object]] = []
585
- for segment in _command_segments(prompt):
586
- for device, vlan_id, name in re.findall(r"set\s+([A-Za-z0-9_-]+)\s+vlan\s+(\d+)\s+name\s+([A-Za-z0-9_-]+)", segment, flags=re.IGNORECASE):
587
- ops.append({"op": "set_vlan", "device": device, "vlan": int(vlan_id), "name": name})
588
- for device, port, vlan_id in re.findall(r"set\s+([A-Za-z0-9_-]+)\s+access-port\s+([A-Za-z0-9/._-]+)\s+vlan\s+(\d+)", segment, flags=re.IGNORECASE):
589
- ops.append({"op": "set_access_port", "device": device, "port": port, "vlan": int(vlan_id)})
590
- trunk_pattern = re.compile(
591
- r"set\s+([A-Za-z0-9_-]+)\s+trunk-port\s+([A-Za-z0-9/._-]+)\s+allowed\s+([0-9,\s]+?)(?:\s+native\s+(\d+))?(?=\s+set\s+|$)",
592
- flags=re.IGNORECASE,
593
- )
594
- for segment in _command_segments(prompt):
595
- for device, port, allowed, native in trunk_pattern.findall(segment):
596
- ops.append(
597
- {
598
- "op": "set_trunk_port",
599
- "device": device,
600
- "port": port,
601
- "allowed": [int(value) for value in re.findall(r"\d+", allowed)],
602
- "native": int(native) if native else None,
603
- }
604
- )
605
- dhcp_snooping_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+dhcp\s+snooping\s+vlan\s+(\d+)(?:\s+trust\s+([A-Za-z0-9/._-]+))?", flags=re.IGNORECASE)
606
- dai_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+dai\s+vlan\s+(\d+)(?:\s+trust\s+([A-Za-z0-9/._-]+))?", flags=re.IGNORECASE)
607
- port_security_pattern = re.compile(
608
- r"set\s+([A-Za-z0-9_-]+)\s+port-security\s+([A-Za-z0-9/._-]+)(?:\s+max\s+(\d+))?(?:\s+violation\s+(protect|restrict|shutdown))?",
609
- flags=re.IGNORECASE,
610
- )
611
- lldp_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+lldp(?:\s+(?:enable|run))?", flags=re.IGNORECASE)
612
- rep_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+rep\s+segment\s+(\d+)\s+interface\s+([A-Za-z0-9/._-]+)", flags=re.IGNORECASE)
613
- span_pattern = re.compile(
614
- r"set\s+([A-Za-z0-9_-]+)\s+(?:r?span)\s+(\d+)\s+source\s+([A-Za-z0-9/._-]+)\s+destination\s+([A-Za-z0-9/._-]+)",
615
- flags=re.IGNORECASE,
616
- )
617
- dot1x_pattern = re.compile(
618
- r"set\s+([A-Za-z0-9_-]+)\s+dot1x\s+interface\s+([A-Za-z0-9/._-]+)\s+mode\s+(auto|force-authorized|force-unauthorized)"
619
- r"(?:\s+radius\s+(\d+\.\d+\.\d+\.\d+)\s+key\s+([A-Za-z0-9._-]+))?",
620
- flags=re.IGNORECASE,
621
- )
622
- qos_pattern = re.compile(
623
- r"set\s+([A-Za-z0-9_-]+)\s+qos\s+class-map\s+([A-Za-z0-9_-]+)\s+match\s+(.+?)\s+policy-map\s+([A-Za-z0-9_-]+)\s+class\s+\2\s+(priority|bandwidth\s+\d+|police\s+\d+)"
624
- r"\s+service-policy\s+(input|output)\s+([A-Za-z0-9/._-]+)(?=\s+set\s+|$)",
625
- flags=re.IGNORECASE,
626
- )
627
- stp_pattern = re.compile(
628
- r"set\s+([A-Za-z0-9_-]+)\s+stp\s+mode\s+([A-Za-z0-9_-]+)(?:\s+vlan\s+(\d+)\s+root\s+(primary|secondary))?",
629
- flags=re.IGNORECASE,
630
- )
631
- etherchannel_pattern = re.compile(
632
- r"set\s+([A-Za-z0-9_-]+)\s+etherchannel\s+(\d+)\s+mode\s+(active|passive|desirable|auto|on)\s+interfaces\s+(.+?)(?=\s+set\s+|$)",
633
- flags=re.IGNORECASE,
634
- )
635
- vtp_pattern = re.compile(
636
- r"set\s+([A-Za-z0-9_-]+)\s+vtp\s+domain\s+([A-Za-z0-9_.-]+)\s+mode\s+(server|client|transparent)(?:\s+version\s+(\d+))?",
637
- flags=re.IGNORECASE,
638
- )
639
- dtp_pattern = re.compile(
640
- r"set\s+([A-Za-z0-9_-]+)\s+dtp\s+interface\s+([A-Za-z0-9/._-]+)\s+mode\s+(dynamic\s+desirable|dynamic\s+auto|trunk|access)",
641
- flags=re.IGNORECASE,
642
- )
643
- for segment in _command_segments(prompt):
644
- for device, vlan_id, trust_port in dhcp_snooping_pattern.findall(segment):
645
- ops.append({"op": "set_dhcp_snooping", "device": device, "vlan": int(vlan_id), "trust_port": trust_port or None})
646
- for device, vlan_id, trust_port in dai_pattern.findall(segment):
647
- ops.append({"op": "set_dai", "device": device, "vlan": int(vlan_id), "trust_port": trust_port or None})
648
- for device, port, maximum, violation in port_security_pattern.findall(segment):
649
- ops.append(
650
- {
651
- "op": "set_port_security",
652
- "device": device,
653
- "port": port,
654
- "maximum": int(maximum) if maximum else None,
655
- "violation": violation.lower() if violation else None,
656
- }
657
- )
658
- for device in lldp_pattern.findall(segment):
659
- ops.append({"op": "set_lldp", "device": device})
660
- for device, segment_id, interface_name in rep_pattern.findall(segment):
661
- ops.append({"op": "set_rep", "device": device, "segment": int(segment_id), "interface": interface_name})
662
- for device, session, source, destination in span_pattern.findall(segment):
663
- ops.append({"op": "set_span", "device": device, "session": int(session), "source": source, "destination": destination})
664
- for device, interface_name, mode, radius_host, radius_key in dot1x_pattern.findall(segment):
665
- ops.append(
666
- {
667
- "op": "set_dot1x",
668
- "device": device,
669
- "interface": interface_name,
670
- "mode": mode.lower(),
671
- "radius_host": radius_host or None,
672
- "radius_key": radius_key or None,
673
- }
674
- )
675
- for device, class_map, match_expr, policy_map, action, direction, interface_name in qos_pattern.findall(segment):
676
- ops.append(
677
- {
678
- "op": "set_qos_policy",
679
- "device": device,
680
- "class_map": class_map,
681
- "match": match_expr.strip(),
682
- "policy_map": policy_map,
683
- "action": action.lower(),
684
- "direction": direction.lower(),
685
- "interface": interface_name,
686
- }
687
- )
688
- for device, mode, vlan_id, root_priority in stp_pattern.findall(segment):
689
- ops.append(
690
- {
691
- "op": "set_stp",
692
- "device": device,
693
- "mode": mode.lower(),
694
- "vlan": int(vlan_id) if vlan_id else None,
695
- "root": root_priority.lower() if root_priority else None,
696
- }
697
- )
698
- for device, channel, mode, interfaces_text in etherchannel_pattern.findall(segment):
699
- interfaces = [value for value in re.findall(r"[A-Za-z]+[A-Za-z]*[0-9/._-]+", interfaces_text) if value.lower() != "set"]
700
- ops.append(
701
- {
702
- "op": "set_etherchannel",
703
- "device": device,
704
- "channel": int(channel),
705
- "mode": mode.lower(),
706
- "interfaces": interfaces,
707
- }
708
- )
709
- for device, domain, mode, version in vtp_pattern.findall(segment):
710
- ops.append(
711
- {
712
- "op": "set_vtp",
713
- "device": device,
714
- "domain": domain,
715
- "mode": mode.lower(),
716
- "version": int(version) if version else None,
717
- }
718
- )
719
- for device, interface_name, mode in dtp_pattern.findall(segment):
720
- ops.append({"op": "set_dtp", "device": device, "interface": interface_name, "mode": mode.lower()})
721
- return ops
722
-
723
-
724
- def _extract_router_ops(prompt: str) -> list[dict[str, object]]:
725
- ops: list[dict[str, object]] = []
726
- subif_pattern = re.compile(
727
- r"set\s+([A-Za-z0-9_-]+)\s+subinterface\s+([A-Za-z0-9/._-]+)\s+encapsulation\s+dot1q\s+(\d+)\s+ip\s+(\d+\.\d+\.\d+\.\d+)/(\d+)",
728
- flags=re.IGNORECASE,
729
- )
730
- for segment in _command_segments(prompt):
731
- for device, subinterface, vlan_id, ip, prefix in subif_pattern.findall(segment):
732
- ops.append({"op": "set_subinterface", "device": device, "subinterface": subinterface, "vlan": int(vlan_id), "ip": ip, "prefix": int(prefix)})
733
- dhcp_pattern = re.compile(
734
- r"set\s+([A-Za-z0-9_-]+)\s+dhcp\s+pool\s+([A-Za-z0-9_-]+)\s+network\s+(\d+\.\d+\.\d+\.\d+)/(\d+)\s+gateway\s+(\d+\.\d+\.\d+\.\d+)"
735
- r"(?:\s+dns\s+(\d+\.\d+\.\d+\.\d+))?(?:\s+start\s+(\d+\.\d+\.\d+\.\d+))?(?:\s+max\s+(\d+))?",
736
- flags=re.IGNORECASE,
737
- )
738
- for segment in _command_segments(prompt):
739
- for device, name, network, prefix, gateway, dns, start, max_users in dhcp_pattern.findall(segment):
740
- if str(device).strip().lower().startswith("server"):
741
- continue
742
- ops.append(
743
- {
744
- "op": "set_router_dhcp_pool",
745
- "device": device,
746
- "name": name,
747
- "network": network,
748
- "prefix": int(prefix),
749
- "gateway": gateway,
750
- "dns": dns or None,
751
- "start": start or None,
752
- "max_users": int(max_users) if max_users else None,
753
- }
754
- )
755
- acl_create_pattern = re.compile(r"set\s+([A-Za-z0-9_ -]+?)\s+acl\s+(standard|extended)\s+([A-Za-z0-9_-]+)", flags=re.IGNORECASE)
756
- for segment in _command_segments(prompt):
757
- for device, acl_kind, acl_name in acl_create_pattern.findall(segment):
758
- ops.append({"op": "set_acl", "device": device.strip(), "acl_kind": acl_kind.lower(), "acl_name": acl_name})
759
- acl_create_simple_pattern = re.compile(
760
- r"create\s+acl\s+([A-Za-z0-9_-]+)\s+on\s+([A-Za-z0-9_ -]+?)(?:\s+kind\s+(standard|extended))?(?=\s+(?:acl\b|apply\b|enable\b|set\b|associate\b|register\b|disable\b)|$)",
761
- flags=re.IGNORECASE,
762
- )
763
- for segment in _command_segments(prompt):
764
- for acl_name, device, acl_kind in acl_create_simple_pattern.findall(segment):
765
- ops.append(
766
- {
767
- "op": "set_acl",
768
- "device": device.strip(),
769
- "acl_kind": (acl_kind or "standard").lower(),
770
- "acl_name": acl_name,
771
- }
772
- )
773
- acl_rule_pattern = re.compile(
774
- r"acl\s+([A-Za-z0-9_-]+)\s+(permit|deny)\s+(host\s+\d+\.\d+\.\d+\.\d+|\d+\.\d+\.\d+\.\d+\s+\d+\.\d+\.\d+\.\d+|any)"
775
- r"(?:\s+(host\s+\d+\.\d+\.\d+\.\d+|\d+\.\d+\.\d+\.\d+\s+\d+\.\d+\.\d+\.\d+|any))?",
776
- flags=re.IGNORECASE,
777
- )
778
- for segment in _command_segments(prompt):
779
- for acl_name, action, source, destination in acl_rule_pattern.findall(segment):
780
- ops.append({"op": "add_acl_rule", "acl_name": acl_name, "action": action.lower(), "source": source.strip(), "destination": destination.strip() if destination else None})
781
- acl_apply_pattern = re.compile(
782
- r"apply\s+acl\s+([A-Za-z0-9_-]+)\s+(in|out)\s+on\s+([A-Za-z0-9_ -]+?)(?:\s+interface)?\s+([A-Za-z0-9/._-]+)",
783
- flags=re.IGNORECASE,
784
- )
785
- for segment in _command_segments(prompt):
786
- for acl_name, direction, device, interface_name in acl_apply_pattern.findall(segment):
787
- ops.append({"op": "apply_acl", "device": device.strip(), "acl_name": acl_name, "direction": direction.lower(), "interface": interface_name})
788
- ipv6_unicast_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+ipv6\s+unicast-routing", flags=re.IGNORECASE)
789
- ipv6_address_pattern = re.compile(
790
- r"set\s+([A-Za-z0-9_-]+)\s+(?:interface\s+)?([A-Za-z0-9/._-]+)\s+ipv6\s+([0-9A-Fa-f:]+)/(\d+)",
791
- flags=re.IGNORECASE,
792
- )
793
- ipv6_slaac_pattern = re.compile(
794
- r"set\s+([A-Za-z0-9_-]+)\s+slaac\s+on\s+([A-Za-z0-9/._-]+)(?:\s+prefix\s+([0-9A-Fa-f:]+)/(\d+))?",
795
- flags=re.IGNORECASE,
796
- )
797
- dhcpv6_pattern = re.compile(
798
- r"set\s+([A-Za-z0-9_-]+)\s+dhcpv6\s+pool\s+([A-Za-z0-9_-]+)\s+prefix\s+([0-9A-Fa-f:]+)/(\d+)\s+interface\s+([A-Za-z0-9/._-]+)"
799
- r"(?:\s+dns\s+([0-9A-Fa-f:]+))?(?:\s+domain\s+([A-Za-z0-9._-]+))?",
800
- flags=re.IGNORECASE,
801
- )
802
- ospfv3_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+ospfv3\s+(\d+)\s+area\s+(\d+)\s+interface\s+([A-Za-z0-9/._-]+)", flags=re.IGNORECASE)
803
- eigrp_ipv6_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+eigrp\s+ipv6\s+(\d+)\s+interface\s+([A-Za-z0-9/._-]+)", flags=re.IGNORECASE)
804
- ripng_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+ripng\s+([A-Za-z0-9_-]+)\s+interface\s+([A-Za-z0-9/._-]+)", flags=re.IGNORECASE)
805
- hsrp_pattern = re.compile(
806
- r"set\s+([A-Za-z0-9_-]+)\s+hsrp\s+(\d+)\s+ipv6\s+([0-9A-Fa-f:]+)\s+interface\s+([A-Za-z0-9/._-]+)(?:\s+priority\s+(\d+))?",
807
- flags=re.IGNORECASE,
808
- )
809
- ospfv2_pattern = re.compile(
810
- r"set\s+([A-Za-z0-9_-]+)\s+ospfv2\s+(\d+)\s+network\s+(\d+\.\d+\.\d+\.\d+)\s+wildcard\s+(\d+\.\d+\.\d+\.\d+)\s+area\s+(\d+)",
811
- flags=re.IGNORECASE,
812
- )
813
- eigrp_ipv4_pattern = re.compile(
814
- r"set\s+([A-Za-z0-9_-]+)\s+eigrp\s+ipv4\s+(\d+)\s+network\s+(\d+\.\d+\.\d+\.\d+)\s+wildcard\s+(\d+\.\d+\.\d+\.\d+)(?:\s+(no-auto-summary))?",
815
- flags=re.IGNORECASE,
816
- )
817
- ripv2_pattern = re.compile(
818
- r"set\s+([A-Za-z0-9_-]+)\s+rip\s+version\s+2\s+network\s+(\d+\.\d+\.\d+\.\d+)(?:\s+(no-auto-summary))?",
819
- flags=re.IGNORECASE,
820
- )
821
- static_route_pattern = re.compile(
822
- r"set\s+([A-Za-z0-9_-]+)\s+static-route\s+(\d+\.\d+\.\d+\.\d+)/(\d+)\s+via\s+(\d+\.\d+\.\d+\.\d+)",
823
- flags=re.IGNORECASE,
824
- )
825
- dhcp_relay_pattern = re.compile(
826
- r"set\s+([A-Za-z0-9_-]+)\s+dhcp-relay\s+interface\s+([A-Za-z0-9/._-]+)\s+helper\s+(\d+\.\d+\.\d+\.\d+)",
827
- flags=re.IGNORECASE,
828
- )
829
- nat_inside_outside_pattern = re.compile(
830
- r"set\s+([A-Za-z0-9_-]+)\s+nat\s+(inside|outside)\s+interface\s+([A-Za-z0-9/._-]+)",
831
- flags=re.IGNORECASE,
832
- )
833
- nat_static_pattern = re.compile(
834
- r"set\s+([A-Za-z0-9_-]+)\s+nat\s+static\s+(\d+\.\d+\.\d+\.\d+)\s+(\d+\.\d+\.\d+\.\d+)",
835
- flags=re.IGNORECASE,
836
- )
837
- pat_pattern = re.compile(
838
- r"set\s+([A-Za-z0-9_-]+)\s+pat\s+acl\s+([A-Za-z0-9_-]+)\s+interface\s+([A-Za-z0-9/._-]+)(?:\s+(overload))?",
839
- flags=re.IGNORECASE,
840
- )
841
- ssh_ios_pattern = re.compile(
842
- r"set\s+([A-Za-z0-9_-]+)\s+ssh\s+domain\s+([A-Za-z0-9._-]+)\s+username\s+([A-Za-z0-9._-]+)\s+password\s+([A-Za-z0-9._-]+)(?:\s+modulus\s+(\d+))?",
843
- flags=re.IGNORECASE,
844
- )
845
- ntp_ios_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+ntp\s+server\s+(\d+\.\d+\.\d+\.\d+)", flags=re.IGNORECASE)
846
- syslog_ios_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+syslog\s+server\s+(\d+\.\d+\.\d+\.\d+)", flags=re.IGNORECASE)
847
- bgp_pattern = re.compile(
848
- r"set\s+([A-Za-z0-9_-]+)\s+bgp\s+(\d+)\s+neighbor\s+(\d+\.\d+\.\d+\.\d+)\s+remote-as\s+(\d+)"
849
- r"(?:\s+network\s+(\d+\.\d+\.\d+\.\d+)\s+mask\s+(\d+\.\d+\.\d+\.\d+))?",
850
- flags=re.IGNORECASE,
851
- )
852
- snmp_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+snmp\s+community\s+([A-Za-z0-9_-]+)\s+(ro|rw)", flags=re.IGNORECASE)
853
- netflow_pattern = re.compile(
854
- r"set\s+([A-Za-z0-9_-]+)\s+netflow\s+destination\s+(\d+\.\d+\.\d+\.\d+)\s+(\d+)(?:\s+version\s+(\d+))?(?:\s+interface\s+([A-Za-z0-9/._-]+)\s+(ingress|egress))?",
855
- flags=re.IGNORECASE,
856
- )
857
- gre_tunnel_pattern = re.compile(
858
- r"set\s+([A-Za-z0-9_-]+)\s+gre\s+tunnel\s+([A-Za-z0-9/._-]+)\s+source\s+([A-Za-z0-9/._:-]+)\s+destination\s+([A-Za-z0-9/._:-]+)"
859
- r"(?:\s+ip\s+(\d+\.\d+\.\d+\.\d+)/(\d+))?",
860
- flags=re.IGNORECASE,
861
- )
862
- ppp_pattern = re.compile(
863
- r"set\s+([A-Za-z0-9_-]+)\s+ppp\s+interface\s+([A-Za-z0-9/._-]+)(?:\s+authentication\s+(chap|pap))?",
864
- flags=re.IGNORECASE,
865
- )
866
- ipsec_transform_pattern = re.compile(
867
- r"set\s+([A-Za-z0-9_-]+)\s+ipsec\s+transform-set\s+([A-Za-z0-9_-]+)\s+([A-Za-z0-9_-]+)\s+([A-Za-z0-9_-]+)",
868
- flags=re.IGNORECASE,
869
- )
870
- crypto_map_pattern = re.compile(
871
- r"set\s+([A-Za-z0-9_-]+)\s+crypto\s+map\s+([A-Za-z0-9_-]+)\s+(\d+)\s+peer\s+(\d+\.\d+\.\d+\.\d+)\s+transform-set\s+([A-Za-z0-9_-]+)\s+match\s+([A-Za-z0-9_-]+)(?:\s+interface\s+([A-Za-z0-9/._-]+))?",
872
- flags=re.IGNORECASE,
873
- )
874
- cbac_pattern = re.compile(
875
- r"set\s+([A-Za-z0-9_-]+)\s+cbac\s+inspect\s+([A-Za-z0-9_-]+)\s+protocol\s+([A-Za-z0-9_-]+)\s+interface\s+([A-Za-z0-9/._-]+)\s+direction\s+(in|out)",
876
- flags=re.IGNORECASE,
877
- )
878
- zfw_zone_interface_pattern = re.compile(
879
- r"set\s+([A-Za-z0-9_-]+)\s+zfw\s+zone\s+([A-Za-z0-9_-]+)\s+interface\s+([A-Za-z0-9/._-]+)",
880
- flags=re.IGNORECASE,
881
- )
882
- zfw_zone_pair_pattern = re.compile(
883
- r"set\s+([A-Za-z0-9_-]+)\s+zfw\s+zone-pair\s+([A-Za-z0-9_-]+)\s+source\s+([A-Za-z0-9_-]+)\s+destination\s+([A-Za-z0-9_-]+)\s+policy\s+([A-Za-z0-9_-]+)",
884
- flags=re.IGNORECASE,
885
- )
886
- zfw_policy_pattern = re.compile(
887
- r"set\s+([A-Za-z0-9_-]+)\s+zfw\s+class-map\s+([A-Za-z0-9_-]+)\s+match\s+protocol\s+([A-Za-z0-9_-]+)\s+policy-map\s+([A-Za-z0-9_-]+)\s+action\s+(inspect|pass|drop)",
888
- flags=re.IGNORECASE,
889
- )
890
- telephony_service_pattern = re.compile(
891
- r'set\s+"?([^"]+?)"?\s+telephony\s+service\s+source-address\s+(\d+\.\d+\.\d+\.\d+)\s+port\s+(\d+)'
892
- r"(?:\s+max-ephones\s+(\d+))?(?:\s+max-dn\s+(\d+))?",
893
- flags=re.IGNORECASE,
894
- )
895
- ephone_dn_pattern = re.compile(r'set\s+"?([^"]+?)"?\s+ephone-dn\s+(\d+)\s+number\s+([A-Za-z0-9*+#.-]+)', flags=re.IGNORECASE)
896
- ephone_pattern = re.compile(
897
- r'set\s+"?([^"]+?)"?\s+ephone\s+(\d+)\s+mac\s+([0-9A-Fa-f.:-]+)\s+button\s+([0-9:]+)',
898
- flags=re.IGNORECASE,
899
- )
900
- dial_peer_pattern = re.compile(
901
- r'set\s+"?([^"]+?)"?\s+dial-peer\s+voice\s+(\d+)\s+destination-pattern\s+(\S+)\s+session-target\s+ipv4:(\d+\.\d+\.\d+\.\d+)',
902
- flags=re.IGNORECASE,
903
- )
904
- for segment in _command_segments(prompt):
905
- for device in ipv6_unicast_pattern.findall(segment):
906
- ops.append({"op": "enable_ipv6_unicast_routing", "device": device})
907
- for device, interface_name, address, prefix in ipv6_address_pattern.findall(segment):
908
- ops.append({"op": "set_ipv6_address", "device": device, "interface": interface_name, "address": address, "prefix": int(prefix)})
909
- for device, interface_name, prefix_address, prefix_len in ipv6_slaac_pattern.findall(segment):
910
- ops.append(
911
- {
912
- "op": "set_ipv6_slaac",
913
- "device": device,
914
- "interface": interface_name,
915
- "prefix": prefix_address or None,
916
- "prefix_len": int(prefix_len) if prefix_len else None,
917
- }
918
- )
919
- for device, name, prefix_address, prefix_len, interface_name, dns, domain in dhcpv6_pattern.findall(segment):
920
- ops.append(
921
- {
922
- "op": "set_dhcpv6_pool",
923
- "device": device,
924
- "name": name,
925
- "prefix": prefix_address,
926
- "prefix_len": int(prefix_len),
927
- "interface": interface_name,
928
- "dns": dns or None,
929
- "domain": domain or None,
930
- }
931
- )
932
- for device, process_id, area, interface_name in ospfv3_pattern.findall(segment):
933
- ops.append({"op": "set_ospfv3_interface", "device": device, "process_id": int(process_id), "area": int(area), "interface": interface_name})
934
- for device, asn, interface_name in eigrp_ipv6_pattern.findall(segment):
935
- ops.append({"op": "set_eigrp_ipv6_interface", "device": device, "asn": int(asn), "interface": interface_name})
936
- for device, process_name, interface_name in ripng_pattern.findall(segment):
937
- ops.append({"op": "set_ripng_interface", "device": device, "process_name": process_name, "interface": interface_name})
938
- for device, group, virtual_ipv6, interface_name, priority in hsrp_pattern.findall(segment):
939
- ops.append(
940
- {
941
- "op": "set_hsrp_ipv6",
942
- "device": device,
943
- "group": int(group),
944
- "virtual_ipv6": virtual_ipv6,
945
- "interface": interface_name,
946
- "priority": int(priority) if priority else None,
947
- }
948
- )
949
- for device, process_id, network, wildcard, area in ospfv2_pattern.findall(segment):
950
- ops.append({"op": "set_ospfv2_network", "device": device, "process_id": int(process_id), "network": network, "wildcard": wildcard, "area": int(area)})
951
- for device, asn, network, wildcard, no_auto in eigrp_ipv4_pattern.findall(segment):
952
- ops.append({"op": "set_eigrp_ipv4_network", "device": device, "asn": int(asn), "network": network, "wildcard": wildcard, "no_auto_summary": bool(no_auto)})
953
- for device, network, no_auto in ripv2_pattern.findall(segment):
954
- ops.append({"op": "set_ripv2_network", "device": device, "network": network, "no_auto_summary": bool(no_auto)})
955
- for device, network, prefix, next_hop in static_route_pattern.findall(segment):
956
- ops.append({"op": "set_static_route", "device": device, "network": network, "prefix": int(prefix), "next_hop": next_hop})
957
- for device, interface_name, helper in dhcp_relay_pattern.findall(segment):
958
- ops.append({"op": "set_dhcp_relay", "device": device, "interface": interface_name, "helper": helper})
959
- for device, role, interface_name in nat_inside_outside_pattern.findall(segment):
960
- ops.append({"op": "set_nat_interface", "device": device, "role": role.lower(), "interface": interface_name})
961
- for device, inside_local, inside_global in nat_static_pattern.findall(segment):
962
- ops.append({"op": "set_nat_static", "device": device, "inside_local": inside_local, "inside_global": inside_global})
963
- for device, acl, interface_name, overload in pat_pattern.findall(segment):
964
- ops.append({"op": "set_pat_overload", "device": device, "acl": acl, "interface": interface_name, "overload": bool(overload)})
965
- for device, domain, username, password, modulus in ssh_ios_pattern.findall(segment):
966
- ops.append({"op": "set_ssh_ios", "device": device, "domain": domain, "username": username, "password": password, "modulus": int(modulus) if modulus else 1024})
967
- for device, server in ntp_ios_pattern.findall(segment):
968
- ops.append({"op": "set_ntp_server", "device": device, "server": server})
969
- for device, server in syslog_ios_pattern.findall(segment):
970
- ops.append({"op": "set_syslog_server", "device": device, "server": server})
971
- for device, asn, neighbor, remote_as, network, mask in bgp_pattern.findall(segment):
972
- ops.append(
973
- {
974
- "op": "set_bgp_neighbor",
975
- "device": device,
976
- "asn": int(asn),
977
- "neighbor": neighbor,
978
- "remote_as": int(remote_as),
979
- "network": network or None,
980
- "mask": mask or None,
981
- }
982
- )
983
- for device, community, mode in snmp_pattern.findall(segment):
984
- ops.append({"op": "set_snmp_community", "device": device, "community": community, "mode": mode.lower()})
985
- for device, destination, port, version, interface_name, direction in netflow_pattern.findall(segment):
986
- ops.append(
987
- {
988
- "op": "set_netflow",
989
- "device": device,
990
- "destination": destination,
991
- "port": int(port),
992
- "version": int(version) if version else 9,
993
- "interface": interface_name or None,
994
- "direction": direction.lower() if direction else None,
995
- }
996
- )
997
- for device, tunnel_interface, source, destination, ip, prefix in gre_tunnel_pattern.findall(segment):
998
- ops.append(
999
- {
1000
- "op": "set_gre_tunnel",
1001
- "device": device,
1002
- "interface": tunnel_interface,
1003
- "source": source,
1004
- "destination": destination,
1005
- "ip": ip or None,
1006
- "prefix": int(prefix) if prefix else None,
1007
- }
1008
- )
1009
- for device, interface_name, authentication in ppp_pattern.findall(segment):
1010
- ops.append(
1011
- {
1012
- "op": "set_ppp_interface",
1013
- "device": device,
1014
- "interface": interface_name,
1015
- "authentication": authentication.lower() if authentication else None,
1016
- }
1017
- )
1018
- for device, name, encryption, integrity in ipsec_transform_pattern.findall(segment):
1019
- ops.append(
1020
- {
1021
- "op": "set_ipsec_transform_set",
1022
- "device": device,
1023
- "name": name,
1024
- "encryption": encryption,
1025
- "integrity": integrity,
1026
- }
1027
- )
1028
- for device, map_name, sequence, peer, transform_set, acl_name, interface_name in crypto_map_pattern.findall(segment):
1029
- ops.append(
1030
- {
1031
- "op": "set_crypto_map",
1032
- "device": device,
1033
- "map_name": map_name,
1034
- "sequence": int(sequence),
1035
- "peer": peer,
1036
- "transform_set": transform_set,
1037
- "acl_name": acl_name,
1038
- "interface": interface_name or None,
1039
- }
1040
- )
1041
- for device, name, protocol, interface_name, direction in cbac_pattern.findall(segment):
1042
- ops.append(
1043
- {
1044
- "op": "set_cbac_inspect",
1045
- "device": device,
1046
- "name": name,
1047
- "protocol": protocol.lower(),
1048
- "interface": interface_name,
1049
- "direction": direction.lower(),
1050
- }
1051
- )
1052
- for device, zone, interface_name in zfw_zone_interface_pattern.findall(segment):
1053
- ops.append({"op": "set_zfw_zone_interface", "device": device, "zone": zone, "interface": interface_name})
1054
- for device, pair_name, source, destination, policy in zfw_zone_pair_pattern.findall(segment):
1055
- ops.append(
1056
- {
1057
- "op": "set_zfw_zone_pair",
1058
- "device": device,
1059
- "pair_name": pair_name,
1060
- "source": source,
1061
- "destination": destination,
1062
- "policy": policy,
1063
- }
1064
- )
1065
- for device, class_map, protocol, policy_map, action in zfw_policy_pattern.findall(segment):
1066
- ops.append(
1067
- {
1068
- "op": "set_zfw_policy",
1069
- "device": device,
1070
- "class_map": class_map,
1071
- "protocol": protocol.lower(),
1072
- "policy_map": policy_map,
1073
- "action": action.lower(),
1074
- }
1075
- )
1076
- for device, source_address, port, max_ephones, max_dn in telephony_service_pattern.findall(segment):
1077
- ops.append(
1078
- {
1079
- "op": "set_telephony_service",
1080
- "device": device.strip(),
1081
- "source_address": source_address,
1082
- "port": int(port),
1083
- "max_ephones": int(max_ephones) if max_ephones else None,
1084
- "max_dn": int(max_dn) if max_dn else None,
1085
- }
1086
- )
1087
- for device, dn_id, number in ephone_dn_pattern.findall(segment):
1088
- ops.append({"op": "set_ephone_dn", "device": device.strip(), "dn_id": int(dn_id), "number": number})
1089
- for device, ephone_id, mac, button in ephone_pattern.findall(segment):
1090
- ops.append({"op": "set_ephone", "device": device.strip(), "ephone_id": int(ephone_id), "mac": mac.upper(), "button": button})
1091
- for device, peer_id, destination_pattern, session_target in dial_peer_pattern.findall(segment):
1092
- ops.append(
1093
- {
1094
- "op": "set_dial_peer_voice",
1095
- "device": device.strip(),
1096
- "peer_id": int(peer_id),
1097
- "destination_pattern": destination_pattern,
1098
- "session_target": session_target,
1099
- }
1100
- )
1101
- return ops
1102
-
1103
-
1104
- def _extract_server_ops(prompt: str) -> list[dict[str, object]]:
1105
- ops: list[dict[str, object]] = []
1106
- dns_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+dns\s+(A|CNAME)\s+([A-Za-z0-9._-]+)\s+([A-Za-z0-9._-]+)", flags=re.IGNORECASE)
1107
- for segment in _command_segments(prompt):
1108
- for device, record_type, name, value in dns_pattern.findall(segment):
1109
- ops.append({"op": "set_server_dns_record", "device": device, "record_type": record_type.upper(), "name": name, "value": value})
1110
- email_domain_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+email\s+domain\s+([A-Za-z0-9._-]+)", flags=re.IGNORECASE)
1111
- for segment in _command_segments(prompt):
1112
- for device, domain in email_domain_pattern.findall(segment):
1113
- ops.append({"op": "set_server_email_domain", "device": device, "domain": domain})
1114
- aaa_auth_port_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+aaa\s+auth-port\s+(\d+)", flags=re.IGNORECASE)
1115
- for segment in _command_segments(prompt):
1116
- for device, port in aaa_auth_port_pattern.findall(segment):
1117
- ops.append({"op": "set_server_aaa_auth_port", "device": device, "auth_port": int(port)})
1118
- dhcp_pattern = re.compile(
1119
- r"set\s+([A-Za-z0-9_-]+)\s+(server-dhcp|dhcp)\s+pool\s+([A-Za-z0-9_-]+)\s+network\s+(\d+\.\d+\.\d+\.\d+)/(\d+)\s+gateway\s+(\d+\.\d+\.\d+\.\d+)"
1120
- r"(?:\s+dns\s+(\d+\.\d+\.\d+\.\d+))?(?:\s+start\s+(\d+\.\d+\.\d+\.\d+))?(?:\s+max\s+(\d+))?",
1121
- flags=re.IGNORECASE,
1122
- )
1123
- for segment in _command_segments(prompt):
1124
- for device, mode, name, network, prefix, gateway, dns, start, max_users in dhcp_pattern.findall(segment):
1125
- if mode.lower() != "server-dhcp" and not str(device).strip().lower().startswith("server"):
1126
- continue
1127
- ops.append(
1128
- {
1129
- "op": "set_server_dhcp_pool",
1130
- "device": device,
1131
- "name": name,
1132
- "network": network,
1133
- "prefix": int(prefix),
1134
- "gateway": gateway,
1135
- "dns": dns or None,
1136
- "start": start or None,
1137
- "max_users": int(max_users) if max_users else 0,
1138
- }
1139
- )
1140
- for service, device in re.findall(r"enable\s+(dns|http|https|ftp|tftp|ntp|email|syslog|aaa)\s+on\s+([A-Za-z0-9_ -]+)", segment, flags=re.IGNORECASE):
1141
- ops.append({"op": "enable_server_service", "device": device.strip(), "service": service.lower()})
1142
- return ops
1143
-
1144
-
1145
- def _extract_management_ops(prompt: str) -> list[dict[str, object]]:
1146
- ops: list[dict[str, object]] = []
1147
- mgmt_pattern = re.compile(r"set\s+([A-Za-z0-9_ -]+?)\s+management\s+vlan\s+(\d+)\s+ip\s+(\d+\.\d+\.\d+\.\d+)/(\d+)\s+gateway\s+(\d+\.\d+\.\d+\.\d+)", flags=re.IGNORECASE)
1148
- for segment in _command_segments(prompt):
1149
- for device, vlan_id, ip, prefix, gateway in mgmt_pattern.findall(segment):
1150
- ops.append({"op": "set_management_vlan", "device": device.strip(), "vlan": int(vlan_id), "ip": ip, "prefix": int(prefix), "gateway": gateway})
1151
- telnet_pattern = re.compile(r"enable\s+telnet\s+on\s+([A-Za-z0-9_ -]+?)\s+username\s+([A-Za-z0-9._-]+)\s+password\s+([A-Za-z0-9._-]+)", flags=re.IGNORECASE)
1152
- for segment in _command_segments(prompt):
1153
- for device, username, password in telnet_pattern.findall(segment):
1154
- ops.append({"op": "enable_telnet", "device": device.strip(), "username": username, "password": password})
1155
- return ops
1156
-
1157
-
1158
- def _extract_wireless_ops(prompt: str) -> list[dict[str, object]]:
1159
- ops: list[dict[str, object]] = []
1160
- ssid_pattern = re.compile(
1161
- r"set\s+([A-Za-z0-9_ -]+?)\s+ssid\s+([A-Za-z0-9._-]+)"
1162
- r"(?:\s+security\s+(wpa2?\s+enterprise|wpa2?-enterprise|wpa2?-psk|802\.1x|wep|open|wpa2?|wpa))?"
1163
- r"(?:\s+passphrase\s+([A-Za-z0-9._-]+))?"
1164
- r"(?:\s+radius\s+(\d+\.\d+\.\d+\.\d+)\s+secret\s+([A-Za-z0-9._-]+))?"
1165
- r"(?:\s+channel\s+(\d+))?",
1166
- flags=re.IGNORECASE,
1167
- )
1168
- for segment in _command_segments(prompt):
1169
- for device, ssid, security, passphrase, radius_server, radius_secret, channel in ssid_pattern.findall(segment):
1170
- security_key = (security or "open").lower().replace(" ", "-")
1171
- auth_type, encrypt_type = SECURITY_TO_AUTH.get(security_key, ("0", "0"))
1172
- operation = {
1173
- "op": "set_wireless_ssid",
1174
- "device": device.strip(),
1175
- "ssid": ssid,
1176
- "security": security_key,
1177
- "auth_type": auth_type,
1178
- "encrypt_type": encrypt_type,
1179
- "passphrase": passphrase or "",
1180
- "channel": int(channel) if channel else 1,
1181
- }
1182
- if radius_server:
1183
- operation["radius_server"] = radius_server
1184
- operation["radius_secret"] = radius_secret or ""
1185
- ops.append(operation)
1186
- assoc_pattern = re.compile(r"associate\s+([A-Za-z0-9_ -]+?)\s+to\s+([A-Za-z0-9_ -]+?)\s+ssid\s+([A-Za-z0-9._-]+)(?:\s+(dhcp|static))?", flags=re.IGNORECASE)
1187
- for segment in _command_segments(prompt):
1188
- for client, ap, ssid, ip_mode in assoc_pattern.findall(segment):
1189
- ops.append({"op": "associate_wireless_client", "device": client.strip(), "ap": ap.strip(), "ssid": ssid, "ip_mode": (ip_mode or "dhcp").lower()})
1190
- return ops
1191
-
1192
-
1193
- def _extract_end_device_ops(prompt: str) -> list[dict[str, object]]:
1194
- ops: list[dict[str, object]] = []
1195
- ip_pattern = re.compile(
1196
- r"(?:set|change|update)\s+([A-Za-z0-9_-]+)\s+ip\s+(\d+\.\d+\.\d+\.\d+)"
1197
- r"(?:\s+mask\s+(\d+\.\d+\.\d+\.\d+))?"
1198
- r"(?:\s+gw\s+(\d+\.\d+\.\d+\.\d+))?"
1199
- r"(?:\s+dns\s+(\d+\.\d+\.\d+\.\d+))?",
1200
- flags=re.IGNORECASE,
1201
- )
1202
- for segment in _command_segments(prompt):
1203
- for device, ip, mask, gw, dns in ip_pattern.findall(segment):
1204
- operation: dict[str, object] = {"op": "set_host_ip", "device": device, "ip": ip}
1205
- if mask:
1206
- operation["mask"] = mask
1207
- if gw:
1208
- operation["gw"] = gw
1209
- if dns:
1210
- operation["dns"] = dns
1211
- ops.append(operation)
1212
- for device in re.findall(r"set\s+([A-Za-z0-9_-]+)\s+ipv4\s+dhcp", segment, flags=re.IGNORECASE):
1213
- ops.append({"op": "set_host_dhcp", "device": device})
1214
- for device, dns in re.findall(r"set\s+([A-Za-z0-9_-]+)\s+dns\s+(\d+\.\d+\.\d+\.\d+)", segment, flags=re.IGNORECASE):
1215
- ops.append({"op": "set_host_dns", "device": device, "dns": dns})
1216
- return ops
1217
-
1218
-
1219
- def _extract_verification_ops(prompt: str) -> list[dict[str, object]]:
1220
- ops: list[dict[str, object]] = []
1221
- lowered = prompt.lower()
1222
- for command in ["show vlan brief", "show interfaces trunk", "show ip interface brief", "show ip dhcp binding"]:
1223
- if command in lowered:
1224
- ops.append({"op": "verify_command", "command": command})
1225
- if "ping" in lowered:
1226
- ops.append({"op": "verify_ping"})
1227
- if "telnet" in lowered:
1228
- ops.append({"op": "verify_telnet"})
1229
- return ops
1230
-
1231
-
1232
- def _extract_iot_ops(prompt: str) -> list[dict[str, object]]:
1233
- ops: list[dict[str, object]] = []
1234
- registration_pattern = re.compile(
1235
- r"register\s+([A-Za-z0-9_ -]+?)\s+to\s+([A-Za-z0-9_ -]+?)(?=\s+(?:mode|server(?:-address)?|username|password)\b|$)"
1236
- r"(?:\s+mode\s+(lan_server|remote_server))?"
1237
- r"(?:\s+server(?:-address)?\s+(\d+\.\d+\.\d+\.\d+))?"
1238
- r"(?:\s+username\s+([A-Za-z0-9._-]+))?"
1239
- r"(?:\s+password\s+([A-Za-z0-9._-]+))?",
1240
- flags=re.IGNORECASE,
1241
- )
1242
- for segment in _command_segments(prompt):
1243
- for device, target, mode, server_address, username, password in registration_pattern.findall(segment):
1244
- ops.append(
1245
- {
1246
- "op": "set_iot_registration",
1247
- "device": device.strip(),
1248
- "target": target.strip(),
1249
- "mode": (mode or "").upper() or None,
1250
- "server_address": server_address or None,
1251
- "username": username or None,
1252
- "password": password or None,
1253
- }
1254
- )
1255
- rule_pattern = re.compile(
1256
- r"(enable|disable)\s+iot\s+rule\s+(?:\"([^\"]+)\"|'([^']+)'|([A-Za-z0-9_ -]+?))\s+on\s+([A-Za-z0-9_ -]+)",
1257
- flags=re.IGNORECASE,
1258
- )
1259
- for action, quoted_name, single_quoted_name, plain_name, device in rule_pattern.findall(segment):
1260
- rule_name = quoted_name or single_quoted_name or plain_name
1261
- ops.append(
1262
- {
1263
- "op": "set_iot_rule_state",
1264
- "device": device.strip(),
1265
- "rule_name": rule_name.strip(),
1266
- "enabled": action.lower() == "enable",
1267
- }
1268
- )
1269
- return ops
1270
-
1271
-
1272
- def _decode_prompt_string(value: str) -> str:
1273
- return (
1274
- value.replace(r"\\", "\0")
1275
- .replace(r"\"", '"')
1276
- .replace(r"\n", "\n")
1277
- .replace(r"\r", "\r")
1278
- .replace(r"\t", "\t")
1279
- .replace("\0", "\\")
1280
- )
1281
-
1282
-
1283
- def _extract_programming_ops(prompt: str) -> list[dict[str, object]]:
1284
- ops: list[dict[str, object]] = []
1285
- script_pattern = re.compile(
1286
- r'set\s+"((?:\\.|[^"\\])*)"\s+script\s+app\s+"((?:\\.|[^"\\])*)"\s+file\s+"((?:\\.|[^"\\])*)"\s+content\s+"((?:\\.|[^"\\])*)"',
1287
- flags=re.IGNORECASE | re.DOTALL,
1288
- )
1289
- for device, app_name, file_name, content in script_pattern.findall(prompt):
1290
- ops.append(
1291
- {
1292
- "op": "set_script_file_content",
1293
- "device": _decode_prompt_string(device).strip(),
1294
- "app_name": _decode_prompt_string(app_name).strip(),
1295
- "file_name": _decode_prompt_string(file_name).strip(),
1296
- "content": _decode_prompt_string(content),
1297
- }
1298
- )
1299
- return ops
1300
-
1301
-
1302
- def parse_intent(prompt: str) -> IntentPlan:
1303
- pkt_path_match = re.search(r"([A-Za-z]:\\[^\"\n]+?\.pkt)\b", prompt, flags=re.IGNORECASE)
1304
- pkt_path = pkt_path_match.group(1) if pkt_path_match else None
1305
- lowered = prompt.lower()
1306
- normalized_prompt = _normalize_prompt(prompt)
1307
-
1308
- capability_set = {capability for capability, patterns in CAPABILITY_PATTERNS.items() if any(re.search(pattern, lowered) for pattern in patterns)}
1309
- devices = _extract_explicit_devices(prompt)
1310
- links = _extract_explicit_links(prompt)
1311
- device_counts = _extract_natural_device_counts(normalized_prompt)
1312
- vlan_ids = _extract_vlan_ids(normalized_prompt)
1313
- uplink_intent = _extract_uplink_intent(normalized_prompt)
1314
- host_link_intent = _extract_host_link_intent(normalized_prompt)
1315
- host_vlan_assignment = _extract_host_vlan_assignment(normalized_prompt)
1316
- network_style = _extract_network_style(normalized_prompt)
1317
- if (
1318
- network_style == "wireless_advanced"
1319
- and "server_aaa" in capability_set
1320
- and re.search(r"\bradius\b", lowered)
1321
- and not re.search(r"\b(?:aaa|acs|server aaa|aaa server)\b", lowered)
1322
- ):
1323
- capability_set.discard("server_aaa")
1324
- if re.search(r"\bqos\b", lowered) and (
1325
- re.search(r"\b(?:snmp|netflow|span|rspan|lldp|rep|dai|dot1x|802\.1x|dhcp snooping|port-security|port security)\b", lowered)
1326
- or network_style == "l2_security_monitoring"
1327
- ):
1328
- capability_set.add("qos")
1329
- if network_style == "automation_controller" or re.search(r"\b(?:network controller|netcon|blockly|iox)\b", lowered):
1330
- if re.search(r"\bpython\b", lowered):
1331
- capability_set.add("python_programming")
1332
- if re.search(r"\b(?:tcp|udp)\b", lowered) and re.search(r"\b(?:app|application|test app)\b", lowered):
1333
- capability_set.add("tcp_udp_app")
1334
- if "ospf" in capability_set and not re.search(r"\b(?:ipv6|ospfv3)\b", lowered):
1335
- capability_set.add("ospfv2")
1336
- if "eigrp" in capability_set and not re.search(r"\bipv6\b", lowered):
1337
- capability_set.add("eigrp_ipv4")
1338
- if "rip" in capability_set and not re.search(r"\b(?:ipv6|ripng)\b", lowered):
1339
- capability_set.add("ripv2")
1340
- if "nat" in capability_set:
1341
- if re.search(r"\bpat\b|\boverload\b", lowered):
1342
- capability_set.add("pat")
1343
- if re.search(r"\bstatic\s+nat\b|\bnat\s+static\b", lowered):
1344
- capability_set.add("nat_static")
1345
- if re.search(r"\bdynamic\s+nat\b|\bnat\s+dynamic\b", lowered):
1346
- capability_set.add("nat_dynamic")
1347
- if network_style == "ipv4_routing_management" and not {"nat_static", "nat_dynamic"} & capability_set:
1348
- capability_set.update({"nat_static", "nat_dynamic"})
1349
- if "ssh" in capability_set:
1350
- capability_set.add("ssh_ios")
1351
- if "ntp" in capability_set:
1352
- capability_set.add("ntp_ios")
1353
- if (
1354
- "server_syslog" in capability_set
1355
- and re.search(r"\b(?:server|ftp|email|aaa|dns)\b", lowered)
1356
- and not re.search(r"\b(?:logging\s+host|set\s+\S+\s+syslog\s+server)\b", lowered)
1357
- ):
1358
- capability_set.discard("syslog_ios")
1359
- if re.search(r"\blogging\s+host\b", lowered):
1360
- capability_set.add("syslog_ios")
1361
- if re.search(r"\bdefault\s+route\b|\b0\.0\.0\.0/0\b", lowered):
1362
- capability_set.update({"static_route", "default_route"})
1363
- elif re.search(r"\bstatic\s+route\b|\bip\s+route\b", lowered):
1364
- capability_set.add("static_route")
1365
- if re.search(r"\bdhcp\s+relay\b|\bdhcp-relay\b|\bhelper-address\b|\bip\s+helper\b", lowered):
1366
- capability_set.add("dhcp_relay")
1367
- if not re.search(r"\b(?:dhcp\s+pool|server\s+dhcp|dhcp\s+server|default-router)\b", lowered):
1368
- capability_set.discard("dhcp_pool")
1369
- capability_set.discard("router_dhcp")
1370
- capability_set.discard("server_dhcp")
1371
- if "dhcp_snooping" in capability_set and not re.search(r"\b(?:dhcp\s+pool|server\s+dhcp|dhcp\s+server|default-router)\b", lowered):
1372
- capability_set.discard("dhcp_pool")
1373
- capability_set.discard("router_dhcp")
1374
- capability_set.discard("server_dhcp")
1375
- department_count = _extract_department_count(normalized_prompt)
1376
- per_department_devices = _extract_per_department_devices(normalized_prompt)
1377
-
1378
- device_requirements = dict(device_counts)
1379
- for device in devices:
1380
- device_type = str(device["type"])
1381
- device_requirements[device_type] = device_requirements.get(device_type, 0) + 1
1382
-
1383
- department_groups = _build_department_groups(normalized_prompt, department_count, per_department_devices, vlan_ids)
1384
- if department_groups:
1385
- device_requirements["Switch"] = max(device_requirements.get("Switch", 0), department_count)
1386
- for device_type, per_group_count in per_department_devices.items():
1387
- device_requirements[device_type] = max(device_requirements.get(device_type, 0), department_count * per_group_count)
1388
-
1389
- topology_requirements: dict[str, object] = {}
1390
- if vlan_ids:
1391
- topology_requirements["vlan_ids"] = vlan_ids
1392
- if uplink_intent:
1393
- topology_requirements["uplink_intent"] = uplink_intent
1394
- if host_link_intent:
1395
- topology_requirements["host_link_intent"] = host_link_intent
1396
- if host_vlan_assignment:
1397
- topology_requirements["host_vlan_assignment"] = host_vlan_assignment
1398
- if device_requirements.get("Switch", 0) > 1:
1399
- topology_requirements["uplink_topology"] = "core_switch"
1400
- if department_groups:
1401
- topology_requirements["uplink_topology"] = "chain"
1402
- topology_requirements["department_count"] = department_count
1403
- if "router_dhcp" in capability_set or "dhcp_pool" in capability_set:
1404
- topology_requirements["needs_dhcp_pool"] = True
1405
- routing = next((cap for cap in ["ospf", "eigrp", "rip"] if cap in capability_set), None)
1406
- if routing:
1407
- topology_requirements["routing_protocol"] = routing
1408
- service_requirements = _extract_service_requirements(sorted(capability_set), prompt)
1409
- wireless_mode = _extract_wireless_mode(normalized_prompt, sorted(capability_set), device_requirements, network_style)
1410
-
1411
- edit_operations: list[dict[str, object]] = []
1412
- for old_name, new_name in re.findall(r"rename\s+([A-Za-z0-9_-]+)\s+to\s+([A-Za-z0-9_-]+)", prompt, flags=re.IGNORECASE):
1413
- edit_operations.append({"op": "rename_device", "device": old_name, "new_name": new_name})
1414
- edit_operations.extend(_extract_link_edit_operations(links))
1415
-
1416
- switch_ops = _extract_switch_ops(prompt)
1417
- router_ops = _extract_router_ops(prompt)
1418
- server_ops = _extract_server_ops(prompt)
1419
- wireless_ops = _extract_wireless_ops(prompt)
1420
- end_device_ops = _extract_end_device_ops(prompt)
1421
- management_ops = _extract_management_ops(prompt)
1422
- verification_ops = _extract_verification_ops(prompt)
1423
- iot_ops = _extract_iot_ops(prompt)
1424
- programming_ops = _extract_programming_ops(prompt)
1425
-
1426
- if any(op["op"] == "associate_wireless_client" for op in wireless_ops):
1427
- capability_set.update({"wireless_client", "wireless_client_association"})
1428
- if any(op["op"] == "set_wireless_ssid" for op in wireless_ops):
1429
- capability_set.update({"wireless_ap", "wireless_mutation"})
1430
- for op in wireless_ops:
1431
- if op.get("op") != "set_wireless_ssid":
1432
- continue
1433
- security = str(op.get("security") or "")
1434
- if security == "wep":
1435
- capability_set.add("wep")
1436
- if security in {"wpa-enterprise", "wpa2-enterprise", "802.1x"} or op.get("radius_server"):
1437
- capability_set.add("wpa_enterprise")
1438
- elif network_style == "wireless_advanced":
1439
- capability_set.discard("wireless_ap")
1440
- capability_set.discard("wireless_mutation")
1441
- if end_device_ops:
1442
- capability_set.add("end_device_mutation")
1443
- if iot_ops:
1444
- capability_set.update({"iot", "iot_registration"})
1445
- if any(op.get("op") == "set_iot_rule_state" for op in iot_ops):
1446
- capability_set.add("iot_control")
1447
- for operation in programming_ops:
1448
- file_name = str(operation.get("file_name") or "").lower()
1449
- app_name = str(operation.get("app_name") or "").lower()
1450
- content = str(operation.get("content") or "").lower()
1451
- if file_name.endswith(".py") or "python" in app_name:
1452
- capability_set.add("python_programming")
1453
- if file_name.endswith(".js") or "javascript" in app_name:
1454
- capability_set.add("javascript_programming")
1455
- if file_name.endswith(".visual") or "visual" in app_name or "<xml" in content:
1456
- capability_set.add("blockly_programming")
1457
- if "tcp" in " ".join([file_name, app_name, content]) or "udp" in " ".join([file_name, app_name, content]):
1458
- capability_set.add("tcp_udp_app")
1459
- if "realhttp" in content or "real http" in app_name:
1460
- capability_set.add("real_http")
1461
- if "realws" in content or "websocket" in content or "websocket" in app_name:
1462
- capability_set.add("real_websocket")
1463
- if "mqtt" in content or "mqtt" in app_name:
1464
- capability_set.add("mqtt")
1465
- if programming_ops and capability_set & {"python_programming", "javascript_programming", "blockly_programming"} and not capability_set & {"real_http", "real_websocket", "mqtt"}:
1466
- network_style = network_style or "automation_controller"
1467
- switch_op_names = {str(op.get("op")) for op in switch_ops}
1468
- if "set_dhcp_snooping" in switch_op_names:
1469
- capability_set.add("dhcp_snooping")
1470
- if "set_dai" in switch_op_names:
1471
- capability_set.add("dai")
1472
- if "set_lldp" in switch_op_names:
1473
- capability_set.add("lldp")
1474
- if "set_rep" in switch_op_names:
1475
- capability_set.add("rep")
1476
- if "set_span" in switch_op_names:
1477
- capability_set.add("span")
1478
- if "set_port_security" in switch_op_names:
1479
- capability_set.add("port_security")
1480
- if "set_dot1x" in switch_op_names:
1481
- capability_set.add("dot1x")
1482
- if "set_qos_policy" in switch_op_names:
1483
- capability_set.add("qos")
1484
- if "set_stp" in switch_op_names:
1485
- capability_set.add("stp")
1486
- for op in switch_ops:
1487
- if op.get("op") == "set_stp" and re.search(r"(?:rapid|rstp)", str(op.get("mode") or ""), flags=re.IGNORECASE):
1488
- capability_set.add("rstp")
1489
- if "set_etherchannel" in switch_op_names:
1490
- capability_set.add("etherchannel")
1491
- for op in switch_ops:
1492
- if op.get("op") != "set_etherchannel":
1493
- continue
1494
- mode = str(op.get("mode") or "").lower()
1495
- if mode in {"active", "passive"}:
1496
- capability_set.add("lacp")
1497
- if mode in {"desirable", "auto"}:
1498
- capability_set.add("pagp")
1499
- if "set_vtp" in switch_op_names:
1500
- capability_set.add("vtp")
1501
- if "set_dtp" in switch_op_names:
1502
- capability_set.add("dtp")
1503
- router_op_names = {str(op.get("op")) for op in router_ops}
1504
- if "set_bgp_neighbor" in router_op_names:
1505
- capability_set.add("bgp")
1506
- if "set_snmp_community" in router_op_names:
1507
- capability_set.add("snmp")
1508
- if "set_netflow" in router_op_names:
1509
- capability_set.add("netflow")
1510
- if "set_gre_tunnel" in router_op_names:
1511
- capability_set.add("gre")
1512
- if "set_ppp_interface" in router_op_names:
1513
- capability_set.add("ppp")
1514
- if router_op_names & {"set_ipsec_transform_set", "set_crypto_map"}:
1515
- capability_set.update({"ipsec", "vpn"})
1516
- if "set_cbac_inspect" in router_op_names:
1517
- capability_set.update({"cbac", "security_edge"})
1518
- if router_op_names & {"set_zfw_zone_interface", "set_zfw_zone_pair", "set_zfw_policy"}:
1519
- capability_set.update({"zfw", "security_edge"})
1520
- if "set_qos_policy" not in switch_op_names:
1521
- capability_set.discard("qos")
1522
- capability_set.discard("server_http")
1523
- capability_set.discard("server_https")
1524
- if router_op_names & {"enable_ipv6_unicast_routing", "set_ipv6_address", "set_ipv6_slaac"}:
1525
- capability_set.add("ipv6_slaac")
1526
- if "set_dhcpv6_pool" in router_op_names:
1527
- capability_set.add("dhcpv6_stateful")
1528
- if "set_ospfv3_interface" in router_op_names:
1529
- capability_set.add("ospfv3")
1530
- if "set_eigrp_ipv6_interface" in router_op_names:
1531
- capability_set.add("eigrp_ipv6")
1532
- if "set_ripng_interface" in router_op_names:
1533
- capability_set.add("ripng")
1534
- if "set_hsrp_ipv6" in router_op_names:
1535
- capability_set.add("hsrp")
1536
- if "set_ospfv2_network" in router_op_names:
1537
- capability_set.add("ospfv2")
1538
- if "set_eigrp_ipv4_network" in router_op_names:
1539
- capability_set.add("eigrp_ipv4")
1540
- if "set_ripv2_network" in router_op_names:
1541
- capability_set.add("ripv2")
1542
- if "set_static_route" in router_op_names:
1543
- capability_set.add("static_route")
1544
- if any(op.get("op") == "set_static_route" and op.get("network") == "0.0.0.0" and op.get("prefix") == 0 for op in router_ops):
1545
- capability_set.add("default_route")
1546
- if "set_dhcp_relay" in router_op_names:
1547
- capability_set.add("dhcp_relay")
1548
- if "set_nat_interface" in router_op_names:
1549
- capability_set.add("nat")
1550
- if "set_nat_static" in router_op_names:
1551
- capability_set.update({"nat", "nat_static"})
1552
- if "set_pat_overload" in router_op_names:
1553
- capability_set.update({"nat", "pat"})
1554
- if "set_ssh_ios" in router_op_names:
1555
- capability_set.update({"ssh", "ssh_ios"})
1556
- if "set_ntp_server" in router_op_names:
1557
- capability_set.update({"ntp", "ntp_ios"})
1558
- if "set_syslog_server" in router_op_names:
1559
- capability_set.update({"syslog_ios"})
1560
- if router_op_names & {"set_telephony_service", "set_ephone_dn", "set_ephone", "set_dial_peer_voice"}:
1561
- capability_set.update({"voip", "call_manager"})
1562
- if router_op_names & {"set_ephone_dn", "set_ephone"}:
1563
- capability_set.add("ip_phone")
1564
- if capability_set & {"vpn", "ipsec", "gre", "ppp", "security_edge"}:
1565
- network_style = network_style or "wan_security"
1566
- if capability_set & {"ipv6_slaac", "dhcpv6_stateful", "dhcpv6_stateless", "ospfv3", "eigrp_ipv6", "ripng", "hsrp"}:
1567
- network_style = network_style or "ipv6_routing"
1568
- if capability_set & {"ospfv2", "eigrp_ipv4", "ripv2", "static_route", "default_route", "dhcp_relay", "nat_static", "nat_dynamic", "pat", "ssh_ios", "ntp_ios", "syslog_ios"}:
1569
- network_style = network_style or "ipv4_routing_management"
1570
- if capability_set & {"dhcp_snooping", "dai", "dot1x", "lldp", "rep", "snmp", "netflow", "span", "qos", "port_security"}:
1571
- network_style = network_style or "l2_security_monitoring"
1572
- if capability_set & {"bgp", "stp", "rstp", "etherchannel", "lacp", "pagp", "vtp", "dtp"}:
1573
- network_style = "l2_resiliency_routing"
1574
- if capability_set & {"wlc", "wpa_enterprise", "wep", "guest_wifi", "beamforming", "meraki", "cellular_5g", "bluetooth"}:
1575
- network_style = "wireless_advanced"
1576
- if capability_set & {"coaxial", "cable_dsl", "central_office", "cell_tower", "power_distribution", "hot_swappable", "ios_license"}:
1577
- network_style = "physical_media_device"
1578
- if capability_set & {"mqtt", "real_http", "real_websocket", "visual_scripting", "ptp", "profinet", "l2nat", "cyberobserver", "industrial_firewall"}:
1579
- network_style = "industrial_iot"
1580
- if capability_set & {"real_http", "real_websocket"}:
1581
- capability_set.discard("server_http")
1582
- capability_set.discard("server_https")
1583
- if not (
1584
- iot_ops
1585
- or re.search(r"\b(?:home\s+gateway|smart\s+home|iot\s+registration|iot\s+control|iot\s+rule)\b", lowered)
1586
- or re.search(r"\bregister\s+.+\s+to\b", lowered)
1587
- ):
1588
- capability_set.discard("iot")
1589
- if capability_set & {"voip", "ip_phone", "call_manager", "linksys_voice"}:
1590
- network_style = "voice_collaboration"
1591
-
1592
- capabilities = sorted(capability_set)
1593
-
1594
- parse_warnings: list[str] = []
1595
- blocking_gaps: list[str] = []
1596
- assumptions_used: list[str] = []
1597
- if any(word in normalized_prompt for word in TOPOLOGY_HINT_WORDS) and not device_requirements and not devices:
1598
- parse_warnings.append("Prompt includes topology words but no stable device counts were parsed.")
1599
- if any(op["op"] == "set_management_vlan" for op in management_ops):
1600
- topology_requirements["management_vlan"] = True
1601
-
1602
- pc_count = device_requirements.get("PC", 0)
1603
- if department_groups and vlan_ids and len(vlan_ids) >= len(department_groups) and not host_vlan_assignment:
1604
- for index, group in enumerate(department_groups):
1605
- vlan_id = vlan_ids[index]
1606
- host_vlan_assignment[vlan_id] = host_vlan_assignment.get(vlan_id, 0) + int(group["devices"].get("PC", 0))
1607
- assumptions_used.append("Assigned each department's PCs to the matching VLAN order.")
1608
- if vlan_ids and pc_count and not host_vlan_assignment and not any(op["op"] == "set_access_port" for op in switch_ops):
1609
- blocking_gaps.append("Host-to-VLAN assignment is missing. Specify how many PCs belong to each VLAN.")
1610
- if vlan_ids and not device_requirements.get("Switch", 0) and not any(device.get("type") == "Switch" for device in devices):
1611
- blocking_gaps.append("VLAN planning requires at least one switch.")
1612
- if department_groups and vlan_ids and len(vlan_ids) < len(department_groups):
1613
- blocking_gaps.append("Department count is larger than provided VLAN IDs.")
1614
-
1615
- if department_groups and "Switch" not in device_counts:
1616
- assumptions_used.append("Added one switch per department group.")
1617
- if department_groups and not network_style:
1618
- network_style = "campus"
1619
- assumptions_used.append("Interpreted department-based prompt as campus style.")
1620
- if network_style == "wan_security":
1621
- assumptions_used.append("Interpreted VPN/WAN/security wording as WAN-security edge style.")
1622
-
1623
- goal = "edit" if pkt_path or any(word in normalized_prompt for word in ["deyis", "edit", "modify", "change", "rename", "update"]) else "generate"
1624
- confidence_score = _estimate_confidence(device_requirements, capabilities, parse_warnings, blocking_gaps, devices, links)
1625
- return IntentPlan(
1626
- goal=goal,
1627
- prompt=prompt,
1628
- pkt_path=pkt_path,
1629
- capabilities=capabilities,
1630
- network_style=network_style,
1631
- wireless_mode=wireless_mode,
1632
- device_requirements=device_requirements,
1633
- device_counts=device_counts,
1634
- department_groups=department_groups,
1635
- service_requirements=service_requirements,
1636
- topology_requirements=topology_requirements,
1637
- vlan_ids=vlan_ids,
1638
- uplink_intent=uplink_intent,
1639
- host_link_intent=host_link_intent,
1640
- host_vlan_assignment=host_vlan_assignment,
1641
- assumptions_used=assumptions_used,
1642
- confidence_score=confidence_score,
1643
- parse_warnings=parse_warnings,
1644
- blocking_gaps=blocking_gaps,
1645
- edit_operations=edit_operations,
1646
- devices=devices,
1647
- links=links,
1648
- switch_ops=switch_ops,
1649
- router_ops=router_ops,
1650
- server_ops=server_ops,
1651
- wireless_ops=wireless_ops,
1652
- end_device_ops=end_device_ops,
1653
- management_ops=management_ops,
1654
- verification_ops=verification_ops,
1655
- iot_ops=iot_ops,
1656
- programming_ops=programming_ops,
1657
- )
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ import re
6
+ import unicodedata
7
+ from dataclasses import asdict, dataclass, field
8
+ from functools import lru_cache
9
+
10
+
11
+ CAPABILITY_PATTERNS = {
12
+ "vlan": [r"\bvlan(?:larda|lar|da|de|a|e)?\b", r"\btrunk\b", r"\baccess port\b", r"\brouter-on-a-stick\b", r"\bdot1q\b"],
13
+ "trunk": [r"\btrunk\b", r"\ballowed vlan\b", r"\bnative vlan\b"],
14
+ "access_port": [r"\baccess port\b", r"\baccess-port\b"],
15
+ "router_on_a_stick": [r"\brouter-on-a-stick\b", r"\bsubinterface\b", r"\bdot1q\b"],
16
+ "dhcp_pool": [r"\bdhcp\b", r"\bdhcp pool\b"],
17
+ "router_dhcp": [r"\bdhcp\b", r"\bdhcp pool\b", r"\bdefault-router\b"],
18
+ "server_dhcp": [r"\bserver dhcp\b", r"\bdhcp server\b"],
19
+ "dns": [r"\bdns\b", r"\ba record\b", r"\bcname\b"],
20
+ "server_dns": [r"\bdns\b", r"\ba record\b", r"\bcname\b"],
21
+ "server_http": [r"\bhttp\b", r"\bweb\b"],
22
+ "server_ftp": [r"\bftp\b", r"\btftp\b"],
23
+ "server_https": [r"\bhttps\b"],
24
+ "server_tftp": [r"\btftp\b"],
25
+ "server_email": [r"\bemail\b", r"\bsmtp\b", r"\bpop3\b"],
26
+ "server_syslog": [r"\bsyslog\b"],
27
+ "server_aaa": [r"\baaa\b", r"\bacs\b", r"\bradius\b"],
28
+ "iot": [r"\biot\b", r"\bhome gateway\b", r"\bsmart home\b", r"\bsensor\b", r"\bactuator\b", r"\bmcu\b"],
29
+ "iot_registration": [r"\biot registration\b", r"\bregister\b", r"\bregistration server\b"],
30
+ "iot_control": [r"\biot control\b", r"\bcontrol\b", r"\btrigger\b", r"\bturn on\b", r"\bturn off\b", r"\block\b", r"\bunlock\b"],
31
+ "management_vlan": [r"\bmanagement vlan\b", r"\bvlan 99\b", r"\bdefault-gateway\b"],
32
+ "telnet": [r"\btelnet\b", r"\bvty\b", r"\btransport input telnet\b"],
33
+ "wireless_ap": [r"\bssid\b", r"\bwifi\b", r"\bwpa\b", r"\bwpa2\b", r"\bwireless\b", r"\baccess point\b", r"\bap\b"],
34
+ "wireless_mutation": [r"\bset\s+[A-Za-z0-9_ -]+?\s+ssid\b", r"\bsecurity\s+(?:wpa|wpa2|wpa-enterprise|wpa2-enterprise|wep|open|802\.1x)\b", r"\bpassphrase\b", r"\bchannel\s+\d+\b"],
35
+ "wireless_client": [r"\btablet\b", r"\blaptop\b", r"\bsmartphone\b", r"\bassociate\b", r"\bjoin\b"],
36
+ "wireless_client_association": [r"\bassociate\b", r"\bjoin\b"],
37
+ "tablet": [r"\btablet\b"],
38
+ "printer": [r"\bprinter\b"],
39
+ "end_device_mutation": [r"\bset\s+[A-Za-z0-9_-]+\s+ip\b", r"\bset\s+[A-Za-z0-9_-]+\s+ipv4\s+dhcp\b", r"\bset\s+[A-Za-z0-9_-]+\s+dns\b"],
40
+ "verification": [r"\bshow vlan brief\b", r"\bshow interfaces trunk\b", r"\bshow ip interface brief\b", r"\bshow ip dhcp binding\b", r"\bping\b", r"\btelnet\b"],
41
+ "ospf": [r"\bospf\b"],
42
+ "eigrp": [r"\beigrp\b"],
43
+ "rip": [r"\brip\b"],
44
+ "ospfv2": [r"\bospfv2\b", r"\bospf v2\b", r"\bsingle-area ospf\b", r"\brouter ospf\b"],
45
+ "eigrp_ipv4": [r"\beigrp ipv4\b", r"\bipv4 eigrp\b", r"\brouter eigrp\b"],
46
+ "ripv2": [r"\bripv2\b", r"\brip v2\b", r"\brip version 2\b", r"\brouter rip\b"],
47
+ "static_route": [r"\bstatic route\b", r"\bstatic-route\b", r"\bip route\b"],
48
+ "default_route": [r"\bdefault route\b", r"\bdefault-route\b", r"\b0\.0\.0\.0/0\b"],
49
+ "dhcp_relay": [r"\bdhcp relay\b", r"\bdhcp-relay\b", r"\bhelper-address\b", r"\bip helper\b"],
50
+ "nat_static": [r"\bstatic nat\b", r"\bnat static\b"],
51
+ "nat_dynamic": [r"\bdynamic nat\b", r"\bnat dynamic\b"],
52
+ "pat": [r"\bpat\b", r"\boverload\b"],
53
+ "ssh_ios": [r"\bssh\b", r"\bip ssh\b", r"\bcrypto key\b"],
54
+ "ntp_ios": [r"\bntp\b", r"\bntp server\b"],
55
+ "syslog_ios": [r"\bsyslog\b", r"\blogging host\b"],
56
+ # Plain `ipv6` was only ever a network-style tag, never a capability, so
57
+ # "ipv6 olsun" produced nothing for the planner to act on.
58
+ "ipv6": [r"\bipv6\b", r"\bdual[- ]?stack\b"],
59
+ # Without this every OSPF lab was single-area, whatever the prompt asked.
60
+ "ospf_multiarea": [r"\bmulti[- ]?area\b", r"\barea\s+[1-9]\d*\b", r"\bcoxsaheli\b"],
61
+ "ipv6_slaac": [r"\bslaac\b", r"\bipv6\s+slaac\b"],
62
+ "dhcpv6_stateful": [r"\bdhcpv6\b", r"\bstateful dhcpv6\b", r"\bipv6 stateful\b"],
63
+ "dhcpv6_stateless": [r"\bstateless dhcpv6\b", r"\bipv6 stateless\b"],
64
+ "ipv6_prefix_delegation": [r"\bprefix delegation\b", r"\bipv6 prefix\b"],
65
+ "ipv6_dns_aaaa": [r"\baaaa\b", r"\bipv6 dns\b"],
66
+ "ipv6_tunneling": [r"\bipv6ip\b", r"\bipv6 tunnel\b", r"\bipv6 tunneling\b"],
67
+ "isatap": [r"\bisatap\b"],
68
+ "ospfv3": [r"\bospfv3\b", r"\bipv6 ospf\b"],
69
+ "eigrp_ipv6": [r"\bipv6 eigrp\b", r"\beigrp ipv6\b"],
70
+ "ripng": [r"\bripng\b", r"\bipv6 rip\b"],
71
+ "hsrp": [r"\bhsrp\b"],
72
+ "nat": [r"\bnat\b"],
73
+ "acl": [r"\bacl\b", r"\baccess-list\b"],
74
+ "dhcp_snooping": [r"\bdhcp snooping\b"],
75
+ "dai": [r"\bdai\b", r"\bdynamic arp inspection\b"],
76
+ "dot1x": [r"\bdot1x\b", r"\b802\.1x\b", r"\bnac\b"],
77
+ "lldp": [r"\blldp\b"],
78
+ "rep": [r"\brep\b"],
79
+ "snmp": [r"\bsnmp\b"],
80
+ "netflow": [r"\bnetflow\b"],
81
+ "span": [r"\bmonitor\s+session\b", r"\bspan\b", r"\brspan\b"],
82
+ "qos": [r"\bqos policy\b", r"\bqos class\b", r"\bclass-map\b", r"\bpolicy-map\b", r"\bquality of service\b"],
83
+ "port_security": [r"\bport security\b", r"\bport-security\b"],
84
+ "bgp": [r"\bbgp\b", r"\brouter bgp\b", r"\bremote-as\b"],
85
+ "stp": [r"\bstp\b", r"\bspanning tree\b", r"\bspanning-tree\b", r"\bpvst\b"],
86
+ "rstp": [r"\brstp\b", r"\brapid-pvst\b", r"\brapid pvst\b", r"\brapid spanning\b"],
87
+ "etherchannel": [r"\betherchannel\b", r"\bport-channel\b", r"\bchannel-group\b"],
88
+ "lacp": [r"\blacp\b"],
89
+ "pagp": [r"\bpagp\b"],
90
+ "vtp": [r"\bvtp\b"],
91
+ "dtp": [r"\bdtp\b", r"\bdynamic desirable\b", r"\bdynamic auto\b"],
92
+ "vpn": [r"\bvpn\b", r"\bsite-to-site\b", r"\bipsec\b", r"\btunnel\b"],
93
+ "ipsec": [r"\bipsec\b", r"\bike\b", r"\bphase 1\b", r"\bphase 2\b"],
94
+ "gre": [r"\bgre\b", r"\bgre tunnel\b"],
95
+ "ppp": [r"\bppp\b", r"\bchap\b", r"\bpap\b"],
96
+ # `_add_wan_link` gates the router-to-router link on a `wan` capability that
97
+ # nothing ever produced, so "routerler arasinda serial kabel olsun" came out
98
+ # with the two routers not connected to each other at all -- six copper
99
+ # links and no serial one. A prompt naming serial, WAN or a leased line is
100
+ # asking for exactly that link.
101
+ "wan": [
102
+ r"\bwan\b", r"\bserial\b", r"\bleased line\b",
103
+ r"\bpoint-to-point\b", r"\bnoqteden noqteye\b",
104
+ ],
105
+ "security_edge": [r"\bsecurity edge\b", r"\bsecurity appliance\b", r"\bfirewall\b", r"\basa\b"],
106
+ "asa_acl_nat": [r"\basa acl\b", r"\basa nat\b", r"\basa acl nat\b"],
107
+ "asa_service_policy": [r"\basa service policy\b", r"\bservice policy\b"],
108
+ "clientless_vpn": [r"\bclientless vpn\b"],
109
+ "cbac": [r"\bcbac\b"],
110
+ "zfw": [r"\bzfw\b", r"\bzone based firewall\b", r"\bzone-based firewall\b"],
111
+ "sniffer": [r"\bsniffer\b"],
112
+ "multilayer_switching": [r"\bmultilayer switch\b", r"\blayer 3 switch\b", r"\b3560\b", r"\bsvi\b"],
113
+ "wlc": [r"\bwlc\b", r"\bwireless lan controller\b"],
114
+ "wpa_enterprise": [r"\bwpa enterprise\b", r"\bwpa2 enterprise\b", r"\bwpa-enterprise\b", r"\bwpa2-enterprise\b", r"\b802\.1x wireless\b"],
115
+ "wep": [r"\bwep\b"],
116
+ "guest_wifi": [r"\bguest wifi\b", r"\bguest wlan\b"],
117
+ "beamforming": [r"\bbeamforming\b"],
118
+ "meraki": [r"\bmeraki\b"],
119
+ "cellular_5g": [r"\b5g\b", r"\bcellular\b", r"\bcell tower\b"],
120
+ "bluetooth": [r"\bbluetooth\b"],
121
+ "network_controller": [r"\bnetwork controller\b", r"\bnetcon\b"],
122
+ "python_programming": [r"\bpython programming\b", r"\bpython app\b"],
123
+ "javascript_programming": [r"\bjavascript\b", r"\bjs app\b"],
124
+ "blockly_programming": [r"\bblockly\b"],
125
+ "tcp_udp_app": [r"\btcp test app\b", r"\budp test app\b", r"\btcp app\b", r"\budp app\b"],
126
+ "vm_iox": [r"\biox\b", r"\bvm\b", r"\bvirtual machine\b"],
127
+ "voip": [r"\bvoip\b"],
128
+ "ip_phone": [r"\bip phone\b", r"\bphone\b"],
129
+ "call_manager": [r"\bcall manager\b", r"\btelephony\b"],
130
+ "linksys_voice": [r"\blinksys voice\b"],
131
+ "mqtt": [r"\bmqtt\b"],
132
+ "real_http": [r"\breal http\b"],
133
+ "real_websocket": [r"\bwebsocket\b", r"\breal websocket\b"],
134
+ "visual_scripting": [r"\bvisual scripting\b"],
135
+ "ptp": [r"\bptp\b", r"\bprecision time protocol\b"],
136
+ "profinet": [r"\bprofinet\b"],
137
+ "l2nat": [r"\bl2nat\b", r"\bl2 nat\b"],
138
+ "cyberobserver": [r"\bcyberobserver\b"],
139
+ "industrial_firewall": [r"\bindustrial firewall\b", r"\bisa3000\b"],
140
+ "coaxial": [r"\bcoaxial\b"],
141
+ "cable_dsl": [r"\bcable modem\b", r"\bdsl\b", r"\bdsl modem\b"],
142
+ "central_office": [r"\bcentral office\b"],
143
+ "cell_tower": [r"\bcell tower\b"],
144
+ "power_distribution": [r"\bpower distribution\b"],
145
+ "hot_swappable": [r"\bhot swappable\b", r"\bhot-swappable\b"],
146
+ "ios_license": [r"\bios license\b", r"\blicense\b", r"\bios15\b", r"\bios 15\b"],
147
+ }
148
+
149
+ DEVICE_SYNONYMS = {
150
+ "router": "Router",
151
+ "switch": "Switch",
152
+ "pc": "PC",
153
+ "server": "Server",
154
+ "access-point": "LightWeightAccessPoint",
155
+ "accesspoint": "LightWeightAccessPoint",
156
+ "ap": "LightWeightAccessPoint",
157
+ "wireless-router": "WirelessRouter",
158
+ "wirelessrouter": "WirelessRouter",
159
+ "home-router": "WirelessRouter",
160
+ "homerouter": "WirelessRouter",
161
+ "tablet": "Tablet",
162
+ "laptop": "Laptop",
163
+ "smartphone": "Smartphone",
164
+ "printer": "Printer",
165
+ "multilayer-switch": "MultiLayerSwitch",
166
+ "multilayerswitch": "MultiLayerSwitch",
167
+ "layer3-switch": "MultiLayerSwitch",
168
+ "layer-3-switch": "MultiLayerSwitch",
169
+ "cloud": "Cloud",
170
+ "cable-modem": "Cable Modem",
171
+ "cablemodem": "Cable Modem",
172
+ "dsl-modem": "Dsl Modem",
173
+ "dslmodem": "Dsl Modem",
174
+ "security-appliance": "Security Appliance",
175
+ "securityappliance": "Security Appliance",
176
+ "asa": "ASA",
177
+ }
178
+
179
+ NATURAL_DEVICE_ALIASES = {
180
+ "Router": ["router", "routerler", "routerlerin"],
181
+ "Switch": ["switch", "switchler", "switchlerin", "komutator", "kommutator"],
182
+ "PC": ["pc", "pcs", "computer", "computers", "komputer", "komputerler", "kompyuter", "kompyuterler"],
183
+ "Server": ["server", "serverler"],
184
+ "WirelessRouter": ["wireless router", "wireless-router", "wirelessrouter", "home router", "home-router", "wrt"],
185
+ # `smartphone` belongs here rather than under a key of its own. Packet
186
+ # Tracer's palette lists SMARTPHONE-PT separately, but a lab saves it as
187
+ # `Pda`, which normalises to `Tablet` -- so a `Smartphone` key would be one
188
+ # the planner never produces, the same mistake `Cable Modem` made.
189
+ "Tablet": ["tablet", "tabletler", "pda", "handheld", "smartphone", "smartfon", "smart phone"],
190
+ "Laptop": ["laptop", "laptoplar"],
191
+ "Printer": ["printer", "printerler"],
192
+ "LightWeightAccessPoint": ["ap", "aps", "accesspoint", "access-point", "access point", "apler"],
193
+ "MultiLayerSwitch": ["multilayer switch", "multilayer-switch", "multilayerswitch", "layer 3 switch", "layer3 switch", "3560"],
194
+ "Cloud": ["cloud", "wan cloud"],
195
+ # Keyed as `normalize_device_type` spells them, not as English does. Written
196
+ # `Cable Modem` and `Dsl Modem`, these were keys the planner never produces,
197
+ # so both aliases resolved to nothing -- the same defect the `IoT` note below
198
+ # describes, repeated twice. Donors carry three cable modems and two DSL
199
+ # modems, none of which could be asked for.
200
+ "CableModem": ["cable modem", "cable-modem", "cablemodem", "kabel modem"],
201
+ "DslModem": ["dsl modem", "dsl-modem", "dslmodem"],
202
+ "ASA": ["asa", "security appliance", "security-appliance", "firewall"],
203
+ # Voice and IoT devices could not be asked for at all: "4 ip phone qur"
204
+ # parsed the count and then dropped it, because no alias matched.
205
+ "IpPhone": ["ip phone", "ipphone", "ip-phone", "telefon", "voip telefon"],
206
+ # Alias keys must be the *normalised* kind the planner works in, not the
207
+ # raw XML type. A device library stores IoT things as `MCUComponent`, which
208
+ # `normalize_device_type` folds to `IoT`; asking for `MCUComponent` looked
209
+ # for a kind the planner never produces, so a donor holding 112 of them
210
+ # still reported "no spare device".
211
+ "IoT": [
212
+ "iot device", "iot cihaz", "smart device", "agilli cihaz", "sensor",
213
+ "smart light", "smart door", "motion sensor", "temperature sensor",
214
+ "smoke detector", "fan", "lamp", "termostat", "thermostat",
215
+ ],
216
+ "CCTVCamera": ["camera", "kamera", "cctv", "webcam"],
217
+ "Hub": ["hub", "konsentrator"],
218
+ "Repeater": ["repeater", "təkrarlayıcı", "tekrarlayici"],
219
+ # Added from what the donor pool actually holds, not from Packet Tracer's
220
+ # catalogue. Measured across 60 local donors: WirelessLanController appears
221
+ # in 3, Pda in 5, AnalogPhone and HomeVoip in 2 each. Patch panels, bridges,
222
+ # sniffers, Meraki gear, cell towers, TVs and coaxial splitters appear in
223
+ # none of them, so aliasing those would only turn a prompt into a refusal.
224
+ "WirelessLanController": [
225
+ "wlc", "wireless lan controller", "wireless controller",
226
+ "simsiz kontroller", "wlan controller",
227
+ ],
228
+ "AnalogPhone": ["analog phone", "analog telefon", "analoq telefon"],
229
+ "HomeVoip": ["home voip", "home phone", "ev telefonu", "voip phone"],
230
+ # These exist in Packet Tracer's palette and in no local donor, so the
231
+ # earlier note here said aliasing them would only produce refusals. That was
232
+ # true of the donors that happened to be on disk, not of the tool: a donor
233
+ # carrying them was built through the live bridge and saved with the running
234
+ # build, and the kinds normalise to themselves, so they resolve.
235
+ "Patch Panel": ["patch panel", "patch-panel", "patchpanel", "kross panel"],
236
+ "Bridge": ["bridge", "kopru", "körpü"],
237
+ "Sniffer": ["sniffer", "paket analizatoru", "packet sniffer"],
238
+ "SecurityAppliance": ["meraki", "meraki mx", "security appliance mx"],
239
+ "TV": ["tv", "televizor", "smart tv"],
240
+ "CoAxialSplitter": ["coaxial splitter", "koaksial splitter", "splitter"],
241
+ "CellTower": ["cell tower", "mobil qulle", "base station", "bts"],
242
+ # Kinds the local donor pool actually holds and no prompt could name. Counted
243
+ # across 140 labs: CentralOfficeServer in 11, MerakiServer in 7, PLC in 6,
244
+ # NetworkController in 5, CyberObserver in 3, Power Distribution Device in 2,
245
+ # DataHistorian in 1. Each is listed here only because a donor carries it --
246
+ # an alias for a kind nobody has cannot do anything but turn a prompt into a
247
+ # refusal, which is what the note above this block was about.
248
+ #
249
+ # `meraki` alone stays with SecurityAppliance: the Meraki MX is the security
250
+ # appliance, and the server is the separate cloud-controller item.
251
+ "CentralOfficeServer": ["central office server", "central office", "co server"],
252
+ "MerakiServer": ["meraki server", "meraki cloud", "meraki dashboard"],
253
+ "NetworkController": ["network controller", "netcon", "sebeke kontrolleri"],
254
+ "PLC": ["plc", "programmable logic controller", "kontroller plc"],
255
+ "CyberObserver": ["cyber observer", "cyberobserver"],
256
+ "DataHistorian": ["data historian", "historian"],
257
+ "Power Distribution Device": [
258
+ "power distribution device", "power distribution", "guc paylayici",
259
+ ],
260
+ # Added after building a donor that carries them, and checking what Packet
261
+ # Tracer actually writes for each. The palette lists more than these, and
262
+ # the saved types do not line up with it one for one:
263
+ #
264
+ # SMARTPHONE-PT saves as `Pda` -> folded into Tablet above
265
+ # Fiber Patch Panel saves as `Patch Panel` -> same kind as the copper one
266
+ # Copper Wall Mount saves as `Wall Mount` -> so does the fiber one
267
+ # Embedded-Server-PT Packet Tracer refused to create it at all
268
+ #
269
+ # So a palette of 74 entries is not 74 askable kinds. These four are the
270
+ # ones that survive as distinct types in a saved lab.
271
+ "SBC": ["sbc", "single board computer", "raspberry", "raspberry pi"],
272
+ "WiredEndDevice": ["wired end device", "simli son cihaz"],
273
+ "WirelessEndDevice": ["wireless end device", "simsiz son cihaz"],
274
+ "Wall Mount": ["wall mount", "wall-mount", "divar montaji"],
275
+ }
276
+
277
+ NETWORK_STYLE_PATTERNS = {
278
+ "campus": [r"\bcampus\b", r"\bkampus\b", r"\bsobeli\b", r"\bdepart", r"\bdepartment\b"],
279
+ "branch": [r"\bbranch\b", r"\bfilial\b"],
280
+ "ipv6_routing": [r"\bipv6\b", r"\bslaac\b", r"\bdhcpv6\b", r"\bospfv3\b", r"\bhsrp\b"],
281
+ "ipv4_routing_management": [r"\bospfv2\b", r"\bospf v2\b", r"\beigrp ipv4\b", r"\bripv2\b", r"\brip version 2\b", r"\bstatic route\b", r"\bdefault route\b", r"\bdhcp relay\b", r"\bnat\b", r"\bpat\b", r"\bip ssh\b", r"\bntp server\b", r"\blogging host\b"],
282
+ "l2_security_monitoring": [r"\bdhcp snooping\b", r"\bdai\b", r"\bdot1x\b", r"\b802\.1x\b", r"\bsnmp\b", r"\bnetflow\b", r"\bspan\b", r"\brspan\b", r"\bquality of service\b", r"\blldp\b", r"\brep\b", r"\bport security\b", r"\bport-security\b"],
283
+ "l2_resiliency_routing": [r"\bbgp\b", r"\bstp\b", r"\brstp\b", r"\bspanning tree\b", r"\bspanning-tree\b", r"\betherchannel\b", r"\bport-channel\b", r"\bchannel-group\b", r"\blacp\b", r"\bpagp\b", r"\bvtp\b", r"\bdtp\b"],
284
+ "wireless_advanced": [r"\bwlc\b", r"\bwireless lan controller\b", r"\bmeraki\b", r"\bbluetooth\b", r"\b5g\b", r"\bcellular\b", r"\bwpa enterprise\b", r"\bwpa2 enterprise\b", r"\bwep\b", r"\bguest wifi\b", r"\bguest wlan\b", r"\bbeamforming\b"],
285
+ "automation_controller": [r"\bnetwork controller\b", r"\bnetcon\b", r"\bblockly\b", r"\bjavascript\b", r"\bpython programming\b", r"\biox\b"],
286
+ "voice_collaboration": [r"\bvoip\b", r"\bip phone\b", r"\bcall manager\b", r"\btelephony\b"],
287
+ "industrial_iot": [r"\bmqtt\b", r"\bwebsocket\b", r"\bprofinet\b", r"\bl2nat\b", r"\bptp\b", r"\bcyberobserver\b", r"\bindustrial\b"],
288
+ "physical_media_device": [r"\bcoaxial\b", r"\bcable modem\b", r"\bdsl modem\b", r"\bcentral office\b", r"\bcell tower\b", r"\bpower distribution\b"],
289
+ "small_office": [r"\bsmall office\b", r"\bhome\b", r"\bofis\b"],
290
+ "wireless_branch": [r"\bwireless\b", r"\bwifi\b", r"\bssid\b"],
291
+ "wan_security": [r"\bwan\b", r"\bgre\b", r"\bppp\b", r"\bipsec\b", r"\bvpn\b", r"\bsecurity edge\b", r"\bfirewall\b", r"\basa\b"],
292
+ }
293
+
294
+ PER_DEPARTMENT_DEVICE_ALIASES = {
295
+ "PC": ["pc", "komputer", "computer"],
296
+ "Printer": ["printer"],
297
+ "LightWeightAccessPoint": ["ap", "access point", "accesspoint"],
298
+ "Tablet": ["tablet"],
299
+ "Laptop": ["laptop"],
300
+ "Server": ["server"],
301
+ }
302
+
303
+ TOPOLOGY_HINT_WORDS = [
304
+ "switch",
305
+ "switchler",
306
+ "router",
307
+ "komputer",
308
+ "pc",
309
+ "server",
310
+ "printer",
311
+ "ap",
312
+ "tablet",
313
+ "qosul",
314
+ "qo",
315
+ "gig",
316
+ "fastethernet",
317
+ "vlan",
318
+ ]
319
+
320
+ SECURITY_TO_AUTH = {
321
+ "open": ("0", "0"),
322
+ "wep": ("1", "1"),
323
+ "wpa-psk": ("3", "3"),
324
+ "wpa2-psk": ("4", "4"),
325
+ "wpa": ("2", "2"),
326
+ "wpa2": ("4", "4"),
327
+ "802.1x": ("5", "5"),
328
+ "wpa-enterprise": ("5", "5"),
329
+ "wpa2-enterprise": ("5", "5"),
330
+ }
331
+
332
+ TRANSLITERATION_TABLE = str.maketrans(
333
+ {
334
+ "ə": "e",
335
+ "Ə": "e",
336
+ "ş": "s",
337
+ "Ş": "s",
338
+ "ı": "i",
339
+ "İ": "i",
340
+ "ç": "c",
341
+ "Ç": "c",
342
+ "ö": "o",
343
+ "Ö": "o",
344
+ "ü": "u",
345
+ "Ü": "u",
346
+ "ğ": "g",
347
+ "Ğ": "g",
348
+ "â": "a",
349
+ "Â": "a",
350
+ "ê": "e",
351
+ "Ê": "e",
352
+ }
353
+ )
354
+
355
+
356
+ def _command_segments(prompt: str) -> list[str]:
357
+ parts = re.split(
358
+ r"(?=(?<!-)\b(?:device|connect|link|set|enable|associate|change|update|rename|apply)\b)",
359
+ prompt,
360
+ flags=re.IGNORECASE,
361
+ )
362
+ return [part.strip() for part in parts if part.strip()]
363
+
364
+
365
+ def _transliterate(prompt: str) -> str:
366
+ """Fold Azerbaijani letters to ASCII while keeping capitalisation.
367
+
368
+ Device names carry their case into the generated lab, so the rename
369
+ patterns cannot run against the fully normalised prompt -- but they do need
370
+ `dəyiş` to read as `deyis`.
371
+ """
372
+ text = prompt.translate(TRANSLITERATION_TABLE)
373
+ for source, target in _MOJIBAKE_REPLACEMENTS.items():
374
+ text = text.replace(source, target)
375
+ text = unicodedata.normalize("NFKD", text)
376
+ return text.encode("ascii", "ignore").decode("ascii")
377
+
378
+
379
+ _MOJIBAKE_REPLACEMENTS = {
380
+ "É™": "e", "Æ": "e", "ÅŸ": "s", "Å": "s", "ı": "i", "İ": "i",
381
+ "ç": "c", "Ç": "c", "ö": "o", "Ö": "o", "ü": "u", "Ü": "u",
382
+ "ÄŸ": "g", "Ä": "g",
383
+ }
384
+
385
+
386
+ def _normalize_prompt(prompt: str) -> str:
387
+ text = prompt.translate(TRANSLITERATION_TABLE)
388
+ for source, target in {
389
+ "É™": "e",
390
+ "Ə": "e",
391
+ "ÅŸ": "s",
392
+ "Ş": "s",
393
+ "ı": "i",
394
+ "İ": "i",
395
+ "ç": "c",
396
+ "Ç": "c",
397
+ "ö": "o",
398
+ "Ö": "o",
399
+ "ü": "u",
400
+ "Ü": "u",
401
+ "ÄŸ": "g",
402
+ "Ğ": "g",
403
+ }.items():
404
+ text = text.replace(source, target)
405
+ text = unicodedata.normalize("NFKD", text)
406
+ text = text.encode("ascii", "ignore").decode("ascii")
407
+ text = text.lower()
408
+ text = re.sub(r"[^a-z0-9./\\,:_-]+", " ", text)
409
+ text = re.sub(r"\s+", " ", text).strip()
410
+ return _digits_for_number_words(text)
411
+
412
+
413
+ # Written after transliteration, so these are the folded forms: `uc` for uc,
414
+ # `dord` for dord, `bes` for bes, `sekkiz` for sekkiz.
415
+ #
416
+ # `on` -- ten in Azerbaijani -- is deliberately absent. Prompts here mix
417
+ # Azerbaijani and English, where `on` is a preposition sitting in front of the
418
+ # very words that identify a device: `dhcp on router`, `telnet on switch`. No
419
+ # lookahead separates the two readings, and guessing wrong silently orders ten
420
+ # routers instead of one. `10 komputer` still works; a misparse would not be
421
+ # noticed until Packet Tracer opened the wrong lab.
422
+ NUMBER_WORDS = {
423
+ "bir": 1, "iki": 2, "uc": 3, "dord": 4, "bes": 5,
424
+ "alti": 6, "yeddi": 7, "sekkiz": 8, "doqquz": 9,
425
+ "one": 1, "two": 2, "three": 3, "four": 4, "five": 5,
426
+ "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10,
427
+ }
428
+
429
+ @lru_cache(maxsize=1)
430
+ def _number_word_pattern() -> re.Pattern[str]:
431
+ """Match a spelled-out count only when a device word follows it.
432
+
433
+ The narrow lookahead is not optional. `on` is ten in Azerbaijani and a
434
+ preposition in English, so a bare substitution turns `dhcp on router` into
435
+ `dhcp 10 router` and silently orders ten routers. Requiring a device alias
436
+ next confines the rewrite to phrases that are actually counts.
437
+ """
438
+ words = "|".join(sorted(NUMBER_WORDS, key=len, reverse=True))
439
+ return re.compile(
440
+ rf"\b({words})\b(?=\s+(?:dene|eded|tane)?\s*(?:{_any_device_alias_pattern()})\b)"
441
+ )
442
+
443
+
444
+ def _digits_for_number_words(text: str) -> str:
445
+ """Rewrite spelled-out counts as digits.
446
+
447
+ Every count extractor downstream matches `\\d+`, so `bir router iki switch
448
+ uc komputer` parsed as no devices at all -- a silent empty topology from a
449
+ perfectly ordinary Azerbaijani phrasing.
450
+
451
+ Converting here rather than in each extractor keeps one model of what a
452
+ count is.
453
+ """
454
+ return _number_word_pattern().sub(lambda match: str(NUMBER_WORDS[match.group(1)]), text)
455
+
456
+
457
+ @dataclass
458
+ class IntentPlan:
459
+ goal: str
460
+ prompt: str
461
+ pkt_path: str | None = None
462
+ capabilities: list[str] = field(default_factory=list)
463
+ network_style: str | None = None
464
+ wireless_mode: str | None = None
465
+ wireless_settings: dict[str, object] = field(default_factory=dict)
466
+ requested_models: list[str] = field(default_factory=list)
467
+ device_requirements: dict[str, int] = field(default_factory=dict)
468
+ device_counts: dict[str, int] = field(default_factory=dict)
469
+ department_groups: list[dict[str, object]] = field(default_factory=list)
470
+ service_requirements: dict[str, object] = field(default_factory=dict)
471
+ topology_requirements: dict[str, object] = field(default_factory=dict)
472
+ vlan_ids: list[int] = field(default_factory=list)
473
+ uplink_intent: str | None = None
474
+ host_link_intent: str | None = None
475
+ host_vlan_assignment: dict[int, int] = field(default_factory=dict)
476
+ assumptions_used: list[str] = field(default_factory=list)
477
+ confidence_score: float = 0.0
478
+ parse_warnings: list[str] = field(default_factory=list)
479
+ blocking_gaps: list[str] = field(default_factory=list)
480
+ compatibility_profile: dict[str, object] = field(default_factory=dict)
481
+ unsafe_mutations_requested: list[str] = field(default_factory=list)
482
+ blocked_mutations: list[str] = field(default_factory=list)
483
+ acceptance_stage_plan: list[dict[str, object]] = field(default_factory=list)
484
+ capability_matrix_hits: list[dict[str, object]] = field(default_factory=list)
485
+ unsupported_capabilities: list[str] = field(default_factory=list)
486
+ coverage_gap_report: dict[str, object] = field(default_factory=dict)
487
+ blueprint_plan: dict[str, object] = field(default_factory=dict)
488
+ remote_search_results: list[dict[str, object]] = field(default_factory=list)
489
+ edit_operations: list[dict[str, object]] = field(default_factory=list)
490
+ devices: list[dict[str, object]] = field(default_factory=list)
491
+ links: list[dict[str, object]] = field(default_factory=list)
492
+ switch_ops: list[dict[str, object]] = field(default_factory=list)
493
+ router_ops: list[dict[str, object]] = field(default_factory=list)
494
+ server_ops: list[dict[str, object]] = field(default_factory=list)
495
+ wireless_ops: list[dict[str, object]] = field(default_factory=list)
496
+ end_device_ops: list[dict[str, object]] = field(default_factory=list)
497
+ management_ops: list[dict[str, object]] = field(default_factory=list)
498
+ verification_ops: list[dict[str, object]] = field(default_factory=list)
499
+ iot_ops: list[dict[str, object]] = field(default_factory=list)
500
+ programming_ops: list[dict[str, object]] = field(default_factory=list)
501
+
502
+ def all_operations(self) -> list[dict[str, object]]:
503
+ merged: list[dict[str, object]] = []
504
+ for bucket in [
505
+ self.edit_operations,
506
+ self.switch_ops,
507
+ self.router_ops,
508
+ self.server_ops,
509
+ self.wireless_ops,
510
+ self.end_device_ops,
511
+ self.management_ops,
512
+ self.verification_ops,
513
+ self.iot_ops,
514
+ self.programming_ops,
515
+ ]:
516
+ merged.extend(bucket)
517
+ return merged
518
+
519
+ def to_dict(self) -> dict[str, object]:
520
+ data = asdict(self)
521
+ data["all_operations"] = self.all_operations()
522
+ return data
523
+
524
+
525
+ def _normalize_device_type(raw_type: str) -> str:
526
+ return DEVICE_SYNONYMS.get(raw_type.strip().lower(), raw_type.strip())
527
+
528
+
529
+ # Plural forms were listed by hand, so the table disagreed with itself: `PC`
530
+ # carried `computers` and `pcs` while `Switch` and `Router` had no English
531
+ # plural at all. `2 switches 1 router 4 computers` therefore parsed as a router
532
+ # and four PCs -- the switches vanished silently, and the user got a topology
533
+ # with none. Matching the suffix instead of enumerating it keeps every device
534
+ # type on the same footing.
535
+ _PLURAL_SUFFIX = r"(?:s|es|ler|lar)?"
536
+
537
+
538
+ def _alias_alternation(aliases: list[str]) -> str:
539
+ """Alternation over aliases, longest first so prefixes lose, plural-tolerant."""
540
+ ordered = sorted(set(aliases), key=len, reverse=True)
541
+ return "|".join(re.escape(alias) + _PLURAL_SUFFIX for alias in ordered)
542
+
543
+
544
+ def _any_device_alias_pattern() -> str:
545
+ """Alternation over every device alias, longest first so prefixes lose."""
546
+ return _alias_alternation(
547
+ [alias for alias_list in NATURAL_DEVICE_ALIASES.values() for alias in alias_list]
548
+ )
549
+
550
+
551
+ # Cisco model designations sit right next to the device word -- `2911 router`,
552
+ # `2960 switch` -- exactly where a count goes. Reading one as a count made
553
+ # `2911 router qur` request two thousand nine hundred and eleven routers, which
554
+ # planned for minutes before failing.
555
+ KNOWN_DEVICE_MODELS = {
556
+ 819, 829, 1240, 1941, 2504, 2620, 2621, 2811, 2901, 2911, 2950, 2960,
557
+ 3560, 3650, 3702, 4321, 4331, 8200, 5505, 5506, 1841, 1921, 3945,
558
+ }
559
+ # A ceiling only to catch stray four-digit numbers that are neither counts nor
560
+ # known models. It was 200, which silently turned "500 komputer" -- an ordinary
561
+ # enterprise request -- into a single PC, because the bare-name fallback then
562
+ # supplied 1. Real labs do carry hundreds of devices, so the ceiling has to sit
563
+ # well above anything a person would actually ask for.
564
+ MAX_REASONABLE_DEVICE_COUNT = 2000
565
+
566
+
567
+ def _is_device_count(value: int) -> bool:
568
+ return 0 <= value <= MAX_REASONABLE_DEVICE_COUNT and value not in KNOWN_DEVICE_MODELS
569
+
570
+
571
+ def _extract_requested_models(normalized_prompt: str) -> list[str]:
572
+ """Model designations the prompt names, e.g. `2911`, `ISR4331`, `2960-24TT`.
573
+
574
+ Recorded so the answer can be honest about them. A generated lab takes its
575
+ device models from whichever donor supplied the prototype, so asking for a
576
+ 2911 and receiving a PT8200 is the normal outcome -- but it used to happen
577
+ silently, which reads as the request having been honoured.
578
+ """
579
+ found: list[str] = []
580
+ for match in re.finditer(r"\b(isr\s?\d{3,4}|\d{3,4})(?:-[a-z0-9]+)?\b", normalized_prompt):
581
+ token = match.group(0).replace(" ", "").upper()
582
+ # Only the leading number identifies the family: `2960-24TT` is a 2960.
583
+ leading = re.match(r"[A-Z]*(\d{3,4})", token)
584
+ if leading and int(leading.group(1)) in KNOWN_DEVICE_MODELS and token not in found:
585
+ found.append(token)
586
+ return found
587
+
588
+
589
+ def _extract_natural_device_counts(normalized_prompt: str) -> dict[str, int]:
590
+ """Count devices per type from a natural prompt.
591
+
592
+ Two phrasings are supported: `3 switch` (count first, the common form) and
593
+ `switch 3`. The trailing form used to swallow the *next* device's count —
594
+ "1 router 8 komputer" matched `router 8` and, because the two forms were
595
+ pooled through `max`, the router ended up with 8. Trailing counts are now
596
+ rejected when another device word follows, and the leading form wins
597
+ outright when both are present.
598
+ """
599
+ counts: dict[str, int] = {}
600
+ any_alias = _any_device_alias_pattern()
601
+ for device_type, aliases in NATURAL_DEVICE_ALIASES.items():
602
+ alias_pattern = _alias_alternation(aliases)
603
+ leading = re.compile(
604
+ rf"(?<![,/])\b(\d+)\s*(?:dene|dene?|eded|eded|tane)?\s*(?:{alias_pattern})\b"
605
+ )
606
+ trailing = re.compile(
607
+ rf"\b(?:{alias_pattern})\s+(\d+)\b(?!\s*(?:dene|eded|tane)?\s*(?:{any_alias})\b)"
608
+ )
609
+ # Counted matches need the longer-alias guard too. The note on
610
+ # `_without_longer_aliases` claimed the digit anchors them, which holds
611
+ # only while the shorter alias sits at the *end* of the longer phrase:
612
+ # `1 wireless router` keeps `wireless ` between the digit and `router`,
613
+ # so nothing matches. `1 meraki server` does not -- `meraki` follows the
614
+ # digit directly -- and the prompt credited a security appliance and a
615
+ # Meraki server both.
616
+ counted_text = _without_longer_aliases(normalized_prompt, aliases)
617
+ leading_values = [
618
+ int(value) for value in leading.findall(counted_text) if _is_device_count(int(value))
619
+ ]
620
+ if leading_values:
621
+ counts[device_type] = max(leading_values)
622
+ continue
623
+ trailing_values = [
624
+ int(value) for value in trailing.findall(counted_text) if _is_device_count(int(value))
625
+ ]
626
+ if trailing_values:
627
+ counts[device_type] = max(trailing_values)
628
+ continue
629
+ # A device named with no number at all means one of it. `router switch pc
630
+ # qur` was refused with "this prompt does not describe a topology" -- a
631
+ # message contradicted by the prompt itself, which names three devices.
632
+ #
633
+ # Applied per device type and only when that type carries no count
634
+ # anywhere, so `2 switch ve router qur` still reads as two switches and
635
+ # one router rather than flattening either.
636
+ bare = re.compile(rf"\b(?:{alias_pattern})\b")
637
+ if bare.search(_without_longer_aliases(normalized_prompt, aliases)):
638
+ counts[device_type] = 1
639
+ return counts
640
+
641
+
642
+ def _without_longer_aliases(text: str, aliases: list[str]) -> str:
643
+ """Blank out longer device names that merely contain one of `aliases`.
644
+
645
+ `wireless router` contains `router`, so a bare scan credited both a wireless
646
+ router and a plain one from `1 wireless router 2 laptop qur`. A counted
647
+ match never had this problem because the digit anchors it; only the
648
+ no-number fallback needs the guard.
649
+
650
+ A device type's *own* longer aliases are never masked. They are the same
651
+ device, not a different one, and blanking them deleted the only word the
652
+ scan had to go on: `switchler ve routerler qur` masked `switchler` because
653
+ it contains `switch`, masked `routerler` because it contains `router`, and
654
+ parsed as an empty plan -- no devices, no warning, nothing to build.
655
+ """
656
+ own = set(aliases)
657
+ masked = text
658
+ for other in {alias for group in NATURAL_DEVICE_ALIASES.values() for alias in group}:
659
+ if other in own:
660
+ continue
661
+ if any(alias != other and alias in other for alias in aliases):
662
+ masked = re.sub(rf"\b{re.escape(other)}\b", " ", masked)
663
+ return masked
664
+
665
+
666
+ def _extract_vlan_ids(normalized_prompt: str) -> list[int]:
667
+ """Every VLAN a prompt names, whether listed once or repeated.
668
+
669
+ The separators used to be a character class, `[0-9,\\s/veand]`, which
670
+ contains `v`. On `vlan 10 ve vlan 20` it swallowed the start of the second
671
+ keyword and the lab came out with one VLAN instead of two. A separator is
672
+ now only accepted between two numbers.
673
+ """
674
+ vlan_ids: list[int] = []
675
+ pattern = r"\bvlan(?:larda|lar|da|de|a|e)?\s+((?:\d+(?:\s*(?:,|/|ve|and|-)\s*|\s+)?)+)"
676
+ for match in re.findall(pattern, normalized_prompt):
677
+ vlan_ids.extend(int(value) for value in re.findall(r"\d+", match))
678
+ return sorted(dict.fromkeys(vlan_ids))
679
+
680
+
681
+ def _extract_host_vlan_assignment(normalized_prompt: str) -> dict[int, int]:
682
+ """Per-VLAN host counts, but only where the prompt really states one.
683
+
684
+ `4 komputer vlan 10 ve vlan 20 qur` reads as a host count next to a VLAN
685
+ *list*, not as "4 hosts in VLAN 10". Taken literally it produced
686
+ {10: 4}, which then suppressed the even split and left VLAN 20 empty.
687
+
688
+ A trailing `ve vlan 20` therefore disqualifies the match, while
689
+ `2 komputer vlan 10 ve 3 komputer vlan 20` -- a genuine pair of
690
+ assignments -- still parses, because there the separator is followed by a
691
+ count rather than by another VLAN.
692
+ """
693
+ vlan_list_follows = r"(?!\s*(?:,|/|ve|and)\s*vlan\s*\d)"
694
+ assignments: dict[int, int] = {}
695
+ patterns = [
696
+ re.compile(r"\bvlan\s+(\d+)\s*(?:da|de|a|e)?\s+(\d+)\s+(?:pc|komputer|computer)\b"),
697
+ re.compile(
698
+ r"\b(\d+)\s+(?:pc|komputer|computer)\s+vlan(?:da|de|a|e)?\s+(\d+)\b" + vlan_list_follows
699
+ ),
700
+ ]
701
+ for pattern_index, pattern in enumerate(patterns):
702
+ for first, second in pattern.findall(normalized_prompt):
703
+ if pattern_index == 0:
704
+ vlan_id, count = int(first), int(second)
705
+ else:
706
+ count, vlan_id = int(first), int(second)
707
+ assignments[vlan_id] = assignments.get(vlan_id, 0) + count
708
+ return assignments
709
+
710
+
711
+ def _extract_uplink_intent(normalized_prompt: str) -> str | None:
712
+ if any(token in normalized_prompt for token in ["gig port", "gigabit", " gig ", "gi0", "routerle aralarinda gig", "switchlerin oz aralarinda"]):
713
+ return "gigabit"
714
+ return None
715
+
716
+
717
+ def _extract_host_link_intent(normalized_prompt: str) -> str | None:
718
+ if any(token in normalized_prompt for token in ["fa port", "fastethernet", " fa ", "pc ler ise fa", "komputerler ise fa"]):
719
+ return "fastethernet"
720
+ return None
721
+
722
+
723
+ def _extract_network_style(normalized_prompt: str) -> str | None:
724
+ for style, patterns in NETWORK_STYLE_PATTERNS.items():
725
+ if any(re.search(pattern, normalized_prompt) for pattern in patterns):
726
+ return style
727
+ return None
728
+
729
+
730
+ # One vocabulary for "a repeated group of devices". The count pattern and the
731
+ # per-group pattern used to carry their own lists -- the first knew
732
+ # `department`, the second knew `departmentda` -- so a word added to one half
733
+ # worked in silence. Measured: `3 mertebe, her mertebede 6 komputer` produced
734
+ # six PCs on one switch, because neither list had heard of a floor, and no
735
+ # warning said so. The same prompt with `sobe` produced eighteen PCs on three.
736
+ #
737
+ # Stems are matched with optional Azerbaijani and English suffixes; locatives
738
+ # are spelled out because Azerbaijani vowel harmony picks -de or -da and
739
+ # guessing it in a regex would be worse than listing twelve words.
740
+ GROUP_NOUN_STEMS = (
741
+ "departament",
742
+ "department",
743
+ "building",
744
+ "mertebe",
745
+ "filial",
746
+ "branch",
747
+ "office",
748
+ "bolme",
749
+ "floor",
750
+ "sobe",
751
+ "ofis",
752
+ "bina",
753
+ "kat",
754
+ )
755
+
756
+ GROUP_NOUN_LOCATIVES = (
757
+ "departamentde",
758
+ "departmentda",
759
+ "mertebede",
760
+ "filialda",
761
+ "bolmede",
762
+ "sobede",
763
+ "ofisde",
764
+ "binada",
765
+ "katda",
766
+ )
767
+
768
+
769
+ def _group_noun_alternation(words: tuple[str, ...]) -> str:
770
+ """Longest first, so `department` never matches inside `departament`."""
771
+ return "|".join(re.escape(word) for word in sorted(words, key=len, reverse=True))
772
+
773
+
774
+ def _extract_department_count(normalized_prompt: str) -> int:
775
+ stems = _group_noun_alternation(GROUP_NOUN_STEMS)
776
+ pattern = re.compile(rf"\b(\d+)\s+(?:{stems})(?:li|lu|ler|lar|s)?\b")
777
+ match = pattern.search(normalized_prompt)
778
+ return int(match.group(1)) if match else 0
779
+
780
+
781
+ def _extract_per_department_devices(normalized_prompt: str) -> dict[str, int]:
782
+ counts: dict[str, int] = {}
783
+ stems = _group_noun_alternation(GROUP_NOUN_STEMS)
784
+ locatives = _group_noun_alternation(GROUP_NOUN_LOCATIVES)
785
+ group_segment = ""
786
+ segment_match = re.search(
787
+ rf"\bher\s+(?:{locatives})\s+(.+?)(?=\b(?:router|dhcp|vlan|ssid|acl|telnet|ospf|eigrp|rip|nat)\b|$)",
788
+ normalized_prompt,
789
+ )
790
+ if segment_match:
791
+ group_segment = segment_match.group(1)
792
+ for device_type, aliases in PER_DEPARTMENT_DEVICE_ALIASES.items():
793
+ alias_pattern = "|".join(re.escape(alias) for alias in aliases)
794
+ # Each pattern says whether it may be read from the whole prompt or only
795
+ # from the per-group segment. That used to be inferred from whether the
796
+ # pattern text began with a digit group, which the reversed English form
797
+ # below would have tripped: `6 computers each` starts with one and must
798
+ # still be read from the whole prompt.
799
+ patterns: list[tuple[re.Pattern[str], bool]] = [
800
+ (re.compile(rf"\bher\s+(?:{locatives})\s+(\d+)\s+(?:dene\s+)?(?:{alias_pattern})s?\b"), False),
801
+ (re.compile(rf"\b(?:each|per)\s+(?:{stems}|group)\s+(\d+)\s+(?:{alias_pattern})s?\b"), False),
802
+ (re.compile(rf"\b(\d+)\s+(?:{alias_pattern})s?\s+(?:each|per\s+(?:{stems}))\b"), False),
803
+ (re.compile(rf"\b(\d+)\s+(?:dene\s+)?(?:{alias_pattern})s?\b"), True),
804
+ ]
805
+ values: list[int] = []
806
+ for pattern, prefer_segment in patterns:
807
+ target_text = group_segment if prefer_segment and group_segment else normalized_prompt
808
+ values.extend(int(value) for value in pattern.findall(target_text))
809
+ if values:
810
+ counts[device_type] = max(values)
811
+ return counts
812
+
813
+
814
+ def _build_department_groups(
815
+ normalized_prompt: str,
816
+ department_count: int,
817
+ per_department_devices: dict[str, int],
818
+ vlan_ids: list[int],
819
+ ) -> list[dict[str, object]]:
820
+ if department_count <= 0:
821
+ return []
822
+ groups: list[dict[str, object]] = []
823
+ for index in range(department_count):
824
+ group_name = f"DEPT{index + 1}"
825
+ groups.append(
826
+ {
827
+ "name": group_name,
828
+ "switch_name": f"{group_name}-SW",
829
+ "vlan_id": vlan_ids[index] if index < len(vlan_ids) else None,
830
+ "devices": dict(per_department_devices),
831
+ }
832
+ )
833
+ return groups
834
+
835
+
836
+ def _extract_service_requirements(capabilities: list[str], prompt: str) -> dict[str, object]:
837
+ lowered = prompt.lower()
838
+ requirements: dict[str, object] = {
839
+ "routing": next((cap for cap in ["ospf", "eigrp", "rip"] if cap in capabilities), None),
840
+ "services": [],
841
+ "security": [],
842
+ }
843
+ for service in ["dhcp", "dns", "http", "https", "ftp", "tftp", "ntp", "email", "syslog", "aaa"]:
844
+ if service in lowered:
845
+ requirements["services"].append(service)
846
+ for security in ["acl", "telnet", "wpa2", "wpa", "wep", "nat", "vpn", "ipsec", "gre", "ppp", "security_edge", "ssh"]:
847
+ if security in lowered:
848
+ requirements["security"].append(security)
849
+ return requirements
850
+
851
+
852
+ def _extract_wireless_mode(
853
+ normalized_prompt: str,
854
+ capabilities: list[str],
855
+ device_requirements: dict[str, int],
856
+ network_style: str | None,
857
+ ) -> str | None:
858
+ has_wireless = any(cap in capabilities for cap in ["wireless_ap", "wireless_client"])
859
+ if not has_wireless and not device_requirements.get("WirelessRouter", 0):
860
+ return None
861
+ mentions_home_router = any(token in normalized_prompt for token in ["home router", "wireless router", "wirelessrouter", "wrt"])
862
+ campus_like = network_style in {"campus", "branch", "wireless_branch"} or "department" in normalized_prompt or "sobeli" in normalized_prompt
863
+ if mentions_home_router and campus_like:
864
+ return "mixed"
865
+ if mentions_home_router or "nat" in capabilities or network_style == "small_office" or device_requirements.get("WirelessRouter", 0):
866
+ return "home_router_edge"
867
+ return "ap_bridge"
868
+
869
+
870
+ def _estimate_confidence(
871
+ device_requirements: dict[str, int],
872
+ capabilities: list[str],
873
+ parse_warnings: list[str],
874
+ blocking_gaps: list[str],
875
+ explicit_devices: list[dict[str, object]],
876
+ links: list[dict[str, object]],
877
+ ) -> float:
878
+ score = 0.2
879
+ if device_requirements or explicit_devices:
880
+ score += 0.25
881
+ if capabilities:
882
+ score += 0.2
883
+ if links:
884
+ score += 0.15
885
+ score -= min(len(parse_warnings) * 0.08, 0.16)
886
+ score -= min(len(blocking_gaps) * 0.2, 0.4)
887
+ return max(0.0, min(1.0, round(score, 2)))
888
+
889
+
890
+ def _extract_explicit_devices(prompt: str) -> list[dict[str, object]]:
891
+ devices: list[dict[str, object]] = []
892
+ pattern = re.compile(
893
+ r"(?:device\s+)?([A-Za-z][A-Za-z0-9_-]*)\s+type\s+([A-Za-z-]+)(?:\s+model\s+([A-Za-z0-9._-]+))?",
894
+ flags=re.IGNORECASE,
895
+ )
896
+ for segment in _command_segments(prompt):
897
+ for name, raw_type, model in pattern.findall(segment):
898
+ devices.append({"name": name, "type": _normalize_device_type(raw_type), "model": model or None})
899
+ return devices
900
+
901
+
902
+ def _extract_explicit_links(prompt: str) -> list[dict[str, object]]:
903
+ links: list[dict[str, object]] = []
904
+ pattern = re.compile(
905
+ r"(?:connect|link)\s+([A-Za-z][A-Za-z0-9_-]*):([A-Za-z0-9/._-]+)\s+(?:to|->)\s+([A-Za-z][A-Za-z0-9_-]*):([A-Za-z0-9/._-]+)"
906
+ r"(?:\s+with\s+([A-Za-z0-9._-]+))?",
907
+ flags=re.IGNORECASE,
908
+ )
909
+ for segment in _command_segments(prompt):
910
+ for left_dev, left_port, right_dev, right_port, cable in pattern.findall(segment):
911
+ links.append(
912
+ {
913
+ "a": {"dev": left_dev, "port": left_port},
914
+ "b": {"dev": right_dev, "port": right_port},
915
+ "media": (cable or "copper").lower(),
916
+ }
917
+ )
918
+ deduped: list[dict[str, object]] = []
919
+ seen: set[tuple[str, str, str, str, str]] = set()
920
+ for link in links:
921
+ key = (
922
+ str(link["a"]["dev"]),
923
+ str(link["a"]["port"]),
924
+ str(link["b"]["dev"]),
925
+ str(link["b"]["port"]),
926
+ str(link["media"]),
927
+ )
928
+ if key not in seen:
929
+ seen.add(key)
930
+ deduped.append(link)
931
+ return deduped
932
+
933
+
934
+ def _extract_link_edit_operations(links: list[dict[str, object]]) -> list[dict[str, object]]:
935
+ return [{"op": "set_link", **link} for link in links]
936
+
937
+
938
+ def _extract_natural_edit_operations(prompt: str) -> tuple[list[dict[str, object]], list[dict[str, object]]]:
939
+ """Edit requests as an ordinary sentence phrases them.
940
+
941
+ `pkt_editor` implements every operation below already. Only the command form
942
+ was ever parsed -- `set SW1 vlan 20 name SALES` -- so nine of eleven
943
+ realistic requests produced no operations at all, and `--edit` wrote the
944
+ input back out unchanged.
945
+
946
+ Returns `(edit_operations, switch_operations)`; the editor routes those two
947
+ buckets differently.
948
+ """
949
+ text = _transliterate(prompt)
950
+ edits: list[dict[str, object]] = []
951
+ switch_ops: list[dict[str, object]] = []
952
+
953
+ def add(bucket: list[dict[str, object]], operation: dict[str, object]) -> None:
954
+ if operation not in bucket:
955
+ bucket.append(operation)
956
+
957
+ # `SW1 de vlan 20 yarat` / `SW1 uzerinde vlan 20 ac`
958
+ for device, vlan in re.findall(
959
+ r"\b([A-Za-z0-9_-]+)\s*(?:de|da|uzerinde|ustunde)?\s*vlan\s+(\d+)\s*(?:yarat|ac|elave et|qur)",
960
+ text,
961
+ flags=re.IGNORECASE,
962
+ ):
963
+ add(switch_ops, {"op": "set_vlan", "device": device, "vlan": int(vlan), "name": f"VLAN{vlan}"})
964
+
965
+ # `PC1 ve SW1 arasinda link qur` / `PC1 ile SW1 i birlesdir`
966
+ link_patterns = (
967
+ r"\b([A-Za-z0-9_-]+)\s+(?:ve|ile)\s+([A-Za-z0-9_-]+)\s*(?:i|ni|nu)?\s*aras[ıi]nda\s+(?:link|kabel|elaqe)\s*(?:qur|cek|yarat)",
968
+ r"\b([A-Za-z0-9_-]+)\s+(?:ve|ile)\s+([A-Za-z0-9_-]+)\s*(?:i|ni|nu)?\s*birlesdir",
969
+ )
970
+ for pattern in link_patterns:
971
+ for left, right in re.findall(pattern, text, flags=re.IGNORECASE):
972
+ # Ports are left unset on purpose: the planner picks free ones, and
973
+ # naming a port the device lacks makes Packet Tracer reject the file.
974
+ add(edits, {"op": "set_link", "a": {"dev": left, "port": ""}, "b": {"dev": right, "port": ""}, "media": "copper"})
975
+
976
+ # `R1 ve SW1 arasindaki linki sil`
977
+ for left, right in re.findall(
978
+ r"\b([A-Za-z0-9_-]+)\s+(?:ve|ile)\s+([A-Za-z0-9_-]+)\s*aras[ıi]ndak[ıi]\s+(?:link|kabel|elaqe)\w*\s+(?:sil|kes|qopar)",
979
+ text,
980
+ flags=re.IGNORECASE,
981
+ ):
982
+ add(edits, {"op": "remove_link", "a": {"dev": left, "port": ""}, "b": {"dev": right, "port": ""}})
983
+
984
+ # `PC3 u sil` -- the trailing vowel is the Azerbaijani accusative suffix.
985
+ for device in re.findall(
986
+ r"\b([A-Za-z][A-Za-z0-9_-]*\d[A-Za-z0-9_-]*)\s*(?:i|u|ni|nu|nı|yi)?\s+(?:sil|cixart|kenar et)\b",
987
+ text,
988
+ flags=re.IGNORECASE,
989
+ ):
990
+ add(edits, {"op": "prune_device", "device": device})
991
+
992
+ # `cli R1: <commands>` hands the user's own IOS straight through. Newlines
993
+ # keep their indentation, which is what marks a block body -- `ip dhcp pool
994
+ # X` followed by an indented `network ...` is a pool, and the same two lines
995
+ # unindented are two unrelated globals. A semicolon is a plain separator for
996
+ # one-liners, so every part it produces is top level.
997
+ cli_pattern = re.compile(
998
+ r"(?:^|\s)cli\s+([A-Za-z0-9_-]+)\s*:\s*(.+?)"
999
+ r"(?=(?:\r?\n)\s*cli\s+[A-Za-z0-9_-]+\s*:|$)",
1000
+ flags=re.IGNORECASE | re.DOTALL,
1001
+ )
1002
+ for device, body in cli_pattern.findall(prompt):
1003
+ lines: list[str] = []
1004
+ for raw_line in body.splitlines():
1005
+ if ";" in raw_line:
1006
+ lines.extend(part.strip() for part in raw_line.split(";") if part.strip())
1007
+ elif raw_line.strip():
1008
+ lines.append(raw_line.rstrip())
1009
+ if lines:
1010
+ add(edits, {"op": "apply_cli", "device": device, "lines": lines})
1011
+
1012
+ return edits, switch_ops
1013
+
1014
+
1015
+ def _extract_switch_ops(prompt: str) -> list[dict[str, object]]:
1016
+ ops: list[dict[str, object]] = []
1017
+ for segment in _command_segments(prompt):
1018
+ for device, vlan_id, name in re.findall(r"set\s+([A-Za-z0-9_-]+)\s+vlan\s+(\d+)\s+name\s+([A-Za-z0-9_-]+)", segment, flags=re.IGNORECASE):
1019
+ ops.append({"op": "set_vlan", "device": device, "vlan": int(vlan_id), "name": name})
1020
+ for device, port, vlan_id in re.findall(r"set\s+([A-Za-z0-9_-]+)\s+access-port\s+([A-Za-z0-9/._-]+)\s+vlan\s+(\d+)", segment, flags=re.IGNORECASE):
1021
+ ops.append({"op": "set_access_port", "device": device, "port": port, "vlan": int(vlan_id)})
1022
+ trunk_pattern = re.compile(
1023
+ r"set\s+([A-Za-z0-9_-]+)\s+trunk-port\s+([A-Za-z0-9/._-]+)\s+allowed\s+([0-9,\s]+?)(?:\s+native\s+(\d+))?(?=\s+set\s+|$)",
1024
+ flags=re.IGNORECASE,
1025
+ )
1026
+ for segment in _command_segments(prompt):
1027
+ for device, port, allowed, native in trunk_pattern.findall(segment):
1028
+ ops.append(
1029
+ {
1030
+ "op": "set_trunk_port",
1031
+ "device": device,
1032
+ "port": port,
1033
+ "allowed": [int(value) for value in re.findall(r"\d+", allowed)],
1034
+ "native": int(native) if native else None,
1035
+ }
1036
+ )
1037
+ dhcp_snooping_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+dhcp\s+snooping\s+vlan\s+(\d+)(?:\s+trust\s+([A-Za-z0-9/._-]+))?", flags=re.IGNORECASE)
1038
+ dai_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+dai\s+vlan\s+(\d+)(?:\s+trust\s+([A-Za-z0-9/._-]+))?", flags=re.IGNORECASE)
1039
+ port_security_pattern = re.compile(
1040
+ r"set\s+([A-Za-z0-9_-]+)\s+port-security\s+([A-Za-z0-9/._-]+)(?:\s+max\s+(\d+))?(?:\s+violation\s+(protect|restrict|shutdown))?",
1041
+ flags=re.IGNORECASE,
1042
+ )
1043
+ lldp_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+lldp(?:\s+(?:enable|run))?", flags=re.IGNORECASE)
1044
+ rep_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+rep\s+segment\s+(\d+)\s+interface\s+([A-Za-z0-9/._-]+)", flags=re.IGNORECASE)
1045
+ span_pattern = re.compile(
1046
+ r"set\s+([A-Za-z0-9_-]+)\s+(?:r?span)\s+(\d+)\s+source\s+([A-Za-z0-9/._-]+)\s+destination\s+([A-Za-z0-9/._-]+)",
1047
+ flags=re.IGNORECASE,
1048
+ )
1049
+ dot1x_pattern = re.compile(
1050
+ r"set\s+([A-Za-z0-9_-]+)\s+dot1x\s+interface\s+([A-Za-z0-9/._-]+)\s+mode\s+(auto|force-authorized|force-unauthorized)"
1051
+ r"(?:\s+radius\s+(\d+\.\d+\.\d+\.\d+)\s+key\s+([A-Za-z0-9._-]+))?",
1052
+ flags=re.IGNORECASE,
1053
+ )
1054
+ qos_pattern = re.compile(
1055
+ r"set\s+([A-Za-z0-9_-]+)\s+qos\s+class-map\s+([A-Za-z0-9_-]+)\s+match\s+(.+?)\s+policy-map\s+([A-Za-z0-9_-]+)\s+class\s+\2\s+(priority|bandwidth\s+\d+|police\s+\d+)"
1056
+ r"\s+service-policy\s+(input|output)\s+([A-Za-z0-9/._-]+)(?=\s+set\s+|$)",
1057
+ flags=re.IGNORECASE,
1058
+ )
1059
+ stp_pattern = re.compile(
1060
+ r"set\s+([A-Za-z0-9_-]+)\s+stp\s+mode\s+([A-Za-z0-9_-]+)(?:\s+vlan\s+(\d+)\s+root\s+(primary|secondary))?",
1061
+ flags=re.IGNORECASE,
1062
+ )
1063
+ etherchannel_pattern = re.compile(
1064
+ r"set\s+([A-Za-z0-9_-]+)\s+etherchannel\s+(\d+)\s+mode\s+(active|passive|desirable|auto|on)\s+interfaces\s+(.+?)(?=\s+set\s+|$)",
1065
+ flags=re.IGNORECASE,
1066
+ )
1067
+ vtp_pattern = re.compile(
1068
+ r"set\s+([A-Za-z0-9_-]+)\s+vtp\s+domain\s+([A-Za-z0-9_.-]+)\s+mode\s+(server|client|transparent)(?:\s+version\s+(\d+))?",
1069
+ flags=re.IGNORECASE,
1070
+ )
1071
+ dtp_pattern = re.compile(
1072
+ r"set\s+([A-Za-z0-9_-]+)\s+dtp\s+interface\s+([A-Za-z0-9/._-]+)\s+mode\s+(dynamic\s+desirable|dynamic\s+auto|trunk|access)",
1073
+ flags=re.IGNORECASE,
1074
+ )
1075
+ for segment in _command_segments(prompt):
1076
+ for device, vlan_id, trust_port in dhcp_snooping_pattern.findall(segment):
1077
+ ops.append({"op": "set_dhcp_snooping", "device": device, "vlan": int(vlan_id), "trust_port": trust_port or None})
1078
+ for device, vlan_id, trust_port in dai_pattern.findall(segment):
1079
+ ops.append({"op": "set_dai", "device": device, "vlan": int(vlan_id), "trust_port": trust_port or None})
1080
+ for device, port, maximum, violation in port_security_pattern.findall(segment):
1081
+ ops.append(
1082
+ {
1083
+ "op": "set_port_security",
1084
+ "device": device,
1085
+ "port": port,
1086
+ "maximum": int(maximum) if maximum else None,
1087
+ "violation": violation.lower() if violation else None,
1088
+ }
1089
+ )
1090
+ for device in lldp_pattern.findall(segment):
1091
+ ops.append({"op": "set_lldp", "device": device})
1092
+ for device, segment_id, interface_name in rep_pattern.findall(segment):
1093
+ ops.append({"op": "set_rep", "device": device, "segment": int(segment_id), "interface": interface_name})
1094
+ for device, session, source, destination in span_pattern.findall(segment):
1095
+ ops.append({"op": "set_span", "device": device, "session": int(session), "source": source, "destination": destination})
1096
+ for device, interface_name, mode, radius_host, radius_key in dot1x_pattern.findall(segment):
1097
+ ops.append(
1098
+ {
1099
+ "op": "set_dot1x",
1100
+ "device": device,
1101
+ "interface": interface_name,
1102
+ "mode": mode.lower(),
1103
+ "radius_host": radius_host or None,
1104
+ "radius_key": radius_key or None,
1105
+ }
1106
+ )
1107
+ for device, class_map, match_expr, policy_map, action, direction, interface_name in qos_pattern.findall(segment):
1108
+ ops.append(
1109
+ {
1110
+ "op": "set_qos_policy",
1111
+ "device": device,
1112
+ "class_map": class_map,
1113
+ "match": match_expr.strip(),
1114
+ "policy_map": policy_map,
1115
+ "action": action.lower(),
1116
+ "direction": direction.lower(),
1117
+ "interface": interface_name,
1118
+ }
1119
+ )
1120
+ for device, mode, vlan_id, root_priority in stp_pattern.findall(segment):
1121
+ ops.append(
1122
+ {
1123
+ "op": "set_stp",
1124
+ "device": device,
1125
+ "mode": mode.lower(),
1126
+ "vlan": int(vlan_id) if vlan_id else None,
1127
+ "root": root_priority.lower() if root_priority else None,
1128
+ }
1129
+ )
1130
+ for device, channel, mode, interfaces_text in etherchannel_pattern.findall(segment):
1131
+ interfaces = [value for value in re.findall(r"[A-Za-z]+[A-Za-z]*[0-9/._-]+", interfaces_text) if value.lower() != "set"]
1132
+ ops.append(
1133
+ {
1134
+ "op": "set_etherchannel",
1135
+ "device": device,
1136
+ "channel": int(channel),
1137
+ "mode": mode.lower(),
1138
+ "interfaces": interfaces,
1139
+ }
1140
+ )
1141
+ for device, domain, mode, version in vtp_pattern.findall(segment):
1142
+ ops.append(
1143
+ {
1144
+ "op": "set_vtp",
1145
+ "device": device,
1146
+ "domain": domain,
1147
+ "mode": mode.lower(),
1148
+ "version": int(version) if version else None,
1149
+ }
1150
+ )
1151
+ for device, interface_name, mode in dtp_pattern.findall(segment):
1152
+ ops.append({"op": "set_dtp", "device": device, "interface": interface_name, "mode": mode.lower()})
1153
+ return ops
1154
+
1155
+
1156
+ def _extract_router_ops(prompt: str) -> list[dict[str, object]]:
1157
+ ops: list[dict[str, object]] = []
1158
+ subif_pattern = re.compile(
1159
+ r"set\s+([A-Za-z0-9_-]+)\s+subinterface\s+([A-Za-z0-9/._-]+)\s+encapsulation\s+dot1q\s+(\d+)\s+ip\s+(\d+\.\d+\.\d+\.\d+)/(\d+)",
1160
+ flags=re.IGNORECASE,
1161
+ )
1162
+ for segment in _command_segments(prompt):
1163
+ for device, subinterface, vlan_id, ip, prefix in subif_pattern.findall(segment):
1164
+ ops.append({"op": "set_subinterface", "device": device, "subinterface": subinterface, "vlan": int(vlan_id), "ip": ip, "prefix": int(prefix)})
1165
+ dhcp_pattern = re.compile(
1166
+ r"set\s+([A-Za-z0-9_-]+)\s+dhcp\s+pool\s+([A-Za-z0-9_-]+)\s+network\s+(\d+\.\d+\.\d+\.\d+)/(\d+)\s+gateway\s+(\d+\.\d+\.\d+\.\d+)"
1167
+ r"(?:\s+dns\s+(\d+\.\d+\.\d+\.\d+))?(?:\s+start\s+(\d+\.\d+\.\d+\.\d+))?(?:\s+max\s+(\d+))?",
1168
+ flags=re.IGNORECASE,
1169
+ )
1170
+ for segment in _command_segments(prompt):
1171
+ for device, name, network, prefix, gateway, dns, start, max_users in dhcp_pattern.findall(segment):
1172
+ if str(device).strip().lower().startswith("server"):
1173
+ continue
1174
+ ops.append(
1175
+ {
1176
+ "op": "set_router_dhcp_pool",
1177
+ "device": device,
1178
+ "name": name,
1179
+ "network": network,
1180
+ "prefix": int(prefix),
1181
+ "gateway": gateway,
1182
+ "dns": dns or None,
1183
+ "start": start or None,
1184
+ "max_users": int(max_users) if max_users else None,
1185
+ }
1186
+ )
1187
+ acl_create_pattern = re.compile(r"set\s+([A-Za-z0-9_ -]+?)\s+acl\s+(standard|extended)\s+([A-Za-z0-9_-]+)", flags=re.IGNORECASE)
1188
+ for segment in _command_segments(prompt):
1189
+ for device, acl_kind, acl_name in acl_create_pattern.findall(segment):
1190
+ ops.append({"op": "set_acl", "device": device.strip(), "acl_kind": acl_kind.lower(), "acl_name": acl_name})
1191
+ acl_create_simple_pattern = re.compile(
1192
+ r"create\s+acl\s+([A-Za-z0-9_-]+)\s+on\s+([A-Za-z0-9_ -]+?)(?:\s+kind\s+(standard|extended))?(?=\s+(?:acl\b|apply\b|enable\b|set\b|associate\b|register\b|disable\b)|$)",
1193
+ flags=re.IGNORECASE,
1194
+ )
1195
+ for segment in _command_segments(prompt):
1196
+ for acl_name, device, acl_kind in acl_create_simple_pattern.findall(segment):
1197
+ ops.append(
1198
+ {
1199
+ "op": "set_acl",
1200
+ "device": device.strip(),
1201
+ "acl_kind": (acl_kind or "standard").lower(),
1202
+ "acl_name": acl_name,
1203
+ }
1204
+ )
1205
+ acl_rule_pattern = re.compile(
1206
+ r"acl\s+([A-Za-z0-9_-]+)\s+(permit|deny)\s+(host\s+\d+\.\d+\.\d+\.\d+|\d+\.\d+\.\d+\.\d+\s+\d+\.\d+\.\d+\.\d+|any)"
1207
+ r"(?:\s+(host\s+\d+\.\d+\.\d+\.\d+|\d+\.\d+\.\d+\.\d+\s+\d+\.\d+\.\d+\.\d+|any))?",
1208
+ flags=re.IGNORECASE,
1209
+ )
1210
+ for segment in _command_segments(prompt):
1211
+ for acl_name, action, source, destination in acl_rule_pattern.findall(segment):
1212
+ ops.append({"op": "add_acl_rule", "acl_name": acl_name, "action": action.lower(), "source": source.strip(), "destination": destination.strip() if destination else None})
1213
+ acl_apply_pattern = re.compile(
1214
+ r"apply\s+acl\s+([A-Za-z0-9_-]+)\s+(in|out)\s+on\s+([A-Za-z0-9_ -]+?)(?:\s+interface)?\s+([A-Za-z0-9/._-]+)",
1215
+ flags=re.IGNORECASE,
1216
+ )
1217
+ for segment in _command_segments(prompt):
1218
+ for acl_name, direction, device, interface_name in acl_apply_pattern.findall(segment):
1219
+ ops.append({"op": "apply_acl", "device": device.strip(), "acl_name": acl_name, "direction": direction.lower(), "interface": interface_name})
1220
+ ipv6_unicast_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+ipv6\s+unicast-routing", flags=re.IGNORECASE)
1221
+ ipv6_address_pattern = re.compile(
1222
+ r"set\s+([A-Za-z0-9_-]+)\s+(?:interface\s+)?([A-Za-z0-9/._-]+)\s+ipv6\s+([0-9A-Fa-f:]+)/(\d+)",
1223
+ flags=re.IGNORECASE,
1224
+ )
1225
+ ipv6_slaac_pattern = re.compile(
1226
+ r"set\s+([A-Za-z0-9_-]+)\s+slaac\s+on\s+([A-Za-z0-9/._-]+)(?:\s+prefix\s+([0-9A-Fa-f:]+)/(\d+))?",
1227
+ flags=re.IGNORECASE,
1228
+ )
1229
+ dhcpv6_pattern = re.compile(
1230
+ r"set\s+([A-Za-z0-9_-]+)\s+dhcpv6\s+pool\s+([A-Za-z0-9_-]+)\s+prefix\s+([0-9A-Fa-f:]+)/(\d+)\s+interface\s+([A-Za-z0-9/._-]+)"
1231
+ r"(?:\s+dns\s+([0-9A-Fa-f:]+))?(?:\s+domain\s+([A-Za-z0-9._-]+))?",
1232
+ flags=re.IGNORECASE,
1233
+ )
1234
+ ospfv3_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+ospfv3\s+(\d+)\s+area\s+(\d+)\s+interface\s+([A-Za-z0-9/._-]+)", flags=re.IGNORECASE)
1235
+ eigrp_ipv6_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+eigrp\s+ipv6\s+(\d+)\s+interface\s+([A-Za-z0-9/._-]+)", flags=re.IGNORECASE)
1236
+ ripng_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+ripng\s+([A-Za-z0-9_-]+)\s+interface\s+([A-Za-z0-9/._-]+)", flags=re.IGNORECASE)
1237
+ hsrp_pattern = re.compile(
1238
+ r"set\s+([A-Za-z0-9_-]+)\s+hsrp\s+(\d+)\s+ipv6\s+([0-9A-Fa-f:]+)\s+interface\s+([A-Za-z0-9/._-]+)(?:\s+priority\s+(\d+))?",
1239
+ flags=re.IGNORECASE,
1240
+ )
1241
+ ospfv2_pattern = re.compile(
1242
+ r"set\s+([A-Za-z0-9_-]+)\s+ospfv2\s+(\d+)\s+network\s+(\d+\.\d+\.\d+\.\d+)\s+wildcard\s+(\d+\.\d+\.\d+\.\d+)\s+area\s+(\d+)",
1243
+ flags=re.IGNORECASE,
1244
+ )
1245
+ eigrp_ipv4_pattern = re.compile(
1246
+ r"set\s+([A-Za-z0-9_-]+)\s+eigrp\s+ipv4\s+(\d+)\s+network\s+(\d+\.\d+\.\d+\.\d+)\s+wildcard\s+(\d+\.\d+\.\d+\.\d+)(?:\s+(no-auto-summary))?",
1247
+ flags=re.IGNORECASE,
1248
+ )
1249
+ ripv2_pattern = re.compile(
1250
+ r"set\s+([A-Za-z0-9_-]+)\s+rip\s+version\s+2\s+network\s+(\d+\.\d+\.\d+\.\d+)(?:\s+(no-auto-summary))?",
1251
+ flags=re.IGNORECASE,
1252
+ )
1253
+ static_route_pattern = re.compile(
1254
+ r"set\s+([A-Za-z0-9_-]+)\s+static-route\s+(\d+\.\d+\.\d+\.\d+)/(\d+)\s+via\s+(\d+\.\d+\.\d+\.\d+)",
1255
+ flags=re.IGNORECASE,
1256
+ )
1257
+ dhcp_relay_pattern = re.compile(
1258
+ r"set\s+([A-Za-z0-9_-]+)\s+dhcp-relay\s+interface\s+([A-Za-z0-9/._-]+)\s+helper\s+(\d+\.\d+\.\d+\.\d+)",
1259
+ flags=re.IGNORECASE,
1260
+ )
1261
+ nat_inside_outside_pattern = re.compile(
1262
+ r"set\s+([A-Za-z0-9_-]+)\s+nat\s+(inside|outside)\s+interface\s+([A-Za-z0-9/._-]+)",
1263
+ flags=re.IGNORECASE,
1264
+ )
1265
+ nat_static_pattern = re.compile(
1266
+ r"set\s+([A-Za-z0-9_-]+)\s+nat\s+static\s+(\d+\.\d+\.\d+\.\d+)\s+(\d+\.\d+\.\d+\.\d+)",
1267
+ flags=re.IGNORECASE,
1268
+ )
1269
+ pat_pattern = re.compile(
1270
+ r"set\s+([A-Za-z0-9_-]+)\s+pat\s+acl\s+([A-Za-z0-9_-]+)\s+interface\s+([A-Za-z0-9/._-]+)(?:\s+(overload))?",
1271
+ flags=re.IGNORECASE,
1272
+ )
1273
+ ssh_ios_pattern = re.compile(
1274
+ r"set\s+([A-Za-z0-9_-]+)\s+ssh\s+domain\s+([A-Za-z0-9._-]+)\s+username\s+([A-Za-z0-9._-]+)\s+password\s+([A-Za-z0-9._-]+)(?:\s+modulus\s+(\d+))?",
1275
+ flags=re.IGNORECASE,
1276
+ )
1277
+ ntp_ios_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+ntp\s+server\s+(\d+\.\d+\.\d+\.\d+)", flags=re.IGNORECASE)
1278
+ syslog_ios_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+syslog\s+server\s+(\d+\.\d+\.\d+\.\d+)", flags=re.IGNORECASE)
1279
+ bgp_pattern = re.compile(
1280
+ r"set\s+([A-Za-z0-9_-]+)\s+bgp\s+(\d+)\s+neighbor\s+(\d+\.\d+\.\d+\.\d+)\s+remote-as\s+(\d+)"
1281
+ r"(?:\s+network\s+(\d+\.\d+\.\d+\.\d+)\s+mask\s+(\d+\.\d+\.\d+\.\d+))?",
1282
+ flags=re.IGNORECASE,
1283
+ )
1284
+ snmp_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+snmp\s+community\s+([A-Za-z0-9_-]+)\s+(ro|rw)", flags=re.IGNORECASE)
1285
+ netflow_pattern = re.compile(
1286
+ r"set\s+([A-Za-z0-9_-]+)\s+netflow\s+destination\s+(\d+\.\d+\.\d+\.\d+)\s+(\d+)(?:\s+version\s+(\d+))?(?:\s+interface\s+([A-Za-z0-9/._-]+)\s+(ingress|egress))?",
1287
+ flags=re.IGNORECASE,
1288
+ )
1289
+ gre_tunnel_pattern = re.compile(
1290
+ r"set\s+([A-Za-z0-9_-]+)\s+gre\s+tunnel\s+([A-Za-z0-9/._-]+)\s+source\s+([A-Za-z0-9/._:-]+)\s+destination\s+([A-Za-z0-9/._:-]+)"
1291
+ r"(?:\s+ip\s+(\d+\.\d+\.\d+\.\d+)/(\d+))?",
1292
+ flags=re.IGNORECASE,
1293
+ )
1294
+ ppp_pattern = re.compile(
1295
+ r"set\s+([A-Za-z0-9_-]+)\s+ppp\s+interface\s+([A-Za-z0-9/._-]+)(?:\s+authentication\s+(chap|pap))?",
1296
+ flags=re.IGNORECASE,
1297
+ )
1298
+ ipsec_transform_pattern = re.compile(
1299
+ r"set\s+([A-Za-z0-9_-]+)\s+ipsec\s+transform-set\s+([A-Za-z0-9_-]+)\s+([A-Za-z0-9_-]+)\s+([A-Za-z0-9_-]+)",
1300
+ flags=re.IGNORECASE,
1301
+ )
1302
+ crypto_map_pattern = re.compile(
1303
+ r"set\s+([A-Za-z0-9_-]+)\s+crypto\s+map\s+([A-Za-z0-9_-]+)\s+(\d+)\s+peer\s+(\d+\.\d+\.\d+\.\d+)\s+transform-set\s+([A-Za-z0-9_-]+)\s+match\s+([A-Za-z0-9_-]+)(?:\s+interface\s+([A-Za-z0-9/._-]+))?",
1304
+ flags=re.IGNORECASE,
1305
+ )
1306
+ cbac_pattern = re.compile(
1307
+ r"set\s+([A-Za-z0-9_-]+)\s+cbac\s+inspect\s+([A-Za-z0-9_-]+)\s+protocol\s+([A-Za-z0-9_-]+)\s+interface\s+([A-Za-z0-9/._-]+)\s+direction\s+(in|out)",
1308
+ flags=re.IGNORECASE,
1309
+ )
1310
+ zfw_zone_interface_pattern = re.compile(
1311
+ r"set\s+([A-Za-z0-9_-]+)\s+zfw\s+zone\s+([A-Za-z0-9_-]+)\s+interface\s+([A-Za-z0-9/._-]+)",
1312
+ flags=re.IGNORECASE,
1313
+ )
1314
+ zfw_zone_pair_pattern = re.compile(
1315
+ r"set\s+([A-Za-z0-9_-]+)\s+zfw\s+zone-pair\s+([A-Za-z0-9_-]+)\s+source\s+([A-Za-z0-9_-]+)\s+destination\s+([A-Za-z0-9_-]+)\s+policy\s+([A-Za-z0-9_-]+)",
1316
+ flags=re.IGNORECASE,
1317
+ )
1318
+ zfw_policy_pattern = re.compile(
1319
+ r"set\s+([A-Za-z0-9_-]+)\s+zfw\s+class-map\s+([A-Za-z0-9_-]+)\s+match\s+protocol\s+([A-Za-z0-9_-]+)\s+policy-map\s+([A-Za-z0-9_-]+)\s+action\s+(inspect|pass|drop)",
1320
+ flags=re.IGNORECASE,
1321
+ )
1322
+ telephony_service_pattern = re.compile(
1323
+ r'set\s+"?([^"]+?)"?\s+telephony\s+service\s+source-address\s+(\d+\.\d+\.\d+\.\d+)\s+port\s+(\d+)'
1324
+ r"(?:\s+max-ephones\s+(\d+))?(?:\s+max-dn\s+(\d+))?",
1325
+ flags=re.IGNORECASE,
1326
+ )
1327
+ ephone_dn_pattern = re.compile(r'set\s+"?([^"]+?)"?\s+ephone-dn\s+(\d+)\s+number\s+([A-Za-z0-9*+#.-]+)', flags=re.IGNORECASE)
1328
+ ephone_pattern = re.compile(
1329
+ r'set\s+"?([^"]+?)"?\s+ephone\s+(\d+)\s+mac\s+([0-9A-Fa-f.:-]+)\s+button\s+([0-9:]+)',
1330
+ flags=re.IGNORECASE,
1331
+ )
1332
+ dial_peer_pattern = re.compile(
1333
+ r'set\s+"?([^"]+?)"?\s+dial-peer\s+voice\s+(\d+)\s+destination-pattern\s+(\S+)\s+session-target\s+ipv4:(\d+\.\d+\.\d+\.\d+)',
1334
+ flags=re.IGNORECASE,
1335
+ )
1336
+ for segment in _command_segments(prompt):
1337
+ for device in ipv6_unicast_pattern.findall(segment):
1338
+ ops.append({"op": "enable_ipv6_unicast_routing", "device": device})
1339
+ for device, interface_name, address, prefix in ipv6_address_pattern.findall(segment):
1340
+ ops.append({"op": "set_ipv6_address", "device": device, "interface": interface_name, "address": address, "prefix": int(prefix)})
1341
+ for device, interface_name, prefix_address, prefix_len in ipv6_slaac_pattern.findall(segment):
1342
+ ops.append(
1343
+ {
1344
+ "op": "set_ipv6_slaac",
1345
+ "device": device,
1346
+ "interface": interface_name,
1347
+ "prefix": prefix_address or None,
1348
+ "prefix_len": int(prefix_len) if prefix_len else None,
1349
+ }
1350
+ )
1351
+ for device, name, prefix_address, prefix_len, interface_name, dns, domain in dhcpv6_pattern.findall(segment):
1352
+ ops.append(
1353
+ {
1354
+ "op": "set_dhcpv6_pool",
1355
+ "device": device,
1356
+ "name": name,
1357
+ "prefix": prefix_address,
1358
+ "prefix_len": int(prefix_len),
1359
+ "interface": interface_name,
1360
+ "dns": dns or None,
1361
+ "domain": domain or None,
1362
+ }
1363
+ )
1364
+ for device, process_id, area, interface_name in ospfv3_pattern.findall(segment):
1365
+ ops.append({"op": "set_ospfv3_interface", "device": device, "process_id": int(process_id), "area": int(area), "interface": interface_name})
1366
+ for device, asn, interface_name in eigrp_ipv6_pattern.findall(segment):
1367
+ ops.append({"op": "set_eigrp_ipv6_interface", "device": device, "asn": int(asn), "interface": interface_name})
1368
+ for device, process_name, interface_name in ripng_pattern.findall(segment):
1369
+ ops.append({"op": "set_ripng_interface", "device": device, "process_name": process_name, "interface": interface_name})
1370
+ for device, group, virtual_ipv6, interface_name, priority in hsrp_pattern.findall(segment):
1371
+ ops.append(
1372
+ {
1373
+ "op": "set_hsrp_ipv6",
1374
+ "device": device,
1375
+ "group": int(group),
1376
+ "virtual_ipv6": virtual_ipv6,
1377
+ "interface": interface_name,
1378
+ "priority": int(priority) if priority else None,
1379
+ }
1380
+ )
1381
+ for device, process_id, network, wildcard, area in ospfv2_pattern.findall(segment):
1382
+ ops.append({"op": "set_ospfv2_network", "device": device, "process_id": int(process_id), "network": network, "wildcard": wildcard, "area": int(area)})
1383
+ for device, asn, network, wildcard, no_auto in eigrp_ipv4_pattern.findall(segment):
1384
+ ops.append({"op": "set_eigrp_ipv4_network", "device": device, "asn": int(asn), "network": network, "wildcard": wildcard, "no_auto_summary": bool(no_auto)})
1385
+ for device, network, no_auto in ripv2_pattern.findall(segment):
1386
+ ops.append({"op": "set_ripv2_network", "device": device, "network": network, "no_auto_summary": bool(no_auto)})
1387
+ for device, network, prefix, next_hop in static_route_pattern.findall(segment):
1388
+ ops.append({"op": "set_static_route", "device": device, "network": network, "prefix": int(prefix), "next_hop": next_hop})
1389
+ for device, interface_name, helper in dhcp_relay_pattern.findall(segment):
1390
+ ops.append({"op": "set_dhcp_relay", "device": device, "interface": interface_name, "helper": helper})
1391
+ for device, role, interface_name in nat_inside_outside_pattern.findall(segment):
1392
+ ops.append({"op": "set_nat_interface", "device": device, "role": role.lower(), "interface": interface_name})
1393
+ for device, inside_local, inside_global in nat_static_pattern.findall(segment):
1394
+ ops.append({"op": "set_nat_static", "device": device, "inside_local": inside_local, "inside_global": inside_global})
1395
+ for device, acl, interface_name, overload in pat_pattern.findall(segment):
1396
+ ops.append({"op": "set_pat_overload", "device": device, "acl": acl, "interface": interface_name, "overload": bool(overload)})
1397
+ for device, domain, username, password, modulus in ssh_ios_pattern.findall(segment):
1398
+ ops.append({"op": "set_ssh_ios", "device": device, "domain": domain, "username": username, "password": password, "modulus": int(modulus) if modulus else 1024})
1399
+ for device, server in ntp_ios_pattern.findall(segment):
1400
+ ops.append({"op": "set_ntp_server", "device": device, "server": server})
1401
+ for device, server in syslog_ios_pattern.findall(segment):
1402
+ ops.append({"op": "set_syslog_server", "device": device, "server": server})
1403
+ for device, asn, neighbor, remote_as, network, mask in bgp_pattern.findall(segment):
1404
+ ops.append(
1405
+ {
1406
+ "op": "set_bgp_neighbor",
1407
+ "device": device,
1408
+ "asn": int(asn),
1409
+ "neighbor": neighbor,
1410
+ "remote_as": int(remote_as),
1411
+ "network": network or None,
1412
+ "mask": mask or None,
1413
+ }
1414
+ )
1415
+ for device, community, mode in snmp_pattern.findall(segment):
1416
+ ops.append({"op": "set_snmp_community", "device": device, "community": community, "mode": mode.lower()})
1417
+ for device, destination, port, version, interface_name, direction in netflow_pattern.findall(segment):
1418
+ ops.append(
1419
+ {
1420
+ "op": "set_netflow",
1421
+ "device": device,
1422
+ "destination": destination,
1423
+ "port": int(port),
1424
+ "version": int(version) if version else 9,
1425
+ "interface": interface_name or None,
1426
+ "direction": direction.lower() if direction else None,
1427
+ }
1428
+ )
1429
+ for device, tunnel_interface, source, destination, ip, prefix in gre_tunnel_pattern.findall(segment):
1430
+ ops.append(
1431
+ {
1432
+ "op": "set_gre_tunnel",
1433
+ "device": device,
1434
+ "interface": tunnel_interface,
1435
+ "source": source,
1436
+ "destination": destination,
1437
+ "ip": ip or None,
1438
+ "prefix": int(prefix) if prefix else None,
1439
+ }
1440
+ )
1441
+ for device, interface_name, authentication in ppp_pattern.findall(segment):
1442
+ ops.append(
1443
+ {
1444
+ "op": "set_ppp_interface",
1445
+ "device": device,
1446
+ "interface": interface_name,
1447
+ "authentication": authentication.lower() if authentication else None,
1448
+ }
1449
+ )
1450
+ for device, name, encryption, integrity in ipsec_transform_pattern.findall(segment):
1451
+ ops.append(
1452
+ {
1453
+ "op": "set_ipsec_transform_set",
1454
+ "device": device,
1455
+ "name": name,
1456
+ "encryption": encryption,
1457
+ "integrity": integrity,
1458
+ }
1459
+ )
1460
+ for device, map_name, sequence, peer, transform_set, acl_name, interface_name in crypto_map_pattern.findall(segment):
1461
+ ops.append(
1462
+ {
1463
+ "op": "set_crypto_map",
1464
+ "device": device,
1465
+ "map_name": map_name,
1466
+ "sequence": int(sequence),
1467
+ "peer": peer,
1468
+ "transform_set": transform_set,
1469
+ "acl_name": acl_name,
1470
+ "interface": interface_name or None,
1471
+ }
1472
+ )
1473
+ for device, name, protocol, interface_name, direction in cbac_pattern.findall(segment):
1474
+ ops.append(
1475
+ {
1476
+ "op": "set_cbac_inspect",
1477
+ "device": device,
1478
+ "name": name,
1479
+ "protocol": protocol.lower(),
1480
+ "interface": interface_name,
1481
+ "direction": direction.lower(),
1482
+ }
1483
+ )
1484
+ for device, zone, interface_name in zfw_zone_interface_pattern.findall(segment):
1485
+ ops.append({"op": "set_zfw_zone_interface", "device": device, "zone": zone, "interface": interface_name})
1486
+ for device, pair_name, source, destination, policy in zfw_zone_pair_pattern.findall(segment):
1487
+ ops.append(
1488
+ {
1489
+ "op": "set_zfw_zone_pair",
1490
+ "device": device,
1491
+ "pair_name": pair_name,
1492
+ "source": source,
1493
+ "destination": destination,
1494
+ "policy": policy,
1495
+ }
1496
+ )
1497
+ for device, class_map, protocol, policy_map, action in zfw_policy_pattern.findall(segment):
1498
+ ops.append(
1499
+ {
1500
+ "op": "set_zfw_policy",
1501
+ "device": device,
1502
+ "class_map": class_map,
1503
+ "protocol": protocol.lower(),
1504
+ "policy_map": policy_map,
1505
+ "action": action.lower(),
1506
+ }
1507
+ )
1508
+ for device, source_address, port, max_ephones, max_dn in telephony_service_pattern.findall(segment):
1509
+ ops.append(
1510
+ {
1511
+ "op": "set_telephony_service",
1512
+ "device": device.strip(),
1513
+ "source_address": source_address,
1514
+ "port": int(port),
1515
+ "max_ephones": int(max_ephones) if max_ephones else None,
1516
+ "max_dn": int(max_dn) if max_dn else None,
1517
+ }
1518
+ )
1519
+ for device, dn_id, number in ephone_dn_pattern.findall(segment):
1520
+ ops.append({"op": "set_ephone_dn", "device": device.strip(), "dn_id": int(dn_id), "number": number})
1521
+ for device, ephone_id, mac, button in ephone_pattern.findall(segment):
1522
+ ops.append({"op": "set_ephone", "device": device.strip(), "ephone_id": int(ephone_id), "mac": mac.upper(), "button": button})
1523
+ for device, peer_id, destination_pattern, session_target in dial_peer_pattern.findall(segment):
1524
+ ops.append(
1525
+ {
1526
+ "op": "set_dial_peer_voice",
1527
+ "device": device.strip(),
1528
+ "peer_id": int(peer_id),
1529
+ "destination_pattern": destination_pattern,
1530
+ "session_target": session_target,
1531
+ }
1532
+ )
1533
+ return ops
1534
+
1535
+
1536
+ def _extract_server_ops(prompt: str) -> list[dict[str, object]]:
1537
+ ops: list[dict[str, object]] = []
1538
+ dns_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+dns\s+(A|CNAME)\s+([A-Za-z0-9._-]+)\s+([A-Za-z0-9._-]+)", flags=re.IGNORECASE)
1539
+ for segment in _command_segments(prompt):
1540
+ for device, record_type, name, value in dns_pattern.findall(segment):
1541
+ ops.append({"op": "set_server_dns_record", "device": device, "record_type": record_type.upper(), "name": name, "value": value})
1542
+ email_domain_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+email\s+domain\s+([A-Za-z0-9._-]+)", flags=re.IGNORECASE)
1543
+ for segment in _command_segments(prompt):
1544
+ for device, domain in email_domain_pattern.findall(segment):
1545
+ ops.append({"op": "set_server_email_domain", "device": device, "domain": domain})
1546
+ aaa_auth_port_pattern = re.compile(r"set\s+([A-Za-z0-9_-]+)\s+aaa\s+auth-port\s+(\d+)", flags=re.IGNORECASE)
1547
+ for segment in _command_segments(prompt):
1548
+ for device, port in aaa_auth_port_pattern.findall(segment):
1549
+ ops.append({"op": "set_server_aaa_auth_port", "device": device, "auth_port": int(port)})
1550
+ dhcp_pattern = re.compile(
1551
+ r"set\s+([A-Za-z0-9_-]+)\s+(server-dhcp|dhcp)\s+pool\s+([A-Za-z0-9_-]+)\s+network\s+(\d+\.\d+\.\d+\.\d+)/(\d+)\s+gateway\s+(\d+\.\d+\.\d+\.\d+)"
1552
+ r"(?:\s+dns\s+(\d+\.\d+\.\d+\.\d+))?(?:\s+start\s+(\d+\.\d+\.\d+\.\d+))?(?:\s+max\s+(\d+))?",
1553
+ flags=re.IGNORECASE,
1554
+ )
1555
+ for segment in _command_segments(prompt):
1556
+ for device, mode, name, network, prefix, gateway, dns, start, max_users in dhcp_pattern.findall(segment):
1557
+ if mode.lower() != "server-dhcp" and not str(device).strip().lower().startswith("server"):
1558
+ continue
1559
+ ops.append(
1560
+ {
1561
+ "op": "set_server_dhcp_pool",
1562
+ "device": device,
1563
+ "name": name,
1564
+ "network": network,
1565
+ "prefix": int(prefix),
1566
+ "gateway": gateway,
1567
+ "dns": dns or None,
1568
+ "start": start or None,
1569
+ "max_users": int(max_users) if max_users else 0,
1570
+ }
1571
+ )
1572
+ for service, device in re.findall(r"enable\s+(dns|http|https|ftp|tftp|ntp|email|syslog|aaa)\s+on\s+([A-Za-z0-9_ -]+)", segment, flags=re.IGNORECASE):
1573
+ ops.append({"op": "enable_server_service", "device": device.strip(), "service": service.lower()})
1574
+ return ops
1575
+
1576
+
1577
+ def _extract_management_ops(prompt: str) -> list[dict[str, object]]:
1578
+ ops: list[dict[str, object]] = []
1579
+ mgmt_pattern = re.compile(r"set\s+([A-Za-z0-9_ -]+?)\s+management\s+vlan\s+(\d+)\s+ip\s+(\d+\.\d+\.\d+\.\d+)/(\d+)\s+gateway\s+(\d+\.\d+\.\d+\.\d+)", flags=re.IGNORECASE)
1580
+ for segment in _command_segments(prompt):
1581
+ for device, vlan_id, ip, prefix, gateway in mgmt_pattern.findall(segment):
1582
+ ops.append({"op": "set_management_vlan", "device": device.strip(), "vlan": int(vlan_id), "ip": ip, "prefix": int(prefix), "gateway": gateway})
1583
+ telnet_pattern = re.compile(r"enable\s+telnet\s+on\s+([A-Za-z0-9_ -]+?)\s+username\s+([A-Za-z0-9._-]+)\s+password\s+([A-Za-z0-9._-]+)", flags=re.IGNORECASE)
1584
+ for segment in _command_segments(prompt):
1585
+ for device, username, password in telnet_pattern.findall(segment):
1586
+ ops.append({"op": "enable_telnet", "device": device.strip(), "username": username, "password": password})
1587
+ return ops
1588
+
1589
+
1590
+ def _extract_wireless_settings(normalized_prompt: str) -> dict[str, object]:
1591
+ """SSID, security and passphrase as an ordinary sentence names them.
1592
+
1593
+ `_extract_wireless_ops` only understands the command form
1594
+ `set AP1 ssid TEST security wpa2-psk passphrase test12345`. Nobody writing a
1595
+ prompt types that, so "ssid EvSebeke wpa2 sifre 12345678" produced no
1596
+ wireless operations at all -- the same shape as the DHCP and telnet gaps.
1597
+ """
1598
+ settings: dict[str, object] = {}
1599
+ ssid = re.search(r"\bssid\s+(?:adi\s+)?([a-z0-9._-]{2,32})\b", normalized_prompt)
1600
+ # `olsun`/`ile`/`ve` are ordinary words that can follow `ssid`; reading one
1601
+ # as a network name would silently misname the network.
1602
+ if ssid and ssid.group(1) not in {"olsun", "ile", "ve", "adi", "ismi", "qur"}:
1603
+ settings["ssid"] = ssid.group(1)
1604
+
1605
+ for key in ("wpa2-enterprise", "wpa-enterprise", "wpa2-psk", "wpa-psk", "wpa2", "wpa", "wep", "open"):
1606
+ if re.search(rf"\b{re.escape(key)}\b", normalized_prompt):
1607
+ settings["security"] = key
1608
+ break
1609
+
1610
+ # `sifre`/`parol` are the Azerbaijani words; `password`/`passphrase` the English.
1611
+ secret = re.search(
1612
+ r"\b(?:sifre|sifresi|parol|parolu|password|passphrase|key)\s+([a-z0-9._-]{4,63})\b",
1613
+ normalized_prompt,
1614
+ )
1615
+ if secret:
1616
+ settings["passphrase"] = secret.group(1)
1617
+
1618
+ channel = re.search(r"\bchannel\s+(\d{1,2})\b", normalized_prompt)
1619
+ if channel:
1620
+ settings["channel"] = int(channel.group(1))
1621
+ return settings
1622
+
1623
+
1624
+ def _restore_wireless_casing(settings: dict[str, object], raw_prompt: str) -> dict[str, object]:
1625
+ """Give the SSID and passphrase back the capitalisation the user typed.
1626
+
1627
+ Normalisation lowercases everything so the patterns can be simple, but a
1628
+ network name and a passphrase are user-visible strings -- `EvSebeke` must
1629
+ not reach Packet Tracer as `evsebeke`, and a lowercased passphrase would not
1630
+ even be the same secret.
1631
+ """
1632
+ restored = dict(settings)
1633
+ for key in ("ssid", "passphrase"):
1634
+ value = str(restored.get(key) or "")
1635
+ if not value:
1636
+ continue
1637
+ match = re.search(rf"\b{re.escape(value)}\b", raw_prompt, flags=re.IGNORECASE)
1638
+ if match:
1639
+ restored[key] = match.group(0)
1640
+ return restored
1641
+
1642
+
1643
+ def _extract_wireless_ops(prompt: str) -> list[dict[str, object]]:
1644
+ ops: list[dict[str, object]] = []
1645
+ ssid_pattern = re.compile(
1646
+ r"set\s+([A-Za-z0-9_ -]+?)\s+ssid\s+([A-Za-z0-9._-]+)"
1647
+ r"(?:\s+security\s+(wpa2?\s+enterprise|wpa2?-enterprise|wpa2?-psk|802\.1x|wep|open|wpa2?|wpa))?"
1648
+ r"(?:\s+passphrase\s+([A-Za-z0-9._-]+))?"
1649
+ r"(?:\s+radius\s+(\d+\.\d+\.\d+\.\d+)\s+secret\s+([A-Za-z0-9._-]+))?"
1650
+ r"(?:\s+channel\s+(\d+))?",
1651
+ flags=re.IGNORECASE,
1652
+ )
1653
+ for segment in _command_segments(prompt):
1654
+ for device, ssid, security, passphrase, radius_server, radius_secret, channel in ssid_pattern.findall(segment):
1655
+ security_key = (security or "open").lower().replace(" ", "-")
1656
+ auth_type, encrypt_type = SECURITY_TO_AUTH.get(security_key, ("0", "0"))
1657
+ operation = {
1658
+ "op": "set_wireless_ssid",
1659
+ "device": device.strip(),
1660
+ "ssid": ssid,
1661
+ "security": security_key,
1662
+ "auth_type": auth_type,
1663
+ "encrypt_type": encrypt_type,
1664
+ "passphrase": passphrase or "",
1665
+ "channel": int(channel) if channel else 1,
1666
+ }
1667
+ if radius_server:
1668
+ operation["radius_server"] = radius_server
1669
+ operation["radius_secret"] = radius_secret or ""
1670
+ ops.append(operation)
1671
+ assoc_pattern = re.compile(r"associate\s+([A-Za-z0-9_ -]+?)\s+to\s+([A-Za-z0-9_ -]+?)\s+ssid\s+([A-Za-z0-9._-]+)(?:\s+(dhcp|static))?", flags=re.IGNORECASE)
1672
+ for segment in _command_segments(prompt):
1673
+ for client, ap, ssid, ip_mode in assoc_pattern.findall(segment):
1674
+ ops.append({"op": "associate_wireless_client", "device": client.strip(), "ap": ap.strip(), "ssid": ssid, "ip_mode": (ip_mode or "dhcp").lower()})
1675
+ return ops
1676
+
1677
+
1678
+ def _extract_end_device_ops(prompt: str) -> list[dict[str, object]]:
1679
+ ops: list[dict[str, object]] = []
1680
+ ip_pattern = re.compile(
1681
+ r"(?:set|change|update)\s+([A-Za-z0-9_-]+)\s+ip\s+(\d+\.\d+\.\d+\.\d+)"
1682
+ r"(?:\s+mask\s+(\d+\.\d+\.\d+\.\d+))?"
1683
+ r"(?:\s+gw\s+(\d+\.\d+\.\d+\.\d+))?"
1684
+ r"(?:\s+dns\s+(\d+\.\d+\.\d+\.\d+))?",
1685
+ flags=re.IGNORECASE,
1686
+ )
1687
+ for segment in _command_segments(prompt):
1688
+ for device, ip, mask, gw, dns in ip_pattern.findall(segment):
1689
+ operation: dict[str, object] = {"op": "set_host_ip", "device": device, "ip": ip}
1690
+ if mask:
1691
+ operation["mask"] = mask
1692
+ if gw:
1693
+ operation["gw"] = gw
1694
+ if dns:
1695
+ operation["dns"] = dns
1696
+ ops.append(operation)
1697
+ for device in re.findall(r"set\s+([A-Za-z0-9_-]+)\s+ipv4\s+dhcp", segment, flags=re.IGNORECASE):
1698
+ ops.append({"op": "set_host_dhcp", "device": device})
1699
+ for device, dns in re.findall(r"set\s+([A-Za-z0-9_-]+)\s+dns\s+(\d+\.\d+\.\d+\.\d+)", segment, flags=re.IGNORECASE):
1700
+ ops.append({"op": "set_host_dns", "device": device, "dns": dns})
1701
+ return ops
1702
+
1703
+
1704
+ def _extract_verification_ops(prompt: str) -> list[dict[str, object]]:
1705
+ ops: list[dict[str, object]] = []
1706
+ lowered = prompt.lower()
1707
+ for command in ["show vlan brief", "show interfaces trunk", "show ip interface brief", "show ip dhcp binding"]:
1708
+ if command in lowered:
1709
+ ops.append({"op": "verify_command", "command": command})
1710
+ if "ping" in lowered:
1711
+ ops.append({"op": "verify_ping"})
1712
+ if "telnet" in lowered:
1713
+ ops.append({"op": "verify_telnet"})
1714
+ return ops
1715
+
1716
+
1717
+ def _extract_iot_ops(prompt: str) -> list[dict[str, object]]:
1718
+ ops: list[dict[str, object]] = []
1719
+ registration_pattern = re.compile(
1720
+ r"register\s+([A-Za-z0-9_ -]+?)\s+to\s+([A-Za-z0-9_ -]+?)(?=\s+(?:mode|server(?:-address)?|username|password)\b|$)"
1721
+ r"(?:\s+mode\s+(lan_server|remote_server))?"
1722
+ r"(?:\s+server(?:-address)?\s+(\d+\.\d+\.\d+\.\d+))?"
1723
+ r"(?:\s+username\s+([A-Za-z0-9._-]+))?"
1724
+ r"(?:\s+password\s+([A-Za-z0-9._-]+))?",
1725
+ flags=re.IGNORECASE,
1726
+ )
1727
+ for segment in _command_segments(prompt):
1728
+ for device, target, mode, server_address, username, password in registration_pattern.findall(segment):
1729
+ ops.append(
1730
+ {
1731
+ "op": "set_iot_registration",
1732
+ "device": device.strip(),
1733
+ "target": target.strip(),
1734
+ "mode": (mode or "").upper() or None,
1735
+ "server_address": server_address or None,
1736
+ "username": username or None,
1737
+ "password": password or None,
1738
+ }
1739
+ )
1740
+ rule_pattern = re.compile(
1741
+ r"(enable|disable)\s+iot\s+rule\s+(?:\"([^\"]+)\"|'([^']+)'|([A-Za-z0-9_ -]+?))\s+on\s+([A-Za-z0-9_ -]+)",
1742
+ flags=re.IGNORECASE,
1743
+ )
1744
+ for action, quoted_name, single_quoted_name, plain_name, device in rule_pattern.findall(segment):
1745
+ rule_name = quoted_name or single_quoted_name or plain_name
1746
+ ops.append(
1747
+ {
1748
+ "op": "set_iot_rule_state",
1749
+ "device": device.strip(),
1750
+ "rule_name": rule_name.strip(),
1751
+ "enabled": action.lower() == "enable",
1752
+ }
1753
+ )
1754
+ return ops
1755
+
1756
+
1757
+ def _decode_prompt_string(value: str) -> str:
1758
+ return (
1759
+ value.replace(r"\\", "\0")
1760
+ .replace(r"\"", '"')
1761
+ .replace(r"\n", "\n")
1762
+ .replace(r"\r", "\r")
1763
+ .replace(r"\t", "\t")
1764
+ .replace("\0", "\\")
1765
+ )
1766
+
1767
+
1768
+ def _extract_programming_ops(prompt: str) -> list[dict[str, object]]:
1769
+ ops: list[dict[str, object]] = []
1770
+ script_pattern = re.compile(
1771
+ r'set\s+"((?:\\.|[^"\\])*)"\s+script\s+app\s+"((?:\\.|[^"\\])*)"\s+file\s+"((?:\\.|[^"\\])*)"\s+content\s+"((?:\\.|[^"\\])*)"',
1772
+ flags=re.IGNORECASE | re.DOTALL,
1773
+ )
1774
+ for device, app_name, file_name, content in script_pattern.findall(prompt):
1775
+ ops.append(
1776
+ {
1777
+ "op": "set_script_file_content",
1778
+ "device": _decode_prompt_string(device).strip(),
1779
+ "app_name": _decode_prompt_string(app_name).strip(),
1780
+ "file_name": _decode_prompt_string(file_name).strip(),
1781
+ "content": _decode_prompt_string(content),
1782
+ }
1783
+ )
1784
+ return ops
1785
+
1786
+
1787
+ def strict_vlan_assignment() -> bool:
1788
+ """Refuse instead of defaulting host-to-VLAN distribution."""
1789
+ return (os.getenv("PACKET_TRACER_STRICT_VLAN_ASSIGNMENT") or "").strip().lower() in {"1", "true", "yes", "on"}
1790
+
1791
+
1792
+ def distribute_hosts_across_vlans(host_count: int, vlan_ids: list[int]) -> dict[int, int]:
1793
+ """Spread hosts across VLANs as evenly as possible, remainder to the first.
1794
+
1795
+ 6 hosts over VLAN 10/20/30 gives 2/2/2; 7 gives 3/2/2. VLANs beyond the host
1796
+ count are still created, they just start empty — an empty VLAN is a valid
1797
+ configuration and more useful than a refusal.
1798
+ """
1799
+ if host_count <= 0 or not vlan_ids:
1800
+ return {}
1801
+ base, remainder = divmod(host_count, len(vlan_ids))
1802
+ return {
1803
+ vlan_id: base + (1 if index < remainder else 0)
1804
+ for index, vlan_id in enumerate(vlan_ids)
1805
+ }
1806
+
1807
+
1808
+ def parse_intent(prompt: str) -> IntentPlan:
1809
+ pkt_path_match = re.search(r"([A-Za-z]:\\[^\"\n]+?\.pkt)\b", prompt, flags=re.IGNORECASE)
1810
+ pkt_path = pkt_path_match.group(1) if pkt_path_match else None
1811
+ lowered = prompt.lower()
1812
+ normalized_prompt = _normalize_prompt(prompt)
1813
+
1814
+ capability_set = {capability for capability, patterns in CAPABILITY_PATTERNS.items() if any(re.search(pattern, lowered) for pattern in patterns)}
1815
+ devices = _extract_explicit_devices(prompt)
1816
+ links = _extract_explicit_links(prompt)
1817
+ device_counts = _extract_natural_device_counts(normalized_prompt)
1818
+ vlan_ids = _extract_vlan_ids(normalized_prompt)
1819
+ uplink_intent = _extract_uplink_intent(normalized_prompt)
1820
+ host_link_intent = _extract_host_link_intent(normalized_prompt)
1821
+ host_vlan_assignment = _extract_host_vlan_assignment(normalized_prompt)
1822
+ network_style = _extract_network_style(normalized_prompt)
1823
+ if (
1824
+ network_style == "wireless_advanced"
1825
+ and "server_aaa" in capability_set
1826
+ and re.search(r"\bradius\b", lowered)
1827
+ and not re.search(r"\b(?:aaa|acs|server aaa|aaa server)\b", lowered)
1828
+ ):
1829
+ capability_set.discard("server_aaa")
1830
+ if re.search(r"\bqos\b", lowered) and (
1831
+ re.search(r"\b(?:snmp|netflow|span|rspan|lldp|rep|dai|dot1x|802\.1x|dhcp snooping|port-security|port security)\b", lowered)
1832
+ or network_style == "l2_security_monitoring"
1833
+ ):
1834
+ capability_set.add("qos")
1835
+ if network_style == "automation_controller" or re.search(r"\b(?:network controller|netcon|blockly|iox)\b", lowered):
1836
+ if re.search(r"\bpython\b", lowered):
1837
+ capability_set.add("python_programming")
1838
+ if re.search(r"\b(?:tcp|udp)\b", lowered) and re.search(r"\b(?:app|application|test app)\b", lowered):
1839
+ capability_set.add("tcp_udp_app")
1840
+ if "ospf" in capability_set and not re.search(r"\b(?:ipv6|ospfv3)\b", lowered):
1841
+ capability_set.add("ospfv2")
1842
+ if "eigrp" in capability_set and not re.search(r"\bipv6\b", lowered):
1843
+ capability_set.add("eigrp_ipv4")
1844
+ if "rip" in capability_set and not re.search(r"\b(?:ipv6|ripng)\b", lowered):
1845
+ capability_set.add("ripv2")
1846
+ if "nat" in capability_set:
1847
+ if re.search(r"\bpat\b|\boverload\b", lowered):
1848
+ capability_set.add("pat")
1849
+ if re.search(r"\bstatic\s+nat\b|\bnat\s+static\b", lowered):
1850
+ capability_set.add("nat_static")
1851
+ if re.search(r"\bdynamic\s+nat\b|\bnat\s+dynamic\b", lowered):
1852
+ capability_set.add("nat_dynamic")
1853
+ if network_style == "ipv4_routing_management" and not {"nat_static", "nat_dynamic"} & capability_set:
1854
+ capability_set.update({"nat_static", "nat_dynamic"})
1855
+ if "ssh" in capability_set:
1856
+ capability_set.add("ssh_ios")
1857
+ if "ntp" in capability_set:
1858
+ capability_set.add("ntp_ios")
1859
+ if (
1860
+ "server_syslog" in capability_set
1861
+ and re.search(r"\b(?:server|ftp|email|aaa|dns)\b", lowered)
1862
+ and not re.search(r"\b(?:logging\s+host|set\s+\S+\s+syslog\s+server)\b", lowered)
1863
+ ):
1864
+ capability_set.discard("syslog_ios")
1865
+ if re.search(r"\blogging\s+host\b", lowered):
1866
+ capability_set.add("syslog_ios")
1867
+ if re.search(r"\bdefault\s+route\b|\b0\.0\.0\.0/0\b", lowered):
1868
+ capability_set.update({"static_route", "default_route"})
1869
+ elif re.search(r"\bstatic\s+route\b|\bip\s+route\b", lowered):
1870
+ capability_set.add("static_route")
1871
+ if re.search(r"\bdhcp\s+relay\b|\bdhcp-relay\b|\bhelper-address\b|\bip\s+helper\b", lowered):
1872
+ capability_set.add("dhcp_relay")
1873
+ if not re.search(r"\b(?:dhcp\s+pool|server\s+dhcp|dhcp\s+server|default-router)\b", lowered):
1874
+ capability_set.discard("dhcp_pool")
1875
+ capability_set.discard("router_dhcp")
1876
+ capability_set.discard("server_dhcp")
1877
+ if "dhcp_snooping" in capability_set and not re.search(r"\b(?:dhcp\s+pool|server\s+dhcp|dhcp\s+server|default-router)\b", lowered):
1878
+ capability_set.discard("dhcp_pool")
1879
+ capability_set.discard("router_dhcp")
1880
+ capability_set.discard("server_dhcp")
1881
+ department_count = _extract_department_count(normalized_prompt)
1882
+ per_department_devices = _extract_per_department_devices(normalized_prompt)
1883
+
1884
+ device_requirements = dict(device_counts)
1885
+ for device in devices:
1886
+ device_type = str(device["type"])
1887
+ device_requirements[device_type] = device_requirements.get(device_type, 0) + 1
1888
+
1889
+ # Hosts have to plug into something. "bir sebeke lazimdir 10 kompyuter
1890
+ # ucun" named ten PCs and no switch, so all ten became standalone targets
1891
+ # and generation refused when the donor ran out of spare PCs -- reported as
1892
+ # a donor limit, though the real problem was a topology with no switch in
1893
+ # it. One switch is the smallest thing that makes the request buildable.
1894
+ HOST_TYPES = {"PC", "Server", "Printer", "Laptop", "Tablet", "Smartphone"}
1895
+ host_total = sum(count for kind, count in device_requirements.items() if kind in HOST_TYPES)
1896
+ if host_total and not device_requirements.get("Switch") and not device_requirements.get("WirelessRouter"):
1897
+ device_requirements["Switch"] = 1
1898
+ inferred_switch = True
1899
+ else:
1900
+ inferred_switch = False
1901
+
1902
+ department_groups = _build_department_groups(normalized_prompt, department_count, per_department_devices, vlan_ids)
1903
+ if department_groups:
1904
+ device_requirements["Switch"] = max(device_requirements.get("Switch", 0), department_count)
1905
+ for device_type, per_group_count in per_department_devices.items():
1906
+ device_requirements[device_type] = max(device_requirements.get(device_type, 0), department_count * per_group_count)
1907
+
1908
+ topology_requirements: dict[str, object] = {}
1909
+ if vlan_ids:
1910
+ topology_requirements["vlan_ids"] = vlan_ids
1911
+ if uplink_intent:
1912
+ topology_requirements["uplink_intent"] = uplink_intent
1913
+ if host_link_intent:
1914
+ topology_requirements["host_link_intent"] = host_link_intent
1915
+ if host_vlan_assignment:
1916
+ topology_requirements["host_vlan_assignment"] = host_vlan_assignment
1917
+ if device_requirements.get("Switch", 0) > 1:
1918
+ topology_requirements["uplink_topology"] = "core_switch"
1919
+ if department_groups:
1920
+ topology_requirements["uplink_topology"] = "chain"
1921
+ topology_requirements["department_count"] = department_count
1922
+ if "router_dhcp" in capability_set or "dhcp_pool" in capability_set:
1923
+ topology_requirements["needs_dhcp_pool"] = True
1924
+ routing = next((cap for cap in ["ospf", "eigrp", "rip"] if cap in capability_set), None)
1925
+ if routing:
1926
+ topology_requirements["routing_protocol"] = routing
1927
+ service_requirements = _extract_service_requirements(sorted(capability_set), prompt)
1928
+ wireless_mode = _extract_wireless_mode(normalized_prompt, sorted(capability_set), device_requirements, network_style)
1929
+
1930
+ edit_operations: list[dict[str, object]] = []
1931
+ # `rename X to Y` is the command form. `X adini Y et` and `X in adi Y olsun`
1932
+ # are how the request is actually phrased; without them `--edit` understood
1933
+ # nothing, fell back to generating, and wrote an unchanged copy.
1934
+ rename_patterns = (
1935
+ r"rename\s+([A-Za-z0-9_-]+)\s+to\s+([A-Za-z0-9_-]+)",
1936
+ r"\b([A-Za-z0-9_-]+)\s*(?:in|nin|nun|un)?\s*adini\s+([A-Za-z0-9_-]+)\s*(?:et|ele|deyis)",
1937
+ r"\b([A-Za-z0-9_-]+)\s*(?:in|nin)?\s*adi\s+([A-Za-z0-9_-]+)\s+olsun",
1938
+ )
1939
+ rename_source = _transliterate(prompt)
1940
+ for pattern in rename_patterns:
1941
+ for old_name, new_name in re.findall(pattern, rename_source, flags=re.IGNORECASE):
1942
+ operation = {"op": "rename_device", "device": old_name, "new_name": new_name}
1943
+ if operation not in edit_operations:
1944
+ edit_operations.append(operation)
1945
+ edit_operations.extend(_extract_link_edit_operations(links))
1946
+ natural_edits, natural_switch_ops = _extract_natural_edit_operations(prompt)
1947
+ for operation in natural_edits:
1948
+ if operation not in edit_operations:
1949
+ edit_operations.append(operation)
1950
+
1951
+ switch_ops = _extract_switch_ops(prompt)
1952
+ for operation in natural_switch_ops:
1953
+ if operation not in switch_ops:
1954
+ switch_ops.append(operation)
1955
+ router_ops = _extract_router_ops(prompt)
1956
+ server_ops = _extract_server_ops(prompt)
1957
+ wireless_ops = _extract_wireless_ops(prompt)
1958
+ end_device_ops = _extract_end_device_ops(prompt)
1959
+ management_ops = _extract_management_ops(prompt)
1960
+ verification_ops = _extract_verification_ops(prompt)
1961
+ iot_ops = _extract_iot_ops(prompt)
1962
+ programming_ops = _extract_programming_ops(prompt)
1963
+
1964
+ if any(op["op"] == "associate_wireless_client" for op in wireless_ops):
1965
+ capability_set.update({"wireless_client", "wireless_client_association"})
1966
+ if any(op["op"] == "set_wireless_ssid" for op in wireless_ops):
1967
+ capability_set.update({"wireless_ap", "wireless_mutation"})
1968
+ for op in wireless_ops:
1969
+ if op.get("op") != "set_wireless_ssid":
1970
+ continue
1971
+ security = str(op.get("security") or "")
1972
+ if security == "wep":
1973
+ capability_set.add("wep")
1974
+ if security in {"wpa-enterprise", "wpa2-enterprise", "802.1x"} or op.get("radius_server"):
1975
+ capability_set.add("wpa_enterprise")
1976
+ elif network_style == "wireless_advanced":
1977
+ capability_set.discard("wireless_ap")
1978
+ capability_set.discard("wireless_mutation")
1979
+ if end_device_ops:
1980
+ capability_set.add("end_device_mutation")
1981
+ if iot_ops:
1982
+ capability_set.update({"iot", "iot_registration"})
1983
+ if any(op.get("op") == "set_iot_rule_state" for op in iot_ops):
1984
+ capability_set.add("iot_control")
1985
+ for operation in programming_ops:
1986
+ file_name = str(operation.get("file_name") or "").lower()
1987
+ app_name = str(operation.get("app_name") or "").lower()
1988
+ content = str(operation.get("content") or "").lower()
1989
+ if file_name.endswith(".py") or "python" in app_name:
1990
+ capability_set.add("python_programming")
1991
+ if file_name.endswith(".js") or "javascript" in app_name:
1992
+ capability_set.add("javascript_programming")
1993
+ if file_name.endswith(".visual") or "visual" in app_name or "<xml" in content:
1994
+ capability_set.add("blockly_programming")
1995
+ if "tcp" in " ".join([file_name, app_name, content]) or "udp" in " ".join([file_name, app_name, content]):
1996
+ capability_set.add("tcp_udp_app")
1997
+ if "realhttp" in content or "real http" in app_name:
1998
+ capability_set.add("real_http")
1999
+ if "realws" in content or "websocket" in content or "websocket" in app_name:
2000
+ capability_set.add("real_websocket")
2001
+ if "mqtt" in content or "mqtt" in app_name:
2002
+ capability_set.add("mqtt")
2003
+ if programming_ops and capability_set & {"python_programming", "javascript_programming", "blockly_programming"} and not capability_set & {"real_http", "real_websocket", "mqtt"}:
2004
+ network_style = network_style or "automation_controller"
2005
+ switch_op_names = {str(op.get("op")) for op in switch_ops}
2006
+ if "set_dhcp_snooping" in switch_op_names:
2007
+ capability_set.add("dhcp_snooping")
2008
+ if "set_dai" in switch_op_names:
2009
+ capability_set.add("dai")
2010
+ if "set_lldp" in switch_op_names:
2011
+ capability_set.add("lldp")
2012
+ if "set_rep" in switch_op_names:
2013
+ capability_set.add("rep")
2014
+ if "set_span" in switch_op_names:
2015
+ capability_set.add("span")
2016
+ if "set_port_security" in switch_op_names:
2017
+ capability_set.add("port_security")
2018
+ if "set_dot1x" in switch_op_names:
2019
+ capability_set.add("dot1x")
2020
+ if "set_qos_policy" in switch_op_names:
2021
+ capability_set.add("qos")
2022
+ if "set_stp" in switch_op_names:
2023
+ capability_set.add("stp")
2024
+ for op in switch_ops:
2025
+ if op.get("op") == "set_stp" and re.search(r"(?:rapid|rstp)", str(op.get("mode") or ""), flags=re.IGNORECASE):
2026
+ capability_set.add("rstp")
2027
+ if "set_etherchannel" in switch_op_names:
2028
+ capability_set.add("etherchannel")
2029
+ for op in switch_ops:
2030
+ if op.get("op") != "set_etherchannel":
2031
+ continue
2032
+ mode = str(op.get("mode") or "").lower()
2033
+ if mode in {"active", "passive"}:
2034
+ capability_set.add("lacp")
2035
+ if mode in {"desirable", "auto"}:
2036
+ capability_set.add("pagp")
2037
+ if "set_vtp" in switch_op_names:
2038
+ capability_set.add("vtp")
2039
+ if "set_dtp" in switch_op_names:
2040
+ capability_set.add("dtp")
2041
+ router_op_names = {str(op.get("op")) for op in router_ops}
2042
+ if "set_bgp_neighbor" in router_op_names:
2043
+ capability_set.add("bgp")
2044
+ if "set_snmp_community" in router_op_names:
2045
+ capability_set.add("snmp")
2046
+ if "set_netflow" in router_op_names:
2047
+ capability_set.add("netflow")
2048
+ if "set_gre_tunnel" in router_op_names:
2049
+ capability_set.add("gre")
2050
+ if "set_ppp_interface" in router_op_names:
2051
+ capability_set.add("ppp")
2052
+ if router_op_names & {"set_ipsec_transform_set", "set_crypto_map"}:
2053
+ capability_set.update({"ipsec", "vpn"})
2054
+ if "set_cbac_inspect" in router_op_names:
2055
+ capability_set.update({"cbac", "security_edge"})
2056
+ if router_op_names & {"set_zfw_zone_interface", "set_zfw_zone_pair", "set_zfw_policy"}:
2057
+ capability_set.update({"zfw", "security_edge"})
2058
+ if "set_qos_policy" not in switch_op_names:
2059
+ capability_set.discard("qos")
2060
+ capability_set.discard("server_http")
2061
+ capability_set.discard("server_https")
2062
+ if router_op_names & {"enable_ipv6_unicast_routing", "set_ipv6_address", "set_ipv6_slaac"}:
2063
+ capability_set.add("ipv6_slaac")
2064
+ if "set_dhcpv6_pool" in router_op_names:
2065
+ capability_set.add("dhcpv6_stateful")
2066
+ if "set_ospfv3_interface" in router_op_names:
2067
+ capability_set.add("ospfv3")
2068
+ if "set_eigrp_ipv6_interface" in router_op_names:
2069
+ capability_set.add("eigrp_ipv6")
2070
+ if "set_ripng_interface" in router_op_names:
2071
+ capability_set.add("ripng")
2072
+ if "set_hsrp_ipv6" in router_op_names:
2073
+ capability_set.add("hsrp")
2074
+ if "set_ospfv2_network" in router_op_names:
2075
+ capability_set.add("ospfv2")
2076
+ if "set_eigrp_ipv4_network" in router_op_names:
2077
+ capability_set.add("eigrp_ipv4")
2078
+ if "set_ripv2_network" in router_op_names:
2079
+ capability_set.add("ripv2")
2080
+ if "set_static_route" in router_op_names:
2081
+ capability_set.add("static_route")
2082
+ if any(op.get("op") == "set_static_route" and op.get("network") == "0.0.0.0" and op.get("prefix") == 0 for op in router_ops):
2083
+ capability_set.add("default_route")
2084
+ if "set_dhcp_relay" in router_op_names:
2085
+ capability_set.add("dhcp_relay")
2086
+ if "set_nat_interface" in router_op_names:
2087
+ capability_set.add("nat")
2088
+ if "set_nat_static" in router_op_names:
2089
+ capability_set.update({"nat", "nat_static"})
2090
+ if "set_pat_overload" in router_op_names:
2091
+ capability_set.update({"nat", "pat"})
2092
+ if "set_ssh_ios" in router_op_names:
2093
+ capability_set.update({"ssh", "ssh_ios"})
2094
+ if "set_ntp_server" in router_op_names:
2095
+ capability_set.update({"ntp", "ntp_ios"})
2096
+ if "set_syslog_server" in router_op_names:
2097
+ capability_set.update({"syslog_ios"})
2098
+ if router_op_names & {"set_telephony_service", "set_ephone_dn", "set_ephone", "set_dial_peer_voice"}:
2099
+ capability_set.update({"voip", "call_manager"})
2100
+ if router_op_names & {"set_ephone_dn", "set_ephone"}:
2101
+ capability_set.add("ip_phone")
2102
+ if capability_set & {"vpn", "ipsec", "gre", "ppp", "security_edge"}:
2103
+ network_style = network_style or "wan_security"
2104
+ if capability_set & {"ipv6_slaac", "dhcpv6_stateful", "dhcpv6_stateless", "ospfv3", "eigrp_ipv6", "ripng", "hsrp"}:
2105
+ network_style = network_style or "ipv6_routing"
2106
+ if capability_set & {"ospfv2", "eigrp_ipv4", "ripv2", "static_route", "default_route", "dhcp_relay", "nat_static", "nat_dynamic", "pat", "ssh_ios", "ntp_ios", "syslog_ios"}:
2107
+ network_style = network_style or "ipv4_routing_management"
2108
+ if capability_set & {"dhcp_snooping", "dai", "dot1x", "lldp", "rep", "snmp", "netflow", "span", "qos", "port_security"}:
2109
+ network_style = network_style or "l2_security_monitoring"
2110
+ if capability_set & {"bgp", "stp", "rstp", "etherchannel", "lacp", "pagp", "vtp", "dtp"}:
2111
+ network_style = "l2_resiliency_routing"
2112
+ if capability_set & {"wlc", "wpa_enterprise", "wep", "guest_wifi", "beamforming", "meraki", "cellular_5g", "bluetooth"}:
2113
+ network_style = "wireless_advanced"
2114
+ if capability_set & {"coaxial", "cable_dsl", "central_office", "cell_tower", "power_distribution", "hot_swappable", "ios_license"}:
2115
+ network_style = "physical_media_device"
2116
+ if capability_set & {"mqtt", "real_http", "real_websocket", "visual_scripting", "ptp", "profinet", "l2nat", "cyberobserver", "industrial_firewall"}:
2117
+ network_style = "industrial_iot"
2118
+ if capability_set & {"real_http", "real_websocket"}:
2119
+ capability_set.discard("server_http")
2120
+ capability_set.discard("server_https")
2121
+ if not (
2122
+ iot_ops
2123
+ or re.search(r"\b(?:home\s+gateway|smart\s+home|iot\s+registration|iot\s+control|iot\s+rule)\b", lowered)
2124
+ or re.search(r"\bregister\s+.+\s+to\b", lowered)
2125
+ ):
2126
+ capability_set.discard("iot")
2127
+ if capability_set & {"voip", "ip_phone", "call_manager", "linksys_voice"}:
2128
+ network_style = "voice_collaboration"
2129
+
2130
+ capabilities = sorted(capability_set)
2131
+
2132
+ parse_warnings: list[str] = []
2133
+ blocking_gaps: list[str] = []
2134
+ assumptions_used: list[str] = []
2135
+ if any(word in normalized_prompt for word in TOPOLOGY_HINT_WORDS) and not device_requirements and not devices:
2136
+ parse_warnings.append("Prompt includes topology words but no stable device counts were parsed.")
2137
+ if any(op["op"] == "set_management_vlan" for op in management_ops):
2138
+ topology_requirements["management_vlan"] = True
2139
+
2140
+ pc_count = device_requirements.get("PC", 0)
2141
+ if department_groups and vlan_ids and len(vlan_ids) >= len(department_groups) and not host_vlan_assignment:
2142
+ for index, group in enumerate(department_groups):
2143
+ vlan_id = vlan_ids[index]
2144
+ host_vlan_assignment[vlan_id] = host_vlan_assignment.get(vlan_id, 0) + int(group["devices"].get("PC", 0))
2145
+ assumptions_used.append("Assigned each department's PCs to the matching VLAN order.")
2146
+ if vlan_ids and pc_count and not host_vlan_assignment and not any(op["op"] == "set_access_port" for op in switch_ops):
2147
+ # "3 switch, 6 PC, VLAN 10/20/30" has an obvious reading: two hosts per
2148
+ # VLAN. Refusing was inconsistent with the branch directly above, which
2149
+ # already assigns department PCs to VLANs by order, and with the planner
2150
+ # defaulting port speeds, cable types, addressing and the VLAN IDs
2151
+ # themselves. Set PACKET_TRACER_STRICT_VLAN_ASSIGNMENT=1 to refuse instead.
2152
+ if strict_vlan_assignment():
2153
+ blocking_gaps.append("Host-to-VLAN assignment is missing. Specify how many PCs belong to each VLAN.")
2154
+ else:
2155
+ host_vlan_assignment.update(distribute_hosts_across_vlans(pc_count, vlan_ids))
2156
+ spread = ", ".join(f"VLAN {vlan_id}: {count}" for vlan_id, count in host_vlan_assignment.items())
2157
+ assumptions_used.append(
2158
+ f"Distributed {pc_count} PCs evenly across {len(vlan_ids)} VLANs ({spread}). "
2159
+ "Say how many PCs belong to each VLAN to override."
2160
+ )
2161
+ if vlan_ids and not device_requirements.get("Switch", 0) and not any(device.get("type") == "Switch" for device in devices):
2162
+ blocking_gaps.append("VLAN planning requires at least one switch.")
2163
+ if department_groups and vlan_ids and len(vlan_ids) < len(department_groups):
2164
+ blocking_gaps.append("Department count is larger than provided VLAN IDs.")
2165
+
2166
+
2167
+ if department_groups and "Switch" not in device_counts:
2168
+ assumptions_used.append("Added one switch per department group.")
2169
+ if inferred_switch:
2170
+ assumptions_used.append(
2171
+ "Added one switch: the prompt named hosts but nothing for them to connect to."
2172
+ )
2173
+ if department_groups and not network_style:
2174
+ network_style = "campus"
2175
+ assumptions_used.append("Interpreted department-based prompt as campus style.")
2176
+ if network_style == "wan_security":
2177
+ assumptions_used.append("Interpreted VPN/WAN/security wording as WAN-security edge style.")
2178
+
2179
+ goal = "edit" if pkt_path or any(word in normalized_prompt for word in ["deyis", "edit", "modify", "change", "rename", "update"]) else "generate"
2180
+
2181
+ # A prompt that carries no topology signal at all is not an underspecified
2182
+ # lab request, it is a different kind of message. "sebeke haqqinda melumat
2183
+ # ver" used to produce a two-device file, which is worse than a refusal:
2184
+ # the user gets a lab they never asked for and no indication anything
2185
+ # was misread. Every genuine request carries at least one signal.
2186
+ if goal == "generate" and not any(
2187
+ [
2188
+ device_counts,
2189
+ department_groups,
2190
+ vlan_ids,
2191
+ network_style,
2192
+ capabilities,
2193
+ switch_ops,
2194
+ router_ops,
2195
+ server_ops,
2196
+ wireless_ops,
2197
+ management_ops,
2198
+ end_device_ops,
2199
+ ]
2200
+ ):
2201
+ blocking_gaps.append(
2202
+ "This prompt does not describe a topology. Say which devices you want, "
2203
+ "for example '1 router 1 switch 3 komputer qur'."
2204
+ )
2205
+ confidence_score = _estimate_confidence(device_requirements, capabilities, parse_warnings, blocking_gaps, devices, links)
2206
+ return IntentPlan(
2207
+ goal=goal,
2208
+ prompt=prompt,
2209
+ pkt_path=pkt_path,
2210
+ capabilities=capabilities,
2211
+ network_style=network_style,
2212
+ wireless_mode=wireless_mode,
2213
+ requested_models=_extract_requested_models(normalized_prompt),
2214
+ wireless_settings=_restore_wireless_casing(
2215
+ _extract_wireless_settings(normalized_prompt), prompt
2216
+ ),
2217
+ device_requirements=device_requirements,
2218
+ device_counts=device_counts,
2219
+ department_groups=department_groups,
2220
+ service_requirements=service_requirements,
2221
+ topology_requirements=topology_requirements,
2222
+ vlan_ids=vlan_ids,
2223
+ uplink_intent=uplink_intent,
2224
+ host_link_intent=host_link_intent,
2225
+ host_vlan_assignment=host_vlan_assignment,
2226
+ assumptions_used=assumptions_used,
2227
+ confidence_score=confidence_score,
2228
+ parse_warnings=parse_warnings,
2229
+ blocking_gaps=blocking_gaps,
2230
+ edit_operations=edit_operations,
2231
+ devices=devices,
2232
+ links=links,
2233
+ switch_ops=switch_ops,
2234
+ router_ops=router_ops,
2235
+ server_ops=server_ops,
2236
+ wireless_ops=wireless_ops,
2237
+ end_device_ops=end_device_ops,
2238
+ management_ops=management_ops,
2239
+ verification_ops=verification_ops,
2240
+ iot_ops=iot_ops,
2241
+ programming_ops=programming_ops,
2242
+ )