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

305 lines
13 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
"""write_canonical 接受通道对 muse-example 真实库的故障注入测试。
覆盖先审后入主链的机械不变量:
- 接受原子性:事务中断不留半提交(正文/归因/决策要么全有要么全无);
- command_id 重放幂等:同一命令第二次执行不重复写正文、决策;
- revision CAS:旧 expected_revision 拒绝,正文不被旧候选覆盖;
- 先审后入兜底:semantic_status 非 passed 的候选一律不得接受;
- 机械隔离:评测/诊断候选 DB 级拒绝。
测试数据用 unittest-commit- 前缀隔离;example_user_decision 是 append-only 表,
清理时短暂禁用其防删触发器(try/finally 保证恢复)。
跑法(需 Tailscale 内网可达 muse-example):
.venv/bin/python .claude/skills/decide-candidate/scripts/test_write_canonical_db.py
"""
from __future__ import annotations
import hashlib
import pathlib
import sys
import uuid
from typing import Any
from unittest import mock
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
SKILLS_DIR = SCRIPT_DIR.parents[1]
for path in (SCRIPT_DIR, SKILLS_DIR / "access-database" / "scripts"):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
from db import connect # noqa: E402
import write_canonical # noqa: E402
from write_canonical import ConflictError, accept, discard # noqa: E402
SUFFIX = uuid.uuid4().hex[:10]
WORK_TITLE = f"unittest-commit-{SUFFIX}"
_command_ids: list[str] = []
_candidate_ids: list[int] = []
_work_id: int | None = None
def _sha(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def _cleanup() -> None:
"""物理清理测试行;append-only 决策表短暂禁用防删触发器,finally 恢复。"""
if _work_id is None:
return
with connect() as conn:
try:
conn.execute(
"ALTER TABLE example_user_decision DISABLE TRIGGER trg_example_user_decision_append_only")
try:
if _candidate_ids:
conn.execute(
"DELETE FROM example_user_decision WHERE candidate_id = ANY(%s)",
(_candidate_ids,))
if _command_ids:
conn.execute(
"DELETE FROM muse_content_command_log WHERE command_id = ANY(%s)",
(_command_ids,))
conn.execute(
"DELETE FROM muse_content_block_source_attribution WHERE work_id=%s", (_work_id,))
conn.execute("DELETE FROM muse_content_block WHERE work_id=%s", (_work_id,))
conn.execute("DELETE FROM muse_content_chapter WHERE work_id=%s", (_work_id,))
if _candidate_ids:
conn.execute(
"DELETE FROM example_candidate WHERE id = ANY(%s)", (_candidate_ids,))
conn.execute("DELETE FROM muse_content_work WHERE id=%s", (_work_id,))
conn.commit()
finally:
conn.execute(
"ALTER TABLE example_user_decision ENABLE TRIGGER trg_example_user_decision_append_only")
conn.commit()
except Exception:
conn.rollback()
raise
def _setup_work_and_chapter() -> int:
global _work_id
with connect() as conn:
work_id = conn.execute(
"INSERT INTO muse_content_work(owner_user_id, title, status, creator) "
"VALUES (1,%s,'writing','unittest') RETURNING id", (WORK_TITLE,)).fetchone()[0]
conn.execute(
"INSERT INTO muse_content_chapter(work_id, title, order_no, creator) "
"VALUES (%s,'测试章',1,'unittest')", (work_id,))
conn.commit()
_work_id = work_id
return work_id
def _insert_candidate(work_id: int, *, body: str, version: str, run_type: str = "production",
state: str = "passed", semantic_status: str | None = "passed",
semantic_sha: str | None = None) -> int:
"""直接造一个 Shadow 候选行(测试夹具,不经 persist_writer_execution)。"""
sha = _sha(body)
if semantic_status == "passed" and semantic_sha is None:
semantic_sha = _sha("semantic-report:" + sha)
with connect() as conn:
candidate_id = conn.execute(
"INSERT INTO example_candidate(work_id, target_chapter, run_id, attempt, run_type, "
"candidate_version, candidate_sha256, candidate_body, quality_policy_version, mode, "
"source_role, state, acceptance_eligible, semantic_status, semantic_report_sha256, creator) "
"VALUES (%s,1,%s,1,%s,%s,%s,%s,'writer-production-v1','continuation','writer',%s,"
"TRUE,%s,%s,'unittest') RETURNING id",
(work_id, f"unittest-commit-run-{SUFFIX}-{version}", run_type, version, sha, body,
state, semantic_status, semantic_sha)).fetchone()[0]
conn.commit()
_candidate_ids.append(candidate_id)
return candidate_id
def _command_id(tag: str) -> str:
cid = f"unittest-commit-{tag}-{SUFFIX}"
_command_ids.append(cid)
return cid
def _block_revision(work_id: int) -> int:
with connect(readonly=True) as conn:
row = conn.execute(
"SELECT COALESCE(MAX(b.revision),0) FROM muse_content_block b "
"JOIN muse_content_chapter c ON b.chapter_id=c.id AND c.deleted=false "
"WHERE c.work_id=%s AND c.order_no=1 AND b.deleted=false", (work_id,)).fetchone()
return int(row[0])
def _candidate_state(candidate_id: int) -> str:
with connect(readonly=True) as conn:
return conn.execute(
"SELECT state FROM example_candidate WHERE id=%s", (candidate_id,)).fetchone()[0]
def _decision_count(candidate_id: int) -> int:
with connect(readonly=True) as conn:
return int(conn.execute(
"SELECT COUNT(*) FROM example_user_decision WHERE candidate_id=%s",
(candidate_id,)).fetchone()[0])
def test_accept_atomic_and_replay_idempotent(work_id: int) -> None:
body = f"测试正文第一版 {SUFFIX}"
candidate_id = _insert_candidate(work_id, body=body, version="1")
command_id = _command_id("accept-1")
result = accept(candidate_id, rationale="unittest", expected_revision=0, command_id=command_id)
assert result["status"] == "accepted", result
assert result["revision"] == 1
assert _block_revision(work_id) == 1
assert _candidate_state(candidate_id) == "accepted"
assert _decision_count(candidate_id) == 1
# 同一 command_id 重放:不重复写正文、不新增决策
replay = accept(candidate_id, rationale="unittest", expected_revision=1, command_id=command_id)
assert replay["status"] == "already_applied", replay
assert _block_revision(work_id) == 1
assert _decision_count(candidate_id) == 1
with connect(readonly=True) as conn:
block_rows = int(conn.execute(
"SELECT COUNT(*) FROM muse_content_block WHERE work_id=%s AND deleted=false",
(work_id,)).fetchone()[0])
attribution_rows = int(conn.execute(
"SELECT COUNT(*) FROM muse_content_block_source_attribution WHERE work_id=%s AND deleted=false",
(work_id,)).fetchone()[0])
assert block_rows == 1 and attribution_rows == 1, (block_rows, attribution_rows)
def test_stale_expected_revision_rejected(work_id: int) -> None:
body = f"测试正文第二版 {SUFFIX}"
candidate_id = _insert_candidate(work_id, body=body, version="2")
# 当前正文块已在 revision=1;拿旧期望 0 接受必须拒绝,且正文不变
try:
accept(candidate_id, rationale="unittest", expected_revision=0,
command_id=_command_id("accept-stale"))
raise AssertionError("旧 expected_revision 必须被拒绝")
except AssertionError:
raise
except ConflictError as exc:
assert "REVISION_CONFLICT" in str(exc)
assert _block_revision(work_id) == 1, "拒绝后正文不得变化"
assert _candidate_state(candidate_id) == "passed", "拒绝后候选保持 passed 待人工处理"
# 正确期望 1 → 接受成 revision 2(旧候选不能覆盖新正文的顺序保证)
ok = accept(candidate_id, rationale="unittest", expected_revision=1,
command_id=_command_id("accept-v2"))
assert ok["status"] == "accepted" and ok["revision"] == 2
assert _block_revision(work_id) == 2
def test_semantic_backstop(work_id: int) -> None:
# 机械 passed 但语义缺失/未过:DB 兜底拒绝
no_semantic = _insert_candidate(work_id, body=f"无语义证据 {SUFFIX}", version="3",
semantic_status=None, semantic_sha=None)
failed_semantic = _insert_candidate(work_id, body=f"语义未过 {SUFFIX}", version="4",
semantic_status="failed")
for cid in (no_semantic, failed_semantic):
try:
accept(cid, rationale="unittest", expected_revision=_block_revision(work_id),
command_id=_command_id(f"accept-nosem-{cid}"))
raise AssertionError("未过语义审查的候选不得接受")
except AssertionError:
raise
except ConflictError as exc:
assert "语义审查" in str(exc), str(exc)
assert _candidate_state(no_semantic) == "passed"
assert _candidate_state(failed_semantic) == "passed"
# discard 不受语义兜底限制(丢弃是安全操作)
dropped = discard(failed_semantic, rationale="unittest",
command_id=_command_id("discard-failed-semantic"))
assert dropped["status"] == "discarded"
assert _candidate_state(failed_semantic) == "discarded"
def test_non_production_rejected(work_id: int) -> None:
eval_candidate = _insert_candidate(work_id, body=f"评测候选 {SUFFIX}", version="5",
run_type="eval")
try:
accept(eval_candidate, rationale="unittest", expected_revision=_block_revision(work_id),
command_id=_command_id("accept-eval"))
raise AssertionError("评测候选不得接受")
except AssertionError:
raise
except ConflictError as exc:
assert "eval" in str(exc)
assert _candidate_state(eval_candidate) == "passed"
def test_interrupted_accept_leaves_no_half_commit(work_id: int) -> None:
"""事务后半段注入故障:正文/归因/命令/决策全部回滚,候选保持 passed。"""
body = f"中断测试正文 {SUFFIX}"
candidate_id = _insert_candidate(work_id, body=body, version="6")
revision_before = _block_revision(work_id)
def _explode(*_args, **_kwargs):
raise RuntimeError("injected-fault: 决策归档前崩溃")
command_id = _command_id("accept-interrupted")
with mock.patch.object(write_canonical, "_refresh_work_metrics", side_effect=_explode):
try:
accept(candidate_id, rationale="unittest", expected_revision=revision_before,
command_id=command_id)
raise AssertionError("注入故障必须中止接受")
except AssertionError:
raise
except RuntimeError as exc:
assert "injected-fault" in str(exc)
# 半提交检查:正文 revision 不变、无归因新增、命令日志未落(整事务回滚)、决策为 0
assert _block_revision(work_id) == revision_before, "中断不得改变正文"
assert _decision_count(candidate_id) == 0, "中断不得留下决策"
assert _candidate_state(candidate_id) == "passed", "中断后候选必须保持 passed"
with connect(readonly=True) as conn:
logged = conn.execute(
"SELECT COUNT(*) FROM muse_content_command_log WHERE command_id=%s",
(command_id,)).fetchone()[0]
assert logged == 0, "命令日志随事务回滚,重放仍可执行"
# 中断后用同一 command_id 重试仍可成功(幂等键未被污染)
retried = accept(candidate_id, rationale="unittest", expected_revision=revision_before,
command_id=command_id)
assert retried["status"] == "accepted"
assert _block_revision(work_id) == revision_before + 1
def test_dry_run_writes_nothing(work_id: int) -> None:
body = f"试跑正文 {SUFFIX}"
candidate_id = _insert_candidate(work_id, body=body, version="7")
revision_before = _block_revision(work_id)
result = accept(candidate_id, rationale="unittest", expected_revision=revision_before,
command_id=_command_id("accept-dry"), dry_run=True)
assert result["status"] == "dry_run_ok"
assert _block_revision(work_id) == revision_before
assert _candidate_state(candidate_id) == "passed"
assert _decision_count(candidate_id) == 0
def main() -> None:
work_id = _setup_work_and_chapter()
tests = (
("accept 原子性与重放幂等", test_accept_atomic_and_replay_idempotent),
("旧 expected_revision 拒绝", test_stale_expected_revision_rejected),
("语义兜底拒绝", test_semantic_backstop),
("非生产候选拒绝", test_non_production_rejected),
("中断不留半提交", test_interrupted_accept_leaves_no_half_commit),
("dry-run 不落库", test_dry_run_writes_nothing),
)
try:
for name, test in tests:
test(work_id)
print(f"PASS: {name}")
print("PASS:write_canonical 真实库故障注入测试全部通过")
finally:
_cleanup()
if __name__ == "__main__":
main()