框架(数据库+读取器): 落库 100 规划+冻结表,建冻结落库模块

- example_planning_section(规划/细纲/设定,书级/章级双部分唯一索引,shadow→confirmed)
  + example_context_freeze(冻结清单,append-only,manifestSha256 幂等)
- persist_context_freeze.py:assemble_context 之后由调用方落冻结(manifest/context哈希+纳入来源+省略项+预算),
  组装器保持不碰库(合同如此);dry-run 已验证
- read-context SKILL.md 增冻结落库挂点说明
This commit is contained in:
zizi 2026-07-31 22:29:55 +08:00
parent bf6cbc02b1
commit 96aa1e61a3
3 changed files with 178 additions and 3 deletions

View File

@ -23,8 +23,9 @@ scenario 映射功能链分类:生成类为 `generation`,抽取类为 `extra
3. **按指针回读原文**:在 `REPEATABLE READ READ ONLY` 事务中按 `sourceVersion/sourceRefs/stateAsOf` 回读 Canonical block。缺少可验证指针的内容只能作为未验证索引提示,不能进入事实证据。
4. **双证据组装**:连续前四章全文构成近期基线;卡指针召回的历史正文作为补充并按不可变来源去重。`factEvidence` 只接受冻结历史原文、正式设定、Canonical 状态或细纲明确声明的新事实,并保留各自权威引用。
5. **确定性冻结**:按 `score DESC, sourceVersion ASC, sourceId ASC, sourceOffset ASC` 排序,生成完整 `WriterContext v1`、RetrievalManifest、来源省略原因和上下文 hash。预算裁剪必须可审计,不能静默丢失硬约束或连续正文基线。
6. **冻结落库(assemble 之后,调用方负责)**:`assemble_context()` 算出冻结后,调用方(生产编排 / 回放编排)调 `scripts/persist_context_freeze.py`,把冻结清单(manifestSha256/contextSha256/纳入来源/省略项/预算)落一行 `example_context_freeze`(append-only,manifestSha256 幂等)。组装器本身不碰库(合同如此),落库是调用方的事;回执表的 `context_sha256` 连本表 `context_sha256` 列。
对应机械实现:`scripts/writer_contract.py`、`scripts/retrieve_writer_sources.py`、`scripts/assemble_writer_context.py`。其中 `retrieve_writer_sources.py` 的冻结来源校验、内容泄漏审计与冻结原文只读装载复用 **snapshot** skill(`build_snapshot.py`/`check_snapshot.py`/`audit_leakage.py`/`load_reference_work.py`),单向向下依赖,不复制 SQL 或第二套权限语义。
对应机械实现:`scripts/writer_contract.py`、`scripts/retrieve_writer_sources.py`、`scripts/assemble_writer_context.py`、`scripts/persist_context_freeze.py`(冻结落库)。其中 `retrieve_writer_sources.py` 的冻结来源校验、内容泄漏审计与冻结原文只读装载复用 **snapshot** skill(`build_snapshot.py`/`check_snapshot.py`/`audit_leakage.py`/`load_reference_work.py`),单向向下依赖,不复制 SQL 或第二套权限语义。
## 四层稳定语义
@ -43,7 +44,7 @@ scenario 映射功能链分类:生成类为 `generation`,抽取类为 `extra
|---|---|---|
| writer | `WriterCreativeInput v2`:fineOutline、narrativeState、factConstraints、proseExcerpts、patternReferences、lengthContract、styleConstraints | runId、权限、manifest、hash、候选版本、实验臂、oracle、评审结论、检索工具 |
| semantic detector | `semantic-detector-input-v3` 的模型投影:当前候选、细纲/硬约束、事实证据、历史原文证据、asOf | 真实实验臂、raw 路径、oracle、其他候选/评委、输入与候选 hash、receipt |
| blind judge | `writer-blind-input-v3` 的模型投影:匿名候选正文、共同细纲、oracle 断言、rubric | 真实 A/B/C、卡注入、证据策略、候选 hash、reviewer 身份、raw、其他评委结果 |
| blind judge | `writer-blind-input-v4` 的模型投影:匿名候选正文、共同细纲、oracle 断言、通用指标与当前场景评分策略 | 真实 A/B/C、卡注入、证据策略、候选 hash、reviewer 身份、raw、其他评委结果 |
| planner | 冻结规划视图;只在 planning 授权下读取全局方向和未来规划 | 未授权原文、目标章之后的参考事实、评测答案 |
| extractor | 待处理原文、schema 合同和判重所需的既有知识 | 范式参考、未来内容、无来源推断 |
@ -53,7 +54,7 @@ writer 的 `factConstraints=[]` 在冻结读取确实没有可确认事实时合
- writer 只返回 `WriterDraft v2` 的 `candidateBody`;adapter 生成 CandidateEnvelope v2,并绑定运行身份、候选版本、正文 hash 与上下文 hash。
- semantic detector 和 blind judge 只返回各自模型草稿;字符 offset、输入/报告 hash、模型回执和最终状态均由 adapter 计算。
- A/C 单变量回执比较 `build_writer_creative_input()` 的结果,只允许预注册证据策略导致 `factConstraints` 与 `proseExcerpts` 差异;细纲、叙事状态、篇幅、风格和公共参考必须一致。
- A/C 单变量回执比较 `build_writer_creative_input()` 的结果,只允许预注册卡处理与证据策略导致 `factConstraints`、`proseExcerpts` 与 `patternReferences` 差异;A 的 `patternReferences` 恒空,B/C 使用同一组冻结公共范式卡,细纲、叙事状态、篇幅和风格必须一致。
## 回显与存储

