一、技能重组(动作-对象命名) - 旧目录 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)按"框架与创作分开"未入本提交。
305 lines
13 KiB
Python
305 lines
13 KiB
Python
#!/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()
|