diff --git a/.claude/skills/confirm/SKILL.md b/.claude/skills/confirm/SKILL.md index 7939ae0..cc1678b 100644 --- a/.claude/skills/confirm/SKILL.md +++ b/.claude/skills/confirm/SKILL.md @@ -40,7 +40,7 @@ description: 确认=把创作产出从待审(Shadow)转为正式事实(Canonical ## 知识卡 / 规划:各自的确认轨 -- **知识卡**:确认 = `muse_knowledge_draft` 翻 `confirmed` + 落 `muse_knowledge_entity(active)`(走 db skill,同一条确认链);**采纳正文 ≠ 确认知识**,抽取产出的卡变更要单独确认;有冲突的卡先裁决再确认。 +- **知识卡**:确认 = `muse_knowledge_draft` 翻 `confirmed` + 落 `muse_knowledge_entity(active)`(关系卡落 `muse_knowledge_relation`),并在同一事务内确保作品↔知识库绑定、迁移实体向量 owner。使用 `.venv/bin/python .claude/skills/confirm/scripts/confirm_knowledge.py --draft-id --dry-run` 试跑;实际确认只能在用户明确确认后执行。批量实体/关系必须显式给 `--all-entities ` 或 `--all-relations `。**采纳正文 ≠ 确认知识**,抽取产出的卡变更要单独确认;有冲突的卡先裁决再确认。 - **规划**(大纲/细纲/设定):规划表(100)落库前,暂以 git 提交确认——只 `git add` 用户点名的创作文件,**严禁混入框架文件(agents/skills/meta)**;提交信息 `作品(书名): 确认 设定包/大纲vN | 来源: planner`。规划表建成后改为库内 shadow→confirmed。 ## 红线 diff --git a/.claude/skills/confirm/scripts/confirm_knowledge.py b/.claude/skills/confirm/scripts/confirm_knowledge.py new file mode 100644 index 0000000..ede788d --- /dev/null +++ b/.claude/skills/confirm/scripts/confirm_knowledge.py @@ -0,0 +1,457 @@ +#!/usr/bin/env python3 +"""知识草稿确认:把待审实体/关系写入作品正式知识面。 + +正文候选和知识卡共用 confirm skill 的主权边界,但知识表不是正文表: +实体写入 ``muse_knowledge_entity``,关系写入 ``muse_knowledge_relation``, +草稿状态、知识库绑定和向量 owner 在同一事务内完成。默认命令只确认点名 +草稿;批量确认必须显式给出 ``--all-entities`` 或 ``--all-relations``。 +""" +import argparse +import json +import pathlib +import sys +from copy import deepcopy + + +DB_SCRIPTS = pathlib.Path(__file__).resolve().parents[2] / "db" / "scripts" +sys.path.insert(0, str(DB_SCRIPTS)) +from db import connect # noqa: E402 + + +TENANT = 1 +ACTOR = "1" +DEFAULT_KB_OWNER = 1 +ENTITY_SOURCE_TYPES = frozenset({"chapter_extract", "parse_book", "upgrade_book"}) + + +class KnowledgeConfirmationError(RuntimeError): + """知识草稿不满足确认合同时失败关闭。""" + + +def _string(value, field, *, required=True): + if not isinstance(value, str) or (required and not value.strip()): + if required: + raise KnowledgeConfirmationError(f"{field} 必须是非空字符串") + return "" + return value.strip() + + +def _payload_value(payload, *keys, default=None): + for key in keys: + if key in payload: + return payload[key] + return default + + +def normalize_entity_payload(payload): + """兼容作品卡英文键和拆书卡中文键,产出正式实体字段投影。""" + + if not isinstance(payload, dict): + raise KnowledgeConfirmationError("实体 draft_payload 必须是 JSON 对象") + entity_type = _string(_payload_value(payload, "type", "型"), "entity.type") + name = _string(_payload_value(payload, "name", "名称"), "entity.name") + brief = _string(_payload_value(payload, "brief", "一句话摘要", default="?"), "entity.brief") + fields = _payload_value(payload, "fields", "字段", default={}) + if not isinstance(fields, dict): + raise KnowledgeConfirmationError("entity.fields 必须是对象") + source = payload.get("source") or payload.get("来源") or {} + if not isinstance(source, dict): + source = {} + evidence = _payload_value(payload, "evidence", "证据", default="") + if evidence is not None and not isinstance(evidence, str): + raise KnowledgeConfirmationError("entity.evidence 必须是字符串") + source_ref = { + "workId": source.get("workId"), + "chapter": source.get("chapter"), + "chapterId": source.get("chapterId"), + "evidence": evidence or None, + } + source_ref = {key: value for key, value in source_ref.items() if value is not None} + return { + "entity_type": entity_type, + "name": name, + "normalized_name": name.casefold(), + "description": brief, + "attributes": deepcopy(fields), + "source_ref": source_ref, + "payload": payload, + } + + +def normalize_relation_payload(payload): + """兼容关系草稿的 source/target 对象和字符串形状。""" + + if not isinstance(payload, dict): + raise KnowledgeConfirmationError("关系 draft_payload 必须是 JSON 对象") + + def endpoint(value, field): + if isinstance(value, dict): + value = _payload_value(value, "name", "名称") + return _string(value, field) + + source = endpoint(_payload_value(payload, "source", "甲方"), "relation.source") + target = endpoint(_payload_value(payload, "target", "乙方"), "relation.target") + relation_type = _string(_payload_value(payload, "type", "关系类型"), "relation.type") + description = _string( + _payload_value(payload, "description", "描述", default="?"), + "relation.description", + ) + attributes = _payload_value(payload, "attributes", "字段", default={}) + if not isinstance(attributes, dict): + attributes = {"sourcePayload": attributes} + return { + "source_name": source, + "target_name": target, + "relation_type": relation_type, + "description": description, + "attributes": deepcopy(attributes), + "payload": payload, + } + + +def _json(value): + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + + +def _load_kb(conn, kb_id=None): + if kb_id is not None: + row = conn.execute( + "SELECT id,status,deleted FROM muse_knowledge_base " + "WHERE id=%s AND tenant_id=%s FOR SHARE", + (kb_id, TENANT), + ).fetchone() + if not row or row[1] != "active" or row[2]: + raise KnowledgeConfirmationError(f"知识库不可用:kb_id={kb_id}") + return row[0] + row = conn.execute( + "SELECT id FROM muse_knowledge_base " + "WHERE tenant_id=%s AND owner_user_id=%s AND kb_type='user' " + "AND status='active' AND deleted=false ORDER BY id LIMIT 1 FOR SHARE", + (TENANT, DEFAULT_KB_OWNER), + ).fetchone() + if not row: + raise KnowledgeConfirmationError("没有可用的用户私有知识库") + return row[0] + + +def _ensure_binding(conn, work_id, kb_id, *, dry_run=False): + row = conn.execute( + "SELECT id,binding_status FROM muse_knowledge_binding " + "WHERE tenant_id=%s AND work_id=%s AND kb_id=%s AND deleted=false FOR UPDATE", + (TENANT, work_id, kb_id), + ).fetchone() + if row: + if row[1] != "active": + conn.execute( + "UPDATE muse_knowledge_binding SET binding_status='active', updater=%s, " + "revision=revision+1 WHERE id=%s", + (ACTOR, row[0]), + ) + return row[0] + row = conn.execute( + "INSERT INTO muse_knowledge_binding(work_id,kb_id,binding_type,binding_scope," + "binding_status,source_version,target_version,creator,updater,tenant_id) " + "VALUES (%s,%s,'read','work','active',1,1,%s,%s,%s) RETURNING id", + (work_id, kb_id, ACTOR, ACTOR, TENANT), + ).fetchone() + return row[0] + + +def _lineage(current, source_ref, draft_id, source_type): + lineage = deepcopy(current) if isinstance(current, dict) else {} + refs = lineage.get("sourceRefs") + if not isinstance(refs, list): + refs = [] + if source_ref and source_ref not in refs: + refs.append(source_ref) + lineage["sourceRefs"] = refs + lineage["lastDraftId"] = draft_id + lineage["lastSourceType"] = source_type + return lineage + + +def _find_or_insert_entity(conn, *, work_id, draft_id, draft, kb_id, decided_by): + card = normalize_entity_payload(draft["draft_payload"]) + row = conn.execute( + "SELECT id,kb_id,description,attributes,lineage_payload,status,deleted,revision " + "FROM muse_knowledge_entity WHERE tenant_id=%s AND work_id=%s AND entity_type=%s " + "AND normalized_name=%s AND scope='local' FOR UPDATE", + (TENANT, work_id, card["entity_type"], card["normalized_name"]), + ).fetchone() + if row and row[6]: + raise KnowledgeConfirmationError( + f"实体存在但已删除,禁止自动复活:work={work_id} name={card['name']}" + ) + lineage = _lineage(row[4] if row else None, card["source_ref"], draft_id, draft["source_type"]) + if row: + if row[5] != "active": + raise KnowledgeConfirmationError( + f"实体状态不可确认:entity={row[0]} status={row[5]}" + ) + old_attributes = row[3] if isinstance(row[3], dict) else {} + attributes = {**old_attributes, **card["attributes"]} + entity_id = row[0] + conn.execute( + "UPDATE muse_knowledge_entity SET kb_id=%s,description=%s,attributes=%s::jsonb," + "lineage_payload=%s::jsonb,updater=%s,revision=revision+1 " + "WHERE id=%s AND tenant_id=%s AND deleted=false", + (kb_id, card["description"], _json(attributes), _json(lineage), decided_by, + entity_id, TENANT), + ) + return entity_id + row = conn.execute( + "INSERT INTO muse_knowledge_entity(work_id,kb_id,entity_type,normalized_name,scope," + "description,attributes,source_status,source_action_policy,lineage_payload,status," + "confidence,source_type,source_id,creator,updater,tenant_id) " + "VALUES (%s,%s,%s,%s,'local',%s,%s::jsonb,'active','allowed',%s::jsonb,'active'," + "%s,%s,%s,%s,%s,%s) RETURNING id", + (work_id, kb_id, card["entity_type"], card["normalized_name"], card["description"], + _json(card["attributes"]), _json(lineage), draft["confidence"] or 0.8, + draft["source_type"], draft["source_id"], decided_by, decided_by, TENANT), + ).fetchone() + return row[0] + + +def _move_entity_embeddings(conn, draft_id, entity_id, *, updater): + rows = conn.execute( + "SELECT id,content_hash,model,entity_id FROM example_knowledge_embedding " + "WHERE tenant_id=%s AND draft_id=%s AND deleted=false FOR UPDATE", + (TENANT, draft_id), + ).fetchall() + if len(rows) > 1: + raise KnowledgeConfirmationError(f"draft={draft_id} 存在多条活向量,禁止确认") + for embedding_id, content_hash, model, current_entity_id in rows: + if current_entity_id is not None and current_entity_id != entity_id: + raise KnowledgeConfirmationError( + f"向量已归属其他实体:embedding={embedding_id} entity={current_entity_id}" + ) + conflict = conn.execute( + "SELECT id,entity_id,draft_id FROM example_knowledge_embedding " + "WHERE tenant_id=%s AND content_hash=%s AND model=%s AND deleted=false " + "AND id<>%s FOR UPDATE", + (TENANT, content_hash, model, embedding_id), + ).fetchone() + if conflict and (conflict[1] is not None or conflict[2] != draft_id): + raise KnowledgeConfirmationError( + f"向量唯一 owner 冲突:embedding={embedding_id} other={conflict}" + ) + conn.execute( + "UPDATE example_knowledge_embedding SET draft_id=NULL,entity_id=%s,updater=%s " + "WHERE id=%s AND tenant_id=%s AND deleted=false", + (entity_id, updater, embedding_id, TENANT), + ) + return len(rows) + + +def _confirm_entity_locked(conn, row, *, kb_id, decided_by): + (draft_id, work_id, draft_type, payload, status, source_type, source_id, confidence, + source_status, source_action_policy, revision, entity_id, _target_object_id) = row + if draft_type != "entity": + raise KnowledgeConfirmationError(f"draft={draft_id} 不是 entity 草稿") + if status != "pending": + if status == "confirmed": + return {"draft_id": draft_id, "status": "already_confirmed", "entity_id": entity_id} + raise KnowledgeConfirmationError(f"draft={draft_id} 状态不可确认:{status}") + if source_type not in ENTITY_SOURCE_TYPES: + raise KnowledgeConfirmationError(f"draft={draft_id} 来源类型不允许:{source_type}") + if source_status not in ("active", "authorized") or source_action_policy != "allowed": + raise KnowledgeConfirmationError( + f"draft={draft_id} 来源资格不允许:status={source_status} policy={source_action_policy}" + ) + draft = { + "draft_payload": payload, + "source_type": source_type, + "source_id": source_id, + "confidence": confidence, + } + entity_id = _find_or_insert_entity( + conn, work_id=work_id, draft_id=draft_id, draft=draft, kb_id=kb_id, + decided_by=decided_by, + ) + vectors = _move_entity_embeddings(conn, draft_id, entity_id, updater=decided_by) + updated = conn.execute( + "UPDATE muse_knowledge_draft SET status='confirmed',entity_id=%s,revision=revision+1," + "updater=%s WHERE id=%s AND tenant_id=%s AND deleted=false AND status='pending' " + "AND revision=%s RETURNING revision", + (entity_id, decided_by, draft_id, TENANT, revision), + ).fetchone() + if not updated: + raise KnowledgeConfirmationError(f"draft={draft_id} 确认时 revision 冲突") + return {"draft_id": draft_id, "status": "confirmed", "entity_id": entity_id, + "embedding_count": vectors, "revision": updated[0]} + + +def _find_entity(conn, work_id, name): + return conn.execute( + "SELECT id FROM muse_knowledge_entity WHERE tenant_id=%s AND work_id=%s " + "AND normalized_name=%s AND status='active' AND deleted=false FOR SHARE", + (TENANT, work_id, name.casefold()), + ).fetchone() + + +def _confirm_relation_locked(conn, row, *, kb_id, decided_by): + (draft_id, work_id, draft_type, payload, status, source_type, source_id, confidence, + source_status, source_action_policy, revision, entity_id, target_object_id) = row + if draft_type != "relation": + raise KnowledgeConfirmationError(f"draft={draft_id} 不是 relation 草稿") + if status != "pending": + if status == "confirmed": + return {"draft_id": draft_id, "status": "already_confirmed", + "relation_id": target_object_id} + raise KnowledgeConfirmationError(f"draft={draft_id} 状态不可确认:{status}") + if source_type not in ENTITY_SOURCE_TYPES: + raise KnowledgeConfirmationError(f"draft={draft_id} 来源类型不允许:{source_type}") + if source_status not in ("active", "authorized") or source_action_policy != "allowed": + raise KnowledgeConfirmationError( + f"draft={draft_id} 来源资格不允许:status={source_status} policy={source_action_policy}" + ) + relation = normalize_relation_payload(payload) + source_row = _find_entity(conn, work_id, relation["source_name"]) + target_row = _find_entity(conn, work_id, relation["target_name"]) + if not source_row or not target_row: + missing = [] + if not source_row: + missing.append(relation["source_name"]) + if not target_row: + missing.append(relation["target_name"]) + raise KnowledgeConfirmationError( + f"关系两端尚未确认:draft={draft_id} missing={','.join(missing)}" + ) + command_id = f"confirm-knowledge-relation-{draft_id}" + row = conn.execute( + "INSERT INTO muse_knowledge_relation(work_id,source_entity_id,target_entity_id," + "relation_type,description,attributes,source_status,source_action_policy,command_id," + "creator,updater,tenant_id) VALUES (%s,%s,%s,%s,%s,%s::jsonb,'active','allowed',%s,%s,%s,%s) " + "ON CONFLICT (tenant_id,command_id) WHERE command_id IS NOT NULL DO NOTHING RETURNING id", + (work_id, source_row[0], target_row[0], relation["relation_type"], relation["description"], + _json(relation["attributes"]), command_id, decided_by, decided_by, TENANT), + ).fetchone() + relation_id = row[0] if row else conn.execute( + "SELECT id FROM muse_knowledge_relation WHERE tenant_id=%s AND command_id=%s", + (TENANT, command_id), + ).fetchone()[0] + conn.execute( + "UPDATE example_knowledge_embedding SET deleted=true,updater=%s " + "WHERE tenant_id=%s AND draft_id=%s AND entity_id IS NULL AND deleted=false", + (decided_by, TENANT, draft_id), + ) + updated = conn.execute( + "UPDATE muse_knowledge_draft SET status='confirmed',target_object_id=%s," + "revision=revision+1,updater=%s WHERE id=%s AND tenant_id=%s AND deleted=false " + "AND status='pending' AND revision=%s RETURNING revision", + (relation_id, decided_by, draft_id, TENANT, revision), + ).fetchone() + if not updated: + raise KnowledgeConfirmationError(f"draft={draft_id} 确认时 revision 冲突") + return {"draft_id": draft_id, "status": "confirmed", "relation_id": relation_id, + "revision": updated[0]} + + +def _load_draft(conn, draft_id): + row = conn.execute( + "SELECT id,work_id,draft_type,draft_payload,status,source_type,source_id,confidence," + "source_status,source_action_policy,revision,entity_id,target_object_id " + "FROM muse_knowledge_draft WHERE id=%s AND tenant_id=%s AND deleted=false FOR UPDATE", + (draft_id, TENANT), + ).fetchone() + if not row: + raise KnowledgeConfirmationError(f"draft 不存在或已删除:{draft_id}") + return row + + +def confirm_draft(draft_id, *, kb_id=None, decided_by=ACTOR, dry_run=False): + """确认一张实体或关系草稿;dry_run 走完整事务后回滚。""" + + with connect() as conn: + try: + row = _load_draft(conn, draft_id) + selected_kb = _load_kb(conn, kb_id) + _ensure_binding(conn, row[1], selected_kb) + if row[2] == "entity": + result = _confirm_entity_locked(conn, row, kb_id=selected_kb, decided_by=decided_by) + elif row[2] == "relation": + result = _confirm_relation_locked(conn, row, kb_id=selected_kb, decided_by=decided_by) + else: + raise KnowledgeConfirmationError(f"draft={draft_id} 类型不支持:{row[2]}") + result["kb_id"] = selected_kb + if dry_run: + conn.rollback() + result["status"] = "dry_run_ok" + result["note"] = "试跑已回滚,未落库" + return result + conn.commit() + return result + except Exception: + conn.rollback() + raise + + +def confirm_work(work_id, *, draft_type, kb_id=None, decided_by=ACTOR, dry_run=False): + """按类型显式确认作品下全部 pending 草稿;任一张失败则整批回滚。""" + + if draft_type not in {"entity", "relation"}: + raise ValueError("draft_type 只能是 entity 或 relation") + with connect() as conn: + try: + selected_kb = _load_kb(conn, kb_id) + _ensure_binding(conn, work_id, selected_kb) + rows = conn.execute( + "SELECT id,work_id,draft_type,draft_payload,status,source_type,source_id,confidence," + "source_status,source_action_policy,revision,entity_id,target_object_id " + "FROM muse_knowledge_draft WHERE tenant_id=%s AND work_id=%s AND draft_type=%s " + "AND status='pending' AND deleted=false ORDER BY id FOR UPDATE", + (TENANT, work_id, draft_type), + ).fetchall() + results = [] + for row in rows: + if draft_type == "entity": + result = _confirm_entity_locked( + conn, row, kb_id=selected_kb, decided_by=decided_by + ) + else: + result = _confirm_relation_locked( + conn, row, kb_id=selected_kb, decided_by=decided_by + ) + results.append(result) + if dry_run: + conn.rollback() + return {"status": "dry_run_ok", "work_id": work_id, "draft_type": draft_type, + "count": len(results), "results": results, "note": "试跑已回滚,未落库"} + conn.commit() + return {"status": "confirmed", "work_id": work_id, "draft_type": draft_type, + "count": len(results), "results": results} + except Exception: + conn.rollback() + raise + + +def main(): + parser = argparse.ArgumentParser(description="知识草稿确认(Shadow -> Canonical)") + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--draft-id", type=int, help="点名确认一张草稿") + group.add_argument("--all-entities", type=int, metavar="WORK_ID", help="显式确认作品下全部实体草稿") + group.add_argument("--all-relations", type=int, metavar="WORK_ID", help="显式确认作品下全部关系草稿") + parser.add_argument("--kb-id", type=int, default=None) + parser.add_argument("--decided-by", default=ACTOR) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + try: + if args.draft_id is not None: + result = confirm_draft( + args.draft_id, kb_id=args.kb_id, decided_by=args.decided_by, dry_run=args.dry_run + ) + else: + draft_type = "entity" if args.all_entities is not None else "relation" + work_id = args.all_entities if args.all_entities is not None else args.all_relations + result = confirm_work( + work_id, draft_type=draft_type, kb_id=args.kb_id, + decided_by=args.decided_by, dry_run=args.dry_run, + ) + except (KnowledgeConfirmationError, ValueError) as error: + print(f"[拒绝] {error}", file=sys.stderr) + raise SystemExit(1) from error + print(json.dumps(result, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/confirm/scripts/reconcile_work_metrics.py b/.claude/skills/confirm/scripts/reconcile_work_metrics.py new file mode 100644 index 0000000..1c651f3 --- /dev/null +++ b/.claude/skills/confirm/scripts/reconcile_work_metrics.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""按 Canonical 章节/正文重算作品聚合指标。 + +用于修复历史接受通道未回写的 ``muse_content_work.chapter_count`` 与 +``word_count``;正式接受路径已在 ``write_canonical.accept`` 内同步维护。 +""" +import argparse +import json +import pathlib +import sys + + +DB_SCRIPTS = pathlib.Path(__file__).resolve().parents[2] / "db" / "scripts" +sys.path.insert(0, str(DB_SCRIPTS)) +from db import connect # noqa: E402 + + +TENANT = 1 +ACTOR = "metrics-reconcile" + + +def reconcile_work(work_id, *, updater=ACTOR, dry_run=False): + with connect() as conn: + try: + row = conn.execute( + """SELECT COUNT(DISTINCT c.id)::bigint, + COALESCE(SUM(b.word_count),0)::bigint + FROM muse_content_chapter c + LEFT JOIN muse_content_block b + ON b.chapter_id=c.id AND b.deleted=false + WHERE c.work_id=%s AND c.deleted=false""", + (work_id,), + ).fetchone() + updated = conn.execute( + "UPDATE muse_content_work SET chapter_count=%s,word_count=%s," + "revision=revision+1,updater=%s WHERE id=%s AND deleted=false " + "RETURNING chapter_count,word_count,revision", + (row[0], row[1], updater, work_id), + ).fetchone() + if not updated: + raise ValueError(f"作品不存在或已删除:work_id={work_id}") + result = {"work_id": work_id, "chapter_count": int(updated[0]), + "word_count": int(updated[1]), "revision": updated[2]} + if dry_run: + conn.rollback() + result["status"] = "dry_run_ok" + result["note"] = "试跑已回滚,未落库" + return result + conn.commit() + result["status"] = "reconciled" + return result + except Exception: + conn.rollback() + raise + + +def main(): + parser = argparse.ArgumentParser(description="重算作品 Canonical 聚合指标") + parser.add_argument("--work-id", type=int, required=True) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + print(json.dumps(reconcile_work(args.work_id, dry_run=args.dry_run), ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/confirm/scripts/test_confirm_knowledge_offline.py b/.claude/skills/confirm/scripts/test_confirm_knowledge_offline.py new file mode 100644 index 0000000..28749ec --- /dev/null +++ b/.claude/skills/confirm/scripts/test_confirm_knowledge_offline.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""知识确认合同的离线测试,不连接 PostgreSQL。""" +import pathlib +import sys +import unittest + + +SCRIPT_DIR = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +import confirm_knowledge as confirm # noqa: E402 + + +class ConfirmKnowledgeContractTest(unittest.TestCase): + def test英文实体卡投影到正式实体字段(self): + card = confirm.normalize_entity_payload({ + "type": "character", "name": "林深", "brief": "机师", + "fields": {"身份": "驾驶员"}, + "source": {"workId": 12, "chapter": 1, "chapterId": 17473}, + "evidence": "林深坐在二号观测位。", + }) + self.assertEqual(card["entity_type"], "character") + self.assertEqual(card["normalized_name"], "林深") + self.assertEqual(card["attributes"], {"身份": "驾驶员"}) + self.assertEqual(card["source_ref"]["chapterId"], 17473) + + def test关系端点从对象名称解析(self): + relation = confirm.normalize_relation_payload({ + "type": "驾驶", "source": {"name": "林深"}, + "target": {"name": "茧"}, "description": "首次实战驾驶", + }) + self.assertEqual(relation["source_name"], "林深") + self.assertEqual(relation["target_name"], "茧") + self.assertEqual(relation["relation_type"], "驾驶") + + def test缺失关系端点失败关闭(self): + with self.assertRaisesRegex(confirm.KnowledgeConfirmationError, "relation.target"): + confirm.normalize_relation_payload({ + "type": "驾驶", "source": "林深", "description": "x", + }) + + def test来源回放链只追加去重结构化引用(self): + old = {"sourceRefs": [{"chapter": 1}], "lastDraftId": 10} + same = confirm._lineage(old, {"chapter": 1}, 11, "chapter_extract") + self.assertEqual(same["sourceRefs"], [{"chapter": 1}]) + newer = confirm._lineage(same, {"chapter": 2}, 12, "chapter_extract") + self.assertEqual(newer["sourceRefs"], [{"chapter": 1}, {"chapter": 2}]) + self.assertEqual(newer["lastDraftId"], 12) + + def test已确认关系点名重试返回幂等结果(self): + row = (7, 12, "relation", {}, "confirmed", "chapter_extract", 17473, 0.9, + "active", "allowed", 2, None, 91) + result = confirm._confirm_relation_locked(None, row, kb_id=3, decided_by="1") + self.assertEqual(result, { + "draft_id": 7, "status": "already_confirmed", "relation_id": 91, + }) + + +if __name__ == "__main__": + unittest.main() diff --git a/.claude/skills/confirm/scripts/write_canonical.py b/.claude/skills/confirm/scripts/write_canonical.py index 3a8836c..e95cb49 100644 --- a/.claude/skills/confirm/scripts/write_canonical.py +++ b/.claude/skills/confirm/scripts/write_canonical.py @@ -30,6 +30,25 @@ class ConflictError(Exception): """接受/丢弃被拒(状态不对、revision 冲突、候选不存在等)——失败关闭,不静默。""" +def _refresh_work_metrics(conn, work_id, updater): + """在正文接受同一事务内回写作品聚合指标,避免工作表停留在创建时的 0。""" + metrics = conn.execute( + """SELECT COUNT(DISTINCT c.id)::bigint, + COALESCE(SUM(b.word_count),0)::bigint + FROM muse_content_chapter c + LEFT JOIN muse_content_block b + ON b.chapter_id=c.id AND b.deleted=false + WHERE c.work_id=%s AND c.deleted=false""", + (work_id,), + ).fetchone() + conn.execute( + "UPDATE muse_content_work SET chapter_count=%s,word_count=%s,revision=revision+1,updater=%s " + "WHERE id=%s AND deleted=false", + (metrics[0], metrics[1], updater, work_id), + ) + return {"chapter_count": int(metrics[0]), "word_count": int(metrics[1])} + + def accept(candidate_id, decided_by="1", rationale=None, basis_ref=None, expected_revision=None, command_id=None, source_type="ai_candidate", dry_run=False): @@ -119,13 +138,15 @@ def accept(candidate_id, decided_by="1", rationale=None, basis_ref=None, (decided_by, cid)).rowcount if n != 1: raise ConflictError("候选状态被并发修改") + metrics = _refresh_work_metrics(conn, work_id, decided_by) if dry_run: conn.rollback() return {"status": "dry_run_ok", "block_id": block_id, "revision": new_rev, - "decision_id": dec_id, "word_count": word_count, "note": "试跑已回滚,未落库"} + "decision_id": dec_id, "word_count": word_count, "metrics": metrics, + "note": "试跑已回滚,未落库"} conn.commit() return {"status": "accepted", "block_id": block_id, "revision": new_rev, - "decision_id": dec_id, "word_count": word_count} + "decision_id": dec_id, "word_count": word_count, "metrics": metrics} except Exception: conn.rollback() raise diff --git a/.claude/skills/continuation/SKILL.md b/.claude/skills/continuation/SKILL.md index 03dd130..02573ad 100644 --- a/.claude/skills/continuation/SKILL.md +++ b/.claude/skills/continuation/SKILL.md @@ -21,7 +21,7 @@ disable-model-invocation: true - `narrativeState`:目标章前的叙事状态。 - `factConstraints`:由卡索引和原文证据归纳出的事实约束。 - `proseExcerpts`:用于连续性与叙事声音的历史正文摘录。 -- `patternReferences`:范式参考。 +- `patternReferences`:可参考的写作范式——每条给名字(name)、一句话摘要(summary)与写法要点(writingPoints);只供借鉴写法,不是事实约束。 - `lengthContract`:本章动态篇幅合同。 - `styleConstraints`:文风约束。 @@ -35,6 +35,11 @@ Writer 不接收 `runId`、权限信息、manifest、hash、候选版本、验 4. 缺少细纲字段、`factConstraints` 字段或篇幅合同属于 adapter 输入错误,必须在模型调用前失败。`factConstraints=[]` 在冻结检索确实没有可确认事实时是合法输入,不等于“事实已验证”或“可以自由补造”;候选中的未知事实仍由 semantic detector 标记为 `unknown/evidenceGaps`。 5. detector 发现事实或证据缺口后,由编排层补证并冻结新的上下文快照,再启动无会话继承的 Writer 调用;Writer 输出不承载补证请求或审查结论。 +## 生产落库 + +- `run_writer_with_receipt()` 只负责可信 writer adapter;生产编排在组装后调用 `read-context/scripts/persist_context_freeze.py`,机械门之后调用 `scripts/persist_writer_run.py`。 +- `persist_writer_run.py` 要求本次 `run_id` 已有成功 writer 调用的 raw 指针,随后登记 `example_candidate`、追加 `example_run_receipt` 和 `example_quality_result`;它不接受正文,Shadow→Canonical 仍只能由 `confirm/write_canonical.py` 完成。 + ## 输出合同 只返回严格 `WriterDraft v2` JSON,不写文件: diff --git a/.claude/skills/continuation/scripts/persist_writer_run.py b/.claude/skills/continuation/scripts/persist_writer_run.py new file mode 100644 index 0000000..2e4b7b5 --- /dev/null +++ b/.claude/skills/continuation/scripts/persist_writer_run.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""正文生产运行的统一数据库写入适配器。 + +调用顺序由生产编排负责:assemble -> persist_freeze -> run_writer_with_receipt +-> mechanical gate -> 本模块 -> 用户 confirm。这里不接受正文、不接受用户决策, +只把本次 writer 的候选、身份回执和机械质量结果写进 Shadow 账本。 +""" +import hashlib +import json +import pathlib +import sys +from typing import Any, Mapping + + +SCRIPT_DIR = pathlib.Path(__file__).resolve().parent +RUNTIME_DIR = SCRIPT_DIR.parents[1] / "runtime" / "scripts" +READ_CONTEXT_DIR = SCRIPT_DIR.parents[1] / "read-context" / "scripts" +DB_DIR = SCRIPT_DIR.parents[1] / "db" / "scripts" +for path in (RUNTIME_DIR, READ_CONTEXT_DIR, DB_DIR): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +from db import connect # noqa: E402 +from persist_context_freeze import persist_freeze # noqa: E402 +from run_registry import finish_run, start_run # noqa: E402 + + +CREATOR = "continuation" + + +class WriterPersistenceError(RuntimeError): + """生产候选缺少必要身份或证据时失败关闭。""" + + +def _bare_hash(value, field): + value = str(value or "") + if value.startswith("sha256:"): + value = value[7:] + if len(value) != 64 or any(char not in "0123456789abcdef" for char in value): + raise WriterPersistenceError(f"{field} 必须是 64 位小写 SHA-256") + return value + + +def _json(value): + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str) + + +def _raw_response(conn, run_id): + row = conn.execute( + "SELECT id,raw_content_id FROM example_llm_call " + "WHERE run_id=%s AND caller='writer' AND out_tokens>0 AND raw_content_id IS NOT NULL " + "ORDER BY id DESC LIMIT 1", + (run_id,), + ).fetchone() + if not row: + raise WriterPersistenceError( + f"writer 运行缺少成功调用 raw:run_id={run_id};禁止写入看似完整的候选" + ) + return row + + +def persist_writer_execution( + context: Mapping[str, Any], + candidate: Mapping[str, Any], + receipt: Any, + mechanical_report: Mapping[str, Any], + *, + assemble_result: Mapping[str, Any] | None = None, + dry_run: bool = False, +) -> dict[str, Any]: + """落库一次 writer Shadow 运行;失败不会创建半套候选账本。""" + + run_id = str(context.get("runId") or "") + work_id = context.get("workId") + target_chapter = context.get("targetChapter") + if not run_id or not isinstance(work_id, int) or not isinstance(target_chapter, int): + raise WriterPersistenceError("WriterContext 缺 runId/workId/targetChapter") + if candidate.get("runId") != run_id: + raise WriterPersistenceError("候选 runId 与上下文不一致") + candidate_sha = _bare_hash(candidate.get("candidateSha256"), "candidateSha256") + context_sha = _bare_hash(context["contextSnapshot"]["contextSha256"], "contextSha256") + candidate_version = str(candidate.get("candidateVersion")) + body = candidate.get("candidateBody") + if not body: + raise WriterPersistenceError("候选正文为空") + passed = mechanical_report.get("passed") + if not isinstance(passed, bool): + raise WriterPersistenceError("机械门报告缺少布尔 passed") + + if assemble_result is not None: + persist_freeze(assemble_result, dry_run=dry_run) + if not dry_run: + start_run( + run_id=run_id, work_id=work_id, target_chapter=target_chapter, + trigger_detail={"stage": "writer-production", "contextSha256": context_sha}, + creator=CREATOR, + ) + + try: + with connect() as conn: + call_id, raw_content_id = _raw_response(conn, run_id) + existing = conn.execute( + "SELECT id,candidate_sha256,state FROM example_candidate " + "WHERE tenant_id=0 AND work_id=%s AND target_chapter=%s AND candidate_version=%s", + (work_id, target_chapter, candidate_version), + ).fetchone() + if existing: + if existing[1] != candidate_sha: + raise WriterPersistenceError( + f"候选版本已绑定其他正文:work={work_id} chapter={target_chapter} version={candidate_version}" + ) + candidate_id = existing[0] + else: + candidate_id = conn.execute( + "INSERT INTO example_candidate(work_id,target_chapter,run_id,attempt,run_type," + "candidate_version,candidate_sha256,candidate_body,context_sha256,quality_policy_version," + "mode,source_role,state,acceptance_eligible,creator) " + "VALUES (%s,%s,%s,%s,'production',%s,%s,%s,%s,'writer-production-v1','continuation'," + "'writer',%s,TRUE,%s) RETURNING id", + (work_id, target_chapter, run_id, candidate.get("attempt", 1), candidate_version, + candidate_sha, body, context_sha, "passed" if passed else "rejected", CREATOR), + ).fetchone()[0] + + receipt_row = conn.execute( + "SELECT COALESCE(MAX(revision),0)+1 FROM example_run_receipt " + "WHERE tenant_id=0 AND run_id=%s AND sample_id=%s", + (run_id, f"writer-ch{target_chapter}"), + ).fetchone() + revision = receipt_row[0] + usage = getattr(receipt, "usage", None) or {} + cost = getattr(receipt, "total_cost_usd", None) + receipt_id = conn.execute( + "INSERT INTO example_run_receipt(run_id,sample_id,revision,adapter_role,stage_kind," + "attempt,candidate_version,candidate_sha256,context_sha256,requested_model_id," + "actual_model_id,model_match,effort,total_cost_usd,usage,stop_reason,terminal_reason," + "is_error,safe_summary,raw_content_id,creator,tenant_id) " + "VALUES (%s,%s,%s,'writer','generation',%s,%s,%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s,%s,%s,%s::jsonb,%s,%s,0) RETURNING id", + (run_id, f"writer-ch{target_chapter}", revision, candidate.get("attempt", 1), + candidate_version, candidate_sha, context_sha, + getattr(receipt, "requested_model_id", None), getattr(receipt, "actual_model_id", None), + getattr(receipt, "model_match", False), getattr(receipt, "effort", None), cost, + _json(dict(usage)), getattr(receipt, "stop_reason", None), + getattr(receipt, "terminal_reason", None), bool(getattr(receipt, "is_error", False)), + _json({"mechanicalPassed": passed, + "blockingFailures": mechanical_report.get("blockingFailures", []), + "sourceCallId": call_id}), raw_content_id, CREATOR), + ).fetchone()[0] + quality_id = conn.execute( + "INSERT INTO example_quality_result(run_id,receipt_id,candidate_sha256,judge_kind," + "scale_version,conclusion,failure_class,detail,raw_content_id,creator,tenant_id) " + "VALUES (%s,%s,%s,'detection','writer-production-v1',%s,%s,%s::jsonb,%s,%s,0) " + "ON CONFLICT (tenant_id,run_id,judge_kind,COALESCE(dimension,''),COALESCE(candidate_sha256,'')) DO NOTHING RETURNING id", + (run_id, receipt_id, candidate_sha, "pass" if passed else "fail", + None if passed else "mechanical_gate_failed", _json(dict(mechanical_report)), + raw_content_id, CREATOR), + ).fetchone() + result = {"status": "dry_run_ok" if dry_run else "persisted", + "candidate_id": candidate_id, "receipt_id": receipt_id, + "quality_id": quality_id[0] if quality_id else None, + "raw_content_id": raw_content_id, "state": "passed" if passed else "rejected"} + if dry_run: + conn.rollback() + result["note"] = "试跑已回滚,未落库" + return result + conn.commit() + finish_run(run_id, "completed", creator=CREATOR, + trigger_detail={"stage": "writer-production", "candidateId": candidate_id, + "mechanicalPassed": passed}) + return result + except Exception: + if not dry_run: + finish_run(run_id, "failed", creator=CREATOR, + trigger_detail={"stage": "writer-production", "error": "persistence_failed"}) + raise + + +__all__ = ["WriterPersistenceError", "persist_writer_execution"] diff --git a/.claude/skills/continuation/scripts/run_writer.py b/.claude/skills/continuation/scripts/run_writer.py index 007ed6c..06b4d87 100644 --- a/.claude/skills/continuation/scripts/run_writer.py +++ b/.claude/skills/continuation/scripts/run_writer.py @@ -234,6 +234,8 @@ def run_writer_with_receipt( runner=runner, binding_verifier=binding_verifier, business_validator=validate_writer_draft, + run_id=normalized_context.get("runId"), + caller="writer", ) except ClaudeRuntimeError as exc: # runtime 只提供受控原因和回执;这里不拼接 subprocess stderr 或 stdout。 diff --git a/.claude/skills/continuation/scripts/test_persist_writer_run.py b/.claude/skills/continuation/scripts/test_persist_writer_run.py new file mode 100644 index 0000000..289c146 --- /dev/null +++ b/.claude/skills/continuation/scripts/test_persist_writer_run.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""writer 统一落库适配器的输入合同离线测试。""" +import pathlib +import sys +import unittest + + +SCRIPT_DIR = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +import persist_writer_run as writer_persist # noqa: E402 + + +class WriterPersistenceContractTest(unittest.TestCase): + def test带前缀哈希归一(self): + value = "sha256:" + "a" * 64 + self.assertEqual(writer_persist._bare_hash(value, "x"), "a" * 64) + + def test非法哈希失败关闭(self): + with self.assertRaises(writer_persist.WriterPersistenceError): + writer_persist._bare_hash("not-a-hash", "candidateSha256") + + +if __name__ == "__main__": + unittest.main() diff --git a/.claude/skills/embed/SKILL.md b/.claude/skills/embed/SKILL.md index 87c63db..7e64a35 100644 --- a/.claude/skills/embed/SKILL.md +++ b/.claude/skills/embed/SKILL.md @@ -13,20 +13,23 @@ description: New-API 嵌入封装——Qwen3-Embedding-8B、dimensions=1024、 # 批量补嵌 pending 草稿(无活向量,或活向量的当前 payload+model hash 已过期) .venv/bin/python .claude/skills/embed/scripts/embed_drafts.py -# 指定 work(=参考书拆书批次)或限量 +# 指定 work(默认兼容参考书拆书批次,按 source_id)或限量 .venv/bin/python .claude/skills/embed/scripts/embed_drafts.py --work-id 3 --limit 100 +# 章后抽卡按作品的 draft.work_id 筛选(source_id 是章节 id) +.venv/bin/python .claude/skills/embed/scripts/embed_drafts.py --work-id 12 --source-type chapter_extract + # 自由文本试嵌(调试/B3 查询端复用同实现) .venv/bin/python .claude/skills/embed/scripts/embed_drafts.py --probe "机甲近战的节奏控制" ``` ## 合同 -- **嵌入文本构造**:`【型】名称:一句话摘要\n字段正文摘选`(draft_payload 的 embed_text 字段优先;无则按固定拼接),与检索端 query 语义对齐。 +- **嵌入文本构造**:`【型】名称:一句话摘要\n字段正文摘选`(draft_payload 的 `embed_text` 字段优先;无则兼容中文键 `型/名称/一句话摘要/字段` 与作品抽卡英文键 `type/name/brief/fields`),与检索端 query 语义对齐。 - **幂等与 owner**:sha256(嵌入文本+模型) 为 `content_hash`(uk: tenant+hash+model)。只有唯一行 `deleted=FALSE`、绑定同一 `draft_id`,且 owner draft 同租户并 `deleted=FALSE` 时才幂等跳过;`entity_id` 非空或其他 active draft owner 明确冲突并失败,绝不迁移 owner。旧 owner draft 已软删时,允许在写前活性重验后把唯一行条件迁到当前 draft。 - **批量**:读取每个 pending draft 的全部活向量,在 Python 中复用统一文本与 hash 规则筛选“无活向量”或“活向量 hash/model 与当前目标不一致”的候选;对完整候选集完成状态、同批 hash 与目标 owner 只读预检并提交后,`limit` 才限制实际 HTTP/写入行,每个实际 chunk 在 HTTP 前再次预查 owner 以封住竞态。每请求 ≤16 条文本;响应 `index` 必须是范围内唯一整数并完整覆盖请求槽位,缺项、重复或越界进入既有整批重试和逐条降级。失败整批重试 2 次(指数退避),仍失败逐条降级重试,坏行记错并继续(不断批)。网络异常、返回 `bad`、向量缺项或 `None` 均只记失败,不改旧向量,下一轮仍可重试;owner 冲突、多条活向量、同批目标 hash 冲突属于确定性异常,明确报告后令整条命令失败退出,不降级成失败计数。 - **落库与 reset/confirm/parse 并发**:HTTP 期间不持数据库事务。每个 draft 写入使用独立事务,先 `SELECT ... FOR UPDATE` 锁定 draft 并重验租户、`deleted=FALSE`、`status='pending'`;同时读取当前 `draft_payload`,重构文本与 hash,和 HTTP 前快照任一不一致即跳过。随后 `SELECT ... FOR UPDATE` 锁定该 draft 全部活向量:多条活向量是异常状态并失败关闭,任一 `entity_id` 非空则冲突失败,同 hash 且同 model 的当前活向量才幂等跳过,其他 hash 或 model 的无 entity 旧活向量在 UPSERT 前统一软删,保证每 draft 仅一个活向量。最后锁同 hash 唯一行,执行带 owner 条件的 UPSERT 并用 `RETURNING draft_id` 校验。同批多个 draft 的目标 hash 相同时整组失败,不按执行顺序抢 owner。该 draft 行锁与 reset 的 11 表 `SHARE ROW EXCLUSIVE`(4 个输入源表 + 7 个产出表)配合:embed 先锁时 reset 等待且随后能发现快照漂移;reset 先完成时 embed 等待后看到软删并跳过。confirm/parse 先完成时 embed 在锁后看到状态或 payload/hash 漂移并跳过。以上跳过或失败路径均零向量写入。 -- **落库字段**:`example_knowledge_embedding(draft_id, content_hash, embed_text, model, dimensions=1024, embedding)`;draft 确认落 entity 后由 confirm 流程回填 entity_id(嵌入行不动)。 +- **落库字段**:`example_knowledge_embedding(draft_id, content_hash, embed_text, model, dimensions=1024, embedding)`;draft 确认落 entity 后由 confirm 流程把 owner 迁到 `entity_id` 并清空 `draft_id`,关系草稿确认后关闭无 canonical owner 的临时向量。 - 汇报:新嵌 N、跳过 M、失败 K;幂等、失活和冲突原因均输出可追踪明细。 ## 离线验证 diff --git a/.claude/skills/embed/scripts/embed_drafts.py b/.claude/skills/embed/scripts/embed_drafts.py index c5c5d3e..b74a21f 100644 --- a/.claude/skills/embed/scripts/embed_drafts.py +++ b/.claude/skills/embed/scripts/embed_drafts.py @@ -79,9 +79,9 @@ def build_embed_text(payload: dict) -> str: # 型取值补 type 键:升格卡 payload 用 type 存型(非 型/target_type),漏认会产出「【】名称…」丢型文本, # 令升格卡向量与检索端跨型语义错位;补一段式回退(additive,不动 型/target_type 既有行为)。 t = payload.get("型") or payload.get("type") or payload.get("target_type", "") - name = payload.get("名称", "") - brief = payload.get("一句话摘要", "") - fields = payload.get("字段") or {} + name = payload.get("名称") or payload.get("name", "") + brief = payload.get("一句话摘要") or payload.get("brief", "") + fields = payload.get("字段") or payload.get("fields") or {} body = "\n".join(f"{k}:{v}" for k, v in fields.items() if v and k not in ("名称", "一句话摘要")) return f"【{t}】{name}:{brief}\n{body}"[:4000] @@ -254,8 +254,13 @@ def _write_embedding(conn, draft_id, content_hash, text, vector): return True -def _load_bulk_candidates(conn, work_id, limit): - """读取 pending draft 的全部活向量,在 Python 中按当前文本和模型筛选补嵌候选。""" +def _load_bulk_candidates(conn, work_id, limit, source_type=None): + """读取 pending draft 的全部活向量,在 Python 中按当前文本和模型筛选补嵌候选。 + + 拆书草稿的 ``work_id`` 仍表示参考书,历史调用因此按 ``source_id`` 筛选。 + 章后抽卡直接把作品写入 draft.work_id,必须用显式 source_type 切换到该口径, + 避免同一个 CLI 参数在两类数据上产生歧义。 + """ sql = """SELECT d.id, d.draft_payload, e.id, e.content_hash, e.model, e.entity_id @@ -264,7 +269,12 @@ def _load_bulk_candidates(conn, work_id, limit): ON e.tenant_id=%s AND e.draft_id=d.id AND e.deleted=FALSE WHERE d.tenant_id=%s AND d.deleted=FALSE AND d.status='pending'""" args = [TENANT, TENANT] - if work_id is not None: + if source_type == "chapter_extract": + if work_id is None: + raise ValueError("source_type=chapter_extract 必须同时指定 --work-id") + sql += " AND d.work_id=%s AND d.source_type=%s" + args.extend([work_id, source_type]) + elif work_id is not None: sql += " AND d.source_id=%s" args.append(work_id) # 必须先取得每个 draft 的全部活向量,不能在 SQL 层 LIMIT 后漏掉旧 hash 或异常状态。 @@ -342,10 +352,10 @@ def _load_bulk_candidates(conn, work_id, limit): return prechecked_candidates, failures -def _run_bulk(conn, sess, work_id, limit): +def _run_bulk(conn, sess, work_id, limit, source_type=None): """执行一次 bulk 补嵌;HTTP 前后均保持既有 owner、锁和 stale-write 边界。""" - rows, read_failures = _load_bulk_candidates(conn, work_id, limit) + rows, read_failures = _load_bulk_candidates(conn, work_id, limit, source_type) for draft_id, reason in read_failures: click.echo(f" [失败] draft={draft_id}: {reason}", err=True) if read_failures: @@ -415,10 +425,11 @@ def _run_bulk(conn, sess, work_id, limit): @click.command() -@click.option("--work-id", type=int, help="限定拆书批次的 work(draft.work_id=0 为全局行,用 source_id 关联参考书)") +@click.option("--work-id", type=int, help="限定作品/拆书批次;拆书默认按 source_id,章后抽卡按 draft.work_id") +@click.option("--source-type", type=str, help="来源类型;chapter_extract 按作品的 draft.work_id 筛选") @click.option("--limit", type=click.IntRange(min=0), default=0, help="最多处理条数(0=不限)") @click.option("--probe", help="自由文本试嵌(打印维度与前 5 维,不落库)") -def main(work_id, limit, probe): +def main(work_id, source_type, limit, probe): sess = _session() if probe: vecs, bad = embed_texts(sess, [probe]) @@ -429,7 +440,7 @@ def main(work_id, limit, probe): return with psycopg.connect(DSN) as conn: - _run_bulk(conn, sess, work_id, limit) + _run_bulk(conn, sess, work_id, limit, source_type) if __name__ == "__main__": diff --git a/.claude/skills/embed/scripts/test_embed_drafts_offline.py b/.claude/skills/embed/scripts/test_embed_drafts_offline.py index de11e14..4ec0bd8 100644 --- a/.claude/skills/embed/scripts/test_embed_drafts_offline.py +++ b/.claude/skills/embed/scripts/test_embed_drafts_offline.py @@ -238,8 +238,15 @@ class _BulkConnection: "status": "pending", "payload": payload, "source_id": source_id, + "work_id": work_id, + "source_type": source_type, } - for draft_id, payload, source_id in drafts + for draft_id, payload, source_id, source_type, work_id in ( + (item[0], item[1], item[2], item[3], item[4]) if len(item) == 5 + else (item[0], item[1], item[2], item[3], item[2]) if len(item) == 4 + else (*item, None, item[2]) + for item in drafts + ) } self.vectors = [dict(vector) for vector in (vectors or [])] self.events = [] @@ -252,13 +259,17 @@ class _BulkConnection: if normalized.startswith("select d.id, d.draft_payload"): self._assert_bulk_select_sql(normalized) - work_id = params[2] if len(params) == 3 else None + work_id = params[2] if len(params) >= 3 else None rows = [] for draft_id, draft in sorted(self.drafts.items()): if (draft["tenant"] != embed.TENANT or draft["deleted"] or draft["status"] != "pending"): continue - if work_id is not None and draft["source_id"] != work_id: + if "d.work_id=%s" in normalized and "d.source_type=%s" in normalized: + if (draft["work_id"] != work_id + or draft["source_type"] != params[3]): + continue + elif work_id is not None and draft["source_id"] != work_id: continue active = sorted( (row for row in self.vectors @@ -400,8 +411,10 @@ class _BulkConnection: return False -def _draft(draft_id, text, source_id=1): - return draft_id, {"embed_text": text}, source_id +def _draft(draft_id, text, source_id=1, source_type=None, work_id=None): + if work_id is None: + return draft_id, {"embed_text": text}, source_id, source_type + return draft_id, {"embed_text": text}, source_id, source_type, work_id def _vector(vector_id, draft_id, content_hash, *, model=embed.MODEL, @@ -449,6 +462,21 @@ class EmbedDraftsOfflineTest(unittest.TestCase): VECTOR = [0.1, 0.2] + def test英文作品卡的名称摘要字段进入嵌入文本(self): + first = embed.build_embed_text({ + "type": "character", "name": "林深", "brief": "机师", + "fields": {"身份": "驾驶员"}, + }) + second = embed.build_embed_text({ + "type": "character", "name": "何岚", "brief": "指挥员", + "fields": {"身份": "舰长"}, + }) + + self.assertIn("林深", first) + self.assertIn("机师", first) + self.assertIn("身份:驾驶员", first) + self.assertNotEqual(embed._content_hash(first), embed._content_hash(second)) + def test_reset先完成时软删draft零写入且有可追踪输出(self): conn = _EmbeddingConnection( candidate_deleted=True, @@ -921,6 +949,26 @@ class BulkSelfHealingOfflineTest(unittest.TestCase): self.assertIn("101", messages) self.assertIn("102", messages) + def test章后抽卡按draft_work_id和source_type筛选(self): + conn = _BulkConnection( + drafts=[ + _draft(201, TEXT, source_id=17473, source_type="chapter_extract", work_id=12), + _draft(202, NEW_TEXT, source_id=17474, source_type="chapter_extract", work_id=12), + _draft(203, TEXT, source_id=12, source_type="parse_book"), + ], + ) + + candidates, failures = embed._load_bulk_candidates( + conn, work_id=12, limit=0, source_type="chapter_extract" + ) + + self.assertEqual([row[0] for row in candidates], [201, 202]) + self.assertEqual(failures, []) + select_sql, params = next(sql for sql in conn.sql if sql[0].startswith("select d.id")) + self.assertIn("d.work_id=%s", select_sql) + self.assertIn("d.source_type=%s", select_sql) + self.assertEqual(params, [embed.TENANT, embed.TENANT, 12, "chapter_extract"]) + class EmbedDraftsCliTest(unittest.TestCase): """验证 CLI 参数在创建 session 和访问外部资源前完成校验。""" diff --git a/.claude/skills/extract-knowledge/scripts/extract_knowledge.py b/.claude/skills/extract-knowledge/scripts/extract_knowledge.py new file mode 100644 index 0000000..804f27c --- /dev/null +++ b/.claude/skills/extract-knowledge/scripts/extract_knowledge.py @@ -0,0 +1,449 @@ +#!/usr/bin/env python3 +"""从已接受章节抽取作品知识草稿。 + +本入口只负责“正文 -> draft/shadow”这一段,不自动确认知识;确认由 confirm skill +单独触发。模型调用经 llm skill,所有输入输出挂到本次 run_id。 +""" +import hashlib +import json +import pathlib +import sys +from typing import Any + +import click +import psycopg +from psycopg.types.json import Jsonb + + +HERE = pathlib.Path(__file__).resolve().parent +SKILLS = HERE.parents[1] +for import_path in ( + SKILLS / "llm" / "scripts", + SKILLS / "runtime" / "scripts", +): + if str(import_path) not in sys.path: + sys.path.insert(0, str(import_path)) + +from llm import chat_governed, cost_usd, extract_json # noqa: E402 +from record_failed_run import record_failure # noqa: E402 +from run_registry import finish_run, new_run_id, start_run # noqa: E402 + + +DB_SCRIPTS = SKILLS / "db" / "scripts" +if str(DB_SCRIPTS) not in sys.path: + sys.path.insert(0, str(DB_SCRIPTS)) +from db import connect # noqa: E402 + + +TENANT, ACTOR = 1, "1" +MODEL = "MiniMax-M3" +ENTITY_TYPES = frozenset({ + "character", "location", "faction", "power_system", "item", "event", +}) +TYPE_ALIASES = { + "人物": "character", "地点": "location", "组织": "faction", "势力": "faction", + "能力体系": "power_system", "力量体系": "power_system", "物件": "item", "事件": "event", +} + + +class ExtractionContractError(ValueError): + """模型输出无法绑定到正文事实时失败关闭。""" + + +PROMPT = """你是长篇小说章后知识抽取员。只从给定的已接受正文抽取作品私有知识草稿。 +不要确认知识,不要补写正文没有的事实;低置信内容仍保留但在 brief/fields 中标注“?”。 + +实体类型只能使用:character、location、faction、power_system、item、event。 +立卡门槛:具名且有跨章复用或后续履约潜力;一次性龙套和一次性道具不要列实体。 +证据必须是正文中的逐字连续片段,不能改写。 + +只输出一个 JSON 对象,严格使用以下 ASCII 字段,不要 markdown: +{{ + "entities": [{{"type":"character", "name":"", "brief":"", "fields": {{}}, "evidence":"正文逐字片段"}}], + "relations": [{{"source":"实体名", "target":"实体名", "type":"关系类型", "description":"", "evidence":"正文逐字片段"}}], + "state": {{"currentSituation":"", "characterStates": {{}}, "foreshadowing": {{"埋":[],"推":[],"收":[]}}, "handoff":""}} +}} + +作品:《{title}》 +章节:第 {chapter_order} 章《{chapter_title}》 +已有确认实体名(只用于判重,不得把没有正文证据的内容写进本章):{existing_names} + +【正文】 +{body} +""" + + +def _first(item: dict[str, Any], *keys, default=None): + for key in keys: + if key in item: + return item[key] + return default + + +def _text(value, field): + if not isinstance(value, str) or not value.strip(): + raise ExtractionContractError(f"{field} 必须是非空字符串") + return value.strip() + + +def _hash_payload(value: Any) -> str: + return hashlib.sha256( + json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + + +def _merge_usage(total, current): + """只累加顶层数值 token,保留嵌套明细的简单形状。""" + for key, value in (current or {}).items(): + if isinstance(value, (int, float)): + total[key] = total.get(key, 0) + value + + +def normalize_extraction(raw: Any, body: str) -> dict[str, Any]: + """把模型输出归一化并机械绑定到本章正文。""" + if not isinstance(raw, dict): + raise ExtractionContractError("抽取输出必须是对象") + entities = raw.get("entities") or raw.get("实体") or [] + relations = raw.get("relations") or raw.get("关系") or [] + state = raw.get("state") or raw.get("状态") or {} + if not isinstance(entities, list) or not isinstance(relations, list) or not isinstance(state, dict): + raise ExtractionContractError("entities/relations/state 类型非法") + + normalized_entities = [] + names = set() + for index, item in enumerate(entities): + if not isinstance(item, dict): + raise ExtractionContractError(f"entities[{index}] 必须是对象") + entity_type = _text(_first(item, "type", "型"), f"entities[{index}].type") + entity_type = TYPE_ALIASES.get(entity_type, entity_type) + if entity_type not in ENTITY_TYPES: + raise ExtractionContractError(f"entities[{index}].type 非法: {entity_type}") + name = _text(_first(item, "name", "名称"), f"entities[{index}].name") + brief = _text(_first(item, "brief", "一句话摘要", default="?"), f"entities[{index}].brief") + evidence = _text(_first(item, "evidence", "证据"), f"entities[{index}].evidence") + if evidence not in body: + raise ExtractionContractError(f"entities[{index}] 证据不在正文中") + key = (entity_type, name.casefold()) + if key in names: + raise ExtractionContractError(f"实体重复: {entity_type}/{name}") + names.add(key) + fields = _first(item, "fields", "字段", default={}) + if not isinstance(fields, dict): + raise ExtractionContractError(f"entities[{index}].fields 必须是对象") + normalized_entities.append({ + "type": entity_type, + "name": name, + "brief": brief, + "fields": fields, + "evidence": evidence, + }) + + normalized_relations = [] + for index, item in enumerate(relations): + if not isinstance(item, dict): + raise ExtractionContractError(f"relations[{index}] 必须是对象") + source = _text(_first(item, "source", "甲方"), f"relations[{index}].source") + target = _text(_first(item, "target", "乙方"), f"relations[{index}].target") + relation_type = _text(_first(item, "type", "关系类型"), f"relations[{index}].type") + description = _text(_first(item, "description", "描述", default="?"), f"relations[{index}].description") + evidence = _text(_first(item, "evidence", "证据"), f"relations[{index}].evidence") + if evidence not in body: + raise ExtractionContractError(f"relations[{index}] 证据不在正文中") + normalized_relations.append({ + "source": source, "target": target, "type": relation_type, + "description": description, "evidence": evidence, + }) + + return { + "entities": normalized_entities, + "relations": normalized_relations, + "state": state, + } + + +def salvage_extraction(raw: Any, body: str) -> dict[str, Any]: + """删除无法绑定的模型条目,再复用同一严格归一器。 + + 这是保守收口,不替模型编造证据:实体名本身若逐字出现在正文,可作为最小证据; + 关系缺证据则直接丢弃。丢弃数量写入 payload,供质量结果和看板解释。 + """ + if not isinstance(raw, dict): + raise ExtractionContractError("无法从非对象输出做保守收口") + candidate = dict(raw) + entity_key = "entities" if "entities" in candidate else "实体" + relation_key = "relations" if "relations" in candidate else "关系" + kept_entities, dropped_entities = [], 0 + for item in candidate.get(entity_key) or []: + if not isinstance(item, dict): + dropped_entities += 1 + continue + name = _first(item, "name", "名称") + evidence = _first(item, "evidence", "证据") + if isinstance(evidence, str) and evidence in body: + kept_entities.append(item) + elif isinstance(name, str) and name.strip() and name.strip() in body: + fixed = dict(item) + fixed["evidence"] = name.strip() + kept_entities.append(fixed) + else: + dropped_entities += 1 + kept_relations, dropped_relations = [], 0 + for item in candidate.get(relation_key) or []: + if not isinstance(item, dict) or not isinstance(_first(item, "evidence", "证据"), str) \ + or _first(item, "evidence", "证据") not in body: + dropped_relations += 1 + continue + kept_relations.append(item) + candidate[entity_key] = kept_entities + candidate[relation_key] = kept_relations + normalized = normalize_extraction(candidate, body) + normalized["mechanicalDrops"] = { + "entities": dropped_entities, + "relations": dropped_relations, + } + return normalized + + +def _load_chapter(work_id, chapter_order): + with connect(readonly=True) as conn: + row = conn.execute( + "SELECT w.title,c.id,c.title,b.id,b.content_text " + "FROM muse_content_work w JOIN muse_content_chapter c ON c.work_id=w.id " + "JOIN muse_content_block b ON b.chapter_id=c.id AND b.deleted=false " + "WHERE w.id=%s AND c.order_no=%s AND w.deleted=false AND c.deleted=false " + "ORDER BY b.revision DESC LIMIT 1", + (work_id, chapter_order), + ).fetchone() + if not row: + raise ValueError(f"作品 {work_id} 第 {chapter_order} 章没有可用 Canonical 正文") + existing = [r[0] for r in conn.execute( + "SELECT normalized_name FROM muse_knowledge_entity WHERE tenant_id=%s AND work_id=%s " + "AND deleted=false ORDER BY id", (TENANT, work_id) + ).fetchall()] + return row, existing + + +def _insert_draft(conn, *, work_id, chapter_id, chapter_order, run_id, entity, index): + payload = { + "type": entity["type"], + "name": entity["name"], + "brief": entity["brief"], + "fields": entity["fields"], + "evidence": entity["evidence"], + "source": {"workId": work_id, "chapter": chapter_order, "chapterId": chapter_id}, + "extractRunId": run_id, + } + command_id = f"extract-{work_id}-ch{chapter_order}-entity-{index}-{_hash_payload(payload)[:12]}" + normalized_name = entity["name"].strip().casefold() + current = conn.execute( + "SELECT id,description,attributes,revision FROM muse_knowledge_entity " + "WHERE tenant_id=%s AND work_id=%s AND entity_type=%s AND normalized_name=%s " + "AND scope='local' AND deleted=false FOR SHARE", + (TENANT, work_id, entity["type"], normalized_name), + ).fetchone() + existing_id = current[0] if current else None + snapshot = None if not current else { + "id": current[0], "description": current[1], "attributes": current[2], "revision": current[3] + } + row = conn.execute( + "INSERT INTO muse_knowledge_draft(work_id,entity_id,draft_type,target_object_id,proposed_changes," + "current_canonical_snapshot,draft_payload,source_status,source_action_policy,status,confidence," + "source_type,source_id,command_id,creator,updater,tenant_id) " + "VALUES (%s,%s,'entity',%s,%s::jsonb,%s::jsonb,%s::jsonb,'active','allowed','pending',%s," + "'chapter_extract',%s,%s,%s,%s,%s) " + "ON CONFLICT (tenant_id,command_id) WHERE command_id IS NOT NULL DO NOTHING RETURNING id", + ( + work_id, existing_id, existing_id, + json.dumps({"description": entity["brief"], "attributes": entity["fields"]}, ensure_ascii=False), + json.dumps(snapshot, ensure_ascii=False) if snapshot else None, + json.dumps(payload, ensure_ascii=False), + 0.8, chapter_id, command_id, ACTOR, ACTOR, TENANT, + ), + ).fetchone() + return row[0] if row else None + + +def persist_extraction(work_id, chapter_id, chapter_order, run_id, payload, *, requested_model, actual_model, usage): + """一次事务写实体/关系草稿、状态 shadow、运行回执和质量结果。""" + result_sha = _hash_payload(payload) + with connect() as conn: + try: + draft_ids = [] + for index, entity in enumerate(payload["entities"], start=1): + draft_id = _insert_draft( + conn, work_id=work_id, chapter_id=chapter_id, chapter_order=chapter_order, + run_id=run_id, entity=entity, index=index, + ) + if draft_id: + draft_ids.append(draft_id) + for index, relation in enumerate(payload["relations"], start=1): + relation_payload = { + **relation, + "source": {"name": relation["source"]}, + "target": {"name": relation["target"]}, + "sourceRef": {"workId": work_id, "chapter": chapter_order, "chapterId": chapter_id}, + "extractRunId": run_id, + } + command_id = f"extract-{work_id}-ch{chapter_order}-relation-{index}-{_hash_payload(relation_payload)[:12]}" + row = conn.execute( + "INSERT INTO muse_knowledge_draft(work_id,draft_type,draft_payload,source_status," + "source_action_policy,status,confidence,source_type,source_id,command_id,creator,updater,tenant_id) " + "VALUES (%s,'relation',%s::jsonb,'active','allowed','pending',%s,'chapter_extract',%s,%s,%s,%s,%s) " + "ON CONFLICT (tenant_id,command_id) WHERE command_id IS NOT NULL DO NOTHING RETURNING id", + (work_id, json.dumps(relation_payload, ensure_ascii=False), 0.7, chapter_id, + command_id, ACTOR, ACTOR, TENANT), + ).fetchone() + if row: + draft_ids.append(row[0]) + + state = payload.get("state") or {} + state_payload = { + "schemaVersion": "narrative-state-v1", + "workId": work_id, + "chapter": chapter_order, + "state": state, + "source": {"chapterId": chapter_id, "runId": run_id}, + } + state_id = None + if state: + version = conn.execute( + "SELECT COALESCE(MAX(version),0)+1 FROM example_planning_section " + "WHERE tenant_id=%s AND work_id=%s AND section_type='state' AND target_chapter IS NULL", + (TENANT, work_id), + ).fetchone()[0] + state_id = conn.execute( + "INSERT INTO example_planning_section(work_id,section_type,schema_type,version,payload,state,creator,updater,tenant_id) " + "VALUES (%s,'state','narrative_state',%s,%s::jsonb,'shadow',%s,%s,%s) RETURNING id", + (work_id, version, json.dumps(state_payload, ensure_ascii=False), ACTOR, ACTOR, TENANT), + ).fetchone()[0] + + raw_id = conn.execute( + "SELECT raw_content_id FROM example_llm_call WHERE run_id=%s AND caller='extract-knowledge' " + "ORDER BY id DESC LIMIT 1", (run_id,) + ).fetchone() + raw_content_id = raw_id[0] if raw_id else None + receipt_id = conn.execute( + "INSERT INTO example_run_receipt(run_id,sample_id,revision,adapter_role,stage_kind,attempt," + "requested_model_id,actual_model_id,model_match,total_cost_usd,usage,stop_reason,terminal_reason," + "is_error,safe_summary,result_sha256,raw_content_id,creator,tenant_id) " + "VALUES (%s,%s,1,'extractor','generation',1,%s,%s,%s,%s,%s::jsonb,'stop','completed',FALSE,%s::jsonb,%s,%s,%s,%s) " + "ON CONFLICT (tenant_id,run_id,sample_id,revision) DO NOTHING RETURNING id", + ( + run_id, f"extract-ch{chapter_order}", requested_model, actual_model, + requested_model == actual_model, cost_usd(actual_model, usage), + json.dumps(usage, ensure_ascii=False), + json.dumps({"entityDrafts": len(draft_ids), "stateDraftId": state_id, + "mechanicalDrops": payload.get("mechanicalDrops", {})}, ensure_ascii=False), + result_sha, raw_content_id, ACTOR, TENANT, + ), + ).fetchone() + receipt_id = receipt_id[0] if receipt_id else None + if receipt_id is None: + receipt_id = conn.execute( + "SELECT id FROM example_run_receipt WHERE tenant_id=%s AND run_id=%s AND sample_id=%s AND revision=1", + (TENANT, run_id, f"extract-ch{chapter_order}"), + ).fetchone()[0] + conn.execute( + "INSERT INTO example_quality_result(run_id,receipt_id,judge_kind,scale_version,conclusion,detail,raw_content_id,creator,tenant_id) " + "VALUES (%s,%s,'detection','extractor-contract-v1','pass',%s::jsonb,%s,%s,%s) " + "ON CONFLICT (tenant_id,run_id,judge_kind,COALESCE(dimension,''),COALESCE(candidate_sha256,'')) DO NOTHING", + (run_id, receipt_id, json.dumps({"entityDrafts": len(draft_ids), "relationDrafts": len(payload["relations"]), + "stateDraftId": state_id, + "mechanicalDrops": payload.get("mechanicalDrops", {})}, ensure_ascii=False), + raw_content_id, ACTOR, TENANT), + ) + conn.commit() + return {"draft_ids": draft_ids, "state_draft_id": state_id, "receipt_id": receipt_id, + "result_sha256": result_sha} + except Exception: + conn.rollback() + raise + + +def extract_chapter(work_id, chapter_order, *, run_id=None): + record = start_run( + run_id=run_id or new_run_id("extract-knowledge", work_id=work_id, target_chapter=chapter_order), + work_id=work_id, + target_chapter=chapter_order, + trigger_detail={"stage": "chapter-after-extraction"}, + creator=ACTOR, + ) + active_run = record["run_id"] + try: + (title, chapter_id, chapter_title, _block_id, body), existing = _load_chapter(work_id, chapter_order) + prompt = PROMPT.format( + title=title, + chapter_order=chapter_order, + chapter_title=chapter_title or "", + existing_names="、".join(existing[:200]) or "(暂无)", + body=body, + ) + content, usage, actual_model = chat_governed( + prompt, model=MODEL, caller="extract-knowledge", run_id=active_run, + ) + if actual_model is None: + raise RuntimeError("抽取模型治理链全部耗尽") + total_usage = dict(usage or {}) + raw_output = extract_json(content) + try: + payload = normalize_extraction(raw_output, body) + except ExtractionContractError as first_error: + # 只允许模型按正文逐字重绑证据;不能借 repair 轮新增实体、关系或事实。 + repair_prompt = ( + prompt + + "\n\n【机械校验失败,允许一次修复】\n" + + f"失败原因:{first_error}\n" + + "只修正证据字段,使每条 evidence 都是上方正文中的逐字连续片段;" + "删除无法找到逐字证据的条目,不得新增条目、事实、关系或状态。仍只输出同一 JSON 对象。" + ) + content, repair_usage, repair_model = chat_governed( + repair_prompt, model=MODEL, caller="extract-knowledge", run_id=active_run, + ) + if repair_model is None: + raise + _merge_usage(total_usage, repair_usage) + repaired_raw = extract_json(content) + try: + payload = normalize_extraction(repaired_raw, body) + except ExtractionContractError: + payload = salvage_extraction(repaired_raw, body) + actual_model = repair_model + result = persist_extraction( + work_id, chapter_id, chapter_order, active_run, payload, + requested_model=MODEL, actual_model=actual_model, usage=total_usage, + ) + finish_run(active_run, "completed", creator=ACTOR, + trigger_detail={"stage": "chapter-after-extraction", "drafts": len(result["draft_ids"])}) + return {"run_id": active_run, **result, "actual_model": actual_model} + except BaseException as exc: + finish_run(active_run, "failed", creator=ACTOR, + trigger_detail={"stage": "chapter-after-extraction", "error_type": type(exc).__name__}) + try: + record_failure( + active_run, + sample_id=f"extract-ch{chapter_order}", + adapter_role="extractor", + caller="extract-knowledge", + failure_type=type(exc).__name__, + ) + except Exception as receipt_error: + click.echo( + f"[警告] 失败回执补写失败:{type(receipt_error).__name__}", + err=True, + ) + raise + + +@click.command() +@click.option("--work-id", type=int, required=True) +@click.option("--chapter-order", type=int, required=True) +@click.option("--run-id", default=None) +def main(work_id, chapter_order, run_id): + try: + click.echo(json.dumps(extract_chapter(work_id, chapter_order, run_id=run_id), ensure_ascii=False)) + except (ExtractionContractError, RuntimeError, psycopg.Error) as exc: + raise click.ClickException(str(exc)) from exc + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/extract-knowledge/scripts/test_extract_knowledge_offline.py b/.claude/skills/extract-knowledge/scripts/test_extract_knowledge_offline.py new file mode 100644 index 0000000..a25f5e9 --- /dev/null +++ b/.claude/skills/extract-knowledge/scripts/test_extract_knowledge_offline.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""章后抽取的机械绑定测试,不连接数据库、不调用模型。""" +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from extract_knowledge import ExtractionContractError, normalize_extraction, salvage_extraction # noqa: E402 + + +BODY = "林深走进舰桥,何岚把黑色钥匙交给他。" + + +def test_normalize_binds_evidence_and_aliases(): + result = normalize_extraction({ + "entities": [{"type": "人物", "name": "林深", "brief": "机师", "fields": {}, "evidence": "林深走进舰桥"}], + "relations": [], + "state": {"handoff": "继续调查"}, + }, BODY) + assert result["entities"][0]["type"] == "character" + assert result["entities"][0]["evidence"] in BODY + + +def test_normalize_rejects_fabricated_evidence(): + try: + normalize_extraction({ + "entities": [{"type": "item", "name": "黑钥匙", "brief": "钥匙", "fields": {}, "evidence": "正文没有这句"}], + "relations": [], "state": {}, + }, BODY) + except ExtractionContractError as exc: + assert "证据不在正文" in str(exc) + else: + raise AssertionError("编造证据必须失败关闭") + + +def test_salvage_drops_unbound_relation_and_keeps_exact_name_evidence(): + result = salvage_extraction({ + "entities": [ + {"type": "item", "name": "黑色钥匙", "brief": "钥匙", "fields": {}, "evidence": "模型编造"}, + {"type": "item", "name": "不存在的物件", "brief": "?", "fields": {}, "evidence": "模型编造"}, + ], + "relations": [{"source": "林深", "target": "何岚", "type": "伙伴", "description": "?", "evidence": "模型编造"}], + "state": {}, + }, "林深走进舰桥,何岚把黑色钥匙交给他。") + assert [item["name"] for item in result["entities"]] == ["黑色钥匙"] + assert result["entities"][0]["evidence"] == "黑色钥匙" + assert result["relations"] == [] + assert result["mechanicalDrops"] == {"entities": 1, "relations": 1} + + +def main(): + test_normalize_binds_evidence_and_aliases() + test_normalize_rejects_fabricated_evidence() + test_salvage_drops_unbound_relation_and_keeps_exact_name_evidence() + print("全部通过") + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/llm/scripts/llm.py b/.claude/skills/llm/scripts/llm.py index c2a2dce..71fa90c 100644 --- a/.claude/skills/llm/scripts/llm.py +++ b/.claude/skills/llm/scripts/llm.py @@ -58,8 +58,18 @@ class PlanQuotaExhausted(Exception): 不同,普通 429 仍保留指数退避重试。""" +def _default_persist_call(event): + """按需加载 runtime 持久化器,避免 llm 单测和纯离线调用被迫连库。""" + runtime_scripts = pathlib.Path(__file__).resolve().parents[2] / "runtime" / "scripts" + if str(runtime_scripts) not in sys.path: + sys.path.insert(0, str(runtime_scripts)) + from persist_llm_call import persist_call + return persist_call(event) + + def chat(prompt, model=DEFAULT_MODEL, max_tokens=512000, temperature=0.2, - retries=2, timeout=900, system=None, top_p=None): + retries=2, timeout=900, system=None, top_p=None, *, run_id=None, + caller=None, requested_model_id=None, persist_call=None): """单轮对话,返回 (content, usage)。网络错/5xx/普通 429 指数退避重试。 content 已剥离 …(推理模型可能把思考混进正文)。 @@ -70,6 +80,10 @@ def chat(prompt, model=DEFAULT_MODEL, max_tokens=512000, temperature=0.2, 结算按实际用量,余额充足时预扣不产生额外成本)——**余额须 ≥ 并发路数 × $0.154**, 否则触发 403「预扣费额度失败」(2026-07-15 余额见底实测坐实此机制)。 """ + if persist_call is None and (run_id or caller): + persist_call = _default_persist_call + if persist_call is not None and not callable(persist_call): + raise TypeError("persist_call 必须是可调用对象") s = requests.Session() s.trust_env = False # 本机代理 env 会劫持内网直连 messages = ([{"role": "system", "content": system}] if system else []) \ @@ -87,6 +101,9 @@ def chat(prompt, model=DEFAULT_MODEL, max_tokens=512000, temperature=0.2, } if top_p is not None: payload["top_p"] = top_p + prompt_raw = json.dumps({"messages": messages, **payload}, + ensure_ascii=False, sort_keys=True, separators=(",", ":")) + requested_model_id = requested_model_id or model last_err = None for attempt in range(retries + 1): try: @@ -116,6 +133,22 @@ def chat(prompt, model=DEFAULT_MODEL, max_tokens=512000, temperature=0.2, f"cached={cached} out={usage.get('completion_tokens', '?')} " f"耗时{time.time() - t0:.0f}s finish={data['choices'][0].get('finish_reason')}", file=sys.stderr) + if persist_call is not None: + persist_call({ + "window_key": window_key(_now()), + "run_id": run_id, + "caller": caller or "", + "requested_model_id": requested_model_id, + "actual_model_id": model, + "usage": usage, + "cost_usd": cost_usd(model, usage), + "stop_reason": data["choices"][0].get("finish_reason"), + "duration_ms": max(0, int(round((time.time() - t0) * 1000))), + "prompt": prompt_raw, + "response": json.dumps(data, ensure_ascii=False, sort_keys=True, + separators=(",", ":"), default=str), + "role": caller, + }) return content, usage except (requests.RequestException, KeyError, json.JSONDecodeError) as e: last_err = str(e) @@ -280,7 +313,8 @@ def _bump_window(wk, add_usd): def chat_governed(prompt, model=DEFAULT_MODEL, system=None, max_tokens=512000, - temperature=0.2, top_p=None): + temperature=0.2, top_p=None, *, run_id=None, caller=None, + persist_call=None): """全局额度治理下的对话入口,返回 (content, usage, actual_model)。 契约:成功→三元组;全链耗尽(所有模型敏感/不可用)→(None,None,None)。 model 参数仅作兼容保留:实际用哪个模型由全局额度策略决定,不由调用方指定。 @@ -319,8 +353,18 @@ def chat_governed(prompt, model=DEFAULT_MODEL, system=None, max_tokens=512000, # 3) 沿链逐个模型调用;撞敏感/不可用换下一个 for m in chain: try: - content, usage = chat(prompt, model=m, system=system, max_tokens=max_tokens, - temperature=temperature, top_p=top_p) + content, usage = chat( + prompt, + model=m, + system=system, + max_tokens=max_tokens, + temperature=temperature, + top_p=top_p, + run_id=run_id, + caller=caller, + requested_model_id=model, + persist_call=persist_call, + ) except PlanQuotaExhausted as e: plan_quota_open.add(m) print(f"[llm] 治理链 {m} Token Plan 本窗耗尽,立即熔断并降级下一个:{str(e)[:80]}", @@ -352,7 +396,8 @@ def cli(): @click.option("--extract-json", "extract_", is_flag=True, help="容错提取 JSON 后输出") def chat_cmd(prompt_file, model, max_tokens, temperature, out, extract_): prompt = pathlib.Path(prompt_file).read_text() - content, _ = chat(prompt, model=model, max_tokens=max_tokens, temperature=temperature) + content, _ = chat(prompt, model=model, max_tokens=max_tokens, temperature=temperature, + run_id=None, caller="llm-cli") if extract_: content = json.dumps(extract_json(content), ensure_ascii=False, indent=1) if out: diff --git a/.claude/skills/llm/scripts/test_call_persistence.py b/.claude/skills/llm/scripts/test_call_persistence.py new file mode 100644 index 0000000..f6554ec --- /dev/null +++ b/.claude/skills/llm/scripts/test_call_persistence.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""模型调用持久化合同的离线测试。 + +这些测试不连网、不连库,只固定共享 LLM 入口必须向持久化适配器提供的证据形状。 +""" +import json +import pathlib +import sys +import types + + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +import llm # noqa: E402 + + +USAGE = { + "prompt_tokens": 12, + "completion_tokens": 7, + "prompt_tokens_details": {"cached_tokens": 3}, +} + + +class FakeResponse: + status_code = 200 + text = "" + + def raise_for_status(self): + return None + + def json(self): + return { + "choices": [{ + "message": {"content": "模型输出"}, + "finish_reason": "stop", + }], + "usage": USAGE, + "id": "completion-1", + } + + +def test_chat_emits_a_complete_persistence_event(): + """成功调用应把完整请求/响应和审计字段交给原子落库适配器。""" + events = [] + + class FakeSession: + trust_env = True + + def post(self, url, headers=None, json=None, timeout=None): + return FakeResponse() + + old_session, old_time = llm.requests.Session, llm.time + try: + llm.requests.Session = FakeSession + llm.time = types.SimpleNamespace(time=lambda: 100.0, sleep=lambda _: None) + content, usage = llm.chat( + "用户提示", + model="MiniMax-M3", + system="系统提示", + retries=0, + run_id="run-1", + caller="parse-book", + persist_call=events.append, + ) + finally: + llm.requests.Session, llm.time = old_session, old_time + + assert (content, usage) == ("模型输出", USAGE) + assert len(events) == 1 + event = events[0] + assert event["run_id"] == "run-1" + assert event["caller"] == "parse-book" + assert event["requested_model_id"] == "MiniMax-M3" + assert event["actual_model_id"] == "MiniMax-M3" + assert event["usage"] == USAGE + assert event["duration_ms"] == 0 + assert event["stop_reason"] == "stop" + assert json.loads(event["prompt"]) == { + "messages": [ + {"role": "system", "content": "系统提示"}, + {"role": "user", "content": "用户提示"}, + ], + "model": "MiniMax-M3", + "max_tokens": 512000, + "temperature": 0.2, + } + assert json.loads(event["response"])["choices"][0]["message"]["content"] == "模型输出" + + +def test_governed_forwards_persistence_context_to_actual_model_call(): + """额度治理选出的实际模型必须继续携带 run/caller/持久化适配器。""" + calls = [] + + def fake_chat(prompt, model=None, **kwargs): + calls.append((prompt, model, kwargs)) + return "ok", {"prompt_tokens": 1, "completion_tokens": 1} + + old_now = llm._now + old_read = llm._read_window + old_bump = llm._bump_window + old_chat = llm.chat + old_pricing = llm._PRICING_CACHE + try: + llm._now = lambda: __import__("datetime").datetime(2026, 7, 16, 12, 0) + llm._read_window = lambda _: (0.0, 0) + llm._bump_window = lambda *_: (0.0, 1) + llm._PRICING_CACHE = dict(llm.PRICING_FALLBACK) + llm.chat = fake_chat + marker = object() + content, _, used = llm.chat_governed( + "prompt", + run_id="run-2", + caller="extract-knowledge", + persist_call=marker, + ) + finally: + llm._now = old_now + llm._read_window = old_read + llm._bump_window = old_bump + llm.chat = old_chat + llm._PRICING_CACHE = old_pricing + + assert (content, used) == ("ok", "MiniMax-M3") + assert calls[0][2]["run_id"] == "run-2" + assert calls[0][2]["caller"] == "extract-knowledge" + assert calls[0][2]["persist_call"] is marker + + +def main(): + for test in ( + test_chat_emits_a_complete_persistence_event, + test_governed_forwards_persistence_context_to_actual_model_call, + ): + test() + print(f" ✓ {test.__name__}") + print("全部通过") + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/planning/SKILL.md b/.claude/skills/planning/SKILL.md index 0d71758..253d72a 100644 --- a/.claude/skills/planning/SKILL.md +++ b/.claude/skills/planning/SKILL.md @@ -40,7 +40,10 @@ disable-model-invocation: true 1. 落库:`.venv/bin/python .claude/skills/planning/scripts/persist_planning.py write --work-id N --section-type [--target-chapter N] [--schema-type X] [--version N]`(planner 不碰库/git,主会话落库;先 `--dry-run` 试跑)。 2. 确认:`persist_planning.py confirm `(用户确认后 shadow→confirmed;**确认后的规划才进生成上下文**)。 -3. 已确认细纲取用(read-context 改道读库,不再当内存参数): +3. 规划执行的输入/输出必须可追溯:`record_planning_execution.py --section-id ` 为已落库 section 登记 `example_run`、prompt/response raw 和 `example_run_receipt`;该记录标明 `executionKind=deterministic_persist`,不伪装成模型调用。 + 历史回执若误把确定性执行写成 `model_match=true`,使用 `repair_deterministic_receipt.py --run-id ` 追加修正 revision;旧回执保持不变。 +4. 公共范式选择使用 `select_patterns.py --work-id --intent "..."`:只从 `scope=public_pattern` 召回,结果写入 `assembly` 的 `shadow`,并把检索意图与候选投影落 raw/回执;未确认 assembly 不得进入正文上下文。 +5. 已确认细纲取用(read-context 改道读库,不再当内存参数): ```sql SELECT payload FROM example_planning_section WHERE work_id=? AND section_type='fine_outline' AND target_chapter=? AND state='confirmed' AND deleted=false diff --git a/.claude/skills/planning/scripts/record_planning_execution.py b/.claude/skills/planning/scripts/record_planning_execution.py new file mode 100644 index 0000000..b3859d6 --- /dev/null +++ b/.claude/skills/planning/scripts/record_planning_execution.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""把已落库规划的执行输入/输出挂到运行、raw 和回执账本。 + +规划内容的正式权威仍是 ``example_planning_section``;本脚本只记录一次 +``persist_planning`` 的确定性执行证据,不把它伪装成模型调用。可用于历史 +规划回填,也可由新的 planner 编排器在 section 写入后调用。 +""" +import argparse +import hashlib +import json +import pathlib +import sys + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +for path in (ROOT / "db" / "scripts", ROOT / "runtime" / "scripts"): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) +from db import connect # noqa: E402 +from run_registry import finish_run, start_run # noqa: E402 +from persist_raw import _bare_sha256, _check_no_secrets # noqa: E402 + + +ACTOR = "planning" + + +def _json(value): + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def _raw(conn, *, run_id, role, kind, content, lease_id=None): + _check_no_secrets(content) + sha = _bare_sha256(content) + if lease_id is None: + lease_id = conn.execute( + "INSERT INTO example_raw_lease(run_id,source_version,content_hashes,purpose,status,creator) " + "VALUES (%s,%s,%s::jsonb,'planning_persistence','closed',%s) RETURNING id", + (run_id, "planning-persistence-v1", _json({kind: sha}), ACTOR), + ).fetchone()[0] + row = conn.execute( + "INSERT INTO example_raw_content(lease_id,kind,run_id,role,content_sha256,content,creator) " + "VALUES (%s,%s,%s,%s,%s,%s,%s) ON CONFLICT (lease_id,content_sha256) DO NOTHING RETURNING id", + (lease_id, kind, run_id, role, sha, content, ACTOR), + ).fetchone() + if row: + return lease_id, row[0] + existing = conn.execute( + "SELECT id FROM example_raw_content WHERE lease_id=%s AND content_sha256=%s", + (lease_id, sha), + ).fetchone() + if not existing: + raise RuntimeError(f"规划 raw 写入后无法回读:run_id={run_id} kind={kind}") + return lease_id, existing[0] + + +def record_section(section_id, *, dry_run=False, input_payload=None, output_payload=None): + """为一条规划 section 记录一次确定性写入执行;重复调用幂等。""" + with connect() as conn: + section = conn.execute( + "SELECT id,work_id,target_chapter,section_type,schema_type,version,state,payload " + "FROM example_planning_section WHERE id=%s AND deleted=false", + (section_id,), + ).fetchone() + if not section: + raise ValueError(f"规划 section 不存在:{section_id}") + sid, work_id, target_chapter, section_type, schema_type, version, state, payload = section + run_id = f"planning-persist-w{work_id}-s{sid}" + existing = None + with connect() as conn: + existing = conn.execute( + "SELECT run_id,terminal_state FROM example_run WHERE run_id=%s AND deleted=false", + (run_id,), + ).fetchone() + if existing and existing[1] == "completed": + return {"status": "already_recorded", "run_id": run_id, "section_id": sid} + if not dry_run: + start_run( + run_id=run_id, work_id=work_id, target_chapter=target_chapter, + trigger_detail={"stage": "planning-persistence", "sectionId": sid, + "sectionType": section_type, "executionKind": "deterministic"}, + creator=ACTOR, + ) + input_payload = input_payload or { + "operation": "persist_planning", + "sectionId": sid, + "workId": work_id, + "targetChapter": target_chapter, + "sectionType": section_type, + "schemaType": schema_type, + "version": version, + "requestedState": state, + } + output_payload = output_payload or { + "sectionId": sid, + "sectionType": section_type, + "state": state, + "version": version, + "payload": payload, + } + input_text = _json(input_payload) + output_text = _json(output_payload) + result_sha = hashlib.sha256(output_text.encode("utf-8")).hexdigest() + try: + with connect() as conn: + prompt_sha = _bare_sha256(input_text) + response_sha = _bare_sha256(output_text) + lease_id = conn.execute( + "INSERT INTO example_raw_lease(run_id,source_version,content_hashes,purpose,status,creator) " + "VALUES (%s,%s,%s::jsonb,'planning_persistence','closed',%s) RETURNING id", + (run_id, "planning-persistence-v1", + _json({"prompt": prompt_sha, "response": response_sha}), ACTOR), + ).fetchone()[0] + _lease_id, _prompt_id = _raw( + conn, run_id=run_id, role="planner", kind="prompt", content=input_text, + lease_id=lease_id, + ) + _lease_id, response_id = _raw( + conn, run_id=run_id, role="planner", kind="response", content=output_text, + lease_id=lease_id, + ) + conn.execute( + "INSERT INTO example_run_receipt(run_id,sample_id,revision,adapter_role,stage_kind," + "attempt,model_match,is_error,safe_summary,result_sha256,raw_content_id,creator,tenant_id) " + "VALUES (%s,%s,1,'planner','generation',1,NULL,FALSE,%s::jsonb,%s,%s,%s,0) " + "ON CONFLICT (tenant_id,run_id,sample_id,revision) DO NOTHING", + (run_id, f"planning-section-{sid}", _json({ + "executionKind": "deterministic_persist", + "sectionId": sid, + "sectionType": section_type, + "state": state, + }), result_sha, response_id, ACTOR), + ) + if dry_run: + conn.rollback() + return {"status": "dry_run_ok", "run_id": run_id, "section_id": sid, + "note": "试跑已回滚,未落库"} + conn.commit() + finish_run(run_id, "completed", creator=ACTOR, + trigger_detail={"stage": "planning-persistence", "sectionId": sid}) + return {"status": "recorded", "run_id": run_id, "section_id": sid, + "raw_response_id": response_id, "result_sha256": result_sha} + except Exception: + if not dry_run: + finish_run(run_id, "failed", creator=ACTOR, + trigger_detail={"stage": "planning-persistence", "sectionId": sid, + "error": "persistence_failed"}) + raise + + +def main(): + parser = argparse.ArgumentParser(description="规划 section 执行输入/输出落 raw 与回执") + parser.add_argument("--section-id", type=int, required=True) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + print(json.dumps(record_section(args.section_id, dry_run=args.dry_run), ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/planning/scripts/repair_deterministic_receipt.py b/.claude/skills/planning/scripts/repair_deterministic_receipt.py new file mode 100644 index 0000000..c7a226f --- /dev/null +++ b/.claude/skills/planning/scripts/repair_deterministic_receipt.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""为确定性规划执行追加模型字段修正回执。 + +规划落库不是模型生成,``model_match`` 必须保持 NULL。历史脚本曾把该字段 +误写为 TRUE,本脚本只追加 revision,不更新 append-only 回执历史。 +""" +import argparse +import json +import pathlib +import sys + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +for path in (ROOT / "db" / "scripts", ROOT / "runtime" / "scripts"): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) +from db import connect # noqa: E402 + + +CREATOR = "planning-receipt-repair" + + +def _is_deterministic(summary, model_match): + return ( + isinstance(summary, dict) + and summary.get("executionKind") == "deterministic_persist" + and model_match is True + ) + + +def _corrected_summary(summary): + corrected = dict(summary or {}) + corrected.update({ + "evidenceCorrection": "deterministic-model-match-v1", + "previousModelMatch": True, + }) + return corrected + + +def repair_run(run_id, *, dry_run=False): + with connect() as conn: + rows = conn.execute( + "SELECT run_id,sample_id,revision,arm,attempt,adapter_role,stage_kind,loop_seq," + "candidate_version,candidate_sha256,context_sha256,previous_state_sha256," + "requested_model_id,actual_model_id,model_match,effort,total_cost_usd,usage," + "stop_reason,terminal_reason,is_error,safe_summary,result_sha256,raw_content_id," + "creator,tenant_id FROM example_run_receipt WHERE run_id=%s " + "ORDER BY sample_id,revision", + (run_id,), + ).fetchall() + if not rows: + raise ValueError(f"运行没有回执:{run_id}") + + latest = {} + for row in rows: + latest[row[1]] = row + + added = [] + for row in latest.values(): + (rid, sample_id, revision, arm, attempt, adapter_role, stage_kind, loop_seq, + candidate_version, candidate_sha, context_sha, previous_state_sha, + requested_model, actual_model, model_match, effort, total_cost, usage, + stop_reason, terminal_reason, is_error, safe_summary, result_sha, raw_id, + creator, tenant_id) = row + if not _is_deterministic(safe_summary, model_match): + continue + next_revision = revision + 1 + new_row = conn.execute( + "INSERT INTO example_run_receipt(run_id,sample_id,revision,arm,attempt," + "adapter_role,stage_kind,loop_seq,candidate_version,candidate_sha256," + "context_sha256,previous_state_sha256,requested_model_id,actual_model_id," + "model_match,effort,total_cost_usd,usage,stop_reason,terminal_reason,is_error," + "safe_summary,result_sha256,raw_content_id,creator,tenant_id) " + "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,NULL,NULL,NULL,NULL,NULL,NULL,%s,%s,%s,%s::jsonb,%s,%s,%s,%s) " + "ON CONFLICT (tenant_id,run_id,sample_id,revision) DO NOTHING RETURNING id", + (rid, sample_id, next_revision, arm, attempt, adapter_role, stage_kind, loop_seq, + candidate_version, candidate_sha, context_sha, previous_state_sha, + stop_reason, terminal_reason, is_error, json.dumps( + _corrected_summary(safe_summary), ensure_ascii=False), result_sha, raw_id, + CREATOR, tenant_id), + ).fetchone() + if new_row: + added.append({ + "sample_id": sample_id, + "previous_revision": revision, + "revision": next_revision, + "receipt_id": new_row[0], + }) + + result = { + "status": "dry_run_ok" if dry_run else "repaired", + "run_id": run_id, + "added": added, + } + if dry_run: + conn.rollback() + result["note"] = "试跑已回滚,未落库" + return result + conn.commit() + return result + + +def main(): + parser = argparse.ArgumentParser(description="追加确定性规划回执的模型字段修正") + parser.add_argument("--run-id", action="append", required=True) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + results = [repair_run(run_id, dry_run=args.dry_run) for run_id in args.run_id] + print(json.dumps({"results": results}, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/planning/scripts/select_patterns.py b/.claude/skills/planning/scripts/select_patterns.py new file mode 100644 index 0000000..1a1bde8 --- /dev/null +++ b/.claude/skills/planning/scripts/select_patterns.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""为作品选择公共范式,并把选择结果以 assembly shadow 落库。 + +检索只从 work_id=0 的公共范式治理面召回;结果先成为 shadow assembly, +未确认前不进入正文上下文。检索输入和结果同时交给 planning execution +recorder,保证这一段也有 run/raw/receipt 证据。 +""" +import json +import pathlib +import sys + +import click + + +HERE = pathlib.Path(__file__).resolve().parent +SKILLS = HERE.parents[1] +for path in (SKILLS / "planning" / "scripts", SKILLS / "search" / "scripts"): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) +from persist_planning import write_section # noqa: E402 +from record_planning_execution import record_section # noqa: E402 +from search import search_cards # noqa: E402 + + +def select_patterns(work_id, intent, *, top=5, dry_run=False): + if not intent or not intent.strip(): + raise ValueError("范式选择 intent 不能为空") + cards = search_cards(intent.strip(), scope="public_pattern", purpose="planning", top=top) + refs = [] + for card in cards: + refs.append({ + "sourceId": card["sourceId"], + "sourceVersion": card["sourceVersion"], + "sourceType": "public_pattern", + "name": str(card.get("name") or "")[:40], + "summary": str(card.get("summary") or "")[:120], + "writingPoints": { + str(key)[:40]: str(value)[:200] + for key, value in (card.get("visibleFields") or {}).items() + }, + }) + selection_input = { + "operation": "select_public_patterns", + "workId": work_id, + "intent": intent.strip(), + "scope": "public_pattern", + "purpose": "planning", + "top": top, + } + assembly = { + "schemaVersion": "assembly-v1", + "workId": work_id, + "patternSelection": { + "intent": intent.strip(), + "scope": "public_pattern", + "selected": refs, + "selectionStatus": "shadow", + "emptyReason": None if refs else "公共范式检索没有命中可授权卡", + }, + "patternReferences": refs, + "knowledgeBindings": [], + } + section = write_section( + work_id, + section_type="assembly", + schema_type="assembly", + payload=assembly, + state="shadow", + dry_run=dry_run, + ) + if dry_run: + return {"status": "dry_run_ok", "section": section, "selected": len(refs), + "note": "规划写入已回滚,未落库"} + execution = record_section( + section["id"], + input_payload=selection_input, + output_payload={"sectionId": section["id"], "assembly": assembly}, + ) + return {"status": "written", "section": section, "selected": len(refs), + "execution": execution} + + +@click.command() +@click.option("--work-id", type=int, required=True) +@click.option("--intent", required=True) +@click.option("--top", type=click.IntRange(min=1, max=20), default=5, show_default=True) +@click.option("--dry-run", is_flag=True) +def main(work_id, intent, top, dry_run): + click.echo(json.dumps(select_patterns(work_id, intent, top=top, dry_run=dry_run), ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/planning/scripts/test_record_planning_execution.py b/.claude/skills/planning/scripts/test_record_planning_execution.py new file mode 100644 index 0000000..f45f548 --- /dev/null +++ b/.claude/skills/planning/scripts/test_record_planning_execution.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""规划执行留痕脚本的离线合同测试。""" +import pathlib +import sys +import unittest + + +SCRIPT_DIR = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +import record_planning_execution as recorder # noqa: E402 + + +class PlanningExecutionContractTest(unittest.TestCase): + def test_json稳定且保留结构化输出(self): + text = recorder._json({"sectionType": "setting", "payload": {"title": "深渊机神"}}) + self.assertEqual(text, '{"payload":{"title":"深渊机神"},"sectionType":"setting"}') + + def test输入输出不允许密钥进入raw(self): + with self.assertRaises(ValueError): + recorder._check_no_secrets("api_key=sk-1234567890123456") + + +if __name__ == "__main__": + unittest.main() diff --git a/.claude/skills/planning/scripts/test_repair_deterministic_receipt.py b/.claude/skills/planning/scripts/test_repair_deterministic_receipt.py new file mode 100644 index 0000000..d715d9e --- /dev/null +++ b/.claude/skills/planning/scripts/test_repair_deterministic_receipt.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""确定性规划回执修正的离线合同测试。""" +import pathlib +import sys +import unittest + + +SCRIPT_DIR = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +import repair_deterministic_receipt as repair # noqa: E402 + + +class DeterministicReceiptRepairTest(unittest.TestCase): + def test只修正误写为_true的确定性回执(self): + summary = {"executionKind": "deterministic_persist", "sectionId": 3} + self.assertTrue(repair._is_deterministic(summary, True)) + self.assertFalse(repair._is_deterministic(summary, None)) + self.assertFalse(repair._is_deterministic({"executionKind": "model"}, True)) + + def test修正摘要保留原字段并标记证据修正(self): + corrected = repair._corrected_summary({"sectionId": 3}) + self.assertEqual(corrected["sectionId"], 3) + self.assertEqual(corrected["evidenceCorrection"], "deterministic-model-match-v1") + self.assertTrue(corrected["previousModelMatch"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/.claude/skills/planning/scripts/test_select_patterns_offline.py b/.claude/skills/planning/scripts/test_select_patterns_offline.py new file mode 100644 index 0000000..78d5083 --- /dev/null +++ b/.claude/skills/planning/scripts/test_select_patterns_offline.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""范式选择投影的离线测试,不调用向量服务。""" +import pathlib +import sys +import unittest +from unittest.mock import patch + + +SCRIPT_DIR = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +import select_patterns as selector # noqa: E402 + + +class SelectPatternsContractTest(unittest.TestCase): + def test检索结果投影成严格范式来源指针(self): + cards = [{ + "sourceId": "draft:677", "sourceVersion": "draft-revision:1", + "name": "借尸试人", "summary": "借外在突袭验证立场", + "visibleFields": {"写法": "先试探后收手"}, + }] + with patch.object(selector, "search_cards", return_value=cards), \ + patch.object(selector, "write_section", return_value={"id": 9, "status": "written"}) as write, \ + patch.object(selector, "record_section", return_value={"status": "recorded"}): + result = selector.select_patterns(12, "深空机甲微恐战斗", top=5) + ref = result["section"] + self.assertEqual(result["selected"], 1) + self.assertEqual(ref["id"], 9) + write.assert_called_once() + payload = write.call_args.kwargs["payload"] + self.assertEqual(payload["patternReferences"][0]["sourceId"], "draft:677") + self.assertEqual(payload["patternReferences"][0]["sourceType"], "public_pattern") + + def test无命中记录明确原因(self): + with patch.object(selector, "search_cards", return_value=[]), \ + patch.object(selector, "write_section", return_value={"id": 10, "status": "written"}) as write, \ + patch.object(selector, "record_section", return_value={"status": "recorded"}): + selector.select_patterns(12, "无命中意图") + payload = write.call_args.kwargs["payload"] + self.assertEqual(payload["patternSelection"]["emptyReason"], "公共范式检索没有命中可授权卡") + + +if __name__ == "__main__": + unittest.main() diff --git a/.claude/skills/read-context/scripts/assemble_writer_context.py b/.claude/skills/read-context/scripts/assemble_writer_context.py index d77c789..d3d5334 100644 --- a/.claude/skills/read-context/scripts/assemble_writer_context.py +++ b/.claude/skills/read-context/scripts/assemble_writer_context.py @@ -18,6 +18,7 @@ from writer_contract import ( build_writer_creative_input, canonical_json, normalize_text, + project_pattern_pointers, retrieval_identity, validate_writer_context, ) @@ -280,7 +281,10 @@ def _manifest( ref = copy.deepcopy(dict(item["sourceRef"])) unique[_source_key(ref)] = ref for raw_ref in pattern_references: - ref = copy.deepcopy(dict(raw_ref)) + # manifest 是来源账本(审计用),只登记可回读指针;范式卡的内容字段(name/ + # summary/writingPoints)剥掉,不进 manifest——内容只由上下文内的 patternReferences + # 承载。否则 manifest 的 _source_ref 严格校验会以「未知字段」拒收内容字段。 + ref = project_pattern_pointers(raw_ref) unique[_source_key(ref)] = ref for hint in index_hints: # 这里记录的是全部冻结卡索引,并不代表卡缺少原文来源。 @@ -395,9 +399,14 @@ def _collect_diff_paths(left: Any, right: Any, path: str = "$") -> list[str]: def _is_allowed_ac_diff(path: str) -> bool: - """只允许预注册证据策略改变模型可见的事实约束与原文摘录。""" + """只允许预注册证据策略改变模型可见的事实约束、原文摘录与范式引用。 - return path.startswith(("$.factConstraints", "$.proseExcerpts")) + WHY 含 patternReferences:Gate A 的唯一变量是「有无卡(含范式卡)」。C 臂注入公共 + 范式卡、A 臂恒空,因此两臂创意输入的 patternReferences 必然不同——这是实验设计本身, + 不是越界改动。把它列入白名单,门禁才不会把预期的范式差异误判为非法字段漂移。 + """ + + return path.startswith(("$.factConstraints", "$.proseExcerpts", "$.patternReferences")) def build_context_allowlist_diff_receipt( diff --git a/.claude/skills/read-context/scripts/persist_context_freeze.py b/.claude/skills/read-context/scripts/persist_context_freeze.py index ed1a9eb..c28c4c4 100644 --- a/.claude/skills/read-context/scripts/persist_context_freeze.py +++ b/.claude/skills/read-context/scripts/persist_context_freeze.py @@ -43,18 +43,26 @@ def persist_freeze(assemble_result, *, reference_work_id=None, reference_version raise ValueError("assemble 结果缺 asOf,无法落冻结") with connect() as conn: try: - # manifest_sha256 唯一:同一冻结重放幂等(ON CONFLICT 返回既有行) + # manifest_sha256 唯一:同一冻结重放幂等。表是 append-only,冲突只能回读,不能 UPDATE。 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 " + "ON CONFLICT (manifest_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), CREATOR)).fetchone() + if not row: + row = conn.execute( + "SELECT id,manifest_sha256,context_sha256 FROM example_context_freeze " + "WHERE manifest_sha256=%s", + (manifest_sha,), + ).fetchone() + if not row: + raise RuntimeError("冻结幂等回读失败") if dry_run: conn.rollback() return {"status": "dry_run_ok", "id": row[0], "manifest_sha256": row[1], diff --git a/.claude/skills/read-context/scripts/test_writer_contract.py b/.claude/skills/read-context/scripts/test_writer_contract.py index 91b2cba..3c1c05e 100644 --- a/.claude/skills/read-context/scripts/test_writer_contract.py +++ b/.claude/skills/read-context/scripts/test_writer_contract.py @@ -427,5 +427,105 @@ class WriterContractTest(unittest.TestCase): validate_writer_context(context) +class PatternReferenceContractTest(unittest.TestCase): + """SoT 变更:patternReferences 携带范式卡内容(名字/摘要/写法要点)并投影给写手。""" + + def _context_with_pattern(self, references: list) -> dict: + context = valid_context() + context["patternReferences"] = references + context["contextSnapshot"]["contextSha256"] = retrieval_identity(context) + return context + + def test_content_fields_pass_and_project_to_writer(self): + """内容字段过合同,且写手创作输入真正读到名字/摘要/写法要点。""" + + context = self._context_with_pattern( + [ + { + "sourceId": "draft:combat-1", + "sourceVersion": "draft-revision:2", + "sourceType": "combat", + "name": "三段式逆转", + "summary": "先压后扬再反转。", + "writingPoints": {"节拍": "压制—喘息—反杀", "钩子": "身份揭破"}, + } + ] + ) + validate_writer_context(context) + creative = build_writer_creative_input(context) + self.assertEqual( + creative["patternReferences"], + [ + { + "referenceId": "pattern-1", + "kind": "combat", + "name": "三段式逆转", + "summary": "先压后扬再反转。", + "writingPoints": {"节拍": "压制—喘息—反杀", "钩子": "身份揭破"}, + } + ], + ) + # 来源指针只供审计回读,绝不允许泄进写手创作输入。 + serialized = canonical_json(creative) + self.assertNotIn("sourceId", serialized) + self.assertNotIn("sourceVersion", serialized) + + def test_content_fields_optional(self): + """内容字段可选:只带来源指针(历史形状)仍合法,投影只给标签。""" + + context = self._context_with_pattern( + [{"sourceId": "draft:trope-1", "sourceVersion": "draft-revision:1", "sourceType": "trope"}] + ) + validate_writer_context(context) + creative = build_writer_creative_input(context) + self.assertEqual( + creative["patternReferences"], + [{"referenceId": "pattern-1", "kind": "trope"}], + ) + + def test_content_oversize_or_unknown_field_fails_closed(self): + """超量内容、超字段数、未知字段或非对象写法要点,合同一律失败关闭。""" + + base = { + "sourceId": "draft:craft-1", + "sourceVersion": "draft-revision:1", + "sourceType": "craft", + } + mutations = { + "name_overlong": {**base, "name": "超长名字" * 20}, + "summary_overlong": {**base, "summary": "超长摘要" * 60}, + "point_value_overlong": {**base, "writingPoints": {"节拍": "超长写法要点" * 100}}, + "too_many_points": {**base, "writingPoints": {f"字段{i}": "要点" for i in range(7)}}, + "unknown_field": {**base, "score": 0.9}, + "writing_points_not_object": {**base, "writingPoints": ["不是对象"]}, + } + for label, ref in mutations.items(): + with self.subTest(mutation=label), self.assertRaises(ContractError): + validate_writer_context(self._context_with_pattern([ref])) + + def test_strict_source_pointer_unaffected_by_relaxation(self): + """放宽只针对 patternReferences:事实证据的来源指针夹带 name 仍被拒收。""" + + context = valid_context() + context["factEvidence"] = [ + { + "evidenceId": "fact-1", + "fact": "林澈仍在圣蒂曼城内", + "sourceType": "canonical_state", + "sourceRef": { + "sourceId": "state:8", + "sourceVersion": "state-v1", + # 内容字段只允许出现在 patternReferences;夹带到其它来源指针必须被拒。 + "name": "夹带的名字", + }, + "contentSha256": "sha256:" + "a" * 64, + "riskLevel": "high", + } + ] + context["contextSnapshot"]["contextSha256"] = retrieval_identity(context) + with self.assertRaises(ContractError): + validate_writer_context(context) + + if __name__ == "__main__": unittest.main() diff --git a/.claude/skills/read-context/scripts/writer_contract.py b/.claude/skills/read-context/scripts/writer_contract.py index 4acbfd3..57078e8 100644 --- a/.claude/skills/read-context/scripts/writer_contract.py +++ b/.claude/skills/read-context/scripts/writer_contract.py @@ -7,6 +7,7 @@ Unicode NFC 与换行归一化,所有身份哈希都来自同一份规范 JSON from __future__ import annotations +import copy import hashlib import json import math @@ -46,11 +47,19 @@ class ContractError(ValueError): def normalize_text(value: str) -> str: - """把文本统一为 NFC 与 LF,供哈希和 Unicode 偏移共同使用。""" + """把文本统一为 NFC 与 LF,供哈希和 Unicode 偏移共同使用。 + + 模型在 JSON 输出里常把换行双重转义成字面 ``\\n``(反斜杠+n 两个字符),这里连同 + 真实的 CRLF/CR 一并还原为真正的换行符 LF,避免正文带着字面 ``\\n`` 显示异常、 + 以及检测/盲评的跨段引文因换行表示不同而匹配失败。 + """ if not isinstance(value, str): raise ContractError("待归一化文本必须是字符串") - return unicodedata.normalize("NFC", value.replace("\r\n", "\n").replace("\r", "\n")) + value = value.replace("\r\n", "\n").replace("\r", "\n") # 真实 CRLF/CR → LF + # 字面转义还原:先处理 \r\n(4 字符)再处理 \n / \r(2 字符),顺序避免半截替换 + value = value.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\r", "\n") + return unicodedata.normalize("NFC", value) def _normalize_json(value: Any) -> Any: @@ -247,15 +256,30 @@ def _hash(value: Any, path: str) -> str: return text -def _source_ref(value: Any, path: str) -> None: - """校验不可变来源引用;历史原文可额外携带块和字符区间。""" +_SOURCE_REF_REQUIRED = frozenset({"sourceId", "sourceVersion"}) +_SOURCE_REF_OPTIONAL = frozenset( + {"chapter", "blockId", "startCodePoint", "endCodePoint", "contentSha256", "sourceType"} +) + +# 范式引用(patternReferences)在严格来源指针之外额外允许的内容字段。 +# WHY(SoT 变更):此前 patternReferences 只能带来源指针,写手最终只看到一个空标签 +# (referenceId+kind),读不到范式卡的名字/摘要/写法,「范式指导」这个实验单变量 +# 形同虚设。放宽这三个内容字段只针对 patternReferences,其它来源指针不受影响。 +PATTERN_CONTENT_FIELDS = frozenset({"name", "summary", "writingPoints"}) + +# 范式引用内容字段的体量硬上限(name/summary/写法要点值按 code point 计,字段数按个计)。 +# WHY:范式卡原始字段可能长达数千字,直接灌给写手会撑爆上下文预算。合同侧按这些上限 +# 失败关闭——无论检索端将来怎么换,超量内容都进不了写手输入;检索端投影时应先截断到 +# 上限以内,合同复核是第二道闸。 +PATTERN_NAME_MAX_CHARS = 40 +PATTERN_SUMMARY_MAX_CHARS = 120 +PATTERN_POINTS_MAX_FIELDS = 6 +PATTERN_POINT_MAX_CHARS = 200 + + +def _validate_source_ref_pointers(ref: Mapping[str, Any], path: str) -> None: + """校验来源指针自身(sourceId/sourceVersion 必填及定位字段),供严格与放宽校验复用。""" - ref = _object( - value, - path, - frozenset({"sourceId", "sourceVersion"}), - frozenset({"chapter", "blockId", "startCodePoint", "endCodePoint", "contentSha256", "sourceType"}), - ) _string(ref["sourceId"], f"{path}.sourceId") _string(ref["sourceVersion"], f"{path}.sourceVersion") for field in ("chapter", "blockId", "startCodePoint", "endCodePoint"): @@ -269,6 +293,74 @@ def _source_ref(value: Any, path: str) -> None: _string(ref["sourceType"], f"{path}.sourceType") +def _source_ref(value: Any, path: str) -> None: + """校验不可变来源引用;历史原文可额外携带块和字符区间。""" + + ref = _object(value, path, _SOURCE_REF_REQUIRED, _SOURCE_REF_OPTIONAL) + _validate_source_ref_pointers(ref, path) + + +def _pattern_source_ref(value: Any, path: str) -> None: + """校验范式引用:严格来源指针 + 放宽且限量的内容字段。 + + WHY(SoT 变更):让写手真正读到范式卡——名字、一句话摘要、写法要点——而不是 + 只看到一个来源标签。来源指针仍必填,保证可回读、可审计;内容字段全部限量并失败 + 关闭,防止撑爆写手上下文。放宽只针对 patternReferences:proseEvidence/factEvidence/ + manifest 等其它来源指针继续走严格的 _source_ref,任何名字/摘要字段仍按「未知字段」拒收。 + """ + + ref = _object(value, path, _SOURCE_REF_REQUIRED, _SOURCE_REF_OPTIONAL | PATTERN_CONTENT_FIELDS) + _validate_source_ref_pointers(ref, path) + if "name" in ref and len(_string(ref["name"], f"{path}.name")) > PATTERN_NAME_MAX_CHARS: + raise ContractError(f"{path}.name 超出体量上限 {PATTERN_NAME_MAX_CHARS} 字") + if "summary" in ref and len(_string(ref["summary"], f"{path}.summary")) > PATTERN_SUMMARY_MAX_CHARS: + raise ContractError(f"{path}.summary 超出体量上限 {PATTERN_SUMMARY_MAX_CHARS} 字") + if "writingPoints" in ref: + points = ref["writingPoints"] + if not isinstance(points, Mapping): + raise ContractError(f"{path}.writingPoints 必须是对象") + if len(points) > PATTERN_POINTS_MAX_FIELDS: + raise ContractError(f"{path}.writingPoints 超出 {PATTERN_POINTS_MAX_FIELDS} 个字段上限") + for key, item in points.items(): + key_text = _string(key, f"{path}.writingPoints.") + if len(key_text) > PATTERN_NAME_MAX_CHARS: + raise ContractError(f"{path}.writingPoints 字段名超出体量上限 {PATTERN_NAME_MAX_CHARS} 字") + if len(_string(item, f"{path}.writingPoints.{key_text}")) > PATTERN_POINT_MAX_CHARS: + raise ContractError( + f"{path}.writingPoints.{key_text} 超出体量上限 {PATTERN_POINT_MAX_CHARS} 字" + ) + + +def pattern_references_for_arm( + arm: str, c_references: Sequence[Mapping[str, Any]] +) -> list[dict[str, Any]]: + """按臂分配范式引用的唯一事实源:A 臂恒空,其余臂(B/C)拿 C 臂候选范式卡。 + + WHY:Gate A 的唯一实验变量是「有无卡(含范式卡)」。A 臂是纯历史原文对照,必须 + 恒空,否则 A/C 单变量对照被破坏。范式卡的链路有两段独立 assemble:装配端 + (load_writer_reference_work)检索出 C 臂候选并冻结进 config.json 的 + ``writerContextInput.patternReferences``;回放端(run_writer_replay)真写时再从 + config.json 读出候选、重新 assemble 各臂上下文。两段必须按完全相同的规则分臂, + 因此把规则收敛到合同模块这一处由两端复用——任一段各写一套,就会出现「C 臂真写 + 读不到范式卡(实验失效)」或「A 臂混入范式卡(对照破坏)」。返回深拷贝,避免 + 各臂上下文与冻结候选互相串改。 + """ + + if arm == "A": + return [] + return [copy.deepcopy(dict(item)) for item in c_references] + + +def project_pattern_pointers(value: Mapping[str, Any]) -> dict[str, Any]: + """从可能携带内容字段的引用中投影出纯来源指针(供 manifest 等审计账本使用)。 + + WHY:manifest 记录「哪些来源入包」,只承载可回读指针,不承载范式卡正文;范式卡 + 内容只由上下文内的 patternReferences 承载(并计入上下文身份哈希)。 + """ + + return {key: value[key] for key in (_SOURCE_REF_REQUIRED | _SOURCE_REF_OPTIONAL) if key in value} + + def _validate_plan(value: Any, path: str) -> None: """校验固定检索计划,不允许写手临场扩张查询。""" @@ -506,7 +598,8 @@ def validate_writer_context(value: Any) -> dict[str, Any]: raise ContractError("原文证据必须包含截至冻结点的连续前四章基线") for index, reference in enumerate(_array(context["patternReferences"], "$.patternReferences")): - _source_ref(reference, f"$.patternReferences[{index}]") + # 范式引用走放宽校验:来源指针仍严格,另允许 name/summary/writingPoints 内容字段。 + _pattern_source_ref(reference, f"$.patternReferences[{index}]") for index, coverage in enumerate(_array(context["evidenceCoverage"], "$.evidenceCoverage")): item = _object(coverage, f"$.evidenceCoverage[{index}]", frozenset({"elementId", "elementType", "name", "status", "factEvidenceIds", "proseEvidenceIds", "gapReason"})) for field in ("elementId", "elementType", "name", "gapReason"): @@ -577,13 +670,23 @@ def build_writer_creative_input(value: Any) -> dict[str, Any]: } for item in context["proseEvidence"] ] - pattern_references = [ - { + # SoT 变更:把范式卡内容投影给写手。WHY——此前只投影 referenceId+kind 两个标签, + # 写手看不到范式卡写什么,「范式指导」单变量实际为空;这里把名字、一句话摘要和写法 + # 要点 surface 出来(均为可选,存在且非空才给)。来源指针(sourceId/sourceVersion) + # 一律不进写手输入,只留在冻结上下文供审计回读。 + pattern_references = [] + for index, item in enumerate(context["patternReferences"]): + reference: dict[str, Any] = { "referenceId": f"pattern-{index + 1}", "kind": item.get("sourceType", "authorized_pattern"), } - for index, item in enumerate(context["patternReferences"]) - ] + if item.get("name"): + reference["name"] = item["name"] + if item.get("summary"): + reference["summary"] = item["summary"] + if item.get("writingPoints"): + reference["writingPoints"] = dict(item["writingPoints"]) + pattern_references.append(reference) output_contract = context["outputContract"] creative_input = { "fineOutline": { @@ -696,7 +799,10 @@ def validate_writer_output(value: Any) -> dict[str, Any]: __all__ = [ "CONTEXT_VERSION", "DRAFT_VERSION", "OUTPUT_VERSION", "PLAN_VERSION", "MANIFEST_VERSION", "TIE_BREAK", + "PATTERN_CONTENT_FIELDS", "PATTERN_NAME_MAX_CHARS", "PATTERN_SUMMARY_MAX_CHARS", + "PATTERN_POINTS_MAX_FIELDS", "PATTERN_POINT_MAX_CHARS", "ContractError", "normalize_text", "canonical_json", "retrieval_identity", "han_count", "calculate_target_chars", "validate_writer_context", "build_writer_creative_input", "validate_writer_draft", "build_candidate_envelope", "validate_writer_output", + "project_pattern_pointers", ] diff --git a/.claude/skills/replay-eval/scripts/test_pattern_reference_injection.py b/.claude/skills/replay-eval/scripts/test_pattern_reference_injection.py new file mode 100644 index 0000000..7baa11f --- /dev/null +++ b/.claude/skills/replay-eval/scripts/test_pattern_reference_injection.py @@ -0,0 +1,480 @@ +#!/usr/bin/env python3 +"""公共范式卡接入 C 臂写手输入的离线验证。 + +只验证取数与合同投影,不真连库、不真调 writer/detector/judge: +检索器用 stub 注入;装配器跑真实 dry-run 路径(内部会过 validate_writer_context +的严格合同,从而证明注入的范式引用字段合法)。 +""" + +from __future__ import annotations + +import json +import pathlib +import sys +import unittest + +SCRIPT_DIR = pathlib.Path(__file__).resolve().parent +SKILLS_DIR = SCRIPT_DIR.parents[1] +READ_CONTEXT_SCRIPTS = SKILLS_DIR / "read-context" / "scripts" +# 端到端链路测试要导入回放包(run_writer_replay.sample),其依赖散布在 runtime 与 +# quality-gate 两个 skill 的 scripts 目录,路径口径与 test_run_writer_replay 保持一致。 +RUNTIME_SCRIPTS = SKILLS_DIR / "runtime" / "scripts" +QUALITY_GATE_SCRIPTS = SKILLS_DIR / "quality-gate" / "scripts" +for _path in (SCRIPT_DIR, READ_CONTEXT_SCRIPTS, RUNTIME_SCRIPTS, QUALITY_GATE_SCRIPTS): + sys.path.insert(0, str(_path)) + +import load_writer_reference_work as loader # noqa: E402 +from load_writer_reference_work import ( # noqa: E402 + PATTERN_CARD_TYPES, + PATTERN_TOTAL_CAP, + _pattern_references_for_arm, + _retrieve_pattern_references, + assemble_writer_gate_config, +) +from run_writer_replay.sample import ( # noqa: E402 + _build_arm_contexts, + _common_controls, +) +from writer_contract import ( # noqa: E402 + PATTERN_NAME_MAX_CHARS, + PATTERN_POINTS_MAX_FIELDS, + PATTERN_POINT_MAX_CHARS, + PATTERN_SUMMARY_MAX_CHARS, + ContractError, + _pattern_source_ref, + _source_ref, + build_writer_creative_input, + normalize_text, + pattern_references_for_arm, + retrieval_identity, +) + +# 复用既有测试夹具:真实五章 base 配置 + 纯数据快照,能让装配器跑通真实 dry-run。 +from test_load_writer_reference_work import ( # noqa: E402 + BASE_CONFIG_PATH, + SELECTOR_DIGEST, + SELECTOR_PATH, + _assembly_rows, + _refresh_self_hash, +) + + +def _prepared_base_config() -> dict[str, object]: + """按既有用例的口径准备 base 配置:预算置 pending 并重签自哈希。""" + + base_config = json.loads(BASE_CONFIG_PATH.read_text(encoding="utf-8")) + budget = base_config["executionAuthorization"]["budget"] + budget["status"] = "pending" + budget.pop("totalBudgetUsd", None) + _refresh_self_hash(budget) + return base_config + + +def _stub_searcher(per_type: int = 2, record: list | None = None): + """构造确定性范式卡检索 stub,签名与真实检索器一致:(intent, *, ttype, top)。 + + 返回的卡含 search_cards 的稳定来源指针字段(sourceId/sourceVersion/type)与内容 + 字段(name/summary/visibleFields)——后者正是 _pattern_content_projection 读取、 + 用来验证「写手真正读到卡内容」这条链路的集合。 + """ + + def _searcher(intent: str, *, ttype: str, top: int) -> list[dict[str, object]]: + if record is not None: + record.append({"intent": intent, "ttype": ttype, "top": top}) + cards = [] + for index in range(per_type): + cards.append( + { + "cardId": f"{ttype}-{index}", + "type": ttype, + "name": f"范式-{ttype}-{index}", + "score": round(0.9 - 0.01 * index, 4), + "summary": f"{ttype} 摘要 {index}", + "visibleFields": { + "节拍": f"压制—反转—落点({ttype}-{index})", + "钩子": "身份揭破", + }, + "sourceId": f"draft:{ttype}-{index}", + "sourceVersion": f"draft-revision:{index + 1}", + "sourceKind": "draft", + "retrievalScope": "public_pattern", + "productionRetrievalEligible": True, + } + ) + return cards[:top] + + return _searcher + + +class RetrievePatternReferencesTest(unittest.TestCase): + """_retrieve_pattern_references 的合同形状、总量控制与失败关闭。""" + + def test_projects_pointer_plus_content_fields(self): + """每张范式卡投影成「来源指针 + 内容字段」,且通过范式专用合同校验。""" + + refs = _retrieve_pattern_references( + "本章要完成一场决战", card_searcher=_stub_searcher(per_type=2) + ) + self.assertTrue(refs) + pointer_fields = {"sourceId", "sourceVersion", "sourceType"} + content_fields = {"name", "summary", "writingPoints"} + for index, ref in enumerate(refs): + # 来源指针必备;内容字段(stub 全给了)齐备;不允许合同白名单外的未知字段。 + self.assertTrue(pointer_fields.issubset(ref), ref) + self.assertTrue(set(ref) <= pointer_fields | content_fields, ref) + for field in ("sourceId", "sourceVersion", "sourceType", "name", "summary"): + self.assertIsInstance(ref[field], str) + self.assertTrue(ref[field].strip()) + # 必须已 NFC 归一化,合同 _string 会复核。 + self.assertEqual(ref[field], normalize_text(ref[field])) + # writingPoints 是字符串→字符串,字段数与值都在合同上限内。 + self.assertIsInstance(ref["writingPoints"], dict) + self.assertTrue(ref["writingPoints"]) + self.assertLessEqual(len(ref["writingPoints"]), PATTERN_POINTS_MAX_FIELDS) + for key, value in ref["writingPoints"].items(): + self.assertIsInstance(key, str) + self.assertIsInstance(value, str) + self.assertLessEqual(len(value), PATTERN_POINT_MAX_CHARS) + # 直接过范式专用合同 _pattern_source_ref:不抛异常即字段合法。 + _pattern_source_ref(ref, f"$.patternReferences[{index}]") + # sourceType 必须落在五型内(成为写手最终看到的 kind)。 + self.assertTrue(all(ref["sourceType"] in PATTERN_CARD_TYPES for ref in refs)) + + def test_content_truncated_to_contract_caps(self): + """卡内容过长/字段过多时,投影截断到合同上限以内(合同侧再失败关闭复核)。""" + + def _huge_searcher(intent: str, *, ttype: str, top: int) -> list[dict[str, object]]: + del intent, top + return [ + { + "type": ttype, + "name": "超长范式名" * 20, + "summary": "超长摘要" * 50, + # 10 个字段超过上限 6;值也超长,验证双重截断。 + "visibleFields": {f"字段{i:02d}": f"写法要点{i}" * 100 for i in range(10)}, + "sourceId": f"draft:{ttype}-huge", + "sourceVersion": "draft-revision:9", + "sourceKind": "draft", + "retrievalScope": "public_pattern", + "productionRetrievalEligible": True, + } + ] + + refs = _retrieve_pattern_references("意图", card_searcher=_huge_searcher) + self.assertTrue(refs) + for index, ref in enumerate(refs): + self.assertLessEqual(len(ref["name"]), PATTERN_NAME_MAX_CHARS) + self.assertLessEqual(len(ref["summary"]), PATTERN_SUMMARY_MAX_CHARS) + self.assertLessEqual(len(ref["writingPoints"]), PATTERN_POINTS_MAX_FIELDS) + for value in ref["writingPoints"].values(): + self.assertLessEqual(len(value), PATTERN_POINT_MAX_CHARS) + # 截断后仍须通过范式专用合同(合同侧也失败关闭)。 + _pattern_source_ref(ref, f"$.patternReferences[{index}]") + + def test_strict_source_ref_still_rejects_content_fields(self): + """放宽只针对 patternReferences:严格 _source_ref 仍把 name/summary 当未知字段拒收。""" + + refs = _retrieve_pattern_references("决战", card_searcher=_stub_searcher(per_type=1)) + self.assertTrue(refs) + # 前提:内容字段确已注入;拿给其它来源指针用的严格校验必须失败关闭。 + self.assertIn("name", refs[0]) + with self.assertRaises(ContractError): + _source_ref(refs[0], "$.factEvidence[0].sourceRef") + + def test_per_type_top_and_total_cap(self): + """默认每型 2 张合计 10;显式加大每型 top 时总量硬上限 12 生效。""" + + default_refs = _retrieve_pattern_references( + "决战意图", card_searcher=_stub_searcher(per_type=5) + ) + # 默认 top_per_type=2:五型 × 2 = 10。 + self.assertEqual(len(default_refs), len(PATTERN_CARD_TYPES) * 2) + + capped_refs = _retrieve_pattern_references( + "决战意图", + card_searcher=_stub_searcher(per_type=5), + top_per_type=5, + total_cap=PATTERN_TOTAL_CAP, + ) + # 5 型 × 5 = 25 会被硬上限裁到恰好 12。 + self.assertEqual(len(capped_refs), PATTERN_TOTAL_CAP) + self.assertLessEqual(len(capped_refs), PATTERN_TOTAL_CAP) + + def test_dedup_and_missing_pointer_skipped(self): + """跨型同卡只注入一次;缺稳定来源指针的卡被跳过而非混入空引用。""" + + def _dup_searcher(intent: str, *, ttype: str, top: int) -> list[dict[str, object]]: + del intent, top + # 所有型都返回同一张卡(同 sourceVersion+sourceId),应去重为 1 张。 + return [{ + "type": ttype, + "sourceId": "draft:shared", + "sourceVersion": "draft-revision:1", + "sourceKind": "draft", + "retrievalScope": "public_pattern", + "productionRetrievalEligible": True, + }] + + refs = _retrieve_pattern_references("意图", card_searcher=_dup_searcher) + self.assertEqual(len(refs), 1) + + def _missing_pointer(intent: str, *, ttype: str, top: int) -> list[dict[str, object]]: + del intent, top + # 缺 sourceVersion:不得进入冻结上下文。 + return [{ + "type": ttype, + "sourceId": f"draft:{ttype}", + "sourceKind": "draft", + "retrievalScope": "public_pattern", + "productionRetrievalEligible": True, + }] + + self.assertEqual(_retrieve_pattern_references("意图", card_searcher=_missing_pointer), []) + + def test_empty_intent_fails_closed(self): + """没有检索意图(细纲为空)就不召回。""" + + self.assertEqual(_retrieve_pattern_references("", card_searcher=_stub_searcher()), []) + self.assertEqual(_retrieve_pattern_references(" \n ", card_searcher=_stub_searcher()), []) + + def test_non_public_or_ineligible_cards_are_skipped(self): + """替换检索实现缺少公共范围资格时,loader 第二道门必须拒绝注入。""" + + def wrong_scope(_intent: str, *, ttype: str, top: int): + del top + return [{ + "type": ttype, + "sourceId": "draft:private", + "sourceVersion": "draft-revision:1", + "sourceKind": "draft", + "retrievalScope": "admin", + "productionRetrievalEligible": True, + }] + + self.assertEqual( + _retrieve_pattern_references("意图", card_searcher=wrong_scope), + [], + ) + + +class PatternReferencesForArmTest(unittest.TestCase): + """单变量对照在取数端的直接证据:A 恒空、C 才拿卡。""" + + def test_a_empty_c_nonempty(self): + refs = _retrieve_pattern_references("决战", card_searcher=_stub_searcher(per_type=2)) + self.assertTrue(refs) # 前提:C 臂候选非空 + # C 臂候选携带内容字段(写手真正读到卡内容)。 + self.assertTrue(all("name" in ref and "writingPoints" in ref for ref in refs)) + self.assertEqual(_pattern_references_for_arm("A", refs), []) # A 臂恒空,对照不破 + c_refs = _pattern_references_for_arm("C", refs) + self.assertEqual(c_refs, refs) + self.assertTrue(all("name" in ref and "writingPoints" in ref for ref in c_refs)) + # C 臂返回深拷贝,改动不污染源候选。 + c_refs[0]["sourceId"] = "被改写" + self.assertNotEqual(refs[0]["sourceId"], "被改写") + + +class PatternCreativeInputProjectionTest(unittest.TestCase): + """创作输入层证据:C 臂写手读到 name/summary/写法要点,A 臂为空,指针不外泄。""" + + def _creative_input(self, refs: list, arm: str) -> dict: + """把某臂的范式引用装进最小合法上下文,跑出写手唯一可见的创作输入。""" + + from test_writer_contract import valid_context # 复用合同测试的最小合法上下文夹具 + + context = valid_context() + context["patternReferences"] = _pattern_references_for_arm(arm, refs) + context["contextSnapshot"]["contextSha256"] = retrieval_identity(context) + return build_writer_creative_input(context) + + def test_c_arm_content_reaches_writer_and_a_arm_empty(self): + refs = _retrieve_pattern_references("决战", card_searcher=_stub_searcher(per_type=2)) + self.assertTrue(refs) + + creative_c = self._creative_input(refs, "C") + self.assertTrue(creative_c["patternReferences"]) + for index, item in enumerate(creative_c["patternReferences"]): + self.assertEqual(item["referenceId"], f"pattern-{index + 1}") + # 写手真正看到的范式卡内容:名字、一句话摘要、写法要点,加可作范式的型。 + self.assertTrue(item["name"]) + self.assertTrue(item["summary"]) + self.assertTrue(item["writingPoints"]) + self.assertIn(item["kind"], PATTERN_CARD_TYPES) + # 来源指针只供审计回读,绝不允许泄进写手输入。 + self.assertNotIn("sourceId", item) + self.assertNotIn("sourceVersion", item) + + creative_a = self._creative_input(refs, "A") + self.assertEqual(creative_a["patternReferences"], []) # A 臂恒空 + + +class PatternInjectionIntegrationTest(unittest.TestCase): + """经真实装配 dry-run 验证:C 注入范式卡过严格合同,A 保持空,门禁放行。""" + + def setUp(self): + self.base_config = _prepared_base_config() + self.selectors = json.loads(SELECTOR_PATH.read_text(encoding="utf-8")) + + def _assemble(self, searcher): + return assemble_writer_gate_config( + base_config=self.base_config, + selector_config=self.selectors, + selector_digest=SELECTOR_DIGEST, + rows=_assembly_rows(self.base_config), + pattern_card_searcher=searcher, + ) + + def test_c_arm_pattern_references_pass_strict_contract_and_diff_allowed(self): + """注入范式卡后装配成功(即过 validate_writer_context),且 A/C 差异落在白名单。""" + + record: list = [] + config = self._assemble(_stub_searcher(per_type=2, record=record)) + # 五个样本都按五型发起了检索(证明 C 臂取数真正接线)。 + self.assertTrue(record) + self.assertTrue(all(call["ttype"] in PATTERN_CARD_TYPES for call in record)) + + receipts = config["writerContextDiffReceipts"] + self.assertEqual(len(receipts), len(config["samples"])) + for sample in config["samples"]: + receipt = receipts[sample["sampleId"]] + self.assertTrue(receipt["ok"]) + # A/C 上下文哈希必须不同(C 多了范式卡)。 + self.assertNotEqual(receipt["contextSha256"]["A"], receipt["contextSha256"]["C"]) + # 差异里必须出现 patternReferences——因 A 臂硬编码为空,差异只可能来自 C 臂非空。 + pattern_paths = [ + path for path in receipt["allowedDifferencePaths"] if path.startswith("$.patternReferences") + ] + self.assertTrue(pattern_paths, receipt["allowedDifferencePaths"]) + # 白名单不含任何越界字段。 + self.assertTrue( + all( + path.startswith(("$.factConstraints", "$.proseExcerpts", "$.patternReferences")) + for path in receipt["allowedDifferencePaths"] + ) + ) + + def test_default_without_searcher_keeps_pattern_references_empty(self): + """不传检索器时保持历史行为:A/C 的 patternReferences 均空,差异不含该字段。""" + + config = self._assemble(None) + for sample in config["samples"]: + receipt = config["writerContextDiffReceipts"][sample["sampleId"]] + self.assertTrue(receipt["ok"]) + self.assertFalse( + any(path.startswith("$.patternReferences") for path in receipt["allowedDifferencePaths"]), + receipt["allowedDifferencePaths"], + ) + + +class PatternEndToEndLinkTest(unittest.TestCase): + """端到端链路回归:loader 装配 → config.json 序列化 → 回放端重新 assemble。 + + WHY 单独补这组用例:上一轮单点测试用 stub 直接喂装配器,只覆盖取数与单次 + assemble,没覆盖「C 臂候选冻结进 config.json、真写时回放再读回分臂」这条真实 + 链路——断点正藏在两段之间(loader 没把候选写进 writerContextInput.patternReferences, + 回放又把同一份原样喂给所有臂)。这里用 stub 检索器走完整链路:不连库、不调 + Claude,只证明范式卡在链路每一段的形态正确。 + """ + + def setUp(self): + self.base_config = _prepared_base_config() + self.selectors = json.loads(SELECTOR_PATH.read_text(encoding="utf-8")) + + def _loader_config(self, searcher): + """装配后做 JSON 往返,模拟 config.json 落盘再被回放读取的真实路径。""" + + config = assemble_writer_gate_config( + base_config=self.base_config, + selector_config=self.selectors, + selector_digest=SELECTOR_DIGEST, + rows=_assembly_rows(self.base_config), + pattern_card_searcher=searcher, + ) + return json.loads(json.dumps(config, ensure_ascii=False)) + + def _replay(self, config): + """走回放端重新 assemble 入口;内含与 loader 冻结回执的一致性复核。""" + + sample = config["samples"][0] + common = _common_controls(config, sample) + contexts, writer_contexts, diff_receipt, _requirements, _mode = _build_arm_contexts( + config=config, + sample=sample, + common=common, + replay_run_id="replay-e2e-pattern.v1", + ) + return writer_contexts, diff_receipt + + def test_pattern_references_survive_config_roundtrip_into_c_arm_only(self): + """C 臂候选经 config.json 往返后,真写重组装只有 C 臂拿到范式卡内容。""" + + config = self._loader_config(_stub_searcher(per_type=2)) + sample = config["samples"][0] + + # 段间证据(上一轮的断点):loader 已把 C 臂候选冻结进 writerContextInput。 + frozen_refs = sample["writerContextInput"]["patternReferences"] + self.assertTrue(frozen_refs) + for ref in frozen_refs: + self.assertTrue(ref["name"]) + self.assertTrue(ref["summary"]) + self.assertTrue(ref["writingPoints"]) + + writer_contexts, diff_receipt = self._replay(config) + + # C 臂真写上下文拿到范式卡内容;A 臂恒空,单变量对照不破。 + c_refs = writer_contexts["C"]["patternReferences"] + self.assertTrue(c_refs) + for ref in c_refs: + self.assertTrue(ref["name"]) + self.assertTrue(ref["summary"]) + self.assertTrue(ref["writingPoints"]) + self.assertEqual(writer_contexts["A"]["patternReferences"], []) + # B 臂按合同单一事实源语义同属「有卡」处理组(非 A 即拿候选)。 + self.assertEqual( + writer_contexts["B"]["patternReferences"], + pattern_references_for_arm("B", frozen_refs), + ) + + # 单变量门禁回执放行,且差异确实落在 patternReferences 白名单。 + self.assertTrue(diff_receipt["ok"]) + self.assertTrue( + any( + path.startswith("$.patternReferences") + for path in diff_receipt["allowedDifferencePaths"] + ), + diff_receipt["allowedDifferencePaths"], + ) + + # 写手唯一可见的创作输入:C 臂含范式内容、A 臂空;来源指针只留冻结上下文。 + creative_c = build_writer_creative_input(writer_contexts["C"])["patternReferences"] + self.assertTrue(creative_c) + for item in creative_c: + self.assertTrue(item["name"]) + self.assertTrue(item["summary"]) + self.assertTrue(item["writingPoints"]) + self.assertNotIn("sourceId", item) + self.assertNotIn("sourceVersion", item) + self.assertEqual( + build_writer_creative_input(writer_contexts["A"])["patternReferences"], [] + ) + + def test_replay_keeps_both_arms_empty_for_legacy_config_without_searcher(self): + """loader 未接检索器的历史 config:writerContextInput 为空列表,回放两臂恒空。""" + + config = self._loader_config(None) + sample = config["samples"][0] + self.assertEqual(sample["writerContextInput"]["patternReferences"], []) + + writer_contexts, diff_receipt = self._replay(config) + self.assertEqual(writer_contexts["A"]["patternReferences"], []) + self.assertEqual(writer_contexts["C"]["patternReferences"], []) + self.assertFalse( + any( + path.startswith("$.patternReferences") + for path in diff_receipt["allowedDifferencePaths"] + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.claude/skills/replay-eval/scripts/test_run_writer_replay.py b/.claude/skills/replay-eval/scripts/test_run_writer_replay.py index d25cb00..d2ad179 100644 --- a/.claude/skills/replay-eval/scripts/test_run_writer_replay.py +++ b/.claude/skills/replay-eval/scripts/test_run_writer_replay.py @@ -7,7 +7,9 @@ import copy import hashlib import inspect import json +import os import pathlib +import signal import subprocess import sys import tempfile @@ -26,6 +28,7 @@ for import_path in (SCRIPT_DIR, READ_CONTEXT_DIR, RUNTIME_DIR, QUALITY_GATE_DIR) sys.path.insert(0, str(import_path)) import run_writer_replay as replay_module # noqa: E402 +import run_writer_replay.execute as execute_module # noqa: E402 from run_writer_replay import ( # noqa: E402 BudgetLedgerError, WriterReplayError, @@ -33,15 +36,30 @@ from run_writer_replay import ( # noqa: E402 WriterReplayTestAdapters, run_writer_replay, ) -from claude_runtime import ExecutionProfile, sha256_json, sha256_text # noqa: E402 +from run_writer_replay.blind import ( # noqa: E402 + _SOURCE_REF_ALLOWED, + _clean_source_ref, + _project_oracle_pack, + _semantic_input_v3, +) +from claude_runtime import ClaudeRuntimeError, ExecutionProfile, sha256_json, sha256_text # noqa: E402 from file_cas import CasConflictError, FileCasStore # noqa: E402 from gate_input_builder import GateInputBuildError, GateInputBuilder, canonical_sha256 # noqa: E402 from raw_vault import RawVaultError, RawVaultManager # noqa: E402 from run_writer_blind_judge import BLIND_JUDGE_REPORT_JSON_SCHEMA # noqa: E402 from run_writer_semantic_detector import SEMANTIC_DETECTOR_REPORT_JSON_SCHEMA # noqa: E402 from run_writer import build_writer_execution_profile # noqa: E402 +from writer_eval_preregister import build_balanced_preregistration # noqa: E402 +from writer_gate import decide_gate # noqa: E402 from writer_contract import calculate_target_chars, han_count, validate_writer_context # noqa: E402 -from writer_rubric import DIMENSIONS, RUBRIC_PROFILE, adjudicate_structured_reviews # noqa: E402 +from writer_rubric import ( # noqa: E402 + COMMON_DIMENSIONS, + DIMENSIONS, + RUBRIC_POLICY_VERSION, + RUBRIC_PROFILE, + SCENARIO_DIMENSION, + adjudicate_structured_reviews, +) AUTHORIZATION = { @@ -204,7 +222,7 @@ def config() -> dict[str, object]: "samples": [ { "sampleId": "deep-space-489", - "scenario": "combat", + "scenario": "battle", "asOfChapter": 488, "targetChapter": 489, "snapshotVersion": "writer-deep-space-489-v1", @@ -365,6 +383,9 @@ class FakeSubprocessRunner: "minChars", "maxChars", "targetChars", + "countingRule", + "revisionDirection", + "targetDeltaHanChars", "instruction", } revision = context["lengthRevision"] @@ -372,6 +393,45 @@ class FakeSubprocessRunner: raise AssertionError( f"lengthRevision 块字段非法: {sorted(revision) if isinstance(revision, dict) else revision}" ) + if revision["countingRule"] != "han_chars_only": + raise AssertionError("lengthRevision 计数口径非法") + if revision["revisionDirection"] not in {"expand", "trim"}: + raise AssertionError("lengthRevision 修订方向非法") + numeric_fields = { + "actualHanChars", + "minChars", + "maxChars", + "targetChars", + "targetDeltaHanChars", + } + if any( + not isinstance(revision[field], int) or isinstance(revision[field], bool) + for field in numeric_fields + ): + raise AssertionError("lengthRevision 数字字段非法") + expected_delta = abs(revision["targetChars"] - revision["actualHanChars"]) + if revision["targetDeltaHanChars"] != expected_delta: + raise AssertionError("lengthRevision 距目标差值未按当前正文重算") + expected_direction = ( + "expand" + if revision["actualHanChars"] < revision["targetChars"] + else "trim" + ) + if revision["revisionDirection"] != expected_direction: + raise AssertionError("lengthRevision 方向未按当前正文重算") + + +class InterruptingRunner(FakeSubprocessRunner): + """模拟外部终止落在模型调用中间。""" + + def __init__(self, *, interrupt_on_call: int) -> None: + super().__init__(include_legacy_result=False) + self.interrupt_on_call = interrupt_on_call + + def __call__(self, command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + if len(self.calls) + 1 == self.interrupt_on_call: + signal.raise_signal(signal.SIGTERM) + return super().__call__(command, **kwargs) class FakeSemanticDetector: @@ -720,11 +780,93 @@ class ProductionSemanticRunner: raise AssertionError("semantic adapter 未传正式 v3 model schema") +class NeedsEvidenceSemanticRunner(ProductionSemanticRunner): + """在指定调用生成合同合法但需要补证的 detector 草稿。""" + + def __init__(self, *, needs_evidence_on_call: int | None = None) -> None: + super().__init__() + self.needs_evidence_on_call = needs_evidence_on_call + + def run(self, *, adapter_role, model_input, output_schema): + result = super().run( + adapter_role=adapter_role, + model_input=model_input, + output_schema=output_schema, + ) + if ( + self.needs_evidence_on_call is not None + and len(self.calls) != self.needs_evidence_on_call + ): + return result + draft = result["structuredOutput"] + draft["evidenceGaps"] = [ + { + "gapId": "gap-1", + "query": "补查候选新事实", + "reason": "冻结证据不足", + "priority": "high", + "candidateQuote": model_input["candidateBody"][:3], + } + ] + self.structured_outputs[-1] = copy.deepcopy(draft) + self.receipts[-1]["structuredOutputSha256"] = canonical_sha256(draft) + result["modelReceiptSha256"] = sha256_json(self.receipts[-1]) + return result + + +class InvalidQuoteSemanticRunner(ProductionSemanticRunner): + """每轮都返回候选中不存在的引文,稳定耗尽 detector 纠错。""" + + def run(self, *, adapter_role, model_input, output_schema): + result = super().run( + adapter_role=adapter_role, + model_input=model_input, + output_schema=output_schema, + ) + draft = result["structuredOutput"] + for field in ("assertionVerdicts", "hardConstraintVerdicts"): + for item in draft[field]: + item["candidateQuote"] = "候选中不存在的引文" + self.structured_outputs[-1] = copy.deepcopy(draft) + self.receipts[-1]["structuredOutputSha256"] = canonical_sha256(draft) + result["modelReceiptSha256"] = sha256_json(self.receipts[-1]) + return result + + +class CorrectingSemanticRunner(ProductionSemanticRunner): + """首轮引文非法、带 correction 的第二轮恢复合法。""" + + def run(self, *, adapter_role, model_input, output_schema): + result = super().run( + adapter_role=adapter_role, + model_input=model_input, + output_schema=output_schema, + ) + if len(self.calls) != 1: + return result + draft = result["structuredOutput"] + for field in ("assertionVerdicts", "hardConstraintVerdicts"): + for item in draft[field]: + item["candidateQuote"] = "候选中不存在的首轮引文" + self.structured_outputs[-1] = copy.deepcopy(draft) + self.receipts[-1]["structuredOutputSha256"] = canonical_sha256(draft) + result["modelReceiptSha256"] = sha256_json(self.receipts[-1]) + return result + + class ProductionJudgeRunner: """动态回放 blind judge v3 模型草稿,并可制造第三评不稳定。""" - def __init__(self, *, unstable: bool = False) -> None: + def __init__( + self, + *, + unstable: bool = False, + invalid_first_quote: bool = False, + api_error_first: bool = False, + ) -> None: self.unstable = unstable + self.invalid_first_quote = invalid_first_quote + self.api_error_first = api_error_first self.calls: list[dict[str, object]] = [] self.receipts: list[dict[str, object]] = [] self.structured_outputs: list[dict[str, object]] = [] @@ -796,12 +938,35 @@ class ProductionJudgeRunner: for constraint_id in constraint_ids ], } + if self.invalid_first_quote and call_index == 1: + draft["candidateScores"][0]["scores"][DIMENSIONS[0]][ + "candidateQuote" + ] = "候选正文中不存在的首轮引文" receipt = _fake_model_receipt( adapter_role, call_index, dict(model_input), draft, ) + if self.api_error_first and call_index == 1: + receipt.update( + { + "actualModelId": None, + "modelMatch": False, + "totalCostUsd": "0.000000", + "terminalReason": "api_error", + "isError": True, + "apiErrorStatus": None, + "exitCode": 1, + "structuredOutputSha256": None, + } + ) + self.receipts.append(receipt) + raise ClaudeRuntimeError( + "BLIND_JUDGE_API_ERROR", + "测试瞬时 API 错误", + receipt=receipt, + ) self.receipts.append(receipt) self.structured_outputs.append(copy.deepcopy(draft)) receipt_hash = sha256_json(receipt) @@ -887,10 +1052,61 @@ def _production_config_with_writer_budget( return value -def _oracle_pack() -> dict[str, object]: - """构造由所有 reviewer 共用、但 detector 不可见的最小 oracle。""" +def _semantic_call_index_for_arm(evaluation_config: dict[str, object], arm: str) -> int: + """返回单样本预注册顺序中指定臂对应的 detector 调用序号。""" - return { + preregistration = build_balanced_preregistration( + evaluation_set_version=str(evaluation_config["evaluationSetVersion"]), + sample_ids=[str(evaluation_config["samples"][0]["sampleId"])], + ) + order = preregistration["armOrderTable"][0]["armOrder"] + return order.index(arm) + 1 + + +def _five_scenario_gate_input(gate_input: dict[str, object]) -> dict[str, object]: + """把单样本生产链结果扩成五场景,只用于机械验证 Gate A 的 C 臂裁决。""" + + scenarios = ( + "battle", + "character_dialogue", + "turning_point", + "information_reveal", + "returning_character", + ) + expanded = copy.deepcopy(gate_input) + template = expanded["samples"][0] + expanded["samples"] = [] + for index, scenario in enumerate(scenarios, start=1): + sample = copy.deepcopy(template) + sample["sampleId"] = f"gate-a-c-failed-{index}" + sample["scenario"] = scenario + expanded["samples"].append(sample) + expanded.pop("builderReceiptSha256", None) + expanded.pop("gateInputSha256", None) + expanded["builderReceiptSha256"] = canonical_sha256(expanded) + expanded["gateInputSha256"] = canonical_sha256(expanded) + return expanded + + +def _oracle_pack() -> dict[str, object]: + """构造正式 loader 形状的 oracle;执行时再投影成 reviewer 最小合同。""" + + target_statement = "目标章不得提前泄露终局真相" + historical = [] + for chapter in range(485, 489): + statement = f"林澈在第{chapter}章仍位于圣蒂曼" + historical.append( + { + "assertionId": f"assertion-history-{chapter}", + "assertionType": "canonical_history", + "statement": statement, + "sourceVersion": f"canonical-v{chapter}", + "chapterBoundary": {"minChapter": chapter, "maxChapter": chapter}, + "contentSha256": "sha256:" + + hashlib.sha256(statement.encode("utf-8")).hexdigest(), + } + ) + payload = { "schemaVersion": "oracle-truth-pack-v1", "evaluationSetVersion": "writer-gate-a-test-v1", "sampleId": "deep-space-489", @@ -898,24 +1114,35 @@ def _oracle_pack() -> dict[str, object]: "asOf": 488, "sourceSnapshotSha256": "sha256:" + "7" * 64, "authorizationSnapshotId": "auth-work-8", - "historicalAssertions": [ + "authorization": { + "allowedPurpose": "offline_evaluation", + "sourceStatus": "authorized", + "sourceVersion": "canonical-v488", + "revalidationAt": "2026-07-25T00:00:00+00:00", + "evaluatorOnly": True, + "snapshotId": "auth-work-8", + }, + "historicalAssertions": historical, + "targetAssertions": [ { - "assertionId": "assertion-history-1", - "text": "林澈仍在圣蒂曼", + "assertionId": "assertion-target-1", + "assertionType": "target_reference_scaffold", + "statement": target_statement, "sourceVersion": "canonical-v488", - "chapterStart": 488, - "chapterEnd": 488, - "contentSha256": "sha256:" + "8" * 64, + "chapterBoundary": {"minChapter": 489, "maxChapter": 489}, + "contentSha256": "sha256:" + hashlib.sha256(target_statement.encode("utf-8")).hexdigest(), } ], - "targetAssertions": [], } + return {**payload, "packSha256": canonical_sha256(payload)} def _production_adapters( *, semantic_fail_on: int | None = None, judge_unstable: bool = False, + judge_invalid_first_quote: bool = False, + judge_api_error_first: bool = False, vault_factory=RawVaultManager, cas_factory=FileCasStore, builder_factory=GateInputBuilder, @@ -924,7 +1151,11 @@ def _production_adapters( writer_runner = FakeSubprocessRunner(include_legacy_result=False) semantic_runner = ProductionSemanticRunner(fail_on_call=semantic_fail_on) - judge_runner = ProductionJudgeRunner(unstable=judge_unstable) + judge_runner = ProductionJudgeRunner( + unstable=judge_unstable, + invalid_first_quote=judge_invalid_first_quote, + api_error_first=judge_api_error_first, + ) writer_profile = _production_writer_profile() semantic_profile = _role_profile("semantic_detector", SEMANTIC_DETECTOR_REPORT_JSON_SCHEMA) judge_profile = _role_profile("blind_judge", BLIND_JUDGE_REPORT_JSON_SCHEMA) @@ -946,8 +1177,38 @@ def _production_adapters( class WriterReplayDryRunTest(unittest.TestCase): """验证 dry-run 的合同、冻结、安全和控制变量。""" + def test_pattern_references_are_included_in_leakage_audit(self): + """范式卡若含目标章禁用事实,dry-run 必须在装配各臂前失败关闭。""" + + evaluation_config = config() + evaluation_config["samples"][0]["writerContextInput"]["patternReferences"] = [ + { + "sourceId": "fixture:public-pattern:1", + "sourceVersion": "public-pattern-v1", + "name": "转折范式", + "summary": "目标章专属秘密", + "writingPoints": {"转折": "先压后扬"}, + } + ] + + result = run_writer_replay(evaluation_config, run_id="pattern-leakage-dry-run") + + self.assertFalse(result["ok"]) + self.assertEqual(result["status"], "invalid_leakage") + self.assertEqual(result["samples"][0]["status"], "invalid_snapshot") + findings = result["samples"][0]["leakageAudit"]["findings"] + self.assertTrue( + any("patternReferences" in finding["path"] for finding in findings), + findings, + ) + @staticmethod - def _ledger(*, planned: int = 1, total: str = "3.000000"): + def _ledger( + *, + planned: int = 1, + total: str = "3.000000", + pre_call_guard=None, + ): """构造只用于账本边界测试的三角色 profile。""" profiles = { @@ -962,8 +1223,53 @@ class WriterReplayDryRunTest(unittest.TestCase): planned_calls={role: planned for role in replay_module.BUDGET_ROLES}, max_calls={role: planned for role in replay_module.BUDGET_ROLES}, profiles=profiles, + pre_call_guard=pre_call_guard, ) + def test_raw_guard_runs_before_budget_slot_is_consumed(self): + """下一调用租期不足时,不得消耗计划槽位或制造在途成本。""" + + checked_roles = [] + + def reject(role): + checked_roles.append(role) + raise RawVaultError( + "RAW_LEASE_INSUFFICIENT_RETENTION", + "测试下一调用租期不足", + ) + + ledger = self._ledger(pre_call_guard=reject) + + with self.assertRaisesRegex(RawVaultError, "测试下一调用租期不足"): + ledger.begin("writer") + + self.assertEqual(checked_roles, ["writer"]) + self.assertEqual( + ledger.snapshot()["usedCalls"], + {"writer": 0, "semantic_detector": 0, "blind_judge": 0}, + ) + self.assertEqual(ledger.snapshot()["inFlightRoles"], []) + self.assertFalse(ledger.snapshot()["costUnknown"]) + + def test_raw_call_window_uses_one_role_timeout_plus_cleanup_margin(self): + """固定时钟证明单次 30 秒 timeout 加 60 秒清理余量的精确边界。""" + + now = datetime(2026, 7, 27, 0, 0, tzinfo=timezone.utc) + profile = _role_profile("semantic_detector", SEMANTIC_DETECTOR_REPORT_JSON_SCHEMA) + + replay_module._assert_raw_call_window( + (now + timedelta(seconds=90)).isoformat(), + profile, + now=now, + ) + with self.assertRaises(RawVaultError) as raised: + replay_module._assert_raw_call_window( + (now + timedelta(seconds=89)).isoformat(), + profile, + now=now, + ) + self.assertEqual(raised.exception.code, "RAW_LEASE_INSUFFICIENT_RETENTION") + def test_budget_ledger_settles_failed_call_receipt_before_reraising(self): """模型失败但带可信回执时,实际成本仍必须进入账本。""" @@ -1033,6 +1339,26 @@ class WriterReplayDryRunTest(unittest.TestCase): ledger.complete("writer", token, receipt) self.assertEqual(ledger.snapshot()["totalActualCostUsd"], "0.010000") + def test_budget_settlement_keeps_in_flight_until_cost_is_recorded(self): + """结算中断时在途标记必须仍在,供信号处理转成成本未知。""" + + class InterruptingSet(set): + def add(self, _value): + raise replay_module.ReplayInterrupted("测试结算窗口") + + ledger = self._ledger() + token = ledger.begin("writer") + ledger._settled_invocations = InterruptingSet() + receipt = _fake_model_receipt("writer", 1, {"input": "x"}, {"output": "y"}) + + with self.assertRaises(replay_module.ReplayInterrupted): + ledger.complete("writer", token, receipt) + + self.assertEqual(ledger.snapshot()["inFlightRoles"], ["writer"]) + with self.assertRaisesRegex(BudgetLedgerError, "EXECUTION_COST_UNKNOWN"): + ledger.mark_cost_unknown("writer", token) + self.assertTrue(ledger.snapshot()["costUnknown"]) + def test_budget_ledger_rejects_missing_cost_and_over_cap(self): """缺成本和超过单次 cap 都必须 fail closed,不能静默按零计费。""" @@ -1054,15 +1380,15 @@ class WriterReplayDryRunTest(unittest.TestCase): over_cap.complete("writer", token, receipt) self.assertEqual(over_cap.snapshot()["failureReason"], "EXECUTION_COST_OVER_CAP") - def test_budget_plan_requires_closed_planned_calls_and_reserves_only_375(self): - """Gate A 启动按 writer 45(15 基础+30 修订)与两角色各 15 乘 $5 cap 预留 $375,maxCalls 不是预算计划。""" + def test_budget_plan_requires_closed_planned_calls_and_reserves_570(self): + """45/24/45 计划乘 $5 预留 $570;maxCalls 仍只是安全容量上限。""" required = {role: 15 for role in replay_module.BUDGET_ROLES} caps = {role: Decimal("5.000000") for role in replay_module.BUDGET_ROLES} valid = { "status": "approved", - "totalBudgetUsd": "450.000000", - "plannedCalls": {"writer": 45, "semantic_detector": 15, "blind_judge": 15}, + "totalBudgetUsd": "2250.000000", + "plannedCalls": {"writer": 60, "semantic_detector": 24, "blind_judge": 45}, "maxCalls": {role: 150 for role in replay_module.BUDGET_ROLES}, } planned, maximum, total, error = replay_module._validate_budget_plan( @@ -1071,10 +1397,10 @@ class WriterReplayDryRunTest(unittest.TestCase): self.assertIsNone(error) self.assertEqual(planned, valid["plannedCalls"]) self.assertEqual(maximum, valid["maxCalls"]) - self.assertEqual(total, Decimal("450.000000")) + self.assertEqual(total, Decimal("2250.000000")) self.assertEqual( sum(caps[role] * planned[role] for role in replay_module.BUDGET_ROLES), - Decimal("375.000000"), + Decimal("645.000000"), ) cases = ( @@ -1083,7 +1409,7 @@ class WriterReplayDryRunTest(unittest.TestCase): ("invalid-type", lambda budget: budget["plannedCalls"].update({"writer": "15"}), "BUDGET_PLANNED_CALLS_INSUFFICIENT"), ("insufficient", lambda budget: budget["plannedCalls"].update({"writer": 14}), "BUDGET_PLANNED_CALLS_INSUFFICIENT"), ("planned-over-max", lambda budget: budget["plannedCalls"].update({"writer": 151}), "BUDGET_MAX_CALLS_INSUFFICIENT"), - ("insufficient-total", lambda budget: budget.update({"totalBudgetUsd": "374.999999"}), "BUDGET_AUTHORIZATION_INVALID"), + ("insufficient-total", lambda budget: budget.update({"totalBudgetUsd": "644.999999"}), "BUDGET_AUTHORIZATION_INVALID"), ) for name, mutate, expected in cases: with self.subTest(name=name): @@ -1119,8 +1445,8 @@ class WriterReplayDryRunTest(unittest.TestCase): budget["receiptSha256"], canonical_sha256({key: value for key, value in budget.items() if key != "receiptSha256"}), ) - self.assertEqual(budget["plannedCalls"]["writer"], 45) - self.assertEqual(budget["totalBudgetUsd"], "450.000000") + self.assertEqual(budget["plannedCalls"]["writer"], 60) + self.assertEqual(budget["totalBudgetUsd"], "2250.000000") for sample in gate_config["samples"]: target = sample["targetChars"] expected_min, expected_max = replay_module._length_bounds(target) @@ -1146,7 +1472,11 @@ class WriterReplayDryRunTest(unittest.TestCase): lambda context, call_index: {"candidateBody": short_body}, ): result = run_writer_replay( - _production_config(), + _production_config_with_writer_budget( + planned=4, + max_calls=4, + total_usd="12.000000", + ), run_id="length-violation", output_dir=pathlib.Path(directory) / "run", execute=True, @@ -1200,6 +1530,43 @@ class WriterReplayDryRunTest(unittest.TestCase): self.assertNotIn("reportSha256", result["structuredOutput"]) self.assertEqual(result["modelReceiptSha256"], sha256_json(receipt)) + def test_recording_runner_preserves_failure_receipt_before_reraising(self): + """失败回执必须留在 runner,外层才能保留真实错误码并结算可信费用。""" + + profile = _role_profile("semantic_detector", SEMANTIC_DETECTOR_REPORT_JSON_SCHEMA) + receipt = _fake_model_receipt( + "semantic_detector", 1, {"candidateBody": "甲"}, {"error": "api"} + ) + + class Receipt: + def as_dict(self): + return copy.deepcopy(receipt) + + original = replay_module.run_claude + + def fail(_profile, _input): + raise ClaudeRuntimeError( + "SEMANTIC_DETECTOR_API_ERROR", + "模型调用失败", + receipt=Receipt(), + ) + + replay_module.run_claude = fail + try: + runner = replay_module._RecordingRuntimeModelRunner(profile) + with self.assertRaises(ClaudeRuntimeError) as caught: + runner.run( + adapter_role="semantic_detector", + model_input={"candidateBody": "甲"}, + output_schema=SEMANTIC_DETECTOR_REPORT_JSON_SCHEMA, + ) + finally: + replay_module.run_claude = original + + self.assertEqual(caught.exception.primary_code, "SEMANTIC_DETECTOR_API_ERROR") + self.assertEqual(runner.receipts, [receipt]) + self.assertEqual(runner.structured_outputs, []) + def test_three_arms_validate_full_context_and_only_change_evidence_strategy(self): result = run_writer_replay(config(), run_id="dry-1") @@ -1400,19 +1767,29 @@ class WriterReplayDryRunTest(unittest.TestCase): self.assertEqual(gate_config["executionAuthorization"]["budget"]["status"], "approved") self.assertEqual( gate_config["executionAuthorization"]["budget"]["totalBudgetUsd"], - "450.000000", + "2250.000000", ) profile_cap = Decimal("5.000000") + self.assertEqual( + gate_config["executionAuthorization"]["budget"]["plannedCalls"], + {"writer": 60, "semantic_detector": 24, "blind_judge": 45}, + ) + self.assertEqual( + profile_cap * sum( + gate_config["executionAuthorization"]["budget"]["plannedCalls"].values() + ), + Decimal("645.000000"), + ) max_calls = gate_config["executionAuthorization"]["budget"]["maxCalls"] worst_case_budget = profile_cap * sum(max_calls.values()) self.assertEqual(worst_case_budget, Decimal("2250.000000")) - self.assertGreater( + self.assertEqual( worst_case_budget, Decimal(gate_config["executionAuthorization"]["budget"]["totalBudgetUsd"]), ) self.assertEqual(gate_config["executionAuthorization"]["rawRetention"]["status"], "approved") - self.assertIn("总预算 450 美元", gate_config["executionAuthorization"]["budget"]["reason"]) - self.assertIn("maxCalls 为各 150 次安全上限", gate_config["executionAuthorization"]["budget"]["reason"]) + self.assertIn("2250 美元覆盖三角色各 150 次", gate_config["executionAuthorization"]["budget"]["reason"]) + self.assertIn("安全上限", gate_config["executionAuthorization"]["budget"]["reason"]) def test_gate_a_formal_zero_budget_or_zero_ac_difference_fails_closed(self): """正式 Gate A 必须真实改变 writer 创作输入,不能只改变隐藏索引。""" @@ -1533,7 +1910,7 @@ class WriterReplayDryRunTest(unittest.TestCase): tampered["oracleTruthPacks"] = {} budget = tampered["executionAuthorization"]["budget"] self.assertEqual(budget["status"], "approved") - self.assertEqual(budget["totalBudgetUsd"], "450.000000") + self.assertEqual(budget["totalBudgetUsd"], "2250.000000") budget["status"] = "approved" budget.pop("totalBudgetUsd", None) budget["receiptSha256"] = canonical_sha256( @@ -1821,7 +2198,14 @@ class WriterReplayExecuteBoundaryTest(unittest.TestCase): class WriterReplayProductionIntegrationTest(unittest.TestCase): """用生产 fake 串通 Vault、CAS、v2 adapter 与 GateInputBuilder。""" - def _run(self, adapters, *, evaluation_config=None, run_id="production-fake"): + def _run( + self, + adapters, + *, + evaluation_config=None, + run_id="production-fake", + raw_archive_dir=None, + ): """在安全临时目录执行一次生产链,并返回结果与输出目录内容。""" directory = tempfile.TemporaryDirectory(dir="/private/tmp") @@ -1831,11 +2215,106 @@ class WriterReplayProductionIntegrationTest(unittest.TestCase): evaluation_config or _production_config(), run_id=run_id, output_dir=output, + raw_archive_dir=raw_archive_dir, execute=True, production_adapters=adapters, ) return result, output + def test_default_runtime_requires_explicit_raw_archive_before_vault_or_model(self): + """正式默认 runtime 未给仓外归档根时必须在建 vault 和调模型前失败关闭。""" + + evaluation_config = _production_config() + adapters, writer, semantic, judge = _production_adapters() + authorized, blocked_status, blocked_code = replay_module._validate_execute_authorization( + evaluation_config, adapters + ) + self.assertIsNotNone(authorized, (blocked_status, blocked_code)) + directory = tempfile.TemporaryDirectory(dir="/private/tmp") + self.addCleanup(directory.cleanup) + output = pathlib.Path(directory.name) / "run" + + with mock.patch.object( + replay_module, + "_validate_execute_authorization", + return_value=(authorized, None, None), + ): + result = run_writer_replay( + evaluation_config, + run_id="missing-raw-archive", + output_dir=output, + execute=True, + ) + + self.assertFalse(result["ok"]) + self.assertEqual(result["status"], "blocked_raw_archive") + self.assertEqual(result["errors"], ["RAW_ARCHIVE_REQUIRED"]) + self.assertEqual(writer.calls, []) + self.assertEqual(semantic.calls, []) + self.assertEqual(judge.calls, []) + self.assertFalse((output / "journal" / "raw-vault").exists()) + + def test_relative_raw_archive_blocks_before_vault_or_model(self): + """显式归档根必须是绝对路径,不能退回仓库相对目录。""" + + adapters, writer, semantic, judge = _production_adapters() + + result, output = self._run( + adapters, + run_id="relative-raw-archive", + raw_archive_dir=pathlib.Path("relative/archive"), + ) + + self.assertFalse(result["ok"]) + self.assertEqual(result["status"], "blocked_raw_archive") + self.assertEqual(result["errors"], ["RAW_ARCHIVE_INVALID"]) + self.assertEqual(writer.calls, []) + self.assertEqual(semantic.calls, []) + self.assertEqual(judge.calls, []) + self.assertFalse((output / "journal" / "raw-vault").exists()) + + @staticmethod + def _cas_safe_summary(output: pathlib.Path, sample_id: str) -> dict[str, object]: + cas_root = output / "journal" / "cas" / sample_id + state = json.loads((cas_root / "state.json").read_text(encoding="utf-8")) + return json.loads( + (cas_root / "artifacts" / state["safeSummaryArtifact"]).read_text( + encoding="utf-8" + ) + ) + + def test_oracle_projection_keeps_only_common_recent_four_chapters(self): + """完整源包可验真全历史,但 blind judge 只能收到共同的最近四章与目标断言。""" + + source = _oracle_pack() + statement = "第484章的更早历史只留在受控 oracle 源包" + source["historicalAssertions"].insert( + 0, + { + "assertionId": "assertion-history-484", + "assertionType": "canonical_history", + "statement": statement, + "sourceVersion": "canonical-v484", + "chapterBoundary": {"minChapter": 484, "maxChapter": 484}, + "contentSha256": "sha256:" + + hashlib.sha256(statement.encode("utf-8")).hexdigest(), + }, + ) + source["packSha256"] = canonical_sha256( + {key: value for key, value in source.items() if key != "packSha256"} + ) + + projected = _project_oracle_pack(source) + + self.assertEqual( + [item["chapterStart"] for item in projected["historicalAssertions"]], + [485, 486, 487, 488], + ) + self.assertTrue(projected["targetAssertions"]) + self.assertNotIn("authorization", projected) + self.assertNotIn("assertionType", json.dumps(projected, ensure_ascii=False)) + self.assertNotIn("第484章", json.dumps(projected, ensure_ascii=False)) + def test_production_execute_rejects_sanitized_fixture_before_vault_or_runner(self): """真实 execute 只接受 loader 产出的 canonical_frozen_prose。""" @@ -1864,6 +2343,340 @@ class WriterReplayProductionIntegrationTest(unittest.TestCase): self.assertEqual(judge.calls, []) self.assertFalse((output / "journal" / "raw-vault").exists()) + def test_default_runtime_missing_auth_blocks_before_vault_or_budget_slot(self): + """真实 runtime 缺显式 token 时必须在任何副作用前返回准确阻断码。""" + + config = _production_config() + adapters, _writer, _semantic, _judge = _production_adapters() + with ( + mock.patch.dict(os.environ, {}, clear=True), + mock.patch( + "run_writer_replay._production_adapters_from_config", + return_value=adapters, + ), + tempfile.TemporaryDirectory(dir="/private/tmp") as directory, + ): + output = pathlib.Path(directory) / "run" + result = run_writer_replay( + config, + run_id="runtime-auth-missing", + output_dir=output, + execute=True, + ) + self.assertFalse((output / "journal" / "raw-vault").exists()) + + self.assertFalse(result["ok"]) + self.assertEqual(result["status"], "blocked_runtime_authentication") + self.assertEqual(result["errors"], ["RUNTIME_AUTHENTICATION_REQUIRED"]) + self.assertEqual(result["samples"][0]["status"], "ready") + self.assertNotIn("budgetLedger", result) + + def test_planned_timeout_sum_does_not_block_next_call_safe_run(self): + """理论调用总时长超过租期时,只要每笔调用窗口充足就允许真实编排继续。""" + + vault_calls = [] + + def vault_factory(path): + vault_calls.append(path) + return RawVaultManager(path) + + adapters, writer, semantic, judge = _production_adapters(vault_factory=vault_factory) + config = _production_config() + raw = config["executionAuthorization"]["rawRetention"] + raw["retainUntil"] = (datetime.now(timezone.utc) + timedelta(minutes=4)).isoformat() + raw["receiptSha256"] = canonical_sha256( + {key: value for key, value in raw.items() if key != "receiptSha256"} + ) + + result, output = self._run( + adapters, + evaluation_config=config, + run_id="next-call-retention-allowed", + ) + + # 测试 profile 每角色 plannedCalls=3、timeout=30 秒,理论总和加清理为 330 秒, + # 高于 4 分钟租期;单次调用只需 30+60=90 秒,所以不应被总和误阻断。 + self.assertTrue(result["ok"], result) + self.assertEqual(result["status"], "completed") + self.assertEqual(len(vault_calls), 1) + self.assertEqual((len(writer.calls), len(semantic.calls), len(judge.calls)), (3, 3, 2)) + self.assertTrue((output / "gate-input.json").exists()) + + def test_b_needs_evidence_keeps_candidate_and_reaches_judge_and_gate(self): + """B 臂合法补证信号属于对照质量,不得误报为执行系统失败。""" + + adapters, _writer, _semantic, judge = _production_adapters() + config = _production_config() + semantic = NeedsEvidenceSemanticRunner( + needs_evidence_on_call=_semantic_call_index_for_arm(config, "B") + ) + adapters = replace(adapters, semantic_model_runner=semantic) + + result, output = self._run( + adapters, + evaluation_config=config, + run_id="semantic-needs-evidence-b", + ) + + self.assertTrue(result["ok"], result) + self.assertEqual(result["status"], "completed") + sample = result["samples"][0] + self.assertEqual(set(sample["candidates"]), {"A", "B", "C"}) + self.assertNotIn("detector", sample) + diagnostic = sample["semanticDiagnostics"]["B"] + self.assertEqual(diagnostic["outcome"], "needs_evidence") + self.assertEqual(diagnostic["blockingCounts"]["evidenceGaps"], 1) + self.assertEqual(diagnostic["blockingCounts"]["unknownAssertions"], 0) + self.assertEqual(len(semantic.calls), 3) + self.assertEqual(len(judge.calls), 2) + rubric = judge.calls[0]["rubric"] + self.assertEqual(rubric["policyVersion"], RUBRIC_POLICY_VERSION) + self.assertEqual(rubric["scenarioType"], "battle") + self.assertEqual( + [item["dimensionId"] for item in rubric["commonDimensions"]], + list(COMMON_DIMENSIONS), + ) + self.assertEqual( + rubric["scenarioDimension"]["dimensionId"], SCENARIO_DIMENSION + ) + self.assertEqual(rubric["scenarioDimension"]["displayName"], "战斗执行") + self.assertTrue((output / "gate-input.json").exists()) + manifest = json.loads((output / "manifest.json").read_text(encoding="utf-8")) + self.assertEqual(manifest["samples"][0]["semanticDiagnostics"], {"B": diagnostic}) + self.assertEqual( + self._cas_safe_summary(output, "deep-space-489")["semanticDiagnostics"], + {"B": diagnostic}, + ) + serialized = json.dumps(manifest, ensure_ascii=False) + for forbidden in ( + "candidateQuote", + "补查候选新事实", + "冻结证据不足", + "gap-1", + '"query"', + '"message"', + "/private/tmp", + ): + self.assertNotIn(forbidden, serialized) + + def test_pre_call_judge_failure_preserves_primary_code_without_receipt(self): + """评委输入在首调前失败时,不能用“缺回执”覆盖真正错误码。""" + + adapters, _writer, _semantic, judge = _production_adapters() + invalid = { + "ok": False, + "status": "failed_judge_invalid", + "primaryCode": "BLIND_INPUT_INVALID", + "reviewCount": 0, + "attemptCount": 0, + "safeDiagnostic": { + "schemaVersion": "blind-judge-safe-diagnostic-v1", + "errorCode": "BLIND_INPUT_INVALID", + "errorMessage": "输入绑定非法", + "draftAvailable": False, + }, + } + + with mock.patch.object( + execute_module, + "run_writer_blind_judge_panel", + return_value=invalid, + ): + result, output = self._run(adapters, run_id="judge-pre-call-invalid") + + self.assertFalse(result["ok"]) + self.assertEqual(result["status"], "failed_judge_invalid") + self.assertEqual( + result["samples"][0]["errors"], + ["failed_judge_invalid:BLIND_INPUT_INVALID"], + ) + self.assertEqual(judge.calls, []) + self.assertFalse((output / "gate-input.json").exists()) + diagnostic = result["samples"][0]["judgeDiagnostic"] + self.assertEqual(diagnostic["primaryCode"], "BLIND_INPUT_INVALID") + self.assertEqual(diagnostic["modelCallCount"], 0) + self.assertEqual( + diagnostic["modelOutput"]["errorCode"], "BLIND_INPUT_INVALID" + ) + self.assertNotIn("candidateBody", json.dumps(diagnostic, ensure_ascii=False)) + + def test_detector_correction_exhaustion_keeps_failed_sample(self): + """detector 不合约必须保留首样本并停止,不能继续启动第二样本。""" + + adapters, _writer, _semantic, judge = _production_adapters() + semantic = InvalidQuoteSemanticRunner() + evaluation_config = _production_config() + second_sample = copy.deepcopy(evaluation_config["samples"][0]) + second_sample["sampleId"] = "deep-space-490" + evaluation_config["samples"].append(second_sample) + budget = evaluation_config["executionAuthorization"]["budget"] + for role in replay_module.BUDGET_ROLES: + budget["plannedCalls"][role] = 6 + budget["maxCalls"][role] = 6 + budget["totalBudgetUsd"] = "18.000000" + budget["receiptSha256"] = canonical_sha256( + {key: value for key, value in budget.items() if key != "receiptSha256"} + ) + second_oracle = copy.deepcopy(_oracle_pack()) + second_oracle["sampleId"] = "deep-space-490" + second_oracle["packSha256"] = canonical_sha256( + {key: value for key, value in second_oracle.items() if key != "packSha256"} + ) + adapters = replace( + adapters, + semantic_model_runner=semantic, + oracle_truth_packs={ + **adapters.oracle_truth_packs, + "deep-space-490": second_oracle, + }, + ) + + result, output = self._run( + adapters, + evaluation_config=evaluation_config, + run_id="semantic-invalid-exhausted", + ) + + self.assertFalse(result["ok"]) + self.assertEqual(result["status"], "failed_semantic_detector") + self.assertEqual(len(result["samples"]), 1) + self.assertEqual(result["samples"][0]["status"], "failed_semantic_detector") + self.assertEqual( + result["samples"][0]["errors"], + ["SEMANTIC_DETECTOR_QUOTE_NOT_FOUND"], + ) + diagnostic = result["samples"][0]["semanticDiagnostic"] + self.assertEqual(diagnostic["reasonCode"], "QUOTE_NOT_FOUND") + self.assertEqual(diagnostic["attemptCount"], 3) + self.assertEqual(diagnostic["correctionCount"], 2) + self.assertEqual(len(semantic.calls), 3) + self.assertEqual(judge.calls, []) + cas_dirs = sorted( + path.name + for path in (output / "journal" / "cas").iterdir() + if path.is_dir() + ) + self.assertEqual(cas_dirs, [result["samples"][0]["sampleId"]]) + self.assertFalse((output / "gate-input.json").exists()) + manifest_text = (output / "manifest.json").read_text(encoding="utf-8") + for forbidden in ("candidateQuote", "候选中不存在的引文", "previousDraft"): + self.assertNotIn(forbidden, manifest_text) + self.assertEqual( + self._cas_safe_summary(output, result["samples"][0]["sampleId"])[ + "semanticDiagnostic" + ], + diagnostic, + ) + + def test_detector_correction_receipts_bind_final_success_in_gate(self): + """首轮非法、第二轮纠正成功时,Gate 绑定最后回执且整轮可完成。""" + + adapters, _writer, _semantic, _judge = _production_adapters() + semantic = CorrectingSemanticRunner() + adapters = replace(adapters, semantic_model_runner=semantic) + config = _production_config() + budget = config["executionAuthorization"]["budget"] + budget["plannedCalls"]["semantic_detector"] = 4 + budget["maxCalls"]["semantic_detector"] = 4 + budget["totalBudgetUsd"] = "10.000000" + budget["receiptSha256"] = canonical_sha256( + {key: value for key, value in budget.items() if key != "receiptSha256"} + ) + + result, output = self._run( + adapters, + evaluation_config=config, + run_id="semantic-correction-success", + ) + + self.assertTrue(result["ok"], result) + self.assertEqual(len(semantic.calls), 4) + receipts = json.loads((output / "execution-receipts.json").read_text()) + self.assertEqual( + len(receipts["deep-space-489"]["A"]["semantic_detector"]["executionReceipts"]), + 2, + ) + gate_input = json.loads((output / "gate-input.json").read_text()) + self.assertFalse(gate_input["samples"][0]["systemFailure"]) + self.assertTrue(all("semanticDiagnostic" not in sample for sample in result["samples"])) + self.assertNotIn( + "semanticDiagnostic", + self._cas_safe_summary(output, "deep-space-489"), + ) + + def test_raw_expiry_inside_sample_is_recorded_and_cas_closed(self): + """样本执行中的 raw 异常必须保留样本安全终态并收口 CAS。""" + + class ExpiringManager(RawVaultManager): + def __init__(self, root): + super().__init__(root) + self.write_count = 0 + + def write_bytes(self, lease, relative_path, content): + self.write_count += 1 + if self.write_count == 2: + raise RawVaultError("RAW_LEASE_EXPIRED", "测试租约到期") + return super().write_bytes(lease, relative_path, content) + + adapters, writer, semantic, judge = _production_adapters(vault_factory=ExpiringManager) + + result, output = self._run(adapters, run_id="raw-expired-in-sample") + + self.assertFalse(result["ok"]) + self.assertEqual(result["status"], "failed_raw_vault") + self.assertEqual(len(result["samples"]), 1) + self.assertEqual(result["samples"][0]["errors"], ["RAW_LEASE_EXPIRED"]) + self.assertEqual((writer.calls, semantic.calls, judge.calls), ([], [], [])) + cas = json.loads( + (output / "journal" / "cas" / "deep-space-489" / "state.json").read_text() + ) + self.assertEqual(cas["state"], "FAILED") + self.assertFalse((output / "gate-input.json").exists()) + + def test_gate_publish_failure_removes_partially_published_input(self): + """Gate 文件原子写后若后续发布动作失败,失败 manifest 前必须撤销文件。""" + + adapters, _writer, _semantic, _judge = _production_adapters() + original_write = replay_module.atomic_write_json + + def fail_after_gate_write(path, value): + original_write(path, value) + if path.name == "gate-input.json": + raise OSError("测试 Gate 发布失败") + + with mock.patch("run_writer_replay.execute.atomic_write_json", side_effect=fail_after_gate_write): + result, output = self._run(adapters, run_id="gate-publish-failure") + + self.assertFalse(result["ok"]) + self.assertEqual(result["status"], "failed_system") + self.assertFalse((output / "gate-input.json").exists()) + + def test_judge_quote_correction_keeps_full_receipts_and_binds_final_reports(self): + """评委纠错必须保留全量调用,并用最终索引通过 Gate builder 反篡改复核。""" + + adapters, _writer, _semantic, judge = _production_adapters( + judge_invalid_first_quote=True + ) + + result, output = self._run(adapters, run_id="judge-quote-correction") + + self.assertTrue(result["ok"], result) + self.assertEqual(len(judge.calls), 3) + self.assertNotIn("correction", judge.calls[0]) + self.assertIn("correction", judge.calls[1]) + execution_receipts = json.loads( + (output / "execution-receipts.json").read_text(encoding="utf-8") + ) + panel_receipts = execution_receipts["deep-space-489"]["A"][ + "blind_judge" + ]["executionReceipts"] + self.assertEqual(len(panel_receipts), 3) + gate_input = json.loads((output / "gate-input.json").read_text(encoding="utf-8")) + self.assertTrue( + all(not sample["systemFailure"] for sample in gate_input["samples"]), + gate_input["samples"], + ) + def test_production_fake_happy_path_runs_v2_adapters_receipts_vault_cas_and_builder(self): adapters, writer, semantic, judge = _production_adapters() @@ -1887,7 +2700,8 @@ class WriterReplayProductionIntegrationTest(unittest.TestCase): self.assertTrue(all(receipt["adapterRole"] == "semantic_detector" for receipt in semantic.receipts)) self.assertTrue(all(receipt["adapterRole"] == "blind_judge" for receipt in judge.receipts)) gate_input = json.loads((output / "gate-input.json").read_text(encoding="utf-8")) - self.assertEqual(gate_input["schemaVersion"], "writer-gate-input-v2") + self.assertEqual(gate_input["schemaVersion"], "writer-gate-input-v3") + self.assertEqual(gate_input["rubricPolicyVersion"], RUBRIC_POLICY_VERSION) self.assertEqual(gate_input["gateInputSha256"], result["gateInputSha256"]) self.assertTrue( all(not sample["systemFailure"] for sample in gate_input["samples"]), @@ -1917,12 +2731,18 @@ class WriterReplayProductionIntegrationTest(unittest.TestCase): lease_files = list((output / "journal" / "raw-vault" / "leases").glob("*.json")) self.assertEqual(len(lease_files), 1) lease = json.loads(lease_files[0].read_text(encoding="utf-8")) - self.assertEqual(lease["status"], "closed") + self.assertEqual(lease["status"], "migrated") + self.assertEqual(result["rawDisposition"]["status"], "migrated") + self.assertEqual(result["rawDisposition"]["archiveId"], lease["archiveId"]) + archive_root = output.parent / "production-fake-raw-archive" + archived = archive_root / f"muse-raw-archive-{lease['archiveId']}" + self.assertTrue((archived / "run" / "config.json").is_file()) + self.assertTrue((archived / ".migration-receipt.json").is_file()) cas_state = json.loads( (output / "journal" / "cas" / "deep-space-489" / "state.json").read_text() ) self.assertEqual(cas_state["state"], "COMPLETED") - self.assertEqual(cas_state["cleanupState"], "closed") + self.assertEqual(cas_state["cleanupState"], "migrated") def test_length_revision_loop_recovers_out_of_range_draft(self): """首版越界时退回写手修订一遍即达标:样本成功,修订块带固定指令与上一版正文。""" @@ -1972,31 +2792,95 @@ class WriterReplayProductionIntegrationTest(unittest.TestCase): self.assertEqual(revision["minChars"], 2800) self.assertEqual(revision["maxChars"], 5200) self.assertEqual(revision["targetChars"], 4000) + self.assertEqual(revision["countingRule"], "han_chars_only") + self.assertEqual(revision["revisionDirection"], "expand") + self.assertEqual(revision["targetDeltaHanChars"], 2000) self.assertEqual( {context["lengthRevision"]["instruction"] for context in revision_inputs}, {replay_module.LENGTH_REVISION_INSTRUCTION}, ) + instruction = replay_module.LENGTH_REVISION_INSTRUCTION + self.assertIn("只统计 candidateBody 中的汉字", instruction) + self.assertIn("不统计标点、空格、数字或拉丁字母", instruction) + self.assertIn("以 targetChars 为修订目标", instruction) + self.assertIn("不要只擦到", instruction) + self.assertIn("必须完整保留上一版已有内容", instruction) + self.assertIn("不得压缩、删除或合并已有内容", instruction) + self.assertIn("revisionDirection=trim", instruction) + self.assertIn("删减约 targetDeltaHanChars 个汉字", instruction) + self.assertIn("不得继续扩写", instruction) - def test_length_revision_exhausted_still_fails_with_actual_han_chars(self): - """修订两遍仍越界才失败:记下实际字数,writer 调用数 = 1 首版 + 2 修订 = 3。""" + def test_length_revision_recomputes_direction_and_target_delta_each_round(self): + """同一臂每轮都按当前正文重算方向与距目标差值,不能复用首版数字。""" adapters, writer, _semantic, _judge = _production_adapters() + config = _production_config_with_writer_budget( + planned=9, max_calls=9, total_usd="18.000000" + ) + short_body = "短" * 2000 + long_body = "长" * 6000 + + original_writer_output = _writer_output + + def output_without_recursion(context, *, call_index): + if call_index == 0: + return {"candidateBody": short_body} + if call_index == 1: + return {"candidateBody": long_body} + return original_writer_output(context, call_index=call_index) + + with mock.patch.object( + sys.modules[__name__], "_writer_output", output_without_recursion + ): + result, _output = self._run( + adapters, evaluation_config=config, run_id="length-revision-recomputed" + ) + + self.assertTrue(result["ok"], result) + first_revision = writer.contexts[1]["lengthRevision"] + second_revision = writer.contexts[2]["lengthRevision"] + self.assertEqual(first_revision["previousDraft"], short_body) + self.assertEqual(first_revision["actualHanChars"], 2000) + self.assertEqual(first_revision["revisionDirection"], "expand") + self.assertEqual(first_revision["targetDeltaHanChars"], 2000) + self.assertEqual(second_revision["previousDraft"], long_body) + self.assertEqual(second_revision["actualHanChars"], 6000) + self.assertEqual(second_revision["revisionDirection"], "trim") + self.assertEqual(second_revision["targetDeltaHanChars"], 2000) + self.assertEqual( + {first_revision["countingRule"], second_revision["countingRule"]}, + {"han_chars_only"}, + ) + + def test_length_revision_exhausted_still_fails_with_actual_han_chars(self): + """修订三遍仍越界才失败:记下实际字数,writer 调用数 = 1 首版 + 3 修订 = 4。""" + + adapters, writer, _semantic, _judge = _production_adapters() + config = _production_config_with_writer_budget( + planned=4, + max_calls=4, + total_usd="12.000000", + ) short_body = "短" * 2000 with mock.patch.object( sys.modules[__name__], "_writer_output", lambda context, *, call_index: {"candidateBody": short_body}, ): - result, _output = self._run(adapters, run_id="length-revision-exhausted") + result, _output = self._run( + adapters, + evaluation_config=config, + run_id="length-revision-exhausted", + ) self.assertFalse(result["ok"]) sample = result["samples"][0] self.assertIn("候选正文汉字数超出动态篇幅区间", sample["errors"]) self.assertIn("lengthViolation", sample) self.assertEqual(sample["lengthViolation"]["actualHanChars"], 2000) - # 首臂耗尽 1+2=3 次 writer 调用后即终局门失败,后续臂不再运行。 - self.assertEqual(len(writer.calls), 3) - self.assertEqual(result["budgetLedger"]["usedCalls"]["writer"], 3) + # 首臂耗尽 1+3=4 次 writer 调用后即终局门失败,后续臂不再运行。 + self.assertEqual(len(writer.calls), 4) + self.assertEqual(result["budgetLedger"]["usedCalls"]["writer"], 4) def test_length_in_range_skips_revision_loop(self): """首版即在区间内:0 修订,每臂仅 1 次 writer 调用,输入不带 lengthRevision。""" @@ -2074,7 +2958,8 @@ class WriterReplayProductionIntegrationTest(unittest.TestCase): # 正向对照:未篡改的合法报告(原始输出与报告一致)必须通过 builder,无系统失败且评分入输入。 gate_input = GateInputBuilder().build(**copy.deepcopy(captured)) - self.assertEqual(gate_input["schemaVersion"], "writer-gate-input-v2") + self.assertEqual(gate_input["schemaVersion"], "writer-gate-input-v3") + self.assertEqual(gate_input["rubricPolicyVersion"], RUBRIC_POLICY_VERSION) legit_sample = next( item for item in gate_input["samples"] if item["sampleId"] == "deep-space-489" ) @@ -2177,16 +3062,102 @@ class WriterReplayProductionIntegrationTest(unittest.TestCase): stripped_sample["systemFailureReasons"], ) - def test_semantic_failure_stops_sample_before_judge(self): - adapters, writer, semantic, judge = _production_adapters(semantic_fail_on=2) + def test_a_semantic_failure_keeps_candidate_and_reaches_judge_and_gate(self): + """A 臂合法 failed 是对照质量信号,不得阻断剩余臂和 Gate builder。""" - result, _output = self._run(adapters, run_id="semantic-failure") + config = _production_config() + adapters, writer, semantic, judge = _production_adapters( + semantic_fail_on=_semantic_call_index_for_arm(config, "A") + ) - self.assertFalse(result["ok"]) - self.assertEqual(result["status"], "failed_semantic_detector") - self.assertEqual(len(writer.calls), 2) - self.assertEqual(len(semantic.calls), 2) - self.assertEqual(judge.calls, []) + result, output = self._run( + adapters, + evaluation_config=config, + run_id="semantic-failure-a", + ) + + self.assertTrue(result["ok"], result) + self.assertEqual(result["status"], "completed") + self.assertEqual((len(writer.calls), len(semantic.calls), len(judge.calls)), (3, 3, 2)) + sample = result["samples"][0] + self.assertEqual(set(sample["candidates"]), {"A", "B", "C"}) + self.assertNotIn("detector", sample) + diagnostic = sample["semanticDiagnostics"]["A"] + self.assertEqual(diagnostic["outcome"], "failed") + self.assertEqual(diagnostic["blockingCounts"]["highFindings"], 1) + self.assertEqual(diagnostic["blockingCounts"]["failedHardConstraints"], 1) + gate_input = json.loads((output / "gate-input.json").read_text(encoding="utf-8")) + self.assertFalse(gate_input["samples"][0]["systemFailure"]) + self.assertEqual(gate_input["samples"][0]["cArm"]["hardConstraintCoverage"], 1.0) + self.assertEqual(gate_input["samples"][0]["cArm"]["highSeverityResidualCount"], 0) + manifest = json.loads((output / "manifest.json").read_text(encoding="utf-8")) + self.assertEqual(manifest["samples"][0]["semanticDiagnostics"], {"A": diagnostic}) + self.assertEqual( + self._cas_safe_summary(output, "deep-space-489")["semanticDiagnostics"], + {"A": diagnostic}, + ) + serialized = json.dumps(manifest, ensure_ascii=False) + for forbidden in ( + "candidateQuote", + "硬约束未满足", + "finding-high-1", + '"findingId"', + '"message"', + "/private/tmp", + ): + self.assertNotIn(forbidden, serialized) + + def test_judge_api_retry_aligns_null_output_and_binds_final_reports(self): + """前置 API 失败无输出;null 占位与全回执对齐后,终稿索引仍可反篡改复核。""" + + adapters, _writer, _semantic, judge = _production_adapters( + judge_api_error_first=True + ) + + result, output = self._run(adapters, run_id="judge-api-retry") + + self.assertTrue(result["ok"], result) + self.assertEqual(len(judge.calls), 3) + receipts = json.loads((output / "execution-receipts.json").read_text()) + panel_receipts = receipts["deep-space-489"]["A"]["blind_judge"][ + "executionReceipts" + ] + self.assertEqual(len(panel_receipts), 3) + self.assertEqual(panel_receipts[0]["terminalReason"], "api_error") + gate_input = json.loads((output / "gate-input.json").read_text()) + self.assertFalse(gate_input["samples"][0]["systemFailure"]) + + def test_c_semantic_failure_reaches_gate_and_gate_a_fails_quality(self): + """C 臂合法 failed 必须走完整链,并由 Gate A 的 C 臂硬门判为 failed。""" + + config = _production_config() + adapters, writer, semantic, judge = _production_adapters( + semantic_fail_on=_semantic_call_index_for_arm(config, "C") + ) + + result, output = self._run( + adapters, + evaluation_config=config, + run_id="semantic-failure-c", + ) + + self.assertTrue(result["ok"], result) + self.assertEqual((len(writer.calls), len(semantic.calls), len(judge.calls)), (3, 3, 2)) + sample = result["samples"][0] + self.assertEqual(sample["semanticDiagnostics"]["C"]["outcome"], "failed") + gate_input = json.loads((output / "gate-input.json").read_text(encoding="utf-8")) + self.assertFalse(gate_input["samples"][0]["systemFailure"]) + self.assertEqual(gate_input["samples"][0]["cArm"]["hardConstraintCoverage"], 0.0) + self.assertEqual(gate_input["samples"][0]["cArm"]["highSeverityResidualCount"], 1) + + gate_report = decide_gate(_five_scenario_gate_input(gate_input)) + + self.assertEqual(gate_report["status"], "failed") + self.assertIn("c_arm_high_severity_residual", gate_report["reasons"]) + self.assertIn( + "c_arm_hard_constraint_coverage_below_100_percent", + gate_report["reasons"], + ) def test_third_judge_still_unstable_fails_run(self): adapters, _writer, _semantic, judge = _production_adapters(judge_unstable=True) @@ -2200,22 +3171,29 @@ class WriterReplayProductionIntegrationTest(unittest.TestCase): self.assertEqual(result["budgetLedger"]["remainingPlannedCalls"]["blind_judge"], 0) self.assertEqual(result["budgetLedger"]["totalActualCostUsd"], "0.090000") - def test_vault_cleanup_failure_overrides_success(self): - class CleanupFailureManager(RawVaultManager): - """先实际清理 raw,再模拟清理回执失败,避免测试残留敏感目录。""" + def test_vault_migration_failure_overrides_success(self): + class MigrationFailureManager(RawVaultManager): + """先实际迁移 raw,再模拟迁移回执失败;归档内容仍必须保留。""" - def cleanup(self, lease): - super().cleanup(lease) - raise RawVaultError("RAW_CLEANUP_FAILED", "测试清理失败") + def migrate(self, lease, *, archive_root): + super().migrate(lease, archive_root=archive_root) + raise RawVaultError("RAW_MIGRATION_FAILED", "测试迁移失败") adapters, _writer, _semantic, _judge = _production_adapters( - vault_factory=CleanupFailureManager + vault_factory=MigrationFailureManager ) - result, _output = self._run(adapters, run_id="cleanup-failure") + result, output = self._run(adapters, run_id="migration-failure") self.assertFalse(result["ok"]) - self.assertEqual(result["status"], "failed_raw_cleanup") + self.assertEqual(result["status"], "failed_raw_migration") + cas_state = json.loads( + (output / "journal" / "cas" / "deep-space-489" / "state.json").read_text( + encoding="utf-8" + ) + ) + self.assertEqual(cas_state["state"], "FAILED") + self.assertEqual(cas_state["cleanupState"], "failed") def test_cas_conflict_fails_sample_and_run(self): class ConflictingCas: @@ -2254,6 +3232,128 @@ class WriterReplayProductionIntegrationTest(unittest.TestCase): self.assertEqual((len(writer.calls), len(semantic.calls), len(judge.calls)), (3, 3, 2)) self.assertFalse((output / "gate-input.json").exists()) + def test_builder_failure_closes_all_multi_sample_judge_terminals_as_failed(self): + """整轮 builder 失败时,所有已到 JUDGE_COMPLETED 的样本仍必须失败关闭。""" + + class FailingBuilder: + def build(self, **_kwargs): + raise GateInputBuildError("测试多样本 builder 绑定失败") + + evaluation_config = _production_config() + second_sample = copy.deepcopy(evaluation_config["samples"][0]) + second_sample["sampleId"] = "deep-space-490" + evaluation_config["samples"].append(second_sample) + budget = evaluation_config["executionAuthorization"]["budget"] + for role in replay_module.BUDGET_ROLES: + budget["plannedCalls"][role] = 6 + budget["maxCalls"][role] = 6 + budget["totalBudgetUsd"] = "18.000000" + budget["receiptSha256"] = canonical_sha256( + {key: value for key, value in budget.items() if key != "receiptSha256"} + ) + second_oracle = copy.deepcopy(_oracle_pack()) + second_oracle["sampleId"] = "deep-space-490" + second_oracle["packSha256"] = canonical_sha256( + {key: value for key, value in second_oracle.items() if key != "packSha256"} + ) + adapters, writer, semantic, judge = _production_adapters( + builder_factory=FailingBuilder + ) + adapters = replace( + adapters, + oracle_truth_packs={ + **adapters.oracle_truth_packs, + "deep-space-490": second_oracle, + }, + ) + + result, output = self._run( + adapters, + evaluation_config=evaluation_config, + run_id="builder-failure-multi-sample", + ) + + self.assertFalse(result["ok"]) + self.assertEqual(result["status"], "failed_gate_input_builder") + self.assertEqual((len(writer.calls), len(semantic.calls), len(judge.calls)), (6, 6, 4)) + self.assertFalse((output / "gate-input.json").exists()) + for sample_id in ("deep-space-489", "deep-space-490"): + cas_state = json.loads( + (output / "journal" / "cas" / sample_id / "state.json").read_text( + encoding="utf-8" + ) + ) + self.assertEqual(cas_state["state"], "FAILED") + self.assertEqual(cas_state["cleanupState"], "migrated") + + def test_sigterm_after_vault_creation_recovers_open_lease_and_raw_vault(self): + """create_vault 返回前被首次 SIGTERM 打断时必须恢复并关闭 raw lease。""" + + class InterruptingCreateVaultManager(RawVaultManager): + def create_vault(self, **kwargs): + super().create_vault(**kwargs) + signal.raise_signal(signal.SIGTERM) + + adapters, _writer, _semantic, _judge = _production_adapters( + vault_factory=InterruptingCreateVaultManager + ) + + result, output = self._run(adapters, run_id="sigterm-vault-create") + + self.assertFalse(result["ok"]) + self.assertEqual(result["status"], "failed_interrupted") + manifest = json.loads((output / "manifest.json").read_text(encoding="utf-8")) + self.assertEqual(manifest["status"], "failed_interrupted") + self.assertNotIn("candidateBody", json.dumps(manifest, ensure_ascii=False)) + self.assertNotIn("/private/tmp", json.dumps(manifest, ensure_ascii=False)) + lease_files = list((output / "journal" / "raw-vault" / "leases").glob("*.json")) + self.assertEqual(len(lease_files), 1) + self.assertEqual(json.loads(lease_files[0].read_text())["status"], "closed") + self.assertEqual( + sorted(path.name for path in (output / "journal" / "raw-vault").iterdir()), + ["leases"], + ) + + def test_sigterm_after_cas_initialize_closes_started_cas_as_failed(self): + """CAS.initialize 提交 STARTED 后首次 SIGTERM 也必须失败收口。""" + + class InterruptingInitializeCas: + def __init__(self, root): + self.delegate = FileCasStore(root) + + def initialize(self, **kwargs): + self.delegate.initialize(**kwargs) + signal.raise_signal(signal.SIGTERM) + + def latest(self): + return self.delegate.latest() + + def transition(self, **kwargs): + return self.delegate.transition(**kwargs) + + adapters, _writer, _semantic, _judge = _production_adapters( + cas_factory=InterruptingInitializeCas + ) + + result, output = self._run(adapters, run_id="sigterm-cas-initialize") + + self.assertFalse(result["ok"]) + self.assertEqual(result["status"], "failed_interrupted") + manifest = json.loads((output / "manifest.json").read_text(encoding="utf-8")) + self.assertEqual(manifest["status"], "failed_interrupted") + self.assertNotIn("candidateBody", json.dumps(manifest, ensure_ascii=False)) + self.assertNotIn("/private/tmp", json.dumps(manifest, ensure_ascii=False)) + cas_state = json.loads( + (output / "journal" / "cas" / "deep-space-489" / "state.json").read_text( + encoding="utf-8" + ) + ) + self.assertEqual(cas_state["state"], "FAILED") + self.assertEqual(cas_state["cleanupState"], "migrated") + lease_files = list((output / "journal" / "raw-vault" / "leases").glob("*.json")) + self.assertEqual(len(lease_files), 1) + self.assertEqual(json.loads(lease_files[0].read_text())["status"], "migrated") + def test_pending_probe_and_budget_block_before_vault_or_runner(self): vault_calls: list[pathlib.Path] = [] @@ -2428,6 +3528,260 @@ class WriterReplayProductionIntegrationTest(unittest.TestCase): self.assertEqual(result["status"], "completed") self.assertEqual(len(writer.calls), 3) + def test_termination_handlers_install_and_restore_without_killing_process(self): + """handler 安装/恢复必须是确定性的:装上后信号被接管,恢复后还原调用方原 handler。""" + + old_term = signal.getsignal(signal.SIGTERM) + old_int = signal.getsignal(signal.SIGINT) + previous = replay_module.install_termination_handlers() + try: + self.assertIn(signal.SIGTERM, previous) + self.assertIn(signal.SIGINT, previous) + self.assertIsNot(signal.getsignal(signal.SIGTERM), old_term) + self.assertIsNot(signal.getsignal(signal.SIGINT), old_int) + finally: + replay_module.restore_termination_handlers(previous) + self.assertIs(signal.getsignal(signal.SIGTERM), old_term) + self.assertIs(signal.getsignal(signal.SIGINT), old_int) + + def test_sigterm_during_writer_call_fails_closed_with_cost_unknown(self): + """终止落在已发起但无回执的 writer 调用中:成本未知、raw 仍迁移、manifest 安全。""" + + old_term = signal.getsignal(signal.SIGTERM) + old_int = signal.getsignal(signal.SIGINT) + adapters, _writer, semantic, judge = _production_adapters() + adapters = replace(adapters, writer_runner=InterruptingRunner(interrupt_on_call=1)) + + result, output = self._run(adapters, run_id="sigterm-writer") + + self.assertFalse(result["ok"]) + self.assertEqual(result["status"], "failed_interrupted") + self.assertEqual(result["errors"], ["EXECUTION_COST_UNKNOWN"]) + self.assertTrue(result["budgetLedger"]["costUnknown"]) + self.assertEqual(result["budgetLedger"]["failureReason"], "EXECUTION_COST_UNKNOWN") + # 终止之后不得再发起任何 detector / judge 调用。 + self.assertEqual(semantic.calls, []) + self.assertEqual(judge.calls, []) + # manifest 必须是安全字段:不含正文、不含 raw 路径。 + manifest_text = (output / "manifest.json").read_text(encoding="utf-8") + self.assertNotIn("candidateBody", manifest_text) + self.assertNotIn("/private/tmp", manifest_text) + # open lease 必须被收敛为 migrated,临时 vault 消失但受控归档保留。 + lease_files = list((output / "journal" / "raw-vault" / "leases").glob("*.json")) + self.assertEqual(len(lease_files), 1) + lease = json.loads(lease_files[0].read_text(encoding="utf-8")) + self.assertEqual(lease["status"], "migrated") + archive_root = output.parent / "sigterm-writer-raw-archive" + self.assertTrue( + (archive_root / f"muse-raw-archive-{lease['archiveId']}").is_dir() + ) + # 运行 journal 只留非敏感 lease;raw 已迁往独立受控归档。 + self.assertEqual( + [path.name for path in (output / "journal" / "raw-vault").iterdir()], + ["leases"], + ) + cas_state = json.loads( + (output / "journal" / "cas" / "deep-space-489" / "state.json").read_text( + encoding="utf-8" + ) + ) + self.assertEqual(cas_state["state"], "FAILED") + self.assertEqual(cas_state["cleanupState"], "migrated") + self.assertIs(signal.getsignal(signal.SIGTERM), old_term) + self.assertIs(signal.getsignal(signal.SIGINT), old_int) + + def test_termination_is_deferred_during_migration_and_restored_after_manifest(self): + """最终迁移和 manifest 写入期间必须忽略后续终止,返回后恢复原 handler。""" + + handler_states: list[tuple[object, object]] = [] + + class HandlerObservingManager(RawVaultManager): + def migrate(self, lease, *, archive_root): + handler_states.append( + ( + signal.getsignal(signal.SIGTERM), + signal.getsignal(signal.SIGINT), + ) + ) + return super().migrate(lease, archive_root=archive_root) + + old_term = signal.getsignal(signal.SIGTERM) + old_int = signal.getsignal(signal.SIGINT) + adapters, _writer, _semantic, _judge = _production_adapters( + vault_factory=HandlerObservingManager + ) + adapters = replace(adapters, writer_runner=InterruptingRunner(interrupt_on_call=1)) + + result, _output = self._run(adapters, run_id="sigterm-migration-handler") + + self.assertFalse(result["ok"]) + self.assertEqual(handler_states, [(signal.SIG_IGN, signal.SIG_IGN)]) + self.assertIs(signal.getsignal(signal.SIGTERM), old_term) + self.assertIs(signal.getsignal(signal.SIGINT), old_int) + + def test_short_retention_lease_blocks_before_vault_contents_or_runner(self): + """27 秒短租约必须在落 lease / 建 vault / 调模型前稳定失败关闭。""" + + vault_calls: list[pathlib.Path] = [] + + def vault_factory(path): + vault_calls.append(path) + return RawVaultManager(path) + + adapters, writer, semantic, judge = _production_adapters(vault_factory=vault_factory) + short = _production_config() + raw = short["executionAuthorization"]["rawRetention"] + raw["retainUntil"] = (datetime.now(timezone.utc) + timedelta(seconds=27)).isoformat() + raw["receiptSha256"] = canonical_sha256( + {key: value for key, value in raw.items() if key != "receiptSha256"} + ) + + result, output = self._run(adapters, evaluation_config=short, run_id="short-lease") + + self.assertFalse(result["ok"]) + self.assertEqual(result["status"], "failed_raw_vault") + self.assertEqual(result["errors"], ["RAW_LEASE_INSUFFICIENT_RETENTION"]) + # 失败关闭在任何模型 runner 之前。 + self.assertEqual(writer.calls, []) + self.assertEqual(semantic.calls, []) + self.assertEqual(judge.calls, []) + # 不能留下 lease journal 或 raw vault 目录(manager 外壳目录可有,内容必须空)。 + self.assertEqual(list((output / "journal" / "raw-vault" / "leases").glob("*.json")), []) + + +class SemanticInputSourceRefCleaningTest(unittest.TestCase): + """验证 _semantic_input_v3 投影检测输入时清洗证据 sourceRef 的多余字段。 + + C 臂(卡索引+原文臂)的 proseEvidence sourceRef 带 sourceType(如 card_chapter_proxy), + 检测输入校验把 sourceRef 当闭集会拒收多余字段。投影时必须清洗成合同形状,且 writer_context + 本身不动(writer 看到的 proseEvidence 仍带 sourceType,那是 writer 侧合同)。 + """ + + PROSE_TEXT = "脱敏原文片段,C 臂知识卡投影。" + + @classmethod + def _writer_context(cls) -> dict[str, object]: + prose_hash = "sha256:" + hashlib.sha256(cls.PROSE_TEXT.encode("utf-8")).hexdigest() + prose_source_ref = { + "sourceId": "fixture:card-chapter-proxy:1", + "sourceVersion": "cards-frozen-488-v1", + "chapter": 488, + "blockId": 1120, + "startCodePoint": 0, + "endCodePoint": len(cls.PROSE_TEXT), + "contentSha256": prose_hash, + # writer 侧合同允许、但检测闭集拒收的多余字段: + "sourceType": "card_chapter_proxy", + "cardId": "card-1", + } + fact_source_ref = { + "sourceId": "fixture:fact:1", + "sourceVersion": "canonical-v488", + "contentSha256": "sha256:" + "8" * 64, + "sourceType": "canonical_state", + } + return { + "fineOutline": { + "sourceRef": { + "sourceId": "fixture:outline:489", + "sourceVersion": "outline-frozen-489-v1", + }, + "hardConstraints": ["人物保持冻结状态"], + "adjustableBeats": ["过场节奏可调"], + "declaredNewFacts": [], + }, + "contextSnapshot": {"contextSha256": "sha256:" + "5" * 64}, + "asOf": 488, + "authorizationSnapshot": {"snapshotId": "auth-work-8"}, + "factEvidence": [ + { + "evidenceId": "fact-ev-1", + "fact": "林澈仍在圣蒂曼", + "sourceType": "canonical_state", + "sourceRef": fact_source_ref, + "contentSha256": "sha256:" + "8" * 64, + "riskLevel": "low", + } + ], + "proseEvidence": [ + { + "evidenceId": "prose-ev-1", + "chapter": 488, + "sourceRef": prose_source_ref, + "contentSha256": prose_hash, + "purpose": "style_baseline", + "text": cls.PROSE_TEXT, + "isRecentBaseline": True, + } + ], + } + + @staticmethod + def _candidate() -> dict[str, object]: + body = "候选正文。" + return { + "candidateVersion": 1, + "candidateSha256": "sha256:" + hashlib.sha256(body.encode("utf-8")).hexdigest(), + "candidateBody": body, + } + + def _project(self, writer_context: dict[str, object], opaque_arm_id: str) -> dict[str, object]: + return _semantic_input_v3( + run_id="run-clean", + sample_id="deep-space-321", + opaque_arm_id=opaque_arm_id, + writer_context=writer_context, + candidate=self._candidate(), + ) + + def test_prose_evidence_source_ref_drops_extra_fields(self): + result = self._project(self._writer_context(), "blind-1") + cleaned_ref = result["proseEvidence"][0]["sourceRef"] + # 多余字段(sourceType/cardId)被清洗掉。 + self.assertNotIn("sourceType", cleaned_ref) + self.assertNotIn("cardId", cleaned_ref) + # 只保留检测闭集允许的字段。 + self.assertLessEqual(set(cleaned_ref), _SOURCE_REF_ALLOWED) + # 关键定位字段保留。 + self.assertEqual(cleaned_ref["sourceId"], "fixture:card-chapter-proxy:1") + self.assertEqual(cleaned_ref["sourceVersion"], "cards-frozen-488-v1") + self.assertEqual(cleaned_ref["chapter"], 488) + self.assertEqual(cleaned_ref["blockId"], 1120) + # 证据其它字段原样保留(只清洗 sourceRef 子对象)。 + prose_item = result["proseEvidence"][0] + self.assertEqual(prose_item["evidenceId"], "prose-ev-1") + self.assertEqual(prose_item["purpose"], "style_baseline") + self.assertIs(prose_item["isRecentBaseline"], True) + self.assertEqual(prose_item["text"], self.PROSE_TEXT) + + def test_fact_evidence_source_ref_drops_extra_fields(self): + result = self._project(self._writer_context(), "blind-2") + cleaned_ref = result["factEvidence"][0]["sourceRef"] + self.assertNotIn("sourceType", cleaned_ref) + self.assertLessEqual(set(cleaned_ref), _SOURCE_REF_ALLOWED) + self.assertEqual(cleaned_ref["sourceId"], "fixture:fact:1") + self.assertEqual(cleaned_ref["sourceVersion"], "canonical-v488") + # factEvidence 条目自身的 sourceType(检测合同要求)保留,只有 sourceRef 子对象被清洗。 + self.assertEqual(result["factEvidence"][0]["sourceType"], "canonical_state") + + def test_writer_context_source_ref_untouched(self): + writer_context = self._writer_context() + self._project(writer_context, "blind-3") + # writer 侧合同不变:投影后 writer_context 的 sourceRef 仍带 sourceType。 + self.assertEqual( + writer_context["proseEvidence"][0]["sourceRef"]["sourceType"], + "card_chapter_proxy", + ) + self.assertEqual( + writer_context["factEvidence"][0]["sourceRef"]["sourceType"], + "canonical_state", + ) + + def test_clean_source_ref_non_mapping_passthrough(self): + # ref 不是 dict 时原样返回,交由下游检测校验按合同处理。 + self.assertEqual(_clean_source_ref("fixture:plain"), "fixture:plain") + self.assertIsNone(_clean_source_ref(None)) + if __name__ == "__main__": unittest.main() diff --git a/.claude/skills/runtime/SKILL.md b/.claude/skills/runtime/SKILL.md index 356efcd..ee07eec 100644 --- a/.claude/skills/runtime/SKILL.md +++ b/.claude/skills/runtime/SKILL.md @@ -16,6 +16,8 @@ Claude CLI 受控子进程调用(fresh/sandbox/deadline/structured_output/回 | `file_cas.py` | 不可变内容寻址(CAS)revision journal:按内容哈希落盘,冲突即失败关闭,支撑 Gate receipt 与逐层回执的不可篡改。 | | `raw_vault.py` | 租约式 raw 物料保险库:原始候选/物料先写 0700 临时 vault,正式评测结束后同盘原子迁移到显式仓外受控归档;安全回执不携带 raw 路径。**raw 落库改写后降为可选备份**(见下)。 | | `persist_raw.py` | raw 落库(2026-07-30 改写):完整 prompt/response/oracle/原书全文/供应商响应落 `example_raw_lease` + `example_raw_content`(访问控制 + append-only),写入前过滤密钥,看板可看全文。 | +| `repair_receipt_evidence.py` | 仅对成功回执追加不可变 receipt revision,把已存在的模型调用 raw 指针补回回执;失败回执不参与补证,不更新旧账。 | +| `record_failed_run.py` | 为失败运行追加 `is_error=true` 的回执和按 sample 隔离的 `runtime-failure-v1` 质量结果,保留安全失败类型与 raw 指针。 | ## 依赖方向 diff --git a/.claude/skills/runtime/scripts/claude_runtime.py b/.claude/skills/runtime/scripts/claude_runtime.py index ad8c708..a9529a7 100644 --- a/.claude/skills/runtime/scripts/claude_runtime.py +++ b/.claude/skills/runtime/scripts/claude_runtime.py @@ -15,6 +15,7 @@ import os import pathlib import re import shutil +import signal import subprocess import tempfile import time @@ -108,6 +109,30 @@ def _json_value(value: Any) -> Any: raise TypeError(f"值不是受支持的 JSON 类型: {type(value).__name__}") +def _normalize_structured_json(value: Any) -> Any: + """把 Decimal 解析结果收敛为下游可直接持久化的标准 JSON 值。""" + + if isinstance(value, Decimal): + if not value.is_finite(): + raise ValueError("structured_output 不允许非有限数字") + integral = value.to_integral_value() + if value == integral: + return int(integral) + number = float(value) + if not math.isfinite(number) or Decimal(str(number)) != value: + raise ValueError("structured_output 小数无法无歧义归一化") + return number + if value is None or isinstance(value, (str, bool, int, float)): + if isinstance(value, float) and not math.isfinite(value): + raise ValueError("structured_output 不允许非有限数字") + return value + if isinstance(value, Mapping): + return {str(key): _normalize_structured_json(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_normalize_structured_json(item) for item in value] + raise TypeError(f"structured_output 含非 JSON 类型: {type(value).__name__}") + + def canonical_json(value: Any) -> str: """生成 UTF-8、排序键、无多余空白的规范 JSON。""" @@ -337,10 +362,12 @@ class ExecutionReceipt: @dataclass(frozen=True) class ClaudeInvocationResult: - """成功调用的业务对象和安全回执。""" + """成功调用的业务对象、安全回执和可选 raw(raw 由持久化器接管)。""" structured_output: Mapping[str, Any] receipt: ExecutionReceipt + raw_request: str | None = None + raw_response: str | None = None def _safe_file_sha256(path: pathlib.Path) -> str: @@ -643,6 +670,60 @@ def _validate_usage(value: Any, path: str = "usage") -> None: _validate_safe_json(item, field_path) +def _kill_process_group(process: subprocess.Popen[str]) -> None: + """强制回收 fresh Claude 进程组,避免父进程收口后模型仍继续运行或计费。""" + + try: + os.killpg(process.pid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + try: + process.kill() + except ProcessLookupError: + pass + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +def _run_default_subprocess( + command: Sequence[str], + *, + input: str, + text: bool, + capture_output: bool, + timeout: float, + check: bool, + cwd: str, + env: Mapping[str, str], + start_new_session: bool, +) -> subprocess.CompletedProcess[str]: + """以可控 Popen 执行真实 CLI,任何非正常离开都先回收整个独立进程组。""" + + if not text or not capture_output or check or not start_new_session: + raise ValueError("默认 Claude 子进程参数不符合受控运行合同") + process = subprocess.Popen( + list(command), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + cwd=cwd, + env=dict(env), + start_new_session=True, + ) + try: + stdout, stderr = process.communicate(input=input, timeout=timeout) + except BaseException: + # WHY: execute 的 SIGTERM handler 会抛 ReplayInterrupted(BaseException)。如果继续使用 + # subprocess.run,它的 Popen 上下文只等待、不终止子进程,父进程会卡在失败收口,模型 + # 仍可能继续计费。这里必须先杀独立进程组并 wait,再原样传播超时/中断。 + _kill_process_group(process) + raise + return subprocess.CompletedProcess(list(command), process.returncode, stdout, stderr) + + def _schema_type_matches(value: Any, expected: str) -> bool: """按 JSON 类型语义判断 Python 值,显式排除 bool 伪装整数。""" @@ -765,6 +846,56 @@ def _receipt( ) +def _default_persist_call(event: Mapping[str, Any]): + """按需加载 runtime raw 写入器,保持离线 fake runner 无数据库副作用。""" + import sys + + runtime_dir = pathlib.Path(__file__).resolve().parent + if str(runtime_dir) not in sys.path: + sys.path.insert(0, str(runtime_dir)) + from persist_llm_call import persist_call + return persist_call(dict(event)) + + +def _runtime_prompt(profile: ExecutionProfile, business_input: Mapping[str, Any]) -> str: + """记录不含认证头的完整业务输入和执行 profile。""" + return json.dumps( + { + "businessInput": business_input, + "executionProfile": { + "profileVersion": profile.profile_version, + "adapterRole": profile.adapter_role, + "resolvedModelId": profile.resolved_model_id, + "effort": profile.effort, + "maxBudgetUsdPerCall": format(profile.max_budget_usd_per_call, "f"), + "timeoutSeconds": profile.timeout_seconds, + "maxContextChars": profile.max_context_chars, + "jsonSchemaId": profile.json_schema_id, + "jsonSchemaSha256": profile.json_schema_sha256, + "systemPromptId": profile.system_prompt_id, + "systemPrompt": profile.system_prompt, + "systemPromptSha256": profile.system_prompt_sha256, + "executionProfileSha256": profile.execution_profile_sha256, + }, + }, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + default=str, + ) + + +def _runtime_usage(usage: Mapping[str, Any] | None) -> dict[str, Any]: + """把 Claude usage 映射到 example_llm_call 的统一计数口径。""" + usage = usage or {} + cached = int(usage.get("cache_read_input_tokens", 0) or 0) + return { + "prompt_tokens": int(usage.get("input_tokens", 0) or 0) + cached, + "completion_tokens": int(usage.get("output_tokens", 0) or 0), + "prompt_tokens_details": {"cached_tokens": cached}, + } + + def run_claude( profile: ExecutionProfile, business_input: Mapping[str, Any], @@ -773,6 +904,9 @@ def run_claude( binding_verifier: Callable[[ExecutionProfile], None] = verify_execution_profile, business_validator: Callable[[Any], Mapping[str, Any]] | None = None, source_environment: Mapping[str, str] | None = None, + run_id: str | None = None, + caller: str | None = None, + persist_call: Callable[[Mapping[str, Any]], Any] | None = None, ) -> ClaudeInvocationResult: """以 fresh sandbox 进程执行一次 Claude 调用并联合校验回执。""" @@ -803,7 +937,8 @@ def run_claude( raise ClaudeRuntimeError(f"{prefix}_RECEIPT_INVALID", str(exc)) from exc command = build_sandbox_command(profile, isolation_path) try: - completed = runner( + invocation_runner = _run_default_subprocess if runner is subprocess.run else runner + completed = invocation_runner( command, input=input_text, text=True, @@ -951,11 +1086,17 @@ def run_claude( if "structured_output" not in envelope: issues.add(f"{prefix}_SCHEMA_INVALID") else: - structured_output = envelope["structured_output"] try: + # WHY: envelope 为精确核账使用 Decimal 解析,但结构化业务输出必须是标准 + # JSON 值。若把 Decimal 原样交给适配器或 raw 持久化,半分评分等合法小数 + # 会在 json.dumps 处抛 TypeError,并使回执哈希与实际业务对象失去绑定。 + structured_output = _normalize_structured_json( + envelope["structured_output"] + ) validate_json_schema(structured_output, profile.json_schema) if business_validator is not None: structured_output = business_validator(structured_output) + structured_output = _normalize_structured_json(structured_output) if not isinstance(structured_output, Mapping): raise ValueError("业务校验器必须返回对象") structured_output_sha256 = sha256_json(structured_output) @@ -997,7 +1138,40 @@ def run_claude( receipt=receipt, ) assert isinstance(structured_output, Mapping) - return ClaudeInvocationResult(dict(structured_output), receipt) + raw_request = _runtime_prompt(profile, business_input) + raw_response = completed.stdout or "" + if persist_call is None and runner is subprocess.run and run_id: + persist_call = _default_persist_call + if persist_call is not None: + try: + persist_call({ + "window_key": None, + "run_id": run_id, + "caller": caller or profile.adapter_role, + "requested_model_id": receipt.requested_model_id, + "actual_model_id": receipt.actual_model_id or profile.resolved_model_id, + "usage": _runtime_usage(receipt.usage), + "cost_usd": receipt.total_cost_usd or 0, + "stop_reason": receipt.terminal_reason or receipt.stop_reason, + "duration_ms": receipt.duration_ms, + "prompt": raw_request, + "response": raw_response, + "role": caller or profile.adapter_role, + "source_version": receipt.execution_profile_sha256, + "purpose": "production_generation" if caller == "writer" else "evaluation", + }) + except Exception as exc: + raise ClaudeRuntimeError( + f"{prefix}_RAW_PERSIST_FAILED", + "Claude 调用成功但 raw/调用明细未能落库", + receipt=receipt, + ) from exc + return ClaudeInvocationResult( + structured_output=dict(structured_output), + receipt=receipt, + raw_request=raw_request, + raw_response=raw_response, + ) finally: if isolation_path is not None: # 运行隔离目录不承担 raw 保留职责;调用结束始终立即删除。 diff --git a/.claude/skills/runtime/scripts/persist_llm_call.py b/.claude/skills/runtime/scripts/persist_llm_call.py new file mode 100644 index 0000000..f849fd7 --- /dev/null +++ b/.claude/skills/runtime/scripts/persist_llm_call.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""把一次 LLM 调用的输入、输出和调用明细原子落库。 + +调用入口只负责采集供应商响应;本模块负责把同一尝试的 raw lease、prompt、response +和 ``example_llm_call`` 放进一个事务。数据库失败时调用方不能拿到一份看似成功、却 +没有审计证据的结果。 +""" +import json +import sys +from pathlib import Path + + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) +from persist_raw import _bare_sha256, _check_no_secrets # noqa: E402 + + +CREATOR = "llm" + + +def _json_text(value) -> str: + """生成稳定、可重放且不带请求头的 JSON 原文。""" + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str) + + +def _usage_int(usage, key): + """读取供应商 usage,缺失或脏值按 0 记账。""" + value = (usage or {}).get(key, 0) + try: + return max(0, int(value or 0)) + except (TypeError, ValueError): + return 0 + + +def _insert_raw(conn, *, lease_id, kind, content, run_id, role, creator): + """在调用事务内幂等插入 raw,避免 append-only 表上的冲突 UPDATE。""" + sha = _bare_sha256(content) + row = conn.execute( + "INSERT INTO example_raw_content(lease_id, kind, run_id, role, content_sha256, content, creator) " + "VALUES (%s,%s,%s,%s,%s,%s,%s) ON CONFLICT (lease_id, content_sha256) DO NOTHING " + "RETURNING id", + (lease_id, kind, run_id, role, sha, content, creator), + ).fetchone() + if row: + return row[0], sha + row = conn.execute( + "SELECT id FROM example_raw_content WHERE lease_id=%s AND content_sha256=%s", + (lease_id, sha), + ).fetchone() + if not row: + raise RuntimeError(f"raw {kind} 插入后无法回读 id") + return row[0], sha + + +def _reuse_raw(conn, *, lease_id, prompt_sha, response_sha): + """迁移已有 raw 时只回读并校验,不复制全文或改变 append-only 数据。""" + prompt_row = conn.execute( + "SELECT id FROM example_raw_content WHERE lease_id=%s AND kind='prompt' AND content_sha256=%s", + (lease_id, prompt_sha), + ).fetchone() + if not prompt_row: + raise ValueError(f"raw lease={lease_id} 不含匹配的 prompt") + response_id = None + if response_sha: + response_row = conn.execute( + "SELECT id FROM example_raw_content WHERE lease_id=%s AND kind='response' AND content_sha256=%s", + (lease_id, response_sha), + ).fetchone() + if not response_row: + raise ValueError(f"raw lease={lease_id} 不含匹配的 response") + response_id = response_row[0] + return prompt_row[0], response_id + + +def persist_call(event, *, creator=CREATOR, dry_run=False): + """原子写入一条成功调用。 + + ``event`` 由 llm.chat 生成,至少包含 prompt/response、模型、usage 和调用方字段。 + 返回各证据行 id;dry-run 只验证事务并回滚。 + """ + from db import connect + + prompt = event.get("prompt") + response = event.get("response") + if not isinstance(prompt, str) or not prompt: + raise ValueError("LLM raw prompt 不能为空") + if response is not None and not isinstance(response, str): + raise ValueError("LLM raw response 必须是字符串或 NULL") + _check_no_secrets(prompt) + if response: + _check_no_secrets(response) + + requested = str(event.get("requested_model_id") or "") + actual = str(event.get("actual_model_id") or "") + caller = str(event.get("caller") or "") + if not requested or not actual: + raise ValueError("LLM 调用明细缺 requested_model_id/actual_model_id") + if len(requested) > 64 or len(actual) > 64 or len(caller) > 64: + raise ValueError("LLM 调用明细字段超长") + + usage = event.get("usage") or {} + cached = _usage_int((usage.get("prompt_tokens_details") or {}), "cached_tokens") + prompt_sha = _bare_sha256(prompt) + response_sha = _bare_sha256(response) if response else None + content_hashes = {"prompt": prompt_sha} + if response_sha: + content_hashes["response"] = response_sha + + with connect() as conn: + try: + if event.get("raw_lease_id") is not None: + lease_id = int(event["raw_lease_id"]) + prompt_id, response_id = _reuse_raw( + conn, lease_id=lease_id, prompt_sha=prompt_sha, response_sha=response_sha + ) + else: + lease_id = conn.execute( + "INSERT INTO example_raw_lease(run_id, source_version, content_hashes, purpose, status, creator) " + "VALUES (%s,%s,%s::jsonb,%s,%s,%s) RETURNING id", + ( + event.get("run_id"), + event.get("source_version"), + json.dumps(content_hashes, ensure_ascii=False), + event.get("purpose") or "llm_call", + "closed", + creator, + ), + ).fetchone()[0] + prompt_id, _ = _insert_raw( + conn, + lease_id=lease_id, + kind="prompt", + content=prompt, + run_id=event.get("run_id"), + role=event.get("role") or caller, + creator=creator, + ) + response_id = None + if response: + response_id, _ = _insert_raw( + conn, + lease_id=lease_id, + kind="response", + content=response, + run_id=event.get("run_id"), + role=event.get("role") or caller, + creator=creator, + ) + call_id = conn.execute( + "INSERT INTO example_llm_call(window_key, run_id, caller, requested_model_id, actual_model_id, " + "model_match, in_tokens, cached_tokens, out_tokens, cost_usd, stop_reason, duration_ms, " + "prompt_sha256, raw_content_id, creator) " + "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING id", + ( + event.get("window_key"), + event.get("run_id"), + caller, + requested, + actual, + requested == actual, + _usage_int(usage, "prompt_tokens"), + cached, + _usage_int(usage, "completion_tokens"), + event.get("cost_usd") or 0, + str(event.get("stop_reason") or "")[:32] or None, + event.get("duration_ms"), + prompt_sha, + response_id, + creator, + ), + ).fetchone()[0] + if dry_run: + conn.rollback() + return { + "status": "dry_run_ok", + "lease_id": lease_id, + "prompt_id": prompt_id, + "response_id": response_id, + "call_id": call_id, + "note": "试跑已回滚,未落库", + } + conn.commit() + return { + "status": "written", + "lease_id": lease_id, + "prompt_id": prompt_id, + "response_id": response_id, + "call_id": call_id, + } + except Exception: + conn.rollback() + raise diff --git a/.claude/skills/runtime/scripts/persist_raw.py b/.claude/skills/runtime/scripts/persist_raw.py index ccc2211..1d84f75 100644 --- a/.claude/skills/runtime/scripts/persist_raw.py +++ b/.claude/skills/runtime/scripts/persist_raw.py @@ -9,6 +9,7 @@ raw 进库可看全文(看板可读),仓外 vault 降级为可选备份; """ import hashlib import json +import re import sys from pathlib import Path @@ -20,16 +21,21 @@ from db import connect # noqa: E402 CREATOR = "runtime" KINDS = ("prompt", "response", "source_text", "oracle", "supplier") -# 写入前过滤:命中即拒,密钥/token/凭据绝不入 raw 表(列级 CHECK 之外的第二道) -_SECRET_MARKERS = ("api_key", "apikey", "secret", "token", "password", "passwd", - "authorization: bearer", "sk-", "-----begin private key-----") +# 写入前过滤:命中即拒,密钥/token/凭据绝不入 raw 表(列级 CHECK 之外的第二道)。 +# 不能把普通提示中的 ``token budget`` 当成密钥;只拦赋值形态、Bearer 和足够长的真实 key。 +_SECRET_PATTERNS = ( + re.compile(r"(?i)\b(?:api[_-]?key|apikey|secret|password|passwd|token)\b\s*[:=]\s*['\"]?[^\s,'\"}]+"), + re.compile(r"(?i)authorization\s*:\s*bearer\s+\S+"), + re.compile(r"(?i)\bsk-[a-z0-9_-]{16,}\b"), + re.compile(r"(?i)-----begin private key-----"), +) def _check_no_secrets(content: str) -> None: - low = content.lower() - for marker in _SECRET_MARKERS: - if marker in low: - raise ValueError(f"raw 内容疑似含密钥/凭据标记 '{marker}',拒绝入库(安全)") + for pattern in _SECRET_PATTERNS: + match = pattern.search(content) + if match: + raise ValueError(f"raw 内容疑似含密钥/凭据标记 '{match.group(0)[:40]}',拒绝入库(安全)") def _bare_sha256(text: str) -> str: @@ -70,8 +76,16 @@ def write_content(lease_id, kind, content, *, run_id=None, role=None, creator=CR row = conn.execute( "INSERT INTO example_raw_content(lease_id, kind, run_id, role, content_sha256, content, creator) " "VALUES (%s,%s,%s,%s,%s,%s,%s) ON CONFLICT (lease_id, content_sha256) " - "DO UPDATE SET creator=EXCLUDED.creator RETURNING id, content_sha256", + "DO NOTHING RETURNING id, content_sha256", (lease_id, kind, run_id, role, sha, content, creator)).fetchone() + if not row: + row = conn.execute( + "SELECT id,content_sha256 FROM example_raw_content " + "WHERE lease_id=%s AND content_sha256=%s", + (lease_id, sha), + ).fetchone() + if not row: + raise RuntimeError("raw 幂等回读失败") if dry_run: conn.rollback() return {"status": "dry_run_ok", "id": row[0], "content_sha256": row[1], "note": "试跑已回滚,未落库"} diff --git a/.claude/skills/runtime/scripts/record_failed_run.py b/.claude/skills/runtime/scripts/record_failed_run.py new file mode 100644 index 0000000..d196cf3 --- /dev/null +++ b/.claude/skills/runtime/scripts/record_failed_run.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""为已失败的模型阶段追加失败回执和质量结果。 + +失败运行也必须有可浏览的账本;该入口只写 safe failure summary 和已存在的 +raw 指针,不复制供应商响应,不把失败伪装成通过。 +""" +import argparse +import hashlib +import json +import pathlib +import sys + + +DB_SCRIPTS = pathlib.Path(__file__).resolve().parents[2] / "db" / "scripts" +sys.path.insert(0, str(DB_SCRIPTS)) +from db import connect # noqa: E402 + + +CREATOR = "runtime-failure-receipt" + + +def _failure_dimension(sample_id): + """为同一运行内的不同失败样本生成稳定且不泄露正文的质量维度。""" + digest = hashlib.sha256(str(sample_id).encode("utf-8")).hexdigest()[:16] + return f"runtime-{digest}" + + +def record_failure(run_id, *, sample_id, adapter_role, failure_type, caller=None, dry_run=False): + caller = caller or adapter_role + with connect() as conn: + try: + run = conn.execute( + "SELECT terminal_state,target_chapter FROM example_run " + "WHERE run_id=%s AND deleted=false", + (run_id,), + ).fetchone() + if not run: + raise ValueError(f"运行不存在:{run_id}") + if run[0] != "failed": + raise ValueError(f"运行不是 failed:run_id={run_id} state={run[0]}") + latest = conn.execute( + "SELECT requested_model_id,actual_model_id,model_match,cost_usd,in_tokens," + "cached_tokens,out_tokens,stop_reason,raw_content_id FROM example_llm_call " + "WHERE run_id=%s AND caller=%s ORDER BY id DESC LIMIT 1", + (run_id, caller), + ).fetchone() + requested = actual = None + model_match = None + cost = None + usage = None + stop_reason = None + raw_content_id = None + if latest: + (requested, actual, model_match, cost, in_tokens, cached_tokens, out_tokens, + stop_reason, raw_content_id) = latest + usage = {"input_tokens": in_tokens, "cached_tokens": cached_tokens, + "output_tokens": out_tokens} + revision = conn.execute( + "SELECT COALESCE(MAX(revision),0)+1 FROM example_run_receipt " + "WHERE tenant_id=0 AND run_id=%s AND sample_id=%s", + (run_id, sample_id), + ).fetchone()[0] + safe = {"failureType": str(failure_type)[:64], "sampleId": str(sample_id)[:128], + "recordedBy": CREATOR} + receipt_id = conn.execute( + "INSERT INTO example_run_receipt(run_id,sample_id,revision,adapter_role,stage_kind," + "attempt,requested_model_id,actual_model_id,model_match,total_cost_usd,usage,stop_reason," + "terminal_reason,is_error,safe_summary,raw_content_id,creator,tenant_id) " + "VALUES (%s,%s,%s,%s,'generation',1,%s,%s,%s,%s,%s::jsonb,%s,'failed',TRUE,%s::jsonb,%s,%s,0) " + "ON CONFLICT (tenant_id,run_id,sample_id,revision) DO NOTHING RETURNING id", + (run_id, sample_id, revision, adapter_role, requested, actual, model_match, cost, + json.dumps(usage or {}, ensure_ascii=False), stop_reason, json.dumps(safe, ensure_ascii=False), + raw_content_id, CREATOR), + ).fetchone() + quality_id = conn.execute( + "INSERT INTO example_quality_result(run_id,receipt_id,judge_kind,dimension,scale_version," + "conclusion,failure_class,detail,raw_content_id,creator,tenant_id) " + "VALUES (%s,%s,'detection',%s,'runtime-failure-v1','fail','runtime_failed',%s::jsonb,%s,%s,0) " + "ON CONFLICT (tenant_id,run_id,judge_kind,COALESCE(dimension,''),COALESCE(candidate_sha256,'')) DO NOTHING RETURNING id", + (run_id, receipt_id[0] if receipt_id else None, _failure_dimension(sample_id), + json.dumps(safe, ensure_ascii=False), + raw_content_id, CREATOR), + ).fetchone() + result = {"status": "dry_run_ok" if dry_run else "recorded", "run_id": run_id, + "receipt_id": receipt_id[0] if receipt_id else None, + "quality_id": quality_id[0] if quality_id else None, + "raw_content_id": raw_content_id} + if dry_run: + conn.rollback() + result["note"] = "试跑已回滚,未落库" + return result + conn.commit() + return result + except Exception: + conn.rollback() + raise + + +def main(): + parser = argparse.ArgumentParser(description="追加失败运行回执") + parser.add_argument("--run-id", required=True) + parser.add_argument("--sample-id", required=True) + parser.add_argument("--adapter-role", required=True) + parser.add_argument("--caller", default=None) + parser.add_argument("--failure-type", required=True) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + print(json.dumps(record_failure( + args.run_id, sample_id=args.sample_id, adapter_role=args.adapter_role, + caller=args.caller, failure_type=args.failure_type, dry_run=args.dry_run, + ), ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/runtime/scripts/repair_receipt_evidence.py b/.claude/skills/runtime/scripts/repair_receipt_evidence.py new file mode 100644 index 0000000..75cc991 --- /dev/null +++ b/.claude/skills/runtime/scripts/repair_receipt_evidence.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""为已有成功运行追加一条不可变回执,把成功模型调用的 raw 指针补齐。 + +只处理“成功回执已有、成功调用明细和 raw 已存在、但回执 raw_content_id 为空”的情况。 +不更新旧回执;新 revision 的 safe_summary 标明这是证据补正。 +""" +import argparse +import json +import pathlib +import sys + + +DB_SCRIPTS = pathlib.Path(__file__).resolve().parents[2] / "db" / "scripts" +sys.path.insert(0, str(DB_SCRIPTS)) +from db import connect # noqa: E402 + + +CREATOR = "runtime-receipt-repair" + + +def _eligible_receipt(is_error, raw_content_id): + """失败回执不参与成功调用补证,避免看板把失败显示成通过。""" + return not is_error and raw_content_id is None + + +def repair_run(run_id, *, dry_run=False): + with connect() as conn: + try: + receipts = conn.execute( + "SELECT run_id,sample_id,revision,attempt,loop_seq,adapter_role,stage_kind," + "candidate_version,candidate_sha256,context_sha256,requested_model_id,actual_model_id," + "model_match,effort,total_cost_usd,usage,stop_reason,terminal_reason,is_error," + "safe_summary,result_sha256,raw_content_id FROM example_run_receipt " + "WHERE run_id=%s ORDER BY sample_id,revision", + (run_id,), + ).fetchall() + if not receipts: + raise ValueError(f"运行没有回执:{run_id}") + added = [] + for receipt in receipts: + (rid, sample_id, revision, attempt, loop_seq, role, stage_kind, candidate_version, + candidate_sha, context_sha, requested, actual, model_match, effort, total_cost, + usage, stop_reason, terminal_reason, is_error, summary, result_sha, raw_id) = receipt + if not _eligible_receipt(is_error, raw_id): + continue + call = conn.execute( + "SELECT id,requested_model_id,actual_model_id,model_match,in_tokens,cached_tokens," + "out_tokens,cost_usd,stop_reason,raw_content_id FROM example_llm_call " + "WHERE run_id=%s AND caller=%s AND out_tokens>0 AND raw_content_id IS NOT NULL " + "ORDER BY id DESC LIMIT 1", + (run_id, role), + ).fetchone() + if not call: + continue + call_id, call_requested, call_actual, call_match, in_tokens, cached_tokens, out_tokens, cost, call_stop, call_raw = call + next_revision = conn.execute( + "SELECT COALESCE(MAX(revision),0)+1 FROM example_run_receipt " + "WHERE tenant_id=0 AND run_id=%s AND sample_id=%s", + (run_id, sample_id), + ).fetchone()[0] + safe = dict(summary or {}) if isinstance(summary, dict) else {} + safe.update({ + "evidenceCorrection": "receipt-raw-pointer-v1", + "sourceCallId": call_id, + "sourceRawContentId": call_raw, + }) + new_row = conn.execute( + "INSERT INTO example_run_receipt(run_id,sample_id,revision,adapter_role,stage_kind," + "attempt,loop_seq,candidate_version,candidate_sha256,context_sha256,requested_model_id," + "actual_model_id,model_match,effort,total_cost_usd,usage,stop_reason,terminal_reason," + "is_error,safe_summary,result_sha256,raw_content_id,creator,tenant_id) " + "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s,%s,%s,%s::jsonb,%s,%s,%s,0) " + "ON CONFLICT (tenant_id,run_id,sample_id,revision) DO NOTHING RETURNING id", + (rid, sample_id, next_revision, role, stage_kind, attempt, loop_seq, + candidate_version, candidate_sha, context_sha, call_requested, call_actual, + call_match, effort, cost, json.dumps({ + "input_tokens": in_tokens, "cached_tokens": cached_tokens, + "output_tokens": out_tokens, + }, ensure_ascii=False), call_stop or stop_reason, terminal_reason, + is_error, json.dumps(safe, ensure_ascii=False), result_sha, call_raw, CREATOR), + ).fetchone() + if new_row: + added.append({"sample_id": sample_id, "revision": next_revision, + "receipt_id": new_row[0], "raw_content_id": call_raw}) + result = {"status": "dry_run_ok" if dry_run else "repaired", + "run_id": run_id, "added": added} + if dry_run: + conn.rollback() + result["note"] = "试跑已回滚,未落库" + return result + conn.commit() + return result + except Exception: + conn.rollback() + raise + + +def main(): + parser = argparse.ArgumentParser(description="追加 writer 回执 raw 指针补正") + parser.add_argument("--run-id", required=True) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + print(json.dumps(repair_run(args.run_id, dry_run=args.dry_run), ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/runtime/scripts/run_registry.py b/.claude/skills/runtime/scripts/run_registry.py new file mode 100644 index 0000000..8fe1758 --- /dev/null +++ b/.claude/skills/runtime/scripts/run_registry.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""运行注册表的最小写路径。 + +各 skill 只负责声明阶段和作品,本模块统一登记 ``example_run`` 并收口终态更新, +让 dashboard 能把模型调用、回执和质量结果挂到同一个运行下。数据库写入保持短事务。 +""" +from contextlib import contextmanager +from datetime import datetime +import json +import re +import sys +import uuid +from pathlib import Path + + +DB_SCRIPTS = Path(__file__).resolve().parents[2] / "db" / "scripts" +if str(DB_SCRIPTS) not in sys.path: + sys.path.insert(0, str(DB_SCRIPTS)) +from db import connect # noqa: E402 + + +CREATOR = "runtime" +_RUN_ID_PART = re.compile(r"[^a-zA-Z0-9_.-]+") +_TERMINAL_STATES = frozenset(("completed", "failed")) + + +def new_run_id(stage, *, work_id=None, target_chapter=None): + """生成可读且全局足够唯一的运行 ID(数据库上限 64 字符)。""" + prefix = _RUN_ID_PART.sub("-", str(stage or "run")).strip("-.") or "run" + scope = "" if work_id is None else f"-w{int(work_id)}" + target = "" if target_chapter is None else f"-c{int(target_chapter)}" + stamp = datetime.now().strftime("%Y%m%dT%H%M%S") + return f"{prefix}{scope}{target}-{stamp}-{uuid.uuid4().hex[:10]}"[:64] + + +def start_run(*, run_id=None, work_id=None, target_chapter=None, + trigger_source="user", trigger_detail=None, creator=CREATOR): + """登记或回读一个运行;已有同 ID 运行必须属于同一作品和目标章。""" + if trigger_source not in ("user", "replay_eval", "diagnostic"): + raise ValueError(f"trigger_source 非法: {trigger_source}") + run_id = run_id or new_run_id("stage", work_id=work_id, target_chapter=target_chapter) + if len(run_id) > 64: + raise ValueError("run_id 超过 64 字符") + detail = json.dumps(trigger_detail, ensure_ascii=False) if trigger_detail is not None else None + with connect() as conn: + try: + conn.execute( + "INSERT INTO example_run(run_id, work_id, target_chapter, trigger_source, trigger_detail, " + "terminal_state, creator, updater) VALUES (%s,%s,%s,%s,%s::jsonb,'running',%s,%s) " + "ON CONFLICT (run_id) DO NOTHING", + (run_id, work_id, target_chapter, trigger_source, detail, creator, creator), + ) + row = conn.execute( + "SELECT run_id, work_id, target_chapter, terminal_state FROM example_run " + "WHERE run_id=%s AND deleted=FALSE", + (run_id,), + ).fetchone() + if not row: + raise RuntimeError(f"运行登记后无法回读: {run_id}") + if row[1] != work_id or row[2] != target_chapter: + raise ValueError(f"run_id 已绑定其他作品/目标章: {run_id}") + conn.commit() + return { + "run_id": row[0], + "work_id": row[1], + "target_chapter": row[2], + "terminal_state": row[3], + "status": "existing" if row[3] != "running" else "started", + } + except Exception: + conn.rollback() + raise + + +def finish_run(run_id, terminal_state, *, creator=CREATOR, trigger_detail=None): + """把运行置为 completed/failed,并由数据库约束保证有 finished_at。""" + if terminal_state not in _TERMINAL_STATES: + raise ValueError(f"终态非法: {terminal_state}") + detail = json.dumps(trigger_detail, ensure_ascii=False) if trigger_detail is not None else None + with connect() as conn: + try: + row = conn.execute( + "UPDATE example_run SET terminal_state=%s, finished_at=CURRENT_TIMESTAMP, " + "trigger_detail=COALESCE(%s::jsonb, trigger_detail), updater=%s " + "WHERE run_id=%s AND deleted=FALSE RETURNING run_id, terminal_state, finished_at", + (terminal_state, detail, creator, run_id), + ).fetchone() + if not row: + raise ValueError(f"运行不存在或已删除: {run_id}") + conn.commit() + return {"run_id": row[0], "terminal_state": row[1], "finished_at": row[2]} + except Exception: + conn.rollback() + raise + + +@contextmanager +def managed_run(*, run_id=None, work_id=None, target_chapter=None, + trigger_source="user", trigger_detail=None, creator=CREATOR): + """以成功/失败终态包住一个生产阶段。""" + record = start_run( + run_id=run_id, + work_id=work_id, + target_chapter=target_chapter, + trigger_source=trigger_source, + trigger_detail=trigger_detail, + creator=creator, + ) + active_id = record["run_id"] + try: + yield active_id + except BaseException as exc: + finish_run(active_id, "failed", creator=creator, + trigger_detail={"error_type": type(exc).__name__}) + raise + else: + finish_run(active_id, "completed", creator=creator) + + +__all__ = ["finish_run", "managed_run", "new_run_id", "start_run"] diff --git a/.claude/skills/runtime/scripts/test_claude_runtime.py b/.claude/skills/runtime/scripts/test_claude_runtime.py index db8b07c..006b49c 100644 --- a/.claude/skills/runtime/scripts/test_claude_runtime.py +++ b/.claude/skills/runtime/scripts/test_claude_runtime.py @@ -4,14 +4,17 @@ from __future__ import annotations import copy +from dataclasses import replace import hashlib import json import os import pathlib +import signal import subprocess import sys import tempfile import unittest +from unittest import mock from decimal import Decimal SCRIPT_DIR = pathlib.Path(__file__).resolve().parent @@ -20,6 +23,7 @@ sys.path.insert(0, str(SCRIPT_DIR)) from claude_runtime import ( # noqa: E402 ClaudeRuntimeError, ExecutionProfile, + _run_default_subprocess, _minimal_environment, _validate_usage, build_sandbox_command, @@ -33,6 +37,38 @@ from claude_runtime import ( # noqa: E402 FULL_MODEL_ID = "claude-opus-4-1-20250805" + + +class DefaultSubprocessCleanupTest(unittest.TestCase): + """真实子进程边界必须在父进程异常时回收整个模型进程组。""" + + def test_parent_interruption_kills_and_waits_for_process_group(self): + class ParentInterrupted(BaseException): + pass + + process = mock.Mock() + process.pid = 4321 + process.communicate.side_effect = ParentInterrupted("测试父进程中断") + with ( + mock.patch("claude_runtime.subprocess.Popen", return_value=process), + mock.patch("claude_runtime.os.killpg") as killpg, + self.assertRaises(ParentInterrupted), + ): + _run_default_subprocess( + ["/frozen/claude"], + input="{}", + text=True, + capture_output=True, + timeout=1200, + check=False, + cwd="/private/tmp/runtime-fixture", + env={}, + start_new_session=True, + ) + + killpg.assert_called_once_with(4321, signal.SIGKILL) + process.wait.assert_called_once_with(timeout=5) + CAPABILITY_MODEL_ID = "claude-opus-4-8[1m]" OUTPUT_SCHEMA = { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -249,6 +285,49 @@ class ClaudeRuntimeTest(unittest.TestCase): self.assertEqual(real_auth_environment["ANTHROPIC_AUTH_TOKEN"], auth_token) self.assertEqual(real_auth_environment["ANTHROPIC_BASE_URL"], base_url) + def test_structured_output_decimal_numbers_are_json_native_and_hash_bound(self): + """精确解析出的半分小数必须先归一,再交给适配器、持久化并计算回执哈希。""" + + numeric_schema = { + "type": "object", + "additionalProperties": False, + "required": ["score", "nested"], + "properties": { + "score": {"type": "number"}, + "nested": { + "type": "array", + "items": {"type": "number"}, + }, + }, + } + profile = replace( + execution_profile(), + json_schema_id="test-numeric-output-v1", + json_schema=numeric_schema, + json_schema_sha256=sha256_json(numeric_schema), + ) + envelope = success_envelope() + envelope["structured_output"] = { + "score": 7.5, + "nested": [2.25, 3], + } + + result = run_claude( + profile, + {"request": "x"}, + runner=runner_for(envelope), + binding_verifier=lambda _profile: None, + ) + + self.assertEqual(result.structured_output, {"score": 7.5, "nested": [2.25, 3]}) + self.assertIsInstance(result.structured_output["score"], float) + self.assertIsInstance(result.structured_output["nested"][1], int) + json.dumps(result.structured_output, ensure_ascii=False) + self.assertEqual( + result.receipt.structured_output_sha256, + sha256_json(result.structured_output), + ) + def test_auth_token_requires_a_safe_base_url(self): """AUTH_TOKEN 必须绑定无凭据、无查询和无片段的 HTTP(S) 网关地址。""" diff --git a/.claude/skills/runtime/scripts/test_persist_raw.py b/.claude/skills/runtime/scripts/test_persist_raw.py new file mode 100644 index 0000000..600cdfa --- /dev/null +++ b/.claude/skills/runtime/scripts/test_persist_raw.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""raw 凭据过滤的离线回归测试。""" +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from persist_raw import _check_no_secrets # noqa: E402 + + +def test_normal_prompt_words_are_allowed(): + _check_no_secrets("输出不超过 4000 字;token budget 只用于篇幅估算。") + + +def test_secret_assignments_are_rejected(): + for text in ( + "api_key=sk-example-secret-value", + "token: abcdefghijklmnop", + "Authorization: Bearer abcdefghijklmnop", + "-----BEGIN PRIVATE KEY-----", + ): + try: + _check_no_secrets(text) + except ValueError: + continue + raise AssertionError(f"凭据未被拒绝: {text}") + + +def main(): + test_normal_prompt_words_are_allowed() + test_secret_assignments_are_rejected() + print("全部通过") + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/runtime/scripts/test_record_failed_run.py b/.claude/skills/runtime/scripts/test_record_failed_run.py new file mode 100644 index 0000000..97d9546 --- /dev/null +++ b/.claude/skills/runtime/scripts/test_record_failed_run.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""失败回执 CLI 的输入边界烟测。""" +import pathlib +import sys +import unittest + + +SCRIPT_DIR = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +import record_failed_run as failure # noqa: E402 + + +class FailedRunContractTest(unittest.TestCase): + def test模块可导入并保持安全摘要短字段(self): + self.assertLessEqual(len(failure.CREATOR), 64) + self.assertTrue(callable(failure.record_failure)) + + def test同一运行不同失败样本使用不同质量维度(self): + self.assertNotEqual(failure._failure_dimension("sample-a"), + failure._failure_dimension("sample-b")) + self.assertLessEqual(len(failure._failure_dimension("sample-a")), 32) + + +if __name__ == "__main__": + unittest.main() diff --git a/.claude/skills/runtime/scripts/test_repair_receipt_evidence.py b/.claude/skills/runtime/scripts/test_repair_receipt_evidence.py new file mode 100644 index 0000000..de056b9 --- /dev/null +++ b/.claude/skills/runtime/scripts/test_repair_receipt_evidence.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +"""成功回执补证的离线合同测试。""" +import pathlib +import sys +import unittest + + +SCRIPT_DIR = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +import repair_receipt_evidence as repair # noqa: E402 + + +class ReceiptEvidenceRepairTest(unittest.TestCase): + def test失败回执不具备成功补证资格(self): + self.assertFalse(repair._eligible_receipt(True, None)) + self.assertFalse(repair._eligible_receipt(False, 10)) + self.assertTrue(repair._eligible_receipt(False, None)) + + +if __name__ == "__main__": + unittest.main() diff --git a/.claude/skills/runtime/scripts/test_run_registry.py b/.claude/skills/runtime/scripts/test_run_registry.py new file mode 100644 index 0000000..e9cc93d --- /dev/null +++ b/.claude/skills/runtime/scripts/test_run_registry.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""运行 ID 和终态合同的离线测试。""" +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +import run_registry # noqa: E402 + + +def test_new_run_id_is_scoped_and_bounded(): + run_id = run_registry.new_run_id("parse-book/chapters", work_id=12, target_chapter=2) + assert run_id.startswith("parse-book-chapters-w12-c2-") + assert len(run_id) <= 64 + + +def test_invalid_terminal_state_is_rejected_before_db_access(): + try: + run_registry.finish_run("never", "running") + except ValueError as exc: + assert "终态非法" in str(exc) + else: + raise AssertionError("running 不应被 finish_run 接受") + + +def main(): + for test in (test_new_run_id_is_scoped_and_bounded, + test_invalid_terminal_state_is_rejected_before_db_access): + test() + print(f" ✓ {test.__name__}") + print("全部通过") + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/search/scripts/search.py b/.claude/skills/search/scripts/search.py index 852337e..28e0202 100644 --- a/.claude/skills/search/scripts/search.py +++ b/.claude/skills/search/scripts/search.py @@ -19,14 +19,14 @@ DSN = "postgresql://root:f6710e2d0294eb1c10e26a805a64bc54@100.64.0.8:5433/muse-e TENANT = 1 -def load_ai_context(conn): +def load_ai_context(conn, *, tenant_id: int = TENANT): """读库内 23 型的字段级 aiContext 细则:{target_type: {field: true/false/[用途]}}。""" rows = conn.execute( """SELECT s.target_type, v.policy_snapshot->'fieldAiContext' FROM muse_meta_schema s JOIN muse_meta_schema_version sv ON sv.id = s.active_version_id JOIN muse_meta_visibility_policy v ON v.schema_version_id = sv.id - WHERE s.tenant_id=%s AND s.deleted=FALSE""", (TENANT,)).fetchall() + WHERE s.tenant_id=%s AND s.deleted=FALSE""", (tenant_id,)).fetchall() return {t: (m or {}) for t, m in rows} @@ -96,8 +96,8 @@ def search_cards( 保留原有 draft 能力,但正文生产适配器不会调用治理面。 """ - if scope not in {"admin", "work"}: - raise ValueError("scope 只能是 admin 或 work") + if scope not in {"admin", "public_pattern", "work"}: + raise ValueError("scope 只能是 admin、public_pattern 或 work") if scope == "work" and not work_id: raise ValueError("scope=work 必须提供 work_id") if purpose not in {"generation", "planning", "detection", "extraction"}: @@ -107,7 +107,7 @@ def search_cards( qvec = json.dumps(embedder(intent)) with connection_factory(dsn) as conn: - ai_rules = load_ai_context(conn) + ai_rules = load_ai_context(conn, tenant_id=tenant_id) if scope == "admin": sql = """SELECT 'draft' AS src, d.id, d.draft_payload AS payload, d.status, 1 - (e.embedding <=> %s::vector) AS score, @@ -117,6 +117,31 @@ def search_cards( JOIN muse_knowledge_draft d ON d.id = e.draft_id WHERE e.tenant_id=%s AND e.deleted=FALSE AND d.deleted=FALSE""" args = [qvec, tenant_id] + elif scope == "public_pattern": + # WHY: 公共范式仍处于 draft 双轨,不能走作品 entity/binding 面;专用查询必须在 + # SQL 层同时锁住全局作品号、公共目标库、全局库存在性和来源资格,不能复用会 + # 召回同租户全部治理草稿的 admin 面。当前 draft schema 没有 kb_id/scope 列, + # 所以库归属按已登记的数据合同由 work_id + 目标库绑定,scope 缺省按 global。 + sql = """SELECT 'draft' AS src, d.id, d.draft_payload AS payload, d.status, + 1 - (e.embedding <=> %s::vector) AS score, + d.revision, d.current_canonical_snapshot AS lineage, + NULL::varchar AS binding_status, d.source_status + FROM example_knowledge_embedding e + JOIN muse_knowledge_draft d ON d.id = e.draft_id + WHERE e.tenant_id=%s AND d.tenant_id=%s + AND e.deleted=FALSE AND d.deleted=FALSE + AND d.work_id=0 + AND d.draft_payload->>'目标库'='公共范式库' + AND COALESCE(d.draft_payload->>'scope', 'global')='global' + AND d.status IN ('pending','confirmed') + AND COALESCE(d.source_status, 'active') IN ('active','authorized') + AND COALESCE(d.source_action_policy, 'allowed')='allowed' + AND EXISTS ( + SELECT 1 FROM muse_knowledge_base kb + WHERE kb.tenant_id=%s AND kb.deleted=FALSE + AND kb.kb_type='global' AND kb.status='active' + )""" + args = [qvec, tenant_id, tenant_id, tenant_id] else: sql = """SELECT 'entity' AS src, en.id, jsonb_build_object('型', en.entity_type, '名称', en.normalized_name, @@ -133,10 +158,15 @@ def search_cards( AND en.source_action_policy='allowed'""" args = [qvec, work_id, tenant_id, tenant_id] if ttype: - sql += (" AND d.draft_payload->>'型' = %s" if scope == "admin" else " AND en.entity_type = %s") + sql += ( + " AND d.draft_payload->>'型' = %s" + if scope in {"admin", "public_pattern"} + else " AND en.entity_type = %s" + ) args.append(ttype) - id_column = "d.id" if scope == "admin" else "en.id" - revision_column = "d.revision" if scope == "admin" else "en.revision" + draft_scope = scope in {"admin", "public_pattern"} + id_column = "d.id" if draft_scope else "en.id" + revision_column = "d.revision" if draft_scope else "en.revision" sql += f" ORDER BY score DESC, {revision_column}::text ASC, {id_column}::text ASC LIMIT %s" args.append(top) rows = conn.execute(sql, args).fetchall() @@ -167,7 +197,11 @@ def search_cards( "sourceKind": "canonical_entity" if src == "entity" else "draft", "sourceStatus": source_status or status, "bindingStatus": binding_status, - "productionRetrievalEligible": src == "entity" and status == "active" and binding_status == "active", + "retrievalScope": scope, + "productionRetrievalEligible": ( + scope == "public_pattern" + or (src == "entity" and status == "active" and binding_status == "active") + ), } ) return results @@ -175,8 +209,8 @@ def search_cards( @click.command() @click.argument("intent") -@click.option("--scope", type=click.Choice(["admin", "work"]), default="admin", show_default=True, - help="admin=治理面(含草稿); work=作品面(仅已确认+已绑定)") +@click.option("--scope", type=click.Choice(["admin", "public_pattern", "work"]), default="admin", show_default=True, + help="admin=治理面; public_pattern=公共范式草稿; work=作品面") @click.option("--work-id", type=int, help="scope=work 时必填") @click.option("--type", "ttype", help="限定型(如 craft/combat/emotion/scene_pattern/trope)") @click.option("--purpose", default="generation", show_default=True, diff --git a/.claude/skills/search/scripts/test_search.py b/.claude/skills/search/scripts/test_search.py new file mode 100644 index 0000000..84abf9d --- /dev/null +++ b/.claude/skills/search/scripts/test_search.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""search 公共范式专用范围的离线合同测试。""" + +from __future__ import annotations + +import unittest + +from search import search_cards + + +class _Result: + def __init__(self, rows): + self._rows = rows + + def fetchall(self): + return self._rows + + +class _Connection: + def __init__(self): + self.calls = [] + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def execute(self, sql, args): + self.calls.append((sql, list(args))) + if "muse_meta_schema" in sql: + return _Result([]) + return _Result( + [ + ( + "draft", + 7, + { + "型": "craft", + "名称": "递进揭示", + "一句话摘要": "逐层增加信息压力", + "字段": {"节拍": "三段递进"}, + "目标库": "公共范式库", + }, + "pending", + 0.91, + 3, + {}, + None, + "active", + ) + ] + ) + + +class SearchCardsTest(unittest.TestCase): + def test_public_pattern_scope_is_sql_enforced_and_tenant_bound(self): + conn = _Connection() + + cards = search_cards( + "信息揭示", + scope="public_pattern", + ttype="craft", + dsn="postgresql://test/muse-example", + tenant_id=7, + connection_factory=lambda dsn: conn if dsn == "postgresql://test/muse-example" else None, + embedder=lambda _intent: [0.1, 0.2], + ) + + self.assertEqual(len(conn.calls), 2) + policy_sql, policy_args = conn.calls[0] + self.assertIn("muse_meta_schema", policy_sql) + self.assertEqual(policy_args, [7]) + sql, args = conn.calls[1] + for fragment in ( + "d.work_id=0", + "d.draft_payload->>'目标库'='公共范式库'", + "kb.kb_type='global'", + "d.source_action_policy", + "d.draft_payload->>'型' = %s", + ): + self.assertIn(fragment, sql) + self.assertEqual(args, ["[0.1, 0.2]", 7, 7, 7, "craft", 5]) + self.assertEqual(cards[0]["retrievalScope"], "public_pattern") + self.assertTrue(cards[0]["productionRetrievalEligible"]) + + def test_unknown_scope_fails_before_connecting(self): + with self.assertRaises(ValueError): + search_cards( + "冲突", + scope="unknown", + connection_factory=lambda _dsn: _Connection(), + embedder=lambda _intent: [0.1], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/dashboard/server.py b/dashboard/server.py index 7ca0cf2..07f135d 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -51,6 +51,36 @@ def table_exists(name): return one("SELECT 1 FROM pg_tables WHERE schemaname='public' AND tablename=%s", (name,)) +def table_counts(names): + """一次只读往返返回一组表的活行数,缺表不阻断总览。""" + requested = list(dict.fromkeys(names)) + if not requested: + return {} + _, existing_rows = q( + "SELECT tablename FROM pg_tables WHERE schemaname='public' AND tablename = ANY(%s)", + (requested,), + ) + existing = [row[0] for row in existing_rows] + if not existing: + return {} + _, deleted_rows = q( + "SELECT table_name FROM information_schema.columns " + "WHERE table_schema='public' AND column_name='deleted' AND table_name = ANY(%s)", + (existing,), + ) + has_deleted = {row[0] for row in deleted_rows} + parts = [] + params = [] + for name in existing: + # name 来自 pg_tables 且只取请求白名单,双引号转义仍保留防御性边界。 + identifier = '"' + name.replace('"', '""') + '"' + where = " WHERE deleted=false" if name in has_deleted else "" + parts.append(f"SELECT %s::text AS table_name, count(*)::bigint AS row_count FROM {identifier}{where}") + params.append(name) + _, rows = q(" UNION ALL ".join(parts), tuple(params)) + return {row[0]: int(row[1]) for row in rows} + + # ── 视觉系统:dataviz 已验证调色板(light/dark),状态色固定不主题化 ────────────── CSS = """ :root { @@ -260,13 +290,19 @@ def _quota_block(): def view_home(): - works = one("SELECT count(*) FROM muse_content_work WHERE deleted=false") or 0 - chapters = one("SELECT count(*) FROM muse_content_chapter WHERE deleted=false") or 0 - blocks = one("SELECT count(*) FROM muse_content_block WHERE deleted=false") or 0 - drafts = one("SELECT count(*) FROM muse_knowledge_draft WHERE deleted=false") or 0 - entities = one("SELECT count(*) FROM muse_knowledge_entity WHERE deleted=false") or 0 - refs = one("SELECT count(*) FROM example_reference_work WHERE deleted=false") or 0 - cleans = one("SELECT count(*) FROM example_clean_log WHERE deleted=false") or 0 + ledger_names = [tbl for _, tbl in _LEDGER] + counts = table_counts([ + "muse_content_work", "muse_content_chapter", "muse_content_block", + "muse_knowledge_draft", "muse_knowledge_entity", "example_reference_work", + "example_clean_log", *ledger_names, + ]) + works = counts.get("muse_content_work", 0) + chapters = counts.get("muse_content_chapter", 0) + blocks = counts.get("muse_content_block", 0) + drafts = counts.get("muse_knowledge_draft", 0) + entities = counts.get("muse_knowledge_entity", 0) + refs = counts.get("example_reference_work", 0) + cleans = counts.get("example_clean_log", 0) tiles = f"""
@@ -281,10 +317,10 @@ def view_home(): rows = [] for label, tbl in _LEDGER: - if not table_exists(tbl): + if tbl not in counts: st, cnt = chip("not_built", "未建 · 待落库"), "—" else: - n = one(f'SELECT count(*) FROM "{tbl}"') or 0 + n = counts[tbl] st = chip("good", "已建 · 有数据") if n > 0 else chip("warn", "已建 · 0 行") cnt = fmt_num(n) rows.append(f"{esc(label)}{esc(tbl)}" @@ -303,8 +339,14 @@ def view_home(): # ── 作品(01 作品领域:作品 → 章节 → 正文全文)──────────────────────────────── def view_works(): - _, rows = q("SELECT id,title,status,genre,chapter_count,word_count FROM muse_content_work " - "WHERE deleted=false ORDER BY id") + _, rows = q( + "SELECT w.id,w.title,w.status,w.genre,COUNT(DISTINCT c.id)::bigint AS chapter_count, " + "COALESCE(SUM(b.word_count),0)::bigint AS word_count " + "FROM muse_content_work w " + "LEFT JOIN muse_content_chapter c ON c.work_id=w.id AND c.deleted=false " + "LEFT JOIN muse_content_block b ON b.chapter_id=c.id AND b.deleted=false " + "WHERE w.deleted=false GROUP BY w.id,w.title,w.status,w.genre ORDER BY w.id" + ) if not rows: return layout("作品", "

作品

" + empty_gap("库里还没有作品", "导入或创建后在此显示"), "/works") trs = "".join( @@ -317,9 +359,98 @@ def view_works(): return layout("作品", body, "/works") +def _work_knowledge_panel(wid): + """作品级知识链下钻:抽取运行、草稿、向量、实体、关系和绑定。""" + if not table_exists("muse_knowledge_draft"): + return empty_gap("知识抽取 · 待落库", "知识草稿表尚未建立。") + _, summary = q( + """SELECT + COUNT(*) FILTER (WHERE d.status='pending'), + COUNT(*) FILTER (WHERE d.status='confirmed'), + COUNT(*) FILTER (WHERE d.draft_type='entity' AND d.status='pending'), + COUNT(*) FILTER (WHERE d.draft_type='relation' AND d.status='pending'), + (SELECT COUNT(*) FROM muse_knowledge_entity e + WHERE e.work_id=%s AND e.deleted=false), + (SELECT COUNT(*) FROM muse_knowledge_relation r + WHERE r.work_id=%s AND r.deleted=false), + (SELECT COUNT(*) FROM muse_knowledge_binding b + WHERE b.work_id=%s AND b.deleted=false AND b.binding_status='active'), + (SELECT COUNT(*) FROM example_knowledge_embedding e + WHERE e.deleted=false AND ( + EXISTS (SELECT 1 FROM muse_knowledge_draft d2 + WHERE d2.id=e.draft_id AND d2.work_id=%s AND d2.deleted=false) + OR EXISTS (SELECT 1 FROM muse_knowledge_entity e2 + WHERE e2.id=e.entity_id AND e2.work_id=%s AND e2.deleted=false) + )) + FROM muse_knowledge_draft d + WHERE d.work_id=%s AND d.deleted=false""", + (wid, wid, wid, wid, wid, wid), + ) + pending, confirmed_drafts, pending_entities, pending_relations, entities, relations, bindings, embeddings = summary[0] + _, rows = q( + """SELECT d.id,d.source_id,d.draft_type,d.status, + COALESCE(d.draft_payload->>'name',d.draft_payload->>'名称', + d.draft_payload->>'type',d.draft_payload->>'型','?'), + COALESCE(d.draft_payload->>'type',d.draft_payload->>'型',d.draft_type), + EXISTS (SELECT 1 FROM example_knowledge_embedding e + WHERE e.deleted=false AND (e.draft_id=d.id OR e.entity_id=d.entity_id)) + FROM muse_knowledge_draft d + WHERE d.work_id=%s AND d.deleted=false + ORDER BY d.source_id,d.id""", + (wid,), + ) + draft_rows = "".join( + f"{esc(r[0])}{esc(r[1])}" + f"{esc(r[2])}{esc(r[4])}{esc(r[5])}" + f"{chip(r[3])}{chip('active' if r[6] else 'not_built', '已建' if r[6] else '待补')}" + for r in rows + ) + cards = ( + f"
" + f"
{fmt_num(pending)}
待确认草稿
" + f"
实体 {fmt_num(pending_entities)} · 关系 {fmt_num(pending_relations)}
" + f"
{fmt_num(confirmed_drafts)}
已确认草稿
" + f"
{fmt_num(entities)}
正式实体
" + f"
{fmt_num(relations)}
正式关系
" + f"
{fmt_num(embeddings)}
草稿向量
" + f"
{fmt_num(bindings)}
有效知识库绑定
" + f"
" + ) + draft_html = ( + f"
章后抽卡明细({len(rows)})
" + f"" + f"" + f"{draft_rows}
draft来源章草稿型名称实体型/关系型状态向量
" + ) if rows else empty_gap("还没有章后抽卡", "正文接受后运行 extract-knowledge 才会产生草稿。") + _, runs = q( + "SELECT run_id,target_chapter,trigger_source,terminal_state,started_at,finished_at " + "FROM example_run WHERE work_id=%s AND deleted=false ORDER BY started_at DESC LIMIT 30", + (wid,), + ) + run_rows = "".join( + f"{esc(r[0])}" + f"{esc(r[1])}{esc(r[2])}{chip(r[3])}" + f"{esc(r[4])} → {esc(r[5])}" for r in runs + ) + runs_html = ( + f"
作品运行链(最近 {len(runs)})
" + f"" + f"{run_rows}
run_id章触发终态起止
" + ) if runs else empty_gap("作品暂无运行", "运行登记后会显示模型、回执和原始证据下钻入口。") + return f"

知识闭环

抽取 → 草稿 → 向量 → 用户确认 → 正式实体/关系;看板只读,确认仍走 confirm。

{cards}{draft_html}{runs_html}" + + def view_work(wid): - _, wrows = q("SELECT id,title,status,genre,summary,word_count,chapter_count " - "FROM muse_content_work WHERE id=%s AND deleted=false", (wid,)) + _, wrows = q( + "SELECT w.id,w.title,w.status,w.genre,w.summary, " + "COALESCE(SUM(b.word_count),0)::bigint AS word_count, " + "COUNT(DISTINCT c.id)::bigint AS chapter_count " + "FROM muse_content_work w " + "LEFT JOIN muse_content_chapter c ON c.work_id=w.id AND c.deleted=false " + "LEFT JOIN muse_content_block b ON b.chapter_id=c.id AND b.deleted=false " + "WHERE w.id=%s AND w.deleted=false GROUP BY w.id,w.title,w.status,w.genre,w.summary", + (wid,), + ) if not wrows: return layout("作品", "

作品不存在

", "/works") wid_, title, status, genre, summary, wc, cc = wrows[0] @@ -346,10 +477,11 @@ def view_work(wid): + "
") if secs else "
规划表已建 · 暂无规划(planner 落库后显示)
" else: plan = empty_gap("规划 · 待落库", "规划表(100)未建。") + knowledge = _work_knowledge_panel(wid) body = (f"
作品 / {esc(title)}
" f"

{esc(title)} {chip(status)}

" f"

{esc(genre)} · {fmt_num(wc)} 字 · {esc(cc)} 章

" - f"

{esc(summary)}

{chap}

规划

{plan}") + f"

{esc(summary)}

{chap}

规划

{plan}{knowledge}") return layout(title, body, "/works") @@ -388,7 +520,23 @@ def _barset(rows, slots): return "".join(out) -def view_knowledge(): +def view_knowledge(params=None): + params = params or {} + selected = (params.get("work") or [""])[0] + _, works = q("SELECT id,title FROM muse_content_work WHERE deleted=false ORDER BY id") + selector = _selector("按作品:", "work", [(w[0], w[1]) for w in works], selected) + if selected: + try: + work_id = int(selected) + except ValueError: + return layout("知识库", "

作品参数非法

", "/knowledge") + _, work = q("SELECT title FROM muse_content_work WHERE id=%s AND deleted=false", (work_id,)) + if not work: + return layout("知识库", "

作品不存在

", "/knowledge") + body = (f"
知识库 / {esc(work[0][0])}
" + f"

知识库作品级创作闭环 · {esc(work[0][0])}

" + f"{selector}{_work_knowledge_panel(work_id)}") + return layout("知识库 · " + str(work[0][0]), body, "/knowledge") drafts = one("SELECT count(*) FROM muse_knowledge_draft WHERE deleted=false") or 0 entities = one("SELECT count(*) FROM muse_knowledge_entity WHERE deleted=false") or 0 public = one("SELECT count(*) FROM muse_knowledge_draft WHERE work_id=0 AND deleted=false") or 0 @@ -443,7 +591,7 @@ def view_knowledge(): ) if recent else "" body = (f"

知识库实体与范式(muse_knowledge_*)+ 它们的生产线(拆书·清洗)· 实体 02 · 范式 03

" - f"{cards}

参考书 · 拆书 · 清洗

{reftbl}{pt_html}{clean_html}") + f"{selector}{cards}

参考书 · 拆书 · 清洗

{reftbl}{pt_html}{clean_html}") return layout("知识库", body, "/knowledge") @@ -572,14 +720,92 @@ def view_run_detail(run_id): + "".join(f"{esc(c[0])}{esc(c[1])}{esc(c[2])}{esc(c[3])}" f"{fmt_num(c[4])}${float(c[5] or 0):.5f}" for c in calls) + "") if calls else empty_gap("暂无模型调用", "") - cand = (empty_gap("待审候选 · 待落库", "候选表(99)未建。") if not table_exists("example_candidate") - else "
候选表已建 · 该运行暂无候选
") - dec = (empty_gap("用户决策 · 待落库", "决策记录表(99)未建。") if not table_exists("example_user_decision") - else "
决策表已建 · 该运行暂无决策
") + if table_exists("example_candidate"): + _, candidates = q( + "SELECT id, target_chapter, candidate_version, run_type, state, source_role, " + "candidate_sha256, context_sha256 FROM example_candidate " + "WHERE run_id=%s AND deleted=false ORDER BY target_chapter, candidate_version", + (run_id,), + ) + cand = ( + "
候选(本运行)
" + "" + "" + + "".join( + f"" + f"" + f"" + for c in candidates + ) + + "
id章版本运行类型状态角色正文哈希上下文哈希
{esc(c[0])}{esc(c[1])}{esc(c[2])}{esc(c[3])}{chip(c[4])}{esc(c[5])}{esc(c[6])}{esc(c[7])}
" + if candidates else "
候选表已建 · 该运行暂无候选
" + ) + else: + cand = empty_gap("待审候选 · 待落库", "候选表(99)未建。") + if table_exists("example_user_decision") and table_exists("example_candidate"): + _, decisions = q( + "SELECT d.id,d.decision,d.rationale,d.decided_by,d.canonical_block_id,d.create_time " + "FROM example_user_decision d JOIN example_candidate c ON c.id=d.candidate_id " + "WHERE c.run_id=%s ORDER BY d.create_time", + (run_id,), + ) + dec = ( + "
用户决策(本运行)
" + "" + "" + + "".join( + f"" + f"" + for d in decisions + ) + + "
id决策理据决策人正式正文块时间
{esc(d[0])}{chip({'accept':'good','merge':'warn','discard':'crit'}.get(d[1],'warn'), d[1])}{esc(d[2])}{esc(d[3])}{esc(d[4])}{esc(d[5])}
" + if decisions else "
决策表已建 · 该运行暂无决策
" + ) + else: + dec = empty_gap("用户决策 · 待落库", "决策表(99)或候选表未建。") + if table_exists("example_raw_lease"): + _, raw_rows = q( + "SELECT l.id,l.purpose,l.status,count(c.id),max(c.create_time) " + "FROM example_raw_lease l LEFT JOIN example_raw_content c ON c.lease_id=l.id " + "WHERE l.run_id=%s GROUP BY l.id,l.purpose,l.status ORDER BY l.id", + (run_id,), + ) + raw_html = ( + "
raw 证据(本运行)
" + "" + + "".join( + f"" + f"" for r in raw_rows + ) + + "
lease用途状态内容数最近写入
{esc(r[0])}{esc(r[1])}{chip(r[2])}{esc(r[3])}{esc(r[4])}
" + if raw_rows else empty_gap("raw · 该运行暂无证据", "模型输入输出落库后在此显示。") + ) + else: + raw_html = empty_gap("raw · 待落库", "raw 表(101)未建。") + if table_exists("example_context_freeze") and wid is not None and chap is not None: + _, freezes = q( + "SELECT id,as_of_chapter,manifest_sha256,context_sha256,token_budget,create_time " + "FROM example_context_freeze WHERE work_id=%s AND target_chapter=%s " + "ORDER BY create_time DESC", + (wid, chap), + ) + freeze_html = ( + "
上下文冻结(匹配本作品/目标章)
" + "" + + "".join( + f"" + f"" + f"" for f in freezes + ) + + "
idasOfmanifestcontext预算时间
{esc(f[0])}{esc(f[1])}{esc(f[2])}{esc(f[3])}{esc(f[4])}{esc(f[5])}
" + if freezes else empty_gap("上下文冻结 · 该目标章暂无记录", "assemble 后的冻结清单落库后在此显示。") + ) + else: + freeze_html = empty_gap("上下文冻结 · 待落库", "冻结表(100)未建或该运行不是章级运行。") body = (f"
运行记录 / {esc(rid)}
" f"

运行详情

{info}" f"

回执链

{rc_html}

质量评判

{q_html}

模型调用

{call_html}" - f"

候选与决策

{cand}{dec}") + f"

候选与决策

{cand}{dec}

证据与上下文

{raw_html}{freeze_html}") return layout(f"运行 · {rid}", body, "/runs") @@ -673,7 +899,7 @@ _ROUTES = [ (re.compile(r"^/works$"), lambda m, p: view_works()), (re.compile(r"^/works/(\d+)$"), lambda m, p: view_work(int(m.group(1)))), (re.compile(r"^/works/(\d+)/ch/(\d+)$"), lambda m, p: view_chapter(int(m.group(1)), int(m.group(2)))), - (re.compile(r"^/knowledge$"), lambda m, p: view_knowledge()), + (re.compile(r"^/knowledge$"), lambda m, p: view_knowledge(p)), (re.compile(r"^/agents$"), lambda m, p: view_agents()), (re.compile(r"^/agents/role/([\w-]+)$"), lambda m, p: view_agent_role(m.group(1))), (re.compile(r"^/agents/skill/([\w-]+)$"), lambda m, p: view_skill(m.group(1))),