Sfoglia il codice sorgente

fix: skills4claude

Talegorithm 1 mese fa
parent
commit
5b037963ca

+ 3 - 1
.claude/settings.local.json

@@ -17,7 +17,9 @@
       "Bash(sed:*)",
       "Bash(PYTHONIOENCODING=utf-8 python:*)",
       "mcp__ide__getDiagnostics",
-      "Bash(TOOL_AGENT_ROUTER_URL=\"http://43.106.118.91:8001\" python:*)"
+      "Bash(TOOL_AGENT_ROUTER_URL=\"http://43.106.118.91:8001\" python:*)",
+      "Bash(rm /Users/sunlit/.claude/skills/agent /Users/sunlit/.claude/skills/toolhub /Users/sunlit/.claude/skills/knowhub /Users/sunlit/.claude/skills/content-search)",
+      "Read(//Users/sunlit/.claude/skills/**)"
     ],
     "deny": [],
     "ask": []

+ 0 - 68
.claude/skills/agent/SKILL.md

@@ -1,68 +0,0 @@
----
-name: agent
-description: 调用 Agent 执行任务 —— 能从知识库获取内容制作经验、擅长使用浏览器做深度调研。
----
-
-# Agent 调用
-
-统一入口调用远端(KnowHub 服务器)或本地 Agent。底层走 `cyber-agent` SDK 的 `invoke_agent()`。
-
-## 前置
-
-需要 `cyber-agent` 包可 import。若未安装,在该仓库根目录执行:
-
-```bash
-pip install -e .
-```
-
-## 用法
-
-```bash
-python <this_skill_dir>/invoke.py \
-    --agent_type=<type> \
-    --task="<任务描述>" \
-    [--skills=skill1,skill2] \
-    [--continue_from=<sub_trace_id>] \
-    [--project_root=<本地项目目录>]
-```
-
-## 远端 Agent(`remote_` 前缀)
-
-**`remote_librarian`** — 知识库查询与上传:
-- `skills=ask_strategy`(默认):查询整合,返回带引用的回答
-- `skills=upload_strategy`:上传(`task` 为 JSON 字符串 `{knowledge:[...], tools:[...], resources:[...]}`)
-
-**`remote_research`** — 深度调研:自动全网搜集 + 总结,成果自动入库。
-
-## 本地 Agent
-
-`agent_type` 无 `remote_` 前缀,需 `--project_root` 指向项目目录。约定项目结构:
-- `config.py` 定义 `RUN_CONFIG`(必需)
-- `presets.json` 定义 preset(可选)
-- `tools/__init__.py` 注册自定义工具(可选)
-
-## 续跑
-
-首次调用返回的 `sub_trace_id` 作为下次的 `--continue_from`,同一 Agent 累积上下文。
-
-## 返回值
-
-stdout 输出 JSON,成功退出码 0、失败 1:
-
-```json
-{
-  "mode": "remote" | "local",
-  "agent_type": "...",
-  "sub_trace_id": "...",
-  "status": "completed" | "failed",
-  "summary": "Agent 最终产出的 message 文本(结构化信息由 prompt 约定写在这里)",
-  "stats": {"total_messages": N, "total_tokens": N, "total_cost": 0.xxx},
-  "error": null
-}
-```
-
-## 注意
-
-- 远端 `skills` 由服务器白名单过滤,非法项静默丢弃
-- 远端 Agent 无法访问调用方本地文件系统——大数据先通过 knowhub / toolhub 上传再传 ID
-- `summary` 是纯文本;需要结构化字段(引用来源、ID 等)时由 Agent prompt 约定格式,调用方自己 parse

+ 0 - 47
.claude/skills/agent/invoke.py

@@ -1,47 +0,0 @@
-#!/usr/bin/env python
-"""
-薄 CLI 脚本:透传命令行参数到 cyber-agent 的 invoke_agent() SDK。
-远端 / 本地由 agent_type 前缀决定,同步返回 JSON 到 stdout。
-"""
-
-import argparse
-import asyncio
-import json
-import sys
-
-from agent import invoke_agent
-
-
-def main() -> int:
-    parser = argparse.ArgumentParser(description="调用 Agent(远端或本地)")
-    parser.add_argument("--agent_type", required=True,
-                        help="Agent 类型。'remote_' 前缀走 KnowHub 服务器;否则本地执行")
-    parser.add_argument("--task", required=True, help="任务描述")
-    parser.add_argument("--skills", default=None,
-                        help="逗号分隔的 skill 列表(如 ask_strategy,upload_strategy)")
-    parser.add_argument("--continue_from", default=None,
-                        help="已有 sub_trace_id,传入则续跑")
-    parser.add_argument("--messages", default=None,
-                        help="预置消息(JSON 数组字符串,OpenAI 格式)")
-    parser.add_argument("--project_root", default=None,
-                        help="本地 agent 必填——项目目录(含 config.py)")
-    args = parser.parse_args()
-
-    skills = [s.strip() for s in args.skills.split(",") if s.strip()] if args.skills else None
-    messages = json.loads(args.messages) if args.messages else None
-
-    result = asyncio.run(invoke_agent(
-        agent_type=args.agent_type,
-        task=args.task,
-        skills=skills,
-        continue_from=args.continue_from,
-        messages=messages,
-        project_root=args.project_root,
-    ))
-
-    print(json.dumps(result, ensure_ascii=False, indent=2))
-    return 0 if result.get("status") == "completed" else 1
-
-
-if __name__ == "__main__":
-    sys.exit(main())

+ 147 - 0
knowhub/scripts/dedup_howard_round4.py

@@ -0,0 +1,147 @@
+#!/usr/bin/env python3
+"""
+Round 4 cleanup: 合并 howard_dedup 里因 alias miss 产生的重复 capability.
+
+所有合并都是 member → canonical:junction 全部改向 canonical,member 删除。
+"""
+import argparse
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+from knowhub.knowhub_db.pg_capability_store import PostgreSQLCapabilityStore
+
+
+# canonical -> [members]. canonical 必须存在且是"更好"的版本(含原始 id 或更详细 desc)
+CLEANUP = {
+    # === 视频/动画 ===
+    "CAP-d92ffc99": ["CAP-bcdfba3c"],                                # AI 虚拟换装
+    "CAP-017": ["CAP-1f7f4d72"],                                      # 全向参考
+    "CAP-3b8df701": ["CAP-974c9781"],                                 # AI VFX 合成
+    "CAP-98490894": ["CAP-e5934ab7", "CAP-c020af1d"],                # 唇形同步
+    "CAP-7b9d2baf": ["CAP-8c7b1114"],                                 # 首尾帧衔接
+    "CAP-49175b92": ["CAP-22768ee3"],                                 # 电影镜头运动
+    # === 图像/构图 ===
+    "CAP-c9426dcc": ["CAP-8b3d4b8d"],                                 # 产品爆炸分解图
+    "CAP-7c8532dc": ["CAP-059c8d22"],                                 # 宠物服装虚拟上身
+    "CAP-fddd3349": ["CAP-7abc6773"],                                 # 网格/全景大图切割
+    "CAP-1649b549": ["CAP-69f5a034"],                                 # 戏剧性明暗对比
+    "CAP-d043d289": ["CAP-14378e6f"],                                 # 手持物体精准细节
+    "CAP-5a1ac59d": ["CAP-5f4aa6b7", "CAP-d1234756", "CAP-82851c39"], # 多情绪表情
+    "CAP-d93a0ac2": ["CAP-50f66faa"],                                 # 场景光影一致性校正
+    "CAP-3c49ff0a": ["CAP-f5ea6d52"],                                 # 空间透视
+    "CAP-d1f429ff": ["CAP-7d07e293"],                                 # 科技感元素
+    "CAP-8467736a": ["CAP-d1757626"],                                 # 粒子光效
+    "CAP-5b000814": ["CAP-92c7cc81"],                                 # 结构化 Prompt
+    "CAP-a35e7966": ["CAP-0545b3ca"],                                 # 霓虹发光
+    "CAP-2de278d6": ["CAP-6485105e"],                                 # 高饱和配色
+    "CAP-562d91c1": ["CAP-2ba237a5", "CAP-85ed8dc9", "CAP-a5f8e368"], # 海报多元素排版
+    "CAP-792fd807": ["CAP-fb3906c8", "CAP-d9f9692f"],                 # 景深虚化
+    "CAP-8d69865f": ["CAP-421b1002"],                                 # 照片转绘插画
+    # === 文字 ===
+    "CAP-16c5174b": ["CAP-db0cb47e"],                                 # 文字描边阴影
+    "CAP-00c474e2": ["CAP-e714656e", "CAP-920091f9"],                # 视频动效字幕/文字动画
+    "CAP-ce113c41" if False else "CAP-7423a8b2": [],                  # (placeholder)
+    # === 拼贴 ===
+    "CAP-bc67d346" if False else "CAP-6f73062a": [],                  # (skip, 6f73062a is current)
+    # === 信息层级 ===
+    "CAP-067edd94": ["CAP-64f5a261"],                                 # 层级排版 + 标题字号
+    # === 数据模板 ===
+    "CAP-a08749c3": ["CAP-89bf9fd7"],                                 # 模板数据批量套版
+    # === 文字特效 ===
+    "CAP-a4b638a6": [],                                               # keep alone (文字特效与动画渲染)
+    # === 毛绒 ===
+    "CAP-96182b8f": ["CAP-9692aa0a"],                                 # 毛绒材质渲染
+    # === 复古大字报 ===
+    # CAP-c6dfb2b0 特定风格,保留独立
+}
+
+# Remove placeholder keys with empty member list
+CLEANUP = {k: v for k, v in CLEANUP.items() if v}
+
+JUNCTIONS = [
+    ("requirement_capability", "capability_id", "requirement_id"),
+    ("capability_tool", "capability_id", "tool_id"),
+    ("capability_knowledge", "capability_id", "knowledge_id"),
+    ("capability_resource", "capability_id", "resource_id"),
+    ("strategy_capability", "capability_id", "strategy_id"),
+]
+
+
+def merge_one(cur, canonical, members):
+    stats = {'junction_upd': 0, 'junction_dup_del': 0, 'cap_del': 0, 'skipped_missing': []}
+    for member in members:
+        cur.execute('SELECT 1 FROM capability WHERE id = %s', (member,))
+        if not cur.fetchone():
+            stats['skipped_missing'].append(member)
+            continue
+        for table, col, other_col in JUNCTIONS:
+            # delete dups (member has (other_col) that canonical already has)
+            cur.execute(
+                f"DELETE FROM {table} WHERE {col} = %s AND {other_col} IN ("
+                f"  SELECT {other_col} FROM {table} WHERE {col} = %s)",
+                (member, canonical))
+            stats['junction_dup_del'] += cur.rowcount or 0
+            # update remaining member refs to canonical
+            cur.execute(f"UPDATE {table} SET {col} = %s WHERE {col} = %s",
+                        (canonical, member))
+            stats['junction_upd'] += cur.rowcount or 0
+        cur.execute('DELETE FROM capability WHERE id = %s', (member,))
+        stats['cap_del'] += cur.rowcount or 0
+    return stats
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    g = ap.add_mutually_exclusive_group(required=True)
+    g.add_argument('--dry-run', action='store_true')
+    g.add_argument('--execute', action='store_true')
+    args = ap.parse_args()
+
+    s = PostgreSQLCapabilityStore()
+    cur = s._get_cursor()
+    try:
+        cur.execute("SELECT COUNT(*) c FROM capability")
+        before = cur.fetchone()['c']
+        print(f'before: {before} caps', flush=True)
+
+        total = {'upd': 0, 'dup': 0, 'del': 0, 'skip': []}
+        for canonical, members in CLEANUP.items():
+            cur.execute('SELECT name FROM capability WHERE id = %s', (canonical,))
+            r = cur.fetchone()
+            if not r:
+                print(f'❌ canonical {canonical} missing, SKIP cluster', flush=True)
+                continue
+            if args.dry_run:
+                # just check members exist
+                exists = [m for m in members
+                          if (cur.execute('SELECT 1 FROM capability WHERE id=%s',(m,)),
+                              cur.fetchone())[1]]
+                print(f'[DRY] {canonical} ({r["name"]}) ← {len(exists)}/{len(members)} members to merge',
+                      flush=True)
+            else:
+                stats = merge_one(cur, canonical, members)
+                total['upd'] += stats['junction_upd']
+                total['dup'] += stats['junction_dup_del']
+                total['del'] += stats['cap_del']
+                total['skip'].extend(stats['skipped_missing'])
+                print(f'✓ {canonical} ({r["name"][:40]}) ← {stats["cap_del"]} merged '
+                      f'(upd={stats["junction_upd"]}, dup={stats["junction_dup_del"]})',
+                      flush=True)
+
+        cur.execute("SELECT COUNT(*) c FROM capability")
+        after = cur.fetchone()['c']
+        print(f'\n{"="*50}')
+        print(f'{"DRY-RUN" if args.dry_run else "EXECUTE"}: {before} → {after} (-{before-after})', flush=True)
+        if not args.dry_run:
+            print(f'junction UPDATE={total["upd"]}, dup DELETE={total["dup"]}, cap DELETE={total["del"]}',
+                  flush=True)
+            if total['skip']:
+                print(f'skipped (missing): {total["skip"]}', flush=True)
+    finally:
+        cur.close()
+        s.close()
+
+
+if __name__ == '__main__':
+    main()

