""" 数据持久化层 - SQLite数据库管理 (v2.0新增) 提供评估记录的持久化存储、查询和导出功能, 替代原有的session_state易失性存储。 模块组成: - database.py: 数据库连接管理、建表语句、迁移版本控制 - models.py: AssessmentRecord数据模型定义 - repositories.py: CRUD操作封装 作者: Emotion Fusion System v2.0 日期: 2026-05-26 """ from __future__ import annotations import json import sqlite3 import threading from datetime import datetime, timezone from pathlib import Path from typing import Any # ============================================================ # 全局配置 # ============================================================ DB_DIR = None # 由init_database()设置 DB_FILENAME = "emotion_assessments.db" _db_lock = threading.Lock() def init_database(db_dir: str | Path | None = None) -> sqlite3.Connection: """ 初始化数据库连接(线程安全,懒加载单例模式) Args: db_dir: 数据库文件存放目录,None则使用默认位置 Returns: sqlite3.Connection 对象 """ global DB_DIR if db_dir is not None: DB_DIR = Path(db_dir) elif DB_DIR is None: # 默认位置: 项目根目录下的 data/ 目录 from config import DATA_DIR DB_DIR = DATA_DIR db_path = DB_DIR / DB_FILENAME with _db_lock: conn = sqlite3.connect(str(db_path), check_same_thread=False) conn.row_factory = sqlite3.Row # 支持字典式访问 _ensure_tables(conn) return conn def get_connection() -> sqlite3.Connection | None: """获取当前数据库连接(如果已初始化)""" if DB_DIR is None: return None return init_database(DB_DIR) # ============================================================ # 建表和迁移 # ============================================================ def _ensure_tables(conn: sqlite3.Connection) -> None: """确保所有必需的表存在,支持增量迁移""" # 检查并创建assessment_records表 cursor = conn.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name='assessment_records'" ) if cursor.fetchone() is None: _create_assessment_table(conn) print("[persistence] OK: Created assessment_records table") else: # 检查是否需要添加新列(增量迁移) _migrate_schema(conn) print("[persistence] OK: Database schema ready") def _create_assessment_table(conn: sqlite3.Connection) -> None: """创建主表 assessment_records""" conn.execute(""" CREATE TABLE IF NOT EXISTS assessment_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, -- 时间戳 timestamp TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')), -- 可选患者标识(预留扩展) patient_id TEXT DEFAULT '', patient_name TEXT DEFAULT '', -- 四模态原始结果 (JSON序列化) text_result TEXT, speech_result TEXT, face_result TEXT, ecg_result TEXT, -- 融合结果 final_emotion TEXT NOT NULL DEFAULT 'unknown', valence REAL, arousal REAL, confidence REAL DEFAULT 0.0, quality REAL DEFAULT 0.0, -- 元数据 modality_count INTEGER DEFAULT 0, fusion_mode TEXT DEFAULT '', available_modalities TEXT DEFAULT '', -- 不确定性信息 (v2.0新增) uncertainty_level TEXT DEFAULT '', uncertainty_score REAL DEFAULT 0.5, suggestion TEXT DEFAULT '', -- 警告信息 (JSON数组) warnings TEXT DEFAULT '[]', -- 会话元数据 session_info TEXT DEFAULT '{}', -- 唯一性约束(防重复) content_hash TEXT DEFAULT '' ) """) # 创建索引以加速查询 conn.execute("CREATE INDEX IF NOT EXISTS idx_assessment_time ON assessment_records(timestamp)") conn.execute("CREATE INDEX IF NOT EXISTS idx_assessment_patient ON assessment_records(patient_id)") conn.execute("CREATE INDEX IF NOT EXISTS idx_assessment_emotion ON assessment_records(final_emotion)") conn.execute("CREATE INDEX IF NOT EXISTS idx_assessment_hash ON assessment_records(content_hash)") conn.commit() def _migrate_schema(conn: sqlite3.Connection) -> None: """增量迁移:添加新列而不破坏现有数据""" migrations = [ ("uncertainty_level", "TEXT DEFAULT ''"), ("uncertainty_score", "REAL DEFAULT 0.5"), ("suggestion", "TEXT DEFAULT ''"), ("modality_count", "INTEGER DEFAULT 0"), ("fusion_mode", "TEXT DEFAULT ''"), ("available_modalities", "TEXT DEFAULT ''"), ("session_info", "TEXT DEFAULT '{}'"), ("content_hash", "TEXT DEFAULT ''"), ] for col_name, col_def in migrations: try: # 尝试选择该列,如果不存在会抛出异常 conn.execute(f"SELECT {col_name} FROM assessment_records LIMIT 1") except sqlite3.OperationalError: try: conn.execute(f"ALTER TABLE assessment_records ADD COLUMN {col_name} {col_def}") print(f"[persistence] Added column: {col_name}") except Exception as e: pass # 列可能已存在 conn.commit() # ============================================================ # 核心操作函数 # ============================================================ def save_assessment( conn: sqlite3.Connection, fusion_result: dict[str, Any], raw_results: list[dict[str, Any]] | None = None, ) -> int: """ 保存一次完整的融合评估结果到数据库。 Args: conn: 数据库连接 fusion_result: fuse_multimodal_va()返回的完整结果字典 raw_results: 各模态的原始结果列表(可选) Returns: 新插入记录的ID """ import hashlib # 计算内容hash用于去重 content_str = json.dumps({ "v": fusion_result.get("valence"), "a": fusion_result.get("arousal"), "e": fusion_result.get("final_emotion"), "t": fusion_result.get("timestamp") or datetime.now().isoformat(), }, sort_keys=True) content_hash = hashlib.md5(content_str.encode()).hexdigest()[:16] # 准备各模态原始结果的JSON序列化 def safe_json_dump(obj): return json.dumps(obj, ensure_ascii=False, default=str) if obj else None text_result = next((safe_json_dump(r) for r in (raw_results or []) if r.get("modality") == "text"), None) speech_result = next((safe_json_dump(r) for r in (raw_results or []) if r.get("modality") == "speech"), None) face_result = next((safe_json_dump(r) for r in (raw_results or []) if r.get("modality") == "face"), None) ecg_result = next((safe_json_dump(r) for r in (raw_results or []) if r.get("modality") == "ecg"), None) warnings_json = json.dumps(fusion_result.get("warnings", []), ensure_ascii=False) session_info = { "modalities_used": [r.get("modality") for r in (raw_results or []) if r.get("available") is True], "fusion_mode": fusion_result.get("fusion_mode", "adaptive_v2"), "saved_at": datetime.now().isoformat(), } cursor = conn.execute(""" INSERT INTO assessment_records ( timestamp, patient_id, patient_name, text_result, speech_result, face_result, ecg_result, final_emotion, valence, arousal, confidence, quality, modality_count, fusion_mode, available_modalities, uncertainty_level, uncertainty_score, suggestion, warnings, session_info, content_hash ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) """, ( datetime.now(timezone.utc).isoformat(), "", # patient_id 预留 "", # patient_name 预留 text_result, speech_result, face_result, ecg_result, fusion_result.get("final_emotion", "unknown"), fusion_result.get("valence"), fusion_result.get("arousal"), fusion_result.get("confidence", 0), fusion_result.get("quality", 0), len([r for r in (raw_results or []) if r.get("available") is True]), fusion_result.get("fusion_mode", "adaptive_v2"), ",".join([str(r.get("modality")) for r in (raw_results or []) if r.get("available")]), fusion_result.get("uncertainty_level", ""), fusion_result.get("uncertainty_score", 0.5), fusion_result.get("suggestion", ""), warnings_json, json.dumps(session_info, ensure_ascii=False), content_hash, )) record_id = int(cursor.lastrowid) conn.commit() return record_id def query_assessments( conn: sqlite3.Connection, limit: int = 50, offset: int = 0, patient_id: str | None = None, emotion_filter: str | None = None, min_confidence: float = 0.0, start_date: str | None = None, end_date: str | None = None, ) -> tuple[list[dict[str, Any]], int]: """ 查询历史评估记录。 Returns: (records_list, total_count) 元组 records_list中的每条记录为dict,包含所有字段 """ conditions = [] params = [] if patient_id: conditions.append("patient_id = ?") params.append(patient_id) if emotion_filter and emotion_filter != "all": conditions.append("final_emotion = ?") params.append(emotion_filter) if min_confidence > 0: conditions.append("confidence >= ?") params.append(min_confidence) if start_date: conditions.append("timestamp >= ?") params.append(start_date) if end_date: conditions.append("timestamp <= ?") params.append(end_date) where_clause = " AND ".join(conditions) if conditions else "1=1" # 先查总数 count_sql = f"SELECT COUNT(*) FROM assessment_records WHERE {where_clause}" total = int(conn.execute(count_sql, params).fetchone()[0]) # 再查分页数据 sql = f""" SELECT * FROM assessment_records WHERE {where_clause} ORDER BY id DESC LIMIT ? OFFSET ? """ rows = conn.execute(sql, [*params, limit, offset]).fetchall() records = [dict(row) for row in rows] return records, total def export_to_csv( conn: sqlite3.Connection, output_path: str | Path, **query_filters, ) -> bool: """ 导出评估记录为CSV格式。 Returns: True表示成功 """ import csv as csv_module records, _ = query_assessments(conn, limit=10000, **query_filters) if not records: return False fieldnames = [ "id", "timestamp", "final_emotion", "valence", "arousal", "confidence", "quality", "modality_count", "uncertainty_level", "available_modalities" ] with open(output_path, "w", newline="", encoding="utf-8-sig") as f: writer = csv_module.DictWriter(f, fieldnames=fieldnames) writer.writeheader() for rec in records: # 只导出指定字段 row = {k: rec.get(k, "") for k in fieldnames} writer.writerow(row) return True def export_to_json( conn: sqlite3.Connection, output_path: str | Path, **query_filters, ) -> bool: """ 导出评估记录为JSON格式。 Returns: True表示成功 """ records, _ = query_assessments(conn, limit=10000, **query_filters) if not records: return False with open(output_path, "w", encoding="utf-8") as f: json.dump(records, f, ensure_ascii=False, indent=2, default=str) return True def get_statistics(conn: sqlite3.Connection) -> dict[str, Any]: """ 返回数据库统计概览: - 总记录数 - 最近30天记录数 - 平均valence/arousal/confidence - 情绪分布统计 """ stats = {} # 总记录数 stats["total_records"] = int(conn.execute("SELECT COUNT(*) FROM assessment_records").fetchone()[0]) # 最近30天 thirty_days_ago = (datetime.now().replace(tzinfo=None).__sub__(__import__("datetime").timedelta(days=30))).isoformat() stats["recent_30d"] = int(conn.execute( "SELECT COUNT(*) FROM assessment_records WHERE timestamp >= ?", (thirty_days_ago,) ).fetchone()[0]) # 平均值 avg_row = conn.execute(""" SELECT AVG(valence), AVG(arousal), AVG(confidence), AVG(quality) FROM assessment_records """).fetchone() stats["avg_valence"] = round(float(avg_row[0] or 0), 4) if avg_row[0] else 0 stats["avg_arousal"] = round(float(avg_row[1] or 0), 4) if avg_row[1] else 0 stats["avg_confidence"] = round(float(avg_row[2] or 0), 4) if avg_row[2] else 0 stats["avg_quality"] = round(float(avg_row[3] or 0), 4) if avg_row[3] else 0 # 情绪分布 emotion_dist = conn.execute(""" SELECT final_emotion, COUNT(*) as cnt FROM assessment_records GROUP BY final_emotion ORDER BY cnt DESC """).fetchall() stats["emotion_distribution"] = {row["final_emotion"]: row["cnt"] for row in emotion_dist} # 不确定性分布 uncertainty_dist = conn.execute(""" SELECT COALESCE(uncertainty_level, 'unknown') as level, COUNT(*) as cnt FROM assessment_records GROUP BY level ORDER BY level """).fetchall() stats["uncertainty_distribution"] = {row["level"]: row["cnt"] for row in uncertainty_dist} return stats def delete_old_records( conn: sqlite3.Connection, days_threshold: float = 90, ) -> int: """删除超过指定天数的旧记录,返回删除数量""" cutoff = (datetime.now().replace(tzinfo=None).__sub__( __import__("datetime").timedelta(days=days_threshold) )).isoformat() cursor = conn.execute("DELETE FROM assessment_records WHERE timestamp < ?", (cutoff,)) deleted = cursor.rowcount conn.commit() return deleted def backup_database( conn: sqlite3.Connection, output_path: str | Path | None = None, ) -> str | None: """ 备份整个数据库文件到指定路径。 Returns: 备份文件的路径,失败返回None """ if DB_DIR is None: return None src_path = DB_DIR / DB_FILENAME if output_path is None: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") output_path = DB_DIR / f"backup_{timestamp}.db" import shutil shutil.copy2(str(src_path), str(output_path)) return str(output_path)