#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Data Modules 单元测试 """ import pytest import asyncio import json import tempfile import sys from pathlib import Path from data_modules import ( DataModulesConfig, EntityLinker, StateManager, IndexManager, RAGAdapter, StyleSampler, EntityState, ChapterMeta, SceneMeta, StyleSample, ) import data_modules.index_manager as index_manager_module from data_modules.index_manager import ( EntityMeta, StateChangeMeta, RelationshipMeta, OverrideContractMeta, ChaseDebtMeta, ChapterReadingPowerMeta, ReviewMetrics, WritingChecklistScoreMeta, ) @pytest.fixture def temp_project(): """创建临时项目目录""" with tempfile.TemporaryDirectory() as tmpdir: config = DataModulesConfig.from_project_root(tmpdir) config.ensure_dirs() yield config class TestEntityLinker: """实体链接器测试""" def test_register_and_lookup_alias(self, temp_project): linker = EntityLinker(temp_project) # 先注册实体,否则 aliases JOIN 不会返回 IndexManager(temp_project).upsert_entity( EntityMeta( id="xiaoyan", type="角色", canonical_name="萧炎", current={}, first_appearance=1, last_appearance=1, ) ) # 注册别名 assert linker.register_alias("xiaoyan", "萧炎") assert linker.register_alias("xiaoyan", "小炎子") # 查找 assert linker.lookup_alias("萧炎") == "xiaoyan" assert linker.lookup_alias("小炎子") == "xiaoyan" assert linker.lookup_alias("不存在") is None def test_alias_one_to_many(self, temp_project): """v5.0: 同一别名可映射多个实体(一对多)""" linker = EntityLinker(temp_project) idx = IndexManager(temp_project) idx.upsert_entity( EntityMeta( id="xiaoyan", type="角色", canonical_name="萧炎", current={}, first_appearance=1, last_appearance=1, ) ) idx.upsert_entity( EntityMeta( id="other_person", type="角色", canonical_name="萧炎", current={}, first_appearance=1, last_appearance=1, ) ) linker.register_alias("xiaoyan", "萧炎", "角色") # v5.0: 同一别名可绑定不同实体(一对多) assert linker.register_alias("other_person", "萧炎", "角色") # 查找所有匹配 entries = linker.lookup_alias_all("萧炎") assert len(entries) == 2 def test_get_all_aliases(self, temp_project): linker = EntityLinker(temp_project) IndexManager(temp_project).upsert_entity( EntityMeta( id="xiaoyan", type="角色", canonical_name="萧炎", current={}, first_appearance=1, last_appearance=1, ) ) linker.register_alias("xiaoyan", "萧炎") linker.register_alias("xiaoyan", "小炎子") linker.register_alias("xiaoyan", "炎哥") aliases = linker.get_all_aliases("xiaoyan") assert len(aliases) == 3 assert "萧炎" in aliases def test_confidence_evaluation(self, temp_project): linker = EntityLinker(temp_project) # 高置信度 action, adopt, warning = linker.evaluate_confidence(0.9) assert action == "auto" assert adopt is True assert warning is None # 中置信度 action, adopt, warning = linker.evaluate_confidence(0.6) assert action == "warn" assert adopt is True assert warning is not None # 低置信度 action, adopt, warning = linker.evaluate_confidence(0.3) assert action == "manual" assert adopt is False def test_process_uncertain(self, temp_project): linker = EntityLinker(temp_project) result = linker.process_uncertain( mention="那位前辈", candidates=["yaolao", "elder_zhang"], suggested="yaolao", confidence=0.7 ) assert result.mention == "那位前辈" assert result.entity_id == "yaolao" assert result.adopted is True assert result.warning is not None class TestStateManager: """状态管理器测试""" def test_add_and_get_entity(self, temp_project): manager = StateManager(temp_project) entity = EntityState( id="xiaoyan", name="萧炎", type="角色", tier="核心" ) assert manager.add_entity(entity) # 获取实体 result = manager.get_entity("xiaoyan") assert result is not None assert result["canonical_name"] == "萧炎" def test_update_entity(self, temp_project): manager = StateManager(temp_project) entity = EntityState(id="xiaoyan", name="萧炎", type="角色") manager.add_entity(entity) # 更新属性 (v5.0: attributes 存在 current 字段) manager.update_entity("xiaoyan", {"current": {"realm": "斗师"}}) result = manager.get_entity("xiaoyan") assert result["current"]["realm"] == "斗师" def test_record_state_change(self, temp_project): manager = StateManager(temp_project) entity = EntityState(id="xiaoyan", name="萧炎", type="角色") manager.add_entity(entity) manager.record_state_change( entity_id="xiaoyan", field="realm", old_value="斗者", new_value="斗师", reason="突破", chapter=100 ) changes = manager.get_state_changes("xiaoyan") assert len(changes) == 1 assert changes[0]["new_value"] == "斗师" def test_add_relationship(self, temp_project): manager = StateManager(temp_project) manager.add_relationship( from_entity="xiaoyan", to_entity="yaolao", rel_type="师徒", description="药老收萧炎为徒", chapter=10 ) rels = manager.get_relationships("xiaoyan") assert len(rels) == 1 assert rels[0]["type"] == "师徒" def test_process_chapter_result(self, temp_project): manager = StateManager(temp_project) result = { "entities_appeared": [ {"id": "xiaoyan", "mentions": ["萧炎", "他"]} ], "entities_new": [ {"suggested_id": "hongyi_girl", "name": "红衣女子", "type": "角色", "tier": "装饰"} ], "state_changes": [ {"entity_id": "xiaoyan", "field": "realm", "old": "斗者", "new": "斗师", "reason": "突破"} ], "relationships_new": [ {"from": "xiaoyan", "to": "hongyi_girl", "type": "相识", "description": "初次见面"} ] } # 先添加萧炎 manager.add_entity(EntityState(id="xiaoyan", name="萧炎", type="角色")) warnings = manager.process_chapter_result(100, result) # 验证新实体被添加 assert manager.get_entity("hongyi_girl") is not None # 验证状态变化 changes = manager.get_state_changes("xiaoyan") assert len(changes) == 1 # 验证进度更新 assert manager.get_current_chapter() == 100 def test_save_state_with_init_project_schema(self, temp_project): """回归:init_project 生成的 state.json,StateManager 仍应可写入。(v5.1 SQLite-only)""" # v5.1: state.json 不再包含 entities_v3/alias_index,实体数据在 SQLite init_state = { "project_info": {"title": "测试书名", "genre": "修仙/玄幻", "created_at": "2026-01-01"}, "progress": {"current_chapter": 0, "total_words": 0, "last_updated": "2026-01-01 00:00:00"}, "protagonist_state": {"name": "测试主角"}, "relationships": {}, "world_settings": {"power_system": [], "factions": [], "locations": []}, "plot_threads": {"active_threads": [], "foreshadowing": []}, "review_checkpoints": [], "strand_tracker": {"current_dominant": "quest", "history": []}, } temp_project.state_file.write_text(json.dumps(init_state, ensure_ascii=False, indent=2), encoding="utf-8") manager = StateManager(temp_project) manager.update_progress(5, words=100) manager.save_state() saved = json.loads(temp_project.state_file.read_text(encoding="utf-8")) assert "meta" not in saved assert saved["progress"]["current_chapter"] == 5 assert saved["progress"]["total_words"] == 100 # v5.1: entities_v3/alias_index 不再在 state.json 中 def test_save_state_preserves_unrelated_fields(self, temp_project): """回归:仅写入增量,不应覆盖/丢失其他模块维护的字段。(v5.1 SQLite-only)""" init_state = { "project_info": {"title": "测试书名", "genre": "修仙/玄幻", "created_at": "2026-01-01"}, "progress": {"current_chapter": 10, "total_words": 1000, "last_updated": "2026-01-01 00:00:00"}, "protagonist_state": {"name": "测试主角"}, "relationships": {"allies": ["药老"], "enemies": []}, "world_settings": {"power_system": [], "factions": [], "locations": []}, "plot_threads": {"active_threads": [{"id": "t1", "title": "主线"}], "foreshadowing": []}, "review_checkpoints": [], "strand_tracker": {"current_dominant": "quest", "history": []}, "custom_field": {"keep": True}, } temp_project.state_file.write_text(json.dumps(init_state, ensure_ascii=False, indent=2), encoding="utf-8") manager = StateManager(temp_project) manager.add_entity(EntityState(id="xiaoyan", name="萧炎", type="角色", tier="核心")) manager.save_state() saved = json.loads(temp_project.state_file.read_text(encoding="utf-8")) assert saved.get("custom_field", {}).get("keep") is True assert saved.get("plot_threads", {}).get("active_threads", [])[0].get("id") == "t1" assert isinstance(saved.get("relationships"), dict) def test_disambiguation_feedback_persisted(self, temp_project): """回归:中/低置信度消歧必须对 Writer 可见(写入 state.json)。""" manager = StateManager(temp_project) result = { "entities_appeared": [], "entities_new": [], "state_changes": [], "relationships_new": [], "uncertain": [ { "mention": "那位前辈", "context": "那位前辈看了他一眼", "candidates": [{"type": "角色", "id": "yaolao"}, {"type": "角色", "id": "elder_zhang"}], "suggested": "yaolao", "confidence": 0.6, }, { "mention": "宗主", "context": "宗主出现在血煞秘境", "candidates": ["xueshazonzhu", "lintian"], "suggested": "xueshazonzhu", "confidence": 0.4, }, ], } warnings = manager.process_chapter_result(100, result) manager.save_state() state = json.loads(temp_project.state_file.read_text(encoding="utf-8")) assert isinstance(state.get("disambiguation_warnings"), list) assert isinstance(state.get("disambiguation_pending"), list) assert len(state["disambiguation_warnings"]) == 1 assert len(state["disambiguation_pending"]) == 1 warn = state["disambiguation_warnings"][0] assert warn.get("chapter") == 100 assert warn.get("mention") == "那位前辈" assert warn.get("chosen_id") == "yaolao" pending = state["disambiguation_pending"][0] assert pending.get("chapter") == 100 assert pending.get("mention") == "宗主" # 返回值也应包含可见警告,便于 CLI/日志透出 assert any("消歧警告" in w for w in warnings) assert any("需人工确认" in w for w in warnings) class TestIndexManager: """索引管理器测试""" def test_add_and_get_chapter(self, temp_project): manager = IndexManager(temp_project) meta = ChapterMeta( chapter=100, title="突破", location="天云宗", word_count=3500, characters=["xiaoyan", "yaolao"] ) manager.add_chapter(meta) result = manager.get_chapter(100) assert result is not None assert result["title"] == "突破" assert "xiaoyan" in result["characters"] def test_add_scenes(self, temp_project): manager = IndexManager(temp_project) scenes = [ SceneMeta(chapter=100, scene_index=1, start_line=1, end_line=50, location="天云宗·闭关室", summary="萧炎闭关突破", characters=["xiaoyan"]), SceneMeta(chapter=100, scene_index=2, start_line=51, end_line=100, location="天云宗·演武场", summary="展示实力", characters=["xiaoyan", "lintian"]) ] manager.add_scenes(100, scenes) result = manager.get_scenes(100) assert len(result) == 2 assert result[0]["location"] == "天云宗·闭关室" def test_record_appearance(self, temp_project): manager = IndexManager(temp_project) manager.record_appearance("xiaoyan", 100, ["萧炎", "他"], 0.95) manager.record_appearance("yaolao", 100, ["药老"], 0.92) appearances = manager.get_chapter_appearances(100) assert len(appearances) == 2 entity_history = manager.get_entity_appearances("xiaoyan") assert len(entity_history) == 1 def test_search_scenes_by_location(self, temp_project): manager = IndexManager(temp_project) scenes = [ SceneMeta(chapter=100, scene_index=1, start_line=1, end_line=50, location="天云宗·闭关室", summary="闭关", characters=[]), SceneMeta(chapter=101, scene_index=1, start_line=1, end_line=50, location="天云宗·大殿", summary="议事", characters=[]) ] manager.add_scenes(100, scenes[:1]) manager.add_scenes(101, scenes[1:]) results = manager.search_scenes_by_location("天云宗") assert len(results) == 2 def test_get_stats(self, temp_project): manager = IndexManager(temp_project) manager.upsert_entity( EntityMeta( id="xiaoyan", type="角色", canonical_name="萧炎", current={}, first_appearance=1, last_appearance=1, ) ) manager.add_chapter(ChapterMeta(chapter=1, title="", location="", word_count=1000, characters=[])) manager.add_scenes(1, [SceneMeta(chapter=1, scene_index=1, start_line=1, end_line=50, location="", summary="", characters=[])]) manager.record_appearance("xiaoyan", 1, [], 1.0) stats = manager.get_stats() assert stats["chapters"] == 1 assert stats["scenes"] == 1 assert stats["entities"] == 1 def test_entity_alias_and_relationships(self, temp_project): manager = IndexManager(temp_project) entity_main = EntityMeta( id="xiaoyan", type="角色", canonical_name="萧炎", tier="核心", desc="主角", current={"realm": "斗者"}, first_appearance=1, last_appearance=1, is_protagonist=True, ) entity_other = EntityMeta( id="yaolao", type="角色", canonical_name="药老", tier="重要", current={}, first_appearance=1, last_appearance=2, ) assert manager.upsert_entity(entity_main) is True assert manager.upsert_entity(entity_other) is True # 更新 current assert manager.update_entity_current("xiaoyan", {"realm": "斗师"}) is True entity = manager.get_entity("xiaoyan") assert entity["current_json"]["realm"] == "斗师" # 元数据更新 entity_main.desc = "主角(更新)" entity_main.last_appearance = 3 assert manager.upsert_entity(entity_main, update_metadata=True) is False # 别名管理 assert manager.register_alias("炎帝", "xiaoyan", "角色") assert "炎帝" in manager.get_entity_aliases("xiaoyan") assert manager.get_entities_by_alias("炎帝")[0]["id"] == "xiaoyan" assert manager.remove_alias("炎帝", "xiaoyan") assert manager.get_entities_by_alias("炎帝") == [] # 类型/层级/核心/主角查询 assert len(manager.get_entities_by_type("角色")) == 2 assert any(e["id"] == "xiaoyan" for e in manager.get_entities_by_tier("核心")) assert any(e["id"] == "xiaoyan" for e in manager.get_core_entities()) assert manager.get_protagonist()["id"] == "xiaoyan" # 归档实体 assert manager.archive_entity("yaolao") is True assert all(e["id"] != "yaolao" for e in manager.get_entities_by_type("角色")) assert any( e["id"] == "yaolao" for e in manager.get_entities_by_type("角色", include_archived=True) ) # 关系管理(新建 + 更新) rel = RelationshipMeta( from_entity="xiaoyan", to_entity="yaolao", type="师徒", description="收徒", chapter=1, ) assert manager.upsert_relationship(rel) is True rel.description = "收徒(更新)" rel.chapter = 2 assert manager.upsert_relationship(rel) is False assert len(manager.get_entity_relationships("xiaoyan", "from")) == 1 assert len(manager.get_entity_relationships("yaolao", "to")) == 1 assert len(manager.get_entity_relationships("xiaoyan", "both")) >= 1 assert len(manager.get_relationship_between("xiaoyan", "yaolao")) == 1 assert len(manager.get_recent_relationships(limit=5)) >= 1 def test_state_changes_and_appearances(self, temp_project): manager = IndexManager(temp_project) entity = EntityMeta( id="xiaoyan", type="角色", canonical_name="萧炎", current={}, first_appearance=1, last_appearance=1, ) manager.upsert_entity(entity) change = StateChangeMeta( entity_id="xiaoyan", field="realm", old_value="斗者", new_value="斗师", reason="突破", chapter=2, ) change_id = manager.record_state_change(change) assert change_id > 0 assert len(manager.get_entity_state_changes("xiaoyan")) == 1 assert len(manager.get_recent_state_changes(limit=5)) == 1 assert len(manager.get_chapter_state_changes(2)) == 1 # 出场记录(含 skip_if_exists 分支) manager.record_appearance("xiaoyan", 2, ["萧炎"], 1.0) manager.record_appearance("xiaoyan", 2, ["萧炎"], 1.0, skip_if_exists=True) manager.record_appearance("xiaoyan", 3, ["萧炎"], 1.0) assert len(manager.get_entity_appearances("xiaoyan")) == 2 assert len(manager.get_recent_appearances(limit=5)) >= 1 assert len(manager.get_chapter_appearances(2)) == 1 def test_chapter_queries_and_bulk(self, temp_project): manager = IndexManager(temp_project) manager.add_chapter( ChapterMeta( chapter=1, title="起点", location="天云宗", word_count=1000, characters=["xiaoyan"], ) ) manager.add_chapter( ChapterMeta( chapter=2, title="突破", location="天云宗", word_count=1200, characters=["xiaoyan", "yaolao"], ) ) recent = manager.get_recent_chapters() assert recent[0]["chapter"] == 2 scenes = [ SceneMeta( chapter=1, scene_index=1, start_line=1, end_line=50, location="天云宗·闭关室", summary="闭关", characters=["xiaoyan"], ), SceneMeta( chapter=1, scene_index=2, start_line=51, end_line=80, location="天云宗·演武场", summary="练习", characters=["xiaoyan"], ), ] manager.add_scenes(1, scenes) assert len(manager.get_scenes(1)) == 2 results = manager.search_scenes_by_location("天云宗") assert len(results) >= 2 stats = manager.process_chapter_data( chapter=10, title="试炼", location="秘境", word_count=1500, entities=[{"id": "xiaoyan", "type": "角色", "mentions": ["萧炎"]}], scenes=[{"index": 1, "start_line": 1, "end_line": 20, "location": "秘境", "summary": "开场", "characters": ["xiaoyan"]}], ) assert stats["chapters"] == 1 assert stats["scenes"] == 1 assert stats["appearances"] == 1 def test_debt_and_override_flow(self, temp_project): manager = IndexManager(temp_project) contract = OverrideContractMeta( chapter=1, constraint_type="SOFT_MICROPAYOFF", constraint_id="micropayoff_count", rationale_type="TRANSITIONAL_SETUP", rationale_text="铺垫需要", payback_plan="下章补偿", due_chapter=3, status="pending", ) contract_id = manager.create_override_contract(contract) assert contract_id > 0 # pending 状态允许更新 contract.rationale_text = "调整理由" contract.due_chapter = 4 assert manager.create_override_contract(contract) == contract_id updated = manager.get_chapter_overrides(1)[0] assert updated["rationale_text"] == "调整理由" assert updated["due_chapter"] == 4 # 终态冻结 contract.status = "fulfilled" contract.rationale_text = "终态理由" contract.due_chapter = 5 manager.create_override_contract(contract) frozen = manager.get_chapter_overrides(1)[0] assert frozen["status"] == "fulfilled" assert frozen["rationale_text"] == "终态理由" # 试图回写 pending,不应改动终态字段 contract.status = "pending" contract.rationale_text = "不应生效" contract.due_chapter = 99 manager.create_override_contract(contract) frozen_again = manager.get_chapter_overrides(1)[0] assert frozen_again["status"] == "fulfilled" assert frozen_again["rationale_text"] == "终态理由" assert frozen_again["due_chapter"] == 5 debt_contract_id = manager.create_override_contract( OverrideContractMeta( chapter=2, constraint_type="SOFT_HOOK_STRENGTH", constraint_id="hook_strength", rationale_type="ARC_TIMING", rationale_text="节奏安排", payback_plan="后续补强", due_chapter=4, status="pending", ) ) debt1 = ChaseDebtMeta( debt_type="hook_strength", original_amount=1.0, current_amount=1.0, interest_rate=0.1, source_chapter=1, due_chapter=2, override_contract_id=debt_contract_id, status="active", ) debt2 = ChaseDebtMeta( debt_type="micropayoff", original_amount=2.0, current_amount=2.0, interest_rate=0.2, source_chapter=1, due_chapter=2, override_contract_id=debt_contract_id, status="active", ) debt_id_1 = manager.create_debt(debt1) debt_id_2 = manager.create_debt(debt2) assert len(manager.get_active_debts()) == 2 assert manager.get_total_debt_balance() > 0 # 计息与幂等保护 result = manager.accrue_interest(current_chapter=2) assert result["debts_processed"] == 2 result_again = manager.accrue_interest(current_chapter=2) assert result_again["skipped_already_processed"] == 2 # 逾期标记 result_overdue = manager.accrue_interest(current_chapter=3) assert result_overdue["new_overdues"] >= 1 overdue = manager.get_overdue_debts(current_chapter=3) assert any(d["status"] == "overdue" for d in overdue) history = manager.get_debt_history(debt_id_1) assert any(h["event_type"] == "interest_accrued" for h in history) # 金额校验 error = manager.pay_debt(debt_id_1, 0, chapter=3) assert "error" in error # 部分偿还 partial = manager.pay_debt(debt_id_1, 0.5, chapter=3) assert partial["fully_paid"] is False # 完全偿还(仍有另一笔债务时不应 fulfilled) full = manager.pay_debt(debt_id_1, 100, chapter=3) assert full["fully_paid"] is True assert full["override_fulfilled"] is False # 清空最后一笔债务 -> fulfilled full2 = manager.pay_debt(debt_id_2, 100, chapter=3) assert full2["fully_paid"] is True assert full2["override_fulfilled"] is True def test_reading_power_and_debt_summary(self, temp_project): manager = IndexManager(temp_project) # 追读力元数据 manager.save_chapter_reading_power( ChapterReadingPowerMeta( chapter=1, hook_type="渴望钩", hook_strength="strong", coolpoint_patterns=["打脸权威", "身份掉马"], micropayoffs=["能力兑现"], hard_violations=[], soft_suggestions=["SOFT_HOOK_STRENGTH"], is_transition=False, override_count=1, debt_balance=1.5, ) ) manager.save_chapter_reading_power( ChapterReadingPowerMeta( chapter=2, hook_type="悬念钩", hook_strength="medium", coolpoint_patterns=["身份掉马"], micropayoffs=["信息兑现"], hard_violations=["HARD-004"], soft_suggestions=[], is_transition=True, override_count=0, debt_balance=0.0, ) ) record = manager.get_chapter_reading_power(1) assert record["hook_type"] == "渴望钩" assert "身份掉马" in record["coolpoint_patterns"] assert record["is_transition"] == 0 # SQLite 存储为 0/1 assert manager.get_chapter_reading_power(999) is None recent = manager.get_recent_reading_power(limit=2) assert len(recent) == 2 pattern_stats = manager.get_pattern_usage_stats(last_n_chapters=5) assert pattern_stats.get("身份掉马") == 2 hook_stats = manager.get_hook_type_stats(last_n_chapters=5) assert hook_stats.get("渴望钩") == 1 # 债务汇总 contract_id = manager.create_override_contract( OverrideContractMeta( chapter=3, constraint_type="SOFT_HOOK_STRENGTH", constraint_id="hook_strength", rationale_type="ARC_TIMING", rationale_text="节奏安排", payback_plan="后续补强", due_chapter=5, status="pending", ) ) manager.create_debt( ChaseDebtMeta( debt_type="hook_strength", original_amount=1.0, current_amount=1.0, interest_rate=0.1, source_chapter=3, due_chapter=4, override_contract_id=contract_id, status="active", ) ) manager.create_debt( ChaseDebtMeta( debt_type="micropayoff", original_amount=2.0, current_amount=2.0, interest_rate=0.1, source_chapter=3, due_chapter=4, override_contract_id=0, status="overdue", ) ) summary = manager.get_debt_summary() assert summary["active_debts"] == 1 assert summary["overdue_debts"] == 1 assert summary["pending_overrides"] >= 1 assert summary["total_balance"] == summary["active_total"] + summary["overdue_total"] pending = manager.get_pending_overrides() assert any(o["id"] == contract_id for o in pending) pending_before = manager.get_pending_overrides(before_chapter=10) assert any(o["id"] == contract_id for o in pending_before) overdue_overrides = manager.get_overdue_overrides(current_chapter=6) assert any(o["id"] == contract_id for o in overdue_overrides) other_id = manager.create_override_contract( OverrideContractMeta( chapter=4, constraint_type="SOFT_EXPECTATION_OVERLOAD", constraint_id="expectation_count", rationale_type="EDITORIAL_INTENT", rationale_text="作者意图", payback_plan="后续补足", due_chapter=6, status="pending", ) ) assert manager.fulfill_override(other_id) is True assert manager.get_chapter_overrides(4)[0]["status"] == "fulfilled" def test_review_metrics_and_trends(self, temp_project): manager = IndexManager(temp_project) manager.save_review_metrics( ReviewMetrics( start_chapter=1, end_chapter=1, overall_score=48, dimension_scores={ "爽点密度": 8, "设定一致性": 7, "节奏控制": 7, "人物塑造": 8, "连贯性": 9, "追读力": 9, }, severity_counts={"critical": 0, "high": 1, "medium": 2, "low": 0}, critical_issues=[], report_file="审查报告/第1-1章审查报告.md", ) ) manager.save_review_metrics( ReviewMetrics( start_chapter=2, end_chapter=2, overall_score=42, dimension_scores={ "爽点密度": 6, "设定一致性": 8, "节奏控制": 7, "人物塑造": 7, "连贯性": 7, "追读力": 7, }, severity_counts={"critical": 1, "high": 0, "medium": 1, "low": 2}, critical_issues=["设定自相矛盾"], report_file="审查报告/第2-2章审查报告.md", ) ) recent = manager.get_recent_review_metrics(limit=2) assert len(recent) == 2 trends = manager.get_review_trend_stats(last_n=5) assert trends["count"] == 2 assert trends["overall_avg"] > 0 assert "爽点密度" in trends["dimension_avg"] def test_writing_checklist_score_persistence_and_trend(self, temp_project): manager = IndexManager(temp_project) manager.save_writing_checklist_score( WritingChecklistScoreMeta( chapter=10, template="plot", total_items=6, required_items=4, completed_items=4, completed_required=3, total_weight=6.2, completed_weight=4.1, completion_rate=0.6667, score=78.5, score_breakdown={"weighted_completion_rate": 0.66}, pending_items=["段末留钩"], ) ) manager.save_writing_checklist_score( WritingChecklistScoreMeta( chapter=11, template="plot", total_items=6, required_items=4, completed_items=5, completed_required=4, total_weight=6.2, completed_weight=5.4, completion_rate=0.8333, score=86.0, score_breakdown={"weighted_completion_rate": 0.87}, pending_items=[], ) ) one = manager.get_writing_checklist_score(10) assert one is not None assert one["chapter"] == 10 assert one["score"] == 78.5 recent = manager.get_recent_writing_checklist_scores(limit=2) assert len(recent) == 2 assert recent[0]["chapter"] == 11 trend = manager.get_writing_checklist_score_trend(last_n=5) assert trend["count"] == 2 assert trend["score_avg"] > 0 assert trend["completion_avg"] > 0 def test_index_manager_cli(self, temp_project, monkeypatch, capsys): root = str(temp_project.project_root) manager = IndexManager(temp_project) # 基础数据 manager.upsert_entity( EntityMeta( id="xiaoyan", type="角色", canonical_name="萧炎", tier="核心", current={"realm": "斗者"}, first_appearance=1, last_appearance=1, is_protagonist=True, ) ) manager.upsert_entity( EntityMeta( id="yaolao", type="角色", canonical_name="药老", tier="重要", current={}, first_appearance=1, last_appearance=2, ) ) manager.register_alias("炎帝", "xiaoyan", "角色") manager.add_chapter( ChapterMeta( chapter=1, title="起点", location="天云宗", word_count=1000, characters=["xiaoyan"], ) ) manager.add_scenes( 1, [ SceneMeta( chapter=1, scene_index=1, start_line=1, end_line=20, location="天云宗·闭关室", summary="闭关", characters=["xiaoyan"], ) ], ) manager.record_appearance("xiaoyan", 1, ["萧炎"], 1.0) manager.record_state_change( StateChangeMeta( entity_id="xiaoyan", field="realm", old_value="斗者", new_value="斗师", reason="突破", chapter=1, ) ) manager.upsert_relationship( RelationshipMeta( from_entity="xiaoyan", to_entity="yaolao", type="师徒", description="收徒", chapter=1, ) ) # 追读力与债务 manager.save_chapter_reading_power( ChapterReadingPowerMeta( chapter=1, hook_type="渴望钩", hook_strength="medium", coolpoint_patterns=["身份掉马"], micropayoffs=["能力兑现"], hard_violations=[], soft_suggestions=[], ) ) contract_id = manager.create_override_contract( OverrideContractMeta( chapter=1, constraint_type="SOFT_HOOK_STRENGTH", constraint_id="hook_strength", rationale_type="ARC_TIMING", rationale_text="节奏安排", payback_plan="后续补强", due_chapter=2, status="pending", ) ) debt_id = manager.create_debt( ChaseDebtMeta( debt_type="hook_strength", original_amount=1.0, current_amount=1.0, interest_rate=0.1, source_chapter=1, due_chapter=2, override_contract_id=contract_id, status="active", ) ) def run_cli(args): monkeypatch.setattr(sys, "argv", ["index_manager"] + args) index_manager_module.main() # 基础命令 run_cli(["--project-root", root, "stats"]) run_cli(["--project-root", root, "get-chapter", "--chapter", "1"]) run_cli(["--project-root", root, "get-chapter", "--chapter", "99"]) run_cli(["--project-root", root, "recent-appearances", "--limit", "5"]) run_cli(["--project-root", root, "entity-appearances", "--entity", "xiaoyan", "--limit", "5"]) run_cli(["--project-root", root, "search-scenes", "--location", "天云宗", "--limit", "5"]) # 处理章节 run_cli( [ "--project-root", root, "process-chapter", "--chapter", "2", "--title", "试炼", "--location", "秘境", "--word-count", "1200", "--entities", json.dumps([{"id": "xiaoyan", "mentions": ["萧炎"]}], ensure_ascii=False), "--scenes", json.dumps( [ { "index": 1, "start_line": 1, "end_line": 10, "location": "秘境", "summary": "开场", "characters": ["xiaoyan"], } ], ensure_ascii=False, ), ] ) # v5.1 命令 run_cli(["--project-root", root, "get-entity", "--id", "xiaoyan"]) run_cli(["--project-root", root, "get-entity", "--id", "missing"]) run_cli(["--project-root", root, "get-core-entities"]) run_cli(["--project-root", root, "get-protagonist"]) run_cli( ["--project-root", root, "get-entities-by-type", "--type", "角色", "--include-archived"] ) run_cli(["--project-root", root, "get-by-alias", "--alias", "炎帝"]) run_cli(["--project-root", root, "get-by-alias", "--alias", "不存在"]) run_cli(["--project-root", root, "get-aliases", "--entity", "xiaoyan"]) run_cli(["--project-root", root, "register-alias", "--alias", "炎哥", "--entity", "xiaoyan", "--type", "角色"]) run_cli(["--project-root", root, "get-relationships", "--entity", "xiaoyan", "--direction", "from"]) run_cli(["--project-root", root, "get-state-changes", "--entity", "xiaoyan", "--limit", "20"]) run_cli( [ "--project-root", root, "upsert-entity", "--data", json.dumps( { "id": "lintian", "type": "角色", "canonical_name": "林天", "tier": "装饰", "current": {"realm": "斗者"}, }, ensure_ascii=False, ), ] ) run_cli( [ "--project-root", root, "upsert-relationship", "--data", json.dumps( { "from_entity": "xiaoyan", "to_entity": "lintian", "type": "相识", "description": "初见", "chapter": 2, }, ensure_ascii=False, ), ] ) run_cli( [ "--project-root", root, "record-state-change", "--data", json.dumps( { "entity_id": "xiaoyan", "field": "realm", "old_value": "斗者", "new_value": "斗师", "reason": "突破", "chapter": 2, }, ensure_ascii=False, ), ] ) # v5.3 命令 run_cli(["--project-root", root, "get-debt-summary"]) run_cli(["--project-root", root, "get-recent-reading-power", "--limit", "5"]) run_cli(["--project-root", root, "get-chapter-reading-power", "--chapter", "1"]) run_cli(["--project-root", root, "get-chapter-reading-power", "--chapter", "99"]) run_cli(["--project-root", root, "get-pattern-usage-stats", "--last-n", "5"]) run_cli(["--project-root", root, "get-hook-type-stats", "--last-n", "5"]) run_cli(["--project-root", root, "get-pending-overrides"]) run_cli(["--project-root", root, "get-overdue-overrides", "--current-chapter", "3"]) run_cli(["--project-root", root, "get-active-debts"]) run_cli(["--project-root", root, "get-overdue-debts", "--current-chapter", "3"]) run_cli(["--project-root", root, "accrue-interest", "--current-chapter", "3"]) run_cli(["--project-root", root, "pay-debt", "--debt-id", str(debt_id), "--amount", "0", "--chapter", "3"]) run_cli(["--project-root", root, "pay-debt", "--debt-id", str(debt_id), "--amount", "5", "--chapter", "3"]) run_cli( [ "--project-root", root, "create-override-contract", "--data", json.dumps( { "chapter": 3, "constraint_type": "SOFT_MICROPAYOFF", "constraint_id": "micropayoff_count", "rationale_type": "TRANSITIONAL_SETUP", "rationale_text": "铺垫", "payback_plan": "后续补偿", "due_chapter": 4, }, ensure_ascii=False, ), ] ) run_cli( [ "--project-root", root, "create-debt", "--data", json.dumps( { "debt_type": "micropayoff", "original_amount": 1.0, "current_amount": 1.0, "interest_rate": 0.1, "source_chapter": 3, "due_chapter": 4, "override_contract_id": contract_id, }, ensure_ascii=False, ), ] ) run_cli(["--project-root", root, "fulfill-override", "--contract-id", str(contract_id)]) run_cli( [ "--project-root", root, "save-chapter-reading-power", "--data", json.dumps( { "chapter": 3, "hook_type": "悬念钩", "hook_strength": "medium", "coolpoint_patterns": ["打脸权威"], "micropayoffs": ["信息兑现"], "hard_violations": [], "soft_suggestions": [], "is_transition": False, "override_count": 0, "debt_balance": 0.0, }, ensure_ascii=False, ), ] ) review_payload = { "start_chapter": 1, "end_chapter": 1, "overall_score": 50, "dimension_scores": { "爽点密度": 8, "设定一致性": 7, "节奏控制": 8, "人物塑造": 8, "连贯性": 9, "追读力": 10, }, "severity_counts": {"critical": 0, "high": 1, "medium": 2, "low": 0}, "critical_issues": [], "report_file": "审查报告/第1-1章审查报告.md", } run_cli( [ "--project-root", root, "save-review-metrics", "--data", json.dumps(review_payload, ensure_ascii=False), ] ) run_cli(["--project-root", root, "get-recent-review-metrics", "--limit", "5"]) run_cli(["--project-root", root, "get-review-trend-stats", "--last-n", "5"]) checklist_payload = { "chapter": 5, "template": "plot", "total_items": 6, "required_items": 4, "completed_items": 4, "completed_required": 3, "total_weight": 6.5, "completed_weight": 4.8, "completion_rate": 0.6667, "score": 79.2, "score_breakdown": {"weighted_completion_rate": 0.73}, "pending_items": ["钩子差异化"], "source": "context_manager", } run_cli( [ "--project-root", root, "save-writing-checklist-score", "--data", json.dumps(checklist_payload, ensure_ascii=False), ] ) run_cli(["--project-root", root, "get-writing-checklist-score", "--chapter", "5"]) run_cli(["--project-root", root, "get-writing-checklist-score", "--chapter", "99"]) run_cli(["--project-root", root, "get-recent-writing-checklist-scores", "--limit", "5"]) run_cli(["--project-root", root, "get-writing-checklist-score-trend", "--last-n", "5"]) capsys.readouterr() class TestStyleSampler: """风格样本测试""" def test_add_and_get_sample(self, temp_project): sampler = StyleSampler(temp_project) sample = StyleSample( id="ch100_s1", chapter=100, scene_type="战斗", content="萧炎一拳轰出...", score=0.85, tags=["战斗", "激烈"] ) assert sampler.add_sample(sample) results = sampler.get_samples_by_type("战斗") assert len(results) == 1 assert results[0].id == "ch100_s1" def test_extract_candidates(self, temp_project): sampler = StyleSampler(temp_project) scenes = [ {"index": 1, "summary": "战斗场景", "content": "萧炎一拳轰出,斗气如虹,直接将对手击退三丈,周围的空气都被震得嗡嗡作响..." + "a" * 200} ] # 低分不提取 candidates = sampler.extract_candidates(100, "", 70, scenes) assert len(candidates) == 0 # 高分提取 candidates = sampler.extract_candidates(100, "", 85, scenes) assert len(candidates) == 1 assert candidates[0].scene_type == "战斗" def test_select_samples_for_chapter(self, temp_project): sampler = StyleSampler(temp_project) # 添加一些样本 for i in range(3): sampler.add_sample(StyleSample( id=f"battle_{i}", chapter=i, scene_type="战斗", content=f"战斗内容 {i}", score=0.9, tags=[] )) samples = sampler.select_samples_for_chapter("本章有一场激烈的战斗") assert len(samples) <= 3 assert all(s.scene_type == "战斗" for s in samples) class TestRAGAdapter: """RAG 适配器测试(不包含 API 调用)""" def test_bm25_search(self, temp_project): adapter = RAGAdapter(temp_project) # 手动插入一些测试数据 with adapter._get_conn() as conn: cursor = conn.cursor() # 插入向量记录(空向量,只测试 BM25) cursor.execute(""" INSERT INTO vectors (chunk_id, chapter, scene_index, content, embedding) VALUES (?, ?, ?, ?, ?) """, ("ch1_s1", 1, 1, "萧炎在天云宗修炼斗气", b"")) cursor.execute(""" INSERT INTO vectors (chunk_id, chapter, scene_index, content, embedding) VALUES (?, ?, ?, ?, ?) """, ("ch1_s2", 1, 2, "药老传授炼药技巧", b"")) conn.commit() # 更新 BM25 索引 adapter._update_bm25_index(cursor, "ch1_s1", "萧炎在天云宗修炼斗气") adapter._update_bm25_index(cursor, "ch1_s2", "药老传授炼药技巧") conn.commit() # BM25 搜索 results = adapter.bm25_search("萧炎修炼", top_k=5) assert len(results) >= 1 assert results[0].chunk_id == "ch1_s1" def test_tokenize(self, temp_project): adapter = RAGAdapter(temp_project) tokens = adapter._tokenize("萧炎hello世界world") assert "萧" in tokens assert "炎" in tokens assert "hello" in tokens assert "world" in tokens if __name__ == "__main__": pytest.main([__file__, "-v"])