feat: initial commit
This commit is contained in:
@@ -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, B;C")
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user