zizi aa82445ec7 框架(数据库+确认通道): 落库 99 候选+决策,建正文 Canonical 写入层,confirm 改 DB 语义
- example_candidate(七态状态机,DB 级评测候选禁接受硬拒 05§8.4) + example_user_decision(append-only+理据)
- write_canonical.py:accept/discard 单事务(写正文块CAS revision+1→来源归因落块→命令幂等→决策归档→翻候选态),
  任一失败整体回滚;dry-run + eval/draft 守卫已验证;复用 db skill DSN
- confirm/SKILL.md 从 git commit 语义改为写库语义(正文走 write_canonical;知识卡走双轨;规划暂 git)
2026-07-31 11:02:16 +08:00

206 lines
12 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
"""正文 Canonical 写入层 —— 候选接受/丢弃的唯一 DB 写路径(Shadow→Canonical)。
落库设计 §2.9 的单事务序列(任一失败整体回滚,绝不留"无来源指针的正式正文"):
accept preflight(主会话先调 check_writer_acceptance 纯函数做生产模式全检)
-> 读候选 + 硬校验(run_type=production、state=passed,DB 级兜底)
-> 写 muse_content_block(content_text,revision+1,CAS 乐观锁) [拿到 block_id]
-> 写 muse_content_block_source_attribution(block_id, revision, lineage_payload=候选身份;来源权威落块,架构-02 §3)
-> 写 muse_content_command_log(command_id 幂等审计)
-> 写 example_user_decision(canonical_block_id, command_id, 理据)
-> UPDATE example_candidate SET state='accepted'
只由 confirm 入口调用;主会话与智能体不得自行拼接这些写。
--dry-run 试跑:完整走一遍事务再回滚,校验通过但不落库。
"""
import json
import sys
from pathlib import Path
# 复用 db skill 锁死的 DSN(.claude/skills/db/scripts),不另硬编码连接串
DB_SCRIPTS = Path(__file__).resolve().parents[2] / "db" / "scripts"
sys.path.insert(0, str(DB_SCRIPTS))
from db import connect # noqa: E402
CREATOR = "confirm"
class ConflictError(Exception):
"""接受/丢弃被拒(状态不对、revision 冲突、候选不存在等)——失败关闭,不静默。"""
def accept(candidate_id, decided_by="1", rationale=None, basis_ref=None,
expected_revision=None, command_id=None, source_type="ai_candidate",
dry_run=False):
"""接受候选为正式正文。单事务;返回 {status, block_id, revision, decision_id, ...}。"""
with connect() as conn:
try:
# 0) command_id 幂等:重放同一命令直接返回,不重复写
if command_id and conn.execute(
"SELECT 1 FROM muse_content_command_log WHERE command_id=%s AND deleted=false",
(command_id,)).fetchone():
return {"status": "already_applied", "command_id": command_id}
# 1) 读候选 + DB 级硬校验(preflight 的兜底,不靠调用方自觉)
row = conn.execute(
"SELECT id, work_id, target_chapter, run_id, attempt, run_type, candidate_version, "
"candidate_sha256, candidate_body, mode, source_role, state FROM example_candidate "
"WHERE id=%s AND deleted=false", (candidate_id,)).fetchone()
if not row:
raise ConflictError(f"候选 {candidate_id} 不存在")
cid, work_id, chap, run_id, attempt, run_type, cver, csha, body, mode, source_role, state = row
if run_type != "production":
raise ConflictError(f"run_type={run_type} 为评测/诊断候选,不得接受(05 §8.4)")
if state != "passed":
raise ConflictError(f"候选 state={state},只有 passed 可接受")
if not body:
raise ConflictError("候选正文为空,不能接受")
# 2) 定位章 → 现有正文块(一章一块);revision CAS 乐观锁
ch = conn.execute(
"SELECT id, title FROM muse_content_chapter WHERE work_id=%s AND order_no=%s AND deleted=false",
(work_id, chap)).fetchone()
if not ch:
raise ConflictError(f"作品 {work_id} 无第 {chap} 章,无法写正文")
chapter_id, chapter_title = ch
blk = conn.execute(
"SELECT id, revision FROM muse_content_block WHERE chapter_id=%s AND deleted=false "
"ORDER BY revision DESC LIMIT 1", (chapter_id,)).fetchone()
word_count = len(body)
if blk:
block_id, cur_rev = blk
if expected_revision is not None and int(expected_revision) != cur_rev:
raise ConflictError(f"REVISION_CONFLICT:期望 {expected_revision},正文块当前 {cur_rev}")
new_rev = cur_rev + 1
n = conn.execute(
"UPDATE muse_content_block SET content_text=%s, revision=%s, word_count=%s, "
"command_id=%s, updater=%s WHERE id=%s AND revision=%s",
(body, new_rev, word_count, command_id, decided_by, block_id, cur_rev)).rowcount
if n != 1:
raise ConflictError("REVISION_CONFLICT:正文块被并发修改")
else:
if expected_revision not in (None, 0):
raise ConflictError(f"REVISION_CONFLICT:正文块不存在但 expected_revision={expected_revision}")
new_rev = 1
block_id = conn.execute(
"INSERT INTO muse_content_block(work_id, chapter_id, order_no, block_type, title, "
"content_text, revision, word_count, command_id, creator, updater) "
"VALUES (%s,%s,%s,'scene',%s,%s,1,%s,%s,%s,%s) RETURNING id",
(work_id, chapter_id, chap, chapter_title, body, word_count, command_id,
decided_by, decided_by)).fetchone()[0]
# 3) 来源归因落块(来源权威;block_id+revision 唯一,按版本归因)
lineage = {"candidate_id": cid, "run_id": run_id, "candidate_version": cver,
"candidate_sha256": csha, "mode": mode, "source_role": source_role,
"source_type": source_type}
conn.execute(
"INSERT INTO muse_content_block_source_attribution(work_id, block_id, revision, source_type, "
"source_object_id, source_version, lineage_payload, creator) "
"VALUES (%s,%s,%s,%s,%s,%s,%s::jsonb,%s)",
(work_id, block_id, new_rev, source_type, str(cid), int(attempt or 1),
json.dumps(lineage, ensure_ascii=False), CREATOR))
# 4) 命令幂等审计
if command_id:
conn.execute(
"INSERT INTO muse_content_command_log(command_id, command_type, owner_user_id, target_type, "
"target_id, request_hash, result_snapshot, creator) "
"VALUES (%s,'accept_candidate',1,'content_block',%s,%s,%s::jsonb,%s)",
(command_id, block_id, csha,
json.dumps({"canonical_block_id": block_id, "revision": new_rev}, ensure_ascii=False),
CREATOR))
# 5) 决策归档(append-only)
dec_id = conn.execute(
"INSERT INTO example_user_decision(candidate_id, work_id, target_chapter, decision, rationale, "
"basis_ref, decided_by, expected_revision, command_id, canonical_block_id, creator) "
"VALUES (%s,%s,%s,'accept',%s,%s,%s,%s,%s,%s,%s) RETURNING id",
(cid, work_id, chap, rationale, basis_ref, decided_by, expected_revision, command_id,
block_id, CREATOR)).fetchone()[0]
# 6) 翻候选状态
n = conn.execute(
"UPDATE example_candidate SET state='accepted', updater=%s WHERE id=%s AND state='passed'",
(decided_by, cid)).rowcount
if n != 1:
raise ConflictError("候选状态被并发修改")
if dry_run:
conn.rollback()
return {"status": "dry_run_ok", "block_id": block_id, "revision": new_rev,
"decision_id": dec_id, "word_count": word_count, "note": "试跑已回滚,未落库"}
conn.commit()
return {"status": "accepted", "block_id": block_id, "revision": new_rev,
"decision_id": dec_id, "word_count": word_count}
except Exception:
conn.rollback()
raise
def discard(candidate_id, decided_by="1", rationale=None, basis_ref=None,
command_id=None, dry_run=False):
"""丢弃候选:正式正文不变,记一条 discard 决策 + 候选翻 discarded。"""
with connect() as conn:
try:
if command_id and conn.execute(
"SELECT 1 FROM muse_content_command_log WHERE command_id=%s AND deleted=false",
(command_id,)).fetchone():
return {"status": "already_applied", "command_id": command_id}
row = conn.execute(
"SELECT id, work_id, target_chapter, state FROM example_candidate WHERE id=%s AND deleted=false",
(candidate_id,)).fetchone()
if not row:
raise ConflictError(f"候选 {candidate_id} 不存在")
cid, work_id, chap, state = row
if state in ("accepted", "discarded", "archived"):
raise ConflictError(f"候选 state={state},不可再丢弃")
if command_id:
conn.execute(
"INSERT INTO muse_content_command_log(command_id, command_type, owner_user_id, target_type, "
"target_id, request_hash, creator) VALUES (%s,'discard_candidate',1,'candidate',%s,%s,%s)",
(command_id, cid, str(cid), CREATOR))
dec_id = conn.execute(
"INSERT INTO example_user_decision(candidate_id, work_id, target_chapter, decision, rationale, "
"basis_ref, decided_by, command_id, creator) VALUES (%s,%s,%s,'discard',%s,%s,%s,%s,%s) RETURNING id",
(cid, work_id, chap, rationale, basis_ref, decided_by, command_id, CREATOR)).fetchone()[0]
conn.execute("UPDATE example_candidate SET state='discarded', updater=%s WHERE id=%s",
(decided_by, cid))
if dry_run:
conn.rollback()
return {"status": "dry_run_ok", "decision_id": dec_id, "note": "试跑已回滚,未落库"}
conn.commit()
return {"status": "discarded", "decision_id": dec_id}
except Exception:
conn.rollback()
raise
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser(description="正文 Canonical 写入层(候选接受/丢弃)")
sub = ap.add_subparsers(dest="cmd", required=True)
pa = sub.add_parser("accept", help="接受候选为正式正文")
pa.add_argument("candidate_id", type=int)
pa.add_argument("--decided-by", default="1")
pa.add_argument("--rationale", default=None)
pa.add_argument("--basis-ref", default=None)
pa.add_argument("--expected-revision", type=int, default=None)
pa.add_argument("--command-id", default=None)
pa.add_argument("--source-type", default="ai_candidate",
choices=["ai_candidate", "user_merge"])
pa.add_argument("--dry-run", action="store_true")
pd = sub.add_parser("discard", help="丢弃候选")
pd.add_argument("candidate_id", type=int)
pd.add_argument("--decided-by", default="1")
pd.add_argument("--rationale", default=None)
pd.add_argument("--basis-ref", default=None)
pd.add_argument("--command-id", default=None)
pd.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
try:
if args.cmd == "accept":
result = accept(args.candidate_id, decided_by=args.decided_by, rationale=args.rationale,
basis_ref=args.basis_ref, expected_revision=args.expected_revision,
command_id=args.command_id, source_type=args.source_type, dry_run=args.dry_run)
else:
result = discard(args.candidate_id, decided_by=args.decided_by, rationale=args.rationale,
basis_ref=args.basis_ref, command_id=args.command_id, dry_run=args.dry_run)
print(json.dumps(result, ensure_ascii=False))
except ConflictError as e:
print(f"[拒绝] {e}", file=sys.stderr)
sys.exit(1)