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。
|
- **规划**(大纲/细纲/设定):规划表(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 冲突、候选不存在等)——失败关闭,不静默。"""
|
"""接受/丢弃被拒(状态不对、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,
|
def accept(candidate_id, decided_by="1", rationale=None, basis_ref=None,
|
||||||
expected_revision=None, command_id=None, source_type="ai_candidate",
|
expected_revision=None, command_id=None, source_type="ai_candidate",
|
||||||
dry_run=False):
|
dry_run=False):
|
||||||
@ -119,13 +138,15 @@ def accept(candidate_id, decided_by="1", rationale=None, basis_ref=None,
|
|||||||
(decided_by, cid)).rowcount
|
(decided_by, cid)).rowcount
|
||||||
if n != 1:
|
if n != 1:
|
||||||
raise ConflictError("候选状态被并发修改")
|
raise ConflictError("候选状态被并发修改")
|
||||||
|
metrics = _refresh_work_metrics(conn, work_id, decided_by)
|
||||||
if dry_run:
|
if dry_run:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
return {"status": "dry_run_ok", "block_id": block_id, "revision": new_rev,
|
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()
|
conn.commit()
|
||||||
return {"status": "accepted", "block_id": block_id, "revision": new_rev,
|
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:
|
except Exception:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
raise
|
raise
|
||||||
|
|||||||
@ -21,7 +21,7 @@ disable-model-invocation: true
|
|||||||
- `narrativeState`:目标章前的叙事状态。
|
- `narrativeState`:目标章前的叙事状态。
|
||||||
- `factConstraints`:由卡索引和原文证据归纳出的事实约束。
|
- `factConstraints`:由卡索引和原文证据归纳出的事实约束。
|
||||||
- `proseExcerpts`:用于连续性与叙事声音的历史正文摘录。
|
- `proseExcerpts`:用于连续性与叙事声音的历史正文摘录。
|
||||||
- `patternReferences`:范式参考。
|
- `patternReferences`:可参考的写作范式——每条给名字(name)、一句话摘要(summary)与写法要点(writingPoints);只供借鉴写法,不是事实约束。
|
||||||
- `lengthContract`:本章动态篇幅合同。
|
- `lengthContract`:本章动态篇幅合同。
|
||||||
- `styleConstraints`:文风约束。
|
- `styleConstraints`:文风约束。
|
||||||
|
|
||||||
@ -35,6 +35,11 @@ Writer 不接收 `runId`、权限信息、manifest、hash、候选版本、验
|
|||||||
4. 缺少细纲字段、`factConstraints` 字段或篇幅合同属于 adapter 输入错误,必须在模型调用前失败。`factConstraints=[]` 在冻结检索确实没有可确认事实时是合法输入,不等于“事实已验证”或“可以自由补造”;候选中的未知事实仍由 semantic detector 标记为 `unknown/evidenceGaps`。
|
4. 缺少细纲字段、`factConstraints` 字段或篇幅合同属于 adapter 输入错误,必须在模型调用前失败。`factConstraints=[]` 在冻结检索确实没有可确认事实时是合法输入,不等于“事实已验证”或“可以自由补造”;候选中的未知事实仍由 semantic detector 标记为 `unknown/evidenceGaps`。
|
||||||
5. detector 发现事实或证据缺口后,由编排层补证并冻结新的上下文快照,再启动无会话继承的 Writer 调用;Writer 输出不承载补证请求或审查结论。
|
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,不写文件:
|
只返回严格 `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,
|
runner=runner,
|
||||||
binding_verifier=binding_verifier,
|
binding_verifier=binding_verifier,
|
||||||
business_validator=validate_writer_draft,
|
business_validator=validate_writer_draft,
|
||||||
|
run_id=normalized_context.get("runId"),
|
||||||
|
caller="writer",
|
||||||
)
|
)
|
||||||
except ClaudeRuntimeError as exc:
|
except ClaudeRuntimeError as exc:
|
||||||
# runtime 只提供受控原因和回执;这里不拼接 subprocess stderr 或 stdout。
|
# 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 已过期)
|
# 批量补嵌 pending 草稿(无活向量,或活向量的当前 payload+model hash 已过期)
|
||||||
.venv/bin/python .claude/skills/embed/scripts/embed_drafts.py
|
.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
|
.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 查询端复用同实现)
|
# 自由文本试嵌(调试/B3 查询端复用同实现)
|
||||||
.venv/bin/python .claude/skills/embed/scripts/embed_drafts.py --probe "机甲近战的节奏控制"
|
.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。
|
- **幂等与 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 冲突属于确定性异常,明确报告后令整条命令失败退出,不降级成失败计数。
|
- **批量**:读取每个 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 漂移并跳过。以上跳过或失败路径均零向量写入。
|
- **落库与 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;幂等、失活和冲突原因均输出可追踪明细。
|
- 汇报:新嵌 N、跳过 M、失败 K;幂等、失活和冲突原因均输出可追踪明细。
|
||||||
|
|
||||||
## 离线验证
|
## 离线验证
|
||||||
|
|||||||
@ -79,9 +79,9 @@ def build_embed_text(payload: dict) -> str:
|
|||||||
# 型取值补 type 键:升格卡 payload 用 type 存型(非 型/target_type),漏认会产出「【】名称…」丢型文本,
|
# 型取值补 type 键:升格卡 payload 用 type 存型(非 型/target_type),漏认会产出「【】名称…」丢型文本,
|
||||||
# 令升格卡向量与检索端跨型语义错位;补一段式回退(additive,不动 型/target_type 既有行为)。
|
# 令升格卡向量与检索端跨型语义错位;补一段式回退(additive,不动 型/target_type 既有行为)。
|
||||||
t = payload.get("型") or payload.get("type") or payload.get("target_type", "")
|
t = payload.get("型") or payload.get("type") or payload.get("target_type", "")
|
||||||
name = payload.get("名称", "")
|
name = payload.get("名称") or payload.get("name", "")
|
||||||
brief = payload.get("一句话摘要", "")
|
brief = payload.get("一句话摘要") or payload.get("brief", "")
|
||||||
fields = payload.get("字段") or {}
|
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 ("名称", "一句话摘要"))
|
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]
|
return f"【{t}】{name}:{brief}\n{body}"[:4000]
|
||||||
|
|
||||||
@ -254,8 +254,13 @@ def _write_embedding(conn, draft_id, content_hash, text, vector):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _load_bulk_candidates(conn, work_id, limit):
|
def _load_bulk_candidates(conn, work_id, limit, source_type=None):
|
||||||
"""读取 pending draft 的全部活向量,在 Python 中按当前文本和模型筛选补嵌候选。"""
|
"""读取 pending draft 的全部活向量,在 Python 中按当前文本和模型筛选补嵌候选。
|
||||||
|
|
||||||
|
拆书草稿的 ``work_id`` 仍表示参考书,历史调用因此按 ``source_id`` 筛选。
|
||||||
|
章后抽卡直接把作品写入 draft.work_id,必须用显式 source_type 切换到该口径,
|
||||||
|
避免同一个 CLI 参数在两类数据上产生歧义。
|
||||||
|
"""
|
||||||
|
|
||||||
sql = """SELECT d.id, d.draft_payload,
|
sql = """SELECT d.id, d.draft_payload,
|
||||||
e.id, e.content_hash, e.model, e.entity_id
|
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
|
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'"""
|
WHERE d.tenant_id=%s AND d.deleted=FALSE AND d.status='pending'"""
|
||||||
args = [TENANT, TENANT]
|
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"
|
sql += " AND d.source_id=%s"
|
||||||
args.append(work_id)
|
args.append(work_id)
|
||||||
# 必须先取得每个 draft 的全部活向量,不能在 SQL 层 LIMIT 后漏掉旧 hash 或异常状态。
|
# 必须先取得每个 draft 的全部活向量,不能在 SQL 层 LIMIT 后漏掉旧 hash 或异常状态。
|
||||||
@ -342,10 +352,10 @@ def _load_bulk_candidates(conn, work_id, limit):
|
|||||||
return prechecked_candidates, failures
|
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 边界。"""
|
"""执行一次 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:
|
for draft_id, reason in read_failures:
|
||||||
click.echo(f" [失败] draft={draft_id}: {reason}", err=True)
|
click.echo(f" [失败] draft={draft_id}: {reason}", err=True)
|
||||||
if read_failures:
|
if read_failures:
|
||||||
@ -415,10 +425,11 @@ def _run_bulk(conn, sess, work_id, limit):
|
|||||||
|
|
||||||
|
|
||||||
@click.command()
|
@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("--limit", type=click.IntRange(min=0), default=0, help="最多处理条数(0=不限)")
|
||||||
@click.option("--probe", help="自由文本试嵌(打印维度与前 5 维,不落库)")
|
@click.option("--probe", help="自由文本试嵌(打印维度与前 5 维,不落库)")
|
||||||
def main(work_id, limit, probe):
|
def main(work_id, source_type, limit, probe):
|
||||||
sess = _session()
|
sess = _session()
|
||||||
if probe:
|
if probe:
|
||||||
vecs, bad = embed_texts(sess, [probe])
|
vecs, bad = embed_texts(sess, [probe])
|
||||||
@ -429,7 +440,7 @@ def main(work_id, limit, probe):
|
|||||||
return
|
return
|
||||||
|
|
||||||
with psycopg.connect(DSN) as conn:
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@ -238,8 +238,15 @@ class _BulkConnection:
|
|||||||
"status": "pending",
|
"status": "pending",
|
||||||
"payload": payload,
|
"payload": payload,
|
||||||
"source_id": source_id,
|
"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.vectors = [dict(vector) for vector in (vectors or [])]
|
||||||
self.events = []
|
self.events = []
|
||||||
@ -252,13 +259,17 @@ class _BulkConnection:
|
|||||||
|
|
||||||
if normalized.startswith("select d.id, d.draft_payload"):
|
if normalized.startswith("select d.id, d.draft_payload"):
|
||||||
self._assert_bulk_select_sql(normalized)
|
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 = []
|
rows = []
|
||||||
for draft_id, draft in sorted(self.drafts.items()):
|
for draft_id, draft in sorted(self.drafts.items()):
|
||||||
if (draft["tenant"] != embed.TENANT or draft["deleted"]
|
if (draft["tenant"] != embed.TENANT or draft["deleted"]
|
||||||
or draft["status"] != "pending"):
|
or draft["status"] != "pending"):
|
||||||
continue
|
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
|
continue
|
||||||
active = sorted(
|
active = sorted(
|
||||||
(row for row in self.vectors
|
(row for row in self.vectors
|
||||||
@ -400,8 +411,10 @@ class _BulkConnection:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _draft(draft_id, text, source_id=1):
|
def _draft(draft_id, text, source_id=1, source_type=None, work_id=None):
|
||||||
return draft_id, {"embed_text": text}, source_id
|
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,
|
def _vector(vector_id, draft_id, content_hash, *, model=embed.MODEL,
|
||||||
@ -449,6 +462,21 @@ class EmbedDraftsOfflineTest(unittest.TestCase):
|
|||||||
|
|
||||||
VECTOR = [0.1, 0.2]
|
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):
|
def test_reset先完成时软删draft零写入且有可追踪输出(self):
|
||||||
conn = _EmbeddingConnection(
|
conn = _EmbeddingConnection(
|
||||||
candidate_deleted=True,
|
candidate_deleted=True,
|
||||||
@ -921,6 +949,26 @@ class BulkSelfHealingOfflineTest(unittest.TestCase):
|
|||||||
self.assertIn("101", messages)
|
self.assertIn("101", messages)
|
||||||
self.assertIn("102", 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):
|
class EmbedDraftsCliTest(unittest.TestCase):
|
||||||
"""验证 CLI 参数在创建 session 和访问外部资源前完成校验。"""
|
"""验证 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 仍保留指数退避重试。"""
|
不同,普通 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,
|
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, usage)。网络错/5xx/普通 429 指数退避重试。
|
||||||
|
|
||||||
content 已剥离 <think>…</think>(推理模型可能把思考混进正文)。
|
content 已剥离 <think>…</think>(推理模型可能把思考混进正文)。
|
||||||
@ -70,6 +80,10 @@ def chat(prompt, model=DEFAULT_MODEL, max_tokens=512000, temperature=0.2,
|
|||||||
结算按实际用量,余额充足时预扣不产生额外成本)——**余额须 ≥ 并发路数 × $0.154**,
|
结算按实际用量,余额充足时预扣不产生额外成本)——**余额须 ≥ 并发路数 × $0.154**,
|
||||||
否则触发 403「预扣费额度失败」(2026-07-15 余额见底实测坐实此机制)。
|
否则触发 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 = requests.Session()
|
||||||
s.trust_env = False # 本机代理 env 会劫持内网直连
|
s.trust_env = False # 本机代理 env 会劫持内网直连
|
||||||
messages = ([{"role": "system", "content": system}] if system else []) \
|
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:
|
if top_p is not None:
|
||||||
payload["top_p"] = top_p
|
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
|
last_err = None
|
||||||
for attempt in range(retries + 1):
|
for attempt in range(retries + 1):
|
||||||
try:
|
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"cached={cached} out={usage.get('completion_tokens', '?')} "
|
||||||
f"耗时{time.time() - t0:.0f}s finish={data['choices'][0].get('finish_reason')}",
|
f"耗时{time.time() - t0:.0f}s finish={data['choices'][0].get('finish_reason')}",
|
||||||
file=sys.stderr)
|
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
|
return content, usage
|
||||||
except (requests.RequestException, KeyError, json.JSONDecodeError) as e:
|
except (requests.RequestException, KeyError, json.JSONDecodeError) as e:
|
||||||
last_err = str(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,
|
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)。
|
"""全局额度治理下的对话入口,返回 (content, usage, actual_model)。
|
||||||
契约:成功→三元组;全链耗尽(所有模型敏感/不可用)→(None,None,None)。
|
契约:成功→三元组;全链耗尽(所有模型敏感/不可用)→(None,None,None)。
|
||||||
model 参数仅作兼容保留:实际用哪个模型由全局额度策略决定,不由调用方指定。
|
model 参数仅作兼容保留:实际用哪个模型由全局额度策略决定,不由调用方指定。
|
||||||
@ -319,8 +353,18 @@ def chat_governed(prompt, model=DEFAULT_MODEL, system=None, max_tokens=512000,
|
|||||||
# 3) 沿链逐个模型调用;撞敏感/不可用换下一个
|
# 3) 沿链逐个模型调用;撞敏感/不可用换下一个
|
||||||
for m in chain:
|
for m in chain:
|
||||||
try:
|
try:
|
||||||
content, usage = chat(prompt, model=m, system=system, max_tokens=max_tokens,
|
content, usage = chat(
|
||||||
temperature=temperature, top_p=top_p)
|
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:
|
except PlanQuotaExhausted as e:
|
||||||
plan_quota_open.add(m)
|
plan_quota_open.add(m)
|
||||||
print(f"[llm] 治理链 {m} Token Plan 本窗耗尽,立即熔断并降级下一个:{str(e)[:80]}",
|
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 后输出")
|
@click.option("--extract-json", "extract_", is_flag=True, help="容错提取 JSON 后输出")
|
||||||
def chat_cmd(prompt_file, model, max_tokens, temperature, out, extract_):
|
def chat_cmd(prompt_file, model, max_tokens, temperature, out, extract_):
|
||||||
prompt = pathlib.Path(prompt_file).read_text()
|
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_:
|
if extract_:
|
||||||
content = json.dumps(extract_json(content), ensure_ascii=False, indent=1)
|
content = json.dumps(extract_json(content), ensure_ascii=False, indent=1)
|
||||||
if out:
|
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` 试跑)。
|
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;**确认后的规划才进生成上下文**)。
|
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
|
```sql
|
||||||
SELECT payload FROM example_planning_section
|
SELECT payload FROM example_planning_section
|
||||||
WHERE work_id=? AND section_type='fine_outline' AND target_chapter=? AND state='confirmed' AND deleted=false
|
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,
|
build_writer_creative_input,
|
||||||
canonical_json,
|
canonical_json,
|
||||||
normalize_text,
|
normalize_text,
|
||||||
|
project_pattern_pointers,
|
||||||
retrieval_identity,
|
retrieval_identity,
|
||||||
validate_writer_context,
|
validate_writer_context,
|
||||||
)
|
)
|
||||||
@ -280,7 +281,10 @@ def _manifest(
|
|||||||
ref = copy.deepcopy(dict(item["sourceRef"]))
|
ref = copy.deepcopy(dict(item["sourceRef"]))
|
||||||
unique[_source_key(ref)] = ref
|
unique[_source_key(ref)] = ref
|
||||||
for raw_ref in pattern_references:
|
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
|
unique[_source_key(ref)] = ref
|
||||||
for hint in index_hints:
|
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:
|
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(
|
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,无法落冻结")
|
raise ValueError("assemble 结果缺 asOf,无法落冻结")
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
try:
|
try:
|
||||||
# manifest_sha256 唯一:同一冻结重放幂等(ON CONFLICT 返回既有行)
|
# manifest_sha256 唯一:同一冻结重放幂等。表是 append-only,冲突只能回读,不能 UPDATE。
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"INSERT INTO example_context_freeze(work_id, target_chapter, as_of_chapter, manifest_sha256, "
|
"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, "
|
"context_sha256, reference_work_id, reference_version, arm_config, sections, token_budget, "
|
||||||
"omitted_sources, creator) "
|
"omitted_sources, creator) "
|
||||||
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s,%s::jsonb,%s) "
|
"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",
|
"RETURNING id, manifest_sha256, context_sha256",
|
||||||
(work_id, target_chapter, as_of, manifest_sha, context_sha, reference_work_id,
|
(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,
|
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(sections, ensure_ascii=False), used_chars,
|
||||||
json.dumps(omitted, ensure_ascii=False), CREATOR)).fetchone()
|
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:
|
if dry_run:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
return {"status": "dry_run_ok", "id": row[0], "manifest_sha256": row[1],
|
return {"status": "dry_run_ok", "id": row[0], "manifest_sha256": row[1],
|
||||||
|
|||||||
@ -427,5 +427,105 @@ class WriterContractTest(unittest.TestCase):
|
|||||||
validate_writer_context(context)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@ -7,6 +7,7 @@ Unicode NFC 与换行归一化,所有身份哈希都来自同一份规范 JSON
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
@ -46,11 +47,19 @@ class ContractError(ValueError):
|
|||||||
|
|
||||||
|
|
||||||
def normalize_text(value: str) -> str:
|
def normalize_text(value: str) -> str:
|
||||||
"""把文本统一为 NFC 与 LF,供哈希和 Unicode 偏移共同使用。"""
|
"""把文本统一为 NFC 与 LF,供哈希和 Unicode 偏移共同使用。
|
||||||
|
|
||||||
|
模型在 JSON 输出里常把换行双重转义成字面 ``\\n``(反斜杠+n 两个字符),这里连同
|
||||||
|
真实的 CRLF/CR 一并还原为真正的换行符 LF,避免正文带着字面 ``\\n`` 显示异常、
|
||||||
|
以及检测/盲评的跨段引文因换行表示不同而匹配失败。
|
||||||
|
"""
|
||||||
|
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
raise ContractError("待归一化文本必须是字符串")
|
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:
|
def _normalize_json(value: Any) -> Any:
|
||||||
@ -247,15 +256,30 @@ def _hash(value: Any, path: str) -> str:
|
|||||||
return text
|
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["sourceId"], f"{path}.sourceId")
|
||||||
_string(ref["sourceVersion"], f"{path}.sourceVersion")
|
_string(ref["sourceVersion"], f"{path}.sourceVersion")
|
||||||
for field in ("chapter", "blockId", "startCodePoint", "endCodePoint"):
|
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")
|
_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:
|
def _validate_plan(value: Any, path: str) -> None:
|
||||||
"""校验固定检索计划,不允许写手临场扩张查询。"""
|
"""校验固定检索计划,不允许写手临场扩张查询。"""
|
||||||
|
|
||||||
@ -506,7 +598,8 @@ def validate_writer_context(value: Any) -> dict[str, Any]:
|
|||||||
raise ContractError("原文证据必须包含截至冻结点的连续前四章基线")
|
raise ContractError("原文证据必须包含截至冻结点的连续前四章基线")
|
||||||
|
|
||||||
for index, reference in enumerate(_array(context["patternReferences"], "$.patternReferences")):
|
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")):
|
for index, coverage in enumerate(_array(context["evidenceCoverage"], "$.evidenceCoverage")):
|
||||||
item = _object(coverage, f"$.evidenceCoverage[{index}]", frozenset({"elementId", "elementType", "name", "status", "factEvidenceIds", "proseEvidenceIds", "gapReason"}))
|
item = _object(coverage, f"$.evidenceCoverage[{index}]", frozenset({"elementId", "elementType", "name", "status", "factEvidenceIds", "proseEvidenceIds", "gapReason"}))
|
||||||
for field in ("elementId", "elementType", "name", "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"]
|
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}",
|
"referenceId": f"pattern-{index + 1}",
|
||||||
"kind": item.get("sourceType", "authorized_pattern"),
|
"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"]
|
output_contract = context["outputContract"]
|
||||||
creative_input = {
|
creative_input = {
|
||||||
"fineOutline": {
|
"fineOutline": {
|
||||||
@ -696,7 +799,10 @@ def validate_writer_output(value: Any) -> dict[str, Any]:
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CONTEXT_VERSION", "DRAFT_VERSION", "OUTPUT_VERSION", "PLAN_VERSION", "MANIFEST_VERSION", "TIE_BREAK",
|
"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",
|
"ContractError", "normalize_text", "canonical_json", "retrieval_identity", "han_count",
|
||||||
"calculate_target_chars", "validate_writer_context", "build_writer_creative_input",
|
"calculate_target_chars", "validate_writer_context", "build_writer_creative_input",
|
||||||
"validate_writer_draft", "build_candidate_envelope", "validate_writer_output",
|
"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 与逐层回执的不可篡改。 |
|
| `file_cas.py` | 不可变内容寻址(CAS)revision journal:按内容哈希落盘,冲突即失败关闭,支撑 Gate receipt 与逐层回执的不可篡改。 |
|
||||||
| `raw_vault.py` | 租约式 raw 物料保险库:原始候选/物料先写 0700 临时 vault,正式评测结束后同盘原子迁移到显式仓外受控归档;安全回执不携带 raw 路径。**raw 落库改写后降为可选备份**(见下)。 |
|
| `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),写入前过滤密钥,看板可看全文。 |
|
| `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 pathlib
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
@ -108,6 +109,30 @@ def _json_value(value: Any) -> Any:
|
|||||||
raise TypeError(f"值不是受支持的 JSON 类型: {type(value).__name__}")
|
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:
|
def canonical_json(value: Any) -> str:
|
||||||
"""生成 UTF-8、排序键、无多余空白的规范 JSON。"""
|
"""生成 UTF-8、排序键、无多余空白的规范 JSON。"""
|
||||||
|
|
||||||
@ -337,10 +362,12 @@ class ExecutionReceipt:
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ClaudeInvocationResult:
|
class ClaudeInvocationResult:
|
||||||
"""成功调用的业务对象和安全回执。"""
|
"""成功调用的业务对象、安全回执和可选 raw(raw 由持久化器接管)。"""
|
||||||
|
|
||||||
structured_output: Mapping[str, Any]
|
structured_output: Mapping[str, Any]
|
||||||
receipt: ExecutionReceipt
|
receipt: ExecutionReceipt
|
||||||
|
raw_request: str | None = None
|
||||||
|
raw_response: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def _safe_file_sha256(path: pathlib.Path) -> str:
|
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)
|
_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:
|
def _schema_type_matches(value: Any, expected: str) -> bool:
|
||||||
"""按 JSON 类型语义判断 Python 值,显式排除 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(
|
def run_claude(
|
||||||
profile: ExecutionProfile,
|
profile: ExecutionProfile,
|
||||||
business_input: Mapping[str, Any],
|
business_input: Mapping[str, Any],
|
||||||
@ -773,6 +904,9 @@ def run_claude(
|
|||||||
binding_verifier: Callable[[ExecutionProfile], None] = verify_execution_profile,
|
binding_verifier: Callable[[ExecutionProfile], None] = verify_execution_profile,
|
||||||
business_validator: Callable[[Any], Mapping[str, Any]] | None = None,
|
business_validator: Callable[[Any], Mapping[str, Any]] | None = None,
|
||||||
source_environment: Mapping[str, str] | 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:
|
) -> ClaudeInvocationResult:
|
||||||
"""以 fresh sandbox 进程执行一次 Claude 调用并联合校验回执。"""
|
"""以 fresh sandbox 进程执行一次 Claude 调用并联合校验回执。"""
|
||||||
|
|
||||||
@ -803,7 +937,8 @@ def run_claude(
|
|||||||
raise ClaudeRuntimeError(f"{prefix}_RECEIPT_INVALID", str(exc)) from exc
|
raise ClaudeRuntimeError(f"{prefix}_RECEIPT_INVALID", str(exc)) from exc
|
||||||
command = build_sandbox_command(profile, isolation_path)
|
command = build_sandbox_command(profile, isolation_path)
|
||||||
try:
|
try:
|
||||||
completed = runner(
|
invocation_runner = _run_default_subprocess if runner is subprocess.run else runner
|
||||||
|
completed = invocation_runner(
|
||||||
command,
|
command,
|
||||||
input=input_text,
|
input=input_text,
|
||||||
text=True,
|
text=True,
|
||||||
@ -951,11 +1086,17 @@ def run_claude(
|
|||||||
if "structured_output" not in envelope:
|
if "structured_output" not in envelope:
|
||||||
issues.add(f"{prefix}_SCHEMA_INVALID")
|
issues.add(f"{prefix}_SCHEMA_INVALID")
|
||||||
else:
|
else:
|
||||||
structured_output = envelope["structured_output"]
|
|
||||||
try:
|
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)
|
validate_json_schema(structured_output, profile.json_schema)
|
||||||
if business_validator is not None:
|
if business_validator is not None:
|
||||||
structured_output = business_validator(structured_output)
|
structured_output = business_validator(structured_output)
|
||||||
|
structured_output = _normalize_structured_json(structured_output)
|
||||||
if not isinstance(structured_output, Mapping):
|
if not isinstance(structured_output, Mapping):
|
||||||
raise ValueError("业务校验器必须返回对象")
|
raise ValueError("业务校验器必须返回对象")
|
||||||
structured_output_sha256 = sha256_json(structured_output)
|
structured_output_sha256 = sha256_json(structured_output)
|
||||||
@ -997,7 +1138,40 @@ def run_claude(
|
|||||||
receipt=receipt,
|
receipt=receipt,
|
||||||
)
|
)
|
||||||
assert isinstance(structured_output, Mapping)
|
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:
|
finally:
|
||||||
if isolation_path is not None:
|
if isolation_path is not None:
|
||||||
# 运行隔离目录不承担 raw 保留职责;调用结束始终立即删除。
|
# 运行隔离目录不承担 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 hashlib
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@ -20,16 +21,21 @@ from db import connect # noqa: E402
|
|||||||
CREATOR = "runtime"
|
CREATOR = "runtime"
|
||||||
KINDS = ("prompt", "response", "source_text", "oracle", "supplier")
|
KINDS = ("prompt", "response", "source_text", "oracle", "supplier")
|
||||||
|
|
||||||
# 写入前过滤:命中即拒,密钥/token/凭据绝不入 raw 表(列级 CHECK 之外的第二道)
|
# 写入前过滤:命中即拒,密钥/token/凭据绝不入 raw 表(列级 CHECK 之外的第二道)。
|
||||||
_SECRET_MARKERS = ("api_key", "apikey", "secret", "token", "password", "passwd",
|
# 不能把普通提示中的 ``token budget`` 当成密钥;只拦赋值形态、Bearer 和足够长的真实 key。
|
||||||
"authorization: bearer", "sk-", "-----begin private 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:
|
def _check_no_secrets(content: str) -> None:
|
||||||
low = content.lower()
|
for pattern in _SECRET_PATTERNS:
|
||||||
for marker in _SECRET_MARKERS:
|
match = pattern.search(content)
|
||||||
if marker in low:
|
if match:
|
||||||
raise ValueError(f"raw 内容疑似含密钥/凭据标记 '{marker}',拒绝入库(安全)")
|
raise ValueError(f"raw 内容疑似含密钥/凭据标记 '{match.group(0)[:40]}',拒绝入库(安全)")
|
||||||
|
|
||||||
|
|
||||||
def _bare_sha256(text: str) -> str:
|
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(
|
row = conn.execute(
|
||||||
"INSERT INTO example_raw_content(lease_id, kind, run_id, role, content_sha256, content, creator) "
|
"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) "
|
"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()
|
(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:
|
if dry_run:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
return {"status": "dry_run_ok", "id": row[0], "content_sha256": row[1], "note": "试跑已回滚,未落库"}
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
|
from dataclasses import replace
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
|
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
|
||||||
@ -20,6 +23,7 @@ sys.path.insert(0, str(SCRIPT_DIR))
|
|||||||
from claude_runtime import ( # noqa: E402
|
from claude_runtime import ( # noqa: E402
|
||||||
ClaudeRuntimeError,
|
ClaudeRuntimeError,
|
||||||
ExecutionProfile,
|
ExecutionProfile,
|
||||||
|
_run_default_subprocess,
|
||||||
_minimal_environment,
|
_minimal_environment,
|
||||||
_validate_usage,
|
_validate_usage,
|
||||||
build_sandbox_command,
|
build_sandbox_command,
|
||||||
@ -33,6 +37,38 @@ from claude_runtime import ( # noqa: E402
|
|||||||
|
|
||||||
|
|
||||||
FULL_MODEL_ID = "claude-opus-4-1-20250805"
|
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]"
|
CAPABILITY_MODEL_ID = "claude-opus-4-8[1m]"
|
||||||
OUTPUT_SCHEMA = {
|
OUTPUT_SCHEMA = {
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/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_AUTH_TOKEN"], auth_token)
|
||||||
self.assertEqual(real_auth_environment["ANTHROPIC_BASE_URL"], base_url)
|
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):
|
def test_auth_token_requires_a_safe_base_url(self):
|
||||||
"""AUTH_TOKEN 必须绑定无凭据、无查询和无片段的 HTTP(S) 网关地址。"""
|
"""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
|
TENANT = 1
|
||||||
|
|
||||||
|
|
||||||
def load_ai_context(conn):
|
def load_ai_context(conn, *, tenant_id: int = TENANT):
|
||||||
"""读库内 23 型的字段级 aiContext 细则:{target_type: {field: true/false/[用途]}}。"""
|
"""读库内 23 型的字段级 aiContext 细则:{target_type: {field: true/false/[用途]}}。"""
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""SELECT s.target_type, v.policy_snapshot->'fieldAiContext'
|
"""SELECT s.target_type, v.policy_snapshot->'fieldAiContext'
|
||||||
FROM muse_meta_schema s
|
FROM muse_meta_schema s
|
||||||
JOIN muse_meta_schema_version sv ON sv.id = s.active_version_id
|
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
|
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}
|
return {t: (m or {}) for t, m in rows}
|
||||||
|
|
||||||
|
|
||||||
@ -96,8 +96,8 @@ def search_cards(
|
|||||||
保留原有 draft 能力,但正文生产适配器不会调用治理面。
|
保留原有 draft 能力,但正文生产适配器不会调用治理面。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if scope not in {"admin", "work"}:
|
if scope not in {"admin", "public_pattern", "work"}:
|
||||||
raise ValueError("scope 只能是 admin 或 work")
|
raise ValueError("scope 只能是 admin、public_pattern 或 work")
|
||||||
if scope == "work" and not work_id:
|
if scope == "work" and not work_id:
|
||||||
raise ValueError("scope=work 必须提供 work_id")
|
raise ValueError("scope=work 必须提供 work_id")
|
||||||
if purpose not in {"generation", "planning", "detection", "extraction"}:
|
if purpose not in {"generation", "planning", "detection", "extraction"}:
|
||||||
@ -107,7 +107,7 @@ def search_cards(
|
|||||||
|
|
||||||
qvec = json.dumps(embedder(intent))
|
qvec = json.dumps(embedder(intent))
|
||||||
with connection_factory(dsn) as conn:
|
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":
|
if scope == "admin":
|
||||||
sql = """SELECT 'draft' AS src, d.id, d.draft_payload AS payload, d.status,
|
sql = """SELECT 'draft' AS src, d.id, d.draft_payload AS payload, d.status,
|
||||||
1 - (e.embedding <=> %s::vector) AS score,
|
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
|
JOIN muse_knowledge_draft d ON d.id = e.draft_id
|
||||||
WHERE e.tenant_id=%s AND e.deleted=FALSE AND d.deleted=FALSE"""
|
WHERE e.tenant_id=%s AND e.deleted=FALSE AND d.deleted=FALSE"""
|
||||||
args = [qvec, tenant_id]
|
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:
|
else:
|
||||||
sql = """SELECT 'entity' AS src, en.id,
|
sql = """SELECT 'entity' AS src, en.id,
|
||||||
jsonb_build_object('型', en.entity_type, '名称', en.normalized_name,
|
jsonb_build_object('型', en.entity_type, '名称', en.normalized_name,
|
||||||
@ -133,10 +158,15 @@ def search_cards(
|
|||||||
AND en.source_action_policy='allowed'"""
|
AND en.source_action_policy='allowed'"""
|
||||||
args = [qvec, work_id, tenant_id, tenant_id]
|
args = [qvec, work_id, tenant_id, tenant_id]
|
||||||
if ttype:
|
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)
|
args.append(ttype)
|
||||||
id_column = "d.id" if scope == "admin" else "en.id"
|
draft_scope = scope in {"admin", "public_pattern"}
|
||||||
revision_column = "d.revision" if scope == "admin" else "en.revision"
|
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"
|
sql += f" ORDER BY score DESC, {revision_column}::text ASC, {id_column}::text ASC LIMIT %s"
|
||||||
args.append(top)
|
args.append(top)
|
||||||
rows = conn.execute(sql, args).fetchall()
|
rows = conn.execute(sql, args).fetchall()
|
||||||
@ -167,7 +197,11 @@ def search_cards(
|
|||||||
"sourceKind": "canonical_entity" if src == "entity" else "draft",
|
"sourceKind": "canonical_entity" if src == "entity" else "draft",
|
||||||
"sourceStatus": source_status or status,
|
"sourceStatus": source_status or status,
|
||||||
"bindingStatus": binding_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
|
return results
|
||||||
@ -175,8 +209,8 @@ def search_cards(
|
|||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@click.argument("intent")
|
@click.argument("intent")
|
||||||
@click.option("--scope", type=click.Choice(["admin", "work"]), default="admin", show_default=True,
|
@click.option("--scope", type=click.Choice(["admin", "public_pattern", "work"]), default="admin", show_default=True,
|
||||||
help="admin=治理面(含草稿); work=作品面(仅已确认+已绑定)")
|
help="admin=治理面; public_pattern=公共范式草稿; work=作品面")
|
||||||
@click.option("--work-id", type=int, help="scope=work 时必填")
|
@click.option("--work-id", type=int, help="scope=work 时必填")
|
||||||
@click.option("--type", "ttype", help="限定型(如 craft/combat/emotion/scene_pattern/trope)")
|
@click.option("--type", "ttype", help="限定型(如 craft/combat/emotion/scene_pattern/trope)")
|
||||||
@click.option("--purpose", default="generation", show_default=True,
|
@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,))
|
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),状态色固定不主题化 ──────────────
|
# ── 视觉系统:dataviz 已验证调色板(light/dark),状态色固定不主题化 ──────────────
|
||||||
CSS = """
|
CSS = """
|
||||||
:root {
|
:root {
|
||||||
@ -260,13 +290,19 @@ def _quota_block():
|
|||||||
|
|
||||||
|
|
||||||
def view_home():
|
def view_home():
|
||||||
works = one("SELECT count(*) FROM muse_content_work WHERE deleted=false") or 0
|
ledger_names = [tbl for _, tbl in _LEDGER]
|
||||||
chapters = one("SELECT count(*) FROM muse_content_chapter WHERE deleted=false") or 0
|
counts = table_counts([
|
||||||
blocks = one("SELECT count(*) FROM muse_content_block WHERE deleted=false") or 0
|
"muse_content_work", "muse_content_chapter", "muse_content_block",
|
||||||
drafts = one("SELECT count(*) FROM muse_knowledge_draft WHERE deleted=false") or 0
|
"muse_knowledge_draft", "muse_knowledge_entity", "example_reference_work",
|
||||||
entities = one("SELECT count(*) FROM muse_knowledge_entity WHERE deleted=false") or 0
|
"example_clean_log", *ledger_names,
|
||||||
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
|
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"""
|
tiles = f"""
|
||||||
<div class="tiles">
|
<div class="tiles">
|
||||||
@ -281,10 +317,10 @@ def view_home():
|
|||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
for label, tbl in _LEDGER:
|
for label, tbl in _LEDGER:
|
||||||
if not table_exists(tbl):
|
if tbl not in counts:
|
||||||
st, cnt = chip("not_built", "未建 · 待落库"), "—"
|
st, cnt = chip("not_built", "未建 · 待落库"), "—"
|
||||||
else:
|
else:
|
||||||
n = one(f'SELECT count(*) FROM "{tbl}"') or 0
|
n = counts[tbl]
|
||||||
st = chip("good", "已建 · 有数据") if n > 0 else chip("warn", "已建 · 0 行")
|
st = chip("good", "已建 · 有数据") if n > 0 else chip("warn", "已建 · 0 行")
|
||||||
cnt = fmt_num(n)
|
cnt = fmt_num(n)
|
||||||
rows.append(f"<tr><td>{esc(label)}</td><td><code>{esc(tbl)}</code></td>"
|
rows.append(f"<tr><td>{esc(label)}</td><td><code>{esc(tbl)}</code></td>"
|
||||||
@ -303,8 +339,14 @@ def view_home():
|
|||||||
|
|
||||||
# ── 作品(01 作品领域:作品 → 章节 → 正文全文)────────────────────────────────
|
# ── 作品(01 作品领域:作品 → 章节 → 正文全文)────────────────────────────────
|
||||||
def view_works():
|
def view_works():
|
||||||
_, rows = q("SELECT id,title,status,genre,chapter_count,word_count FROM muse_content_work "
|
_, rows = q(
|
||||||
"WHERE deleted=false ORDER BY id")
|
"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:
|
if not rows:
|
||||||
return layout("作品", "<h1>作品</h1>" + empty_gap("库里还没有作品", "导入或创建后在此显示"), "/works")
|
return layout("作品", "<h1>作品</h1>" + empty_gap("库里还没有作品", "导入或创建后在此显示"), "/works")
|
||||||
trs = "".join(
|
trs = "".join(
|
||||||
@ -317,9 +359,98 @@ def view_works():
|
|||||||
return layout("作品", body, "/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):
|
def view_work(wid):
|
||||||
_, wrows = q("SELECT id,title,status,genre,summary,word_count,chapter_count "
|
_, wrows = q(
|
||||||
"FROM muse_content_work WHERE id=%s AND deleted=false", (wid,))
|
"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:
|
if not wrows:
|
||||||
return layout("作品", "<h1>作品不存在</h1>", "/works")
|
return layout("作品", "<h1>作品不存在</h1>", "/works")
|
||||||
wid_, title, status, genre, summary, wc, cc = wrows[0]
|
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>"
|
+ "</tbody></table></div>") if secs else "<div class='empty'>规划表已建 · 暂无规划(planner 落库后显示)</div>"
|
||||||
else:
|
else:
|
||||||
plan = empty_gap("规划 · 待落库", "规划表(100)未建。")
|
plan = empty_gap("规划 · 待落库", "规划表(100)未建。")
|
||||||
|
knowledge = _work_knowledge_panel(wid)
|
||||||
body = (f"<div class='crumb'><a href='/works'>作品</a> / {esc(title)}</div>"
|
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"<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(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")
|
return layout(title, body, "/works")
|
||||||
|
|
||||||
|
|
||||||
@ -388,7 +520,23 @@ def _barset(rows, slots):
|
|||||||
return "".join(out)
|
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
|
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
|
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
|
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 ""
|
) if recent else ""
|
||||||
|
|
||||||
body = (f"<h1>知识库<span class='sub'>实体与范式(muse_knowledge_*)+ 它们的生产线(拆书·清洗)· 实体 02 · 范式 03</span></h1>"
|
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")
|
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>"
|
+ "".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)
|
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("暂无模型调用", "")
|
+ "</tbody></table></div>") if calls else empty_gap("暂无模型调用", "")
|
||||||
cand = (empty_gap("待审候选 · 待落库", "候选表(99)未建。") if not table_exists("example_candidate")
|
if table_exists("example_candidate"):
|
||||||
else "<div class='empty'>候选表已建 · 该运行暂无候选</div>")
|
_, candidates = q(
|
||||||
dec = (empty_gap("用户决策 · 待落库", "决策记录表(99)未建。") if not table_exists("example_user_decision")
|
"SELECT id, target_chapter, candidate_version, run_type, state, source_role, "
|
||||||
else "<div class='empty'>决策表已建 · 该运行暂无决策</div>")
|
"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>"
|
body = (f"<div class='crumb'><a href='/runs'>运行记录</a> / <code>{esc(rid)}</code></div>"
|
||||||
f"<h1>运行详情</h1>{info}"
|
f"<h1>运行详情</h1>{info}"
|
||||||
f"<h2>回执链</h2>{rc_html}<h2>质量评判</h2>{q_html}<h2>模型调用</h2>{call_html}"
|
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")
|
return layout(f"运行 · {rid}", body, "/runs")
|
||||||
|
|
||||||
|
|
||||||
@ -673,7 +899,7 @@ _ROUTES = [
|
|||||||
(re.compile(r"^/works$"), lambda m, p: view_works()),
|
(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+)$"), 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"^/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$"), lambda m, p: view_agents()),
|
||||||
(re.compile(r"^/agents/role/([\w-]+)$"), lambda m, p: view_agent_role(m.group(1))),
|
(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))),
|
(re.compile(r"^/agents/skill/([\w-]+)$"), lambda m, p: view_skill(m.group(1))),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user