框架(数据库): 落库 97/98 运行级账本四表并加固,db skill 增参数化通道与只读 query

- 新增 example_llm_call(97)、example_run/example_run_receipt/example_quality_result(98)
- 加固:全对象幂等(IF NOT EXISTS/OR REPLACE)、append-only 表加 TRUNCATE 防护、
  运行终态闭合约束、token/成本非负与 model_match 一致性 CHECK(已 DROP 空表重 apply 验证)
- db.py:新增 execparams 参数化写(%s 服务端绑定+语句白名单+stdin 大对象);
  query 改只读连接(拒 INSERT...RETURNING);tables 用 sql.Identifier 安全拼接;connect() 统一入口
- 表映射登记 97/98、96 标不启用(仅本轮改动,未含他处未提交内容)
This commit is contained in:
zizi 2026-07-31 00:30:54 +08:00
parent 4adc85cafd
commit bb1dcc1a58
6 changed files with 385 additions and 11 deletions

View File

@ -19,6 +19,14 @@ description: muse-example 实验库的唯一数据库通道——查询/DML/DDL/
# 单条写操作(INSERT/UPDATE/DELETE/DDL):报影响行数
.venv/bin/python .claude/skills/db/scripts/db.py exec "UPDATE ... WHERE ..."
# 参数化写操作:SQL 用 %s 占位,参数走服务端绑定(防注入;大内容不拼命令行)
.venv/bin/python .claude/skills/db/scripts/db.py execparams "INSERT INTO example_raw_content(kind,content_sha256,content) VALUES (%s,%s,%s)" --param response --param <sha> --param "短文本"
# 大对象(raw 全文)经 stdin 传 JSON 数组(避开 shell 转义 / ARG_MAX):
.venv/bin/python .claude/skills/db/scripts/db.py execparams "INSERT ... VALUES (%s,%s)" --stdin < params.json # params.json = ["<sha>", "<完整全文>"]
# execparams 参数装载逻辑离线自测(不连库)
.venv/bin/python .claude/skills/db/scripts/test_db_params.py
# SQL 文件应用:整文件一个事务,失败全回滚
.venv/bin/python .claude/skills/db/scripts/db.py apply db/ddl/91-example实验私货.sql
@ -35,6 +43,7 @@ description: muse-example 实验库的唯一数据库通道——查询/DML/DDL/
- 软删约定照主仓:删除=UPDATE `deleted=TRUE`,不物理删(example_* 表同样遵守)。
- 批量导入/嵌入等专用写路径由 import/embed skill 封装(内部同走 psycopg 直连),本 skill 承担通用查改与 DDL 应用。
- 建表/改表先落 `db/ddl/` 文件再 `apply`,不敲一次性 DDL——文件即审计。
- 大对象写入(raw 全文等)走 `execparams` 参数化通道(大内容经 stdin JSON),不得把大内容拼进 `exec` 的 SQL 字符串(shell 转义 + ARG_MAX);参数化绑定同时防 SQL 注入。
## 元数据驱动

View File