+ 372 - 0
knowhub/scripts/merge_capabilities.py

@@ -0,0 +1,372 @@
+#!/usr/bin/env python3
+"""
+合并 tao_dev_1 版本内的重复 capability,并删除 VCAP 占位条目。
+
+策略:
+  1. 对每个 (canonical, members) 簇:
+     对 5 个 junction (requirement_capability / capability_tool /
+     capability_knowledge / capability_resource / strategy_capability)
+     - 先删除会和 canonical 冲突的 member 引用(避免 PK 冲突)
+     - 再 UPDATE member → canonical
+  2. 删除被合并的 member 行 + VCAP 占位行
+  3. 全程打印进度(flush=True),不管道给 tail
+
+用法:
+  python knowhub/scripts/merge_capabilities.py --dry-run   # 只打印计划
+  python knowhub/scripts/merge_capabilities.py --execute   # 真正执行
+"""
+import argparse
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+from knowhub.knowhub_db.pg_capability_store import PostgreSQLCapabilityStore
+
+
+# canonical_id -> [member_ids to merge into canonical]
+MERGE_CLUSTERS = {
+    # ── Round C (跨版本:v0 foundation 吸收 tao_dev_1 同义条) ────
+    # C1 动画帧/文生视频
+    "CAP-009": ["CAP-a95da8d7"],
+    # C2 图像上色
+    "CAP-007": ["CAP-75ba0eac"],
+    # C3 图像细节增强与高清放大
+    "CAP-006": ["CAP-767e7848"],
+    # C4 草图/3D → 效果图
+    "CAP-019": ["CAP-c7c8479a"],
+    # C5 图像内文字翻译/重写替换
+    "CAP-021": ["CAP-44ada771"],
+    # C6 主体/角色一致性(borderline 合并)
+    "CAP-003": ["CAP-1c71b52e"],
+
+    # ── Round 2 ──────────────────────────────────────────
+    # B1 室内多光源
+    "CAP-5fb6dd66": ["CAP-aa75f198", "CAP-4da21eb2"],
+    # B2 室内色调
+    "CAP-3bf86ae3": ["CAP-20e2e0e6"],
+    # B3 室内自然材质
+    "CAP-56368e3a": ["CAP-e8b46ca2"],
+    # B4 电影级光照
+    "CAP-aaaef688": ["CAP-9658a2d9"],
+    # B5 虚拟相机运动
+    "CAP-49175b92": ["CAP-943f7709"],
+    # B6 结构化 Prompt 框架
+    "CAP-5b000814": ["CAP-f0d52dd1", "CAP-28983111"],
+    # B7 多景别 prompt
+    "CAP-4f15a85f": ["CAP-1da1bf7a", "CAP-0e64f839"],
+    # B8 微距/极端特写
+    "CAP-26100ea8": ["CAP-b93f5cf9", "CAP-2382899f"],
+    # B9 图文卡片自动排版
+    "CAP-87ba3b7d": ["CAP-25c0eef3", "CAP-a648fb4a"],
+    # B10 海报多元素排版
+    "CAP-562d91c1": ["CAP-2df55478", "CAP-cee93f12", "CAP-dba33b2d",
+                     "CAP-02b573f4"],
+    # B11 空间透视景深
+    "CAP-3c49ff0a": ["CAP-edb949a3", "CAP-ee3bac1d", "CAP-19e77a40",
+                     "CAP-eb5e0ad2"],
+    # B12 产品摄影光照
+    "CAP-c2c42fc7": ["CAP-be42acba"],
+    # B13 材质质感
+    "CAP-0dc2a15b": ["CAP-7f63a4f5"],
+    # B14 霓虹/光晕/发光
+    "CAP-a35e7966": ["CAP-ec490469", "CAP-ef8c3e8b", "CAP-9dea4396"],
+    # B15 数据图表生成
+    "CAP-3ee6c232": ["CAP-84a60ca0", "CAP-d02af2bd"],
+    # B16 复古印刷质感
+    "CAP-9359b49f": ["CAP-59414657", "CAP-718daa01"],
+    # B17 超现实空间
+    "CAP-1f898cd9": ["CAP-d1a4709c"],
+    # B18 文字透视融合
+    "CAP-bd4828fc": ["CAP-f3d42bb0"],
+    # B19 拟人化角色
+    "CAP-e962c3ef": ["CAP-93b35d43", "CAP-5c7abd0c"],
+    # B20 镜面水面反射
+    "CAP-3b51102e": ["CAP-bb547523"],
+    # B21 粒子光效
+    "CAP-8467736a": ["CAP-a6c6d4fc"],
+    # B22 自然光时刻
+    "CAP-e8a77f70": ["CAP-7f2d1a72"],
+    # B23 经典布光模式
+    "CAP-ed4b506e": ["CAP-08c54a3c"],
+    # B24 几何蒙版裁剪
+    "CAP-47d6893f": ["CAP-7889940a"],
+    # B25 批量系列海报
+    "CAP-832e80ac": ["CAP-bb063798"],
+    # B26 文生视频通用
+    "CAP-a95da8d7": ["CAP-523c8623"],
+
+    # ── Round 1(已执行完毕,保留以保障脚本幂等)───────
+    # A1 抠图
+    "CAP-12d2aa10": ["CAP-b9c2cafc", "CAP-2a40d757", "CAP-88816f1e",
+                     "CAP-2ba2bc19", "CAP-f0137cfa", "CAP-ccb3a2fd",
+                     "CAP-ad3fd294"],
+    # A2 多图拼贴(后处理)
+    "CAP-41ac8100": ["CAP-17732b2b", "CAP-409eefd9", "CAP-6f791b59",
+                     "CAP-bdc6c7c8", "CAP-c009dcce", "CAP-e7467ebf",
+                     "CAP-462897e2", "CAP-a815960c", "CAP-f3d22954"],
+    # A2b 大图切割
+    "CAP-fddd3349": ["CAP-3cf6ab47", "CAP-336fe318"],
+    # A3 鱼眼/广角畸变
+    "CAP-0e3d61ca": ["CAP-71de6ed3", "CAP-4e9c99fa", "CAP-d49daa5b"],
+    # A4 选择性着色
+    "CAP-3178172e": ["CAP-1dcb6702", "CAP-d18b8a24"],
+    # A5 丁达尔/体积光
+    "CAP-3086677b": ["CAP-c6ee82db", "CAP-f07aa0df", "CAP-81f097e1"],
+    # A6 胶片光学
+    "CAP-6c14041c": ["CAP-2c85b37d", "CAP-8ce1a9c6", "CAP-c9b3c7a5"],
+    # A7 摄影参数
+    "CAP-ef0a4c0c": ["CAP-17ffe5ea", "CAP-84d68e1b", "CAP-074db966"],
+    # A8 戏剧明暗/Chiaroscuro
+    "CAP-1649b549": ["CAP-e06eeb84", "CAP-e707003d", "CAP-aa25bdf9"],
+    # A9 唇形同步
+    "CAP-98490894": ["CAP-6fe5aecb", "CAP-89b9875b", "CAP-ad6d8e0d"],
+    # A10 虚拟试衣/服装迁移
+    "CAP-d92ffc99": ["CAP-1fdb00c2", "CAP-35974014", "CAP-b56d72c7",
+                     "CAP-f697cb22"],
+    # A11 AI 配乐
+    "CAP-5f9644fb": ["CAP-9fe2869e"],
+    # A12 角色多视图(prompt 驱动)
+    "CAP-5342ad19": ["CAP-2aee7861", "CAP-6566ec42", "CAP-faa53945",
+                     "CAP-ea7f3b27", "CAP-9edfec88"],
+    # A12b 参数化多视角(ControlNet/坐标)
+    "CAP-ee7df476": ["CAP-939c3610", "CAP-1374aa64", "CAP-3758e4a5"],
+    # A13 逆光/轮廓光
+    # CAP-47151d87 单独保留(VCAP 会在最后删除)
+    # A14 冷暖对比
+    "CAP-a185d6d2": ["CAP-6994f914", "CAP-a6d5ef60"],
+    # A15 双重曝光
+    "CAP-19e5402a": ["CAP-7841b44d", "CAP-6a7ebaa3"],
+    # A16 对话气泡
+    "CAP-fc2bd5cf": ["CAP-fd64812b"],
+    # A17 文字描边/阴影
+    "CAP-16c5174b": ["CAP-c13f0764", "CAP-e67c259b"],
+    # A18 LLM 提示词扩写
+    "CAP-4d8ba002": ["CAP-dd8c832f", "CAP-cc8d042f", "CAP-eeb71b76"],
+    # A18b LLM 故事脚本
+    "CAP-da51c2ec": ["CAP-da6ef3eb", "CAP-88752108", "CAP-2880810c",
+                     "CAP-3313a654", "CAP-34567777"],
+    # A19 360 全景
+    "CAP-1b3e966f": ["CAP-fd4786c5"],
+    # A20 信息层级排版
+    "CAP-067edd94": ["CAP-c321e41d", "CAP-bb78b124", "CAP-5753e9dd"],
+    # A21 卡片版式
+    "CAP-6e77db54": ["CAP-33a038e1"],
+    # A22 单色调锁定(prompt)
+    "CAP-2bd87e28": ["CAP-8dfe77e3", "CAP-7f123558", "CAP-a3985fda"],
+    # A22b 后处理色调映射
+    "CAP-79590b09": ["CAP-39e1bfda", "CAP-eccd1ce8", "CAP-f08eb0eb",
+                     "CAP-a0e2c93a", "CAP-dd9e6d34"],
+    # A23 低饱和/去色
+    "CAP-298dcb55": ["CAP-a5fb0745"],
+    # A24 高饱和多色并置
+    "CAP-2de278d6": ["CAP-3ba4cb6e", "CAP-76b117fc", "CAP-252b422a"],
+    # A26 极端视角
+    "CAP-0c30af82": ["CAP-fecf1f7d", "CAP-a0800d7d"],
+    # A28 手部修复
+    "CAP-0ba3159e": ["CAP-db60d72e"],
+    # A29 皮肤真实感
+    "CAP-3b0de1ce": ["CAP-ad971785", "CAP-b36560ff"],
+    # A30 AI 一次生成多格图
+    "CAP-306c15fe": ["CAP-033b21b5", "CAP-ae5276f1", "CAP-611ac7bd",
+                     "CAP-e5fbbe2a", "CAP-8e4dbefa", "CAP-a1e8fd4e",
+                     "CAP-56175058", "CAP-5bb77d65", "CAP-e861c293",
+                     "CAP-07a8b9a2"],
+    # A31 多格一致性
+    "CAP-e9b763d2": ["CAP-ddb5e870", "CAP-e1e9a807", "CAP-08b34a6b",
+                     "CAP-020d2c05", "CAP-6fa6bd25", "CAP-38d84d76"],
+    # A32 多情绪表情矩阵
+    "CAP-5a1ac59d": ["CAP-92f70ebf", "CAP-164ecc20"],
+    # A33 配色方案推荐
+    "CAP-689bac61": ["CAP-4bd95998", "CAP-86887ed5", "CAP-d6954059"],
+    # A38 轮播分页
+    "CAP-20409fa6": ["CAP-716dd15a", "CAP-e4a5c708", "CAP-a9d3293f"],
+}
+
+# VCAP 占位 + 空描述(单独删除)
+VCAP_AND_EMPTY = [
+    "CAP-47310932",
+    "CAP-tao_dev_1-00-01", "CAP-tao_dev_1-00-02", "CAP-tao_dev_1-00-03",
+    "CAP-tao_dev_1-00-04", "CAP-tao_dev_1-01-01", "CAP-tao_dev_1-01-02",
+    "CAP-tao_dev_1-01-03", "CAP-tao_dev_1-02-01", "CAP-tao_dev_1-02-02",
+    "CAP-tao_dev_1-02-03", "CAP-tao_dev_1-03-01", "CAP-tao_dev_1-03-02",
+    "CAP-tao_dev_1-03-03", "CAP-tao_dev_1-03-04", "CAP-tao_dev_1-03-05",
+    "CAP-tao_dev_1-03-06", "CAP-tao_dev_1-04-01", "CAP-tao_dev_1-04-02",
+    "CAP-tao_dev_1-04-03", "CAP-tao_dev_1-04-04", "CAP-tao_dev_1-04-05",
+]
+
+JUNCTIONS = [
+    ("requirement_capability", "capability_id"),
+    ("capability_tool", "capability_id"),
+    ("capability_knowledge", "capability_id"),
+    ("capability_resource", "capability_id"),
+    ("strategy_capability", "capability_id"),
+]
+
+
+def sanity_check(cur):
+    """校验所有 canonical/member 都真实存在于 DB。"""
+    all_ids = set(MERGE_CLUSTERS.keys())
+    for members in MERGE_CLUSTERS.values():
+        all_ids.update(members)
+    all_ids.update(VCAP_AND_EMPTY)
+
+    cur.execute("SELECT id FROM capability WHERE id = ANY(%s)", (list(all_ids),))
+    existing = {r['id'] for r in cur.fetchall()}
+    missing = all_ids - existing
+    if missing:
+        print(f"⚠️  以下 ID 在 DB 中不存在(会跳过):{sorted(missing)}", flush=True)
+    return existing
+
+
+def merge_one_cluster(cur, canonical, members, existing, dry_run):
+    """把 members 合并到 canonical。"""
+    members = [m for m in members if m in existing]
+    if not members:
+        print(f"  [skip] {canonical}: 所有 members 都已不存在", flush=True)
+        return 0, 0, 0
+
+    junction_updates = 0
+    junction_dups_removed = 0
+    # 逐个 member 处理 —— 避免 members 之间互相冲突(都指向同一 other_id)
+    for member in members:
+        for table, col in JUNCTIONS:
+            other_col = {
+                "requirement_capability": "requirement_id",
+                "capability_tool": "tool_id",
+                "capability_knowledge": "knowledge_id",
+                "capability_resource": "resource_id",
+                "strategy_capability": "strategy_id",
+            }[table]
+
+            # 删除会和 canonical 当前占用冲突的 member 引用
+            del_sql = (
+                f"DELETE FROM {table} WHERE {col} = %s "
+                f"AND {other_col} IN ("
+                f"  SELECT {other_col} FROM {table} WHERE {col} = %s"
+                f")"
+            )
+            if dry_run:
+                cur.execute(
+                    f"SELECT COUNT(*) AS c FROM {table} WHERE {col} = %s "
+                    f"AND {other_col} IN ("
+                    f"  SELECT {other_col} FROM {table} WHERE {col} = %s)",
+                    (member, canonical),
+                )
+                dup_n = cur.fetchone()['c']
+            else:
+                cur.execute(del_sql, (member, canonical))
+                dup_n = cur.rowcount or 0
+            junction_dups_removed += dup_n
+
+            # UPDATE 剩余 member 引用 → canonical
+            upd_sql = f"UPDATE {table} SET {col} = %s WHERE {col} = %s"
+            if dry_run:
+                cur.execute(
+                    f"SELECT COUNT(*) AS c FROM {table} WHERE {col} = %s",
+                    (member,),
+                )
+                upd_n = cur.fetchone()['c']
+            else:
+                cur.execute(upd_sql, (canonical, member))
+                upd_n = cur.rowcount or 0
+            junction_updates += upd_n
+
+    # 删除 member 行
+    del_caps_sql = "DELETE FROM capability WHERE id = ANY(%s)"
+    if dry_run:
+        n_del = len(members)
+    else:
+        cur.execute(del_caps_sql, (members,))
+        n_del = cur.rowcount or 0
+
+    print(f"  [{'DRY' if dry_run else 'OK '}] {canonical} ← {len(members)} members | "
+          f"junction upd={junction_updates}, dup_removed={junction_dups_removed}, "
+          f"cap_deleted={n_del}",
+          flush=True)
+    return junction_updates, junction_dups_removed, n_del
+
+
+def delete_vcap(cur, ids_to_delete, existing, dry_run):
+    ids = [i for i in ids_to_delete if i in existing]
+    if not ids:
+        print("  [skip] VCAP: 都已不存在", flush=True)
+        return 0
+
+    total_j = 0
+    for table, col in JUNCTIONS:
+        if dry_run:
+            cur.execute(
+                f"SELECT COUNT(*) AS c FROM {table} WHERE {col} = ANY(%s)", (ids,)
+            )
+            n = cur.fetchone()['c']
+        else:
+            cur.execute(f"DELETE FROM {table} WHERE {col} = ANY(%s)", (ids,))
+            n = cur.rowcount or 0
+        total_j += n
+
+    if dry_run:
+        n_cap = len(ids)
+    else:
+        cur.execute("DELETE FROM capability WHERE id = ANY(%s)", (ids,))
+        n_cap = cur.rowcount or 0
+    print(f"  [{'DRY' if dry_run else 'OK '}] VCAP delete: "
+          f"{len(ids)} caps, junction_deleted={total_j}, cap_deleted={n_cap}",
+          flush=True)
+    return n_cap
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    g = ap.add_mutually_exclusive_group(required=True)
+    g.add_argument("--dry-run", action="store_true")
+    g.add_argument("--execute", action="store_true")
+    args = ap.parse_args()
+
+    dry = args.dry_run
+    print(f"\n{'='*60}\n{'DRY RUN' if dry else 'EXECUTE'} — capability 合并\n"
+          f"{'='*60}", flush=True)
+
+    store = PostgreSQLCapabilityStore()
+    cur = store._get_cursor()
+    try:
+        # 前置统计
+        cur.execute("SELECT COUNT(*) AS c FROM capability WHERE version='tao_dev_1'")
+        before = cur.fetchone()['c']
+        print(f"📊 合并前 tao_dev_1 capability 数: {before}\n", flush=True)
+
+        existing = sanity_check(cur)
+        print(f"📋 待合并簇: {len(MERGE_CLUSTERS)},VCAP 待删: "
+              f"{len(VCAP_AND_EMPTY)}\n", flush=True)
+
+        print("── 阶段 1: 簇内合并 ─────────────", flush=True)
+        tot_upd = tot_dup = tot_del = 0
+        for idx, (canonical, members) in enumerate(MERGE_CLUSTERS.items(), 1):
+            print(f"\n[{idx}/{len(MERGE_CLUSTERS)}] canonical={canonical}", flush=True)
+            u, d, dl = merge_one_cluster(cur, canonical, members, existing, dry)
+            tot_upd += u
+            tot_dup += d
+            tot_del += dl
+
+        print("\n── 阶段 2: 删除 VCAP + 空数据 ─────", flush=True)
+        n_vcap = delete_vcap(cur, VCAP_AND_EMPTY, existing, dry)
+
+        # 事后统计
+        cur.execute("SELECT COUNT(*) AS c FROM capability WHERE version='tao_dev_1'")
+        after = cur.fetchone()['c']
+        print(f"\n{'='*60}\n汇总:", flush=True)
+        print(f"  junction UPDATE: {tot_upd}", flush=True)
+        print(f"  junction 去重 DELETE: {tot_dup}", flush=True)
+        print(f"  capability 簇合并删除: {tot_del}", flush=True)
+        print(f"  VCAP 删除: {n_vcap}", flush=True)
+        print(f"  合并前: {before}  合并{'后(预测)' if dry else '后'}: {after}",
+              flush=True)
+        if dry:
+            print(f"  预期合并后: {before - tot_del - n_vcap}", flush=True)
+        print(f"{'='*60}\n", flush=True)
+    finally:
+        cur.close()
+        store.close()
+
+
+if __name__ == "__main__":
+    main()

