foreshadowing-cleanup-runner.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. /**
  2. * I/O wrapper for foreshadowing cleanup: scan + execute + backup + sync docs.
  3. */
  4. import {
  5. readFile,
  6. writeFile,
  7. deleteFile,
  8. listDirectory,
  9. fileExists,
  10. createDirectory,
  11. } from "@/commands/fs"
  12. import { streamChat } from "@/lib/llm-client"
  13. import { normalizePath } from "@/lib/path-utils"
  14. import type { LlmConfig } from "@/stores/wiki-store"
  15. import { useWikiStore } from "@/stores/wiki-store"
  16. import {
  17. applyBulkDeleteAndAbandon,
  18. applyCleanupIssue,
  19. buildOverview,
  20. defaultCleanupAction,
  21. detectCleanupIssues,
  22. toForeshadowingSummary,
  23. type CleanupApplyAction,
  24. type CleanupBatchProgress,
  25. type CleanupIssue,
  26. type CleanupLlmCall,
  27. } from "@/lib/foreshadowing-cleanup"
  28. import {
  29. loadForeshadowingTracker,
  30. saveForeshadowingTracker,
  31. type ForeshadowingStore,
  32. } from "@/lib/novel/foreshadowing-tracker"
  33. import { writeForeshadowingMd } from "@/lib/novel/tracking-files"
  34. import {
  35. exportStructuredMemoryToWiki,
  36. finalizeProjectMemoryRebuild,
  37. listSnapshots,
  38. loadSnapshot,
  39. } from "@/lib/novel/chapter-ingest"
  40. import { loadForeshadowingKeep } from "@/lib/foreshadowing-cleanup-cache"
  41. import type { FileNode } from "@/types/wiki"
  42. export type ForeshadowingCleanupScanStage = "loading" | "detecting"
  43. export type ForeshadowingCleanupApplyStage = "loading" | "applying" | "writing"
  44. type CleanupLogFn = (message: string) => void
  45. export interface ForeshadowingCleanupScanProgress {
  46. stage: ForeshadowingCleanupScanStage
  47. /** 0–100; loading uses a small fixed value, detecting follows batches */
  48. percent: number
  49. batch?: CleanupBatchProgress
  50. }
  51. function describeLlm(llmConfig: LlmConfig): string {
  52. const provider = llmConfig.provider?.trim() || "unknown"
  53. const model = llmConfig.model?.trim() || "unknown"
  54. return `${provider}/${model}`
  55. }
  56. function buildCleanupLlmCall(llmConfig: LlmConfig): CleanupLlmCall {
  57. return async (systemPrompt, userMessage, signal) => {
  58. let result = ""
  59. let streamError: Error | null = null
  60. await new Promise<void>((resolve) => {
  61. streamChat(
  62. llmConfig,
  63. [
  64. { role: "system", content: systemPrompt },
  65. { role: "user", content: userMessage },
  66. ],
  67. {
  68. onToken: (t) => {
  69. result += t
  70. },
  71. onDone: () => resolve(),
  72. onError: (err) => {
  73. streamError = err
  74. resolve()
  75. },
  76. },
  77. signal,
  78. { temperature: 0.1 },
  79. ).catch((err) => {
  80. streamError = err instanceof Error ? err : new Error(String(err))
  81. resolve()
  82. })
  83. })
  84. if (streamError) throw streamError
  85. return result
  86. }
  87. }
  88. export async function resolveCurrentChapter(projectPath: string): Promise<number> {
  89. const numbers = await listSnapshots(projectPath)
  90. const positive = numbers.filter((n) => n > 0)
  91. if (positive.length === 0) return 1
  92. return Math.max(...positive)
  93. }
  94. interface ForeshadowingCleanupScanResult {
  95. issues: CleanupIssue[]
  96. scannedItemCount: number
  97. currentChapter: number
  98. overview: ReturnType<typeof buildOverview>
  99. store: ForeshadowingStore
  100. }
  101. export async function runForeshadowingCleanupScan(
  102. projectPath: string,
  103. llmConfig: LlmConfig,
  104. options: {
  105. signal?: AbortSignal
  106. onProgress?: (progress: ForeshadowingCleanupScanProgress) => void
  107. onLog?: CleanupLogFn
  108. } = {},
  109. ): Promise<ForeshadowingCleanupScanResult> {
  110. const log = options.onLog
  111. const report = (progress: ForeshadowingCleanupScanProgress) => {
  112. options.onProgress?.(progress)
  113. }
  114. const pp = normalizePath(projectPath)
  115. log?.(`开始扫描伏笔,模型:${describeLlm(llmConfig)}`)
  116. report({ stage: "loading", percent: 2 })
  117. log?.("正在读取伏笔追踪器…")
  118. const store = await loadForeshadowingTracker(pp)
  119. const currentChapter = await resolveCurrentChapter(pp)
  120. const overview = buildOverview(store)
  121. log?.(
  122. `已读取 ${store.items.length} 条伏笔(活跃 ${overview.active} / 已回收 ${overview.resolved} / 已放弃 ${overview.abandoned}),当前约第 ${currentChapter} 章`,
  123. )
  124. report({ stage: "loading", percent: 8 })
  125. if (store.items.length === 0) {
  126. log?.("无伏笔数据,跳过检测")
  127. report({ stage: "detecting", percent: 100 })
  128. return {
  129. issues: [],
  130. scannedItemCount: 0,
  131. currentChapter,
  132. overview,
  133. store,
  134. }
  135. }
  136. report({ stage: "detecting", percent: 10 })
  137. const keep = await loadForeshadowingKeep(pp)
  138. if (keep.length > 0) {
  139. log?.(`已加载 ${keep.length} 组「保留」白名单`)
  140. }
  141. const activeCount = overview.active
  142. const estimatedBatches = Math.max(1, Math.ceil(activeCount / 80))
  143. log?.(
  144. `正在调用模型分析伏笔问题(活跃 ${activeCount} 条,约 ${estimatedBatches} 批)…`,
  145. )
  146. const llm = buildCleanupLlmCall(llmConfig)
  147. const summaries = store.items.map(toForeshadowingSummary)
  148. const issues = await detectCleanupIssues(summaries, currentChapter, llm, {
  149. signal: options.signal,
  150. keepKeys: keep,
  151. onBatchProgress: (batch) => {
  152. // loading 10% + detecting 90%
  153. const base = 10
  154. const span = 90
  155. const completed =
  156. batch.phase === "batch_done" ? batch.current : batch.current - 1
  157. const percent = Math.min(
  158. 99,
  159. Math.round(base + (completed / Math.max(1, batch.total)) * span),
  160. )
  161. if (batch.phase === "batch_start") {
  162. log?.(
  163. `分析第 ${batch.current}/${batch.total} 批(本批 ${batch.batchSize} 条,活跃共 ${batch.activeCount} 条)…`,
  164. )
  165. } else {
  166. log?.(`第 ${batch.current}/${batch.total} 批完成`)
  167. }
  168. report({ stage: "detecting", percent, batch })
  169. },
  170. })
  171. report({ stage: "detecting", percent: 100 })
  172. log?.(
  173. `分析完成:${issues.filter((i) => i.kind === "duplicate").length} 组重复,${issues.filter((i) => i.kind === "noise").length} 条噪声,${issues.filter((i) => i.kind === "stale").length} 条失效`,
  174. )
  175. return {
  176. issues,
  177. scannedItemCount: store.items.length,
  178. currentChapter,
  179. overview,
  180. store,
  181. }
  182. }
  183. async function backupFiles(
  184. projectPath: string,
  185. stamp: string,
  186. ): Promise<string> {
  187. const pp = normalizePath(projectPath)
  188. const backupDir = `${pp}/.qmai/page-history/foreshadowing-${stamp}`
  189. await createDirectory(backupDir)
  190. const trackerPath = `${pp}/.novel/foreshadowing-tracker.json`
  191. if (await fileExists(trackerPath)) {
  192. const content = await readFile(trackerPath)
  193. await writeFile(`${backupDir}/foreshadowing-tracker.json`, content)
  194. }
  195. for (const rel of [
  196. "wiki/tracking/伏笔.md",
  197. "QM/tracking/伏笔.md",
  198. "wiki/memory/foreshadowing-tracker.md",
  199. "QM/memory/foreshadowing-tracker.md",
  200. ]) {
  201. const abs = `${pp}/${rel}`
  202. try {
  203. if (await fileExists(abs)) {
  204. const content = await readFile(abs)
  205. const sanitized = rel.replace(/[/\\]/g, "_")
  206. await writeFile(`${backupDir}/${sanitized}`, content)
  207. }
  208. } catch {
  209. // optional paths
  210. }
  211. }
  212. return backupDir
  213. }
  214. async function syncDerivedDocs(projectPath: string, store: ForeshadowingStore): Promise<void> {
  215. const pp = normalizePath(projectPath)
  216. const resolvedRecords = store.items
  217. .filter((f) => f.status === "resolved" && f.resolvedChapter != null)
  218. .map((f) => ({
  219. id: f.id,
  220. resolvedInChapter: f.resolvedChapter!,
  221. resolution: `伏笔「${f.name}」在第${f.resolvedChapter}章回收`,
  222. }))
  223. try {
  224. await writeForeshadowingMd(pp, store.items, resolvedRecords)
  225. } catch (err) {
  226. console.warn("[ForeshadowingCleanup] writeForeshadowingMd failed:", err)
  227. }
  228. try {
  229. const numbers = await listSnapshots(pp)
  230. const latestPositive = numbers.filter((n) => n > 0).sort((a, b) => b - a)[0]
  231. if (latestPositive != null) {
  232. const snap = await loadSnapshot(pp, latestPositive)
  233. if (snap) {
  234. await exportStructuredMemoryToWiki(pp, snap)
  235. }
  236. }
  237. } catch (err) {
  238. console.warn("[ForeshadowingCleanup] memory doc rewrite failed:", err)
  239. }
  240. }
  241. export async function executeCleanupTask(
  242. projectPath: string,
  243. issue: CleanupIssue,
  244. options: {
  245. canonicalId?: string
  246. action?: CleanupApplyAction
  247. signal?: AbortSignal
  248. onProgress?: (stage: ForeshadowingCleanupApplyStage) => void
  249. onLog?: CleanupLogFn
  250. currentChapter?: number
  251. } = {},
  252. ): Promise<void> {
  253. const pp = normalizePath(projectPath)
  254. const log = options.onLog
  255. const action = options.action ?? defaultCleanupAction(issue.kind)
  256. options.signal?.throwIfAborted()
  257. const actionLabel =
  258. action === "delete" ? "删除" : action === "abandon" ? "放弃" : "合并"
  259. log?.(
  260. `开始${actionLabel} ${issue.kind}:${issue.ids.join(", ")}${
  261. action === "merge" ? ` → ${options.canonicalId || issue.canonicalId}` : ""
  262. }`,
  263. )
  264. options.onProgress?.("loading")
  265. const store = await loadForeshadowingTracker(pp)
  266. const present = issue.ids.filter((id) => store.items.some((f) => f.id === id))
  267. const missing = issue.ids.filter((id) => !present.includes(id))
  268. if (present.length === 0) {
  269. throw new Error(
  270. `伏笔已不存在:${issue.ids.join(", ")} — 可能已被先前任务处理或重建覆盖`,
  271. )
  272. }
  273. if (missing.length > 0) {
  274. if (action === "delete") {
  275. log?.(`部分条目已不存在,将删除剩余 ${present.length} 条:${present.join(", ")}`)
  276. } else {
  277. throw new Error(
  278. `伏笔已不存在:${missing.join(", ")} — 可能已被先前任务处理或重建覆盖`,
  279. )
  280. }
  281. }
  282. options.onProgress?.("applying")
  283. const stamp = new Date().toISOString().replace(/[:.]/g, "-")
  284. const backupDir = await backupFiles(pp, stamp)
  285. log?.(`已备份 → ${backupDir}`)
  286. const effectiveIssue =
  287. action === "delete" && missing.length > 0
  288. ? { ...issue, ids: present }
  289. : issue
  290. applyCleanupIssue(store, effectiveIssue, {
  291. canonicalId: options.canonicalId,
  292. reason: issue.reason,
  293. chapter: options.currentChapter,
  294. action,
  295. })
  296. options.onProgress?.("writing")
  297. await saveForeshadowingTracker(pp, store)
  298. log?.("已写入 foreshadowing-tracker.json")
  299. await syncDerivedDocs(pp, store)
  300. log?.("已同步 tracking / memory 文档")
  301. useWikiStore.getState().bumpDataVersion()
  302. log?.("处理完成")
  303. }
  304. export async function executeBulkNoiseAndStaleCleanup(
  305. projectPath: string,
  306. options: {
  307. deleteIds: readonly string[]
  308. abandonIds: readonly string[]
  309. currentChapter?: number
  310. onLog?: CleanupLogFn
  311. onProgress?: (stage: ForeshadowingCleanupApplyStage) => void
  312. signal?: AbortSignal
  313. },
  314. ): Promise<{ deleted: number; abandoned: number }> {
  315. const pp = normalizePath(projectPath)
  316. const log = options.onLog
  317. const deleteIds = [...new Set(options.deleteIds.filter(Boolean))]
  318. const abandonIds = [...new Set(options.abandonIds.filter(Boolean))].filter(
  319. (id) => !deleteIds.includes(id),
  320. )
  321. if (deleteIds.length === 0 && abandonIds.length === 0) {
  322. log?.("没有可清理的噪声/失效条目")
  323. return { deleted: 0, abandoned: 0 }
  324. }
  325. options.signal?.throwIfAborted()
  326. log?.(
  327. `开始一键清理:删除噪声 ${deleteIds.length} 条,放弃失效 ${abandonIds.length} 条`,
  328. )
  329. options.onProgress?.("loading")
  330. const store = await loadForeshadowingTracker(pp)
  331. options.onProgress?.("applying")
  332. const stamp = new Date().toISOString().replace(/[:.]/g, "-")
  333. const backupDir = await backupFiles(pp, `bulk-${stamp}`)
  334. log?.(`已备份 → ${backupDir}`)
  335. const result = applyBulkDeleteAndAbandon(store, {
  336. deleteIds,
  337. abandonIds,
  338. reason: "一键清理噪声/失效",
  339. chapter: options.currentChapter,
  340. })
  341. log?.(`已处理:删除 ${result.deleted} 条,放弃 ${result.abandoned} 条`)
  342. options.onProgress?.("writing")
  343. await saveForeshadowingTracker(pp, store)
  344. log?.("已写入 foreshadowing-tracker.json")
  345. await syncDerivedDocs(pp, store)
  346. log?.("已同步 tracking / memory 文档")
  347. useWikiStore.getState().bumpDataVersion()
  348. log?.("一键清理完成")
  349. return result
  350. }
  351. export async function rebuildForeshadowingFromSnapshots(
  352. projectPath: string,
  353. options: { onLog?: CleanupLogFn } = {},
  354. ): Promise<void> {
  355. const pp = normalizePath(projectPath)
  356. const log = options.onLog
  357. log?.("正在备份当前伏笔数据…")
  358. const stamp = new Date().toISOString().replace(/[:.]/g, "-")
  359. const backupDir = await backupFiles(pp, `rebuild-${stamp}`)
  360. log?.(`已备份 → ${backupDir}`)
  361. log?.("正在从快照全量重建伏笔追踪器…")
  362. await finalizeProjectMemoryRebuild(pp)
  363. const store = await loadForeshadowingTracker(pp)
  364. const overview = buildOverview(store)
  365. log?.(
  366. `重建完成:共 ${overview.total} 条(活跃 ${overview.active} / 已回收 ${overview.resolved} / 已放弃 ${overview.abandoned})`,
  367. )
  368. }
  369. export interface InvalidSnapshotInfo {
  370. fileName: string
  371. path: string
  372. chapterNumber: number
  373. foreshadowingChangeCount: number
  374. }
  375. function* walkFiles(nodes: FileNode[], prefix: string): Generator<FileNode> {
  376. for (const node of nodes) {
  377. if (node.is_dir) {
  378. if (node.children) yield* walkFiles(node.children, prefix)
  379. continue
  380. }
  381. if (node.path.includes(prefix)) yield node
  382. }
  383. }
  384. export async function listInvalidSnapshots(
  385. projectPath: string,
  386. ): Promise<InvalidSnapshotInfo[]> {
  387. const pp = normalizePath(projectPath)
  388. let tree: FileNode[]
  389. try {
  390. tree = await listDirectory(pp)
  391. } catch {
  392. return []
  393. }
  394. const results: InvalidSnapshotInfo[] = []
  395. for (const node of walkFiles(tree, ".novel/snapshots")) {
  396. if (!node.name.endsWith(".snapshot.json")) continue
  397. try {
  398. const raw = await readFile(node.path)
  399. const data = JSON.parse(raw) as {
  400. chapterNumber?: number
  401. foreshadowingChanges?: string[]
  402. }
  403. const chapterNumber = data.chapterNumber
  404. if (typeof chapterNumber !== "number" || chapterNumber > 0) continue
  405. results.push({
  406. fileName: node.name,
  407. path: node.path,
  408. chapterNumber,
  409. foreshadowingChangeCount: Array.isArray(data.foreshadowingChanges)
  410. ? data.foreshadowingChanges.length
  411. : 0,
  412. })
  413. } catch {
  414. // skip unreadable
  415. }
  416. }
  417. return results.sort((a, b) => a.chapterNumber - b.chapterNumber)
  418. }
  419. export async function deleteInvalidSnapshots(
  420. _projectPath: string,
  421. paths: string[],
  422. options: { onLog?: CleanupLogFn } = {},
  423. ): Promise<number> {
  424. const log = options.onLog
  425. let deleted = 0
  426. for (const path of paths) {
  427. try {
  428. await deleteFile(path)
  429. // also try companion .md
  430. if (path.endsWith(".json")) {
  431. const md = path.replace(/\.json$/, ".md")
  432. try {
  433. if (await fileExists(md)) await deleteFile(md)
  434. } catch {
  435. // ignore
  436. }
  437. }
  438. deleted++
  439. log?.(`已删除 ${path.split("/").pop()}`)
  440. } catch (err) {
  441. log?.(
  442. `删除失败 ${path}: ${err instanceof Error ? err.message : String(err)}`,
  443. )
  444. }
  445. }
  446. return deleted
  447. }