mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-03 18:33:14 +08:00
feat: 完善问问微信查询理解与检索链路
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 暂时无法生成回答']
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user