+ 600 - 0
knowhub/scripts/rebuild_howard_dedup.py

@@ -0,0 +1,600 @@
+#!/usr/bin/env python3
+"""
+一次性修复脚本:重建 howard_dedup 版本的 capability / strategy / resource
+
+背景:同事的 agent 失控污染了 tao_dev_1 数据。
+这个脚本:
+  1. 备份当前 DB 状态到 /tmp/knowhub_backup_<date>/
+  2. 用 /tmp/capabilities_all.md(465 快照)+ MERGE_CLUSTERS 构建别名表
+  3. Purge 当前 capability + strategy + resource + junctions(所有版本)
+  4. 从 output 2/ 的 99 folder 重新 ingest,version='howard_dedup'
+  5. 应用 RENAMES 保留改名工作
+
+不动的表:requirement、knowledge 及其 junction。
+
+用法:
+  python knowhub/scripts/rebuild_howard_dedup.py --backup-only
+  python knowhub/scripts/rebuild_howard_dedup.py --dry-run
+  python knowhub/scripts/rebuild_howard_dedup.py --execute
+"""
+import argparse
+import hashlib
+import json
+import os
+import re
+import sys
+import time
+from datetime import date
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+from knowhub.knowhub_db.pg_capability_store import PostgreSQLCapabilityStore
+from knowhub.knowhub_db.pg_resource_store import PostgreSQLResourceStore
+from knowhub.knowhub_db.pg_requirement_store import PostgreSQLRequirementStore
+from knowhub.knowhub_db.pg_strategy_store import PostgreSQLStrategyStore
+from knowhub.scripts.merge_capabilities import MERGE_CLUSTERS
+from knowhub.scripts.rename_merged_capabilities import RENAMES
+
+OUTPUT_DIR = Path('/Users/sunlit/Downloads/output 2')
+SNAPSHOT_PATH = Path('/tmp/capabilities_all.md')
+BACKUP_DIR = Path(f'/tmp/knowhub_backup_{date.today().isoformat()}')
+DEDUP_VERSION = 'howard_dedup'
+
+# CAP-006 在同事操作后丢失,从 conversation 缓存中重建
+CAP_006 = {
+    'id': 'CAP-006',
+    'name': '图像细节增强与高清放大',
+    'description': '对已生成的图像进行分辨率提升和细节增强,在放大的同时补充高频细节(后处理路径,区别于生成阶段直接高清输出的 CAP-016)',
+    'criterion': '',
+}
+
+
+def norm(s):
+    return s.strip().lower() if s else ''
+
+
+def hash8(text):
+    return hashlib.sha256(text.encode('utf-8')).hexdigest()[:8]
+
+
+def hash12(text):
+    return hashlib.sha256(text.encode('utf-8')).hexdigest()[:12]
+
+
+def gen_cap_id(name):
+    return f'CAP-{hash8(norm(name))}'
+
+
+def gen_resource_id(platform, url):
+    p = (platform or 'unknown').lower().strip()
+    return f'resource/research/{p}/{hash12(url)}'
+
+
+def gen_strategy_id(req_text, strategy_name):
+    return f'strategy-{hash8((req_text or "") + "|" + (strategy_name or ""))}'
+
+
+# ═══════════════════════════════════════════════════════════
+def parse_snapshot(path):
+    """Parse /tmp/capabilities_all.md -> {id: name}."""
+    if not path.exists():
+        print(f'⚠️  snapshot file missing: {path}', flush=True)
+        return {}
+    content = path.read_text(encoding='utf-8')
+    pat = re.compile(r'^## (.+?)\n\*\*(CAP-[\w\-]+)\*\*', re.MULTILINE)
+    return {cid: name.strip() for name, cid in pat.findall(content)}
+
+
+def build_alias_map(snapshot, current_caps):
+    """Build normalized_name -> final canonical_id (transitively resolved)."""
+    # Step A: build member→canonical map; resolve transitively (A→B→C → A,B→C)
+    member_to_canonical = {}
+    for canonical, members in MERGE_CLUSTERS.items():
+        for m in members:
+            member_to_canonical[m] = canonical
+
+    def final(cid, limit=10):
+        seen = set()
+        while cid in member_to_canonical and cid not in seen and limit > 0:
+            seen.add(cid)
+            cid = member_to_canonical[cid]
+            limit -= 1
+        return cid
+
+    # Resolve: every member maps to final canonical
+    for m in list(member_to_canonical.keys()):
+        member_to_canonical[m] = final(m)
+
+    alias = {}
+
+    # 1. All snapshot names → final canonical (member) or self (non-member)
+    for cid, name in snapshot.items():
+        alias[norm(name)] = member_to_canonical.get(cid, cid)
+
+    # 2. Canonical names from current DB (post-rename) → final canonical
+    for canonical in MERGE_CLUSTERS.keys():
+        final_id = final(canonical)
+        if canonical in current_caps:
+            alias[norm(current_caps[canonical]['name'])] = final_id
+
+    # 3. RENAMES: new name → same canonical (final-resolved)
+    for cid, (new_name, _) in RENAMES.items():
+        alias[norm(new_name)] = final(cid)
+
+    # 4. v0 foundation CAP-001..021 + CAP-006 reconstruction → self
+    for cid, cap in current_caps.items():
+        if cid.startswith('CAP-') and len(cid) == 7 and cid[4:].isdigit():
+            alias[norm(cap['name'])] = cid
+    alias[norm(CAP_006['name'])] = CAP_006['id']
+
+    # 5. Any remaining current DB capability (non-VCAP) → self
+    for cid, cap in current_caps.items():
+        if cid.startswith('CAP-tao_dev_'):
+            continue
+        alias.setdefault(norm(cap['name']), cid)
+
+    return alias
+
+
+# ═══════════════════════════════════════════════════════════
+# PHASE 0: BACKUP
+def backup(stores):
+    BACKUP_DIR.mkdir(parents=True, exist_ok=True)
+    print(f'📦 Backing up to {BACKUP_DIR}/', flush=True)
+    tables = [
+        'capability', 'strategy', 'resource', 'requirement', 'knowledge',
+        'requirement_capability', 'capability_tool', 'capability_knowledge',
+        'capability_resource', 'strategy_capability', 'strategy_resource',
+        'strategy_knowledge', 'requirement_strategy', 'requirement_resource',
+    ]
+    cur = stores['cap']._get_cursor()
+    try:
+        for t in tables:
+            try:
+                cur.execute(f'SELECT * FROM {t}')
+                rows = [dict(r) for r in cur.fetchall()]
+                # strip embedding (too big, not essential for restore)
+                for r in rows:
+                    r.pop('embedding', None)
+                (BACKUP_DIR / f'{t}.json').write_text(
+                    json.dumps(rows, default=str, ensure_ascii=False))
+                print(f'  ✓ {t}: {len(rows)} rows', flush=True)
+            except Exception as e:
+                print(f'  ❌ {t}: {e}', flush=True)
+    finally:
+        cur.close()
+
+
+# PHASE 1: PURGE
+def purge(stores):
+    print('\n🧹 Purging capability / strategy / resource (all versions) + junctions...', flush=True)
+    cur = stores['cap']._get_cursor()
+    try:
+        # junctions first (no FK but keep clean)
+        for t in ['requirement_capability', 'capability_tool', 'capability_knowledge',
+                  'capability_resource', 'strategy_capability', 'strategy_resource',
+                  'strategy_knowledge', 'requirement_strategy', 'requirement_resource']:
+            cur.execute(f'DELETE FROM {t}')
+            print(f'  ✓ {t} cleared', flush=True)
+        for t in ['capability', 'strategy', 'resource']:
+            cur.execute(f'DELETE FROM {t}')
+            print(f'  ✓ {t} cleared', flush=True)
+    finally:
+        cur.close()
+
+
+# PHASE 2: SEED
+def seed(stores, current_caps, snapshot):
+    """Insert:
+      - 21 v0 foundation caps (CAP-001..021) in howard_dedup
+      - CAP-006 reconstructed if missing
+      - All surviving non-VCAP tao_dev_1 canonicals (preserves R1/R2/C renames)
+      - Missing canonicals (in MERGE_CLUSTERS.keys but gone) recovered from snapshot
+    """
+    print('\n🌱 Seeding howard_dedup...', flush=True)
+    cur = stores['cap']._get_cursor()
+    try:
+        inserted = 0
+        # 1. Insert from current_caps: everything non-VCAP gets version=howard_dedup
+        for cap_id, cap in current_caps.items():
+            if cap_id.startswith('CAP-tao_dev_'):
+                continue  # skip VCAP
+            cur.execute(
+                """INSERT INTO capability (id, name, criterion, description, effects, version)
+                   VALUES (%s, %s, %s, %s, %s, %s)""",
+                (cap_id, cap.get('name', ''), cap.get('criterion', '') or '',
+                 cap.get('description', '') or '',
+                 json.dumps(cap.get('effects', []) or [], ensure_ascii=False, default=str),
+                 DEDUP_VERSION))
+            inserted += 1
+
+        # 2. CAP-006 reconstruct if missing
+        if 'CAP-006' not in current_caps:
+            cur.execute(
+                """INSERT INTO capability (id, name, criterion, description, effects, version)
+                   VALUES (%s, %s, %s, %s, %s, %s)""",
+                (CAP_006['id'], CAP_006['name'], CAP_006['criterion'],
+                 CAP_006['description'], '[]', DEDUP_VERSION))
+            inserted += 1
+            print(f'  ✓ CAP-006 reconstructed', flush=True)
+
+        # 3. Missing canonicals (in MERGE_CLUSTERS but not in DB) recovered from snapshot
+        merged_members = set()
+        for members in MERGE_CLUSTERS.values():
+            merged_members.update(members)
+        recovered = 0
+        for cid in MERGE_CLUSTERS.keys():
+            if cid in current_caps or cid in merged_members:
+                continue
+            # Not in DB, not a member — need to recover
+            name = snapshot.get(cid)
+            if not name:
+                continue
+            # Use RENAMES version if available
+            if cid in RENAMES:
+                name, desc = RENAMES[cid]
+            else:
+                desc = ''
+            cur.execute(
+                """INSERT INTO capability (id, name, criterion, description, effects, version)
+                   VALUES (%s, %s, %s, %s, %s, %s)""",
+                (cid, name, '', desc, '[]', DEDUP_VERSION))
+            recovered += 1
+        print(f'  seeded: {inserted} existing + {recovered} recovered from snapshot', flush=True)
+    finally:
+        cur.close()
+
+
+# PHASE 3: INGEST from output 2/
+def ingest_folder(folder, stores, alias, cur, stats):
+    folder_key = folder.name
+    # blueprint → requirement match
+    bp_path = folder / 'blueprint.json'
+    if not bp_path.exists():
+        stats['bad_folders'].append(folder_key + ':no_blueprint')
+        return
+    try:
+        bp = json.loads(bp_path.read_text(encoding='utf-8'))
+    except Exception as e:
+        stats['bad_folders'].append(folder_key + f':bp_parse_err({e})')
+        return
+    req_text = bp.get('requirement', '')
+    if not req_text:
+        stats['bad_folders'].append(folder_key + ':empty_req')
+        return
+
+    cur.execute('SELECT id FROM requirement WHERE description = %s LIMIT 1', (req_text,))
+    row = cur.fetchone()
+    if not row:
+        # try fuzzier: first 80 chars prefix
+        cur.execute('SELECT id FROM requirement WHERE description LIKE %s LIMIT 1',
+                    (req_text[:80].replace('%', '\\%') + '%',))
+        row = cur.fetchone()
+    if not row:
+        print(f'  ⚠️  no matching requirement: {req_text[:60]}', flush=True)
+        stats['missing_req'].append(folder_key)
+        return
+    req_id = row['id']
+
+    # resources from raw_cases/
+    raw_dir = folder / 'raw_cases'
+    resource_ids = []
+    if raw_dir.exists():
+        for cf in sorted(raw_dir.glob('*.json')):
+            try:
+                data = json.loads(cf.read_text(encoding='utf-8'))
+            except Exception:
+                continue
+            cases = data.get('cases', []) if isinstance(data, dict) else data
+            if not isinstance(cases, list):
+                continue
+            for case in cases:
+                if not isinstance(case, dict):
+                    continue
+                url = case.get('source_url') or case.get('url')
+                if not url:
+                    continue
+                platform = case.get('platform') or cf.stem.replace('case_', '')
+                rid = gen_resource_id(platform, url)
+                title = (case.get('title') or '')[:200]
+                metrics = case.get('metrics') if isinstance(case.get('metrics'), dict) else {}
+                likes = (metrics.get('likes') or 0) if metrics else 0
+
+                cur.execute('DELETE FROM resource WHERE id = %s', (rid,))
+                cur.execute(
+                    """INSERT INTO resource (id, title, body, content_type, images, metadata, sort_order, version)
+                       VALUES (%s, %s, %s, %s, %s, %s, %s, %s)""",
+                    (rid, title,
+                     json.dumps(case, ensure_ascii=False)[:8000],
+                     'research_case',
+                     json.dumps(case.get('images', []) or [], ensure_ascii=False),
+                     json.dumps({'platform': platform, 'source_url': url,
+                                 'metrics': metrics, 'folder': folder_key},
+                                ensure_ascii=False),
+                     -int(likes), DEDUP_VERSION))
+                resource_ids.append(rid)
+                stats['resource'] += 1
+
+    # capabilities
+    caps_path = folder / 'capabilities_extracted.json'
+    cap_resolved = {}  # source_key -> resolved_id
+    if caps_path.exists():
+        try:
+            caps_data = json.loads(caps_path.read_text(encoding='utf-8'))
+        except Exception as e:
+            stats['bad_folders'].append(folder_key + f':caps_parse_err({e})')
+            caps_data = {'extracted_capabilities': []}
+
+        for cap in caps_data.get('extracted_capabilities', []):
+            name = (cap.get('name') or '').strip()
+            if not name:
+                continue
+            src_id = cap.get('id')
+            resolved = None
+
+            # (1) source id exists in DB?
+            if src_id:
+                cur.execute('SELECT 1 FROM capability WHERE id = %s', (src_id,))
+                if cur.fetchone():
+                    resolved = src_id
+            # (2) alias by name?
+            if not resolved:
+                cand = alias.get(norm(name))
+                if cand:
+                    cur.execute('SELECT 1 FROM capability WHERE id = %s', (cand,))
+                    if cur.fetchone():
+                        resolved = cand
+            # (3) create new with hash ID
+            if not resolved:
+                new_id = gen_cap_id(name)
+                cur.execute('SELECT 1 FROM capability WHERE id = %s', (new_id,))
+                if not cur.fetchone():
+                    cur.execute(
+                        """INSERT INTO capability (id, name, criterion, description, effects, version)
+                           VALUES (%s, %s, %s, %s, %s, %s)""",
+                        (new_id, name, cap.get('criterion', '') or '',
+                         cap.get('description', '') or '',
+                         json.dumps(cap.get('effects', []) or [], ensure_ascii=False, default=str),
+                         DEDUP_VERSION))
+                    alias[norm(name)] = new_id
+                    stats['capability_new'] += 1
+                resolved = new_id
+            else:
+                # backfill criterion/effects if missing
+                cur.execute('SELECT criterion, effects FROM capability WHERE id = %s', (resolved,))
+                ex = cur.fetchone()
+                if ex:
+                    if (not (ex.get('criterion') or '').strip()) and cap.get('criterion'):
+                        cur.execute('UPDATE capability SET criterion = %s WHERE id = %s',
+                                    (cap['criterion'], resolved))
+                        stats['criterion_backfilled'] += 1
+                    cur_eff = ex.get('effects')
+                    if (not cur_eff or cur_eff in ([], '[]')) and cap.get('effects'):
+                        cur.execute('UPDATE capability SET effects = %s WHERE id = %s',
+                                    (json.dumps(cap['effects'], ensure_ascii=False, default=str), resolved))
+                        stats['effects_backfilled'] += 1
+                stats['capability_linked'] += 1
+            cap_resolved[src_id or name] = resolved
+
+    # strategy (is_selected only)
+    strat_path = folder / 'strategy.json'
+    if not strat_path.exists():
+        return
+    try:
+        strat_data = json.loads(strat_path.read_text(encoding='utf-8'))
+    except Exception as e:
+        stats['bad_folders'].append(folder_key + f':strat_parse_err({e})')
+        return
+
+    selected = next((s for s in strat_data.get('strategies', []) if s.get('is_selected')), None)
+    if not selected:
+        sel_list = strat_data.get('strategies', [])
+        selected = sel_list[0] if sel_list else None
+    if not selected:
+        return
+
+    strategy_name = selected.get('name') or f'Strategy-{folder_key}'
+    strat_id = gen_strategy_id(req_text, strategy_name)
+    now = int(time.time())
+    cur.execute('DELETE FROM strategy WHERE id = %s', (strat_id,))
+    cur.execute(
+        """INSERT INTO strategy (id, name, description, body, status, created_at, updated_at, version)
+           VALUES (%s, %s, %s, %s, %s, %s, %s, %s)""",
+        (strat_id, strategy_name, (selected.get('reasoning') or '')[:2000],
+         json.dumps(selected, ensure_ascii=False, indent=2),
+         'draft', now, now, DEDUP_VERSION))
+    stats['strategy'] += 1
+
+    # wire junctions
+    for rid in resource_ids:
+        cur.execute("""INSERT INTO requirement_resource (requirement_id, resource_id)
+                       VALUES (%s, %s) ON CONFLICT DO NOTHING""", (req_id, rid))
+        cur.execute("""INSERT INTO strategy_resource (strategy_id, resource_id)
+                       VALUES (%s, %s) ON CONFLICT DO NOTHING""", (strat_id, rid))
+    cur.execute("""INSERT INTO requirement_strategy (requirement_id, strategy_id)
+                   VALUES (%s, %s) ON CONFLICT DO NOTHING""", (req_id, strat_id))
+
+    strat_cap_ids = set()
+    wo = selected.get('workflow_outline') or []
+    if isinstance(wo, list):
+        for phase in wo:
+            if not isinstance(phase, dict):
+                continue
+            caps = phase.get('capabilities') or []
+            if not isinstance(caps, list):
+                continue
+            for c_ref in caps:
+                if not isinstance(c_ref, dict):
+                    continue
+                key = c_ref.get('id') or c_ref.get('name', '')
+                resolved = cap_resolved.get(key) or alias.get(norm(c_ref.get('name', '')))
+                if resolved:
+                    strat_cap_ids.add(resolved)
+    for cid in strat_cap_ids:
+        cur.execute("""INSERT INTO strategy_capability (strategy_id, capability_id, relation_type)
+                       VALUES (%s, %s, 'compose') ON CONFLICT DO NOTHING""",
+                    (strat_id, cid))
+        cur.execute("""INSERT INTO requirement_capability (requirement_id, capability_id)
+                       VALUES (%s, %s) ON CONFLICT DO NOTHING""",
+                    (req_id, cid))
+    stats['folder_cap_count'] = len(strat_cap_ids)
+    stats['folder_res_count'] = len(resource_ids)
+
+
+# PHASE 4: apply renames (defensive — some may have already been applied during seeding)
+def apply_renames(stores):
+    print('\n📝 Applying RENAMES...', flush=True)
+    cur = stores['cap']._get_cursor()
+    try:
+        applied = 0
+        for cid, (name, desc) in RENAMES.items():
+            cur.execute('UPDATE capability SET name = %s, description = %s WHERE id = %s',
+                        (name, desc, cid))
+            if (cur.rowcount or 0) > 0:
+                applied += 1
+        print(f'  applied {applied}/{len(RENAMES)}', flush=True)
+    finally:
+        cur.close()
+
+
+# ═══════════════════════════════════════════════════════════
+def main():
+    ap = argparse.ArgumentParser()
+    g = ap.add_mutually_exclusive_group(required=True)
+    g.add_argument('--backup-only', action='store_true')
+    g.add_argument('--dry-run', action='store_true')
+    g.add_argument('--execute', action='store_true')
+    ap.add_argument('--skip-backup', action='store_true', help='Skip backup (if already done)')
+    args = ap.parse_args()
+
+    print(f'\n{"="*60}', flush=True)
+    print(f'{"BACKUP" if args.backup_only else "DRY RUN" if args.dry_run else "EXECUTE"} '
+          f'— rebuild howard_dedup', flush=True)
+    print(f'{"="*60}', flush=True)
+
+    stores = {
+        'cap': PostgreSQLCapabilityStore(),
+        'res': PostgreSQLResourceStore(),
+        'req': PostgreSQLRequirementStore(),
+        'strat': PostgreSQLStrategyStore(),
+    }
+
+    try:
+        if not args.skip_backup:
+            backup(stores)
+        if args.backup_only:
+            print('\n✅ Backup only. Exit.', flush=True)
+            return
+
+        # dump current caps (used for seed)
+        cur = stores['cap']._get_cursor()
+        cur.execute('SELECT id, name, criterion, description, effects, version FROM capability')
+        current_caps = {r['id']: dict(r) for r in cur.fetchall()}
+        cur.close()
+
+        snapshot = parse_snapshot(SNAPSHOT_PATH)
+        alias = build_alias_map(snapshot, current_caps)
+        (BACKUP_DIR / 'alias_map.json').write_text(
+            json.dumps(alias, ensure_ascii=False, indent=2))
+        print(f'\n🔗 Alias map: {len(alias)} entries (snapshot={len(snapshot)}, '
+              f'current_caps={len(current_caps)})', flush=True)
+
+        if args.dry_run:
+            print('\n[DRY-RUN] Simulating folder 001 ingest...', flush=True)
+            f1 = OUTPUT_DIR / '001'
+            if f1.exists():
+                caps = json.loads((f1 / 'capabilities_extracted.json').read_text())
+                new = linked = 0
+                for c in caps.get('extracted_capabilities', [])[:15]:
+                    name = (c.get('name') or '').strip()
+                    src = c.get('id')
+                    cand = alias.get(norm(name))
+                    status = 'LINK' if cand else 'NEW'
+                    if cand: linked += 1
+                    else: new += 1
+                    print(f'  [{status}] src_id={src!r} → {cand!r} | {name[:50]}', flush=True)
+                print(f'\n  folder 001 sample (first 15): link={linked} new={new}', flush=True)
+            print('\n[DRY-RUN] Skipping purge + ingest. Use --execute to run.', flush=True)
+            return
+
+        # Skip purge+seed if howard_dedup already has canonical seeds
+        # (enables resume after connection drop)
+        cur = stores['cap']._get_cursor()
+        cur.execute("SELECT COUNT(*) AS c FROM capability WHERE version = %s",
+                    (DEDUP_VERSION,))
+        hd_count = cur.fetchone()['c']
+        cur.close()
+        if hd_count > 100:
+            print(f'\n⚠️  howard_dedup already has {hd_count} caps — skipping purge+seed (resume mode)',
+                  flush=True)
+        else:
+            purge(stores)
+            seed(stores, current_caps, snapshot)
+
+        # Ingest
+        print('\n📂 Ingesting output 2/ ...', flush=True)
+        stats = {'folder_processed': 0, 'bad_folders': [], 'missing_req': [],
+                 'resource': 0, 'capability_new': 0, 'capability_linked': 0,
+                 'strategy': 0, 'criterion_backfilled': 0, 'effects_backfilled': 0,
+                 'folder_cap_count': 0, 'folder_res_count': 0}
+        folders = sorted([d for d in OUTPUT_DIR.iterdir() if d.is_dir()])
+        cur = stores['cap']._get_cursor()
+        try:
+            for idx, folder in enumerate(folders, 1):
+                before_cap_new = stats['capability_new']
+                before_cap_link = stats['capability_linked']
+                before_res = stats['resource']
+                before_strat = stats['strategy']
+                stats['folder_cap_count'] = 0
+                stats['folder_res_count'] = 0
+                try:
+                    ingest_folder(folder, stores, alias, cur, stats)
+                    stats['folder_processed'] += 1
+                    d_new = stats['capability_new'] - before_cap_new
+                    d_link = stats['capability_linked'] - before_cap_link
+                    d_res = stats['resource'] - before_res
+                    d_strat = stats['strategy'] - before_strat
+                    print(f"[{idx:3d}/{len(folders)}] 📁 {folder.name}/ "
+                          f"cap:link={d_link} new={d_new}  res={d_res}  strat={d_strat}  "
+                          f"strat_caps={stats['folder_cap_count']}",
+                          flush=True)
+                except Exception as e:
+                    print(f'[{idx:3d}/{len(folders)}] 📁 {folder.name}/  ❌ {type(e).__name__}: {e}',
+                          flush=True)
+                    stats['bad_folders'].append(folder.name + f':{type(e).__name__}')
+                    # reconnect cursor if connection dropped
+                    try:
+                        cur.close()
+                    except Exception:
+                        pass
+                    cur = stores['cap']._get_cursor()
+        finally:
+            try:
+                cur.close()
+            except Exception:
+                pass
+
+        apply_renames(stores)
+
+        # Verify
+        print(f'\n{"="*60}\n验证:', flush=True)
+        cur = stores['cap']._get_cursor()
+        for tbl in ['capability', 'strategy', 'resource', 'requirement']:
+            cur.execute(f"SELECT version, COUNT(*) AS c FROM {tbl} GROUP BY version")
+            for r in cur.fetchall():
+                print(f"  {tbl} / {r['version']}: {r['c']}", flush=True)
+        cur.close()
+        print(f'\n📊 Stats:', flush=True)
+        for k, v in stats.items():
+            if isinstance(v, list):
+                print(f'  {k}: {len(v)} {v[:5]}{"..." if len(v)>5 else ""}', flush=True)
+            else:
+                print(f'  {k}: {v}', flush=True)
+        print(f'{"="*60}', flush=True)
+    finally:
+        for s in stores.values():
+            s.close()
+
+
+if __name__ == '__main__':
+    main()

