feat: 完成创作数据与看板闭环
This commit is contained in:
parent
e39e178f48
commit
93415a0930
@ -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。
|
||||
|
||||
## 红线
|
||||
|
||||
457
.claude/skills/confirm/scripts/confirm_knowledge.py
Normal file
457
.claude/skills/confirm/scripts/confirm_knowledge.py
Normal 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()
|
||||
66
.claude/skills/confirm/scripts/reconcile_work_metrics.py
Normal file
66
.claude/skills/confirm/scripts/reconcile_work_metrics.py
Normal 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()
|
||||
@ -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()
|
||||
@ -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
|
||||
|
||||
@ -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,不写文件:
|
||||
|
||||
177
.claude/skills/continuation/scripts/persist_writer_run.py
Normal file
177
.claude/skills/continuation/scripts/persist_writer_run.py
Normal 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"]
|
||||
@ -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。
|
||||
|
||||
@ -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()
|
||||
@ -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;幂等、失活和冲突原因均输出可追踪明细。
|
||||
|
||||
## 离线验证
|
||||
|
||||
@ -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__":
|
||||
|
||||
@ -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 和访问外部资源前完成校验。"""
|
||||
|
||||
449
.claude/skills/extract-knowledge/scripts/extract_knowledge.py
Normal file
449
.claude/skills/extract-knowledge/scripts/extract_knowledge.py
Normal 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()
|
||||
@ -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()
|
||||
@ -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:
|
||||
|
||||
139
.claude/skills/llm/scripts/test_call_persistence.py
Normal file
139
.claude/skills/llm/scripts/test_call_persistence.py
Normal 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()
|
||||
@ -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
|
||||
|
||||
159
.claude/skills/planning/scripts/record_planning_execution.py
Normal file
159
.claude/skills/planning/scripts/record_planning_execution.py
Normal 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()
|
||||
113
.claude/skills/planning/scripts/repair_deterministic_receipt.py
Normal file
113
.claude/skills/planning/scripts/repair_deterministic_receipt.py
Normal 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()
|
||||
93
.claude/skills/planning/scripts/select_patterns.py
Normal file
93
.claude/skills/planning/scripts/select_patterns.py
Normal 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()
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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(
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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
@ -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 指针。 |
|
||||
|
||||
## 依赖方向
|
||||
|
||||
|
||||
@ -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 保留职责;调用结束始终立即删除。
|
||||
|
||||
193
.claude/skills/runtime/scripts/persist_llm_call.py
Normal file
193
.claude/skills/runtime/scripts/persist_llm_call.py
Normal 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
|
||||
@ -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": "试跑已回滚,未落库"}
|
||||
|
||||
115
.claude/skills/runtime/scripts/record_failed_run.py
Normal file
115
.claude/skills/runtime/scripts/record_failed_run.py
Normal 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()
|
||||
107
.claude/skills/runtime/scripts/repair_receipt_evidence.py
Normal file
107
.claude/skills/runtime/scripts/repair_receipt_evidence.py
Normal 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()
|
||||
120
.claude/skills/runtime/scripts/run_registry.py
Normal file
120
.claude/skills/runtime/scripts/run_registry.py
Normal 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"]
|
||||
@ -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) 网关地址。"""
|
||||
|
||||
|
||||
35
.claude/skills/runtime/scripts/test_persist_raw.py
Normal file
35
.claude/skills/runtime/scripts/test_persist_raw.py
Normal 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()
|
||||
26
.claude/skills/runtime/scripts/test_record_failed_run.py
Normal file
26
.claude/skills/runtime/scripts/test_record_failed_run.py
Normal 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()
|
||||
@ -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()
|
||||
34
.claude/skills/runtime/scripts/test_run_registry.py
Normal file
34
.claude/skills/runtime/scripts/test_run_registry.py
Normal 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()
|
||||
@ -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,
|
||||
|
||||
98
.claude/skills/search/scripts/test_search.py
Normal file
98
.claude/skills/search/scripts/test_search.py
Normal 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()
|
||||
@ -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))),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user