Files
mai-bot/src/common/database/migrations/legacy_v1_to_v2.py

1490 lines
50 KiB
Python

"""旧版 ``0.x`` 数据库升级到最新 schema 的迁移逻辑。"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, cast
from sqlalchemy import text
from sqlalchemy.engine import Connection
import json
import msgpack
from src.common.logger import get_logger
from .exceptions import DatabaseMigrationExecutionError
from .models import DatabaseSchemaSnapshot, MigrationExecutionContext
from .schema import SQLiteSchemaInspector
logger = get_logger("database_migration")
_LEGACY_V1_BACKUP_PREFIX = "__legacy_v1_"
_LEGACY_V1_TABLE_NAMES = (
"action_records",
"chat_history",
"chat_streams",
"emoji",
"emoji_description_cache",
"expression",
"group_info",
"image_descriptions",
"images",
"jargon",
"llm_usage",
"messages",
"online_time",
"person_info",
"thinking_back",
)
_EMPTY_MESSAGE_SEQUENCE_BYTES = msgpack.packb([], use_bin_type=True)
@dataclass(frozen=True)
class LegacyTableData:
"""旧版表数据快照。"""
source_table_name: str
columns: Set[str]
rows: List[Dict[str, Any]]
def migrate_legacy_v1_to_v2(context: MigrationExecutionContext) -> None:
"""执行旧版 ``0.x`` 数据库到最新 schema 的迁移。
Args:
context: 当前迁移步骤执行上下文。
"""
from sqlmodel import SQLModel
import src.common.database.database_model # noqa: F401
schema_inspector = SQLiteSchemaInspector()
snapshot = schema_inspector.inspect(context.connection)
_rename_legacy_v1_tables(context.connection, snapshot)
SQLModel.metadata.create_all(context.connection)
table_migration_jobs: List[Tuple[str, Callable[[MigrationExecutionContext], int]]] = [
("chat_sessions", _migrate_chat_sessions),
("llm_usage", _migrate_model_usage),
("images", _migrate_images),
("mai_messages", _migrate_messages),
("action_records", _migrate_action_records),
("tool_records", _migrate_tool_records),
("online_time", _migrate_online_time),
("person_info", _migrate_person_info),
("expressions", _migrate_expressions),
("jargons", _migrate_jargons),
("chat_history", _migrate_chat_history),
("thinking_questions", _migrate_thinking_questions),
]
migrated_counts: Dict[str, int] = {}
total_record_count = _estimate_total_record_count(context.connection)
context.start_progress(
total_tables=len(table_migration_jobs),
total_records=total_record_count,
description="总迁移进度",
table_unit_name="",
record_unit_name="记录",
)
for table_name, migration_handler in table_migration_jobs:
migrated_counts[table_name] = migration_handler(context)
summary_text = ", ".join(f"{table_name}={count}" for table_name, count in migrated_counts.items())
logger.info(f"旧版数据库迁移完成: {summary_text}")
def _legacy_backup_table_name(table_name: str) -> str:
"""构建旧版表的备份表名。
Args:
table_name: 旧版原始表名。
Returns:
str: 带前缀的备份表名。
"""
return f"{_LEGACY_V1_BACKUP_PREFIX}{table_name}"
def _quote_identifier(identifier: str) -> str:
"""为 SQLite 标识符添加安全引号。
Args:
identifier: 待引用的标识符。
Returns:
str: 可安全拼接到 SQL 中的标识符。
"""
escaped_identifier = identifier.replace('"', '""')
return f'"{escaped_identifier}"'
def _rename_legacy_v1_tables(connection: Connection, snapshot: DatabaseSchemaSnapshot) -> None:
"""将旧版表统一改名为带备份前缀的表名。
Args:
connection: 当前数据库连接。
snapshot: 当前数据库结构快照。
Raises:
DatabaseMigrationExecutionError: 当发现同名旧表与备份表同时存在时抛出。
"""
for table_name in _LEGACY_V1_TABLE_NAMES:
if not snapshot.has_table(table_name):
continue
backup_table_name = _legacy_backup_table_name(table_name)
if snapshot.has_table(backup_table_name):
raise DatabaseMigrationExecutionError(
"检测到旧版表与迁移备份表同时存在,无法安全继续迁移。"
f" 冲突表={table_name},备份表={backup_table_name}"
)
connection.execute(
text(
f"ALTER TABLE {_quote_identifier(table_name)} "
f"RENAME TO {_quote_identifier(backup_table_name)}"
)
)
def _load_legacy_table_data(connection: Connection, original_table_name: str) -> Optional[LegacyTableData]:
"""加载单张旧版备份表的数据快照。
Args:
connection: 当前数据库连接。
original_table_name: 旧版原始表名。
Returns:
Optional[LegacyTableData]: 若备份表存在则返回其数据快照,否则返回 ``None``。
"""
backup_table_name = _legacy_backup_table_name(original_table_name)
schema_inspector = SQLiteSchemaInspector()
if not schema_inspector.table_exists(connection, backup_table_name):
return None
table_schema = schema_inspector.get_table_schema(connection, backup_table_name)
rows = connection.execute(text(f"SELECT * FROM {_quote_identifier(backup_table_name)}")).mappings().all()
return LegacyTableData(
source_table_name=backup_table_name,
columns=set(table_schema.columns),
rows=[dict(row) for row in rows],
)
def _normalize_optional_text(value: Any) -> Optional[str]:
"""将任意值标准化为可空字符串。
Args:
value: 待标准化的原始值。
Returns:
Optional[str]: 标准化后的文本;若值为空则返回 ``None``。
"""
if value is None:
return None
text_value = str(value).strip()
return text_value or None
def _normalize_required_text(value: Any, default: str = "") -> str:
"""将任意值标准化为非空字符串。
Args:
value: 待标准化的原始值。
default: 为空时使用的默认值。
Returns:
str: 标准化后的字符串。
"""
normalized_value = _normalize_optional_text(value)
if normalized_value is None:
return default
return normalized_value
def _normalize_int(value: Any, default: int = 0) -> int:
"""将任意值标准化为整数。
Args:
value: 待标准化的原始值。
default: 转换失败时的默认值。
Returns:
int: 标准化后的整数。
"""
if value is None or value == "":
return default
try:
return int(value)
except (TypeError, ValueError):
return default
def _normalize_float(value: Any, default: float = 0.0) -> float:
"""将任意值标准化为浮点数。
Args:
value: 待标准化的原始值。
default: 转换失败时的默认值。
Returns:
float: 标准化后的浮点数。
"""
if value is None or value == "":
return default
try:
return float(value)
except (TypeError, ValueError):
return default
def _normalize_optional_bool(value: Any) -> Optional[bool]:
"""将任意值标准化为可空布尔值。
Args:
value: 待标准化的原始值。
Returns:
Optional[bool]: 标准化后的布尔值;若无法确定则返回 ``None``。
"""
if value is None:
return None
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(int(value))
normalized_text = str(value).strip().lower()
if normalized_text in {"", "null", "none"}:
return None
if normalized_text in {"1", "true", "t", "yes", "y"}:
return True
if normalized_text in {"0", "false", "f", "no", "n"}:
return False
return None
def _normalize_bool(value: Any, default: bool = False) -> bool:
"""将任意值标准化为布尔值。
Args:
value: 待标准化的原始值。
default: 无法识别时的默认值。
Returns:
bool: 标准化后的布尔值。
"""
parsed_value = _normalize_optional_bool(value)
return default if parsed_value is None else parsed_value
def _coerce_datetime(value: Any, fallback_now: bool = False) -> Optional[datetime]:
"""将旧版时间字段标准化为 ``datetime``。
Args:
value: 待转换的原始值。
fallback_now: 转换失败时是否回退到当前时间。
Returns:
Optional[datetime]: 转换后的时间对象。
"""
if value is None or value == "":
return datetime.now() if fallback_now else None
if isinstance(value, datetime):
return value
if isinstance(value, (int, float)):
try:
return datetime.fromtimestamp(float(value))
except (OSError, OverflowError, ValueError):
return datetime.now() if fallback_now else None
normalized_text = str(value).strip()
if not normalized_text:
return datetime.now() if fallback_now else None
try:
return datetime.fromtimestamp(float(normalized_text))
except (TypeError, ValueError, OSError, OverflowError):
pass
try:
return datetime.fromisoformat(normalized_text.replace("Z", "+00:00"))
except ValueError:
return datetime.now() if fallback_now else None
def _normalize_string_list(value: Any) -> List[str]:
"""将旧版文本或 JSON 字段规范化为字符串列表。
Args:
value: 待标准化的原始值。
Returns:
List[str]: 规范化后的字符串列表。
"""
if value is None:
return []
if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()]
normalized_text = str(value).strip()
if not normalized_text:
return []
try:
parsed_value = json.loads(normalized_text)
except json.JSONDecodeError:
return [normalized_text]
if isinstance(parsed_value, list):
return [str(item).strip() for item in parsed_value if str(item).strip()]
if isinstance(parsed_value, str):
parsed_text = parsed_value.strip()
return [parsed_text] if parsed_text else []
if parsed_value is None:
return []
return [str(parsed_value).strip()]
def _normalize_json_dict_text(value: Any) -> Optional[str]:
"""将旧版附加配置标准化为 JSON 字典字符串。
Args:
value: 待标准化的原始值。
Returns:
Optional[str]: 合法的 JSON 字典字符串;若无内容则返回 ``None``。
"""
if value is None:
return None
if isinstance(value, dict):
return json.dumps(value, ensure_ascii=False)
normalized_text = str(value).strip()
if not normalized_text:
return None
try:
parsed_value = json.loads(normalized_text)
except json.JSONDecodeError:
return json.dumps({"_legacy_additional_config_raw": normalized_text}, ensure_ascii=False)
if isinstance(parsed_value, dict):
return json.dumps(parsed_value, ensure_ascii=False)
return json.dumps({"_legacy_additional_config_raw": parsed_value}, ensure_ascii=False)
def _normalize_group_cardname_json(value: Any) -> Optional[str]:
"""将旧版群昵称字段转换为当前使用的 JSON 结构。
Args:
value: 旧版 ``group_nick_name`` 字段值。
Returns:
Optional[str]: 新版 ``group_cardname`` JSON 字符串。
"""
if value is None:
return None
normalized_text = str(value).strip()
if not normalized_text:
return None
try:
parsed_value = json.loads(normalized_text)
except json.JSONDecodeError:
return None
if not isinstance(parsed_value, list):
return None
normalized_items: List[Dict[str, str]] = []
for item in parsed_value:
if not isinstance(item, Mapping):
continue
group_id = _normalize_required_text(item.get("group_id"))
group_cardname = _normalize_required_text(item.get("group_cardname") or item.get("group_nick_name"))
if not group_id or not group_cardname:
continue
normalized_items.append(
{
"group_id": group_id,
"group_cardname": group_cardname,
}
)
if not normalized_items:
return None
return json.dumps(normalized_items, ensure_ascii=False)
def _normalize_modified_by(value: Any) -> Optional[str]:
"""将旧版审核来源字段标准化为当前枚举名称。
Args:
value: 待标准化的原始值。
Returns:
Optional[str]: 若能识别则返回 ``AI`` / ``USER``,否则返回 ``None``。
"""
normalized_text = _normalize_required_text(value).lower()
if normalized_text in {"", "null", "none"}:
return None
if normalized_text in {"ai"}:
return "AI"
if normalized_text in {"user"}:
return "USER"
return None
def _build_session_id_dict(value: Any, fallback_count: int) -> str:
"""将旧版 ``chat_id`` 字段转换为新版 ``session_id_dict``。
Args:
value: 旧版 ``chat_id`` 字段值。
fallback_count: 默认引用次数。
Returns:
str: 新版 ``session_id_dict`` JSON 字符串。
"""
if value is None:
return json.dumps({}, ensure_ascii=False)
normalized_text = str(value).strip()
if not normalized_text:
return json.dumps({}, ensure_ascii=False)
try:
parsed_value = json.loads(normalized_text)
except json.JSONDecodeError:
return json.dumps({normalized_text: max(fallback_count, 1)}, ensure_ascii=False)
if isinstance(parsed_value, str):
parsed_text = parsed_value.strip()
if not parsed_text:
return json.dumps({}, ensure_ascii=False)
return json.dumps({parsed_text: max(fallback_count, 1)}, ensure_ascii=False)
if not isinstance(parsed_value, list):
return json.dumps({}, ensure_ascii=False)
session_counts: Dict[str, int] = {}
for item in parsed_value:
if not isinstance(item, list) or not item:
continue
session_id = _normalize_required_text(item[0])
if not session_id:
continue
session_count = fallback_count
if len(item) > 1:
session_count = _normalize_int(item[1], default=fallback_count)
session_counts[session_id] = max(session_count, 1)
return json.dumps(session_counts, ensure_ascii=False)
def _build_legacy_message_additional_config(row: Mapping[str, Any]) -> Optional[str]:
"""构建新版消息表使用的附加配置 JSON。
Args:
row: 旧版消息表行数据。
Returns:
Optional[str]: 新版消息表 ``additional_config`` 字段内容。
"""
additional_config_text = _normalize_json_dict_text(row.get("additional_config"))
if additional_config_text:
merged_config = json.loads(additional_config_text)
else:
merged_config = {}
legacy_fields = {
"intercept_message_level": row.get("intercept_message_level"),
"interest_value": row.get("interest_value"),
"key_words": row.get("key_words"),
"key_words_lite": row.get("key_words_lite"),
"priority_info": row.get("priority_info"),
"priority_mode": row.get("priority_mode"),
"selected_expressions": row.get("selected_expressions"),
}
for field_name, field_value in legacy_fields.items():
if field_value is None:
continue
merged_config[field_name] = field_value
if not merged_config:
return None
return json.dumps(merged_config, ensure_ascii=False)
def _build_message_raw_content(processed_plain_text: Optional[str], display_message: Optional[str]) -> bytes:
"""为旧版消息构造一个可被当前代码读取的占位 ``raw_content``。
Args:
processed_plain_text: 旧版消息的处理后文本。
display_message: 旧版消息的展示文本。
Returns:
bytes: 可被当前消息模型安全反序列化的 msgpack 字节串。
"""
message_text = _normalize_optional_text(display_message) or _normalize_optional_text(processed_plain_text)
if not message_text:
return cast(bytes, _EMPTY_MESSAGE_SEQUENCE_BYTES)
serialized_payload = [{"type": "text", "data": message_text}]
return cast(bytes, msgpack.packb(serialized_payload, use_bin_type=True))
def _deduce_image_type_name(value: Any) -> str:
"""将旧版图片类型转换为当前枚举名称。
Args:
value: 旧版图片类型字段值。
Returns:
str: 当前 ``ImageType`` 枚举在数据库中的文本值。
"""
normalized_text = _normalize_required_text(value, default="image").lower()
if normalized_text == "emoji":
return "EMOJI"
return "IMAGE"
def _count_legacy_table_rows(connection: Connection, original_table_name: str) -> int:
"""统计单张旧版备份表中的记录总数。
Args:
connection: 当前数据库连接。
original_table_name: 旧版原始表名。
Returns:
int: 备份表中的记录数;若表不存在则返回 ``0``。
"""
backup_table_name = _legacy_backup_table_name(original_table_name)
schema_inspector = SQLiteSchemaInspector()
if not schema_inspector.table_exists(connection, backup_table_name):
return 0
row = connection.execute(
text(f"SELECT COUNT(*) FROM {_quote_identifier(backup_table_name)}")
).first()
if row is None:
return 0
return _normalize_int(row[0], default=0)
def _estimate_total_record_count(connection: Connection) -> int:
"""估算旧版迁移步骤需要处理的总记录数。
Args:
connection: 当前数据库连接。
Returns:
int: 本次迁移预计处理的总记录数。
"""
return (
_count_legacy_table_rows(connection, "chat_streams")
+ _count_legacy_table_rows(connection, "llm_usage")
+ _count_legacy_table_rows(connection, "emoji")
+ _count_legacy_table_rows(connection, "images")
+ _count_legacy_table_rows(connection, "messages")
+ _count_legacy_table_rows(connection, "action_records")
+ _count_legacy_table_rows(connection, "action_records")
+ _count_legacy_table_rows(connection, "online_time")
+ _count_legacy_table_rows(connection, "person_info")
+ _count_legacy_table_rows(connection, "expression")
+ _count_legacy_table_rows(connection, "jargon")
+ _count_legacy_table_rows(connection, "chat_history")
+ _count_legacy_table_rows(connection, "thinking_back")
)
def _complete_table_progress(context: MigrationExecutionContext, table_name: str) -> None:
"""标记单张表的迁移已经完成。
Args:
context: 当前迁移步骤执行上下文。
table_name: 已完成迁移的表名。
"""
context.advance_progress(completed_tables=1, item_name=table_name)
def _migrate_chat_sessions(context: MigrationExecutionContext) -> int:
"""迁移旧版 ``chat_streams`` 到新版 ``chat_sessions``。
Args:
context: 当前迁移步骤执行上下文。
Returns:
int: 迁移成功的记录数。
"""
connection = context.connection
legacy_table = _load_legacy_table_data(connection, "chat_streams")
if legacy_table is None:
_complete_table_progress(context, "chat_sessions")
return 0
migrated_count = 0
insert_sql = text(
"""
INSERT OR IGNORE INTO chat_sessions (
session_id,
created_timestamp,
last_active_timestamp,
user_id,
group_id,
platform
) VALUES (
:session_id,
:created_timestamp,
:last_active_timestamp,
:user_id,
:group_id,
:platform
)
"""
)
for row in legacy_table.rows:
session_id = _normalize_required_text(row.get("stream_id"))
if session_id:
connection.execute(
insert_sql,
{
"session_id": session_id,
"created_timestamp": _coerce_datetime(row.get("create_time"), fallback_now=True),
"last_active_timestamp": _coerce_datetime(row.get("last_active_time"), fallback_now=True),
"user_id": _normalize_optional_text(row.get("user_id")),
"group_id": _normalize_optional_text(row.get("group_id")),
"platform": _normalize_required_text(row.get("platform"), default="unknown"),
},
)
migrated_count += 1
context.advance_progress(records=1)
_complete_table_progress(context, "chat_sessions")
return migrated_count
def _migrate_model_usage(context: MigrationExecutionContext) -> int:
"""迁移旧版 ``llm_usage`` 到新版 ``llm_usage``。
Args:
context: 当前迁移步骤执行上下文。
Returns:
int: 迁移成功的记录数。
"""
connection = context.connection
legacy_table = _load_legacy_table_data(connection, "llm_usage")
if legacy_table is None:
_complete_table_progress(context, "llm_usage")
return 0
migrated_count = 0
insert_sql = text(
"""
INSERT OR IGNORE INTO llm_usage (
id,
model_name,
model_assign_name,
model_api_provider_name,
endpoint,
user_type,
request_type,
time_cost,
timestamp,
prompt_tokens,
completion_tokens,
total_tokens,
cost
) VALUES (
:id,
:model_name,
:model_assign_name,
:model_api_provider_name,
:endpoint,
:user_type,
:request_type,
:time_cost,
:timestamp,
:prompt_tokens,
:completion_tokens,
:total_tokens,
:cost
)
"""
)
for row in legacy_table.rows:
connection.execute(
insert_sql,
{
"id": row.get("id"),
"model_name": _normalize_required_text(row.get("model_name"), default="unknown"),
"model_assign_name": _normalize_optional_text(row.get("model_assign_name")),
"model_api_provider_name": _normalize_required_text(row.get("model_api_provider"), default="unknown"),
"endpoint": _normalize_optional_text(row.get("endpoint")),
"user_type": "SYSTEM",
"request_type": _normalize_required_text(row.get("request_type"), default="unknown"),
"time_cost": _normalize_float(row.get("time_cost"), default=0.0),
"timestamp": _coerce_datetime(row.get("timestamp"), fallback_now=True),
"prompt_tokens": _normalize_int(row.get("prompt_tokens"), default=0),
"completion_tokens": _normalize_int(row.get("completion_tokens"), default=0),
"total_tokens": _normalize_int(row.get("total_tokens"), default=0),
"cost": _normalize_float(row.get("cost"), default=0.0),
},
)
migrated_count += 1
context.advance_progress(records=1)
_complete_table_progress(context, "llm_usage")
return migrated_count
def _migrate_images(context: MigrationExecutionContext) -> int:
"""迁移旧版 ``emoji`` 与 ``images`` 到新版 ``images``。
Args:
context: 当前迁移步骤执行上下文。
Returns:
int: 迁移成功的记录数。
"""
connection = context.connection
migrated_count = 0
existing_keys: Set[Tuple[str, str, str]] = set()
existing_rows = connection.execute(
text("SELECT full_path, image_hash, image_type FROM images")
).mappings().all()
for row in existing_rows:
existing_keys.add(
(
_normalize_required_text(row.get("full_path")),
_normalize_required_text(row.get("image_hash")),
_normalize_required_text(row.get("image_type")),
)
)
insert_sql = text(
"""
INSERT INTO images (
image_hash,
description,
full_path,
image_type,
emotion,
query_count,
is_registered,
is_banned,
no_file_flag,
record_time,
register_time,
last_used_time,
vlm_processed
) VALUES (
:image_hash,
:description,
:full_path,
:image_type,
:emotion,
:query_count,
:is_registered,
:is_banned,
:no_file_flag,
:record_time,
:register_time,
:last_used_time,
:vlm_processed
)
"""
)
legacy_emoji_table = _load_legacy_table_data(connection, "emoji")
if legacy_emoji_table is not None:
for row in legacy_emoji_table.rows:
full_path = _normalize_required_text(row.get("full_path"))
image_hash = _normalize_required_text(row.get("emoji_hash"))
dedupe_key = (full_path, image_hash, "EMOJI")
if full_path and dedupe_key not in existing_keys:
connection.execute(
insert_sql,
{
"image_hash": image_hash,
"description": _normalize_required_text(row.get("description")),
"full_path": full_path,
"image_type": "EMOJI",
"emotion": _normalize_optional_text(row.get("emotion")),
"query_count": _normalize_int(row.get("query_count"), default=0),
"is_registered": _normalize_bool(row.get("is_registered"), default=False),
"is_banned": _normalize_bool(row.get("is_banned"), default=False),
"no_file_flag": False,
"record_time": _coerce_datetime(row.get("record_time"), fallback_now=True),
"register_time": _coerce_datetime(row.get("register_time")),
"last_used_time": _coerce_datetime(row.get("last_used_time")),
"vlm_processed": False,
},
)
existing_keys.add(dedupe_key)
migrated_count += 1
context.advance_progress(records=1)
legacy_images_table = _load_legacy_table_data(connection, "images")
if legacy_images_table is not None:
for row in legacy_images_table.rows:
full_path = _normalize_required_text(row.get("path"))
image_hash = _normalize_required_text(row.get("emoji_hash"))
image_type = _deduce_image_type_name(row.get("type"))
dedupe_key = (full_path, image_hash, image_type)
if full_path and dedupe_key not in existing_keys:
connection.execute(
insert_sql,
{
"image_hash": image_hash,
"description": _normalize_required_text(row.get("description")),
"full_path": full_path,
"image_type": image_type,
"emotion": None,
"query_count": _normalize_int(row.get("count"), default=0),
"is_registered": False,
"is_banned": False,
"no_file_flag": False,
"record_time": _coerce_datetime(row.get("timestamp"), fallback_now=True),
"register_time": None,
"last_used_time": None,
"vlm_processed": _normalize_bool(row.get("vlm_processed"), default=False),
},
)
existing_keys.add(dedupe_key)
migrated_count += 1
context.advance_progress(records=1)
_complete_table_progress(context, "images")
return migrated_count
def _migrate_messages(context: MigrationExecutionContext) -> int:
"""迁移旧版 ``messages`` 到新版 ``mai_messages``。
Args:
context: 当前迁移步骤执行上下文。
Returns:
int: 迁移成功的记录数。
"""
connection = context.connection
legacy_table = _load_legacy_table_data(connection, "messages")
if legacy_table is None:
_complete_table_progress(context, "mai_messages")
return 0
migrated_count = 0
insert_sql = text(
"""
INSERT OR IGNORE INTO mai_messages (
id,
message_id,
timestamp,
platform,
user_id,
user_nickname,
user_cardname,
group_id,
group_name,
is_mentioned,
is_at,
session_id,
reply_to,
is_emoji,
is_picture,
is_command,
is_notify,
raw_content,
processed_plain_text,
display_message,
additional_config
) VALUES (
:id,
:message_id,
:timestamp,
:platform,
:user_id,
:user_nickname,
:user_cardname,
:group_id,
:group_name,
:is_mentioned,
:is_at,
:session_id,
:reply_to,
:is_emoji,
:is_picture,
:is_command,
:is_notify,
:raw_content,
:processed_plain_text,
:display_message,
:additional_config
)
"""
)
for row in legacy_table.rows:
session_id = _normalize_optional_text(row.get("chat_id")) or _normalize_optional_text(row.get("chat_info_stream_id"))
if session_id:
processed_plain_text = _normalize_optional_text(row.get("processed_plain_text"))
display_message = _normalize_optional_text(row.get("display_message"))
connection.execute(
insert_sql,
{
"id": row.get("id"),
"message_id": _normalize_required_text(row.get("message_id"), default=""),
"timestamp": _coerce_datetime(row.get("time"), fallback_now=True),
"platform": _normalize_required_text(
row.get("chat_info_platform") or row.get("user_platform"),
default="unknown",
),
"user_id": _normalize_required_text(
row.get("user_id") or row.get("chat_info_user_id"),
default="",
),
"user_nickname": _normalize_required_text(
row.get("user_nickname") or row.get("chat_info_user_nickname"),
default="",
),
"user_cardname": _normalize_optional_text(
row.get("user_cardname") or row.get("chat_info_user_cardname")
),
"group_id": _normalize_optional_text(row.get("chat_info_group_id")),
"group_name": _normalize_optional_text(row.get("chat_info_group_name")),
"is_mentioned": _normalize_bool(row.get("is_mentioned"), default=False),
"is_at": _normalize_bool(row.get("is_at"), default=False),
"session_id": session_id,
"reply_to": _normalize_optional_text(row.get("reply_to")),
"is_emoji": _normalize_bool(row.get("is_emoji"), default=False),
"is_picture": _normalize_bool(row.get("is_picid"), default=False),
"is_command": _normalize_bool(row.get("is_command"), default=False),
"is_notify": _normalize_bool(row.get("is_notify"), default=False),
"raw_content": _build_message_raw_content(processed_plain_text, display_message),
"processed_plain_text": processed_plain_text,
"display_message": display_message,
"additional_config": _build_legacy_message_additional_config(row),
},
)
migrated_count += 1
context.advance_progress(records=1)
_complete_table_progress(context, "mai_messages")
return migrated_count
def _migrate_action_records(context: MigrationExecutionContext) -> int:
"""迁移旧版 ``action_records`` 到新版 ``action_records``。
Args:
context: 当前迁移步骤执行上下文。
Returns:
int: 迁移成功的记录数。
"""
connection = context.connection
legacy_table = _load_legacy_table_data(connection, "action_records")
if legacy_table is None:
_complete_table_progress(context, "action_records")
return 0
migrated_count = 0
insert_sql = text(
"""
INSERT OR IGNORE INTO action_records (
id,
action_id,
timestamp,
session_id,
action_name,
action_reasoning,
action_data,
action_builtin_prompt,
action_display_prompt
) VALUES (
:id,
:action_id,
:timestamp,
:session_id,
:action_name,
:action_reasoning,
:action_data,
:action_builtin_prompt,
:action_display_prompt
)
"""
)
for row in legacy_table.rows:
session_id = _normalize_optional_text(row.get("chat_id")) or _normalize_optional_text(row.get("chat_info_stream_id"))
if session_id:
connection.execute(
insert_sql,
{
"id": row.get("id"),
"action_id": _normalize_required_text(row.get("action_id")),
"timestamp": _coerce_datetime(row.get("time"), fallback_now=True),
"session_id": session_id,
"action_name": _normalize_required_text(row.get("action_name"), default="unknown"),
"action_reasoning": _normalize_optional_text(row.get("action_reasoning")),
"action_data": _normalize_optional_text(row.get("action_data")),
"action_builtin_prompt": None,
"action_display_prompt": _normalize_optional_text(row.get("action_prompt_display")),
},
)
migrated_count += 1
context.advance_progress(records=1)
_complete_table_progress(context, "action_records")
return migrated_count
def _migrate_tool_records(context: MigrationExecutionContext) -> int:
"""迁移旧版 ``action_records`` 到新版 ``tool_records``。
Args:
context: 当前迁移步骤执行上下文。
Returns:
int: 迁移成功的记录数。
"""
connection = context.connection
legacy_table = _load_legacy_table_data(connection, "action_records")
if legacy_table is None:
_complete_table_progress(context, "tool_records")
return 0
migrated_count = 0
insert_sql = text(
"""
INSERT OR IGNORE INTO tool_records (
id,
tool_id,
timestamp,
session_id,
tool_name,
tool_reasoning,
tool_data,
tool_builtin_prompt,
tool_display_prompt
) VALUES (
:id,
:tool_id,
:timestamp,
:session_id,
:tool_name,
:tool_reasoning,
:tool_data,
:tool_builtin_prompt,
:tool_display_prompt
)
"""
)
for row in legacy_table.rows:
session_id = _normalize_optional_text(row.get("chat_id")) or _normalize_optional_text(row.get("chat_info_stream_id"))
if session_id:
connection.execute(
insert_sql,
{
"id": row.get("id"),
"tool_id": _normalize_required_text(row.get("action_id")),
"timestamp": _coerce_datetime(row.get("time"), fallback_now=True),
"session_id": session_id,
"tool_name": _normalize_required_text(row.get("action_name"), default="unknown"),
"tool_reasoning": _normalize_optional_text(row.get("action_reasoning")),
"tool_data": _normalize_optional_text(row.get("action_data")),
"tool_builtin_prompt": None,
"tool_display_prompt": _normalize_optional_text(row.get("action_prompt_display")),
},
)
migrated_count += 1
context.advance_progress(records=1)
_complete_table_progress(context, "tool_records")
return migrated_count
def _migrate_online_time(context: MigrationExecutionContext) -> int:
"""迁移旧版 ``online_time`` 到新版 ``online_time``。
Args:
context: 当前迁移步骤执行上下文。
Returns:
int: 迁移成功的记录数。
"""
connection = context.connection
legacy_table = _load_legacy_table_data(connection, "online_time")
if legacy_table is None:
_complete_table_progress(context, "online_time")
return 0
migrated_count = 0
insert_sql = text(
"""
INSERT OR IGNORE INTO online_time (
id,
timestamp,
duration_minutes,
start_timestamp,
end_timestamp
) VALUES (
:id,
:timestamp,
:duration_minutes,
:start_timestamp,
:end_timestamp
)
"""
)
for row in legacy_table.rows:
connection.execute(
insert_sql,
{
"id": row.get("id"),
"timestamp": _coerce_datetime(row.get("timestamp"), fallback_now=True),
"duration_minutes": _normalize_int(row.get("duration"), default=0),
"start_timestamp": _coerce_datetime(row.get("start_timestamp"), fallback_now=True),
"end_timestamp": _coerce_datetime(row.get("end_timestamp"), fallback_now=True),
},
)
migrated_count += 1
context.advance_progress(records=1)
_complete_table_progress(context, "online_time")
return migrated_count
def _migrate_person_info(context: MigrationExecutionContext) -> int:
"""迁移旧版 ``person_info`` 到新版 ``person_info``。
Args:
context: 当前迁移步骤执行上下文。
Returns:
int: 迁移成功的记录数。
"""
connection = context.connection
legacy_table = _load_legacy_table_data(connection, "person_info")
if legacy_table is None:
_complete_table_progress(context, "person_info")
return 0
migrated_count = 0
insert_sql = text(
"""
INSERT OR IGNORE INTO person_info (
id,
is_known,
person_id,
person_name,
name_reason,
platform,
user_id,
user_nickname,
group_cardname,
memory_points,
know_counts,
first_known_time,
last_known_time
) VALUES (
:id,
:is_known,
:person_id,
:person_name,
:name_reason,
:platform,
:user_id,
:user_nickname,
:group_cardname,
:memory_points,
:know_counts,
:first_known_time,
:last_known_time
)
"""
)
for row in legacy_table.rows:
first_known_time = _coerce_datetime(row.get("know_times")) or _coerce_datetime(row.get("know_since"))
last_known_time = _coerce_datetime(row.get("last_know")) or _coerce_datetime(row.get("know_since"))
memory_points = _normalize_string_list(row.get("memory_points"))
connection.execute(
insert_sql,
{
"id": row.get("id"),
"is_known": _normalize_bool(row.get("is_known"), default=False),
"person_id": _normalize_required_text(row.get("person_id")),
"person_name": _normalize_optional_text(row.get("person_name")),
"name_reason": _normalize_optional_text(row.get("name_reason")),
"platform": _normalize_required_text(row.get("platform"), default="unknown"),
"user_id": _normalize_required_text(row.get("user_id"), default=""),
"user_nickname": _normalize_required_text(row.get("nickname"), default=""),
"group_cardname": _normalize_group_cardname_json(row.get("group_nick_name")),
"memory_points": json.dumps(memory_points, ensure_ascii=False) if memory_points else None,
"know_counts": 1 if _normalize_bool(row.get("is_known"), default=False) else 0,
"first_known_time": first_known_time,
"last_known_time": last_known_time,
},
)
migrated_count += 1
context.advance_progress(records=1)
_complete_table_progress(context, "person_info")
return migrated_count
def _migrate_expressions(context: MigrationExecutionContext) -> int:
"""迁移旧版 ``expression`` 到新版 ``expressions``。
Args:
context: 当前迁移步骤执行上下文。
Returns:
int: 迁移成功的记录数。
"""
connection = context.connection
legacy_table = _load_legacy_table_data(connection, "expression")
if legacy_table is None:
_complete_table_progress(context, "expressions")
return 0
migrated_count = 0
insert_sql = text(
"""
INSERT OR IGNORE INTO expressions (
id,
situation,
style,
content_list,
count,
last_active_time,
create_time,
session_id,
checked,
rejected,
modified_by
) VALUES (
:id,
:situation,
:style,
:content_list,
:count,
:last_active_time,
:create_time,
:session_id,
:checked,
:rejected,
:modified_by
)
"""
)
for row in legacy_table.rows:
connection.execute(
insert_sql,
{
"id": row.get("id"),
"situation": _normalize_required_text(row.get("situation"), default=""),
"style": _normalize_required_text(row.get("style"), default=""),
"content_list": json.dumps(_normalize_string_list(row.get("content_list")), ensure_ascii=False),
"count": _normalize_int(row.get("count"), default=1),
"last_active_time": _coerce_datetime(row.get("last_active_time"), fallback_now=True),
"create_time": _coerce_datetime(row.get("create_date"), fallback_now=True),
"session_id": _normalize_optional_text(row.get("chat_id")),
"checked": _normalize_bool(row.get("checked"), default=False),
"rejected": _normalize_bool(row.get("rejected"), default=False),
"modified_by": _normalize_modified_by(row.get("modified_by")),
},
)
migrated_count += 1
context.advance_progress(records=1)
_complete_table_progress(context, "expressions")
return migrated_count
def _migrate_jargons(context: MigrationExecutionContext) -> int:
"""迁移旧版 ``jargon`` 到新版 ``jargons``。
Args:
context: 当前迁移步骤执行上下文。
Returns:
int: 迁移成功的记录数。
"""
connection = context.connection
legacy_table = _load_legacy_table_data(connection, "jargon")
if legacy_table is None:
_complete_table_progress(context, "jargons")
return 0
migrated_count = 0
insert_sql = text(
"""
INSERT OR IGNORE INTO jargons (
id,
content,
raw_content,
meaning,
session_id_dict,
count,
is_jargon,
is_complete,
is_global,
last_inference_count,
inference_with_context,
inference_with_content_only
) VALUES (
:id,
:content,
:raw_content,
:meaning,
:session_id_dict,
:count,
:is_jargon,
:is_complete,
:is_global,
:last_inference_count,
:inference_with_context,
:inference_with_content_only
)
"""
)
for row in legacy_table.rows:
count = _normalize_int(row.get("count"), default=0)
connection.execute(
insert_sql,
{
"id": row.get("id"),
"content": _normalize_required_text(row.get("content"), default=""),
"raw_content": json.dumps(_normalize_string_list(row.get("raw_content")), ensure_ascii=False)
if row.get("raw_content") is not None
else None,
"meaning": _normalize_required_text(row.get("meaning")),
"session_id_dict": _build_session_id_dict(row.get("chat_id"), fallback_count=max(count, 1)),
"count": count,
"is_jargon": _normalize_optional_bool(row.get("is_jargon")),
"is_complete": _normalize_bool(row.get("is_complete"), default=False),
"is_global": _normalize_bool(row.get("is_global"), default=False),
"last_inference_count": _normalize_int(row.get("last_inference_count"), default=0),
"inference_with_context": _normalize_optional_text(row.get("inference_with_context")),
"inference_with_content_only": _normalize_optional_text(
row.get("inference_content_only") or row.get("inference_with_content_only")
),
},
)
migrated_count += 1
context.advance_progress(records=1)
_complete_table_progress(context, "jargons")
return migrated_count
def _migrate_chat_history(context: MigrationExecutionContext) -> int:
"""迁移旧版 ``chat_history`` 到新版 ``chat_history``。
Args:
context: 当前迁移步骤执行上下文。
Returns:
int: 迁移成功的记录数。
"""
connection = context.connection
legacy_table = _load_legacy_table_data(connection, "chat_history")
if legacy_table is None:
_complete_table_progress(context, "chat_history")
return 0
migrated_count = 0
insert_sql = text(
"""
INSERT OR IGNORE INTO chat_history (
id,
session_id,
start_timestamp,
end_timestamp,
query_count,
query_forget_count,
original_messages,
participants,
theme,
keywords,
summary
) VALUES (
:id,
:session_id,
:start_timestamp,
:end_timestamp,
:query_count,
:query_forget_count,
:original_messages,
:participants,
:theme,
:keywords,
:summary
)
"""
)
for row in legacy_table.rows:
session_id = _normalize_required_text(row.get("chat_id"))
if session_id:
connection.execute(
insert_sql,
{
"id": row.get("id"),
"session_id": session_id,
"start_timestamp": _coerce_datetime(row.get("start_time"), fallback_now=True),
"end_timestamp": _coerce_datetime(row.get("end_time"), fallback_now=True),
"query_count": _normalize_int(row.get("count"), default=0),
"query_forget_count": _normalize_int(row.get("forget_times"), default=0),
"original_messages": _normalize_required_text(row.get("original_text")),
"participants": _normalize_required_text(row.get("participants"), default="[]"),
"theme": _normalize_required_text(row.get("theme"), default=""),
"keywords": _normalize_required_text(row.get("keywords"), default="[]"),
"summary": _normalize_required_text(row.get("summary"), default=""),
},
)
migrated_count += 1
context.advance_progress(records=1)
_complete_table_progress(context, "chat_history")
return migrated_count
def _migrate_thinking_questions(context: MigrationExecutionContext) -> int:
"""迁移旧版 ``thinking_back`` 到新版 ``thinking_questions``。
Args:
context: 当前迁移步骤执行上下文。
Returns:
int: 迁移成功的记录数。
"""
connection = context.connection
legacy_table = _load_legacy_table_data(connection, "thinking_back")
if legacy_table is None:
_complete_table_progress(context, "thinking_questions")
return 0
migrated_count = 0
insert_sql = text(
"""
INSERT OR IGNORE INTO thinking_questions (
id,
question,
context,
found_answer,
answer,
thinking_steps,
created_timestamp,
updated_timestamp
) VALUES (
:id,
:question,
:context,
:found_answer,
:answer,
:thinking_steps,
:created_timestamp,
:updated_timestamp
)
"""
)
for row in legacy_table.rows:
connection.execute(
insert_sql,
{
"id": row.get("id"),
"question": _normalize_required_text(row.get("question"), default=""),
"context": _normalize_optional_text(row.get("context")),
"found_answer": _normalize_bool(row.get("found_answer"), default=False),
"answer": _normalize_optional_text(row.get("answer")),
"thinking_steps": _normalize_optional_text(row.get("thinking_steps")),
"created_timestamp": _coerce_datetime(row.get("create_time"), fallback_now=True),
"updated_timestamp": _coerce_datetime(row.get("update_time"), fallback_now=True),
},
)
migrated_count += 1
context.advance_progress(records=1)
_complete_table_progress(context, "thinking_questions")
return migrated_count