feat: 完成创作数据与看板闭环

This commit is contained in:
zizi 2026-08-01 14:12:27 +08:00
parent e39e178f48
commit 93415a0930
44 changed files with 5521 additions and 153 deletions

View File

@ -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 <id> --dry-run` 试跑;实际确认只能在用户明确确认后执行。批量实体/关系必须显式给 `--all-entities <work_id>` 或 `--all-relations <work_id>`。**采纳正文 ≠ 确认知识**,抽取产出的卡变更要单独确认;有冲突的卡先裁决再确认。
- **规划**(大纲/细纲/设定):规划表(100)落库前,暂以 git 提交确认——只 `git add` 用户点名的创作文件,**严禁混入框架文件(agents/skills/meta)**;提交信息 `作品(书名): 确认 设定包/大纲vN | 来源: planner`。规划表建成后改为库内 shadow→confirmed。
## 红线

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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,不写文件:

View File

@ -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"]

View File

@ -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。

View File

@ -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()

View File

@ -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;幂等、失活和冲突原因均输出可追踪明细。
## 离线验证

View File

@ -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__":

View File

@ -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 和访问外部资源前完成校验。"""

View File

@ -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()

View File

@ -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()

View File

@ -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 已剥离 <think>…</think>(推理模型可能把思考混进正文)。
@ -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:

View File

@ -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()

View File

@ -40,7 +40,10 @@ disable-model-invocation: true
1. 落库:`.venv/bin/python .claude/skills/planning/scripts/persist_planning.py write <payload.json> --work-id N --section-type <type> [--target-chapter N] [--schema-type X] [--version N]`(planner 不碰库/git,主会话落库;先 `--dry-run` 试跑)。
2. 确认:`persist_planning.py confirm <section_id>`(用户确认后 shadow→confirmed;**确认后的规划才进生成上下文**)。
3. 已确认细纲取用(read-context 改道读库,不再当内存参数):
3. 规划执行的输入/输出必须可追溯:`record_planning_execution.py --section-id <id>` 为已落库 section 登记 `example_run`、prompt/response raw 和 `example_run_receipt`;该记录标明 `executionKind=deterministic_persist`,不伪装成模型调用。
历史回执若误把确定性执行写成 `model_match=true`,使用 `repair_deterministic_receipt.py --run-id <id>` 追加修正 revision;旧回执保持不变。
4. 公共范式选择使用 `select_patterns.py --work-id <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

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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(

View File

@ -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],

View File

@ -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()

View File

@ -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.<key>")
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",
]

View File

@ -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()

File diff suppressed because it is too large Load Diff

View File

@ -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 指针。 |
## 依赖方向

View File

@ -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 保留职责;调用结束始终立即删除。

View File

@ -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

View File

@ -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": "试跑已回滚,未落库"}

View File

@ -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()

View File

@ -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()

View File

@ -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"]

View File