+ 315 - 0
knowhub/scripts/rename_merged_capabilities.py

@@ -0,0 +1,315 @@
+#!/usr/bin/env python3
+"""
+对合并后的 canonical capability 更新 name + description,
+使其覆盖所有被合并成员的语义范围。
+
+不动 embedding / criterion / effects(按约定)。
+
+用法:
+  python knowhub/scripts/rename_merged_capabilities.py --dry-run
+  python knowhub/scripts/rename_merged_capabilities.py --execute
+"""
+import argparse
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+from knowhub.knowhub_db.pg_capability_store import PostgreSQLCapabilityStore
+
+
+# canonical_id -> (new_name, new_description)
+# 只列需要改的(26 条);其余保持原状
+RENAMES = {
+    # [1] 抠图:原 desc 只提"食材/产品",泛化为通用主体 + 毛发边缘
+    "CAP-12d2aa10": (
+        "AI 智能主体抠图与背景分离",
+        "利用 AI 语义分割模型自动识别图像中的主体(人物、动物、食材、产品、物体等),"
+        "精准将其从原始背景中分离,输出带透明通道(Alpha)的 PNG。支持毛发、发丝等复杂边缘的精细处理,"
+        "无需手动绘制蒙版或逐像素操作,供后续叠加合成、背景替换或拼贴使用。"
+    ),
+    # [3] 含"切割"和"无缝拼接/轮播切分"两条路径
+    "CAP-fddd3349": (
+        "网格/全景大图自动切割为独立子图",
+        "将包含多个画面单元的网格大图(3×3 九宫格、4×4 十六格等)或宽幅全景图,"
+        "按网格坐标或等宽比例自动切割为多张尺寸一致、边缘整齐的子图,"
+        "可按顺序单独发布到社交媒体(Instagram 轮播、无缝横幅等)或用于后续处理,无需二次裁剪。"
+    ),
+    # [7] 扩展到色彩偏移 + 色调风格(8ce1a9c6 / c9b3c7a5)
+    "CAP-6c14041c": (
+        "胶片与镜头光学质感模拟(柔焦/散景/颗粒/暗角/色调/光晕)",
+        "通过在提示词中注入胶片摄影的光学特征与色调关键词(柔焦、散景、胶片颗粒、暗角、辉光/Halation、"
+        "Kodak Portra 400 暖调、复古胶片颗粒、镜头光晕 Lens Flare、镜头绽放 Lens Bloom),"
+        "模拟真实胶片相机的光学成像与色调特性,使生成图像呈现朦胧、疏离、梦幻的胶片质感氛围。"
+    ),
+    # [8] 扩展到相机位置(高度/倾斜/旋转/视野)
+    "CAP-ef0a4c0c": (
+        "摄影级镜头与相机参数模拟",
+        "在 AI 图像生成提示词中嵌入专业摄影参数(焦距 mm、光圈 f/值、ISO、快门速度、色温 K 值、画幅格式、"
+        "传感器尺寸、白平衡)以及相机位置与姿态(高度、倾斜、旋转、视野范围),"
+        "使生成图像模拟特定摄影设备和拍摄参数下的真实光学效果(景深虚化、色温偏移、镜头畸变),"
+        "并在多次生成中保持相机参数一致。"
+    ),
+    # [9] 扩展光源方向控制
+    "CAP-1649b549": (
+        "戏剧性明暗对比与光源方向控制(Chiaroscuro / Low-Key)",
+        "通过提示词精准控制画面的明暗分布比例与光源方向(侧光、逆光、硬光、背光),"
+        "实现暗部极深(接近纯黑)、亮部极亮(接近纯白)的戏剧性反差,"
+        "模拟卡拉瓦乔式明暗对照法(Chiaroscuro/Tenebrism)、低调光(Low-Key)或暗调长调配色的视觉风格,"
+        "赋予画面强烈情绪张力和电影感。"
+    ),
+    # [11] 扩展到服装+配饰+保持外观
+    "CAP-d92ffc99": (
+        "AI 虚拟换装与服装配饰迁移",
+        "以参考服装/配饰图像为输入,将指定单品(服装、帽子、眼镜、假发、项链等)自动迁移并穿戴到目标人物或角色上,"
+        "保持服装款式/颜色/纹理/图案细节不变,同时与主体体型、姿态和场景光照自然融合。"
+        "支持平铺图到模特上身转换、保持角色面部外观不变的跨造型换装,适用于电商展示、虚拟试衣。"
+    ),
+    # [13] 加主体库复用
+    "CAP-5342ad19": (
+        "角色设定卡 / 多视图参考表生成",
+        "基于文字描述或单张参考图,一次性生成同一角色的多角度视图(正面/侧面/背面全身)、"
+        "多表情特写以及服装/配饰细节,形成标准化的角色参考表(Character Sheet)或多视图主体库,"
+        "作为后续跨场景、跨工具的角色一致性生成的视觉锚点,支持复用。"
+    ),
+    # [14] 泛化到 ControlNet / 单图多视角
+    "CAP-ee7df476": (
+        "参数化/ControlNet 驱动的多视角图像生成",
+        "以单张参考图为输入,通过参数化控制(方位角 Azimuth、仰角 Elevation、距离 Distance)"
+        "或 LoRA 坐标系、ControlNet、深度估计等技术,精确控制视角旋转,"
+        "生成同一主体在 360° 任意水平/垂直方向上的视角变体,不依赖提示词抽卡。"
+    ),
+    # [16] 扩展到人像景观融合
+    "CAP-19e5402a": (
+        "双重曝光剪影叙事构图生成",
+        "以人物或物体的剪影轮廓为外框/容器,在轮廓内部填充与主题绑定的完整世界观场景"
+        "(宇宙星云、山地景观、粒子场、自然景致、微型世界),"
+        "结合空气透视、水彩刷痕、边缘飞白、雾气/粒子/光晕自然过渡等视觉语言,"
+        "生成具有电影海报质感的双重曝光叙事插画,人像与景观相互渗透。"
+    ),
+    # [19] 扩展到反推路径
+    "CAP-4d8ba002": (
+        "LLM 图像提示词双向转化(扩写 + 反推)",
+        "以大语言模型(ChatGPT/Claude/Gemini/DeepSeek/GPT-4V)为中间层,"
+        "**扩写**方向:将用户简短关键词或模糊创意意图自动扩展为结构化、高质量的图像生成提示词;"
+        "**反推**方向:对参考图像进行语义分析,自动提取色彩、构图、风格、镜头、布光、材质等维度的结构化提示词;"
+        "实现从语义意图到可执行提示词、从视觉参考到文字描述的双向自动转化。"
+    ),
+    # [20] 扩展到故事文案/漫画/叙事排序
+    "CAP-da51c2ec": (
+        "AI 脚本/分镜/故事文案与叙事顺序生成",
+        "根据用户输入的主题、关键词、拟人化角色或故事梗概,由 LLM 自动生成结构完整的脚本与分镜文案,"
+        "涵盖动画脚本、短视频分镜、漫画分镜、情感叙事故事、绘本故事文案、剧本分页文字等多种载体。"
+        "输出内容包含每格画面的场景描述、情绪标注、镜头语言、对白/旁白、叙事逻辑(起承转合)与叙事顺序规划,"
+        "为后续图像/视频生成提供结构化提示词输入。"
+    ),
+    # [24] 扩展到主题色系 + HEX 约束
+    "CAP-2bd87e28": (
+        "主题色调系统约束生成(prompt 驱动)",
+        "通过在提示词中指定主导色调(单一色相锁定:深蓝、赤红、暖色、金色、冷色)或完整的主题色彩系统"
+        "(主色 + 辅助色 + 强调色,HEX/HSL 参数),"
+        "配合光源描述、饱和度和背景处理关键词,使生成图像的整体色彩严格围绕指定方案,"
+        "形成沉浸式的单色调氛围或系统一致的品牌色彩表达。"
+    ),
+    # [27] 扩展到互补色/对比色/双色对撞
+    "CAP-2de278d6": (
+        "高饱和多色并置与互补/对比色张力控制",
+        "在图像生成过程中,通过精确的色彩参数化控制(多高饱和度色值、色相对比关系、色块分布比例),"
+        "指定互补色对(complementary color pairs)、高对比色组合或双色对撞分区结构(对角分屏、红蓝对立),"
+        "使画面呈现最大色彩纯度与最大色相张力,产生迷幻、震撼、多巴胺式的视觉冲击。"
+    ),
+    # [30] 具体化微纹理 + 次表面散射
+    "CAP-3b0de1ce": (
+        "超写实人像去 AI 感(皮肤微纹理 + 次表面散射 + 光线结构化)",
+        "通过将皮肤视为物理材质而非美化效果、将光线视为空间结构而非氛围装饰,"
+        "在提示词中明确加入皮肤微纹理描述(毛孔、绒毛、细纹、雀斑、轻微不对称、油性高光、微瑕疵)"
+        "与次表面散射(Subsurface Scattering)参数,并通过专项皮肤增强工具进一步细化,"
+        "从根本上消除 AI 生成人像的过度光滑、油腻、塑料感,使生成结果达到真实摄影级自然质感。"
+    ),
+    # [31] 重大扩展:加叙事分镜/漫画/表情矩阵
+    "CAP-306c15fe": (
+        "AI 一次生成多格网格图(姿态/分镜/漫画/表情矩阵)",
+        "通过结构化提示词(JSON 格式或自然语言,指定 layout/subject/expressions/lighting/color_tone 等字段),"
+        "驱动 AI 模型一次性生成包含多个分区的完整网格大图。支持多种用途:同一主体的多姿势/多角度/多场景矩阵、"
+        "多格分镜叙事(起承转合)、多格漫画故事板、表情包 9 宫格、Bento Grid、非对称布局等,"
+        "整体输出为单张图像,风格/色调/角色外观在格间保持统一,无需后期拼接。"
+    ),
+    # [34] 扩展到撞色方案/参考图提取/推荐
+    "CAP-689bac61": (
+        "色彩配色方案生成与推荐",
+        "基于色彩理论(对比色、互补色、邻近色、三色组合、渐变过渡色)、内容主题、情感氛围或参考图输入,"
+        "自动生成或推荐协调的配色方案(主色 + 辅助色 + 强调色,附 HEX/RGB 色值),"
+        "涵盖产品摄影、海报、图文排版、品牌物料等场景。"
+        "支持参考图语义分析与 HEX 色板自动提取(从图像提取 5 个核心色值渲染为调色板)。"
+    ),
+    # [35] 扩展到长文分页 + 社交媒体无缝横幅
+    "CAP-20409fa6": (
+        "AI 多页轮播/长文分页/社交媒体序列生成",
+        "以主题、语气、长文内容或多张素材为输入,AI 自动完成内容拆分、版式匹配、图文合成,"
+        "输出多页轮播/幻灯片、长文分图片序列(按段落分页、自动加页码)、"
+        "数字剪贴簿、社交媒体无缝横幅拼接(多帖子在个人主页网格中连续成大图)等多种序列形态,"
+        "所有页面视觉风格统一,适配社交平台比例一键发布。"
+    ),
+    # [36] 加物理光照
+    "CAP-5fb6dd66": (
+        "室内多光源分层与物理光照提示词工程",
+        "在提示词中分层描述室内多光源组合(自然窗光/日光、吊灯、筒灯、射灯、落地灯/台灯、隐藏灯带),"
+        "并指定光源类型、方向、色温、光质(漫射/定向/体积光束)以及物理行为(环境遮蔽、接触阴影、自然反弹、"
+        "由近到远的光影渐变),生成具有丰富光影层次与物理正确感的室内效果图。"
+    ),
+    # [38] 加有机形态元素
+    "CAP-56368e3a": (
+        "室内自然材质纹理与有机形态精准生成",
+        "在室内效果图生成中,精准还原藤编、实木(胡桃木/榆木/橡木)、大理石、宣纸肌理、竹编、棉麻、亚麻"
+        "等天然材质的视觉纹理特征,并控制弧形、拱形、圆润等有机形态设计元素,"
+        "使画面传达自然、温润、生活化的治愈氛围,区别于冷硬工业风或过度精致的奢华风。"
+    ),
+    # [39] 扩展到综合写实
+    "CAP-aaaef688": (
+        "电影级光照与写实质感提示词工程",
+        "通过提示词引入电影摄影的专业光照术语与写实质感维度:"
+        "光照(golden hour lighting 黄金时段、cinematic lighting 电影级照明、editorial lighting 编辑级照明)、"
+        "摄影参数(景深、镜头角度)、材质细节(毛发纹理、皮肤质感)、场景氛围(色彩分级、环境光)"
+        "的综合组合策略,系统化提升 AI 生成图像的戏剧性、艺术性和照片级真实度。"
+    ),
+    # [41] 去掉科技感,泛化
+    "CAP-5b000814": (
+        "结构化 Prompt 框架工程(分段/模板/参数化)",
+        "系统化设计 Prompt 的分段结构(SUBJECT / ENVIRONMENT / MOOD / COLOR LOGIC / CAMERA / SCENE / LIGHTING / QUALITY 等维度),"
+        "使 AI 模型能够分维度理解并精确控制图像;并将验证过的高质量 Prompt 框架抽象为可复用模板,"
+        "通过替换变量(主题、国家、产品、风格词等)批量生成风格统一的系列化内容,"
+        "实现 AI 生成输出的可复现精准风格控制。"
+    ),
+    # [43] 扩展到人体局部
+    "CAP-26100ea8": (
+        "微距/极端特写构图与画幅填充控制",
+        "通过提示词中的镜头参数(macro lens、micro lens、close-up、extreme close-up)"
+        "与构图指令(主体占满画幅、fill the frame、极端裁切),"
+        "精确控制 AI 生成图像中主体(人体局部:眼部/嘴部/手部/指甲/耳部;或产品/物件/食物截面)"
+        "的放大比例、裁切范围与焦点区域,生成画面填充感强、主体突出的微距/特写图像。"
+    ),
+    # [45] 重大扩展
+    "CAP-562d91c1": (
+        "海报/长图多元素自动排版与版式智能设计",
+        "通过 AI 设计工具或生成式排版引擎,将多种异质视觉元素(人物照片、品牌 Logo、活动标识、二维码、"
+        "数据图表、时间线、流程图、文字段落、装饰元素、地图、产品照片)按照信息层级、品牌规范和视觉平衡原则,"
+        "自动选择最优构图方案(居中、左右、对角、时间轴、戏剧性垂直分割、三角形/X 形),"
+        "并按信息优先级分配视觉权重,合成为专业商业海报、信息图或杂志/报告风格的完整长图版面。"
+    ),
+    # [46] 加分层景深语言
+    "CAP-3c49ff0a": (
+        "空间透视与多层景深提示词精确控制",
+        "通过在提示词中精确指定消失点类型(一点/两点/三点透视)、视平线高度、相机视角(低角度/眼平/俯视),"
+        "分层描述前景/中景/背景各层元素(实焦/虚化、高斯模糊、大气透视),"
+        "建筑框景元素(拱门/窗框/门洞/柱廊/地板线条向消失点汇聚/纹理拼接角度)以及镜头参数(广角/超广角),"
+        "或通过 ControlNet 深度图约束,生成具有强烈空间纵深感、三维立体景深和史诗级空间规模感的画面。"
+    ),
+    # [49] 扩展到光晕/泛光/流光线条
+    "CAP-a35e7966": (
+        "霓虹/光晕/泛光/流光发光效果生成",
+        "在 AI 生成图像中通过提示词参数或后处理节点,为文字、轮廓线、场景元素、光源周围"
+        "(灯笼/路灯/窗灯/月亮等深色背景光源)添加多种发光效果:霓虹管状发光、外发光晕、光晕 Halo、"
+        "泛光 Glow、光芒扩散、多层渐变光晕、流光线条、粒子散射、渐变镭射,"
+        "营造赛博朋克/科技感/深色氛围的发光视觉质感。"
+    ),
+    # [51] 加孔版印刷/波普网点
+    "CAP-9359b49f": (
+        "复古印刷质感与半色调/孔版/波普纹理生成",
+        "通过 AI 生成或后处理节点,模拟复古印刷工艺的视觉质感:"
+        "丝网印刷、半色调网点(Halftone)、孔版印刷(Risograph)、波普艺术网点与几何光学错觉纹理、"
+        "混合模式叠印、颜色分层叠印、油墨渗透扩散,"
+        "在高饱和色彩画面上叠加颗粒噪点、做旧肌理和色彩分离效果,"
+        "使画面呈现强烈的年代感与手工印刷质感。"
+    ),
+    # [53] 扩展到3D透视+场景表面融合
+    "CAP-bd4828fc": (
+        "文字透视变形与场景表面 3D 融合",
+        "对叠加或生成的文字应用三维透视变形(梯形、弧形、斜向排列、曲面贴合、消失点对齐),"
+        "使文字随场景物体表面(建筑、桥梁、地面、墙面)产生渐进压缩、深度衰减和空间透视关系,"
+        "形成文字融入场景的氛围感效果(而非漂浮叠加),广泛用于照片加字、创意 P 图、场景海报风格。"
+    ),
+    # [54] 泛化到非人类主体
+    "CAP-e962c3ef": (
+        "非人类主体拟人化角色形象构建",
+        "通过结构化提示词工程,为非人类主体(动物如猫咪、玩具、机器人、材质实体等)赋予完整的人类角色属性——"
+        "职业身份、服饰道具(帽子、厨师服、僧袍、背带裤、球衣、蝴蝶结)、面部表情、肢体姿态与背景故事,"
+        "并使其与道具(书本、购物篮、玩偶)产生自然交互。"
+        "保留物种/材质本体特征(毛色、外形、质感),实现角色扮演级的拟人化视觉表达。"
+    ),
+    # [56] 扩展到环境粒子 + 主体联动
+    "CAP-8467736a": (
+        "粒子光效与环境特效场景生成",
+        "通过 AI 提示词或专业 3D/特效工具,生成大量发光粒子(星点、尘埃、能量球、光束)或环境粒子"
+        "(尘土飞扬、烟雾弥漫、水花飞溅、花瓣飘落)在场景中流动、汇聚、爆发或与主体运动物理联动的视觉效果。"
+        "粒子具有多种颜色(橙、蓝、紫、金等)与自发光属性,形成梦幻/魔幻氛围或强化主体动感与临场感。"
+    ),
+    # [57] 明确加蓝调时刻
+    "CAP-e8a77f70": (
+        "自然光照与特定时刻氛围生成",
+        "生成具有真实自然光照效果的图像,涵盖柔和自然光、金色时刻(Golden Hour,日出后/日落前约 1 小时,"
+        "温暖橙金色调)、蓝调时刻(Blue Hour,日落后约 20 分钟,清冷蓝紫色调)、"
+        "日落暖光、阴天柔光、室内窗光等真实光照条件,"
+        "并配合斑驳树影、水面反光、晨雾等自然光效果,营造特定情绪氛围。"
+    ),
+    # [58] 扩展到非伦勃朗类(丁达尔/黄金逆光/窗户光/影棚柔光)
+    "CAP-ed4b506e": (
+        "人像专业布光模式精准复现",
+        "通过在提示词中使用专业摄影布光术语,精准复现经典或氛围性布光模式:"
+        "伦勃朗光(Rembrandt)、蝴蝶光(Butterfly)、侧逆光(Rim Lighting)、二分光(Split Lighting)、"
+        "丁达尔光晕、黄金时刻逆光、窗户光、影棚柔光箱、光比与光源方向控制,"
+        "使生成人像呈现专业摄影棚级别的光影结构、面部立体感与情绪氛围。"
+    ),
+    # [61] 泛化到任意场景
+    "CAP-a95da8d7": (
+        "AI 文生视频 / 动画帧序列生成",
+        "基于文本描述或参考图像,使用新一代 AI 文生视频模型(Seedance、Veo、Kling 等),"
+        "通过结构化提示词直接生成具有时间连贯性的动画帧序列或完整视频片段,"
+        "涵盖从静态氛围场景(夜景、日间、室内外)到动态运动内容的多种主题,"
+        "将静态描述或插画的情绪氛围转化为具有时间维度的电影级动态视觉叙事。"
+    ),
+}
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    g = ap.add_mutually_exclusive_group(required=True)
+    g.add_argument("--dry-run", action="store_true")
+    g.add_argument("--execute", action="store_true")
+    args = ap.parse_args()
+
+    dry = args.dry_run
+    print(f"\n{'='*60}\n{'DRY RUN' if dry else 'EXECUTE'} — 更新 canonical name/description({len(RENAMES)} 条)\n"
+          f"{'='*60}\n", flush=True)
+
+    store = PostgreSQLCapabilityStore()
+    cur = store._get_cursor()
+    try:
+        updated = 0
+        for idx, (cid, (new_name, new_desc)) in enumerate(RENAMES.items(), 1):
+            cur.execute("SELECT id, name, description FROM capability WHERE id = %s", (cid,))
+            r = cur.fetchone()
+            if not r:
+                print(f"[{idx}] {cid} ⚠️  NOT FOUND", flush=True)
+                continue
+            print(f"[{idx}] {cid}", flush=True)
+            print(f"    old name: {r['name']}", flush=True)
+            print(f"    new name: {new_name}", flush=True)
+            if r['description'] != new_desc:
+                print(f"    desc will be updated ({len(r['description'] or '')} → {len(new_desc)} chars)",
+                      flush=True)
+            if not dry:
+                cur.execute(
+                    "UPDATE capability SET name = %s, description = %s WHERE id = %s",
+                    (new_name, new_desc, cid),
+                )
+                updated += 1
+            else:
+                updated += 1
+        print(f"\n{'='*60}\n{'[DRY] 将' if dry else '已'}更新 {updated} 条 canonical\n{'='*60}\n",
+              flush=True)
+    finally:
+        cur.close()
+        store.close()
+
+
+if __name__ == "__main__":
+    main()

