一、技能重组(动作-对象命名) - 旧目录 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)按"框架与创作分开"未入本提交。
220 lines
8.8 KiB
Python
220 lines
8.8 KiB
Python
#!/usr/bin/env python3
|
|
"""CandidateEnvelope v2 的机械硬门;不调用模型或外部服务。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import pathlib
|
|
import sys
|
|
from typing import Any, Mapping, Sequence
|
|
|
|
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,
|
|
normalize_text,
|
|
validate_writer_context,
|
|
validate_writer_output,
|
|
)
|
|
|
|
|
|
SEMANTIC_CHECKS = (
|
|
"hard_event_semantics",
|
|
"claim_discovery_and_truth_alignment",
|
|
"new_setting_candidates",
|
|
"evidence_gaps",
|
|
"knowledge_scope",
|
|
"ability_cost_consistency",
|
|
)
|
|
|
|
|
|
def _failure(code: str, message: str, **details: Any) -> dict[str, Any]:
|
|
"""生成稳定且可追踪的阻塞项。"""
|
|
|
|
return {"code": code, "message": message, **details}
|
|
|
|
|
|
def _anchors_present(body: str, anchors: Any) -> bool:
|
|
"""只执行确定性的文本锚点匹配,语义等价判断留给模型 detector。"""
|
|
|
|
return isinstance(anchors, list) and bool(anchors) and any(
|
|
isinstance(anchor, str) and anchor and normalize_text(anchor) in body for anchor in anchors
|
|
)
|
|
|
|
|
|
def _safe_sequence(value: Any) -> Sequence[Any]:
|
|
"""把非法数组降为空序列,随后由输入合同阻塞项统一报告。"""
|
|
|
|
return value if isinstance(value, list) else ()
|
|
|
|
|
|
def _precheck_candidate_integrity(candidate: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
"""在严格 schema 前检查候选正文与哈希,保留精确失败码。"""
|
|
|
|
failures: list[dict[str, Any]] = []
|
|
body = candidate.get("candidateBody")
|
|
candidate_hash = candidate.get("candidateSha256")
|
|
if not isinstance(body, str):
|
|
return [_failure("OUTPUT_CONTRACT_INVALID", "candidateBody 必须是字符串")]
|
|
try:
|
|
normalized_body = normalize_text(body)
|
|
except ContractError as exc:
|
|
return [_failure("OUTPUT_CONTRACT_INVALID", str(exc))]
|
|
expected_hash = "sha256:" + hashlib.sha256(normalized_body.encode("utf-8")).hexdigest()
|
|
if candidate_hash != expected_hash:
|
|
failures.append(
|
|
_failure(
|
|
"CANDIDATE_HASH_MISMATCH",
|
|
"candidateSha256 与规范化候选正文不一致",
|
|
expectedSha256=expected_hash,
|
|
)
|
|
)
|
|
return failures
|
|
|
|
|
|
def _check_context_binding(
|
|
context: Mapping[str, Any], candidate: Mapping[str, Any]
|
|
) -> list[dict[str, Any]]:
|
|
"""检查候选元数据来自本次冻结上下文。"""
|
|
|
|
failures: list[dict[str, Any]] = []
|
|
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:
|
|
failures.append(
|
|
_failure("CONTEXT_BINDING_MISMATCH", "候选未绑定当前冻结上下文", fields=mismatches)
|
|
)
|
|
return failures
|
|
|
|
|
|
def _check_outline_anchors(body: str, requirements: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
"""机械检查硬事件、必须出场角色、伏笔动作和章末钩子。"""
|
|
|
|
failures: list[dict[str, Any]] = []
|
|
for item in _safe_sequence(requirements.get("requiredEvents")):
|
|
if not isinstance(item, Mapping) or not _anchors_present(body, item.get("anchors")):
|
|
failures.append(
|
|
_failure(
|
|
"HARD_EVENT_MISSING",
|
|
"候选未命中细纲硬事件锚点",
|
|
requirementId=item.get("requirementId") if isinstance(item, Mapping) else None,
|
|
)
|
|
)
|
|
for character in _safe_sequence(requirements.get("requiredCharacters")):
|
|
if not isinstance(character, str) or not character or normalize_text(character) not in body:
|
|
failures.append(
|
|
_failure("REQUIRED_CHARACTER_MISSING", "细纲要求的角色未出场", character=character)
|
|
)
|
|
for item in _safe_sequence(requirements.get("foreshadowingActions")):
|
|
if not isinstance(item, Mapping) or not _anchors_present(body, item.get("anchors")):
|
|
failures.append(
|
|
_failure(
|
|
"FORESHADOWING_ACTION_MISSING",
|
|
"候选未命中细纲伏笔动作锚点",
|
|
requirementId=item.get("requirementId") if isinstance(item, Mapping) else None,
|
|
)
|
|
)
|
|
hook = requirements.get("chapterEndHook")
|
|
if isinstance(hook, Mapping):
|
|
max_distance = hook.get("maxDistanceFromEnd")
|
|
if isinstance(max_distance, bool) or not isinstance(max_distance, int) or max_distance <= 0:
|
|
failures.append(_failure("DETECTOR_INPUT_INVALID", "章末钩子距离必须是正整数"))
|
|
else:
|
|
tail = body[-max_distance:]
|
|
if not _anchors_present(tail, hook.get("anchors")):
|
|
failures.append(
|
|
_failure(
|
|
"CHAPTER_END_HOOK_MISSING",
|
|
"候选结尾未命中细纲章末钩子",
|
|
requirementId=hook.get("requirementId"),
|
|
)
|
|
)
|
|
else:
|
|
failures.append(_failure("DETECTOR_INPUT_INVALID", "chapterEndHook 必须是对象"))
|
|
return failures
|
|
|
|
|
|
def check_writer_candidate(
|
|
context: Mapping[str, Any], candidate: Mapping[str, Any], requirements: Mapping[str, Any]
|
|
) -> dict[str, Any]:
|
|
"""运行不含模型调用的机械硬门,并返回结构化 detector 报告。"""
|
|
|
|
failures: list[dict[str, Any]] = []
|
|
if not isinstance(context, Mapping) or not isinstance(candidate, Mapping) or not isinstance(requirements, Mapping):
|
|
failures.append(_failure("DETECTOR_INPUT_INVALID", "context、candidate、requirements 必须是对象"))
|
|
return {
|
|
"schemaVersion": "writer-detector-report-v1",
|
|
"passed": False,
|
|
"blockingFailures": failures,
|
|
"semanticReview": {"status": "pending", "checks": list(SEMANTIC_CHECKS)},
|
|
}
|
|
try:
|
|
normalized_context = validate_writer_context(context)
|
|
except ContractError as exc:
|
|
failures.append(_failure("CONTEXT_CONTRACT_INVALID", str(exc)))
|
|
normalized_context = None
|
|
failures.extend(_precheck_candidate_integrity(candidate))
|
|
try:
|
|
normalized_candidate = validate_writer_output(candidate)
|
|
except ContractError as exc:
|
|
failures.append(_failure("OUTPUT_CONTRACT_INVALID", str(exc)))
|
|
normalized_candidate = None
|
|
if normalized_context is not None:
|
|
failures.extend(_check_context_binding(normalized_context, candidate))
|
|
body = candidate.get("candidateBody")
|
|
if isinstance(body, str):
|
|
normalized_body = normalize_text(body)
|
|
if normalized_context is not None:
|
|
contract = normalized_context["outputContract"]
|
|
actual_han_chars = han_count(normalized_body)
|
|
if not contract["minChars"] <= actual_han_chars <= contract["maxChars"]:
|
|
failures.append(
|
|
_failure(
|
|
"CANDIDATE_LENGTH_OUT_OF_RANGE",
|
|
"候选正文汉字数超出动态篇幅合同",
|
|
actualHanChars=actual_han_chars,
|
|
minChars=contract["minChars"],
|
|
maxChars=contract["maxChars"],
|
|
targetChars=contract["targetChars"],
|
|
)
|
|
)
|
|
failures.extend(_check_outline_anchors(normalized_body, requirements))
|
|
# 按 code 与定位去重,避免严格合同与预检重复报告同一处问题。
|
|
unique: list[dict[str, Any]] = []
|
|
seen: set[tuple[Any, ...]] = set()
|
|
for item in failures:
|
|
key = (item.get("code"), item.get("requirementId"), item.get("character"))
|
|
if key not in seen:
|
|
seen.add(key)
|
|
unique.append(item)
|
|
source = normalized_candidate or candidate
|
|
return {
|
|
"schemaVersion": "writer-detector-report-v1",
|
|
"runId": source.get("runId"),
|
|
"attempt": source.get("attempt"),
|
|
"candidateVersion": source.get("candidateVersion"),
|
|
"candidateSha256": source.get("candidateSha256"),
|
|
"passed": not unique,
|
|
"blockingFailures": unique,
|
|
"semanticReview": {
|
|
"status": "pending",
|
|
"checks": list(SEMANTIC_CHECKS),
|
|
"inputContract": "WriterContext v1 + CandidateEnvelope v2 -> SemanticDetection v3",
|
|
},
|
|
}
|
|
|
|
|
|
__all__ = ["SEMANTIC_CHECKS", "check_writer_candidate"]
|