@ -10,12 +10,25 @@ import sys
import click
import psycopg
from psycopg import sql
# 连接事实与凭据来源:db/连接信息.md(内网 Tailscale 段,凭据明文入仓为既定政策)
DSN = "postgresql://root:f6710e2d0294eb1c10e26a805a64bc54@100.64.0.8:5433/muse-example"
TRUNC = 160 # 卡片模式长值截断阈值(字符)
def connect(readonly: bool = False):
"""统一连接入口:复用锁死的 muse-example DSN(即开即关,Tailscale 不持长事务)。
readonly=True 时会话级锁死只读(写语句被 PG 直接拒)——query 命令与看板用。
其它 skill 的写路径需要参数化短连接时,`from db import connect` 复用同一 DSN,
不要各自硬编码连接串(仿 llm skill 的 _bump_window)。
"""
if readonly:
return psycopg.connect(DSN, options="-c default_transaction_read_only=on")
return psycopg.connect(DSN)
def _fmt(value, full: bool) -> str:
"""卡片值格式化:NULL 显示 ∅;长值默认截断并标注总长。"""
if value is None:
@ -37,8 +50,8 @@ def cli():
@click.option("--full", is_flag=True, help="长值不截断")
@click.option("--max", "max_rows", default=50, show_default=True, help="最多打印行数")
def query(sql_text: str, as_json: bool, full: bool, max_rows: int):
"""执行 SELECT,卡片式打印(审查面)。"""
with psycopg.connect(DSN) as conn:
"""执行 SELECT,卡片式打印(审查面)。连接只读,写语句(含 INSERT...RETURNING)会被 PG 拒。"""
with connect(readonly=True) as conn:
cur = conn.execute(sql_text)
if cur.description is None:
raise click.ClickException("该语句无结果集——写操作请用 exec")
@ -63,20 +76,72 @@ def query(sql_text: str, as_json: bool, full: bool, max_rows: int):
@click.argument("sql_text")
def exec_(sql_text: str):
"""执行单条写操作(INSERT/UPDATE/DELETE/DDL),报影响行数。"""
with psycopg.connect(DSN) as conn:
with connect() as conn:
cur = conn.execute(sql_text)
conn.commit()
n = cur.rowcount
click.echo(f"OK(影响 {n} 行)" if n >= 0 else "OK")
def _read_params(stdin_json: bool, param_tuple: tuple, stream=None) -> list:
"""收集参数:--stdin 时从 stream(默认 stdin)读 JSON 数组;否则用 --param 按序。两者互斥。
大对象(raw 全文)走 --stdin:内容经 JSON 数组从标准输入流入,避开 shell 转义与 ARG_MAX。
"""
if stdin_json:
if param_tuple:
raise click.ClickException("--stdin 与 --param 互斥,二选一")
raw = (stream if stream is not None else sys.stdin).read()
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
raise click.ClickException(f"--stdin 参数不是合法 JSON:{e}")
if not isinstance(data, list):
raise click.ClickException("--stdin 参数必须是 JSON 数组,元素按 %s 顺序排列")
return data
return list(param_tuple)
# execparams 只允许这些种类的单条语句(参数化写);拒绝 DO/CALL/DDL/TRUNCATE/多语句
_EXECPARAMS_ALLOWED = ("INSERT", "UPDATE", "DELETE", "WITH")
def _guard_execparams(sql_text: str) -> None:
stripped = sql_text.strip()
if ";" in stripped.rstrip(";"):
raise click.ClickException("execparams 只接受单条语句(禁止多语句)")
first = stripped.split(None, 1)[0].upper() if stripped else ""
if first not in _EXECPARAMS_ALLOWED:
raise click.ClickException(
f"execparams 只做参数化写(INSERT/UPDATE/DELETE/WITH),收到 {first or '空'} 语句")
@cli.command("execparams")
@click.argument("sql_text")
@click.option("--stdin", "stdin_json", is_flag=True,
help="参数从 stdin 读入(JSON 数组,按 %s 顺序);大对象用,避开 shell 转义与 ARG_MAX")
@click.option("--param", "param_tuple", multiple=True,
help="按序传参(小值,可重复);与 --stdin 互斥")
def execparams(sql_text: str, stdin_json: bool, param_tuple: tuple):
"""参数化写操作:SQL 用 %s 占位,参数走服务端绑定(防注入;大内容不拼命令行)。"""
_guard_execparams(sql_text)
params = _read_params(stdin_json, param_tuple)
with connect() as conn:
cur = conn.execute(sql_text, params)
if cur.description is not None:
raise click.ClickException("该语句有结果集——查询请用 query(execparams 只做写)")
conn.commit()
n = cur.rowcount
click.echo(f"OK(影响 {n} 行)" if n >= 0 else "OK")
@cli.command()
@click.argument("file", type=click.Path(exists=True))
def apply(file: str):
"""应用 SQL 文件:整文件一个事务(无参数走 simple protocol 支持多语句),失败全回滚。"""
with open(file, encoding="utf-8") as f:
sql = f.read()
with psycopg.connect(DSN) as conn:
with connect() as conn:
conn.execute(sql)
conn.commit()
click.echo(f"已应用: {file}")
@ -85,15 +150,19 @@ def apply(file: str):
@cli.command()
def tables():
"""表清单 + 活行数(有 deleted 列的表计 deleted=FALSE,其余计全行)。"""
with psycopg.connect(DSN) as conn:
with connect() as conn:
names = [r[0] for r in conn.execute(
"SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename")]
for t in names:
has_deleted = conn.execute(
"SELECT 1 FROM information_schema.columns WHERE table_name=%s AND column_name='deleted'",
(t,)).fetchone()
where = " WHERE deleted = FALSE" if has_deleted else ""
n = conn.execute(f'SELECT count(*) FROM "{t}"{where}').fetchone()[0]
# 表名用 sql.Identifier 安全拼接(不手工拼双引号),避免恶意表名破坏语句
if has_deleted:
stmt = sql.SQL("SELECT count(*) FROM {} WHERE deleted = FALSE").format(sql.Identifier(t))
else:
stmt = sql.SQL("SELECT count(*) FROM {}").format(sql.Identifier(t))
n = conn.execute(stmt).fetchone()[0]
click.echo(f"{t:<45} {n}")