+ 355 - 0
knowhub/scripts/salvage_malformed_folders.py

@@ -0,0 +1,355 @@
+#!/usr/bin/env python3
+"""
+Salvage folders with non-standard JSON schemas that standard ingest missed.
+
+Target folders (from data problem audit):
+  004, 031, 053, 066, 070 — non-standard strategy.json structure (LLM 自由发挥)
+  044 — capabilities_extracted uses old schema (capability_id/capability_name)
+
+Strategy: for each folder, manually locate the relevant fields and wire the junctions.
+All insertions use version='howard_dedup'.
+
+用法:
+  python knowhub/scripts/salvage_malformed_folders.py --execute
+"""
+import argparse
+import hashlib
+import json
+import sys
+import time
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+from knowhub.knowhub_db.pg_capability_store import PostgreSQLCapabilityStore
+from knowhub.scripts.rebuild_howard_dedup import (
+    parse_snapshot, build_alias_map, norm, gen_cap_id, gen_strategy_id,
+    gen_resource_id, SNAPSHOT_PATH, OUTPUT_DIR, DEDUP_VERSION,
+)
+
+DEDUP = DEDUP_VERSION
+
+
+def resolve_cap(cur, cap_name_or_id, alias, current_caps, create_if_missing=False,
+                cap_desc='', cap_criterion=''):
+    """Resolve a capability name/id → DB id. Optionally create if missing."""
+    if not cap_name_or_id:
+        return None
+    # If it looks like a cap ID and exists in DB, use it
+    if cap_name_or_id.startswith('CAP-') and cap_name_or_id in current_caps:
+        return cap_name_or_id
+    # Try alias
+    cand = alias.get(norm(cap_name_or_id))
+    if cand and cand in current_caps:
+        return cand
+    # Try checking if name matches DB
+    cur.execute('SELECT id FROM capability WHERE name = %s LIMIT 1', (cap_name_or_id,))
+    row = cur.fetchone()
+    if row:
+        return row['id']
+    if create_if_missing:
+        new_id = gen_cap_id(cap_name_or_id)
+        cur.execute('SELECT 1 FROM capability WHERE id = %s', (new_id,))
+        if not cur.fetchone():
+            cur.execute(
+                """INSERT INTO capability (id, name, criterion, description, effects, version)
+                   VALUES (%s, %s, %s, %s, %s, %s)""",
+                (new_id, cap_name_or_id, cap_criterion, cap_desc, '[]', DEDUP))
+            current_caps[new_id] = {'name': cap_name_or_id}
+            alias[norm(cap_name_or_id)] = new_id
+        return new_id
+    return None
+
+
+def insert_strategy(cur, folder_key, req_id, req_text, strategy_name, strategy_body,
+                    reasoning, resource_ids, cap_ids):
+    strat_id = gen_strategy_id(req_text, strategy_name)
+    now = int(time.time())
+    cur.execute('DELETE FROM strategy WHERE id = %s', (strat_id,))
+    cur.execute(
+        """INSERT INTO strategy (id, name, description, body, status, created_at, updated_at, version)
+           VALUES (%s, %s, %s, %s, %s, %s, %s, %s)""",
+        (strat_id, strategy_name, (reasoning or '')[:2000],
+         json.dumps(strategy_body, ensure_ascii=False, indent=2),
+         'draft', now, now, DEDUP))
+    # requirement → strategy
+    cur.execute("""INSERT INTO requirement_strategy (requirement_id, strategy_id)
+                   VALUES (%s, %s) ON CONFLICT DO NOTHING""", (req_id, strat_id))
+    # strategy → resources
+    for rid in resource_ids:
+        cur.execute("""INSERT INTO strategy_resource (strategy_id, resource_id)
+                       VALUES (%s, %s) ON CONFLICT DO NOTHING""", (strat_id, rid))
+    # strategy → capabilities (+ req → cap)
+    for cid in cap_ids:
+        cur.execute("""INSERT INTO strategy_capability (strategy_id, capability_id, relation_type)
+                       VALUES (%s, %s, 'compose') ON CONFLICT DO NOTHING""", (strat_id, cid))
+        cur.execute("""INSERT INTO requirement_capability (requirement_id, capability_id)
+                       VALUES (%s, %s) ON CONFLICT DO NOTHING""", (req_id, cid))
+    return strat_id
+
+
+def ensure_resources(cur, folder, req_id):
+    """Read raw_cases/, insert resources, return list of resource IDs."""
+    raw_dir = folder / 'raw_cases'
+    resource_ids = []
+    if not raw_dir.exists():
+        return resource_ids
+    for cf in sorted(raw_dir.glob('*.json')):
+        try:
+            data = json.loads(cf.read_text(encoding='utf-8'))
+        except Exception:
+            continue
+        cases = data.get('cases', []) if isinstance(data, dict) else data
+        if not isinstance(cases, list):
+            continue
+        for case in cases:
+            if not isinstance(case, dict):
+                continue
+            url = case.get('source_url') or case.get('url')
+            if not url:
+                continue
+            platform = case.get('platform') or cf.stem.replace('case_', '')
+            rid = gen_resource_id(platform, url)
+            title = (case.get('title') or '')[:200]
+            metrics = case.get('metrics') if isinstance(case.get('metrics'), dict) else {}
+            likes = (metrics.get('likes') or 0) if metrics else 0
+            cur.execute('DELETE FROM resource WHERE id = %s', (rid,))
+            cur.execute(
+                """INSERT INTO resource (id, title, body, content_type, images, metadata, sort_order, version)
+                   VALUES (%s, %s, %s, %s, %s, %s, %s, %s)""",
+                (rid, title, json.dumps(case, ensure_ascii=False)[:8000],
+                 'research_case',
+                 json.dumps(case.get('images', []) or [], ensure_ascii=False),
+                 json.dumps({'platform': platform, 'source_url': url,
+                             'metrics': metrics, 'folder': folder.name},
+                            ensure_ascii=False),
+                 -int(likes), DEDUP))
+            resource_ids.append(rid)
+            cur.execute("""INSERT INTO requirement_resource (requirement_id, resource_id)
+                           VALUES (%s, %s) ON CONFLICT DO NOTHING""", (req_id, rid))
+    return resource_ids
+
+
+# ═══════════════════════════════════════════════════════════
+# Per-folder handlers
+
+def salvage_004(cur, alias, current_caps):
+    """004: strategy.strategy.phases + capability_mapping (capability=name)."""
+    folder = OUTPUT_DIR / '004'
+    req_id = 'REQ_004'
+    d = json.loads((folder / 'strategy.json').read_text())
+    req_text = d.get('requirement', '')
+    strat_dict = d.get('strategy', {})
+    overview = strat_dict.get('overview', '')
+    name = f'Strategy-004'
+    # capability_mapping: [{capability: name, role, used_in_phases, key_tools}]
+    cap_ids = set()
+    for entry in d.get('capability_mapping', []) or []:
+        if not isinstance(entry, dict):
+            continue
+        cap_name = entry.get('capability') or entry.get('capability_name')
+        cid = resolve_cap(cur, cap_name, alias, current_caps, create_if_missing=True)
+        if cid: cap_ids.add(cid)
+    resource_ids = ensure_resources(cur, folder, req_id)
+    body = {'strategy': strat_dict, 'capability_mapping': d.get('capability_mapping', [])}
+    insert_strategy(cur, '004', req_id, req_text, name, body, overview,
+                    resource_ids, cap_ids)
+    return len(cap_ids), len(resource_ids)
+
+
+def salvage_031(cur, alias, current_caps):
+    """031: strategy.strategy.phases + capability_mapping (capability_id/name)."""
+    folder = OUTPUT_DIR / '031'
+    req_id = 'REQ_031'
+    d = json.loads((folder / 'strategy.json').read_text())
+    req_text = d.get('requirement', '')
+    strat_dict = d.get('strategy', {})
+    overview = strat_dict.get('overview', '')
+    name = f'Strategy-031'
+    cap_ids = set()
+    for entry in d.get('capability_mapping', []) or []:
+        if not isinstance(entry, dict):
+            continue
+        cid_src = entry.get('capability_id')
+        cname = entry.get('capability_name')
+        cid = None
+        if cid_src:
+            cid = resolve_cap(cur, cid_src, alias, current_caps)
+        if not cid and cname:
+            cid = resolve_cap(cur, cname, alias, current_caps, create_if_missing=True)
+        if cid: cap_ids.add(cid)
+    resource_ids = ensure_resources(cur, folder, req_id)
+    body = {'strategy': strat_dict, 'capability_mapping': d.get('capability_mapping', [])}
+    insert_strategy(cur, '031', req_id, req_text, name, body, overview,
+                    resource_ids, cap_ids)
+    return len(cap_ids), len(resource_ids)
+
+
+def salvage_053(cur, alias, current_caps):
+    """053: phases[] (phase/description/capabilities) + core_workflow (text)."""
+    folder = OUTPUT_DIR / '053'
+    req_id = 'REQ_053'
+    d = json.loads((folder / 'strategy.json').read_text())
+    req_text = d.get('requirement', '')
+    name = f'Strategy-053'
+    cap_ids = set()
+    for phase in d.get('phases', []) or []:
+        if not isinstance(phase, dict):
+            continue
+        for cap_ref in phase.get('capabilities', []) or []:
+            if isinstance(cap_ref, str):
+                # string name or id
+                cid = resolve_cap(cur, cap_ref, alias, current_caps, create_if_missing=True)
+                if cid: cap_ids.add(cid)
+            elif isinstance(cap_ref, dict):
+                src = cap_ref.get('id') or cap_ref.get('capability_id')
+                nm = cap_ref.get('name') or cap_ref.get('capability_name')
+                cid = None
+                if src: cid = resolve_cap(cur, src, alias, current_caps)
+                if not cid and nm:
+                    cid = resolve_cap(cur, nm, alias, current_caps, create_if_missing=True)
+                if cid: cap_ids.add(cid)
+    resource_ids = ensure_resources(cur, folder, req_id)
+    body = {'phases': d.get('phases', []), 'core_workflow': d.get('core_workflow', '')}
+    insert_strategy(cur, '053', req_id, req_text, name, body,
+                    d.get('core_workflow', '')[:500], resource_ids, cap_ids)
+    return len(cap_ids), len(resource_ids)
+
+
+def salvage_066(cur, alias, current_caps):
+    """066: execution_phases + key_capabilities (id/name/role)."""
+    folder = OUTPUT_DIR / '066'
+    req_id = 'REQ_066'
+    d = json.loads((folder / 'strategy.json').read_text())
+    req_text = d.get('requirement', '')
+    name = f'Strategy-066'
+    cap_ids = set()
+    # key_capabilities is primary
+    for cap in d.get('key_capabilities', []) or []:
+        if not isinstance(cap, dict): continue
+        cid = None
+        if cap.get('id'):
+            cid = resolve_cap(cur, cap['id'], alias, current_caps)
+        if not cid and cap.get('name'):
+            cid = resolve_cap(cur, cap['name'], alias, current_caps, create_if_missing=True)
+        if cid: cap_ids.add(cid)
+    # execution_phases may reference more
+    for phase in d.get('execution_phases', []) or []:
+        if not isinstance(phase, dict): continue
+        for c in phase.get('capabilities_used', []) or []:
+            if isinstance(c, str):
+                cid = resolve_cap(cur, c, alias, current_caps, create_if_missing=True)
+                if cid: cap_ids.add(cid)
+            elif isinstance(c, dict):
+                cid = None
+                if c.get('id'): cid = resolve_cap(cur, c['id'], alias, current_caps)
+                if not cid and c.get('name'):
+                    cid = resolve_cap(cur, c['name'], alias, current_caps, create_if_missing=True)
+                if cid: cap_ids.add(cid)
+    resource_ids = ensure_resources(cur, folder, req_id)
+    body = {'execution_phases': d.get('execution_phases', []),
+            'key_capabilities': d.get('key_capabilities', []),
+            'recommended_tools': d.get('recommended_tools', [])}
+    insert_strategy(cur, '066', req_id, req_text, name, body, '', resource_ids, cap_ids)
+    return len(cap_ids), len(resource_ids)
+
+
+def salvage_070(cur, alias, current_caps):
+    """070: capabilities_mapping (id/name/role_in_strategy/is_new) + tool_chain."""
+    folder = OUTPUT_DIR / '070'
+    req_id = 'REQ_070'
+    d = json.loads((folder / 'strategy.json').read_text())
+    req_text = d.get('requirement', '')
+    name = f'Strategy-070'
+    cap_ids = set()
+    for cap in d.get('capabilities_mapping', []) or []:
+        if not isinstance(cap, dict): continue
+        cid = None
+        if cap.get('id'): cid = resolve_cap(cur, cap['id'], alias, current_caps)
+        if not cid and cap.get('name'):
+            cid = resolve_cap(cur, cap['name'], alias, current_caps, create_if_missing=True)
+        if cid: cap_ids.add(cid)
+    resource_ids = ensure_resources(cur, folder, req_id)
+    body = {'capabilities_mapping': d.get('capabilities_mapping', []),
+            'tool_chain': d.get('tool_chain', []),
+            'selected_blueprint': d.get('selected_blueprint', '')}
+    insert_strategy(cur, '070', req_id, req_text, name, body, '', resource_ids, cap_ids)
+    return len(cap_ids), len(resource_ids)
+
+
+def salvage_044(cur, alias, current_caps):
+    """044: capabilities_extracted uses capability_id/capability_name schema.
+    Just link these caps to existing REQ_044's strategy (already created)."""
+    folder = OUTPUT_DIR / '044'
+    req_id = 'REQ_044'
+    # find existing strategy
+    cur.execute('SELECT strategy_id FROM requirement_strategy WHERE requirement_id=%s LIMIT 1', (req_id,))
+    row = cur.fetchone()
+    if not row:
+        print('  ⚠️  044 has no strategy yet', flush=True)
+        return 0, 0
+    strat_id = row['strategy_id']
+
+    d = json.loads((folder / 'capabilities_extracted.json').read_text())
+    cap_ids = set()
+    for cap in d.get('extracted_capabilities', []) or []:
+        if not isinstance(cap, dict): continue
+        cid_src = cap.get('capability_id')
+        cname = cap.get('capability_name')
+        cid = None
+        if cid_src:
+            cid = resolve_cap(cur, cid_src, alias, current_caps)
+        if not cid and cname:
+            cid = resolve_cap(cur, cname, alias, current_caps, create_if_missing=True)
+        if cid: cap_ids.add(cid)
+    for cid in cap_ids:
+        cur.execute("""INSERT INTO strategy_capability (strategy_id, capability_id, relation_type)
+                       VALUES (%s, %s, 'compose') ON CONFLICT DO NOTHING""", (strat_id, cid))
+        cur.execute("""INSERT INTO requirement_capability (requirement_id, capability_id)
+                       VALUES (%s, %s) ON CONFLICT DO NOTHING""", (req_id, cid))
+    return len(cap_ids), 0
+
+
+# ═══════════════════════════════════════════════════════════
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--execute', action='store_true', required=True)
+    args = ap.parse_args()
+
+    s = PostgreSQLCapabilityStore()
+    cur = s._get_cursor()
+    cur.execute("SELECT id, name FROM capability WHERE version = %s", (DEDUP,))
+    current_caps = {r['id']: {'name': r['name']} for r in cur.fetchall()}
+    snapshot = parse_snapshot(SNAPSHOT_PATH)
+    alias = build_alias_map(snapshot, current_caps)
+    print(f'alias: {len(alias)}, caps: {len(current_caps)}', flush=True)
+
+    handlers = [
+        ('004', salvage_004), ('031', salvage_031),
+        ('044', salvage_044), ('053', salvage_053),
+        ('066', salvage_066), ('070', salvage_070),
+    ]
+    for key, fn in handlers:
+        print(f'\n📁 salvaging {key}/ ...', flush=True)
+        try:
+            n_cap, n_res = fn(cur, alias, current_caps)
+            print(f'  ✓ {key}: {n_cap} caps, {n_res} resources', flush=True)
+        except Exception as e:
+            print(f'  ❌ {key}: {type(e).__name__}: {e}', flush=True)
+            import traceback; traceback.print_exc()
+
+    # Final check
+    print(f'\n{"="*50}\nFinal requirement coverage:', flush=True)
+    for req in ['REQ_004','REQ_031','REQ_044','REQ_053','REQ_066','REQ_070']:
+        cur.execute('SELECT COUNT(*) AS c FROM requirement_strategy WHERE requirement_id=%s', (req,))
+        strat = cur.fetchone()['c']
+        cur.execute('SELECT COUNT(*) AS c FROM requirement_capability WHERE requirement_id=%s', (req,))
+        cap = cur.fetchone()['c']
+        cur.execute('SELECT COUNT(*) AS c FROM requirement_resource WHERE requirement_id=%s', (req,))
+        res = cur.fetchone()['c']
+        print(f'  {req}: strat={strat} cap={cap} res={res}', flush=True)
+    cur.close()
+    s.close()
+
+
+if __name__ == '__main__':
+    main()

