Files
WechatExplorer/src/shared/ai-search.ts
T
Wxw-Gu 2775cc424e feat: 优化问问微信检索性能与分析交互
- 补充 Worker、WCDB、sender、IPC、序列化时间账
- 增加 Agent 增量覆盖统计和重复检索停止条件
- 补充性能与交互回归测试
2026-08-07 15:28:45 +08:00

669 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
KnowledgeEvidence,
KnowledgeSearchIpcResult,
KnowledgeVoiceCoverage
} from './knowledge'
export type AiSearchScope = 'global' | 'groups' | 'contacts' | 'conversation'
export type AiSearchRange = 'today' | '7d' | '30d' | 'all'
/**
* Retrieval semantics, not presentation labels. Each intent has a constrained
* execution path in the main process; a model must not be able to quietly turn
* an identity lookup into a generic message-keyword search.
*/
export type AiSearchIntent =
| 'conversation_recall'
| 'conversation_topic_search'
| 'global_topic_search'
| 'conversation_name_search'
| 'general'
export interface AiSearchTimeRange {
/** Unix seconds. Undefined start means the user explicitly allowed all history. */
startTime?: number
endTime?: number
label: string
reason: string
source: 'ui' | 'query' | 'user_retry' | 'user_selected'
}
export type AiSearchProgressStage =
| 'query_understanding'
| 'agent_start'
| 'agent_tool'
| 'agent_decision'
| 'search_plan_ready'
| 'knowledge_searching'
| 'evidence_ranking'
| 'evidence_ready'
| 'aggregation'
| 'ai_generating'
| 'completed'
| 'error'
export type AiSearchProgressStatus = 'running' | 'completed' | 'error'
export interface AiSearchPlan {
intent: AiSearchIntent
keywords: string[]
variants: string[]
source: 'local' | 'ai' | 'hybrid'
scopeLabel: string
rangeLabel: string
timeRange: AiSearchTimeRange
contactNames: string[]
/** A user-supplied identity candidate. It must be resolved by Contact Resolution. */
contactQuery?: string
/** The message-content query, never a contact display name. */
topicQuery?: string
}
export interface AiSearchPipelineRequest {
requestId: string
text: string
scope: AiSearchScope
range: AiSearchRange
conversationId?: string
/** Explicit UI choice or retry takes precedence over natural-language inference. */
timeRangeOverride?: AiSearchTimeRange
}
export interface AiSearchProgressEvent {
requestId: string
stage: AiSearchProgressStage
status: AiSearchProgressStatus
message: string
plan?: AiSearchPlan
stats?: {
knowledgeMessageCount?: number
matchedMessages?: number
evidenceCount?: number
contextEvidenceCount?: number
tokenEstimate?: number
inputTokens?: number
inputTokensEstimated?: boolean
elapsedMs?: number
deduplicatedMessages?: number
peopleCount?: number
conversationCount?: number
}
timings?: AiSearchPipelineTimings
modelName?: string
agentTrace?: AiSearchAgentTraceItem
error?: string
}
export type AiSearchAgentToolName =
| 'search_conversations'
| 'search_people'
| 'search_messages'
| 'get_conversation_messages'
| 'get_messages_by_time'
| 'get_message_context'
export type AiSearchAgentTraceEvent =
| 'agentStart'
| 'toolCallStart'
| 'toolCallEnd'
| 'agentDecision'
| 'evidenceBuild'
| 'summaryStart'
| 'summaryEnd'
| 'fallback'
/** Public trace: deliberately contains no SQL, paths, raw IDs, or Worker details. */
export interface AiSearchAgentTraceItem {
sequence: number
event: AiSearchAgentTraceEvent
label: string
toolName?: AiSearchAgentToolName
/** Sanitized, human-readable arguments only. */
arguments?: Record<string, string | number | boolean>
resultCount?: number
uniqueCandidateCount?: number
newCandidateCount?: number
newEvidenceCount?: number
newConversationCount?: number
newSenderCount?: number
queryFingerprint?: string
hasMore?: boolean
elapsedMs?: number
decision?: string
}
export interface AiSearchAgentRun {
mode: 'agent' | 'fallback'
toolCalls: number
trace: AiSearchAgentTraceItem[]
fallbackReason?: string
}
export interface AiSearchPipelineEvidence extends KnowledgeEvidence {
conversationName: string
conversationType: 'user' | 'group'
}
/** A program-generated, stable citation. This is the only Evidence shape sent to AI/UI. */
export interface AiSearchFinalEvidence extends AiSearchPipelineEvidence {
id: `E${number}`
}
export interface AiSearchPersonAggregation {
id: string
name: string
messageCount: number
conversationCount: number
lastMessageAt: number
evidenceIds: Array<`E${number}`>
}
export interface AiSearchConversationAggregation {
id: string
name: string
type: 'user' | 'group'
messageCount: number
peopleCount: number
lastMessageAt: number
evidenceIds: Array<`E${number}`>
}
export interface AiSearchAggregation {
messageCount: number
peopleCount: number
conversationCount: number
people: AiSearchPersonAggregation[]
conversations: AiSearchConversationAggregation[]
}
/** All fields are directly measured around real work. */
export interface AiSearchPipelineTimings {
queryUnderstandingMs: number
contactResolutionMs: number
knowledgeSearchMs: number
workerIpcMs: number
workerBootMs: number
dispatchMs: number
workerSqlMs: number
responseSerializeMs: number
responseTransferMs: number
workerQueueMs: number
workerExecutionMs: number
globalCountMs: number
voiceCoverageMs: number
wcdbQueueMs: number
wcdbExecutionMs: number
senderEnrichmentMs: number
ipcMs: number
serializationMs: number
otherMs: number
ftsMs: number
chunkExpandMs: number
messageLoadMs: number
rankingMs: number
candidateRankingMs: number
evidenceBuildMs: number
aggregationMs: number
contextPreparationMs: number
agentDecisionMs: number
agentToolMs: number
aiGenerationMs: number
totalMs: number
}
export interface AiSearchCitationValidation {
status: 'valid' | 'sanitized'
invalidCitationIds: string[]
}
/** Truthful retrieval metadata shared by the AI, UI and diagnostics. */
export interface AiSearchRetrievalContract {
intent: AiSearchIntent
conversationId?: string
timeRange: AiSearchTimeRange
retrievalMode:
| 'conversation_metadata'
| 'conversation_topic_fts'
| 'global_fts'
| 'conversation_name'
| 'unresolved_identity'
candidateCount: number
uniqueCandidateCount: number
sourceMessageCount?: number
sourceCoverage: 'complete' | 'partial' | 'keyword_match' | 'unknown'
isComplete: boolean
fallbackUsed: boolean
fallbackReason?: string
suspicious: boolean
voiceCoverage?: KnowledgeVoiceCoverage
}
export interface AiSearchPipelineResult {
requestId: string
status:
| 'completed'
| 'no_evidence'
| 'retrieval_incomplete'
| 'ai_failed'
| 'failed'
| 'cancelled'
plan: AiSearchPlan
knowledge: Pick<
KnowledgeSearchIpcResult,
| 'source'
| 'state'
| 'fallbackReason'
| 'indexedMessageCount'
| 'indexedChunkCount'
| 'totalMessages'
| 'voiceCoverage'
>
candidateEvidenceCount: number
retrieval: AiSearchRetrievalContract
evidence: AiSearchFinalEvidence[]
contextEvidenceCount: number
aggregation: AiSearchAggregation
agent: AiSearchAgentRun
citationValidation?: AiSearchCitationValidation
timings: AiSearchPipelineTimings
answer?: string
ai?: {
providerName: string
modelName: string
inputTokens?: number
inputTokensEstimated: boolean
}
error?: string
errorStage?: Exclude<AiSearchProgressStage, 'completed' | 'error'>
elapsedMs: number
}
export interface AiSearchCancelResult {
cancelled: boolean
}
const RANGE_LABELS: Record<AiSearchRange, string> = {
today: '今天',
'7d': '近 7 天',
'30d': '近 30 天',
all: '全部历史'
}
const SEARCH_INTENT_PHRASES = [
'全局搜一下',
'全局搜索',
'搜索一下',
'搜一下',
'查询一下',
'查一下',
'找一下',
'我和谁聊过',
'谁和我聊过',
'谁聊过',
'哪些人和我聊过',
'最近讨论了什么',
'最近聊了什么',
'最近说了什么',
'讨论了什么',
'讨论什么',
'聊了什么',
'聊些什么',
'说了什么',
'说些什么',
'最近讨论',
'最近聊天',
'这个话题',
'相关话题',
'的聊天',
'的内容',
'的记录',
'关于',
'聊天',
'记录',
'聊天记录',
'帮我',
'请问',
'最近'
].sort((left, right) => right.length - left.length)
const RECALL_QUESTION = '聊了什么|聊过什么|说了什么|谈了什么|聊了啥|聊啥|说了啥|说啥'
const SEARCH_STOP_WORDS = new Set([
'我',
'谁',
'什么',
'哪些',
'哪个',
'人',
'和',
'聊过',
'说过',
'提到',
'讨论',
'聊天',
'记录',
'说',
'聊',
'话题',
'内容',
'相关',
'最近',
'一下'
])
export const aiSearchRangeLabel = (range: AiSearchRange): string => RANGE_LABELS[range]
export const aiSearchRangeStart = (range: AiSearchRange): number | undefined => {
if (range === 'all') return undefined
if (range === 'today') {
const now = new Date()
return Math.floor(new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000)
}
return Math.floor(Date.now() / 1000) - (range === '7d' ? 7 : 30) * 86400
}
const dayStart = (date: Date): number =>
Math.floor(new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000)
const currentYearStart = (date: Date): number =>
Math.floor(new Date(date.getFullYear(), 0, 1).getTime() / 1000)
const currentMonthStart = (date: Date): number =>
Math.floor(new Date(date.getFullYear(), date.getMonth(), 1).getTime() / 1000)
const CHINESE_NUMBERS: Record<string, number> = {
一: 1,
二: 2,
两: 2,
三: 3,
四: 4,
五: 5,
六: 6,
七: 7,
八: 8,
九: 9,
十: 10
}
const parseNaturalNumber = (value: string | undefined): number | undefined => {
if (!value) return undefined
const numeric = Number(value)
if (Number.isFinite(numeric)) return numeric
return CHINESE_NUMBERS[value]
}
/**
* Query time expressions are part of SearchPlan, never a renderer-only rule.
* A natural-language time constraint is more specific than the broad "all" UI scope.
*/
export const inferAiSearchTimeRange = (
query: string,
uiRange: AiSearchRange,
now = new Date(),
override?: AiSearchTimeRange
): AiSearchTimeRange => {
if (override?.source === 'user_retry' || override?.source === 'user_selected') return override
const nowSeconds = Math.floor(now.getTime() / 1000)
const fromQuery = (startTime: number, label: string, reason: string): AiSearchTimeRange => ({
startTime,
endTime: nowSeconds,
label,
reason,
source: 'query'
})
const recentDays = query.match(/最近\s*(\d{1,3}|[一二两三四五六七八九十])\s*天/)
if (recentDays) {
const days = Math.max(1, Math.min(365, parseNaturalNumber(recentDays[1]) || 30))
return fromQuery(nowSeconds - days * 86400, `近 ${days} 天`, `用户说“最近 ${days} 天”`)
}
const recentMonths = query.match(/最近\s*(\d{1,2}|[一二两三四五六七八九十])\s*个?月/)
if (recentMonths) {
const months = Math.max(1, Math.min(24, parseNaturalNumber(recentMonths[1]) || 1))
const start = new Date(now.getFullYear(), now.getMonth() - months, now.getDate()).getTime()
return fromQuery(Math.floor(start / 1000), `近 ${months} 个月`, `用户说“最近 ${months} 个月”`)
}
if (/刚刚|刚才/.test(query))
return fromQuery(nowSeconds - 24 * 3600, '近 24 小时', '用户说“刚刚”')
if (/这几天/.test(query)) return fromQuery(nowSeconds - 7 * 86400, '近 7 天', '用户说“这几天”')
if (/这周|本周/.test(query)) {
const weekday = now.getDay() || 7
return fromQuery(dayStart(now) - (weekday - 1) * 86400, '本周', '用户说“这周”')
}
if (/这个月|本月/.test(query)) return fromQuery(currentMonthStart(now), '本月', '用户说“这个月”')
if (/上个月/.test(query)) {
const start = Math.floor(new Date(now.getFullYear(), now.getMonth() - 1, 1).getTime() / 1000)
const end = Math.floor(new Date(now.getFullYear(), now.getMonth(), 1).getTime() / 1000) - 1
return {
startTime: start,
endTime: end,
label: '上个月',
reason: '用户说“上个月”',
source: 'query'
}
}
if (/今年/.test(query)) return fromQuery(currentYearStart(now), '今年', '用户说“今年”')
if (/最近/.test(query)) return fromQuery(nowSeconds - 30 * 86400, '近 30 天', '用户说“最近”')
return {
startTime: aiSearchRangeStart(uiRange),
endTime: undefined,
label: aiSearchRangeLabel(uiRange),
reason: '使用界面选择的时间范围',
source: 'ui'
}
}
export const aiSearchIntentLabel = (intent: AiSearchIntent): string => {
if (intent === 'conversation_recall') return '回顾最近聊天'
if (intent === 'conversation_topic_search') return '在指定聊天中查找话题'
if (intent === 'global_topic_search') return '按话题查找'
if (intent === 'conversation_name_search') return '查找聊天'
return '综合查找'
}
export const aiSearchScopeLabel = (scope: AiSearchScope, conversationName?: string): string => {
if (scope === 'groups') return '群聊'
if (scope === 'contacts') return '单聊'
if (scope === 'conversation') return conversationName || '当前会话'
return '所有聊天'
}
const normalizeTerms = (terms: unknown): string[] => {
if (!Array.isArray(terms)) return []
return Array.from(
new Set(
terms
.filter((term): term is string => typeof term === 'string')
.map((term) => term.trim())
.filter((term) => term.length >= 2 && term.length <= 32)
)
).slice(0, 16)
}
const extractKeywords = (query: string): string[] => {
const cleaned = SEARCH_INTENT_PHRASES.reduce(
(value, phrase) => value.split(phrase).join(' '),
query.toLowerCase()
)
return Array.from(
new Set(
cleaned
.split(/[\s,,。!?!?、::;;"“”‘’()()[\]【】]+/)
.map((token) => token.trim())
.filter((token) => token.length >= 2 && !SEARCH_STOP_WORDS.has(token))
)
)
}
const keywordVariants = (keywords: string[]): string[] =>
Array.from(
new Set(
keywords.flatMap((keyword) => {
const variants = [keyword]
if (/^[\u4e00-\u9fff]+$/.test(keyword) && keyword.length > 2) {
variants.push(keyword.slice(-2))
}
return variants
})
)
)
export const buildLocalAiSearchPlan = (
query: string
): Pick<
AiSearchPlan,
'intent' | 'keywords' | 'variants' | 'source' | 'contactQuery' | 'topicQuery'
> => {
const keywords = extractKeywords(query)
const normalized = query.replace(/[“”"'‘’「」『』]/g, '').trim()
const recall = normalized.match(
new RegExp(
`(?:我和|我跟|我与)\\s*(.+?)\\s*(?:最近|这几天|本周|这个月|本月|今年|上个月|刚刚|刚才)?\\s*(?:${RECALL_QUESTION})`
)
)
const reverseRecall = normalized.match(
new RegExp(`^\\s*(.+?)\\s*(?:最近)?(?:跟我|和我|与我)\\s*(?:${RECALL_QUESTION})`)
)
const namedConversationRecall = normalized.match(
new RegExp(`(?:我在|在)\\s*(.+?)\\s*(?:最近)?\\s*(?:${RECALL_QUESTION})`)
)
const bareNamedConversationRecall = normalized.match(
new RegExp(
`^\\s*(.{2,32}?(?:群聊|交流群|群))\\s*(?:最近|这几天|本周|这个月|本月|今年|上个月|刚刚|刚才)?\\s*(?:${RECALL_QUESTION})[,,。!?!?]*$`
)
)
const conversationTopic = normalized.match(
/(?:我和|我跟|我与)\s*(.+?)\s*(?:最近|这几天|本周|这个月|本月|今年|上个月)?\s*(?:聊过|提过|说过|讨论过)\s*(.+?)(?:吗|么|沒有|没有)?[??。!!]*$/
)
const globalTopic = normalized.match(
/(?:最近|这几天|本周|这个月|本月|今年)?\s*(?:谁|哪些人|大家)\s*(?:聊过|提过|说过|讨论过)\s*(.+?)[??。!!]*$/
)
const conversationName =
!recall &&
!reverseRecall &&
!conversationTopic &&
!namedConversationRecall &&
!bareNamedConversationRecall &&
!globalTopic &&
/^[^,,。!?!?]{2,32}(?:群|群聊|交流群)$/.test(normalized)
? normalized
: undefined
const contactQuery = (
conversationTopic?.[1] ||
recall?.[1] ||
reverseRecall?.[1] ||
namedConversationRecall?.[1] ||
bareNamedConversationRecall?.[1]
)
?.replace(/^(?:和|跟|与)\s*/, '')
.trim()
const topicQuery = (conversationTopic?.[2] || globalTopic?.[1])
?.replace(/^(?:关于|一下|吗|么)\s*/, '')
.trim()
const intent: AiSearchIntent = conversationTopic
? 'conversation_topic_search'
: recall || reverseRecall
? 'conversation_recall'
: namedConversationRecall || bareNamedConversationRecall
? 'conversation_name_search'
: globalTopic
? 'global_topic_search'
: conversationName
? 'conversation_name_search'
: keywords.length
? 'global_topic_search'
: 'general'
const effectiveKeywords = topicQuery ? [topicQuery] : keywords
return {
intent,
keywords: effectiveKeywords,
variants: keywordVariants(effectiveKeywords),
source: 'local',
contactQuery: contactQuery || conversationName,
topicQuery
}
}
export const parseAiSearchPlan = (
value: string
): Partial<Pick<AiSearchPlan, 'intent' | 'keywords' | 'variants' | 'topicQuery'>> | null => {
const jsonMatch = value.match(/\{[\s\S]*\}/)
if (!jsonMatch) return null
try {
const parsed = JSON.parse(jsonMatch[0]) as Record<string, unknown>
const intent = [
'general',
'conversation_recall',
'conversation_topic_search',
'global_topic_search',
'conversation_name_search'
].includes(String(parsed.intent))
? (parsed.intent as AiSearchIntent)
: undefined
return {
intent,
keywords: normalizeTerms(parsed.keywords),
variants: normalizeTerms(parsed.variants),
topicQuery:
typeof parsed.topicQuery === 'string' && parsed.topicQuery.trim().length >= 2
? parsed.topicQuery.trim().slice(0, 64)
: undefined
}
} catch {
return null
}
}
export const mergeAiSearchPlans = (
local: Pick<
AiSearchPlan,
'intent' | 'keywords' | 'variants' | 'source' | 'contactQuery' | 'topicQuery'
>,
ai: Partial<Pick<AiSearchPlan, 'intent' | 'keywords' | 'variants' | 'topicQuery'>> | null
): Pick<
AiSearchPlan,
'intent' | 'keywords' | 'variants' | 'source' | 'contactQuery' | 'topicQuery'
> => {
if (!ai) return local
const keywords = normalizeTerms([...local.keywords, ...(ai.keywords || [])])
const variants = normalizeTerms([
...keywordVariants(keywords),
...local.variants,
...(ai.variants || [])
])
// Identity-bearing local intents are deterministic contracts. A planner may
// refine topic terms but may not weaken them into an unrelated FTS intent.
const lockedIntent =
local.intent === 'conversation_recall' ||
local.intent === 'conversation_topic_search' ||
local.intent === 'conversation_name_search'
return {
intent: lockedIntent ? local.intent : ai.intent || local.intent,
keywords,
variants,
source: 'hybrid',
contactQuery: local.contactQuery,
topicQuery: local.topicQuery || ai.topicQuery
}
}
export const includesExplicitAiSearchAlias = (query: string, alias: string): boolean => {
const name = alias.trim()
if (!name || name.length < 2) return false
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const quoted = new RegExp(`[“"'‘「『]${escaped}[”"'’」』]`)
const relational = new RegExp(
`(?:我和|我跟|我与|和|跟|与|在|给|向|@)${escaped}(?=$|[\\s,,。!?!?、::;;])`
)
if (quoted.test(query) || relational.test(query)) return true
// Users often omit a nickname's punctuation, for example typing
// “中田健身弘毅” for “中田健身-弘毅”. Keep this tolerant matching limited
// to an explicit relational query so a short alias cannot accidentally
// select a contact from unrelated prose.
const compact = (value: string): string =>
value.toLocaleLowerCase().replace(/[^\p{L}\p{N}]+/gu, '')
const compactName = compact(name)
return (
compactName.length >= 2 &&
/(?:我和|我跟|我与|和|跟|与|在|给|向|@)/.test(query) &&
compact(query).includes(compactName)
)
}