#!/usr/bin/env python3 """正文 Canonical 写入层 —— 候选接受/丢弃的唯一 DB 写路径(Shadow→Canonical)。 落库设计 §2.9 的单事务序列(任一失败整体回滚,绝不留"无来源指针的正式正文"): accept preflight(主会话先调 check_writer_acceptance 纯函数做生产模式全检,实时状态由 acceptance_state 重读) -> 读候选 + 硬校验(run_type=production、state=passed、semantic_status=passed,DB 级兜底) -> 写 muse_content_block(content_text,revision+1,CAS 乐观锁) [拿到 block_id] -> 写 muse_content_block_source_attribution(block_id, revision, lineage_payload=候选身份;来源权威落块,架构-02 §3) -> 合并已批准事实增量(fact_delta:校验引文与类型闭集,进 example_fact_ledger,绑 block_revision) -> 登记投影(projection_registry:旧 revision 投影翻 stale,新 revision 登记 pending) -> 写 muse_content_command_log(command_id 幂等审计) -> 写 example_user_decision(canonical_block_id, command_id, 理据) -> UPDATE example_candidate SET state='accepted' 只由 confirm 入口调用;主会话与智能体不得自行拼接这些写。 --dry-run 试跑:完整走一遍事务再回滚,校验通过但不落库。 """ import hashlib import json import sys from pathlib import Path # 复用 access-database Skill 锁死的 DSN,不另硬编码连接串 DB_SCRIPTS = Path(__file__).resolve().parents[2] / "access-database" / "scripts" sys.path.insert(0, str(DB_SCRIPTS)) from db import connect # noqa: E402 from fact_delta import FactDeltaError, apply_accepted_deltas # noqa: E402 from projection_registry import ( # noqa: E402 mark_stale_before_revision, register_pending_projections, ) CREATOR = "confirm" class ConflictError(Exception): """接受/丢弃被拒(状态不对、revision 冲突、候选不存在等)——失败关闭,不静默。""" def _refresh_work_metrics(conn, work_id, updater): """在正文接受同一事务内回写作品聚合指标,避免工作表停留在创建时的 0。""" metrics = conn.execute( """SELECT COUNT(DISTINCT c.id)::bigint, COALESCE(SUM(b.word_count),0)::bigint FROM muse_content_chapter c LEFT JOIN muse_content_block b ON b.chapter_id=c.id AND b.deleted=false WHERE c.work_id=%s AND c.deleted=false""", (work_id,), ).fetchone() conn.execute( "UPDATE muse_content_work SET chapter_count=%s,word_count=%s,revision=revision+1,updater=%s " "WHERE id=%s AND deleted=false", (metrics[0], metrics[1], updater, work_id), ) return {"chapter_count": int(metrics[0]), "word_count": int(metrics[1])} def accept(candidate_id, decided_by="1", rationale=None, basis_ref=None, expected_revision=None, command_id=None, source_type="ai_candidate", approved_deltas=None, projection_kinds=None, dry_run=False): """接受候选为正式正文。单事务;返回 {status, block_id, revision, decision_id, ...}。 approved_deltas 非空时,这些**已被用户批准**的结构化事实增量随正文在同一事务进正典账本 (ChapterCommit);任一增量非法则整体回滚,绝不产生"正文已提交但事实半合并"。 抽取出的提案默认不在此列——升格必须先经显式批准(先审后入)。 projection_kinds 非空时,同事务把旧 revision 的投影标 stale 并按新 revision 登记 pending 投影(摘要/抽取/embedding 等派生物只能是正文的幂等投影,坏了可重建)。 """ 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, " "semantic_status, semantic_report_sha256 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, semantic_status, semantic_sha) = row if run_type != "production": raise ConflictError(f"run_type={run_type} 为评测/诊断候选,不得接受(05 §8.4)") if state != "passed": raise ConflictError(f"候选 state={state},只有 passed 可接受") # 先审后入兜底(05 §5):机械门通过(state=passed)之外,还必须有过审的语义审查。 # 语义状态由 persist_writer_execution 校验真实报告后写入;编排层跳过 detector 时这里失败关闭。 if semantic_status != "passed" or not semantic_sha: raise ConflictError( f"候选 semantic_status={semantic_status},未通过语义审查的候选不得接受(先审后入)") 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)) # 3.5) 事实增量:已批准增量随正文同一事务进账本(ChapterCommit 原子性的一部分) delta_ids = [] if approved_deltas: delta_ids = apply_accepted_deltas( conn, work_id=work_id, target_chapter=chap, run_id=run_id, candidate_sha256_bare=csha, candidate_body=body, deltas=list(approved_deltas), block_revision=new_rev, command_id=command_id, decided_by=decided_by, rationale=rationale) # 3.6) 投影:旧 revision 的派生物同事务标 stale,新 revision 登记 pending(可重建) projection_ids = [] if projection_kinds: mark_stale_before_revision( conn, work_id=work_id, target_chapter=chap, new_revision=new_rev, creator=CREATOR) projection_ids = register_pending_projections( conn, work_id=work_id, target_chapter=chap, kinds=list(projection_kinds), source_revision=new_rev, source_text_hash_bare=hashlib.sha256(body.encode("utf-8")).hexdigest(), candidate_sha256_bare=csha, creator=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("候选状态被并发修改") metrics = _refresh_work_metrics(conn, work_id, decided_by) 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, "metrics": metrics, "delta_ids": delta_ids, "projection_ids": projection_ids, "note": "试跑已回滚,未落库"} conn.commit() return {"status": "accepted", "block_id": block_id, "revision": new_rev, "decision_id": dec_id, "word_count": word_count, "metrics": metrics, "delta_ids": delta_ids, "projection_ids": projection_ids} 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("--approved-deltas", default=None, help="已批准事实增量的 JSON 文件路径(数组,DeltaProposal 合同)") pa.add_argument("--projection-kinds", default=None, help="随接受登记的投影类型,逗号分隔(如 extraction,summary);缺省不登记") 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": deltas = None if args.approved_deltas: deltas = json.loads(Path(args.approved_deltas).read_text(encoding="utf-8")) if not isinstance(deltas, list): raise ConflictError("--approved-deltas 必须是 JSON 数组") projection_kinds = None if args.projection_kinds: projection_kinds = [kind.strip() for kind in args.projection_kinds.split(",") if kind.strip()] 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, approved_deltas=deltas, projection_kinds=projection_kinds, 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) except FactDeltaError as e: print(f"[拒绝] {e.code}: {e}", file=sys.stderr) sys.exit(1)