+ 6 - 6
skills4claude/knowhub/SKILL.md

@@ -30,13 +30,13 @@ description: 查询和上传知识到 KnowHub 知识库。当用户需要检索
 
 ```bash
 # 调 remote_librarian(默认,快,基于已入库知识)
-python /Users/sunlit/.claude/skills/knowhub/knowhub.py ask --query="ControlNet 相关的工具"
+python <this_skill_dir>/knowhub.py ask --query="ControlNet 相关的工具"
 
 # 调 remote_research(深,全网调研 + 入库,慢,分钟级)
-python /Users/sunlit/.claude/skills/knowhub/knowhub.py ask --query="..." --deep
+python <this_skill_dir>/knowhub.py ask --query="..." --deep
 
 # 复用上下文(用上次返回的 sub_trace_id)
-python /Users/sunlit/.claude/skills/knowhub/knowhub.py ask \
+python <this_skill_dir>/knowhub.py ask \
     --query="基于刚才的结果补充..." \
     --continue_from=65298f18-7cc4-4bc0-9fb8-6f2dd048df31
 ```
@@ -46,7 +46,7 @@ python /Users/sunlit/.claude/skills/knowhub/knowhub.py ask \
 ### search —— 快速检索
 
 ```bash
-python /Users/sunlit/.claude/skills/knowhub/knowhub.py search \
+python <this_skill_dir>/knowhub.py search \
     --query="图片批量生成" \
     --top_k=5 \
     --min_score=3 \
@@ -56,7 +56,7 @@ python /Users/sunlit/.claude/skills/knowhub/knowhub.py search \
 **按关系过滤**(只看某 capability/tool/requirement 关联的):
 
 ```bash
