retrieval.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  2. import * as fs from 'node:fs'
  3. import * as os from 'node:os'
  4. import * as path from 'node:path'
  5. import { execFileSync } from 'node:child_process'
  6. import { createHash } from 'node:crypto'
  7. import { createRequire } from 'node:module'
  8. import { searchFinalized, syncFinalizedIndex, withBookLockAsync, withBookWrite, writeBatchAtomic, type EmbeddingProvider, type FinalizedSearchResult } from '../src'
  9. import { removeSync } from '../src/repo/remove'
  10. import { scanFinalized } from '../src/retrieval/source'
  11. import { CACHE_PATH } from '../src/retrieval/cache'
  12. let root: string
  13. beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-search-')) })
  14. afterEach(() => { vi.restoreAllMocks(); removeSync(root) })
  15. function write(relative: string, raw: string): string {
  16. const file = path.join(root, relative)
  17. fs.mkdirSync(path.dirname(file), { recursive: true })
  18. fs.writeFileSync(file, raw)
  19. return file
  20. }
  21. function chapter(number: number, body: string, fields = '版本: 1\n角色: 已定稿'): string {
  22. return write(`定稿/卷01/${String(number).padStart(4, '0')}-章${number}.md`, `---\n${fields}\n---\n\n${body}\n`)
  23. }
  24. function success(result: FinalizedSearchResult): Extract<FinalizedSearchResult, { ok: true }> {
  25. expect(result, JSON.stringify(result)).toMatchObject({ ok: true })
  26. if (!result.ok) throw new Error(result.reason)
  27. return result
  28. }
  29. function deferred<T>() {
  30. let resolve!: (value: T) => void
  31. const promise = new Promise<T>(done => { resolve = done })
  32. return { promise, resolve }
  33. }
  34. function provider(embed?: EmbeddingProvider['embed'], revision = 'fixture-v1', dimensions = 2): EmbeddingProvider {
  35. return {
  36. metadata: { provider: 'controlled-fixture', model: 'fixture', dimensions, revision },
  37. embed: embed ?? (async inputs => inputs.map(() => [1, 0])),
  38. embedBatch: embed ?? (async inputs => inputs.map(() => [1, 0])),
  39. }
  40. }
  41. const git = (...args: string[]): string => execFileSync('git', args, { cwd: root, encoding: 'utf8', windowsHide: true })
  42. describe('定稿来源、中文词句与定位', () => {
  43. it('单字、双字、人名子串、标点及 FTS 运算符全部按字面查询', async () => {
  44. chapter(1, '张三丰在青云剑宗等林舟。星光照着 A*B 和 "OR",还有 😀星河。')
  45. chapter(2, '只有甲乙丙丁,不含其他搜索词。')
  46. for (const query of ['张', '三丰', '青云剑', '林舟', 'A*B', '"OR"', '😀星河']) {
  47. const result = success(await searchFinalized(root, { query }))
  48. expect(result).toMatchObject({ mode: 'keyword', status: 'matches' })
  49. expect(result.hits.map(hit => hit.chapter)).toEqual([1])
  50. expect(result.hits[0]!.snippet).toContain(query)
  51. }
  52. expect(success(await searchFinalized(root, { query: '林舟 OR 甲乙' })).status).toBe('no-matches')
  53. })
  54. it('只索引规范定稿正文,排除 frontmatter、候选事实和其他工件', async () => {
  55. chapter(1, '真实正文。\n\n## 草稿候选事实\n- 候选秘密', '版本: 7\n角色: 已定稿\n备注: 页眉秘密')
  56. for (const relative of ['世界书/人物/甲.md', '草稿区/草稿/稿1.md', '定稿/卷01/摘要.md', '定稿/卷01/0000-空.md', '定稿/卷01/.隐藏.md', '定稿/卷01/子目录/0002-不规范.md', '定稿/.隐藏/0002-隐藏.md']) write(relative, '工件秘密')
  57. for (const query of ['页眉秘密', '候选秘密', '工件秘密']) {
  58. expect(success(await searchFinalized(root, { query })).hits).toEqual([])
  59. }
  60. const result = success(await searchFinalized(root, { query: '真实正文' }))
  61. expect(result.index.chapters).toBe(1)
  62. expect(result.hits[0]!.version).toBe(7)
  63. })
  64. it('CRLF、空行、重复段和长段切片能回到原文行号、版本与字节哈希', async () => {
  65. const raw = ('---\n版本: 3\n角色: 已定稿\n---\n\n\n' + '前序行\n' + '甲'.repeat(785) + '😀跨段线索' + '乙'.repeat(1700) + '\n\n尾行\n').replaceAll('\n', '\r\n')
  66. const file = write('定稿/卷02/0042-跨段.md', raw)
  67. const snapshot = await scanFinalized(root)
  68. expect(snapshot.documents[0]!.chunks.length).toBeGreaterThan(2)
  69. const result = success(await searchFinalized(root, { query: '😀跨段线索' }))
  70. for (const hit of result.hits) {
  71. expect(hit.absolutePath).toBe(file)
  72. expect(hit).toMatchObject({ volume: 2, chapter: 42, version: 3 })
  73. expect(hit.hash).toBe(createHash('sha256').update(raw).digest('hex'))
  74. expect(raw.replaceAll('\r\n', '\n').split('\n').slice(hit.startLine - 1, hit.endLine).join('\n')).toContain(hit.snippet)
  75. expect(hit.来源标注.状态).toContain('未经原文核对')
  76. expect(Object.keys(hit.来源标注)).toHaveLength(6)
  77. expect(hit.snippet).not.toMatch(/^[\uDC00-\uDFFF]|[\uD800-\uDBFF]$/)
  78. }
  79. })
  80. it('没有定稿、没有命中、部分源错误与缓存故障分别呈报', async () => {
  81. expect(success(await searchFinalized(root, { query: '线索' })).status).toBe('no-finalized')
  82. chapter(1, '林舟回家。')
  83. expect(success(await searchFinalized(root, { query: '线索' })).status).toBe('no-matches')
  84. write('定稿/卷01/0002-坏文件.md', '---\n坏 YAML: [\n---\n线索')
  85. const partial = success(await searchFinalized(root, { query: '林舟' }))
  86. expect(partial.status).toBe('partial')
  87. expect(partial.issues).toEqual([expect.objectContaining({ path: '定稿/卷01/0002-坏文件.md', code: 'invalid-document' })])
  88. expect(partial.hits[0]!.来源标注.完整性).toContain('部分')
  89. fs.unlinkSync(path.join(root, CACHE_PATH))
  90. fs.mkdirSync(path.join(root, CACHE_PATH))
  91. expect(await searchFinalized(root, { query: '林舟' })).toMatchObject({ ok: false, code: 'unsafe-cache' })
  92. })
  93. it('不可读文件不得静默变成完整搜索', async () => {
  94. chapter(1, '可读线索')
  95. const blocked = chapter(2, '隐藏线索')
  96. const open = fs.promises.open.bind(fs.promises)
  97. vi.spyOn(fs.promises, 'open').mockImplementation(((filename: fs.PathLike, flags: string | number) => {
  98. if (String(filename) === blocked) return Promise.reject(Object.assign(new Error('denied'), { code: 'EACCES' }))
  99. return open(filename, flags)
  100. }) as typeof fs.promises.open)
  101. const result = success(await searchFinalized(root, { query: '线索' }))
  102. expect(result.status).toBe('partial')
  103. expect(result.hits.map(hit => hit.chapter)).toEqual([1])
  104. expect(result.issues[0]!.code).toBe('read-error')
  105. })
  106. it('目录链接、越界文件链接和硬链接不参与正文索引', async () => {
  107. const outside = write('资料/0001-外部.md', '外部秘密')
  108. fs.mkdirSync(path.join(root, '定稿/卷01'), { recursive: true })
  109. fs.symlinkSync(path.dirname(outside), path.join(root, '定稿/卷02'), 'junction')
  110. fs.linkSync(outside, path.join(root, '定稿/卷01/0003-硬链接.md'))
  111. const result = success(await searchFinalized(root, { query: '外部秘密' }))
  112. expect(result.hits).toEqual([])
  113. expect(result.status).toBe('partial')
  114. expect(result.issues.map(issue => issue.code)).toEqual(['unsafe-path', 'unsafe-path'])
  115. })
  116. it('检查路径后被替换成其他文件时,不索引替换文件的内容', async () => {
  117. const file = chapter(1, '书仓原文')
  118. const external = write('资料/外部.md', '不应发送的外部内容')
  119. const open = fs.promises.open.bind(fs.promises)
  120. vi.spyOn(fs.promises, 'open').mockImplementation(((filename: fs.PathLike, flags: string | number) => open(String(filename) === file ? external : filename, flags)) as typeof fs.promises.open)
  121. const embeddings = provider(vi.fn(async () => [[1, 0]]))
  122. const result = success(await searchFinalized(root, { query: '外部内容', provider: embeddings }))
  123. expect(result).toMatchObject({ status: 'partial', hits: [] })
  124. expect(result.issues[0]!.code).toBe('unsafe-path')
  125. expect(embeddings.embed).not.toHaveBeenCalled()
  126. })
  127. it('不存在的书仓明确报源错误,不为它创建目录', async () => {
  128. const missing = path.join(root, '不存在的书')
  129. expect(await searchFinalized(missing, { query: '线索' })).toMatchObject({ ok: false, code: 'source-error' })
  130. expect(fs.existsSync(missing)).toBe(false)
  131. })
  132. })
  133. describe('可丢弃缓存与真实内容校准', () => {
  134. it('同大小同 mtime 修改、更名和删除会移除旧结果', async () => {
  135. const file = chapter(1, '旧港线索')
  136. const before = fs.statSync(file)
  137. expect(success(await searchFinalized(root, { query: '旧港' })).hits).toHaveLength(1)
  138. fs.writeFileSync(file, fs.readFileSync(file, 'utf8').replace('旧港', '新港'))
  139. fs.utimesSync(file, before.atime, before.mtime)
  140. expect(success(await searchFinalized(root, { query: '旧港' })).hits).toEqual([])
  141. expect(success(await searchFinalized(root, { query: '新港' })).hits).toHaveLength(1)
  142. const renamed = path.join(path.dirname(file), '0001-改名.md')
  143. fs.renameSync(file, renamed)
  144. expect(success(await searchFinalized(root, { query: '新港' })).hits[0]!.title).toBe('改名')
  145. fs.unlinkSync(renamed)
  146. const empty = success(await searchFinalized(root, { query: '新港' }))
  147. expect(empty).toMatchObject({ status: 'no-finalized', hits: [], index: { chunks: 0 } })
  148. })
  149. it('损坏和删除索引都能重建出相同结果,排序不随插入顺序变化', async () => {
  150. chapter(2, '线索在此')
  151. await searchFinalized(root, { query: '线索' })
  152. chapter(1, '线索在此')
  153. const first = success(await searchFinalized(root, { query: '线索', limit: 1 }))
  154. expect(first.hits[0]!.chapter).toBe(1)
  155. expect(first.limited).toBe(true)
  156. fs.writeFileSync(path.join(root, CACHE_PATH), 'broken sqlite')
  157. const rebuilt = success(await searchFinalized(root, { query: '线索', limit: 1 }))
  158. expect(rebuilt.index.state).toBe('rebuilt')
  159. expect(rebuilt.hits).toEqual(first.hits)
  160. fs.unlinkSync(path.join(root, CACHE_PATH))
  161. expect(success(await searchFinalized(root, { query: '线索', limit: 1 })).hits).toEqual(first.hits)
  162. })
  163. it.each(['DELETE FROM terms', 'DROP TABLE terms', 'DELETE FROM terms_data WHERE id > 10', "UPDATE metadata SET value='obsolete' WHERE key='chunk-version'"])(
  164. '缓存结构或内容损坏自动重建:%s', async sql => {
  165. chapter(1, '可靠原文线索')
  166. const first = success(await searchFinalized(root, { query: '原文线索' }))
  167. const { DatabaseSync } = createRequire(import.meta.url)('node:sqlite') as typeof import('node:sqlite')
  168. const db = new DatabaseSync(path.join(root, CACHE_PATH))
  169. try {
  170. // Only the disposable fixture disables SQLite's default shadow-table guard.
  171. if (sql.includes('terms_data')) (db as typeof db & { enableDefensive?(enabled: boolean): void }).enableDefensive?.(false)
  172. db.exec(sql)
  173. } finally { db.close() }
  174. const rebuilt = success(await searchFinalized(root, { query: '原文线索' }))
  175. expect(rebuilt.index.state).toBe('rebuilt')
  176. expect(rebuilt.hits).toEqual(first.hits)
  177. },
  178. )
  179. it('未知 SQLite 和共享缓存硬链接保持原样并明确拒绝', async () => {
  180. chapter(1, '线索')
  181. const { DatabaseSync } = createRequire(import.meta.url)('node:sqlite') as typeof import('node:sqlite')
  182. fs.mkdirSync(path.join(root, '.webnovel'))
  183. const file = path.join(root, CACHE_PATH)
  184. const db = new DatabaseSync(file)
  185. try { db.exec('CREATE TABLE author_data(value TEXT); INSERT INTO author_data VALUES (\'保留\')') } finally { db.close() }
  186. const original = fs.readFileSync(file)
  187. expect(await searchFinalized(root, { query: '线索' })).toMatchObject({ ok: false, code: 'foreign-cache' })
  188. expect(fs.readFileSync(file)).toEqual(original)
  189. fs.linkSync(file, path.join(root, '其他缓存.sqlite'))
  190. expect(await searchFinalized(root, { query: '线索' })).toMatchObject({ ok: false, code: 'unsafe-cache' })
  191. expect(fs.readFileSync(file)).toEqual(original)
  192. })
  193. it('分支切换按文件字节重新校准,搜索自身不改作品或 Git HEAD', async () => {
  194. const file = chapter(1, '旧分支线索')
  195. git('init', '--quiet')
  196. git('config', 'user.name', 'Search Test')
  197. git('config', 'user.email', 'search@example.invalid')
  198. git('config', 'commit.gpgsign', 'false')
  199. git('config', 'core.autocrlf', 'false')
  200. git('add', '.')
  201. git('commit', '--quiet', '-m', 'fixture')
  202. const firstHead = git('rev-parse', 'HEAD').trim()
  203. const original = fs.readFileSync(file, 'utf8')
  204. await searchFinalized(root, { query: '旧分支' })
  205. expect(fs.readFileSync(file, 'utf8')).toBe(original)
  206. expect(git('rev-parse', 'HEAD').trim()).toBe(firstHead)
  207. expect(git('status', '--porcelain')).toBe('')
  208. git('checkout', '--quiet', '-b', 'fixture-next')
  209. fs.writeFileSync(file, original.replace('旧分支', '新分支'))
  210. git('add', '.')
  211. git('commit', '--quiet', '-m', 'next')
  212. expect(success(await searchFinalized(root, { query: '旧分支' })).hits).toEqual([])
  213. const nextHead = git('rev-parse', 'HEAD').trim()
  214. expect(success(await searchFinalized(root, { query: '新分支' })).hits).toHaveLength(1)
  215. expect(git('rev-parse', 'HEAD').trim()).toBe(nextHead)
  216. git('checkout', '--quiet', '--detach', firstHead)
  217. expect(success(await searchFinalized(root, { query: '新分支' })).hits).toEqual([])
  218. expect(success(await searchFinalized(root, { query: '旧分支' })).hits).toHaveLength(1)
  219. expect(git('status', '--porcelain')).toBe('')
  220. })
  221. })
  222. describe('向量融合、新鲜度与取消', () => {
  223. it('融合、去重、稳定排序并复用当前版本向量', async () => {
  224. chapter(1, '归家:林舟回到故乡。')
  225. chapter(2, '他终于重返阔别多年的家园。')
  226. chapter(3, '山巅落雪。')
  227. const embed = vi.fn<EmbeddingProvider['embed']>(async (inputs, role) => inputs.map(input => role === 'query' ? [1, 0] : input.text.includes('山巅') ? [-1, 0] : input.text.includes('归家') ? [0.8, 0.2] : [1, 0]))
  228. const embeddings = provider(embed)
  229. expect(await syncFinalizedIndex(root, { getProvider: () => embeddings })).toMatchObject({ ok: true, state: { generated: 3 } })
  230. const result = success(await searchFinalized(root, { query: '归家', provider: embeddings }))
  231. expect(result.mode).toBe('hybrid')
  232. expect(result.hits.map(hit => hit.chapter)).toEqual([1, 2])
  233. expect(result.hits.map(hit => hit.matchedBy)).toEqual([['keyword', 'semantic'], ['semantic']])
  234. expect(result.index).toMatchObject({ embedded: 0, reused: 3 })
  235. const again = success(await searchFinalized(root, { query: '归家', provider: embeddings }))
  236. expect(again.index).toMatchObject({ embedded: 0, reused: 3 })
  237. expect(embed.mock.calls.map(call => call[1])).toEqual(['document', 'query', 'query'])
  238. expect(again.hits).toEqual(result.hits)
  239. const changed = provider(embed, 'fixture-v2')
  240. expect(await syncFinalizedIndex(root, { getProvider: () => changed })).toMatchObject({ ok: true, state: { generated: 3 } })
  241. })
  242. it.each(['short-batch', 'sparse-batch', 'wrong-width', 'zero-vector', 'non-finite', 'throw'] as const)('%s 明确降级而不保存错配向量', async failure => {
  243. chapter(1, '线索原文')
  244. const broken = provider(async () => {
  245. if (failure === 'throw') throw new Error('SECRET remote payload')
  246. if (failure === 'sparse-batch') return new Array(1)
  247. return failure === 'short-batch' ? [] : failure === 'wrong-width' ? [[1]] : failure === 'zero-vector' ? [[0, 0]] : [[NaN, 1]]
  248. })
  249. expect((await syncFinalizedIndex(root, { getProvider: () => broken, retry: { maxRetries: 0 } })).ok).toBe(false)
  250. const result = success(await searchFinalized(root, { query: '线索', provider: broken }))
  251. expect(result).toMatchObject({ mode: 'keyword', index: { embedded: 0, reused: 0 } })
  252. expect(result.degraded).toContain('仅检索关键词')
  253. expect(JSON.stringify(result)).not.toContain('SECRET')
  254. const recovered = provider()
  255. expect(await syncFinalizedIndex(root, { getProvider: () => recovered })).toMatchObject({ ok: true, state: { generated: 1, reused: 0 } })
  256. })
  257. it('网络等待不持锁,写入后拒绝发布旧向量', async () => {
  258. chapter(1, '旧线索原文')
  259. const entered = deferred<void>()
  260. const response = deferred<readonly (readonly number[])[]>()
  261. const embeddings = provider(async (_inputs, role) => {
  262. if (role === 'query') return [[1, 0]]
  263. entered.resolve(); return response.promise
  264. })
  265. const pending = syncFinalizedIndex(root, { getProvider: () => embeddings })
  266. await entered.promise
  267. expect(fs.existsSync(path.join(root, '.webnovel/book.lock'))).toBe(false)
  268. withBookWrite(root, () => writeBatchAtomic(root, [{ relPath: '定稿/卷01/0001-章1.md', content: '新线索原文' }]))
  269. response.resolve([[1, 0]])
  270. expect(await pending).toMatchObject({ ok: false, failure: { code: 'source-changed' } })
  271. expect(success(await searchFinalized(root, { query: '旧线索' })).hits).toEqual([])
  272. })
  273. it('提供方在等待中被替换,即使同一模型版本也拒绝迟到发布', async () => {
  274. chapter(1, '线索')
  275. const entered = deferred<void>()
  276. const response = deferred<readonly (readonly number[])[]>()
  277. let current = provider(async (_inputs, role) => {
  278. if (role === 'query') return [[1, 0]]
  279. entered.resolve(); return response.promise
  280. })
  281. const pending = syncFinalizedIndex(root, { getProvider: () => current })
  282. await entered.promise
  283. current = provider()
  284. response.resolve([[1, 0]])
  285. expect(await pending).toMatchObject({ ok: false, failure: { code: 'provider-changed' } })
  286. expect(await syncFinalizedIndex(root, { getProvider: () => current })).toMatchObject({ ok: true, state: { generated: 1 } })
  287. })
  288. it('预取消零调用;未配合的提供方也不能阻塞取消或迟到写入缓存', async () => {
  289. chapter(1, '线索')
  290. const abort = new AbortController()
  291. abort.abort()
  292. const embed = vi.fn<EmbeddingProvider['embed']>(async () => [[1, 0]])
  293. expect(await searchFinalized(root, { query: '线索', provider: provider(embed), signal: abort.signal })).toMatchObject({ code: 'cancelled' })
  294. expect(embed).not.toHaveBeenCalled()
  295. expect(fs.existsSync(path.join(root, CACHE_PATH))).toBe(false)
  296. const active = new AbortController()
  297. const entered = deferred<void>()
  298. const late = deferred<readonly (readonly number[])[]>()
  299. const delayed = provider(async () => { entered.resolve(); return late.promise })
  300. const pending = syncFinalizedIndex(root, { signal: active.signal, getProvider: () => delayed })
  301. await entered.promise
  302. active.abort()
  303. expect(await pending).toMatchObject({ ok: false, failure: { code: 'cancelled' } })
  304. late.resolve([[1, 0]])
  305. const recovered = provider()
  306. expect(await syncFinalizedIndex(root, { getProvider: () => recovered })).toMatchObject({ ok: true, state: { generated: 1 } })
  307. })
  308. it('活跃锁和待恢复事务明确拒绝,保留锁及事务证据', async () => {
  309. chapter(1, '线索')
  310. const release = deferred<void>()
  311. const held = withBookLockAsync(root, () => release.promise)
  312. const lock = fs.readFileSync(path.join(root, '.webnovel/book.lock'), 'utf8')
  313. try {
  314. expect(await searchFinalized(root, { query: '线索' })).toMatchObject({ ok: false, code: 'book-busy' })
  315. expect(fs.readFileSync(path.join(root, '.webnovel/book.lock'), 'utf8')).toBe(lock)
  316. } finally { release.resolve(); await held }
  317. const evidence = write('.webnovel/transactions/unfinished/manifest.json', '{"incomplete":true}')
  318. expect(await searchFinalized(root, { query: '线索' })).toMatchObject({ ok: false, code: 'recovery-required' })
  319. expect(fs.readFileSync(evidence, 'utf8')).toBe('{"incomplete":true}')
  320. })
  321. it.each([{ query: '' }, { query: ' ' }, { query: '\0' }, { query: '甲'.repeat(257) }, { query: '线索', limit: 0 }, { query: '线索', limit: 1.5 }])('拒绝无效查询 %j', async options => {
  322. expect(await searchFinalized(root, options)).toMatchObject({ ok: false, code: 'invalid-query' })
  323. expect(fs.existsSync(path.join(root, CACHE_PATH))).toBe(false)
  324. })
  325. })