mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
test: 暂存代码
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
import { mkdtempSync } from 'fs'
|
||||
import { rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { performance } from 'perf_hooks'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_KNOWLEDGE_CHUNKER,
|
||||
type KnowledgeEvidence,
|
||||
type KnowledgeFtsConfig,
|
||||
type KnowledgeSourceMessage
|
||||
} from '../../src/shared/knowledge'
|
||||
import { KnowledgeStore } from '../../src/main/knowledge/knowledge-store'
|
||||
import {
|
||||
createKnowledgeBenchmarkFixture,
|
||||
type KnowledgeBenchmarkCase
|
||||
} from '../fixtures/knowledge-rag'
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), 'wxe-knowledge-benchmark-'))
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
type Retrieval = { messageIds: string[] }
|
||||
type Metrics = {
|
||||
recallAt5: number
|
||||
recallAt10: number
|
||||
mrr: number
|
||||
evidenceAccuracy: number
|
||||
findSuccessAt10: number
|
||||
p50LatencyMs: number
|
||||
p95LatencyMs: number
|
||||
averageInputTokens: number
|
||||
}
|
||||
|
||||
function percentile(values: number[], percentileValue: number): number {
|
||||
if (!values.length) return 0
|
||||
const sorted = values.slice().sort((left, right) => left - right)
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * percentileValue) - 1)]
|
||||
}
|
||||
|
||||
function estimateInputTokens(messages: KnowledgeSourceMessage[]): number {
|
||||
const chars = messages.reduce((total, message) => total + (message.text || '').length, 0)
|
||||
// Conservative Chinese-oriented baseline: question/system metadata plus the selected old-search context.
|
||||
return 1_000 + Math.ceil(chars / 2)
|
||||
}
|
||||
|
||||
function oldSearch(
|
||||
messages: KnowledgeSourceMessage[],
|
||||
testCase: KnowledgeBenchmarkCase
|
||||
): Retrieval[] {
|
||||
const normalizedTerms = testCase.oldSearchTerms.map((term) => term.toLowerCase())
|
||||
return messages
|
||||
.map((message) => {
|
||||
const text =
|
||||
`${message.text || ''}\n${message.voiceTranscript || ''}\n${message.attachment?.name || ''}`.toLowerCase()
|
||||
const score = normalizedTerms.reduce(
|
||||
(total, term) => total + (text.includes(term) ? 1 : 0),
|
||||
0
|
||||
)
|
||||
return { messageIds: [message.messageId], score }
|
||||
})
|
||||
.filter((item) => item.score > 0)
|
||||
.sort((left, right) => right.score - left.score)
|
||||
}
|
||||
|
||||
function scoreCases(
|
||||
cases: KnowledgeBenchmarkCase[],
|
||||
search: (testCase: KnowledgeBenchmarkCase) => Retrieval[],
|
||||
inputTokens: (testCase: KnowledgeBenchmarkCase) => number
|
||||
): Metrics {
|
||||
const latency: number[] = []
|
||||
let recallAt5 = 0
|
||||
let recallAt10 = 0
|
||||
let reciprocalRank = 0
|
||||
let evidenceAccuracy = 0
|
||||
let findSuccessAt10 = 0
|
||||
let totalInputTokens = 0
|
||||
for (const testCase of cases) {
|
||||
const started = performance.now()
|
||||
const retrieved = search(testCase)
|
||||
latency.push(performance.now() - started)
|
||||
const flattened = retrieved.map((item) => item.messageIds)
|
||||
const expected = new Set(testCase.expectedMessageIds)
|
||||
const hitPosition = flattened.findIndex((ids) => ids.some((id) => expected.has(id)))
|
||||
if (flattened.slice(0, 5).some((ids) => ids.some((id) => expected.has(id)))) recallAt5 += 1
|
||||
if (hitPosition >= 0 && hitPosition < 10) {
|
||||
recallAt10 += 1
|
||||
findSuccessAt10 += 1
|
||||
reciprocalRank += 1 / (hitPosition + 1)
|
||||
}
|
||||
const firstFive = flattened.slice(0, 5)
|
||||
if (firstFive.length) {
|
||||
evidenceAccuracy +=
|
||||
firstFive.filter((ids) => ids.some((id) => expected.has(id))).length / firstFive.length
|
||||
}
|
||||
totalInputTokens += inputTokens(testCase)
|
||||
}
|
||||
return {
|
||||
recallAt5: recallAt5 / cases.length,
|
||||
recallAt10: recallAt10 / cases.length,
|
||||
mrr: reciprocalRank / cases.length,
|
||||
evidenceAccuracy: evidenceAccuracy / cases.length,
|
||||
findSuccessAt10: findSuccessAt10 / cases.length,
|
||||
p50LatencyMs: percentile(latency, 0.5),
|
||||
p95LatencyMs: percentile(latency, 0.95),
|
||||
averageInputTokens: totalInputTokens / cases.length
|
||||
}
|
||||
}
|
||||
|
||||
const profiles: KnowledgeFtsConfig[] = [
|
||||
{
|
||||
profileId: 'unicode61-external-full-columnsize',
|
||||
tokenizer: 'unicode61',
|
||||
contentMode: 'external',
|
||||
detail: 'full',
|
||||
columnsize: 1
|
||||
},
|
||||
{
|
||||
profileId: 'trigram-external-full-columnsize',
|
||||
tokenizer: 'trigram',
|
||||
contentMode: 'external',
|
||||
detail: 'full',
|
||||
columnsize: 1
|
||||
},
|
||||
{
|
||||
profileId: 'trigram-external-column-no-columnsize',
|
||||
tokenizer: 'trigram',
|
||||
contentMode: 'external',
|
||||
detail: 'column',
|
||||
columnsize: 0
|
||||
},
|
||||
{
|
||||
profileId: 'trigram-internal-none-no-columnsize',
|
||||
tokenizer: 'trigram',
|
||||
contentMode: 'internal',
|
||||
detail: 'none',
|
||||
columnsize: 0
|
||||
}
|
||||
]
|
||||
|
||||
describe('desensitized local knowledge benchmark', () => {
|
||||
it('records the 100-question Old Search baseline and FTS5 configuration comparisons', async () => {
|
||||
const fixture = createKnowledgeBenchmarkFixture()
|
||||
expect(fixture.cases).toHaveLength(100)
|
||||
expect(new Set(fixture.cases.map((item) => item.category))).toEqual(
|
||||
new Set(['fact', 'person', 'time', 'decision', 'semantic'])
|
||||
)
|
||||
const sourceMessages = fixture.conversations.flatMap((conversation) => conversation.messages)
|
||||
const oldMetrics = scoreCases(
|
||||
fixture.cases,
|
||||
(testCase) => oldSearch(sourceMessages, testCase),
|
||||
(testCase) => {
|
||||
const selected = oldSearch(sourceMessages, testCase).slice(0, 240)
|
||||
const ids = new Set(selected.flatMap((item) => item.messageIds))
|
||||
return estimateInputTokens(sourceMessages.filter((message) => ids.has(message.messageId)))
|
||||
}
|
||||
)
|
||||
|
||||
const comparisons: Array<{
|
||||
profile: KnowledgeFtsConfig
|
||||
metrics: Metrics
|
||||
databaseBytes: number
|
||||
}> = []
|
||||
for (const profile of profiles) {
|
||||
const store = new KnowledgeStore(
|
||||
join(root, profile.profileId),
|
||||
sourceMessages[0].accountId,
|
||||
profile
|
||||
)
|
||||
await store.index({
|
||||
conversations: fixture.conversations,
|
||||
chunker: DEFAULT_KNOWLEDGE_CHUNKER
|
||||
})
|
||||
const metrics = scoreCases(
|
||||
fixture.cases,
|
||||
(testCase) =>
|
||||
store.search({
|
||||
accountId: sourceMessages[0].accountId,
|
||||
text: testCase.question,
|
||||
terms: testCase.oldSearchTerms,
|
||||
limit: 10
|
||||
}),
|
||||
(testCase) => {
|
||||
const evidence: KnowledgeEvidence[] = store.search({
|
||||
accountId: sourceMessages[0].accountId,
|
||||
text: testCase.question,
|
||||
terms: testCase.oldSearchTerms,
|
||||
limit: 10
|
||||
})
|
||||
return (
|
||||
1_000 + Math.ceil(evidence.reduce((total, item) => total + item.text.length, 0) / 2)
|
||||
)
|
||||
}
|
||||
)
|
||||
store.checkpoint()
|
||||
comparisons.push({ profile, metrics, databaseBytes: store.getStorageStats().databaseBytes })
|
||||
store.close()
|
||||
}
|
||||
console.log(
|
||||
`KNOWLEDGE_BENCHMARK_REPORT=${JSON.stringify(
|
||||
{
|
||||
fixture: 'synthetic-desensitized-v1',
|
||||
questions: fixture.cases.length,
|
||||
categories: ['fact', 'person', 'time', 'decision', 'semantic'],
|
||||
oldSearch: oldMetrics,
|
||||
fts5Comparisons: comparisons
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}`
|
||||
)
|
||||
expect(oldMetrics.averageInputTokens).toBeGreaterThan(1_000)
|
||||
expect(comparisons).toHaveLength(profiles.length)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { mkdtempSync } from 'fs'
|
||||
import { rename, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { performance } from 'perf_hooks'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DEFAULT_KNOWLEDGE_CHUNKER, type KnowledgeFtsConfig } from '../../src/shared/knowledge'
|
||||
import { KnowledgeStore } from '../../src/main/knowledge/knowledge-store'
|
||||
import { createSyntheticConversation, FIXTURE_ACCOUNT_A } from '../fixtures/knowledge-rag'
|
||||
|
||||
const runCapacity = process.env.KNOWLEDGE_CAPACITY === '1'
|
||||
const capacityIt = runCapacity ? it : it.skip
|
||||
const scales = [100_000, 500_000, 1_000_000] as const
|
||||
const distributions = ['short', 'mixed', 'long'] as const
|
||||
const batchSize = 10_000
|
||||
const reportPath = process.env.KNOWLEDGE_CAPACITY_REPORT_PATH || join(tmpdir(), 'wechatexplorer-knowledge-capacity-report.json')
|
||||
const profile: KnowledgeFtsConfig = {
|
||||
profileId: 'capacity-unicode-external-full-columnsize',
|
||||
tokenizer: 'unicode61',
|
||||
contentMode: 'external',
|
||||
detail: 'full',
|
||||
columnsize: 1
|
||||
}
|
||||
|
||||
describe('knowledge capacity benchmark', () => {
|
||||
capacityIt(
|
||||
'measures 100k, 500k and 1m desensitized messages across text distributions',
|
||||
async () => {
|
||||
const reports: Array<Record<string, number | string>> = []
|
||||
for (const distribution of distributions) {
|
||||
for (const messageCount of scales) {
|
||||
const root = mkdtempSync(join(tmpdir(), `wxe-knowledge-capacity-${distribution}-${messageCount}-`))
|
||||
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, profile)
|
||||
let peakWalBytes = 0
|
||||
let peakTemporaryBytes = 0
|
||||
let peakRssBytes = process.memoryUsage().rss
|
||||
const started = performance.now()
|
||||
try {
|
||||
for (let offset = 0; offset < messageCount; offset += batchSize) {
|
||||
const count = Math.min(batchSize, messageCount - offset)
|
||||
await store.index({
|
||||
conversations: [
|
||||
createSyntheticConversation(
|
||||
FIXTURE_ACCOUNT_A,
|
||||
`capacity-${distribution}-${offset / batchSize}`,
|
||||
offset,
|
||||
count,
|
||||
distribution
|
||||
)
|
||||
],
|
||||
chunker: DEFAULT_KNOWLEDGE_CHUNKER
|
||||
})
|
||||
const stats = store.getStorageStats()
|
||||
peakWalBytes = Math.max(peakWalBytes, stats.walBytes)
|
||||
peakTemporaryBytes = Math.max(peakTemporaryBytes, stats.walBytes + stats.shmBytes)
|
||||
peakRssBytes = Math.max(peakRssBytes, process.memoryUsage().rss)
|
||||
}
|
||||
store.checkpoint()
|
||||
const stats = store.getStorageStats()
|
||||
reports.push({
|
||||
profile: profile.profileId,
|
||||
distribution,
|
||||
messageCount,
|
||||
finalDatabaseBytes: stats.databaseBytes,
|
||||
perTenThousandMessagesBytes: Math.round(stats.databaseBytes / (messageCount / 10_000)),
|
||||
peakWalBytes,
|
||||
peakTemporaryBytes,
|
||||
elapsedMs: Math.round(performance.now() - started),
|
||||
workerPeakRssBytes: peakRssBytes,
|
||||
pageSize: stats.pageSize,
|
||||
pageCount: stats.pageCount,
|
||||
freelistCount: stats.freelistCount
|
||||
})
|
||||
} finally {
|
||||
store.close()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
fixture: 'synthetic-desensitized-v1',
|
||||
profile,
|
||||
scenarios: reports
|
||||
}
|
||||
const temporaryReportPath = `${reportPath}.partial`
|
||||
await writeFile(temporaryReportPath, JSON.stringify(report, null, 2), 'utf8')
|
||||
await rename(temporaryReportPath, reportPath)
|
||||
console.log(`KNOWLEDGE_CAPACITY_REPORT_PATH=${reportPath}`)
|
||||
expect(reports).toHaveLength(scales.length * distributions.length)
|
||||
},
|
||||
20 * 60 * 1000
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,209 @@
|
||||
import { mkdtempSync } from 'fs'
|
||||
import { rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { performance } from 'perf_hooks'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_KNOWLEDGE_CHUNKER,
|
||||
type KnowledgeEvidence,
|
||||
type KnowledgeFtsConfig,
|
||||
type KnowledgeSourceMessage
|
||||
} from '../../src/shared/knowledge'
|
||||
import { KnowledgeStore } from '../../src/main/knowledge/knowledge-store'
|
||||
import {
|
||||
createRealisticKnowledgeFixture,
|
||||
type RealisticBenchmarkCase,
|
||||
type RealisticBenchmarkCategory
|
||||
} from '../fixtures/knowledge-realistic'
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), 'wxe-realistic-fts-'))
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
type Retrieval = { messageIds: string[] }
|
||||
type Metrics = {
|
||||
recallAt5: number
|
||||
recallAt10: number
|
||||
mrr: number
|
||||
evidenceAccuracy: number
|
||||
findSuccessAt10: number
|
||||
p50LatencyMs: number
|
||||
p95LatencyMs: number
|
||||
}
|
||||
|
||||
function percentile(values: number[], ratio: number): number {
|
||||
if (!values.length) return 0
|
||||
const sorted = values.slice().sort((left, right) => left - right)
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)]
|
||||
}
|
||||
|
||||
function oldSearch(
|
||||
messages: KnowledgeSourceMessage[],
|
||||
testCase: RealisticBenchmarkCase
|
||||
): Retrieval[] {
|
||||
return messages
|
||||
.map((message) => {
|
||||
const text =
|
||||
`${message.text || ''}\n${message.voiceTranscript || ''}\n${message.attachment?.name || ''}`.toLowerCase()
|
||||
const score = testCase.searchTerms.reduce(
|
||||
(total, term) => total + (text.includes(term.toLowerCase()) ? 1 : 0),
|
||||
0
|
||||
)
|
||||
return { messageIds: [message.messageId], score }
|
||||
})
|
||||
.filter((item) => item.score > 0)
|
||||
.sort((left, right) => right.score - left.score)
|
||||
}
|
||||
|
||||
function score(
|
||||
cases: RealisticBenchmarkCase[],
|
||||
search: (testCase: RealisticBenchmarkCase) => Retrieval[]
|
||||
): Metrics {
|
||||
const latency: number[] = []
|
||||
let recallAt5 = 0
|
||||
let recallAt10 = 0
|
||||
let reciprocalRank = 0
|
||||
let evidenceAccuracy = 0
|
||||
let findSuccessAt10 = 0
|
||||
for (const testCase of cases) {
|
||||
const started = performance.now()
|
||||
const results = search(testCase)
|
||||
latency.push(performance.now() - started)
|
||||
const expected = new Set(testCase.expectedMessageIds)
|
||||
const firstTen = results.slice(0, 10)
|
||||
const hitIndex = firstTen.findIndex((item) => item.messageIds.some((id) => expected.has(id)))
|
||||
if (results.slice(0, 5).some((item) => item.messageIds.some((id) => expected.has(id)))) {
|
||||
recallAt5 += 1
|
||||
}
|
||||
if (hitIndex >= 0) {
|
||||
recallAt10 += 1
|
||||
findSuccessAt10 += 1
|
||||
reciprocalRank += 1 / (hitIndex + 1)
|
||||
}
|
||||
const firstFive = results.slice(0, 5)
|
||||
if (firstFive.length) {
|
||||
evidenceAccuracy +=
|
||||
firstFive.filter((item) => item.messageIds.some((id) => expected.has(id))).length /
|
||||
firstFive.length
|
||||
}
|
||||
}
|
||||
return {
|
||||
recallAt5: recallAt5 / cases.length,
|
||||
recallAt10: recallAt10 / cases.length,
|
||||
mrr: reciprocalRank / cases.length,
|
||||
evidenceAccuracy: evidenceAccuracy / cases.length,
|
||||
findSuccessAt10: findSuccessAt10 / cases.length,
|
||||
p50LatencyMs: percentile(latency, 0.5),
|
||||
p95LatencyMs: percentile(latency, 0.95)
|
||||
}
|
||||
}
|
||||
|
||||
function groupedByCategory<T>(
|
||||
cases: RealisticBenchmarkCase[],
|
||||
evaluate: (items: RealisticBenchmarkCase[]) => T
|
||||
): Record<RealisticBenchmarkCategory, T> {
|
||||
const groups = new Map<RealisticBenchmarkCategory, RealisticBenchmarkCase[]>()
|
||||
for (const item of cases) groups.set(item.category, [...(groups.get(item.category) || []), item])
|
||||
return Object.fromEntries(
|
||||
Array.from(groups.entries()).map(([category, items]) => [category, evaluate(items)])
|
||||
) as Record<RealisticBenchmarkCategory, T>
|
||||
}
|
||||
|
||||
const profiles: KnowledgeFtsConfig[] = [
|
||||
{
|
||||
profileId: 'unicode61-external-full-columnsize',
|
||||
tokenizer: 'unicode61',
|
||||
contentMode: 'external',
|
||||
detail: 'full',
|
||||
columnsize: 1
|
||||
},
|
||||
{
|
||||
profileId: 'trigram-external-full-columnsize',
|
||||
tokenizer: 'trigram',
|
||||
contentMode: 'external',
|
||||
detail: 'full',
|
||||
columnsize: 1
|
||||
},
|
||||
{
|
||||
profileId: 'trigram-external-column-no-columnsize',
|
||||
tokenizer: 'trigram',
|
||||
contentMode: 'external',
|
||||
detail: 'column',
|
||||
columnsize: 0
|
||||
}
|
||||
]
|
||||
|
||||
describe('realistic desensitized WeChat FTS5 benchmark', () => {
|
||||
it('compares unicode61 and trigram by recall quality before Task 3 chooses a runtime profile', async () => {
|
||||
const fixture = createRealisticKnowledgeFixture()
|
||||
const messages = fixture.conversations.flatMap((conversation) => conversation.messages)
|
||||
const oldMetrics = score(fixture.cases, (testCase) => oldSearch(messages, testCase))
|
||||
const comparisons: Array<{
|
||||
profile: KnowledgeFtsConfig
|
||||
metrics: Metrics
|
||||
categoryMetrics: Record<RealisticBenchmarkCategory, Metrics>
|
||||
databaseBytes: number
|
||||
}> = []
|
||||
for (const profile of profiles) {
|
||||
const store = new KnowledgeStore(
|
||||
join(root, profile.profileId),
|
||||
messages[0].accountId,
|
||||
profile
|
||||
)
|
||||
await store.index({
|
||||
conversations: fixture.conversations,
|
||||
chunker: DEFAULT_KNOWLEDGE_CHUNKER
|
||||
})
|
||||
const search = (testCase: RealisticBenchmarkCase): Retrieval[] =>
|
||||
store
|
||||
.search({
|
||||
accountId: messages[0].accountId,
|
||||
text: testCase.question,
|
||||
terms: testCase.searchTerms,
|
||||
limit: 10
|
||||
})
|
||||
.map((item: KnowledgeEvidence) => ({ messageIds: item.messageIds }))
|
||||
const metrics = score(fixture.cases, search)
|
||||
store.checkpoint()
|
||||
comparisons.push({
|
||||
profile,
|
||||
metrics,
|
||||
categoryMetrics: groupedByCategory(fixture.cases, (items) => score(items, search)),
|
||||
databaseBytes: store.getStorageStats().databaseBytes
|
||||
})
|
||||
store.close()
|
||||
}
|
||||
console.log(
|
||||
`KNOWLEDGE_REALISTIC_FTS_REPORT=${JSON.stringify(
|
||||
{
|
||||
fixture: 'realistic-desensitized-wechat-v1',
|
||||
questionCount: fixture.cases.length,
|
||||
categories: Array.from(new Set(fixture.cases.map((item) => item.category))),
|
||||
oldSearch: oldMetrics,
|
||||
fts5Comparisons: comparisons
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}`
|
||||
)
|
||||
expect(fixture.cases).toHaveLength(14)
|
||||
expect(new Set(fixture.cases.map((item) => item.category))).toEqual(
|
||||
new Set([
|
||||
'chinese-continuous',
|
||||
'chinese-short',
|
||||
'person-name',
|
||||
'mixed-language',
|
||||
'url',
|
||||
'file-name',
|
||||
'technical-term',
|
||||
'number-email-path',
|
||||
'short-message',
|
||||
'long-voice'
|
||||
])
|
||||
)
|
||||
expect(comparisons).toHaveLength(profiles.length)
|
||||
})
|
||||
})
|
||||
Vendored
+158
@@ -0,0 +1,158 @@
|
||||
import type { KnowledgeConversationInput, KnowledgeSourceMessage } from '../../src/shared/knowledge'
|
||||
|
||||
export type KnowledgeBenchmarkCategory = 'fact' | 'person' | 'time' | 'decision' | 'semantic'
|
||||
|
||||
export interface KnowledgeBenchmarkCase {
|
||||
id: string
|
||||
category: KnowledgeBenchmarkCategory
|
||||
question: string
|
||||
oldSearchTerms: string[]
|
||||
expectedMessageIds: string[]
|
||||
}
|
||||
|
||||
export const FIXTURE_ACCOUNT_A = 'fixture-account-alpha'
|
||||
export const FIXTURE_ACCOUNT_B = 'fixture-account-beta'
|
||||
|
||||
function message(
|
||||
id: string,
|
||||
conversationId: string,
|
||||
createTime: number,
|
||||
text: string,
|
||||
extra: Partial<KnowledgeSourceMessage> = {}
|
||||
): KnowledgeSourceMessage {
|
||||
return {
|
||||
accountId: extra.accountId || FIXTURE_ACCOUNT_A,
|
||||
conversationId,
|
||||
messageId: id,
|
||||
createTime,
|
||||
senderId: extra.senderId || 'fixture-sender',
|
||||
senderName: extra.senderName || '脱敏成员',
|
||||
kind: extra.kind || 'text',
|
||||
text,
|
||||
attachment: extra.attachment,
|
||||
voiceTranscript: extra.voiceTranscript
|
||||
}
|
||||
}
|
||||
|
||||
/** Deterministic and fully synthetic: no wxid, file path, secret, or real chat text. */
|
||||
export function createKnowledgeBenchmarkFixture(): {
|
||||
conversations: KnowledgeConversationInput[]
|
||||
cases: KnowledgeBenchmarkCase[]
|
||||
} {
|
||||
const messages: KnowledgeSourceMessage[] = []
|
||||
const cases: KnowledgeBenchmarkCase[] = []
|
||||
const base = Date.UTC(2025, 0, 1)
|
||||
const add = (
|
||||
category: KnowledgeBenchmarkCategory,
|
||||
index: number,
|
||||
text: string,
|
||||
question: string,
|
||||
oldSearchTerms: string[],
|
||||
extra: Partial<KnowledgeSourceMessage> = {}
|
||||
): void => {
|
||||
const conversationId = `fixture-${category}-${index % 5}`
|
||||
const id = `fixture-${category}-${index}-evidence`
|
||||
messages.push(message(id, conversationId, base + (cases.length + 1) * 60_000, text, extra))
|
||||
messages.push(
|
||||
message(
|
||||
`fixture-${category}-${index}-context`,
|
||||
conversationId,
|
||||
base + (cases.length + 1) * 60_000 + 20_000,
|
||||
`脱敏上下文 ${index}:确认后续会回到原始消息核对。`,
|
||||
{ senderId: 'fixture-context', senderName: '脱敏同事' }
|
||||
)
|
||||
)
|
||||
cases.push({
|
||||
id: `question-${category}-${index}`,
|
||||
category,
|
||||
question,
|
||||
oldSearchTerms,
|
||||
expectedMessageIds: [id]
|
||||
})
|
||||
}
|
||||
for (let index = 1; index <= 20; index += 1) {
|
||||
add(
|
||||
'fact',
|
||||
index,
|
||||
`资料编号 FACT-${index} 的部署地址是 https://example.invalid/fact-${index},附件名称是 runbook-${index}.pdf。`,
|
||||
`第 ${index} 项部署资料在哪里?`,
|
||||
[`FACT-${index}`, `runbook-${index}.pdf`],
|
||||
{ attachment: { name: `runbook-${index}.pdf`, kind: 'file' } }
|
||||
)
|
||||
add(
|
||||
'person',
|
||||
index,
|
||||
`成员 代号成员${index} 负责发布检查,并说明本周会完成验证清单。`,
|
||||
`代号成员${index} 最近负责什么?`,
|
||||
[`代号成员${index}`, '发布检查']
|
||||
)
|
||||
add(
|
||||
'time',
|
||||
index,
|
||||
`日期标记 TIME-${index}:在第 ${index} 次周会讨论了回归安排和验收顺序。`,
|
||||
`TIME-${index} 当天讨论了什么?`,
|
||||
[`TIME-${index}`, '回归安排']
|
||||
)
|
||||
add(
|
||||
'decision',
|
||||
index,
|
||||
`决策 DECISION-${index}:最终选择方案蓝图${index},原因是可追溯、可回滚且维护成本更低。`,
|
||||
`为什么第 ${index} 个决策选择方案蓝图${index}?`,
|
||||
[`DECISION-${index}`, `方案蓝图${index}`]
|
||||
)
|
||||
add(
|
||||
'semantic',
|
||||
index,
|
||||
`语义样本 ${index}:把分散的讨论归档,方便以后重新查看和核对当时的上下文。`,
|
||||
`哪里提到把内容收起来以后查看?第 ${index} 条。`,
|
||||
[`内容收起来${index}`],
|
||||
{ kind: 'voice', voiceTranscript: `请将分散讨论集中保存,便于之后重新查看,第 ${index} 条。` }
|
||||
)
|
||||
}
|
||||
const grouped = new Map<string, KnowledgeSourceMessage[]>()
|
||||
for (const item of messages) {
|
||||
const current = grouped.get(item.conversationId) || []
|
||||
current.push(item)
|
||||
grouped.set(item.conversationId, current)
|
||||
}
|
||||
return {
|
||||
conversations: Array.from(grouped.entries()).map(([conversationId, source]) => ({
|
||||
conversationId,
|
||||
completeSnapshot: true,
|
||||
messages: source
|
||||
})),
|
||||
cases
|
||||
}
|
||||
}
|
||||
|
||||
export function createSyntheticConversation(
|
||||
accountId: string,
|
||||
conversationId: string,
|
||||
startIndex: number,
|
||||
count: number,
|
||||
distribution: 'short' | 'mixed' | 'long'
|
||||
): KnowledgeConversationInput {
|
||||
const base = Date.UTC(2025, 0, 1) + startIndex * 1000
|
||||
const messages: KnowledgeSourceMessage[] = []
|
||||
const shortText = '脱敏短消息:已确认。'
|
||||
const mixedText = '脱敏普通消息:讨论本地知识库、索引状态、证据回跳和增量恢复。'
|
||||
const longText = `脱敏长文本/语音转写:${'用于容量测试的可检索上下文。'.repeat(12)}`
|
||||
for (let offset = 0; offset < count; offset += 1) {
|
||||
const index = startIndex + offset
|
||||
const text = distribution === 'short' ? shortText : distribution === 'mixed' ? mixedText : longText
|
||||
messages.push({
|
||||
accountId,
|
||||
conversationId,
|
||||
messageId: `synthetic-${distribution}-${index}`,
|
||||
createTime: base + offset * 60_000,
|
||||
senderId: `fixture-member-${index % 8}`,
|
||||
senderName: `脱敏成员${index % 8}`,
|
||||
kind: distribution === 'long' && index % 4 === 0 ? 'voice' : 'text',
|
||||
text,
|
||||
voiceTranscript: distribution === 'long' && index % 4 === 0 ? longText : undefined,
|
||||
attachment:
|
||||
index % 97 === 0 ? { name: `fixture-${index}.txt`, kind: 'file', sizeBytes: 2048 } : undefined
|
||||
})
|
||||
}
|
||||
return { conversationId, completeSnapshot: true, messages }
|
||||
}
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
import type { KnowledgeConversationInput, KnowledgeSourceMessage } from '../../src/shared/knowledge'
|
||||
|
||||
export type RealisticBenchmarkCategory =
|
||||
| 'chinese-continuous'
|
||||
| 'chinese-short'
|
||||
| 'person-name'
|
||||
| 'mixed-language'
|
||||
| 'url'
|
||||
| 'file-name'
|
||||
| 'technical-term'
|
||||
| 'number-email-path'
|
||||
| 'short-message'
|
||||
| 'long-voice'
|
||||
|
||||
export interface RealisticBenchmarkCase {
|
||||
id: string
|
||||
category: RealisticBenchmarkCategory
|
||||
/** The question as a user would naturally phrase it. */
|
||||
question: string
|
||||
/** Deterministic local query-router output, not an LLM-generated answer. */
|
||||
searchTerms: string[]
|
||||
expectedMessageIds: string[]
|
||||
}
|
||||
|
||||
export const REALISTIC_FIXTURE_ACCOUNT = 'fixture-realistic-account'
|
||||
|
||||
function sourceMessage(
|
||||
messageId: string,
|
||||
conversationId: string,
|
||||
createTime: number,
|
||||
text: string,
|
||||
extra: Partial<KnowledgeSourceMessage> = {}
|
||||
): KnowledgeSourceMessage {
|
||||
return {
|
||||
accountId: REALISTIC_FIXTURE_ACCOUNT,
|
||||
conversationId,
|
||||
messageId,
|
||||
createTime,
|
||||
senderId: extra.senderId || 'fixture-member-a',
|
||||
senderName: extra.senderName || '脱敏成员甲',
|
||||
kind: extra.kind || 'text',
|
||||
text,
|
||||
attachment: extra.attachment,
|
||||
voiceTranscript: extra.voiceTranscript
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully artificial messages written in the style of real WeChat conversations.
|
||||
* Nicknames, domains, addresses, mailboxes, file names and paths are all fixtures;
|
||||
* no user chat record, wxid, account directory, or source-database value is included.
|
||||
*/
|
||||
export function createRealisticKnowledgeFixture(): {
|
||||
conversations: KnowledgeConversationInput[]
|
||||
cases: RealisticBenchmarkCase[]
|
||||
} {
|
||||
const base = Date.UTC(2026, 6, 12, 8, 0, 0)
|
||||
const messages: KnowledgeSourceMessage[] = [
|
||||
sourceMessage(
|
||||
'msg-cn-continuous-1',
|
||||
'conv-product-group',
|
||||
base,
|
||||
'刚确认:聊天档案导出失败时,先保留原图链接,再回退缩略图,避免用户以为图片丢了。',
|
||||
{ senderId: 'member-lan', senderName: '蓝图同学' }
|
||||
),
|
||||
sourceMessage(
|
||||
'msg-cn-continuous-noise',
|
||||
'conv-product-group',
|
||||
base + 60,
|
||||
'导出完成后可以在任务中心查看文件夹。',
|
||||
{ senderId: 'member-river', senderName: '河岸' }
|
||||
),
|
||||
sourceMessage('msg-cn-short-1', 'conv-family', base + 120, '周六见,咖啡我来带。', {
|
||||
senderId: 'member-yu',
|
||||
senderName: '小雨'
|
||||
}),
|
||||
sourceMessage(
|
||||
'msg-person-1',
|
||||
'conv-product-group',
|
||||
base + 180,
|
||||
'林澈把 Windows 安装包的签名检查补好了,今晚发测试包。',
|
||||
{ senderId: 'member-lin', senderName: '林澈' }
|
||||
),
|
||||
sourceMessage(
|
||||
'msg-person-noise',
|
||||
'conv-product-group',
|
||||
base + 240,
|
||||
'小林晚点把截图发到群里。',
|
||||
{ senderId: 'member-lin', senderName: '林澈' }
|
||||
),
|
||||
sourceMessage(
|
||||
'msg-mixed-1',
|
||||
'conv-engineering',
|
||||
base + 300,
|
||||
'Web 端的 dark mode 先跟随系统,Desktop 端继续保留手动切换。',
|
||||
{ senderId: 'member-echo', senderName: 'Echo' }
|
||||
),
|
||||
sourceMessage(
|
||||
'msg-url-1',
|
||||
'conv-engineering',
|
||||
base + 360,
|
||||
'排障说明在 https://docs.example.invalid/guide/image-export?from=wechat ,不要把真实日志贴到公开 issue。',
|
||||
{ senderId: 'member-echo', senderName: 'Echo' }
|
||||
),
|
||||
sourceMessage(
|
||||
'msg-file-1',
|
||||
'conv-filehelper',
|
||||
base + 420,
|
||||
'已上传 release-checklist-v2.1.9.xlsx,发布前把 macOS 和 Windows 两栏都勾完。',
|
||||
{
|
||||
senderId: 'self-fixture',
|
||||
senderName: '我',
|
||||
attachment: { name: 'release-checklist-v2.1.9.xlsx', kind: 'file', sizeBytes: 20480 }
|
||||
}
|
||||
),
|
||||
sourceMessage(
|
||||
'msg-tech-mcp',
|
||||
'conv-engineering',
|
||||
base + 480,
|
||||
'MCP Reader 只暴露只读查询;写入操作必须经过本地确认,不能让 Agent 直接改微信数据。',
|
||||
{ senderId: 'member-q', senderName: 'Q' }
|
||||
),
|
||||
sourceMessage(
|
||||
'msg-tech-react',
|
||||
'conv-engineering',
|
||||
base + 540,
|
||||
'React 列表先做虚拟滚动,Electron 主进程不要把十万条消息一次性发给 renderer。',
|
||||
{ senderId: 'member-q', senderName: 'Q' }
|
||||
),
|
||||
sourceMessage(
|
||||
'msg-tech-sqlite',
|
||||
'conv-engineering',
|
||||
base + 600,
|
||||
'SQLite FTS5 的 trigram 对中文子串更友好,但短词仍要有精确匹配补偿。',
|
||||
{ senderId: 'member-lan', senderName: '蓝图同学' }
|
||||
),
|
||||
sourceMessage(
|
||||
'msg-number-email-path',
|
||||
'conv-operations',
|
||||
base + 660,
|
||||
'工单 48291 请发给 fixture@example.invalid;测试附件放到 /tmp/wechat-fixture/export-preview/,不要使用个人目录。',
|
||||
{ senderId: 'member-ops', senderName: '运营小组' }
|
||||
),
|
||||
sourceMessage('msg-short-1', 'conv-family', base + 720, '收到,明早十点。', {
|
||||
senderId: 'member-yu',
|
||||
senderName: '小雨'
|
||||
}),
|
||||
sourceMessage('msg-short-noise', 'conv-family', base + 780, '好的,晚安。', {
|
||||
senderId: 'member-yu',
|
||||
senderName: '小雨'
|
||||
}),
|
||||
sourceMessage('msg-voice-long-1', 'conv-project-sync', base + 840, '[语音消息]', {
|
||||
senderId: 'member-voice',
|
||||
senderName: '语音同学',
|
||||
kind: 'voice',
|
||||
voiceTranscript:
|
||||
'刚才同步一下长语音结论:本周不做向量检索,也不新增记忆页面。先把现有问问微信的关键词检索放进独立 Knowledge Worker,索引只读取原始数据库,结果必须保留 messageId、会话、发送人和时间,异常时继续使用旧搜索。'
|
||||
}),
|
||||
sourceMessage(
|
||||
'msg-decision-1',
|
||||
'conv-project-sync',
|
||||
base + 900,
|
||||
'决定先上 FTS,不接 Embedding:先验证中文、文件名和技术词的召回,再考虑下一阶段。',
|
||||
{ senderId: 'member-voice', senderName: '语音同学' }
|
||||
),
|
||||
sourceMessage(
|
||||
'msg-url-noise',
|
||||
'conv-engineering',
|
||||
base + 960,
|
||||
'本周会议链接仍然走内部日历,不要混在发布文档里。',
|
||||
{ senderId: 'member-echo', senderName: 'Echo' }
|
||||
),
|
||||
sourceMessage(
|
||||
'msg-file-noise',
|
||||
'conv-filehelper',
|
||||
base + 1020,
|
||||
'旧版 release-note.txt 仅供历史核对,不要再上传。',
|
||||
{ senderId: 'self-fixture', senderName: '我' }
|
||||
),
|
||||
sourceMessage(
|
||||
'msg-long-text-1',
|
||||
'conv-project-sync',
|
||||
base + 1080,
|
||||
'补充记录:当索引仍在建立或 Worker 发生异常,界面行为不能中断。主进程需要保留旧关键词检索作为临时回退,但 renderer 不应重新批量加载全部会话消息。等索引完成后,Evidence 应统一由知识库返回,并能跳回原聊天。',
|
||||
{ senderId: 'member-lan', senderName: '蓝图同学' }
|
||||
)
|
||||
]
|
||||
|
||||
const cases: RealisticBenchmarkCase[] = [
|
||||
{
|
||||
id: 'cn-continuous',
|
||||
category: 'chinese-continuous',
|
||||
question: '图片导出失败时应该怎样避免用户误以为图片丢失?',
|
||||
searchTerms: ['原图链接', '缩略图'],
|
||||
expectedMessageIds: ['msg-cn-continuous-1']
|
||||
},
|
||||
{
|
||||
id: 'cn-short',
|
||||
category: 'chinese-short',
|
||||
question: '周六谁带咖啡?',
|
||||
searchTerms: ['周六见', '咖啡'],
|
||||
expectedMessageIds: ['msg-cn-short-1']
|
||||
},
|
||||
{
|
||||
id: 'person-name',
|
||||
category: 'person-name',
|
||||
question: '林澈最近补了什么?',
|
||||
searchTerms: ['林澈', '签名检查'],
|
||||
expectedMessageIds: ['msg-person-1']
|
||||
},
|
||||
{
|
||||
id: 'mixed-language',
|
||||
category: 'mixed-language',
|
||||
question: 'dark mode 在 Web 和 Desktop 分别怎么处理?',
|
||||
searchTerms: ['dark mode', 'Desktop'],
|
||||
expectedMessageIds: ['msg-mixed-1']
|
||||
},
|
||||
{
|
||||
id: 'url',
|
||||
category: 'url',
|
||||
question: '图片导出排障文档的网址是什么?',
|
||||
searchTerms: ['docs.example.invalid/guide/image-export'],
|
||||
expectedMessageIds: ['msg-url-1']
|
||||
},
|
||||
{
|
||||
id: 'file-name',
|
||||
category: 'file-name',
|
||||
question: '发布检查表文件叫什么?',
|
||||
searchTerms: ['release-checklist-v2.1.9.xlsx'],
|
||||
expectedMessageIds: ['msg-file-1']
|
||||
},
|
||||
{
|
||||
id: 'technical-mcp',
|
||||
category: 'technical-term',
|
||||
question: 'MCP Reader 的写入限制是什么?',
|
||||
searchTerms: ['MCP Reader', '只读查询'],
|
||||
expectedMessageIds: ['msg-tech-mcp']
|
||||
},
|
||||
{
|
||||
id: 'technical-react-electron',
|
||||
category: 'technical-term',
|
||||
question: 'React 和 Electron 的大量消息处理原则是什么?',
|
||||
searchTerms: ['React', 'Electron'],
|
||||
expectedMessageIds: ['msg-tech-react']
|
||||
},
|
||||
{
|
||||
id: 'technical-sqlite',
|
||||
category: 'technical-term',
|
||||
question: 'SQLite 的中文全文检索要选什么?',
|
||||
searchTerms: ['SQLite FTS5', 'trigram'],
|
||||
expectedMessageIds: ['msg-tech-sqlite']
|
||||
},
|
||||
{
|
||||
id: 'number-email-path',
|
||||
category: 'number-email-path',
|
||||
question: '工单 48291 的邮箱和测试附件目录在哪?',
|
||||
searchTerms: ['48291', 'fixture@example.invalid', '/tmp/wechat-fixture/export-preview'],
|
||||
expectedMessageIds: ['msg-number-email-path']
|
||||
},
|
||||
{
|
||||
id: 'short-message',
|
||||
category: 'short-message',
|
||||
question: '约的是几点?',
|
||||
searchTerms: ['十点'],
|
||||
expectedMessageIds: ['msg-short-1']
|
||||
},
|
||||
{
|
||||
id: 'long-voice',
|
||||
category: 'long-voice',
|
||||
question: '长语音里对 Knowledge Worker 和 fallback 的要求是什么?',
|
||||
searchTerms: ['Knowledge Worker', '旧搜索'],
|
||||
expectedMessageIds: ['msg-voice-long-1']
|
||||
},
|
||||
{
|
||||
id: 'decision',
|
||||
category: 'long-voice',
|
||||
question: '为什么暂时不接 Embedding?',
|
||||
searchTerms: ['不接 Embedding', 'FTS'],
|
||||
expectedMessageIds: ['msg-decision-1']
|
||||
},
|
||||
{
|
||||
id: 'fallback',
|
||||
category: 'long-voice',
|
||||
question: '索引未完成时搜索如何处理?',
|
||||
searchTerms: ['Worker 发生异常', '旧关键词检索'],
|
||||
expectedMessageIds: ['msg-long-text-1']
|
||||
}
|
||||
]
|
||||
|
||||
const grouped = new Map<string, KnowledgeSourceMessage[]>()
|
||||
for (const item of messages) {
|
||||
const current = grouped.get(item.conversationId) || []
|
||||
current.push(item)
|
||||
grouped.set(item.conversationId, current)
|
||||
}
|
||||
return {
|
||||
conversations: Array.from(grouped.entries()).map(([conversationId, source]) => ({
|
||||
conversationId,
|
||||
completeSnapshot: true,
|
||||
messages: source
|
||||
})),
|
||||
cases
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AiSearchPipelineRequest } from '../../src/shared/ai-search'
|
||||
import type { KnowledgeSearchIpcRequest } from '../../src/shared/knowledge'
|
||||
|
||||
const invoke = vi.fn()
|
||||
const on = vi.fn()
|
||||
@@ -37,6 +39,28 @@ describe('preload IPC contract', () => {
|
||||
limit: 50
|
||||
})
|
||||
|
||||
const knowledgeSearch: KnowledgeSearchIpcRequest = {
|
||||
text: '测试 Knowledge Worker 检索',
|
||||
terms: ['Knowledge Worker'],
|
||||
conversationIds: ['fixture-user'],
|
||||
startTime: 10,
|
||||
limit: 20
|
||||
}
|
||||
await api.searchKnowledge(knowledgeSearch)
|
||||
expect(invoke).toHaveBeenLastCalledWith('knowledge:search', knowledgeSearch)
|
||||
const aiSearch: AiSearchPipelineRequest = {
|
||||
requestId: 'fixture-search',
|
||||
text: '最近谁聊过健身',
|
||||
scope: 'global',
|
||||
range: '7d'
|
||||
}
|
||||
await api.runAiSearch(aiSearch)
|
||||
expect(invoke).toHaveBeenLastCalledWith('ai-search:run', aiSearch)
|
||||
await api.startKnowledgeIndex()
|
||||
expect(invoke).toHaveBeenLastCalledWith('knowledge:startIndex')
|
||||
await api.clearCache('knowledge')
|
||||
expect(invoke).toHaveBeenLastCalledWith('cache:clear', 'knowledge')
|
||||
|
||||
await api.getImage('fixture-md5', 'fixture.dat', 'fixture-session', {
|
||||
force: true,
|
||||
priority: 0
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user