返回 oh-my-ppt
reference-document-retrieval.ts
根目录 / src / main / generation / reference-document-retrieval.ts
1 import fs from 'fs'
2 import path from 'path'
3 import { createRequire } from 'module'
4 import log from 'electron-log/main.js'
5 import { Jieba } from '@node-rs/jieba'
6
7 type JiebaDictModule = {
8 dict: Uint8Array
9 }
10
11 export type ReferenceDocumentSnippet = {
12 sourcePath: string
13 headingPath: string[]
14 text: string
15 startLine: number
16 endLine: number
17 score: number
18 }
19
20 type ReferenceChunk = {
21 id: string
22 sourcePath: string
23 absolutePath: string
24 headingPath: string[]
25 text: string
26 normalizedText: string
27 normalizedHeading: string
28 startLine: number
29 endLine: number
30 }
31
32 type SearchInput = {
33 pageId: string
34 pageTitle: string
35 pageOutline: string
36 userMessage: string
37 }
38
39 type ReferenceDocumentRetriever = {
40 search: (input: SearchInput) => ReferenceDocumentSnippet[]
41 }
42
43 let jiebaInstance: Jieba | null = null
44
45 const getJieba = (): Jieba => {
46 if (!jiebaInstance) {
47 const require = createRequire(import.meta.url)
48 const { dict } = require('@node-rs/jieba/dict.js') as JiebaDictModule
49 jiebaInstance = Jieba.withDict(dict)
50 }
51 return jiebaInstance
52 }
53
54 const MAX_CHUNK_CHARS = 1200
55 const MIN_CHUNK_CHARS = 300
56 const MAX_SNIPPETS = 5
57 const MAX_INJECTED_CHARS = 6000
58
59 const GENERIC_TERMS = new Set([
60 '背景',
61 '目标',
62 '价值',
63 '方案',
64 '介绍',
65 '说明',
66 '分析',
67 '概述',
68 '总结',
69 '规划',
70 '设计',
71 '能力',
72 '功能',
73 '核心',
74 '重点',
75 '整体',
76 '业务',
77 '内容',
78 '页面',
79 '展示'
80 ])
81
82 const normalizeForSearch = (value: string): string =>
83 value
84 .toLowerCase()
85 .replace(/\s+/g, '')
86 .replace(/[,。!?;:、“”‘’()【】《》,.!?;:"'()[\]<>]/g, '')
87 .trim()
88
89 const cleanText = (value: string): string => value.replace(/\s+/g, ' ').trim()
90
91 const isMostlyChinese = (value: string): boolean => /[\u4e00-\u9fff]/.test(value)
92
93 const isGenericTerm = (value: string): boolean => {
94 const normalized = normalizeForSearch(value)
95 if (!normalized) return true
96 if (GENERIC_TERMS.has(normalized)) return true
97 if (normalized.length <= 1) return true
98 return false
99 }
100
101 const splitQueryPhrases = (input: string): string[] => {
102 const phrases = input
103 .split(/[\n\r。!?;;::,,、|/\\()[\]【】《》<>]+/g)
104 .map((item) => item.replace(/^\s*(?:第?\d+[.、))]|[-*•]+)\s*/u, '').trim())
105 .filter((item) => item.length > 0)
106 .filter((item) => item.length >= 3 && item.length <= 40)
107 .filter((item) => !isGenericTerm(item))
108 return Array.from(new Set(phrases)).slice(0, 8)
109 }
110
111 const extractStructuredTokens = (input: string): string[] => {
112 const tokenMatches = input.match(
113 /(?:[Pp][0-9]+)|(?:\d{4}[-/.年]\d{1,2}(?:[-/.月]\d{1,2}日?)?)|(?:\d+(?:\.\d+)?%)|(?:\d+(?:\.\d+)?(?:万|亿|元|万元|亿元)?)|(?:[A-Za-z][A-Za-z0-9_-]{1,})/g
114 )
115 return Array.from(new Set(tokenMatches || [])).filter((item) => !isGenericTerm(item))
116 }
117
118 const extractWordQueries = (input: string): string[] => {
119 const words = getJieba().cutForSearch(input, false)
120 const structured = extractStructuredTokens(input)
121 const candidates = [...words, ...structured]
122 .map((item) => item.trim())
123 .filter((item) => item.length > 0)
124 .filter((item) => {
125 if (isGenericTerm(item)) return false
126 if (isMostlyChinese(item)) return item.length >= 2 && item.length <= 12
127 return item.length >= 2 && item.length <= 32
128 })
129 return Array.from(new Set(candidates)).slice(0, 20)
130 }
131
132 const createQueries = (input: SearchInput): { phraseQueries: string[]; wordQueries: string[] } => {
133 const source = [input.pageTitle, input.pageOutline, input.userMessage].filter(Boolean).join('\n')
134 const phraseQueries = splitQueryPhrases(source)
135 const wordQueries = extractWordQueries(source).filter(
136 (word) => !phraseQueries.some((phrase) => normalizeForSearch(phrase) === normalizeForSearch(word))
137 )
138 return { phraseQueries, wordQueries }
139 }
140
141 const splitLongText = (text: string): string[] => {
142 const normalized = text.trim()
143 if (normalized.length <= MAX_CHUNK_CHARS) return [normalized]
144 const parts = normalized
145 .split(/(?<=[。!?;;.!?])\s*/u)
146 .map((item) => item.trim())
147 .filter(Boolean)
148 const chunks: string[] = []
149 let current = ''
150 for (const part of parts.length > 0 ? parts : [normalized]) {
151 if (current && `${current}${part}`.length > MAX_CHUNK_CHARS) {
152 chunks.push(current.trim())
153 current = ''
154 }
155 current = current ? `${current}${part}` : part
156 }
157 if (current.trim()) chunks.push(current.trim())
158 return chunks.length > 0 ? chunks : [normalized.slice(0, MAX_CHUNK_CHARS)]
159 }
160
161 const createChunk = (args: {
162 sourcePath: string
163 absolutePath: string
164 headingPath: string[]
165 text: string
166 startLine: number
167 endLine: number
168 index: number
169 }): ReferenceChunk => {
170 const text = cleanText(args.text)
171 const headingPath = args.headingPath.filter(Boolean)
172 return {
173 id: `${args.sourcePath}:${args.startLine}-${args.endLine}:${args.index}`,
174 sourcePath: args.sourcePath,
175 absolutePath: args.absolutePath,
176 headingPath,
177 text,
178 normalizedText: normalizeForSearch(text),
179 normalizedHeading: normalizeForSearch(headingPath.join(' ')),
180 startLine: args.startLine,
181 endLine: args.endLine
182 }
183 }
184
185 const chunkDocument = (args: {
186 sourcePath: string
187 absolutePath: string
188 content: string
189 }): ReferenceChunk[] => {
190 const lines = args.content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')
191 const chunks: ReferenceChunk[] = []
192 const headingPath: string[] = []
193 let buffer: string[] = []
194 let bufferStartLine = 1
195 let chunkIndex = 0
196
197 const pushBuffer = (endLine: number) => {
198 const text = buffer.join('\n').trim()
199 if (!text) {
200 buffer = []
201 return
202 }
203 for (const part of splitLongText(text)) {
204 chunks.push(
205 createChunk({
206 sourcePath: args.sourcePath,
207 absolutePath: args.absolutePath,
208 headingPath,
209 text: part,
210 startLine: bufferStartLine,
211 endLine,
212 index: chunkIndex
213 })
214 )
215 chunkIndex += 1
216 }
217 buffer = []
218 }
219
220 const appendLine = (line: string, lineNumber: number) => {
221 if (buffer.length === 0) bufferStartLine = lineNumber
222 buffer.push(line)
223 const currentLength = buffer.join('\n').length
224 if (currentLength >= MAX_CHUNK_CHARS) {
225 pushBuffer(lineNumber)
226 }
227 }
228
229 for (let index = 0; index < lines.length; index += 1) {
230 const line = lines[index]
231 const lineNumber = index + 1
232 const headingMatch = line.match(/^(#{1,6})\s+(.+?)\s*#*$/)
233 if (headingMatch) {
234 pushBuffer(lineNumber - 1)
235 const level = headingMatch[1].length
236 headingPath.splice(level - 1)
237 headingPath[level - 1] = headingMatch[2].trim()
238 continue
239 }
240 if (!line.trim()) {
241 pushBuffer(lineNumber - 1)
242 continue
243 }
244 appendLine(line, lineNumber)
245 }
246 pushBuffer(lines.length)
247
248 const merged: ReferenceChunk[] = []
249 for (const chunk of chunks) {
250 const prev = merged[merged.length - 1]
251 if (
252 prev &&
253 prev.text.length < MIN_CHUNK_CHARS &&
254 chunk.text.length < MIN_CHUNK_CHARS &&
255 prev.sourcePath === chunk.sourcePath &&
256 prev.headingPath.join('/') === chunk.headingPath.join('/')
257 ) {
258 const text = `${prev.text}\n${chunk.text}`.trim()
259 merged[merged.length - 1] = {
260 ...prev,
261 text,
262 normalizedText: normalizeForSearch(text),
263 endLine: chunk.endLine
264 }
265 continue
266 }
267 merged.push(chunk)
268 }
269 return merged
270 }
271
272 const resolveSourcePath = (projectDir: string, sourceDocumentPath: string): string | null => {
273 if (!sourceDocumentPath.startsWith('/docs/')) return null
274 const absolutePath = path.resolve(projectDir, sourceDocumentPath.replace(/^\/+/, ''))
275 const relativeToProject = path.relative(projectDir, absolutePath)
276 if (relativeToProject.startsWith('..') || path.isAbsolute(relativeToProject)) return null
277 return absolutePath
278 }
279
280 const scoreChunk = (
281 chunk: ReferenceChunk,
282 queries: { phraseQueries: string[]; wordQueries: string[] }
283 ): number => {
284 let score = 0
285 const seen = new Set<string>()
286 for (const phrase of queries.phraseQueries) {
287 const normalized = normalizeForSearch(phrase)
288 if (!normalized || seen.has(`phrase:${normalized}`)) continue
289 seen.add(`phrase:${normalized}`)
290 if (chunk.normalizedText.includes(normalized)) score += 2
291 if (chunk.normalizedHeading.includes(normalized)) score += 2
292 }
293 for (const word of queries.wordQueries) {
294 const normalized = normalizeForSearch(word)
295 if (!normalized || seen.has(`word:${normalized}`)) continue
296 seen.add(`word:${normalized}`)
297 if (chunk.normalizedText.includes(normalized)) score += 1
298 if (chunk.normalizedHeading.includes(normalized)) score += 2
299 }
300 return score
301 }
302
303 const selectSnippets = (
304 chunks: ReferenceChunk[],
305 queries: { phraseQueries: string[]; wordQueries: string[] }
306 ): { snippets: ReferenceDocumentSnippet[]; matchedChunkCount: number } => {
307 const scored = chunks
308 .map((chunk) => ({ chunk, score: scoreChunk(chunk, queries) }))
309 .filter((item) => item.score > 0)
310 .sort((a, b) => b.score - a.score || a.chunk.startLine - b.chunk.startLine)
311
312 const snippets: ReferenceDocumentSnippet[] = []
313 let injectedChars = 0
314 for (const item of scored) {
315 if (snippets.length >= MAX_SNIPPETS) break
316 if (injectedChars >= MAX_INJECTED_CHARS) break
317 const text =
318 item.chunk.text.length > 1400 ? `${item.chunk.text.slice(0, 1400).trimEnd()}...` : item.chunk.text
319 snippets.push({
320 sourcePath: item.chunk.sourcePath,
321 headingPath: item.chunk.headingPath,
322 text,
323 startLine: item.chunk.startLine,
324 endLine: item.chunk.endLine,
325 score: item.score
326 })
327 injectedChars += text.length
328 }
329 return { snippets, matchedChunkCount: scored.length }
330 }
331
332 export const formatReferenceDocumentSnippets = (
333 snippets: ReferenceDocumentSnippet[]
334 ): string => {
335 if (snippets.length === 0) return ''
336 return [
337 '参考文档检索片段(程序侧根据当前页标题和大纲预检索,优先使用):',
338 '',
339 ...snippets.flatMap((snippet, index) => [
340 `[片段 ${index + 1}] ${snippet.sourcePath}#L${snippet.startLine}-L${snippet.endLine}`,
341 snippet.headingPath.length > 0 ? `标题路径:${snippet.headingPath.join(' / ')}` : '',
342 `内容:${snippet.text}`,
343 ''
344 ])
345 ]
346 .filter((line) => line !== '')
347 .join('\n')
348 }
349
350 export const createReferenceDocumentRetriever = async (args: {
351 sessionId: string
352 projectDir: string
353 sourceDocumentPaths?: string[]
354 }): Promise<ReferenceDocumentRetriever | null> => {
355 const sourceDocumentPaths = (args.sourceDocumentPaths || []).filter(Boolean)
356 if (sourceDocumentPaths.length === 0) return null
357
358 const chunkCache = new Map<string, ReferenceChunk[]>()
359 for (const sourcePath of sourceDocumentPaths) {
360 const absolutePath = resolveSourcePath(args.projectDir, sourcePath)
361 if (!absolutePath || !fs.existsSync(absolutePath)) {
362 log.warn('[referenceDocument:grep] source missing', {
363 sessionId: args.sessionId,
364 sourcePath
365 })
366 continue
367 }
368 try {
369 const content = await fs.promises.readFile(absolutePath, 'utf-8')
370 const chunks = chunkDocument({ sourcePath, absolutePath, content })
371 chunkCache.set(sourcePath, chunks)
372 log.info('[referenceDocument:grep] chunked', {
373 sessionId: args.sessionId,
374 sourcePath,
375 chunkCount: chunks.length,
376 characterCount: content.length
377 })
378 } catch (error) {
379 log.warn('[referenceDocument:grep] read failed', {
380 sessionId: args.sessionId,
381 sourcePath,
382 message: error instanceof Error ? error.message : String(error)
383 })
384 }
385 }
386
387 const chunks = Array.from(chunkCache.values()).flat()
388 if (chunks.length === 0) return null
389
390 return {
391 search: (input: SearchInput): ReferenceDocumentSnippet[] => {
392 const queries = createQueries(input)
393 const { snippets, matchedChunkCount } = selectSnippets(chunks, queries)
394 log.info('[referenceDocument:grep] search', {
395 sessionId: args.sessionId,
396 pageId: input.pageId,
397 sourceDocumentPaths,
398 phraseQueryCount: queries.phraseQueries.length,
399 wordQueryCount: queries.wordQueries.length,
400 chunkCount: chunks.length,
401 matchedChunkCount,
402 injectedChunkCount: snippets.length
403 })
404 return snippets
405 }
406 }
407 }
408
408 lines TYPESCRIPT