| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414 |
- #!/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"])
|