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