From facc29244358d5dcbeb865ee6b2f0617cea8847b Mon Sep 17 00:00:00 2001 From: Wxw-Gu Date: Wed, 9 Sep 2026 15:55:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E9=97=AE=E9=97=AE?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E6=9F=A5=E8=AF=A2=E7=90=86=E8=A7=A3=E4=B8=8E?= =?UTF-8?q?=E6=A3=80=E7=B4=A2=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../knowledge/knowledge-search-service.ts | 11 +- src/main/knowledge/knowledge-store.ts | 52 ++- .../services/ai-search-pipeline-service.ts | 283 +++++++++++++-- .../components/search/AISearchWorkspace.tsx | 11 +- .../search/hooks/useSearchHistory.ts | 9 +- .../src/components/search/searchMappers.ts | 5 +- .../src/components/search/searchState.ts | 24 ++ .../src/components/search/searchUtils.ts | 48 ++- src/shared/ai-search.ts | 203 ++++++++++- src/shared/knowledge.ts | 4 +- tests/unit/ai-search-pipeline-service.test.ts | 328 +++++++++++++++++- tests/unit/ai-search-time-range.test.ts | 21 ++ ...ai-search-workspace-pure-functions.test.ts | 55 ++- tests/unit/conversation-boundary.test.ts | 119 +++++++ 14 files changed, 1107 insertions(+), 66 deletions(-) create mode 100644 tests/unit/conversation-boundary.test.ts diff --git a/src/main/knowledge/knowledge-search-service.ts b/src/main/knowledge/knowledge-search-service.ts index 09c798d..3977b8d 100644 --- a/src/main/knowledge/knowledge-search-service.ts +++ b/src/main/knowledge/knowledge-search-service.ts @@ -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 = { diff --git a/src/main/knowledge/knowledge-store.ts b/src/main/knowledge/knowledge-store.ts index 9bf97c3..77a5965 100644 --- a/src/main/knowledge/knowledge-store.ts +++ b/src/main/knowledge/knowledge-store.ts @@ -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() ) diff --git a/src/main/services/ai-search-pipeline-service.ts b/src/main/services/ai-search-pipeline-service.ts index 1a4743c..b04ee37 100644 --- a/src/main/services/ai-search-pipeline-service.ts +++ b/src/main/services/ai-search-pipeline-service.ts @@ -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> + 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) && diff --git a/src/renderer/src/components/search/AISearchWorkspace.tsx b/src/renderer/src/components/search/AISearchWorkspace.tsx index 20eba7a..72dcb7d 100644 --- a/src/renderer/src/components/search/AISearchWorkspace.tsx +++ b/src/renderer/src/components/search/AISearchWorkspace.tsx @@ -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({ ✓ 已完成

{resultQuery || query}

- 知识库已收录 {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 ? ' · 已使用缓存' : ''}

{searchTrace && diff --git a/src/renderer/src/components/search/hooks/useSearchHistory.ts b/src/renderer/src/components/search/hooks/useSearchHistory.ts index 80a791b..4e8a541 100644 --- a/src/renderer/src/components/search/hooks/useSearchHistory.ts +++ b/src/renderer/src/components/search/hooks/useSearchHistory.ts @@ -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([]) diff --git a/src/renderer/src/components/search/searchMappers.ts b/src/renderer/src/components/search/searchMappers.ts index 1ed761e..4d1b08b 100644 --- a/src/renderer/src/components/search/searchMappers.ts +++ b/src/renderer/src/components/search/searchMappers.ts @@ -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, diff --git a/src/renderer/src/components/search/searchState.ts b/src/renderer/src/components/search/searchState.ts index 56b26ab..7938065 100644 --- a/src/renderer/src/components/search/searchState.ts +++ b/src/renderer/src/components/search/searchState.ts @@ -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', diff --git a/src/renderer/src/components/search/searchUtils.ts b/src/renderer/src/components/search/searchUtils.ts index 260c4f1..5506d01 100644 --- a/src/renderer/src/components/search/searchUtils.ts +++ b/src/renderer/src/components/search/searchUtils.ts @@ -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, + 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 diff --git a/src/shared/ai-search.ts b/src/shared/ai-search.ts index e9102ff..78b59ae 100644 --- a/src/shared/ai-search.ts +++ b/src/shared/ai-search.ts @@ -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 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 + try { + parsed = JSON.parse(raw) as Record + } 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> | null + ai: Partial> | 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 } } diff --git a/src/shared/knowledge.ts b/src/shared/knowledge.ts index 7d4d9fe..74ac50a 100644 --- a/src/shared/knowledge.ts +++ b/src/shared/knowledge.ts @@ -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 } diff --git a/tests/unit/ai-search-pipeline-service.test.ts b/tests/unit/ai-search-pipeline-service.test.ts index 1fdcf3d..96b6ed0 100644 --- a/tests/unit/ai-search-pipeline-service.test.ts +++ b/tests/unit/ai-search-pipeline-service.test.ts @@ -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 diff --git a/tests/unit/ai-search-time-range.test.ts b/tests/unit/ai-search-time-range.test.ts index 92cfdd7..273f263 100644 --- a/tests/unit/ai-search-time-range.test.ts +++ b/tests/unit/ai-search-time-range.test.ts @@ -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, { diff --git a/tests/unit/ai-search-workspace-pure-functions.test.ts b/tests/unit/ai-search-workspace-pure-functions.test.ts index 100cb5c..9254965 100644 --- a/tests/unit/ai-search-workspace-pure-functions.test.ts +++ b/tests/unit/ai-search-workspace-pure-functions.test.ts @@ -99,6 +99,28 @@ const makeTrace = (overrides: Partial = {}): 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 暂时无法生成回答'] diff --git a/tests/unit/conversation-boundary.test.ts b/tests/unit/conversation-boundary.test.ts new file mode 100644 index 0000000..b4fa1ea --- /dev/null +++ b/tests/unit/conversation-boundary.test.ts @@ -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() + }) +})