dsh-ensp 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +21 -1
- package/README.md +21 -1
- package/assets/icon.webp +0 -0
- package/docs/SCENARIOS.md +184 -0
- package/docs/plugin-detail.en.md +1 -2
- package/docs/plugin-detail.zh.md +1 -2
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +15 -5
- package/scripts/gen-curriculum.mjs +181 -0
- package/scripts/gen-knowledge.mjs +59 -20
- package/scripts/gen-scenarios-doc.mjs +11 -0
- package/src/cli/ensp.mjs +36 -2
- package/src/core/curriculum.json +1163 -0
- package/src/core/curriculum.mjs +70 -0
- package/src/core/index.mjs +8 -0
- package/src/core/knowledge.json +676 -128
- package/src/dsh/index.mjs +30 -6
- package/src/mcp/server.mjs +15 -4
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the curriculum index from extracted vendor lab manuals.
|
|
3
|
+
*
|
|
4
|
+
* The manuals are hundreds of pages each and cannot ship: they are vendor
|
|
5
|
+
* documentation, they are far larger than this package, and most of a page is
|
|
6
|
+
* prose that a tool has no use for. What *is* useful is the skeleton of each
|
|
7
|
+
* chapter: what it claims to teach, which `display` commands it uses to prove the
|
|
8
|
+
* claim, and which built-in scenario covers the same ground. That skeleton is
|
|
9
|
+
* small, factual, and stable across manual revisions.
|
|
10
|
+
*
|
|
11
|
+
* Input is the page-by-page JSON produced from the PDFs by the extractor in
|
|
12
|
+
* `_huawei/pages.py`; output is `src/core/curriculum.json`. The extractor is not
|
|
13
|
+
* part of the package because it needs the PDFs, which are not redistributable.
|
|
14
|
+
*
|
|
15
|
+
* node scripts/gen-curriculum.mjs <guides.json> [more.json ...]
|
|
16
|
+
*
|
|
17
|
+
* @module scripts/gen-curriculum
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
21
|
+
import { fileURLToPath } from 'node:url'
|
|
22
|
+
import path from 'node:path'
|
|
23
|
+
|
|
24
|
+
/** Manuals whose chapters duplicate another manual; keep the first of each pair. */
|
|
25
|
+
const SKIP_MANUAL = /YunShan_OS|eNSP_Pro/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Which built-in scenarios a manual chapter teaches.
|
|
29
|
+
*
|
|
30
|
+
* This mapping is the actual editorial work. It is keyed by
|
|
31
|
+
* `<level>:<chapter title>` so it survives a chapter being renumbered in a later
|
|
32
|
+
* manual revision, and so a missing chapter is a visible gap rather than a silent
|
|
33
|
+
* mis-filing.
|
|
34
|
+
*/
|
|
35
|
+
const CHAPTER_SCENARIOS = {
|
|
36
|
+
'HCIA:华为网络设备基本操作': ['vlan-access', 'trunk-interconnect'],
|
|
37
|
+
'HCIA:VLAN 配置实验': ['vlan-access', 'trunk-interconnect', 'vlanif-routing', 'super-vlan'],
|
|
38
|
+
'HCIA:生成树配置实验': ['stp-rstp', 'mstp-load-balance'],
|
|
39
|
+
'HCIA:以太网链路聚合实验': ['link-aggregation'],
|
|
40
|
+
'HCIA:直连路由实验': ['vlanif-routing'],
|
|
41
|
+
'HCIA:静态路由实验': ['static-route'],
|
|
42
|
+
'HCIA:IP 地址自动配置实验': ['dhcp', 'dhcp-snooping'],
|
|
43
|
+
'HCIA:OSPF 配置实验': ['ospf-single'],
|
|
44
|
+
'HCIA:IPv6 地址配置实验': ['ipv6', 'ospfv3-ipv6'],
|
|
45
|
+
'HCIA:ACL 配置实验': ['acl-filter', 'nat'],
|
|
46
|
+
'HCIA:文件管理实验': ['aaa-local', 'network-management'],
|
|
47
|
+
'HCIA:远程登录实验': ['aaa-local', 'network-management'],
|
|
48
|
+
'HCIA:NAT 配置实验': ['nat', 'acl-filter'],
|
|
49
|
+
'HCIA:WAC+FIT AP 组网实验': ['wlan-basic', 'wlan-roaming'],
|
|
50
|
+
'HCIA:网络编程自动化实验': ['network-management'],
|
|
51
|
+
'HCIA:网络故障排除实验': ['ospf-troubleshooting', 'bgp-troubleshooting'],
|
|
52
|
+
'HCIA:园区网络综合实验': ['campus-network', 'vlanif-routing', 'static-route'],
|
|
53
|
+
|
|
54
|
+
'HCIP:OSPF 基础实验': [
|
|
55
|
+
'ospf-single',
|
|
56
|
+
'ospf-multi-area',
|
|
57
|
+
'ospf-lsa-types',
|
|
58
|
+
'ospf-stub-nssa',
|
|
59
|
+
'ospf-virtual-link',
|
|
60
|
+
],
|
|
61
|
+
'HCIP:IS-IS 基础实验': ['isis-multi-level'],
|
|
62
|
+
'HCIP:BGP 实验': ['bgp-ebgp-ibgp', 'bgp-route-reflector', 'bgp-aggregation'],
|
|
63
|
+
'HCIP:路由策略与路由控制实验': ['route-policy-pbr', 'bgp-attributes'],
|
|
64
|
+
'HCIP:RSTP 与MSTP 实验': ['stp-rstp', 'mstp-load-balance', 'vrrp-basics'],
|
|
65
|
+
'HCIP:组播实验': ['multicast-pim-sm'],
|
|
66
|
+
'HCIP:防火墙技术实验': ['firewall-zones', 'nat'],
|
|
67
|
+
'HCIP:VRRP 实验': ['vrrp-basics'],
|
|
68
|
+
'HCIP:DHCP 实验': ['dhcp', 'dhcp-snooping'],
|
|
69
|
+
'HCIP:WLAN 实验': ['wlan-basic', 'wlan-roaming'],
|
|
70
|
+
|
|
71
|
+
'HCIP:IGP 高级特性实验': ['ospf-troubleshooting', 'bfd-fast-convergence', 'isis-multi-level'],
|
|
72
|
+
'HCIP:BGP 高级特性实验': ['bgp-troubleshooting', 'bgp-attributes', 'bgp-aggregation'],
|
|
73
|
+
'HCIP:IPv6 实验': ['ipv6', 'ospfv3-ipv6'],
|
|
74
|
+
'HCIP:VLAN 高级技术实验': ['super-vlan', 'port-isolation'],
|
|
75
|
+
'HCIP:以太网交换安全实验': ['port-security', 'arp-security', 'dhcp-snooping'],
|
|
76
|
+
'HCIP:BGP/MPLS IP VPN 实验': ['mpls-basic', 'mpls-l3vpn'],
|
|
77
|
+
'HCIP:网络准入控制实验': ['port-security', 'aaa-local', 'dhcp-snooping'],
|
|
78
|
+
'HCIP:VXLAN 与EVPN 实验': ['spine-leaf-vxlan', 'mpls-basic'],
|
|
79
|
+
|
|
80
|
+
'HCIE:IPv4 路由技术': ['ospf-troubleshooting', 'isis-multi-level', 'bgp-troubleshooting', 'bfd-fast-convergence'],
|
|
81
|
+
'HCIE:MPLS VPN': ['mpls-basic', 'mpls-l3vpn', 'ipsec-gre-tunnel'],
|
|
82
|
+
'HCIE:EVPN': ['mpls-basic', 'spine-leaf-vxlan'],
|
|
83
|
+
'HCIE:IPv6 路由技术': ['ospfv3-ipv6', 'ipv6'],
|
|
84
|
+
'HCIE:VXLAN 实验': ['spine-leaf-vxlan'],
|
|
85
|
+
'HCIE:VXLAN 虚拟化园区部署': ['spine-leaf-vxlan', 'wlan-roaming'],
|
|
86
|
+
'HCIE:广域互联网络部署': ['dual-homed-isp', 'ipsec-gre-tunnel'],
|
|
87
|
+
'HCIE:SR-MPLS 实验': ['mpls-basic', 'mpls-l3vpn'],
|
|
88
|
+
'HCIE:SRv6 实验': ['ipv6', 'ospfv3-ipv6', 'mpls-l3vpn'],
|
|
89
|
+
'HCIE:网络开放可编程': ['network-management'],
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Drop sentences that are exam questions or half-cut lines from the extraction. */
|
|
93
|
+
const NOISE = /^(分析|思考|讨论|如果|若|假设|为什么|如何)|?$|,$|、$/
|
|
94
|
+
|
|
95
|
+
const MAX_GOALS = 6
|
|
96
|
+
const MAX_VERIFY = 10
|
|
97
|
+
|
|
98
|
+
/** Chapter titles carry suffixes such as (可选)that vary between manual editions. */
|
|
99
|
+
function titleKey(title) {
|
|
100
|
+
return tidy(title).replace(/[((][^))]{1,8}[))]$/,'').trim()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function tidy(text) {
|
|
104
|
+
return String(text ?? '')
|
|
105
|
+
.replace(/\s+/g, ' ')
|
|
106
|
+
.replace(/(/g, '(')
|
|
107
|
+
.replace(/)/g, ')')
|
|
108
|
+
.trim()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** `display ip routing-tableprotocol ospf` comes back fused; split it. */
|
|
112
|
+
function fixCommand(cmd) {
|
|
113
|
+
return tidy(cmd).replace(/(routing-table|interface|brief|peer)(protocol|interface|verbose)/gi, '$1 $2')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function pick(list, max, filter = () => true) {
|
|
117
|
+
const out = []
|
|
118
|
+
for (const raw of list) {
|
|
119
|
+
const value = tidy(raw)
|
|
120
|
+
if (!value || value.length < 4 || NOISE.test(value)) continue
|
|
121
|
+
if (!filter(value)) continue
|
|
122
|
+
if (out.includes(value)) continue
|
|
123
|
+
out.push(value)
|
|
124
|
+
if (out.length >= max) break
|
|
125
|
+
}
|
|
126
|
+
return out
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function buildCurriculum(manuals) {
|
|
130
|
+
const entries = []
|
|
131
|
+
const seen = new Set()
|
|
132
|
+
for (const chapter of manuals) {
|
|
133
|
+
if (SKIP_MANUAL.test(chapter.manual)) continue
|
|
134
|
+
const title = tidy(chapter.title)
|
|
135
|
+
const key = `${chapter.level}:${titleKey(title)}`
|
|
136
|
+
if (seen.has(key)) continue
|
|
137
|
+
seen.add(key)
|
|
138
|
+
entries.push({
|
|
139
|
+
level: chapter.level,
|
|
140
|
+
title,
|
|
141
|
+
page: chapter.page,
|
|
142
|
+
scenarios: CHAPTER_SCENARIOS[key] || [],
|
|
143
|
+
goals: pick(chapter.goals || [], MAX_GOALS, (g) => /^(实现|描述|阐明|掌握|理解|了解|配置)/.test(g)),
|
|
144
|
+
verify: pick((chapter.verify || []).map(fixCommand), MAX_VERIFY, (c) => /^(display|ping|tracert)\s/.test(c)),
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
entries.sort((a, b) => {
|
|
148
|
+
const order = { HCIA: 0, HCIP: 1, HCIE: 2 }
|
|
149
|
+
return (order[a.level] ?? 9) - (order[b.level] ?? 9) || a.page - b.page
|
|
150
|
+
})
|
|
151
|
+
const byLevel = { HCIA: [], HCIP: [], HCIE: [] }
|
|
152
|
+
for (const entry of entries) (byLevel[entry.level] ||= []).push(entry)
|
|
153
|
+
return { version: 1, levels: byLevel }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const invokedDirectly =
|
|
157
|
+
process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
158
|
+
|
|
159
|
+
if (invokedDirectly) {
|
|
160
|
+
const sources = process.argv.slice(2).filter((a) => !a.startsWith('--'))
|
|
161
|
+
if (!sources.length) {
|
|
162
|
+
console.error('usage: node scripts/gen-curriculum.mjs <guides.json> [more.json ...]')
|
|
163
|
+
process.exit(2)
|
|
164
|
+
}
|
|
165
|
+
const chapters = []
|
|
166
|
+
for (const file of sources) {
|
|
167
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
168
|
+
chapters.push(...(Array.isArray(parsed) ? parsed : parsed.chapters || []))
|
|
169
|
+
}
|
|
170
|
+
const index = buildCurriculum(chapters)
|
|
171
|
+
const target = new URL('../src/core/curriculum.json', import.meta.url)
|
|
172
|
+
writeFileSync(target, JSON.stringify(index, null, 1) + '\n')
|
|
173
|
+
const counts = Object.entries(index.levels).map(([k, v]) => `${k} ${v.length}`)
|
|
174
|
+
const mapped = Object.values(index.levels)
|
|
175
|
+
.flat()
|
|
176
|
+
.filter((e) => e.scenarios.length).length
|
|
177
|
+
const bytes = JSON.stringify(index).length
|
|
178
|
+
console.log(
|
|
179
|
+
`wrote src/core/curriculum.json (${counts.join(', ')}; ${mapped} chapters mapped to scenarios; ${bytes} bytes)`,
|
|
180
|
+
)
|
|
181
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Regenerate `src/core/knowledge.json`.
|
|
3
3
|
*
|
|
4
4
|
* Usage:
|
|
5
|
-
* node scripts/gen-knowledge.mjs <source.json>
|
|
5
|
+
* node scripts/gen-knowledge.mjs <source.json> [more.json ...]
|
|
6
6
|
*
|
|
7
7
|
* The source is any array of `{ n, type, stem, options: [{key, text}], answer,
|
|
8
8
|
* analysis, topics }`. It is not part of this package; what ships is the index
|
|
@@ -44,8 +44,9 @@ const TOPIC_SCENARIOS = {
|
|
|
44
44
|
mpls: ['mpls-basic', 'mpls-l3vpn'],
|
|
45
45
|
ipv6: ['ipv6', 'ospfv3-ipv6'],
|
|
46
46
|
qos: ['qos-basics'],
|
|
47
|
+
management: ['network-management'],
|
|
47
48
|
reliability: ['vrrp-basics', 'bfd-fast-convergence', 'firewall-hrp', 'dual-homed-isp'],
|
|
48
|
-
security: ['acl-filter', 'aaa-local', 'ipsec-vpn', 'firewall-zones', 'firewall-hrp', 'arp-security', 'dhcp-snooping'],
|
|
49
|
+
security: ['acl-filter', 'aaa-local', 'ipsec-vpn', 'firewall-zones', 'firewall-hrp', 'arp-security', 'dhcp-snooping', 'port-security'],
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
/** Keywords that decide which topic a question belongs to. */
|
|
@@ -58,6 +59,7 @@ const TOPIC_TERMS = {
|
|
|
58
59
|
mpls: ['mpls', 'ldp', 'lsr', '标签', 'vpn-instance'],
|
|
59
60
|
ipv6: ['ipv6', 'ospfv3', 'slaac'],
|
|
60
61
|
qos: ['qos', '队列', '拥塞', '流量监管', '优先级映射'],
|
|
62
|
+
management: ['网络管理', '网管', 'snmp', 'ntp', '日志', 'info-center', 'netconf', 'telemetry'],
|
|
61
63
|
reliability: ['vrrp', 'bfd', 'hrp', '双机热备', '主备'],
|
|
62
64
|
security: ['acl', '安全区域', 'security-policy', 'ipsec', 'ike', 'aaa', 'radius', '防火墙', 'nat'],
|
|
63
65
|
}
|
|
@@ -65,8 +67,15 @@ const TOPIC_TERMS = {
|
|
|
65
67
|
/** A question phrased this way keeps only a false statement once stripped. */
|
|
66
68
|
const NEGATIVE = /错误的是|不正确的是|不属于|不支持|不能|无需/
|
|
67
69
|
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Points kept per topic.
|
|
72
|
+
*
|
|
73
|
+
* The brief renders at most ten of them, so this cap is about how much depth a
|
|
74
|
+
* topic carries into the index, not about screen space. It has to be generous:
|
|
75
|
+
* every source that feeds the index contributes its own best rows, and a tight
|
|
76
|
+
* cap lets a later source push out an earlier one entirely.
|
|
77
|
+
*/
|
|
78
|
+
const MAX_POINTS = 24
|
|
70
79
|
|
|
71
80
|
/** A question must score at least this many keyword hits to be filed at all. */
|
|
72
81
|
const MIN_SCORE = 2
|
|
@@ -78,24 +87,39 @@ const clip = (s, n) => (s.length <= n ? s : s.slice(0, n - 1).replace(/[,,、
|
|
|
78
87
|
* The answer as readable text.
|
|
79
88
|
*
|
|
80
89
|
* A bare key list (`A,C`) teaches nothing, so every chosen option is spelled out.
|
|
81
|
-
*
|
|
82
|
-
*
|
|
90
|
+
* When the option text is unusable the explanation carries the fact instead: it
|
|
91
|
+
* is the part a reader can actually learn from, and dropping the question because
|
|
92
|
+
* its options came back blank throws away the answer along with them. Returns ''
|
|
93
|
+
* only when neither source says anything.
|
|
83
94
|
*/
|
|
84
95
|
function answerText(q) {
|
|
85
96
|
const keys = clean(q.answer).toUpperCase().match(/[A-Z]/g)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
97
|
+
const picked = (q.options || []).filter(
|
|
98
|
+
(o) => keys?.includes(String(o.key).toUpperCase()) && clean(o.text).replace(/^[.、\s]*$/, ''),
|
|
99
|
+
)
|
|
100
|
+
if (picked.length) {
|
|
101
|
+
return picked.map((o) => `${o.key}. ${clip(clean(o.text), 70)}`).join(' / ')
|
|
102
|
+
}
|
|
103
|
+
const explained = clean(q.analysis)
|
|
104
|
+
if (explained.length >= 12) return clip(explained, 130)
|
|
105
|
+
if (q.type === '判断题') return clean(q.answer) || ''
|
|
106
|
+
return ''
|
|
90
107
|
}
|
|
91
108
|
|
|
92
109
|
/**
|
|
93
110
|
* Build the index.
|
|
94
111
|
*
|
|
95
|
-
*
|
|
112
|
+
* Two sources feed one topic's points: the keyword pass over the raw questions,
|
|
113
|
+
* and `carry` rows that are already in the shipped shape (`{topic, n, q, a}`).
|
|
114
|
+
* The carry path exists because the index is built from more than one source
|
|
115
|
+
* over time, and rebuilding from a single source would silently drop whatever
|
|
116
|
+
* the other one contributed.
|
|
117
|
+
*
|
|
118
|
+
* @param {Array<object>} source raw questions
|
|
119
|
+
* @param {Array<{topic: string, n: number, q: string, a: string}>} [carry] points to keep as-is
|
|
96
120
|
* @returns {object}
|
|
97
121
|
*/
|
|
98
|
-
export function buildIndex(source) {
|
|
122
|
+
export function buildIndex(source, carry = []) {
|
|
99
123
|
const perTopic = {}
|
|
100
124
|
for (const q of source) {
|
|
101
125
|
const answer = answerText(q)
|
|
@@ -117,18 +141,30 @@ export function buildIndex(source) {
|
|
|
117
141
|
}
|
|
118
142
|
|
|
119
143
|
const topics = {}
|
|
120
|
-
for (const
|
|
144
|
+
for (const topic of new Set([...Object.keys(perTopic), ...carry.map((c) => c.topic)])) {
|
|
145
|
+
const rows = perTopic[topic] || []
|
|
121
146
|
// Highest keyword score first, then the shortest stem: a one-line question
|
|
122
147
|
// states a fact, while a long one describes a scene.
|
|
123
148
|
const ranked = rows.slice().sort((a, b) => b.score - a.score || a.q.length - b.q.length)
|
|
124
149
|
const seen = new Set()
|
|
125
150
|
const points = []
|
|
151
|
+
// Carried points go first. They were already reviewed when they shipped, so a
|
|
152
|
+
// later source adds to them rather than displacing them; the alternative
|
|
153
|
+
// silently traded an established fact for a new one that scored higher.
|
|
154
|
+
for (const c of carry) {
|
|
155
|
+
if (c.topic !== topic) continue
|
|
156
|
+
if (points.length >= MAX_POINTS) break
|
|
157
|
+
const key = c.q.slice(0, 40) + '|' + c.a.slice(0, 30)
|
|
158
|
+
if (seen.has(key)) continue
|
|
159
|
+
seen.add(key)
|
|
160
|
+
points.push({ n: c.n, q: c.q, a: c.a })
|
|
161
|
+
}
|
|
126
162
|
for (const r of ranked) {
|
|
163
|
+
if (points.length >= MAX_POINTS) break
|
|
127
164
|
const key = r.q.slice(0, 40) + '|' + r.a.slice(0, 30)
|
|
128
165
|
if (seen.has(key)) continue
|
|
129
166
|
seen.add(key)
|
|
130
167
|
points.push({ n: r.n, q: r.q, a: r.a })
|
|
131
|
-
if (points.length >= MAX_POINTS) break
|
|
132
168
|
}
|
|
133
169
|
topics[topic] = { scenarios: TOPIC_SCENARIOS[topic], points }
|
|
134
170
|
}
|
|
@@ -143,15 +179,18 @@ const invokedDirectly =
|
|
|
143
179
|
process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
144
180
|
|
|
145
181
|
if (invokedDirectly) {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
182
|
+
const args = process.argv.slice(2)
|
|
183
|
+
const carryPath = args.find((a) => a.startsWith('--carry='))?.slice('--carry='.length)
|
|
184
|
+
const sourcePaths = args.filter((a) => !a.startsWith('--'))
|
|
185
|
+
if (!sourcePaths.length) {
|
|
186
|
+
console.error('usage: node scripts/gen-knowledge.mjs [--carry=index.json] <source.json> [more.json ...]')
|
|
149
187
|
process.exit(2)
|
|
150
188
|
}
|
|
151
|
-
const source = JSON.parse(readFileSync(
|
|
152
|
-
const
|
|
189
|
+
const source = sourcePaths.flatMap((p) => JSON.parse(readFileSync(p, 'utf8')))
|
|
190
|
+
const carry = carryPath ? JSON.parse(readFileSync(carryPath, 'utf8')) : []
|
|
191
|
+
const index = buildIndex(source, carry)
|
|
153
192
|
const target = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'core', 'knowledge.json')
|
|
154
193
|
writeFileSync(target, JSON.stringify(index, null, 1) + '\n', 'utf8')
|
|
155
194
|
const total = Object.values(index.topics).reduce((sum, t) => sum + t.points.length, 0)
|
|
156
|
-
console.log(`wrote src/core/knowledge.json (${total} points)`)
|
|
195
|
+
console.log(`wrote src/core/knowledge.json (${total} points from ${source.length} questions)`)
|
|
157
196
|
}
|
|
@@ -19,6 +19,7 @@ import { SCENARIOS, SCENARIO_IDS, byLevel } from '../src/core/presets.mjs'
|
|
|
19
19
|
import { LABS, LAB_IDS, hasLab, labAddressing, labDevices, labModels } from '../src/core/labs.mjs'
|
|
20
20
|
import { imageNotesFor } from '../src/core/compat.mjs'
|
|
21
21
|
import { hasChecks, EXPECTATIONS } from '../src/core/acceptance.mjs'
|
|
22
|
+
import { curriculumFor } from '../src/core/curriculum.mjs'
|
|
22
23
|
|
|
23
24
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
24
25
|
const root = path.resolve(here, '..')
|
|
@@ -137,6 +138,16 @@ function render(catalogue = SCENARIOS) {
|
|
|
137
138
|
: `- 验收:\`ensp verify ${id} --topology ${id}.topo\`(无专门断言,逐条跑本场景的验证命令)`
|
|
138
139
|
out.push(verifyLine)
|
|
139
140
|
}
|
|
141
|
+
|
|
142
|
+
// Which vendor manual chapter teaches this scenario, when one does.
|
|
143
|
+
const chapters = curriculumFor(id)
|
|
144
|
+
if (chapters.length) {
|
|
145
|
+
out.push('', '**教材章节**')
|
|
146
|
+
for (const chapter of chapters) {
|
|
147
|
+
const where = `${chapter.level} · ${chapter.title}(手册第 ${chapter.page} 页)`
|
|
148
|
+
out.push(`- ${where}`)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
140
151
|
out.push('')
|
|
141
152
|
}
|
|
142
153
|
}
|
package/src/cli/ensp.mjs
CHANGED
|
@@ -36,6 +36,7 @@ import { runAcceptance, formatAcceptance, hasChecks, planFor } from '../core/acc
|
|
|
36
36
|
import { pushConfig } from '../core/provision.mjs'
|
|
37
37
|
import { SCENARIOS, SCENARIO_IDS, byLevel, renderScenario, searchScenarios } from '../core/presets.mjs'
|
|
38
38
|
import { knowledgeFor, renderKnowledge, searchKnowledge } from '../core/knowledge.mjs'
|
|
39
|
+
import { curriculumFor, renderCurriculum, searchCurriculum } from '../core/curriculum.mjs'
|
|
39
40
|
import {
|
|
40
41
|
LAB_IDS,
|
|
41
42
|
buildScenarioTopology,
|
|
@@ -65,7 +66,7 @@ ${C.bold('USAGE')}
|
|
|
65
66
|
ensp verify <scenario-id> --topology <topology.topo> [--json] [--wait MS]
|
|
66
67
|
ensp probe [--json]
|
|
67
68
|
ensp stop [--topology <topology.topo>]
|
|
68
|
-
ensp preset [<id>] [--search <keyword>] [--topo <out.topo>] [--json]
|
|
69
|
+
ensp preset [<id>] [--search <keyword>] [--syllabus <keyword>] [--topo <out.topo>] [--json]
|
|
69
70
|
ensp mcp (start the MCP server on stdio)
|
|
70
71
|
|
|
71
72
|
${C.bold('OPTIONS')}
|
|
@@ -75,6 +76,8 @@ ${C.bold('OPTIONS')}
|
|
|
75
76
|
--device NAME apply only to one device
|
|
76
77
|
--timeout MS per-device readiness timeout (default 240000)
|
|
77
78
|
--wait MS verify: how long a failing assertion may keep being retried (default 60000)
|
|
79
|
+
--syllabus KW search the vendor manual chapter index (goals and verification commands)
|
|
80
|
+
--knowledge KW search the term index (short answers filed by topic)
|
|
78
81
|
--topo PATH write the scenario's topology blueprint to a .topo file
|
|
79
82
|
--topology PATH the .topo file that is running (doctor, verify)
|
|
80
83
|
--exe PATH full path to eNSP_Client.exe (default: auto-detect via registry and install roots)
|
|
@@ -92,6 +95,7 @@ ${C.bold('EXAMPLES')}
|
|
|
92
95
|
ensp up lab.topo --exe "C:/Program Files/Huawei/eNSP/eNSP_Client.exe"
|
|
93
96
|
ensp probe
|
|
94
97
|
ensp preset --search ospf
|
|
98
|
+
ensp preset --syllabus "MPLS"
|
|
95
99
|
ensp preset ospf-single
|
|
96
100
|
ensp preset campus-network --topo campus.topo
|
|
97
101
|
ensp mcp
|
|
@@ -321,6 +325,24 @@ async function cmdStatus({ flags }) {
|
|
|
321
325
|
async function cmdPreset({ positional, flags }) {
|
|
322
326
|
const id = positional[0]
|
|
323
327
|
|
|
328
|
+
if (flags.syllabus) {
|
|
329
|
+
const hits = searchCurriculum(String(flags.syllabus))
|
|
330
|
+
if (flags.json) {
|
|
331
|
+
console.log(JSON.stringify(hits, null, 2))
|
|
332
|
+
return hits.length ? 0 : 1
|
|
333
|
+
}
|
|
334
|
+
if (!hits.length) {
|
|
335
|
+
console.log(C.yellow('no manual chapter matched') + ` "${flags.syllabus}"`)
|
|
336
|
+
return 1
|
|
337
|
+
}
|
|
338
|
+
for (const h of hits) {
|
|
339
|
+
console.log(`${C.bold(h.level)} ${h.title} ${C.dim('p' + h.page)}`)
|
|
340
|
+
if (h.scenarios?.length) console.log(C.dim(` 场景: ${h.scenarios.join(', ')}`))
|
|
341
|
+
if (h.verify?.length) console.log(C.dim(` 验证: ${h.verify.slice(0, 3).join(' / ')}`))
|
|
342
|
+
}
|
|
343
|
+
return 0
|
|
344
|
+
}
|
|
345
|
+
|
|
324
346
|
if (flags.knowledge) {
|
|
325
347
|
const hits = searchKnowledge(String(flags.knowledge))
|
|
326
348
|
if (flags.json) {
|
|
@@ -411,7 +433,19 @@ async function cmdPreset({ positional, flags }) {
|
|
|
411
433
|
|
|
412
434
|
const text = renderScenario(id)
|
|
413
435
|
const points = knowledgeFor(id)
|
|
414
|
-
|
|
436
|
+
const chapters = curriculumFor(id).map((c) => ({
|
|
437
|
+
level: c.level,
|
|
438
|
+
title: c.title,
|
|
439
|
+
page: c.page,
|
|
440
|
+
verify: c.verify,
|
|
441
|
+
}))
|
|
442
|
+
console.log(
|
|
443
|
+
flags.json
|
|
444
|
+
? JSON.stringify({ ...SCENARIOS[id], knowledge: points, curriculum: chapters }, null, 2)
|
|
445
|
+
: text,
|
|
446
|
+
)
|
|
447
|
+
const taught = renderCurriculum(id)
|
|
448
|
+
if (taught) console.log(taught)
|
|
415
449
|
const knowledge = renderKnowledge(id)
|
|
416
450
|
if (knowledge) console.log(knowledge)
|
|
417
451
|
if (hasLab(id)) {
|