#!/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"]