View File

@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""db execparams 参数装载逻辑离线自测(不连库)。
跑法(仓库根目录):.venv/bin/python .claude/skills/db/scripts/test_db_params.py
"""
import io
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import click # noqa: E402
import db # noqa: E402
def expect_exc(fn, msg):
try:
fn()
except click.ClickException:
return
raise AssertionError(f"应抛 ClickException:{msg}")
def main():
# --param 按序
assert db._read_params(False, ("a", "b")) == ["a", "b"]
assert db._read_params(False, ()) == []
# --stdin 合法 JSON 数组(含 null/数字)
assert db._read_params(True, (), stream=io.StringIO('["x", 1, null]')) == ["x", 1, None]
# 大对象(含中文引号/换行)经 stdin 不破坏
big = '中文"引号"\n换行\\反斜杠' * 5000
assert db._read_params(True, (), stream=io.StringIO(json.dumps([big])))[0] == big
# --stdin 与 --param 互斥
expect_exc(lambda: db._read_params(True, ("a",)), "--stdin 与 --param 互斥")
# 非法 JSON
expect_exc(lambda: db._read_params(True, (), stream=io.StringIO("not json")), "非法 JSON")
# 非数组
expect_exc(lambda: db._read_params(True, (), stream=io.StringIO('{"a":1}')), "非数组")
print("PASS:db execparams 参数装载逻辑全部通过")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,67 @@
-- example_llm_call:逐次模型调用明细账本(运行态设施,非业务实体)。
-- 设计拍板 2026-07-30(docs/2026-07-30-落库与看板设计.md §2.1,已吸收四路评审 + Codex 加固)。
-- 与 example_llm_quota(95,按 5 小时窗的 $/次 聚合水位)互补、不重叠:
-- 额度账本是按窗 ON CONFLICT += 的累加器(且非 MiniMax 成本记 0),回答"限速/预算";
-- 本表是逐次调用明细(含真实单次成本、用量、提示词哈希、raw 指针),回答"每次调了什么、花了多少"。
-- 刻意不带业务公共列(updater/update_time/deleted/tenant_id):运行态设施、append-only、无业务归属、无软删语义(仿 95)。
-- 只保留 creator/call_time 记录"何时由哪个调用方写入"(call_time 兼作创建时间)。
-- 哈希列按落库设计 §1 归一规则存裸 64 位小写 hex(剥 sha256: 前缀)。
-- append-only:禁止 UPDATE/DELETE/TRUNCATE,错误/重试产生新行。
-- 全部对象用 IF NOT EXISTS / OR REPLACE,重复 apply 幂等。
CREATE TABLE IF NOT EXISTS example_llm_call (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
window_key VARCHAR(16), -- → example_llm_quota.window_key(软引用,可空:跨窗或无窗调用)
run_id VARCHAR(64), -- → example_run.run_id(软引用,可空:清洗/拆书批处理无 run,靠 caller 区分)
caller VARCHAR(64) NOT NULL DEFAULT '', -- 调用方(clean/parse-book/extractor/review-cards/migrate-upgrade…)
requested_model_id VARCHAR(64) NOT NULL, -- 请求的模型
actual_model_id VARCHAR(64) NOT NULL, -- 实际执行的模型(降级后可能不同)
model_match BOOLEAN NOT NULL DEFAULT TRUE, -- 请求 == 实际(CHECK 强制与两个模型 ID 一致)
in_tokens INTEGER NOT NULL DEFAULT 0, -- 输入 token
cached_tokens INTEGER NOT NULL DEFAULT 0, -- 命中缓存 token
out_tokens INTEGER NOT NULL DEFAULT 0, -- 输出 token
cost_usd NUMERIC(14,8) NOT NULL DEFAULT 0, -- 单次美元成本(本地估算,非网关权威——网关权威在 New-API)
stop_reason VARCHAR(32), -- 结束原因
duration_ms INTEGER, -- 耗时(毫秒)
prompt_sha256 CHAR(64), -- 提示词哈希(裸 64 hex;提示词全文落 example_raw_content)
raw_content_id BIGINT, -- → example_raw_content.id(完整 prompt/response,受访问控制)
call_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
creator VARCHAR(64) NOT NULL DEFAULT '',
CONSTRAINT chk_example_llm_call_prompt_sha256
CHECK (prompt_sha256 IS NULL OR prompt_sha256 ~ '^[0-9a-f]{64}$'),
CONSTRAINT chk_example_llm_call_model_match
CHECK (model_match = (requested_model_id = actual_model_id)),
CONSTRAINT chk_example_llm_call_non_negative
CHECK (in_tokens >= 0 AND cached_tokens >= 0 AND out_tokens >= 0
AND cost_usd >= 0 AND (duration_ms IS NULL OR duration_ms >= 0))
);
CREATE INDEX IF NOT EXISTS idx_example_llm_call_window ON example_llm_call(window_key);
CREATE INDEX IF NOT EXISTS idx_example_llm_call_caller ON example_llm_call(caller, call_time);
CREATE INDEX IF NOT EXISTS idx_example_llm_call_run ON example_llm_call(run_id) WHERE run_id IS NOT NULL;
-- 共用:append-only 表禁 TRUNCATE(行级 UPDATE/DELETE 触发器拦不住 TRUNCATE,需语句级触发器)。
-- 97/98 各自 OR REPLACE 同一函数(体相同,幂等),任一文件可独立 apply。
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_llm_call_mutation()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION 'example_llm_call 是 append-only 表,禁止 UPDATE/DELETE';
END;
$$;
CREATE OR REPLACE TRIGGER trg_example_llm_call_append_only
BEFORE UPDATE OR DELETE ON example_llm_call
FOR EACH ROW EXECUTE FUNCTION reject_example_llm_call_mutation();
CREATE OR REPLACE TRIGGER trg_example_llm_call_no_truncate
BEFORE TRUNCATE ON example_llm_call
FOR EACH STATEMENT EXECUTE FUNCTION example_reject_truncate();

View File

@ -0,0 +1,180 @@
-- 运行级账本三表:example_run(运行注册)+ example_run_receipt(运行身份回执)+ example_quality_result(质量评判)。
-- 设计拍板 2026-07-30(docs/2026-07-30-落库与看板设计.md §2.2,已吸收四路评审 + Codex 加固)。
-- 三表分工:run 记"这是哪一次运行、哪个作品、谁触发、终态"(可变,运行时更新终态);
-- receipt 记"这次运行的每一段身份与哈希链"(append-only,字段平移自 runtime ExecutionReceipt ⊕ CAS 身份);
-- quality_result 记"这次运行判了什么"(append-only,检测/评分/审核/实验,绑候选哈希、带量表版本)。
-- 公共列分界(落库设计 §1):run 是可变注册表,带公共列六件套 + 软删;
-- receipt/quality_result 是 append-only 账本,只带归属列(creator/create_time/tenant_id),
-- 不带 updater/update_time/deleted 三死列(禁改删时永不写入)。
-- 哈希列按落库设计 §1 归一规则存裸 64 位小写 hex(剥 sha256: 前缀)。
-- 注意:列名用 trigger_source 而非 trigger——trigger 是 SQL 保留字。
-- 全部对象用 IF NOT EXISTS / OR REPLACE,重复 apply 幂等。
-- ══ example_run:运行注册(一次运行一行,所有运行级子表挂它;回应"run_id 无主")══
CREATE TABLE IF NOT EXISTS example_run (
run_id VARCHAR(64) PRIMARY KEY, -- 运行标识(自然键,全局唯一,子表软引用它)
work_id BIGINT, -- → muse_content_work.id(软引用;回答"哪个作品",08 §3 硬要求)
target_chapter INTEGER, -- 目标章序(可空:非章级运行,如拆书/诊断)
trigger_source VARCHAR(32) NOT NULL DEFAULT 'user', -- user/replay_eval/diagnostic(触发来源)
trigger_detail JSONB, -- 触发上下文(用户意图摘要等)
started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
finished_at TIMESTAMP,
terminal_state VARCHAR(20) NOT NULL DEFAULT 'running', -- running/completed/failed
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_run_trigger CHECK (trigger_source IN ('user','replay_eval','diagnostic')),
CONSTRAINT chk_example_run_terminal CHECK (terminal_state IN ('running','completed','failed')),
CONSTRAINT chk_example_run_finish_order CHECK (finished_at IS NULL OR finished_at >= started_at),
-- 终态闭合:终态必须有结束时间;running 不得带结束时间(看板不显示互相矛盾的运行事实)
CONSTRAINT chk_example_run_terminal_needs_finish
CHECK (terminal_state NOT IN ('completed','failed') OR finished_at IS NOT NULL),
CONSTRAINT chk_example_run_running_no_finish
CHECK (terminal_state <> 'running' OR finished_at IS NULL)
);
CREATE INDEX IF NOT EXISTS idx_example_run_work ON example_run(tenant_id, work_id, started_at DESC);
CREATE OR REPLACE TRIGGER trg_example_run_updated_at BEFORE UPDATE ON example_run FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- 终态不得回退到 running(completed/failed 之间可修正,但不能退回进行中)
CREATE OR REPLACE FUNCTION reject_example_run_terminal_rollback()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF OLD.terminal_state IN ('completed','failed') AND NEW.terminal_state NOT IN ('completed','failed') THEN
RAISE EXCEPTION '运行已到终态 %,不得回退到 %', OLD.terminal_state, NEW.terminal_state;
END IF;
RETURN NEW;
END;
$$;
CREATE OR REPLACE TRIGGER trg_example_run_no_terminal_rollback
BEFORE UPDATE ON example_run
FOR EACH ROW EXECUTE FUNCTION reject_example_run_terminal_rollback();
-- ══ example_run_receipt:运行身份回执(append-only;05 §3 绑定键 run_id+attempt+candidate_version+candidate_sha256)══
-- 身份沿用 CAS:revision 是"每个样本内"的单调号(不是按整次运行),故唯一键必须含 sample_id。
-- candidate_version/candidate_sha256 是数据列兼到候选表的连接键,不是回执自身身份;回执↔候选是四列 1:N。
-- revision/attempt/loop_seq 单调性由 CAS 写路径保证;DB 侧只做非负下界约束(完整链校验暂缓,见落库设计 §2.2)。
CREATE TABLE IF NOT EXISTS example_run_receipt (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id VARCHAR(64) NOT NULL, -- → example_run.run_id(软引用)
sample_id VARCHAR(64) NOT NULL, -- CAS 样本标识(生产侧也造运行内标识,不得空)
revision INTEGER NOT NULL, -- CAS 样本内单调号(>= 1)
arm VARCHAR(32), -- 实验臂
attempt INTEGER NOT NULL DEFAULT 1, -- 失败重试次数(与补证/重写的 loop_seq 分工)
adapter_role VARCHAR(32), -- writer/planner/detector/judge…
stage_kind VARCHAR(20) NOT NULL DEFAULT 'generation', -- generation/detection/supplement/rewrite/accept
loop_seq INTEGER NOT NULL DEFAULT 0, -- 补证/重写循环序号(05 §2 的 ≤3/≤2 合同靠它计数)
candidate_version VARCHAR(32), -- 候选版本(候选连接键)
candidate_sha256 CHAR(64), -- 正文哈希(裸 64 hex;候选连接键,非回执身份)
context_sha256 CHAR(64), -- → example_context_freeze.context_sha256(冻结上下文,注意不是 manifest_sha256)
previous_state_sha256 CHAR(64), -- CAS 哈希链上一环
requested_model_id VARCHAR(64),
actual_model_id VARCHAR(64),
model_match BOOLEAN,
effort VARCHAR(16),
total_cost_usd NUMERIC(14,8),
usage JSONB, -- token 用量
stop_reason VARCHAR(32),
terminal_reason VARCHAR(32),
is_error BOOLEAN NOT NULL DEFAULT FALSE,
safe_summary JSONB, -- 检测/质量结论 + 失败类别的安全摘要(完整 prompt/response 落 raw)
result_sha256 CHAR(64),
raw_content_id BIGINT, -- → example_raw_content.id
creator VARCHAR(64) NOT NULL DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
tenant_id BIGINT NOT NULL DEFAULT 0,
CONSTRAINT uk_example_run_receipt UNIQUE (tenant_id, run_id, sample_id, revision),
CONSTRAINT chk_example_run_receipt_counters
CHECK (revision >= 1 AND attempt >= 1 AND loop_seq >= 0),
CONSTRAINT chk_example_run_receipt_stage
CHECK (stage_kind IN ('generation','detection','supplement','rewrite','accept')),
CONSTRAINT chk_example_run_receipt_candidate_sha256
CHECK (candidate_sha256 IS NULL OR candidate_sha256 ~ '^[0-9a-f]{64}$'),
CONSTRAINT chk_example_run_receipt_context_sha256
CHECK (context_sha256 IS NULL OR context_sha256 ~ '^[0-9a-f]{64}$'),
CONSTRAINT chk_example_run_receipt_prev_sha256
CHECK (previous_state_sha256 IS NULL OR previous_state_sha256 ~ '^[0-9a-f]{64}$'),
CONSTRAINT chk_example_run_receipt_result_sha256
CHECK (result_sha256 IS NULL OR result_sha256 ~ '^[0-9a-f]{64}$')
);
CREATE INDEX IF NOT EXISTS idx_example_run_receipt_run ON example_run_receipt(tenant_id, run_id);
CREATE INDEX IF NOT EXISTS idx_example_run_receipt_candidate ON example_run_receipt(tenant_id, candidate_sha256) WHERE candidate_sha256 IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_example_run_receipt_context ON example_run_receipt(context_sha256) WHERE context_sha256 IS NOT NULL;
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_run_receipt_mutation()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION 'example_run_receipt 是 append-only 表,禁止 UPDATE/DELETE';
END;
$$;
CREATE OR REPLACE TRIGGER trg_example_run_receipt_append_only
BEFORE UPDATE OR DELETE ON example_run_receipt
FOR EACH ROW EXECUTE FUNCTION reject_example_run_receipt_mutation();
CREATE OR REPLACE TRIGGER trg_example_run_receipt_no_truncate
BEFORE TRUNCATE ON example_run_receipt
FOR EACH STATEMENT EXECUTE FUNCTION example_reject_truncate();
-- ══ example_quality_result:质量评判账本(append-only;有评判就记录、就可见,06 §3/§5)══
-- 本期:每次检测/评分/审核/实验落一条,看板运行详情逐条可见;跨运行聚合视图二期(06 §9)。
CREATE TABLE IF NOT EXISTS example_quality_result (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id VARCHAR(64) NOT NULL, -- → example_run.run_id(评判必属一次运行)
receipt_id BIGINT, -- → example_run_receipt.id(软引用)
candidate_sha256 CHAR(64), -- 绑候选(06 §5;书级评判可空)
judge_kind VARCHAR(20) NOT NULL, -- detection/scoring/review/experiment
dimension VARCHAR(32), -- 评分维度(评分类用,检测类空)
scale_version VARCHAR(32), -- 量表/质量政策版本(06 §3 量表版本化)
score NUMERIC, -- 分值(非评分类空)
conclusion VARCHAR(20), -- pass/fail/revise…
failure_class VARCHAR(32), -- 失败类别
detail JSONB, -- 审核的问题/修复/复验;实验的基线/假设/干预/预期/结论
raw_content_id BIGINT, -- → example_raw_content.id
creator VARCHAR(64) NOT NULL DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
tenant_id BIGINT NOT NULL DEFAULT 0,
CONSTRAINT chk_example_quality_result_kind
CHECK (judge_kind IN ('detection','scoring','review','experiment')),
CONSTRAINT chk_example_quality_result_candidate_sha256
CHECK (candidate_sha256 IS NULL OR candidate_sha256 ~ '^[0-9a-f]{64}$')
);
-- 幂等唯一键用表达式索引(dimension/candidate_sha256 可空,UNIQUE 约束不能含表达式,故用唯一索引 + COALESCE)
CREATE UNIQUE INDEX IF NOT EXISTS uk_example_quality_result
ON example_quality_result(tenant_id, run_id, judge_kind, COALESCE(dimension, ''), COALESCE(candidate_sha256, ''));
CREATE INDEX IF NOT EXISTS idx_example_quality_result_candidate ON example_quality_result(candidate_sha256) WHERE candidate_sha256 IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_example_quality_result_run ON example_quality_result(run_id);
CREATE INDEX IF NOT EXISTS idx_example_quality_result_kind ON example_quality_result(judge_kind);
CREATE OR REPLACE FUNCTION reject_example_quality_result_mutation()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION 'example_quality_result 是 append-only 表,禁止 UPDATE/DELETE';
END;
$$;
CREATE OR REPLACE TRIGGER trg_example_quality_result_append_only
BEFORE UPDATE OR DELETE ON example_quality_result
FOR EACH ROW EXECUTE FUNCTION reject_example_quality_result_mutation();
CREATE OR REPLACE TRIGGER trg_example_quality_result_no_truncate
BEFORE TRUNCATE ON example_quality_result
FOR EACH STATEMENT EXECUTE FUNCTION example_reject_truncate();

View File

@ -2,8 +2,8 @@
> 口径(创始人拍板③ 2026-07-10):主仓表**原样不改列**;实验私货全进 `example_*` 前缀。
> 建表方式:`db/ddl/` 下文件经 db skill `apply`,主仓部分为 `muse-cloud/sql/muse/` 原文拷贝或逐字摘录。
> 已应用顺序:V1 → V3 → V5 → 90-ALTER摘录 → V26 → 91-example。已于 2026-07-13 应用,共 **24 张表**。
> 待应用:`96-example参考作品授权快照.sql` 已实现但尚未通过 db skill `apply`,库内尚无该表和真实授权记录。
> 已应用顺序:V1 → V3 → V5 → 90-ALTER摘录 → V26 → 91-example(2026-07-13)→ 97/98(2026-07-30)。库内表现状以 `db skill tables` 实时输出为准(当前 37 张;本文档清单可能滞后,94 作品面升格 5 表与 95 额度账本未在下文逐一登记)。
> 96 不启用:`96-example参考作品授权快照.sql` 已实现但**决定不 apply**(单用户本地不做多租户授权机制,2026-07-30 拍板,见领域索引 §9);库内无该表。
## 主仓一致表(20 张)
@ -30,7 +30,7 @@
| muse_knowledge_draft | V5 | V14(两快照列→varchar(128)) | **草稿**知识行(B2 拆书产出落此) |
| muse_knowledge_binding | V5 | V14(同上) | 作品↔库绑定(C3 起用) |
## 实验私货表(6 张,`db/ddl/91-example实验私货.sql` + 92 + 93)
## 实验私货表(`db/ddl/91` + 92 + 93 + 97 + 98;94/95 见库内现状)
| 表 | 用途 |
|---|---|
@ -40,6 +40,10 @@
| example_knowledge_embedding | 知识行嵌入边表:vector(1024)+HNSW(余弦),content_hash 幂等,挂 draft 或 entity |
| example_clean_log | 清洗审计(92):每次删除一行(原文/理由/模型/批次),源 txt 不动可回放 |
| example_parse_outline | 窗级大纲聚合(93):5–10万字窗(细纲+正文)→阶段大纲;终检=细纲对账+跨段纵览 |
| example_llm_call | 逐次模型调用明细(97):模型/token/成本/提示词哈希/raw 指针;运行态设施、append-only,不带业务公共列 |
| example_run | 运行注册(98):一次运行一行(run_id 自然键 + 作品/触发/终态),运行级子表挂它 |
| example_run_receipt | 运行身份回执(98):字段平移 runtime ExecutionReceipt ⊕ CAS 身份;append-only;唯一键 (run_id, sample_id, revision) |
| example_quality_result | 质量评判账本(98):检测/评分/审核/实验,绑候选哈希 + 量表版本;append-only;有评判就记录就可见 |
## 暂缓建表登记(主仓有、实验现阶段未建;需要时按原样加建)
@ -63,4 +67,4 @@
| DDL | 表 | 用途 | 当前状态 |
|---|---|---|---|
| 96 | example_reference_authorization_snapshot | 参考作品原文件版本对应的不可变用途授权快照;同作品快照版本唯一,禁止 UPDATE/DELETE | DDL 已实现待 apply;真实记录未写 |
| 96 | example_reference_authorization_snapshot | 参考作品原文件版本对应的不可变用途授权快照;同作品快照版本唯一,禁止 UPDATE/DELETE | **不启用**(单用户本地不做多租户授权,2026-07-30 拍板,见领域索引 §9);DDL 留存不 apply |