-python /Users/sunlit/.claude/skills/knowhub/knowhub.py search \
+python <this_skill_dir>/knowhub.py search \
     --query="..." --capability_id=CAP-008
 ```
 
@@ -65,7 +65,7 @@ python /Users/sunlit/.claude/skills/knowhub/knowhub.py search \
 ### save —— 保存知识
 
 ```bash
-python /Users/sunlit/.claude/skills/knowhub/knowhub.py save \
+python <this_skill_dir>/knowhub.py save \
     --task="在用 flux 生成海报图时, 怎么让文字不错乱" \
     --content="把文字用 [] 显式标出, 并在 prompt 末尾加 'clean typography, legible text'" \
     --types=strategy \

+ 5 - 5
skills4claude/toolhub/SKILL.md

@@ -14,13 +14,13 @@ ToolHub 托管了各种 AI 工具(图片生成、拼接、风格迁移等)
 
 ```bash
 # 检查服务状态
-python /Users/sunlit/.claude/skills/toolhub/toolhub.py health
+python <this_skill_dir>/toolhub.py health
 
 # 搜索可用工具(不填 keyword 返回全量)
-python /Users/sunlit/.claude/skills/toolhub/toolhub.py search --keyword=image
+python <this_skill_dir>/toolhub.py search --keyword=image
 
 # 调用工具
-python /Users/sunlit/.claude/skills/toolhub/toolhub.py call \
+python <this_skill_dir>/toolhub.py call \
   --tool_id=flux_gen --params='{"prompt":"a cat sitting on the moon"}'
 ```
 
@@ -36,7 +36,7 @@ python /Users/sunlit/.claude/skills/toolhub/toolhub.py call \
 
 ```bash
 # 示例:传入本地图片(自动 OSS 上传)
-python /Users/sunlit/.claude/skills/toolhub/toolhub.py call \
+python <this_skill_dir>/toolhub.py call \
   --tool_id=image_stitcher \
   --params='{"images":["/path/to/a.png","/path/to/b.png"],"direction":"horizontal"}'
 ```
@@ -74,7 +74,7 @@ trace_id 控制图片输出子目录,优先级:
 
 ```bash
 export TRACE_ID=my-session-001
-python /Users/sunlit/.claude/skills/toolhub/toolhub.py call --tool_id=flux_gen --params='{"prompt":"..."}'
+python <this_skill_dir>/toolhub.py call --tool_id=flux_gen --params='{"prompt":"..."}'
 # 图片保存到 <cwd>/.cache/toolhub_outputs/my-session-00/
 ```