feat: 优化问问微信检索性能与分析交互

- 补充 Worker、WCDB、sender、IPC、序列化时间账
- 增加 Agent 增量覆盖统计和重复检索停止条件
- 补充性能与交互回归测试
This commit is contained in:
Wxw-Gu
2026-08-07 15:28:45 +08:00
parent 43654bf0e2
commit 0b845db2e0
20 changed files with 1304 additions and 184 deletions
@@ -1,8 +1,11 @@
import { render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AISearchWorkspace } from '../../src/renderer/src/components/search/AISearchWorkspace'
import { SEARCH_CACHE_KEY, buildSearchCacheKey } from '../../src/renderer/src/components/search/searchUtils'
import {
SEARCH_CACHE_KEY,
buildSearchCacheKey
} from '../../src/renderer/src/components/search/searchUtils'
const api = {
getSettings: vi.fn(),
@@ -12,7 +15,8 @@ const api = {
onAiSearchProgress: vi.fn(),
getAiSearchProviderStatus: vi.fn(),
authorizeAiSearchExternalProvider: vi.fn(),
runAiSearch: vi.fn()
runAiSearch: vi.fn(),
cancelAiSearch: vi.fn()
}
describe('AISearchWorkspace cache privacy boundary', () => {
@@ -23,7 +27,11 @@ describe('AISearchWorkspace cache privacy boundary', () => {
Object.defineProperty(window, 'api', { configurable: true, value: api })
api.getSettings.mockResolvedValue({ settings: { debugEnabled: false } })
api.getAppLogPath.mockResolvedValue('')
api.getKnowledgeStatus.mockResolvedValue({ state: 'ready', processedMessages: 1, totalMessages: 1 })
api.getKnowledgeStatus.mockResolvedValue({
state: 'ready',
processedMessages: 1,
totalMessages: 1
})
api.onKnowledgeStatus.mockReturnValue(() => undefined)
api.onAiSearchProgress.mockReturnValue(() => undefined)
api.getAiSearchProviderStatus.mockResolvedValue({
@@ -32,6 +40,7 @@ describe('AISearchWorkspace cache privacy boundary', () => {
providerId: 'remote-provider',
recipient: 'https://remote.example.test/v1'
})
api.cancelAiSearch.mockResolvedValue({ cancelled: true })
})
it('uses a local cache hit without opening a remote Provider consent dialog or making an AI request', async () => {
@@ -199,7 +208,9 @@ describe('AISearchWorkspace cache privacy boundary', () => {
await screen.findByRole('dialog', { name: '确认发送本次搜索资料' })
await userEvent.click(screen.getByRole('button', { name: '取消' }))
await waitFor(() => expect(onNotice).toHaveBeenCalledWith(expect.stringContaining('已取消本次 AI Search')))
await waitFor(() =>
expect(onNotice).toHaveBeenCalledWith(expect.stringContaining('已取消本次 AI Search'))
)
expect(confirm).not.toHaveBeenCalled()
expect(api.authorizeAiSearchExternalProvider).not.toHaveBeenCalled()
expect(api.runAiSearch).not.toHaveBeenCalled()
@@ -231,7 +242,13 @@ describe('AISearchWorkspace cache privacy boundary', () => {
timestamp: 1_785_900_000_000 + index,
text: `证据 ${index + 1}`
})),
aggregation: { messageCount: 8, peopleCount: 1, conversationCount: 1, people: [], conversations: [] },
aggregation: {
messageCount: 8,
peopleCount: 1,
conversationCount: 1,
people: [],
conversations: []
},
agent: { mode: 'agent', toolCalls: 1, trace: [] },
timings: {},
elapsedMs: 1
@@ -275,7 +292,13 @@ describe('AISearchWorkspace cache privacy boundary', () => {
candidateEvidenceCount: 1,
contextEvidenceCount: 1,
evidence: [],
aggregation: { messageCount: 1, peopleCount: 1, conversationCount: 1, people: [], conversations: [] },
aggregation: {
messageCount: 1,
peopleCount: 1,
conversationCount: 1,
people: [],
conversations: []
},
agent: { mode: 'agent', toolCalls: 1, trace: [] },
timings: {},
elapsedMs: 1
@@ -312,4 +335,88 @@ describe('AISearchWorkspace cache privacy boundary', () => {
expect(screen.queryByRole('heading', { name: 'first question' })).not.toBeInTheDocument()
expect(input).toHaveValue('')
})
it('disables and guards analysis while the knowledge base is synchronizing', async () => {
api.getKnowledgeStatus.mockResolvedValue({
state: 'syncing',
processedMessages: 20,
totalMessages: 100
})
const onNotice = vi.fn()
render(
<AISearchWorkspace
contacts={[]}
selectedContact={null}
dbReady
aiModelConfig={{
configured: true,
providerName: 'Local Provider',
model: 'model',
modelName: 'Model',
status: 'connected'
}}
onSelectContact={vi.fn()}
onOpenEvidence={vi.fn()}
onOpenAISettings={vi.fn()}
onNotice={onNotice}
/>
)
await userEvent.type(screen.getByRole('textbox'), '同步时不能分析')
const button = await screen.findByRole('button', { name: /同步中,暂不可分析/ })
expect(button).toBeDisabled()
const form = screen.getByRole('textbox').closest('form')
expect(form).not.toBeNull()
fireEvent.submit(form as HTMLFormElement)
await waitFor(() =>
expect(onNotice).toHaveBeenCalledWith('知识库正在同步,请等待同步完成后再开始分析')
)
expect(api.getAiSearchProviderStatus).not.toHaveBeenCalled()
expect(api.runAiSearch).not.toHaveBeenCalled()
})
it('cancels an active analysis and ignores its late result', async () => {
api.getAiSearchProviderStatus.mockResolvedValue({ configured: true, requiresConsent: false })
let resolveSearch: ((value: unknown) => void) | undefined
api.runAiSearch.mockImplementation(
() =>
new Promise((resolve) => {
resolveSearch = resolve
})
)
const onNotice = vi.fn()
render(
<AISearchWorkspace
contacts={[]}
selectedContact={null}
dbReady
aiModelConfig={{
configured: true,
providerName: 'Local Provider',
model: 'model',
modelName: 'Model',
status: 'connected'
}}
onSelectContact={vi.fn()}
onOpenEvidence={vi.fn()}
onOpenAISettings={vi.fn()}
onNotice={onNotice}
/>
)
await userEvent.type(screen.getByRole('textbox'), '这个请求稍后才返回')
await userEvent.click(screen.getByRole('button', { name: /开始分析/ }))
const cancelButton = await screen.findByRole('button', { name: /取消分析/ })
const requestId = api.runAiSearch.mock.calls[0][0].requestId as string
await userEvent.click(cancelButton)
expect(api.cancelAiSearch).toHaveBeenCalledWith(requestId)
expect(onNotice).toHaveBeenCalledWith('已取消本次分析')
expect(screen.getByRole('button', { name: /开始分析/ })).toBeEnabled()
resolveSearch?.({ requestId, status: 'completed', answer: '不应显示的迟到结果' })
await waitFor(() => expect(screen.queryByText('不应显示的迟到结果')).not.toBeInTheDocument())
expect(localStorage.getItem(SEARCH_CACHE_KEY) || '').not.toContain('不应显示的迟到结果')
})
})
@@ -56,6 +56,8 @@ describe('preload IPC contract', () => {
}
await api.runAiSearch(aiSearch)
expect(invoke).toHaveBeenLastCalledWith('ai-search:run', aiSearch)
await api.cancelAiSearch(aiSearch.requestId)
expect(invoke).toHaveBeenLastCalledWith('ai-search:cancel', aiSearch.requestId)
await api.startKnowledgeIndex()
expect(invoke).toHaveBeenLastCalledWith('knowledge:startIndex')
await api.clearCache('knowledge')
+33 -3
View File
@@ -44,9 +44,9 @@ describe('AI Search provider identity', () => {
requiresConsent: true,
recipient: 'https://first.example.test/v1'
})
expect(service.save({ ...provider('https://remote.example.test'), type: 'ollama' }).success).toBe(
true
)
expect(
service.save({ ...provider('https://remote.example.test'), type: 'ollama' }).success
).toBe(true)
expect(service.getAiSearchProviderStatus()).toMatchObject({ requiresConsent: true })
expect(service.save(provider('http://localhost:11434/')).success).toBe(true)
expect(service.getAiSearchProviderStatus()).toMatchObject({
@@ -97,4 +97,34 @@ describe('AI Search provider identity', () => {
expect(JSON.stringify(request)).not.toContain('messageId')
vi.unstubAllGlobals()
})
it('aborts the provider fetch when the caller cancels an AI request', async () => {
const service = new AIProviderService()
service.save(provider('http://127.0.0.1:11434'))
let fetchSignal: AbortSignal | undefined
const fetchMock = vi.fn(
(_url: string, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
fetchSignal = init?.signal || undefined
fetchSignal?.addEventListener('abort', () => reject(fetchSignal?.reason), { once: true })
})
)
vi.stubGlobal('fetch', fetchMock)
const controller = new AbortController()
try {
const result = service.chat(
[{ role: 'user', content: 'cancel this request' }],
undefined,
controller.signal
)
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
controller.abort(new DOMException('cancelled by test', 'AbortError'))
await expect(result).rejects.toMatchObject({ name: 'AbortError' })
expect(fetchSignal?.aborted).toBe(true)
} finally {
vi.unstubAllGlobals()
}
})
})
+186 -33
View File
@@ -143,6 +143,140 @@ describe('AiSearchPipelineService', () => {
expect(result.agent).toMatchObject({ mode: 'agent', toolCalls: 1 })
})
it('cancels an active Agent request and aborts the AI call before local retrieval continues', async () => {
let observedSignal: AbortSignal | undefined
let markStarted: (() => void) | undefined
const started = new Promise<void>((resolve) => {
markStarted = resolve
})
aiProvider.chat.mockReset()
aiProvider.chat.mockImplementation(
(_messages: unknown, _options: unknown, signal?: AbortSignal) =>
new Promise((_resolve, reject) => {
observedSignal = signal
markStarted?.()
signal?.addEventListener('abort', () => reject(signal.reason), { once: true })
})
)
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
const resultPromise = service.run(
{
requestId: 'cancel-active-agent',
text: '最近谁聊过健身',
scope: 'global',
range: '7d'
},
() => undefined
)
await started
expect(service.cancel('cancel-active-agent')).toEqual({ cancelled: true })
const result = await resultPromise
expect(observedSignal?.aborted).toBe(true)
expect(result).toMatchObject({ status: 'cancelled', error: '已取消本次分析' })
expect(knowledge.search).not.toHaveBeenCalled()
expect(service.cancel('cancel-active-agent')).toEqual({ cancelled: false })
})
it('stops after the same retrieval fingerprint adds no new coverage', async () => {
aiProvider.chat.mockReset()
aiProvider.chat
.mockResolvedValueOnce({
success: true,
data: '{"action":"tool","tool":"search_messages","arguments":{"query":"健身"}}'
})
.mockResolvedValueOnce({
success: true,
data: '{"action":"tool","tool":"search_messages","arguments":{"query":"健身"}}'
})
.mockResolvedValueOnce({
success: true,
data: '小明提到今天下班去健身。[E1]',
usage: { input: 120 }
})
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
const result = await service.run(
{
requestId: 'duplicate-coverage-stop',
text: '最近谁聊过健身',
scope: 'global',
range: '7d'
},
() => undefined
)
expect(knowledge.search).toHaveBeenCalledTimes(2)
expect(result.agent).toMatchObject({ mode: 'agent', toolCalls: 2 })
const toolEnds = result.agent.trace.filter((item) => item.event === 'toolCallEnd')
expect(toolEnds).toEqual([
expect.objectContaining({
resultCount: 1,
uniqueCandidateCount: 1,
newCandidateCount: 1,
newEvidenceCount: 1,
newConversationCount: 1,
newSenderCount: 1,
queryFingerprint: expect.stringMatching(/^[a-f0-9]{16}$/)
}),
expect.objectContaining({
resultCount: 1,
uniqueCandidateCount: 1,
newCandidateCount: 0,
newEvidenceCount: 0,
newConversationCount: 0,
newSenderCount: 0
})
])
expect(result.agent.trace).toContainEqual(
expect.objectContaining({
event: 'agentDecision',
label: '本地资料已覆盖所选时间范围,可直接整理回答',
elapsedMs: 0
})
)
expect(result.retrieval).toMatchObject({ candidateCount: 2, uniqueCandidateCount: 1 })
})
it('uses conversation coverage to stop a reformulated group lookup', async () => {
aiProvider.chat.mockReset()
aiProvider.chat
.mockResolvedValueOnce({
success: true,
data: '{"action":"tool","tool":"search_messages","arguments":{"query":"健身"}}'
})
.mockResolvedValueOnce({
success: true,
data: '{"action":"tool","tool":"search_messages","arguments":{"query":"健身计划"}}'
})
.mockResolvedValueOnce({
success: true,
data: '健身交流组讨论过健身。[E1]'
})
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
const result = await service.run(
{
requestId: 'conversation-coverage-stop',
text: '哪个群聊过健身?',
scope: 'global',
range: '7d'
},
() => undefined
)
expect(result.agent).toMatchObject({ toolCalls: 2 })
expect(result.agent.trace.filter((item) => item.event === 'toolCallEnd')).toEqual([
expect.objectContaining({ newConversationCount: 1 }),
expect.objectContaining({
newCandidateCount: 0,
newConversationCount: 0,
queryFingerprint: expect.stringMatching(/^[a-f0-9]{16}$/)
})
])
})
it('keeps real evidence when the answer model fails', async () => {
aiProvider.chat.mockReset()
aiProvider.chat
@@ -212,8 +346,8 @@ describe('AiSearchPipelineService', () => {
)
const answerPrompt = aiProvider.chat.mock.calls[2][0][1].content as string
const contextIds = Array.from(answerPrompt.matchAll(/\[E(\d+)\]\nsource:/g)).map(
(match) => Number(match[1])
const contextIds = Array.from(answerPrompt.matchAll(/\[E(\d+)\]\nsource:/g)).map((match) =>
Number(match[1])
)
expect(contextIds).toEqual([1, 2, 3, 4, 5, 6, 7, 8])
expect(answerPrompt).not.toContain('candidate-1 去健身')
@@ -832,7 +966,9 @@ describe('AiSearchPipelineService', () => {
)
const secondAgentCall = aiProvider.chat.mock.calls[1][0] as Array<{ content: string }>
expect(secondAgentCall.map((message) => message.content).join('\n')).not.toContain(injectedMessage)
expect(secondAgentCall.map((message) => message.content).join('\n')).not.toContain(
injectedMessage
)
expect(secondAgentCall[1]?.content).toContain('UNTRUSTED_TOOL_RESULT')
expect(result.agent.trace).not.toContainEqual(
expect.objectContaining({ decisionInput: expect.anything() })
@@ -851,7 +987,12 @@ describe('AiSearchPipelineService', () => {
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
const result = await service.run(
{ requestId: 'provider-consent-required', text: '最近聊过健身吗?', scope: 'global', range: '7d' },
{
requestId: 'provider-consent-required',
text: '最近聊过健身吗?',
scope: 'global',
range: '7d'
},
() => undefined
)
@@ -973,7 +1114,10 @@ describe('AiSearchPipelineService', () => {
() => undefined
)
expect(result).toMatchObject({ status: 'completed', retrieval: { conversationId: 'selected-contact' } })
expect(result).toMatchObject({
status: 'completed',
retrieval: { conversationId: 'selected-contact' }
})
expect(knowledge.search).toHaveBeenCalledWith(
expect.objectContaining({ conversationIds: ['selected-contact'], terms: [] })
)
@@ -991,7 +1135,10 @@ describe('AiSearchPipelineService', () => {
'{"action":"tool","tool":"search_conversations","arguments":{"query":"另一个联系人"}}'
]
],
['finalizes before reading the selected conversation', ['{"action":"finalize","reason":"足够了"}']],
[
'finalizes before reading the selected conversation',
['{"action":"finalize","reason":"足够了"}']
],
[
'exhausts the selected conversation Tool Budget',
[
@@ -1127,7 +1274,10 @@ describe('AiSearchPipelineService', () => {
success: true,
data: '{"action":"tool","tool":"get_conversation_messages","arguments":{"conversationRef":"conversation-1"}}'
})
.mockResolvedValueOnce({ success: true, data: '{"action":"finalize","reason":"没有可用引用"}' })
.mockResolvedValueOnce({
success: true,
data: '{"action":"finalize","reason":"没有可用引用"}'
})
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
const result = await service.run(
@@ -1298,36 +1448,39 @@ describe('AiSearchPipelineService', () => {
recipient: 'https://remote.example.test/v1'
}
]
])('rejects a previously approved request when the Provider %s changes', async (_change, changed) => {
aiProvider.getAiSearchProviderStatus.mockReturnValue({
configured: true,
requiresConsent: true,
providerId: 'fixture-provider',
recipient: 'https://remote.example.test/v1'
})
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
expect(
service.authorizeExternalProvider({
requestId: `provider-change-${_change}`,
])(
'rejects a previously approved request when the Provider %s changes',
async (_change, changed) => {
aiProvider.getAiSearchProviderStatus.mockReturnValue({
configured: true,
requiresConsent: true,
providerId: 'fixture-provider',
recipient: 'https://remote.example.test/v1'
})
).toMatchObject({ success: true })
aiProvider.getAiSearchProviderStatus.mockReturnValue(changed)
aiProvider.chat.mockReset()
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
expect(
service.authorizeExternalProvider({
requestId: `provider-change-${_change}`,
providerId: 'fixture-provider',
recipient: 'https://remote.example.test/v1'
})
).toMatchObject({ success: true })
aiProvider.getAiSearchProviderStatus.mockReturnValue(changed)
aiProvider.chat.mockReset()
const result = await service.run(
{
requestId: `provider-change-${_change}`,
text: '最近聊过健身吗?',
scope: 'global',
range: '7d'
},
() => undefined
)
expect(result.status).toBe('ai_failed')
expect(aiProvider.chat).not.toHaveBeenCalled()
})
const result = await service.run(
{
requestId: `provider-change-${_change}`,
text: '最近聊过健身吗?',
scope: 'global',
range: '7d'
},
() => undefined
)
expect(result.status).toBe('ai_failed')
expect(aiProvider.chat).not.toHaveBeenCalled()
}
)
it('rejects a valid messageRef when it is paired with a different issued conversationRef', async () => {
listContactsAsync.mockResolvedValue([
+60 -7
View File
@@ -139,16 +139,18 @@ describe('KnowledgeSearchService legacy fallback', () => {
createTime: 1_785_895_200
}
])
const { voiceAccountIdentity, voiceMessageIdentity } = await import(
'../../src/main/voice-pipeline/voice-message-identity'
)
const { voiceAccountIdentity, voiceMessageIdentity } =
await import('../../src/main/voice-pipeline/voice-message-identity')
const reference = {
sessionId: 'voice-contact',
localId: 18,
createTime: 1_785_895_200
}
const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js')
service.setVoiceTranscriptResolver(() => ({ state: 'transcribed', transcript: '缓存中的语音文字' }))
service.setVoiceTranscriptResolver(() => ({
state: 'transcribed',
transcript: '缓存中的语音文字'
}))
await service.indexVoiceTranscript({
accountIdentity: voiceAccountIdentity(chatState.accountId),
@@ -220,9 +222,8 @@ describe('KnowledgeSearchService legacy fallback', () => {
releaseFirstIndex = resolve
})
)
const { voiceAccountIdentity, voiceMessageIdentity } = await import(
'../../src/main/voice-pipeline/voice-message-identity'
)
const { voiceAccountIdentity, voiceMessageIdentity } =
await import('../../src/main/voice-pipeline/voice-message-identity')
const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js')
const update = (localId: number, createTime: number): VoiceTranscriptUpdate => {
const reference = { sessionId: 'voice-contact', localId, createTime }
@@ -355,4 +356,56 @@ describe('KnowledgeSearchService legacy fallback', () => {
expect(getGroupSnapshotAsync).toHaveBeenCalledWith('fixture-group')
await service.dispose()
})
it('reuses contacts and group members only within the same retrieval session', 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 request = {
text: '健身',
terms: ['健身'],
retrievalSessionId: 'retrieval-a',
limit: 10
}
const first = await service.search(request)
const second = await service.search(request)
expect(first.evidence).toEqual(second.evidence)
expect(listContactsAsync).toHaveBeenCalledTimes(3)
// Each fallback search needs contacts for scope selection; enrichment is
// the only layer cached, so the second search avoids one extra lookup.
expect(getGroupSnapshotAsync).toHaveBeenCalledTimes(1)
await service.search({ ...request, retrievalSessionId: 'retrieval-b' })
expect(getGroupSnapshotAsync).toHaveBeenCalledTimes(2)
await service.dispose()
})
})
+79
View File
@@ -2,6 +2,7 @@ import { mkdtempSync, existsSync } from 'fs'
import { rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { DatabaseSync } from 'node:sqlite'
import { afterEach, describe, expect, it } from 'vitest'
import { DEFAULT_KNOWLEDGE_CHUNKER, type KnowledgeFtsConfig } from '../../src/shared/knowledge'
import { chunkConversation } from '../../src/main/knowledge/chunker'
@@ -273,6 +274,84 @@ describe('knowledge sqlite', () => {
store.close()
})
it('keeps truthful count snapshots off the repeated-search hot path', async () => {
const root = makeRoot()
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
const source = createSyntheticConversation(FIXTURE_ACCOUNT_A, 'stats-snapshot', 0, 12, 'mixed')
await store.index({ conversations: [source], chunker: DEFAULT_KNOWLEDGE_CHUNKER })
const first = store.searchWithStatus({
accountId: FIXTURE_ACCOUNT_A,
text: '本地知识库',
terms: ['本地知识库'],
limit: 10
})
const second = store.searchWithStatus({
accountId: FIXTURE_ACCOUNT_A,
text: '本地知识库',
terms: ['本地知识库'],
limit: 10
})
expect(first).toMatchObject({
indexedMessageCount: 12,
indexedChunkCount: expect.any(Number)
})
expect(first.timings.globalCountMs).toBeGreaterThanOrEqual(0)
expect(second.timings).toMatchObject({
globalCountMs: 0,
voiceCoverageMs: expect.any(Number),
workerExecutionMs: expect.any(Number)
})
expect(second.indexedMessageCount).toBe(first.indexedMessageCount)
expect(second.indexedChunkCount).toBe(first.indexedChunkCount)
store.close()
const reopened = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
expect(reopened.getSearchStatus()).toMatchObject({
indexedMessageCount: 12,
indexedChunkCount: first.indexedChunkCount
})
reopened.close()
})
it('refreshes statistics only on the final request of a complete source pass', async () => {
const root = makeRoot()
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
const first = createSyntheticConversation(FIXTURE_ACCOUNT_A, 'stats-first', 0, 4, 'mixed')
const second = createSyntheticConversation(FIXTURE_ACCOUNT_A, 'stats-second', 4, 3, 'mixed')
await store.index({
conversations: [first],
chunker: DEFAULT_KNOWLEDGE_CHUNKER,
sourceMessageCount: 4
})
const inspect = new DatabaseSync(getKnowledgeDatabasePath(root, FIXTURE_ACCOUNT_A))
const readMeta = (key: string): string | undefined =>
(
inspect.prepare('SELECT value FROM knowledge_meta WHERE key = ?').get(key) as
| { value: string }
| undefined
)?.value
expect(readMeta('stats_state')).toBe('fresh')
expect(readMeta('stats_message_count')).toBe('4')
await store.index({ conversations: [second], chunker: DEFAULT_KNOWLEDGE_CHUNKER })
expect(readMeta('stats_state')).toBe('stale')
expect(readMeta('stats_message_count')).toBe('4')
await store.index({
conversations: [second],
chunker: DEFAULT_KNOWLEDGE_CHUNKER,
sourceMessageCount: 7
})
expect(readMeta('stats_state')).toBe('fresh')
expect(readMeta('stats_message_count')).toBe('7')
inspect.close()
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)