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 { function sourceKind(message: chat.FormattedMessage): KnowledgeMessageKind {
if (message.voiceTranscript || message.type === '语音') return 'voice' 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') { if (message.contentData?.type === 'share' || message.contentData?.type === 'miniProgram') {
return message.contentData.type === 'share' && message.contentData.typeVal === '6' return message.contentData.type === 'share' && message.contentData.typeVal === '6'
? 'file' ? 'file'
@@ -329,6 +333,7 @@ export class KnowledgeSearchService {
senderIds: request.senderIds, senderIds: request.senderIds,
startTime: request.startTime === undefined ? undefined : request.startTime * 1000, startTime: request.startTime === undefined ? undefined : request.startTime * 1000,
endTime: request.endTime === undefined ? undefined : request.endTime * 1000 endTime: request.endTime === undefined ? undefined : request.endTime * 1000
,conversationBoundary: request.conversationBoundary
} }
const result = await this.searchKnowledge(searchRequest) const result = await this.searchKnowledge(searchRequest)
// An existing derived database can answer while its next incremental pass is running. // An existing derived database can answer while its next incremental pass is running.
@@ -481,12 +486,14 @@ export class KnowledgeSearchService {
.filter(({ message, score }) => { .filter(({ message, score }) => {
const senderMatches = !senderIds.size || senderIds.has(message.senderId || message.from) const senderMatches = !senderIds.size || senderIds.has(message.senderId || message.from)
const termMatches = !terms.length || score > 0 const termMatches = !terms.length || score > 0
return senderMatches && termMatches const boundaryMatches = !request.conversationBoundary || message.contentData?.type !== 'system'
return senderMatches && termMatches && boundaryMatches
}) })
.sort( .sort(
(left, right) => (left, right) =>
right.score - left.score || 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))) .slice(0, Math.max(1, Math.min(request.limit || FALLBACK_LIMIT, FALLBACK_LIMIT)))
const result: KnowledgeSearchIpcResult = { const result: KnowledgeSearchIpcResult = {
+47 -5
View File
@@ -358,6 +358,36 @@ export class KnowledgeStore {
]) ])
).filter(Boolean) ).filter(Boolean)
const senderIds = new Set(query.senderIds || []) 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) { if (!terms.length) {
const messageLoadStartedAt = Date.now() const messageLoadStartedAt = Date.now()
const metadata = this.searchByMetadata(query, conversationIds, senderIds) const metadata = this.searchByMetadata(query, conversationIds, senderIds)
@@ -832,10 +862,15 @@ export class KnowledgeStore {
state TEXT NOT NULL, state TEXT NOT NULL,
high_water_time INTEGER, high_water_time INTEGER,
indexed_message_count INTEGER NOT NULL DEFAULT 0, indexed_message_count INTEGER NOT NULL DEFAULT 0,
complete_snapshot INTEGER NOT NULL DEFAULT 0,
last_error TEXT, last_error TEXT,
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
) STRICT; ) 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( const messageColumns = new Set(
asRows(this.database.prepare('PRAGMA table_info(knowledge_messages)').all()).map((row) => asRows(this.database.prepare('PRAGMA table_info(knowledge_messages)').all()).map((row) =>
String(row.name) String(row.name)
@@ -935,7 +970,9 @@ export class KnowledgeStore {
chunker.version, chunker.version,
'indexing', 'indexing',
null, null,
normalized.length normalized.length,
null,
conversation.completeSnapshot
) )
await this.writeMessageLedger( await this.writeMessageLedger(
conversation.conversationId, conversation.conversationId,
@@ -982,7 +1019,9 @@ export class KnowledgeStore {
chunker.version, chunker.version,
'ready', 'ready',
highWater, highWater,
normalized.length normalized.length,
null,
conversation.completeSnapshot
) )
this.database.exec('COMMIT') this.database.exec('COMMIT')
return { chunkCount: chunks.length, updatedChunks: chunks.length } return { chunkCount: chunks.length, updatedChunks: chunks.length }
@@ -1122,20 +1161,22 @@ export class KnowledgeStore {
state: string, state: string,
highWater: number | null, highWater: number | null,
messageCount: number, messageCount: number,
error: string | null = null error: string | null = null,
completeSnapshot = false
): void { ): void {
this.database this.database
.prepare( .prepare(
`INSERT INTO knowledge_index_state ( `INSERT INTO knowledge_index_state (
conversation_id, account_id, chunker_version, state, high_water_time, conversation_id, account_id, chunker_version, state, high_water_time,
indexed_message_count, last_error, updated_at indexed_message_count, complete_snapshot, last_error, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(conversation_id) DO UPDATE SET ON CONFLICT(conversation_id) DO UPDATE SET
account_id = excluded.account_id, account_id = excluded.account_id,
chunker_version = excluded.chunker_version, chunker_version = excluded.chunker_version,
state = excluded.state, state = excluded.state,
high_water_time = excluded.high_water_time, high_water_time = excluded.high_water_time,
indexed_message_count = excluded.indexed_message_count, indexed_message_count = excluded.indexed_message_count,
complete_snapshot = excluded.complete_snapshot,
last_error = excluded.last_error, last_error = excluded.last_error,
updated_at = excluded.updated_at` updated_at = excluded.updated_at`
) )
@@ -1146,6 +1187,7 @@ export class KnowledgeStore {
state, state,
highWater, highWater,
messageCount, messageCount,
completeSnapshot ? 1 : 0,
error, error,
Date.now() Date.now()
) )
+253 -30
View File
@@ -4,6 +4,7 @@ import {
buildLocalAiSearchPlan, buildLocalAiSearchPlan,
inferAiSearchTimeRange, inferAiSearchTimeRange,
mergeAiSearchPlans, mergeAiSearchPlans,
parseAiQueryUnderstanding,
parseAiSearchPlan, parseAiSearchPlan,
type AiSearchAgentRun, type AiSearchAgentRun,
type AiSearchAgentTraceItem, type AiSearchAgentTraceItem,
@@ -26,6 +27,7 @@ import { runControlledSearchAgent, type AgentAction, type AgentToolResult } from
import { AIProviderService } from './ai-provider-service' import { AIProviderService } from './ai-provider-service'
import { KnowledgeSearchService } from '../knowledge/knowledge-search-service' import { KnowledgeSearchService } from '../knowledge/knowledge-search-service'
import { resolveContact, type ContactResolutionScope } from './contact-resolution-service' import { resolveContact, type ContactResolutionScope } from './contact-resolution-service'
import type { ContactResolutionResult } from '../../shared/contact-resolution'
const DISPLAY_EVIDENCE_LIMIT = 8 const DISPLAY_EVIDENCE_LIMIT = 8
const AGENT_MESSAGE_LIMIT = 100 const AGENT_MESSAGE_LIMIT = 100
@@ -55,14 +57,32 @@ const contactScopeForIntent = (intent: AiSearchPlan['intent']): ContactResolutio
: 'person' : 'person'
const isIdentityIntent = (intent: AiSearchPlan['intent']): boolean => const isIdentityIntent = (intent: AiSearchPlan['intent']): boolean =>
intent === 'conversation_boundary' ||
intent === 'conversation_recall' || intent === 'conversation_recall' ||
intent === 'conversation_topic_search' || intent === 'conversation_topic_search' ||
intent === 'conversation_name_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 = ( const retrievalModeForIntent = (
intent: AiSearchPlan['intent'] intent: AiSearchPlan['intent']
): AiSearchRetrievalContract['retrievalMode'] => ): AiSearchRetrievalContract['retrievalMode'] =>
intent === 'conversation_recall' intent === 'conversation_boundary'
? 'conversation_boundary'
: intent === 'conversation_recall'
? 'conversation_metadata' ? 'conversation_metadata'
: intent === 'conversation_topic_search' : intent === 'conversation_topic_search'
? 'conversation_topic_fts' ? 'conversation_topic_fts'
@@ -97,6 +117,11 @@ const conversationIdsForContacts = (contacts: Contact[]): string[] =>
const messageTime = (timestamp: number): string => const messageTime = (timestamp: number): string =>
new Date(timestamp).toLocaleString('zh-CN', { hour12: false }) 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 estimateTokens = (value: string): number => Math.ceil(value.length / 2)
const emptyAggregation = (): AiSearchAggregation => ({ const emptyAggregation = (): AiSearchAggregation => ({
@@ -259,6 +284,7 @@ export class AiSearchPipelineService {
const localPlan = buildLocalAiSearchPlan(request.text) const localPlan = buildLocalAiSearchPlan(request.text)
let plan: AiSearchPlan = { let plan: AiSearchPlan = {
...localPlan, ...localPlan,
mode: 'structured',
scopeLabel: aiSearchScopeLabel( scopeLabel: aiSearchScopeLabel(
localPlan.intent === 'global_group_topic_search' && request.scope === 'global' localPlan.intent === 'global_group_topic_search' && request.scope === 'global'
? 'groups' ? 'groups'
@@ -288,14 +314,106 @@ export class AiSearchPipelineService {
const contactResolutionStartedAt = Date.now() const contactResolutionStartedAt = Date.now()
const contacts = chat.isReady() ? await chat.listContactsAsync() : [] const contacts = chat.isReady() ? await chat.listContactsAsync() : []
signal.throwIfAborted() 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 = const selectedContact =
request.scope === 'conversation' && request.conversationId request.scope === 'conversation' && request.conversationId
? contacts.find((contact) => contact.md5 === request.conversationId) ? contacts.find((contact) => contact.md5 === request.conversationId)
: undefined : undefined
const sourceContacts = this.scopeContacts(contacts, request, selectedContact, plan.intent) const sourceContacts = this.scopeContacts(contacts, request, selectedContact, plan.intent)
if (!sourceContacts.length) throw new Error('当前搜索范围没有可用会话') if (!sourceContacts.length && !queryUnderstandingError) throw new Error('当前搜索范围没有可用会话')
const contactResolution = plan.contactQuery if (queryUnderstandingError) {
? resolveContact(plan.contactQuery, sourceContacts, contactScopeForIntent(plan.intent)) 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 : undefined
const resolvedContact = const resolvedContact =
selectedContact || selectedContact ||
@@ -313,7 +431,7 @@ export class AiSearchPipelineService {
contactNames: resolvedContact ? [contactLabel(resolvedContact)] : [] contactNames: resolvedContact ? [contactLabel(resolvedContact)] : []
} }
const conversationIds = const conversationIds =
isIdentityIntent(plan.intent) && resolvedContact isIdentityPlan(plan) && resolvedContact
? [resolvedContact.md5] ? [resolvedContact.md5]
: request.scope === 'global' && plan.intent !== 'global_group_topic_search' : request.scope === 'global' && plan.intent !== 'global_group_topic_search'
? undefined ? undefined
@@ -323,7 +441,7 @@ export class AiSearchPipelineService {
let agent: AiSearchAgentRun = { mode: 'fallback', toolCalls: 0, trace: [] } let agent: AiSearchAgentRun = { mode: 'fallback', toolCalls: 0, trace: [] }
let candidateEvidence: AiSearchPipelineEvidence[] let candidateEvidence: AiSearchPipelineEvidence[]
let searchResult: KnowledgeSearchIpcResult let searchResult: KnowledgeSearchIpcResult
const agentOutcome = aiSearchAvailable const agentOutcome = aiSearchAvailable && plan.intent !== 'conversation_boundary' && plan.mode !== 'semantic'
? await this.runAgentSearch( ? await this.runAgentSearch(
request, request,
plan, plan,
@@ -387,8 +505,8 @@ export class AiSearchPipelineService {
timings.rankingMs += agentOutcome.searchTimings.rankingMs timings.rankingMs += agentOutcome.searchTimings.rankingMs
} else { } else {
const deterministicIdentityRetrieval = const deterministicIdentityRetrieval =
Boolean(resolvedContact) && (isIdentityIntent(plan.intent) || Boolean(selectedContact)) Boolean(resolvedContact) && (isIdentityPlan(plan) || Boolean(selectedContact))
const unresolvedIdentity = isIdentityIntent(plan.intent) && !resolvedContact const unresolvedIdentity = isIdentityPlan(plan) && !resolvedContact
const fallbackReason = confirmedConversationNeedsFallback const fallbackReason = confirmedConversationNeedsFallback
? selectedContact ? selectedContact
? '已选择会话的 Agent 未产生可读取消息,已按该会话执行确定性检索' ? '已选择会话的 Agent 未产生可读取消息,已按该会话执行确定性检索'
@@ -423,7 +541,7 @@ export class AiSearchPipelineService {
agentTrace: agent.trace[0], agentTrace: agent.trace[0],
timings: snapshotTimings() timings: snapshotTimings()
}) })
if (aiSearchAvailable && !deterministicIdentityRetrieval && !unresolvedIdentity) { if (aiSearchAvailable && plan.mode !== 'semantic' && !deterministicIdentityRetrieval && !unresolvedIdentity) {
const planningStartedAt = Date.now() const planningStartedAt = Date.now()
const planning = await this.chatForSearchRequest( const planning = await this.chatForSearchRequest(
request.requestId, request.requestId,
@@ -476,22 +594,46 @@ export class AiSearchPipelineService {
} else { } else {
const knowledgeSearchStartedAt = Date.now() const knowledgeSearchStartedAt = Date.now()
const deterministicTerms = const deterministicTerms =
plan.intent === 'conversation_recall' || plan.intent === 'conversation_name_search' plan.mode === 'semantic'
? [] ? Array.from(new Set([plan.semanticQuery, ...(plan.queryVariants || [])].filter((term): term is string => Boolean(term)))).slice(0, 5)
: plan.intent === 'conversation_topic_search' : plan.intent === 'conversation_recall' || plan.intent === 'conversation_name_search'
? plan.topicQuery ? []
? [plan.topicQuery] : plan.intent === 'conversation_topic_search'
: [] ? plan.topicQuery
: Array.from(new Set([...plan.keywords, ...plan.variants])) ? [plan.topicQuery]
searchResult = await this.knowledge.search({ : []
text: request.text, : Array.from(new Set([...plan.keywords, ...plan.variants]))
terms: deterministicTerms, const semanticResults: KnowledgeSearchIpcResult[] = []
retrievalSessionId: request.requestId, const termsToSearch: string[][] =
conversationIds, plan.mode === 'semantic' ? deterministicTerms.map((term) => [term]) : [deterministicTerms]
startTime: plan.timeRange.startTime, for (const terms of termsToSearch) {
endTime: plan.timeRange.endTime, semanticResults.push(await this.knowledge.search({
limit: 240 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() signal.throwIfAborted()
timings.knowledgeSearchMs += Date.now() - knowledgeSearchStartedAt timings.knowledgeSearchMs += Date.now() - knowledgeSearchStartedAt
candidateEvidence = this.toPipelineEvidence(searchResult, contacts) candidateEvidence = this.toPipelineEvidence(searchResult, contacts)
@@ -516,7 +658,7 @@ export class AiSearchPipelineService {
emit({ emit({
stage: 'query_understanding', stage: 'query_understanding',
status: 'completed', status: 'completed',
message: '已理解搜索条件', message: queryUnderstandingSource === 'ai' ? '已通过查询语义解析器理解搜索条件' : '已理解搜索条件',
plan, plan,
timings: snapshotTimings() timings: snapshotTimings()
}) })
@@ -580,7 +722,8 @@ export class AiSearchPipelineService {
resolvedContact, resolvedContact,
searchResult, searchResult,
candidateEvidence, candidateEvidence,
agent agent,
contactResolution
) )
if (retrieval.suspicious && resolvedContact) { if (retrieval.suspicious && resolvedContact) {
// An identity route that somehow yielded 0/1 records is never allowed // An identity route that somehow yielded 0/1 records is never allowed
@@ -593,6 +736,7 @@ export class AiSearchPipelineService {
conversationIds: [resolvedContact.md5], conversationIds: [resolvedContact.md5],
startTime: plan.timeRange.startTime, startTime: plan.timeRange.startTime,
endTime: plan.timeRange.endTime, endTime: plan.timeRange.endTime,
conversationBoundary: plan.boundary,
limit: 240 limit: 240
}) })
signal.throwIfAborted() signal.throwIfAborted()
@@ -603,7 +747,8 @@ export class AiSearchPipelineService {
resolvedContact, resolvedContact,
searchResult, searchResult,
candidateEvidence, candidateEvidence,
agent agent,
contactResolution
) )
} }
@@ -717,7 +862,30 @@ export class AiSearchPipelineService {
timings: snapshotTimings(), timings: snapshotTimings(),
elapsedMs: Date.now() - startedAt 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 (!evidence.length) {
if (plan.intent === 'conversation_boundary') {
const error = `已确认联系人“${plan.contactQuery || ''}”,但当前知识库没有可读取的聊天消息。`
return {
...baseResult,
status: 'no_messages',
error,
timings: snapshotTimings(),
elapsedMs: Date.now() - startedAt
}
}
emit({ emit({
stage: 'completed', stage: 'completed',
status: '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) { if (retrieval.suspicious) {
const error = '已找到目标会话,但当前检索未完整覆盖聊天记录,未生成总结。' const error = '已找到目标会话,但当前检索未完整覆盖聊天记录,未生成总结。'
emit({ emit({
@@ -1681,6 +1894,7 @@ export class AiSearchPipelineService {
: '' : ''
return `检索范围:${plan.scopeLabel},时间:${plan.rangeLabel} return `检索范围:${plan.scopeLabel},时间:${plan.rangeLabel}
用户问题:${query} 用户问题:${query}
当前查询实际时间范围:${plan.timeRange.startTime === undefined ? '未限定开始日期' : queryDate(plan.timeRange.startTime)} 至 ${plan.timeRange.endTime === undefined ? '当前时刻' : queryDate(plan.timeRange.endTime)}。回答只能基于这个程序确定的时间范围,不得根据“上个月”“去年”等原句自行推算其他年份。
检索意图:${aiSearchIntentLabel(plan.intent)} 检索意图:${aiSearchIntentLabel(plan.intent)}
检索关键词:${plan.keywords.join('、') || '未提取到主题关键词'} 检索关键词:${plan.keywords.join('、') || '未提取到主题关键词'}
检索范围消息总数:${totalMessages} 检索范围消息总数:${totalMessages}
@@ -1701,9 +1915,10 @@ ${context}`
resolvedContact: Contact | undefined, resolvedContact: Contact | undefined,
result: KnowledgeSearchIpcResult, result: KnowledgeSearchIpcResult,
candidates: AiSearchPipelineEvidence[], candidates: AiSearchPipelineEvidence[],
agent: AiSearchAgentRun agent: AiSearchAgentRun,
contactResolution?: ContactResolutionResult
): AiSearchRetrievalContract { ): AiSearchRetrievalContract {
const identity = isIdentityIntent(plan.intent) const identity = isIdentityPlan(plan)
const conversationRetrieval = result.conversationRetrieval const conversationRetrieval = result.conversationRetrieval
const sourceMessageCount = const sourceMessageCount =
conversationRetrieval?.totalMessages ?? conversationRetrieval?.totalMessages ??
@@ -1743,6 +1958,14 @@ ${context}`
fallbackUsed: agent.mode === 'fallback' || result.source === 'fallback', fallbackUsed: agent.mode === 'fallback' || result.source === 'fallback',
fallbackReason: agent.fallbackReason || result.fallbackReason, fallbackReason: agent.fallbackReason || result.fallbackReason,
voiceCoverage: result.voiceCoverage, voiceCoverage: result.voiceCoverage,
identityResolution: identity
? resolvedContact
? 'resolved'
: contactResolution?.ambiguous
? 'ambiguous'
: 'not_found'
: 'not_required',
boundary: plan.boundary,
suspicious: suspicious:
plan.intent === 'conversation_recall' && plan.intent === 'conversation_recall' &&
Boolean(resolvedContact) && Boolean(resolvedContact) &&
@@ -236,6 +236,9 @@ export function AISearchWorkspace({
range, range,
timeRangeOverride, timeRangeOverride,
activeContactMd5: activeContact?.md5, activeContactMd5: activeContact?.md5,
knowledgeGeneration: knowledgeStatus
? `${knowledgeStatus.state}:${knowledgeStatus.indexedMessageCount}:${knowledgeStatus.indexedChunkCount}:${knowledgeStatus.processedMessages}`
: undefined,
retry retry
}) })
if (!normalizedQuery) { if (!normalizedQuery) {
@@ -672,8 +675,12 @@ export function AISearchWorkspace({
<span className="ai-search-kicker">✓ 已完成</span> <span className="ai-search-kicker">✓ 已完成</span>
<h2>{resultQuery || query}</h2> <h2>{resultQuery || query}</h2>
<p> <p>
知识库已收录 {messageCount.toLocaleString()} 条消息 → 找到{' '} 知识库已收录 {messageCount.toLocaleString()} 条消息 →{' '}
{searchTrace?.retrievedEvidence || 0} 条相关消息 → {evidence.length} 条 Evidence → {cachedAt
? `缓存中保留 ${evidenceCollection.length} 条 Evidence`
: searchTrace?.retrievedEvidence !== undefined
? `读取 ${searchTrace.retrievedEvidence} 条范围消息`
: `读取 ${evidence.length} 条消息`}{' '}→ {evidence.length} 条 Evidence →
已生成回答{cachedAt ? ' · 已使用缓存' : ''} 已生成回答{cachedAt ? ' · 已使用缓存' : ''}
</p> </p>
{searchTrace && {searchTrace &&
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' 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 type { AiSearchTimeRange } from '../../../../../shared/ai-search'
import { import {
RANGE_LABELS, RANGE_LABELS,
@@ -9,7 +9,6 @@ import {
buildSearchCacheKey, buildSearchCacheKey,
parseSearchCacheKey, parseSearchCacheKey,
readSearchCache, readSearchCache,
readSearchCacheByQuery,
writeSearchCache writeSearchCache
} from '../searchUtils' } from '../searchUtils'
import { createSearchCacheRecord, mapCacheRecordToResult } from '../searchMappers' import { createSearchCacheRecord, mapCacheRecordToResult } from '../searchMappers'
@@ -178,13 +177,15 @@ export function useSearchHistory({
setQuery(historyQuery) setQuery(historyQuery)
setSelectedEvidence(0) setSelectedEvidence(0)
setHistoryOpen(false) setHistoryOpen(false)
const resolvedTimeRange = inferAiSearchTimeRange(historyQuery, range, new Date())
const cacheKey = buildSearchCacheKey( const cacheKey = buildSearchCacheKey(
scope, scope,
scope === 'conversation' ? conversationContactMd5 : '', scope === 'conversation' ? conversationContactMd5 : '',
range, range,
historyQuery historyQuery,
resolvedTimeRange
) )
const cached = readSearchCache(cacheKey) || readSearchCacheByQuery(historyQuery)?.record || null const cached = readSearchCache(cacheKey)
if (!cached) { if (!cached) {
setAnswer('') setAnswer('')
setEvidence([]) setEvidence([])
@@ -58,7 +58,10 @@ export const mapSearchResultToTrace = (
finalEvidenceCount: number finalEvidenceCount: number
): SearchTrace => ({ ): SearchTrace => ({
knowledgeMessages: result.knowledge.indexedMessageCount, knowledgeMessages: result.knowledge.indexedMessageCount,
retrievedEvidence: result.candidateEvidenceCount, retrievedEvidence:
result.retrieval.intent === 'conversation_recall'
? result.retrieval.sourceMessageCount ?? result.candidateEvidenceCount
: result.candidateEvidenceCount,
finalEvidence: finalEvidenceCount, finalEvidence: finalEvidenceCount,
timings: result.timings, timings: result.timings,
contextEvidence: result.contextEvidenceCount, contextEvidence: result.contextEvidenceCount,
@@ -51,6 +51,30 @@ export const resolveSearchResultViewTransition = (
result: AiSearchPipelineResult, result: AiSearchPipelineResult,
range: SearchRange range: SearchRange
): SearchResultViewTransition => { ): 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') { if (result.status === 'no_evidence') {
return { return {
stage: 'insufficient', stage: 'insufficient',
@@ -1,5 +1,5 @@
import type { Contact, Message } from '../../../../shared/types' import type { Contact, Message } from '../../../../shared/types'
import type { AiSearchTimeRange } from '../../../../shared/ai-search' import { inferAiSearchTimeRange, type AiSearchTimeRange } from '../../../../shared/ai-search'
import type { import type {
AISearchCacheRecord, AISearchCacheRecord,
EvidenceItem, EvidenceItem,
@@ -315,8 +315,23 @@ export const buildSearchCacheKey = (
scope: SearchScope, scope: SearchScope,
contactMd5: string, contactMd5: string,
range: SearchRange, range: SearchRange,
query: string query: string,
): string => JSON.stringify([scope, contactMd5, range, query.trim().toLowerCase()]) 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 = { export type CreateSearchRequestContextInput = {
query: string query: string
@@ -324,6 +339,8 @@ export type CreateSearchRequestContextInput = {
range: SearchRange range: SearchRange
timeRangeOverride?: AiSearchTimeRange timeRangeOverride?: AiSearchTimeRange
activeContactMd5?: string activeContactMd5?: string
now?: Date
knowledgeGeneration?: string
retry?: { retry?: {
range: SearchRange range: SearchRange
timeRangeOverride?: AiSearchTimeRange timeRangeOverride?: AiSearchTimeRange
@@ -335,6 +352,7 @@ export type SearchRequestContext = {
effectiveRange: SearchRange effectiveRange: SearchRange
effectiveTimeRangeOverride?: AiSearchTimeRange effectiveTimeRangeOverride?: AiSearchTimeRange
conversationId?: string conversationId?: string
resolvedTimeRange: AiSearchTimeRange
cacheKey: string cacheKey: string
} }
@@ -344,25 +362,41 @@ export const createSearchRequestContext = ({
range, range,
timeRangeOverride, timeRangeOverride,
activeContactMd5, activeContactMd5,
now,
knowledgeGeneration,
retry retry
}: CreateSearchRequestContextInput): SearchRequestContext => { }: CreateSearchRequestContextInput): SearchRequestContext => {
const normalizedQuery = query.trim() const normalizedQuery = query.trim()
const effectiveRange = retry?.range || range const effectiveRange = retry?.range || range
const effectiveTimeRangeOverride = retry?.timeRangeOverride || timeRangeOverride const effectiveTimeRangeOverride = retry?.timeRangeOverride || timeRangeOverride
const conversationId = scope === 'conversation' ? activeContactMd5 : undefined const conversationId = scope === 'conversation' ? activeContactMd5 : undefined
const resolvedTimeRange = inferAiSearchTimeRange(
normalizedQuery,
effectiveRange,
now || new Date(),
effectiveTimeRangeOverride
)
return { return {
normalizedQuery, normalizedQuery,
effectiveRange, effectiveRange,
effectiveTimeRangeOverride, effectiveTimeRangeOverride,
conversationId, conversationId,
cacheKey: buildSearchCacheKey(scope, conversationId || '', effectiveRange, normalizedQuery) resolvedTimeRange,
cacheKey: buildSearchCacheKey(
scope,
conversationId || '',
effectiveRange,
normalizedQuery,
resolvedTimeRange,
knowledgeGeneration
)
} }
} }
export const parseSearchCacheKey = ( export const parseSearchCacheKey = (
key: string 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 { try {
const parts = JSON.parse(key) as unknown const parts = JSON.parse(key) as unknown
if ( if (
@@ -377,7 +411,9 @@ export const parseSearchCacheKey = (
scope: parts[0] as SearchScope, scope: parts[0] as SearchScope,
contactMd5: typeof parts[1] === 'string' ? parts[1] : '', contactMd5: typeof parts[1] === 'string' ? parts[1] : '',
range: parts[2] as SearchRange, 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 { } catch {
return null return null
+195 -8
View File
@@ -6,12 +6,16 @@ import type {
export type AiSearchScope = 'global' | 'groups' | 'contacts' | 'conversation' export type AiSearchScope = 'global' | 'groups' | 'contacts' | 'conversation'
export type AiSearchRange = 'today' | '7d' | '30d' | 'all' 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 * 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 * execution path in the main process; a model must not be able to quietly turn
* an identity lookup into a generic message-keyword search. * an identity lookup into a generic message-keyword search.
*/ */
export type AiSearchIntent = export type AiSearchIntent =
| 'conversation_boundary'
| 'conversation_recall' | 'conversation_recall'
| 'conversation_topic_search' | 'conversation_topic_search'
| 'global_sender_topic_search' | 'global_sender_topic_search'
@@ -44,6 +48,7 @@ export type AiSearchProgressStage =
export type AiSearchProgressStatus = 'running' | 'completed' | 'error' export type AiSearchProgressStatus = 'running' | 'completed' | 'error'
export interface AiSearchPlan { export interface AiSearchPlan {
mode?: AiSearchQueryMode
intent: AiSearchIntent intent: AiSearchIntent
keywords: string[] keywords: string[]
variants: string[] variants: string[]
@@ -56,6 +61,28 @@ export interface AiSearchPlan {
contactQuery?: string contactQuery?: string
/** The message-content query, never a contact display name. */ /** The message-content query, never a contact display name. */
topicQuery?: string 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 { export interface AiSearchPipelineRequest {
@@ -222,6 +249,7 @@ export interface AiSearchRetrievalContract {
conversationId?: string conversationId?: string
timeRange: AiSearchTimeRange timeRange: AiSearchTimeRange
retrievalMode: retrievalMode:
| 'conversation_boundary'
| 'conversation_metadata' | 'conversation_metadata'
| 'conversation_topic_fts' | 'conversation_topic_fts'
| 'global_fts' | 'global_fts'
@@ -236,12 +264,18 @@ export interface AiSearchRetrievalContract {
fallbackReason?: string fallbackReason?: string
suspicious: boolean suspicious: boolean
voiceCoverage?: KnowledgeVoiceCoverage voiceCoverage?: KnowledgeVoiceCoverage
identityResolution?: 'not_required' | 'resolved' | 'not_found' | 'ambiguous'
boundary?: 'first' | 'last'
} }
export interface AiSearchPipelineResult { export interface AiSearchPipelineResult {
requestId: string requestId: string
status: status:
| 'completed' | 'completed'
| 'understanding_failed'
| 'contact_not_found'
| 'ambiguous_contact'
| 'no_messages'
| 'no_evidence' | 'no_evidence'
| 'retrieval_incomplete' | 'retrieval_incomplete'
| 'ai_failed' | 'ai_failed'
@@ -434,16 +468,39 @@ export const inferAiSearchTimeRange = (
} }
if (/这个月|本月/.test(query)) return fromQuery(currentMonthStart(now), '本月', '用户说“这个月”') if (/这个月|本月/.test(query)) return fromQuery(currentMonthStart(now), '本月', '用户说“这个月”')
if (/上个月/.test(query)) { 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 end = Math.floor(new Date(now.getFullYear(), now.getMonth(), 1).getTime() / 1000) - 1
const start = Math.floor(startDate.getTime() / 1000)
return { return {
startTime: start, startTime: start,
endTime: end, endTime: end,
label: '上个月', label: `${startDate.getFullYear()}年${startDate.getMonth() + 1}月`,
reason: '用户说“上个月”', reason: '用户说“上个月”',
source: 'query' 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(currentYearStart(now), '今年', '用户说“今年”')
if (/最近/.test(query)) return fromQuery(nowSeconds - 30 * 86400, '近 30 天', '用户说“最近”') if (/最近/.test(query)) return fromQuery(nowSeconds - 30 * 86400, '近 30 天', '用户说“最近”')
return { return {
@@ -456,6 +513,7 @@ export const inferAiSearchTimeRange = (
} }
export const aiSearchIntentLabel = (intent: AiSearchIntent): string => { export const aiSearchIntentLabel = (intent: AiSearchIntent): string => {
if (intent === 'conversation_boundary') return '查询会话边界'
if (intent === 'conversation_recall') return '回顾最近聊天' if (intent === 'conversation_recall') return '回顾最近聊天'
if (intent === 'conversation_topic_search') return '在指定聊天中查找话题' if (intent === 'conversation_topic_search') return '在指定聊天中查找话题'
if (intent === 'global_sender_topic_search') return '按人物查找' if (intent === 'global_sender_topic_search') return '按人物查找'
@@ -516,10 +574,27 @@ export const buildLocalAiSearchPlan = (
query: string query: string
): Pick< ): Pick<
AiSearchPlan, AiSearchPlan,
'intent' | 'keywords' | 'variants' | 'source' | 'contactQuery' | 'topicQuery' 'intent' | 'keywords' | 'variants' | 'source' | 'contactQuery' | 'topicQuery' | 'boundary' | 'projection'
> => { > => {
const keywords = extractKeywords(query) const keywords = extractKeywords(query)
const normalized = query.replace(/[“”"'‘’「」『』]/g, '').trim() 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( const recall = normalized.match(
new RegExp( new RegExp(
`(?:我和|我跟|我与)\\s*(.+?)\\s*(?:最近|这几天|本周|这个月|本月|今年|上个月|刚刚|刚才)?\\s*(?:${RECALL_QUESTION})` `(?:我和|我跟|我与)\\s*(.+?)\\s*(?:最近|这几天|本周|这个月|本月|今年|上个月|刚刚|刚才)?\\s*(?:${RECALL_QUESTION})`
@@ -603,7 +678,7 @@ export const parseAiSearchPlan = (
const parsed = JSON.parse(jsonMatch[0]) as Record<string, unknown> const parsed = JSON.parse(jsonMatch[0]) as Record<string, unknown>
const intent = [ const intent = [
'general', 'general',
'conversation_recall', 'conversation_recall', 'conversation_boundary',
'conversation_topic_search', 'conversation_topic_search',
'global_sender_topic_search', 'global_sender_topic_search',
'global_group_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 = ( export const mergeAiSearchPlans = (
local: Pick< local: Pick<
AiSearchPlan, 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< ): Pick<
AiSearchPlan, AiSearchPlan,
'intent' | 'keywords' | 'variants' | 'source' | 'contactQuery' | 'topicQuery' 'intent' | 'keywords' | 'variants' | 'source' | 'contactQuery' | 'topicQuery' | 'boundary' | 'projection'
> => { > => {
if (!ai) return local if (!ai) return local
const keywords = normalizeTerms([...local.keywords, ...(ai.keywords || [])]) const keywords = normalizeTerms([...local.keywords, ...(ai.keywords || [])])
@@ -657,7 +842,9 @@ export const mergeAiSearchPlans = (
variants, variants,
source: 'hybrid', source: 'hybrid',
contactQuery: local.contactQuery, 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 KNOWLEDGE_SCHEMA_VERSION = 1
export const DEFAULT_CHUNKER_VERSION = 'conversation-v1' 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 = export type KnowledgeIndexPhase =
| 'idle' | 'idle'
| 'preflight' | 'preflight'
@@ -210,6 +210,7 @@ export interface KnowledgeQuery {
/** Unix epoch milliseconds. */ /** Unix epoch milliseconds. */
endTime?: number endTime?: number
temporalIntent?: KnowledgeTemporalIntent temporalIntent?: KnowledgeTemporalIntent
conversationBoundary?: 'first' | 'last'
} }
export interface KnowledgeSearchRequest extends KnowledgeQuery { export interface KnowledgeSearchRequest extends KnowledgeQuery {
@@ -299,6 +300,7 @@ export interface KnowledgeSearchIpcRequest {
senderIds?: string[] senderIds?: string[]
startTime?: number startTime?: number
endTime?: number endTime?: number
conversationBoundary?: 'first' | 'last'
limit?: number limit?: number
} }
+327 -1
View File
@@ -10,7 +10,8 @@ vi.mock('../../src/main/services/chat-service', () => ({
listContactsAsync 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' import type { KnowledgeEvidence } from '../../src/shared/knowledge'
const makeCandidate = (index: number): KnowledgeEvidence => ({ const makeCandidate = (index: number): KnowledgeEvidence => ({
@@ -143,6 +144,331 @@ describe('AiSearchPipelineService', () => {
expect(result.agent).toMatchObject({ mode: 'agent', toolCalls: 1 }) 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 () => { it('cancels an active Agent request and aborts the AI call before local retrieval continues', async () => {
let observedSignal: AbortSignal | undefined let observedSignal: AbortSignal | undefined
let markStarted: (() => void) | 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', () => { it('keeps an explicit user retry override above the word 最近 in the original question', () => {
expect( expect(
inferAiSearchTimeRange('我和张三最近聊了什么?', 'all', NOW, { inferAiSearchTimeRange('我和张三最近聊了什么?', 'all', NOW, {
@@ -99,6 +99,28 @@ const makeTrace = (overrides: Partial<SearchTrace> = {}): SearchTrace => ({
}) })
describe('AI Search request context', () => { 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', () => { it('trims only the submitted query while keeping cache query normalization unchanged', () => {
const context = createSearchRequestContext({ const context = createSearchRequestContext({
query: ' Mixed Case 问题 ', query: ' Mixed Case 问题 ',
@@ -107,7 +129,7 @@ describe('AI Search request context', () => {
}) })
expect(context.normalizedQuery).toBe('Mixed Case 问题') 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)( it.each(['global', 'groups', 'contacts'] as const)(
@@ -121,7 +143,7 @@ describe('AI Search request context', () => {
}) })
expect(context.conversationId).toBeUndefined() 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.conversationId).toBe(aiSearchContact.md5)
expect(context.cacheKey).toBe( expect(JSON.parse(context.cacheKey).slice(0, 4)).toEqual(
JSON.stringify(['conversation', aiSearchContact.md5, 'today', '会话问题']) ['conversation', aiSearchContact.md5, 'today', '会话问题']
) )
}) })
@@ -147,7 +169,7 @@ describe('AI Search request context', () => {
}) })
expect(context.conversationId).toBeUndefined() 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', () => { 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.effectiveRange).toBe('all')
expect(context.effectiveTimeRangeOverride).toBe(retryOverride) 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', () => { 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', () => { it('maps AI token, citation, and voice coverage details without transforming them', () => {
const result: AiSearchPipelineResult = makeSearchResult() const result: AiSearchPipelineResult = makeSearchResult()
result.ai = { result.ai = {
@@ -560,6 +599,10 @@ describe('AI Search result view transition', () => {
}) })
it.each([ it.each([
['understanding_failed', 'insufficient', '我没有完全理解你想怎么查,可以换一种说法。'],
['contact_not_found', 'insufficient', '我理解你在问这个联系人,但没有在当前通讯录中确认到对应联系人。'],
['ambiguous_contact', 'insufficient', '找到多个可能的联系人,暂时无法确定你指的是哪一个。'],
['no_messages', 'insufficient', '已经确认联系人,但当前可读取记录里没有对应聊天消息。'],
['retrieval_incomplete', 'partial', '当前检索未完整覆盖聊天记录,未生成总结。'], ['retrieval_incomplete', 'partial', '当前检索未完整覆盖聊天记录,未生成总结。'],
['failed', 'insufficient', '本地搜索暂时无法完成'], ['failed', 'insufficient', '本地搜索暂时无法完成'],
['ai_failed', 'partial', '证据已找到,但 AI 暂时无法生成回答'] ['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()
})
})