dsh-card-dispatch 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/import.js ADDED
@@ -0,0 +1,193 @@
1
+ /**
2
+ * import.js:从需求文档里抽出「标题 / 描述 / 判据」。
3
+ *
4
+ * ## 为什么放在服务端
5
+ *
6
+ * 抽取规则如果写在页面里,就没法单测,也没法解释「为什么这条判据没被认出来」。
7
+ * 这里做成纯函数:输入文件名 + 文本,输出三个字段 + 一串警告,页面只负责填。
8
+ *
9
+ * ## 规则(刻意保守,认不出来就说,不猜)
10
+ *
11
+ * 1. **标题**:第一个一级标题;没有就用文件名(去掉扩展名);再没有就用第一行短文本。
12
+ * 2. **判据**:先找「验收判据 / 验收标准 / 判据 / acceptance criteria」这类标题,
13
+ * 把它下面到下一个同级标题之间的列表项收起来;找不到标题就退一步,
14
+ * 全篇找 `- [ ]` 这种勾选框。
15
+ * 3. **描述**:全文去掉标题行和判据那一段,超过 6000 字截断并警告——
16
+ * 描述会原样进 prompt,几万字的文档会把它撑爆。
17
+ *
18
+ * 明确不支持:.docx / .pdf(浏览器解不了)。与其假装能读,不如让人先另存为文本。
19
+ */
20
+
21
+ /** 描述进 prompt 的上限:超过就截断(一条警告,不静默)。 */
22
+ export const MAX_DESCRIPTION = 6000
23
+
24
+ /** 判据标题的识别词:中文优先,带编号前缀也认(「## 4. 验收判据」)。 */
25
+ const CRITERIA_HEADING = /(验收判据|验收标准|验收条件|判据|acceptance\s+criteria|^ac$)/i
26
+
27
+ /** 列表项:`- x`、`* x`、`1. x`、`1) x`,允许带 `[ ]` / `[x]` 勾选框。 */
28
+ const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s*)?(.+?)\s*$/
29
+
30
+ /** 勾选框条目(判据的强信号,用于没有标题时的兜底)。 */
31
+ const CHECKBOX_ITEM = /^\s*[-*+]\s+\[[ xX]\]\s+(.+?)\s*$/
32
+
33
+ const SUPPORTED = /\.(md|markdown|txt|text|json)$/i
34
+
35
+ function headingOf(line) {
36
+ const match = /^(#{1,6})\s+(.+?)\s*$/.exec(line)
37
+ return match === null ? null : { level: match[1].length, text: match[2].trim() }
38
+ }
39
+
40
+ /** 去掉列表符号 / 勾选框 / 行内加粗,留下判据正文。 */
41
+ function cleanItem(text) {
42
+ return String(text ?? '')
43
+ .replace(/^\s*[-*+]\s+/, '')
44
+ .replace(/^\s*\[[ xX]\]\s*/, '')
45
+ .replace(/\*\*/g, '')
46
+ .replace(/`/g, '')
47
+ .trim()
48
+ }
49
+
50
+ function baseName(filename) {
51
+ const name = String(filename ?? '').split(/[\\/]/).pop() ?? ''
52
+ return name.replace(/\.[^.]+$/, '').trim()
53
+ }
54
+
55
+ function normalizeText(text) {
56
+ return String(text ?? '')
57
+ .replace(/^\uFEFF/, '')
58
+ .replace(/\r\n?/g, '\n')
59
+ }
60
+
61
+ /**
62
+ * 解析需求文档。
63
+ *
64
+ * @param {object} input
65
+ * @param {string} [input.filename] 文件名(用来判断格式、兜底标题)
66
+ * @param {string} [input.text] 文档正文
67
+ * @returns {{ok:boolean, format:string, title:string, description:string, criteria:Array<string>, warnings:Array<string>}}
68
+ */
69
+ export function parseRequirementDoc({ filename = '', text = '' } = {}) {
70
+ const warnings = []
71
+ const name = String(filename ?? '')
72
+
73
+ if (name !== '' && !SUPPORTED.test(name)) {
74
+ return {
75
+ ok: false,
76
+ format: 'unsupported',
77
+ title: '',
78
+ description: '',
79
+ criteria: [],
80
+ warnings: [
81
+ `不支持 ${name.split('.').pop() || '这种'} 格式:浏览器里读不出 docx / pdf 的正文。` +
82
+ '先在文档里「另存为」.md 或 .txt,再导入。',
83
+ ],
84
+ }
85
+ }
86
+
87
+ const raw = normalizeText(text)
88
+ if (raw.trim() === '') {
89
+ return { ok: false, format: 'empty', title: '', description: '', criteria: [], warnings: ['文件是空的'] }
90
+ }
91
+
92
+ // JSON:允许直接给结构化需求,省得再猜
93
+ if (/\.json$/i.test(name)) {
94
+ try {
95
+ const data = JSON.parse(raw)
96
+ if (data !== null && typeof data === 'object' && !Array.isArray(data)) {
97
+ const criteria = Array.isArray(data.criteria)
98
+ ? data.criteria.map((item) => cleanItem(typeof item === 'string' ? item : item?.text ?? '')).filter((item) => item !== '')
99
+ : []
100
+ const title = String(data.title ?? '').trim() || baseName(name)
101
+ if (criteria.length === 0) warnings.push('这个 JSON 里没有 criteria 数组,判据需要手填')
102
+ return {
103
+ ok: true,
104
+ format: 'json',
105
+ title,
106
+ description: String(data.description ?? '').slice(0, MAX_DESCRIPTION),
107
+ criteria,
108
+ warnings,
109
+ }
110
+ }
111
+ } catch (error) {
112
+ warnings.push(`JSON 解析失败(${error.message}),按纯文本处理`)
113
+ }
114
+ }
115
+
116
+ const lines = raw.split('\n')
117
+
118
+ // 1) 标题
119
+ let title = ''
120
+ let titleIndex = -1
121
+ for (let index = 0; index < lines.length; index += 1) {
122
+ const heading = headingOf(lines[index])
123
+ if (heading !== null && heading.level === 1) {
124
+ title = heading.text
125
+ titleIndex = index
126
+ break
127
+ }
128
+ }
129
+ if (title === '') {
130
+ const first = lines.find((line) => line.trim() !== '')
131
+ if (first !== undefined && !LIST_ITEM.test(first) && first.trim().length <= 80) {
132
+ title = first.trim()
133
+ titleIndex = lines.indexOf(first)
134
+ }
135
+ }
136
+ if (title === '') {
137
+ title = baseName(name)
138
+ if (title !== '') warnings.push(`没找到标题,用文件名「${title}」顶上`)
139
+ }
140
+
141
+ // 2) 判据:先找标题段,再退到勾选框
142
+ let criteria = []
143
+ let criteriaStart = -1
144
+ let criteriaEnd = -1
145
+ for (let index = 0; index < lines.length; index += 1) {
146
+ const heading = headingOf(lines[index])
147
+ if (heading === null || !CRITERIA_HEADING.test(heading.text)) continue
148
+ criteriaStart = index
149
+ let end = lines.length
150
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
151
+ const next = headingOf(lines[cursor])
152
+ if (next !== null && next.level <= heading.level) {
153
+ end = cursor
154
+ break
155
+ }
156
+ }
157
+ criteriaEnd = end
158
+ criteria = lines
159
+ .slice(index + 1, end)
160
+ .map((line) => {
161
+ const match = LIST_ITEM.exec(line)
162
+ return match === null ? '' : cleanItem(match[1])
163
+ })
164
+ .filter((item) => item !== '')
165
+ break
166
+ }
167
+
168
+ if (criteria.length === 0) {
169
+ criteria = lines
170
+ .map((line) => CHECKBOX_ITEM.exec(line))
171
+ .filter((match) => match !== null)
172
+ .map((match) => cleanItem(match[1]))
173
+ if (criteria.length > 0) warnings.push('没找到「验收判据」标题,按文档里的勾选框条目取判据')
174
+ }
175
+
176
+ if (criteria.length === 0) {
177
+ warnings.push('没认出验收判据:请在文档里加一段「## 验收判据」并用 `- ` 列表写,或导入后手填')
178
+ }
179
+
180
+ // 3) 描述:去掉标题行与判据段
181
+ const kept = lines.filter((line, index) => {
182
+ if (index === titleIndex) return false
183
+ if (criteriaStart >= 0 && index >= criteriaStart && index < criteriaEnd) return false
184
+ return true
185
+ })
186
+ let description = kept.join('\n').replace(/\n{3,}/g, '\n\n').trim()
187
+ if (description.length > MAX_DESCRIPTION) {
188
+ description = description.slice(0, MAX_DESCRIPTION)
189
+ warnings.push(`描述超过 ${MAX_DESCRIPTION} 字,已截断(它会原样进 prompt,太长会挤掉判据)`)
190
+ }
191
+
192
+ return { ok: true, format: 'text', title, description, criteria, warnings }
193
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * model-call.js:问宿主模型要一段 JSON 回复的公共通道。
3
+ *
4
+ * 判据草稿和整段需求解析走的是同一条路(`prepareCall` 有就用,没有就退回
5
+ * `stream`,只取 `text-delta`),逻辑一样,所以抽出来——两边各写一遍迟早分叉。
6
+ *
7
+ * 不自己拼 HTTP、不碰密钥:模型与凭据都归宿主管。
8
+ */
9
+
10
+ /** 一次调用最多等多久(超时即失败,让调用方退回规则提炼)。 */
11
+ export const MODEL_TIMEOUT_MS = 45_000
12
+
13
+ /** 旧版 llm 服务没有 createUserMessage 时用的兜底形状。 */
14
+ function fallbackMessage(text) {
15
+ return { role: 'user', content: [{ type: 'text', text }], source: { kind: 'user' } }
16
+ }
17
+
18
+ async function buildMessage(text) {
19
+ try {
20
+ const module = await import('@deepseek-ai/dsh-llm')
21
+ if (typeof module.createUserMessage === 'function') {
22
+ return module.createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
23
+ }
24
+ } catch {
25
+ /* 宿主没把这个包暴露给我们:用兜底形状,失败就退回规则 */
26
+ }
27
+ return fallbackMessage(text)
28
+ }
29
+
30
+ /**
31
+ * 让模型回一段文本。
32
+ *
33
+ * @param {object} input
34
+ * @param {any} input.llm 宿主的 llm 服务
35
+ * @param {{provider:string, model:string}} input.route
36
+ * @param {string} input.system 系统提示
37
+ * @param {string} input.text 用户内容
38
+ * @param {number} [input.timeoutMs]
39
+ * @returns {Promise<{ok:boolean, reply:string, error:string|null}>}
40
+ */
41
+ export async function callModel({ llm, route, system, text, timeoutMs = MODEL_TIMEOUT_MS } = {}) {
42
+ if (llm === null || llm === undefined) return { ok: false, reply: '', error: '宿主没有提供 llm 服务' }
43
+ if (route === null || route === undefined) return { ok: false, reply: '', error: '没有可用的模型路由' }
44
+ if (typeof llm.stream !== 'function') return { ok: false, reply: '', error: 'llm 服务没有 stream 方法' }
45
+
46
+ const controller = new AbortController()
47
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
48
+ try {
49
+ const call = { provider: route.provider, model: route.model }
50
+ const request = { system, messages: [await buildMessage(String(text ?? ''))], signal: controller.signal }
51
+
52
+ let stream
53
+ if (typeof llm.prepareCall === 'function') {
54
+ const prepared = await llm.prepareCall(call, controller.signal)
55
+ stream = prepared.stream({ ...prepared.config, ...request })
56
+ } else {
57
+ stream = await llm.stream({ ...call, ...request })
58
+ }
59
+
60
+ let reply = ''
61
+ for await (const chunk of stream) {
62
+ if (chunk !== null && chunk !== undefined && chunk.type === 'text-delta') reply += chunk.text
63
+ }
64
+ if (reply.trim() === '') return { ok: false, reply: '', error: '模型没有回内容' }
65
+ return { ok: true, reply, error: null }
66
+ } catch (error) {
67
+ const aborted = controller.signal.aborted
68
+ return {
69
+ ok: false,
70
+ reply: '',
71
+ error: aborted ? `模型 ${timeoutMs / 1000}s 内没答上来` : String(error?.message ?? error),
72
+ }
73
+ } finally {
74
+ clearTimeout(timer)
75
+ }
76
+ }
77
+
78
+ /** 从回复里抠出第一个 JSON 对象(容忍代码围栏与前后废话)。 */
79
+ export function extractJsonObject(reply) {
80
+ const text = String(reply ?? '').replace(/```[a-zA-Z]*\s*/g, '')
81
+ const start = text.indexOf('{')
82
+ const end = text.lastIndexOf('}')
83
+ if (start === -1 || end <= start) return undefined
84
+ try {
85
+ return JSON.parse(text.slice(start, end + 1))
86
+ } catch {
87
+ return undefined
88
+ }
89
+ }
package/src/models.js ADDED
@@ -0,0 +1,249 @@
1
+ /**
2
+ * models.js:读出宿主「已经添加的模型」。
3
+ *
4
+ * 清单的唯一真相是 profile 的 `cordis.patch.yml`:`@deepseek-ai/dsh-llm-pi-ai`
5
+ * 这类 provider 插件把自己的 `providers.*.models` 写在那里,`agent-default-model`
6
+ * 写默认路由。插件只读它,不改它——加模型仍然是去设置里加。
7
+ *
8
+ * 解析交给调用方传进来的 YAML 解析器(`yaml` 包),这样本文件保持纯函数、能单测。
9
+ */
10
+
11
+ /** 把 `provider/model` 拼成一条路由。 */
12
+ export function routeOf(provider, model) {
13
+ return `${provider}/${model}`
14
+ }
15
+
16
+ /** 拆 `provider/model`;不合法返回 null。 */
17
+ export function splitRoute(qualified) {
18
+ const raw = String(qualified ?? '').trim()
19
+ if (raw === '') return null
20
+ const slash = raw.indexOf('/')
21
+ if (slash <= 0 || slash === raw.length - 1) return null
22
+ const provider = raw.slice(0, slash).trim()
23
+ const model = raw.slice(slash + 1).trim()
24
+ return provider === '' || model === '' ? null : { provider, model }
25
+ }
26
+
27
+ /**
28
+ * 从解析后的 cordis 配置里建目录。
29
+ *
30
+ * 容忍一切畸形输入:读不出来就是空目录,页面据此提示「去设置里加模型」,
31
+ * 而不是抛异常把整个面板弄挂。
32
+ *
33
+ * @param {unknown} doc 解析后的 cordis.patch.yml(插件条目数组)
34
+ * @returns {{default:string|null, groups:Array<{provider:string, displayName:string, models:Array<{id:string,name:string}>}>}}
35
+ */
36
+ export function buildCatalog(doc) {
37
+ const groups = []
38
+ let fallback = null
39
+ const entries = Array.isArray(doc) ? doc : []
40
+ for (const entry of entries) {
41
+ if (entry === null || typeof entry !== 'object') continue
42
+ const config = entry.config
43
+ if (config === null || typeof config !== 'object') continue
44
+
45
+ if (entry.id === 'agent-default-model' && typeof config.provider === 'string' && typeof config.model === 'string') {
46
+ fallback = routeOf(config.provider, config.model)
47
+ }
48
+
49
+ const providers = config.providers
50
+ if (providers === null || typeof providers !== 'object' || Array.isArray(providers)) continue
51
+ for (const [providerId, provider] of Object.entries(providers)) {
52
+ if (provider === null || typeof provider !== 'object') continue
53
+ const models = Array.isArray(provider.models)
54
+ ? provider.models
55
+ .filter((model) => model !== null && typeof model === 'object' && typeof model.id === 'string' && model.id !== '')
56
+ .map((model) => ({ id: model.id, name: typeof model.name === 'string' && model.name !== '' ? model.name : model.id }))
57
+ : []
58
+ if (models.length > 0) {
59
+ groups.push({
60
+ provider: providerId,
61
+ displayName: typeof provider.displayName === 'string' && provider.displayName !== '' ? provider.displayName : providerId,
62
+ models,
63
+ })
64
+ }
65
+ }
66
+ }
67
+ return { default: fallback, groups }
68
+ }
69
+
70
+ /**
71
+ * 把目录摊平成下拉选项。
72
+ *
73
+ * @param {{default:string|null, groups:Array}} catalog
74
+ * @returns {Array<{route:string, label:string, provider:string, model:string, isDefault:boolean}>}
75
+ */
76
+ export function listRoutes(catalog) {
77
+ const options = []
78
+ for (const group of catalog?.groups ?? []) {
79
+ for (const model of group.models) {
80
+ const route = routeOf(group.provider, model.id)
81
+ options.push({
82
+ route,
83
+ label: `${group.displayName} · ${model.name}`,
84
+ provider: group.provider,
85
+ model: model.id,
86
+ isDefault: route === catalog.default,
87
+ })
88
+ }
89
+ }
90
+ return options
91
+ }
92
+
93
+ /**
94
+ * 解析默认路由:配置里指定的优先,其次宿主默认,最后取第一个可用。
95
+ *
96
+ * @param {string} configured 配置里的 `provider/model`(可空)
97
+ * @param {{default:string|null, groups:Array}} catalog
98
+ * @returns {string|null}
99
+ */
100
+ export function pickRoute(configured, catalog) {
101
+ if (splitRoute(configured) !== null) return configured.trim()
102
+ if (catalog?.default) return catalog.default
103
+ const first = listRoutes(catalog)[0]
104
+ return first === undefined ? null : first.route
105
+ }
106
+
107
+ /* ── 不依赖 yaml 包的兜底扫描 ─────────────────────────────────────────────
108
+ `yaml` 从插件真实路径不一定解析得到(插件是 junction 进去的),所以留一条
109
+ 纯文本扫描:只认 `providers:` 这一块的结构,认不出就返回空目录,页面提示
110
+ 「去设置里加模型」——不猜、不抛。 */
111
+
112
+ function unquote(value) {
113
+ const text = String(value ?? '').trim()
114
+ if (text.length >= 2 && (text[0] === '"' || text[0] === "'") && text[text.length - 1] === text[0]) {
115
+ return text.slice(1, -1)
116
+ }
117
+ return text
118
+ }
119
+
120
+ function indentOf(line) {
121
+ return line.length - line.trimStart().length
122
+ }
123
+
124
+ /**
125
+ * 扫描 `agent-default-model` 条目的 provider / model。
126
+ *
127
+ * @param {Array<string>} lines
128
+ * @returns {string|null}
129
+ */
130
+ function scanDefault(lines) {
131
+ for (let index = 0; index < lines.length; index += 1) {
132
+ if (!/^-\s+id:\s*agent-default-model\s*$/.test(lines[index].trim())) continue
133
+ let provider = null
134
+ let model = null
135
+ for (let cursor = index + 1; cursor < Math.min(index + 12, lines.length); cursor += 1) {
136
+ const line = lines[cursor].trim()
137
+ if (/^-\s+id:/.test(line)) break
138
+ const p = /^provider:\s*(.+)$/.exec(line)
139
+ const m = /^model:\s*(.+)$/.exec(line)
140
+ if (p !== null) provider = unquote(p[1])
141
+ if (m !== null) model = unquote(m[1])
142
+ }
143
+ if (provider !== null && model !== null) return routeOf(provider, model)
144
+ }
145
+ return null
146
+ }
147
+
148
+ /**
149
+ * 从 YAML 原文里扫出 provider → models(只认 `providers:` 块)。
150
+ *
151
+ * @param {string} text
152
+ * @returns {{default:string|null, groups:Array<{provider:string, displayName:string, models:Array<{id:string,name:string}>}>}}
153
+ */
154
+ export function scanProviders(text) {
155
+ const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n')
156
+ const groups = []
157
+ let providersIndent = -1
158
+ let providerIndent = -1
159
+ let current = null
160
+ let inModels = false
161
+ let modelIndent = -1
162
+ let lastModel = null
163
+
164
+ const close = () => {
165
+ if (current !== null && current.models.length > 0) groups.push(current)
166
+ current = null
167
+ inModels = false
168
+ modelIndent = -1
169
+ lastModel = null
170
+ }
171
+
172
+ for (const raw of lines) {
173
+ const line = raw.trim()
174
+ if (line === '' || line.startsWith('#')) continue
175
+ const indent = indentOf(raw)
176
+
177
+ if (providersIndent === -1) {
178
+ if (/^providers:\s*$/.test(line)) providersIndent = indent
179
+ continue
180
+ }
181
+ if (indent <= providersIndent) {
182
+ close()
183
+ if (/^providers:\s*$/.test(line)) {
184
+ providersIndent = indent
185
+ continue
186
+ }
187
+ providersIndent = -1
188
+ continue
189
+ }
190
+ if (providerIndent === -1) providerIndent = indent
191
+ if (indent < providerIndent) {
192
+ close()
193
+ providerIndent = indent
194
+ }
195
+
196
+ if (indent === providerIndent) {
197
+ const key = /^([\w.-]+):\s*$/.exec(line)
198
+ if (key !== null) {
199
+ close()
200
+ current = { provider: key[1], displayName: key[1], models: [] }
201
+ continue
202
+ }
203
+ }
204
+ if (current === null) continue
205
+
206
+ const display = /^displayName:\s*(.+)$/.exec(line)
207
+ if (display !== null) {
208
+ current.displayName = unquote(display[1])
209
+ continue
210
+ }
211
+ if (/^models:\s*$/.test(line)) {
212
+ inModels = true
213
+ modelIndent = -1
214
+ continue
215
+ }
216
+ if (!inModels) continue
217
+
218
+ /* 只认 models 列表**同一层**的 `- ` 项。模型自己的 `input:` 下面也有 `- text`,
219
+ 层级更深——不按缩进卡住的话,它们会被当成模型。 */
220
+ if (modelIndent === -1 && /^-\s+/.test(line)) modelIndent = indent
221
+
222
+ const byId = /^-\s+id:\s*(.+)$/.exec(line)
223
+ if (byId !== null && indent === modelIndent) {
224
+ lastModel = { id: unquote(byId[1]), name: unquote(byId[1]) }
225
+ current.models.push(lastModel)
226
+ continue
227
+ }
228
+ const bare = /^-\s+([\w.-]+)\s*$/.exec(line)
229
+ if (bare !== null && indent === modelIndent) {
230
+ lastModel = { id: bare[1], name: bare[1] }
231
+ current.models.push(lastModel)
232
+ continue
233
+ }
234
+ const name = /^name:\s*(.+)$/.exec(line)
235
+ if (name !== null && lastModel !== null && indent > modelIndent) {
236
+ lastModel.name = unquote(name[1])
237
+ continue
238
+ }
239
+ // 回到 provider 层的键(displayName / 下一个 provider)就结束 models 块
240
+ if (!/^-\s+/.test(line) && modelIndent !== -1 && indent <= modelIndent) {
241
+ inModels = false
242
+ modelIndent = -1
243
+ lastModel = null
244
+ }
245
+ }
246
+ close()
247
+
248
+ return { default: scanDefault(lines), groups }
249
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * parse-llm.js:把粘贴进来的一段需求原文,交给宿主模型整理成
3
+ * 「标题 + 描述 + 验收判据」三个字段。
4
+ *
5
+ * 和 criteria-llm 的差别只在提示词与解析:这里要一次要三个字段,
6
+ * 而且**不许补原文没有的东西**——编出来的功能会变成一张跑歪的卡。
7
+ */
8
+
9
+ import { callModel, extractJsonObject, MODEL_TIMEOUT_MS } from './model-call.js'
10
+
11
+ export { MODEL_TIMEOUT_MS as PARSE_TIMEOUT_MS }
12
+
13
+ /** 判据条数与长度上限(和规则提炼保持一致)。 */
14
+ const MAX_ITEMS = 8
15
+ const ITEM_LIMIT = 120
16
+ /** 标题上限:看板卡片上放得下。 */
17
+ const TITLE_LIMIT = 30
18
+ /** 描述上限:和导入功能一致,太长的正文不进 prompt。 */
19
+ const DESCRIPTION_LIMIT = 6000
20
+
21
+ /**
22
+ * 系统提示词。单独导出,测试与 README 都引用这同一份,避免文档和代码说两套。
23
+ */
24
+ export const PARSE_SYSTEM = [
25
+ '你把用户粘贴的一段需求原文,整理成任务看板上的一张卡片要的三个字段。',
26
+ '只输出一个 JSON 对象,不要任何解释:',
27
+ '{"title":"...","description":"...","criteria":["...","..."]}',
28
+ '',
29
+ '规则:',
30
+ '- title:一句话说清要做什么,不超过 24 个字;不要以「需求」「文档」「说明」这类空词开头。',
31
+ '- description:整理成「给谁用、要什么、明确不要什么」;保留原文里的具体数字、型号、接口名;原文没有的信息一个字都不要补。',
32
+ '- criteria:3 到 8 条,每条一句话,必须是能判定真假的验收条件(看到什么现象算通过),不要写任务步骤,不要写「代码质量好」这种没法核对的话。',
33
+ '- 用原文本来使用的语言写。',
34
+ ].join('\n')
35
+
36
+ function cleanLine(value) {
37
+ return String(value ?? '')
38
+ .replace(/\s+/g, ' ')
39
+ .replace(/^[-*+]\s+/, '')
40
+ .trim()
41
+ }
42
+
43
+ /**
44
+ * 解析模型回复。
45
+ *
46
+ * @param {string} reply
47
+ * @returns {{ok:boolean, title:string, description:string, criteria:Array<string>, error:string|null}}
48
+ */
49
+ export function parseRequirementReply(reply) {
50
+ const payload = extractJsonObject(reply)
51
+ if (payload === undefined || payload === null || typeof payload !== 'object') {
52
+ return { ok: false, title: '', description: '', criteria: [], error: '模型回复不是约定的 JSON' }
53
+ }
54
+ const title = cleanLine(payload.title).slice(0, TITLE_LIMIT)
55
+ const description = cleanLine(payload.description).slice(0, DESCRIPTION_LIMIT)
56
+ const raw = Array.isArray(payload.criteria) ? payload.criteria : []
57
+ const seen = new Set()
58
+ const criteria = []
59
+ for (const item of raw) {
60
+ const line = cleanLine(typeof item === 'string' ? item : item?.text).slice(0, ITEM_LIMIT)
61
+ if (line === '') continue
62
+ const key = line.replace(/[\s。;;,,::]/g, '')
63
+ if (seen.has(key)) continue
64
+ seen.add(key)
65
+ criteria.push(line)
66
+ if (criteria.length >= MAX_ITEMS) break
67
+ }
68
+ if (title === '' && criteria.length === 0) {
69
+ return { ok: false, title: '', description: '', criteria: [], error: '模型没给出标题,也没给出判据' }
70
+ }
71
+ return { ok: true, title, description, criteria, error: null }
72
+ }
73
+
74
+ /**
75
+ * 让模型解析一段需求原文。
76
+ *
77
+ * @param {object} input
78
+ * @param {any} input.llm
79
+ * @param {{provider:string, model:string}} input.route
80
+ * @param {string} input.text 需求原文
81
+ * @param {number} [input.timeoutMs]
82
+ * @returns {Promise<{ok:boolean, title:string, description:string, criteria:Array<string>, error:string|null}>}
83
+ */
84
+ export async function parseRequirementWithModel({ llm, route, text = '', timeoutMs = MODEL_TIMEOUT_MS } = {}) {
85
+ const source = String(text ?? '').trim()
86
+ if (source === '') return { ok: false, title: '', description: '', criteria: [], error: '没有可解析的原文' }
87
+ const reply = await callModel({
88
+ llm,
89
+ route,
90
+ system: PARSE_SYSTEM,
91
+ text: `需求原文:\n${source.slice(0, DESCRIPTION_LIMIT)}`,
92
+ timeoutMs,
93
+ })
94
+ if (!reply.ok) return { ok: false, title: '', description: '', criteria: [], error: reply.error }
95
+ return parseRequirementReply(reply.reply)
96
+ }