mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-19 20:46:58 +08:00
1241 lines
47 KiB
TypeScript
1241 lines
47 KiB
TypeScript
import {
|
||
aiSearchIntentLabel,
|
||
aiSearchScopeLabel,
|
||
buildLocalAiSearchPlan,
|
||
inferAiSearchTimeRange,
|
||
mergeAiSearchPlans,
|
||
parseAiSearchPlan,
|
||
type AiSearchAgentRun,
|
||
type AiSearchAgentTraceItem,
|
||
type AiSearchAggregation,
|
||
type AiSearchFinalEvidence,
|
||
type AiSearchPipelineEvidence,
|
||
type AiSearchPipelineRequest,
|
||
type AiSearchPipelineResult,
|
||
type AiSearchPipelineTimings,
|
||
type AiSearchPlan,
|
||
type AiSearchRetrievalContract,
|
||
type AiSearchProgressEvent
|
||
} from '../../shared/ai-search'
|
||
import { emptyKnowledgeSearchTimings, type KnowledgeSearchIpcResult } from '../../shared/knowledge'
|
||
import type { Contact } from '../../shared/types'
|
||
import * as chat from './chat-service'
|
||
import { buildFinalEvidence, sanitizeAnswerCitations } from './ai-search-evidence'
|
||
import { runControlledSearchAgent, type AgentAction, type AgentToolResult } from './ai-search-agent'
|
||
import { AIProviderService } from './ai-provider-service'
|
||
import { KnowledgeSearchService } from '../knowledge/knowledge-search-service'
|
||
import { resolveContact, type ContactResolutionScope } from './contact-resolution-service'
|
||
|
||
const DISPLAY_EVIDENCE_LIMIT = 8
|
||
const AGENT_MESSAGE_LIMIT = 100
|
||
const AGENT_SEARCH_LIMIT = 50
|
||
const MAX_AGENT_CANDIDATES = 240
|
||
|
||
type AgentSearchOutcome = {
|
||
invalid?: boolean
|
||
candidateEvidence: AiSearchPipelineEvidence[]
|
||
searchResult: KnowledgeSearchIpcResult
|
||
plan: AiSearchPlan
|
||
agent: AiSearchAgentRun
|
||
searchTimings: ReturnType<typeof emptyKnowledgeSearchTimings>
|
||
knowledgeSearchMs: number
|
||
}
|
||
|
||
const contactScopeForIntent = (intent: AiSearchPlan['intent']): ContactResolutionScope =>
|
||
intent === 'conversation_name_search' ? 'group' : 'person'
|
||
|
||
const isIdentityIntent = (intent: AiSearchPlan['intent']): boolean =>
|
||
intent === 'conversation_recall' ||
|
||
intent === 'conversation_topic_search' ||
|
||
intent === 'conversation_name_search'
|
||
|
||
const retrievalModeForIntent = (
|
||
intent: AiSearchPlan['intent']
|
||
): AiSearchRetrievalContract['retrievalMode'] =>
|
||
intent === 'conversation_recall'
|
||
? 'conversation_metadata'
|
||
: intent === 'conversation_topic_search'
|
||
? 'conversation_topic_fts'
|
||
: intent === 'conversation_name_search'
|
||
? 'conversation_name'
|
||
: intent === 'global_topic_search'
|
||
? 'global_fts'
|
||
: 'global_fts'
|
||
|
||
const contactLabel = (contact: Contact | undefined): string =>
|
||
contact?.m_nsNickName ||
|
||
contact?.remark ||
|
||
contact?.wechatNickname ||
|
||
contact?.m_nsUsrName ||
|
||
'当前会话'
|
||
|
||
const messageTime = (timestamp: number): string =>
|
||
new Date(timestamp).toLocaleString('zh-CN', { hour12: false })
|
||
|
||
const estimateTokens = (value: string): number => Math.ceil(value.length / 2)
|
||
|
||
const emptyAggregation = (): AiSearchAggregation => ({
|
||
messageCount: 0,
|
||
peopleCount: 0,
|
||
conversationCount: 0,
|
||
people: [],
|
||
conversations: []
|
||
})
|
||
|
||
const emptyTimings = (): AiSearchPipelineTimings => ({
|
||
queryUnderstandingMs: 0,
|
||
contactResolutionMs: 0,
|
||
knowledgeSearchMs: 0,
|
||
workerIpcMs: 0,
|
||
workerBootMs: 0,
|
||
dispatchMs: 0,
|
||
workerSqlMs: 0,
|
||
responseSerializeMs: 0,
|
||
responseTransferMs: 0,
|
||
ftsMs: 0,
|
||
chunkExpandMs: 0,
|
||
messageLoadMs: 0,
|
||
rankingMs: 0,
|
||
candidateRankingMs: 0,
|
||
evidenceBuildMs: 0,
|
||
aggregationMs: 0,
|
||
contextPreparationMs: 0,
|
||
agentDecisionMs: 0,
|
||
agentToolMs: 0,
|
||
aiGenerationMs: 0,
|
||
totalMs: 0
|
||
})
|
||
|
||
/**
|
||
* Main-process search orchestrator. It owns the only transition from raw
|
||
* candidates to Final Evidence; the AI and Renderer never receive a wider
|
||
* candidate context than the final program-generated citations.
|
||
*/
|
||
export class AiSearchPipelineService {
|
||
constructor(
|
||
private readonly knowledge: KnowledgeSearchService,
|
||
private readonly aiProvider: AIProviderService
|
||
) {}
|
||
|
||
async run(
|
||
request: AiSearchPipelineRequest,
|
||
publish: (event: AiSearchProgressEvent) => void
|
||
): Promise<AiSearchPipelineResult> {
|
||
const startedAt = Date.now()
|
||
const timings = emptyTimings()
|
||
let activeStage: AiSearchProgressEvent['stage'] = 'query_understanding'
|
||
const initialTimeRange = inferAiSearchTimeRange(
|
||
request.text,
|
||
request.range,
|
||
new Date(),
|
||
request.timeRangeOverride
|
||
)
|
||
let plan: AiSearchPlan = {
|
||
...buildLocalAiSearchPlan(request.text),
|
||
scopeLabel: aiSearchScopeLabel(request.scope),
|
||
timeRange: initialTimeRange,
|
||
rangeLabel: initialTimeRange.label,
|
||
contactNames: []
|
||
}
|
||
const snapshotTimings = (): AiSearchPipelineTimings => ({
|
||
...timings,
|
||
totalMs: Date.now() - startedAt
|
||
})
|
||
const emit = (event: Omit<AiSearchProgressEvent, 'requestId'>): void =>
|
||
publish({ requestId: request.requestId, ...event })
|
||
|
||
try {
|
||
emit({
|
||
stage: 'query_understanding',
|
||
status: 'running',
|
||
message: '正在理解你的问题'
|
||
})
|
||
const queryUnderstandingStartedAt = Date.now()
|
||
const aiConfig = this.aiProvider.getRuntimeConfig()
|
||
const contactResolutionStartedAt = Date.now()
|
||
const contacts = chat.isReady() ? await chat.listContactsAsync() : []
|
||
const selectedContact = request.conversationId
|
||
? contacts.find((contact) => contact.md5 === request.conversationId)
|
||
: undefined
|
||
const sourceContacts = this.scopeContacts(contacts, request, selectedContact)
|
||
if (!sourceContacts.length) throw new Error('当前搜索范围没有可用会话')
|
||
const contactResolution = plan.contactQuery
|
||
? resolveContact(plan.contactQuery, sourceContacts, contactScopeForIntent(plan.intent))
|
||
: undefined
|
||
const resolvedContact = contactResolution?.matched
|
||
? sourceContacts.find((contact) => contact.md5 === contactResolution.conversationId)
|
||
: selectedContact
|
||
plan = {
|
||
...plan,
|
||
scopeLabel: aiSearchScopeLabel(request.scope, contactLabel(selectedContact)),
|
||
contactNames: resolvedContact ? [contactLabel(resolvedContact)] : []
|
||
}
|
||
const conversationIds =
|
||
isIdentityIntent(plan.intent) && resolvedContact
|
||
? [resolvedContact.md5]
|
||
: request.scope === 'global'
|
||
? undefined
|
||
: sourceContacts.map((contact) => contact.md5)
|
||
timings.contactResolutionMs = Date.now() - contactResolutionStartedAt
|
||
|
||
let agent: AiSearchAgentRun = { mode: 'fallback', toolCalls: 0, trace: [] }
|
||
let candidateEvidence: AiSearchPipelineEvidence[]
|
||
let searchResult: KnowledgeSearchIpcResult
|
||
const agentOutcome = aiConfig.configured
|
||
? await this.runAgentSearch(
|
||
request,
|
||
plan,
|
||
contacts,
|
||
sourceContacts,
|
||
selectedContact,
|
||
resolvedContact,
|
||
(trace) => {
|
||
agent.trace.push(trace)
|
||
if (trace.event === 'agentDecision') timings.agentDecisionMs += trace.elapsedMs || 0
|
||
if (trace.event === 'toolCallEnd') timings.agentToolMs += trace.elapsedMs || 0
|
||
emit({
|
||
stage:
|
||
trace.event === 'agentStart'
|
||
? 'agent_start'
|
||
: trace.event === 'agentDecision'
|
||
? 'agent_decision'
|
||
: 'agent_tool',
|
||
status: 'completed',
|
||
message: trace.label,
|
||
plan,
|
||
agentTrace: trace,
|
||
timings: snapshotTimings()
|
||
})
|
||
}
|
||
)
|
||
: null
|
||
|
||
if (agentOutcome && !agentOutcome.invalid) {
|
||
plan = agentOutcome.plan
|
||
agent = agentOutcome.agent
|
||
candidateEvidence = agentOutcome.candidateEvidence
|
||
searchResult = agentOutcome.searchResult
|
||
timings.knowledgeSearchMs += agentOutcome.knowledgeSearchMs
|
||
timings.workerIpcMs += agentOutcome.searchTimings.workerIpcMs
|
||
timings.workerBootMs += agentOutcome.searchTimings.workerBootMs
|
||
timings.dispatchMs += agentOutcome.searchTimings.dispatchMs
|
||
timings.workerSqlMs += agentOutcome.searchTimings.workerSqlMs
|
||
timings.responseSerializeMs += agentOutcome.searchTimings.responseSerializeMs
|
||
timings.responseTransferMs += agentOutcome.searchTimings.responseTransferMs
|
||
timings.ftsMs += agentOutcome.searchTimings.ftsMs
|
||
timings.chunkExpandMs += agentOutcome.searchTimings.chunkExpandMs
|
||
timings.messageLoadMs += agentOutcome.searchTimings.messageLoadMs
|
||
timings.rankingMs += agentOutcome.searchTimings.rankingMs
|
||
} else {
|
||
const deterministicIdentityRetrieval =
|
||
isIdentityIntent(plan.intent) && Boolean(resolvedContact)
|
||
const unresolvedIdentity = isIdentityIntent(plan.intent) && !resolvedContact
|
||
const fallbackReason = deterministicIdentityRetrieval
|
||
? '受控搜索 Agent 未返回有效控制指令,已按相同检索意图的本地确定性策略继续'
|
||
: unresolvedIdentity
|
||
? '未能唯一确认目标联系人或群聊,未执行消息关键词搜索'
|
||
: aiConfig.configured
|
||
? '受控搜索 Agent 暂时不可用,已改用原有检索方式'
|
||
: '尚未配置可用 AI 模型,已改用原有检索方式'
|
||
agent = {
|
||
mode: 'fallback',
|
||
toolCalls: agentOutcome?.agent.toolCalls || 0,
|
||
fallbackReason,
|
||
trace: [
|
||
...(agentOutcome?.agent.trace || []),
|
||
{
|
||
sequence: (agentOutcome?.agent.trace.length || 0) + 1,
|
||
event: 'fallback',
|
||
label: fallbackReason
|
||
}
|
||
]
|
||
}
|
||
emit({
|
||
stage: 'agent_start',
|
||
status: 'completed',
|
||
message: fallbackReason,
|
||
plan,
|
||
agentTrace: agent.trace[0],
|
||
timings: snapshotTimings()
|
||
})
|
||
if (aiConfig.configured && !deterministicIdentityRetrieval && !unresolvedIdentity) {
|
||
const planningStartedAt = Date.now()
|
||
const planning = await this.aiProvider.chat([
|
||
{
|
||
role: 'system',
|
||
content:
|
||
'你是本地聊天检索规划器,不回答用户问题。请从用户问题中提取用于本地数据库检索的主题词和同义短语,只输出 JSON:{"intent":"global_topic_search|general","keywords":["..."],"variants":["..."],"topicQuery":"..."}。不要编造人名或聊天内容;联系人身份和会话回顾由程序决定。'
|
||
},
|
||
{ role: 'user', content: `用户问题:${request.text}` }
|
||
])
|
||
timings.queryUnderstandingMs += Date.now() - planningStartedAt
|
||
if (planning.success && planning.data) {
|
||
plan = {
|
||
...mergeAiSearchPlans(
|
||
buildLocalAiSearchPlan(request.text),
|
||
parseAiSearchPlan(planning.data)
|
||
),
|
||
scopeLabel: plan.scopeLabel,
|
||
rangeLabel: plan.rangeLabel,
|
||
timeRange: plan.timeRange,
|
||
contactNames: resolvedContact ? [contactLabel(resolvedContact)] : []
|
||
}
|
||
}
|
||
}
|
||
|
||
activeStage = 'knowledge_searching'
|
||
emit({
|
||
stage: 'knowledge_searching',
|
||
status: 'running',
|
||
message: '正在本地知识库中查找',
|
||
plan,
|
||
timings: snapshotTimings()
|
||
})
|
||
if (unresolvedIdentity) {
|
||
searchResult = {
|
||
source: 'knowledge',
|
||
state: 'ready',
|
||
indexedMessageCount: 0,
|
||
indexedChunkCount: 0,
|
||
totalMessages: 0,
|
||
evidence: [],
|
||
timings: emptyKnowledgeSearchTimings()
|
||
}
|
||
candidateEvidence = []
|
||
} else {
|
||
const knowledgeSearchStartedAt = Date.now()
|
||
const deterministicTerms =
|
||
plan.intent === 'conversation_recall' || plan.intent === 'conversation_name_search'
|
||
? []
|
||
: plan.intent === 'conversation_topic_search'
|
||
? plan.topicQuery
|
||
? [plan.topicQuery]
|
||
: []
|
||
: Array.from(new Set([...plan.keywords, ...plan.variants]))
|
||
searchResult = await this.knowledge.search({
|
||
text: request.text,
|
||
terms: deterministicTerms,
|
||
conversationIds,
|
||
startTime: plan.timeRange.startTime,
|
||
endTime: plan.timeRange.endTime,
|
||
limit: 240
|
||
})
|
||
timings.knowledgeSearchMs += Date.now() - knowledgeSearchStartedAt
|
||
candidateEvidence = this.toPipelineEvidence(searchResult, contacts)
|
||
}
|
||
}
|
||
timings.queryUnderstandingMs +=
|
||
Date.now() -
|
||
queryUnderstandingStartedAt -
|
||
timings.contactResolutionMs -
|
||
timings.agentDecisionMs -
|
||
timings.agentToolMs
|
||
timings.queryUnderstandingMs = Math.max(0, timings.queryUnderstandingMs)
|
||
|
||
emit({
|
||
stage: 'query_understanding',
|
||
status: 'completed',
|
||
message: '已理解搜索条件',
|
||
plan,
|
||
timings: snapshotTimings()
|
||
})
|
||
activeStage = 'search_plan_ready'
|
||
emit({
|
||
stage: 'search_plan_ready',
|
||
status: 'completed',
|
||
message: `将${aiSearchIntentLabel(plan.intent)}`,
|
||
plan,
|
||
timings: snapshotTimings()
|
||
})
|
||
|
||
const knowledgeTimings = searchResult.timings || emptyKnowledgeSearchTimings()
|
||
if (!agentOutcome) {
|
||
timings.workerIpcMs = knowledgeTimings.workerIpcMs
|
||
timings.workerBootMs = knowledgeTimings.workerBootMs
|
||
timings.dispatchMs = knowledgeTimings.dispatchMs
|
||
timings.workerSqlMs = knowledgeTimings.workerSqlMs
|
||
timings.responseSerializeMs = knowledgeTimings.responseSerializeMs
|
||
timings.responseTransferMs = knowledgeTimings.responseTransferMs
|
||
timings.ftsMs = knowledgeTimings.ftsMs
|
||
timings.chunkExpandMs = knowledgeTimings.chunkExpandMs
|
||
timings.messageLoadMs = knowledgeTimings.messageLoadMs
|
||
timings.rankingMs = knowledgeTimings.rankingMs
|
||
}
|
||
|
||
const usedKnowledge = searchResult.source === 'knowledge'
|
||
const knowledgeMessageCount = usedKnowledge ? searchResult.indexedMessageCount : undefined
|
||
const searchMessage = usedKnowledge
|
||
? candidateEvidence.length
|
||
? `找到 ${candidateEvidence.length} 条相关消息`
|
||
: '没有找到相关消息'
|
||
: searchResult.fallbackReason === 'error'
|
||
? '本地知识库暂时不可用,已改用聊天记录查找'
|
||
: '本地知识库尚未就绪,已改用聊天记录查找'
|
||
emit({
|
||
stage: 'knowledge_searching',
|
||
status: 'completed',
|
||
message: searchMessage,
|
||
plan,
|
||
stats: {
|
||
knowledgeMessageCount,
|
||
matchedMessages: candidateEvidence.length,
|
||
elapsedMs: timings.knowledgeSearchMs
|
||
},
|
||
timings: snapshotTimings()
|
||
})
|
||
|
||
let retrieval = this.buildRetrievalContract(
|
||
plan,
|
||
resolvedContact,
|
||
searchResult,
|
||
candidateEvidence,
|
||
agent
|
||
)
|
||
if (retrieval.suspicious && resolvedContact) {
|
||
// An identity route that somehow yielded 0/1 records is never allowed
|
||
// to masquerade as a complete chat recap. Retry the safe metadata read.
|
||
const retryStartedAt = Date.now()
|
||
searchResult = await this.knowledge.search({
|
||
text: request.text,
|
||
terms: [],
|
||
conversationIds: [resolvedContact.md5],
|
||
startTime: plan.timeRange.startTime,
|
||
endTime: plan.timeRange.endTime,
|
||
limit: 240
|
||
})
|
||
timings.knowledgeSearchMs += Date.now() - retryStartedAt
|
||
candidateEvidence = this.toPipelineEvidence(searchResult, contacts)
|
||
retrieval = this.buildRetrievalContract(
|
||
plan,
|
||
resolvedContact,
|
||
searchResult,
|
||
candidateEvidence,
|
||
agent
|
||
)
|
||
}
|
||
|
||
activeStage = 'evidence_ranking'
|
||
emit({
|
||
stage: 'evidence_ranking',
|
||
status: 'running',
|
||
message: '正在整理最相关的原始消息',
|
||
plan,
|
||
stats: { knowledgeMessageCount, matchedMessages: candidateEvidence.length },
|
||
timings: snapshotTimings()
|
||
})
|
||
const evidenceBuild = buildFinalEvidence(candidateEvidence, DISPLAY_EVIDENCE_LIMIT, {
|
||
strategy: plan.intent === 'conversation_recall' ? 'conversation_coverage' : 'ranked'
|
||
})
|
||
const evidence = evidenceBuild.evidence
|
||
timings.candidateRankingMs = evidenceBuild.candidateRankingMs
|
||
timings.evidenceBuildMs = evidenceBuild.evidenceBuildMs
|
||
timings.aggregationMs = evidenceBuild.aggregationMs
|
||
const evidenceTrace: AiSearchAgentTraceItem = {
|
||
sequence: agent.trace.length + 1,
|
||
event: 'evidenceBuild',
|
||
label: '已从候选消息整理可引用证据',
|
||
resultCount: evidence.length,
|
||
elapsedMs:
|
||
evidenceBuild.candidateRankingMs +
|
||
evidenceBuild.evidenceBuildMs +
|
||
evidenceBuild.aggregationMs
|
||
}
|
||
agent.trace.push(evidenceTrace)
|
||
emit({
|
||
stage: 'evidence_ranking',
|
||
status: 'completed',
|
||
message: `已从 ${evidenceBuild.candidateCount} 条候选消息整理出 ${evidence.length} 条可引用证据`,
|
||
plan,
|
||
stats: {
|
||
knowledgeMessageCount,
|
||
matchedMessages: evidenceBuild.candidateCount,
|
||
evidenceCount: evidence.length,
|
||
contextEvidenceCount: evidence.length,
|
||
deduplicatedMessages: evidenceBuild.deduplicatedCount
|
||
},
|
||
agentTrace: evidenceTrace,
|
||
timings: snapshotTimings()
|
||
})
|
||
activeStage = 'evidence_ready'
|
||
emit({
|
||
stage: 'evidence_ready',
|
||
status: 'completed',
|
||
message: evidence.length ? '已保留可跳转的原始消息引用' : '没有可引用的原始消息',
|
||
plan,
|
||
stats: {
|
||
knowledgeMessageCount,
|
||
matchedMessages: evidenceBuild.candidateCount,
|
||
evidenceCount: evidence.length,
|
||
contextEvidenceCount: evidence.length,
|
||
deduplicatedMessages: evidenceBuild.deduplicatedCount
|
||
},
|
||
timings: snapshotTimings()
|
||
})
|
||
|
||
activeStage = 'aggregation'
|
||
emit({
|
||
stage: 'aggregation',
|
||
status: 'running',
|
||
message: '正在按人物和会话整理证据',
|
||
plan,
|
||
stats: { evidenceCount: evidence.length },
|
||
timings: snapshotTimings()
|
||
})
|
||
emit({
|
||
stage: 'aggregation',
|
||
status: 'completed',
|
||
message: `已整理 ${evidenceBuild.aggregation.peopleCount} 人、${evidenceBuild.aggregation.conversationCount} 个会话`,
|
||
plan,
|
||
stats: {
|
||
evidenceCount: evidence.length,
|
||
peopleCount: evidenceBuild.aggregation.peopleCount,
|
||
conversationCount: evidenceBuild.aggregation.conversationCount
|
||
},
|
||
timings: snapshotTimings()
|
||
})
|
||
|
||
const baseResult = {
|
||
requestId: request.requestId,
|
||
plan,
|
||
agent,
|
||
knowledge: {
|
||
source: searchResult.source,
|
||
state: searchResult.state,
|
||
fallbackReason: searchResult.fallbackReason,
|
||
indexedMessageCount: searchResult.indexedMessageCount,
|
||
indexedChunkCount: searchResult.indexedChunkCount,
|
||
totalMessages: searchResult.totalMessages
|
||
},
|
||
candidateEvidenceCount: evidenceBuild.candidateCount,
|
||
retrieval,
|
||
evidence,
|
||
contextEvidenceCount: evidence.length,
|
||
aggregation: evidenceBuild.aggregation,
|
||
timings: snapshotTimings(),
|
||
elapsedMs: Date.now() - startedAt
|
||
}
|
||
if (!evidence.length) {
|
||
emit({
|
||
stage: 'completed',
|
||
status: 'completed',
|
||
message: '搜索完成,当前条件下没有找到相关消息',
|
||
plan,
|
||
stats: { knowledgeMessageCount, matchedMessages: 0, evidenceCount: 0 },
|
||
timings: snapshotTimings()
|
||
})
|
||
return {
|
||
...baseResult,
|
||
status: 'no_evidence',
|
||
timings: snapshotTimings(),
|
||
elapsedMs: Date.now() - startedAt
|
||
}
|
||
}
|
||
|
||
if (retrieval.suspicious) {
|
||
const error = '已找到目标会话,但当前检索未完整覆盖聊天记录,未生成总结。'
|
||
emit({
|
||
stage: 'completed',
|
||
status: 'completed',
|
||
message: error,
|
||
plan,
|
||
stats: {
|
||
knowledgeMessageCount,
|
||
matchedMessages: evidenceBuild.candidateCount,
|
||
evidenceCount: evidence.length
|
||
},
|
||
timings: snapshotTimings(),
|
||
error
|
||
})
|
||
return {
|
||
...baseResult,
|
||
status: 'retrieval_incomplete',
|
||
error,
|
||
timings: snapshotTimings(),
|
||
elapsedMs: Date.now() - startedAt
|
||
}
|
||
}
|
||
|
||
activeStage = 'ai_generating'
|
||
const summaryTrace: AiSearchAgentTraceItem = {
|
||
sequence: agent.trace.length + 1,
|
||
event: 'summaryStart',
|
||
label: '开始生成带来源的回答'
|
||
}
|
||
agent.trace.push(summaryTrace)
|
||
const contextPreparationStartedAt = Date.now()
|
||
const prompt = this.answerPrompt(
|
||
request.text,
|
||
plan,
|
||
searchResult.totalMessages,
|
||
evidence,
|
||
evidenceBuild.aggregation,
|
||
retrieval
|
||
)
|
||
const tokenEstimate = estimateTokens(prompt)
|
||
timings.contextPreparationMs = Date.now() - contextPreparationStartedAt
|
||
if (!aiConfig.configured) {
|
||
const error = '尚未配置可用 AI 模型'
|
||
emit({
|
||
stage: 'ai_generating',
|
||
status: 'error',
|
||
message: '证据已找到,但无法生成回答',
|
||
plan,
|
||
stats: {
|
||
matchedMessages: evidenceBuild.candidateCount,
|
||
evidenceCount: evidence.length,
|
||
contextEvidenceCount: evidence.length,
|
||
tokenEstimate
|
||
},
|
||
timings: snapshotTimings(),
|
||
error
|
||
})
|
||
return {
|
||
...baseResult,
|
||
status: 'ai_failed',
|
||
error,
|
||
errorStage: 'ai_generating',
|
||
timings: snapshotTimings(),
|
||
elapsedMs: Date.now() - startedAt
|
||
}
|
||
}
|
||
emit({
|
||
stage: 'ai_generating',
|
||
status: 'running',
|
||
message: '正在生成带来源的回答',
|
||
plan,
|
||
modelName: aiConfig.modelName,
|
||
agentTrace: summaryTrace,
|
||
stats: {
|
||
matchedMessages: evidenceBuild.candidateCount,
|
||
evidenceCount: evidence.length,
|
||
contextEvidenceCount: evidence.length,
|
||
tokenEstimate
|
||
},
|
||
timings: snapshotTimings()
|
||
})
|
||
const aiGenerationStartedAt = Date.now()
|
||
const answer = await this.aiProvider.chat([
|
||
{
|
||
role: 'system',
|
||
content:
|
||
'你是 WechatExplorer 的本地聊天记录分析助手。只能基于提供的程序化事实和 Evidence 回答,不得编造事实。请用中文回答,先给出简短摘要,再列出关键主题、结论和不确定性。引用关键事实时,只能使用 Evidence 原文中存在的 [E#],不要创建、猜测或改写 Evidence ID。对人物问题只能描述聊天中的发言主题和可能角色,不做人格或敏感属性判断。'
|
||
},
|
||
{ role: 'user', content: prompt }
|
||
])
|
||
timings.aiGenerationMs = Date.now() - aiGenerationStartedAt
|
||
if (!answer.success || !answer.data) {
|
||
const error = answer.error || 'AI 没有返回可用回答'
|
||
emit({
|
||
stage: 'ai_generating',
|
||
status: 'error',
|
||
message: '证据已找到,但 AI 暂时无法生成回答',
|
||
plan,
|
||
modelName: aiConfig.modelName,
|
||
stats: {
|
||
matchedMessages: evidenceBuild.candidateCount,
|
||
evidenceCount: evidence.length,
|
||
contextEvidenceCount: evidence.length,
|
||
tokenEstimate
|
||
},
|
||
timings: snapshotTimings(),
|
||
error
|
||
})
|
||
return {
|
||
...baseResult,
|
||
status: 'ai_failed',
|
||
error,
|
||
errorStage: 'ai_generating',
|
||
timings: snapshotTimings(),
|
||
elapsedMs: Date.now() - startedAt
|
||
}
|
||
}
|
||
|
||
const citationValidation = sanitizeAnswerCitations(answer.data, evidence)
|
||
const summaryEndTrace: AiSearchAgentTraceItem = {
|
||
sequence: agent.trace.length + 1,
|
||
event: 'summaryEnd',
|
||
label: '已生成带来源的回答',
|
||
elapsedMs: timings.aiGenerationMs
|
||
}
|
||
agent.trace.push(summaryEndTrace)
|
||
const inputTokens = answer.usage?.input || tokenEstimate
|
||
const inputTokensEstimated = !answer.usage?.input || Boolean(answer.usage?.estimated)
|
||
const ai = {
|
||
providerName: aiConfig.providerName,
|
||
modelName: aiConfig.modelName,
|
||
inputTokens,
|
||
inputTokensEstimated
|
||
}
|
||
const completedMessage = citationValidation.invalidCitationIds.length
|
||
? '已生成回答;已移除无法对应原始消息的引用'
|
||
: '已生成带来源的回答'
|
||
emit({
|
||
stage: 'ai_generating',
|
||
status: 'completed',
|
||
message: completedMessage,
|
||
plan,
|
||
modelName: aiConfig.modelName,
|
||
stats: {
|
||
knowledgeMessageCount,
|
||
matchedMessages: evidenceBuild.candidateCount,
|
||
evidenceCount: evidence.length,
|
||
contextEvidenceCount: evidence.length,
|
||
inputTokens,
|
||
inputTokensEstimated,
|
||
peopleCount: evidenceBuild.aggregation.peopleCount,
|
||
conversationCount: evidenceBuild.aggregation.conversationCount,
|
||
elapsedMs: timings.aiGenerationMs
|
||
},
|
||
timings: snapshotTimings(),
|
||
agentTrace: summaryEndTrace
|
||
})
|
||
emit({
|
||
stage: 'completed',
|
||
status: 'completed',
|
||
message: '已完成',
|
||
plan,
|
||
stats: {
|
||
knowledgeMessageCount,
|
||
matchedMessages: evidenceBuild.candidateCount,
|
||
evidenceCount: evidence.length,
|
||
contextEvidenceCount: evidence.length,
|
||
inputTokens,
|
||
inputTokensEstimated,
|
||
peopleCount: evidenceBuild.aggregation.peopleCount,
|
||
conversationCount: evidenceBuild.aggregation.conversationCount,
|
||
elapsedMs: Date.now() - startedAt
|
||
},
|
||
timings: snapshotTimings(),
|
||
modelName: aiConfig.modelName
|
||
})
|
||
return {
|
||
...baseResult,
|
||
status: 'completed',
|
||
answer: citationValidation.answer,
|
||
ai,
|
||
citationValidation: {
|
||
status: citationValidation.status,
|
||
invalidCitationIds: citationValidation.invalidCitationIds
|
||
},
|
||
timings: snapshotTimings(),
|
||
elapsedMs: Date.now() - startedAt
|
||
}
|
||
} catch (caught) {
|
||
const error = caught instanceof Error ? caught.message : '搜索过程发生未知错误'
|
||
emit({
|
||
stage: 'error',
|
||
status: 'error',
|
||
message: this.errorMessage(activeStage),
|
||
plan,
|
||
timings: snapshotTimings(),
|
||
error
|
||
})
|
||
return {
|
||
requestId: request.requestId,
|
||
status: 'failed',
|
||
plan,
|
||
knowledge: {
|
||
source: 'fallback',
|
||
state: 'unavailable',
|
||
indexedMessageCount: 0,
|
||
indexedChunkCount: 0,
|
||
totalMessages: 0
|
||
},
|
||
candidateEvidenceCount: 0,
|
||
evidence: [],
|
||
contextEvidenceCount: 0,
|
||
retrieval: {
|
||
intent: plan.intent,
|
||
timeRange: plan.timeRange,
|
||
retrievalMode: 'global_fts',
|
||
candidateCount: 0,
|
||
sourceCoverage: 'unknown',
|
||
isComplete: false,
|
||
fallbackUsed: true,
|
||
suspicious: false
|
||
},
|
||
aggregation: emptyAggregation(),
|
||
agent: { mode: 'fallback', toolCalls: 0, trace: [] },
|
||
timings: snapshotTimings(),
|
||
error,
|
||
errorStage: activeStage,
|
||
elapsedMs: Date.now() - startedAt
|
||
}
|
||
}
|
||
}
|
||
|
||
private scopeContacts(
|
||
contacts: Contact[],
|
||
request: AiSearchPipelineRequest,
|
||
selectedContact: Contact | undefined
|
||
): Contact[] {
|
||
if (request.scope === 'groups') return contacts.filter((contact) => contact.type === 'group')
|
||
if (request.scope === 'contacts') return contacts.filter((contact) => contact.type !== 'group')
|
||
if (request.scope === 'conversation') return selectedContact ? [selectedContact] : []
|
||
return contacts
|
||
}
|
||
|
||
private toPipelineEvidence(
|
||
result: KnowledgeSearchIpcResult,
|
||
contacts: Contact[]
|
||
): AiSearchPipelineEvidence[] {
|
||
const contactsById = new Map(contacts.map((contact) => [contact.md5, contact]))
|
||
return result.evidence.map((item): AiSearchPipelineEvidence => {
|
||
const contact = contactsById.get(item.conversationId)
|
||
return {
|
||
...item,
|
||
conversationName: contactLabel(contact),
|
||
conversationType:
|
||
contact?.type || (item.conversationId.endsWith('@chatroom') ? 'group' : 'user')
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* The model never receives source IDs. It only receives per-request refs
|
||
* created from Tool results; every ref is checked again before a read.
|
||
*/
|
||
private async runAgentSearch(
|
||
request: AiSearchPipelineRequest,
|
||
initialPlan: AiSearchPlan,
|
||
contacts: Contact[],
|
||
sourceContacts: Contact[],
|
||
selectedContact: Contact | undefined,
|
||
resolvedContact: Contact | undefined,
|
||
onTrace: (item: AiSearchAgentTraceItem) => void
|
||
): Promise<AgentSearchOutcome | null> {
|
||
const contactsInScope = new Map(sourceContacts.map((contact) => [contact.md5, contact]))
|
||
const conversationRefs = new Map<string, Contact>()
|
||
const refsByConversation = new Map<string, string>()
|
||
const messageRefs = new Map<string, AiSearchPipelineEvidence>()
|
||
const candidates: AiSearchPipelineEvidence[] = []
|
||
const trace: AiSearchAgentTraceItem[] = []
|
||
let traceSequence = 0
|
||
let lastSearchResult: KnowledgeSearchIpcResult = {
|
||
source: 'knowledge',
|
||
state: 'unavailable',
|
||
indexedMessageCount: 0,
|
||
indexedChunkCount: 0,
|
||
totalMessages: 0,
|
||
evidence: [],
|
||
timings: emptyKnowledgeSearchTimings()
|
||
}
|
||
let plan = initialPlan
|
||
const searchTimings = emptyKnowledgeSearchTimings()
|
||
let knowledgeSearchMs = 0
|
||
|
||
const recordTrace = (item: Omit<AiSearchAgentTraceItem, 'sequence'>): void => {
|
||
const next = { sequence: ++traceSequence, ...item }
|
||
trace.push(next)
|
||
onTrace(next)
|
||
}
|
||
const addConversationRef = (contact: Contact): string => {
|
||
const existing = refsByConversation.get(contact.md5)
|
||
if (existing) return existing
|
||
const ref = `conversation-${conversationRefs.size + 1}`
|
||
refsByConversation.set(contact.md5, ref)
|
||
conversationRefs.set(ref, contact)
|
||
return ref
|
||
}
|
||
if (selectedContact && contactsInScope.has(selectedContact.md5))
|
||
addConversationRef(selectedContact)
|
||
if (resolvedContact && contactsInScope.has(resolvedContact.md5))
|
||
addConversationRef(resolvedContact)
|
||
|
||
const boundedQuery = (value: unknown): string => {
|
||
if (typeof value !== 'string') throw new Error('查询内容无效')
|
||
const query = value.trim()
|
||
if (query.length < 2 || query.length > 64) throw new Error('查询内容长度不符合限制')
|
||
return query
|
||
}
|
||
const boundedLimit = (value: unknown, fallback: number, maximum: number): number => {
|
||
if (value === undefined) return fallback
|
||
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > maximum) {
|
||
throw new Error('读取数量不符合限制')
|
||
}
|
||
return value
|
||
}
|
||
const resolveConversation = (value: unknown): Contact => {
|
||
if (typeof value !== 'string') throw new Error('必须先通过会话搜索取得目标')
|
||
const contact = conversationRefs.get(value)
|
||
if (!contact || !contactsInScope.has(contact.md5))
|
||
throw new Error('目标会话不在本次允许范围内')
|
||
return contact
|
||
}
|
||
const matchingContacts = (query: string, peopleOnly: boolean): Contact[] => {
|
||
const result = resolveContact(query, sourceContacts, peopleOnly ? 'person' : 'group')
|
||
if (!result.matched || !result.conversationId || result.ambiguous) return []
|
||
const contact = sourceContacts.find((item) => item.md5 === result.conversationId)
|
||
return contact ? [contact] : []
|
||
}
|
||
const rejectForbiddenAction = (action: Extract<AgentAction, { action: 'tool' }>): void => {
|
||
const contactBound = Boolean(resolvedContact || selectedContact)
|
||
const requiresConversationRef =
|
||
action.tool === 'get_conversation_messages' ||
|
||
action.tool === 'get_message_context' ||
|
||
(action.tool === 'search_messages' && plan.intent === 'conversation_topic_search')
|
||
if (requiresConversationRef && typeof action.arguments.conversationRef !== 'string') {
|
||
throw new Error('当前检索意图要求先确认目标会话')
|
||
}
|
||
if (plan.intent === 'conversation_recall') {
|
||
if (action.tool !== 'search_people' && action.tool !== 'get_conversation_messages') {
|
||
throw new Error('联系人回顾只允许定位联系人后读取该会话消息')
|
||
}
|
||
if (action.tool === 'search_people' && contactBound && plan.contactQuery) {
|
||
const actionResolution = resolveContact(
|
||
action.arguments.query as string,
|
||
sourceContacts,
|
||
'person'
|
||
)
|
||
if (
|
||
!actionResolution.matched ||
|
||
actionResolution.conversationId !== resolvedContact?.md5
|
||
) {
|
||
throw new Error('联系人回顾只能使用已解析的目标联系人')
|
||
}
|
||
}
|
||
}
|
||
if (plan.intent === 'conversation_topic_search') {
|
||
if (action.tool !== 'search_people' && action.tool !== 'search_messages') {
|
||
throw new Error('联系人话题查询只允许定位联系人后在该会话内查找话题')
|
||
}
|
||
if (
|
||
action.tool === 'search_messages' &&
|
||
typeof action.arguments.conversationRef !== 'string'
|
||
) {
|
||
throw new Error('联系人话题查询不能执行全局消息搜索')
|
||
}
|
||
}
|
||
if (plan.intent === 'global_topic_search' && action.tool !== 'search_messages') {
|
||
throw new Error('全局话题查询只允许查找消息内容')
|
||
}
|
||
if (plan.intent === 'conversation_name_search') {
|
||
if (action.tool !== 'search_conversations' && action.tool !== 'get_conversation_messages') {
|
||
throw new Error('聊天名称查询只允许定位聊天后读取该会话消息')
|
||
}
|
||
}
|
||
}
|
||
const addSearchCandidates = (result: KnowledgeSearchIpcResult): AiSearchPipelineEvidence[] => {
|
||
lastSearchResult = result
|
||
const evidence = this.toPipelineEvidence(result, contacts)
|
||
evidence.forEach((item) => {
|
||
if (candidates.length < MAX_AGENT_CANDIDATES) candidates.push(item)
|
||
const key = `${item.conversationId}\u0000${item.messageId}`
|
||
if (
|
||
!Array.from(messageRefs.values()).some(
|
||
(value) => `${value.conversationId}\u0000${value.messageId}` === key
|
||
)
|
||
) {
|
||
messageRefs.set(`message-${messageRefs.size + 1}`, item)
|
||
}
|
||
})
|
||
return evidence
|
||
}
|
||
const summarizeMessages = (
|
||
evidence: AiSearchPipelineEvidence[]
|
||
): Array<Record<string, string>> =>
|
||
evidence.slice(0, 12).map((item) => {
|
||
const messageRef = Array.from(messageRefs.entries()).find(
|
||
([, value]) =>
|
||
value.conversationId === item.conversationId && value.messageId === item.messageId
|
||
)?.[0]
|
||
const conversationRef = refsByConversation.get(item.conversationId)
|
||
return {
|
||
messageRef: messageRef || '',
|
||
conversationRef: conversationRef || '',
|
||
sender: item.sender,
|
||
time: messageTime(item.timestamp),
|
||
preview: item.text.replace(/\s+/g, ' ').slice(0, 180)
|
||
}
|
||
})
|
||
const search = async (
|
||
terms: string[],
|
||
conversationIds: string[] | undefined,
|
||
limit: number,
|
||
startTime = initialPlan.timeRange.startTime,
|
||
endTime?: number
|
||
): Promise<AiSearchPipelineEvidence[]> => {
|
||
const startedAt = Date.now()
|
||
const result = await this.knowledge.search({
|
||
text: request.text,
|
||
terms,
|
||
conversationIds,
|
||
startTime,
|
||
endTime,
|
||
limit
|
||
})
|
||
knowledgeSearchMs += Date.now() - startedAt
|
||
const resultTimings = result.timings || emptyKnowledgeSearchTimings()
|
||
// A previously running Worker may return an older timing shape during a
|
||
// desktop hot reload. Missing diagnostic fields must remain zero rather
|
||
// than turning the whole search trace into NaN.
|
||
searchTimings.workerIpcMs += resultTimings.workerIpcMs || 0
|
||
searchTimings.workerBootMs += resultTimings.workerBootMs || 0
|
||
searchTimings.dispatchMs += resultTimings.dispatchMs || 0
|
||
searchTimings.workerSqlMs += resultTimings.workerSqlMs || 0
|
||
searchTimings.responseSerializeMs += resultTimings.responseSerializeMs || 0
|
||
searchTimings.responseTransferMs += resultTimings.responseTransferMs || 0
|
||
searchTimings.ftsMs += resultTimings.ftsMs || 0
|
||
searchTimings.chunkExpandMs += resultTimings.chunkExpandMs || 0
|
||
searchTimings.messageLoadMs += resultTimings.messageLoadMs || 0
|
||
searchTimings.rankingMs += resultTimings.rankingMs || 0
|
||
searchTimings.totalMs += resultTimings.totalMs || 0
|
||
return addSearchCandidates(result)
|
||
}
|
||
const execute = async (
|
||
action: Extract<AgentAction, { action: 'tool' }>
|
||
): Promise<AgentToolResult> => {
|
||
rejectForbiddenAction(action)
|
||
if (action.tool === 'search_people' || action.tool === 'search_conversations') {
|
||
const query = boundedQuery(action.arguments.query)
|
||
const limit = boundedLimit(action.arguments.limit, 10, 20)
|
||
const peopleOnly = action.tool === 'search_people'
|
||
const results = matchingContacts(query, peopleOnly)
|
||
.slice(0, limit)
|
||
.map((contact) => ({
|
||
conversationRef: addConversationRef(contact),
|
||
name: contactLabel(contact),
|
||
type: contact.type,
|
||
matchReason:
|
||
contactLabel(contact).toLocaleLowerCase() === query.toLocaleLowerCase()
|
||
? '名称匹配'
|
||
: '名称相近'
|
||
}))
|
||
if (results.length && peopleOnly) {
|
||
plan = { ...plan, contactNames: results.map((result) => result.name) }
|
||
}
|
||
return { summary: { total: results.length, results }, candidateCount: results.length }
|
||
}
|
||
|
||
if (action.tool === 'search_messages') {
|
||
const query = boundedQuery(action.arguments.query)
|
||
const limit = boundedLimit(action.arguments.limit, AGENT_SEARCH_LIMIT, AGENT_SEARCH_LIMIT)
|
||
const contact = action.arguments.conversationRef
|
||
? resolveConversation(action.arguments.conversationRef)
|
||
: undefined
|
||
const evidence = await search(
|
||
[query],
|
||
contact ? [contact.md5] : sourceContacts.map((item) => item.md5),
|
||
limit
|
||
)
|
||
plan = {
|
||
...plan,
|
||
keywords: [query],
|
||
variants: [],
|
||
intent:
|
||
plan.intent === 'conversation_topic_search' || contact
|
||
? 'conversation_topic_search'
|
||
: 'global_topic_search',
|
||
source: 'ai'
|
||
}
|
||
return {
|
||
summary: { total: evidence.length, messages: summarizeMessages(evidence) },
|
||
candidateCount: evidence.length
|
||
}
|
||
}
|
||
|
||
if (action.tool === 'get_conversation_messages' || action.tool === 'get_messages_by_time') {
|
||
const limit = boundedLimit(action.arguments.limit, AGENT_MESSAGE_LIMIT, AGENT_MESSAGE_LIMIT)
|
||
const contact = action.arguments.conversationRef
|
||
? resolveConversation(action.arguments.conversationRef)
|
||
: undefined
|
||
const startTime =
|
||
typeof action.arguments.startTime === 'number' &&
|
||
Number.isInteger(action.arguments.startTime)
|
||
? action.arguments.startTime
|
||
: initialPlan.timeRange.startTime
|
||
const endTime =
|
||
typeof action.arguments.endTime === 'number' && Number.isInteger(action.arguments.endTime)
|
||
? action.arguments.endTime
|
||
: undefined
|
||
const minimumStartTime = initialPlan.timeRange.startTime
|
||
const now = Math.floor(Date.now() / 1000)
|
||
if (
|
||
(startTime !== undefined && (startTime < (minimumStartTime || 0) || startTime > now)) ||
|
||
(endTime !== undefined && (endTime > now || endTime < (minimumStartTime || 0))) ||
|
||
(endTime !== undefined && startTime !== undefined && endTime < startTime)
|
||
) {
|
||
throw new Error('时间范围无效')
|
||
}
|
||
if (action.tool === 'get_conversation_messages' && !contact) {
|
||
throw new Error('读取会话消息前必须先定位会话')
|
||
}
|
||
const evidence = await search(
|
||
[],
|
||
contact ? [contact.md5] : sourceContacts.map((item) => item.md5),
|
||
limit,
|
||
startTime,
|
||
endTime
|
||
)
|
||
const retrieval = lastSearchResult.conversationRetrieval
|
||
return {
|
||
summary: {
|
||
totalMessages: retrieval?.totalMessages || evidence.length,
|
||
chunks: retrieval?.chunkCount,
|
||
candidateMessages: retrieval?.candidateMessages || evidence.length,
|
||
systemMessagesDeprioritized: retrieval?.systemMessagesDeprioritized || 0,
|
||
truncated: retrieval ? !retrieval.complete : false,
|
||
messages: summarizeMessages(evidence)
|
||
},
|
||
candidateCount: evidence.length,
|
||
finalizeReason:
|
||
action.tool === 'get_conversation_messages' &&
|
||
plan.intent === 'conversation_recall' &&
|
||
retrieval?.complete
|
||
? `已覆盖所选时间范围内 ${retrieval.totalMessages} 条消息,并整理为 ${retrieval.chunkCount} 个本地对话片段`
|
||
: undefined
|
||
}
|
||
}
|
||
|
||
const messageRef = action.arguments.messageRef
|
||
if (typeof messageRef !== 'string') throw new Error('必须先通过消息检索取得上下文目标')
|
||
const target = messageRefs.get(messageRef)
|
||
if (!target || !contactsInScope.has(target.conversationId))
|
||
throw new Error('上下文目标不在本次允许范围内')
|
||
const evidence = await search(
|
||
[],
|
||
[target.conversationId],
|
||
boundedLimit(action.arguments.limit, 30, 50),
|
||
Math.max(0, Math.floor(target.timestamp / 1000) - 15 * 60),
|
||
Math.floor(target.timestamp / 1000) + 15 * 60
|
||
)
|
||
return {
|
||
summary: { total: evidence.length, messages: summarizeMessages(evidence) },
|
||
candidateCount: evidence.length
|
||
}
|
||
}
|
||
|
||
const outcome = await runControlledSearchAgent({
|
||
question: request.text,
|
||
scopeLabel: initialPlan.scopeLabel,
|
||
rangeLabel: initialPlan.rangeLabel,
|
||
maxToolCalls: initialPlan.intent === 'conversation_recall' ? 2 : undefined,
|
||
decide: async (prompt) => {
|
||
const response = await this.aiProvider.chat([
|
||
{ role: 'system', content: prompt },
|
||
{ role: 'user', content: '请输出下一步受控检索 JSON。' }
|
||
])
|
||
return response.success ? response.data : undefined
|
||
},
|
||
execute,
|
||
onTrace: recordTrace
|
||
})
|
||
if (outcome.status === 'invalid') {
|
||
return {
|
||
invalid: true,
|
||
candidateEvidence: candidates,
|
||
searchResult: lastSearchResult,
|
||
plan,
|
||
agent: {
|
||
mode: 'fallback',
|
||
toolCalls: outcome.toolCalls,
|
||
trace,
|
||
fallbackReason: outcome.reason
|
||
},
|
||
searchTimings,
|
||
knowledgeSearchMs
|
||
}
|
||
}
|
||
return {
|
||
candidateEvidence: candidates,
|
||
searchResult: lastSearchResult,
|
||
plan,
|
||
agent: { mode: 'agent', toolCalls: outcome.toolCalls, trace },
|
||
searchTimings,
|
||
knowledgeSearchMs
|
||
}
|
||
}
|
||
|
||
private answerPrompt(
|
||
query: string,
|
||
plan: AiSearchPlan,
|
||
totalMessages: number,
|
||
evidence: AiSearchFinalEvidence[],
|
||
aggregation: AiSearchAggregation,
|
||
retrieval: AiSearchRetrievalContract
|
||
): string {
|
||
const context = evidence
|
||
.map(
|
||
(item) =>
|
||
`[${item.id}]\nconversationId: ${item.conversationId}\nmessageId: ${item.messageId}\nsender: ${item.sender}\ntimestamp: ${messageTime(item.timestamp)}\ncontent: ${item.text}`
|
||
)
|
||
.join('\n\n')
|
||
const people = aggregation.people
|
||
.map(
|
||
(person) =>
|
||
`- ${person.name}:${person.messageCount} 条,${person.conversationCount} 个会话,最近 ${messageTime(person.lastMessageAt)},Evidence ${person.evidenceIds.join('、')}`
|
||
)
|
||
.join('\n')
|
||
const conversations = aggregation.conversations
|
||
.map(
|
||
(conversation) =>
|
||
`- ${conversation.name}:${conversation.messageCount} 条,${conversation.peopleCount} 人,Evidence ${conversation.evidenceIds.join('、')}`
|
||
)
|
||
.join('\n')
|
||
return `检索范围:${plan.scopeLabel},时间:${plan.rangeLabel}
|
||
用户问题:${query}
|
||
检索意图:${aiSearchIntentLabel(plan.intent)}
|
||
检索关键词:${plan.keywords.join('、') || '未提取到主题关键词'}
|
||
检索范围消息总数:${totalMessages}
|
||
程序已确认的事实:最终 Evidence ${aggregation.messageCount} 条,涉及 ${aggregation.peopleCount} 人、${aggregation.conversationCount} 个会话。
|
||
检索覆盖:来源消息 ${retrieval.sourceMessageCount ?? '未知'} 条;候选 ${retrieval.candidateCount} 条;覆盖状态 ${retrieval.sourceCoverage};完整=${retrieval.isComplete}。候选数不等于真实聊天总数,不能据此推断用户只聊了这些消息。
|
||
${plan.intent === 'global_topic_search' ? `这是“按人物查找”问题。优先按以下人物统计作答,不要自行统计人数、会话数或消息数:\n${people || '无'}\n会话统计:\n${conversations || '无'}\n` : ''}以下是唯一允许引用的 Final Evidence。只能引用它们原样给出的 ID;不能使用其他编号:
|
||
${context}`
|
||
}
|
||
|
||
private buildRetrievalContract(
|
||
plan: AiSearchPlan,
|
||
resolvedContact: Contact | undefined,
|
||
result: KnowledgeSearchIpcResult,
|
||
candidates: AiSearchPipelineEvidence[],
|
||
agent: AiSearchAgentRun
|
||
): AiSearchRetrievalContract {
|
||
const identity = isIdentityIntent(plan.intent)
|
||
const conversationRetrieval = result.conversationRetrieval
|
||
const sourceMessageCount =
|
||
conversationRetrieval?.totalMessages ??
|
||
(identity && resolvedContact ? result.totalMessages : undefined)
|
||
const sourceCoverage = identity
|
||
? conversationRetrieval?.complete ||
|
||
(result.source === 'fallback' && Boolean(resolvedContact))
|
||
? 'complete'
|
||
: sourceMessageCount !== undefined
|
||
? 'partial'
|
||
: 'unknown'
|
||
: plan.intent === 'global_topic_search' || plan.intent === 'conversation_topic_search'
|
||
? 'keyword_match'
|
||
: 'unknown'
|
||
const isComplete = sourceCoverage === 'complete'
|
||
return {
|
||
intent: plan.intent,
|
||
conversationId: resolvedContact?.md5,
|
||
timeRange: plan.timeRange,
|
||
retrievalMode: resolvedContact
|
||
? retrievalModeForIntent(plan.intent)
|
||
: identity
|
||
? 'unresolved_identity'
|
||
: retrievalModeForIntent(plan.intent),
|
||
candidateCount: candidates.length,
|
||
sourceMessageCount,
|
||
sourceCoverage,
|
||
isComplete,
|
||
fallbackUsed: agent.mode === 'fallback' || result.source === 'fallback',
|
||
fallbackReason: agent.fallbackReason || result.fallbackReason,
|
||
suspicious:
|
||
plan.intent === 'conversation_recall' &&
|
||
Boolean(resolvedContact) &&
|
||
Boolean(sourceMessageCount && sourceMessageCount > 1) &&
|
||
candidates.length <= 1
|
||
}
|
||
}
|
||
|
||
private errorMessage(stage: AiSearchProgressEvent['stage']): string {
|
||
if (stage === 'query_understanding' || stage === 'search_plan_ready') return '无法理解搜索条件'
|
||
if (stage === 'knowledge_searching') return '本地知识库暂时无法搜索'
|
||
if (stage === 'evidence_ranking' || stage === 'evidence_ready' || stage === 'aggregation')
|
||
return '无法整理原始消息证据'
|
||
if (stage === 'ai_generating') return '证据已找到,但 AI 暂时无法生成回答'
|
||
return '搜索暂时无法完成'
|
||
}
|
||
}
|