Files
WechatExplorer/src/shared/knowledge.ts
T

552 lines
19 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.
/**
* Contracts for the local, derived knowledge base. These values deliberately
* contain no WCDB handles, Electron objects, database keys, or UI state so the
* indexer can run in an isolated process.
*/
export const KNOWLEDGE_SCHEMA_VERSION = 1
export const DEFAULT_CHUNKER_VERSION = 'conversation-v1'
export type KnowledgeMessageKind = 'text' | 'voice' | 'file' | 'link' | 'image' | 'video' | 'sticker' | 'system' | 'other'
export type KnowledgeIndexPhase =
| 'idle'
| 'preflight'
| 'indexing'
| 'ready'
| 'cancelled'
| 'error'
export type KnowledgeTemporalIntent = 'none' | 'current' | 'historical' | 'timeline'
export type KnowledgeFtsTokenizer = 'unicode61' | 'trigram'
export type KnowledgeFtsContentMode = 'external' | 'internal'
export type KnowledgeFtsDetail = 'full' | 'column' | 'none'
export interface KnowledgeAttachmentMetadata {
name: string
kind?: 'file' | 'link' | 'image' | 'video' | 'other'
url?: string
sizeBytes?: number
}
/** A read-only source record prepared by the future WCDB adapter. */
export interface KnowledgeSourceMessage {
accountId: string
conversationId: string
messageId: string
/** Unix epoch milliseconds. Adapters must convert source-specific units. */
createTime: number
senderId?: string
senderName?: string
kind: KnowledgeMessageKind
text?: string
attachment?: KnowledgeAttachmentMetadata
voiceTranscript?: string
/** Local coverage state only. Error text is never copied into the index. */
voiceTranscriptState?: 'pending' | 'transcribed' | 'failed'
/**
* 图片里的文字(本地 System OCR 的派生结果)。
*
* 与 voiceTranscript 同构:这是 **derived content**,原图片消息仍然是
* authoritative source。它绝不写回 message.body,也绝不产生"OCR 消息"。
*/
imageOcrText?: string
/** 图片 OCR 的本地状态;与 voiceTranscriptState 一样不含错误正文。 */
imageOcrState?: KnowledgeImageOcrState
}
/** 图片 OCR 的本地覆盖状态(错误详情绝不进索引)。 */
export type KnowledgeImageOcrState =
| 'pending'
| 'indexed'
| 'empty'
| 'metadata_missing'
| 'image_missing'
| 'decrypt_unavailable'
| 'decrypt_failed'
| 'decode_failed'
| 'ocr_failed'
| 'cancelled'
export interface KnowledgeNormalizedMessage extends KnowledgeSourceMessage {
searchableText: string
contentHash: string
}
export interface KnowledgeChunkerConfig {
version: string
maxGapMs: number
maxMessages: number
maxCharacters: number
overlapMessages: number
}
export interface KnowledgeChunk {
chunkId: string
accountId: string
conversationId: string
startTime: number
endTime: number
text: string
messageIds: string[]
participantIds: string[]
messageKinds: KnowledgeMessageKind[]
contentHash: string
chunkerVersion: string
}
/**
* Every FTS choice is explicit. The first production profile must be selected
* from the Task 0 report rather than being silently hard-coded in the UI.
*/
export interface KnowledgeFtsConfig {
profileId: string
tokenizer: KnowledgeFtsTokenizer
contentMode: KnowledgeFtsContentMode
detail: KnowledgeFtsDetail
columnsize: 0 | 1
}
/**
* Chosen after the realistic desensitized WeChat benchmark: trigram preserves
* Chinese-substring recall while external content avoids a second text copy.
*/
export const DEFAULT_KNOWLEDGE_FTS_CONFIG: KnowledgeFtsConfig = {
profileId: 'trigram-external-full-columnsize-v1',
tokenizer: 'trigram',
contentMode: 'external',
detail: 'full',
columnsize: 1
}
export interface KnowledgeConversationInput {
conversationId: string
/** true means this is a complete read-only snapshot of the conversation. */
completeSnapshot: boolean
messages: KnowledgeSourceMessage[]
/**
* 这个会话在**源侧**(WCDB Session.last_timestamp)已经覆盖到的最后活跃时间(epoch ms)。
*
* 与 `KnowledgeIndexRequest.sourceLatestAt` 的区别:后者是整遍 pass 级别的边界,这里是
* per-conversation checkpoint —— 增量 pass 用它判断「这个会话有没有新消息」,
* 从而跳过整个会话(不读 WCDB、不传 IPC、不写索引)。
*
* 记录**源侧**时间而不是「索引里最后一条可建模消息的时间」:否则最后一条恰好落在
* 图片/空正文上的会话会永远被判定为「有新消息」,增量永远跳不过它。
*/
sourceHighWaterTime?: number
}
export interface KnowledgeIndexRequest {
accountId: string
databaseRoot: string
conversations: KnowledgeConversationInput[]
chunker: KnowledgeChunkerConfig
fts: KnowledgeFtsConfig
/** Written only after a complete source pass; used for truthful coverage. */
sourceMessageCount?: number
/**
* 这一遍完整 pass 实际扫到的源数据最新消息时间(epoch ms)。
*
* 与 `sourceMessageCount` 一样,只在读完全部会话的那一次写入。它是 freshness 的权威口径:
* 拿它与当前源数据最新活跃时间比较,就能确定索引是否已经追上,而不必猜测派生索引的过滤落差。
*/
sourceLatestAt?: number
}
export interface KnowledgeIndexProgress {
accountId: string
phase: KnowledgeIndexPhase
conversationId?: string
processedMessages: number
totalMessages: number
indexedChunks: number
error?: string
}
export interface KnowledgeIndexResult {
accountId: string
processedMessages: number
indexedChunks: number
updatedChunks: number
unchangedConversations: number
databaseBytes: number
walBytes: number
elapsedMs: number
cancelled: boolean
}
export interface KnowledgeCapacityPreflightRequest {
accountId: string
databaseRoot: string
conversations: KnowledgeConversationInput[]
chunker: KnowledgeChunkerConfig
/** Optional free space supplied by the platform layer; this module never probes WCDB paths. */
availableDiskBytes?: number
}
export interface KnowledgeCapacityPreflight {
accountId: string
sourceMessageCount: number
indexableMessageCount: number
indexableTextBytes: number
voiceTranscriptCount: number
attachmentMetadataCount: number
sampledChunkCount: number
estimatedChunkCount: number
estimatedDatabaseBytesLow: number
estimatedDatabaseBytesHigh: number
estimatedBuildPeakBytesLow: number
estimatedBuildPeakBytesHigh: number
availableDiskBytes?: number
hasSufficientDiskSpace?: boolean
warnings: string[]
}
export interface KnowledgeEvidence {
chunkId: string
conversationId: string
startTime: number
endTime: number
/** Stable source-message identity used by the archive jump action. */
messageId: string
senderId?: string
sender: string
/** Unix epoch milliseconds. */
timestamp: number
messageIds: string[]
/** The source type belongs to the original message, not the retrieval method. */
sourceKind: KnowledgeMessageKind
text: string
/**
* 这条证据里「从图片里读出来的文字」(本地 System OCR 的派生结果)。
*
* 只用于**来源解释**:让用户/模型知道这段内容来自图片,而不是群友真的发了一条文字消息。
* authoritative source 始终是原始图片消息 —— 这里不产生任何"OCR 消息"。
*/
imageOcrText?: string
/**
* 命中所依赖的**派生来源**。
*
* 有值 = 这条结果依赖本地派生内容才能命中(而不是原始消息本身的文字)。
* 与 `sourceKind` 正交:`sourceKind` 说的是原始消息是什么,这里说的是"靠什么搜到的"。
*/
derivedSource?: 'image_ocr'
score?: number
}
/**
* 证据文本面向用户 / 模型时的可读化处理。
*
* `searchableText` 里的 `图片文字:` 只是索引期用来区分派生内容的内部标签,
* 它**绝不能出现在 Evidence 里**:用户不该看到引擎内部前缀,
* 而且"这段文字来自图片"应该由结构化的来源标记表达,而不是靠一个冒号前缀。
*/
export function toEvidenceDisplayText(searchableText: string): string {
return searchableText
.split('\n')
.map((line) => line.replace(/^\s*(?:图片文字|OCR|system-ocr)\s*[::]\s*/i, ''))
.join('\n')
.trim()
}
export interface KnowledgeVoiceCoverage {
voiceMessageCount: number
transcribedVoiceCount: number
failedVoiceCount: number
voiceCoverageComplete: boolean
}
/** A bounded, local summary of a single conversation retrieval. */
export interface KnowledgeConversationRetrieval {
conversationId: string
totalMessages: number
chunkCount: number
candidateMessages: number
systemMessagesDeprioritized: number
complete: boolean
}
export interface KnowledgeQuery {
accountId: string
text: string
/** Query-router terms. The raw question remains available for diagnostics. */
terms?: string[]
limit: number
conversationId?: string
conversationIds?: string[]
senderIds?: string[]
/** Unix epoch milliseconds. */
startTime?: number
/** Unix epoch milliseconds. */
endTime?: number
temporalIntent?: KnowledgeTemporalIntent
conversationBoundary?: 'first' | 'last'
}
export interface KnowledgeSearchRequest extends KnowledgeQuery {
databaseRoot: string
fts: KnowledgeFtsConfig
}
export type KnowledgeSearchState = 'unavailable' | 'indexing' | 'ready'
/** Measured in the Worker; never inferred from message counts or UI timers. */
export interface KnowledgeSearchTimings {
/** Parent/child-process transport and host scheduling outside SQLite work. */
workerIpcMs: number
/** First request only: child process spawn and Node initialization until it received the request. */
workerBootMs: number
/** Parent send → Worker handler start. */
dispatchMs: number
/** Worker local SQLite/chunk work; equals the Worker-side search total. */
workerSqlMs: number
/** Worker response preparation → parent receipt; includes IPC serialization/transfer. */
responseTransferMs: number
/** Worker-side serialization preflight for the result payload. */
responseSerializeMs: number
/** FTS (or short-term database lookup) query time. */
ftsMs: number
/**
* `ftsMs` 中**短词回退路径**(<3 字的中文词,走 `LIKE` 而非 FTS `MATCH`)占用的时间。
*
* 跨会话 lexical probe 里最常见的 2 字中文词只能走这条路径,没有这个分解就无法区分
* 「FTS MATCH 慢」和「短词全表 LIKE 慢」。
*/
shortTermSearchMs?: number
/**
* `getSearchStatus()`(含统计快照判定)占用的时间。
* 用于验证「搜索热路径不再做全表聚合」这一性能约束没有被回退。
*/
statusMs?: number
/** Reading source message rows from matching chunks. */
messageLoadMs: number
/** Expanding chunk members, scoring terms and per-chunk de-duplication. */
chunkExpandMs: number
/** Final result ordering and limit application. */
rankingMs: number
/** Worker-side local search total. */
totalMs: number
/** Time spent refreshing a stale on-disk statistics snapshot. */
globalCountMs?: number
/** Voice coverage aggregation time for this query. */
voiceCoverageMs?: number
/** Full Worker handler execution, including status and coverage bookkeeping. */
workerExecutionMs?: number
/** Worker queue wait in the main-process host, when observable. */
workerQueueMs?: number
/** Main-process WCDB FIFO wait, when observable. */
wcdbQueueMs?: number
/** Main-process WCDB operation execution, when observable. */
wcdbExecutionMs?: number
/** Main-process sender/contact enrichment duration. */
senderEnrichmentMs?: number
/** Main-process IPC/transport duration, when separated from Worker execution. */
ipcMs?: number
/** Serialization/encoding duration outside the Worker SQL timer. */
serializationMs?: number
/** Other unclassified waiting in the retrieval path. */
otherMs?: number
}
export const emptyKnowledgeSearchTimings = (): KnowledgeSearchTimings => ({
workerIpcMs: 0,
workerBootMs: 0,
dispatchMs: 0,
workerSqlMs: 0,
responseTransferMs: 0,
responseSerializeMs: 0,
ftsMs: 0,
messageLoadMs: 0,
chunkExpandMs: 0,
rankingMs: 0,
totalMs: 0
})
export interface KnowledgeSearchResult {
state: KnowledgeSearchState
evidence: KnowledgeEvidence[]
indexedMessageCount: number
indexedChunkCount: number
/**
* 派生索引里最新的消息时间(epoch ms);null 表示无法判定。
*
* `state: 'ready'` 只说明「这个派生库可以被查询」,**不等于**它已经追到源数据最新位置。
* 调用方必须把它与请求的时间范围比较,才能判断本次检索是否覆盖了用户问的时间。
*/
indexLatestAt: number | null
timings: KnowledgeSearchTimings
conversationRetrieval?: KnowledgeConversationRetrieval
voiceCoverage?: KnowledgeVoiceCoverage
}
/** Renderer-facing request. Chat timestamps use Unix seconds in the existing UI. */
export interface KnowledgeSearchIpcRequest {
text: string
terms: string[]
/** Bounded request/session cache key for sender enrichment only. */
retrievalSessionId?: string
conversationIds?: string[]
senderIds?: string[]
startTime?: number
endTime?: number
conversationBoundary?: 'first' | 'last'
limit?: number
}
export interface KnowledgeSearchIpcResult extends KnowledgeSearchResult {
source: 'knowledge' | 'fallback'
totalMessages: number
fallbackReason?: 'unavailable' | 'indexing' | 'error'
/**
* 源数据(WCDB Session)里最新的活跃时间(epoch ms);null 表示无法判定。
*
* 与 `indexLatestAt` 一起构成 freshness 判据:`sourceLatestAt > indexLatestAt`
* 说明源数据里已经有了索引还没覆盖的内容。
*/
sourceLatestAt: number | null
}
export type KnowledgeRuntimeState = 'unavailable' | 'building' | 'syncing' | 'ready' | 'error' | 'cancelled'
/**
* 一次后台索引 pass 的真实进度(ADDITIVE)。
*
* 与新鲜度(`indexLatestAt` / `sourceLatestAt`)回答的是不同问题:这里描述这一遍**在做什么、
* 扫了多少、真正写了多少**。
*/
export interface KnowledgePassProgress {
/**
* 这一遍在做什么:
* - `full` 首次全量建立(还没有任何可用分片)
* - `catchup` 追最新:已建立 checkpoint 的会话出现了新消息
* - `backfill` 补历史:从来没有 checkpoint 的会话(历史缺口)
* - `cancelled` / `error` / `idle`
*/
phase: 'idle' | 'full' | 'catchup' | 'backfill' | 'cancelled' | 'error'
/** 现在是否可以取消(真实在跑且未收到取消请求时才是 true)。 */
cancellable: boolean
/** 开始时间(epoch ms)。 */
startedAt: number
/** 这一遍从 WCDB **读取/扫描**的源消息条数(含被可索引性过滤掉的)。 */
scannedMessages: number
/** 这一遍真正**进入索引**的源消息条数。 */
indexedMessages: number
processedConversations: number
totalConversations: number
/** 因为「没有任何新消息」而整段跳过的会话数(增量 pass 的核心指标)。 */
skippedConversations: number
/** 需要「追最新」的会话数(已建立 checkpoint,且源侧有更新)。 */
catchupConversations: number
/** 需要「补历史」的会话数(从来没有 checkpoint,必须整段读)。 */
backfillConversations: number
/** 这一遍已经补齐的历史会话数。 */
backfillCompletedConversations: number
/** 主线程 event loop 在这一遍期间的最大滞后。用于验证重活没有压在 Main 上。 */
mainLoopLagMs: number
}
export interface KnowledgeRuntimeStatus {
accountId: string
state: KnowledgeRuntimeState
indexedMessageCount: number
indexedChunkCount: number
/** Null means this source pass has not yet counted every source message. */
sourceMessageCount: number | null
processedMessages: number
totalMessages: number | null
currentConversationId?: string
/** Null is displayed as unavailable rather than a fabricated ETA. */
estimatedRemainingMs: number | null
databaseBytes: number
walBytes: number
shmBytes: number
lastError?: string
/**
* 派生索引里最新的消息时间(epoch ms);null 表示无法判定。
* `state: 'ready'`(READY)与「已追到源数据最新」(FRESH)是两个概念,它就是两者的判据之一。
*/
indexLatestAt: number | null
/** 源数据(WCDB Session)最新活跃时间(epoch ms);null 表示无法判定。 */
sourceLatestAt: number | null
/**
* 当前/最近一次后台索引 pass 的真实进度(ADDITIVE)。
*
* 与 `indexLatestAt` 是**两个不同维度**:前者回答"这一遍在补什么、进度多少",
* 后者回答"索引已经覆盖到源数据的哪个时刻"。UI 不能再用一个「已同步」把两者混为一谈。
*/
pass?: KnowledgePassProgress
}
export interface KnowledgeStatusRequest {
accountId: string
databaseRoot: string
fts: KnowledgeFtsConfig
}
/**
* 索引「已覆盖到的源数据时间」与源数据「当前最新活跃时间」之间允许的固定落差。
*
* 两侧都来自消息的 create_time(索引侧 = 完整 pass 扫到的最大 create_time,
* 源侧 = Session 行的 last_timestamp),用于吸收 Session 元数据晚于消息落库的漂移。
* main 与 renderer 共用这一份定义,避免 UI 与 Engine 的 freshness 口径漂移。
*/
export const KNOWLEDGE_FRESHNESS_TOLERANCE_MS = 60 * 1000
/**
* 索引是否已经追到源数据最新(FRESH)。
*
* 注意与 `state === 'ready'`(READY:这个派生库**可以被查询**)区分:
* READY 不代表 FRESH。任一侧口径缺失时返回 null 表示无法判定。
*/
export function isKnowledgeFresh(
status: Pick<KnowledgeRuntimeStatus, 'indexLatestAt' | 'sourceLatestAt'>
): boolean | null {
if (status.indexLatestAt === null || status.sourceLatestAt === null) return null
return status.indexLatestAt + KNOWLEDGE_FRESHNESS_TOLERANCE_MS >= status.sourceLatestAt
}
export interface KnowledgeWorkerRequest {
version: 1
type: 'index' | 'preflight' | 'search' | 'status' | 'remove' | 'cancel' | 'close' | 'highWater'
requestId: string
/** Parent monotonic wall-clock used only for transport timing. */
sentAt?: number
payload:
| KnowledgeIndexRequest
| KnowledgeCapacityPreflightRequest
| KnowledgeSearchRequest
| KnowledgeStatusRequest
| { accountId: string; databaseRoot: string }
| { targetRequestId: string }
| Record<string, never>
}
export interface KnowledgeWorkerResponse {
version: 1
type: 'progress' | 'result' | 'error'
requestId: string
payload?:
| KnowledgeIndexProgress
| KnowledgeIndexResult
| KnowledgeCapacityPreflight
| KnowledgeSearchResult
| KnowledgeRuntimeStatus
| { removed: true }
error?: string
transport?: {
/** IPC message arrival timestamp in the Worker event loop. */
messageReceivedAt?: number
workerReceivedAt: number
workerCompletedAt: number
responseSerializeMs: number
workerQueueMs?: number
}
}
export const DEFAULT_KNOWLEDGE_CHUNKER: KnowledgeChunkerConfig = {
version: DEFAULT_CHUNKER_VERSION,
maxGapMs: 10 * 60 * 1000,
maxMessages: 12,
maxCharacters: 1200,
overlapMessages: 3
}