retrieval-checks.mjs 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. import assert from 'node:assert/strict'
  2. import * as fs from 'node:fs'
  3. import * as path from 'node:path'
  4. import { pathToFileURL } from 'node:url'
  5. import { createHash } from 'node:crypto'
  6. import { createServer } from 'node:http'
  7. import { execFileSync } from 'node:child_process'
  8. import { checkAuxiliary } from './auxiliary-checks.mjs'
  9. const deferred = () => {
  10. let resolve
  11. const promise = new Promise(done => { resolve = done })
  12. return { promise, resolve }
  13. }
  14. /** Actual Loader + scoped tools + installed embedding plugin + loopback HTTP. */
  15. export async function checkSearch({ root, workspace, main, child, ctx, service, execute, check, toggle, host }) {
  16. const { credentialRef } = await import(host === undefined ? '@deepseek-ai/dsh-credentials' : pathToFileURL(host.entry('@deepseek-ai/dsh-credentials')).href)
  17. const bookId = 'loader-search'
  18. const book = path.join(workspace, '检索验收书')
  19. const put = (relative, body, base = book) => {
  20. const file = path.join(base, relative)
  21. fs.mkdirSync(path.dirname(file), { recursive: true })
  22. fs.writeFileSync(file, body)
  23. return file
  24. }
  25. put('作品契约/契约.md', `---\n书id: ${bookId}\n---\n# 检索验收书\n`)
  26. const first = put('定稿/卷01/0001-归家.md', '---\n版本: 7\n角色: 已定稿\n备注: 页眉秘密\n---\n\n归家:林舟回到故乡。\n\n## 草稿候选事实\n- 候选秘密\n')
  27. put('定稿/卷01/0002-故乡.md', '---\n版本: 1\n角色: 已定稿\n---\n\n他终于重返阔别多年的家园。\n')
  28. put('草稿区/草稿/稿1.md', '未定稿秘密')
  29. const git = (...args) => execFileSync('git', args, { cwd: book, encoding: 'utf8', windowsHide: true })
  30. git('init', '--quiet')
  31. git('config', 'user.name', 'Search Loader')
  32. git('config', 'user.email', 'search-loader@example.invalid')
  33. git('config', 'commit.gpgsign', 'false')
  34. git('config', 'core.autocrlf', 'false')
  35. git('add', '.')
  36. git('commit', '--quiet', '-m', 'fixture')
  37. const originalHead = git('rev-parse', 'HEAD')
  38. const originalText = fs.readFileSync(first, 'utf8')
  39. const requests = []
  40. let held
  41. const server = createServer((req, res) => {
  42. void (async () => {
  43. let text = ''
  44. for await (const part of req) text += part
  45. const body = JSON.parse(text)
  46. assert.equal(req.url, '/v1/embeddings')
  47. assert.equal(req.headers.authorization, 'Bearer fixture-search-key')
  48. requests.push(body)
  49. if (held) {
  50. const pause = held
  51. pause.entered.resolve()
  52. await pause.release.promise
  53. }
  54. if (res.destroyed) return
  55. res.setHeader('content-type', 'application/json')
  56. const data = body.input.map((input, index) => ({
  57. index,
  58. embedding: Array.from({ length: body.dimensions }, (_, dimension) => dimension === 0 ? 1 : dimension === 1 && input.startsWith('document:\n') && input.includes('归家') ? 0.2 : 0),
  59. })).reverse()
  60. res.end(JSON.stringify({ data }))
  61. })().catch(() => res.destroy())
  62. })
  63. await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
  64. const ns = 'webnovel-embeddings'
  65. const search = async (query = '归家', agent = main, signal) => {
  66. const result = await execute(agent, 'novel_search_finalized', { bookId, query }, signal)
  67. assert.equal(result.isError, false, JSON.stringify(result))
  68. return result.value
  69. }
  70. const manage = async (action = 'status') => {
  71. const result = await execute(main, 'novel_index_manage', { bookId, action })
  72. assert.equal(result.isError, false, JSON.stringify(result))
  73. assert.equal(result.value.ok, true, JSON.stringify(result))
  74. return result.value.index
  75. }
  76. const ready = async () => {
  77. const deadline = Date.now() + 10_000
  78. while (Date.now() < deadline) {
  79. const state = await manage()
  80. if (state.phase === 'ready') return state
  81. if (state.phase === 'failed') assert.fail(JSON.stringify(state))
  82. await new Promise(resolve => setTimeout(resolve, 30))
  83. }
  84. assert.fail('background index did not finish: ' + JSON.stringify(await manage()))
  85. }
  86. const pause = () => { held = { entered: deferred(), release: deferred() }; return held }
  87. const release = () => { held?.release.resolve(); held = undefined }
  88. try {
  89. await check('定稿检索真实权限、关键词与原文定位', async () => {
  90. const result = await search('林舟')
  91. assert.equal(result.ok, true, JSON.stringify(result))
  92. assert.equal(result.mode, 'keyword')
  93. assert.equal(result.hits.length, 1)
  94. const hit = result.hits[0]
  95. assert.equal(hit.absolutePath, first)
  96. assert.equal(hit.version, 7)
  97. assert.equal(hit.hash, createHash('sha256').update(originalText).digest('hex'))
  98. assert.match(hit.来源标注.状态, /未经原文核对/)
  99. assert.ok(originalText.split('\n').slice(hit.startLine - 1, hit.endLine).join('\n').includes(hit.snippet))
  100. const read = await execute(main, 'read', { file_path: hit.absolutePath })
  101. assert.equal(read.isError, false, JSON.stringify(read))
  102. assert.match(JSON.stringify(read), /林舟回到故乡/)
  103. assert.equal(requests.length, 0)
  104. for (const agent of [child, undefined]) {
  105. for (const name of ['novel_search_finalized', 'novel_index_manage']) {
  106. const denied = await execute(agent, name, { bookId, query: '归家' })
  107. assert.equal(denied.isError, true)
  108. assert.match(JSON.stringify(denied), /UNKNOWN_TOOL/)
  109. }
  110. }
  111. const elsewhere = path.join(root, 'search-other-workspace')
  112. const otherBook = path.join(elsewhere, '同id书')
  113. put('作品契约/契约.md', `---\n书id: ${bookId}\n---\n另一工作范围`, otherBook)
  114. put('定稿/卷01/0001-独立.md', '其他工作区线索', otherBook)
  115. const other = await service('agents').create({ sessionId: 'loader-search-other', meta: { cwd: elsewhere } })
  116. try {
  117. assert.equal((await search('林舟', other.agent)).hits.length, 0)
  118. assert.equal((await search('其他工作区线索', other.agent)).hits[0].absolutePath, path.join(otherBook, '定稿/卷01/0001-独立.md'))
  119. } finally { await other.dispose() }
  120. })
  121. await check('独立嵌入提供方真实 HTTP 混合检索与配置刷新', async () => {
  122. await service('credentials').set(credentialRef('WEBNOVEL_SEARCH_TEST_KEY'), 'fixture-search-key')
  123. await service('settings').update(ns, {
  124. enabled: true, protocol: 'openai-compatible', baseURL: `http://127.0.0.1:${server.address().port}/v1`,
  125. model: 'controlled-fixture', dimensions: 3, apiKeyEnv: 'WEBNOVEL_SEARCH_TEST_KEY',
  126. documentPrefix: 'document:', queryPrefix: 'query:', maxRetries: 0,
  127. })
  128. await manage('enable')
  129. assert.equal((await ready()).generated, 2)
  130. const result = await search()
  131. assert.equal(result.mode, 'hybrid', JSON.stringify(result))
  132. assert.deepEqual(result.hits.map(hit => hit.chapter), [1, 2])
  133. assert.deepEqual(result.hits.map(hit => hit.matchedBy), [['keyword', 'semantic'], ['semantic']])
  134. assert.equal(result.index.embedded, 0)
  135. assert.equal(requests.length, 2)
  136. assert.ok(requests[0].input.every(text => text.startsWith('document:\n')))
  137. assert.deepEqual(requests[1].input, ['query:\n归家'])
  138. assert.ok(!/页眉秘密|候选秘密|未定稿秘密/.test(JSON.stringify(requests)))
  139. const cached = await search()
  140. assert.equal(cached.index.embedded, 0)
  141. assert.equal(cached.index.reused, 2)
  142. await service('settings').update(ns, { model: 'fixture-next', dimensions: 4 })
  143. await manage('update')
  144. assert.equal((await ready()).generated, 2)
  145. const changed = await search()
  146. assert.equal(changed.mode, 'hybrid')
  147. assert.equal(changed.index.embedded, 0)
  148. assert.equal(changed.index.reused, 2)
  149. assert.equal(requests.at(-1).dimensions, 4)
  150. assert.equal(git('rev-parse', 'HEAD'), originalHead)
  151. assert.equal(fs.readFileSync(first, 'utf8'), originalText)
  152. assert.equal(git('status', '--porcelain'), '')
  153. })
  154. await check('嵌入提供方卸载、晚到与取消不污染缓存', async () => {
  155. await service('settings').update(ns, { dimensions: 5 })
  156. const firstPause = pause()
  157. await manage('update')
  158. await firstPause.entered.promise
  159. assert.equal(fs.existsSync(path.join(book, '.webnovel/book.lock')), false)
  160. assert.equal((await manage('pause')).phase, 'paused')
  161. release()
  162. await manage('resume')
  163. assert.equal((await ready()).generated, 2)
  164. const retried = await search()
  165. assert.equal(retried.index.embedded, 0)
  166. assert.equal(retried.index.reused, 2)
  167. await service('settings').update(ns, { dimensions: 6 })
  168. const secondPause = pause()
  169. await manage('update')
  170. await secondPause.entered.promise
  171. await toggle('embedding', true)
  172. release()
  173. assert.equal((await search()).mode, 'keyword')
  174. await toggle('embedding', false)
  175. await manage('update')
  176. assert.equal((await ready()).generated, 2)
  177. const restored = await search()
  178. assert.equal(restored.mode, 'hybrid')
  179. assert.equal(restored.index.embedded, 0)
  180. assert.equal(git('rev-parse', 'HEAD'), originalHead)
  181. assert.equal(fs.readFileSync(first, 'utf8'), originalText)
  182. })
  183. await checkAuxiliary({ book, bookId, main, service, execute, check, manage, ready, toggle, host })
  184. } finally {
  185. release()
  186. await manage('disable')
  187. await service('settings').update(ns, { enabled: false })
  188. server.closeAllConnections()
  189. await new Promise(resolve => server.close(resolve))
  190. }
  191. }