test: 暂存代码

This commit is contained in:
电摇小子
2026-08-06 20:29:25 +08:00
parent 307d247660
commit ad4b3a8074
43 changed files with 9978 additions and 512 deletions
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import {
buildFinalEvidence,
evidenceIdentity,
sanitizeAnswerCitations
} from '../../src/main/services/ai-search-evidence'
import type { AiSearchPipelineEvidence } from '../../src/shared/ai-search'
const candidate = (
index: number,
options: Partial<AiSearchPipelineEvidence> = {}
): AiSearchPipelineEvidence => ({
chunkId: `chunk-${index}`,
conversationId: index % 2 ? 'fitness-group-a' : 'fitness-group-b',
conversationName: index % 2 ? '健身群 A' : '健身群 B',
conversationType: 'group',
messageId: `message-${index}`,
senderId: index % 3 ? 'member-yang' : 'member-dongfang',
sender: index % 3 ? '杨伟' : '东方小唠',
startTime: 1_785_895_200_000 + index,
endTime: 1_785_895_200_000 + index,
timestamp: 1_785_895_200_000 + index,
messageIds: [`message-${index}`],
text: `${index} 条去健身相关消息`,
score: -index,
...options
})
describe('Final Evidence builder', () => {
it('uses exactly the same program-owned E1-E8 collection for final context', () => {
const candidates = Array.from({ length: 16 }, (_, index) => candidate(index + 1))
const result = buildFinalEvidence(candidates, 8)
expect(result.candidateCount).toBe(16)
expect(result.evidence).toHaveLength(8)
expect(result.evidence.map((item) => item.id)).toEqual([
'E1',
'E2',
'E3',
'E4',
'E5',
'E6',
'E7',
'E8'
])
expect(result.evidence.map(evidenceIdentity)).toEqual(
Array.from({ length: 8 }, (_, index) => evidenceIdentity(candidate(16 - index)))
)
expect(result.aggregation.messageCount).toBe(8)
expect(result.aggregation.peopleCount).toBe(2)
expect(result.aggregation.conversationCount).toBe(2)
})
it('does not merge same message ids from different conversations', () => {
const first = candidate(1, { conversationId: 'conversation-a', messageId: 'same-message-id' })
const second = candidate(2, { conversationId: 'conversation-b', messageId: 'same-message-id' })
const result = buildFinalEvidence([first, second], 8)
expect(result.evidence).toHaveLength(2)
expect(result.evidence.map(evidenceIdentity)).toEqual([
'conversation-b\u0000same-message-id',
'conversation-a\u0000same-message-id'
])
})
it('removes citations which do not resolve to Final Evidence', () => {
const evidence = buildFinalEvidence([candidate(1), candidate(2)], 8).evidence
const result = sanitizeAnswerCitations('杨伟提到健身。[E1] 另有无效来源。[E10][E23]', evidence)
expect(result.status).toBe('sanitized')
expect(result.invalidCitationIds).toEqual(['E10', 'E23'])
expect(result.answer).toContain('[E1]')
expect(result.answer).not.toMatch(/\[E(?:10|23)\]/)
})
})
@@ -0,0 +1,732 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { chatState, listContactsAsync } = vi.hoisted(() => ({
chatState: { ready: true },
listContactsAsync: vi.fn()
}))
vi.mock('../../src/main/services/chat-service', () => ({
isReady: () => chatState.ready,
listContactsAsync
}))
import { AiSearchPipelineService } from '../../src/main/services/ai-search-pipeline-service'
import type { KnowledgeEvidence } from '../../src/shared/knowledge'
const makeCandidate = (index: number): KnowledgeEvidence => ({
chunkId: `chunk-${index}`,
conversationId: index % 2 ? 'fitness-group-a' : 'fitness-group-b',
startTime: 1785900000000 + index,
endTime: 1785900000000 + index,
messageId: `message-${index}`,
sender: index % 2 ? '杨伟' : '东方小唠',
senderId: index % 2 ? 'member-yang' : 'member-dongfang',
timestamp: 1785900000000 + index,
messageIds: [`message-${index}`],
text: `candidate-${index} 去健身`,
score: -index
})
describe('AiSearchPipelineService', () => {
const knowledge = { search: vi.fn() }
const aiProvider = { getRuntimeConfig: vi.fn(), chat: vi.fn() }
beforeEach(() => {
chatState.ready = true
listContactsAsync.mockReset()
knowledge.search.mockReset()
aiProvider.getRuntimeConfig.mockReset()
aiProvider.chat.mockReset()
listContactsAsync.mockResolvedValue([
{
md5: 'fitness-group',
m_nsUsrName: 'fitness-group@chatroom',
m_nsNickName: '健身交流组',
type: 'group'
}
])
knowledge.search.mockResolvedValue({
source: 'knowledge',
state: 'ready',
indexedMessageCount: 2_000,
indexedChunkCount: 300,
totalMessages: 2_000,
evidence: [
{
chunkId: 'chunk-1',
conversationId: 'fitness-group',
startTime: 1785900000000,
endTime: 1785900000000,
messageId: 'message-1',
sender: '小明',
senderId: 'wxid_fixture',
timestamp: 1785900000000,
messageIds: ['message-1'],
text: '今天下班去健身。'
}
]
})
aiProvider.getRuntimeConfig.mockReturnValue({
configured: true,
providerName: 'DeepSeek',
modelName: 'DeepSeek Chat'
})
aiProvider.chat
.mockResolvedValueOnce({
success: true,
data: '{"action":"tool","tool":"search_messages","arguments":{"query":"健身"}}'
})
.mockResolvedValueOnce({
success: true,
data: '{"action":"finalize","reason":"已找到足够的相关消息"}'
})
.mockResolvedValueOnce({
success: true,
data: '小明提到今天下班去健身。[E1]',
usage: { input: 120 }
})
})
it('emits actual planning, knowledge, evidence and AI completion states', async () => {
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
const events: Array<{ stage: string; status: string; message: string }> = []
const result = await service.run(
{
requestId: 'fixture-request',
text: '最近谁聊过健身',
scope: 'global',
range: '7d'
},
(event) => events.push(event)
)
expect(knowledge.search).toHaveBeenCalledWith(
expect.objectContaining({ text: '最近谁聊过健身', terms: ['健身'] })
)
expect(events).toEqual(
expect.arrayContaining([
expect.objectContaining({ stage: 'query_understanding', status: 'running' }),
expect.objectContaining({ stage: 'agent_start', status: 'completed' }),
expect.objectContaining({ stage: 'agent_tool', status: 'completed' }),
expect.objectContaining({ stage: 'search_plan_ready', status: 'completed' }),
expect.objectContaining({ stage: 'knowledge_searching', status: 'completed' }),
expect.objectContaining({ stage: 'evidence_ready', status: 'completed' }),
expect.objectContaining({ stage: 'aggregation', status: 'completed' }),
expect.objectContaining({
stage: 'ai_generating',
status: 'running',
modelName: 'DeepSeek Chat'
}),
expect.objectContaining({ stage: 'completed', status: 'completed' })
])
)
expect(result).toMatchObject({
status: 'completed',
candidateEvidenceCount: 1,
contextEvidenceCount: 1,
answer: '小明提到今天下班去健身。[E1]',
ai: { inputTokens: 120, inputTokensEstimated: false }
})
expect(result.agent).toMatchObject({ mode: 'agent', toolCalls: 1 })
})
it('keeps real evidence when the answer model fails', async () => {
aiProvider.chat.mockReset()
aiProvider.chat
.mockResolvedValueOnce({
success: true,
data: '{"action":"tool","tool":"search_messages","arguments":{"query":"健身"}}'
})
.mockResolvedValueOnce({ success: true, data: '{"action":"finalize","reason":"证据足够"}' })
.mockResolvedValueOnce({ success: false, error: '模型超时' })
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
const events: Array<{ stage: string; status: string; message: string }> = []
const result = await service.run(
{
requestId: 'fixture-ai-error',
text: '最近聊过健身吗',
scope: 'global',
range: '7d'
},
(event) => events.push(event)
)
expect(result).toMatchObject({ status: 'ai_failed', evidence: [expect.any(Object)] })
expect(events).toContainEqual(
expect.objectContaining({ stage: 'ai_generating', status: 'error', error: '模型超时' })
)
})
it('uses Final Evidence only for AI context and strips invalid citations', async () => {
knowledge.search.mockResolvedValue({
source: 'knowledge',
state: 'ready',
indexedMessageCount: 2_000,
indexedChunkCount: 300,
totalMessages: 2_000,
evidence: Array.from({ length: 16 }, (_, index) => makeCandidate(index + 1)),
timings: {
workerIpcMs: 4,
ftsMs: 8,
messageLoadMs: 5,
chunkExpandMs: 6,
rankingMs: 2,
totalMs: 25
}
})
aiProvider.chat.mockReset()
aiProvider.chat
.mockResolvedValueOnce({
success: true,
data: '{"action":"tool","tool":"search_messages","arguments":{"query":"健身"}}'
})
.mockResolvedValueOnce({ success: true, data: '{"action":"finalize","reason":"证据足够"}' })
.mockResolvedValueOnce({
success: true,
data: '杨伟聊过去健身。[E1] 错误引用。[E10][E23]',
usage: { input: 160 }
})
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
const result = await service.run(
{
requestId: 'final-evidence-only',
text: '全局搜一下 谁聊过 去健身',
scope: 'global',
range: '30d'
},
() => undefined
)
const answerPrompt = aiProvider.chat.mock.calls[2][0][1].content as string
const contextIds = Array.from(answerPrompt.matchAll(/\[E(\d+)\]\nconversationId:/g)).map(
(match) => Number(match[1])
)
expect(contextIds).toEqual([1, 2, 3, 4, 5, 6, 7, 8])
expect(answerPrompt).not.toContain('candidate-1 去健身')
expect(result).toMatchObject({
status: 'completed',
candidateEvidenceCount: 16,
contextEvidenceCount: 8,
citationValidation: { status: 'sanitized', invalidCitationIds: ['E10', 'E23'] }
})
expect(result.evidence.map((item) => item.id)).toEqual([
'E1',
'E2',
'E3',
'E4',
'E5',
'E6',
'E7',
'E8'
])
expect(result.answer).toContain('[E1]')
expect(result.answer).not.toMatch(/\[E(?:10|23)\]/)
expect(result.aggregation).toMatchObject({
messageCount: 8,
peopleCount: 2,
conversationCount: 2
})
expect(result.timings).toMatchObject({
queryUnderstandingMs: expect.any(Number),
contactResolutionMs: expect.any(Number),
knowledgeSearchMs: expect.any(Number),
ftsMs: 8,
totalMs: expect.any(Number)
})
})
it('retries a different conversation query after the first search returns zero results', async () => {
listContactsAsync.mockResolvedValue([
{
md5: 'technology-group',
m_nsUsrName: 'technology-group@chatroom',
m_nsNickName: '技术交流',
type: 'group'
}
])
knowledge.search.mockResolvedValue({
source: 'knowledge',
state: 'ready',
indexedMessageCount: 2_000,
indexedChunkCount: 300,
totalMessages: 2_000,
evidence: [
{
chunkId: 'technology-chunk',
conversationId: 'technology-group',
startTime: 1785900000000,
endTime: 1785900000000,
messageId: 'technology-message',
sender: '小周',
timestamp: 1785900000000,
messageIds: ['technology-message'],
text: '今天讨论了 Electron 的打包问题。'
}
],
timings: {
workerIpcMs: 1,
ftsMs: 2,
messageLoadMs: 1,
chunkExpandMs: 1,
rankingMs: 1,
totalMs: 6
}
})
aiProvider.chat.mockReset()
aiProvider.chat
.mockResolvedValueOnce({
success: true,
data: '{"action":"tool","tool":"search_conversations","arguments":{"query":"技术交流群"}}'
})
.mockResolvedValueOnce({
success: true,
data: '{"action":"tool","tool":"search_conversations","arguments":{"query":"技术交流"}}'
})
.mockResolvedValueOnce({
success: true,
data: '{"action":"tool","tool":"get_conversation_messages","arguments":{"conversationRef":"conversation-1","limit":50}}'
})
.mockResolvedValueOnce({
success: true,
data: '{"action":"finalize","reason":"已获得会话近期消息"}'
})
.mockResolvedValueOnce({ success: true, data: '技术交流讨论了 Electron 打包问题。[E1]' })
const events: Array<Record<string, unknown>> = []
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
const result = await service.run(
{ requestId: 'retry-query', text: '我在技术交流群聊了什么?', scope: 'global', range: '30d' },
(event) => events.push(event as unknown as Record<string, unknown>)
)
expect(result).toMatchObject({ status: 'completed', agent: { mode: 'agent', toolCalls: 3 } })
expect(result.agent.trace).toEqual(
expect.arrayContaining([
expect.objectContaining({ toolName: 'search_conversations', resultCount: 0 }),
expect.objectContaining({ toolName: 'search_conversations', resultCount: 1 }),
expect.objectContaining({ toolName: 'get_conversation_messages', resultCount: 1 })
])
)
expect(knowledge.search).toHaveBeenCalledWith(
expect.objectContaining({ terms: [], conversationIds: ['technology-group'], limit: 50 })
)
expect(events).toEqual(
expect.arrayContaining([
expect.objectContaining({
stage: 'agent_tool',
agentTrace: expect.objectContaining({ resultCount: 0 })
})
])
)
})
it('uses person lookup then metadata conversation retrieval for a contact summary', async () => {
listContactsAsync.mockResolvedValue([
{
md5: 'zhongtian-contact',
m_nsUsrName: 'wxid_zhongtian',
m_nsNickName: '中田健身-弘毅',
type: 'user'
}
])
knowledge.search.mockResolvedValue({
source: 'knowledge',
state: 'ready',
indexedMessageCount: 2_000,
indexedChunkCount: 300,
totalMessages: 2_000,
evidence: Array.from({ length: 8 }, (_, index) => ({
...makeCandidate(index + 1),
conversationId: 'zhongtian-contact'
})),
timings: {
workerIpcMs: 1,
ftsMs: 0,
messageLoadMs: 2,
chunkExpandMs: 0,
rankingMs: 1,
totalMs: 4
},
conversationRetrieval: {
conversationId: 'zhongtian-contact',
totalMessages: 327,
chunkCount: 10,
candidateMessages: 30,
systemMessagesDeprioritized: 2,
complete: true
}
})
aiProvider.chat.mockReset()
aiProvider.chat
.mockResolvedValueOnce({
success: true,
data: '{"action":"tool","tool":"search_people","arguments":{"query":"中田健身-弘毅"}}'
})
.mockResolvedValueOnce({
success: true,
data: '{"action":"tool","tool":"get_conversation_messages","arguments":{"conversationRef":"conversation-1"}}'
})
.mockResolvedValueOnce({ success: true, data: '你们最近聊过健身安排。[E1]' })
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
const result = await service.run(
{
requestId: 'contact-summary',
text: '我和中田健身-弘毅最近聊了什么?',
scope: 'global',
range: '30d'
},
() => undefined
)
expect(result).toMatchObject({ status: 'completed', agent: { mode: 'agent', toolCalls: 2 } })
expect(knowledge.search).toHaveBeenCalledWith(
expect.objectContaining({
terms: [],
conversationIds: ['zhongtian-contact'],
startTime: expect.any(Number)
})
)
expect(knowledge.search).not.toHaveBeenCalledWith(
expect.objectContaining({ terms: expect.arrayContaining(['中田健身-弘毅']) })
)
expect(aiProvider.chat).toHaveBeenCalledTimes(3)
expect(result.agent.trace).toContainEqual(
expect.objectContaining({ label: '本地资料已覆盖所选时间范围,可直接整理回答' })
)
const decisions = result.agent.trace.filter((item) => item.event === 'agentDecision')
expect(decisions[0]?.decisionInput).toContain('上一次 Tool 结果:尚未执行 Tool。')
expect(decisions[1]?.decisionInput).toContain('中田健身-弘毅')
})
it('keeps a direct contact recap on metadata retrieval when the Agent JSON response is invalid', async () => {
listContactsAsync.mockResolvedValue([
{
md5: 'zhongtian-contact',
m_nsUsrName: 'wxid_zhongtian',
m_nsNickName: '中田健身-弘毅',
type: 'user'
}
])
knowledge.search.mockResolvedValue({
source: 'knowledge',
state: 'ready',
indexedMessageCount: 2_000,
indexedChunkCount: 300,
totalMessages: 2_000,
evidence: Array.from({ length: 8 }, (_, index) => ({
...makeCandidate(index + 1),
conversationId: 'zhongtian-contact',
text: `我肚子前面放盒肌酸,才是 ${118 + index}`
}))
})
aiProvider.chat.mockReset()
aiProvider.chat
.mockResolvedValueOnce({ success: true, data: '我建议先找到这位联系人。' })
.mockResolvedValueOnce({ success: true, data: '你们最近聊到了腰围和肌酸。[E1]' })
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
const result = await service.run(
{
requestId: 'contact-summary-agent-recovery',
text: '我和中田健身弘毅最近聊了什么?',
scope: 'global',
range: 'all'
},
() => undefined
)
expect(result).toMatchObject({
status: 'completed',
agent: {
mode: 'fallback',
fallbackReason: expect.stringContaining('相同检索意图的本地确定性策略')
}
})
expect(knowledge.search).toHaveBeenCalledWith(
expect.objectContaining({
conversationIds: ['zhongtian-contact'],
terms: [],
startTime: expect.any(Number)
})
)
expect(knowledge.search).not.toHaveBeenCalledWith(
expect.objectContaining({ terms: expect.arrayContaining(['中田健身弘毅']) })
)
})
it('uses person lookup plus conversation-scoped topic search for a contact question', async () => {
listContactsAsync.mockResolvedValue([
{
md5: 'zhongtian-contact',
m_nsUsrName: 'wxid_zhongtian',
m_nsNickName: '中田健身-弘毅',
type: 'user'
}
])
aiProvider.chat.mockReset()
aiProvider.chat
.mockResolvedValueOnce({
success: true,
data: '{"action":"tool","tool":"search_people","arguments":{"query":"中田健身-弘毅"}}'
})
.mockResolvedValueOnce({
success: true,
data: '{"action":"tool","tool":"search_messages","arguments":{"conversationRef":"conversation-1","query":"健身"}}'
})
.mockResolvedValueOnce({
success: true,
data: '{"action":"finalize","reason":"已找到话题证据"}'
})
.mockResolvedValueOnce({ success: true, data: '你们最近聊过健身。[E1]' })
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
const result = await service.run(
{
requestId: 'contact-topic',
text: '我和中田健身-弘毅最近聊过健身吗?',
scope: 'global',
range: 'all'
},
() => undefined
)
expect(result).toMatchObject({ status: 'completed', agent: { mode: 'agent', toolCalls: 2 } })
expect(knowledge.search).toHaveBeenCalledWith(
expect.objectContaining({
terms: ['健身'],
conversationIds: ['zhongtian-contact'],
startTime: expect.any(Number)
})
)
expect(knowledge.search).not.toHaveBeenCalledWith(
expect.objectContaining({ terms: expect.arrayContaining(['中田健身-弘毅']) })
)
})
it('rejects a forbidden contact-recall FTS action and keeps the deterministic fallback semantic', async () => {
listContactsAsync.mockResolvedValue([
{
md5: 'zhongtian-contact',
m_nsUsrName: 'wxid_zhongtian',
m_nsNickName: '中田健身-弘毅',
type: 'user'
}
])
aiProvider.chat.mockReset()
aiProvider.chat
.mockResolvedValueOnce({
success: true,
data: '{"action":"tool","tool":"search_messages","arguments":{"query":"中田健身弘毅"}}'
})
.mockResolvedValueOnce({ success: true, data: '这不是有效 Agent JSON' })
.mockResolvedValueOnce({ success: true, data: '已从会话中整理出最近内容。[E1]' })
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
const result = await service.run(
{
requestId: 'forbidden-contact-recall-fts',
text: '我和中田健身弘毅最近聊了什么?',
scope: 'global',
range: '30d'
},
() => undefined
)
expect(result.agent).toMatchObject({ mode: 'fallback' })
expect(result.agent.trace).toContainEqual(
expect.objectContaining({
toolName: 'search_messages',
decision: expect.stringContaining('联系人回顾只允许')
})
)
expect(knowledge.search).toHaveBeenCalledWith(
expect.objectContaining({ conversationIds: ['zhongtian-contact'], terms: [] })
)
expect(knowledge.search).not.toHaveBeenCalledWith(
expect.objectContaining({ terms: expect.arrayContaining(['中田健身弘毅']) })
)
})
it('rejects an unscoped FTS action for a contact topic question', async () => {
listContactsAsync.mockResolvedValue([
{
md5: 'zhongtian-contact',
m_nsUsrName: 'wxid_zhongtian',
m_nsNickName: '中田健身-弘毅',
type: 'user'
}
])
aiProvider.chat.mockReset()
aiProvider.chat
.mockResolvedValueOnce({
success: true,
data: '{"action":"tool","tool":"search_messages","arguments":{"query":"健身"}}'
})
.mockResolvedValueOnce({ success: true, data: '无效控制输出' })
.mockResolvedValueOnce({ success: true, data: '你们聊过健身。[E1]' })
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
await service.run(
{
requestId: 'forbidden-unscoped-contact-topic',
text: '我和中田健身弘毅最近聊过健身吗?',
scope: 'global',
range: '30d'
},
() => undefined
)
expect(knowledge.search).toHaveBeenCalledWith(
expect.objectContaining({
terms: ['健身'],
conversationIds: ['zhongtian-contact']
})
)
expect(knowledge.search).not.toHaveBeenCalledWith(
expect.objectContaining({ terms: ['健身'], conversationIds: undefined })
)
})
it('flags suspicious contact retrieval and refuses to summarize one message as a full conversation', async () => {
listContactsAsync.mockResolvedValue([
{
md5: 'zhongtian-contact',
m_nsUsrName: 'wxid_zhongtian',
m_nsNickName: '中田健身-弘毅',
type: 'user'
}
])
knowledge.search.mockResolvedValue({
source: 'knowledge',
state: 'ready',
indexedMessageCount: 2_000,
indexedChunkCount: 300,
totalMessages: 2_000,
evidence: [{ ...makeCandidate(1), conversationId: 'zhongtian-contact' }],
conversationRetrieval: {
conversationId: 'zhongtian-contact',
totalMessages: 134,
chunkCount: 8,
candidateMessages: 1,
systemMessagesDeprioritized: 1,
complete: true
}
})
aiProvider.chat.mockReset()
aiProvider.chat
.mockResolvedValueOnce({
success: true,
data: '{"action":"tool","tool":"search_people","arguments":{"query":"中田健身弘毅"}}'
})
.mockResolvedValueOnce({
success: true,
data: '{"action":"tool","tool":"get_conversation_messages","arguments":{"conversationRef":"conversation-1"}}'
})
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
const result = await service.run(
{
requestId: 'suspicious-contact-retrieval',
text: '我和中田健身弘毅最近聊了什么?',
scope: 'global',
range: '30d'
},
() => undefined
)
expect(result).toMatchObject({
status: 'retrieval_incomplete',
retrieval: {
conversationId: 'zhongtian-contact',
sourceMessageCount: 134,
candidateCount: 1,
suspicious: true
}
})
expect(knowledge.search).toHaveBeenCalledTimes(2)
expect(aiProvider.chat).toHaveBeenCalledTimes(2)
})
it('does not turn a zero-result person lookup or early Agent finalize into contact-name FTS', async () => {
listContactsAsync.mockResolvedValue([
{
md5: 'zhongtian-contact',
m_nsUsrName: 'wxid_zhongtian',
m_nsNickName: '中田健身-弘毅',
type: 'user'
}
])
aiProvider.chat.mockReset()
aiProvider.chat
.mockResolvedValueOnce({
success: true,
data: '{"action":"tool","tool":"search_people","arguments":{"query":"不存在的人"}}'
})
.mockResolvedValueOnce({
success: true,
data: '{"action":"finalize","reason":"没有足够证据"}'
})
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
const result = await service.run(
{
requestId: 'zero-person-lookup-safe',
text: '我和中田健身弘毅最近聊了什么?',
scope: 'global',
range: '30d'
},
() => undefined
)
expect(result).toMatchObject({ status: 'no_evidence', agent: { mode: 'agent', toolCalls: 1 } })
expect(knowledge.search).not.toHaveBeenCalled()
expect(aiProvider.chat).toHaveBeenCalledTimes(2)
})
it('stops after five Tool calls instead of searching indefinitely', async () => {
aiProvider.chat.mockReset()
for (let index = 0; index < 5; index += 1) {
aiProvider.chat.mockResolvedValueOnce({
success: true,
data: `{"action":"tool","tool":"search_conversations","arguments":{"query":"不存在的群${index}"}}`
})
}
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
const result = await service.run(
{
requestId: 'max-tool-calls',
text: '我在一个不存在的群聊了什么?',
scope: 'global',
range: '30d'
},
() => undefined
)
expect(result).toMatchObject({ status: 'no_evidence', agent: { mode: 'agent', toolCalls: 5 } })
expect(result.agent.trace).toContainEqual(
expect.objectContaining({ label: '已达到本次检索上限' })
)
expect(aiProvider.chat).toHaveBeenCalledTimes(5)
expect(knowledge.search).not.toHaveBeenCalled()
})
it('falls back to the existing one-shot search when Agent output violates the control protocol', async () => {
aiProvider.chat.mockReset()
aiProvider.chat
.mockResolvedValueOnce({ success: true, data: '我来执行任意代码' })
.mockResolvedValueOnce({ success: true, data: '{"intent":"topic","keywords":["健身"]}' })
.mockResolvedValueOnce({ success: true, data: '小明聊到健身。[E1]' })
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
const result = await service.run(
{ requestId: 'agent-fallback', text: '最近聊过健身吗?', scope: 'global', range: '7d' },
() => undefined
)
expect(result).toMatchObject({ status: 'completed', agent: { mode: 'fallback', toolCalls: 0 } })
expect(result.agent.fallbackReason).toContain('受控搜索 Agent')
})
})
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import {
buildLocalAiSearchPlan,
includesExplicitAiSearchAlias,
inferAiSearchTimeRange
} from '../../src/shared/ai-search'
const NOW = new Date('2026-08-05T12:00:00+08:00')
describe('AI search natural-language time ranges', () => {
it('tightens an all-history selection when the user says 最近', () => {
expect(inferAiSearchTimeRange('我和张三最近聊了什么?', 'all', NOW)).toMatchObject({
label: '近 30 天',
source: 'query',
startTime: Math.floor(NOW.getTime() / 1000) - 30 * 86400
})
})
it('recognizes explicit recent days and the current year', () => {
expect(inferAiSearchTimeRange('我和张三最近三天聊了什么?', 'all', NOW)).toMatchObject({
label: '近 3 天',
source: 'query'
})
expect(inferAiSearchTimeRange('我和张三今年聊了什么?', 'all', NOW)).toMatchObject({
label: '今年',
startTime: Math.floor(new Date(2026, 0, 1).getTime() / 1000)
})
})
it('keeps an explicit user retry override above the word 最近 in the original question', () => {
expect(
inferAiSearchTimeRange('我和张三最近聊了什么?', 'all', NOW, {
label: '全部历史',
reason: '用户主动扩大到全部历史',
source: 'user_retry'
})
).toMatchObject({
label: '全部历史',
source: 'user_retry'
})
})
it('classifies a direct person recap as conversation_recall rather than a topic FTS query', () => {
expect(buildLocalAiSearchPlan('我和张三最近聊了什么?')).toMatchObject({
intent: 'conversation_recall',
contactQuery: '张三'
})
})
it('keeps identity and message topic separate for a contact topic search', () => {
expect(buildLocalAiSearchPlan('我和张三最近聊过健身吗?')).toMatchObject({
intent: 'conversation_topic_search',
contactQuery: '张三',
topicQuery: '健身',
keywords: ['健身']
})
})
it('classifies global topics and bare conversation names without turning names into FTS terms', () => {
expect(buildLocalAiSearchPlan('最近谁聊过 MCP')).toMatchObject({
intent: 'global_topic_search',
topicQuery: 'MCP',
keywords: ['MCP']
})
expect(buildLocalAiSearchPlan('技术交流群')).toMatchObject({
intent: 'conversation_name_search',
contactQuery: '技术交流群',
topicQuery: undefined
})
})
it('matches an explicitly mentioned nickname when the user omits punctuation', () => {
expect(includesExplicitAiSearchAlias('我和中田健身弘毅最近聊了什么?', '中田健身-弘毅')).toBe(
true
)
})
})
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import { normalizeContactName } from '../../src/shared/contact-resolution'
import { resolveContact } from '../../src/main/services/contact-resolution-service'
const contacts = [
{
md5: 'coach',
m_nsUsrName: 'wxid_coach',
m_nsNickName: '中田健身-弘毅',
type: 'user' as const,
remark: '弘毅教练'
},
{ md5: 'zhangsan', m_nsUsrName: 'wxid_zhangsan', m_nsNickName: '张三', type: 'user' as const },
{
md5: 'zhangsanfeng',
m_nsUsrName: 'wxid_zhangsanfeng',
m_nsNickName: '张三丰',
type: 'user' as const
}
]
describe('ContactResolutionService', () => {
it('canonicalizes whitespace, Unicode separators, punctuation and full-width variants', () => {
const forms = [
'中田健身-弘毅',
'中田健身弘毅',
'中田健身 弘毅',
'中田健身—弘毅',
'中田健身_弘毅'
]
expect(new Set(forms.map(normalizeContactName))).toEqual(new Set(['中田健身弘毅']))
})
it('resolves every canonical name form to one conversation without substring guessing', () => {
for (const value of [
'中田健身-弘毅',
'中田健身弘毅',
'中田健身 弘毅',
'中田健身—弘毅',
'中田健身_弘毅'
]) {
expect(resolveContact(value, contacts, 'person')).toMatchObject({
matched: true,
conversationId: 'coach',
ambiguous: false
})
}
})
it('does not treat a partial name as an identity match', () => {
expect(resolveContact('张三丰老师', contacts, 'person')).toMatchObject({
matched: false,
ambiguous: false,
candidates: []
})
})
it('does not auto-select duplicate canonical aliases', () => {
const duplicate = [
...contacts,
{ ...contacts[0], md5: 'coach-duplicate', m_nsUsrName: 'wxid_other' }
]
expect(resolveContact('中田健身弘毅', duplicate, 'person')).toMatchObject({
matched: false,
ambiguous: true,
candidates: [expect.any(Object), expect.any(Object)]
})
})
})
+208
View File
@@ -0,0 +1,208 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { chatState, getGroupSnapshotAsync, listContactsAsync, listMessagesAsync, knowledgeService } =
vi.hoisted(() => ({
chatState: {
ready: false,
accountId: ''
},
getGroupSnapshotAsync: vi.fn(),
listContactsAsync: vi.fn(),
listMessagesAsync: vi.fn(),
knowledgeService: {
dispose: vi.fn().mockResolvedValue(undefined),
index: vi.fn().mockResolvedValue(undefined),
search: vi.fn()
}
}))
vi.mock('../../src/main/services/chat-service', () => ({
isReady: () => chatState.ready,
getSelfAccountInfo: () => (chatState.accountId ? { wxid: chatState.accountId } : null),
getCurrentAccountRoot: () => chatState.accountId,
getGroupSnapshotAsync,
listContactsAsync,
listMessagesAsync
}))
vi.mock('../../src/main/knowledge/knowledge-service', () => ({
KnowledgeService: class {
dispose = knowledgeService.dispose
index = knowledgeService.index
search = knowledgeService.search
}
}))
import { KnowledgeSearchService } from '../../src/main/knowledge/knowledge-search-service'
describe('KnowledgeSearchService legacy fallback', () => {
beforeEach(() => {
chatState.ready = false
chatState.accountId = ''
getGroupSnapshotAsync.mockReset()
listContactsAsync.mockReset()
listMessagesAsync.mockReset()
knowledgeService.dispose.mockClear()
knowledgeService.index.mockClear()
knowledgeService.search.mockReset()
listContactsAsync.mockResolvedValue([
{
m_nsUsrName: 'fixture-contact',
m_nsNickName: '脱敏会话',
md5: 'fixture-conversation',
type: 'user'
}
])
listMessagesAsync.mockResolvedValue([
{
id: 'fixture-message',
localId: 42,
from: 'user',
type: '普通文本',
datetime: '2026/8/5 10:00:00',
content: '请把 Knowledge Worker 的 fallback 保留下来。',
isSender: false,
senderId: 'fixture-sender',
name: '脱敏成员',
createTime: 1785895200
}
])
getGroupSnapshotAsync.mockResolvedValue(null)
})
it('keeps the old main-process search path when Knowledge is unavailable', async () => {
const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js')
const result = await service.search({
text: 'Knowledge Worker fallback',
terms: ['Knowledge Worker', 'fallback'],
conversationIds: ['fixture-conversation'],
startTime: 1785800000,
limit: 10
})
expect(listMessagesAsync).toHaveBeenCalledWith('fixture-conversation', 1785800000, undefined)
expect(result).toMatchObject({
source: 'fallback',
fallbackReason: 'unavailable',
state: 'unavailable',
totalMessages: 1
})
expect(result.evidence).toEqual([
expect.objectContaining({
messageId: 'local:42',
conversationId: 'fixture-conversation',
sender: '脱敏成员',
senderId: 'fixture-sender',
timestamp: 1785895200000
})
])
await service.dispose()
})
it('uses existing Knowledge evidence while a new incremental pass is running', async () => {
chatState.ready = true
chatState.accountId = 'fixture-account'
knowledgeService.search.mockResolvedValue({
state: 'indexing',
indexedMessageCount: 300,
indexedChunkCount: 60,
evidence: [
{
chunkId: 'chunk-1',
conversationId: 'fixture-conversation',
messageId: 'fixture-message',
senderId: 'fixture-sender',
sender: '脱敏成员',
timestamp: 1785895200000,
startTime: 1785895200000,
endTime: 1785895200000,
messageIds: ['fixture-message'],
text: 'Knowledge 已完成的部分可以立即检索。'
}
]
})
const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js')
const result = await service.search({
text: 'fallback',
terms: ['fallback'],
limit: 10
})
expect(result).toMatchObject({
source: 'knowledge',
state: 'indexing',
totalMessages: 300
})
expect(result.evidence).toHaveLength(1)
expect(listMessagesAsync).not.toHaveBeenCalled()
await service.dispose()
})
it('splits a large scope filter before sending it to the Knowledge Worker', async () => {
chatState.ready = true
chatState.accountId = 'fixture-account'
knowledgeService.search.mockResolvedValue({
state: 'ready',
indexedMessageCount: 1_500,
indexedChunkCount: 300,
evidence: []
})
const conversationIds = Array.from({ length: 1_401 }, (_, index) => `conversation-${index}`)
const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js')
const result = await service.search({
text: '知识库',
terms: ['知识库'],
conversationIds,
limit: 10
})
expect(result).toMatchObject({ source: 'knowledge', totalMessages: 1_500 })
expect(knowledgeService.search).toHaveBeenCalledTimes(3)
for (const [request] of knowledgeService.search.mock.calls) {
expect(request.conversationIds.length).toBeLessThanOrEqual(700)
}
await service.dispose()
})
it('resolves a group member wxid to its group nickname in fallback evidence', async () => {
listContactsAsync.mockResolvedValue([
{ md5: 'fixture-group', m_nsNickName: '脱敏群聊', type: 'group' }
])
listMessagesAsync.mockResolvedValue([
{
id: 'group-message',
from: 'wxid_member',
type: '普通文本',
content: '今天继续健身。',
isSender: false,
senderId: 'wxid_member',
name: 'wxid_member',
createTime: 1785895200
}
])
getGroupSnapshotAsync.mockResolvedValue({
roomId: 'fixture-group@chatroom',
memberCount: 1,
members: [
{
wxid: 'wxid_member',
nickname: '微信昵称',
groupNickname: '健身同学',
wechatNickname: '微信昵称',
remark: '',
avatar: ''
}
]
})
const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js')
const result = await service.search({ text: '健身', terms: ['健身'], limit: 10 })
expect(result.evidence).toEqual([
expect.objectContaining({ senderId: 'wxid_member', sender: '健身同学' })
])
expect(getGroupSnapshotAsync).toHaveBeenCalledWith('fixture-group')
await service.dispose()
})
})
+384
View File
@@ -0,0 +1,384 @@
import { mkdtempSync, existsSync } from 'fs'
import { rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import { DEFAULT_KNOWLEDGE_CHUNKER, type KnowledgeFtsConfig } from '../../src/shared/knowledge'
import { chunkConversation } from '../../src/main/knowledge/chunker'
import {
estimateKnowledgeCapacityPreflight,
getKnowledgeDatabasePath,
KnowledgeStore,
removeKnowledgeDatabase
} from '../../src/main/knowledge/knowledge-store'
import { normalizeKnowledgeMessage } from '../../src/main/knowledge/normalizer'
import {
createSyntheticConversation,
FIXTURE_ACCOUNT_A,
FIXTURE_ACCOUNT_B
} from '../fixtures/knowledge-rag'
const roots: string[] = []
const fts: KnowledgeFtsConfig = {
profileId: 'test-trigram-external-full',
tokenizer: 'trigram',
contentMode: 'external',
detail: 'full',
columnsize: 1
}
function makeRoot(): string {
const root = mkdtempSync(join(tmpdir(), 'wxe-knowledge-'))
roots.push(root)
return root
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('knowledge normalizer and chunker', () => {
it('indexes text, attachment metadata and existing voice transcripts without paths or binary data', () => {
const normalized = normalizeKnowledgeMessage({
accountId: FIXTURE_ACCOUNT_A,
conversationId: 'conversation-a',
messageId: 'message-a',
createTime: 1,
kind: 'voice',
text: ' 原始说明 ',
attachment: { name: 'plan.txt', kind: 'file' },
voiceTranscript: ' 已完成语音转写 '
})
expect(normalized.searchableText).toContain('原始说明')
expect(normalized.searchableText).toContain('附件:plan.txt')
expect(normalized.searchableText).toContain('语音转写:已完成语音转写')
})
it('cuts on time gaps and preserves message evidence ids', () => {
const source = createSyntheticConversation(
FIXTURE_ACCOUNT_A,
'conversation-a',
0,
4,
'short'
).messages
source[3].createTime += 20 * 60 * 1000
const chunks = chunkConversation(source.map(normalizeKnowledgeMessage), {
...DEFAULT_KNOWLEDGE_CHUNKER,
maxMessages: 12
})
expect(chunks).toHaveLength(2)
expect(chunks.flatMap((chunk) => chunk.messageIds)).toEqual(
source.map((item) => item.messageId)
)
})
})
describe('knowledge sqlite', () => {
it('is idempotent, supports FTS evidence lookup, and does not mix accounts', async () => {
const root = makeRoot()
const source = createSyntheticConversation(FIXTURE_ACCOUNT_A, 'conversation-a', 0, 25, 'mixed')
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
const first = await store.index({ conversations: [source], chunker: DEFAULT_KNOWLEDGE_CHUNKER })
const second = await store.index({
conversations: [source],
chunker: DEFAULT_KNOWLEDGE_CHUNKER
})
expect(first.updatedChunks).toBeGreaterThan(0)
expect(second.updatedChunks).toBe(0)
expect(second.unchangedConversations).toBe(1)
const evidence = store.search({ accountId: FIXTURE_ACCOUNT_A, text: '本地知识库', limit: 10 })
expect(evidence).not.toHaveLength(0)
expect(evidence[0]).toMatchObject({
messageId: expect.stringMatching(/^synthetic-mixed-/),
conversationId: 'conversation-a',
sender: expect.any(String),
timestamp: expect.any(Number)
})
expect(
evidence.every((item) => item.messageIds.every((id) => id.startsWith('synthetic-mixed-')))
).toBe(true)
expect(() =>
store.search({ accountId: FIXTURE_ACCOUNT_B, text: '本地知识库', limit: 10 })
).toThrow(/account/)
store.close()
})
it('recovers safely after cancellation and only removes the derived database', async () => {
const root = makeRoot()
const source = createSyntheticConversation(
FIXTURE_ACCOUNT_A,
'conversation-a',
0,
2_000,
'mixed'
)
const controller = new AbortController()
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
const cancelled = await store.index(
{ conversations: [source], chunker: DEFAULT_KNOWLEDGE_CHUNKER },
controller.signal,
(progress) => {
if (progress.processedMessages >= 501) controller.abort()
}
)
expect(cancelled.cancelled).toBe(true)
const resumed = await store.index({
conversations: [source],
chunker: DEFAULT_KNOWLEDGE_CHUNKER
})
expect(resumed.cancelled).toBe(false)
const databasePath = getKnowledgeDatabasePath(root, FIXTURE_ACCOUNT_A)
store.close()
expect(existsSync(databasePath)).toBe(true)
removeKnowledgeDatabase(root, FIXTURE_ACCOUNT_A)
expect(existsSync(databasePath)).toBe(false)
})
it('uses a bounded exact fallback for two-character Chinese queries with the trigram profile', async () => {
const root = makeRoot()
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
await store.index({
conversations: [
{
conversationId: 'short-query',
completeSnapshot: true,
messages: [
{
accountId: FIXTURE_ACCOUNT_A,
conversationId: 'short-query',
messageId: 'short-query-message',
createTime: Date.UTC(2026, 7, 5),
senderId: 'fixture-member',
senderName: '脱敏成员',
kind: 'text',
text: '收到,明早十点。'
}
]
}
],
chunker: DEFAULT_KNOWLEDGE_CHUNKER
})
expect(
store.search({ accountId: FIXTURE_ACCOUNT_A, text: '十点', terms: ['十点'], limit: 10 })
).toEqual([
expect.objectContaining({ messageId: 'short-query-message', conversationId: 'short-query' })
])
store.close()
})
it('keeps equal message ids from different conversations as separate Evidence', async () => {
const root = makeRoot()
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
await store.index({
conversations: ['conversation-a', 'conversation-b'].map((conversationId) => ({
conversationId,
completeSnapshot: true,
messages: [
{
accountId: FIXTURE_ACCOUNT_A,
conversationId,
messageId: 'shared-message-id',
createTime: Date.UTC(2026, 7, 5),
senderId: `${conversationId}-sender`,
senderName: conversationId,
kind: 'text',
text: '今天去健身。'
}
]
})),
chunker: DEFAULT_KNOWLEDGE_CHUNKER
})
const result = store.searchWithStatus({
accountId: FIXTURE_ACCOUNT_A,
text: '去健身',
terms: ['去健身'],
limit: 10
})
const evidence = result.evidence
expect(evidence).toHaveLength(2)
expect(evidence.map((item) => `${item.conversationId}:${item.messageId}`).sort()).toEqual([
'conversation-a:shared-message-id',
'conversation-b:shared-message-id'
])
expect(result.timings).toMatchObject({
workerIpcMs: 0,
ftsMs: expect.any(Number),
messageLoadMs: expect.any(Number),
chunkExpandMs: expect.any(Number),
rankingMs: expect.any(Number),
totalMs: expect.any(Number)
})
expect(result.timings.totalMs).toBeGreaterThanOrEqual(result.timings.ftsMs)
store.close()
})
it('keeps conversation, sender and time filters when a participant question has no topic terms', async () => {
const root = makeRoot()
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
await store.index({
conversations: [
{
conversationId: 'participant-query',
completeSnapshot: true,
messages: [
{
accountId: FIXTURE_ACCOUNT_A,
conversationId: 'participant-query',
messageId: 'participant-a',
createTime: Date.UTC(2026, 7, 5, 9),
senderId: 'member-a',
senderName: '成员甲',
kind: 'text',
text: '第一条讨论。'
},
{
accountId: FIXTURE_ACCOUNT_A,
conversationId: 'participant-query',
messageId: 'participant-b',
createTime: Date.UTC(2026, 7, 5, 10),
senderId: 'member-b',
senderName: '成员乙',
kind: 'text',
text: '第二条讨论。'
}
]
}
],
chunker: DEFAULT_KNOWLEDGE_CHUNKER
})
expect(
store.search({
accountId: FIXTURE_ACCOUNT_A,
text: '成员甲最近聊了什么',
terms: [],
conversationIds: ['participant-query'],
senderIds: ['member-a'],
startTime: Date.UTC(2026, 7, 5, 8),
limit: 10
})
).toEqual([expect.objectContaining({ messageId: 'participant-a', sender: '成员甲' })])
store.close()
})
it('compresses a single-conversation recap into time chunks and deprioritizes system messages', async () => {
const root = makeRoot()
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
const base = Date.UTC(2026, 6, 1)
await store.index({
conversations: [
{
conversationId: 'recap-query',
completeSnapshot: true,
messages: Array.from({ length: 48 }, (_, index) => ({
accountId: FIXTURE_ACCOUNT_A,
conversationId: 'recap-query',
messageId: `recap-${index}`,
createTime: base + Math.floor(index / 12) * 3 * 3600 * 1000 + (index % 12) * 60_000,
senderId: 'fixture-member',
senderName: '脱敏成员',
kind: index % 11 === 0 ? ('system' as const) : ('text' as const),
text: index % 11 === 0 ? '对方撤回了一条消息' : `${index} 条健身计划和饮食安排讨论。`
}))
}
],
chunker: DEFAULT_KNOWLEDGE_CHUNKER
})
const result = store.searchWithStatus({
accountId: FIXTURE_ACCOUNT_A,
text: '我和张三最近聊了什么',
terms: [],
conversationIds: ['recap-query'],
startTime: base,
limit: 100
})
expect(result.conversationRetrieval).toMatchObject({
totalMessages: 48,
chunkCount: 4,
complete: true
})
expect(result.evidence.length).toBeLessThan(48)
expect(new Set(result.evidence.map((item) => item.chunkId)).size).toBeGreaterThan(1)
expect(result.evidence.filter((item) => item.text.includes('撤回')).length).toBeLessThan(5)
store.close()
})
it('keeps late conversation slices when the recap candidate budget is reached', async () => {
const root = makeRoot()
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
const base = Date.UTC(2026, 6, 1)
await store.index({
conversations: [
{
conversationId: 'long-recap-query',
completeSnapshot: true,
messages: Array.from({ length: 90 }, (_, index) => ({
accountId: FIXTURE_ACCOUNT_A,
conversationId: 'long-recap-query',
messageId: `long-recap-${index}`,
createTime: base + Math.floor(index / 3) * 3 * 3600 * 1000 + (index % 3) * 60_000,
senderId: 'fixture-member',
senderName: '脱敏成员',
kind: 'text' as const,
text: `${index} 条近期聊天内容。`
}))
}
],
chunker: DEFAULT_KNOWLEDGE_CHUNKER
})
const result = store.searchWithStatus({
accountId: FIXTURE_ACCOUNT_A,
text: '我和张三最近聊了什么',
terms: [],
conversationIds: ['long-recap-query'],
startTime: base,
limit: 100
})
expect(result.conversationRetrieval).toMatchObject({ chunkCount: 30, candidateMessages: 60 })
expect(Math.max(...result.evidence.map((item) => item.timestamp))).toBeGreaterThan(
base + 28 * 3 * 3600 * 1000
)
store.close()
})
it('provides a read-only capacity preflight before a database exists', async () => {
const root = makeRoot()
const source = createSyntheticConversation(FIXTURE_ACCOUNT_A, 'conversation-a', 0, 20, 'long')
const result = await estimateKnowledgeCapacityPreflight({
accountId: FIXTURE_ACCOUNT_A,
databaseRoot: root,
conversations: [source],
chunker: DEFAULT_KNOWLEDGE_CHUNKER,
availableDiskBytes: 1
})
expect(result.sourceMessageCount).toBe(20)
expect(result.voiceTranscriptCount).toBeGreaterThan(0)
expect(result.hasSufficientDiskSpace).toBe(false)
expect(existsSync(getKnowledgeDatabasePath(root, FIXTURE_ACCOUNT_A))).toBe(false)
})
it('indexes 100,000 desensitized messages without touching the main process database', async () => {
const root = makeRoot()
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
const started = performance.now()
for (let batch = 0; batch < 10; batch += 1) {
const source = createSyntheticConversation(
FIXTURE_ACCOUNT_A,
`performance-${batch}`,
batch * 10_000,
10_000,
'mixed'
)
await store.index({ conversations: [source], chunker: DEFAULT_KNOWLEDGE_CHUNKER })
}
const stats = store.getStorageStats()
expect(stats.databaseBytes).toBeGreaterThan(0)
expect(performance.now() - started).toBeLessThan(60_000)
store.close()
}, 70_000)
})