@ -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) 网关地址。"""

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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,

View File

@ -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()

View File

@ -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"""
<div class="tiles">
@ -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"<tr><td>{esc(label)}</td><td><code>{esc(tbl)}</code></td>"
@ -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("作品", "<h1>作品</h1>" + 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"<tr><td class='num'>{esc(r[0])}</td><td class='num'>{esc(r[1])}</td>"
f"<td>{esc(r[2])}</td><td>{esc(r[4])}</td><td>{esc(r[5])}</td>"
f"<td>{chip(r[3])}</td><td>{chip('active' if r[6] else 'not_built', '已建' if r[6] else '待补')}</td></tr>"
for r in rows
)
cards = (
f"<div class='tiles'>"
f"<div class='tile'><div class='v'>{fmt_num(pending)}</div><div class='l'>待确认草稿</div>"
f"<div class='d'>实体 {fmt_num(pending_entities)} · 关系 {fmt_num(pending_relations)}</div></div>"
f"<div class='tile'><div class='v'>{fmt_num(confirmed_drafts)}</div><div class='l'>已确认草稿</div></div>"
f"<div class='tile'><div class='v'>{fmt_num(entities)}</div><div class='l'>正式实体</div></div>"
f"<div class='tile'><div class='v'>{fmt_num(relations)}</div><div class='l'>正式关系</div></div>"
f"<div class='tile'><div class='v'>{fmt_num(embeddings)}</div><div class='l'>草稿向量</div></div>"
f"<div class='tile'><div class='v'>{fmt_num(bindings)}</div><div class='l'>有效知识库绑定</div></div>"
f"</div>"
)
draft_html = (
f"<div class='card'><div class='hd'>章后抽卡明细({len(rows)})</div>"
f"<table><thead><tr><th class='num'>draft</th><th class='num'>来源章</th><th>草稿型</th>"
f"<th>名称</th><th>实体型/关系型</th><th>状态</th><th>向量</th></tr></thead>"
f"<tbody>{draft_rows}</tbody></table></div>"
) 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"<tr><td><a href='/runs/{esc(r[0])}'><code>{esc(r[0])}</code></a></td>"
f"<td class='num'>{esc(r[1])}</td><td>{esc(r[2])}</td><td>{chip(r[3])}</td>"
f"<td>{esc(r[4])} → {esc(r[5])}</td></tr>" for r in runs
)
runs_html = (
f"<div class='card'><div class='hd'>作品运行链(最近 {len(runs)})</div>"
f"<table><thead><tr><th>run_id</th><th class='num'>章</th><th>触发</th><th>终态</th><th>起止</th></tr></thead>"
f"<tbody>{run_rows}</tbody></table></div>"
) if runs else empty_gap("作品暂无运行", "运行登记后会显示模型、回执和原始证据下钻入口。")
return f"<h2>知识闭环</h2><p class='note'>抽取 → 草稿 → 向量 → 用户确认 → 正式实体/关系;看板只读,确认仍走 confirm。</p>{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("作品", "<h1>作品不存在</h1>", "/works")
wid_, title, status, genre, summary, wc, cc = wrows[0]
@ -346,10 +477,11 @@ def view_work(wid):
+ "</tbody></table></div>") if secs else "<div class='empty'>规划表已建 · 暂无规划(planner 落库后显示)</div>"
else:
plan = empty_gap("规划 · 待落库", "规划表(100)未建。")
knowledge = _work_knowledge_panel(wid)
body = (f"<div class='crumb'><a href='/works'>作品</a> / {esc(title)}</div>"
f"<h1>{esc(title)} <span style='font-size:14px'>{chip(status)}</span></h1>"
f"<p class='note'>{esc(genre)} · {fmt_num(wc)} 字 · {esc(cc)} 章</p>"
f"<p class='note'>{esc(summary)}</p>{chap}<h2>规划</h2>{plan}")
f"<p class='note'>{esc(summary)}</p>{chap}<h2>规划</h2>{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("知识库", "<h1>作品参数非法</h1>", "/knowledge")
_, work = q("SELECT title FROM muse_content_work WHERE id=%s AND deleted=false", (work_id,))
if not work:
return layout("知识库", "<h1>作品不存在</h1>", "/knowledge")
body = (f"<div class='crumb'><a href='/knowledge'>知识库</a> / {esc(work[0][0])}</div>"
f"<h1>知识库<span class='sub'>作品级创作闭环 · {esc(work[0][0])}</span></h1>"
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"<h1>知识库<span class='sub'>实体与范式(muse_knowledge_*)+ 它们的生产线(拆书·清洗)· 实体 02 · 范式 03</span></h1>"
f"{cards}<h2>参考书 · 拆书 · 清洗</h2>{reftbl}{pt_html}{clean_html}")
f"{selector}{cards}<h2>参考书 · 拆书 · 清洗</h2>{reftbl}{pt_html}{clean_html}")
return layout("知识库", body, "/knowledge")
@ -572,14 +720,92 @@ def view_run_detail(run_id):
+ "".join(f"<tr><td>{esc(c[0])}</td><td>{esc(c[1])}</td><td>{esc(c[2])}</td><td>{esc(c[3])}</td>"
f"<td class='num'>{fmt_num(c[4])}</td><td class='num'>${float(c[5] or 0):.5f}</td></tr>" for c in calls)
+ "</tbody></table></div>") if calls else empty_gap("暂无模型调用", "")
cand = (empty_gap("待审候选 · 待落库", "候选表(99)未建。") if not table_exists("example_candidate")
else "<div class='empty'>候选表已建 · 该运行暂无候选</div>")
dec = (empty_gap("用户决策 · 待落库", "决策记录表(99)未建。") if not table_exists("example_user_decision")
else "<div class='empty'>决策表已建 · 该运行暂无决策</div>")
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 = (
"<div class='card'><div class='hd'>候选(本运行)</div><table><thead>"
"<tr><th class='num'>id</th><th class='num'>章</th><th>版本</th><th>运行类型</th>"
"<th>状态</th><th>角色</th><th>正文哈希</th><th>上下文哈希</th></tr></thead><tbody>"
+ "".join(
f"<tr><td class='num'>{esc(c[0])}</td><td class='num'>{esc(c[1])}</td>"
f"<td><code>{esc(c[2])}</code></td><td>{esc(c[3])}</td><td>{chip(c[4])}</td>"
f"<td>{esc(c[5])}</td><td><code>{esc(c[6])}</code></td><td><code>{esc(c[7])}</code></td></tr>"
for c in candidates
)
+ "</tbody></table></div>"
if candidates else "<div class='empty'>候选表已建 · 该运行暂无候选</div>"
)
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 = (
"<div class='card'><div class='hd'>用户决策(本运行)</div><table><thead>"
"<tr><th class='num'>id</th><th>决策</th><th>理据</th><th>决策人</th>"
"<th class='num'>正式正文块</th><th>时间</th></tr></thead><tbody>"
+ "".join(
f"<tr><td class='num'>{esc(d[0])}</td><td>{chip({'accept':'good','merge':'warn','discard':'crit'}.get(d[1],'warn'), d[1])}</td>"
f"<td>{esc(d[2])}</td><td>{esc(d[3])}</td><td class='num'>{esc(d[4])}</td><td>{esc(d[5])}</td></tr>"
for d in decisions
)
+ "</tbody></table></div>"
if decisions else "<div class='empty'>决策表已建 · 该运行暂无决策</div>"
)
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 = (
"<div class='card'><div class='hd'>raw 证据(本运行)</div><table><thead>"
"<tr><th class='num'>lease</th><th>用途</th><th>状态</th><th class='num'>内容数</th><th>最近写入</th></tr></thead><tbody>"
+ "".join(
f"<tr><td class='num'>{esc(r[0])}</td><td>{esc(r[1])}</td><td>{chip(r[2])}</td>"
f"<td class='num'>{esc(r[3])}</td><td>{esc(r[4])}</td></tr>" for r in raw_rows
)
+ "</tbody></table></div>"
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 = (
"<div class='card'><div class='hd'>上下文冻结(匹配本作品/目标章)</div><table><thead>"
"<tr><th class='num'>id</th><th class='num'>asOf</th><th>manifest</th><th>context</th><th class='num'>预算</th><th>时间</th></tr></thead><tbody>"
+ "".join(
f"<tr><td class='num'>{esc(f[0])}</td><td class='num'>{esc(f[1])}</td>"
f"<td><code>{esc(f[2])}</code></td><td><code>{esc(f[3])}</code></td>"
f"<td class='num'>{esc(f[4])}</td><td>{esc(f[5])}</td></tr>" for f in freezes
)
+ "</tbody></table></div>"
if freezes else empty_gap("上下文冻结 · 该目标章暂无记录", "assemble 后的冻结清单落库后在此显示。")
)
else:
freeze_html = empty_gap("上下文冻结 · 待落库", "冻结表(100)未建或该运行不是章级运行。")
body = (f"<div class='crumb'><a href='/runs'>运行记录</a> / <code>{esc(rid)}</code></div>"
f"<h1>运行详情</h1>{info}"
f"<h2>回执链</h2>{rc_html}<h2>质量评判</h2>{q_html}<h2>模型调用</h2>{call_html}"
f"<h2>候选与决策</h2>{cand}{dec}")
f"<h2>候选与决策</h2>{cand}{dec}<h2>证据与上下文</h2>{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))),