View File

@ -0,0 +1,84 @@
#!/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
# 复用 db skill 锁死的 DSN(.claude/skills/db/scripts)
DB_SCRIPTS = Path(__file__).resolve().parents[2] / "db" / "scripts"
sys.path.insert(0, str(DB_SCRIPTS))
from db import connect # noqa: E402
CREATOR = "read-context"
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 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,无法落冻结")
with connect() as conn:
try:
# manifest_sha256 唯一:同一冻结重放幂等(ON CONFLICT 返回既有行)
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, creator) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s,%s::jsonb,%s) "
"ON CONFLICT (manifest_sha256) DO UPDATE SET creator=EXCLUDED.creator "
"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), CREATOR)).fetchone()
if dry_run:
conn.rollback()
return {"status": "dry_run_ok", "id": row[0], "manifest_sha256": row[1],
"context_sha256": row[2], "note": "试跑已回滚,未落库"}
conn.commit()
return {"status": "frozen", "id": row[0], "manifest_sha256": row[1], "context_sha256": row[2]}
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))

View File

@ -0,0 +1,90 @@
-- 规划与上下文冻结两表:example_planning_section(规划/细纲/设定)+ example_context_freeze(冻结清单)。
-- 设计拍板 2026-07-30(docs/2026-07-30-落库与看板设计.md §2.6)。
-- 背景:今天规划/细纲在文件与 gitignore 临时目录(planner 直接写文件),冻结 manifest 是仓外临时 JSON 算完即弃;
-- 主仓 muse_content_planning_section 暂缓未建。两表新建,补齐"已确认细纲/冻结上下文"的持久化缺口。
-- planning_section 可变(shadow→confirmed,带公共列+updated_at);context_freeze append-only(冻结一旦生成不可改)。
-- 全部对象 IF NOT EXISTS / OR REPLACE,重复 apply 幂等。
-- ══ example_planning_section:规划/细纲/设定(可变;书级 target_chapter 为空,章级填章号)══
CREATE TABLE IF NOT EXISTS example_planning_section (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
work_id BIGINT NOT NULL, -- → muse_content_work.id(软引用)
target_chapter INTEGER, -- 章级(fine_outline)填章号;书级(setting/outline/state/assembly)为空
section_type VARCHAR(32) NOT NULL, -- setting/outline/state/assembly/fine_outline
schema_type VARCHAR(32), -- 对应 meta/schemas 型(work_core/outline/narrative_state…)
version INTEGER NOT NULL DEFAULT 1, -- 规划版本
payload JSONB NOT NULL, -- 结构化内容(frontmatter+正文的结构化形态)
state VARCHAR(20) NOT NULL DEFAULT 'shadow', -- shadow/confirmed(用户确认后翻 confirmed)
confirmed_at TIMESTAMP, -- 确认时间
creator VARCHAR(64) NOT NULL DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) NOT NULL DEFAULT '',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT FALSE,
tenant_id BIGINT NOT NULL DEFAULT 0,
CONSTRAINT chk_example_planning_section_type
CHECK (section_type IN ('setting','outline','state','assembly','fine_outline')),
CONSTRAINT chk_example_planning_section_state
CHECK (state IN ('shadow','confirmed'))
);
-- 唯一键用两个部分唯一索引(仿 V26 partial unique,因 target_chapter 可空)
CREATE UNIQUE INDEX IF NOT EXISTS uk_example_planning_section_book
ON example_planning_section(tenant_id, work_id, section_type, version) WHERE target_chapter IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uk_example_planning_section_chapter
ON example_planning_section(tenant_id, work_id, section_type, target_chapter, version) WHERE target_chapter IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_example_planning_section_work
ON example_planning_section(tenant_id, work_id, section_type, state);
CREATE OR REPLACE TRIGGER trg_example_planning_section_updated_at BEFORE UPDATE ON example_planning_section FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ══ example_context_freeze:冻结清单(append-only;冻结一旦生成不可改)══
CREATE TABLE IF NOT EXISTS example_context_freeze (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
work_id BIGINT, -- → muse_content_work.id(软引用)
target_chapter INTEGER, -- 目标章
as_of_chapter INTEGER NOT NULL, -- 冻结上界(严格 <= asOf)
manifest_sha256 CHAR(64) NOT NULL, -- manifest 自身哈希(唯一)
context_sha256 CHAR(64), -- WriterContext 回执哈希 = retrieval_identity(context);回执表连这一列
reference_work_id BIGINT, -- 参考作品 id(回放用)
reference_version VARCHAR(64), -- 参考版本
arm_config JSONB, -- 实验臂配置(A/B/C)
sections JSONB, -- 每节 sourceId/sourceVersion/sha256/charCount
token_budget INTEGER, -- 上下文预算
omitted_fields JSONB, -- 字段级裁剪省略项
omitted_sources JSONB, -- 来源级省略项
creator VARCHAR(64) NOT NULL DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
tenant_id BIGINT NOT NULL DEFAULT 0,
CONSTRAINT uk_example_context_freeze_manifest UNIQUE (manifest_sha256),
CONSTRAINT chk_example_context_freeze_manifest_sha256
CHECK (manifest_sha256 ~ '^[0-9a-f]{64}$'),
CONSTRAINT chk_example_context_freeze_context_sha256
CHECK (context_sha256 IS NULL OR context_sha256 ~ '^[0-9a-f]{64}$')
);
CREATE INDEX IF NOT EXISTS idx_example_context_freeze_context ON example_context_freeze(context_sha256) WHERE context_sha256 IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_example_context_freeze_work ON example_context_freeze(work_id, target_chapter);
CREATE OR REPLACE FUNCTION example_reject_truncate()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION '% 是 append-only 表,禁止 TRUNCATE', TG_TABLE_NAME;
END;
$$;
CREATE OR REPLACE FUNCTION reject_example_context_freeze_mutation()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION 'example_context_freeze 是 append-only 表,禁止 UPDATE/DELETE';
END;
$$;
CREATE OR REPLACE TRIGGER trg_example_context_freeze_append_only
BEFORE UPDATE OR DELETE ON example_context_freeze
FOR EACH ROW EXECUTE FUNCTION reject_example_context_freeze_mutation();
CREATE OR REPLACE TRIGGER trg_example_context_freeze_no_truncate
BEFORE TRUNCATE ON example_context_freeze
FOR EACH STATEMENT EXECUTE FUNCTION example_reject_truncate();