zizi b0bc7a8745 框架: 技能按动作-对象重组 + 先审后入创作闭环
一、技能重组(动作-对象命名)
- 旧目录 clean/confirm/continuation/db/detect/embed/… 重组为
  clean-book-text/decide-candidate/write-next-chapter/access-database/
  check-content-consistency/embed-knowledge/…(git 识别为 rename,内容保持)
- agents/*.md、AGENTS.md/CLAUDE.md 收编、example_skill 登记表同步新名

二、先审后入创作闭环(本次核心)
正文接受从"机械门一过就写正典"改为"机械门+语义审查双通过+用户批准+单事务原子提交",
DB 级兜底,编排层跳步即被硬拒。
- candidate_cas.py + example_candidate_cas(109):持久化 CAS 状态链
- fact_delta.py + example_fact_delta/example_fact_ledger(106):结构化事实增量,
  模型只提六型闭集增量+正文证据引文,仅用户批准的增量随正文同事务入账本
- projection_registry.py + example_projection_run(107):投影登记与恢复
- acceptance_state.py:接受前置实时状态重读
- lesson_registry.py + example_lesson(108):经验升格链,禁止自动升格
- DDL 105:example_candidate 增 semantic_status/semantic_report_sha256
- write_canonical.accept:语义兜底+同事务合并增量+登记投影;
  run_writer_pipeline/persist_writer_run/run_writer_semantic_detector/step2 接入全链
- claude_runtime:兼容新 CLI modelUsage 信息字段

三、审查修复(独立子代理四维审查后)
- 事实增量 propose→approve 翻态正道,不撞唯一键
- 冻结配置探针重刷(CLI 2.1.211→2.1.231 漂移),profileSha256/adapterVersion 再登记
- 可视化合同悬空路径/五六空间矛盾、 SoT 旧技能名漂移、行尾空白清理

测试:离线 65 套 + 真实库集成 5 套(CAS/接受故障注入/事实增量/投影/经验升格)+ 回放 79 项全绿。
创作内容(docs/design、生成正文 artifacts)按"框架与创作分开"未入本提交。
2026-08-14 10:24:08 +08:00

195 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""parse_llm 章级细纲修复的纯离线回归测试。
红线:不连接数据库,不调用网络或真实 LLM。测试只验证细纲专用压缩、失败状态流和
incomplete-only 的目标章筛选,避免放量时用真实额度验证本可机械证明的行为。
"""
import pathlib
import sys
import tempfile
import unittest
from unittest.mock import Mock, patch
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import parse_llm as pll # noqa: E402
class _RowsConnection:
"""只实现目标章查询所需的最小假连接,并记录查询次数。"""
def __init__(self, rows):
self.rows = rows
self.queries = []
def execute(self, sql, args):
self.queries.append((sql, args))
return self
def fetchall(self):
return self.rows
class ParseLlmOfflineTest(unittest.TestCase):
"""覆盖章级比例失败后的最小修复合同。"""
def test_outline_repair_only_replaces_outline_and_uses_short_prompt(self):
"""专用调用只压细纲,首轮实体与线索对象必须原样保留。"""
entities = [{"type": "character", "name": "甲", "brief": "主角"}]
hints = [{"type": "craft", "name": "伏笔", "clue": "埋后收", "evidence": "本章"}]
first = {"outline": "旧细纲" * 80, "entities": entities, "hints": hints}
source = "正文" * 1000
with patch.object(pll, "m3_json", return_value=({"outline": "新纲" * 30}, {"prompt_tokens": 9})) as call:
repaired, usage, error = pll.repair_outline(first, source, "MiniMax-M3")
self.assertIsNone(error)
self.assertEqual("新纲" * 30, repaired["outline"])
self.assertIs(entities, repaired["entities"])
self.assertIs(hints, repaired["hints"])
self.assertEqual(9, usage["prompt_tokens"])
prompt = call.call_args.args[0]
cap = pll.outline_target_cap(source)
self.assertLess(len(prompt), 1200)
self.assertNotIn(source, prompt)
self.assertNotIn("entities", prompt)
self.assertNotIn("hints", prompt)
self.assertIn(f"非空白字符不得超过 {cap}", prompt)
self.assertIn('{"outline": "压缩后的细纲"}', prompt)
def test_outline_repair_retries_finitely_and_never_truncates_or_reingests_over_limit(self):
"""两轮仍超目标时保持首轮失败,不截断、不用超长结果伪造 done。"""
first = {"outline": "首轮" * 100, "entities": [{"name": "甲"}], "hints": [{"name": "线索"}]}
source = "正文" * 1000
over_1 = "第一次仍超" * 30
over_2 = "第二次仍超" * 40
with patch.object(pll, "m3_json", side_effect=[
({"outline": over_1}, {"completion_tokens": 10}),
({"outline": over_2}, {"completion_tokens": 11}),
]) as llm_call, patch.object(pll, "ingest", return_value=(False, "细纲比例 9.0% 超标")) as ingest_call:
ok, output, usage = pll.ingest_scaffold_with_repair(7, 1286, first, source, "MiniMax-M3")
self.assertFalse(ok)
self.assertEqual(2, llm_call.call_count)
self.assertEqual(1, ingest_call.call_count)
self.assertIn(f"压缩输出 {pll.non_whitespace_len(over_2)} 字", output)
self.assertIn("保持 failed", output)
self.assertNotIn(over_2[:pll.outline_target_cap(source)], str(ingest_call.call_args_list))
self.assertEqual(21, usage["completion_tokens"])
def test_compression_retry_breaks_total_cap_into_short_phrase_budget(self):
"""真实 M3 忽略单一总上限后,重试须同时给上一版长度与逐短语预算。"""
first = pll.outline_compression_prompt("甲" * 89, 60, 1)
second = pll.outline_compression_prompt("甲" * 89, 60, 2)
self.assertIn("上一版共 89 个非空白字符", second)
self.assertIn("最多 4 个无标签短语", first)
self.assertIn("最多 2 个无标签短语", second)
self.assertIn("总计仍不得超过 60", second)
def test_final_repair_may_use_eight_percent_hard_cap_without_false_failure(self):
"""最后一轮已满足既有 8% 机械硬门时允许入库,4.5% 只是优选目标。"""
first = {"outline": "首轮" * 100, "entities": [], "hints": []}
source = "正文" * 1000
target = pll.outline_target_cap(source)
hard = pll.outline_hard_cap(source)
self.assertLess(target, hard)
between = "纲" * (target + 10)
with patch.object(pll, "m3_json", side_effect=[
({"outline": "仍超目标" * 30}, {}),
({"outline": between}, {}),
]):
repaired, _usage, error = pll.repair_outline(first, source, "MiniMax-M3")
self.assertIsNone(error)
self.assertEqual(between, repaired["outline"])
def test_incomplete_only_selects_once_while_default_keeps_full_range(self):
"""增量模式一次筛出 pending/failed;默认模式仍按原范围逐章兼容。"""
conn = _RowsConnection([(3,), (8,), (13,)])
self.assertEqual([3, 8, 13], pll.chapter_orders(conn, 7, 1, 20, incomplete_only=True))
self.assertEqual(1, len(conn.queries))
sql, args = conn.queries[0]
self.assertIn("scaffold_status, 'pending') IN ('pending', 'failed')", sql)
self.assertEqual((pll.TENANT, 7, 1, 20), args)
untouched = _RowsConnection([])
self.assertEqual(list(range(1, 21)), pll.chapter_orders(untouched, 7, 1, 20, incomplete_only=False))
self.assertEqual([], untouched.queries)
def test_incomplete_only_skips_full_range_task_initialization(self):
"""任务表已存在的增量恢复不得再对全书逐行执行 init-tasks。"""
with patch.object(pll.subprocess, "run") as run:
pll.initialize_tasks(7, 1, 2250, incomplete_only=True)
run.assert_not_called()
pll.initialize_tasks(7, 1, 2250, incomplete_only=False)
run.assert_called_once()
self.assertIn("init-tasks", run.call_args.args[0])
def test_failed_chapter_reuses_complete_strict_cache_without_full_extraction(self):
"""failed 章命中完整首轮载荷时,直接复用且不得再次执行正文完整抽取。"""
cached = {
"outline": "仍然超长的首轮细纲",
"entities": [{"type": "character", "name": "甲", "brief": "主角"}],
"hints": [{"type": "craft", "name": "伏笔", "clue": "埋后收", "evidence": "本章"}],
}
full_extract = Mock(side_effect=AssertionError("cache hit 不应调用完整 m3_json 抽取"))
with tempfile.TemporaryDirectory() as tmp, patch.object(pll, "TMP", pathlib.Path(tmp)):
cache_path = pathlib.Path(tmp) / "7-1286-scaffold.json"
cache_path.write_text(__import__("json").dumps(cached), encoding="utf-8")
data, usage, trace = pll.resolve_scaffold_payload(7, 1286, "failed", full_extract)
self.assertEqual(cached, data)
self.assertEqual({}, usage)
self.assertIn("cache hit", trace)
self.assertIn(str(cache_path), trace)
full_extract.assert_not_called()
def test_pending_chapter_never_reuses_cache(self):
"""pending 即使存在形状完整的同名文件,也必须重新执行完整抽取。"""
fresh = {"outline": "新细纲", "entities": [], "hints": []}
full_extract = Mock(return_value=(fresh, {"prompt_tokens": 11}))
with tempfile.TemporaryDirectory() as tmp, patch.object(pll, "TMP", pathlib.Path(tmp)):
(pathlib.Path(tmp) / "7-9-scaffold.json").write_text(
'{"outline":"旧细纲","entities":[],"hints":[]}', encoding="utf-8")
data, usage, trace = pll.resolve_scaffold_payload(7, 9, "pending", full_extract)
self.assertEqual(fresh, data)
self.assertEqual({"prompt_tokens": 11}, usage)
self.assertIn("cache miss", trace)
self.assertIn("status=pending", trace)
full_extract.assert_called_once_with()
def test_missing_or_invalid_failed_cache_falls_back_to_full_extraction(self):
"""failed 缓存缺失、非严格对象或字段类型不合理时,都走完整抽取回退。"""
cases = {
"missing": None,
"root-list": '[]',
"bad-outline": '{"outline":[],"entities":[],"hints":[]}',
"bad-entities": '{"outline":"纲","entities":[1],"hints":[]}',
"bad-hints": '{"outline":"纲","entities":[],"hints":"线索"}',
"broken-json": '{"outline":',
}
for name, cache_text in cases.items():
with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp, \
patch.object(pll, "TMP", pathlib.Path(tmp)):
if cache_text is not None:
(pathlib.Path(tmp) / "7-10-scaffold.json").write_text(cache_text, encoding="utf-8")
fresh = {"outline": f"新细纲-{name}", "entities": [], "hints": []}
full_extract = Mock(return_value=(fresh, {"completion_tokens": 7}))
data, usage, trace = pll.resolve_scaffold_payload(7, 10, "failed", full_extract)
self.assertEqual(fresh, data)
self.assertEqual({"completion_tokens": 7}, usage)
self.assertIn("cache miss", trace)
full_extract.assert_called_once_with()
if __name__ == "__main__":
unittest.main(verbosity=2)