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

137 lines
5.7 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
"""接受前置检查的实时状态重读(生产模式)。
check_writer_acceptance 是无副作用纯函数,不碰库;本模块负责在**接受时刻**从 muse-example
重读冻结行、Canonical 正文 revision、已确认细纲状态与授权快照,组装严格 live_state。
编排层不得用内存里的旧快照冒充实时状态——这里读到什么,preflight 就拿什么比对。
"""
from __future__ import annotations
import json
import pathlib
import sys
from datetime import datetime, timedelta, timezone
from typing import Any, Mapping
DB_DIR = pathlib.Path(__file__).resolve().parents[2] / "access-database" / "scripts"
if str(DB_DIR) not in sys.path:
sys.path.insert(0, str(DB_DIR))
from db import connect # noqa: E402
PRODUCTION_POLICY = "writer-production-v1"
# 候选接受窗口:生成后 24 小时内必须完成接受,超期 preflight 报 CANDIDATE_EXPIRED。
ACCEPTANCE_WINDOW = timedelta(hours=24)
class LiveStateError(RuntimeError):
"""实时状态不可读或形状非法——失败关闭,不得带病进入 preflight。"""
def _bare_sha(value: str) -> str:
value = str(value or "")
return value[len("sha256:"):] if value.startswith("sha256:") else value
def _parse_tz(value: Any) -> datetime | None:
"""解析带时区 ISO 时间;非法返回 None(由调用方决定是否失败关闭)。"""
if not isinstance(value, str) or not value:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def _iso_z(value: datetime) -> str:
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
def build_live_acceptance_state(
context: Mapping[str, Any], *, now: datetime | None = None
) -> dict[str, Any]:
"""按 check_writer_acceptance 的 live_state 闭集字段重读实时状态。"""
if not isinstance(context, Mapping):
raise LiveStateError("context 必须是对象")
context_sha = _bare_sha(context.get("contextSnapshot", {}).get("contextSha256"))
if len(context_sha) != 64:
raise LiveStateError("context 缺 contextSnapshot.contextSha256")
work_id = context.get("workId")
target_chapter = context.get("targetChapter")
if not isinstance(work_id, int) or not isinstance(target_chapter, int):
raise LiveStateError("context 缺 workId/targetChapter")
checked_at = now or datetime.now(timezone.utc)
with connect(readonly=True) as conn:
freeze = conn.execute(
"SELECT manifest_sha256, context_sha256, authorization_snapshot, create_time "
"FROM example_context_freeze WHERE context_sha256=%s ORDER BY id DESC LIMIT 1",
(context_sha,),
).fetchone()
if not freeze:
raise LiveStateError("CONTEXT_NOT_FROZEN:冻结上下文未落库,接受前置检查拒绝继续")
manifest_sha, freeze_context_sha, auth_payload, freeze_time = freeze
revision_row = conn.execute(
"SELECT COALESCE(MAX(b.revision),0) FROM muse_content_block b "
"JOIN muse_content_chapter c ON b.chapter_id=c.id AND c.deleted=false "
"WHERE c.work_id=%s AND c.order_no=%s AND b.deleted=false",
(work_id, target_chapter),
).fetchone()
canonical_revision = int(revision_row[0])
outline_row = conn.execute(
"SELECT state FROM example_planning_section WHERE work_id=%s "
"AND section_type='fine_outline' AND target_chapter=%s AND deleted=false "
"ORDER BY version DESC LIMIT 1",
(work_id, target_chapter),
).fetchone()
source_status = "active" if outline_row and outline_row[0] == "confirmed" else "stale"
# 授权快照以冻结行为准重读:快照 ID 必须与候选绑定一致,且核验时间不晚于本次检查。
auth = auth_payload if isinstance(auth_payload, Mapping) else {}
if isinstance(auth_payload, str):
try:
auth = json.loads(auth_payload)
except ValueError:
auth = {}
context_snapshot = context.get("authorizationSnapshot", {})
context_snapshot_id = context_snapshot.get("snapshotId") if isinstance(context_snapshot, Mapping) else None
authorization_valid = bool(
auth.get("snapshotId")
and context_snapshot_id
and auth.get("snapshotId") == context_snapshot_id
)
if authorization_valid:
verified_at = _parse_tz(auth.get("verifiedAt"))
authorization_valid = verified_at is not None and verified_at <= checked_at
# WriterContext 合同里 generatedAt 住在 contextSnapshot 内,顶层没有该字段
generated_at = _parse_tz(context.get("contextSnapshot", {}).get("generatedAt"))
if generated_at is None and freeze_time is not None:
generated_at = freeze_time.replace(tzinfo=timezone.utc)
if generated_at is None:
raise LiveStateError("无法确定候选生成时间,接受窗口不可计算")
expires_at = generated_at + ACCEPTANCE_WINDOW
return {
"qualityPolicyVersion": PRODUCTION_POLICY,
"contextSnapshotId": "sha256:" + str(manifest_sha),
"contextSnapshotSha256": "sha256:" + str(freeze_context_sha),
"authorizationSnapshotId": str(auth.get("snapshotId") or ""),
"authorizationValid": authorization_valid,
"sourceStatus": source_status,
"candidateExpiresAt": _iso_z(expires_at),
"checkedAt": _iso_z(checked_at),
"canonicalRevision": canonical_revision,
}
__all__ = ["ACCEPTANCE_WINDOW", "LiveStateError", "PRODUCTION_POLICY", "build_live_acceptance_state"]