#!/usr/bin/env python3 """search-knowledge Skill:知识向量检索(余弦召回 + 授权过滤 + aiContext 字段裁剪)。 合同见同目录 SKILL.md。查询嵌入与知识行同模型同维(复用 embed-knowledge 的实现)。 """ import json import sys from pathlib import Path from typing import Any, Callable import click import psycopg from muse_embed import _session, embed_texts from muse_db import DSN TENANT = 1 def _project_root() -> Path: return next( parent for parent in (Path(__file__).resolve().parent, *Path(__file__).resolve().parents) if (parent / "AGENTS.md").is_file() and (parent / ".git").exists() ) 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, sqlite_path: str | Path | None = None, ) -> 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 必须是正整数") if sqlite_path is not None: root = _project_root() if str(root) not in sys.path: sys.path.insert(0, str(root)) from muse.store import search_card_vectors return search_card_vectors( embedder(intent), kind=ttype, work_id=work_id if scope == "work" else None, top=top, path=sqlite_path, ) 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: root = _project_root() if str(root) not in sys.path: sys.path.insert(0, str(root)) from muse.store import connect, default_db_path sqlite_path = None db_path = default_db_path() if db_path.is_file(): with connect(db_path) as conn: filled = conn.execute( "SELECT COUNT(*) FROM cards WHERE embedding IS NOT NULL" ).fetchone()[0] if filled: sqlite_path = db_path cards = search_cards( intent, scope=scope, work_id=work_id, ttype=ttype, purpose=purpose, top=top, sqlite_path=sqlite_path, ) 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)