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

374 lines
15 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
"""正文候选 Shadow 准入与用户三决策的纯函数前置检查。"""
from __future__ import annotations
import pathlib
import sys
from datetime import datetime
from typing import Any, Mapping
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
READ_CONTEXT_DIR = SCRIPT_DIR.parents[1] / "assemble-context" / "scripts"
if str(READ_CONTEXT_DIR) not in sys.path:
sys.path.insert(0, str(READ_CONTEXT_DIR))
from writer_contract import ( # noqa: E402
ContractError,
han_count,
validate_writer_context,
validate_writer_output,
)
PRODUCTION_POLICY = "writer-production-v1"
ACTIVE_SOURCE_STATUS = "active"
DECISIONS = frozenset({"accept", "merge", "discard"})
_LIVE_STATE_FIELDS = frozenset(
{
"qualityPolicyVersion",
"contextSnapshotId",
"contextSnapshotSha256",
"authorizationSnapshotId",
"authorizationValid",
"sourceStatus",
"candidateExpiresAt",
"checkedAt",
"canonicalRevision",
}
)
class AcceptanceError(RuntimeError):
"""携带稳定失败码的接受前置检查错误,任何错误都不可接受。"""
def __init__(
self, code: str, message: str, *, details: Mapping[str, Any] | None = None
) -> None:
super().__init__(message)
self.code = code
self.details = dict(details or {})
self.acceptance_eligible = False
def _require_mapping(value: Any, field: str) -> Mapping[str, Any]:
"""拒绝非对象输入,避免宽松取值绕过实时检查。"""
if not isinstance(value, Mapping):
raise AcceptanceError("ACCEPTANCE_STATE_INVALID", f"{field} 必须是对象")
return value
def _parse_timestamp(value: Any, field: str) -> datetime:
"""解析带时区的 ISO-8601 时间;无时区时间失败关闭。"""
if not isinstance(value, str) or not value:
raise AcceptanceError("ACCEPTANCE_STATE_INVALID", f"{field} 必须是时间字符串")
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise AcceptanceError("ACCEPTANCE_STATE_INVALID", f"{field} 格式非法") from exc
if parsed.tzinfo is None or parsed.utcoffset() is None:
raise AcceptanceError("ACCEPTANCE_STATE_INVALID", f"{field} 必须带时区")
return parsed
def _validate_live_state(value: Any) -> dict[str, Any]:
"""严格校验接受时重新读取的可信实时状态。"""
live = dict(_require_mapping(value, "live_state"))
if set(live) != _LIVE_STATE_FIELDS:
missing = sorted(_LIVE_STATE_FIELDS - set(live))
extra = sorted(set(live) - _LIVE_STATE_FIELDS)
raise AcceptanceError(
"ACCEPTANCE_STATE_INVALID",
"live_state 字段不完整或含未知字段",
details={"missing": missing, "extra": extra},
)
if not isinstance(live["authorizationValid"], bool):
raise AcceptanceError(
"ACCEPTANCE_STATE_INVALID", "authorizationValid 必须是布尔值"
)
revision = live["canonicalRevision"]
if isinstance(revision, bool) or not isinstance(revision, int) or revision < 0:
raise AcceptanceError(
"ACCEPTANCE_STATE_INVALID", "canonicalRevision 必须是非负整数"
)
checked_at = _parse_timestamp(live["checkedAt"], "checkedAt")
expires_at = _parse_timestamp(live["candidateExpiresAt"], "candidateExpiresAt")
live["_checkedAt"] = checked_at
live["_expiresAt"] = expires_at
return live
def _validate_detector_result(
detector_result: Any, candidate: Mapping[str, Any]
) -> None:
"""要求最终报告同时证明机械门和语义接口通过并绑定当前候选。"""
report = _require_mapping(detector_result, "detector_result")
if report.get("schemaVersion") != "writer-pipeline-result-v1":
raise AcceptanceError(
"DETECTOR_REPORT_INVALID", "detector 终态报告版本不受支持"
)
if report.get("status") != "PASSED" or report.get("failureCode") is not None:
raise AcceptanceError("DETECTOR_NOT_PASSED", "detector 尚未得到无阻塞通过终态")
expected = {
"runId": candidate["runId"],
"attempt": candidate["attempt"],
"candidateVersion": candidate["candidateVersion"],
"candidateSha256": candidate["candidateSha256"],
}
mismatches = [field for field, value in expected.items() if report.get(field) != value]
if mismatches:
raise AcceptanceError(
"DETECTOR_BINDING_MISMATCH",
"detector 终态未绑定当前候选",
details={"fields": mismatches},
)
trace = report.get("trace")
if not isinstance(trace, list) or not trace or not isinstance(trace[-1], Mapping):
raise AcceptanceError("DETECTOR_REPORT_INVALID", "detector 终态缺少审查轨迹")
final_check = trace[-1]
final_expected = {
"attempt": candidate["attempt"],
"candidateVersion": candidate["candidateVersion"],
"candidateSha256": candidate["candidateSha256"],
}
if any(final_check.get(field) != value for field, value in final_expected.items()):
raise AcceptanceError(
"DETECTOR_BINDING_MISMATCH", "detector 最终审查轨迹绑定了旧候选"
)
if (
final_check.get("mechanicalPassed") is not True
or final_check.get("semanticStatus") != "passed"
or final_check.get("failureCodes") != []
):
raise AcceptanceError(
"DETECTOR_NOT_PASSED", "候选没有同时通过机械硬门和语义审查接口"
)
def _validate_context_binding(
context: Mapping[str, Any], candidate: Mapping[str, Any]
) -> None:
"""保证候选没有逃离本次冻结上下文、运行身份和生产策略。"""
expected = {
"runId": context["runId"],
"attempt": context["attempt"],
"mode": context["mode"],
"qualityPolicyVersion": context["qualityPolicyVersion"],
"contextSnapshotId": context["contextSnapshot"]["manifestId"],
"contextSnapshotSha256": context["contextSnapshot"]["contextSha256"],
"acceptanceEligible": context["acceptanceEligible"],
}
mismatches = [field for field, value in expected.items() if candidate.get(field) != value]
if mismatches:
raise AcceptanceError(
"CONTEXT_BINDING_MISMATCH",
"候选未绑定当前 WriterContext",
details={"fields": mismatches},
)
def check_shadow_ready(
*,
context: Mapping[str, Any],
candidate: Mapping[str, Any],
detector_result: Mapping[str, Any],
live_state: Mapping[str, Any],
) -> dict[str, Any]:
"""纯函数校验候选是否可进入 Shadow 待接受态,不执行任何写入。"""
raw_candidate = _require_mapping(candidate, "candidate")
if raw_candidate.get("acceptanceEligible") is not True:
raise AcceptanceError(
"ACCEPTANCE_NOT_ELIGIBLE", "评测、诊断或显式不可接受候选不得进入接受链"
)
try:
normalized_context = validate_writer_context(context)
except ContractError as exc:
raise AcceptanceError("CONTEXT_CONTRACT_INVALID", str(exc)) from exc
try:
normalized_candidate = validate_writer_output(candidate)
except ContractError as exc:
raise AcceptanceError("CANDIDATE_CONTRACT_INVALID", str(exc)) from exc
if (
normalized_context["mode"] != "production"
or normalized_candidate["mode"] != "production"
):
raise AcceptanceError("ACCEPTANCE_NOT_ELIGIBLE", "只有生产候选可进入接受链")
_validate_context_binding(normalized_context, normalized_candidate)
contract = normalized_context["outputContract"]
actual_han_chars = han_count(normalized_candidate["candidateBody"])
if not contract["minChars"] <= actual_han_chars <= contract["maxChars"]:
raise AcceptanceError(
"CANDIDATE_LENGTH_OUT_OF_RANGE",
"候选正文汉字数超出动态篇幅合同",
details={
"actualHanChars": actual_han_chars,
"minChars": contract["minChars"],
"maxChars": contract["maxChars"],
"targetChars": contract["targetChars"],
},
)
live = _validate_live_state(live_state)
if (
normalized_context["qualityPolicyVersion"] != PRODUCTION_POLICY
or normalized_candidate["qualityPolicyVersion"] != PRODUCTION_POLICY
or live["qualityPolicyVersion"] != PRODUCTION_POLICY
):
raise AcceptanceError("QUALITY_POLICY_STALE", "生产质量策略已变化或绑定错误")
if (
live["contextSnapshotId"] != normalized_candidate["contextSnapshotId"]
or live["contextSnapshotSha256"]
!= normalized_candidate["contextSnapshotSha256"]
):
raise AcceptanceError("CONTEXT_STALE", "冻结上下文已失效或哈希变化")
if (
live["authorizationSnapshotId"]
!= normalized_context["authorizationSnapshot"]["snapshotId"]
or live["authorizationValid"] is not True
):
raise AcceptanceError("AUTHORIZATION_STALE", "授权快照已失效或变化")
if (
live["sourceStatus"] != ACTIVE_SOURCE_STATUS
or normalized_context["sourceStatus"] != ACTIVE_SOURCE_STATUS
):
raise AcceptanceError("SOURCE_STALE", "来源状态已不允许接受")
if live["_checkedAt"] >= live["_expiresAt"]:
raise AcceptanceError("CANDIDATE_EXPIRED", "候选接受窗口已过期")
_validate_detector_result(detector_result, normalized_candidate)
return {
"schemaVersion": "writer-acceptance-result-v1",
"status": "SHADOW_READY",
"runId": normalized_candidate["runId"],
"candidateVersion": normalized_candidate["candidateVersion"],
"candidateSha256": normalized_candidate["candidateSha256"],
"canonicalRevision": live["canonicalRevision"],
"canonicalMutationPerformed": False,
}
def _validate_edited_candidate(
previous_candidate: Any, candidate: Mapping[str, Any]
) -> None:
"""要求用户编辑形成严格下一版本,并保持同一冻结运行身份。"""
if previous_candidate is None:
raise AcceptanceError("EDIT_BASE_REQUIRED", "修改后合并必须提供编辑前候选")
try:
previous = validate_writer_output(previous_candidate)
except ContractError as exc:
raise AcceptanceError("EDIT_BASE_INVALID", str(exc)) from exc
if candidate["candidateVersion"] != previous["candidateVersion"] + 1:
raise AcceptanceError(
"EDIT_VERSION_INVALID", "用户编辑必须生成 candidateVersion 的严格下一版本"
)
identity_fields = (
"runId",
"attempt",
"mode",
"qualityPolicyVersion",
"contextSnapshotId",
"contextSnapshotSha256",
"acceptanceEligible",
)
if any(candidate[field] != previous[field] for field in identity_fields):
raise AcceptanceError("EDIT_BASE_MISMATCH", "编辑候选改变了冻结运行身份")
if candidate["candidateSha256"] == previous["candidateSha256"]:
raise AcceptanceError("EDIT_BODY_UNCHANGED", "修改后合并必须包含实际正文变更")
def check_writer_acceptance(
*,
decision: str,
confirmed: bool,
context: Mapping[str, Any],
candidate: Mapping[str, Any],
detector_result: Mapping[str, Any],
live_state: Mapping[str, Any],
expected_revision: int,
previous_candidate: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""校验用户三决策并返回命令意图;实验台不写 Canonical。"""
if decision not in DECISIONS:
raise AcceptanceError("DECISION_INVALID", "decision 只允许 accept、merge 或 discard")
if confirmed is not True:
raise AcceptanceError("CONFIRMATION_REQUIRED", f"{decision} 必须由用户明确确认")
if decision == "discard":
try:
normalized_context = validate_writer_context(context)
except ContractError as exc:
raise AcceptanceError("CONTEXT_CONTRACT_INVALID", str(exc)) from exc
try:
normalized_candidate = validate_writer_output(candidate)
except ContractError as exc:
raise AcceptanceError("CANDIDATE_CONTRACT_INVALID", str(exc)) from exc
_validate_context_binding(normalized_context, normalized_candidate)
return {
"schemaVersion": "writer-acceptance-result-v1",
"status": "DISCARD_INTENT_READY",
"decision": decision,
"runId": normalized_candidate["runId"],
"candidateVersion": normalized_candidate["candidateVersion"],
"candidateSha256": normalized_candidate["candidateSha256"],
"commandIntent": {
"command": "close_shadow_candidate",
"expectedCandidateVersion": normalized_candidate["candidateVersion"],
"expectedCandidateSha256": normalized_candidate["candidateSha256"],
},
"canonicalMutationPerformed": False,
}
shadow = check_shadow_ready(
context=context,
candidate=candidate,
detector_result=detector_result,
live_state=live_state,
)
if isinstance(expected_revision, bool) or not isinstance(expected_revision, int):
raise AcceptanceError("EXPECTED_REVISION_INVALID", "expectedRevision 必须是整数")
if expected_revision != shadow["canonicalRevision"]:
raise AcceptanceError(
"REVISION_CONFLICT",
"expectedRevision 与当前 Canonical revision 不一致",
details={
"expectedRevision": expected_revision,
"canonicalRevision": shadow["canonicalRevision"],
},
)
normalized_candidate = validate_writer_output(candidate)
if decision == "merge":
_validate_edited_candidate(previous_candidate, normalized_candidate)
# detector 绑定已由 check_shadow_ready 针对编辑后的新版本重新校验。
return {
"schemaVersion": "writer-acceptance-result-v1",
"status": "ACCEPTANCE_INTENT_READY",
"decision": decision,
"runId": normalized_candidate["runId"],
"candidateVersion": normalized_candidate["candidateVersion"],
"candidateSha256": normalized_candidate["candidateSha256"],
"expectedRevision": expected_revision,
"canonicalMutationPerformed": False,
"commandIntent": {
"command": "queue_chapter_extraction",
"dispatch": "async",
"executeAfter": "canonical_commit",
# 本函数只是 preflight,尚无 Canonical 提交凭证;正式提交层验证凭证后才能放行。
"allowed": False,
"requiresCanonicalCommit": True,
"workId": context["workId"],
"chapter": context["targetChapter"],
"candidateSha256": normalized_candidate["candidateSha256"],
},
}
__all__ = [
"AcceptanceError",
"check_shadow_ready",
"check_writer_acceptance",
]