diff --git a/.claude/skills/db/SKILL.md b/.claude/skills/db/SKILL.md index 49d27f9..fb00abd 100644 --- a/.claude/skills/db/SKILL.md +++ b/.claude/skills/db/SKILL.md @@ -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 --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 = ["", "<完整全文>"] + +# 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 注入。 ## 元数据驱动 diff --git a/.claude/skills/db/scripts/db.py b/.claude/skills/db/scripts/db.py index 8d31cf0..27f5824 100644 --- a/.claude/skills/db/scripts/db.py +++ b/.claude/skills/db/scripts/db.py @@ -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}") diff --git a/.claude/skills/db/scripts/test_db_params.py b/.claude/skills/db/scripts/test_db_params.py new file mode 100644 index 0000000..c077a96 --- /dev/null +++ b/.claude/skills/db/scripts/test_db_params.py @@ -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() diff --git a/db/ddl/97-example模型调用明细.sql b/db/ddl/97-example模型调用明细.sql new file mode 100644 index 0000000..3e54b23 --- /dev/null +++ b/db/ddl/97-example模型调用明细.sql @@ -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(); diff --git a/db/ddl/98-example运行与回执与质量结果.sql b/db/ddl/98-example运行与回执与质量结果.sql new file mode 100644 index 0000000..7f6a687 --- /dev/null +++ b/db/ddl/98-example运行与回执与质量结果.sql @@ -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(); diff --git a/db/表映射.md b/db/表映射.md index 87b5ee4..f74b0bc 100644 --- a/db/表映射.md +++ b/db/表映射.md @@ -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 |