feat: initial commit

This commit is contained in:
2026-06-23 20:29:02 +08:00
commit ea8f6066c4
217 changed files with 60754 additions and 0 deletions
@@ -0,0 +1,379 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
migrate_state_to_sqlite.py - 数据迁移脚本 (v5.4)
将 state.json 中的大数据迁移到 SQLite (index.db):
- entities_v3 → entities 表
- alias_index → aliases 表
- state_changes → state_changes 表
- structured_relationships → relationships 表
迁移后 state.json 只保留精简数据 (< 5KB):
- progress
- protagonist_state
- strand_tracker
- disambiguation_warnings/pending
- project_info
- world_settings (骨架)
- plot_threads
- relationships (简化版)
- review_checkpoints
用法:
python -m data_modules.migrate_state_to_sqlite --project-root "D:/wk/斗破苍穹"
python -m data_modules.migrate_state_to_sqlite --project-root "." --dry-run
python -m data_modules.migrate_state_to_sqlite --project-root "." --backup
"""
import json
import shutil
from pathlib import Path
from datetime import datetime
from typing import Dict, Any, List
from .config import get_config, DataModulesConfig
from .sql_state_manager import SQLStateManager, EntityData
def migrate_state_to_sqlite(
config: DataModulesConfig,
dry_run: bool = False,
backup: bool = True,
verbose: bool = True
) -> Dict[str, int]:
"""
执行迁移
参数:
- config: 配置对象
- dry_run: 只分析不实际写入
- backup: 迁移前备份 state.json
- verbose: 打印详细日志
返回: 迁移统计
"""
stats = {
"entities": 0,
"aliases": 0,
"state_changes": 0,
"relationships": 0,
"skipped": 0,
"errors": 0
}
# 读取 state.json
state_file = config.state_file
if not state_file.exists():
if verbose:
print(f"❌ state.json 不存在: {state_file}")
return stats
with open(state_file, 'r', encoding='utf-8') as f:
state = json.load(f)
if verbose:
file_size = state_file.stat().st_size / 1024
print(f"📄 读取 state.json ({file_size:.1f} KB)")
# 备份
if backup and not dry_run:
backup_file = state_file.with_suffix(f".json.backup-{datetime.now().strftime('%Y%m%d_%H%M%S')}")
shutil.copy(state_file, backup_file)
if verbose:
print(f"💾 已备份到: {backup_file}")
# 初始化 SQLStateManager
sql_manager = SQLStateManager(config)
# 1. 迁移 entities_v3
entities_v3 = state.get("entities_v3", {})
if verbose:
print(f"\n🔄 迁移 entities_v3...")
for entity_type, entities in entities_v3.items():
if not isinstance(entities, dict):
continue
for entity_id, entity_data in entities.items():
if not isinstance(entity_data, dict):
stats["skipped"] += 1
continue
try:
entity = EntityData(
id=entity_id,
type=entity_type,
name=entity_data.get("canonical_name", entity_data.get("name", entity_id)),
tier=entity_data.get("tier", "装饰"),
desc=entity_data.get("desc", ""),
current=entity_data.get("current", {}),
aliases=[], # 别名单独处理
first_appearance=entity_data.get("first_appearance", 0),
last_appearance=entity_data.get("last_appearance", 0),
is_protagonist=entity_data.get("is_protagonist", False)
)
if not dry_run:
sql_manager.upsert_entity(entity)
stats["entities"] += 1
if verbose and stats["entities"] % 50 == 0:
print(f" 已迁移 {stats['entities']} 个实体...")
except Exception as e:
stats["errors"] += 1
if verbose:
print(f" ⚠️ 实体迁移失败 {entity_id}: {e}")
if verbose:
print(f" ✅ 实体: {stats['entities']}")
# 2. 迁移 alias_index
alias_index = state.get("alias_index", {})
if verbose:
print(f"\n🔄 迁移 alias_index...")
for alias, entries in alias_index.items():
if not isinstance(entries, list):
continue
for entry in entries:
if not isinstance(entry, dict):
stats["skipped"] += 1
continue
entity_id = entry.get("id")
entity_type = entry.get("type")
if not entity_id or not entity_type:
stats["skipped"] += 1
continue
try:
if not dry_run:
sql_manager.register_alias(alias, entity_id, entity_type)
stats["aliases"] += 1
except Exception as e:
stats["errors"] += 1
if verbose:
print(f" ⚠️ 别名迁移失败 {alias}: {e}")
if verbose:
print(f" ✅ 别名: {stats['aliases']}")
# 3. 迁移 state_changes
state_changes = state.get("state_changes", [])
if verbose:
print(f"\n🔄 迁移 state_changes...")
for change in state_changes:
if not isinstance(change, dict):
stats["skipped"] += 1
continue
try:
entity_id = change.get("entity_id", "")
if not entity_id:
stats["skipped"] += 1
continue
if not dry_run:
sql_manager.record_state_change(
entity_id=entity_id,
field=change.get("field", ""),
old_value=change.get("old", change.get("old_value", "")),
new_value=change.get("new", change.get("new_value", "")),
reason=change.get("reason", ""),
chapter=change.get("chapter", 0)
)
stats["state_changes"] += 1
except Exception as e:
stats["errors"] += 1
if verbose:
print(f" ⚠️ 状态变化迁移失败: {e}")
if verbose:
print(f" ✅ 状态变化: {stats['state_changes']}")
# 4. 迁移 structured_relationships
relationships = state.get("structured_relationships", [])
if verbose:
print(f"\n🔄 迁移 structured_relationships...")
for rel in relationships:
if not isinstance(rel, dict):
stats["skipped"] += 1
continue
try:
from_entity = rel.get("from", rel.get("from_entity", ""))
to_entity = rel.get("to", rel.get("to_entity", ""))
if not from_entity or not to_entity:
stats["skipped"] += 1
continue
if not dry_run:
sql_manager.upsert_relationship(
from_entity=from_entity,
to_entity=to_entity,
type=rel.get("type", "相识"),
description=rel.get("description", ""),
chapter=rel.get("chapter", 0)
)
stats["relationships"] += 1
except Exception as e:
stats["errors"] += 1
if verbose:
print(f" ⚠️ 关系迁移失败: {e}")
if verbose:
print(f" ✅ 关系: {stats['relationships']}")
# 5. 精简 state.json(移除已迁移字段)
if not dry_run:
if verbose:
print(f"\n🔄 精简 state.json...")
# 保留字段
slim_state = {
"project_info": state.get("project_info", {}),
"progress": state.get("progress", {}),
"protagonist_state": state.get("protagonist_state", {}),
"strand_tracker": state.get("strand_tracker", {}),
"world_settings": _slim_world_settings(state.get("world_settings", {})),
"plot_threads": state.get("plot_threads", {}),
"relationships": _slim_relationships(state.get("relationships", {})),
"review_checkpoints": state.get("review_checkpoints", [])[-10:], # 只保留最近10个
"disambiguation_warnings": state.get("disambiguation_warnings", [])[-20:],
"disambiguation_pending": state.get("disambiguation_pending", [])[-10:],
# v5.1 引入标记
"_migrated_to_sqlite": True,
"_migration_timestamp": datetime.now().isoformat()
}
with open(state_file, 'w', encoding='utf-8') as f:
json.dump(slim_state, f, ensure_ascii=False, indent=2)
new_size = state_file.stat().st_size / 1024
if verbose:
print(f" ✅ 精简后: {new_size:.1f} KB")
# 打印统计
if verbose:
print(f"\n" + "=" * 50)
print(f"📊 迁移统计:")
print(f" 实体: {stats['entities']}")
print(f" 别名: {stats['aliases']}")
print(f" 状态变化: {stats['state_changes']}")
print(f" 关系: {stats['relationships']}")
print(f" 跳过: {stats['skipped']}")
print(f" 错误: {stats['errors']}")
if dry_run:
print(f"\n⚠️ 这是 dry-run 模式,实际未写入任何数据")
return stats
def _slim_world_settings(world_settings: Dict) -> Dict:
"""精简 world_settings,只保留骨架"""
if not isinstance(world_settings, dict):
return {}
slim = {}
# power_system: 只保留等级名称
power_system = world_settings.get("power_system", [])
if isinstance(power_system, list):
slim["power_system"] = [
p.get("name") if isinstance(p, dict) else p
for p in power_system[:20] # 最多20个等级
]
# factions: 只保留名称和简述
factions = world_settings.get("factions", [])
if isinstance(factions, list):
slim["factions"] = [
{"name": f.get("name"), "type": f.get("type")}
if isinstance(f, dict) else f
for f in factions[:30] # 最多30个势力
]
# locations: 只保留名称
locations = world_settings.get("locations", [])
if isinstance(locations, list):
slim["locations"] = [
loc.get("name") if isinstance(loc, dict) else loc
for loc in locations[:50] # 最多50个地点
]
return slim
def _slim_relationships(relationships: Dict) -> Dict:
"""精简 relationships,只保留核心关系"""
if not isinstance(relationships, dict):
return {}
# 只保留 relationships 字典本身,不做额外精简
# 因为这个字段本身应该比较小
return relationships
def main():
import argparse
from .cli_output import print_success, print_error
from .index_manager import IndexManager
parser = argparse.ArgumentParser(description="迁移 state.json 到 SQLite (v5.4)")
parser.add_argument("--project-root", type=str, required=True, help="项目根目录")
parser.add_argument("--dry-run", action="store_true", help="只分析不实际写入")
parser.add_argument("--backup", action="store_true", default=True, help="迁移前备份")
parser.add_argument("--no-backup", action="store_true", help="不备份")
parser.add_argument("--quiet", action="store_true", help="安静模式")
args = parser.parse_args()
# 允许传入“工作区根目录”,统一解析到真正的 book project_root(必须包含 .noma/state.json
from project_locator import resolve_project_root
resolved_root = resolve_project_root(args.project_root)
config = DataModulesConfig.from_project_root(resolved_root)
backup = not args.no_backup
logger = IndexManager(config)
tool_name = "migrate_state_to_sqlite"
try:
stats = migrate_state_to_sqlite(
config=config,
dry_run=args.dry_run,
backup=backup,
verbose=False,
)
except Exception as exc:
print_error("MIGRATE_FAILED", str(exc), suggestion="检查 state.json 与 index.db 权限")
try:
logger.log_tool_call(tool_name, False, error_code="MIGRATE_FAILED", error_message=str(exc))
except Exception:
pass
raise SystemExit(1)
if stats.get("errors", 0) > 0:
print_error("MIGRATE_ERRORS", "迁移出现错误", details=stats)
try:
logger.log_tool_call(tool_name, False, error_code="MIGRATE_ERRORS", error_message="迁移出现错误")
except Exception:
pass
raise SystemExit(1)
print_success({"project": str(config.project_root), **stats}, message="migrated")
try:
logger.log_tool_call(tool_name, True)
except Exception:
pass
if __name__ == "__main__":
main()