feat: initial commit

This commit is contained in:
2026-06-23 20:29:02 +08:00
commit ea8f6066c4
217 changed files with 60754 additions and 0 deletions
@@ -0,0 +1 @@
# data_modules tests package
@@ -0,0 +1,485 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
API Client tests
"""
import asyncio
import json
import pytest
from data_modules.config import DataModulesConfig
from data_modules.api_client import (
EmbeddingAPIClient,
RerankAPIClient,
ModalAPIClient,
get_client,
)
class FakeResponse:
def __init__(self, status, json_data=None, text_data=""):
self.status = status
self._json = json_data
if text_data:
self._text = text_data
elif json_data is not None:
self._text = json.dumps(json_data, ensure_ascii=False)
else:
self._text = ""
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def json(self):
return self._json
async def text(self):
return self._text
class FakeSession:
def __init__(self, responses):
self._responses = list(responses)
self.closed = False
def post(self, *args, **kwargs):
if not self._responses:
raise AssertionError("No more responses")
resp = self._responses.pop(0)
if isinstance(resp, Exception):
raise resp
return resp
async def close(self):
self.closed = True
@pytest.mark.asyncio
async def test_embedding_client_success_and_retry(tmp_path, monkeypatch):
config = DataModulesConfig.from_project_root(tmp_path)
config.embed_api_type = "openai"
config.api_max_retries = 2
client = EmbeddingAPIClient(config)
responses = [
FakeResponse(500, text_data="err"),
FakeResponse(
200,
json_data={
"data": [
{"embedding": [0.1, 0.2], "index": 1},
{"embedding": [0.3, 0.4], "index": 0},
]
},
),
]
fake_session = FakeSession(responses)
async def fake_get_session():
return fake_session
monkeypatch.setattr(client, "_get_session", fake_get_session)
result = await client.embed(["a", "b"])
assert result == [[0.3, 0.4], [0.1, 0.2]]
assert client.stats.total_calls == 1
assert client.stats.errors == 0
@pytest.mark.asyncio
async def test_embedding_client_timeout_and_error(tmp_path, monkeypatch):
config = DataModulesConfig.from_project_root(tmp_path)
config.embed_api_type = "openai"
config.api_max_retries = 1
client = EmbeddingAPIClient(config)
responses = [asyncio.TimeoutError()]
fake_session = FakeSession(responses)
async def fake_get_session():
return fake_session
monkeypatch.setattr(client, "_get_session", fake_get_session)
result = await client.embed(["x"])
assert result is None
assert client.stats.errors == 1
@pytest.mark.asyncio
async def test_embedding_batch(tmp_path, monkeypatch):
config = DataModulesConfig.from_project_root(tmp_path)
config.embed_batch_size = 2
client = EmbeddingAPIClient(config)
async def fake_embed(texts):
if len(texts) == 2:
return [[1.0, 0.0], [0.0, 1.0]]
return None
monkeypatch.setattr(client, "embed", fake_embed)
result = await client.embed_batch(["a", "b", "c"], skip_failures=True)
assert result[0] is not None
assert result[2] is None
result_fail = await client.embed_batch(["a", "b", "c"], skip_failures=False)
assert result_fail == []
def test_embedding_build_url_and_payload(tmp_path):
config = DataModulesConfig.from_project_root(tmp_path)
config.embed_api_type = "openai"
config.embed_base_url = "https://api.example.com"
client = EmbeddingAPIClient(config)
assert client._build_url().endswith("/v1/embeddings")
payload = client._build_payload(["hi"])
assert payload["model"] == config.embed_model
config.embed_base_url = "https://api.example.com/v1"
assert client._build_url().endswith("/v1/embeddings")
config.embed_base_url = "https://api.example.com/v1/embeddings"
assert client._build_url().endswith("/v1/embeddings")
config.embed_api_type = "modal"
config.embed_base_url = "https://modal.example.com/embed"
assert client._build_url() == "https://modal.example.com/embed"
payload = client._build_payload(["hi"])
assert "encoding_format" not in payload
@pytest.mark.asyncio
async def test_rerank_client_success(tmp_path, monkeypatch):
config = DataModulesConfig.from_project_root(tmp_path)
config.rerank_api_type = "openai"
config.api_max_retries = 1
client = RerankAPIClient(config)
responses = [
FakeResponse(
200,
json_data={"results": [{"index": 0, "relevance_score": 0.9}]},
)
]
fake_session = FakeSession(responses)
async def fake_get_session():
return fake_session
monkeypatch.setattr(client, "_get_session", fake_get_session)
result = await client.rerank("q", ["doc1"], top_n=1)
assert result[0]["index"] == 0
assert client.stats.total_calls == 1
@pytest.mark.asyncio
async def test_rerank_retry_and_empty(tmp_path, monkeypatch):
config = DataModulesConfig.from_project_root(tmp_path)
config.rerank_api_type = "openai"
config.api_max_retries = 2
client = RerankAPIClient(config)
responses = [
FakeResponse(503, text_data="err"),
FakeResponse(
200,
json_data={"results": [{"index": 0, "relevance_score": 0.8}]},
),
]
fake_session = FakeSession(responses)
async def fake_get_session():
return fake_session
monkeypatch.setattr(client, "_get_session", fake_get_session)
result = await client.rerank("q", ["doc1"], top_n=1)
assert result[0]["relevance_score"] == 0.8
assert await client.rerank("q", []) == []
@pytest.mark.asyncio
async def test_modal_client_warmup_and_passthrough(tmp_path, monkeypatch):
config = DataModulesConfig.from_project_root(tmp_path)
client = ModalAPIClient(config)
async def fake_warmup():
return None
async def fake_embed(texts):
return [[0.1, 0.2] for _ in texts]
async def fake_rerank(query, documents, top_n=None):
return [{"index": 0, "relevance_score": 1.0}]
monkeypatch.setattr(client._embed_client, "warmup", fake_warmup)
monkeypatch.setattr(client._rerank_client, "warmup", fake_warmup)
monkeypatch.setattr(client._embed_client, "embed", fake_embed)
monkeypatch.setattr(client._rerank_client, "rerank", fake_rerank)
await client.warmup()
assert client._warmed_up["embed"] is True
assert client._warmed_up["rerank"] is True
emb = await client.embed(["hi"])
assert emb[0] == [0.1, 0.2]
rr = await client.rerank("q", ["doc"])
assert rr[0]["index"] == 0
def test_get_client_singleton(tmp_path):
cfg = DataModulesConfig.from_project_root(tmp_path)
client1 = get_client(cfg)
client2 = get_client()
assert client1 is client2
client3 = get_client(cfg)
assert client3 is not client1
@pytest.mark.asyncio
async def test_embedding_empty_and_error_paths(tmp_path, monkeypatch):
config = DataModulesConfig.from_project_root(tmp_path)
config.embed_api_key = "sk-test"
config.api_max_retries = 1
client = EmbeddingAPIClient(config)
assert await client.embed([]) == []
headers = client._build_headers()
assert headers["Authorization"] == "Bearer sk-test"
fake_session = FakeSession([FakeResponse(400, text_data="bad request")])
async def fake_get_session():
return fake_session
monkeypatch.setattr(client, "_get_session", fake_get_session)
result = await client.embed(["x"])
assert result is None
assert client.stats.errors == 1
@pytest.mark.asyncio
async def test_embedding_exception_and_close(tmp_path, monkeypatch):
config = DataModulesConfig.from_project_root(tmp_path)
config.api_max_retries = 1
client = EmbeddingAPIClient(config)
class BoomSession:
def __init__(self):
self.closed = False
def post(self, *args, **kwargs):
raise RuntimeError("boom")
async def close(self):
self.closed = True
session = BoomSession()
async def fake_get_session():
return session
monkeypatch.setattr(client, "_get_session", fake_get_session)
result = await client.embed(["x"])
assert result is None
assert client.stats.errors == 1
client._session = session
await client.close()
assert session.closed is True
def test_rerank_headers_payload_and_stats(tmp_path, capsys):
config = DataModulesConfig.from_project_root(tmp_path)
config.rerank_api_key = "rk-test"
client = RerankAPIClient(config)
headers = client._build_headers()
assert headers["Authorization"] == "Bearer rk-test"
payload = client._build_payload("q", ["doc"], top_n=2)
assert payload["top_n"] == 2
modal = ModalAPIClient(config)
modal._embed_client.stats.total_calls = 1
modal._embed_client.stats.total_time = 2.0
modal.print_stats()
output = capsys.readouterr().out
assert "EMBED" in output
@pytest.mark.asyncio
async def test_rerank_non_retry_error(tmp_path, monkeypatch):
config = DataModulesConfig.from_project_root(tmp_path)
config.api_max_retries = 1
client = RerankAPIClient(config)
fake_session = FakeSession([FakeResponse(400, text_data="bad request")])
async def fake_get_session():
return fake_session
monkeypatch.setattr(client, "_get_session", fake_get_session)
result = await client.rerank("q", ["doc"])
assert result is None
assert client.stats.errors == 1
@pytest.mark.asyncio
async def test_embedding_session_parse_and_retry_paths(tmp_path, monkeypatch):
config = DataModulesConfig.from_project_root(tmp_path)
config.embed_api_type = "modal"
config.api_max_retries = 2
config.api_retry_delay = 0
client = EmbeddingAPIClient(config)
session = await client._get_session()
assert session is not None
await client.close()
assert client._parse_response({}) is None
parsed = client._parse_response({"data": [{"embedding": [1.0, 2.0]}]})
assert parsed == [[1.0, 2.0]]
responses = [
asyncio.TimeoutError(),
FakeResponse(200, text_data=json.dumps({"data": [{"embedding": [0.1], "index": 0}]})),
]
fake_session = FakeSession(responses)
async def fake_get_session():
return fake_session
monkeypatch.setattr(client, "_get_session", fake_get_session)
result = await client.embed(["x"])
assert result == [[0.1]]
@pytest.mark.asyncio
async def test_embedding_exception_retry_and_batch(tmp_path, monkeypatch):
config = DataModulesConfig.from_project_root(tmp_path)
config.api_max_retries = 2
config.api_retry_delay = 0
client = EmbeddingAPIClient(config)
responses = [
RuntimeError("boom"),
FakeResponse(200, text_data=json.dumps({"data": [{"embedding": [0.2], "index": 0}]})),
]
fake_session = FakeSession(responses)
async def fake_get_session():
return fake_session
monkeypatch.setattr(client, "_get_session", fake_get_session)
result = await client.embed(["x"])
assert result == [[0.2]]
assert await client.embed_batch([]) == []
async def fake_embed(texts):
return [[0.0] for _ in texts]
monkeypatch.setattr(client, "embed", fake_embed)
await client.warmup()
assert client._warmed_up is True
@pytest.mark.asyncio
async def test_rerank_modal_retry_and_warmup(tmp_path, monkeypatch):
config = DataModulesConfig.from_project_root(tmp_path)
config.rerank_api_type = "modal"
config.rerank_base_url = "https://modal.example.com/rerank"
config.api_max_retries = 2
config.api_retry_delay = 0
client = RerankAPIClient(config)
session = await client._get_session()
assert session is not None
await client.close()
payload = client._build_payload("q", ["doc"], top_n=1)
assert payload["top_n"] == 1
assert client._build_url() == "https://modal.example.com/rerank"
assert client._parse_response({"results": [{"index": 0}]}) == [{"index": 0}]
responses = [
asyncio.TimeoutError(),
FakeResponse(200, json_data={"results": [{"index": 0, "relevance_score": 1.0}]}),
]
fake_session = FakeSession(responses)
async def fake_get_session():
return fake_session
monkeypatch.setattr(client, "_get_session", fake_get_session)
result = await client.rerank("q", ["doc"])
assert result[0]["index"] == 0
responses = [
RuntimeError("boom"),
FakeResponse(200, json_data={"results": [{"index": 0, "relevance_score": 0.5}]}),
]
fake_session = FakeSession(responses)
async def fake_get_session2():
return fake_session
monkeypatch.setattr(client, "_get_session", fake_get_session2)
result = await client.rerank("q", ["doc"])
assert result[0]["relevance_score"] == 0.5
async def fake_rerank(query, docs, top_n=None):
return [{"index": 0, "relevance_score": 1.0}]
monkeypatch.setattr(client, "rerank", fake_rerank)
await client.warmup()
assert client._warmed_up is True
@pytest.mark.asyncio
async def test_modal_client_helpers(tmp_path, monkeypatch, capsys):
config = DataModulesConfig.from_project_root(tmp_path)
client = ModalAPIClient(config)
async def fake_embed_batch(texts, skip_failures=True):
return [[0.1] for _ in texts]
monkeypatch.setattr(client._embed_client, "embed_batch", fake_embed_batch)
result = await client.embed_batch(["a", "b"])
assert result[0] == [0.1]
async def fail_warmup():
raise RuntimeError("fail")
async def ok_warmup():
return None
monkeypatch.setattr(client, "_warmup_embed", fail_warmup)
monkeypatch.setattr(client, "_warmup_rerank", ok_warmup)
await client.warmup()
output = capsys.readouterr().out
assert "[FAIL]" in output
async def fake_get_session():
return FakeSession([])
monkeypatch.setattr(client._embed_client, "_get_session", fake_get_session)
session = await client._get_session()
assert session is not None
closed = {"embed": False, "rerank": False}
async def close_embed():
closed["embed"] = True
async def close_rerank():
closed["rerank"] = True
monkeypatch.setattr(client._embed_client, "close", close_embed)
monkeypatch.setattr(client._rerank_client, "close", close_rerank)
await client.close()
assert closed["embed"] and closed["rerank"]
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pathlib import Path
import pytest
def _load_archive_module():
import sys
scripts_dir = Path(__file__).resolve().parents[2]
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
import archive_manager
return archive_manager
@pytest.fixture
def archive_env(tmp_path):
noma = tmp_path / ".noma"
noma.mkdir(parents=True, exist_ok=True)
state_path = noma / "state.json"
state_path.write_text(
'{"progress":{"current_chapter":10},"plot_threads":{},"review_checkpoints":[]}',
encoding="utf-8",
)
return tmp_path
def test_archive_remove_from_state_missing_sections(archive_env):
module = _load_archive_module()
manager = module.ArchiveManager(project_root=archive_env)
state = {
"progress": {"current_chapter": 50},
}
updated = manager.remove_from_state(state, inactive_chars=[], resolved_threads=[], old_reviews=[])
assert updated.get("progress", {}).get("current_chapter") == 50
def test_archive_check_trigger_conditions_edges(archive_env):
module = _load_archive_module()
manager = module.ArchiveManager(project_root=archive_env)
manager.config["chapter_trigger"] = 10
manager.config["file_size_trigger_mb"] = 9999.0
trigger = manager.check_trigger_conditions({"progress": {"current_chapter": 20}})
assert trigger["chapter_trigger"] is True
assert trigger["should_archive"] is True
def test_archive_identify_old_reviews_handles_mixed_formats(archive_env):
module = _load_archive_module()
manager = module.ArchiveManager(project_root=archive_env)
manager.config["review_old_threshold"] = 5
state = {
"progress": {"current_chapter": 30},
"review_checkpoints": [
{"chapters": "20-22", "report": "r1.md"},
{"chapter_range": [10, 12], "date": "2026-01-01"},
{"report": "Review_Ch5-6.md"},
],
}
results = manager.identify_old_reviews(state)
assert len(results) == 3
assert all(row["chapters_since_review"] >= 5 for row in results)
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sys
from pathlib import Path
def _load_module():
scripts_dir = Path(__file__).resolve().parents[2]
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
import chapter_paths
return chapter_paths
def test_default_chapter_draft_path_uses_outline_heading_title(tmp_path):
module = _load_module()
outline_dir = tmp_path / "大纲"
outline_dir.mkdir(parents=True, exist_ok=True)
(outline_dir / "第1卷-详细大纲.md").write_text("### 第1章:测试标题\n测试大纲", encoding="utf-8")
draft_path = module.default_chapter_draft_path(tmp_path, 1)
assert draft_path.name == "第0001章-测试标题.md"
def test_default_chapter_draft_path_falls_back_to_split_outline_filename(tmp_path):
module = _load_module()
outline_dir = tmp_path / "大纲"
outline_dir.mkdir(parents=True, exist_ok=True)
(outline_dir / "第0002章-标题 文件.md").write_text("无章节标题 heading", encoding="utf-8")
draft_path = module.default_chapter_draft_path(tmp_path, 2)
assert draft_path.name == "第0002章-标题_文件.md"
def test_find_chapter_file_supports_titled_flat_filename(tmp_path):
module = _load_module()
chapter_path = tmp_path / "正文" / "第0003章-山雨欲来.md"
chapter_path.parent.mkdir(parents=True, exist_ok=True)
chapter_path.write_text("正文", encoding="utf-8")
found = module.find_chapter_file(tmp_path, 3)
assert found == chapter_path
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Config tests
"""
import os
from data_modules import config as config_module
from data_modules.config import DataModulesConfig, get_config, set_project_root
def test_config_paths_and_defaults(tmp_path):
cfg = DataModulesConfig.from_project_root(tmp_path)
assert cfg.project_root == tmp_path
assert cfg.noma_dir.name == ".noma"
assert cfg.state_file.name == "state.json"
assert cfg.index_db.name == "index.db"
assert cfg.rag_db.name == "rag.db"
assert cfg.vector_db.name == "vectors.db"
cfg.ensure_dirs()
assert cfg.noma_dir.exists()
def test_get_config_and_set_project_root(tmp_path):
set_project_root(tmp_path)
cfg = get_config()
assert cfg.project_root == tmp_path
def test_load_dotenv(monkeypatch, tmp_path):
# prepare .env
env_path = tmp_path / ".env"
env_path.write_text("EMBED_BASE_URL=https://example.com\n", encoding="utf-8")
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("EMBED_BASE_URL", raising=False)
# call loader explicitly
config_module._load_dotenv()
assert os.environ.get("EMBED_BASE_URL") == "https://example.com"
def test_config_default_context_template_weights_dynamic_is_available(tmp_path):
cfg = DataModulesConfig.from_project_root(tmp_path)
dynamic = cfg.context_template_weights_dynamic
assert isinstance(dynamic, dict)
assert "early" in dynamic
assert "mid" in dynamic
assert "late" in dynamic
assert "plot" in dynamic["early"]
def test_config_dynamic_template_weights_are_independent_instances(tmp_path):
cfg1 = DataModulesConfig.from_project_root(tmp_path)
cfg2 = DataModulesConfig.from_project_root(tmp_path)
cfg1.context_template_weights_dynamic["early"]["plot"]["core"] = 0.77
assert cfg2.context_template_weights_dynamic["early"]["plot"]["core"] != 0.77
@@ -0,0 +1,657 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ContextManager and SnapshotManager tests
"""
import json
import logging
import pytest
from data_modules.config import DataModulesConfig
from data_modules.index_manager import (
IndexManager,
EntityMeta,
ChapterReadingPowerMeta,
ReviewMetrics,
)
from data_modules.context_manager import ContextManager
from data_modules.snapshot_manager import SnapshotManager, SnapshotVersionMismatch
from data_modules.query_router import QueryRouter
@pytest.fixture
def temp_project(tmp_path):
cfg = DataModulesConfig.from_project_root(tmp_path)
cfg.ensure_dirs()
return cfg
def test_snapshot_manager_roundtrip(temp_project):
manager = SnapshotManager(temp_project)
payload = {"hello": "world"}
manager.save_snapshot(1, payload)
loaded = manager.load_snapshot(1)
assert loaded["payload"] == payload
def test_snapshot_version_mismatch(temp_project):
manager = SnapshotManager(temp_project, version="1.0")
manager.save_snapshot(1, {"a": 1})
other = SnapshotManager(temp_project, version="2.0")
with pytest.raises(SnapshotVersionMismatch):
other.load_snapshot(1)
def test_snapshot_delete_roundtrip(temp_project):
manager = SnapshotManager(temp_project)
manager.save_snapshot(2, {"x": 1})
assert manager.delete_snapshot(2) is True
assert manager.load_snapshot(2) is None
def test_context_manager_build_and_filter(temp_project):
state = {
"protagonist_state": {"name": "萧炎", "location": {"current": "天云宗"}},
"chapter_meta": {"0001": {"hook": "测试"}},
}
temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
# preferences and memory
(temp_project.noma_dir / "preferences.json").write_text(json.dumps({"tone": "热血"}, ensure_ascii=False), encoding="utf-8")
(temp_project.noma_dir / "project_memory.json").write_text(json.dumps({"patterns": []}, ensure_ascii=False), encoding="utf-8")
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="bad",
type="角色",
canonical_name="坏人",
current={},
first_appearance=1,
last_appearance=1,
)
)
idx.record_appearance("xiaoyan", 1, ["萧炎"], 1.0)
idx.record_appearance("bad", 1, ["坏人"], 1.0)
invalid_id = idx.mark_invalid_fact("entity", "bad", "错误")
idx.resolve_invalid_fact(invalid_id, "confirm")
manager = ContextManager(temp_project)
payload = manager.build_context(1, use_snapshot=False, save_snapshot=False)
characters = payload["sections"]["scene"]["content"]["appearing_characters"]
assert any(c.get("entity_id") == "xiaoyan" for c in characters)
assert not any(c.get("entity_id") == "bad" for c in characters)
assert payload["sections"]["preferences"]["content"].get("tone") == "热血"
def test_context_manager_loads_volume_outline_file(temp_project):
state = {
"progress": {
"volumes_planned": [
{"volume": 1, "chapters_range": "1-10"},
]
},
"protagonist_state": {},
"chapter_meta": {},
"disambiguation_warnings": [],
"disambiguation_pending": [],
}
temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
temp_project.outline_dir.mkdir(parents=True, exist_ok=True)
(temp_project.outline_dir / "第1卷-详细大纲.md").write_text(
"### 第2章:测试标题\n测试大纲\n\n### 第3章:下一章",
encoding="utf-8",
)
manager = ContextManager(temp_project)
payload = manager.build_context(2, use_snapshot=False, save_snapshot=False)
outline = payload["sections"]["core"]["content"]["chapter_outline"]
assert "### 第2章:测试标题" in outline
assert "测试大纲" in outline
def test_query_router():
router = QueryRouter()
assert router.route("角色是谁") == "entity"
assert router.route("发生了什么剧情") == "plot"
intent = router.route_intent("第10-20章萧炎和药老关系图谱")
assert intent["intent"] == "relationship"
assert intent["needs_graph"] is True
assert intent["time_scope"]["from_chapter"] == 10
assert intent["time_scope"]["to_chapter"] == 20
plans = router.plan_subqueries(intent)
assert plans
assert plans[0]["strategy"] in {"graph_lookup", "graph_hybrid"}
assert "A" in router.split("A, BC")
def test_context_snapshot_respects_template(temp_project):
state = {
"protagonist_state": {"name": "萧炎"},
"chapter_meta": {},
"disambiguation_warnings": [],
"disambiguation_pending": [],
}
temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
manager = ContextManager(temp_project)
plot_payload = manager.build_context(1, template="plot", use_snapshot=True, save_snapshot=True)
battle_payload = manager.build_context(1, template="battle", use_snapshot=True, save_snapshot=True)
assert plot_payload.get("template") == "plot"
assert battle_payload.get("template") == "battle"
def test_context_manager_applies_ranker_and_contract_meta(temp_project):
state = {
"protagonist_state": {"name": "萧炎"},
"chapter_meta": {
"0002": {"hook": "平稳"},
"0003": {"hook": "留下悬念"},
},
"disambiguation_warnings": [
{"chapter": 1, "message": "普通告警"},
{"chapter": 3, "message": "critical 冲突告警", "severity": "high"},
],
"disambiguation_pending": [],
}
temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
manager = ContextManager(temp_project)
payload = manager.build_context(4, use_snapshot=False, save_snapshot=False)
assert payload["meta"].get("context_contract_version") == "v2"
recent_meta = payload["sections"]["core"]["content"]["recent_meta"]
if recent_meta:
assert recent_meta[0]["chapter"] == 3
warnings = payload["sections"]["alerts"]["content"]["disambiguation_warnings"]
if warnings and isinstance(warnings[0], dict):
assert "critical" in str(warnings[0].get("message", "")) or warnings[0].get("severity") == "high"
def test_context_manager_includes_reader_signal_and_genre_profile(temp_project):
state = {
"project": {"genre": "xuanhuan"},
"protagonist_state": {"name": "萧炎"},
"chapter_meta": {},
"disambiguation_warnings": [],
"disambiguation_pending": [],
}
temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
idx = IndexManager(temp_project)
idx.save_chapter_reading_power(
ChapterReadingPowerMeta(
chapter=3,
hook_type="悬念钩",
hook_strength="strong",
coolpoint_patterns=["身份掉马"],
)
)
idx.save_review_metrics(
ReviewMetrics(
start_chapter=1,
end_chapter=3,
overall_score=72,
dimension_scores={"plot": 72},
severity_counts={"high": 1},
critical_issues=["节奏拖沓"],
)
)
manager = ContextManager(temp_project)
payload = manager.build_context(4, use_snapshot=False, save_snapshot=False)
reader_signal = payload["sections"]["reader_signal"]["content"]
assert "recent_reading_power" in reader_signal
assert "pattern_usage" in reader_signal
assert "hook_type_usage" in reader_signal
assert "review_trend" in reader_signal
assert isinstance(reader_signal.get("low_score_ranges"), list)
genre_profile = payload["sections"]["genre_profile"]["content"]
assert genre_profile.get("genre") == "xuanhuan"
assert "profile_excerpt" in genre_profile
assert "taxonomy_excerpt" in genre_profile
def test_context_manager_genre_section_and_refs_extraction(temp_project):
refs_dir = temp_project.project_root / ".claude" / "references"
refs_dir.mkdir(parents=True, exist_ok=True)
(refs_dir / "genre-profiles.md").write_text(
"""
## shuangwen
- 节奏快
- 打脸密集
## xuanhuan
- 升级线清晰
- 资源争夺
""".strip(),
encoding="utf-8",
)
(refs_dir / "reading-power-taxonomy.md").write_text(
"""
## xuanhuan
- 钩子强度优先 strong
- 爽点使用战力跨级
""".strip(),
encoding="utf-8",
)
manager = ContextManager(temp_project)
profile = manager._load_genre_profile({"project": {"genre": "xuanhuan"}})
assert profile["genre"] == "xuanhuan"
assert "升级线清晰" in profile["profile_excerpt"]
assert "钩子强度" in profile["taxonomy_excerpt"]
assert isinstance(profile["reference_hints"], list)
assert profile["reference_hints"]
fallback_excerpt = manager._extract_genre_section("## a\n1\n## b\n2", "unknown")
assert fallback_excerpt.startswith("## a")
def test_context_manager_reader_signal_with_debt_and_disable_switch(temp_project):
manager = ContextManager(temp_project)
manager.config.context_reader_signal_include_debt = True
signal = manager._load_reader_signal(chapter=5)
assert "debt_summary" in signal
manager.config.context_reader_signal_enabled = False
assert manager._load_reader_signal(chapter=5) == {}
manager.config.context_genre_profile_enabled = False
assert manager._load_genre_profile({"project": {"genre": "xuanhuan"}}) == {}
def test_context_manager_includes_writing_guidance(temp_project):
state = {
"project": {"genre": "xuanhuan"},
"protagonist_state": {"name": "萧炎"},
"chapter_meta": {},
"disambiguation_warnings": [],
"disambiguation_pending": [],
}
temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
idx = IndexManager(temp_project)
idx.save_chapter_reading_power(
ChapterReadingPowerMeta(
chapter=3,
hook_type="悬念钩",
hook_strength="strong",
coolpoint_patterns=["身份掉马"],
)
)
idx.save_review_metrics(
ReviewMetrics(
start_chapter=1,
end_chapter=3,
overall_score=70,
dimension_scores={"plot": 70},
severity_counts={"high": 1},
critical_issues=["节奏拖沓"],
)
)
manager = ContextManager(temp_project)
payload = manager.build_context(4, use_snapshot=False, save_snapshot=False)
guidance = payload["sections"]["writing_guidance"]["content"]
assert guidance.get("chapter") == 4
items = guidance.get("guidance_items") or []
assert isinstance(items, list)
assert items
assert guidance.get("signals_used", {}).get("genre") == "xuanhuan"
checklist = guidance.get("checklist") or []
assert isinstance(checklist, list)
assert checklist
checklist_score = guidance.get("checklist_score") or {}
assert isinstance(checklist_score, dict)
assert "score" in checklist_score
assert "completion_rate" in checklist_score
first_item = checklist[0]
assert isinstance(first_item, dict)
assert {"id", "label", "weight", "required", "source", "verify_hint"}.issubset(first_item.keys())
persisted = idx.get_writing_checklist_score(4)
assert isinstance(persisted, dict)
assert persisted.get("chapter") == 4
assert persisted.get("score") is not None
def test_context_manager_dynamic_weights_and_composite_genre(temp_project):
refs_dir = temp_project.project_root / ".claude" / "references"
refs_dir.mkdir(parents=True, exist_ok=True)
(refs_dir / "genre-profiles.md").write_text(
"""
## xuanhuan
- 升级线清晰
## realistic
- 社会议题映射
""".strip(),
encoding="utf-8",
)
(refs_dir / "reading-power-taxonomy.md").write_text(
"""
## xuanhuan
- 钩子强度优先
## realistic
- 人物动机一致
""".strip(),
encoding="utf-8",
)
state = {
"project": {"genre": "xuanhuan+realistic"},
"protagonist_state": {"name": "萧炎"},
"chapter_meta": {},
"disambiguation_warnings": [],
"disambiguation_pending": [],
}
temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
manager = ContextManager(temp_project)
payload_early = manager.build_context(10, template="plot", use_snapshot=False, save_snapshot=False)
payload_late = manager.build_context(150, template="plot", use_snapshot=False, save_snapshot=False)
assert payload_early.get("weights", {}).get("core") >= payload_late.get("weights", {}).get("core")
assert payload_late.get("weights", {}).get("global") >= payload_early.get("weights", {}).get("global")
assert payload_early.get("meta", {}).get("context_weight_stage") == "early"
assert payload_late.get("meta", {}).get("context_weight_stage") == "late"
profile = payload_early["sections"]["genre_profile"]["content"]
assert profile.get("composite") is True
assert profile.get("genre") == "xuanhuan"
assert isinstance(profile.get("genres"), list)
assert "realistic" in (profile.get("genres") or [])
assert isinstance(profile.get("composite_hints"), list)
assert profile.get("composite_hints")
def test_context_manager_genre_alias_guidance_and_heading_extraction(temp_project):
refs_dir = temp_project.project_root / ".claude" / "references"
refs_dir.mkdir(parents=True, exist_ok=True)
(refs_dir / "genre-profiles.md").write_text(
"""
### 电竞
- 联赛升级
### 直播文
- 反馈闭环
### 克苏鲁
- 真相代价
""".strip(),
encoding="utf-8",
)
(refs_dir / "reading-power-taxonomy.md").write_text(
"""
### 电竞
- 战术决策点
""".strip(),
encoding="utf-8",
)
state = {
"project": {"genre": "电竞"},
"protagonist_state": {"name": "林燃"},
"chapter_meta": {},
"disambiguation_warnings": [],
"disambiguation_pending": [],
}
temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
manager = ContextManager(temp_project)
payload = manager.build_context(12, template="plot", use_snapshot=False, save_snapshot=False)
guidance = payload["sections"]["writing_guidance"]["content"]
items = guidance.get("guidance_items") or []
assert any("战术决策点" in str(text) for text in items)
assert any("网文节奏基线" in str(text) for text in items)
assert any("兑现密度基线" in str(text) for text in items)
def test_context_manager_genre_aliases_normalized_for_profile_lookup(temp_project):
refs_dir = temp_project.project_root / ".claude" / "references"
refs_dir.mkdir(parents=True, exist_ok=True)
(refs_dir / "genre-profiles.md").write_text(
"""
## 电竞
- 联赛升级
## 直播文
- 实时反馈
## 克苏鲁
- 真相代价
""".strip(),
encoding="utf-8",
)
(refs_dir / "reading-power-taxonomy.md").write_text(
"""
## 电竞
- 决策后果
## 直播文
- 数据闭环
## 克苏鲁
- 规则优先
""".strip(),
encoding="utf-8",
)
manager = ContextManager(temp_project)
assert manager._parse_genre_tokens("电竞文") == ["电竞"]
assert manager._parse_genre_tokens("直播") == ["直播文"]
assert manager._parse_genre_tokens("克系") == ["克苏鲁"]
assert manager._parse_genre_tokens("修仙/玄幻") == ["修仙"]
assert manager._parse_genre_tokens("都市修真") == ["都市异能"]
assert manager._parse_genre_tokens("古言脑洞") == ["古言"]
state = {
"project": {"genre": "电竞文+直播"},
"protagonist_state": {"name": "叶修"},
"chapter_meta": {},
"disambiguation_warnings": [],
"disambiguation_pending": [],
}
temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
payload = manager.build_context(20, template="plot", use_snapshot=False, save_snapshot=False)
profile = payload["sections"]["genre_profile"]["content"]
assert profile.get("genre") == "电竞"
assert "直播文" in (profile.get("genres") or [])
def test_context_manager_enables_methodology_for_xianxia(temp_project):
state = {
"project": {"genre": "修仙"},
"protagonist_state": {"name": "韩立"},
"chapter_meta": {},
"disambiguation_warnings": [],
"disambiguation_pending": [],
}
temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
manager = ContextManager(temp_project)
manager.config.context_writing_checklist_max_items = 8
payload = manager.build_context(21, template="plot", use_snapshot=False, save_snapshot=False)
guidance = payload["sections"]["writing_guidance"]["content"]
strategy = guidance.get("methodology") or {}
assert strategy.get("enabled") is True
assert strategy.get("pilot") == "xianxia"
assert strategy.get("genre_profile_key") == "xianxia"
assert guidance.get("signals_used", {}).get("methodology_enabled") is True
assert isinstance(strategy.get("observability"), dict)
def test_context_manager_enables_methodology_for_non_xianxia_by_default(temp_project):
state = {
"project": {"genre": "xuanhuan"},
"protagonist_state": {"name": "萧炎"},
"chapter_meta": {},
"disambiguation_warnings": [],
"disambiguation_pending": [],
}
temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
manager = ContextManager(temp_project)
payload = manager.build_context(21, template="plot", use_snapshot=False, save_snapshot=False)
guidance = payload["sections"]["writing_guidance"]["content"]
strategy = guidance.get("methodology") or {}
assert strategy.get("enabled") is True
assert strategy.get("genre_profile_key") == "xuanhuan"
assert guidance.get("signals_used", {}).get("methodology_enabled") is True
def test_context_manager_allows_methodology_whitelist_restriction(temp_project):
state = {
"project": {"genre": "直播文"},
"protagonist_state": {"name": "林默"},
"chapter_meta": {},
"disambiguation_warnings": [],
"disambiguation_pending": [],
}
temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
manager = ContextManager(temp_project)
manager.config.context_methodology_genre_whitelist = ("xianxia",)
payload = manager.build_context(21, template="plot", use_snapshot=False, save_snapshot=False)
guidance = payload["sections"]["writing_guidance"]["content"]
strategy = guidance.get("methodology") or {}
assert strategy == {}
assert guidance.get("signals_used", {}).get("methodology_enabled") is False
def test_context_manager_compact_text_truncation(temp_project):
manager = ContextManager(temp_project)
manager.config.context_compact_text_enabled = True
manager.config.context_compact_min_budget = 80
manager.config.context_compact_head_ratio = 0.6
content = {"a": "x" * 200, "b": "y" * 200}
compact = manager._compact_json_text(content, budget=120)
assert len(compact) <= 120
assert "[TRUNCATED]" in compact
manager.config.context_compact_text_enabled = False
raw_cut = manager._compact_json_text(content, budget=100)
assert len(raw_cut) <= 100
def test_context_manager_persist_writing_checklist_score_logs_failure(temp_project, monkeypatch, caplog):
manager = ContextManager(temp_project)
def _raise_save_error(_meta):
raise RuntimeError("simulated save failure")
monkeypatch.setattr(manager.index_manager, "save_writing_checklist_score", _raise_save_error)
with caplog.at_level(logging.WARNING):
manager._persist_writing_checklist_score(
{
"chapter": 6,
"score": 70.0,
"total_items": 3,
"required_items": 1,
"completed_items": 1,
"completed_required": 1,
"total_weight": 3.0,
"completed_weight": 1.0,
"completion_rate": 0.33,
"pending_items": ["test"],
}
)
message_text = "\n".join(record.getMessage() for record in caplog.records)
assert "failed to persist writing checklist score" in message_text
def test_context_manager_composite_genre_boundary_three_plus(temp_project):
manager = ContextManager(temp_project)
manager.config.context_genre_profile_support_composite = True
manager.config.context_genre_profile_max_genres = 3
genre_raw = "电竞文+直播+克系+修仙/玄幻+电竞文"
tokens = manager._parse_genre_tokens(genre_raw)
assert tokens[:4] == ["电竞", "直播文", "克苏鲁", "修仙"]
state = {
"project": {"genre": genre_raw},
"protagonist_state": {"name": "主角"},
"chapter_meta": {},
"disambiguation_warnings": [],
"disambiguation_pending": [],
}
profile = manager._load_genre_profile(state)
assert profile.get("composite") is True
assert profile.get("genres") == ["电竞", "直播文", "克苏鲁"]
assert profile.get("secondary_genres") == ["直播文", "克苏鲁"]
profile_again = manager._load_genre_profile(state)
assert profile_again.get("genres") == profile.get("genres")
def test_context_manager_dynamic_weights_from_config_override(temp_project):
manager = ContextManager(temp_project)
manager.config.context_dynamic_budget_enabled = True
manager.config.context_template_weights_dynamic = {
"early": {
"plot": {"core": 0.60, "scene": 0.20, "global": 0.20},
}
}
weights = manager._resolve_template_weights("plot", chapter=1)
assert weights == {"core": 0.60, "scene": 0.20, "global": 0.20}
def test_context_manager_genre_profile_fallbacks_to_project_info(temp_project):
manager = ContextManager(temp_project)
profile = manager._load_genre_profile({"project_info": {"genre": "xuanhuan"}})
assert profile.get("genre_raw") == "xuanhuan"
assert profile.get("genre") == "xuanhuan"
def test_context_manager_genre_profile_prefers_project_over_project_info(temp_project):
manager = ContextManager(temp_project)
profile = manager._load_genre_profile(
{
"project": {"genre": "xuanhuan"},
"project_info": {"genre": "dushi"},
}
)
assert profile.get("genre_raw") == "xuanhuan"
assert profile.get("genre") == "xuanhuan"
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from data_modules.config import DataModulesConfig
from data_modules.context_ranker import ContextRanker
def test_rank_recent_summaries_prefers_recency_and_hook(tmp_path):
cfg = DataModulesConfig.from_project_root(tmp_path)
ranker = ContextRanker(cfg)
items = [
{"chapter": 8, "summary": "平稳推进"},
{"chapter": 9, "summary": "最后留下悬念?"},
{"chapter": 7, "summary": "老信息"},
]
ranked = ranker.rank_recent_summaries(items, current_chapter=10)
assert ranked[0]["chapter"] == 9
assert ranked[-1]["chapter"] == 7
def test_rank_appearances_uses_recency_and_frequency(tmp_path):
cfg = DataModulesConfig.from_project_root(tmp_path)
ranker = ContextRanker(cfg)
items = [
{"entity_id": "a", "last_chapter": 9, "total": 1},
{"entity_id": "b", "last_chapter": 8, "total": 8},
{"entity_id": "c", "last_chapter": 9, "total": 3},
]
ranked = ranker.rank_appearances(items, current_chapter=10)
ids = [item["entity_id"] for item in ranked]
assert ids[0] == "c"
assert ids[-1] in {"a", "b"}
def test_rank_pack_adds_context_contract_meta(tmp_path):
cfg = DataModulesConfig.from_project_root(tmp_path)
ranker = ContextRanker(cfg)
pack = {
"meta": {"chapter": 12},
"core": {"recent_summaries": [{"chapter": 11, "summary": "x"}], "recent_meta": []},
"scene": {"appearing_characters": []},
"global": {},
"story_skeleton": [],
"alerts": {"disambiguation_warnings": [], "disambiguation_pending": []},
}
ranked = ranker.rank_pack(pack, chapter=12)
assert ranked["meta"]["context_contract_version"] == "v2"
assert ranked["meta"]["ranker"]["enabled"] is True
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
EntityLinker extra tests + CLI
"""
import sys
import pytest
from data_modules.entity_linker import EntityLinker, main as linker_main
from data_modules.index_manager import IndexManager, EntityMeta
@pytest.fixture
def temp_project(tmp_path):
from data_modules.config import DataModulesConfig
cfg = DataModulesConfig.from_project_root(tmp_path)
cfg.ensure_dirs()
return cfg
def test_process_extraction_and_register_new_entities(temp_project):
linker = EntityLinker(temp_project)
idx = IndexManager(temp_project)
idx.upsert_entity(
EntityMeta(
id="xiaoyan",
type="角色",
canonical_name="萧炎",
current={},
first_appearance=1,
last_appearance=1,
)
)
results, warnings = linker.process_extraction_result(
[
{
"mention": "萧炎",
"candidates": ["xiaoyan"],
"suggested": "xiaoyan",
"confidence": 0.7,
},
{
"mention": "宗主",
"candidates": ["zongzhu"],
"suggested": "zongzhu",
"confidence": 0.4,
},
]
)
assert len(results) == 2
assert len(warnings) == 2
registered = linker.register_new_entities(
[
{
"suggested_id": "hongyi",
"name": "红衣女子",
"type": "角色",
"mentions": ["红衣", "女子"],
}
]
)
assert registered == ["hongyi"]
aliases = idx.get_entity_aliases("hongyi")
assert "红衣女子" in aliases
def test_entity_linker_cli(temp_project, monkeypatch, capsys):
idx = IndexManager(temp_project)
idx.upsert_entity(
EntityMeta(
id="xiaoyan",
type="角色",
canonical_name="萧炎",
current={},
first_appearance=1,
last_appearance=1,
)
)
def run_cli(args):
monkeypatch.setattr(sys, "argv", ["entity_linker"] + args)
linker_main()
root = str(temp_project.project_root)
run_cli(["--project-root", root, "register-alias", "--entity", "xiaoyan", "--alias", "炎帝"])
run_cli(["--project-root", root, "lookup", "--mention", "炎帝"])
run_cli(["--project-root", root, "lookup", "--mention", "不存在"])
run_cli(["--project-root", root, "lookup-all", "--mention", "炎帝"])
run_cli(["--project-root", root, "list-aliases", "--entity", "xiaoyan"])
capsys.readouterr()
@@ -0,0 +1,273 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json
import sys
from pathlib import Path
def test_extract_state_summary_accepts_dominant_key(tmp_path):
scripts_dir = Path(__file__).resolve().parents[2]
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
from extract_chapter_context import extract_state_summary
state = {
"progress": {"current_chapter": 12, "total_words": 12345},
"protagonist_state": {
"power": {"realm": "筑基", "layer": 2},
"location": "宗门",
"golden_finger": {"name": "系统", "level": 1},
},
"strand_tracker": {
"history": [
{"chapter": 10, "dominant": "quest"},
{"chapter": 11, "dominant": "fire"},
]
},
}
noma_dir = tmp_path / ".noma"
noma_dir.mkdir(parents=True, exist_ok=True)
(noma_dir / "state.json").write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
text = extract_state_summary(tmp_path)
assert "Ch10:quest" in text
assert "Ch11:fire" in text
def test_extract_chapter_outline_supports_hyphen_filename(tmp_path):
scripts_dir = Path(__file__).resolve().parents[2]
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
from extract_chapter_context import extract_chapter_outline
outline_dir = tmp_path / "大纲"
outline_dir.mkdir(parents=True, exist_ok=True)
(outline_dir / "第1卷-详细大纲.md").write_text("### 第1章:测试标题\n测试大纲", encoding="utf-8")
outline = extract_chapter_outline(tmp_path, 1)
assert "### 第1章:测试标题" in outline
assert "测试大纲" in outline
def test_extract_chapter_outline_prefers_state_volume_mapping(tmp_path):
scripts_dir = Path(__file__).resolve().parents[2]
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
from extract_chapter_context import extract_chapter_outline
noma_dir = tmp_path / ".noma"
noma_dir.mkdir(parents=True, exist_ok=True)
state = {
"progress": {
"volumes_planned": [
{"volume": 1, "chapters_range": "1-10"},
{"volume": 2, "chapters_range": "11-20"},
]
}
}
(noma_dir / "state.json").write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
outline_dir = tmp_path / "大纲"
outline_dir.mkdir(parents=True, exist_ok=True)
(outline_dir / "第2卷-详细大纲.md").write_text("### 第12章:V2标题\nV2大纲", encoding="utf-8")
outline = extract_chapter_outline(tmp_path, 12)
assert "### 第12章:V2标题" in outline
assert "V2大纲" in outline
def test_extract_chapter_outline_falls_back_when_state_has_no_match(tmp_path):
scripts_dir = Path(__file__).resolve().parents[2]
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
from extract_chapter_context import extract_chapter_outline
noma_dir = tmp_path / ".noma"
noma_dir.mkdir(parents=True, exist_ok=True)
state = {"progress": {"volumes_planned": [{"volume": 1, "chapters_range": "1-10"}]}}
(noma_dir / "state.json").write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
outline_dir = tmp_path / "大纲"
outline_dir.mkdir(parents=True, exist_ok=True)
(outline_dir / "第2卷-详细大纲.md").write_text("### 第60章:V2标题\nV2大纲", encoding="utf-8")
outline = extract_chapter_outline(tmp_path, 60)
assert "### 第60章:V2标题" in outline
assert "V2大纲" in outline
def test_build_chapter_context_payload_includes_contract_sections(tmp_path):
scripts_dir = Path(__file__).resolve().parents[2]
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
from extract_chapter_context import build_chapter_context_payload
from data_modules.config import DataModulesConfig
from data_modules.index_manager import IndexManager, ChapterReadingPowerMeta, ReviewMetrics
cfg = DataModulesConfig.from_project_root(tmp_path)
cfg.ensure_dirs()
state = {
"project": {"genre": "xuanhuan"},
"progress": {"current_chapter": 3, "total_words": 9000},
"protagonist_state": {
"power": {"realm": "筑基", "layer": 2},
"location": "宗门",
"golden_finger": {"name": "系统", "level": 1},
},
"strand_tracker": {"history": [{"chapter": 2, "dominant": "quest"}]},
"chapter_meta": {},
"disambiguation_warnings": [],
"disambiguation_pending": [],
}
(cfg.noma_dir / "state.json").write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
summaries_dir = cfg.noma_dir / "summaries"
summaries_dir.mkdir(parents=True, exist_ok=True)
(summaries_dir / "ch0002.md").write_text("## 剧情摘要\n上一章总结", encoding="utf-8")
outline_dir = tmp_path / "大纲"
outline_dir.mkdir(parents=True, exist_ok=True)
(outline_dir / "第1卷 详细大纲.md").write_text("### 第3章:测试标题\n测试大纲", encoding="utf-8")
refs_dir = tmp_path / ".claude" / "references"
refs_dir.mkdir(parents=True, exist_ok=True)
(refs_dir / "genre-profiles.md").write_text("## xuanhuan\n- 升级线清晰", encoding="utf-8")
(refs_dir / "reading-power-taxonomy.md").write_text("## xuanhuan\n- 悬念钩优先", encoding="utf-8")
idx = IndexManager(cfg)
idx.save_chapter_reading_power(
ChapterReadingPowerMeta(chapter=2, hook_type="悬念钩", hook_strength="strong", coolpoint_patterns=["身份掉马"])
)
idx.save_review_metrics(
ReviewMetrics(start_chapter=1, end_chapter=2, overall_score=71, dimension_scores={"plot": 71})
)
payload = build_chapter_context_payload(tmp_path, 3)
assert payload["context_contract_version"] == "v2"
assert payload.get("context_weight_stage") in {"early", "mid", "late"}
assert "writing_guidance" in payload
assert isinstance(payload["writing_guidance"].get("guidance_items"), list)
assert isinstance(payload["writing_guidance"].get("checklist"), list)
assert isinstance(payload["writing_guidance"].get("checklist_score"), dict)
assert payload["genre_profile"].get("genre") == "xuanhuan"
assert "rag_assist" in payload
assert isinstance(payload["rag_assist"], dict)
assert payload["rag_assist"].get("invoked") is False
def test_render_text_contains_writing_guidance_section(tmp_path):
scripts_dir = Path(__file__).resolve().parents[2]
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
from extract_chapter_context import _render_text
payload = {
"chapter": 10,
"outline": "测试大纲",
"previous_summaries": ["### 第9章摘要\n上一章"],
"state_summary": "状态",
"context_contract_version": "v2",
"context_weight_stage": "early",
"reader_signal": {"review_trend": {"overall_avg": 72}, "low_score_ranges": [{"start_chapter": 8, "end_chapter": 9}]},
"genre_profile": {
"genre": "xuanhuan",
"genres": ["xuanhuan", "realistic"],
"composite_hints": ["以玄幻主线推进,同时保留现实议题表达"],
"reference_hints": ["升级线清晰"],
},
"writing_guidance": {
"guidance_items": ["先修低分", "钩子差异化"],
"checklist": [
{
"id": "fix_low_score_range",
"label": "修复低分区间问题",
"weight": 1.4,
"required": True,
"source": "reader_signal.low_score_ranges",
"verify_hint": "至少完成1处冲突升级",
}
],
"checklist_score": {
"score": 81.5,
"completion_rate": 0.66,
"required_completion_rate": 0.75,
},
"methodology": {
"enabled": True,
"framework": "digital-serial-v1",
"pilot": "xianxia",
"genre_profile_key": "xianxia",
"chapter_stage": "confront",
"observability": {
"next_reason_clarity": 78.0,
"anchor_effectiveness": 74.0,
"rhythm_naturalness": 72.0,
},
"signals": {"risk_flags": ["pattern_overuse_watch"]},
},
},
}
text = _render_text(payload)
assert "## 写作执行建议" in text
assert "先修低分" in text
assert "## Contract (v2)" in text
assert "- 上下文阶段权重: early" in text
assert "### 执行检查清单(可评分)" in text
assert "- 总权重: 1.40" in text
assert "[必做][w=1.4] 修复低分区间问题" in text
assert "### 执行评分" in text
assert "- 评分: 81.5" in text
assert "- 复合题材: xuanhuan + realistic" in text
assert "## 长篇方法论策略" in text
assert "- 适用题材: xianxia" in text
assert "next_reason=78.0" in text
def test_render_text_contains_rag_assist_section_when_hits_exist(tmp_path):
scripts_dir = Path(__file__).resolve().parents[2]
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
from extract_chapter_context import _render_text
payload = {
"chapter": 12,
"outline": "测试大纲",
"previous_summaries": [],
"state_summary": "状态",
"context_contract_version": "v2",
"reader_signal": {},
"genre_profile": {},
"writing_guidance": {},
"rag_assist": {
"invoked": True,
"mode": "auto",
"intent": "relationship",
"query": "第12章 人物关系与动机:萧炎与药老发生冲突",
"hits": [
{
"chapter": 9,
"scene_index": 2,
"source": "graph_hybrid",
"score": 0.91,
"content": "萧炎与药老在修炼方向上发生分歧。",
}
],
},
}
text = _render_text(payload)
assert "## RAG 检索线索" in text
assert "- 模式: auto" in text
assert "[graph_hybrid]" in text
assert "萧炎与药老" in text
@@ -0,0 +1,209 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
migrate_state_to_sqlite tests
"""
import json
import pytest
import data_modules.migrate_state_to_sqlite as migrate_module
from data_modules.migrate_state_to_sqlite import (
migrate_state_to_sqlite,
_slim_world_settings,
_slim_relationships,
)
from data_modules.config import DataModulesConfig
from data_modules.index_manager import IndexManager
@pytest.fixture
def temp_project(tmp_path):
cfg = DataModulesConfig.from_project_root(tmp_path)
cfg.ensure_dirs()
return cfg
def test_migrate_state_missing_file(tmp_path):
cfg = DataModulesConfig.from_project_root(tmp_path)
stats = migrate_state_to_sqlite(cfg, dry_run=True, backup=False, verbose=False)
assert stats["entities"] == 0
def test_migrate_state_to_sqlite_flow(temp_project):
state = {
"entities_v3": {
"角色": {
"xiaoyan": {
"canonical_name": "萧炎",
"tier": "核心",
"desc": "主角",
"current": {"realm": "斗者"},
"first_appearance": 1,
"last_appearance": 2,
"is_protagonist": True,
}
}
},
"alias_index": {
"萧炎": [{"type": "角色", "id": "xiaoyan"}]
},
"state_changes": [
{"entity_id": "xiaoyan", "field": "realm", "old": "斗者", "new": "斗师", "reason": "突破", "chapter": 2}
],
"structured_relationships": [
{"from_entity": "xiaoyan", "to_entity": "yaolao", "type": "师徒", "description": "收徒", "chapter": 1}
],
"world_settings": {
"power_system": [{"name": "斗者"}, {"name": "斗师"}],
"factions": [{"name": "天云宗", "type": "宗门"}],
"locations": [{"name": "天云宗"}],
},
"plot_threads": {"active_threads": [], "foreshadowing": []},
"relationships": {},
"review_checkpoints": [],
"project_info": {"title": "测试书名"},
}
temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
stats = migrate_state_to_sqlite(temp_project, dry_run=True, backup=False, verbose=False)
assert stats["entities"] == 1
assert stats["aliases"] == 1
stats = migrate_state_to_sqlite(temp_project, dry_run=False, backup=False, verbose=False)
assert stats["entities"] == 1
# state.json 被精简
saved = json.loads(temp_project.state_file.read_text(encoding="utf-8"))
assert saved.get("_migrated_to_sqlite") is True
assert "entities_v3" not in saved
# SQLite 中可查询实体
idx = IndexManager(temp_project)
entity = idx.get_entity("xiaoyan")
assert entity is not None
def test_slim_helpers():
world = {
"power_system": [{"name": "斗者"}],
"factions": [{"name": "天云宗", "type": "宗门"}],
"locations": [{"name": "天云宗"}],
}
slim = _slim_world_settings(world)
assert slim["power_system"][0] == "斗者"
rels = _slim_relationships({"a": 1})
assert rels["a"] == 1
def test_slim_helpers_non_dict():
assert _slim_world_settings("bad") == {}
assert _slim_relationships("bad") == {}
def test_migrate_state_verbose_and_dry_run(temp_project, capsys):
state = {
"entities_v3": {},
"alias_index": {},
"state_changes": [],
"structured_relationships": [],
"world_settings": {},
"plot_threads": {},
"relationships": {},
"review_checkpoints": [],
"project_info": {},
}
temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
stats = migrate_state_to_sqlite(temp_project, dry_run=True, backup=False, verbose=True)
output = capsys.readouterr().out
assert stats["errors"] == 0
assert "dry-run" in output or "dry run" in output
def test_migrate_state_cli_main(tmp_path, monkeypatch, capsys):
project_root = tmp_path
args = [
"migrate_state_to_sqlite",
"--project-root",
str(project_root),
"--dry-run",
"--no-backup",
]
monkeypatch.setattr("sys.argv", args)
migrate_module.main()
output = json.loads(capsys.readouterr().out or "{}")
assert output.get("status") == "success"
def test_migrate_state_backup_and_skips(temp_project):
state = {
"entities_v3": {
"角色": {
"good": {"canonical_name": "好人"},
"bad": "not-dict",
}
},
"alias_index": {
"好人": [{"type": "角色", "id": "good"}],
"坏条目": ["oops", {"type": "角色"}],
},
"state_changes": ["bad", {"field": "realm"}],
"structured_relationships": ["bad", {"from_entity": "", "to_entity": ""}],
"relationships": {},
"world_settings": {},
"plot_threads": {},
"review_checkpoints": [],
"project_info": {},
}
temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
stats = migrate_state_to_sqlite(temp_project, dry_run=False, backup=True, verbose=False)
assert stats["entities"] == 1
assert stats["skipped"] >= 3
backups = list(temp_project.state_file.parent.glob("state.json.backup-*"))
assert backups
def test_migrate_state_error_branches(tmp_path, monkeypatch):
cfg = DataModulesConfig.from_project_root(tmp_path)
cfg.ensure_dirs()
state = {
"entities_v3": {"角色": {"boom": {"canonical_name": ""}}},
"alias_index": {"": [{"type": "角色", "id": "boom"}]},
"state_changes": [
{"entity_id": "boom", "field": "realm", "old": "", "new": "斗者", "reason": "测试", "chapter": 1}
],
"structured_relationships": [
{"from_entity": "boom", "to_entity": "yao", "type": "相识", "description": "测试", "chapter": 1}
],
"relationships": {},
"world_settings": {},
"plot_threads": {},
"review_checkpoints": [],
"project_info": {},
}
cfg.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
class BoomSQL:
def __init__(self, *args, **kwargs):
pass
def upsert_entity(self, *args, **kwargs):
raise RuntimeError("boom")
def register_alias(self, *args, **kwargs):
raise RuntimeError("boom")
def record_state_change(self, *args, **kwargs):
raise RuntimeError("boom")
def upsert_relationship(self, *args, **kwargs):
raise RuntimeError("boom")
monkeypatch.setattr(migrate_module, "SQLStateManager", BoomSQL)
stats = migrate_state_to_sqlite(cfg, dry_run=False, backup=False, verbose=False)
assert stats["errors"] >= 4
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sys
from pathlib import Path
def _ensure_scripts_on_path() -> None:
scripts_dir = Path(__file__).resolve().parents[2]
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
def test_resolve_project_root_prefers_cwd_project(tmp_path):
_ensure_scripts_on_path()
from project_locator import resolve_project_root
project_root = tmp_path / "workspace"
(project_root / ".noma").mkdir(parents=True, exist_ok=True)
(project_root / ".noma" / "state.json").write_text("{}", encoding="utf-8")
resolved = resolve_project_root(cwd=project_root)
assert resolved == project_root.resolve()
def test_resolve_project_root_stops_at_git_root(tmp_path):
_ensure_scripts_on_path()
from project_locator import resolve_project_root
repo_root = tmp_path / "repo"
(repo_root / ".git").mkdir(parents=True, exist_ok=True)
nested = repo_root / "sub" / "dir"
nested.mkdir(parents=True, exist_ok=True)
outside_project = tmp_path / "outside_project"
(outside_project / ".noma").mkdir(parents=True, exist_ok=True)
(outside_project / ".noma" / "state.json").write_text("{}", encoding="utf-8")
try:
resolve_project_root(cwd=nested)
assert False, "Expected FileNotFoundError when only parent outside git root has project"
except FileNotFoundError:
pass
def test_resolve_project_root_finds_default_subdir_within_git_root(tmp_path):
_ensure_scripts_on_path()
from project_locator import resolve_project_root
repo_root = tmp_path / "repo"
(repo_root / ".git").mkdir(parents=True, exist_ok=True)
default_project = repo_root / "noma-project"
(default_project / ".noma").mkdir(parents=True, exist_ok=True)
(default_project / ".noma" / "state.json").write_text("{}", encoding="utf-8")
nested = repo_root / "sub" / "dir"
nested.mkdir(parents=True, exist_ok=True)
resolved = resolve_project_root(cwd=nested)
assert resolved == default_project.resolve()
def test_resolve_project_root_uses_workspace_pointer(tmp_path):
_ensure_scripts_on_path()
from project_locator import resolve_project_root, write_current_project_pointer
workspace = tmp_path / "workspace"
(workspace / ".claude").mkdir(parents=True, exist_ok=True)
project_root = workspace / "凡人资本论"
(project_root / ".noma").mkdir(parents=True, exist_ok=True)
(project_root / ".noma" / "state.json").write_text("{}", encoding="utf-8")
pointer_file = write_current_project_pointer(project_root, workspace_root=workspace)
assert pointer_file is not None
assert pointer_file.is_file()
resolved = resolve_project_root(cwd=workspace)
assert resolved == project_root.resolve()
def test_resolve_project_root_ignores_stale_pointer_and_fallbacks(tmp_path):
_ensure_scripts_on_path()
from project_locator import resolve_project_root
workspace = tmp_path / "workspace"
(workspace / ".claude").mkdir(parents=True, exist_ok=True)
# stale pointer
(workspace / ".claude" / ".noma-current-project").write_text(
str(workspace / "missing-project"), encoding="utf-8"
)
default_project = workspace / "noma-project"
(default_project / ".noma").mkdir(parents=True, exist_ok=True)
(default_project / ".noma" / "state.json").write_text("{}", encoding="utf-8")
resolved = resolve_project_root(cwd=workspace)
assert resolved == default_project.resolve()
@@ -0,0 +1,513 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
RAGAdapter tests
"""
import sys
import json
import asyncio
import logging
import sqlite3
from contextlib import closing
import pytest
import data_modules.rag_adapter as rag_module
from data_modules.rag_adapter import RAGAdapter
from data_modules.config import DataModulesConfig
from data_modules.index_manager import EntityMeta, RelationshipMeta
class StubClient:
async def embed(self, texts):
return [[1.0, 0.0] for _ in texts]
async def embed_batch(self, texts, skip_failures=True):
return [[1.0, 0.0] for _ in texts]
async def rerank(self, query, documents, top_n=None):
top_n = top_n or len(documents)
return [{"index": i, "relevance_score": 1.0 / (i + 1)} for i in range(min(top_n, len(documents)))]
class StubClientWithFailures(StubClient):
async def embed_batch(self, texts, skip_failures=True):
if len(texts) == 1:
return [None]
return [None, [1.0, 0.0]]
class StubEmbedClient401:
def __init__(self):
self.last_error_status = 401
self.last_error_message = "auth failed"
class StubClientAuthFailure(StubClient):
def __init__(self):
self._embed_client = StubEmbedClient401()
async def embed(self, texts):
return None
class StubClientRerankFailure(StubClient):
async def rerank(self, query, documents, top_n=None):
return []
@pytest.fixture
def temp_project(tmp_path, monkeypatch):
cfg = DataModulesConfig.from_project_root(tmp_path)
cfg.ensure_dirs()
monkeypatch.setattr(rag_module, "get_client", lambda config: StubClient())
return cfg
@pytest.mark.asyncio
async def test_store_and_search(temp_project):
adapter = RAGAdapter(temp_project)
chunks = [
{"chapter": 1, "scene_index": 1, "content": "萧炎在天云宗修炼斗气"},
{"chapter": 1, "scene_index": 2, "content": "药老传授炼药技巧"},
]
stored = await adapter.store_chunks(chunks)
assert stored == 2
vec_results = await adapter.vector_search("萧炎", top_k=2)
assert len(vec_results) == 2
bm25_results = adapter.bm25_search("萧炎", top_k=2)
assert len(bm25_results) >= 1
stats = adapter.get_stats()
assert stats["vectors"] == 2
@pytest.mark.asyncio
async def test_store_chunks_with_embedding_failure(tmp_path, monkeypatch):
cfg = DataModulesConfig.from_project_root(tmp_path)
cfg.ensure_dirs()
monkeypatch.setattr(rag_module, "get_client", lambda config: StubClientWithFailures())
adapter = RAGAdapter(cfg)
chunks = [
{"chapter": 1, "scene_index": 1, "content": "短内容"},
{"chapter": 1, "scene_index": 2, "content": "稍长内容用于索引"},
]
stored = await adapter.store_chunks(chunks)
assert stored == 1
@pytest.mark.asyncio
async def test_hybrid_search_full_scan(temp_project):
adapter = RAGAdapter(temp_project)
await adapter.store_chunks(
[{"chapter": 1, "scene_index": 1, "content": "萧炎修炼"}]
)
results = await adapter.hybrid_search("萧炎", vector_top_k=5, bm25_top_k=5, rerank_top_n=1)
assert results
assert results[0].source == "hybrid"
@pytest.mark.asyncio
async def test_hybrid_search_prefilter(tmp_path, monkeypatch):
cfg = DataModulesConfig.from_project_root(tmp_path)
cfg.ensure_dirs()
cfg.vector_full_scan_max_vectors = 0
monkeypatch.setattr(rag_module, "get_client", lambda config: StubClient())
adapter = RAGAdapter(cfg)
await adapter.store_chunks(
[
{"chapter": 1, "scene_index": 1, "content": "萧炎修炼"},
{"chapter": 2, "scene_index": 1, "content": "药老出场"},
]
)
results = await adapter.hybrid_search("药老", vector_top_k=2, bm25_top_k=2, rerank_top_n=1)
assert results
@pytest.mark.asyncio
async def test_search_respects_chapter_filter_across_strategies(tmp_path, monkeypatch):
cfg = DataModulesConfig.from_project_root(tmp_path)
cfg.ensure_dirs()
cfg.vector_full_scan_max_vectors = 0 # 强制走预筛选分支
monkeypatch.setattr(rag_module, "get_client", lambda config: StubClient())
adapter = RAGAdapter(cfg)
await adapter.store_chunks(
[
{"chapter": 1, "scene_index": 1, "content": "前文线索,尚未涉及关键宝物"},
{"chapter": 2, "scene_index": 1, "content": "秘宝现世,引发争夺"},
{"chapter": 3, "scene_index": 1, "content": "秘宝大战彻底爆发"},
]
)
vector_results = await adapter.vector_search("秘宝", top_k=5, chapter=1)
assert vector_results
assert all((r.chapter or 0) <= 1 for r in vector_results)
bm25_results = adapter.bm25_search("秘宝", top_k=5, chapter=1)
assert bm25_results
assert all((r.chapter or 0) <= 1 for r in bm25_results)
hybrid_results = await adapter.hybrid_search(
"秘宝",
vector_top_k=5,
bm25_top_k=5,
rerank_top_n=3,
chapter=1,
)
assert hybrid_results
assert all((r.chapter or 0) <= 1 for r in hybrid_results)
@pytest.mark.asyncio
async def test_graph_hybrid_search_with_entity_expansion(tmp_path, monkeypatch):
cfg = DataModulesConfig.from_project_root(tmp_path)
cfg.ensure_dirs()
cfg.graph_rag_enabled = True
monkeypatch.setattr(rag_module, "get_client", lambda config: StubClient())
adapter = RAGAdapter(cfg)
adapter.index_manager.upsert_entity(
EntityMeta(
id="xiaoyan",
type="角色",
canonical_name="萧炎",
current={},
first_appearance=1,
last_appearance=2,
)
)
adapter.index_manager.upsert_entity(
EntityMeta(
id="yaolao",
type="角色",
canonical_name="药老",
current={},
first_appearance=1,
last_appearance=2,
)
)
adapter.index_manager.register_alias("萧炎", "xiaoyan", "角色")
adapter.index_manager.register_alias("药老", "yaolao", "角色")
adapter.index_manager.upsert_relationship(
RelationshipMeta(
from_entity="xiaoyan",
to_entity="yaolao",
type="师徒",
description="收徒",
chapter=1,
)
)
await adapter.store_chunks(
[
{"chapter": 1, "scene_index": 1, "content": "萧炎拜药老为师,正式成为师徒"},
{"chapter": 2, "scene_index": 1, "content": "萧炎在天云宗修炼斗气"},
]
)
results = await adapter.graph_hybrid_search(
"萧炎和药老关系",
top_k=2,
center_entities=["萧炎", "药老"],
)
assert results
assert any("药老" in r.content for r in results)
assert all(r.source == "graph_hybrid" for r in results)
@pytest.mark.asyncio
async def test_search_auto_uses_graph_strategy_when_enabled(tmp_path, monkeypatch):
cfg = DataModulesConfig.from_project_root(tmp_path)
cfg.ensure_dirs()
cfg.graph_rag_enabled = True
monkeypatch.setattr(rag_module, "get_client", lambda config: StubClient())
adapter = RAGAdapter(cfg)
adapter.index_manager.upsert_entity(
EntityMeta(
id="xiaoyan",
type="角色",
canonical_name="萧炎",
current={},
first_appearance=1,
last_appearance=1,
)
)
adapter.index_manager.register_alias("萧炎", "xiaoyan", "角色")
await adapter.store_chunks(
[{"chapter": 1, "scene_index": 1, "content": "萧炎突破斗师"}]
)
results = await adapter.search("萧炎关系", top_k=1, strategy="auto")
assert results
assert results[0].source in {"graph_hybrid", "hybrid"}
@pytest.mark.asyncio
async def test_graph_hybrid_search_fallback_when_graph_disabled(tmp_path, monkeypatch):
cfg = DataModulesConfig.from_project_root(tmp_path)
cfg.ensure_dirs()
cfg.graph_rag_enabled = False
monkeypatch.setattr(rag_module, "get_client", lambda config: StubClient())
adapter = RAGAdapter(cfg)
await adapter.store_chunks(
[{"chapter": 1, "scene_index": 1, "content": "萧炎在天云宗修炼斗气"}]
)
modes = []
def _record_log(query, mode, results, latency_ms, chapter=None):
modes.append(mode)
monkeypatch.setattr(adapter, "_log_query", _record_log)
results = await adapter.graph_hybrid_search("萧炎关系", top_k=1)
assert results
assert modes
assert modes[-1] == "graph_hybrid_fallback"
assert all(r.source == "hybrid" for r in results)
@pytest.mark.asyncio
async def test_graph_hybrid_search_rerank_failure_uses_candidates(tmp_path, monkeypatch):
cfg = DataModulesConfig.from_project_root(tmp_path)
cfg.ensure_dirs()
cfg.graph_rag_enabled = True
monkeypatch.setattr(rag_module, "get_client", lambda config: StubClientRerankFailure())
adapter = RAGAdapter(cfg)
adapter.index_manager.upsert_entity(
EntityMeta(
id="xiaoyan",
type="角色",
canonical_name="萧炎",
current={},
first_appearance=1,
last_appearance=2,
)
)
adapter.index_manager.upsert_entity(
EntityMeta(
id="yaolao",
type="角色",
canonical_name="药老",
current={},
first_appearance=1,
last_appearance=2,
)
)
adapter.index_manager.register_alias("萧炎", "xiaoyan", "角色")
adapter.index_manager.register_alias("药老", "yaolao", "角色")
adapter.index_manager.upsert_relationship(
RelationshipMeta(
from_entity="xiaoyan",
to_entity="yaolao",
type="师徒",
description="收徒",
chapter=1,
)
)
await adapter.store_chunks(
[
{"chapter": 1, "scene_index": 1, "content": "萧炎拜药老为师,正式成为师徒"},
{"chapter": 2, "scene_index": 1, "content": "萧炎在天云宗修炼斗气"},
]
)
results = await adapter.graph_hybrid_search(
"萧炎和药老关系",
top_k=2,
center_entities=["萧炎", "药老"],
)
assert results
assert len(results) <= 2
assert all(r.source == "graph_hybrid" for r in results)
@pytest.mark.asyncio
async def test_search_unknown_strategy_falls_back_to_hybrid(tmp_path, monkeypatch):
cfg = DataModulesConfig.from_project_root(tmp_path)
cfg.ensure_dirs()
monkeypatch.setattr(rag_module, "get_client", lambda config: StubClient())
adapter = RAGAdapter(cfg)
await adapter.store_chunks(
[{"chapter": 1, "scene_index": 1, "content": "萧炎在天云宗修炼斗气"}]
)
results = await adapter.search("萧炎", top_k=1, strategy="not_exists")
assert results
assert all(r.source == "hybrid" for r in results)
@pytest.mark.asyncio
async def test_search_with_backtrack(temp_project):
adapter = RAGAdapter(temp_project)
chunks = [
{
"chapter": 1,
"scene_index": 0,
"content": "章节摘要",
"chunk_type": "summary",
"chunk_id": "ch0001_summary",
"source_file": "summaries/ch0001.md",
},
{
"chapter": 1,
"scene_index": 1,
"content": "场景内容",
"chunk_type": "scene",
"chunk_id": "ch0001_s1",
"parent_chunk_id": "ch0001_summary",
"source_file": "正文/第0001章.md#scene_1",
},
]
await adapter.store_chunks(chunks)
results = await adapter.search_with_backtrack("场景", top_k=1)
assert any(r.chunk_type == "summary" for r in results)
def test_vector_helpers(temp_project):
adapter = RAGAdapter(temp_project)
emb = [1.0, 0.0]
data = adapter._serialize_embedding(emb)
assert adapter._deserialize_embedding(data) == emb
assert adapter._cosine_similarity([0.0, 0.0], [1.0, 0.0]) == 0.0
def test_recent_and_fetch_vectors(temp_project):
adapter = RAGAdapter(temp_project)
with adapter._get_conn() as conn:
cursor = conn.cursor()
cursor.execute(
"INSERT INTO vectors (chunk_id, chapter, scene_index, content, embedding, parent_chunk_id, chunk_type, source_file) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
("ch0001_s1", 1, 1, "内容", b"", None, "scene", "正文/第0001章.md#scene_1"),
)
cursor.execute(
"INSERT INTO vectors (chunk_id, chapter, scene_index, content, embedding, parent_chunk_id, chunk_type, source_file) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
("ch0002_s1", 2, 1, "后文内容", b"", None, "scene", "正文/第0002章.md#scene_1"),
)
conn.commit()
assert adapter._get_vectors_count() == 2
assert adapter._get_recent_chunk_ids(1) == ["ch0002_s1"]
assert adapter._get_recent_chunk_ids(10, chapter=1) == ["ch0001_s1"]
rows = adapter._fetch_vectors_by_chunk_ids(["ch0001_s1"])
assert len(rows) == 1
def test_init_db_migrates_legacy_vectors_schema(tmp_path, monkeypatch):
cfg = DataModulesConfig.from_project_root(tmp_path)
cfg.ensure_dirs()
monkeypatch.setattr(rag_module, "get_client", lambda config: StubClient())
# 旧结构:缺少 parent_chunk_id/chunk_type/source_file/created_at
with closing(sqlite3.connect(str(cfg.vector_db))) as conn:
cursor = conn.cursor()
cursor.execute(
"""
CREATE TABLE vectors (
chunk_id TEXT PRIMARY KEY,
chapter INTEGER,
scene_index INTEGER,
content TEXT,
embedding BLOB
)
"""
)
cursor.execute(
"""
INSERT INTO vectors (chunk_id, chapter, scene_index, content, embedding)
VALUES (?, ?, ?, ?, ?)
""",
("ch0001_s1", 1, 1, "旧数据", b""),
)
conn.commit()
adapter = RAGAdapter(cfg)
with adapter._get_conn() as conn:
cursor = conn.cursor()
cursor.execute("PRAGMA table_info(vectors)")
cols = {row[1] for row in cursor.fetchall()}
assert {"parent_chunk_id", "chunk_type", "source_file", "created_at"}.issubset(cols)
cursor.execute("SELECT COUNT(*) FROM vectors")
assert cursor.fetchone()[0] == 1
cursor.execute("SELECT chunk_type FROM vectors WHERE chunk_id = ?", ("ch0001_s1",))
row = cursor.fetchone()
assert row is not None
assert row[0] == "scene"
backup_dir = cfg.noma_dir / "backups"
backups = list(backup_dir.glob("vectors.db.schema_migration.v*.bak"))
assert backups
def test_rag_adapter_cli(temp_project, monkeypatch, capsys):
# stats
def run_cli(args):
monkeypatch.setattr(sys, "argv", ["rag_adapter"] + args)
rag_module.main()
root = str(temp_project.project_root)
run_cli(["--project-root", root, "stats"])
# index-chapter
run_cli(
[
"--project-root",
root,
"index-chapter",
"--chapter",
"1",
"--scenes",
json.dumps([{"index": 1, "summary": "摘要", "content": "内容"}], ensure_ascii=False),
]
)
# search
run_cli(["--project-root", root, "search", "--query", "内容", "--mode", "bm25", "--top-k", "5"])
run_cli(["--project-root", root, "search", "--query", "内容", "--mode", "vector", "--top-k", "5"])
run_cli(["--project-root", root, "search", "--query", "内容", "--mode", "hybrid", "--top-k", "5"])
run_cli(["--project-root", root, "search", "--query", "内容", "--mode", "auto", "--top-k", "5"])
capsys.readouterr()
def test_rag_adapter_log_query_failure_is_reported(temp_project, monkeypatch, caplog):
adapter = RAGAdapter(temp_project)
def _raise_log_error(*args, **kwargs):
raise RuntimeError("log write failed")
monkeypatch.setattr(adapter.index_manager, "log_rag_query", _raise_log_error)
with caplog.at_level(logging.WARNING):
adapter._log_query("q", "vector", [], 1)
message_text = "\n".join(record.getMessage() for record in caplog.records)
assert "failed to log rag query" in message_text
def test_rag_adapter_cli_search_shows_degraded_warning(temp_project, monkeypatch, capsys):
monkeypatch.setattr(rag_module, "get_client", lambda config: StubClientAuthFailure())
def run_cli(args):
monkeypatch.setattr(sys, "argv", ["rag_adapter"] + args)
rag_module.main()
root = str(temp_project.project_root)
run_cli(["--project-root", root, "search", "--query", "测试", "--mode", "vector", "--top-k", "3"])
captured = capsys.readouterr()
payload = json.loads(captured.out.strip().splitlines()[-1])
assert payload.get("status") == "success"
warnings = payload.get("warnings") or []
assert warnings
assert warnings[0].get("code") == "DEGRADED_MODE"
assert warnings[0].get("reason") == "embedding_auth_failed"
@@ -0,0 +1,333 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
关系事件与关系图谱测试
"""
import json
import sys
import pytest
import data_modules.index_manager as index_manager_module
from data_modules.config import DataModulesConfig
from data_modules.index_manager import (
EntityMeta,
IndexManager,
RelationshipEventMeta,
RelationshipMeta,
)
@pytest.fixture
def temp_project(tmp_path):
cfg = DataModulesConfig.from_project_root(tmp_path)
cfg.ensure_dirs()
return cfg
def test_relationship_events_timeline_and_subgraph(temp_project):
manager = IndexManager(temp_project)
manager.upsert_entity(
EntityMeta(
id="xiaoyan",
type="角色",
canonical_name="萧炎",
tier="核心",
current={},
first_appearance=1,
last_appearance=10,
is_protagonist=True,
)
)
manager.upsert_entity(
EntityMeta(
id="yaolao",
type="角色",
canonical_name="药老",
tier="重要",
current={},
first_appearance=1,
last_appearance=10,
)
)
manager.upsert_entity(
EntityMeta(
id="lintian",
type="角色",
canonical_name="林天",
tier="重要",
current={},
first_appearance=2,
last_appearance=10,
)
)
manager.upsert_relationship(
RelationshipMeta(
from_entity="xiaoyan",
to_entity="yaolao",
type="师徒",
description="正式拜师",
chapter=3,
)
)
manager.upsert_relationship(
RelationshipMeta(
from_entity="yaolao",
to_entity="lintian",
type="敌对",
description="理念冲突",
chapter=5,
)
)
event_id = manager.record_relationship_event(
RelationshipEventMeta(
from_entity="xiaoyan",
to_entity="yaolao",
type="师徒",
chapter=3,
action="create",
polarity=1,
strength=0.9,
description="拜师",
evidence="公开收徒",
confidence=0.95,
)
)
assert event_id > 0
manager.record_relationship_event(
RelationshipEventMeta(
from_entity="yaolao",
to_entity="lintian",
type="敌对",
chapter=5,
action="create",
polarity=-1,
strength=0.8,
description="结怨",
evidence="比斗失手",
confidence=0.8,
)
)
events = manager.get_relationship_events("xiaoyan", direction="both", limit=20)
assert events
timeline = manager.get_relationship_timeline("xiaoyan", "yaolao", limit=20)
assert timeline
assert timeline[0]["type"] == "师徒"
graph = manager.build_relationship_subgraph("xiaoyan", depth=2, chapter=10, top_edges=10)
node_ids = {n["id"] for n in graph["nodes"]}
assert "xiaoyan" in node_ids
assert "yaolao" in node_ids
assert "lintian" in node_ids
assert graph["edges"]
mermaid = manager.render_relationship_subgraph_mermaid(graph)
assert "mermaid" in mermaid
assert "师徒" in mermaid
def test_relationship_subgraph_respects_chapter_slice(temp_project):
manager = IndexManager(temp_project)
manager.upsert_entity(
EntityMeta(
id="a",
type="角色",
canonical_name="",
current={},
first_appearance=1,
last_appearance=3,
is_protagonist=True,
)
)
manager.upsert_entity(
EntityMeta(
id="b",
type="角色",
canonical_name="",
current={},
first_appearance=1,
last_appearance=3,
)
)
manager.record_relationship_event(
RelationshipEventMeta(
from_entity="a",
to_entity="b",
type="同盟",
chapter=1,
action="create",
polarity=1,
strength=0.6,
)
)
manager.record_relationship_event(
RelationshipEventMeta(
from_entity="a",
to_entity="b",
type="同盟",
chapter=2,
action="remove",
polarity=0,
strength=0.0,
)
)
graph_ch1 = manager.build_relationship_subgraph("a", depth=1, chapter=1, top_edges=10)
graph_ch3 = manager.build_relationship_subgraph("a", depth=1, chapter=3, top_edges=10)
assert len(graph_ch1["edges"]) == 1
assert len(graph_ch3["edges"]) == 0
def test_relationship_subgraph_fallbacks_to_snapshot_when_events_missing(temp_project):
manager = IndexManager(temp_project)
manager.upsert_entity(
EntityMeta(
id="a",
type="角色",
canonical_name="",
current={},
first_appearance=1,
last_appearance=5,
is_protagonist=True,
)
)
manager.upsert_entity(
EntityMeta(
id="b",
type="角色",
canonical_name="",
current={},
first_appearance=1,
last_appearance=5,
)
)
# 只写 relationships 快照,不写 relationship_events
manager.upsert_relationship(
RelationshipMeta(
from_entity="a",
to_entity="b",
type="同盟",
description="旧版快照数据",
chapter=3,
)
)
graph = manager.build_relationship_subgraph("a", depth=1, chapter=3, top_edges=10)
assert graph["edges"]
assert graph["edges"][0]["action"] == "snapshot"
assert graph["edges"][0]["type"] == "同盟"
def test_relationship_graph_cli_commands(temp_project, monkeypatch, capsys):
manager = IndexManager(temp_project)
manager.upsert_entity(
EntityMeta(
id="hero",
type="角色",
canonical_name="主角",
current={},
first_appearance=1,
last_appearance=1,
is_protagonist=True,
)
)
manager.upsert_entity(
EntityMeta(
id="mentor",
type="角色",
canonical_name="师父",
current={},
first_appearance=1,
last_appearance=1,
)
)
manager.record_relationship_event(
RelationshipEventMeta(
from_entity="hero",
to_entity="mentor",
type="师徒",
chapter=1,
action="create",
polarity=1,
strength=0.9,
)
)
root = str(temp_project.project_root)
def run_cli(args):
monkeypatch.setattr(sys, "argv", ["index_manager"] + args)
index_manager_module.main()
output = capsys.readouterr().out.strip().splitlines()
assert output
return json.loads(output[-1])
payload = run_cli(
[
"--project-root",
root,
"get-relationship-events",
"--entity",
"hero",
"--direction",
"both",
"--limit",
"10",
]
)
assert payload["status"] == "success"
assert payload["data"]
payload = run_cli(
[
"--project-root",
root,
"get-relationship-graph",
"--center",
"hero",
"--depth",
"1",
"--chapter",
"1",
"--format",
"mermaid",
]
)
assert payload["status"] == "success"
assert "mermaid" in payload["data"]["mermaid"]
payload = run_cli(
[
"--project-root",
root,
"get-relationship-timeline",
"--a",
"hero",
"--b",
"mentor",
"--limit",
"10",
]
)
assert payload["status"] == "success"
assert payload["data"]
payload = run_cli(
[
"--project-root",
root,
"record-relationship-event",
"--data",
json.dumps(
{
"from_entity": "hero",
"type": "师徒",
"chapter": 1,
},
ensure_ascii=False,
),
]
)
assert payload["status"] == "error"
assert payload["error"]["code"] == "INVALID_RELATIONSHIP_EVENT"
@@ -0,0 +1,213 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
SQLStateManager tests
"""
import json
import sys
import pytest
import data_modules.sql_state_manager as sql_state_manager_module
from data_modules.sql_state_manager import SQLStateManager, EntityData
from data_modules.index_manager import EntityMeta
@pytest.fixture
def temp_project(tmp_path):
from data_modules.config import DataModulesConfig
cfg = DataModulesConfig.from_project_root(tmp_path)
cfg.ensure_dirs()
return cfg
def test_sql_state_manager_entity_and_alias(temp_project):
manager = SQLStateManager(temp_project)
entity = EntityData(
id="xiaoyan",
type="角色",
name="萧炎",
tier="核心",
current={"realm": "斗师"},
aliases=["炎帝", "小炎子"],
is_protagonist=True,
)
assert manager.upsert_entity(entity) is True
assert manager.upsert_entity(entity) is False
fetched = manager.get_entity("xiaoyan")
assert "炎帝" in fetched["aliases"]
by_type = manager.get_entities_by_type("角色")
assert any(e["id"] == "xiaoyan" for e in by_type)
core = manager.get_core_entities()
assert any(e["id"] == "xiaoyan" for e in core)
protagonist = manager.get_protagonist()
assert protagonist["id"] == "xiaoyan"
resolved = manager.resolve_alias("炎帝")
assert any(r["id"] == "xiaoyan" for r in resolved)
assert manager.update_entity_current("xiaoyan", {"realm": "斗王"}) is True
updated = manager.get_entity("xiaoyan")
assert updated["current_json"]["realm"] == "斗王"
def test_sql_state_manager_state_changes_and_relationships(temp_project):
manager = SQLStateManager(temp_project)
manager.upsert_entity(
EntityData(id="xiaoyan", type="角色", name="萧炎", current={})
)
change_id = manager.record_state_change(
entity_id="xiaoyan",
field="realm",
old_value="斗者",
new_value="斗师",
reason="突破",
chapter=2,
)
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
assert manager.upsert_relationship(
from_entity="xiaoyan",
to_entity="yaolao",
type="师徒",
description="收徒",
chapter=1,
)
rels = manager.get_entity_relationships("xiaoyan", direction="from")
assert len(rels) == 1
between = manager.get_relationship_between("xiaoyan", "yaolao")
assert len(between) == 1
assert len(manager.get_recent_relationships(limit=5)) >= 1
def test_sql_state_manager_process_chapter_entities_and_exports(temp_project):
manager = SQLStateManager(temp_project)
stats = manager.process_chapter_entities(
chapter=10,
entities_appeared=[{"id": "xiaoyan", "mentions": ["萧炎"], "confidence": 0.9}],
entities_new=[
{"suggested_id": "yaolao", "name": "药老", "type": "角色", "tier": "重要"}
],
state_changes=[
{"entity_id": "yaolao", "field": "status", "old": "", "new": "出场", "reason": "登场"}
],
relationships_new=[
{"from": "xiaoyan", "to": "yaolao", "type": "师徒", "description": "收徒"}
],
)
assert stats["entities_created"] >= 1
assert stats["relationships"] == 1
rel_events = manager._index_manager.get_relationship_events("xiaoyan", direction="both")
assert len(rel_events) >= 1
entities_v3 = manager.export_to_entities_v3_format()
assert "角色" in entities_v3
alias_index = manager.export_to_alias_index_format()
assert isinstance(alias_index, dict)
def test_sql_state_manager_existing_entity_updates_and_stats(temp_project):
manager = SQLStateManager(temp_project)
manager.upsert_entity(
EntityData(id="xiaoyan", type="角色", name="萧炎", current={"hp": 5})
)
stats = manager.process_chapter_entities(
chapter=3,
entities_appeared=[{"id": "xiaoyan", "mentions": ["萧炎"], "confidence": 0.9}],
entities_new=[],
state_changes=[
{"entity_id": "xiaoyan", "field": "hp", "old": 5, "new": 0, "reason": "受伤"}
],
relationships_new=[
{"from_entity": "xiaoyan", "to_entity": "yaolao", "type": "师徒", "description": "收徒"}
],
)
assert stats["entities_updated"] >= 1
assert stats["state_changes"] == 1
updated = manager.get_entity("xiaoyan")
assert updated["current_json"]["hp"] == 0
rels = manager.get_entity_relationships("yaolao", direction="to")
assert rels
stats_summary = manager.get_stats()
assert "entities" in stats_summary
exported = manager.export_to_entities_v3_format()
assert exported["角色"]["xiaoyan"]["canonical_name"] == "萧炎"
def test_sql_state_manager_process_chapter_skips_and_existing(temp_project):
manager = SQLStateManager(temp_project)
manager.upsert_entity(EntityData(id="xiaoyan", type="角色", name="萧炎"))
stats = manager.process_chapter_entities(
chapter=1,
entities_appeared=[{"mentions": ["无ID"]}, {"id": "xiaoyan", "mentions": ["萧炎"]}],
entities_new=[{"name": "无ID"}, {"suggested_id": "xiaoyan", "name": "萧炎"}],
state_changes=[{"field": "realm"}, {"entity_id": "xiaoyan", "field": "hp", "old": 1, "new": 1}],
relationships_new=[{"from": "xiaoyan", "to": ""}],
)
assert stats["entities_updated"] >= 1
assert stats["relationships"] == 0
def test_sql_state_manager_export_protagonist_and_cli(temp_project, monkeypatch, capsys):
manager = SQLStateManager(temp_project)
def run_cli(args):
monkeypatch.setattr(sys, "argv", args)
sql_state_manager_module.main()
return json.loads(capsys.readouterr().out or "{}")
out = run_cli(["sql_state_manager", "--project-root", str(temp_project.project_root), "get-protagonist"])
assert out.get("status") == "error"
manager.upsert_entity(
EntityData(id="xiaoyan", type="角色", name="萧炎", is_protagonist=True)
)
exported = manager.export_to_entities_v3_format()
assert exported["角色"]["xiaoyan"]["is_protagonist"] is True
out = run_cli(["sql_state_manager", "--project-root", str(temp_project.project_root), "get-protagonist"])
assert out["status"] == "success"
assert out["data"].get("canonical_name") == "萧炎"
out = run_cli(["sql_state_manager", "--project-root", str(temp_project.project_root), "stats"])
assert out["status"] == "success"
assert "entities" in out.get("data", {})
out = run_cli(["sql_state_manager", "--project-root", str(temp_project.project_root), "get-core-entities"])
assert out["status"] == "success"
out = run_cli(["sql_state_manager", "--project-root", str(temp_project.project_root), "export-entities-v3"])
assert out["status"] == "success"
assert "角色" in out.get("data", {})
out = run_cli(["sql_state_manager", "--project-root", str(temp_project.project_root), "export-alias-index"])
assert out["status"] == "success"
assert isinstance(out.get("data", {}), dict)
payload = json.dumps({"entities_appeared": [], "entities_new": [], "state_changes": [], "relationships_new": []})
out = run_cli([
"sql_state_manager",
"--project-root",
str(temp_project.project_root),
"process-chapter",
"--chapter",
"2",
"--data",
payload,
])
assert out["status"] == "success"
@@ -0,0 +1,568 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
StateManager extra tests
"""
import json
import sys
import tempfile
from pathlib import Path
import pytest
from data_modules.state_manager import StateManager, EntityState
from data_modules.index_manager import IndexManager, EntityMeta
@pytest.fixture
def temp_project(tmp_path):
from data_modules.config import DataModulesConfig
cfg = DataModulesConfig.from_project_root(tmp_path)
cfg.ensure_dirs()
return cfg
def test_ensure_state_schema_and_progress(temp_project):
# relationships as list should be migrated to structured_relationships
state = {
"relationships": [
{"from_entity": "a", "to_entity": "b", "type": "师徒", "chapter": 1}
],
"progress": {"current_chapter": "2", "total_words": "10"},
}
temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
manager = StateManager(temp_project, enable_sqlite_sync=False)
assert isinstance(manager._state.get("relationships"), dict)
assert isinstance(manager._state.get("structured_relationships"), list)
assert int(manager.get_current_chapter()) == 2
manager.update_progress(3)
assert manager.get_current_chapter() == 3
def test_add_update_entities_and_alias(temp_project):
manager = StateManager(temp_project, enable_sqlite_sync=False)
entity = EntityState(id="xiaoyan", name="萧炎", type="角色", tier="核心", aliases=["炎帝"])
assert manager.add_entity(entity) is True
assert manager.add_entity(entity) is False
manager.update_entity("xiaoyan", {"current": {"realm": "斗师"}})
updated = manager.get_entity("xiaoyan")
assert updated["current"]["realm"] == "斗师"
assert manager.get_entity_type("xiaoyan") == "角色"
assert manager.get_entity_type("missing") is None
assert "xiaoyan" in manager.get_all_entities()
assert "xiaoyan" in manager.get_entities_by_type("角色")
assert "xiaoyan" in manager.get_entities_by_tier("核心")
# unknown type update
assert manager.update_entity("missing", {"current": {"realm": "斗者"}}, "角色") is False
def test_update_entity_appearance_and_relationships(temp_project):
manager = StateManager(temp_project, enable_sqlite_sync=False)
manager.add_entity(EntityState(id="xiaoyan", name="萧炎", type="角色"))
manager.update_entity_appearance("xiaoyan", 5, "角色")
entity = manager.get_entity("xiaoyan")
assert entity.get("first_appearance") == 5
assert entity.get("last_appearance") == 5
# unknown entity should no-op
manager.update_entity_appearance("missing", 3, "角色")
manager.add_relationship("xiaoyan", "yaolao", "师徒", "收徒", 1)
rels = manager.get_relationships("xiaoyan")
assert len(rels) == 1
def test_disambiguation_and_save_state(temp_project):
manager = StateManager(temp_project, enable_sqlite_sync=False)
warnings = manager._record_disambiguation(
1,
[
{
"mention": "宗主",
"candidates": ["zongzhu", "lintian"],
"suggested": "zongzhu",
"confidence": 0.4,
},
{
"mention": "萧炎",
"candidates": [{"type": "角色", "id": "xiaoyan"}],
"suggested": "xiaoyan",
"confidence": 0.6,
},
],
)
assert any("需人工确认" in w for w in warnings)
assert any("消歧警告" in w for w in warnings)
manager.save_state()
assert temp_project.state_file.exists()
def test_save_state_no_pending(temp_project):
manager = StateManager(temp_project, enable_sqlite_sync=False)
manager.save_state()
assert not temp_project.state_file.exists()
def test_save_state_with_sqlite_sync_and_protagonist(temp_project):
manager = StateManager(temp_project)
manager.add_entity(EntityState(id="xiaoyan", name="萧炎", type="角色", tier="核心"))
manager.update_entity("xiaoyan", {"current": {"realm": "斗师", "location": "天云宗"}})
manager.update_progress(10, words=500)
manager.save_state()
state = json.loads(temp_project.state_file.read_text(encoding="utf-8"))
assert state.get("_migrated_to_sqlite") is True
assert state.get("progress", {}).get("current_chapter") == 10
# 标记为主角并同步
idx = IndexManager(temp_project)
idx.upsert_entity(
EntityMeta(
id="xiaoyan",
type="角色",
canonical_name="萧炎",
tier="核心",
current={"realm": "斗王", "location": "天云宗"},
first_appearance=1,
last_appearance=10,
is_protagonist=True,
),
update_metadata=True,
)
manager.sync_protagonist_from_entity()
assert manager._state.get("protagonist_state", {}).get("power", {}).get("realm") == "斗王"
manager._state["protagonist_state"] = {
"power": {"realm": "斗皇", "layer": 2},
"location": {"current": "中州"},
}
manager._state.setdefault("entities_v3", {"角色": {}})
manager._state["entities_v3"]["角色"]["xiaoyan"] = {
"canonical_name": "萧炎",
"tier": "核心",
"desc": "",
"current": {"realm": "斗王", "location": "天云宗"},
"first_appearance": 1,
"last_appearance": 10,
"history": [],
}
manager.sync_protagonist_to_entity("xiaoyan")
manager.save_state()
updated = idx.get_entity("xiaoyan")
assert updated["current_json"]["realm"] == "斗皇"
# export context
exported = manager.export_for_context()
assert exported.get("alias_index") == {}
def test_process_chapter_result_and_sqlite_sync(temp_project):
manager = StateManager(temp_project)
manager.add_entity(EntityState(id="xiaoyan", name="萧炎", type="角色", tier="核心"))
result = {
"entities_appeared": [
{"id": "xiaoyan", "type": "角色", "mentions": ["萧炎"], "confidence": 0.9}
],
"entities_new": [
{
"suggested_id": "yaolao",
"name": "药老",
"type": "角色",
"tier": "重要",
"mentions": ["药老"],
"aliases": ["药老先生"],
}
],
"state_changes": [
{"entity_id": "xiaoyan", "field": "realm", "old": "斗者", "new": "斗师", "reason": "突破"}
],
"relationships_new": [
{"from": "xiaoyan", "to": "yaolao", "type": "师徒", "description": "收徒"}
],
"uncertain": [
{"mention": "宗主", "candidates": ["zongzhu", "lintian"], "suggested": "zongzhu", "confidence": 0.2},
{
"mention": "萧炎",
"candidates": [{"type": "角色", "id": "xiaoyan"}],
"suggested": "xiaoyan",
"confidence": 0.8,
"adopted": True,
},
],
"chapter_meta": {"hook": "test", "end": "ok"},
}
warnings = manager.process_chapter_result(12, result)
assert any("需人工确认" in w for w in warnings)
assert any("消歧警告" in w for w in warnings)
manager.save_state()
idx = IndexManager(temp_project)
assert idx.get_entity("yaolao") is not None
assert idx.get_relationship_between("xiaoyan", "yaolao")
assert idx.get_entity_state_changes("xiaoyan")
by_type = manager.get_entities_by_type("角色")
by_tier = manager.get_entities_by_tier("核心")
assert "xiaoyan" in by_type
assert "xiaoyan" in by_tier
def test_export_context_and_protagonist_alias(temp_project):
manager = StateManager(temp_project, enable_sqlite_sync=False)
manager.add_entity(EntityState(id="xiaoyan", name="萧炎", type="角色", tier="核心"))
manager._state["disambiguation_warnings"] = [{"chapter": 1, "mention": "萧炎"}]
manager._state["disambiguation_pending"] = [{"chapter": 2, "mention": "宗主"}]
exported = manager.export_for_context()
assert "xiaoyan" in exported.get("entities", {})
assert exported["disambiguation"]["warnings"]
assert exported["disambiguation"]["pending"]
manager_sql = StateManager(temp_project)
idx = IndexManager(temp_project)
idx.upsert_entity(
EntityMeta(
id="xiaoyan",
type="角色",
canonical_name="萧炎",
tier="核心",
current={},
first_appearance=1,
last_appearance=1,
is_protagonist=False,
),
update_metadata=True,
)
idx.register_alias("小炎子", "xiaoyan", "角色")
manager_sql._state["protagonist_state"] = {"name": "小炎子"}
assert manager_sql.get_protagonist_entity_id() == "xiaoyan"
idx.upsert_entity(
EntityMeta(
id="xiaoyan",
type="角色",
canonical_name="萧炎",
tier="核心",
current={},
first_appearance=1,
last_appearance=1,
is_protagonist=True,
),
update_metadata=True,
)
assert manager_sql.get_protagonist_entity_id() == "xiaoyan"
def test_sqlite_metadata_update_and_alias_sync(temp_project):
manager = StateManager(temp_project)
idx = IndexManager(temp_project)
idx.upsert_entity(
EntityMeta(
id="xiaoyan",
type="角色",
canonical_name="萧炎",
tier="核心",
current={"realm": "斗者"},
first_appearance=1,
last_appearance=1,
is_protagonist=False,
)
)
manager._state.setdefault("entities_v3", {"角色": {}})
manager._state["entities_v3"]["角色"]["xiaoyan"] = {
"canonical_name": "萧炎",
"tier": "核心",
"desc": "",
"current": {"realm": "斗者"},
"first_appearance": 1,
"last_appearance": 1,
"history": [],
}
manager.update_entity(
"xiaoyan",
{"canonical_name": "萧炎·新", "tier": "重要", "current": {"realm": "斗王"}},
"角色",
)
manager.update_entity("xiaoyan", {"location": "中州"}, "角色")
manager.update_entity_appearance("xiaoyan", 2, "角色")
manager._pending_alias_entries["小炎子"] = [{"type": "角色", "id": "xiaoyan"}]
manager.save_state()
updated = idx.get_entity("xiaoyan")
assert updated["canonical_name"] == "萧炎·新"
assert updated["current_json"]["realm"] == "斗王"
assert updated["current_json"]["location"] == "中州"
assert updated["last_appearance"] == 2
aliases = idx.get_entity_aliases("xiaoyan")
assert "萧炎·新" in aliases
assert "小炎子" in aliases
def test_ensure_state_schema_invalid_inputs(temp_project):
manager = StateManager(temp_project, enable_sqlite_sync=False)
schema = manager._ensure_state_schema("bad")
assert isinstance(schema, dict)
schema2 = manager._ensure_state_schema({
"progress": "bad",
"relationships": "bad",
"disambiguation_warnings": "bad",
"disambiguation_pending": "bad",
})
assert isinstance(schema2["progress"], dict)
assert isinstance(schema2["relationships"], dict)
assert isinstance(schema2["disambiguation_warnings"], list)
assert isinstance(schema2["disambiguation_pending"], list)
def test_save_state_preserves_sqlite_pending_on_sync_failure(temp_project):
manager = StateManager(temp_project)
manager.add_entity(EntityState(id="e1", name="测试角色", type="角色", first_appearance=1, last_appearance=1))
manager.update_entity("e1", {"current": {"realm": "炼气"}}, "角色")
class _BrokenSQLManager:
def process_chapter_entities(self, **kwargs):
raise RuntimeError("boom")
manager._sql_state_manager = _BrokenSQLManager()
manager._pending_sqlite_data["chapter"] = 1
manager.save_state()
state = json.loads(temp_project.state_file.read_text(encoding="utf-8"))
assert state.get("_migrated_to_sqlite") is True
# SQLite 同步失败后,SQLite 相关 pending 不应被清空,便于后续重试
assert manager._pending_entity_patches
assert manager._pending_sqlite_data.get("chapter") == 1
def test_save_state_progress_and_disambiguation_merge(temp_project):
state = {
"progress": {"current_chapter": "bad", "total_words": "bad"},
"disambiguation_warnings": "bad",
"disambiguation_pending": "bad",
}
temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
manager = StateManager(temp_project, enable_sqlite_sync=False)
manager.config.max_disambiguation_warnings = 1
manager.config.max_disambiguation_pending = 1
manager._pending_progress_chapter = 5
manager._pending_progress_words_delta = 10
manager._pending_disambiguation_warnings = [
{"chapter": 1, "mention": "a", "chosen_id": "x", "confidence": 0.5},
{"chapter": 1, "mention": "a", "chosen_id": "x", "confidence": 0.5},
"bad",
]
manager._pending_disambiguation_pending = [
{"chapter": 2, "mention": "b", "suggested_id": "y", "confidence": 0.4},
{"chapter": 2, "mention": "b", "suggested_id": "y", "confidence": 0.4},
"bad",
]
manager.save_state()
saved = json.loads(temp_project.state_file.read_text(encoding="utf-8"))
assert saved["progress"]["current_chapter"] == 5
assert saved["progress"]["total_words"] == 10
assert len(saved["disambiguation_warnings"]) == 1
assert len(saved["disambiguation_pending"]) == 1
def test_sync_to_sqlite_exceptions_and_no_sql_manager(temp_project, monkeypatch):
manager = StateManager(temp_project)
manager._pending_progress_chapter = 1
manager._pending_sqlite_data["chapter"] = 1
manager._pending_alias_entries["alias"] = [{"type": "角色", "id": "xiaoyan"}]
def boom(*args, **kwargs):
raise RuntimeError("boom")
monkeypatch.setattr(manager._sql_state_manager, "process_chapter_entities", boom)
monkeypatch.setattr(manager._sql_state_manager, "register_alias", boom)
manager.save_state()
manager_no_sql = StateManager(temp_project, enable_sqlite_sync=False)
manager_no_sql._sync_pending_patches_to_sqlite()
def test_entity_fallbacks_and_updates(temp_project):
manager = StateManager(temp_project, enable_sqlite_sync=False)
manager.add_entity(EntityState(id="hero", name="主角", type="未知", tier="核心"))
manager.add_entity(EntityState(id="place", name="乌坦城", type="地点", tier="重要"))
assert manager.get_entity("hero", "角色")["canonical_name"] == "主角"
assert manager.get_entity("place")["canonical_name"] == "乌坦城"
assert manager.get_entity_type("place") == "地点"
assert "hero" in manager.get_entities_by_type("角色")
assert "hero" in manager.get_entities_by_tier("核心")
assert "hero" in manager.get_all_entities()
assert manager.update_entity("missing", {"current": {"a": 1}}) is False
manager.update_entity("hero", {"attributes": {"hp": 1}}, "角色")
manager._state["entities_v3"]["角色"]["hero"].pop("current", None)
manager.update_entity("hero", {"current": {"mp": 2}}, "角色")
manager.update_entity("hero", {"tier": "重要"}, "角色")
manager._state["entities_v3"] = "bad"
manager.update_entity_appearance("hero", 1, "角色")
manager._state["entities_v3"]["角色"]["hero"] = {"first_appearance": 0, "last_appearance": 0}
manager.update_entity_appearance("hero", 1, "角色")
manager.update_entity_appearance("hero", 2, "角色")
def test_register_alias_internal_and_get_all_entities_sqlite(temp_project):
manager = StateManager(temp_project)
manager._register_alias_internal("xiaoyan", "角色", "")
manager._register_alias_internal("xiaoyan", "角色", "萧炎")
idx = IndexManager(temp_project)
idx.upsert_entity(
EntityMeta(
id="xiaoyan",
type="角色",
canonical_name="萧炎",
tier="核心",
current={},
first_appearance=1,
last_appearance=1,
is_protagonist=False,
)
)
all_entities = manager.get_all_entities()
assert "xiaoyan" in all_entities
def test_record_disambiguation_and_process_chapter_existing(temp_project):
manager = StateManager(temp_project, enable_sqlite_sync=False)
warnings = manager._record_disambiguation(
1,
[
"bad",
{"mention": "", "confidence": 0.1},
{"mention": "宗主", "confidence": "bad", "adopted": "zongzhu"},
],
)
assert warnings
manager.add_entity(EntityState(id="xiaoyan", name="萧炎", type="角色"))
warnings = manager.process_chapter_result(2, {"entities_new": [{"id": "xiaoyan", "name": "萧炎"}]})
assert any("实体已存在" in w for w in warnings)
def test_sync_protagonist_from_string_and_empty_updates(temp_project):
manager = StateManager(temp_project, enable_sqlite_sync=False)
manager._state.setdefault("entities_v3", {"角色": {}})
manager._state["entities_v3"]["角色"]["bad"] = {
"current": None,
"current_json": "not-json",
}
manager._state["entities_v3"]["角色"]["hero"] = {
"current": None,
"current_json": json.dumps({"realm": "斗师", "layer": 2, "location": "乌坦城", "last_chapter": 3}),
}
manager.sync_protagonist_from_entity("bad")
manager.sync_protagonist_from_entity("hero")
assert manager._state["protagonist_state"]["power"]["realm"] == "斗师"
manager._state["protagonist_state"] = {}
manager.sync_protagonist_to_entity()
def test_state_manager_cli_commands(temp_project, monkeypatch, capsys):
idx = IndexManager(temp_project)
idx.upsert_entity(
EntityMeta(
id="xiaoyan",
type="角色",
canonical_name="萧炎",
tier="核心",
current={},
first_appearance=1,
last_appearance=1,
is_protagonist=False,
)
)
def run_cli(args):
monkeypatch.setattr(sys, "argv", args)
from data_modules import state_manager as sm
sm.main()
out = capsys.readouterr().out
return json.loads(out)
out = run_cli(["state_manager", "--project-root", str(temp_project.project_root), "get-progress"])
assert out["status"] == "success"
assert "current_chapter" in out.get("data", {})
out = run_cli(["state_manager", "--project-root", str(temp_project.project_root), "get-entity", "--id", "missing"])
assert out["status"] == "error"
out = run_cli(["state_manager", "--project-root", str(temp_project.project_root), "get-entity", "--id", "xiaoyan"])
assert out["status"] == "success"
assert out["data"].get("id") == "xiaoyan"
out = run_cli(["state_manager", "--project-root", str(temp_project.project_root), "list-entities", "--type", "角色"])
assert out["status"] == "success"
assert any(e.get("id") == "xiaoyan" for e in out.get("data", []))
out = run_cli(["state_manager", "--project-root", str(temp_project.project_root), "list-entities", "--tier", "核心"])
assert out["status"] == "success"
assert any(e.get("id") == "xiaoyan" for e in out.get("data", []))
payload = json.dumps({"entities_appeared": [], "entities_new": [], "state_changes": [], "relationships_new": []})
out = run_cli([
"state_manager",
"--project-root",
str(temp_project.project_root),
"process-chapter",
"--chapter",
"1",
"--data",
payload,
])
assert out["status"] == "success"
def test_save_state_timeout(monkeypatch, temp_project):
import filelock
from data_modules import state_manager as sm
manager = StateManager(temp_project, enable_sqlite_sync=False)
manager.update_progress(1)
class FakeLock:
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
raise filelock.Timeout("timeout")
def __exit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr(sm.filelock, "FileLock", FakeLock)
with pytest.raises(RuntimeError):
manager.save_state()
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from data_modules.state_validator import (
FORESHADOWING_STATUS_PENDING,
FORESHADOWING_STATUS_RESOLVED,
FORESHADOWING_TIER_CORE,
FORESHADOWING_TIER_DECOR,
FORESHADOWING_TIER_SUB,
count_patterns,
get_chapter_meta_entry,
is_resolved_foreshadowing_status,
normalize_chapter_meta,
normalize_foreshadowing_item,
normalize_foreshadowing_status,
normalize_foreshadowing_tier,
normalize_state_runtime_sections,
resolve_chapter_field,
split_patterns,
to_positive_int,
)
def test_to_positive_int_and_resolve_chapter_field():
assert to_positive_int(12) == 12
assert to_positive_int("ch-18") == 18
assert to_positive_int(0) is None
assert to_positive_int("no number") is None
item = {"added_chapter": "第15章", "target": "200"}
assert resolve_chapter_field(item, ["planted_chapter", "added_chapter"]) == 15
assert resolve_chapter_field(item, ["target_chapter", "target"]) == 200
def test_status_and_tier_normalization():
assert normalize_foreshadowing_status("pending") == FORESHADOWING_STATUS_PENDING
assert normalize_foreshadowing_status("resolved") == FORESHADOWING_STATUS_RESOLVED
assert normalize_foreshadowing_status("") == FORESHADOWING_STATUS_PENDING
assert is_resolved_foreshadowing_status("已回收") is True
assert is_resolved_foreshadowing_status("active") is False
assert normalize_foreshadowing_tier("core") == FORESHADOWING_TIER_CORE
assert normalize_foreshadowing_tier("decoration") == FORESHADOWING_TIER_DECOR
assert normalize_foreshadowing_tier("unknown") == FORESHADOWING_TIER_SUB
def test_pattern_split_and_count():
assert split_patterns(["A", " A ", "B", ""]) == ["A", "B"]
assert split_patterns("A, B / C|A") == ["A", "B", "C"]
assert count_patterns("A,B,C") == 3
assert count_patterns(123) is None
def test_normalize_foreshadowing_item_and_chapter_meta_entry():
item = {
"content": " 遗迹钥匙 ",
"status": "pending",
"tier": "main",
"added_chapter": "第30章",
"target": "120",
}
normalized_item = normalize_foreshadowing_item(item)
assert normalized_item["content"] == "遗迹钥匙"
assert normalized_item["status"] == FORESHADOWING_STATUS_PENDING
assert normalized_item["tier"] == FORESHADOWING_TIER_CORE
assert normalized_item["planted_chapter"] == 30
assert normalized_item["target_chapter"] == 120
state = {
"chapter_meta": {
"0003": {"coolpoint_pattern": "反杀, 掉马"},
"7": {"patterns": ["翻车", "反杀"]},
}
}
meta3 = get_chapter_meta_entry(state, 3)
assert meta3["coolpoint_patterns"] == ["反杀", "掉马"]
meta7 = get_chapter_meta_entry(state, 7)
assert meta7["coolpoint_patterns"] == ["翻车", "反杀"]
def test_normalize_state_runtime_sections():
state = {
"plot_threads": {
"foreshadowing": [
{"content": "伏笔A", "status": "active", "tier": "decor", "chapter": 11, "target": 99},
"invalid",
]
},
"chapter_meta": {
1: {"cool_point_pattern": "打脸|翻车"},
"bad": "invalid",
},
}
normalized = normalize_state_runtime_sections(state)
assert len(normalized["plot_threads"]["foreshadowing"]) == 1
first = normalized["plot_threads"]["foreshadowing"][0]
assert first["status"] == FORESHADOWING_STATUS_PENDING
assert first["tier"] == FORESHADOWING_TIER_DECOR
assert first["planted_chapter"] == 11
assert first["target_chapter"] == 99
chapter_meta = normalize_chapter_meta(normalized["chapter_meta"])
assert "1" in chapter_meta
assert chapter_meta["1"]["coolpoint_patterns"] == ["打脸", "翻车"]
@@ -0,0 +1,235 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json
import tempfile
from data_modules.config import DataModulesConfig
from data_modules.index_manager import (
IndexManager,
ChapterReadingPowerMeta,
EntityMeta,
RelationshipMeta,
RelationshipEventMeta,
)
from status_reporter import StatusReporter
def _write_state(project_root, state: dict):
noma_dir = project_root / ".noma"
noma_dir.mkdir(parents=True, exist_ok=True)
(noma_dir / "state.json").write_text(
json.dumps(state, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def test_foreshadowing_analysis_uses_real_chapters_and_handles_missing_data():
with tempfile.TemporaryDirectory() as tmpdir:
project_root = DataModulesConfig.from_project_root(tmpdir).project_root
state = {
"progress": {"current_chapter": 120, "total_words": 360000},
"plot_threads": {
"foreshadowing": [
{
"content": "林家宝库铭文的秘密",
"status": "未回收",
"tier": "核心",
"planted_chapter": 20,
"target_chapter": 100,
},
{
"content": "神秘玉佩来历",
"status": "待回收",
"tier": "支线",
"added_chapter": 50,
"target": 150,
},
{
"content": "旧日誓言",
"status": "未回收",
"tier": "装饰",
},
{
"content": "已完成伏笔",
"status": "已回收",
"planted_chapter": 10,
"target_chapter": 20,
},
]
},
}
_write_state(project_root, state)
reporter = StatusReporter(str(project_root))
assert reporter.load_state() is True
foreshadowing = reporter.analyze_foreshadowing()
assert len(foreshadowing) == 3
records = {item["content"]: item for item in foreshadowing}
assert records["林家宝库铭文的秘密"]["planted_chapter"] == 20
assert records["林家宝库铭文的秘密"]["elapsed"] == 100
assert records["林家宝库铭文的秘密"]["status"] == "🔴 已超期"
assert records["神秘玉佩来历"]["planted_chapter"] == 50
assert records["神秘玉佩来历"]["target_chapter"] == 150
assert records["神秘玉佩来历"]["status"] in {"🟡 轻度超时", "🟢 正常"}
assert records["旧日誓言"]["planted_chapter"] is None
assert records["旧日誓言"]["status"] == "⚪ 数据不足"
urgency = reporter.analyze_foreshadowing_urgency()
urgency_by_content = {item["content"]: item for item in urgency}
assert urgency_by_content["林家宝库铭文的秘密"]["urgency"] is not None
assert urgency_by_content["林家宝库铭文的秘密"]["status"] == "🔴 已超期"
assert urgency_by_content["旧日誓言"]["urgency"] is None
assert urgency_by_content["旧日誓言"]["status"] == "⚪ 数据不足"
def test_pacing_analysis_prefers_real_coolpoint_metadata_over_estimation():
with tempfile.TemporaryDirectory() as tmpdir:
config = DataModulesConfig.from_project_root(tmpdir)
config.ensure_dirs()
project_root = config.project_root
state = {
"progress": {"current_chapter": 3, "total_words": 12000},
"chapter_meta": {
"0003": {
"hook": "下章有变",
"coolpoint_patterns": ["身份掉马", "反派翻车"],
}
},
}
_write_state(project_root, state)
idx = IndexManager(config)
idx.save_chapter_reading_power(
ChapterReadingPowerMeta(
chapter=1,
hook_type="渴望钩",
hook_strength="strong",
coolpoint_patterns=["打脸权威", "身份掉马"],
)
)
idx.save_chapter_reading_power(
ChapterReadingPowerMeta(
chapter=2,
hook_type="悬念钩",
hook_strength="medium",
coolpoint_patterns=["身份掉马"],
)
)
reporter = StatusReporter(str(project_root))
assert reporter.load_state() is True
reporter.chapters_data = [
{"chapter": 1, "word_count": 4000, "cool_point": "", "dominant": "", "characters": []},
{"chapter": 2, "word_count": 3000, "cool_point": "", "dominant": "", "characters": []},
{"chapter": 3, "word_count": 5000, "cool_point": "", "dominant": "", "characters": []},
]
segments = reporter.analyze_pacing()
assert len(segments) == 1
seg = segments[0]
assert seg["cool_points"] == 5
assert round(seg["words_per_point"], 2) == 2400.00
assert seg["missing_chapters"] == 0
assert seg["dominant_source"] == "chapter_reading_power"
def test_pacing_analysis_marks_missing_data_instead_of_assuming_one_point_per_chapter():
with tempfile.TemporaryDirectory() as tmpdir:
config = DataModulesConfig.from_project_root(tmpdir)
config.ensure_dirs()
project_root = config.project_root
state = {
"progress": {"current_chapter": 1, "total_words": 2000},
"chapter_meta": {},
}
_write_state(project_root, state)
reporter = StatusReporter(str(project_root))
assert reporter.load_state() is True
reporter.chapters_data = [
{"chapter": 1, "word_count": 2000, "cool_point": "", "dominant": "", "characters": []}
]
seg = reporter.analyze_pacing()[0]
assert seg["cool_points"] == 0
assert seg["words_per_point"] is None
assert seg["rating"] == "数据不足"
assert seg["missing_chapters"] == 1
def test_relationship_graph_prefers_index_db_data():
with tempfile.TemporaryDirectory() as tmpdir:
config = DataModulesConfig.from_project_root(tmpdir)
config.ensure_dirs()
project_root = config.project_root
state = {
"progress": {"current_chapter": 12, "total_words": 24000},
"protagonist_state": {"name": "萧炎"},
"relationships": {"allies": [{"name": "旧盟友", "relation": "友好"}], "enemies": []},
}
_write_state(project_root, state)
idx = IndexManager(config)
idx.upsert_entity(
EntityMeta(
id="xiaoyan",
type="角色",
canonical_name="萧炎",
tier="核心",
current={},
first_appearance=1,
last_appearance=12,
is_protagonist=True,
)
)
idx.upsert_entity(
EntityMeta(
id="yaolao",
type="角色",
canonical_name="药老",
tier="重要",
current={},
first_appearance=1,
last_appearance=12,
)
)
idx.upsert_relationship(
RelationshipMeta(
from_entity="xiaoyan",
to_entity="yaolao",
type="师徒",
description="师徒关系",
chapter=10,
)
)
idx.record_relationship_event(
RelationshipEventMeta(
from_entity="xiaoyan",
to_entity="yaolao",
type="师徒",
chapter=10,
action="create",
polarity=1,
strength=0.9,
description="拜师",
evidence="萧炎拜药老为师",
)
)
reporter = StatusReporter(str(project_root))
assert reporter.load_state() is True
graph = reporter.generate_relationship_graph()
assert "mermaid" in graph
assert "药老" in graph
assert "师徒" in graph
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
StyleSampler extra tests + CLI
"""
import sys
import json
import pytest
import data_modules.style_sampler as sampler_module
from data_modules.style_sampler import StyleSampler, StyleSample, SceneType
from data_modules.config import DataModulesConfig
@pytest.fixture
def temp_project(tmp_path):
cfg = DataModulesConfig.from_project_root(tmp_path)
cfg.ensure_dirs()
return cfg
def test_style_sampler_more(temp_project):
sampler = StyleSampler(temp_project)
sample = StyleSample(
id="ch1_s1",
chapter=1,
scene_type=SceneType.BATTLE.value,
content="战斗描写很精彩",
score=0.9,
tags=["战斗"],
)
assert sampler.add_sample(sample) is True
assert sampler.add_sample(sample) is False
best = sampler.get_best_samples(limit=5)
assert len(best) == 1
stats = sampler.get_stats()
assert stats["total"] == 1
# scene type inference
assert sampler._infer_scene_types("一场战斗") == [SceneType.BATTLE.value]
assert sampler._infer_scene_types("对话和谈话") == [SceneType.DIALOGUE.value]
assert sampler._infer_scene_types("心理情感描写") == [SceneType.EMOTION.value]
# classify and tags
scene_type = sampler._classify_scene_type({"summary": "紧张", "content": ""})
assert scene_type == SceneType.TENSION.value
tags = sampler._extract_tags("战斗 修炼 对话 描写")
assert "战斗" in tags
def test_style_sampler_cli(temp_project, monkeypatch, capsys):
root = str(temp_project.project_root)
def run_cli(args):
monkeypatch.setattr(sys, "argv", ["style_sampler"] + args)
sampler_module.main()
run_cli(["--project-root", root, "stats"])
run_cli(["--project-root", root, "list", "--limit", "5"])
run_cli(
[
"--project-root",
root,
"extract",
"--chapter",
"1",
"--score",
"90",
"--scenes",
json.dumps(
[
{
"index": 1,
"summary": "战斗场景",
"content": "战斗" + "a" * 300,
}
],
ensure_ascii=False,
),
]
)
run_cli(["--project-root", root, "list", "--type", "战斗", "--limit", "5"])
run_cli(["--project-root", root, "select", "--outline", "本章有一场战斗", "--max", "2"])
capsys.readouterr()
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json
import sys
def test_update_state_cli_add_review_writes_checkpoint(tmp_path, monkeypatch):
import update_state as update_state_module
noma_dir = tmp_path / ".noma"
noma_dir.mkdir(parents=True, exist_ok=True)
state = {
"project_info": {},
"progress": {"current_chapter": 1, "total_words": 0},
"protagonist_state": {
"power": {"realm": "炼气", "layer": 1, "bottleneck": None},
"location": "村口",
},
"relationships": {},
"world_settings": {},
"plot_threads": {},
"review_checkpoints": [],
}
state_file = noma_dir / "state.json"
state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
# 避免在测试里创建备份目录/修改权限等非核心行为
monkeypatch.setattr(update_state_module.StateUpdater, "backup", lambda self: True)
report_file = "review/report_1_2.md"
monkeypatch.setattr(
sys,
"argv",
["update_state", "--project-root", str(tmp_path), "--add-review", "1-2", report_file],
)
update_state_module.main()
updated = json.loads(state_file.read_text(encoding="utf-8"))
checkpoints = updated.get("review_checkpoints")
assert isinstance(checkpoints, list)
assert checkpoints[-1]["chapters"] == "1-2"
assert checkpoints[-1]["report"] == report_file
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sys
from pathlib import Path
import pytest
def _ensure_scripts_on_path() -> None:
scripts_dir = Path(__file__).resolve().parents[2]
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
def _load_noma_module():
_ensure_scripts_on_path()
import data_modules.noma as noma_module
return noma_module
def test_init_does_not_resolve_existing_project_root(monkeypatch):
module = _load_noma_module()
called = {}
def _fake_run_script(script_name, argv):
called["script_name"] = script_name
called["argv"] = list(argv)
return 0
def _fail_resolve(_explicit_project_root=None):
raise AssertionError("init 子命令不应触发 project_root 解析")
monkeypatch.setenv("WEBNOVEL_PROJECT_ROOT", r"D:\invalid\root")
monkeypatch.setattr(module, "_run_script", _fake_run_script)
monkeypatch.setattr(module, "_resolve_root", _fail_resolve)
monkeypatch.setattr(sys, "argv", ["noma", "init", "proj-dir", "测试书", "修仙"])
with pytest.raises(SystemExit) as exc:
module.main()
assert int(exc.value.code or 0) == 0
assert called["script_name"] == "init_project.py"
assert called["argv"] == ["proj-dir", "测试书", "修仙"]
def test_extract_context_forwards_with_resolved_project_root(monkeypatch, tmp_path):
module = _load_noma_module()
book_root = (tmp_path / "book").resolve()
called = {}
def _fake_resolve(explicit_project_root=None):
return book_root
def _fake_run_script(script_name, argv):
called["script_name"] = script_name
called["argv"] = list(argv)
return 0
monkeypatch.setattr(module, "_resolve_root", _fake_resolve)
monkeypatch.setattr(module, "_run_script", _fake_run_script)
monkeypatch.setattr(
sys,
"argv",
[
"noma",
"--project-root",
str(tmp_path),
"extract-context",
"--chapter",
"12",
"--format",
"json",
],
)
with pytest.raises(SystemExit) as exc:
module.main()
assert int(exc.value.code or 0) == 0
assert called["script_name"] == "extract_chapter_context.py"
assert called["argv"] == [
"--project-root",
str(book_root),
"--chapter",
"12",
"--format",
"json",
]
def test_preflight_succeeds_for_valid_project_root(monkeypatch, tmp_path, capsys):
module = _load_noma_module()
project_root = tmp_path / "book"
(project_root / ".noma").mkdir(parents=True, exist_ok=True)
(project_root / ".noma" / "state.json").write_text("{}", encoding="utf-8")
monkeypatch.setattr(sys, "argv", ["noma", "--project-root", str(project_root), "preflight"])
with pytest.raises(SystemExit) as exc:
module.main()
captured = capsys.readouterr()
assert int(exc.value.code or 0) == 0
assert "OK project_root" in captured.out
assert str(project_root.resolve()) in captured.out
def test_preflight_fails_when_required_scripts_are_missing(monkeypatch, tmp_path, capsys):
module = _load_noma_module()
project_root = tmp_path / "book"
(project_root / ".noma").mkdir(parents=True, exist_ok=True)
(project_root / ".noma" / "state.json").write_text("{}", encoding="utf-8")
fake_scripts_dir = tmp_path / "fake-scripts"
fake_scripts_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(module, "_scripts_dir", lambda: fake_scripts_dir)
monkeypatch.setattr(sys, "argv", ["noma", "--project-root", str(project_root), "preflight", "--format", "json"])
with pytest.raises(SystemExit) as exc:
module.main()
captured = capsys.readouterr()
assert int(exc.value.code or 0) == 1
assert '"ok": false' in captured.out
assert '"name": "entry_script"' in captured.out
def test_quality_trend_report_writes_to_book_root_when_input_is_workspace_root(tmp_path, monkeypatch):
_ensure_scripts_on_path()
import quality_trend_report as quality_trend_report_module
workspace_root = (tmp_path / "workspace").resolve()
book_root = (workspace_root / "凡人资本论").resolve()
(workspace_root / ".claude").mkdir(parents=True, exist_ok=True)
(workspace_root / ".claude" / ".noma-current-project").write_text(str(book_root), encoding="utf-8")
(book_root / ".noma").mkdir(parents=True, exist_ok=True)
(book_root / ".noma" / "state.json").write_text("{}", encoding="utf-8")
output_path = workspace_root / "report.md"
monkeypatch.setattr(
sys,
"argv",
[
"quality_trend_report",
"--project-root",
str(workspace_root),
"--limit",
"1",
"--output",
str(output_path),
],
)
quality_trend_report_module.main()
assert output_path.is_file()
assert (book_root / ".noma" / "index.db").is_file()
assert not (workspace_root / ".noma" / "index.db").exists()
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json
import logging
import sys
from pathlib import Path
from types import SimpleNamespace
def _load_module():
scripts_dir = Path(__file__).resolve().parents[2]
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
import workflow_manager
return workflow_manager
def test_workflow_lifecycle_and_trace(tmp_path, monkeypatch):
module = _load_module()
monkeypatch.setattr(module, "find_project_root", lambda: tmp_path)
noma_dir = tmp_path / ".noma"
noma_dir.mkdir(parents=True, exist_ok=True)
module.start_task("noma-write", {"chapter_num": 7})
module.start_step("Step 1", "Context")
module.complete_step("Step 1", json.dumps({"state_json_modified": True}, ensure_ascii=False))
module.complete_task(json.dumps({"review_completed": True}, ensure_ascii=False))
state = module.load_state()
assert state["current_task"] is None
assert state["history"][-1]["status"] == module.TASK_STATUS_COMPLETED
assert state["last_stable_state"]["artifacts"]["review_completed"] is True
trace_path = module.get_call_trace_path()
assert trace_path.exists()
lines = trace_path.read_text(encoding="utf-8").strip().splitlines()
events = [json.loads(line)["event"] for line in lines if line.strip()]
assert "task_started" in events
assert "step_started" in events
assert "step_completed" in events
assert "task_completed" in events
def test_start_task_reentry_increments_retry(tmp_path, monkeypatch):
module = _load_module()
monkeypatch.setattr(module, "find_project_root", lambda: tmp_path)
noma_dir = tmp_path / ".noma"
noma_dir.mkdir(parents=True, exist_ok=True)
module.start_task("noma-write", {"chapter_num": 8})
module.start_task("noma-write", {"chapter_num": 8})
state = module.load_state()
task = state["current_task"]
assert task is not None
assert task["status"] == module.TASK_STATUS_RUNNING
assert int(task.get("retry_count", 0)) >= 1
def test_complete_step_rejects_mismatch_step_id(tmp_path, monkeypatch):
module = _load_module()
monkeypatch.setattr(module, "find_project_root", lambda: tmp_path)
noma_dir = tmp_path / ".noma"
noma_dir.mkdir(parents=True, exist_ok=True)
module.start_task("noma-write", {"chapter_num": 9})
module.start_step("Step 2A", "Draft")
module.complete_step("Step 2B")
state = module.load_state()
current_step = state["current_task"]["current_step"]
assert current_step is not None
assert current_step["id"] == "Step 2A"
assert current_step["status"] == module.STEP_STATUS_RUNNING
def test_workflow_step_owner_and_order_violation_trace(tmp_path, monkeypatch):
module = _load_module()
monkeypatch.setattr(module, "find_project_root", lambda: tmp_path)
noma_dir = tmp_path / ".noma"
noma_dir.mkdir(parents=True, exist_ok=True)
assert module.expected_step_owner("noma-write", "Step 1") == "context-agent"
assert module.expected_step_owner("noma-write", "Step 5") == "data-agent"
module.start_task("noma-write", {"chapter_num": 12})
module.start_step("Step 3", "Review")
trace_path = module.get_call_trace_path()
lines = [json.loads(line) for line in trace_path.read_text(encoding="utf-8").splitlines() if line.strip()]
events = [row.get("event") for row in lines]
assert "step_order_violation" in events
step_started = [row for row in lines if row.get("event") == "step_started"]
assert step_started
assert step_started[-1].get("payload", {}).get("expected_owner") == "review-agents"
def test_safe_append_call_trace_logs_failure(monkeypatch, caplog):
module = _load_module()
def _raise_trace_error(event, payload=None):
raise RuntimeError("trace failure")
monkeypatch.setattr(module, "append_call_trace", _raise_trace_error)
with caplog.at_level(logging.WARNING):
module.safe_append_call_trace("unit_test_event", {"ok": True})
message_text = "\n".join(record.getMessage() for record in caplog.records)
assert "failed to append call trace" in message_text
assert "unit_test_event" in message_text
def test_get_workflow_paths_support_zero_arg_find_project_root(tmp_path, monkeypatch):
module = _load_module()
monkeypatch.setattr(module, "_cli_project_root", None)
monkeypatch.setattr(module, "find_project_root", lambda: tmp_path)
assert module.get_workflow_state_path() == tmp_path / ".noma" / "workflow_state.json"
assert module.get_call_trace_path() == tmp_path / ".noma" / "observability" / "call_trace.jsonl"
def test_workflow_reentry_does_not_duplicate_history(tmp_path, monkeypatch):
module = _load_module()
monkeypatch.setattr(module, "find_project_root", lambda: tmp_path)
noma_dir = tmp_path / ".noma"
noma_dir.mkdir(parents=True, exist_ok=True)
module.start_task("noma-write", {"chapter_num": 20})
module.start_task("noma-write", {"chapter_num": 20})
module.start_task("noma-write", {"chapter_num": 20})
state = module.load_state()
assert isinstance(state.get("history"), list)
assert len(state.get("history")) == 0
task = state.get("current_task") or {}
assert int(task.get("retry_count", 0)) >= 2
def test_cleanup_artifacts_requires_confirm(tmp_path, monkeypatch):
module = _load_module()
monkeypatch.setattr(module, "find_project_root", lambda: tmp_path)
noma_dir = tmp_path / ".noma"
noma_dir.mkdir(parents=True, exist_ok=True)
draft_path = module.default_chapter_draft_path(tmp_path, 7)
draft_path.parent.mkdir(parents=True, exist_ok=True)
draft_path.write_text("draft", encoding="utf-8")
git_called = {"count": 0}
def _fake_run(*args, **kwargs):
git_called["count"] += 1
return SimpleNamespace(returncode=0, stderr="", stdout="")
monkeypatch.setattr(module.subprocess, "run", _fake_run)
preview = module.cleanup_artifacts(7, confirm=False)
assert draft_path.exists()
assert git_called["count"] == 0
assert any(item.startswith("[预览]") for item in preview)
def test_cleanup_artifacts_confirm_deletes_with_backup(tmp_path, monkeypatch):
module = _load_module()
monkeypatch.setattr(module, "find_project_root", lambda: tmp_path)
noma_dir = tmp_path / ".noma"
noma_dir.mkdir(parents=True, exist_ok=True)
draft_path = module.default_chapter_draft_path(tmp_path, 8)
draft_path.parent.mkdir(parents=True, exist_ok=True)
draft_path.write_text("draft", encoding="utf-8")
git_called = {"count": 0, "cmd": None}
def _fake_run(cmd, **kwargs):
git_called["count"] += 1
git_called["cmd"] = cmd
return SimpleNamespace(returncode=0, stderr="", stdout="")
monkeypatch.setattr(module.subprocess, "run", _fake_run)
cleaned = module.cleanup_artifacts(8, confirm=True)
assert not draft_path.exists()
assert git_called["count"] == 1
assert git_called["cmd"] == ["git", "reset", "HEAD", "."]
assert any("Git 暂存区已清理" in item for item in cleaned)
backup_dir = tmp_path / ".noma" / "recovery_backups"
backups = list(backup_dir.glob("ch0008-*"))
assert backups