mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 20:19:09 +08:00
218 lines
7.0 KiB
TypeScript
218 lines
7.0 KiB
TypeScript
import type {
|
|
AiSearchAggregation,
|
|
AiSearchFinalEvidence,
|
|
AiSearchPipelineEvidence
|
|
} from '../../shared/ai-search'
|
|
|
|
export type EvidenceBuildResult = {
|
|
evidence: AiSearchFinalEvidence[]
|
|
aggregation: AiSearchAggregation
|
|
candidateCount: number
|
|
deduplicatedCount: number
|
|
candidateRankingMs: number
|
|
evidenceBuildMs: number
|
|
aggregationMs: number
|
|
}
|
|
|
|
export type CitationValidationResult = {
|
|
answer: string
|
|
invalidCitationIds: string[]
|
|
status: 'valid' | 'sanitized'
|
|
}
|
|
|
|
export const evidenceIdentity = (
|
|
item: Pick<AiSearchPipelineEvidence, 'conversationId' | 'messageId'>
|
|
): string => `${item.conversationId}\u0000${item.messageId}`
|
|
|
|
const compareEvidence = (left: AiSearchPipelineEvidence, right: AiSearchPipelineEvidence): number =>
|
|
(left.score ?? 0) - (right.score ?? 0) ||
|
|
right.timestamp - left.timestamp ||
|
|
evidenceIdentity(left).localeCompare(evidenceIdentity(right))
|
|
|
|
const personIdentity = (item: AiSearchFinalEvidence): string =>
|
|
item.senderId
|
|
? `sender:${item.senderId}`
|
|
: `conversation:${item.conversationId}:name:${item.sender}`
|
|
|
|
export function buildEvidenceAggregation(evidence: AiSearchFinalEvidence[]): AiSearchAggregation {
|
|
const people = new Map<
|
|
string,
|
|
{
|
|
id: string
|
|
name: string
|
|
messageCount: number
|
|
conversationIds: Set<string>
|
|
lastMessageAt: number
|
|
evidenceIds: AiSearchFinalEvidence['id'][]
|
|
}
|
|
>()
|
|
const conversations = new Map<
|
|
string,
|
|
{
|
|
id: string
|
|
name: string
|
|
type: 'user' | 'group'
|
|
messageCount: number
|
|
people: Set<string>
|
|
lastMessageAt: number
|
|
evidenceIds: AiSearchFinalEvidence['id'][]
|
|
}
|
|
>()
|
|
|
|
for (const item of evidence) {
|
|
const personId = personIdentity(item)
|
|
const person = people.get(personId) || {
|
|
id: personId,
|
|
name: item.sender,
|
|
messageCount: 0,
|
|
conversationIds: new Set<string>(),
|
|
lastMessageAt: item.timestamp,
|
|
evidenceIds: []
|
|
}
|
|
person.messageCount += 1
|
|
person.conversationIds.add(item.conversationId)
|
|
person.lastMessageAt = Math.max(person.lastMessageAt, item.timestamp)
|
|
person.evidenceIds.push(item.id)
|
|
people.set(personId, person)
|
|
|
|
const conversation = conversations.get(item.conversationId) || {
|
|
id: item.conversationId,
|
|
name: item.conversationName,
|
|
type: item.conversationType,
|
|
messageCount: 0,
|
|
people: new Set<string>(),
|
|
lastMessageAt: item.timestamp,
|
|
evidenceIds: []
|
|
}
|
|
conversation.messageCount += 1
|
|
conversation.people.add(personId)
|
|
conversation.lastMessageAt = Math.max(conversation.lastMessageAt, item.timestamp)
|
|
conversation.evidenceIds.push(item.id)
|
|
conversations.set(item.conversationId, conversation)
|
|
}
|
|
|
|
return {
|
|
messageCount: evidence.length,
|
|
peopleCount: people.size,
|
|
conversationCount: conversations.size,
|
|
people: Array.from(people.values())
|
|
.map((person) => ({
|
|
id: person.id,
|
|
name: person.name,
|
|
messageCount: person.messageCount,
|
|
conversationCount: person.conversationIds.size,
|
|
lastMessageAt: person.lastMessageAt,
|
|
evidenceIds: person.evidenceIds
|
|
}))
|
|
.sort(
|
|
(left, right) =>
|
|
right.messageCount - left.messageCount || right.lastMessageAt - left.lastMessageAt
|
|
),
|
|
conversations: Array.from(conversations.values())
|
|
.map((conversation) => ({
|
|
id: conversation.id,
|
|
name: conversation.name,
|
|
type: conversation.type,
|
|
messageCount: conversation.messageCount,
|
|
peopleCount: conversation.people.size,
|
|
lastMessageAt: conversation.lastMessageAt,
|
|
evidenceIds: conversation.evidenceIds
|
|
}))
|
|
.sort(
|
|
(left, right) =>
|
|
right.messageCount - left.messageCount || right.lastMessageAt - left.lastMessageAt
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Performs all candidate ordering, identity de-duplication, final limiting and
|
|
* program-owned citation assignment in one place. Nothing downstream receives
|
|
* the candidate list as an AI context.
|
|
*/
|
|
export function buildFinalEvidence(
|
|
candidates: AiSearchPipelineEvidence[],
|
|
limit: number,
|
|
options?: { strategy?: 'ranked' | 'conversation_coverage' }
|
|
): EvidenceBuildResult {
|
|
const rankingStartedAt = Date.now()
|
|
const ranked = [...candidates].sort(compareEvidence)
|
|
const candidateRankingMs = Date.now() - rankingStartedAt
|
|
|
|
const evidenceStartedAt = Date.now()
|
|
const unique = new Map<string, AiSearchPipelineEvidence>()
|
|
for (const item of ranked) {
|
|
const identity = evidenceIdentity(item)
|
|
if (!unique.has(identity)) unique.set(identity, item)
|
|
}
|
|
const uniqueEvidence = Array.from(unique.values())
|
|
const selected =
|
|
options?.strategy === 'conversation_coverage'
|
|
? selectConversationCoverage(uniqueEvidence, limit)
|
|
: uniqueEvidence.slice(0, Math.max(1, limit))
|
|
const evidence = selected.map((item, index) => ({ ...item, id: `E${index + 1}` as const }))
|
|
const evidenceBuildMs = Date.now() - evidenceStartedAt
|
|
|
|
const aggregationStartedAt = Date.now()
|
|
const aggregation = buildEvidenceAggregation(evidence)
|
|
const aggregationMs = Date.now() - aggregationStartedAt
|
|
|
|
return {
|
|
evidence,
|
|
aggregation,
|
|
candidateCount: candidates.length,
|
|
deduplicatedCount: unique.size,
|
|
candidateRankingMs,
|
|
evidenceBuildMs,
|
|
aggregationMs
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A recent-conversation answer should cover separate local conversation chunks,
|
|
* not merely pick eight adjacent newest messages from one exchange.
|
|
*/
|
|
function selectConversationCoverage(
|
|
evidence: AiSearchPipelineEvidence[],
|
|
limit: number
|
|
): AiSearchPipelineEvidence[] {
|
|
const max = Math.max(1, limit)
|
|
const byChunk = new Map<string, AiSearchPipelineEvidence[]>()
|
|
for (const item of evidence) {
|
|
const chunk = byChunk.get(item.chunkId) || []
|
|
chunk.push(item)
|
|
byChunk.set(item.chunkId, chunk)
|
|
}
|
|
const representatives = Array.from(byChunk.values())
|
|
.map((items) => [...items].sort(compareEvidence)[0])
|
|
.sort((left, right) => left.timestamp - right.timestamp)
|
|
if (representatives.length <= max) return representatives
|
|
const selected: AiSearchPipelineEvidence[] = []
|
|
for (let index = 0; index < max; index += 1) {
|
|
const position = Math.round((index * (representatives.length - 1)) / (max - 1 || 1))
|
|
const item = representatives[position]
|
|
if (item && !selected.includes(item)) selected.push(item)
|
|
}
|
|
return selected
|
|
}
|
|
|
|
/** Do not expose citations that cannot resolve to program-owned Final Evidence. */
|
|
export function sanitizeAnswerCitations(
|
|
answer: string,
|
|
evidence: Array<Pick<AiSearchFinalEvidence, 'id'>>
|
|
): CitationValidationResult {
|
|
const allowed = new Set(evidence.map((item) => item.id))
|
|
const invalidCitationIds = new Set<string>()
|
|
const sanitized = answer.replace(/\[E(\d+)\]/g, (citation, number: string) => {
|
|
const id = `E${number}`
|
|
if (allowed.has(id as AiSearchFinalEvidence['id'])) return citation
|
|
invalidCitationIds.add(id)
|
|
return ''
|
|
})
|
|
return {
|
|
answer: sanitized,
|
|
invalidCitationIds: Array.from(invalidCitationIds),
|
|
status: invalidCitationIds.size ? 'sanitized' : 'valid'
|
|
}
|
|
}
|