dedup-runner.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. /**
  2. * I/O wrapper that connects the pure dedup algorithm in dedup.ts
  3. * to the project's filesystem + LLM. The UI layer calls these
  4. * functions; everything below is about read/write/spawn-llm so
  5. * the algorithm core stays testable without mocks of all that.
  6. */
  7. import { listDirectory, readFile, writeFile, deleteFile } from "@/commands/fs"
  8. import { streamChat } from "@/lib/llm-client"
  9. import { normalizePath } from "@/lib/path-utils"
  10. import type { LlmConfig } from "@/stores/wiki-store"
  11. import type { FileNode } from "@/types/wiki"
  12. import {
  13. detectDuplicateGroups,
  14. extractEntitySummary,
  15. mergeDuplicateGroup,
  16. rewriteIndexMd,
  17. type DedupLlmCall,
  18. type DuplicateGroup,
  19. type EntitySummary,
  20. type MergeResult,
  21. } from "./dedup"
  22. import { loadNotDuplicates } from "./dedup-storage"
  23. const WIKI_READ_CONCURRENCY = 12
  24. const WIKI_WRITE_CONCURRENCY = 12
  25. export type DedupMergeStage = "loading" | "merging" | "writing"
  26. export type DedupScanStage = "loading" | "detecting"
  27. /** Append-only process log line for UI / console. */
  28. type DedupLogFn = (message: string) => void
  29. interface ExecuteMergeOptions {
  30. signal?: AbortSignal
  31. onProgress?: (stage: DedupMergeStage) => void
  32. onLog?: DedupLogFn
  33. }
  34. interface RunDuplicateDetectionOptions {
  35. signal?: AbortSignal
  36. summaries?: EntitySummary[]
  37. onProgress?: (stage: DedupScanStage) => void
  38. onLog?: DedupLogFn
  39. }
  40. function describeLlm(llmConfig: LlmConfig): string {
  41. const provider = llmConfig.provider?.trim() || "unknown"
  42. const model = llmConfig.model?.trim() || "unknown"
  43. return `${provider}/${model}`
  44. }
  45. interface DuplicateDetectionResult {
  46. groups: DuplicateGroup[]
  47. scannedPageCount: number
  48. }
  49. /**
  50. * Run `fn` over `items` with a bounded worker pool. Items where `fn`
  51. * returns null/undefined are omitted from the result.
  52. */
  53. export async function mapWithConcurrency<T, R>(
  54. items: readonly T[],
  55. concurrency: number,
  56. fn: (item: T) => Promise<R | null | undefined>,
  57. ): Promise<R[]> {
  58. if (items.length === 0) return []
  59. const limit = Math.max(1, concurrency)
  60. const results: R[] = []
  61. let index = 0
  62. async function worker(): Promise<void> {
  63. while (true) {
  64. const i = index++
  65. if (i >= items.length) return
  66. const result = await fn(items[i])
  67. if (result !== null && result !== undefined) {
  68. results.push(result)
  69. }
  70. }
  71. }
  72. await Promise.all(
  73. Array.from({ length: Math.min(limit, items.length) }, () => worker()),
  74. )
  75. return results
  76. }
  77. async function runWithConcurrency<T>(
  78. items: readonly T[],
  79. concurrency: number,
  80. fn: (item: T) => Promise<void>,
  81. ): Promise<void> {
  82. if (items.length === 0) return
  83. const limit = Math.max(1, concurrency)
  84. let index = 0
  85. async function worker(): Promise<void> {
  86. while (true) {
  87. const i = index++
  88. if (i >= items.length) return
  89. await fn(items[i])
  90. }
  91. }
  92. await Promise.all(
  93. Array.from({ length: Math.min(limit, items.length) }, () => worker()),
  94. )
  95. }
  96. /**
  97. * Wrap streamChat into the (system, user, signal) → string shape
  98. * the dedup module expects. Same pattern page-merge uses — keeps
  99. * the algorithm modules free of any LlmConfig knowledge.
  100. */
  101. function buildDedupLlmCall(llmConfig: LlmConfig): DedupLlmCall {
  102. return async (systemPrompt, userMessage, signal) => {
  103. let result = ""
  104. let streamError: Error | null = null
  105. await new Promise<void>((resolve) => {
  106. streamChat(
  107. llmConfig,
  108. [
  109. { role: "system", content: systemPrompt },
  110. { role: "user", content: userMessage },
  111. ],
  112. {
  113. onToken: (t) => {
  114. result += t
  115. },
  116. onDone: () => resolve(),
  117. onError: (err) => {
  118. streamError = err
  119. resolve()
  120. },
  121. },
  122. signal,
  123. { temperature: 0.1 },
  124. ).catch((err) => {
  125. streamError = err instanceof Error ? err : new Error(String(err))
  126. resolve()
  127. })
  128. })
  129. if (streamError) throw streamError
  130. return result
  131. }
  132. }
  133. /** Walk a FileNode tree, yielding every .md file under a given prefix. */
  134. function* walkMd(nodes: FileNode[], prefix: string): Generator<FileNode> {
  135. for (const node of nodes) {
  136. if (node.is_dir) {
  137. if (node.children) yield* walkMd(node.children, prefix)
  138. continue
  139. }
  140. if (node.name.endsWith(".md") && node.path.includes(`${prefix}/`)) {
  141. yield node
  142. }
  143. }
  144. }
  145. /** Convert an absolute filesystem path to a wiki-relative one
  146. * (`<project>/wiki/entities/foo.md` → `wiki/entities/foo.md`). */
  147. function toWikiRelative(projectPath: string, absPath: string): string {
  148. const pp = normalizePath(projectPath)
  149. const norm = normalizePath(absPath)
  150. if (norm.startsWith(`${pp}/`)) return norm.slice(pp.length + 1)
  151. return norm
  152. }
  153. /**
  154. * Walk wiki/entities/ and wiki/concepts/, build summaries.
  155. * Pages that fail to parse (no frontmatter, etc.) are skipped
  156. * silently — they can't participate in dedup anyway.
  157. */
  158. export async function loadAllEntitySummaries(
  159. projectPath: string,
  160. ): Promise<EntitySummary[]> {
  161. const pp = normalizePath(projectPath)
  162. const tree = await listDirectory(pp)
  163. const nodes: FileNode[] = []
  164. for (const prefix of ["wiki/entities", "wiki/concepts"]) {
  165. nodes.push(...walkMd(tree, prefix))
  166. }
  167. return mapWithConcurrency(nodes, WIKI_READ_CONCURRENCY, async (node) => {
  168. try {
  169. const content = await readFile(node.path)
  170. const rel = toWikiRelative(pp, node.path)
  171. return extractEntitySummary(rel, content)
  172. } catch {
  173. return null
  174. }
  175. })
  176. }
  177. /** Read every .md under wiki/ as { path, content }. The path is
  178. * the wiki-relative form callers downstream use. */
  179. export async function loadAllWikiPages(
  180. projectPath: string,
  181. ): Promise<{ path: string; content: string }[]> {
  182. const pp = normalizePath(projectPath)
  183. const tree = await listDirectory(pp)
  184. const nodes = [...walkMd(tree, "wiki")]
  185. return mapWithConcurrency(nodes, WIKI_READ_CONCURRENCY, async (node) => {
  186. try {
  187. const content = await readFile(node.path)
  188. return { path: toWikiRelative(pp, node.path), content }
  189. } catch {
  190. return null
  191. }
  192. })
  193. }
  194. /**
  195. * Stage 1 + 2 from the user's perspective: scan the project for
  196. * duplicate-candidate groups. Reads notDuplicates whitelist from
  197. * disk so previously-confirmed false-positives don't reappear.
  198. */
  199. export async function runDuplicateDetection(
  200. projectPath: string,
  201. llmConfig: LlmConfig,
  202. options: RunDuplicateDetectionOptions = {},
  203. ): Promise<DuplicateDetectionResult> {
  204. const log = options.onLog
  205. log?.(`开始扫描,模型:${describeLlm(llmConfig)}`)
  206. options.onProgress?.("loading")
  207. log?.("正在读取实体 / 概念页面…")
  208. const summaries =
  209. options.summaries ?? (await loadAllEntitySummaries(projectPath))
  210. log?.(`已读取 ${summaries.length} 个实体 / 概念页面`)
  211. if (summaries.length < 2) {
  212. log?.("页面不足 2 个,跳过模型检测")
  213. return { groups: [], scannedPageCount: summaries.length }
  214. }
  215. options.onProgress?.("detecting")
  216. const notDup = await loadNotDuplicates(projectPath)
  217. if (notDup.length > 0) {
  218. log?.(`已加载 ${notDup.length} 组「非重复」白名单`)
  219. }
  220. log?.("正在调用模型分析重复候选…")
  221. const llm = buildDedupLlmCall(llmConfig)
  222. const groups = await detectDuplicateGroups(summaries, llm, {
  223. signal: options.signal,
  224. notDuplicates: notDup,
  225. })
  226. log?.(`模型分析完成,得到 ${groups.length} 组重复候选`)
  227. return { groups, scannedPageCount: summaries.length }
  228. }
  229. /**
  230. * Stage 3 + persistence: execute one user-confirmed merge.
  231. *
  232. * Steps:
  233. * 1. Load each group page's full content + every other wiki page
  234. * 2. Run mergeDuplicateGroup (LLM body merge + frontmatter
  235. * union + cross-reference rewrites)
  236. * 3. Snapshot every touched file to .qmai/page-history/
  237. * dedup-<timestamp>/
  238. * 4. Write canonical content
  239. * 5. Apply cross-reference rewrites
  240. * 6. Delete merged-away files
  241. * 7. Apply index.md rewrite (separate pass — index isn't in
  242. * otherWikiPages because removing references is a different
  243. * operation than slug-rewriting them)
  244. */
  245. export async function executeMerge(
  246. projectPath: string,
  247. group: DuplicateGroup,
  248. canonicalSlug: string,
  249. llmConfig: LlmConfig,
  250. options: ExecuteMergeOptions = {},
  251. ): Promise<MergeResult> {
  252. const pp = normalizePath(projectPath)
  253. const { signal, onProgress, onLog: log } = options
  254. log?.(
  255. `开始合并 ${group.slugs.join(", ")} → ${canonicalSlug},模型:${describeLlm(llmConfig)}`,
  256. )
  257. // 1. Resolve each group slug to its actual on-disk path + content
  258. onProgress?.("loading")
  259. log?.("正在读取 wiki 页面…")
  260. const allPages = await loadAllWikiPages(pp)
  261. log?.(`已读取 ${allPages.length} 个 wiki 页面`)
  262. const pathBySlug = new Map<string, string>()
  263. for (const p of allPages) {
  264. const base = p.path.split("/").pop() ?? ""
  265. if (base.endsWith(".md")) {
  266. pathBySlug.set(base.slice(0, -3), p.path)
  267. }
  268. }
  269. const groupPages: { slug: string; path: string; content: string }[] = []
  270. for (const slug of group.slugs) {
  271. const relPath = pathBySlug.get(slug)
  272. if (!relPath) {
  273. throw new Error(
  274. `Slug "${slug}" not found on disk — was the page deleted between detection and merge?`,
  275. )
  276. }
  277. const page = allPages.find((p) => p.path === relPath)
  278. if (!page) {
  279. throw new Error(`Internal: page lookup miss for ${relPath}`)
  280. }
  281. groupPages.push({ slug, path: relPath, content: page.content })
  282. }
  283. const groupPaths = new Set(groupPages.map((p) => p.path))
  284. const otherPages = allPages.filter((p) => !groupPaths.has(p.path))
  285. const llm = buildDedupLlmCall(llmConfig)
  286. onProgress?.("merging")
  287. log?.("正在调用模型合并正文…")
  288. const result = await mergeDuplicateGroup(
  289. {
  290. group: groupPages,
  291. canonicalSlug,
  292. otherWikiPages: otherPages,
  293. },
  294. llm,
  295. { signal },
  296. )
  297. log?.("模型正文合并完成")
  298. onProgress?.("writing")
  299. log?.("正在写入备份与文件…")
  300. // 2. Snapshot backup before any writes. If a write fails partway
  301. // through, the user has the pre-merge state intact in
  302. // .qmai/page-history/.
  303. const stamp = new Date().toISOString().replace(/[:.]/g, "-")
  304. const backupDir = `${pp}/.qmai/page-history/dedup-${stamp}`
  305. await runWithConcurrency(result.backup, WIKI_WRITE_CONCURRENCY, async (b) => {
  306. const sanitized = b.path.replace(/[/\\]/g, "_")
  307. await writeFile(`${backupDir}/${sanitized}`, b.content)
  308. })
  309. log?.(`已备份 ${result.backup.length} 个文件 → ${backupDir}`)
  310. // 3. Write canonical
  311. await writeFile(`${pp}/${result.canonicalPath}`, result.canonicalContent)
  312. log?.(`已写入主条目 ${result.canonicalPath}`)
  313. // 4. Apply rewrites
  314. await runWithConcurrency(result.rewrites, WIKI_WRITE_CONCURRENCY, async (r) => {
  315. await writeFile(`${pp}/${r.path}`, r.newContent)
  316. })
  317. if (result.rewrites.length > 0) {
  318. log?.(`已改写 ${result.rewrites.length} 个交叉引用页面`)
  319. }
  320. // 5. Delete merged-away pages
  321. await runWithConcurrency(result.pagesToDelete, WIKI_WRITE_CONCURRENCY, async (dead) => {
  322. try {
  323. await deleteFile(`${pp}/${dead}`)
  324. } catch (err) {
  325. // Surface as a warning — backup is still safe.
  326. console.warn(`[dedup] failed to delete ${dead}: ${err}`)
  327. log?.(`删除失败(已有备份):${dead}`)
  328. }
  329. })
  330. if (result.pagesToDelete.length > 0) {
  331. log?.(`已删除 ${result.pagesToDelete.length} 个合并掉的页面`)
  332. }
  333. // 6. Rewrite index.md to drop merged-away entries.
  334. const indexPath = `${pp}/wiki/index.md`
  335. const indexEntry = allPages.find((p) => p.path === "wiki/index.md")
  336. if (indexEntry) {
  337. const removed = new Set(
  338. group.slugs.filter((s) => s !== canonicalSlug),
  339. )
  340. const rewritten = rewriteIndexMd(indexEntry.content, removed)
  341. if (rewritten !== indexEntry.content) {
  342. await writeFile(indexPath, rewritten)
  343. log?.("已更新 wiki/index.md")
  344. }
  345. }
  346. log?.("合并完成")
  347. return result
  348. }