zizi 091b66a9bb 重构: 收敛 Agent/Skill 运行时与创作质量闭环
将角色与 Skill 从 .claude 迁入 .agent,移除 Claude CLI 运行时并接入固定 Opus 角色 profile、完整 schema、预算 deadline、raw 与回执证据链。

同步拆分 Skill 职责、复利 lesson、Gate 回放、Dashboard 人审入口、数据库登记和机械门禁;候选设计正文不包含在本提交中。
2026-08-22 02:12:32 +08:00

267 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""search-knowledge Skill:知识向量检索(余弦召回 + 授权过滤 + aiContext 字段裁剪)。
合同见同目录 SKILL.md。查询嵌入与知识行同模型同维(复用 embed-knowledge 的实现)。
"""
import json
import sys
from typing import Any, Callable
import click
import psycopg
from muse_embed import _session, embed_texts
from muse_db import DSN
TENANT = 1
def load_ai_context(conn, *, tenant_id: int = TENANT):
"""读库内 23 型的字段级 aiContext 细则:{target_type: {field: true/false/[用途]}}。"""
rows = conn.execute(
"""SELECT s.target_type, v.policy_snapshot->'fieldAiContext'
FROM muse_meta_schema s
JOIN muse_meta_schema_version sv ON sv.id = s.active_version_id
JOIN muse_meta_visibility_policy v ON v.schema_version_id = sv.id
WHERE s.tenant_id=%s AND s.deleted=FALSE""", (tenant_id,)).fetchall()
return {t: (m or {}) for t, m in rows}
def visible(ai_rule, purpose):
"""aiContext 判定:true 全用途可见;false 不可见;[用途] 仅列出的可见;无规则默认可见。"""
if ai_rule is None:
return True
if isinstance(ai_rule, bool):
return ai_rule
return purpose in ai_rule
def _default_embedder(intent: str) -> list[float]:
"""复用 embed-knowledge 生成单条查询向量,并统一失败语义。"""
vectors, bad = embed_texts(_session(), [intent])
if bad or not vectors:
raise ValueError("查询嵌入失败")
return vectors[0]
def _structured_source_refs(payload: dict[str, Any], lineage: Any) -> list[dict[str, Any]]:
"""只接收结构化来源指针;人类可读的“出处”文本不能冒充可回读引用。"""
candidates = []
if isinstance(lineage, dict):
candidates.append(lineage.get("sourceRefs"))
fields = payload.get("字段")
if isinstance(fields, dict):
candidates.append(fields.get("sourceRefs"))
candidates.append(payload.get("sourceRefs"))
for value in candidates:
if isinstance(value, list) and all(isinstance(item, dict) for item in value):
return value
return []
def _milestones(payload: dict[str, Any]) -> list[dict[str, Any]]:
"""从卡字段中提取历史里程碑,终态摘要不会进入冻结投影。"""
fields = payload.get("字段")
if not isinstance(fields, dict):
return []
for key in ("演变历程", "演变轨迹", "milestones"):
value = fields.get(key)
if isinstance(value, list) and all(isinstance(item, dict) for item in value):
return value
return []
def search_cards(
intent: str,
*,
scope: str = "admin",
work_id: int | None = None,
ttype: str | None = None,
purpose: str = "generation",
top: int = 5,
dsn: str = DSN,
tenant_id: int = TENANT,
connection_factory: Callable[..., Any] = psycopg.connect,
embedder: Callable[[str], list[float]] = _default_embedder,
) -> list[dict[str, Any]]:
"""执行唯一的卡检索语义,CLI 与正文读取器共同调用本函数。
生产作品面只读 active Canonical entity、允许状态和有效绑定;治理面
保留原有 draft 能力,但正文生产适配器不会调用治理面。
"""
if scope not in {"admin", "public_pattern", "work"}:
raise ValueError("scope 只能是 admin、public_pattern 或 work")
if scope == "work" and not work_id:
raise ValueError("scope=work 必须提供 work_id")
if purpose not in {"generation", "planning", "detection", "extraction"}:
raise ValueError("purpose 非法")
if isinstance(top, bool) or not isinstance(top, int) or top <= 0:
raise ValueError("top 必须是正整数")
qvec = json.dumps(embedder(intent))
with connection_factory(dsn) as conn:
ai_rules = load_ai_context(conn, tenant_id=tenant_id)
if scope == "admin":
sql = """SELECT 'draft' AS src, d.id, d.draft_payload AS payload, d.status,
1 - (e.embedding <=> %s::vector) AS score,
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 e.deleted=FALSE AND d.deleted=FALSE"""
args = [qvec, tenant_id]
elif scope == "public_pattern":
# WHY: 公共范式仍处于 draft 双轨,不能走作品 entity/binding 面;专用查询必须在
# SQL 层同时锁住全局作品号、公共目标库、全局库存在性和来源资格,不能复用会
# 召回同租户全部治理草稿的 admin 面。当前 draft schema 没有 kb_id/scope 列,
# 所以库归属按已登记的数据合同由 work_id + 目标库绑定,scope 缺省按 global。
sql = """SELECT 'draft' AS src, d.id, d.draft_payload AS payload, d.status,
1 - (e.embedding <=> %s::vector) AS score,
d.revision, d.current_canonical_snapshot AS lineage,
NULL::varchar AS binding_status, d.source_status
FROM example_knowledge_embedding e
JOIN muse_knowledge_draft d ON d.id = e.draft_id
WHERE e.tenant_id=%s AND d.tenant_id=%s
AND e.deleted=FALSE AND d.deleted=FALSE
AND d.work_id=0
AND d.draft_payload->>'目标库'='公共范式库'
AND COALESCE(d.draft_payload->>'scope', 'global')='global'
AND d.status IN ('pending','confirmed')
AND COALESCE(d.source_status, 'active') IN ('active','authorized')
AND COALESCE(d.source_action_policy, 'allowed')='allowed'
AND EXISTS (
SELECT 1 FROM muse_knowledge_base kb
WHERE kb.tenant_id=%s AND kb.deleted=FALSE
AND kb.kb_type='global' AND kb.status='active'
)"""
args = [qvec, tenant_id, tenant_id, tenant_id]
else:
sql = """SELECT 'entity' AS src, en.id,
jsonb_build_object('型', en.entity_type, '名称', en.normalized_name,
'一句话摘要', en.description, '字段', en.attributes) AS payload,
en.status, 1 - (e.embedding <=> %s::vector) AS score,
en.revision, en.lineage_payload AS lineage,
b.binding_status, en.source_status
FROM example_knowledge_embedding e
JOIN muse_knowledge_entity en ON en.id = e.entity_id
JOIN muse_knowledge_binding b ON b.kb_id = en.kb_id AND b.work_id = %s
AND b.binding_status='active' AND b.deleted=FALSE AND b.tenant_id=%s
WHERE e.tenant_id=%s AND e.deleted=FALSE AND en.deleted=FALSE
AND en.status='active' AND en.source_status IN ('active','authorized')
AND en.source_action_policy='allowed'"""
args = [qvec, work_id, tenant_id, tenant_id]
if ttype:
sql += (
" AND d.draft_payload->>'型' = %s"
if scope in {"admin", "public_pattern"}
else " AND en.entity_type = %s"
)
args.append(ttype)
draft_scope = scope in {"admin", "public_pattern"}
id_column = "d.id" if draft_scope else "en.id"
revision_column = "d.revision" if draft_scope else "en.revision"
sql += f" ORDER BY score DESC, {revision_column}::text ASC, {id_column}::text ASC LIMIT %s"
args.append(top)
rows = conn.execute(sql, args).fetchall()
results = []
for src, row_id, raw_payload, status, score, revision, lineage, binding_status, source_status in rows:
payload = raw_payload or {}
card_type = payload.get("型") or payload.get("type") or "?"
rules = ai_rules.get(card_type, {})
fields = payload.get("字段") or {}
visible_fields = {key: item for key, item in fields.items() if visible(rules.get(key), purpose)}
source_version = f"{src}-revision:{revision or 0}"
source_id = f"canonical-entity:{row_id}" if src == "entity" else f"draft:{row_id}"
results.append(
{
"cardId": str(row_id),
"type": card_type,
"name": payload.get("名称"),
"score": float(score),
"summary": payload.get("一句话摘要"),
"visibleFields": visible_fields,
"omittedFields": sorted(set(fields) - set(visible_fields)),
"sourceId": source_id,
"sourceVersion": source_version,
"sourceOffset": 0,
"sourceRefs": _structured_source_refs(payload, lineage),
"milestones": _milestones(payload),
"sourceKind": "canonical_entity" if src == "entity" else "draft",
"sourceStatus": source_status or status,
"bindingStatus": binding_status,
"retrievalScope": scope,
"productionRetrievalEligible": (
scope == "public_pattern"
or (src == "entity" and status == "active" and binding_status == "active")
),
}
)
return results
@click.command()
@click.argument("intent")
@click.option("--scope", type=click.Choice(["admin", "public_pattern", "work"]), default="admin", show_default=True,
help="admin=治理面; public_pattern=公共范式草稿; work=作品面")
@click.option("--work-id", type=int, help="scope=work 时必填")
@click.option("--type", "ttype", help="限定型(如 craft/combat/emotion/scene_pattern/trope)")
@click.option("--purpose", default="generation", show_default=True,
type=click.Choice(["generation", "planning", "detection", "extraction"]))
@click.option("--top", default=5, show_default=True)
@click.option("--json", "as_json", is_flag=True)
def main(intent, scope, work_id, ttype, purpose, top, as_json):
try:
cards = search_cards(
intent,
scope=scope,
work_id=work_id,
ttype=ttype,
purpose=purpose,
top=top,
)
except ValueError as error:
raise click.ClickException(str(error)) from error
results = [
{
"来源": item["sourceId"],
"型": item["type"],
"名称": item["name"],
"状态": item["sourceStatus"],
"相似度": round(item["score"], 4),
"一句话摘要": item["summary"],
"可见字段": item["visibleFields"],
"出处": item["sourceRefs"],
"裁剪回显": item["omittedFields"],
}
for item in cards
]
if as_json:
click.echo(json.dumps(results, ensure_ascii=False, indent=1))
return
for i, r in enumerate(results, 1):
click.echo(f"── {i}. [{r['相似度']}] {r['型']} · {r['名称']}({r['状态']},{r['来源']})")
click.echo(f" 摘要: {r['一句话摘要']}")
for k, v in (r["可见字段"] or {}).items():
click.echo(f" {k}: {str(v)[:120]}")
if r["出处"]:
click.echo(f" 出处: {r['出处']}")
if r["裁剪回显"]:
click.echo(f" [裁剪回显·{purpose} 不可见] {','.join(r['裁剪回显'])}")
if not results:
click.echo("(无召回)")
if __name__ == "__main__":
try:
main()
except psycopg.Error as e:
click.echo(f"[db错误] {type(e).__name__}: {e}", err=True)
sys.exit(1)