mirror of
https://github.com/ILoveBingLu/CipherTalk.git
synced 2026-05-24 21:40:21 +08:00
78dd50de17
兼容最新版Deepseek
936 lines
28 KiB
TypeScript
936 lines
28 KiB
TypeScript
import Database from 'better-sqlite3'
|
|
import { existsSync, mkdirSync } from 'fs'
|
|
import { join, dirname } from 'path'
|
|
import type {
|
|
AnalysisBlock,
|
|
ExtractedStructuredAnalysis,
|
|
StructuredAnalysis,
|
|
SummaryEvidenceRef
|
|
} from '../ai-agent/types/analysis'
|
|
import { parseStoredStructuredAnalysis } from '../ai-agent/types/analysis'
|
|
|
|
type AnalysisRunStatus =
|
|
| 'completed'
|
|
| 'fallback_legacy'
|
|
| 'backfill_facts_only'
|
|
| 'legacy_placeholder'
|
|
|
|
type AnalysisSourceKind =
|
|
| 'generate_summary'
|
|
| 'generate_summary_legacy'
|
|
| 'backfill_structured'
|
|
| 'backfill_legacy'
|
|
|
|
export interface SaveAnalysisArtifactsInput {
|
|
summaryId: number
|
|
sessionId: string
|
|
timeRangeStart: number
|
|
timeRangeEnd: number
|
|
timeRangeDays: number
|
|
rawMessageCount: number
|
|
effectiveMessageCount?: number
|
|
blockCount: number
|
|
provider: string
|
|
model: string
|
|
status: AnalysisRunStatus
|
|
sourceKind: AnalysisSourceKind
|
|
evidenceResolved: boolean
|
|
blocksAvailable: boolean
|
|
blocks?: AnalysisBlock[]
|
|
blockAnalyses?: ExtractedStructuredAnalysis[]
|
|
finalAnalysis?: StructuredAnalysis
|
|
createdAt?: number
|
|
updatedAt?: number
|
|
}
|
|
|
|
interface SummaryBackfillRow {
|
|
id: number
|
|
session_id: string
|
|
time_range_start: number
|
|
time_range_end: number
|
|
time_range_days: number
|
|
message_count: number
|
|
provider: string
|
|
model: string
|
|
created_at: number
|
|
structured_result_json?: string | null
|
|
}
|
|
|
|
interface FactRecord {
|
|
factType: 'topic' | 'decision' | 'todo' | 'risk' | 'event' | 'open_question'
|
|
factKey: string
|
|
sortOrder: number
|
|
displayText: string
|
|
confidence?: number
|
|
importance?: number
|
|
severity?: string
|
|
owner?: string
|
|
deadline?: string
|
|
status?: string
|
|
eventDate?: string
|
|
payloadJson: string
|
|
evidenceRefs: SummaryEvidenceRef[]
|
|
}
|
|
|
|
const ANALYSIS_STORAGE_BOOTSTRAP_KEY = 'analysis_storage_bootstrap_v1_completed'
|
|
|
|
function normalizeComparableText(value?: string): string {
|
|
return String(value || '')
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/\s+/g, '')
|
|
.replace(/[,。!?;:、“”‘’()【】《》,.!?:;"'()\-_/\\[\]{}]+/g, '')
|
|
}
|
|
|
|
function hasAnyEvidenceRefs(analysis?: StructuredAnalysis): boolean {
|
|
if (!analysis) return false
|
|
|
|
return [
|
|
...analysis.decisions,
|
|
...analysis.todos,
|
|
...analysis.risks,
|
|
...analysis.events
|
|
].some((item) => item.evidenceRefs.length > 0)
|
|
}
|
|
|
|
function buildTopicFactRecords(analysis: StructuredAnalysis): FactRecord[] {
|
|
return analysis.topics.map((item, index) => ({
|
|
factType: 'topic',
|
|
factKey: normalizeComparableText(item.name),
|
|
sortOrder: index,
|
|
displayText: item.name,
|
|
importance: item.importance,
|
|
payloadJson: JSON.stringify(item),
|
|
evidenceRefs: []
|
|
}))
|
|
}
|
|
|
|
function buildDecisionFactRecords(analysis: StructuredAnalysis): FactRecord[] {
|
|
return analysis.decisions.map((item, index) => ({
|
|
factType: 'decision',
|
|
factKey: normalizeComparableText(item.text),
|
|
sortOrder: index,
|
|
displayText: item.text,
|
|
confidence: item.confidence,
|
|
payloadJson: JSON.stringify(item),
|
|
evidenceRefs: item.evidenceRefs
|
|
}))
|
|
}
|
|
|
|
function buildTodoFactRecords(analysis: StructuredAnalysis): FactRecord[] {
|
|
return analysis.todos.map((item, index) => ({
|
|
factType: 'todo',
|
|
factKey: `${normalizeComparableText(item.owner)}|${normalizeComparableText(item.task)}|${normalizeComparableText(item.deadline)}`,
|
|
sortOrder: index,
|
|
displayText: item.task,
|
|
confidence: item.confidence,
|
|
owner: item.owner,
|
|
deadline: item.deadline,
|
|
status: item.status,
|
|
payloadJson: JSON.stringify(item),
|
|
evidenceRefs: item.evidenceRefs
|
|
}))
|
|
}
|
|
|
|
function buildRiskFactRecords(analysis: StructuredAnalysis): FactRecord[] {
|
|
return analysis.risks.map((item, index) => ({
|
|
factType: 'risk',
|
|
factKey: normalizeComparableText(item.text),
|
|
sortOrder: index,
|
|
displayText: item.text,
|
|
confidence: item.confidence,
|
|
severity: item.severity,
|
|
payloadJson: JSON.stringify(item),
|
|
evidenceRefs: item.evidenceRefs
|
|
}))
|
|
}
|
|
|
|
function buildEventFactRecords(analysis: StructuredAnalysis): FactRecord[] {
|
|
return analysis.events.map((item, index) => ({
|
|
factType: 'event',
|
|
factKey: `${normalizeComparableText(item.text)}|${normalizeComparableText(item.date)}`,
|
|
sortOrder: index,
|
|
displayText: item.text,
|
|
confidence: item.confidence,
|
|
eventDate: item.date,
|
|
payloadJson: JSON.stringify(item),
|
|
evidenceRefs: item.evidenceRefs
|
|
}))
|
|
}
|
|
|
|
function buildOpenQuestionFactRecords(analysis: StructuredAnalysis): FactRecord[] {
|
|
return analysis.openQuestions.map((item, index) => ({
|
|
factType: 'open_question',
|
|
factKey: normalizeComparableText(item.text),
|
|
sortOrder: index,
|
|
displayText: item.text,
|
|
payloadJson: JSON.stringify(item),
|
|
evidenceRefs: []
|
|
}))
|
|
}
|
|
|
|
function buildFactRecords(analysis?: StructuredAnalysis): FactRecord[] {
|
|
if (!analysis) {
|
|
return []
|
|
}
|
|
|
|
return [
|
|
...buildTopicFactRecords(analysis),
|
|
...buildDecisionFactRecords(analysis),
|
|
...buildTodoFactRecords(analysis),
|
|
...buildRiskFactRecords(analysis),
|
|
...buildEventFactRecords(analysis),
|
|
...buildOpenQuestionFactRecords(analysis)
|
|
].filter((item) => item.factKey && item.displayText)
|
|
}
|
|
|
|
/**
|
|
* AI 专用数据库管理
|
|
*/
|
|
export class AIDatabase {
|
|
private db: Database.Database | null = null
|
|
private dbPath: string | null = null
|
|
|
|
/**
|
|
* 初始化数据库
|
|
*/
|
|
init(cachePath: string, wxid: string): void {
|
|
void wxid
|
|
|
|
this.dbPath = join(cachePath, 'ai_summary.db')
|
|
|
|
const dir = dirname(this.dbPath)
|
|
if (!existsSync(dir)) {
|
|
mkdirSync(dir, { recursive: true })
|
|
}
|
|
|
|
this.db = new Database(this.dbPath)
|
|
this.createTables()
|
|
|
|
try {
|
|
this.bootstrapAnalysisStorageBackfill()
|
|
this.catchUpMissingAnalysisRuns()
|
|
} catch (error) {
|
|
console.warn('[AIDatabase] 分析存储回填初始化失败,将在后续启动继续补录:', error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 创建表结构
|
|
*/
|
|
private createTables(): void {
|
|
if (!this.db) return
|
|
|
|
this.db.exec(`
|
|
CREATE TABLE IF NOT EXISTS summaries (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT NOT NULL,
|
|
time_range_start INTEGER NOT NULL,
|
|
time_range_end INTEGER NOT NULL,
|
|
time_range_days INTEGER NOT NULL,
|
|
message_count INTEGER NOT NULL,
|
|
summary_text TEXT NOT NULL,
|
|
tokens_used INTEGER,
|
|
cost REAL,
|
|
provider TEXT NOT NULL,
|
|
model TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_summaries_session ON summaries(session_id);
|
|
CREATE INDEX IF NOT EXISTS idx_summaries_created ON summaries(created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_summaries_time_range ON summaries(time_range_start, time_range_end);
|
|
|
|
CREATE TABLE IF NOT EXISTS usage_stats (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
date TEXT NOT NULL,
|
|
provider TEXT NOT NULL,
|
|
model TEXT,
|
|
total_tokens INTEGER DEFAULT 0,
|
|
total_cost REAL DEFAULT 0,
|
|
request_count INTEGER DEFAULT 0,
|
|
UNIQUE(date, provider, model)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_usage_date ON usage_stats(date);
|
|
|
|
CREATE TABLE IF NOT EXISTS summary_cache (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
cache_key TEXT UNIQUE NOT NULL,
|
|
summary_id INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL,
|
|
FOREIGN KEY (summary_id) REFERENCES summaries(id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_cache_key ON summary_cache(cache_key);
|
|
CREATE INDEX IF NOT EXISTS idx_cache_expires ON summary_cache(expires_at);
|
|
|
|
CREATE TABLE IF NOT EXISTS db_meta (
|
|
meta_key TEXT PRIMARY KEY,
|
|
meta_value TEXT NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS analysis_runs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
summary_id INTEGER NOT NULL,
|
|
session_id TEXT NOT NULL,
|
|
time_range_start INTEGER NOT NULL,
|
|
time_range_end INTEGER NOT NULL,
|
|
time_range_days INTEGER NOT NULL,
|
|
raw_message_count INTEGER NOT NULL,
|
|
effective_message_count INTEGER,
|
|
block_count INTEGER NOT NULL DEFAULT 0,
|
|
provider TEXT NOT NULL,
|
|
model TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
source_kind TEXT NOT NULL,
|
|
evidence_resolved INTEGER NOT NULL DEFAULT 0,
|
|
blocks_available INTEGER NOT NULL DEFAULT 0,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_analysis_runs_summary ON analysis_runs(summary_id);
|
|
CREATE INDEX IF NOT EXISTS idx_analysis_runs_session ON analysis_runs(session_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS analysis_blocks (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
run_id INTEGER NOT NULL,
|
|
block_index INTEGER NOT NULL,
|
|
block_id TEXT NOT NULL,
|
|
start_time INTEGER NOT NULL,
|
|
end_time INTEGER NOT NULL,
|
|
message_count INTEGER NOT NULL,
|
|
char_count INTEGER NOT NULL,
|
|
rendered_text TEXT NOT NULL,
|
|
messages_json TEXT NOT NULL,
|
|
extracted_result_json TEXT
|
|
);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_analysis_blocks_run_block ON analysis_blocks(run_id, block_index);
|
|
CREATE INDEX IF NOT EXISTS idx_analysis_blocks_run ON analysis_blocks(run_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS extracted_facts (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
run_id INTEGER NOT NULL,
|
|
fact_type TEXT NOT NULL,
|
|
fact_key TEXT NOT NULL,
|
|
sort_order INTEGER NOT NULL,
|
|
display_text TEXT NOT NULL,
|
|
confidence REAL,
|
|
importance REAL,
|
|
severity TEXT,
|
|
owner TEXT,
|
|
deadline TEXT,
|
|
status TEXT,
|
|
event_date TEXT,
|
|
payload_json TEXT NOT NULL
|
|
);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_extracted_facts_run_type_key ON extracted_facts(run_id, fact_type, fact_key);
|
|
CREATE INDEX IF NOT EXISTS idx_extracted_facts_run_sort ON extracted_facts(run_id, fact_type, sort_order);
|
|
|
|
CREATE TABLE IF NOT EXISTS evidence_links (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
fact_id INTEGER NOT NULL,
|
|
run_id INTEGER NOT NULL,
|
|
evidence_order INTEGER NOT NULL,
|
|
session_id TEXT NOT NULL,
|
|
local_id INTEGER NOT NULL,
|
|
create_time INTEGER NOT NULL,
|
|
sort_seq INTEGER NOT NULL,
|
|
sender_username TEXT,
|
|
sender_display_name TEXT,
|
|
preview_text TEXT NOT NULL
|
|
);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_evidence_links_fact_message ON evidence_links(fact_id, local_id, create_time, sort_seq);
|
|
CREATE INDEX IF NOT EXISTS idx_evidence_links_run ON evidence_links(run_id);
|
|
`)
|
|
|
|
try {
|
|
this.db.exec("ALTER TABLE summaries ADD COLUMN prompt_text TEXT")
|
|
} catch (e) {
|
|
// 忽略错误,列已存在
|
|
}
|
|
|
|
try {
|
|
this.db.exec("ALTER TABLE summaries ADD COLUMN custom_name TEXT")
|
|
} catch (e) {
|
|
// 忽略错误,列已存在
|
|
}
|
|
|
|
try {
|
|
this.db.exec("ALTER TABLE summaries ADD COLUMN structured_result_json TEXT")
|
|
} catch (e) {
|
|
// 忽略错误,列已存在
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 获取数据库实例
|
|
*/
|
|
getDb(): Database.Database {
|
|
if (!this.db) {
|
|
throw new Error('数据库未初始化')
|
|
}
|
|
return this.db
|
|
}
|
|
|
|
/**
|
|
* 保存摘要
|
|
*/
|
|
saveSummary(summary: {
|
|
sessionId: string
|
|
timeRangeStart: number
|
|
timeRangeEnd: number
|
|
timeRangeDays: number
|
|
messageCount: number
|
|
summaryText: string
|
|
tokensUsed: number
|
|
cost: number
|
|
provider: string
|
|
model: string
|
|
promptText?: string
|
|
structuredResultJson?: string
|
|
createdAt?: number
|
|
}): number {
|
|
const db = this.getDb()
|
|
const createdAt = summary.createdAt || Date.now()
|
|
|
|
console.log('[AIDatabase] 保存摘要:', {
|
|
sessionId: summary.sessionId,
|
|
timeRangeDays: summary.timeRangeDays,
|
|
messageCount: summary.messageCount,
|
|
provider: summary.provider,
|
|
model: summary.model
|
|
})
|
|
|
|
const result = db.prepare(`
|
|
INSERT INTO summaries (
|
|
session_id, time_range_start, time_range_end, time_range_days,
|
|
message_count, summary_text, tokens_used, cost,
|
|
provider, model, created_at, prompt_text, structured_result_json
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
summary.sessionId,
|
|
summary.timeRangeStart,
|
|
summary.timeRangeEnd,
|
|
summary.timeRangeDays,
|
|
summary.messageCount,
|
|
summary.summaryText,
|
|
summary.tokensUsed,
|
|
summary.cost,
|
|
summary.provider,
|
|
summary.model,
|
|
createdAt,
|
|
summary.promptText || '',
|
|
summary.structuredResultJson || null
|
|
)
|
|
|
|
console.log('[AIDatabase] 摘要已保存,ID:', result.lastInsertRowid)
|
|
|
|
return result.lastInsertRowid as number
|
|
}
|
|
|
|
saveAnalysisArtifacts(input: SaveAnalysisArtifactsInput): void {
|
|
const db = this.getDb()
|
|
|
|
const saveTx = db.transaction((payload: SaveAnalysisArtifactsInput) => {
|
|
const createdAt = payload.createdAt || Date.now()
|
|
const updatedAt = payload.updatedAt || createdAt
|
|
|
|
const existingRun = db.prepare(`
|
|
SELECT id FROM analysis_runs WHERE summary_id = ?
|
|
`).get(payload.summaryId) as { id: number } | undefined
|
|
|
|
let runId = existingRun?.id
|
|
|
|
if (runId) {
|
|
db.prepare('DELETE FROM evidence_links WHERE run_id = ?').run(runId)
|
|
db.prepare('DELETE FROM extracted_facts WHERE run_id = ?').run(runId)
|
|
db.prepare('DELETE FROM analysis_blocks WHERE run_id = ?').run(runId)
|
|
|
|
db.prepare(`
|
|
UPDATE analysis_runs
|
|
SET
|
|
session_id = ?,
|
|
time_range_start = ?,
|
|
time_range_end = ?,
|
|
time_range_days = ?,
|
|
raw_message_count = ?,
|
|
effective_message_count = ?,
|
|
block_count = ?,
|
|
provider = ?,
|
|
model = ?,
|
|
status = ?,
|
|
source_kind = ?,
|
|
evidence_resolved = ?,
|
|
blocks_available = ?,
|
|
updated_at = ?
|
|
WHERE id = ?
|
|
`).run(
|
|
payload.sessionId,
|
|
payload.timeRangeStart,
|
|
payload.timeRangeEnd,
|
|
payload.timeRangeDays,
|
|
payload.rawMessageCount,
|
|
payload.effectiveMessageCount ?? null,
|
|
payload.blockCount,
|
|
payload.provider,
|
|
payload.model,
|
|
payload.status,
|
|
payload.sourceKind,
|
|
payload.evidenceResolved ? 1 : 0,
|
|
payload.blocksAvailable ? 1 : 0,
|
|
updatedAt,
|
|
runId
|
|
)
|
|
} else {
|
|
const result = db.prepare(`
|
|
INSERT INTO analysis_runs (
|
|
summary_id, session_id, time_range_start, time_range_end, time_range_days,
|
|
raw_message_count, effective_message_count, block_count,
|
|
provider, model, status, source_kind,
|
|
evidence_resolved, blocks_available, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
payload.summaryId,
|
|
payload.sessionId,
|
|
payload.timeRangeStart,
|
|
payload.timeRangeEnd,
|
|
payload.timeRangeDays,
|
|
payload.rawMessageCount,
|
|
payload.effectiveMessageCount ?? null,
|
|
payload.blockCount,
|
|
payload.provider,
|
|
payload.model,
|
|
payload.status,
|
|
payload.sourceKind,
|
|
payload.evidenceResolved ? 1 : 0,
|
|
payload.blocksAvailable ? 1 : 0,
|
|
createdAt,
|
|
updatedAt
|
|
)
|
|
|
|
runId = result.lastInsertRowid as number
|
|
}
|
|
|
|
if (payload.blocksAvailable && payload.blocks?.length) {
|
|
const insertBlock = db.prepare(`
|
|
INSERT INTO analysis_blocks (
|
|
run_id, block_index, block_id, start_time, end_time,
|
|
message_count, char_count, rendered_text, messages_json, extracted_result_json
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`)
|
|
|
|
payload.blocks.forEach((block, index) => {
|
|
insertBlock.run(
|
|
runId,
|
|
index,
|
|
block.blockId,
|
|
block.startTime,
|
|
block.endTime,
|
|
block.messageCount,
|
|
block.charCount,
|
|
block.renderedText,
|
|
JSON.stringify(block.messages),
|
|
payload.blockAnalyses?.[index] ? JSON.stringify(payload.blockAnalyses[index]) : null
|
|
)
|
|
})
|
|
}
|
|
|
|
const factRecords = buildFactRecords(payload.finalAnalysis)
|
|
if (factRecords.length > 0) {
|
|
const insertFact = db.prepare(`
|
|
INSERT INTO extracted_facts (
|
|
run_id, fact_type, fact_key, sort_order, display_text,
|
|
confidence, importance, severity, owner, deadline, status, event_date, payload_json
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`)
|
|
|
|
const insertEvidenceLink = db.prepare(`
|
|
INSERT INTO evidence_links (
|
|
fact_id, run_id, evidence_order, session_id, local_id, create_time,
|
|
sort_seq, sender_username, sender_display_name, preview_text
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`)
|
|
|
|
for (const factRecord of factRecords) {
|
|
const factResult = insertFact.run(
|
|
runId,
|
|
factRecord.factType,
|
|
factRecord.factKey,
|
|
factRecord.sortOrder,
|
|
factRecord.displayText,
|
|
factRecord.confidence ?? null,
|
|
factRecord.importance ?? null,
|
|
factRecord.severity ?? null,
|
|
factRecord.owner ?? null,
|
|
factRecord.deadline ?? null,
|
|
factRecord.status ?? null,
|
|
factRecord.eventDate ?? null,
|
|
factRecord.payloadJson
|
|
)
|
|
|
|
const factId = factResult.lastInsertRowid as number
|
|
factRecord.evidenceRefs.forEach((evidenceRef, evidenceIndex) => {
|
|
insertEvidenceLink.run(
|
|
factId,
|
|
runId,
|
|
evidenceIndex,
|
|
evidenceRef.sessionId,
|
|
evidenceRef.localId,
|
|
evidenceRef.createTime,
|
|
evidenceRef.sortSeq,
|
|
evidenceRef.senderUsername ?? null,
|
|
evidenceRef.senderDisplayName ?? null,
|
|
evidenceRef.previewText
|
|
)
|
|
})
|
|
}
|
|
}
|
|
})
|
|
|
|
saveTx(input)
|
|
}
|
|
|
|
bootstrapAnalysisStorageBackfill(): void {
|
|
const db = this.getDb()
|
|
const bootstrapCompleted = this.getMetaValue(ANALYSIS_STORAGE_BOOTSTRAP_KEY)
|
|
|
|
if (bootstrapCompleted === '1') {
|
|
return
|
|
}
|
|
|
|
const insertedCount = this.backfillMissingAnalysisRuns()
|
|
this.setMetaValue(ANALYSIS_STORAGE_BOOTSTRAP_KEY, '1')
|
|
console.log('[AIDatabase] analysis storage bootstrap backfill 完成:', { insertedCount })
|
|
}
|
|
|
|
catchUpMissingAnalysisRuns(): void {
|
|
const insertedCount = this.backfillMissingAnalysisRuns()
|
|
if (insertedCount > 0) {
|
|
console.log('[AIDatabase] analysis storage catch-up 完成:', { insertedCount })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 保存缓存
|
|
*/
|
|
saveCache(cacheKey: string, summaryId: number, expiresAt: number): void {
|
|
const db = this.getDb()
|
|
|
|
db.prepare(`
|
|
INSERT OR REPLACE INTO summary_cache (cache_key, summary_id, expires_at)
|
|
VALUES (?, ?, ?)
|
|
`).run(cacheKey, summaryId, expiresAt)
|
|
}
|
|
|
|
/**
|
|
* 获取缓存的摘要
|
|
*/
|
|
getCachedSummary(cacheKey: string): any | null {
|
|
const db = this.getDb()
|
|
const now = Date.now()
|
|
|
|
const row: any = db.prepare(`
|
|
SELECT s.* FROM summaries s
|
|
JOIN summary_cache c ON s.id = c.summary_id
|
|
WHERE c.cache_key = ? AND c.expires_at > ?
|
|
`).get(cacheKey, now)
|
|
|
|
if (!row) return null
|
|
|
|
return {
|
|
id: row.id,
|
|
sessionId: row.session_id,
|
|
timeRangeStart: row.time_range_start,
|
|
timeRangeEnd: row.time_range_end,
|
|
timeRangeDays: row.time_range_days,
|
|
messageCount: row.message_count,
|
|
summaryText: row.summary_text,
|
|
tokensUsed: row.tokens_used,
|
|
cost: row.cost,
|
|
provider: row.provider,
|
|
model: row.model,
|
|
createdAt: row.created_at,
|
|
promptText: row.prompt_text,
|
|
structuredAnalysis: this.parseStructuredAnalysisColumn(row.structured_result_json)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 更新使用统计
|
|
*/
|
|
updateUsageStats(provider: string, model: string, tokens: number, cost: number): void {
|
|
const db = this.getDb()
|
|
const date = new Date().toISOString().split('T')[0]
|
|
|
|
db.prepare(`
|
|
INSERT INTO usage_stats (date, provider, model, total_tokens, total_cost, request_count)
|
|
VALUES (?, ?, ?, ?, ?, 1)
|
|
ON CONFLICT(date, provider, model) DO UPDATE SET
|
|
total_tokens = total_tokens + excluded.total_tokens,
|
|
total_cost = total_cost + excluded.total_cost,
|
|
request_count = request_count + 1
|
|
`).run(date, provider, model, tokens, cost)
|
|
}
|
|
|
|
/**
|
|
* 获取使用统计
|
|
*/
|
|
getUsageStats(startDate?: string, endDate?: string): any[] {
|
|
const db = this.getDb()
|
|
|
|
let query = 'SELECT * FROM usage_stats'
|
|
const params: any[] = []
|
|
|
|
if (startDate && endDate) {
|
|
query += ' WHERE date >= ? AND date <= ?'
|
|
params.push(startDate, endDate)
|
|
} else if (startDate) {
|
|
query += ' WHERE date >= ?'
|
|
params.push(startDate)
|
|
} else if (endDate) {
|
|
query += ' WHERE date <= ?'
|
|
params.push(endDate)
|
|
}
|
|
|
|
query += ' ORDER BY date DESC'
|
|
|
|
return db.prepare(query).all(...params)
|
|
}
|
|
|
|
/**
|
|
* 获取会话的摘要历史
|
|
*/
|
|
getSummaryHistory(sessionId: string, limit: number = 10): Array<{
|
|
id: number
|
|
sessionId: string
|
|
timeRangeStart: number
|
|
timeRangeEnd: number
|
|
timeRangeDays: number
|
|
messageCount: number
|
|
summaryText: string
|
|
tokensUsed: number
|
|
cost: number
|
|
provider: string
|
|
model: string
|
|
createdAt: number
|
|
promptText?: string
|
|
customName?: string
|
|
structuredAnalysis?: StructuredAnalysis
|
|
}> {
|
|
const db = this.getDb()
|
|
|
|
console.log('[AIDatabase] 查询历史记录:', { sessionId, limit })
|
|
|
|
const rows = db.prepare(`
|
|
SELECT * FROM summaries
|
|
WHERE session_id = ?
|
|
ORDER BY created_at DESC
|
|
LIMIT ?
|
|
`).all(sessionId, limit)
|
|
|
|
console.log('[AIDatabase] 查询到', rows.length, '条历史记录')
|
|
|
|
return rows.map((row: any) => ({
|
|
id: row.id,
|
|
sessionId: row.session_id,
|
|
timeRangeStart: row.time_range_start,
|
|
timeRangeEnd: row.time_range_end,
|
|
timeRangeDays: row.time_range_days,
|
|
messageCount: row.message_count,
|
|
summaryText: row.summary_text,
|
|
tokensUsed: row.tokens_used,
|
|
cost: row.cost,
|
|
provider: row.provider,
|
|
model: row.model,
|
|
createdAt: row.created_at,
|
|
promptText: row.prompt_text,
|
|
customName: row.custom_name,
|
|
structuredAnalysis: this.parseStructuredAnalysisColumn(row.structured_result_json)
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* 删除摘要
|
|
*/
|
|
deleteSummary(id: number): boolean {
|
|
const db = this.getDb()
|
|
|
|
try {
|
|
const deleteTx = db.transaction((summaryId: number) => {
|
|
db.prepare('DELETE FROM summary_cache WHERE summary_id = ?').run(summaryId)
|
|
db.prepare(`
|
|
DELETE FROM evidence_links
|
|
WHERE run_id IN (SELECT id FROM analysis_runs WHERE summary_id = ?)
|
|
`).run(summaryId)
|
|
db.prepare(`
|
|
DELETE FROM extracted_facts
|
|
WHERE run_id IN (SELECT id FROM analysis_runs WHERE summary_id = ?)
|
|
`).run(summaryId)
|
|
db.prepare(`
|
|
DELETE FROM analysis_blocks
|
|
WHERE run_id IN (SELECT id FROM analysis_runs WHERE summary_id = ?)
|
|
`).run(summaryId)
|
|
db.prepare('DELETE FROM analysis_runs WHERE summary_id = ?').run(summaryId)
|
|
return db.prepare('DELETE FROM summaries WHERE id = ?').run(summaryId)
|
|
})
|
|
|
|
const result = deleteTx(id)
|
|
return result.changes > 0
|
|
} catch (e) {
|
|
console.error('[AIDatabase] 删除摘要失败:', e)
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 重命名摘要
|
|
*/
|
|
renameSummary(id: number, customName: string): boolean {
|
|
const db = this.getDb()
|
|
|
|
try {
|
|
const result = db.prepare('UPDATE summaries SET custom_name = ? WHERE id = ?').run(customName, id)
|
|
return result.changes > 0
|
|
} catch (e) {
|
|
console.error('[AIDatabase] 重命名摘要失败:', e)
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 清理过期缓存
|
|
*/
|
|
cleanExpiredCache(): void {
|
|
const db = this.getDb()
|
|
const now = Date.now()
|
|
|
|
db.prepare('DELETE FROM summary_cache WHERE expires_at <= ?').run(now)
|
|
}
|
|
|
|
/**
|
|
* 关闭数据库
|
|
*/
|
|
close(): void {
|
|
if (this.db) {
|
|
this.db.close()
|
|
this.db = null
|
|
}
|
|
}
|
|
|
|
private parseStructuredAnalysisColumn(rawValue: unknown): StructuredAnalysis | undefined {
|
|
const parsed = parseStoredStructuredAnalysis(rawValue)
|
|
if (!parsed && rawValue) {
|
|
console.warn('[AIDatabase] structured_result_json 解析失败,已忽略该字段')
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
private getMetaValue(metaKey: string): string | undefined {
|
|
const db = this.getDb()
|
|
const row = db.prepare(`
|
|
SELECT meta_value FROM db_meta WHERE meta_key = ?
|
|
`).get(metaKey) as { meta_value: string } | undefined
|
|
|
|
return row?.meta_value
|
|
}
|
|
|
|
private setMetaValue(metaKey: string, metaValue: string): void {
|
|
const db = this.getDb()
|
|
db.prepare(`
|
|
INSERT INTO db_meta (meta_key, meta_value, updated_at)
|
|
VALUES (?, ?, ?)
|
|
ON CONFLICT(meta_key) DO UPDATE SET
|
|
meta_value = excluded.meta_value,
|
|
updated_at = excluded.updated_at
|
|
`).run(metaKey, metaValue, Date.now())
|
|
}
|
|
|
|
private backfillMissingAnalysisRuns(): number {
|
|
const candidates = this.getBackfillCandidates()
|
|
let insertedCount = 0
|
|
|
|
for (const summaryRow of candidates) {
|
|
try {
|
|
const structuredAnalysis = this.parseStructuredAnalysisColumn(summaryRow.structured_result_json)
|
|
const payload: SaveAnalysisArtifactsInput = structuredAnalysis
|
|
? {
|
|
summaryId: summaryRow.id,
|
|
sessionId: summaryRow.session_id,
|
|
timeRangeStart: summaryRow.time_range_start,
|
|
timeRangeEnd: summaryRow.time_range_end,
|
|
timeRangeDays: summaryRow.time_range_days,
|
|
rawMessageCount: summaryRow.message_count,
|
|
effectiveMessageCount: undefined,
|
|
blockCount: 0,
|
|
provider: summaryRow.provider,
|
|
model: summaryRow.model,
|
|
status: 'backfill_facts_only',
|
|
sourceKind: 'backfill_structured',
|
|
evidenceResolved: hasAnyEvidenceRefs(structuredAnalysis),
|
|
blocksAvailable: false,
|
|
finalAnalysis: structuredAnalysis,
|
|
createdAt: summaryRow.created_at,
|
|
updatedAt: Date.now()
|
|
}
|
|
: {
|
|
summaryId: summaryRow.id,
|
|
sessionId: summaryRow.session_id,
|
|
timeRangeStart: summaryRow.time_range_start,
|
|
timeRangeEnd: summaryRow.time_range_end,
|
|
timeRangeDays: summaryRow.time_range_days,
|
|
rawMessageCount: summaryRow.message_count,
|
|
effectiveMessageCount: undefined,
|
|
blockCount: 0,
|
|
provider: summaryRow.provider,
|
|
model: summaryRow.model,
|
|
status: 'legacy_placeholder',
|
|
sourceKind: 'backfill_legacy',
|
|
evidenceResolved: false,
|
|
blocksAvailable: false,
|
|
createdAt: summaryRow.created_at,
|
|
updatedAt: Date.now()
|
|
}
|
|
|
|
this.saveAnalysisArtifacts(payload)
|
|
insertedCount += 1
|
|
} catch (error) {
|
|
console.warn('[AIDatabase] 回填 analysis_runs 失败,已跳过该摘要:', {
|
|
summaryId: summaryRow.id,
|
|
error: String(error)
|
|
})
|
|
}
|
|
}
|
|
|
|
return insertedCount
|
|
}
|
|
|
|
private getBackfillCandidates(): SummaryBackfillRow[] {
|
|
const db = this.getDb()
|
|
return db.prepare(`
|
|
SELECT
|
|
s.id,
|
|
s.session_id,
|
|
s.time_range_start,
|
|
s.time_range_end,
|
|
s.time_range_days,
|
|
s.message_count,
|
|
s.provider,
|
|
s.model,
|
|
s.created_at,
|
|
s.structured_result_json
|
|
FROM summaries s
|
|
LEFT JOIN analysis_runs ar ON ar.summary_id = s.id
|
|
WHERE ar.id IS NULL
|
|
ORDER BY s.id ASC
|
|
`).all() as SummaryBackfillRow[]
|
|
}
|
|
}
|
|
|
|
export const aiDatabase = new AIDatabase()
|