@p-dsh-market/conversation-knowledge-map 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.
@@ -0,0 +1,401 @@
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ import { validateKnowledgeGraph } from './knowledge-graph-schema.js'
4
+ import { validateMindMap } from './mind-map-schema.js'
5
+ import { clone, errorMessage, makeUserMessage, shortText } from './protocol.js'
6
+ import { chunkSourceText, readSelectedSurfaces } from './session-source.js'
7
+
8
+ const MAX_SUMMARY_CHARS = 2400
9
+
10
+ function phaseMessage(status) {
11
+ return {
12
+ confirming: '等待用户确认生成范围…',
13
+ 'reading-sources': '正在读取已确认的对话表面…',
14
+ summarizing: '正在分段整理对话并保留来源…',
15
+ 'building-mind-map': '正在生成思维导图…',
16
+ 'building-knowledge-graph': '正在生成知识图谱…',
17
+ validating: '正在校验结构化图数据…',
18
+ saving: '正在原子保存工作区结果…',
19
+ completed: '知识视图生成完成。',
20
+ failed: '知识视图生成失败。',
21
+ cancelled: '知识视图生成已取消。'
22
+ }[status] || status
23
+ }
24
+
25
+ function asObject(value) {
26
+ if (value && typeof value === 'object') {
27
+ if (value.result && typeof value.result === 'object') return value.result
28
+ if (value.value && typeof value.value === 'object') return value.value
29
+ return value
30
+ }
31
+ return null
32
+ }
33
+
34
+ export function parseStructuredOutput(value) {
35
+ const object = asObject(value)
36
+ if (object) return object
37
+ const text = String(value || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '')
38
+ const start = text.indexOf('{')
39
+ const end = text.lastIndexOf('}')
40
+ if (start < 0 || end <= start) throw new Error('模型没有返回 JSON 对象。')
41
+ try {
42
+ return JSON.parse(text.slice(start, end + 1))
43
+ } catch (error) {
44
+ throw new Error(`模型 JSON 无法解析:${error.message}`)
45
+ }
46
+ }
47
+
48
+ function sourceRefsFromChunk(source, chunk) {
49
+ const refs = chunk.sourceRefs?.length ? chunk.sourceRefs : (source.events || []).slice(0, 4).map((event) => ({
50
+ sessionId: source.sessionId,
51
+ eventSeqs: [event.seq]
52
+ }))
53
+ return refs
54
+ }
55
+
56
+ function sanitizeSourceRefs(refs, source, fallback) {
57
+ const allowed = new Set((source.events || []).flatMap((event) => [event.seq, ...(event.sourceEventSeqs || [])]))
58
+ const normalized = Array.isArray(refs) ? refs.map((ref) => ({
59
+ sessionId: String(ref?.sessionId || ''),
60
+ eventSeqs: Array.isArray(ref?.eventSeqs) ? [...new Set(ref.eventSeqs.filter((seq) => Number.isInteger(seq) && allowed.has(seq)))] : []
61
+ })).filter((ref) => ref.sessionId === source.sessionId && ref.eventSeqs.length).slice(0, 12) : []
62
+ return normalized.length ? normalized : fallback
63
+ }
64
+
65
+ function normalizeSummary(value, source, chunk) {
66
+ const result = parseStructuredOutput(value)
67
+ const summary = shortText(result.summary || result.narrative || result.text, MAX_SUMMARY_CHARS)
68
+ if (!summary) throw new Error(`对话 ${source.sessionId} 的摘要为空。`)
69
+ const keyPoints = Array.isArray(result.keyPoints)
70
+ ? result.keyPoints.map((item) => shortText(item, 500)).filter(Boolean).slice(0, 12)
71
+ : []
72
+ const sourceRefs = sanitizeSourceRefs(result.sourceRefs, source, sourceRefsFromChunk(source, chunk))
73
+ return {
74
+ sessionId: source.sessionId,
75
+ title: source.title,
76
+ summary,
77
+ keyPoints,
78
+ sourceRefs
79
+ }
80
+ }
81
+
82
+ function assertOutputSourceRefs(value, sources) {
83
+ const allowed = new Map(sources.map((source) => [source.sessionId, new Set((source.events || []).flatMap((event) => [event.seq, ...(event.sourceEventSeqs || [])]))]))
84
+ const check = (ref, label) => {
85
+ const sessionId = String(ref?.sessionId || '')
86
+ const seqs = allowed.get(sessionId)
87
+ if (!seqs) throw new Error(`${label} 引用了未读取的来源 Session:${sessionId}`)
88
+ for (const seq of ref.eventSeqs || []) if (!seqs.has(seq)) throw new Error(`${label} 引用了未读取的事件序号:${sessionId}/${seq}`)
89
+ }
90
+ for (const node of value?.mindMap?.nodes || []) for (const ref of node.sourceRefs || []) check(ref, `思维导图节点 ${node.id}`)
91
+ for (const entity of value?.knowledgeGraph?.entities || []) for (const ref of entity.sourceRefs || []) check(ref, `知识图谱实体 ${entity.id}`)
92
+ for (const relation of value?.knowledgeGraph?.relations || []) for (const ref of relation.evidence || []) check(ref, `知识图谱关系 ${relation.id}`)
93
+ }
94
+
95
+ function outputPrompt(kind, summaries, prompt, strict) {
96
+ const rules = [
97
+ '只输出一个 JSON 对象,不要 Markdown 代码围栏,不要额外解释。',
98
+ '所有 sourceRefs/evidence 必须使用给定的 sessionId 和 eventSeqs,不能虚构来源。',
99
+ strict ? '严格模式:没有直接来源的内容不要写成 confirmed 事实;知识图谱每条关系必须带 evidence。' : '对没有直接依据的内容使用 inferred 或 conflicted。'
100
+ ]
101
+ const context = JSON.stringify(summaries)
102
+ if (kind === 'mind-map') {
103
+ return [
104
+ '请根据以下多个对话摘要生成阶段性思维导图。节点不是关键词,narrative 必须是至少一段完整说明,包含背景、当前认识和下一步/未决点。',
105
+ 'JSON 形状:{"rootId":"...","nodes":[{"id":"...","parentId":null,"type":"theme|stage|question|decision|solution|risk|conclusion","title":"...","narrative":"...","primarySourceSessionId":"...","sourceRefs":[{"sessionId":"...","eventSeqs":[1]}],"openQuestions":[]}]}。',
106
+ ...rules,
107
+ `额外要求:${shortText(prompt, 2000) || '没有额外要求。'}`,
108
+ `对话摘要:${context}`
109
+ ].join('\n\n')
110
+ }
111
+ return [
112
+ '请根据以下多个对话摘要生成静态知识图谱。抽取实体、概念、模块、接口、决策、风险和外部系统,并只建立有依据的关系。',
113
+ 'JSON 形状:{"entities":[{"id":"...","type":"...","name":"...","summary":"...","confidence":"confirmed|inferred|conflicted","sourceRefs":[{"sessionId":"...","eventSeqs":[1]}]}],"relations":[{"id":"...","from":"...","to":"...","type":"depends_on|calls|supports|constrains|belongs_to|derived_from","confidence":"confirmed|inferred|conflicted","evidence":[{"sessionId":"...","eventSeqs":[1]}]}]}。',
114
+ ...rules,
115
+ `额外要求:${shortText(prompt, 2000) || '没有额外要求。'}`,
116
+ `对话摘要:${context}`
117
+ ].join('\n\n')
118
+ }
119
+
120
+ function summaryPrompt(source, chunk, strict) {
121
+ return [
122
+ '请把一段 DSH 对话整理成带来源的结构化摘要。不要复述完整聊天,不要添加对话中没有的事实。',
123
+ '只输出 JSON:{"summary":"完整阶段性说明","keyPoints":["..."],"sourceRefs":[{"sessionId":"...","eventSeqs":[1]}]}。',
124
+ strict ? '严格模式:每个关键点都要能回指给定事件。' : '允许标记尚未确认的冲突,但不能编造事件序号。',
125
+ `Session:${source.sessionId}`,
126
+ `标题:${source.title}`,
127
+ `分段:${chunk.text}`
128
+ ].join('\n\n')
129
+ }
130
+
131
+ function followUpPrompt(node, source, targetSessionId, strict) {
132
+ return [
133
+ '请根据思维导图节点和目标对话最近内容,形成一个用于继续原对话的推进问题。不要回答问题,不要自动发送。',
134
+ '只输出 JSON:{"targetSessionId":"...","question":"...","alternatives":["..."],"reason":"..."}。',
135
+ strict ? '问题必须只依赖节点、来源引用和目标对话,不得引入其他会话内容。' : '问题应优先推动验证、决策或下一步行动。',
136
+ `节点:${JSON.stringify(node)}`,
137
+ `目标对话:${targetSessionId}`,
138
+ `目标对话最近内容:${shortText(source?.text, 5000)}`
139
+ ].join('\n\n')
140
+ }
141
+
142
+ function extractAgentText(surface) {
143
+ const events = Array.isArray(surface?.events) ? surface.events : []
144
+ for (let index = events.length - 1; index >= 0; index -= 1) {
145
+ const event = events[index]
146
+ if (event?.type === 'assistant/message') {
147
+ const message = event.data?.message || event.data || {}
148
+ const content = message.content
149
+ if (Array.isArray(content)) return content.filter((block) => block?.type !== 'reasoning').map((block) => block?.text || block?.value || '').join('')
150
+ if (typeof content === 'string') return content
151
+ }
152
+ }
153
+ return ''
154
+ }
155
+
156
+ export class KnowledgeGenerationOrchestrator {
157
+ constructor({
158
+ sessionQuery,
159
+ sessions,
160
+ agents,
161
+ agentDefaultModel,
162
+ storage,
163
+ modelRunner,
164
+ sourceReader = readSelectedSurfaces,
165
+ now = () => Date.now(),
166
+ idFactory = () => randomUUID()
167
+ } = {}) {
168
+ this.sessionQuery = sessionQuery
169
+ this.sessions = sessions
170
+ this.agents = agents
171
+ this.agentDefaultModel = agentDefaultModel
172
+ this.storage = storage
173
+ this.modelRunner = modelRunner
174
+ this.sourceReader = sourceReader
175
+ this.now = now
176
+ this.idFactory = idFactory
177
+ this.tasks = new Map()
178
+ this.busyByWorkspace = new Map()
179
+ }
180
+
181
+ taskView(task) {
182
+ return {
183
+ id: task.id,
184
+ status: task.status,
185
+ message: task.message,
186
+ phase: task.status,
187
+ error: task.error || '',
188
+ createdAt: task.createdAt,
189
+ updatedAt: task.updatedAt,
190
+ revision: task.revision || 0,
191
+ result: task.result ? clone(task.result) : null,
192
+ events: task.events.slice(-40)
193
+ }
194
+ }
195
+
196
+ get(id) {
197
+ const task = this.tasks.get(String(id || ''))
198
+ return task ? this.taskView(task) : null
199
+ }
200
+
201
+ subscribe(id, listener, since = 0) {
202
+ const task = this.tasks.get(String(id || ''))
203
+ if (!task) return null
204
+ for (const event of task.events) if (event.id > since) listener(event, this.taskView(task))
205
+ task.listeners.add(listener)
206
+ return () => task.listeners.delete(listener)
207
+ }
208
+
209
+ update(task, status, extra = {}) {
210
+ task.status = status
211
+ task.message = phaseMessage(status)
212
+ task.updatedAt = this.now()
213
+ Object.assign(task, extra)
214
+ const event = { id: task.nextEventId++, status, message: task.message, at: task.updatedAt, ...extra }
215
+ task.events.push(event)
216
+ if (task.events.length > 120) task.events.splice(0, task.events.length - 120)
217
+ for (const listener of task.listeners) {
218
+ try { listener(event, this.taskView(task)) } catch { /* UI observers cannot break generation */ }
219
+ }
220
+ }
221
+
222
+ start(request) {
223
+ const key = String(request.cwd || '').toLowerCase()
224
+ if (this.busyByWorkspace.has(key)) throw new Error('当前工作路径已有生成任务在运行,请先取消或等待完成。')
225
+ const task = {
226
+ id: this.idFactory(),
227
+ status: 'created',
228
+ message: phaseMessage('created'),
229
+ error: '',
230
+ result: null,
231
+ revision: 0,
232
+ createdAt: this.now(),
233
+ updatedAt: this.now(),
234
+ nextEventId: 1,
235
+ events: [],
236
+ listeners: new Set(),
237
+ controller: new AbortController(),
238
+ promise: null
239
+ }
240
+ this.tasks.set(task.id, task)
241
+ this.busyByWorkspace.set(key, task.id)
242
+ this.update(task, 'created', { request: { ...request, prompt: shortText(request.prompt, 500) } })
243
+ task.promise = this.run(task, request).finally(() => {
244
+ if (this.busyByWorkspace.get(key) === task.id) this.busyByWorkspace.delete(key)
245
+ })
246
+ return this.taskView(task)
247
+ }
248
+
249
+ cancel(id) {
250
+ const task = this.tasks.get(String(id || ''))
251
+ if (!task) throw new Error('生成任务不存在。')
252
+ if (['completed', 'failed', 'cancelled'].includes(task.status)) return this.taskView(task)
253
+ task.controller.abort(new Error('用户取消了生成。'))
254
+ return this.taskView(task)
255
+ }
256
+
257
+ assertNotCancelled(task) {
258
+ if (task.controller.signal.aborted) throw task.controller.signal.reason || new Error('生成已取消。')
259
+ }
260
+
261
+ async run(task, request) {
262
+ try {
263
+ this.update(task, 'reading-sources')
264
+ const sources = await this.sourceReader({ sessionQuery: this.sessionQuery, sessions: this.sessions }, {
265
+ cwd: request.cwd,
266
+ sessionIds: request.selectedSessionIds,
267
+ includeSubagents: request.includeSubagents === true
268
+ })
269
+ this.assertNotCancelled(task)
270
+ this.update(task, 'summarizing', { sourceCount: sources.length })
271
+ const summaries = []
272
+ for (const source of sources) {
273
+ for (const chunk of chunkSourceText(source)) {
274
+ this.assertNotCancelled(task)
275
+ const value = await this.runModel({
276
+ kind: 'summary',
277
+ prompt: summaryPrompt(source, chunk, request.strict === true),
278
+ cwd: request.cwd,
279
+ strict: request.strict === true,
280
+ selectedSessionIds: request.selectedSessionIds,
281
+ signal: task.controller.signal
282
+ })
283
+ summaries.push(normalizeSummary(value, source, chunk))
284
+ }
285
+ }
286
+ this.assertNotCancelled(task)
287
+ let mindMap = null
288
+ let knowledgeGraph = null
289
+ if (request.outputMode === 'mind-map' || request.outputMode === 'both') {
290
+ this.update(task, 'building-mind-map')
291
+ const value = await this.runModel({
292
+ kind: 'mind-map',
293
+ prompt: outputPrompt('mind-map', summaries, request.prompt, request.strict === true),
294
+ cwd: request.cwd,
295
+ strict: request.strict === true,
296
+ selectedSessionIds: request.selectedSessionIds,
297
+ signal: task.controller.signal
298
+ })
299
+ mindMap = validateMindMap(value, { selectedSessionIds: request.selectedSessionIds, strict: request.strict === true })
300
+ }
301
+ if (request.outputMode === 'knowledge-graph' || request.outputMode === 'both') {
302
+ this.update(task, 'building-knowledge-graph')
303
+ const value = await this.runModel({
304
+ kind: 'knowledge-graph',
305
+ prompt: outputPrompt('knowledge-graph', summaries, request.prompt, request.strict === true),
306
+ cwd: request.cwd,
307
+ strict: request.strict === true,
308
+ selectedSessionIds: request.selectedSessionIds,
309
+ signal: task.controller.signal
310
+ })
311
+ knowledgeGraph = validateKnowledgeGraph(value, { selectedSessionIds: request.selectedSessionIds, strict: request.strict === true })
312
+ }
313
+ assertOutputSourceRefs({ mindMap, knowledgeGraph }, sources)
314
+ this.assertNotCancelled(task)
315
+ this.update(task, 'validating')
316
+ this.update(task, 'saving')
317
+ const saved = await this.storage.saveBundle({
318
+ cwd: request.cwd,
319
+ expectedRevision: request.expectedRevision,
320
+ generationId: task.id,
321
+ sourceSessionIds: request.selectedSessionIds,
322
+ prompt: request.prompt,
323
+ strict: request.strict,
324
+ outputMode: request.outputMode,
325
+ model: request.model,
326
+ mindMap,
327
+ knowledgeGraph
328
+ })
329
+ task.revision = saved.revision
330
+ task.result = { revision: saved.revision, manifest: saved.manifest, mindMap, knowledgeGraph }
331
+ this.update(task, 'completed', { revision: saved.revision, result: task.result })
332
+ } catch (error) {
333
+ if (task.controller.signal.aborted || /取消|cancel/i.test(errorMessage(error))) {
334
+ this.update(task, 'cancelled', { error: '' })
335
+ } else {
336
+ this.update(task, 'failed', { error: errorMessage(error) })
337
+ }
338
+ }
339
+ return this.taskView(task)
340
+ }
341
+
342
+ async runModel(input) {
343
+ if (typeof this.modelRunner === 'function') return parseStructuredOutput(await this.modelRunner(input))
344
+ if (!this.agents?.create) throw new Error('当前 DSH Runtime 未提供 agents.create,无法生成知识视图。')
345
+ let selection = {}
346
+ try { selection = this.agentDefaultModel?.currentSelection?.() || {} } catch { selection = {} }
347
+ const provider = String(selection.provider || '').trim()
348
+ const model = String(selection.model || '').trim()
349
+ if (!provider || !model) throw new Error('当前没有可用的默认 Provider/Model。')
350
+ const sessionId = `knowledge-map-${this.idFactory()}`
351
+ const handle = await this.agents.create({
352
+ sessionId,
353
+ meta: { cwd: input.cwd, origin: 'subagent' },
354
+ agentOptions: { provider, model, maxTokens: input.kind === 'summary' ? 2500 : 6000 },
355
+ signal: input.signal,
356
+ setup: async (agentCtx) => {
357
+ agentCtx?.systemPrompt?.section?.({
358
+ name: 'knowledge-map:protocol',
359
+ order: 0,
360
+ text: '你是 DSH 知识视图生成器。只输出调用方要求的 JSON;不要调用外部网络、文件写入或其他 Agent 工具。'
361
+ })
362
+ try {
363
+ agentCtx?.tools?.restrict?.({ deny: ['multi_agent_discuss', 'shell', 'filesystem', 'web_search', 'browser'] })
364
+ } catch { /* older runtimes may not expose tool restriction */ }
365
+ }
366
+ })
367
+ try {
368
+ handle.agent.followup(makeUserMessage(input.prompt, `${sessionId}-${input.kind}`))
369
+ await handle.agent.whenIdle()
370
+ const surface = await this.sessionQuery?.readSurface?.(sessionId)
371
+ return parseStructuredOutput(extractAgentText(surface))
372
+ } finally {
373
+ await handle.dispose?.()
374
+ }
375
+ }
376
+
377
+ async formFollowUp({ cwd, node, targetSessionId, strict = true, signal }) {
378
+ const sources = await this.sourceReader({ sessionQuery: this.sessionQuery, sessions: this.sessions }, {
379
+ cwd,
380
+ sessionIds: [targetSessionId],
381
+ includeSubagents: false
382
+ })
383
+ const value = await this.runModel({
384
+ kind: 'follow-up',
385
+ prompt: followUpPrompt(node, sources[0], targetSessionId, strict),
386
+ cwd,
387
+ strict,
388
+ selectedSessionIds: [targetSessionId],
389
+ signal
390
+ })
391
+ const result = parseStructuredOutput(value)
392
+ const question = shortText(result.question, 2000)
393
+ if (!question) throw new Error('模型没有形成后续问题。')
394
+ return {
395
+ targetSessionId,
396
+ question,
397
+ alternatives: Array.isArray(result.alternatives) ? result.alternatives.map((item) => shortText(item, 500)).filter(Boolean).slice(0, 2) : [],
398
+ reason: shortText(result.reason, 800)
399
+ }
400
+ }
401
+ }