zizi 091b66a9bb 重构: 收敛 Agent/Skill 运行时与创作质量闭环
将角色与 Skill 从 .claude 迁入 .agent,移除 Claude CLI 运行时并接入固定 Opus 角色 profile、完整 schema、预算 deadline、raw 与回执证据链。

同步拆分 Skill 职责、复利 lesson、Gate 回放、Dashboard 人审入口、数据库登记和机械门禁;候选设计正文不包含在本提交中。
2026-08-22 02:12:32 +08:00

411 lines
21 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
from fact_delta import FactDeltaError, apply_accepted_deltas
from muse_db import connect
from projection_registry import (
mark_stale_before_revision, register_pending_projections,
)
CREATOR = "confirm"
EVIDENCE_SCRIPTS = Path(__file__).resolve().parents[2] / "record-run-evidence" / "scripts"
if str(EVIDENCE_SCRIPTS) not in sys.path:
sys.path.insert(0, str(EVIDENCE_SCRIPTS))
def _propose_decision_lesson(*, decision: str, run_id: str | None, candidate_sha: str | None,
work_id: int, target_chapter: int, candidate_id: int,
rationale: str | None) -> dict:
from lesson_registry import propose_lesson_dedup # noqa: WPS433
if decision == "accept":
kind, title = "win", "用户接受正文候选"
else:
kind, title = "lesson", "用户丢弃正文候选"
return propose_lesson_dedup(
kind=kind,
title=title,
detail={
"skill": "decide-candidate",
"decision": decision,
"candidate_id": candidate_id,
"work_id": work_id,
"target_chapter": target_chapter,
"rationale": rationale,
},
work_id=work_id,
run_id=run_id,
candidate_sha256=candidate_sha,
creator="decide-candidate",
)
def next_steps_after_decision(
*,
decision: str,
work_id: int,
target_chapter: int,
candidate_id: int,
) -> list[dict]:
"""确认/丢弃后的下一步。
accept:正式正文已入库;下游 shadow 草稿(抽卡等)标记为已授权自动
(auto=true),转正式仍须人确认。执行接线未建时仍返回该意图,便于编排对齐。
"""
chapter_path = f"/works/{work_id}/ch/{target_chapter}"
if decision == "accept":
return [
{
"id": "read_canonical",
"kind": "review",
"summary": "在看板阅读已入库正式正文",
"dashboard_path": chapter_path,
"auto": False,
},
{
"id": "extract_chapter_knowledge",
"kind": "draft_auto",
"summary": "accept 已授权:自动生成章后抽卡草稿(shadow);转正式须你 confirm",
"skill": "extract-chapter-knowledge",
"then": "confirm-knowledge-draft",
"auto": True,
"requires": "confirm_to_promote",
},
{
"id": "ai_flavor_followup",
"kind": "draft_auto",
"summary": "accept 已授权:可自动采集 AI 味案例/规则候选(proposed);激活仍须你确认",
"skills": ["capture-ai-flavor-cases"],
"auto": True,
"requires": "confirm_to_promote",
},
{
"id": "lesson_review",
"kind": "review",
"summary": "过一眼经验登记;升格/驳回在确认通道",
"dashboard_path": "/lessons",
"confirm_path": "http://127.0.0.1:8766/",
"auto": False,
},
{
"id": "next_chapter_planning",
"kind": "content_change",
"summary": "下一章:先确认细纲(若尚未 confirmed),再进入正文产出节点",
"skill": "plan-chapter",
"auto": False,
"requires": "human_authorize",
},
]
return [
{
"id": "read_discard",
"kind": "review",
"summary": f"候选 {candidate_id} 已丢弃;正式正文未变。可重跑生产或改细纲后再产候选",
"dashboard_path": chapter_path,
"auto": False,
},
{
"id": "lesson_review",
"kind": "review",
"summary": "丢弃效果信号已登记;可在经验确认通道审阅",
"dashboard_path": "/lessons",
"auto": False,
},
]
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()
lesson = _propose_decision_lesson(
decision="accept", run_id=run_id, candidate_sha=csha,
work_id=work_id, target_chapter=chap, candidate_id=cid, rationale=rationale,
)
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, "lesson": lesson,
"next_steps": next_steps_after_decision(
decision="accept", work_id=work_id, target_chapter=chap, candidate_id=cid,
),
"gate": "候选已确认入库为正式正文;下列下一步须你授权后才改创作内容",
}
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, run_id, candidate_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, state, run_id, csha = 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()
lesson = _propose_decision_lesson(
decision="discard", run_id=run_id, candidate_sha=csha,
work_id=work_id, target_chapter=chap, candidate_id=cid, rationale=rationale,
)
return {
"status": "discarded", "decision_id": dec_id, "lesson": lesson,
"next_steps": next_steps_after_decision(
decision="discard", work_id=work_id, target_chapter=chap, candidate_id=cid,
),
"gate": "候选已丢弃;正式正文未变;重产或改规划须你授权",
}
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)