一、技能重组(动作-对象命名) - 旧目录 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)按"框架与创作分开"未入本提交。
374 lines
15 KiB
Python
374 lines
15 KiB
Python
#!/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",
|
||
]
|