145 lines
6.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
"""上下文冻结落库 —— 在 assemble_context() 之后把冻结清单落一行 example_context_freeze。
落库设计 §2.6 的落点:assemble_writer_context.assemble_context() 是纯函数不碰库(合同如此),
本模块由调用方(生产编排 / 回放编排)在 assemble 之后调用,算出的 contextSha256/manifest
不再"即弃",落一行冻结记录(append-only)。组装器保持 DB-free。
哈希归一:assemble 产出的哈希带 "sha256:" 前缀,入库剥成裸 64 hex(列 CHECK 要求)。
--dry-run 试跑:插入后回滚,校验但不落库。
"""
import json
import sys
from pathlib import Path
from muse_db import connect
CREATOR = "read-context"
EVIDENCE_SCRIPTS = Path(__file__).resolve().parents[2] / "record-run-evidence" / "scripts"
if str(EVIDENCE_SCRIPTS) not in sys.path:
sys.path.insert(0, str(EVIDENCE_SCRIPTS))
def _bare_sha(h):
"""剥 'sha256:' 前缀成裸 64 hex(列 CHECK 要求 ^[0-9a-f]{64}$)。"""
if not h:
return None
return h[len("sha256:"):] if str(h).startswith("sha256:") else str(h)
def _propose_freeze_lesson(
*,
run_id: str,
work_id,
target_chapter,
as_of,
manifest_sha: str,
context_sha: str,
section_count: int,
) -> dict:
"""上下文冻结提交后登记效果信号。"""
from lesson_registry import propose_lesson_dedup # noqa: WPS433
return propose_lesson_dedup(
kind="win",
title="写作上下文已冻结",
detail={
"skill": "assemble-context",
"work_id": work_id,
"target_chapter": target_chapter,
"as_of": as_of,
"manifest_sha256": manifest_sha,
"context_sha256": context_sha,
"section_count": section_count,
},
work_id=work_id if isinstance(work_id, int) else None,
run_id=str(run_id),
creator=CREATOR,
)
def persist_freeze(assemble_result, *, reference_work_id=None, reference_version=None,
arm_config=None, dry_run=False):
"""把 assemble_context 的返回落一行 example_context_freeze。返回 {status, id, manifest_sha256, context_sha256}。"""
ctx = assemble_result["context"]
manifest_sha = _bare_sha(ctx["contextSnapshot"]["manifestId"])
context_sha = _bare_sha(ctx["contextSnapshot"]["contextSha256"])
sections = ctx["retrievalManifest"].get("sources", [])
omitted = ctx.get("omittedSources", [])
used_chars = ctx.get("tokenBudget", {}).get("usedContextChars")
work_id = ctx.get("workId")
target_chapter = ctx.get("targetChapter")
as_of = ctx.get("asOf")
if as_of is None:
raise ValueError("assemble 结果缺 asOf,无法落冻结")
# 授权快照随冻结落库:接受前置检查(acceptance_state)重读它做实时比对,
# 不再信任编排层内存里的快照副本。
authorization = ctx.get("authorizationSnapshot")
auth_json = json.dumps(authorization, ensure_ascii=False) if isinstance(authorization, dict) else None
with connect() as conn:
try:
# (manifest, context) 对唯一:同一冻结重放幂等;同清单不同上下文的冻结各得其所。
# 表是 append-only,冲突只能回读,不能 UPDATE。
# WHY:检索清单可跨运行不变而上下文每次不同(预防合同/时间进 SHA),
# manifest 单键会让新上下文永远落不了库,接受前置检查按 context_sha 必查不到。
row = conn.execute(
"INSERT INTO example_context_freeze(work_id, target_chapter, as_of_chapter, manifest_sha256, "
"context_sha256, reference_work_id, reference_version, arm_config, sections, token_budget, "
"omitted_sources, authorization_snapshot, creator) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s,%s::jsonb,%s::jsonb,%s) "
"ON CONFLICT (manifest_sha256, context_sha256) DO NOTHING "
"RETURNING id, manifest_sha256, context_sha256",
(work_id, target_chapter, as_of, manifest_sha, context_sha, reference_work_id,
reference_version, json.dumps(arm_config, ensure_ascii=False) if arm_config is not None else None,
json.dumps(sections, ensure_ascii=False), used_chars,
json.dumps(omitted, ensure_ascii=False), auth_json, CREATOR)).fetchone()
if not row:
row = conn.execute(
"SELECT id,manifest_sha256,context_sha256 FROM example_context_freeze "
"WHERE manifest_sha256=%s AND context_sha256=%s",
(manifest_sha, context_sha),
).fetchone()
if not row:
raise RuntimeError("冻结幂等回读失败")
if dry_run:
conn.rollback()
return {"status": "dry_run_ok", "id": row[0], "manifest_sha256": row[1],
"context_sha256": row[2], "note": "试跑已回滚,未落库"}
conn.commit()
lesson = None
run_id = ctx.get("runId")
if run_id:
lesson = _propose_freeze_lesson(
run_id=run_id,
work_id=work_id,
target_chapter=target_chapter,
as_of=as_of,
manifest_sha=manifest_sha,
context_sha=context_sha,
section_count=len(sections),
)
return {"status": "frozen", "id": row[0], "manifest_sha256": row[1],
"context_sha256": row[2], "lesson": lesson}
except Exception:
conn.rollback()
raise
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser(description="上下文冻结落库(assemble_context 之后调用)")
ap.add_argument("result_json", nargs="?", default="-",
help="assemble_context 返回的 JSON 文件路径(默认 stdin)")
ap.add_argument("--reference-work-id", type=int, default=None)
ap.add_argument("--reference-version", default=None)
ap.add_argument("--arm-config", default=None, help="实验臂配置 JSON 字符串")
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
raw = sys.stdin.read() if args.result_json == "-" else Path(args.result_json).read_text(encoding="utf-8")
result = json.loads(raw)
arm = json.loads(args.arm_config) if args.arm_config else None
out = persist_freeze(result, reference_work_id=args.reference_work_id,
reference_version=args.reference_version, arm_config=arm, dry_run=args.dry_run)
print(json.dumps(out, ensure_ascii=False))