将角色与 Skill 从 .claude 迁入 .agent,移除 Claude CLI 运行时并接入固定 Opus 角色 profile、完整 schema、预算 deadline、raw 与回执证据链。 同步拆分 Skill 职责、复利 lesson、Gate 回放、Dashboard 人审入口、数据库登记和机械门禁;候选设计正文不包含在本提交中。
411 lines
21 KiB
Python
411 lines
21 KiB
Python
#!/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)
|