mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
feat: 优化问问微信检索性能与分析交互
- 补充 Worker、WCDB、sender、IPC、序列化时间账 - 增加 Agent 增量覆盖统计和重复检索停止条件 - 补充性能与交互回归测试
This commit is contained in:
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user