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

288 lines
17 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 纯函数做生产模式全检,实时状态由 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)