feat: 完善问问微信查询理解与检索链路

This commit is contained in:
Wxw-Gu
2026-09-09 16:51:31 +08:00
parent 7554de7d72
commit facc292443
14 changed files with 1107 additions and 66 deletions
@@ -81,6 +81,10 @@ function sourceMessageId(message: chat.FormattedMessage): string {
function sourceKind(message: chat.FormattedMessage): KnowledgeMessageKind {
if (message.voiceTranscript || message.type === '语音') return 'voice'
if (message.exportMediaType === 'image' || message.exportMediaType === 'video' || message.exportMediaType === 'sticker') {
return message.exportMediaType
}
if (message.exportMediaType === 'file') return 'file'
if (message.contentData?.type === 'share' || message.contentData?.type === 'miniProgram') {
return message.contentData.type === 'share' && message.contentData.typeVal === '6'
? 'file'
@@ -329,6 +333,7 @@ export class KnowledgeSearchService {
senderIds: request.senderIds,
startTime: request.startTime === undefined ? undefined : request.startTime * 1000,
endTime: request.endTime === undefined ? undefined : request.endTime * 1000
,conversationBoundary: request.conversationBoundary
}
const result = await this.searchKnowledge(searchRequest)
// An existing derived database can answer while its next incremental pass is running.
@@ -481,12 +486,14 @@ export class KnowledgeSearchService {
.filter(({ message, score }) => {
const senderMatches = !senderIds.size || senderIds.has(message.senderId || message.from)
const termMatches = !terms.length || score > 0
return senderMatches && termMatches
const boundaryMatches = !request.conversationBoundary || message.contentData?.type !== 'system'
return senderMatches && termMatches && boundaryMatches
})
.sort(
(left, right) =>
right.score - left.score ||
(right.message.createTime || 0) - (left.message.createTime || 0)
(request.conversationBoundary === 'first' ? -1 : 1) *
((right.message.createTime || 0) - (left.message.createTime || 0))
)
.slice(0, Math.max(1, Math.min(request.limit || FALLBACK_LIMIT, FALLBACK_LIMIT)))
const result: KnowledgeSearchIpcResult = {
+47 -5
View File
@@ -358,6 +358,36 @@ export class KnowledgeStore {
])
).filter(Boolean)
const senderIds = new Set(query.senderIds || [])
if (query.conversationBoundary && conversationIds.length === 1) {
const direction = query.conversationBoundary === 'first' ? 'ASC' : 'DESC'
const row = this.database
.prepare(
`SELECT conversation_id, message_id, create_time, searchable_text, kind, sender_id, sender_name
FROM knowledge_messages
WHERE conversation_id = ? AND kind <> 'system'
ORDER BY create_time ${direction}, message_id ${direction}
LIMIT 1`
)
.get(conversationIds[0]) as DbRow | undefined
const count = this.database
.prepare('SELECT COUNT(*) AS total FROM knowledge_messages WHERE conversation_id = ?')
.get(conversationIds[0]) as DbRow | undefined
const indexState = this.database
.prepare('SELECT state, complete_snapshot FROM knowledge_index_state WHERE conversation_id = ?')
.get(conversationIds[0]) as DbRow | undefined
return {
evidence: row ? [this.metadataEvidence(row)] : [],
conversationRetrieval: {
conversationId: conversationIds[0],
totalMessages: Number(count?.total || 0),
chunkCount: 0,
candidateMessages: row ? 1 : 0,
systemMessagesDeprioritized: 0,
complete: String(indexState?.state || '') === 'ready' && Number(indexState?.complete_snapshot || 0) === 1
},
timings: { ...emptyKnowledgeSearchTimings(), messageLoadMs: Date.now() - startedAt, totalMs: Date.now() - startedAt }
}
}
if (!terms.length) {
const messageLoadStartedAt = Date.now()
const metadata = this.searchByMetadata(query, conversationIds, senderIds)
@@ -832,10 +862,15 @@ export class KnowledgeStore {
state TEXT NOT NULL,
high_water_time INTEGER,
indexed_message_count INTEGER NOT NULL DEFAULT 0,
complete_snapshot INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
updated_at INTEGER NOT NULL
) STRICT;
`)
const stateColumns = new Set(asRows(this.database.prepare('PRAGMA table_info(knowledge_index_state)').all()).map((row) => String(row.name)))
if (!stateColumns.has('complete_snapshot')) {
this.database.exec('ALTER TABLE knowledge_index_state ADD COLUMN complete_snapshot INTEGER NOT NULL DEFAULT 0')
}
const messageColumns = new Set(
asRows(this.database.prepare('PRAGMA table_info(knowledge_messages)').all()).map((row) =>
String(row.name)
@@ -935,7 +970,9 @@ export class KnowledgeStore {
chunker.version,
'indexing',
null,
normalized.length
normalized.length,
null,
conversation.completeSnapshot
)
await this.writeMessageLedger(
conversation.conversationId,
@@ -982,7 +1019,9 @@ export class KnowledgeStore {
chunker.version,
'ready',
highWater,
normalized.length
normalized.length,
null,
conversation.completeSnapshot
)
this.database.exec('COMMIT')
return { chunkCount: chunks.length, updatedChunks: chunks.length }
@@ -1122,20 +1161,22 @@ export class KnowledgeStore {
state: string,
highWater: number | null,
messageCount: number,
error: string | null = null
error: string | null = null,
completeSnapshot = false
): void {
this.database
.prepare(
`INSERT INTO knowledge_index_state (
conversation_id, account_id, chunker_version, state, high_water_time,
indexed_message_count, last_error, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
indexed_message_count, complete_snapshot, last_error, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(conversation_id) DO UPDATE SET
account_id = excluded.account_id,
chunker_version = excluded.chunker_version,
state = excluded.state,
high_water_time = excluded.high_water_time,
indexed_message_count = excluded.indexed_message_count,
complete_snapshot = excluded.complete_snapshot,
last_error = excluded.last_error,
updated_at = excluded.updated_at`
)
@@ -1146,6 +1187,7 @@ export class KnowledgeStore {
state,
highWater,
messageCount,
completeSnapshot ? 1 : 0,
error,
Date.now()
)
+253 -30
View File
@@ -4,6 +4,7 @@ import {
buildLocalAiSearchPlan,
inferAiSearchTimeRange,
mergeAiSearchPlans,
parseAiQueryUnderstanding,
parseAiSearchPlan,
type AiSearchAgentRun,
type AiSearchAgentTraceItem,
@@ -26,6 +27,7 @@ import { runControlledSearchAgent, type AgentAction, type AgentToolResult } from
import { AIProviderService } from './ai-provider-service'
import { KnowledgeSearchService } from '../knowledge/knowledge-search-service'
import { resolveContact, type ContactResolutionScope } from './contact-resolution-service'
import type { ContactResolutionResult } from '../../shared/contact-resolution'
const DISPLAY_EVIDENCE_LIMIT = 8
const AGENT_MESSAGE_LIMIT = 100
@@ -55,14 +57,32 @@ const contactScopeForIntent = (intent: AiSearchPlan['intent']): ContactResolutio
: 'person'
const isIdentityIntent = (intent: AiSearchPlan['intent']): boolean =>
intent === 'conversation_boundary' ||
intent === 'conversation_recall' ||
intent === 'conversation_topic_search' ||
intent === 'conversation_name_search'
const isIdentityPlan = (plan: AiSearchPlan): boolean =>
isIdentityIntent(plan.intent) || (plan.mode === 'semantic' && Boolean(plan.contactQuery || plan.targetQuery))
export const isSuspiciousLocalPlan = (query: string, plan: AiSearchPlan): boolean => {
const hasRelationalShape = /我\s*(?:和|跟|与)|(?:和|跟|与)\s*我|第一次|最早|最后一次|最近一次/.test(query)
const hasBoundaryShape = /第一次|最早|最后一次|最近一次|上一次|什么时候开始/.test(query)
const hasInterpretiveShape = /熟起来|刚认识|答应|是不是|提过|后来|那个|事情/.test(query)
return (
(plan.intent === 'global_topic_search' && hasRelationalShape) ||
(hasInterpretiveShape && plan.intent !== 'conversation_boundary') ||
(hasBoundaryShape && plan.intent !== 'conversation_boundary') ||
(hasRelationalShape && !plan.contactQuery)
)
}
const retrievalModeForIntent = (
intent: AiSearchPlan['intent']
): AiSearchRetrievalContract['retrievalMode'] =>
intent === 'conversation_recall'
intent === 'conversation_boundary'
? 'conversation_boundary'
: intent === 'conversation_recall'
? 'conversation_metadata'
: intent === 'conversation_topic_search'
? 'conversation_topic_fts'
@@ -97,6 +117,11 @@ const conversationIdsForContacts = (contacts: Contact[]): string[] =>
const messageTime = (timestamp: number): string =>
new Date(timestamp).toLocaleString('zh-CN', { hour12: false })
const queryDate = (timestamp: number): string => {
const date = new Date(timestamp * 1000)
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
}
const estimateTokens = (value: string): number => Math.ceil(value.length / 2)
const emptyAggregation = (): AiSearchAggregation => ({
@@ -259,6 +284,7 @@ export class AiSearchPipelineService {
const localPlan = buildLocalAiSearchPlan(request.text)
let plan: AiSearchPlan = {
...localPlan,
mode: 'structured',
scopeLabel: aiSearchScopeLabel(
localPlan.intent === 'global_group_topic_search' && request.scope === 'global'
? 'groups'
@@ -288,14 +314,106 @@ export class AiSearchPipelineService {
const contactResolutionStartedAt = Date.now()
const contacts = chat.isReady() ? await chat.listContactsAsync() : []
signal.throwIfAborted()
const shouldUseQueryUnderstanding =
localPlan.intent === 'general' || isSuspiciousLocalPlan(request.text, plan)
let queryUnderstandingSource: 'local' | 'ai' = 'local'
let queryUnderstandingError: string | undefined
if (shouldUseQueryUnderstanding && aiSearchAvailable) {
let parserResult: Awaited<ReturnType<AiSearchPipelineService['chatForSearchRequest']>>
let parserException: string | undefined
try {
parserResult = await this.chatForSearchRequest(
request.requestId,
aiConfig.providerId,
aiConfig.model,
[
{
role: 'system',
content:
'你是 TraceMemo Query Compiler。只输出严格 JSON,不回答问题、不搜索聊天、不编造联系人。mode 只能是 structured、semantic、clarification。structured 复用已有 intent;conversation_boundary 允许 boundary=first|last 和 projection=time|content|time_and_content。semantic 用 semanticQuery(1-200 字)、queryVariants(最多 4 个)、可选 targetQuery(联系人显示名)和 answerMode=extract|synthesis;clarification 仅用于确实缺少必要上下文且 requiresClarification=true。只允许输出 schema 中字段:mode、intent、contactQuery、topicQuery、boundary、projection、targetQuery、semanticQuery、queryVariants、answerMode、confidence、requiresClarification、clarificationReason。禁止 conversationId、wxid、messageId、SQL、路径、Evidence、时间戳、Tool、Provider。',
},
{
role: 'user',
content: `用户问题:${request.text}\n当前界面范围:${request.scope}`
}
],
signal
)
} catch (error) {
parserException = error instanceof Error ? error.message : '查询语义解析器执行失败'
parserResult = {
success: false,
error: parserException
}
}
const understanding = parserResult.success && parserResult.data
? parseAiQueryUnderstanding(parserResult.data)
: null
if (understanding && understanding.confidence >= 0.6 && !understanding.requiresClarification) {
queryUnderstandingSource = 'ai'
plan = {
...plan,
intent: understanding.intent,
mode: understanding.mode || 'structured',
contactQuery: understanding.contactQuery || understanding.targetQuery,
targetQuery: understanding.targetQuery,
semanticQuery: understanding.semanticQuery,
queryVariants: understanding.queryVariants,
answerMode: understanding.answerMode,
topicQuery: understanding.topicQuery,
boundary: understanding.boundary,
projection: understanding.projection,
keywords: understanding.topicQuery ? [understanding.topicQuery] : [],
variants: understanding.topicQuery ? [understanding.topicQuery] : [],
source: 'hybrid'
}
} else {
queryUnderstandingError =
understanding?.clarificationReason || parserException || parserResult.error || '无法可靠理解查询意图'
}
} else if (shouldUseQueryUnderstanding) {
queryUnderstandingError = aiConfig.configured ? '查询语义解析器暂时不可用' : '尚未配置可用 AI 模型'
}
const selectedContact =
request.scope === 'conversation' && request.conversationId
? contacts.find((contact) => contact.md5 === request.conversationId)
: undefined
const sourceContacts = this.scopeContacts(contacts, request, selectedContact, plan.intent)
if (!sourceContacts.length) throw new Error('当前搜索范围没有可用会话')
const contactResolution = plan.contactQuery
? resolveContact(plan.contactQuery, sourceContacts, contactScopeForIntent(plan.intent))
if (!sourceContacts.length && !queryUnderstandingError) throw new Error('当前搜索范围没有可用会话')
if (queryUnderstandingError) {
const retrieval: AiSearchRetrievalContract = {
intent: plan.intent,
timeRange: plan.timeRange,
retrievalMode: 'unresolved_identity',
candidateCount: 0,
uniqueCandidateCount: 0,
sourceCoverage: 'unknown',
isComplete: false,
fallbackUsed: false,
suspicious: true
}
const error = `我没有完全理解你想怎么查:${queryUnderstandingError}`
emit({ stage: 'query_understanding', status: 'error', message: error, plan, error })
return {
requestId: request.requestId,
status: 'understanding_failed',
plan,
knowledge: { source: 'knowledge', state: 'unavailable', indexedMessageCount: 0, indexedChunkCount: 0, totalMessages: 0 },
candidateEvidenceCount: 0,
retrieval,
evidence: [],
evidenceCollection: [],
contextEvidenceCount: 0,
aggregation: emptyAggregation(),
agent: { mode: 'fallback', toolCalls: 0, trace: [], fallbackReason: error },
timings: snapshotTimings(),
error,
errorStage: 'query_understanding',
elapsedMs: Date.now() - startedAt
}
}
const contactResolution = (plan.contactQuery || plan.targetQuery)
? resolveContact(plan.contactQuery || plan.targetQuery || '', sourceContacts, contactScopeForIntent(plan.intent))
: undefined
const resolvedContact =
selectedContact ||
@@ -313,7 +431,7 @@ export class AiSearchPipelineService {
contactNames: resolvedContact ? [contactLabel(resolvedContact)] : []
}
const conversationIds =
isIdentityIntent(plan.intent) && resolvedContact
isIdentityPlan(plan) && resolvedContact
? [resolvedContact.md5]
: request.scope === 'global' && plan.intent !== 'global_group_topic_search'
? undefined
@@ -323,7 +441,7 @@ export class AiSearchPipelineService {
let agent: AiSearchAgentRun = { mode: 'fallback', toolCalls: 0, trace: [] }
let candidateEvidence: AiSearchPipelineEvidence[]
let searchResult: KnowledgeSearchIpcResult
const agentOutcome = aiSearchAvailable
const agentOutcome = aiSearchAvailable && plan.intent !== 'conversation_boundary' && plan.mode !== 'semantic'
? await this.runAgentSearch(
request,
plan,
@@ -387,8 +505,8 @@ export class AiSearchPipelineService {
timings.rankingMs += agentOutcome.searchTimings.rankingMs
} else {
const deterministicIdentityRetrieval =
Boolean(resolvedContact) && (isIdentityIntent(plan.intent) || Boolean(selectedContact))
const unresolvedIdentity = isIdentityIntent(plan.intent) && !resolvedContact
Boolean(resolvedContact) && (isIdentityPlan(plan) || Boolean(selectedContact))
const unresolvedIdentity = isIdentityPlan(plan) && !resolvedContact
const fallbackReason = confirmedConversationNeedsFallback
? selectedContact
? '已选择会话的 Agent 未产生可读取消息,已按该会话执行确定性检索'
@@ -423,7 +541,7 @@ export class AiSearchPipelineService {
agentTrace: agent.trace[0],
timings: snapshotTimings()
})
if (aiSearchAvailable && !deterministicIdentityRetrieval && !unresolvedIdentity) {
if (aiSearchAvailable && plan.mode !== 'semantic' && !deterministicIdentityRetrieval && !unresolvedIdentity) {
const planningStartedAt = Date.now()
const planning = await this.chatForSearchRequest(
request.requestId,
@@ -476,22 +594,46 @@ export class AiSearchPipelineService {
} else {
const knowledgeSearchStartedAt = Date.now()
const deterministicTerms =
plan.intent === 'conversation_recall' || plan.intent === 'conversation_name_search'
? []
: plan.intent === 'conversation_topic_search'
? plan.topicQuery
? [plan.topicQuery]
: []
: Array.from(new Set([...plan.keywords, ...plan.variants]))
searchResult = await this.knowledge.search({
text: request.text,
terms: deterministicTerms,
retrievalSessionId: request.requestId,
conversationIds,
startTime: plan.timeRange.startTime,
endTime: plan.timeRange.endTime,
limit: 240
})
plan.mode === 'semantic'
? Array.from(new Set([plan.semanticQuery, ...(plan.queryVariants || [])].filter((term): term is string => Boolean(term)))).slice(0, 5)
: plan.intent === 'conversation_recall' || plan.intent === 'conversation_name_search'
? []
: plan.intent === 'conversation_topic_search'
? plan.topicQuery
? [plan.topicQuery]
: []
: Array.from(new Set([...plan.keywords, ...plan.variants]))
const semanticResults: KnowledgeSearchIpcResult[] = []
const termsToSearch: string[][] =
plan.mode === 'semantic' ? deterministicTerms.map((term) => [term]) : [deterministicTerms]
for (const terms of termsToSearch) {
semanticResults.push(await this.knowledge.search({
text: request.text,
terms,
retrievalSessionId: request.requestId,
conversationIds,
startTime: plan.timeRange.startTime,
endTime: plan.timeRange.endTime,
conversationBoundary: plan.boundary,
limit: 240
}))
}
const firstResult = semanticResults[0]
const mergedEvidence = Array.from(
new Map(semanticResults.flatMap((result) => result.evidence).map((item) => [`${item.conversationId}\u0000${item.messageId}`, item])).values()
)
searchResult = {
...(firstResult || {
source: 'knowledge', state: 'ready', indexedMessageCount: 0, indexedChunkCount: 0,
totalMessages: 0, evidence: [], timings: emptyKnowledgeSearchTimings()
}),
evidence: mergedEvidence,
indexedMessageCount: Math.max(...semanticResults.map((result) => result.indexedMessageCount), 0),
indexedChunkCount: Math.max(...semanticResults.map((result) => result.indexedChunkCount), 0),
totalMessages: Math.max(...semanticResults.map((result) => result.totalMessages), 0),
conversationRetrieval: semanticResults.find((result) => result.conversationRetrieval)?.conversationRetrieval,
voiceCoverage: semanticResults.find((result) => result.voiceCoverage)?.voiceCoverage
}
signal.throwIfAborted()
timings.knowledgeSearchMs += Date.now() - knowledgeSearchStartedAt
candidateEvidence = this.toPipelineEvidence(searchResult, contacts)
@@ -516,7 +658,7 @@ export class AiSearchPipelineService {
emit({
stage: 'query_understanding',
status: 'completed',
message: '已理解搜索条件',
message: queryUnderstandingSource === 'ai' ? '已通过查询语义解析器理解搜索条件' : '已理解搜索条件',
plan,
timings: snapshotTimings()
})
@@ -580,7 +722,8 @@ export class AiSearchPipelineService {
resolvedContact,
searchResult,
candidateEvidence,
agent
agent,
contactResolution
)
if (retrieval.suspicious && resolvedContact) {
// An identity route that somehow yielded 0/1 records is never allowed
@@ -593,6 +736,7 @@ export class AiSearchPipelineService {
conversationIds: [resolvedContact.md5],
startTime: plan.timeRange.startTime,
endTime: plan.timeRange.endTime,
conversationBoundary: plan.boundary,
limit: 240
})
signal.throwIfAborted()
@@ -603,7 +747,8 @@ export class AiSearchPipelineService {
resolvedContact,
searchResult,
candidateEvidence,
agent
agent,
contactResolution
)
}
@@ -717,7 +862,30 @@ export class AiSearchPipelineService {
timings: snapshotTimings(),
elapsedMs: Date.now() - startedAt
}
if (plan.intent === 'conversation_boundary' && retrieval.identityResolution !== 'resolved') {
const ambiguous = retrieval.identityResolution === 'ambiguous'
const error = ambiguous
? `联系人“${plan.contactQuery || ''}”存在多个匹配,请先确认具体联系人。`
: `没有确认联系人“${plan.contactQuery || ''}”,无法查询会话边界。`
return {
...baseResult,
status: ambiguous ? 'ambiguous_contact' : 'contact_not_found',
error,
timings: snapshotTimings(),
elapsedMs: Date.now() - startedAt
}
}
if (!evidence.length) {
if (plan.intent === 'conversation_boundary') {
const error = `已确认联系人“${plan.contactQuery || ''}”,但当前知识库没有可读取的聊天消息。`
return {
...baseResult,
status: 'no_messages',
error,
timings: snapshotTimings(),
elapsedMs: Date.now() - startedAt
}
}
emit({
stage: 'completed',
status: 'completed',
@@ -734,6 +902,51 @@ export class AiSearchPipelineService {
}
}
if (plan.intent === 'conversation_boundary') {
const item = evidence[0]
const boundaryLabel = plan.boundary === 'first' ? '最早' : '最后'
const coverageNote = retrieval.isComplete
? '当前知识库已完成该会话的可读取记录覆盖。'
: '当前知识库覆盖不完整,不能据此确认历史上的第一次或最后一次交流。'
const projection = plan.projection || 'time'
const content = item.sourceKind === 'text'
? item.text || '(文本消息为空)'
: item.sourceKind === 'voice'
? item.text ? `语音转写:${item.text}` : '语音消息(没有可用转写)'
: item.sourceKind === 'file'
? '文件消息(没有可展示文本)'
: item.sourceKind === 'image'
? '图片消息(没有可展示文本)'
: item.sourceKind === 'video'
? '视频消息(没有可展示文本)'
: item.sourceKind === 'sticker'
? '表情消息(没有可展示文本)'
: item.sourceKind === 'link'
? '链接消息(没有可展示文本)'
: '非文本消息(没有可展示文本)'
const timeLine = `在 TraceMemo 当前可读取的聊天记录中,你和${plan.contactQuery || item.conversationName} ${boundaryLabel}的一条聊天记录出现在 ${messageTime(item.timestamp)}。`
const contentLine = `${item.sender}:${content} [E1]`
const answer = projection === 'content'
? `在 TraceMemo 当前可读取的聊天记录中,你和${plan.contactQuery || item.conversationName} ${boundaryLabel}的一条聊天记录内容是:\n${contentLine}\n${coverageNote}`
: `${timeLine}\n${contentLine}\n${coverageNote}`
emit({
stage: 'completed',
status: 'completed',
message: '已确定会话边界并生成可追溯答案',
plan,
stats: { matchedMessages: 1, evidenceCount: 1 },
timings: snapshotTimings()
})
return {
...baseResult,
status: 'completed',
answer,
citationValidation: { status: 'valid', invalidCitationIds: [] },
timings: snapshotTimings(),
elapsedMs: Date.now() - startedAt
}
}
if (retrieval.suspicious) {
const error = '已找到目标会话,但当前检索未完整覆盖聊天记录,未生成总结。'
emit({
@@ -1681,6 +1894,7 @@ export class AiSearchPipelineService {
: ''
return `检索范围:${plan.scopeLabel},时间:${plan.rangeLabel}
用户问题:${query}
当前查询实际时间范围:${plan.timeRange.startTime === undefined ? '未限定开始日期' : queryDate(plan.timeRange.startTime)} 至 ${plan.timeRange.endTime === undefined ? '当前时刻' : queryDate(plan.timeRange.endTime)}。回答只能基于这个程序确定的时间范围,不得根据“上个月”“去年”等原句自行推算其他年份。
检索意图:${aiSearchIntentLabel(plan.intent)}
检索关键词:${plan.keywords.join('、') || '未提取到主题关键词'}
检索范围消息总数:${totalMessages}
@@ -1701,9 +1915,10 @@ ${context}`
resolvedContact: Contact | undefined,
result: KnowledgeSearchIpcResult,
candidates: AiSearchPipelineEvidence[],
agent: AiSearchAgentRun
agent: AiSearchAgentRun,
contactResolution?: ContactResolutionResult
): AiSearchRetrievalContract {
const identity = isIdentityIntent(plan.intent)
const identity = isIdentityPlan(plan)
const conversationRetrieval = result.conversationRetrieval
const sourceMessageCount =
conversationRetrieval?.totalMessages ??
@@ -1743,6 +1958,14 @@ ${context}`
fallbackUsed: agent.mode === 'fallback' || result.source === 'fallback',
fallbackReason: agent.fallbackReason || result.fallbackReason,
voiceCoverage: result.voiceCoverage,
identityResolution: identity
? resolvedContact
? 'resolved'
: contactResolution?.ambiguous
? 'ambiguous'
: 'not_found'
: 'not_required',
boundary: plan.boundary,
suspicious:
plan.intent === 'conversation_recall' &&
Boolean(resolvedContact) &&
@@ -236,6 +236,9 @@ export function AISearchWorkspace({
range,
timeRangeOverride,
activeContactMd5: activeContact?.md5,
knowledgeGeneration: knowledgeStatus
? `${knowledgeStatus.state}:${knowledgeStatus.indexedMessageCount}:${knowledgeStatus.indexedChunkCount}:${knowledgeStatus.processedMessages}`
: undefined,
retry
})
if (!normalizedQuery) {
@@ -672,8 +675,12 @@ export function AISearchWorkspace({
<span className="ai-search-kicker">✓ 已完成</span>
<h2>{resultQuery || query}</h2>
<p>
知识库已收录 {messageCount.toLocaleString()} 条消息 → 找到{' '}
{searchTrace?.retrievedEvidence || 0} 条相关消息 → {evidence.length} 条 Evidence →
知识库已收录 {messageCount.toLocaleString()} 条消息 →{' '}
{cachedAt
? `缓存中保留 ${evidenceCollection.length} 条 Evidence`
: searchTrace?.retrievedEvidence !== undefined
? `读取 ${searchTrace.retrievedEvidence} 条范围消息`
: `读取 ${evidence.length} 条消息`}{' '}→ {evidence.length} 条 Evidence →
已生成回答{cachedAt ? ' · 已使用缓存' : ''}
</p>
{searchTrace &&
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
import { aiSearchRangeStart } from '../../../../../shared/ai-search'
import { aiSearchRangeStart, inferAiSearchTimeRange } from '../../../../../shared/ai-search'
import type { AiSearchTimeRange } from '../../../../../shared/ai-search'
import {
RANGE_LABELS,
@@ -9,7 +9,6 @@ import {
buildSearchCacheKey,
parseSearchCacheKey,
readSearchCache,
readSearchCacheByQuery,
writeSearchCache
} from '../searchUtils'
import { createSearchCacheRecord, mapCacheRecordToResult } from '../searchMappers'
@@ -178,13 +177,15 @@ export function useSearchHistory({
setQuery(historyQuery)
setSelectedEvidence(0)
setHistoryOpen(false)
const resolvedTimeRange = inferAiSearchTimeRange(historyQuery, range, new Date())
const cacheKey = buildSearchCacheKey(
scope,
scope === 'conversation' ? conversationContactMd5 : '',
range,
historyQuery
historyQuery,
resolvedTimeRange
)
const cached = readSearchCache(cacheKey) || readSearchCacheByQuery(historyQuery)?.record || null
const cached = readSearchCache(cacheKey)
if (!cached) {
setAnswer('')
setEvidence([])
@@ -58,7 +58,10 @@ export const mapSearchResultToTrace = (
finalEvidenceCount: number
): SearchTrace => ({
knowledgeMessages: result.knowledge.indexedMessageCount,
retrievedEvidence: result.candidateEvidenceCount,
retrievedEvidence:
result.retrieval.intent === 'conversation_recall'
? result.retrieval.sourceMessageCount ?? result.candidateEvidenceCount
: result.candidateEvidenceCount,
finalEvidence: finalEvidenceCount,
timings: result.timings,
contextEvidence: result.contextEvidenceCount,
@@ -51,6 +51,30 @@ export const resolveSearchResultViewTransition = (
result: AiSearchPipelineResult,
range: SearchRange
): SearchResultViewTransition => {
if (result.status === 'understanding_failed') {
return {
stage: 'insufficient',
analysisError: result.error || '我没有完全理解你想怎么查,可以换一种说法。'
}
}
if (result.status === 'contact_not_found') {
return {
stage: 'insufficient',
analysisError: result.error || '我理解你在问这个联系人,但没有在当前通讯录中确认到对应联系人。'
}
}
if (result.status === 'ambiguous_contact') {
return {
stage: 'insufficient',
analysisError: result.error || '找到多个可能的联系人,暂时无法确定你指的是哪一个。'
}
}
if (result.status === 'no_messages') {
return {
stage: 'insufficient',
analysisError: result.error || '已经确认联系人,但当前可读取记录里没有对应聊天消息。'
}
}
if (result.status === 'no_evidence') {
return {
stage: 'insufficient',
@@ -1,5 +1,5 @@
import type { Contact, Message } from '../../../../shared/types'
import type { AiSearchTimeRange } from '../../../../shared/ai-search'
import { inferAiSearchTimeRange, type AiSearchTimeRange } from '../../../../shared/ai-search'
import type {
AISearchCacheRecord,
EvidenceItem,
@@ -315,8 +315,23 @@ export const buildSearchCacheKey = (
scope: SearchScope,
contactMd5: string,
range: SearchRange,
query: string
): string => JSON.stringify([scope, contactMd5, range, query.trim().toLowerCase()])
query: string,
resolvedTimeRange?: Pick<AiSearchTimeRange, 'startTime' | 'endTime'>,
knowledgeGeneration?: string
): string => {
const base = [scope, contactMd5, range, query.trim().toLowerCase()]
const hasResolvedTime = Boolean(
resolvedTimeRange &&
(resolvedTimeRange.startTime !== undefined || resolvedTimeRange.endTime !== undefined)
)
if (!hasResolvedTime && !knowledgeGeneration) return JSON.stringify(base)
return JSON.stringify([
...base,
hasResolvedTime ? resolvedTimeRange?.startTime ?? null : null,
hasResolvedTime ? resolvedTimeRange?.endTime ?? null : null,
knowledgeGeneration || null
])
}
export type CreateSearchRequestContextInput = {
query: string
@@ -324,6 +339,8 @@ export type CreateSearchRequestContextInput = {
range: SearchRange
timeRangeOverride?: AiSearchTimeRange
activeContactMd5?: string
now?: Date
knowledgeGeneration?: string
retry?: {
range: SearchRange
timeRangeOverride?: AiSearchTimeRange
@@ -335,6 +352,7 @@ export type SearchRequestContext = {
effectiveRange: SearchRange
effectiveTimeRangeOverride?: AiSearchTimeRange
conversationId?: string
resolvedTimeRange: AiSearchTimeRange
cacheKey: string
}
@@ -344,25 +362,41 @@ export const createSearchRequestContext = ({
range,
timeRangeOverride,
activeContactMd5,
now,
knowledgeGeneration,
retry
}: CreateSearchRequestContextInput): SearchRequestContext => {
const normalizedQuery = query.trim()
const effectiveRange = retry?.range || range
const effectiveTimeRangeOverride = retry?.timeRangeOverride || timeRangeOverride
const conversationId = scope === 'conversation' ? activeContactMd5 : undefined
const resolvedTimeRange = inferAiSearchTimeRange(
normalizedQuery,
effectiveRange,
now || new Date(),
effectiveTimeRangeOverride
)
return {
normalizedQuery,
effectiveRange,
effectiveTimeRangeOverride,
conversationId,
cacheKey: buildSearchCacheKey(scope, conversationId || '', effectiveRange, normalizedQuery)
resolvedTimeRange,
cacheKey: buildSearchCacheKey(
scope,
conversationId || '',
effectiveRange,
normalizedQuery,
resolvedTimeRange,
knowledgeGeneration
)
}
}
export const parseSearchCacheKey = (
key: string
): { scope: SearchScope; contactMd5: string; range: SearchRange; query: string } | null => {
): { scope: SearchScope; contactMd5: string; range: SearchRange; query: string; startTime?: number; endTime?: number } | null => {
try {
const parts = JSON.parse(key) as unknown
if (
@@ -377,7 +411,9 @@ export const parseSearchCacheKey = (
scope: parts[0] as SearchScope,
contactMd5: typeof parts[1] === 'string' ? parts[1] : '',
range: parts[2] as SearchRange,
query: parts[3]
query: parts[3],
startTime: typeof parts[4] === 'number' ? parts[4] : undefined,
endTime: typeof parts[5] === 'number' ? parts[5] : undefined
}
} catch {
return null
+195 -8
View File
@@ -6,12 +6,16 @@ import type {
export type AiSearchScope = 'global' | 'groups' | 'contacts' | 'conversation'
export type AiSearchRange = 'today' | '7d' | '30d' | 'all'
export type AiSearchProjection = 'time' | 'content' | 'time_and_content'
export type AiSearchQueryMode = 'structured' | 'semantic' | 'clarification'
export type AiSearchAnswerMode = 'extract' | 'synthesis'
/**
* Retrieval semantics, not presentation labels. Each intent has a constrained
* execution path in the main process; a model must not be able to quietly turn
* an identity lookup into a generic message-keyword search.
*/
export type AiSearchIntent =
| 'conversation_boundary'
| 'conversation_recall'
| 'conversation_topic_search'
| 'global_sender_topic_search'
@@ -44,6 +48,7 @@ export type AiSearchProgressStage =
export type AiSearchProgressStatus = 'running' | 'completed' | 'error'
export interface AiSearchPlan {
mode?: AiSearchQueryMode
intent: AiSearchIntent
keywords: string[]
variants: string[]
@@ -56,6 +61,28 @@ export interface AiSearchPlan {
contactQuery?: string
/** The message-content query, never a contact display name. */
topicQuery?: string
boundary?: 'first' | 'last'
projection?: AiSearchProjection
targetQuery?: string
semanticQuery?: string
queryVariants?: string[]
answerMode?: AiSearchAnswerMode
}
export interface AiQueryUnderstanding {
mode?: AiSearchQueryMode
intent: AiSearchIntent
contactQuery?: string
topicQuery?: string
boundary?: 'first' | 'last'
projection?: AiSearchProjection
targetQuery?: string
semanticQuery?: string
queryVariants?: string[]
answerMode?: AiSearchAnswerMode
confidence: number
requiresClarification: boolean
clarificationReason?: string
}
export interface AiSearchPipelineRequest {
@@ -222,6 +249,7 @@ export interface AiSearchRetrievalContract {
conversationId?: string
timeRange: AiSearchTimeRange
retrievalMode:
| 'conversation_boundary'
| 'conversation_metadata'
| 'conversation_topic_fts'
| 'global_fts'
@@ -236,12 +264,18 @@ export interface AiSearchRetrievalContract {
fallbackReason?: string
suspicious: boolean
voiceCoverage?: KnowledgeVoiceCoverage
identityResolution?: 'not_required' | 'resolved' | 'not_found' | 'ambiguous'
boundary?: 'first' | 'last'
}
export interface AiSearchPipelineResult {
requestId: string
status:
| 'completed'
| 'understanding_failed'
| 'contact_not_found'
| 'ambiguous_contact'
| 'no_messages'
| 'no_evidence'
| 'retrieval_incomplete'
| 'ai_failed'
@@ -434,16 +468,39 @@ export const inferAiSearchTimeRange = (
}
if (/这个月|本月/.test(query)) return fromQuery(currentMonthStart(now), '本月', '用户说“这个月”')
if (/上个月/.test(query)) {
const start = Math.floor(new Date(now.getFullYear(), now.getMonth() - 1, 1).getTime() / 1000)
const startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1)
const end = Math.floor(new Date(now.getFullYear(), now.getMonth(), 1).getTime() / 1000) - 1
const start = Math.floor(startDate.getTime() / 1000)
return {
startTime: start,
endTime: end,
label: '上个月',
label: `${startDate.getFullYear()}年${startDate.getMonth() + 1}月`,
reason: '用户说“上个月”',
source: 'query'
}
}
if (/今天/.test(query)) return fromQuery(dayStart(now), '今天', '用户说“今天”')
if (/昨天|前天/.test(query)) {
const daysAgo = query.includes('前天') ? 2 : 1
const start = dayStart(now) - daysAgo * 86400
return {
startTime: start,
endTime: dayStart(now) - (daysAgo - 1) * 86400 - 1,
label: daysAgo === 1 ? '昨天' : '前天',
reason: `用户说“${daysAgo === 1 ? '昨天' : '前天'}”`,
source: 'query'
}
}
if (/去年/.test(query)) {
const startDate = new Date(now.getFullYear() - 1, 0, 1)
return {
startTime: Math.floor(startDate.getTime() / 1000),
endTime: Math.floor(new Date(now.getFullYear(), 0, 1).getTime() / 1000) - 1,
label: `${startDate.getFullYear()}年`,
reason: '用户说“去年”',
source: 'query'
}
}
if (/今年/.test(query)) return fromQuery(currentYearStart(now), '今年', '用户说“今年”')
if (/最近/.test(query)) return fromQuery(nowSeconds - 30 * 86400, '近 30 天', '用户说“最近”')
return {
@@ -456,6 +513,7 @@ export const inferAiSearchTimeRange = (
}
export const aiSearchIntentLabel = (intent: AiSearchIntent): string => {
if (intent === 'conversation_boundary') return '查询会话边界'
if (intent === 'conversation_recall') return '回顾最近聊天'
if (intent === 'conversation_topic_search') return '在指定聊天中查找话题'
if (intent === 'global_sender_topic_search') return '按人物查找'
@@ -516,10 +574,27 @@ export const buildLocalAiSearchPlan = (
query: string
): Pick<
AiSearchPlan,
'intent' | 'keywords' | 'variants' | 'source' | 'contactQuery' | 'topicQuery'
'intent' | 'keywords' | 'variants' | 'source' | 'contactQuery' | 'topicQuery' | 'boundary' | 'projection'
> => {
const keywords = extractKeywords(query)
const normalized = query.replace(/[“”"'‘’「」『』]/g, '').trim()
const boundary =
normalized.match(/^(?:我和|我跟|我与)\s*【?(.+?)】?\s*(第一次聊天|第一次说话|最早(?:一次)?(?:聊天|说话|聊过)?|最后一次聊天|最后一次说话|最近一次(?:聊天|说话))是什么时候[??。!!]*$/) ||
normalized.match(/^我(最早|最后|最近一次)\s*什么时候\s*(?:和|跟|与)\s*【?(.+?)】?\s*(?:聊过|聊天|说话)[??。!!]*$/) ||
normalized.match(/^我(最早|最后|最近一次)\s*(?:和|跟|与)\s*【?(.+?)】?\s*(?:聊过|聊天|说话)是什么时候[??。!!]*$/)
if (boundary) {
const relationalFirst = /^(?:我和|我跟|我与)/.test(normalized)
const phrase = relationalFirst ? boundary[2] : boundary[1]
const first = /第一次|最早/.test(phrase)
const contact = (relationalFirst ? boundary[1] : boundary[2]).replace(/[【】]/g, '').trim()
const asksContent = /说了什么|说的什么|聊了什么|聊的什么/.test(normalized)
const asksTime = /什么时候|哪天/.test(normalized)
return {
intent: 'conversation_boundary', keywords: [], variants: [], source: 'local',
contactQuery: contact, boundary: first ? 'first' : 'last', topicQuery: undefined,
projection: asksContent && asksTime ? 'time_and_content' : asksContent ? 'content' : 'time'
}
}
const recall = normalized.match(
new RegExp(
`(?:我和|我跟|我与)\\s*(.+?)\\s*(?:最近|这几天|本周|这个月|本月|今年|上个月|刚刚|刚才)?\\s*(?:${RECALL_QUESTION})`
@@ -603,7 +678,7 @@ export const parseAiSearchPlan = (
const parsed = JSON.parse(jsonMatch[0]) as Record<string, unknown>
const intent = [
'general',
'conversation_recall',
'conversation_recall', 'conversation_boundary',
'conversation_topic_search',
'global_sender_topic_search',
'global_group_topic_search',
@@ -626,15 +701,125 @@ export const parseAiSearchPlan = (
}
}
const QUERY_UNDERSTANDING_INTENTS: AiSearchIntent[] = [
'conversation_recall',
'conversation_boundary',
'conversation_topic_search',
'global_sender_topic_search',
'global_group_topic_search',
'global_topic_search',
'conversation_name_search',
'general'
]
/** Strict host-side validation for the small semantic parser contract. */
export const parseAiQueryUnderstanding = (value: string): AiQueryUnderstanding | null => {
const raw = value.trim()
if (!raw.startsWith('{') || !raw.endsWith('}')) return null
let parsed: Record<string, unknown>
try {
parsed = JSON.parse(raw) as Record<string, unknown>
} catch {
return null
}
const allowed = new Set([
'mode',
'intent',
'contactQuery',
'topicQuery',
'boundary',
'projection',
'targetQuery',
'semanticQuery',
'queryVariants',
'answerMode',
'confidence',
'requiresClarification',
'clarificationReason'
])
if (Object.keys(parsed).some((key) => !allowed.has(key))) return null
const intent = String(parsed.intent) as AiSearchIntent
if (!QUERY_UNDERSTANDING_INTENTS.includes(intent)) return null
const mode = parsed.mode === null || parsed.mode === undefined ? 'structured' : parsed.mode
if (mode !== 'structured' && mode !== 'semantic' && mode !== 'clarification') return null
if (typeof parsed.confidence !== 'number' || !Number.isFinite(parsed.confidence)) return null
const confidence = Math.max(0, Math.min(1, parsed.confidence))
if (typeof parsed.requiresClarification !== 'boolean') return null
const textField = (key: 'contactQuery' | 'topicQuery'): string | undefined | null => {
const candidate = parsed[key]
if (candidate === null || candidate === undefined) return undefined
if (typeof candidate !== 'string') return null
const normalized = candidate.replace(/[【】]/g, '').trim()
if (!normalized || normalized.length > 64 || /conversationId|messageId|wxid_|SELECT\s|INSERT\s/i.test(normalized)) return null
return normalized
}
const contactQuery = textField('contactQuery')
const topicQuery = textField('topicQuery')
if (contactQuery === null || topicQuery === null) return null
const boundedField = (key: 'targetQuery' | 'semanticQuery', max: number): string | undefined | null => {
const candidate = parsed[key]
if (candidate === null || candidate === undefined) return undefined
if (typeof candidate !== 'string') return null
const normalized = candidate.trim()
if (!normalized || normalized.length > max || /conversationId|messageId|wxid_|SELECT\s|INSERT\s/i.test(normalized)) return null
return normalized
}
const targetQuery = boundedField('targetQuery', 64)
const semanticQuery = boundedField('semanticQuery', 200)
if (targetQuery === null || semanticQuery === null) return null
const queryVariants = parsed.queryVariants === null || parsed.queryVariants === undefined ? [] : parsed.queryVariants
if (!Array.isArray(queryVariants) || queryVariants.length > 4) return null
const normalizedVariants: string[] = []
for (const variant of queryVariants) {
if (typeof variant !== 'string') return null
const normalized = variant.trim()
if (!normalized || normalized.length > 80 || /conversationId|messageId|wxid_|SELECT\s|INSERT\s/i.test(normalized)) return null
normalizedVariants.push(normalized)
}
const answerMode = parsed.answerMode === null || parsed.answerMode === undefined ? undefined : parsed.answerMode
if (answerMode !== undefined && answerMode !== 'extract' && answerMode !== 'synthesis') return null
const boundary = parsed.boundary === null || parsed.boundary === undefined ? undefined : parsed.boundary
if (boundary !== undefined && boundary !== 'first' && boundary !== 'last') return null
const projection = parsed.projection === null || parsed.projection === undefined ? undefined : parsed.projection
if (projection !== undefined && projection !== 'time' && projection !== 'content' && projection !== 'time_and_content') return null
if (intent === 'conversation_boundary' && !boundary) return null
if (intent === 'conversation_boundary' && topicQuery !== undefined) return null
if (intent !== 'conversation_boundary' && boundary !== undefined) return null
if (intent !== 'conversation_boundary' && projection !== undefined) return null
if (intent === 'conversation_boundary' && !contactQuery) return null
if (mode === 'structured' && (targetQuery !== undefined || semanticQuery !== undefined || normalizedVariants.length || answerMode !== undefined)) return null
if (mode === 'semantic' && !semanticQuery) return null
if (mode === 'clarification' && !parsed.requiresClarification) return null
if (mode === 'semantic' && parsed.requiresClarification) return null
return {
mode,
intent,
contactQuery,
topicQuery,
boundary,
projection: intent === 'conversation_boundary' ? projection || 'time' : undefined,
targetQuery,
semanticQuery,
queryVariants: normalizedVariants,
answerMode: mode === 'semantic' ? answerMode || 'synthesis' : undefined,
confidence,
requiresClarification: parsed.requiresClarification,
clarificationReason:
typeof parsed.clarificationReason === 'string'
? parsed.clarificationReason.slice(0, 160)
: undefined
}
}
export const mergeAiSearchPlans = (
local: Pick<
AiSearchPlan,
'intent' | 'keywords' | 'variants' | 'source' | 'contactQuery' | 'topicQuery'
'intent' | 'keywords' | 'variants' | 'source' | 'contactQuery' | 'topicQuery' | 'boundary' | 'projection'
>,
ai: Partial<Pick<AiSearchPlan, 'intent' | 'keywords' | 'variants' | 'topicQuery'>> | null
ai: Partial<Pick<AiSearchPlan, 'intent' | 'keywords' | 'variants' | 'topicQuery' | 'projection'>> | null
): Pick<
AiSearchPlan,
'intent' | 'keywords' | 'variants' | 'source' | 'contactQuery' | 'topicQuery'
'intent' | 'keywords' | 'variants' | 'source' | 'contactQuery' | 'topicQuery' | 'boundary' | 'projection'
> => {
if (!ai) return local
const keywords = normalizeTerms([...local.keywords, ...(ai.keywords || [])])
@@ -657,7 +842,9 @@ export const mergeAiSearchPlans = (
variants,
source: 'hybrid',
contactQuery: local.contactQuery,
topicQuery: local.topicQuery || ai.topicQuery
topicQuery: local.topicQuery || ai.topicQuery,
boundary: local.boundary,
projection: local.projection || ai.projection
}
}
+3 -1
View File
@@ -7,7 +7,7 @@
export const KNOWLEDGE_SCHEMA_VERSION = 1
export const DEFAULT_CHUNKER_VERSION = 'conversation-v1'
export type KnowledgeMessageKind = 'text' | 'voice' | 'file' | 'link' | 'system' | 'other'
export type KnowledgeMessageKind = 'text' | 'voice' | 'file' | 'link' | 'image' | 'video' | 'sticker' | 'system' | 'other'
export type KnowledgeIndexPhase =
| 'idle'
| 'preflight'
@@ -210,6 +210,7 @@ export interface KnowledgeQuery {
/** Unix epoch milliseconds. */
endTime?: number
temporalIntent?: KnowledgeTemporalIntent
conversationBoundary?: 'first' | 'last'
}
export interface KnowledgeSearchRequest extends KnowledgeQuery {
@@ -299,6 +300,7 @@ export interface KnowledgeSearchIpcRequest {
senderIds?: string[]
startTime?: number
endTime?: number
conversationBoundary?: 'first' | 'last'
limit?: number
}
+327 -1
View File
@@ -10,7 +10,8 @@ vi.mock('../../src/main/services/chat-service', () => ({
listContactsAsync
}))
import { AiSearchPipelineService } from '../../src/main/services/ai-search-pipeline-service'
import { AiSearchPipelineService, isSuspiciousLocalPlan } from '../../src/main/services/ai-search-pipeline-service'
import { buildLocalAiSearchPlan } from '../../src/shared/ai-search'
import type { KnowledgeEvidence } from '../../src/shared/knowledge'
const makeCandidate = (index: number): KnowledgeEvidence => ({
@@ -143,6 +144,331 @@ describe('AiSearchPipelineService', () => {
expect(result.agent).toMatchObject({ mode: 'agent', toolCalls: 1 })
})
it('uses the AI query-understanding fallback for the real 小史 boundary expression', async () => {
listContactsAsync.mockResolvedValue([
{ md5: 'xiaoshi', m_nsUsrName: 'wxid_xiaoshi', m_nsNickName: '小史', type: 'user' }
])
aiProvider.chat.mockReset()
aiProvider.chat.mockResolvedValueOnce({
success: true,
data: JSON.stringify({
intent: 'conversation_boundary', contactQuery: '小史', topicQuery: null,
boundary: 'first', confidence: 0.98, requiresClarification: false
})
})
knowledge.search.mockResolvedValue({
source: 'knowledge', state: 'ready', indexedMessageCount: 2,
indexedChunkCount: 1, totalMessages: 2,
evidence: [{
chunkId: 'boundary', conversationId: 'xiaoshi', startTime: 1, endTime: 1,
messageId: 'message-1', sender: '小史', senderId: 'wxid_xiaoshi', timestamp: 1,
messageIds: ['message-1'], sourceKind: 'text', text: '你好'
}],
conversationRetrieval: {
conversationId: 'xiaoshi', totalMessages: 2, chunkCount: 1,
candidateMessages: 1, systemMessagesDeprioritized: 0, complete: true
}
})
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
const localPlan = buildLocalAiSearchPlan('我和小史第一次聊天是在什么时候')
expect(localPlan).toMatchObject({ intent: 'global_topic_search' })
expect(localPlan.contactQuery).toBeUndefined()
expect(localPlan.boundary).toBeUndefined()
expect(isSuspiciousLocalPlan('我和小史第一次聊天是在什么时候', localPlan as never)).toBe(true)
const result = await service.run(
{ requestId: 'ai-boundary', text: '我和小史第一次聊天是在什么时候', scope: 'global', range: 'all' },
() => undefined
)
expect(aiProvider.chat).toHaveBeenCalledTimes(1)
const parserMessages = aiProvider.chat.mock.calls[0][0] as Array<{ role: string; content: string }>
expect(parserMessages[1].content).toContain('我和小史第一次聊天是在什么时候')
expect(parserMessages[1].content).toContain('当前界面范围:global')
expect(parserMessages[1].content).not.toMatch(/Evidence|conversationId|wxid_|knowledge|contacts|数据库|路径/)
expect(knowledge.search).toHaveBeenCalledWith(expect.objectContaining({
conversationIds: ['xiaoshi'], conversationBoundary: 'first', terms: []
}))
expect(result).toMatchObject({ status: 'completed', plan: {
intent: 'conversation_boundary', contactQuery: '小史', boundary: 'first'
}, retrieval: { identityResolution: 'resolved', conversationId: 'xiaoshi' }, answer: expect.stringContaining('[E1]'), evidence: [expect.any(Object)] })
expect(result.agent).toMatchObject({ toolCalls: 0 })
})
it('supports content projection through the real BOBO boundary pipeline', async () => {
listContactsAsync.mockResolvedValue([
{ md5: 'bobo', m_nsUsrName: 'wxid_bobo', m_nsNickName: 'BOBO', type: 'user' }
])
aiProvider.chat.mockReset()
aiProvider.chat.mockResolvedValueOnce({
success: true,
data: JSON.stringify({
intent: 'conversation_boundary', contactQuery: 'BOBO', topicQuery: null,
boundary: 'first', projection: 'content', confidence: 0.98, requiresClarification: false
})
})
knowledge.search.mockResolvedValue({
source: 'knowledge', state: 'ready', indexedMessageCount: 3,
indexedChunkCount: 1, totalMessages: 3,
evidence: [{
chunkId: 'bobo-boundary', conversationId: 'bobo', startTime: 2, endTime: 2,
messageId: 'bobo-1', sender: 'BOBO', senderId: 'wxid_bobo', timestamp: 2,
messageIds: ['bobo-1'], sourceKind: 'voice', text: '语音转写:你好'
}],
conversationRetrieval: {
conversationId: 'bobo', totalMessages: 3, chunkCount: 1,
candidateMessages: 1, systemMessagesDeprioritized: 0, complete: true
}
})
const result = await new AiSearchPipelineService(knowledge as never, aiProvider as never).run(
{ requestId: 'bobo-content', text: '我和BOBO第一次讲话说的什么', scope: 'global', range: 'all' },
() => undefined
)
expect(aiProvider.chat).toHaveBeenCalledTimes(1)
expect(knowledge.search).toHaveBeenCalledWith(expect.objectContaining({
conversationIds: ['bobo'], conversationBoundary: 'first', terms: []
}))
expect(result).toMatchObject({
status: 'completed',
plan: { intent: 'conversation_boundary', contactQuery: 'BOBO', boundary: 'first', projection: 'content' },
answer: expect.stringContaining('语音转写:你好'),
evidence: [expect.any(Object)]
})
expect(result.answer).not.toContain('正在生成')
})
it('executes a semantic retrieval plan with bounded probes and one answer AI call', async () => {
listContactsAsync.mockResolvedValue([
{ md5: 'bobo', m_nsUsrName: 'wxid_bobo', m_nsNickName: 'BOBO', type: 'user' }
])
aiProvider.chat.mockReset()
aiProvider.chat
.mockResolvedValueOnce({
success: true,
data: JSON.stringify({
mode: 'semantic', intent: 'general', targetQuery: 'BOBO',
semanticQuery: '双方关系开始明显变熟、互动增加或开始更深入交流',
queryVariants: ['开始熟起来', '关系变熟', '聊天明显增多', '开始聊更深入的话题'],
answerMode: 'synthesis', confidence: 0.97, requiresClarification: false
})
})
.mockResolvedValueOnce({ success: true, data: 'BOBO后来逐渐和我熟悉起来。[E1]' })
knowledge.search.mockImplementation(async (query: { terms: string[] }) => ({
source: 'knowledge', state: 'ready', indexedMessageCount: 10,
indexedChunkCount: 4, totalMessages: 10,
evidence: [{
chunkId: `semantic-${query.terms[0]}`, conversationId: 'bobo', startTime: 2, endTime: 2,
messageId: `message-${query.terms[0]}`, sender: 'BOBO', senderId: 'wxid_bobo', timestamp: 2,
messageIds: [`message-${query.terms[0]}`], sourceKind: 'text', text: `关于${query.terms[0]}的聊天`
}]
}))
const result = await new AiSearchPipelineService(knowledge as never, aiProvider as never).run(
{ requestId: 'semantic-bobo', text: 'BOBO什么时候开始跟我熟起来的', scope: 'global', range: 'all' },
() => undefined
)
expect(result).toMatchObject({
status: 'completed',
plan: {
mode: 'semantic', targetQuery: 'BOBO', contactQuery: 'BOBO',
semanticQuery: '双方关系开始明显变熟、互动增加或开始更深入交流',
queryVariants: ['开始熟起来', '关系变熟', '聊天明显增多', '开始聊更深入的话题'],
answerMode: 'synthesis'
},
retrieval: { identityResolution: 'resolved', conversationId: 'bobo' },
answer: expect.stringContaining('BOBO后来逐渐和我熟悉起来。[E1]')
})
expect(knowledge.search).toHaveBeenCalledTimes(5)
expect(knowledge.search.mock.calls.map(([query]) => query.terms)).toEqual([
['双方关系开始明显变熟、互动增加或开始更深入交流'], ['开始熟起来'], ['关系变熟'],
['聊天明显增多'], ['开始聊更深入的话题']
])
expect(aiProvider.chat).toHaveBeenCalledTimes(2)
})
it('keeps promise-like natural language in semantic retrieval instead of understanding_failed', async () => {
listContactsAsync.mockResolvedValue([
{ md5: 'laowang', m_nsUsrName: 'wxid_laowang', m_nsNickName: '老王', type: 'user' }
])
aiProvider.chat.mockReset()
aiProvider.chat
.mockResolvedValueOnce({
success: true,
data: JSON.stringify({
mode: 'semantic', intent: 'general', targetQuery: '老王',
semanticQuery: '承诺之后提供、发送或完成某件事情', queryVariants: ['我给你', '我发你', '答应'],
answerMode: 'synthesis', confidence: 0.94, requiresClarification: false
})
})
.mockResolvedValueOnce({ success: true, data: '老王答应过随后把东西发给我。[E1]' })
knowledge.search.mockResolvedValue({
source: 'knowledge', state: 'ready', indexedMessageCount: 4,
indexedChunkCount: 1, totalMessages: 4,
evidence: [{
chunkId: 'promise', conversationId: 'laowang', startTime: 3, endTime: 3,
messageId: 'promise-1', sender: '老王', senderId: 'wxid_laowang', timestamp: 3,
messageIds: ['promise-1'], sourceKind: 'text', text: '我发你,答应'
}]
})
const result = await new AiSearchPipelineService(knowledge as never, aiProvider as never).run(
{ requestId: 'semantic-laowang', text: '老王之前答应我的东西是什么', scope: 'global', range: 'all' },
() => undefined
)
expect(result.status).toBe('completed')
expect(result.plan).toMatchObject({ mode: 'semantic', targetQuery: '老王', contactQuery: '老王' })
expect(result.retrieval).toMatchObject({ identityResolution: 'resolved', conversationId: 'laowang' })
expect(aiProvider.chat).toHaveBeenCalledTimes(2)
})
it.each([
['我和张三最近聊了什么', 'conversation_recall'],
['我和张三第一次聊天是什么时候', 'conversation_boundary'],
['我和张三第一次聊天是在什么时候', 'conversation_boundary'],
['我和张三第一次说话是哪天', 'conversation_boundary'],
['我第一次跟张三聊天是什么时候', 'conversation_boundary'],
['我最早什么时候和张三聊过', 'conversation_boundary'],
['我和张三是从什么时候开始聊天的', 'conversation_boundary'],
['我和张三什么时候开始聊天的', 'conversation_boundary'],
['我和张三最后一次聊天是什么时候', 'conversation_boundary'],
['我和张三最后一次聊天是在什么时候', 'conversation_boundary'],
['我最近一次跟张三说话是什么时候', 'conversation_boundary'],
['我上一次和张三聊天是哪天', 'conversation_boundary'],
['张三最后一次和我聊天是在什么时候', 'conversation_boundary'],
['我和张三最近聊得怎么样', 'not_boundary'],
['我和张三聊过装修吗', 'not_boundary'],
['最近张三说了什么', 'not_boundary']
])('keeps the boundary synonym/negative matrix out of accidental intent: %s', (query, expected) => {
const plan = buildLocalAiSearchPlan(query)
if (expected === 'conversation_recall') expect(plan.intent).toBe('conversation_recall')
else if (expected === 'conversation_boundary') {
expect(plan.intent === 'conversation_boundary' || plan.intent === 'global_topic_search').toBe(true)
} else expect(plan.intent).not.toBe('conversation_boundary')
})
it.each([
'BOBO什么时候开始跟我熟起来的',
'我跟BOBO刚认识的时候聊了什么',
'老王之前答应我的东西是什么',
'我之前是不是跟谁提过买房'
])('routes interpretive natural language to the Query Compiler: %s', (query) => {
const plan = buildLocalAiSearchPlan(query)
expect(isSuspiciousLocalPlan(query, plan as never)).toBe(true)
})
it.each([
['我和张三第一次聊天是什么时候', 'first'],
['我和张三第一次聊天是在什么时候', 'first'],
['我和张三第一次说话是哪天', 'first'],
['我第一次跟张三聊天是什么时候', 'first'],
['我最早什么时候和张三聊过', 'first'],
['我和张三是从什么时候开始聊天的', 'first'],
['我和张三什么时候开始聊天的', 'first'],
['我和张三最后一次聊天是什么时候', 'last'],
['我和张三最后一次聊天是在什么时候', 'last'],
['我最近一次跟张三说话是什么时候', 'last'],
['我上一次和张三聊天是哪天', 'last'],
['张三最后一次和我聊天是在什么时候', 'last']
])('normalizes the full boundary synonym matrix through the Pipeline: %s', async (query, boundary) => {
listContactsAsync.mockResolvedValue([
{ md5: 'zhangsan', m_nsUsrName: 'wxid_zhangsan', m_nsNickName: '张三', type: 'user' }
])
aiProvider.getRuntimeConfig.mockReturnValue({ configured: true, providerId: 'fixture-provider', model: 'fixture-model' })
aiProvider.getAiSearchProviderStatus.mockReturnValue({ configured: true, requiresConsent: false, providerId: 'fixture-provider', recipient: 'fixture' })
aiProvider.chat.mockReset()
aiProvider.chat.mockResolvedValueOnce({
success: true,
data: JSON.stringify({ intent: 'conversation_boundary', contactQuery: '张三', topicQuery: null, boundary, confidence: 0.98, requiresClarification: false })
})
knowledge.search.mockResolvedValue({
source: 'knowledge', state: 'ready', indexedMessageCount: 1, indexedChunkCount: 1, totalMessages: 1,
evidence: [{ chunkId: 'boundary', conversationId: 'zhangsan', startTime: 1, endTime: 1, messageId: 'message-1', sender: '张三', senderId: 'wxid_zhangsan', timestamp: 1, messageIds: ['message-1'], sourceKind: 'text', text: '你好' }],
conversationRetrieval: { conversationId: 'zhangsan', totalMessages: 1, chunkCount: 1, candidateMessages: 1, systemMessagesDeprioritized: 0, complete: true }
})
const result = await new AiSearchPipelineService(knowledge as never, aiProvider as never).run(
{ requestId: `matrix-${boundary}-${query}`, text: query, scope: 'global', range: 'all' },
() => undefined
)
const local = buildLocalAiSearchPlan(query)
const expectedParserCalls = local.intent === 'conversation_boundary' ? 0 : 1
expect(result).toMatchObject({ status: 'completed', plan: { intent: 'conversation_boundary', contactQuery: '张三', boundary }, retrieval: { identityResolution: 'resolved' }, evidence: [expect.any(Object)] })
expect(aiProvider.chat).toHaveBeenCalledTimes(expectedParserCalls)
expect(knowledge.search).toHaveBeenCalledWith(expect.objectContaining({ conversationIds: ['zhangsan'], terms: [], conversationBoundary: boundary }))
})
it('returns understanding_failed for unavailable and throwing Query Parser fallback', async () => {
listContactsAsync.mockResolvedValue([
{ md5: 'zhangsan', m_nsUsrName: 'wxid_zhangsan', m_nsNickName: '张三', type: 'user' }
])
aiProvider.getRuntimeConfig.mockReturnValue({ configured: false })
aiProvider.getAiSearchProviderStatus.mockReturnValue({ configured: false })
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
const unavailable = await service.run(
{ requestId: 'understanding-unavailable', text: '我和张三第一次聊天是在什么时候', scope: 'global', range: 'all' },
() => undefined
)
expect(unavailable).toMatchObject({ status: 'understanding_failed' })
expect(knowledge.search).not.toHaveBeenCalled()
aiProvider.getRuntimeConfig.mockReturnValue({ configured: true, providerId: 'fixture-provider', model: 'fixture-model' })
aiProvider.getAiSearchProviderStatus.mockReturnValue({ configured: true, requiresConsent: false, providerId: 'fixture-provider', recipient: 'fixture' })
aiProvider.chat.mockReset()
aiProvider.chat.mockRejectedValueOnce(new Error('Query Parser timeout'))
const throwing = await service.run(
{ requestId: 'understanding-timeout', text: '我和张三第一次聊天是在什么时候', scope: 'global', range: 'all' },
() => undefined
)
expect(throwing).toMatchObject({ status: 'understanding_failed', error: expect.stringContaining('Query Parser timeout') })
expect(knowledge.search).not.toHaveBeenCalled()
})
it('keeps local recall and local boundary at zero Query Parser calls when AI is unavailable', async () => {
listContactsAsync.mockResolvedValue([
{ md5: 'zhangsan', m_nsUsrName: 'wxid_zhangsan', m_nsNickName: '张三', type: 'user' }
])
aiProvider.getRuntimeConfig.mockReturnValue({ configured: false })
aiProvider.getAiSearchProviderStatus.mockReturnValue({ configured: false })
knowledge.search.mockResolvedValue({ source: 'knowledge', state: 'ready', indexedMessageCount: 1, indexedChunkCount: 1, totalMessages: 1, evidence: [] })
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
const recall = await service.run({ requestId: 'local-recall-no-ai', text: '我和张三最近聊了什么', scope: 'global', range: 'all' }, () => undefined)
expect(recall.plan.intent).toBe('conversation_recall')
expect(aiProvider.chat).toHaveBeenCalledTimes(0)
aiProvider.chat.mockReset()
const boundary = await service.run({ requestId: 'local-boundary-no-ai', text: '我和张三第一次聊天是什么时候', scope: 'global', range: 'all' }, () => undefined)
expect(boundary.plan.intent).toBe('conversation_boundary')
expect(aiProvider.chat).toHaveBeenCalledTimes(0)
})
it('stops before Knowledge when contact resolution is ambiguous or not found', async () => {
const parser = JSON.stringify({ intent: 'conversation_boundary', contactQuery: '张三', topicQuery: null, boundary: 'first', confidence: 0.98, requiresClarification: false })
aiProvider.chat.mockReset()
aiProvider.chat.mockResolvedValue({ success: true, data: parser })
aiProvider.getRuntimeConfig.mockReturnValue({ configured: true, providerId: 'fixture-provider', model: 'fixture-model' })
aiProvider.getAiSearchProviderStatus.mockReturnValue({ configured: true, requiresConsent: false, providerId: 'fixture-provider', recipient: 'fixture' })
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
listContactsAsync.mockResolvedValue([
{ md5: 'zhangsan-a', m_nsUsrName: 'wxid_a', m_nsNickName: '张三', type: 'user' },
{ md5: 'zhangsan-b', m_nsUsrName: 'wxid_b', m_nsNickName: '张三', type: 'user' }
])
const ambiguous = await service.run({ requestId: 'ambiguous-boundary', text: '我和张三第一次聊天是在什么时候', scope: 'global', range: 'all' }, () => undefined)
expect(ambiguous).toMatchObject({ status: 'ambiguous_contact' })
expect(knowledge.search).not.toHaveBeenCalled()
aiProvider.chat.mockReset()
aiProvider.chat.mockResolvedValueOnce({ success: true, data: parser.replace('张三', '不存在的人') })
listContactsAsync.mockResolvedValue([{ md5: 'other', m_nsUsrName: 'wxid_other', m_nsNickName: '其他人', type: 'user' }])
const missing = await service.run({ requestId: 'missing-boundary', text: '我和不存在的人第一次聊天是在什么时候', scope: 'global', range: 'all' }, () => undefined)
expect(missing).toMatchObject({ status: 'contact_not_found' })
expect(knowledge.search).not.toHaveBeenCalled()
})
it('distinguishes a confirmed contact with no readable messages', async () => {
listContactsAsync.mockResolvedValue([{ md5: 'zhangsan', m_nsUsrName: 'wxid_zhangsan', m_nsNickName: '张三', type: 'user' }])
knowledge.search.mockResolvedValue({ source: 'knowledge', state: 'ready', indexedMessageCount: 1, indexedChunkCount: 1, totalMessages: 1, evidence: [], conversationRetrieval: { conversationId: 'zhangsan', totalMessages: 1, chunkCount: 1, candidateMessages: 0, systemMessagesDeprioritized: 1, complete: true } })
aiProvider.getRuntimeConfig.mockReturnValue({ configured: false })
aiProvider.getAiSearchProviderStatus.mockReturnValue({ configured: false })
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
const result = await service.run({ requestId: 'no-boundary-messages', text: '我和张三第一次聊天是什么时候', scope: 'global', range: 'all' }, () => undefined)
expect(result).toMatchObject({ status: 'no_messages' })
expect(knowledge.search).toHaveBeenCalledWith(expect.objectContaining({ conversationBoundary: 'first' }))
})
it('cancels an active Agent request and aborts the AI call before local retrieval continues', async () => {
let observedSignal: AbortSignal | undefined
let markStarted: (() => void) | undefined
+21
View File
@@ -27,6 +27,27 @@ describe('AI search natural-language time ranges', () => {
})
})
it.each([
['我和BOBO上个月聊了什么', '2026-08-01T00:00:00+08:00', '2026-08-31T23:59:59+08:00'],
['我和BOBO这个月聊了什么', '2026-09-01T00:00:00+08:00', undefined],
['我和BOBO昨天聊了什么', '2026-09-08T00:00:00+08:00', '2026-09-08T23:59:59+08:00'],
['我和BOBO前天聊了什么', '2026-09-07T00:00:00+08:00', '2026-09-07T23:59:59+08:00'],
['我和BOBO去年聊了什么', '2025-01-01T00:00:00+08:00', '2025-12-31T23:59:59+08:00']
])('%s resolves against the injected clock', (query, start, end) => {
const range = inferAiSearchTimeRange(query, 'all', new Date('2026-09-09T15:00:00+08:00'))
expect(range.startTime).toBe(Math.floor(new Date(start).getTime() / 1000))
expect(range.endTime).toBe(end ? Math.floor(new Date(end).getTime() / 1000) : Math.floor(new Date('2026-09-09T15:00:00+08:00').getTime() / 1000))
})
it.each([
['2026-01-10T12:00:00+08:00', '2025-12-01T00:00:00+08:00', '2025-12-31T23:59:59+08:00'],
['2026-03-01T12:00:00+08:00', '2026-02-01T00:00:00+08:00', '2026-02-28T23:59:59+08:00']
])('handles previous-month year and month boundaries from %s', (now, start, end) => {
const range = inferAiSearchTimeRange('我和BOBO上个月聊了什么', 'all', new Date(now))
expect(range.startTime).toBe(Math.floor(new Date(start).getTime() / 1000))
expect(range.endTime).toBe(Math.floor(new Date(end).getTime() / 1000))
})
it('keeps an explicit user retry override above the word 最近 in the original question', () => {
expect(
inferAiSearchTimeRange('我和张三最近聊了什么?', 'all', NOW, {
@@ -99,6 +99,28 @@ const makeTrace = (overrides: Partial<SearchTrace> = {}): SearchTrace => ({
})
describe('AI Search request context', () => {
it('includes the resolved absolute range in relative-time cache keys', () => {
const query = '我和BOBO上个月聊了什么'
const first = createSearchRequestContext({
query, scope: 'global', range: 'all', now: new Date('2025-09-09T12:00:00+08:00')
})
const second = createSearchRequestContext({
query, scope: 'global', range: 'all', now: new Date('2026-09-09T12:00:00+08:00')
})
const repeat = createSearchRequestContext({
query, scope: 'global', range: 'all', now: new Date('2026-09-09T12:00:00+08:00')
})
const refreshed = createSearchRequestContext({
query, scope: 'global', range: 'all', now: new Date('2026-09-09T12:00:00+08:00'),
knowledgeGeneration: 'ready:200:20:200'
})
expect(first.resolvedTimeRange.label).toBe('2025年8月')
expect(second.resolvedTimeRange.label).toBe('2026年8月')
expect(second.cacheKey).not.toBe(first.cacheKey)
expect(second.cacheKey).toBe(repeat.cacheKey)
expect(refreshed.cacheKey).not.toBe(second.cacheKey)
})
it('trims only the submitted query while keeping cache query normalization unchanged', () => {
const context = createSearchRequestContext({
query: ' Mixed Case 问题 ',
@@ -107,7 +129,7 @@ describe('AI Search request context', () => {
})
expect(context.normalizedQuery).toBe('Mixed Case 问题')
expect(context.cacheKey).toBe(JSON.stringify(['global', '', '30d', 'mixed case 问题']))
expect(JSON.parse(context.cacheKey).slice(0, 4)).toEqual(['global', '', '30d', 'mixed case 问题'])
})
it.each(['global', 'groups', 'contacts'] as const)(
@@ -121,7 +143,7 @@ describe('AI Search request context', () => {
})
expect(context.conversationId).toBeUndefined()
expect(context.cacheKey).toBe(JSON.stringify([scope, '', '7d', '范围问题']))
expect(JSON.parse(context.cacheKey).slice(0, 4)).toEqual([scope, '', '7d', '范围问题'])
}
)
@@ -134,8 +156,8 @@ describe('AI Search request context', () => {
})
expect(context.conversationId).toBe(aiSearchContact.md5)
expect(context.cacheKey).toBe(
JSON.stringify(['conversation', aiSearchContact.md5, 'today', '会话问题'])
expect(JSON.parse(context.cacheKey).slice(0, 4)).toEqual(
['conversation', aiSearchContact.md5, 'today', '会话问题']
)
})
@@ -147,7 +169,7 @@ describe('AI Search request context', () => {
})
expect(context.conversationId).toBeUndefined()
expect(context.cacheKey).toBe(JSON.stringify(['conversation', '', 'all', '未选择会话']))
expect(JSON.parse(context.cacheKey).slice(0, 4)).toEqual(['conversation', '', 'all', '未选择会话'])
})
it('uses retry range and retry time override when both are provided', () => {
@@ -171,7 +193,7 @@ describe('AI Search request context', () => {
expect(context.effectiveRange).toBe('all')
expect(context.effectiveTimeRangeOverride).toBe(retryOverride)
expect(context.cacheKey).toBe(JSON.stringify(['global', '', 'all', '重试问题']))
expect(JSON.parse(context.cacheKey).slice(0, 4)).toEqual(['global', '', 'all', '重试问题'])
})
it('falls back to the current time override when retry does not provide one', () => {
@@ -386,6 +408,23 @@ describe('AI Search trace and cache mapping', () => {
})
})
it('reports conversation recall coverage instead of keyword-hit count', () => {
const result = makeSearchResult()
result.candidateEvidenceCount = 8
result.retrieval = {
intent: 'conversation_recall',
sourceMessageCount: 31,
sourceCoverage: 'complete',
isComplete: true,
candidateCount: 8,
uniqueCandidateCount: 8,
fallbackUsed: false,
suspicious: false,
timeRange: { label: '2026年8月', reason: 'test', source: 'query' }
}
expect(mapSearchResultToTrace(result, 8).retrievedEvidence).toBe(31)
})
it('maps AI token, citation, and voice coverage details without transforming them', () => {
const result: AiSearchPipelineResult = makeSearchResult()
result.ai = {
@@ -560,6 +599,10 @@ describe('AI Search result view transition', () => {
})
it.each([
['understanding_failed', 'insufficient', '我没有完全理解你想怎么查,可以换一种说法。'],
['contact_not_found', 'insufficient', '我理解你在问这个联系人,但没有在当前通讯录中确认到对应联系人。'],
['ambiguous_contact', 'insufficient', '找到多个可能的联系人,暂时无法确定你指的是哪一个。'],
['no_messages', 'insufficient', '已经确认联系人,但当前可读取记录里没有对应聊天消息。'],
['retrieval_incomplete', 'partial', '当前检索未完整覆盖聊天记录,未生成总结。'],
['failed', 'insufficient', '本地搜索暂时无法完成'],
['ai_failed', 'partial', '证据已找到,但 AI 暂时无法生成回答']
+119
View File
@@ -0,0 +1,119 @@
import { mkdtempSync } from 'fs'
import { rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import { buildLocalAiSearchPlan, parseAiQueryUnderstanding } from '../../src/shared/ai-search'
import { DEFAULT_KNOWLEDGE_CHUNKER, type KnowledgeFtsConfig } from '../../src/shared/knowledge'
import { KnowledgeStore } from '../../src/main/knowledge/knowledge-store'
const roots: string[] = []
const fts: KnowledgeFtsConfig = {
profileId: 'boundary-test', tokenizer: 'trigram', contentMode: 'external', detail: 'full', columnsize: 1
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('conversation boundary query planning', () => {
it('validates the AI query understanding contract without accepting unsafe fields', () => {
expect(parseAiQueryUnderstanding(JSON.stringify({
intent: 'conversation_boundary', contactQuery: '小史', topicQuery: null,
boundary: 'first', confidence: 0.98, requiresClarification: false
}))).toMatchObject({ intent: 'conversation_boundary', contactQuery: '小史', boundary: 'first' })
expect(parseAiQueryUnderstanding('```json\n{"intent":"conversation_boundary"}\n```')).toBeNull()
expect(parseAiQueryUnderstanding('{"intent":"conversation_boundary"}{"intent":"general"}')).toBeNull()
expect(parseAiQueryUnderstanding(JSON.stringify({ intent: 'deep_research', confidence: 1, requiresClarification: false }))).toBeNull()
expect(parseAiQueryUnderstanding(JSON.stringify({ intent: 'conversation_boundary', contactQuery: '张三', boundary: 'middle', confidence: 1, requiresClarification: false }))).toBeNull()
expect(parseAiQueryUnderstanding(JSON.stringify({ intent: 'conversation_boundary', contactQuery: '张三'.repeat(33), boundary: 'first', confidence: 1, requiresClarification: false }))).toBeNull()
expect(parseAiQueryUnderstanding(JSON.stringify({ intent: 'conversation_boundary', contactQuery: '张三', confidence: 1, requiresClarification: false }))).toBeNull()
expect(parseAiQueryUnderstanding(JSON.stringify({ intent: 'conversation_boundary', contactQuery: '张三', topicQuery: '装修', boundary: 'first', confidence: 1, requiresClarification: false }))).toBeNull()
expect(parseAiQueryUnderstanding(JSON.stringify({ intent: 'conversation_recall', contactQuery: '张三', boundary: 'first', confidence: 1, requiresClarification: false }))).toBeNull()
expect(parseAiQueryUnderstanding(JSON.stringify({
intent: 'conversation_boundary', contactQuery: '张三', boundary: 'first', projection: 'content',
confidence: 1, requiresClarification: false
}))).toMatchObject({ projection: 'content' })
expect(parseAiQueryUnderstanding(JSON.stringify({
intent: 'conversation_boundary', contactQuery: '张三', boundary: 'first', projection: 'middle',
confidence: 1, requiresClarification: false
}))).toBeNull()
expect(parseAiQueryUnderstanding(JSON.stringify({
intent: 'conversation_recall', contactQuery: '张三', projection: 'content',
confidence: 1, requiresClarification: false
}))).toBeNull()
expect(parseAiQueryUnderstanding(JSON.stringify({
mode: 'semantic', intent: 'general', targetQuery: 'BOBO',
semanticQuery: '双方关系开始明显变熟、互动增加或开始更深入交流',
queryVariants: ['开始熟起来', '关系变熟'], answerMode: 'synthesis',
confidence: 0.95, requiresClarification: false
}))).toMatchObject({ mode: 'semantic', targetQuery: 'BOBO', answerMode: 'synthesis', queryVariants: ['开始熟起来', '关系变熟'] })
expect(parseAiQueryUnderstanding(JSON.stringify({
mode: 'semantic', intent: 'general', semanticQuery: '关系变熟',
queryVariants: ['一', '二', '三', '四', '五'], confidence: 1, requiresClarification: false
}))).toBeNull()
expect(parseAiQueryUnderstanding(JSON.stringify({
mode: 'semantic', intent: 'general', semanticQuery: '关系变熟', sql: 'SELECT 1',
confidence: 1, requiresClarification: false
}))).toBeNull()
})
it.each([
'conversationId', 'wxid', 'messageId', 'sql', 'databasePath', 'filePath',
'startTime', 'endTime', 'evidenceId', 'tool', 'provider'
])('%s is rejected as an unknown trusted field', (field) => {
expect(parseAiQueryUnderstanding(JSON.stringify({
intent: 'conversation_boundary', contactQuery: '张三', boundary: 'first',
confidence: 1, requiresClarification: false, [field]: 'unsafe'
}))).toBeNull()
})
it.each([
['我和张三第一次聊天是什么时候?', '张三', 'first'],
['我和【张三】第一次说话是什么时候?', '张三', 'first'],
['我最早什么时候和张三聊过?', '张三', 'first'],
['我和张三最后一次聊天是什么时候?', '张三', 'last'],
['我最近一次和张三说话是什么时候?', '张三', 'last']
])('%s -> %s boundary', (query, contactQuery, boundary) => {
expect(buildLocalAiSearchPlan(query)).toMatchObject({
intent: 'conversation_boundary', contactQuery, boundary
})
})
it('keeps ordinary conversation recall separate', () => {
expect(buildLocalAiSearchPlan('我和张三最近聊了什么')).toMatchObject({
intent: 'conversation_recall', contactQuery: '张三'
})
})
it.each([
'我和BOBO第一次讲话说的什么',
'我和BOBO第一次聊天说了什么,是什么时候',
'我最后一次跟BOBO说了什么'
])('routes content boundary wording to suspicious fallback: %s', (query) => {
const plan = buildLocalAiSearchPlan(query)
expect(plan.intent).not.toBe('conversation_boundary')
})
})
describe('KnowledgeStore conversation boundary', () => {
it('uses the indexed time order and skips system messages', async () => {
const root = mkdtempSync(join(tmpdir(), 'trace-boundary-'))
roots.push(root)
const store = new KnowledgeStore(root, 'account', fts)
await store.index({
chunker: DEFAULT_KNOWLEDGE_CHUNKER,
conversations: [{
conversationId: 'conversation', completeSnapshot: true,
messages: [
{ accountId: 'account', conversationId: 'conversation', messageId: 'system', createTime: 1, kind: 'system', text: '已添加联系人' },
{ accountId: 'account', conversationId: 'conversation', messageId: 'first', createTime: 2, kind: 'text', text: '你好', senderName: '我' },
{ accountId: 'account', conversationId: 'conversation', messageId: 'last', createTime: 3, kind: 'voice', voiceTranscript: '晚安', senderName: '张三' }
]
}]
})
expect(store.search({ accountId: 'account', text: '', terms: [], limit: 1, conversationIds: ['conversation'], conversationBoundary: 'first' })[0]).toMatchObject({ messageId: 'first' })
expect(store.search({ accountId: 'account', text: '', terms: [], limit: 1, conversationIds: ['conversation'], conversationBoundary: 'last' })[0]).toMatchObject({ messageId: 'last', sourceKind: 'voice' })
store.close()
})
})