Files
novelmaster/noma/scripts/data_modules/tests/test_data_modules.py
T
2026-06-23 20:29:02 +08:00

1415 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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.jsonStateManager 仍应可写入。(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"])