Talegorithm пре 1 месец
родитељ
комит
7ba17857dc
32 измењених фајлова са 6814 додато и 1511 уклоњено
  1. 201 0
      knowhub/docs/2026-04-22_strategy_migration_complete.md
  2. 459 0
      knowhub/docs/API_INTERFACE_NOTES.md
  3. 90 0
      knowhub/docs/dashboard-filtering-design.md
  4. 44 1
      knowhub/docs/schema.md
  5. 290 0
      knowhub/docs/strategy_abstraction_methodology.md
  6. 70 20
      knowhub/frontend/src/components/common/SideDrawer.tsx
  7. 17 2
      knowhub/frontend/src/components/dashboard/CategoryTree.tsx
  8. 206 0
      knowhub/frontend/src/components/dashboard/cards/RelationCard.tsx
  9. 1207 0
      knowhub/frontend/src/components/dashboard/details/DrawerContentComponents.tsx
  10. 224 0
      knowhub/frontend/src/hooks/useDashboardData.ts
  11. 148 0
      knowhub/frontend/src/hooks/useDashboardFilter.ts
  12. 82 0
      knowhub/frontend/src/lib/dashboard-theme.ts
  13. 253 0
      knowhub/frontend/src/lib/dashboardGraph.ts
  14. 245 1486
      knowhub/frontend/src/pages/Dashboard.tsx
  15. 20 0
      knowhub/frontend/src/services/api.ts
  16. 6 2
      knowhub/knowhub_db/pg_requirement_store.py
  17. 330 0
      knowhub/knowhub_db/pg_requirement_store.py.orig
  18. 400 0
      knowhub/scripts/abstract_patterns.py
  19. 98 0
      knowhub/scripts/backup_before_strategy_refactor.py
  20. 286 0
      knowhub/scripts/cluster_strategies.py
  21. 391 0
      knowhub/scripts/ingest_all_strategies.py
  22. 176 0
      knowhub/scripts/migrate_add_external_refs.py
  23. 144 0
      knowhub/scripts/migrate_add_version_and_patterns.py
  24. 98 0
      knowhub/scripts/phase2_schema_migration.py
  25. 467 0
      knowhub/scripts/phase4_5_migrate.py
  26. 112 0
      knowhub/scripts/phase5_add_knowledge_capability.py
  27. 205 0
      knowhub/scripts/phase5_fill_alt_knowledge_capability.py
  28. 140 0
      knowhub/scripts/phase5_fuzzy_fill_remaining.py
  29. 353 0
      knowhub/scripts/salvage_placeholder_strategies.py
  30. 14 0
      knowhub/server.py
  31. 23 0
      knowhub/update_server.patch
  32. 15 0
      knowhub/update_subqueries.patch

+ 201 - 0
knowhub/docs/2026-04-22_strategy_migration_complete.md

@@ -0,0 +1,201 @@
+# Strategy 抽象化迁移完成报告
+
+**日期**:2026-04-22
+**范围**:Phase 0 → Phase 6 全部完成
+
+---
+
+## 一、整体变化对照
+
+| 实体 | 迁移前 | 迁移后 | 说明 |
+|---|---|---|---|
+| strategy 表 | 99(全是 req-specific 具体方案) | **26 abstract pattern**(version=`howard_strategy_instance`)| 每条 = 一种可跨 req 复用的"方法套路 / skill" |
+| knowledge 表 | 1046 条 v0 老数据 | 1092 v0 + **193 新**(version=`howard_strategy_instance`)| 新增 193 条承载 req-specific 执行细节 |
+| requirement_strategy | 99(严格 1:1)| **155**(M:N)| 每 req 可对应多个 pattern(含 selected + alt)|
+| requirement_knowledge | 0 | **193** | 新 junction,精确记录 (req, knowledge) 关系 |
+| strategy_knowledge | 0 | **193** | abstract pattern ↔ knowledge 实例 |
+| knowledge_resource | 306(老)| 306 + **5339 新** | knowledge 引用的 resources |
+| knowledge_capability | 0 | **1198**(新建)| 保证 knowledge_cap ⊆ req_cap 约束的关键 junction |
+| strategy_capability | 1202(具体 strat → cap)| 653(abstract 的 cap 联合)| 降下来是因为抽象后多条并一条 |
+| strategy_resource | 2736(具体 strat → resource)| 2702(abstract 的 resource 联合)| 同上 |
+| requirement_capability(研究全集)| 1106 | 1106(不变)| Research superset 不变 |
+| capability 表 | 332 | 332(不变)| 抽象化不涉及 cap 层 |
+
+---
+
+## 二、数据模型变更
+
+### 增量 schema 变更
+
+```sql
+-- Phase 2 schema migration (已执行)
+ALTER TABLE requirement_strategy
+    ADD COLUMN is_selected BOOLEAN,
+    ADD COLUMN coverage_score FLOAT,
+    ADD COLUMN coverage_explanation TEXT;
+
+-- requirement_knowledge 已存在(空),ALTER 补 3 列
+ALTER TABLE requirement_knowledge
+    ADD COLUMN is_selected BOOLEAN,
+    ADD COLUMN coverage_score FLOAT,
+    ADD COLUMN coverage_explanation TEXT;
+
+-- knowledge_resource:已存在(306 行老数据 knowledge→tools),沿用
+-- knowledge_capability:新建
+CREATE TABLE knowledge_capability (
+    knowledge_id   VARCHAR NOT NULL,
+    capability_id  VARCHAR NOT NULL,
+    PRIMARY KEY (knowledge_id, capability_id)
+) DISTRIBUTED BY (knowledge_id);
+```
+
+### is_selected / coverage 的双存冗余
+
+用户决策:**两张表都存,允许语义漂移**
+- `requirement_strategy.is_selected` = "req 的多个候选 pattern 中,这个 pattern 被选"(pattern 粒度)
+- `requirement_knowledge.is_selected` = "具体执行实例里,这条 knowledge 是被选的执行"(execution 粒度)
+- 初始状态两者同值;随着时间推移可能独立演化(未来扩展允许某 pattern 被选而某具体执行被改)
+
+---
+
+## 三、典型查询路径
+
+### Q1: 某 req 选了哪些 pattern + 各自覆盖度
+
+```sql
+SELECT s.name pattern_name, rs.is_selected, rs.coverage_score, rs.coverage_explanation
+FROM requirement_strategy rs
+JOIN strategy s ON s.id = rs.strategy_id
+WHERE rs.requirement_id = 'REQ_003';
+```
+
+### Q2: 某 req 针对某 pattern 的具体执行细节
+
+```sql
+SELECT k.content
+FROM knowledge k
+JOIN requirement_knowledge rk ON rk.knowledge_id = k.id
+JOIN strategy_knowledge sk ON sk.knowledge_id = k.id
+WHERE rk.requirement_id = 'REQ_003'
+  AND sk.strategy_id = 'strategy-abstract-p17'
+  AND k.version = 'howard_strategy_instance';
+```
+
+### Q3: 某 abstract pattern 跨多少 req 被使用(pattern 价值分析)
+
+```sql
+SELECT COUNT(DISTINCT rs.requirement_id) reuse_count
+FROM requirement_strategy rs
+WHERE rs.strategy_id = 'strategy-abstract-p01';
+-- 返回:22(P01 最高复用度)
+```
+
+### Q4: 某 knowledge 精确用到了哪些 cap(作为约束验证)
+
+```sql
+SELECT c.id, c.name FROM knowledge_capability kc
+JOIN capability c ON c.id = kc.capability_id
+WHERE kc.knowledge_id = 'knowledge-stratinst-xxx';
+```
+
+---
+
+## 四、新旧数据隔离
+
+所有新增数据用 `version='howard_strategy_instance'` 标记:
+
+```sql
+-- 只看新增的 strategy instance knowledge
+WHERE k.version = 'howard_strategy_instance';
+
+-- 不动老 knowledge
+WHERE k.version = 'v0';  -- 1092 行
+```
+
+abstract strategy 也用同一 version 标记(26 行)。
+
+---
+
+## 五、复用度 TOP 8 pattern(抽象化的价值证明)
+
+| Pattern | 复用次数 | 服务 reqs |
+|---|---|---|
+| 结构化提示词单步直出套路 | **22** | 各种直出场景(图文/色调/单步生成)|
+| ComfyUI 节点链精控套路 | 11 | 精细控制类需求 |
+| 多图层分层合成套路 | 9 | 人物+场景合成类 |
+| 网格/分镜一次性直出套路 | 7 | 九宫格/分镜类 |
+| 参考图垫图控制套路 | 7 | 版式迁移/风格锁定 |
+| 智能抠图 + 拼贴排版套路 | 7 | 拼贴类 |
+| AI 图内文字渲染 + 排版套路 | 7 | 含文字排版的图 |
+| Coze 工作流编排全自动套路 | 7 | 大规模自动化 |
+
+这些 pattern 验证了"方法论跨 req 迁移"的抽象价值。例如 `P01 结构化提示词单步直出` 方法在 22 种不同视觉需求下都适用。
+
+---
+
+## 六、已知数据缺口
+
+| 问题 | 数量 | 原因 | 是否关键 |
+|---|---|---|---|
+| knowledge 无 cap 链接 | 10 条 | 源 strategy 用了 LLM 自造的 cap ID(如 `aigc_color_unification`),alias 和 fuzzy 都对不上 | 边缘情况,不影响主功能 |
+| coverage_score 为 null | 42 条 req_strategy | 13 个 req 的 coverage_scores.json 缺失 + 部分 alt 名字 fuzzy 对不上 | 接受,查询时要处理 null |
+| 5 个占位 strategy(004/031/053/066/070)| Phase 1 已修 | 源 strategy.json 用非标准 schema | 已解决 |
+
+---
+
+## 七、约束验证(回归测试)
+
+✅ **每 req 有完整关系**(99/99)
+- requirement_strategy: 99/99 覆盖
+- requirement_knowledge: 99/99 覆盖
+- requirement_resource: 99/99 覆盖
+- requirement_capability: 99/99 覆盖
+
+✅ **核心约束保持**
+- `knowledge_cap ⊆ req_cap`(经 requirement_knowledge):0 违规
+- 每 req 至多 1 条 is_selected=TRUE:✓
+
+⚠️ **约束变更(由设计决定)**
+- 旧约束 `strat_cap ⊆ req_cap`(经 requirement_strategy):3674 违规 —— 这是**预期**的,因为 abstract strategy_capability 是联合集,不再是单 req 的 compose 子集。新的同等约束转移到 knowledge 层(见上)
+
+---
+
+## 八、备份可回滚
+
+`bk_20260422_*` 6 张快照表保留:
+- `bk_20260422_strategy`(99 行)
+- `bk_20260422_requirement_strategy`(99 行)
+- `bk_20260422_strategy_capability`(703 行,Phase 1 后 + Phase 2 前快照)
+- `bk_20260422_strategy_resource`(2736 行)
+- `bk_20260422_strategy_knowledge`(0 行)
+- `bk_20260422_knowledge`(1067 行)
+
+**确认无问题后**可 `DROP TABLE bk_20260422_*;` 释放空间。建议保留 7 天以防万一。
+
+---
+
+## 九、全链路脚本清单
+
+Phase 0 → Phase 6 涉及的脚本都在 `knowhub/scripts/`:
+
+| 脚本 | 阶段 | 作用 |
+|---|---|---|
+| `salvage_placeholder_strategies.py` | Phase 1 | 修 5 个占位 strategy 的 body |
+| `backup_before_strategy_refactor.py` | Pre-Phase 2 | DB 内快照 |
+| `phase2_schema_migration.py` | Phase 2 | ALTER requirement_strategy + 检查 junctions |
+| `ingest_all_strategies.py` | Phase 2 | 入库全部 strategy(含备选),填 coverage |
+| `abstract_patterns.py` | Phase 3 | 27 pattern 定义 + 193→pattern 映射 |
+| `phase4_5_migrate.py` | Phase 4+5 | 主迁移脚本 |
+| `phase5_add_knowledge_capability.py` | Phase 5 补 | 创建 knowledge_capability + 从 backup 恢复 |
+| `phase5_fill_alt_knowledge_capability.py` | Phase 5 补 | 从源 JSON 补 alt 的 cap 链接 |
+| `phase5_fuzzy_fill_remaining.py` | Phase 5 补 | 最后 12 条的 fuzzy 匹配尝试 |
+
+---
+
+## 十、下一步可能优化(非紧急)
+
+1. **精细化 knowledge_resource**:当前是 req 级联合(每 knowledge 链到 req 的所有 case),可精化为从 `body.source` 字段逐 case 匹配,实现 knowledge 粒度的精确引用
+2. **解决 10 条无 cap knowledge**:人工判断 LLM 自造 cap 名 → 现有 canonical 的映射,加入 LLM_RENAMES 或 LEGACY_REFS
+3. **Pattern 边界动态调优**:实际使用一段时间后,看哪些 pattern 被频繁搜到、哪些总是空查,据此拆/合
+4. **capability_tool 补全**:本次没涉及,是独立的待办(见 2026-04-21 handoff §5 待办 2)
+5. **embedding 重算**:所有 strategy / capability / knowledge 的 embedding 字段目前空(task/content 嵌入未算),启用语义搜索前需要批量生成

+ 459 - 0
knowhub/docs/API_INTERFACE_NOTES.md

@@ -0,0 +1,459 @@
+
+## 接口清单
+
+| 接口 | 方法 | 作用 |
+|------|------|------|
+| `https://pattern.aiddit.com/api/pattern/category_tree` | `GET` | 获取分类树扁平节点列表,用于组装 `category_tree.json` |
+| `https://pattern.aiddit.com/api/pattern/tools/get_library_frequent_itemsets/execute` | `POST` | 获取 execution 对应的频繁项集库 |
+| `https://pattern.aiddit.com/api/pattern/tools/get_itemset_detail/execute` | `POST` | 根据 `itemset_id` 获取 itemset 详情和 `post_ids` |
+| `https://pattern.aiddit.com/api/pattern/posts/batch` | `POST` | 根据 `post_id` 批量获取帖子详情 |
+
+说明:
+
+- 实测时间:`2026-04-22`
+- 当前文档只保留仍有实际用途的接口
+
+---
+
+## 接口详情
+
+### 1. 分类树接口
+
+**接口**
+
+```text
+GET https://pattern.aiddit.com/api/pattern/category_tree
+```
+
+**用途**
+
+- 获取完整分类树节点
+- 返回的是扁平 `categories`
+- 本地再组装成 `category_tree.json`
+
+对应代码:
+
+- [collect_node_data.py](./collect_node_data.py)
+
+**参数**
+
+#### `execution_id`
+
+- 位置:query 参数
+- 是否必填:是
+- 示例:
+
+```bash
+curl "https://pattern.aiddit.com/api/pattern/category_tree?execution_id=56"
+```
+
+**请求格式**
+
+```text
+GET /api/pattern/category_tree?execution_id=56
+```
+
+**返回格式**
+
+```json
+{
+  "success": true,
+  "categories": [
+    {
+      "id": 14886,
+      "source_stable_id": 57,
+      "source_type": "实质",
+      "name": "装饰元素",
+      "description": "用于装饰或美化的图案、纹理、文字等视觉元素",
+      "category_nature": "领域",
+      "path": "/表象/符号/装饰符号/装饰元素",
+      "level": 4,
+      "parent_id": 15718,
+      "element_count": 8,
+      "elements": []
+    }
+  ]
+}
+```
+
+常见字段:
+
+- `id`
+- `source_stable_id`
+- `source_type`
+- `name`
+- `description`
+- `category_nature`
+- `path`
+- `level`
+- `parent_id`
+- `element_count`
+- `elements`
+
+### 2. 频繁项集库接口
+
+**接口**
+
+```text
+POST https://pattern.aiddit.com/api/pattern/tools/get_library_frequent_itemsets/execute
+```
+
+**用途**
+
+- 获取一个 execution 对应的频繁项集库
+- 当前最稳定的用法是“按 execution 取全库”
+
+对应代码背景:
+
+- 旧代码原本调用的是 `get_frequent_itemsets`
+- 当前可用的是 `get_library_frequent_itemsets`
+
+**参数**
+
+#### `execution_id`
+
+- 位置:body 顶层
+- 是否必填:是
+- 最小可用请求:
+
+```bash
+curl -X POST "https://pattern.aiddit.com/api/pattern/tools/get_library_frequent_itemsets/execute" \
+  -H "Content-Type: application/json" \
+  -d '{"execution_id":56}'
+```
+
+#### `args.top_n`
+
+- 位置:`args` 内
+- 是否必填:否
+- 作用:限制返回展示条数
+- 示例:
+
+```bash
+curl -X POST "https://pattern.aiddit.com/api/pattern/tools/get_library_frequent_itemsets/execute" \
+  -H "Content-Type: application/json" \
+  -d '{"execution_id":56,"args":{"top_n":5}}'
+```
+
+#### `args.sort_by`
+
+- 位置:`args` 内
+- 是否必填:否
+- 作用:当前至少不会报错
+- 实测值:`absolute_support`
+- 示例:
+
+```bash
+curl -X POST "https://pattern.aiddit.com/api/pattern/tools/get_library_frequent_itemsets/execute" \
+  -H "Content-Type: application/json" \
+  -d '{"execution_id":56,"args":{"sort_by":"absolute_support"}}'
+```
+
+#### `args.category_ids`
+
+- 位置:`args` 内
+- 是否必填:否
+- 说明:沿用旧接口写法时会返回空结果
+- 当前不建议使用
+
+**请求格式**
+
+最小请求:
+
+```json
+{
+  "execution_id": 56
+}
+```
+
+带参数请求:
+
+```json
+{
+  "execution_id": 56,
+  "args": {
+    "top_n": 20,
+    "sort_by": "absolute_support"
+  }
+}
+```
+
+**返回格式**
+
+顶层返回:
+
+```json
+{
+  "success": true,
+  "tool_name": "get_library_frequent_itemsets",
+  "result": "{\"total\": 612, \"showing\": 20, \"groups\": {...}}"
+}
+```
+
+注意:
+
+- `result` 是 JSON 字符串
+- 需要再解析一次
+
+解析后格式:
+
+```json
+{
+  "total": 612,
+  "showing": 20,
+  "groups": {
+    "full/max": {
+      "dimension_mode": "full",
+      "target_depth": "max",
+      "total": 612,
+      "itemsets": [
+        {
+          "id": 132046,
+          "item_count": 3,
+          "absolute_support": 28,
+          "items": [
+            {
+              "point_type": "关键点",
+              "dimension": "形式",
+              "category_id": 15055,
+              "category_path": "架构>修辞>...>拟人化主体"
+            }
+          ]
+        }
+      ]
+    }
+  }
+}
+```
+
+常见字段:
+
+- 顶层:`total` `showing` `groups`
+- 分组:`dimension_mode` `target_depth` `total` `itemsets`
+- itemset:`id` `item_count` `absolute_support` `items`
+- item:`point_type` `dimension` `category_id` `category_path`
+
+### 3. Itemset 详情接口
+
+**接口**
+
+```text
+POST https://pattern.aiddit.com/api/pattern/tools/get_itemset_detail/execute
+```
+
+**用途**
+
+- 根据 `itemset_id` 获取 itemset 详情
+- 结果中包含 `post_ids`
+
+对应代码:
+
+- [collect_node_data.py](./collect_node_data.py)
+- [get_itemset_posts.py](./get_itemset_posts.py)
+
+**参数**
+
+#### `execution_id`
+
+- 位置:body 顶层
+- 是否必填:是
+
+#### `args.itemset_ids`
+
+- 位置:`args` 内
+- 是否必填:是
+- 类型:整数数组
+
+**请求格式**
+
+```json
+{
+  "execution_id": 56,
+  "args": {
+    "itemset_ids": [132046]
+  }
+}
+```
+
+**请求示例**
+
+```bash
+curl -X POST "https://pattern.aiddit.com/api/pattern/tools/get_itemset_detail/execute" \
+  -H "Content-Type: application/json" \
+  -d '{"execution_id":56,"args":{"itemset_ids":[132046]}}'
+```
+
+**返回格式**
+
+顶层返回:
+
+```json
+{
+  "success": true,
+  "tool_name": "get_itemset_detail",
+  "result": "[{...}]"
+}
+```
+
+注意:
+
+- `result` 是 JSON 字符串
+- 需要再解析一次
+
+解析后格式:
+
+```json
+[
+  {
+    "id": 132046,
+    "dimension_mode": "full",
+    "target_depth": "max",
+    "item_count": 3,
+    "absolute_support": 28,
+    "support": 0.0,
+    "items": [],
+    "post_ids": []
+  }
+]
+```
+
+常见字段:
+
+- `id`
+- `dimension_mode`
+- `target_depth`
+- `item_count`
+- `absolute_support`
+- `support`
+- `items`
+- `post_ids`
+
+### 4. 帖子批量详情接口
+
+**接口**
+
+```text
+POST https://pattern.aiddit.com/api/pattern/posts/batch
+```
+
+**用途**
+
+- 根据一批 `post_id` 获取帖子详情
+- 常用于:
+  - 判断平台是不是 `xiaohongshu`
+  - 获取标题、正文、图片、作者等字段
+
+对应代码:
+
+- [collect_node_data.py](./collect_node_data.py)
+- [sort_requirements.py](./sort_requirements.py)
+- [data_preparation/post_collector.py](./data_preparation/post_collector.py)
+- [visualize/fetch_xhs_posts.py](./visualize/fetch_xhs_posts.py)
+
+**参数**
+
+#### `post_ids`
+
+- 位置:body 顶层
+- 是否必填:是
+- 类型:字符串数组
+
+**请求格式**
+
+```json
+{
+  "post_ids": [
+    "671f7fab000000003c01fffc",
+    "63712737"
+  ]
+}
+```
+
+**请求示例**
+
+```bash
+curl -X POST "https://pattern.aiddit.com/api/pattern/posts/batch" \
+  -H "Content-Type: application/json" \
+  -d '{"post_ids":["671f7fab000000003c01fffc","63712737"]}'
+```
+
+**返回格式**
+
+```json
+{
+  "success": true,
+  "posts": [
+    {
+      "post_id": "671f7fab000000003c01fffc",
+      "title": "...",
+      "body_text": "...",
+      "images": [],
+      "like_count": 0,
+      "comment_count": 0,
+      "collect_count": 0,
+      "platform": "xiaohongshu",
+      "platform_account_name": "...",
+      "publish_date": "...",
+      "decode_result": {}
+    }
+  ]
+}
+```
+
+常见字段:
+
+- `post_id`
+- `title`
+- `body_text`
+- `images`
+- `like_count`
+- `comment_count`
+- `collect_count`
+- `platform`
+- `platform_account_name`
+- `publish_date`
+- `decode_result`
+
+---
+
+## 特殊情况
+
+### 1. `execution_id` 的传法不统一
+
+- `category_tree`:放在 query 参数
+- `get_library_frequent_itemsets`:放在 body 顶层
+- `get_itemset_detail`:放在 body 顶层
+
+### 2. `get_library_frequent_itemsets` 和旧接口不是简单改名
+
+虽然名字相近,但当前行为不同:
+
+- 只传 `execution_id` 就能返回整库 itemset
+- `args.top_n` 有效
+- 旧接口常用的 `category_ids` 传进去会得到空结果
+
+所以当前不要直接把旧接口 payload 原样搬过去。
+
+### 3. `result` 字段常常是字符串
+
+两个工具接口:
+
+- `get_library_frequent_itemsets`
+- `get_itemset_detail`
+
+返回里都有:
+
+```json
+{
+  "result": "..."
+}
+```
+
+这里的 `result` 不是最终对象,而是 JSON 字符串,需要再解析一次。
+
+### 4. 旧频繁项集接口已不可用
+
+当前:
+
+- `get_frequent_itemsets/execute` 会返回“不允许调用工具”
+- 如果需要 itemset 数据,应改用 `get_library_frequent_itemsets/execute`

+ 90 - 0
knowhub/docs/dashboard-filtering-design.md

@@ -0,0 +1,90 @@
+# Dashboard 筛选与多选过滤逻辑详细设计
+
+这个进阶方案在前面“禁止同级攀附”的基础上,完善了对**关联底座的优化**、**中间环节双向过滤**以及**跨层多选**的逻辑定义。这是接下来对 `Dashboard.tsx` 及其附属组件进行重构时的业务指导原则。
+
+## 一、 数据源解耦前置假设(后端配合)
+
+鉴于后续需求与 Node/Pattern 的关联将转为数据库连表记录,这给前端性能和逻辑纯粹性带来了巨大提升:
+- **摒弃动态正则比对**:前端无需在 `useMemo` 里处理 `pattern.leaf_names` 与 `req.source_nodes` 的字符串包含、相交计算。
+- **图结构一致性**:关联表落地后,在前端看来,**“呈现(Node/Pattern) $\leftrightarrow$ 需求(Req)”** 跟 **“需求(Req) $\leftrightarrow$ 能力(Cap)”** 在数据结构和遍历策略上变得完全一致。全图彻底转化为基于明确 ID 的节点边网络。
+
+---
+
+## 二、 单选中间环节:双向过滤 (Pivot Filtering)
+
+如果用户不仅能点选两端的“源头(Node/Req)”或“底层(Tool)”,而是在中间点选了一项(例如:某一个**能力 (Capability)** 或 某一个**工序 (Proc)**),筛选逻辑应该如何表现?
+
+**设计结论:支持双向严格映射,但绝对禁止路径上的“U型掉头”(U-Turn)。**
+
+以选中【能力(Cap_A):文案润色】为例,我们把它作为枢轴(Pivot Node),向左右散发:
+*   **向左(Upstream / 向上溯源)**:展示“我们要在哪里用到这个能力?”
+    *   亮起直接组合了该能力的 **工序 (Proc)**。
+    *   亮起直接依赖此能力的 **需求 (Req)**,以及被上述工序满足的需求。
+    *   亮起提出这些需求的 **特征/场景 (Node/Pattern)**。
+*   **向右(Downstream / 向下找落地球元)**:展示“这个能力由什么具体手段实现?”
+    *   亮起具备该能力的 **工具 (Tool)**。
+
+**【禁 U 型掉头原则】的实际表现:**
+向左走到【工序 P】时,工序 P 可能同时除了【文案润色 Cap_A】外,还组合了【图片生成 Cap_B】。但我们的搜索路径在这里**只能继续向左(去推导Req)**,**绝不能转个弯向右**去点亮【图片生成 Cap_B】相关的卡片,更不能顺着 Cap_B 把所有绘图工具牵扯出来。
+*(这就完美解决了“选拔一个能力,弹出一堆毫不相干的配套能力”的连坐噪音)*。
+
+---
+
+## 三、 超级复选与组合筛选逻辑设计
+
+业务中经常会出现复杂交叉筛选,例如“我想看关于【文旅场景 Node_A】的需求中,有几个是用上了【GPT-4 Tool_B】的”。这就要求支持跨列(甚至同列)多选。
+
+我们采用 **“同维并集 (OR) + 跨维交集 (AND)”** 模型。这套“路径约束图”算法是重构代码时的核心骨架。
+
+### 1. 同列内多选:交集(AND)为主,支持切换并集(OR)
+*   **操作**:用户在【特征树/节点列】同时勾选了【节点A】和【节点B】;或在【能力列】同时勾选了【语音识别 Cap_1】和【文字生成 Cap_2】。
+*   **业务动机**:
+    *   **组装/相交查询(默认 AND)**:“我想找同时包含节点A和节点B这两个特征组合的高频 Pattern”、“我要找同时运用了这两种能力的具体工序(看复杂多模态融合的例子)”。这就要求对**组合关系**作极其精准的收口。
+    *   **拓宽盘子(可选 OR 切换)**:“我想看看只要具备这两种能力之一的所有盘子有多大”。
+*   **逻辑**:
+    *   **默认模式(AND)**:当同列复选多项时,只有那些在下一跳(乃至后续路径)中**同时**通过有效边连接到这些所有选项的节点,才会被保留。例如,只有 `Pattern1 -> NodeA` 且 `Pattern1 -> NodeB` 都成立时,基于这组复选推导出的网络才是以 Pattern1 为根的。
+    *   **交互支持**:建议在各个 Column 的表头增加一个隐性或显性的类似 `[AND / OR]` 的小 Toggle 按键,让用户自由决定同维度的查询性质(即“满足所有”还是“满足其一”)。
+
+### 2. 跨列多选:交集(Intersection / AND)
+*   **操作**:用户同时选中了【需求 Req_X】和【工具 Tool_Y】。
+*   **业务动机**:增加条件限制,做归因分析。“这个需求被很多工具满足,我想单独看 Tool_Y 是怎么介入到 Req_X 这个案子里的”。
+*   **逻辑(漏斗交集算法)**:
+    1. 前端先依据前面的“单选逻辑”,算出 Req_X 向右衍生出的整个闭包图网络 `Graph_ReqX`。
+    2. 算出 Tool_Y 向左逆推衍生出的闭包图网络 `Graph_ToolY`。
+    3. 取这两个网络的有效交集节点与连线:`VisibleGraph = Graph_ReqX ∩ Graph_ToolY`。
+*   **UI 视觉表现**:
+    *   如果在 Req_X 到 Tool_Y 之间完全没有链路关联(比如 Req_X 处理图片,Tool_Y 是搞文本的爬虫),这两者没有任何交集连线。结果是:两者高亮,但中间地带全部一片灰暗。明确告诉用户:“此路不通,这俩组合无解”。
+    *   如果两者有关联,那么界面上**只会保留直接把 Req_X 和 Tool_Y 串起来的特定的工序和能力节点**。这极大去除了噪音,能让业务人员一眼看穿这个特定的工具在这个特定的需求流里充当了什么具体的环节。
+
+## 总结:给前端代码重构的算法建议
+
+为了满足上述复杂的全链路有向交互,接下来的重构不要在视图层继续写几十个零散的 `useMemo` 或递归了。我们可以:
+1. **统一初始化一张有向图 (Graph)**:使用邻接表(Adjacency List)在浏览器内存中建立 `Node -> Req -> Proc -> Cap -> Tool` 及其反向关系的图。
+2. **抽象通用探路器 (Pathfinder)**:实现一个禁止 U-turn 的 `BFS/DFS` 遍历函数。
+3. **状态机过滤**:
+   维护全局状态:`selections = { reqs: Set, caps: Set, tools: Set ... }`。
+   每次选中变动,针对 selections 里的每个层级先取**并集**探路,再将不同层级探出的结果子图取**交集**,最后把结果子图注入给 UI 视图去亮起组件即可。这种方式代码行数能大幅精简 60% 以上,性能和健壮性也将得到指数级改善。
+
+## 四、 卡片状态定义与交互排序定序 (Item States & Sorting)
+
+在复杂的由点及面的探索过程中,卡片如果因为失去焦点、或匹配值变动而在列表中“上下乱飞”(Layout Shift),会严重破坏用户的空间记忆。因此我们需要专门设计状态与排序机制。
+
+### 1. 三层卡片视觉状态
+在任何时刻,全盘卡片仅会处于以下三种确定的物理状态之一:
+*   **状态 `Active` (枢纽选中)**:用户主动点选的“条件锚点”(你点的那些源按钮)。
+    *   *视觉*:强烈的边框高光、指示态样式(例如醒目的 Orange/Sky 边框加亮),表明它是过滤漏斗的发起者。
+*   **状态 `Matched` (有效连通)**:没有被主动选中,但在当前的过滤漏斗交集中**幸存下来**的强关联者。
+    *   *视觉*:保持原始正常的颜色的清晰度(Opacity 100%),可以正常交互与查看抽屉。
+*   **状态 `Dimmed` (掉线无关)**:在交集网络之外,因条件限制或路径不通被舍弃的卡片。
+    *   *视觉*:大幅度变为去色的灰阶或透明度降至 30%(Opacity 30%)。**注意:不直接 `display: none` 隐藏**,以防止列表突然塌陷。
+
+### 2. 置顶与防跳排序逻辑 (Pinning & Frozen Ordering)
+
+结合你提出的核心痛点(*上一轮发现感兴趣的节点,取消旧筛选后找不到它*),我们必须对纯冻结做改良,引入**“选中即置顶(Pin to Top)”**机制,这既能防跳跃,又能锁住焦点:
+
+1. **选中项终极置顶(Active Floating)**:只要卡片被你**主动点击选中(变为 Active 状态)**,它必须立刻脱离原本长列表,**置顶吸附**在该列的最顶端!
+2. **平滑过渡分析法(解决痛点)**:
+   * 原操作导致丢失的原因:看到感兴趣节点 $\rightarrow$ 直接取消旧筛选 $\rightarrow$ 感兴趣节点失去 `Matched` 落回大海深处。
+   * **新的标准交互流**:看到感兴趣的节点 $\rightarrow$ **先点击它(它立刻吸顶锁定,变为 Active)** $\rightarrow$ **此时再取消原有的旧筛选条目**。
+   * *结果*:由于感兴趣的节点已经被判定为“上浮且置顶的 Active 参数”,当你取消旧条件时,它岿然不动地留在该列顶部,并且仪表盘顺滑地过渡为“围绕这个新节点向上/下推导发散”的全新视角。完全不会有找不到的头疼事。
+3. **未选中项的静默(Dimmed Freezing)**:只有处于 `Active` 状态的卡片享有置顶特权。剩下的卡片(包括被连线波及的高亮 `Matched` 项和无关灰阶的 `Dimmed` 项)的相对内部顺序一定要锁死不跳,从而捍卫视觉的空间记忆。配合列头的 `[收起全列无关灰阶卡片]` 按键,不仅防跳跃,更兼顾了密集信息的阅读折叠。

+ 44 - 1
knowhub/docs/schema.md

@@ -133,6 +133,10 @@ Embedding 模型:`google/gemini-2.5-flash-lite`(通过 OpenRouter)。实
 - `knowledge_ids` / `knowledge_links` ← requirement_knowledge
 - `resource_ids` ← requirement_resource
 - `strategy_ids` ← requirement_strategy(满足该需求的 strategy)
+- `node_refs` ← requirement_node(外部内容树节点引用,带 execution_id)
+- `pattern_refs` ← requirement_pattern(外部 itemset 引用,带 execution_id)
+
+注:`source_nodes` JSONB 列保留为快照字段,正规化后权威来源是 `requirement_node`;新数据应同步写入 junction 表。
 
 ### capability — 原子能力表
 
@@ -197,7 +201,7 @@ Embedding 模型:`google/gemini-2.5-flash-lite`(通过 OpenRouter)。实
 
 ---
 
-## 关联表(14
+## 关联表(16
 
 ### 实体链(3)
 
@@ -333,6 +337,45 @@ PK: (source_id, target_id, relation_type)
 
 PK: (tool_id, provider_id)
 
+### 外部引用(2)
+
+外部系统(pattern.aiddit.com)持有的实体,我们不建实体表、不持权威副本,只在 junction 里保存外部 ID + `execution_id`(外部数据快照版本)。外部 ID 无 FK 约束;`execution_id` 必须显式记录,因为外部 ID 在不同 execution 之间语义会漂移。
+
+**这两张表都带自己的 `version` 列,与"实体链"/"来源链"等其它 junction 不同**。原因:本类 junction 不是由实体状态确定性推出,而是**标注/算法产出**——同一 (requirement, node, execution) 可能被不同算法/标注批次映射成不同结果,需要多版本共存对比。`version` 必须进 PK。
+
+三种版本维度在本库并存、正交,不可混淆:
+
+| 维度 | 载体 | 语义 |
+|------|------|------|
+| 实体多租户版本 | `requirement.version` 等实体表 | 我方数据隔离(`v0` / `tao_dev_1`),junction 一般通过 JOIN 继承 |
+| 外部快照版本 | `*.execution_id`(本节两张表) | 外部系统的树/pattern 版本,当前 `56` |
+| 关系标注版本 | `*.version`(本节两张表) | 本次映射由哪个算法/人员产出(`v0` / `ruotian` 等) |
+
+**requirement_node** — 需求来源的内容树节点(外部)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `requirement_id` | VARCHAR | → requirement.id |
+| `node_id` | INTEGER | 外部 `category.id`(pattern.aiddit.com/category_tree) |
+| `execution_id` | INTEGER | 外部数据快照版本,当前使用 `56` |
+| `version` | VARCHAR(32) | 关系标注版本,DEFAULT `'v0'` |
+| `node_path` | TEXT | 节点路径快照(排查用,不保证与外部同步;权威数据需现场调 API) |
+
+PK: (requirement_id, node_id, execution_id, version)
+INDEX: (node_id, execution_id) — 反查"某节点产生了哪些需求"
+
+**requirement_pattern** — 需求来源的频繁项集(外部)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `requirement_id` | VARCHAR | → requirement.id |
+| `itemset_id` | INTEGER | 外部 `itemset.id`(pattern.aiddit.com/get_library_frequent_itemsets) |
+| `execution_id` | INTEGER | 外部数据快照版本,当前使用 `56` |
+| `version` | VARCHAR(32) | 关系标注版本,DEFAULT `'v0'` |
+
+PK: (requirement_id, itemset_id, execution_id, version)
+INDEX: (itemset_id, execution_id) — 反查"某 pattern 产生了哪些需求"
+
 ---
 
 ## Embedding 策略

+ 290 - 0
knowhub/docs/strategy_abstraction_methodology.md

@@ -0,0 +1,290 @@
+# Strategy 抽象化合并方法论
+
+**适用场景**:已有 N 条 req-specific 的具体 strategy(每条是"为 req X 设计的解法"),
+想把它们合并成 M 条抽象 pattern(每条是"跨 req 可迁移的方法套路 / skill"),
+N 通常 100-300,M 目标 20-40。
+
+**记录时间**:2026-04-22(193 → 27 实操时)
+
+---
+
+## 一、核心立场
+
+> **strategy 作为"skill"的价值在于迁移复用**。合并的目的是识别"同一种做事方法"
+> 跨 req 重现的规律,不是机械压缩数量。
+
+因此:
+- 合不合的唯一标准是**方法论本身是否相同**,不是 cap 集合是否重叠
+- **宁可过细不过粗**——过细还能查询,过粗会让后续工作失真
+- **不信任自动化聚类结果**,但可作为起点
+- **不用 embedding**(LLM 生成的 strategy 文本有大量命名漂移,embedding 对"方法差异"敏感度低)
+
+---
+
+## 二、五步执行流程
+
+### Step 1 · 准备特征数据
+
+对每条 strategy 提取以下信号:
+
+| 信号 | 用途 |
+|---|---|
+| **cap_signature**(set of canonical cap ids) | 机械粗筛基础 |
+| **name**(strategy 名) | 理解命名者的本意 |
+| **phase 列表**(phase 名 + 简述) | 理解执行步骤 |
+| **reasoning**(选择理由) | 理解作者强调的差异点 |
+| **body.source** / workflow_outline 里的 implements | 关键技术锚点(工具/模型/参数)|
+
+⚠️ 不要只取名字——名字往往是被 LLM 即兴起的,方法论藏在 phases 和 reasoning 里。
+
+### Step 2 · 机械粗筛(不是最终结果)
+
+算法选择:**中心贪心 + Jaccard 相似度**
+
+```python
+# 按 cap_count 降序排列(cap 多的做中心候选)
+indices.sort(key=lambda i: -cap_count[i])
+
+for center_idx in indices:
+    if assigned: continue
+    members = [center_idx]
+    for other in indices:
+        if other not in assigned and Jaccard(cap[center], cap[other]) >= threshold:
+            members.append(other); assigned.add(other)
+    clusters.append(members)
+```
+
+**关键选择**:
+
+- ❌ 不用传递闭包:A-B=0.6, B-C=0.6 不代表 A-C 相似,容易滚成假大簇
+- ✅ 中心-成员结构:每个簇所有成员都和中心相似,不跨中心扩散
+- **阈值 0.5** 是起点(0.4 太松、0.7 太紧;193 条时 0.5 产出 67 簇适合审)
+- 0-cap strategy 单独处理(没有 signature 可比)
+
+粗筛产出:候选簇 + 单例。把每个簇的成员(带 name/phases/reasoning)输出到 md,供下一步人工判断。
+
+### Step 3 · 人工判断(核心环节)
+
+**不要信任粗筛结果**。逐簇读内容,问三个问题:
+
+#### 判断三问(按优先级)
+
+**Q1:方法论主轴是否相同?**(决定能否合并)
+
+方法论主轴即"怎么做"的根本路径。典型主轴:
+
+| 主轴 | 判别特征 |
+|---|---|
+| 提示词直出 | 核心是单条 prompt,无多节点 |
+| 工作流编排 | Coze/ComfyUI/Lovart 串节点,LLM + 批量生图 + 后处理 |
+| 参考图驱动 | 上传图做 anchor(风格/主体/构图)|
+| 局部重绘 | Inpaint 蒙版,主体保持其余换 |
+| 分层合成 | 分别生成底/人/光/字再合 |
+| 双图融合 | 主体 + 目标两张图合一 |
+| 模板填充 | 预设模板 + 数据源灌入 |
+| 图生视频 | 静态图作首帧/尾帧驱动视频 |
+
+主轴不同 → **不合并**(即使 cap 完全一样)。
+
+**Q2:技术锚点是否相同?**(决定同主轴下要不要再拆)
+
+技术锚点 = 工具生态 / 模型 / 关键参数:
+- Coze vs ComfyUI(都"工作流",但技能栈完全不同)
+- Midjourney --sref vs IP-Adapter vs ControlNet(都"参考图驱动",但技术路径不同)
+- Nano Banana 多模态 vs GPT-Image 2 文字渲染(都"AI 直出",但模型特定能力不同)
+
+技术锚点不同 → 同主轴也拆开(他们是不同的 skill)。
+
+**Q3:产出形态是否与合并相容?**(次要维度)
+
+- 一条出九宫格一条出长图,方法(Coze 批量拼合)完全一致 → **合并**
+- 方法相同但产出不同 → 产出是参数层的事,不影响合并
+- 产出相同但方法不同 → 不合并(产出不是方法论)
+
+#### 决策树
+
+```
+两条 strategy 应该合并吗?
+├─ 方法论主轴相同吗?
+│   ├─ No → 不合并
+│   └─ Yes ↓
+├─ 技术锚点相同吗?(工具/模型/关键技术)
+│   ├─ No → 不合并(同主轴但不同 skill)
+│   └─ Yes ↓
+└─ 合并(产出形态差异可接受)
+```
+
+### Step 4 · 命名抽象 strategy
+
+每个 pattern 起名的规则:
+
+- 必须体现**方法论**,不是产出:❌ "九宫格生成"  ✅ "Coze 工作流编排全自动套路"
+- 用"**套路**"/"**skill**"后缀(和"路线"/"流派"这种 req-specific 叫法区分开)
+- 结构:`[工具/生态] + [核心动作] + 套路`  例如:
+  - "Nano Banana 多模态单模型直出套路"
+  - "双图融合虚拟试穿套路"
+  - "QA 闭环自动优化套路"
+  - "Character Sheet 多视角参考表套路"
+
+避免:
+- 过于通用的后缀如"套路"前不带任何区分词("AI 生成套路" 无信息量)
+- req-specific 词汇("表情包套路"太窄,改为"多情绪矩阵批量生成套路")
+- 英文术语堆砌("Prompt-driven Single-Step Generation Pattern")
+
+### Step 5 · 质量检查
+
+合并完成后验证:
+
+| 检查 | 期望 |
+|---|---|
+| 每个 pattern 的成员能用一句话描述共通做法 | ✓ |
+| pattern 数量在 20-40(针对 100-300 条 strategy)| ✓(过少过合,过多过散)|
+| 单例 pattern 不超过总量 20% | 允许 1:1 的"独特方法",但不能全是单例 |
+| 最大簇不超过总量 15% | 超过说明粒度太粗 |
+| 每条具体 strategy 被分配到一个(且仅一个)pattern | ✓ |
+
+---
+
+## 三、反模式清单
+
+**以下做法即使诱人也要避免:**
+
+### 反模式 1 · 按 cap 重合度机械合并
+- 典型:Jaccard >= 0.7 传递闭包 → 45 条归一大类
+- 问题:基础 cap(CAP-001 文生图、CAP-008 批量、CAP-014 文字)人人都用,重合度高不代表方法同
+- 对策:cap 只做粗筛,判断要看 phases + reasoning
+
+### 反模式 2 · 按 name 文本相似度合并
+- 典型:用 2-gram Jaccard 比 strategy name
+- 问题:LLM 生成的 name 命名漂移严重("3D夸张风格路线"和"多镜头拟人化故事视频流派"都用"多维"和"拟人化"词汇,但方法完全不同)
+- 对策:名字只是参考,以 phases 为准
+
+### 反模式 3 · 用 embedding 做相似度
+- 典型:把每条 strategy 文本化后 embed,cosine > 0.85 合并
+- 问题:embedding 对"讨论话题"敏感,对"方法差异"不敏感(两条都讨论"生成猫咪表情包"得分很高,但一条是 prompt 直出,一条是 ComfyUI 换装,不该合并)
+- 对策:看不见的信号不用
+
+### 反模式 4 · 用传递闭包
+- 典型:A-B=0.6、B-C=0.6,推出 A-B-C 一簇
+- 问题:A-C 可能 0.2,真实不相似,但被强行归到一起
+- 对策:中心-成员结构(每个成员必须和簇中心相似,不跨中心传递)
+
+### 反模式 5 · 按产出形态分类
+- 典型:把所有"九宫格"归一类、所有"视频"归一类
+- 问题:产出相同但方法可以天差地别;同方法应用到不同产出反而被拆开,失去迁移复用价值
+- 对策:方法优先,产出次要
+
+### 反模式 6 · 过度细分到失去抽象
+- 典型:每条 strategy 都有自己独特之处 → 27 条变 50+ pattern
+- 问题:没达到抽象目的,相当于 1:1 映射
+- 对策:强制合并"大同小异",抓主轴忽略细节差异
+
+### 反模式 7 · 过度合并到失去信息
+- 典型:所有 AI 生成归为"AI 生成套路"
+- 问题:抽象层级太高,查询时无区分度
+- 对策:至少区分"方法论 × 技术锚点"两维
+
+---
+
+## 四、边界情况处理
+
+### 情况 1 · Strategy 跨多个 pattern
+
+**示例**:REQ_045 的"AI脚本驱动·批量生图·文字嵌入·宫格自动排版一体化流派" 同时涉及:
+- Coze 工作流编排(P04)
+- 网格直出(P15)
+- AI 文字渲染(P26)
+
+**处理**:归到**主要方法**那个 pattern(通常是方法论主轴最重的那个)。这里归 P04,因为 Coze 编排是灵魂,网格和文字只是其中一个环节。
+
+### 情况 2 · 单例 strategy
+
+**示例**:只有 1 条成员的候选 pattern。
+
+**处理**:
+- 若确实是独特方法(无其他 strategy 可合并),保留为单例 pattern
+- 若是因数据稀少导致的漏判(比如 alt strategy 没生成),归到最近的 pattern
+- 判断标准:这条 strategy 的方法论是否将来可能被其他 req 采用?是则独立,否则合并
+
+### 情况 3 · 0-cap 或 body 缺失的 strategy
+
+**处理**:优先用 salvage 脚本补全 body(参考 `salvage_placeholder_strategies.py`),再参与合并。无法补全的标 `data_degraded`,不参与聚类。
+
+### 情况 4 · Selected 和 Alt 归到同一 pattern
+
+**这是正常的**。一个 req 的 selected 和 alt 如果用同一方法(只是参数不同)就应归同一 pattern。
+不同 req 各自的 alt 归到同一 pattern 也正常——说明那个方法在多 req 下都被考虑过但未被选中。
+
+### 情况 5 · 方法论混合型 strategy
+
+**示例**:一条 strategy 前半用 Coze 工作流后半用 ComfyUI 精修。
+
+**处理**:归到**阶段数占比最大**的那个 pattern。若真 50-50,归到**门槛更低**的那个(工作流 > ComfyUI)。
+
+---
+
+## 五、实操产出形式
+
+完成后给用户审核的 md 应包含:
+
+```markdown
+## P01 · 套路名
+> 一句话方法论描述
+
+**成员(N)**:
+- REQ_XXX selected/alt name
+- REQ_YYY selected/alt name
+...
+```
+
+以及汇总表:
+
+```
+| # | 套路 | 成员数 |
+|---|---|---|
+| P01 | ... | 17 |
+```
+
+用户审核时主要看:
+1. **套路命名**是否一眼看懂方法论
+2. **成员是否符合命名**(粗翻,找明显不搭的)
+3. **边界是否合理**(有没有想拆/想合的冲动)
+
+用户通常只会"看一眼没问题就行",所以方案要**一读即懂**,命名和分类要经得起 5 秒直觉判断。
+
+---
+
+## 六、后续入库建议
+
+合并方案确认后:
+
+1. **保留**全部原具体 strategy 作为 knowledge(带 `version` 标记,如 `howard_strategy_instance`)
+2. **新建**抽象 strategy(约 N/6 条,N=具体 strategy 数量)
+3. `requirement_strategy` 改 M:N:每 req 关联其具体 strategy 对应的抽象 pattern
+4. `strategy_capability` 重建为抽象 strategy ↔ cap signature(取簇内 cap 联合集或频次 ≥50% 的核心集)
+5. `strategy_resource` 为抽象 strategy ↔ 簇内所有成员 resource 联合集
+
+---
+
+## 七、本次(2026-04-22)实操验证
+
+| 指标 | 值 |
+|---|---|
+| 输入具体 strategy 数量 | 193 |
+| 机械粗筛候选簇数(Jaccard>=0.5 中心贪心)| 67(31 多簇 + 36 单例)|
+| 人工判断后最终 pattern 数 | **27** |
+| pattern 分布 | 最大 17 成员 / 平均 7 / 最小 1 |
+| 耗时(纯判断环节) | ~30 分钟 |
+
+关键观察:
+- 粗筛的 31 多簇最终**拆成了 20+ 个 pattern**(方法论主轴不同被拆)
+- 但也有**跨候选簇的 strategy 被合并**(机械聚类因 cap 不完全重合未识别,但方法论相同)
+- 说明机械聚类召回和精度都不够,**人工判断不可省**
+
+---
+
+## 参考脚本
+
+- `knowhub/scripts/salvage_placeholder_strategies.py` - 占位 strategy 修复(Step 0 前置)
+- `knowhub/scripts/ingest_all_strategies.py` - 全量入库含 alt(本方法论的输入数据准备)
+- Phase 3 聚类脚本(center-greedy clustering) - 本方法论 Step 2 的实现

+ 70 - 20
knowhub/frontend/src/components/common/SideDrawer.tsx

@@ -1,5 +1,6 @@
 import { X } from 'lucide-react';
 import { cn } from '../../lib/utils';
+import { useState, useEffect, useCallback } from 'react';
 
 interface SideDrawerProps {
   isOpen: boolean;
@@ -7,33 +8,82 @@ interface SideDrawerProps {
   title: React.ReactNode;
   children: React.ReactNode;
   width?: string;
+  defaultWidth?: number;
 }
 
-export function SideDrawer({ isOpen, onClose, title, children, width = 'w-[360px]' }: SideDrawerProps) {
+export function SideDrawer({ isOpen, onClose, title, children, width: originalWidthStr, defaultWidth = 480 }: SideDrawerProps) {
+  const [width, setWidth] = useState(defaultWidth);
+  const [isResizing, setIsResizing] = useState(false);
+
+  const startResizing = useCallback((e: React.PointerEvent) => {
+    setIsResizing(true);
+    // Capture pointer to track even when outside the browser window
+    (e.target as HTMLElement).setPointerCapture(e.pointerId);
+    e.preventDefault();
+  }, []);
+
+  const stopResizing = useCallback((e: React.PointerEvent) => {
+    setIsResizing(false);
+    (e.target as HTMLElement).releasePointerCapture(e.pointerId);
+  }, []);
+
+  const handlePointerMove = useCallback((e: React.PointerEvent) => {
+    if (!isResizing) return;
+    // Moving left (negative movementX) increases the width
+    setWidth(prev => Math.max(300, Math.min(prev - e.movementX, 1000)));
+  }, [isResizing]);
+
+  useEffect(() => {
+    if (isResizing) {
+      document.body.style.userSelect = 'none';
+      document.body.style.cursor = 'col-resize';
+    } else {
+      document.body.style.userSelect = '';
+      document.body.style.cursor = '';
+    }
+    return () => {
+      document.body.style.userSelect = '';
+      document.body.style.cursor = '';
+    };
+  }, [isResizing]);
+
+  // We use inline style for width instead of tailwind classes to allow smooth resizing
+  const drawerStyle = isOpen ? { width: `${width}px` } : { width: '0px', borderLeftWidth: '0px' };
+
   return (
     <aside
       className={cn(
-        "shrink-0 h-full overflow-hidden transition-[width,opacity,margin] duration-300 ease-in-out",
-        isOpen ? cn(width, "opacity-100 ml-4") : "w-0 opacity-0 ml-0"
+        "relative shrink-0 h-full flex flex-col bg-white border-l border-slate-200 z-10",
+        !isResizing && "transition-[width,opacity,border-color] duration-300 ease-in-out",
+        isOpen ? "opacity-100" : "opacity-0 overflow-hidden"
       )}
+      style={drawerStyle}
     >
-      <div className="h-full bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden flex flex-col">
-        <div className="border-b border-slate-100 bg-white shrink-0 px-4 py-4">
-          <div className="flex justify-between items-center gap-2">
-            <div className="text-lg font-bold text-slate-900 min-w-0 flex-1 truncate">{title}</div>
-            <button
-              onClick={onClose}
-              className="p-2 hover:bg-slate-100 rounded-lg text-slate-400 hover:text-slate-600 transition-colors"
-              title="关闭"
-              type="button"
-            >
-              <X size={18} />
-            </button>
-          </div>
-        </div>
-        <div className="flex-1 overflow-y-auto p-6 bg-slate-50">
-          {children}
-        </div>
+      <div 
+        className="absolute left-[-4px] top-0 bottom-0 w-2 hover:w-3 cursor-col-resize z-20 flex flex-col justify-center items-center group -translate-x-1/2" 
+        onPointerDown={startResizing}
+        onPointerUp={stopResizing}
+        onPointerMove={handlePointerMove}
+        onPointerCancel={stopResizing}
+        aria-label="拖拽调整宽度"
+      >
+        <div className={cn("h-16 w-1 rounded-full bg-slate-300 transition-opacity", isResizing ? "opacity-100" : "opacity-0 group-hover:opacity-100")} />
+      </div>
+
+      <div className="border-b border-slate-100 bg-white shrink-0 px-4 py-4 flex justify-between items-center gap-2">
+        <div className="text-lg font-bold text-slate-900 min-w-0 flex-1 truncate">{title}</div>
+        <button
+          onClick={onClose}
+          className="p-2 hover:bg-slate-100 rounded-lg text-slate-400 hover:text-slate-600 transition-colors"
+          title="关闭"
+          type="button"
+        >
+          <X size={18} />
+        </button>
+      </div>
+
+      <div className="flex-1 overflow-y-auto p-6 bg-slate-50 custom-scrollbar">
+        {children}
       </div>
     </aside>
   );

+ 17 - 2
knowhub/frontend/src/components/dashboard/CategoryTree.tsx

@@ -1,11 +1,12 @@
 import { useState, useRef, useEffect } from 'react';
 import { createPortal } from 'react-dom';
 import { cn } from '../../lib/utils';
-import { ChevronRight, ChevronDown, ChevronLeft, ZoomIn, ZoomOut, Maximize, FolderTree } from 'lucide-react';
+import { ChevronRight, ChevronDown, ChevronLeft, ZoomIn, ZoomOut, Maximize, FolderTree, FileText } from 'lucide-react';
 
 interface NodeProps {
   node: any;
   onSelect: (node: any) => void;
+  onOpenDetail?: (node: any) => void;
   selectedId: string | number | null;
   level: number;
   highlightLeafNames: Set<string> | null; // null = no filter active
@@ -26,7 +27,7 @@ function nodeHasHighlightedLeaf(node: any, highlightLeafNames: Set<string> | nul
   return node.children.some((child: any) => nodeHasHighlightedLeaf(child, highlightLeafNames));
 }
 
-function HorizontalTreeNode({ node, onSelect, selectedId, level, highlightLeafNames, subtreeHighlightNodeIds, sourceNodeIds, patternNodeIds, nodeMetricsMap, dimensionColor, focusedTreeNodeId = null, focusTrigger = 0 }: NodeProps) {
+function HorizontalTreeNode({ node, onSelect, onOpenDetail, selectedId, level, highlightLeafNames, subtreeHighlightNodeIds, sourceNodeIds, patternNodeIds, nodeMetricsMap, dimensionColor, focusedTreeNodeId = null, focusTrigger = 0 }: NodeProps) {
   const [expanded, setExpanded] = useState(true);
   const [hoveredMetric, setHoveredMetric] = useState<null | { key: string; count: number; colorClass: string; x: number; y: number }>(null);
   const nodeRef = useRef<HTMLDivElement>(null);
@@ -112,6 +113,16 @@ function HorizontalTreeNode({ node, onSelect, selectedId, level, highlightLeafNa
           />
         </div>
 
+        {onOpenDetail && (
+          <button
+            className="ml-2 px-1.5 py-1 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded focus:outline-none transition-colors"
+            onClick={(e) => { e.stopPropagation(); onOpenDetail(node); }}
+            title="查看详情"
+          >
+            <FileText size={13} />
+          </button>
+        )}
+
         {hasChildren && (
           <button
             className="ml-2 px-1 text-slate-400 hover:text-slate-700 focus:outline-none transition-transform"
@@ -142,6 +153,7 @@ function HorizontalTreeNode({ node, onSelect, selectedId, level, highlightLeafNa
               <HorizontalTreeNode
                 node={child}
                 onSelect={onSelect}
+                onOpenDetail={onOpenDetail}
                 selectedId={selectedId}
                 level={level + 1}
                 highlightLeafNames={highlightLeafNames}
@@ -172,6 +184,7 @@ function HorizontalTreeNode({ node, onSelect, selectedId, level, highlightLeafNa
 export function CategoryTree({
   data,
   onSelect,
+  onOpenDetail,
   selectedId,
   highlightLeafNames = null,
   subtreeHighlightNodeIds = null,
@@ -191,6 +204,7 @@ export function CategoryTree({
 }: {
   data: any;
   onSelect: (node: any) => void;
+  onOpenDetail?: (node: any) => void;
   selectedId: any;
   highlightLeafNames?: Set<string> | null;
   subtreeHighlightNodeIds?: Set<string> | null;
@@ -317,6 +331,7 @@ export function CategoryTree({
                         key={subNode.id || subIdx}
                         node={subNode}
                         onSelect={onSelect}
+                        onOpenDetail={onOpenDetail}
                         selectedId={selectedId}
                         level={1}
                         highlightLeafNames={highlightLeafNames}

+ 206 - 0
knowhub/frontend/src/components/dashboard/cards/RelationCard.tsx

@@ -0,0 +1,206 @@
+import { useEffect, useRef } from 'react';
+import { FileText } from 'lucide-react';
+import { cn } from '../../../lib/utils';
+import { DASHBOARD_COLUMN_THEME } from '../../../lib/dashboard-theme';
+
+export function RelationCard({
+  type,
+  item,
+  activeId,
+  shouldScrollIntoView = false,
+  selectedLeafNames,
+  directMatch = false,
+  dimmed = false,
+  showAllSourceTags = false,
+  metrics,
+  relationTags = [],
+  onSingleClick,
+  onSourceNodeClick,
+  onOpenDetail
+}: {
+  type: string;
+  item: any;
+  activeId: string | null;
+  shouldScrollIntoView?: boolean;
+  selectedLeafNames?: Set<string>;
+  directMatch?: boolean;
+  dimmed?: boolean;
+  showAllSourceTags?: boolean;
+  metrics?: { nodeCount: number; reqCount: number; procCount: number; capCount: number; toolCount: number; patternCount: number };
+  relationTags?: Array<{ label: string; tone?: 'pattern' | 'direct' }>;
+  onSingleClick: (nodeId: string) => void;
+  onSourceNodeClick?: (nodeName: string) => void;
+  onOpenDetail?: (item: any) => void;
+}) {
+  const cardRef = useRef<HTMLDivElement | null>(null);
+  const nodeId = `${type}:${item.id}`;
+  const isSelected = activeId === nodeId;
+
+  const formatSourceNodeTag = (sn: any): { ref: string; label: string } | null => {
+    const rawRef = typeof sn === 'object'
+      ? (sn.node_path || sn.path || sn.node_name || sn.name || '')
+      : String(sn || '');
+    if (!rawRef || rawRef === '__abstract__' || rawRef === '__meta__') return null;
+    const separator = rawRef.includes('/') ? '/' : '>';
+    const parts = rawRef.split(separator).map((part: string) => part.trim()).filter(Boolean);
+    const label = parts[parts.length - 1] || rawRef;
+    return { ref: rawRef, label };
+  };
+
+  const allSourceNodeTags: Array<{ ref: string; label: string }> = type === 'req'
+    ? (item.source_nodes || [])
+        .map((sn: any) => formatSourceNodeTag(sn))
+        .filter((tag: { ref: string; label: string } | null): tag is { ref: string; label: string } => Boolean(tag))
+    : [];
+  const sourceNodeTags = showAllSourceTags ? allSourceNodeTags : allSourceNodeTags.slice(0, 3);
+  const totalSourceNodes = type === 'req' ? allSourceNodeTags.length : 0;
+  const extraCount = type === 'req' ? Math.max(0, totalSourceNodes - 3) : 0;
+
+  const label = item.name || item.description || item.task || item.id;
+
+  const typeColors: Record<string, { accent: string; tagBg: string; tagText: string; leftBar: string }> = {
+    req:  { accent: DASHBOARD_COLUMN_THEME.req.cardAccent, tagBg: DASHBOARD_COLUMN_THEME.req.tagBg, tagText: DASHBOARD_COLUMN_THEME.req.tagText, leftBar: 'bg-cyan-400' },
+    proc: { accent: DASHBOARD_COLUMN_THEME.proc.cardAccent, tagBg: DASHBOARD_COLUMN_THEME.proc.tagBg, tagText: DASHBOARD_COLUMN_THEME.proc.tagText, leftBar: 'bg-green-400' },
+    cap:  { accent: DASHBOARD_COLUMN_THEME.cap.cardAccent, tagBg: DASHBOARD_COLUMN_THEME.cap.tagBg, tagText: DASHBOARD_COLUMN_THEME.cap.tagText, leftBar: 'bg-amber-400' },
+    tool: { accent: DASHBOARD_COLUMN_THEME.tool.cardAccent, tagBg: DASHBOARD_COLUMN_THEME.tool.tagBg, tagText: DASHBOARD_COLUMN_THEME.tool.tagText, leftBar: 'bg-orange-400' },
+    know: { accent: DASHBOARD_COLUMN_THEME.proc.cardAccent, tagBg: DASHBOARD_COLUMN_THEME.proc.tagBg, tagText: DASHBOARD_COLUMN_THEME.proc.tagText, leftBar: 'bg-green-400' },
+  };
+  const tc = typeColors[type] ?? typeColors.req;
+  const cardMetrics = metrics || { nodeCount: 0, reqCount: 0, procCount: 0, capCount: 0, toolCount: 0, patternCount: 0 };
+  
+  const metricItems = [
+    { key: 'node', count: cardMetrics.nodeCount, className: 'bg-slate-500 text-white' },
+    { key: 'pattern', count: cardMetrics.patternCount, className: DASHBOARD_COLUMN_THEME.pattern.metric },
+    { key: 'req', count: cardMetrics.reqCount, className: DASHBOARD_COLUMN_THEME.req.metric },
+    { key: 'proc', count: cardMetrics.procCount, className: DASHBOARD_COLUMN_THEME.proc.metric },
+    { key: 'cap', count: cardMetrics.capCount, className: DASHBOARD_COLUMN_THEME.cap.metric },
+    { key: 'tool', count: cardMetrics.toolCount, className: DASHBOARD_COLUMN_THEME.tool.metric },
+  ];
+  
+  const metricDots = (
+    <>
+      {metricItems.map((metric) => (
+        <span
+          key={`${nodeId}-${metric.key}`}
+          className={cn(
+            "min-w-[18px] h-[18px] px-1 rounded-full text-[10px] font-black flex items-center justify-center transition-all",
+            metric.count > 0 ? cn(metric.className, "opacity-60 hover:opacity-100 ring-2 ring-transparent hover:ring-slate-200") : "opacity-0 invisible"
+          )}
+        >
+          {metric.count > 0 ? metric.count : '0'}
+        </span>
+      ))}
+    </>
+  );
+
+  useEffect(() => {
+    if (shouldScrollIntoView && cardRef.current) {
+      cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
+    }
+  }, [shouldScrollIntoView]);
+
+  return (
+    <div
+      ref={cardRef}
+      onClick={() => {
+        if (dimmed) return;
+        onSingleClick(nodeId);
+      }}
+      className={cn(
+        "group relative p-3 rounded-xl cursor-pointer mb-2 select-none bg-white transition-all border-l-4",
+        tc.accent,
+        isSelected
+          ? "border border-orange-400 border-l-4 shadow-[0_0_0_1px_rgba(251,146,60,0.7)] z-10"
+          : directMatch
+          ? "border border-sky-300 border-l-4 bg-sky-50/70 shadow-[0_0_0_1px_rgba(125,211,252,0.5)]"
+          : dimmed
+          ? "border border-slate-200 border-l-4 bg-slate-50 opacity-45 saturate-50"
+          : "border border-transparent border-l-4 hover:border-slate-200"
+      ,
+        dimmed && "cursor-not-allowed"
+      )}
+    >
+      {onOpenDetail && (
+        <button
+          onClick={(e) => {
+            e.stopPropagation();
+            if (dimmed) return;
+            onOpenDetail(item);
+          }}
+          className={cn(
+            "absolute right-2 top-2 p-1.5 rounded-md text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors",
+            dimmed && "cursor-not-allowed hover:bg-transparent"
+          )}
+          title="查看详情"
+        >
+          <FileText size={14} />
+        </button>
+      )}
+
+      <div className="flex items-start gap-2 pr-6">
+        <div className="min-w-0 flex-1">
+          <div className="flex items-start gap-3">
+            <div className={cn("text-xs font-bold leading-snug min-w-0 flex-1", isSelected ? "text-orange-800" : "text-slate-700")}>
+              {label}
+            </div>
+            {type === 'tool' && (item.status === '已接入' || item.status === 'active' || item.status === 'available' || item.status === '已使用') && (
+              <span className="shrink-0 text-[9px] px-1.5 py-0.5 rounded-full bg-orange-100 text-orange-700 font-bold border border-orange-200">
+                可接入
+              </span>
+            )}
+          </div>
+          {sourceNodeTags.length > 0 && (
+            <div className="flex flex-wrap gap-1 mt-1.5">
+              {relationTags.map((tag) => (
+                <span
+                  key={`${nodeId}-${tag.label}`}
+                  className={cn(
+                    "text-[9px] px-1.5 py-0.5 rounded-md font-bold border",
+                    tag.tone === 'pattern'
+                      ? "bg-blue-50 text-blue-700 border-blue-200"
+                      : "bg-sky-50 text-sky-700 border-sky-200"
+                  )}
+                >
+                  {tag.label}
+                </span>
+              ))}
+              {sourceNodeTags.map((tag) => {
+                const isHighlighted = selectedLeafNames && selectedLeafNames.has(tag.label);
+                return (
+                  <span key={tag.ref} 
+                    onClick={(e) => {
+                      if (dimmed) return;
+                      if (onSourceNodeClick) {
+                        e.stopPropagation();
+                        onSourceNodeClick(tag.ref);
+                      }
+                    }}
+                    className={cn(
+                    "text-[9px] px-1.5 py-0.5 rounded-md font-medium truncate max-w-[100px]",
+                    isHighlighted ? "bg-sky-100 text-sky-700 ring-1 ring-sky-400 font-bold" : cn(tc.tagBg, tc.tagText),
+                    onSourceNodeClick && !dimmed && "cursor-pointer hover:ring-1 hover:ring-cyan-300"
+                  )}>
+                    {tag.label}
+                  </span>
+                );
+              })}
+              {!showAllSourceTags && extraCount > 0 && (
+                <span className="text-[9px] px-1.5 py-0.5 rounded-md bg-slate-100 text-slate-400">+{extraCount}</span>
+              )}
+              {type === 'req' && (
+                <div className="flex items-center gap-1.5 ml-1 transition-opacity duration-150 opacity-100">
+                  {metricDots}
+                </div>
+              )}
+            </div>
+          )}
+          {type !== 'req' && (
+            <div className="flex items-center gap-1.5 mt-2 transition-opacity duration-150 opacity-100">
+              {metricDots}
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}

+ 1207 - 0
knowhub/frontend/src/components/dashboard/details/DrawerContentComponents.tsx

@@ -0,0 +1,1207 @@
+import { useState, useEffect, useMemo, useRef, Fragment, ReactNode, WheelEvent } from 'react';
+import { createPortal } from 'react-dom';
+import { X, ChevronLeft, ChevronRight } from 'lucide-react';
+import { cn } from '../../../lib/utils';
+import { getResource, batchGetPosts } from '../../../services/api';
+
+// ─── 详情抽屉内容 ──────────────────────────────────────────────────────────────
+export function DrawerContent({ type, data, dbData, onOpenPost, nodePostsMap, selectedReqId }: { type: string; data: any; dbData: any; onOpenPost: (postId: string, post: any) => void; nodePostsMap?: Record<string, string[]>; selectedReqId?: string | null }) {
+  if (type === 'itemset') {
+    return (
+      <ItemsetPostsDrawer
+        itemset={data}
+        onOpenPost={onOpenPost}
+      />
+    );
+  }
+
+  if (type === 'req') {
+    return (
+      <RequirementPostsDrawer
+        requirement={data}
+        nodePostsMap={nodePostsMap || {}}
+        onOpenPost={onOpenPost}
+      />
+    );
+  }
+
+  const capNameById = new Map((dbData.caps || []).map((cap: any) => [cap.id, cap.name || cap.description || cap.id]));
+
+  const renderStructuredValue = (value: any, path: string): ReactNode => {
+    if (value === null || value === undefined || value === '') {
+      return <span className="text-slate-400">空</span>;
+    }
+
+    if (Array.isArray(value)) {
+      if (value.length === 0) return <span className="text-slate-400">[]</span>;
+      return (
+        <div className="space-y-2">
+          {value.map((item, index) => (
+            <div key={`${path}-${index}`} className="rounded-lg border border-slate-200 bg-white p-2">
+              <div className="text-[10px] font-mono text-slate-400 mb-1">[{index}]</div>
+              {renderStructuredValue(item, `${path}-${index}`)}
+            </div>
+          ))}
+        </div>
+      );
+    }
+
+    if (typeof value === 'object') {
+      const entries = Object.entries(value);
+      if (entries.length === 0) return <span className="text-slate-400">{'{}'}</span>;
+      return (
+        <div className="space-y-2">
+          {entries.map(([key, nestedValue]) => (
+            <div key={`${path}-${key}`} className="rounded-lg border border-slate-200 bg-white p-3">
+              <div className="text-[10px] font-bold text-slate-400 mb-1 break-all">{key}</div>
+              {renderStructuredValue(nestedValue, `${path}-${key}`)}
+            </div>
+          ))}
+        </div>
+      );
+    }
+
+    if (typeof value === 'boolean') {
+      return <span className="text-sm text-slate-700 font-medium">{value ? 'true' : 'false'}</span>;
+    }
+
+    return <div className="text-sm text-slate-700 whitespace-pre-wrap break-words leading-relaxed">{String(value)}</div>;
+  };
+
+  const normalizeList = (value: any): any[] => {
+    if (!value) return [];
+    if (Array.isArray(value)) return value;
+    if (typeof value === 'object') return Object.values(value);
+    return [];
+  };
+
+  const parseWorkflowBody = (rawBody: any, dataItem?: any) => {
+    let parsed: any = {};
+    let parseError = false;
+    try {
+      parsed = typeof rawBody === 'string' ? JSON.parse(rawBody || '{}') : (rawBody || {});
+    } catch (e) {
+      parseError = true;
+    }
+
+    let workflowOutlineRaw: any[] = [];
+    if (dataItem?.fine_steps && Array.isArray(dataItem.fine_steps) && dataItem.fine_steps.length > 0) {
+      workflowOutlineRaw = dataItem.fine_steps;
+    } else if (Array.isArray(parsed)) {
+      workflowOutlineRaw = parsed;
+    } else if (parsed && typeof parsed === 'object') {
+      workflowOutlineRaw =
+        parsed.workflow
+        || parsed.phases
+        || parsed.workflow_outline
+        || parsed.selected_strategy?.workflow_outline
+        || parsed.steps
+        || parsed.process_flow
+        || parsed.flow
+        || [];
+    }
+
+    const workflowOutline = normalizeList(workflowOutlineRaw).map((phase: any, index: number) => {
+      const phaseObj = typeof phase === 'string' ? { name: phase } : (phase || {});
+      const title = phaseObj.phase || phaseObj.name || phaseObj.title || phaseObj.step || phaseObj.module_label || `步骤 ${index + 1}`;
+      const description = phaseObj.description || phaseObj.desc || phaseObj.details || '';
+      const capsRaw = phaseObj.capabilities || phaseObj.capability || phaseObj.capability_ids || [];
+      
+      const capabilities = normalizeList(capsRaw).map((c: any) => {
+        if (typeof c === 'string') return { capability_name: c, capability_id: c, is_new: false };
+        return {
+          ...c,
+          capability_name: c.capability_name || c.name,
+          capability_id: c.capability_id || c.id,
+        };
+      });
+
+      return {
+        ...phaseObj,
+        __title: title,
+        __description: description,
+        capabilities,
+        __index: index,
+      };
+    });
+
+    return { parsed, parseError, workflowOutline };
+  };
+
+  const formatFieldLabel = (key: string) =>
+    key
+      .replace(/_/g, ' ')
+      .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
+      .replace(/\s+/g, ' ')
+      .trim();
+
+  const renderCompactFieldValue = (value: any): ReactNode => {
+    if (value === null || value === undefined || value === '') {
+      return <span className="text-slate-400">空</span>;
+    }
+    if (Array.isArray(value)) {
+      if (value.length === 0) return <span className="text-slate-400">[]</span>;
+      return (
+        <div className="flex flex-wrap gap-1.5">
+          {value.map((item, index) => (
+            <span
+              key={`field-${index}-${typeof item === 'object' ? 'obj' : String(item)}`}
+              className="px-2 py-0.5 rounded-full text-[11px] bg-slate-100 text-slate-700 border border-slate-200"
+            >
+              {typeof item === 'object' ? JSON.stringify(item) : String(item)}
+            </span>
+          ))}
+        </div>
+      );
+    }
+    if (typeof value === 'object') {
+      return (
+        <div className="flex flex-wrap gap-1.5">
+          {Object.entries(value).map(([nestedKey, nestedValue]) => (
+            <span key={nestedKey} className="px-2 py-0.5 rounded-full text-[11px] bg-slate-100 text-slate-700 border border-slate-200">
+              <span className="font-semibold text-slate-500">{formatFieldLabel(nestedKey)}:</span>{' '}
+              {typeof nestedValue === 'object' ? JSON.stringify(nestedValue) : String(nestedValue)}
+            </span>
+          ))}
+        </div>
+      );
+    }
+    return <span className="text-sm text-slate-700 whitespace-pre-wrap break-words leading-relaxed">{String(value)}</span>;
+  };
+
+  return (
+    <div className="space-y-4">
+      {/* 主内容 */}
+      {type === 'req' && (
+        <>
+          <div className="bg-cyan-50 p-4 rounded-xl border border-cyan-100">
+            <div className="text-xs font-bold text-cyan-500 mb-2">需求描述</div>
+            <p className="text-cyan-800 text-sm leading-relaxed whitespace-pre-wrap">{data.description}</p>
+          </div>
+          <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+            <div className="text-[10px] text-slate-400 mb-1">追踪 ID</div>
+            <div className="font-mono text-slate-600 text-[11px] break-all">{data.id}</div>
+          </div>
+        </>
+      )}
+      {type === 'cap' && (
+        <>
+          <div className="bg-amber-50 p-4 rounded-xl border border-amber-100">
+            <div className="text-xs font-bold text-amber-600 mb-2">能力定义</div>
+            <p className="text-amber-800 text-sm leading-relaxed">{data.description || '暂无描述'}</p>
+          </div>
+          <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+            <div className="text-[10px] text-slate-400 mb-1">能力 ID</div>
+            <div className="font-mono text-slate-600 text-[11px] break-all">{data.id}</div>
+          </div>
+        </>
+      )}
+      {type === 'tool' && (
+        <>
+          <div className="bg-emerald-50 p-4 rounded-xl border border-emerald-100">
+            <div className="text-xs font-bold text-emerald-600 mb-2">工具介绍</div>
+            <p className="text-emerald-800 text-sm leading-relaxed">{data.introduction || '暂无介绍'}</p>
+          </div>
+          {data.status && (
+            <div className="bg-slate-50 p-3 rounded-xl border border-slate-100 flex items-center justify-between">
+              <span className="text-[10px] text-slate-400">接入状态</span>
+              <span className={cn("text-xs font-bold px-2 py-1 rounded-full",
+                (data.status === '已接入' || data.status === '正常') ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-200 text-slate-500'
+              )}>{data.status}</span>
+            </div>
+          )}
+          <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+            <div className="text-[10px] text-slate-400 mb-1">执行端 ID</div>
+            <div className="font-mono text-slate-600 text-[11px] break-all">{data.id}</div>
+          </div>
+        </>
+      )}
+      {type === 'proc' && (
+        <>
+          <div className="bg-green-50 p-4 rounded-xl border border-green-100">
+            <div className="text-xs font-bold text-green-600 mb-2">工序说明</div>
+            <p className="text-green-900 text-sm leading-relaxed whitespace-pre-wrap">{data.description || '暂无说明'}</p>
+          </div>
+          {data.resource_ids?.length > 0 && (
+            <StrategyResourcesGrid resourceIds={data.resource_ids} onOpenPost={onOpenPost} />
+          )}
+          <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
+            <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+              <div className="text-[10px] text-slate-400 mb-1">工序 ID</div>
+              <div className="font-mono text-slate-700 text-[11px] break-all">{data.id}</div>
+            </div>
+            {data.category && (
+              <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+                <div className="text-[10px] text-slate-400 mb-1">所属品类</div>
+                <div className="text-sm text-slate-800 font-semibold">{data.category}</div>
+              </div>
+            )}
+          </div>
+          {data.path_labels?.length > 0 && (
+            <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+              <div className="text-[10px] text-slate-400 mb-2">工序链路</div>
+              <div className="flex flex-wrap gap-2">
+                {data.path_labels.map((label: string, idx: number) => (
+                  <span key={`${data.id}-step-${idx}`} className="text-[11px] px-2.5 py-1 rounded-lg bg-white text-green-700 font-semibold border border-green-200 shadow-sm">
+                    {data.path[idx]} · {label}
+                  </span>
+                ))}
+              </div>
+            </div>
+          )}
+          {(() => {
+            const { parsed, parseError, workflowOutline } = parseWorkflowBody(data.body, data);
+
+            const source = parsed.selected_strategy?.source || parsed.source;
+            const excludedBodyKeys = new Set([
+              'workflow',
+              'process_flow',
+              'flow',
+              'phases',
+              'workflow_outline',
+              'steps',
+              'selected_strategy',
+              'source',
+              'reasoning',
+              'highlight_coverage',
+              'baseline_coverage',
+              'vs_alternatives',
+              'uncovered_requirements',
+            ]);
+            const globalExtraFields = Object.entries(parsed || {}).filter(([key]) => !excludedBodyKeys.has(key));
+
+            return (
+              <>
+                {source && (
+                  <div className="bg-blue-50 p-3 rounded-xl border border-blue-100">
+                    <div className="text-[10px] text-blue-400 mb-1">灵感来源</div>
+                    <p className="text-sm text-blue-800 leading-relaxed">{source}</p>
+                  </div>
+                )}
+                {workflowOutline.length > 0 && (
+                  <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+                    <div className="flex items-center justify-between mb-3">
+                      <div className="text-[10px] text-slate-400">工序流程</div>
+                      <div className="text-[10px] font-bold text-green-600">{workflowOutline.length} 步</div>
+                    </div>
+                    <div className="space-y-3">
+                      {workflowOutline.map((phase: any, index: number) => (
+                        <div key={`${data.id}-phase-${index}`} className="rounded-lg border border-slate-200 bg-white p-3">
+                          <div className="flex items-start gap-3">
+                            <div className="shrink-0 w-6 h-6 rounded-full bg-green-100 text-green-700 text-[11px] font-black flex items-center justify-center">
+                              {index + 1}
+                            </div>
+                            <div className="min-w-0 flex-1">
+                              <div className="text-sm font-bold text-slate-800 leading-snug mb-1">
+                                {phase.__title}
+                              </div>
+                              {phase.__description && (
+                                <p className="text-xs text-slate-600 leading-relaxed whitespace-pre-wrap">{phase.__description}</p>
+                              )}
+                            </div>
+                          </div>
+                          {phase.capabilities && phase.capabilities.length > 0 && (
+                            <div className="mt-3 pl-9 space-y-2">
+                              {phase.capabilities.map((cap: any, capIdx: number) => (
+                                <div key={`${data.id}-phase-${index}-cap-${capIdx}`} className="bg-slate-50 p-2 rounded border border-slate-200">
+                                  <div className="flex items-start gap-2">
+                                    <span className={cn(
+                                      "text-[10px] px-1.5 py-0.5 rounded font-bold",
+                                      cap.is_new ? "bg-green-100 text-green-700" : "bg-amber-100 text-amber-700"
+                                    )}>
+                                      {cap.is_new ? "新能力" : "关联能力"}
+                                    </span>
+                                    <div className="flex-1">
+                                      <div className="text-xs font-medium text-slate-800">
+                                        {cap.capability_id && cap.capability_id !== cap.capability_name && (
+                                          <span className="font-mono text-amber-600 mr-1">{cap.capability_id}</span>
+                                        )}
+                                        {cap.capability_name || cap.capability_id || '未命名能力'}
+                                      </div>
+                                      {cap.suggested_tools && cap.suggested_tools.length > 0 && (
+                                        <div className="mt-1 flex flex-wrap gap-1">
+                                          {cap.suggested_tools.map((tool: string, toolIdx: number) => (
+                                            <span key={`tool-${toolIdx}`} className="text-[10px] px-1.5 py-0.5 rounded bg-orange-50 text-orange-700 border border-orange-200">
+                                              {tool}
+                                            </span>
+                                          ))}
+                                        </div>
+                                      )}
+                                      {cap.case_references && cap.case_references.length > 0 && (
+                                        <div className="mt-1 text-[10px] text-slate-500">
+                                          <span className="font-medium">案例引用:</span>
+                                          {cap.case_references.join(' · ')}
+                                        </div>
+                                      )}
+                                    </div>
+                                  </div>
+                                </div>
+                              ))}
+                            </div>
+                          )}
+                        </div>
+                      ))}
+                    </div>
+                  </div>
+                )}
+                {!parseError && globalExtraFields.length > 0 && (
+                  <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+                    <div className="text-[10px] text-slate-400 mb-2">流程补充信息</div>
+                    <div className="flex flex-wrap gap-2">
+                      {globalExtraFields.map(([fieldKey, fieldValue]) => (
+                        <div key={`${data.id}-body-field-${fieldKey}`} className="rounded-lg border border-slate-200 bg-white px-3 py-2 min-w-[180px] max-w-full">
+                          <div className="text-[10px] font-bold text-slate-400 mb-1">{formatFieldLabel(fieldKey)}</div>
+                          {renderCompactFieldValue(fieldValue)}
+                        </div>
+                      ))}
+                    </div>
+                  </div>
+                )}
+                {parseError && (
+                  <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+                    <div className="text-[10px] text-slate-400 mb-2">原始 Body</div>
+                    <pre className="text-[12px] text-slate-700 whitespace-pre-wrap break-words leading-relaxed bg-white rounded-lg border border-slate-200 p-3">
+                      {typeof data.body === 'string'
+                        ? (data.body || '无 body')
+                        : JSON.stringify(data.body ?? {}, null, 2)}
+                    </pre>
+                  </div>
+                )}
+              </>
+            );
+          })()}
+          {data.requirement_texts?.length > 0 && (
+            <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+              <div className="text-[10px] text-slate-400 mb-2">绑定需求</div>
+              <div className="space-y-2">
+                {data.requirement_ids.map((reqId: string, idx: number) => (
+                  <div key={`${data.id}-req-${reqId}`} className="text-sm text-slate-700">
+                    <span className="font-mono text-[11px] text-cyan-600 mr-2">{reqId}</span>
+                    <span>{data.requirement_texts[idx]}</span>
+                  </div>
+                ))}
+              </div>
+            </div>
+          )}
+          {(() => {
+            const { parsed } = parseWorkflowBody(data.body, data);
+            
+            const rationale = data.rationale || parsed.selected_strategy?.reasoning || parsed.reasoning;
+            const highlightCoverage = parsed.selected_strategy?.highlight_coverage || parsed.highlight_coverage || [];
+            const baselineCoverage = parsed.selected_strategy?.baseline_coverage || parsed.baseline_coverage || [];
+            const vsAlternatives = parsed.vs_alternatives || [];
+            const uncoveredRequirements = parsed.uncovered_requirements || [];
+
+            return (
+              <div className="space-y-4">
+                {rationale && (
+                  <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+                    <div className="text-[10px] text-slate-400 mb-1">因果说明 / 决策推理</div>
+                    <p className="text-sm text-slate-700 leading-relaxed">{rationale}</p>
+                  </div>
+                )}
+                {highlightCoverage.length > 0 && (
+                  <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+                    <div className="text-[10px] text-slate-400 mb-2">高光覆盖点表现在</div>
+                    <ul className="list-disc list-inside space-y-1">
+                      {highlightCoverage.map((item: string, idx: number) => (
+                        <li key={`hc-${idx}`} className="text-sm text-slate-700">{item}</li>
+                      ))}
+                    </ul>
+                  </div>
+                )}
+                {baselineCoverage.length > 0 && (
+                  <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+                    <div className="text-[10px] text-slate-400 mb-2">基础要求满足</div>
+                    <ul className="list-disc list-inside space-y-1">
+                      {baselineCoverage.map((item: string, idx: number) => (
+                        <li key={`bc-${idx}`} className="text-sm text-slate-700">{item}</li>
+                      ))}
+                    </ul>
+                  </div>
+                )}
+                {vsAlternatives.length > 0 && (
+                  <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+                    <div className="text-[10px] text-slate-400 mb-2">备选方案比对</div>
+                    <div className="space-y-2">
+                      {vsAlternatives.map((alt: any, idx: number) => (
+                        <div key={`alt-${idx}`} className="bg-white p-2 border border-slate-200 rounded-lg text-sm">
+                          <div className="font-bold text-slate-800">{alt.alternative}</div>
+                          <div className="text-slate-600 mt-1"><span className="text-rose-500 font-medium">未能选中原因:</span>{alt.why_not}</div>
+                          {alt.could_switch_if && (
+                            <div className="text-slate-500 mt-1 text-xs"><span className="text-sky-500 font-medium">触发何种条件才切换:</span>{alt.could_switch_if}</div>
+                          )}
+                        </div>
+                      ))}
+                    </div>
+                  </div>
+                )}
+                {uncoveredRequirements.length > 0 && (
+                  <div className="bg-rose-50 p-3 rounded-xl border border-rose-100">
+                    <div className="text-[10px] text-rose-400 mb-2">未覆盖的需求风险</div>
+                    <ul className="list-disc list-inside space-y-1">
+                      {uncoveredRequirements.map((item: string, idx: number) => (
+                        <li key={`ur-${idx}`} className="text-sm text-rose-700">{item}</li>
+                      ))}
+                    </ul>
+                  </div>
+                )}
+              </div>
+            );
+          })()}
+          {data.source_workflows?.length > 0 && (
+            <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+              <div className="text-[10px] text-slate-400 mb-2">来源工作流</div>
+              <div className="flex flex-wrap gap-2">
+                {data.source_workflows.map((wfId: string) => (
+                  <span key={`${data.id}-${wfId}`} className="text-[11px] px-2 py-1 rounded-md bg-slate-200 text-slate-700 font-medium">
+                    {wfId}
+                  </span>
+                ))}
+              </div>
+            </div>
+          )}
+        </>
+      )}
+      {type === 'know' && (
+        <>
+          <div className="bg-violet-50 p-4 rounded-xl border border-violet-100">
+            <div className="text-xs font-bold text-violet-600 mb-2">知识正文</div>
+            <p className="text-violet-800 text-sm leading-relaxed whitespace-pre-wrap">{data.content}</p>
+          </div>
+          <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+            <div className="text-[10px] text-slate-400 mb-1">知识库 ID</div>
+            <div className="font-mono text-slate-600 text-[11px] break-all">{data.id}</div>
+          </div>
+        </>
+      )}
+
+    </div>
+  );
+}
+
+export function PostDetailModal({ post, postId, onClose }: { post: any; postId: string; onClose: () => void }) {
+  useEffect(() => {
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', onKeyDown);
+    return () => window.removeEventListener('keydown', onKeyDown);
+  }, [onClose]);
+
+  const images: string[] = post?.images || [];
+
+  return createPortal(
+    <div className="fixed inset-0 z-[260] flex items-center justify-center p-6">
+      <button className="absolute inset-0 bg-slate-900/45 backdrop-blur-[1px]" onClick={onClose} aria-label="关闭帖子详情" />
+      <div className="relative z-[261] w-full max-w-4xl max-h-[85vh] overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-2xl flex flex-col">
+        <div className="flex items-start justify-between gap-4 px-6 py-4 border-b border-slate-100 shrink-0">
+          <div className="min-w-0">
+            <div className="text-xs font-bold text-slate-400 mb-1">帖子详情</div>
+            <div className="text-base font-bold text-slate-800 leading-snug">{post?.title || '无标题'}</div>
+            <div className="text-[11px] text-slate-400 mt-1 break-all">{postId}</div>
+          </div>
+          <button onClick={onClose} className="p-2 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors">
+            <X size={16} />
+          </button>
+        </div>
+        <div className="overflow-y-auto px-6 py-5 space-y-5">
+          {(post?.platform || post?.platform_account_name || post?.publish_date) && (
+            <div className="flex flex-wrap gap-2">
+              {post?.platform && <span className="text-xs px-2 py-1 rounded-full bg-slate-100 text-slate-600 font-medium">{post.platform}</span>}
+              {post?.platform_account_name && <span className="text-xs px-2 py-1 rounded-full bg-indigo-50 text-indigo-700 font-medium">{post.platform_account_name}</span>}
+              {post?.publish_date && <span className="text-xs px-2 py-1 rounded-full bg-emerald-50 text-emerald-700 font-medium">{post.publish_date}</span>}
+            </div>
+          )}
+          {images.length > 0 && (
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
+              {images.map((url: string, i: number) => (
+                <div key={i} className="rounded-2xl overflow-hidden bg-slate-100 border border-slate-100">
+                  <img src={url} alt="" className="w-full h-full object-cover" loading="lazy" />
+                </div>
+              ))}
+            </div>
+          )}
+          <div className="space-y-4">
+            {post?.body_text && (
+              <div className="bg-slate-50 rounded-2xl border border-slate-100 p-4">
+                <div className="text-xs font-bold text-slate-500 mb-2">正文</div>
+                <p className="text-sm text-slate-700 whitespace-pre-wrap leading-relaxed">{post.body_text}</p>
+              </div>
+            )}
+            {post?.decode_result && (
+              <div className="bg-indigo-50 rounded-2xl border border-indigo-100 p-4 space-y-3">
+                <div className="text-xs font-bold text-indigo-600">解析信息</div>
+                {Object.entries(post.decode_result).map(([key, value]) => (
+                  value ? (
+                    <div key={key}>
+                      <div className="text-[11px] font-bold text-indigo-400 uppercase tracking-wide">{key}</div>
+                      <div className="text-sm text-indigo-900 whitespace-pre-wrap leading-relaxed">{String(value)}</div>
+                    </div>
+                  ) : null
+                ))}
+              </div>
+            )}
+          </div>
+        </div>
+      </div>
+    </div>,
+    document.body
+  );
+}
+
+export function PostCard({ postId, post, loading, onClick, compact = false }: { postId: string; post?: any; loading?: boolean; onClick?: () => void; compact?: boolean }) {
+  const images: string[] = post?.images || [];
+
+  return (
+    <button
+      type="button"
+      onClick={post ? onClick : undefined}
+      className={cn(
+        compact ? "w-full" : "w-[220px] shrink-0",
+        "bg-white rounded-xl border border-slate-100 overflow-hidden shadow-sm flex flex-col text-left",
+        post ? "cursor-pointer hover:border-indigo-200 hover:shadow-md transition-all" : "cursor-default"
+      )}
+    >
+      {post ? (
+        <>
+          {images.length > 0 && (
+            <div className={cn("shrink-0", compact ? "grid grid-cols-2 gap-0.5" : "grid grid-cols-3 gap-0.5")}>
+              {images.slice(0, compact ? 2 : 3).map((url: string, i: number) => (
+                <div key={i} className={cn("relative overflow-hidden bg-slate-100", compact ? "aspect-[4/3]" : "aspect-square")}>
+                  <img
+                    src={url}
+                    alt=""
+                    className="w-full h-full object-cover"
+                    loading="lazy"
+                    onError={(e) => { (e.target as HTMLImageElement).parentElement!.style.display = 'none'; }}
+                  />
+                </div>
+              ))}
+            </div>
+          )}
+          <div className={cn("shrink-0", compact ? "px-3 pt-3 pb-1.5" : "px-2.5 pt-2 pb-1")}>
+            <div className={cn("font-bold text-slate-800 leading-snug", compact ? "text-xs line-clamp-2" : "text-[11px] line-clamp-2")}>{post.title || '无标题'}</div>
+          </div>
+          {post.body_text && (
+            <div className={cn("flex-1 overflow-hidden", compact ? "px-3 pb-3" : "px-2.5 pb-2")}>
+              <p className={cn("text-slate-400 leading-relaxed whitespace-pre-wrap", compact ? "text-[11px] line-clamp-3" : "text-[10px] line-clamp-4")}>{post.body_text}</p>
+            </div>
+          )}
+        </>
+      ) : !loading ? (
+        <div className={cn("font-mono text-slate-300 break-all", compact ? "p-4 text-[11px]" : "p-3 text-[10px]")}>{postId}</div>
+      ) : (
+        <div className={cn("h-full bg-slate-50 animate-pulse", compact ? "min-h-[220px]" : "min-h-[160px]")}></div>
+      )}
+    </button>
+  );
+}
+
+export function HorizontalPostScroller({ children, className = '' }: { children: ReactNode; className?: string }) {
+  const scrollRef = useRef<HTMLDivElement | null>(null);
+  const [canScrollLeft, setCanScrollLeft] = useState(false);
+  const [canScrollRight, setCanScrollRight] = useState(false);
+
+  const updateScrollState = () => {
+    const el = scrollRef.current;
+    if (!el) return;
+    const maxScrollLeft = el.scrollWidth - el.clientWidth;
+    setCanScrollLeft(el.scrollLeft > 4);
+    setCanScrollRight(el.scrollLeft < maxScrollLeft - 4);
+  };
+
+  useEffect(() => {
+    updateScrollState();
+    const el = scrollRef.current;
+    if (!el) return;
+    const onResize = () => updateScrollState();
+    window.addEventListener('resize', onResize);
+    return () => window.removeEventListener('resize', onResize);
+  }, [children]);
+
+  const handleWheel = (e: WheelEvent<HTMLDivElement>) => {
+    const el = scrollRef.current;
+    if (!el) return;
+
+    const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
+    if (delta === 0) return;
+
+    const canScroll = el.scrollWidth > el.clientWidth;
+    if (!canScroll) return;
+
+    const maxScrollLeft = el.scrollWidth - el.clientWidth;
+    const nextLeft = el.scrollLeft + delta;
+    const willScrollWithinBounds = nextLeft > 0 && nextLeft < maxScrollLeft;
+
+    if (willScrollWithinBounds || (delta < 0 && el.scrollLeft > 0) || (delta > 0 && el.scrollLeft < maxScrollLeft)) {
+      e.preventDefault();
+      el.scrollLeft += delta;
+      window.requestAnimationFrame(updateScrollState);
+    }
+  };
+
+  const scrollByPage = (direction: -1 | 1) => {
+    const el = scrollRef.current;
+    if (!el) return;
+    el.scrollBy({ left: direction * Math.max(el.clientWidth * 0.8, 240), behavior: 'smooth' });
+    window.setTimeout(updateScrollState, 250);
+  };
+
+  return (
+    <div className={cn("relative min-w-0 max-w-full overflow-hidden", className)}>
+      {canScrollLeft && (
+        <button
+          type="button"
+          onClick={() => scrollByPage(-1)}
+          className="absolute left-2 top-1/2 z-20 -translate-y-1/2 rounded-full bg-white/95 border border-slate-200 shadow-md p-1.5 text-slate-500 hover:text-slate-700 hover:border-slate-300"
+          aria-label="向左滚动帖子"
+        >
+          <ChevronLeft size={16} />
+        </button>
+      )}
+      {canScrollRight && (
+        <button
+          type="button"
+          onClick={() => scrollByPage(1)}
+          className="absolute right-2 top-1/2 z-20 -translate-y-1/2 rounded-full bg-white/95 border border-slate-200 shadow-md p-1.5 text-slate-500 hover:text-slate-700 hover:border-slate-300"
+          aria-label="向右滚动帖子"
+        >
+          <ChevronRight size={16} />
+        </button>
+      )}
+      <div
+        ref={scrollRef}
+        onWheel={handleWheel}
+        onScroll={updateScrollState}
+        className="w-full overflow-x-auto overflow-y-hidden scrollbar-thin"
+      >
+        {children}
+      </div>
+    </div>
+  );
+}
+
+// ─── 工序与策略来源资源聚合抽屉 ───────────────────────────────────────────────────
+
+export function StrategyResourcesGrid({ resourceIds, onOpenPost }: { resourceIds: string[], onOpenPost: (postId: string, post: any) => void }) {
+  const [posts, setPosts] = useState<Record<string, any>>({});
+  const [loading, setLoading] = useState(false);
+
+  useEffect(() => {
+    if (!resourceIds || resourceIds.length === 0) return;
+    
+    let isMounted = true;
+    
+    async function loadResources() {
+      setLoading(true);
+      const map: Record<string, any> = {};
+      
+      for (const rid of resourceIds) {
+        if (!isMounted) break;
+        try {
+          const res = await getResource(encodeURIComponent(rid));
+          if (res) {
+            map[rid] = {
+              title: res.title,
+              body_text: res.body,
+              images: res.images || [],
+              platform: res.metadata?.platform,
+              publish_date: res.metadata?.last_seen || res.metadata?.acquired_at,
+              decode_result: {
+                '原链接': res.metadata?.source_url,
+                '本地 Case': res.metadata?.local_case_id,
+              }
+            };
+            // 每次加载完立刻更新 UI,提供渐进式呈现
+            setPosts({ ...map });
+          }
+        } catch (e) {
+          console.error(`Failed to fetch resource ${rid}`, e);
+        }
+      }
+      
+      if (isMounted) {
+        setLoading(false);
+      }
+    }
+    
+    loadResources();
+    
+    return () => { isMounted = false; };
+  }, [resourceIds]);
+
+  if (!resourceIds || resourceIds.length === 0) return null;
+
+  return (
+    <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+      <div className="text-[10px] text-slate-400 mb-2">参考资料 ({resourceIds.length})</div>
+      <div className="grid grid-cols-1 gap-2 mt-2">
+        {loading && (
+          <div className="flex items-center gap-2 text-xs text-slate-400 py-2">
+            <div className="w-3 h-3 border-2 border-purple-200 border-t-purple-500 rounded-full animate-spin"></div>
+            正在加载参考资料...
+          </div>
+        )}
+        {!loading && resourceIds.map(pid => {
+          const post = posts[pid];
+          return (
+            <div key={pid} className="w-full bg-white rounded-lg border border-slate-200 overflow-hidden relative group">
+              <PostCard postId={pid} post={post} compact onClick={() => onOpenPost(pid, post)} />
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}
+
+// ─── 业务需求帖子聚合抽屉 ──────────────────────────────────────────────────────
+
+export function ItemsetPostsDrawer({
+  itemset,
+  onOpenPost,
+}: {
+  itemset: any;
+  onOpenPost: (postId: string, post: any) => void;
+}) {
+  const [posts, setPosts] = useState<Record<string, any>>({});
+  const [loading, setLoading] = useState(false);
+
+  const postIds = itemset.post_ids || [];
+
+  useEffect(() => {
+    if (postIds.length === 0) return;
+    setLoading(true);
+    setPosts({});
+    batchGetPosts(postIds)
+      .then(map => setPosts(map))
+      .catch(err => { console.error('Failed to load itemset posts:', err); })
+      .finally(() => setLoading(false));
+  }, [itemset.id, postIds]);
+
+  return (
+    <div className="flex flex-col gap-3 h-full overflow-hidden min-w-0">
+      <div className="shrink-0 bg-blue-50 p-4 rounded-xl border border-blue-100">
+        <div className="text-xs font-bold text-blue-600 mb-2">Pattern 节点组合</div>
+        <div className="flex flex-wrap gap-1.5">
+          {(itemset.leaf_names || []).map((name: string) => (
+            <span key={name} className="px-2 py-0.5 rounded-full text-xs bg-blue-100 text-blue-800 border border-blue-200 font-medium">{name}</span>
+          ))}
+        </div>
+        <div className="mt-3 grid grid-cols-2 gap-2">
+          <div className="rounded-lg border border-blue-200 bg-white/70 px-3 py-2">
+            <div className="text-[10px] font-bold text-blue-500 mb-1">Pattern ID</div>
+            <div className="font-mono text-[11px] text-blue-800">#{itemset.id}</div>
+          </div>
+          <div className="rounded-lg border border-blue-200 bg-white/70 px-3 py-2">
+            <div className="text-[10px] font-bold text-blue-500 mb-1">支持度</div>
+            <div className="text-[11px] font-bold text-blue-800">{itemset.absolute_support}</div>
+          </div>
+        </div>
+      </div>
+
+      <div className="flex-1 min-h-0 overflow-y-auto pr-1 custom-scrollbar">
+        <div className="text-xs font-bold text-slate-500 mb-2">关联帖子 ({postIds.length})</div>
+        <div className="grid grid-cols-1 gap-3">
+          {loading && (
+            <div className="flex items-center justify-center gap-2 text-slate-400 rounded-xl border border-slate-100 bg-slate-50 min-h-[160px]">
+              <div className="w-4 h-4 border-2 border-indigo-200 border-t-indigo-500 rounded-full animate-spin"></div>
+              加载中...
+            </div>
+          )}
+          {!loading && postIds.length === 0 && (
+            <div className="flex items-center justify-center text-xs text-slate-300 font-bold rounded-xl border border-slate-100 bg-slate-50 min-h-[160px]">
+              该 Pattern 暂无关联帖子
+            </div>
+          )}
+          {!loading && postIds.map((pid: string) => {
+            const post = posts[pid];
+            return (
+              <div key={pid} className="w-full">
+                <PostCard postId={pid} post={post} compact onClick={() => onOpenPost(pid, post)} />
+              </div>
+            );
+          })}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+export function RequirementPostsDrawer({
+  requirement,
+  nodePostsMap,
+  onOpenPost,
+}: {
+  requirement: any;
+  nodePostsMap: Record<string, string[]>;
+  onOpenPost: (postId: string, post: any) => void;
+}) {
+  const [selectedNodeName, setSelectedNodeName] = useState<string | null>(null);
+  const [posts, setPosts] = useState<Record<string, any>>({});
+  const [loading, setLoading] = useState(false);
+
+  const nodeNames = Object.keys(nodePostsMap);
+  const allPostIds = useMemo(() => {
+    const ids: string[] = [];
+    Object.values(nodePostsMap).forEach(pids => pids.forEach(pid => { if (!ids.includes(pid)) ids.push(pid); }));
+    return ids;
+  }, [nodePostsMap]);
+
+  useEffect(() => {
+    if (allPostIds.length === 0) return;
+    setLoading(true);
+    setPosts({});
+    batchGetPosts(allPostIds)
+      .then(map => setPosts(map))
+      .catch(err => { console.error('Failed to load requirement posts:', err); })
+      .finally(() => setLoading(false));
+  }, [allPostIds, requirement.id]);
+
+  const displayPostIds = selectedNodeName ? (nodePostsMap[selectedNodeName] || []) : allPostIds;
+
+  return (
+    <div className="flex flex-col gap-3 h-full overflow-hidden min-w-0">
+      <div className="shrink-0 bg-slate-50 rounded-xl border border-slate-100 p-3">
+        <div className="text-xs font-bold text-slate-600 mb-2">关联节点 ({nodeNames.length})</div>
+        <div className="flex flex-wrap gap-2 max-h-[140px] overflow-y-auto">
+          <button
+            onClick={() => setSelectedNodeName(null)}
+            className={cn(
+              "text-left px-2.5 py-1.5 rounded-lg text-xs transition-colors",
+              selectedNodeName === null ? "bg-indigo-100 text-indigo-700 font-bold" : "bg-white hover:bg-slate-100 text-slate-600 border border-slate-200"
+            )}
+          >
+            全部 ({allPostIds.length})
+          </button>
+          {nodeNames.map(name => (
+            <button
+              key={name}
+              onClick={() => setSelectedNodeName(name)}
+              className={cn(
+                "text-left px-2.5 py-1.5 rounded-lg text-xs transition-colors truncate max-w-full",
+                selectedNodeName === name ? "bg-indigo-100 text-indigo-700 font-bold" : "bg-white hover:bg-slate-100 text-slate-600 border border-slate-200"
+              )}
+            >
+              {name === '__unmatched__' ? '未定位帖子' : name} ({nodePostsMap[name].length})
+            </button>
+          ))}
+        </div>
+      </div>
+      <div className="flex-1 min-h-0 overflow-y-auto pr-1">
+        <div className="grid grid-cols-1 gap-3">
+          {loading && (
+            <div className="flex items-center justify-center flex-1 gap-2 text-slate-400">
+              <div className="w-4 h-4 border-2 border-indigo-200 border-t-indigo-500 rounded-full animate-spin"></div>
+              加载中...
+            </div>
+          )}
+          {!loading && displayPostIds.length === 0 && (
+            <div className="flex items-center justify-center flex-1 text-xs text-slate-300 font-bold">暂无帖子</div>
+          )}
+          {!loading && displayPostIds.map(pid => {
+            const post = posts[pid];
+            return (
+              <div key={pid} className="w-full">
+                <PostCard postId={pid} post={post} compact onClick={() => onOpenPost(pid, post)} />
+              </div>
+            );
+          })}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// ─── 叶子节点详情抽屉 ─────────────────────────────────────────────────────────
+
+export function LeafNodeDrawer({ node, onOpenPost }: { node: any; onOpenPost: (postId: string, post: any) => void }) {
+  const [posts, setPosts] = useState<Record<string, any>>({});
+  const [loading, setLoading] = useState(false);
+
+  const postIds: string[] = useMemo(() => {
+    const ids: string[] = [];
+    const walk = (current: any) => {
+      (current.elements || []).forEach((el: any) => {
+        (el.post_ids || []).forEach((pid: string) => {
+          if (!ids.includes(pid)) ids.push(pid);
+        });
+      });
+      (current.children || []).forEach((child: any) => walk(child));
+    };
+    walk(node);
+    return ids;
+  }, [node]);
+
+  useEffect(() => {
+    if (postIds.length === 0) return;
+    setLoading(true);
+    setPosts({});
+    batchGetPosts(postIds)
+      .then(map => setPosts(map))
+      .catch(err => {
+        console.error('Failed to load leaf node posts:', err);
+      })
+      .finally(() => setLoading(false));
+  }, [node.name, postIds]);
+
+  return (
+    <div className="flex flex-col gap-3 h-full overflow-hidden min-w-0">
+      <div className="shrink-0 bg-slate-50 rounded-xl border border-slate-100 p-3">
+        <div className="text-xs font-bold text-slate-600 mb-2">节点概览</div>
+        <div className="grid grid-cols-2 gap-3">
+          <div className="rounded-lg border border-slate-200 bg-white p-3">
+            <div className="text-[10px] text-slate-400 mb-1">帖子总数</div>
+            <div className="text-xl font-black text-slate-800">{node.total_posts_count || 0}</div>
+          </div>
+          <div className="rounded-lg border border-slate-200 bg-white p-3">
+            <div className="text-[10px] text-slate-400 mb-1">去重帖子</div>
+            <div className="text-xl font-black text-slate-800">{postIds.length}</div>
+          </div>
+        </div>
+      </div>
+
+      <div className="flex-1 min-h-0 overflow-y-auto pr-1">
+        <div className="grid grid-cols-1 gap-3">
+          {loading && (
+            <div className="flex items-center justify-center gap-2 text-slate-400 rounded-xl border border-slate-100 bg-slate-50 min-h-[160px]">
+              <div className="w-4 h-4 border-2 border-indigo-200 border-t-indigo-500 rounded-full animate-spin"></div>
+              加载中...
+            </div>
+          )}
+          {!loading && postIds.length === 0 && (
+            <div className="flex items-center justify-center text-xs text-slate-300 font-bold rounded-xl border border-slate-100 bg-slate-50 min-h-[160px]">
+              该节点暂无帖子
+            </div>
+          )}
+          {!loading && postIds.map(pid => {
+            const post = posts[pid];
+            return (
+              <div key={pid} className="w-full">
+                <PostCard postId={pid} post={post} compact onClick={() => onOpenPost(pid, post)} />
+              </div>
+            );
+          })}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// ─── 频繁模式列 ────────────────────────────────────────────────────────────────
+
+function PatternColumn({
+  selectedItemsetId,
+  itemsets,
+  eligibleItemsetIds,
+  metricsMap,
+  frozenOrder,
+  contextNodeNames,
+  patternMatchedNodesMap,
+  nodeRoleByName,
+  hasAnyFilter,
+  focusIndex,
+  focusTrigger,
+  onSelectItemset,
+  onOpenDrawer,
+  onNodeClick,
+  onFocusPrev,
+  onFocusNext,
+}: {
+  selectedItemsetId: number | null;
+  itemsets: any[];
+  eligibleItemsetIds: Set<string>;
+  metricsMap: Record<string, { nodeCount: number; reqCount: number; procCount: number; capCount: number; toolCount: number }>;
+  frozenOrder?: string[];
+  contextNodeNames: Set<string>;
+  patternMatchedNodesMap?: Record<string, Set<string>>;
+  nodeRoleByName: Record<string, 'substance' | 'form' | 'both'>;
+  hasAnyFilter: boolean;
+  focusIndex: number;
+  focusTrigger: number;
+  onSelectItemset: (itemsetId: number | null, currentOrderIds: string[]) => void;
+  onOpenDrawer: (itemset: any) => void;
+  onNodeClick?: (nodeName: string) => void;
+  onFocusPrev: () => void;
+  onFocusNext: () => void;
+}) {
+  const itemRefs = useRef<Record<number, HTMLDivElement | null>>({});
+  const displayItemsets = useMemo(() => {
+    const withMatches = [...itemsets].map(itemset => {
+      const leafNames: string[] = itemset.leaf_names || [];
+      let matchedNodes: string[] = [];
+      if (patternMatchedNodesMap && patternMatchedNodesMap[String(itemset.id)]) {
+        const specificMatches = patternMatchedNodesMap[String(itemset.id)];
+        matchedNodes = leafNames.filter(n => specificMatches.has(n));
+      } else {
+        matchedNodes = contextNodeNames.size > 0 ? leafNames.filter(n => contextNodeNames.has(n)) : [];
+      }
+      return { ...itemset, matched_nodes: matchedNodes };
+    });
+    if (selectedItemsetId !== null && frozenOrder && frozenOrder.length > 0) {
+      const rank = new Map<string, number>(frozenOrder.map((id, index) => [id, index]));
+      return [...withMatches].sort((a, b) => {
+        const aRank = rank.get(String(a.id));
+        const bRank = rank.get(String(b.id));
+        if (aRank === undefined && bRank === undefined) return 0;
+        if (aRank === undefined) return 1;
+        if (bRank === undefined) return -1;
+        return aRank - bRank;
+      });
+    }
+    return withMatches.sort((a, b) => {
+      const aEligible = eligibleItemsetIds.has(String(a.id)) ? 0 : 1;
+      const bEligible = eligibleItemsetIds.has(String(b.id)) ? 0 : 1;
+      if (aEligible !== bEligible) return aEligible - bEligible;
+      return 0;
+    });
+  }, [contextNodeNames, patternMatchedNodesMap, itemsets, eligibleItemsetIds, selectedItemsetId, frozenOrder]);
+
+  const matchedIndices = useMemo(() => {
+    const indices: number[] = [];
+    displayItemsets.forEach((itemset, idx) => {
+      const isSelected = selectedItemsetId === itemset.id;
+      const isEligible = eligibleItemsetIds.has(String(itemset.id));
+      if (isSelected || isEligible) indices.push(idx);
+    });
+    return indices;
+  }, [displayItemsets, selectedItemsetId, eligibleItemsetIds]);
+
+  const clampedFocusIdx = matchedIndices.length > 0 ? Math.min(focusIndex, matchedIndices.length - 1) : 0;
+  const focusedItemIndex = matchedIndices[clampedFocusIdx] ?? -1;
+
+  useEffect(() => {
+    if (focusTrigger > 0 && focusedItemIndex >= 0) {
+      itemRefs.current[focusedItemIndex]?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+    }
+  }, [focusedItemIndex, focusTrigger]);
+
+  return (
+    <div className="w-[260px] shrink-0 flex flex-col h-full bg-slate-100/50 rounded-2xl border border-slate-200 overflow-hidden border-t-2 border-t-blue-500">
+      <div className="px-4 py-3 font-bold text-slate-700 border-b bg-slate-200/50 shrink-0">
+        <div className="flex justify-between items-center">
+          <span>Pattern</span>
+          <div className="flex items-center gap-2">
+            {matchedIndices.length > 1 && (
+              <div className="flex items-center gap-1">
+                <button
+                  type="button"
+                  onClick={onFocusPrev}
+                  disabled={clampedFocusIdx === 0}
+                  className="p-0.5 rounded text-slate-400 hover:text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+                >
+                  <ChevronLeft size={13} />
+                </button>
+                <span className="text-[10px] font-bold text-slate-500 min-w-[28px] text-center">
+                  {clampedFocusIdx + 1}/{matchedIndices.length}
+                </span>
+                <button
+                  type="button"
+                  onClick={onFocusNext}
+                  disabled={clampedFocusIdx === matchedIndices.length - 1}
+                  className="p-0.5 rounded text-slate-400 hover:text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+                >
+                  <ChevronRight size={13} />
+                </button>
+              </div>
+            )}
+            <span className="text-slate-400">{displayItemsets.length}</span>
+          </div>
+        </div>
+      </div>
+      <div className="flex-1 overflow-y-auto p-3 custom-scrollbar">
+        {displayItemsets.map((itemset, idx) => {
+          const isSelected = selectedItemsetId === itemset.id;
+          const isEligible = eligibleItemsetIds.has(String(itemset.id));
+          const metrics = metricsMap[String(itemset.id)] || { nodeCount: 0, reqCount: 0, procCount: 0, capCount: 0, toolCount: 0 };
+          const metricItems = [
+            { key: 'node', count: metrics.nodeCount, className: 'bg-slate-500 text-white' },
+            { key: 'pattern', count: 0, className: DASHBOARD_COLUMN_THEME.pattern.metric },
+            { key: 'req', count: metrics.reqCount, className: DASHBOARD_COLUMN_THEME.req.metric },
+            { key: 'proc', count: metrics.procCount, className: DASHBOARD_COLUMN_THEME.proc.metric },
+            { key: 'cap', count: metrics.capCount, className: DASHBOARD_COLUMN_THEME.cap.metric },
+            { key: 'tool', count: metrics.toolCount, className: DASHBOARD_COLUMN_THEME.tool.metric },
+          ];
+          const metricDots = (
+            <>
+              {metricItems.map((metric) => (
+                <span
+                  key={`${itemset.id}-${metric.key}`}
+                  className={cn(
+                    "min-w-[18px] h-[18px] px-1 rounded-full text-[10px] font-black flex items-center justify-center",
+                    metric.count > 0 ? metric.className : "opacity-0"
+                  )}
+                >
+                  {metric.count > 0 ? metric.count : '0'}
+                </span>
+              ))}
+            </>
+          );
+          return (
+            <div
+              key={itemset.id}
+              ref={(el) => {
+                itemRefs.current[idx] = el;
+              }}
+              onClick={() => {
+                if (hasAnyFilter && !isEligible && !isSelected) return;
+                const next = isSelected ? null : itemset.id;
+                onSelectItemset(next, displayItemsets.map((entry) => String(entry.id)));
+                if (next !== null) onOpenDrawer(itemset);
+              }}
+              className={cn(
+                "group bg-white rounded-xl p-3 mb-2 cursor-pointer transition-all border-l-4 border-l-blue-400",
+                isSelected
+                  ? "border border-orange-400 shadow-[0_0_0_1px_rgba(251,146,60,0.7)]"
+                  : isEligible
+                  ? "border border-sky-300 bg-sky-50/70 shadow-[0_0_0_1px_rgba(125,211,252,0.5)]"
+                  : "border border-transparent hover:border-slate-200",
+                hasAnyFilter && !isEligible && !isSelected && "border border-slate-200 bg-slate-50 opacity-45 saturate-50 cursor-not-allowed"
+              )}
+            >
+              <div className="flex items-start gap-2">
+                <div className="min-w-0 flex-1">
+                  <div className="flex flex-wrap gap-1 mt-1.5">
+                    {(itemset.leaf_names || []).map((name: string) => {
+                      const isMatched = itemset.matched_nodes.includes(name);
+                      return (
+                        <span
+                          key={name}
+                          onClick={(e) => {
+                            if (hasAnyFilter && !isEligible && !isSelected) return;
+                            if (!onNodeClick) return;
+                            e.stopPropagation();
+                            onNodeClick(name);
+                          }}
+                          className={cn(
+                            "text-xs px-2 py-0.5 rounded-md font-medium truncate max-w-[120px] border",
+                            (isSelected || isEligible) && isMatched
+                              ? "bg-blue-100 text-blue-700 ring-1 ring-blue-400 border-blue-200 font-bold"
+                              : "bg-slate-50 text-slate-700 border-slate-200",
+                            onNodeClick && !(hasAnyFilter && !isEligible && !isSelected) && "cursor-pointer hover:ring-1 hover:ring-blue-300"
+                          )}
+                        >
+                          {name}
+                        </span>
+                      );
+                    })}
+                  </div>
+                  <div className={cn(
+                    "flex items-center gap-1.5 mt-2 transition-opacity duration-150",
+                    isSelected ? "opacity-100" : "opacity-0 group-hover:opacity-100"
+                  )}>
+                    {metricDots}
+                  </div>
+                </div>
+              </div>
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}
+
+// ─── Dashboard 主体 ────────────────────────────────────────────────────────────
+

+ 224 - 0
knowhub/frontend/src/hooks/useDashboardData.ts

@@ -0,0 +1,224 @@
+import { useState, useEffect, useMemo, useCallback } from 'react';
+import { 
+  getDashboardSnapshot, 
+  getRequirements, 
+  getCapabilities, 
+  getTools, 
+  getStrategies, 
+  getKnowledge,
+  getItemsetsAll,
+  getRequirementsPlanB
+} from '../services/api';
+
+// Global cache variables (matching previous Dashboard.tsx behavior)
+let globalCacheLoaded = false;
+let globalCacheTreeData: any = null;
+let globalCacheDbData: any = null;
+let globalCacheNameToNodeMap: Record<string, any> = {};
+let globalCacheIdToNodeMap: Record<string, any> = {};
+
+// Helper functions that were in Dashboard.tsx
+const getLeafNodes = (nodes: any[]): any[] => {
+  let leaves: any[] = [];
+  nodes.forEach(node => {
+    if (!node.children || node.children.length === 0) leaves.push(node);
+    else leaves = leaves.concat(getLeafNodes(node.children));
+  });
+  return leaves;
+};
+
+export const useDashboardData = () => {
+  const [loadingText, setLoadingText] = useState<string | null>(null);
+  
+  // Data State
+  const [treeData, setTreeData] = useState<any>(null);
+  const [dbData, setDbData] = useState<{ reqs: any[], caps: any[], tools: any[], know: any[], procs: any[] }>({
+    reqs: [], caps: [], tools: [], know: [], procs: []
+  });
+  
+  const [allItemsets, setAllItemsets] = useState<any[]>([]);
+  const [reqPlanBData, setReqPlanBData] = useState<{ requirements: any[] } | null>(null);
+
+  // Mapping State
+  const [nameToNodeMap, setNameToNodeMap] = useState<Record<string, any>>({});
+  const [idToNodeMap, setIdToNodeMap] = useState<Record<string, any>>({});
+
+  useEffect(() => {
+    async function loadStats() {
+      try {
+        if (globalCacheLoaded && globalCacheTreeData && globalCacheDbData) {
+          setTreeData(globalCacheTreeData);
+          setDbData(globalCacheDbData);
+          setNameToNodeMap(globalCacheNameToNodeMap);
+          setIdToNodeMap(globalCacheIdToNodeMap);
+          setLoadingText(null);
+          
+          // Re-fetch static files quietly in background if using cache
+          getItemsetsAll().then(d => setAllItemsets(d.itemsets || [])).catch(() => {});
+          getRequirementsPlanB().then(d => setReqPlanBData(d)).catch(() => {});
+          return;
+        }
+
+        setLoadingText('获取数据快照...');
+
+        let data: any;
+        let reqs: any[];
+        let caps: any[];
+        let tools: any[];
+        let procs: any[];
+        let know: any[];
+
+        try {
+          const snapshot = await getDashboardSnapshot();
+          data = snapshot.tree;
+          reqs = snapshot.reqs || [];
+          caps = snapshot.caps || [];
+          tools = snapshot.tools || [];
+          procs = snapshot.procs || [];
+          know = snapshot.know || [];
+        } catch {
+          setLoadingText('回退模式:并行获取底座数据...');
+          const [treeRes, reqRes, capRes, toolRes, procRes, knowRes] = await Promise.all([
+            fetch('/category_tree.json').then((r) => r.json()),
+            getRequirements(1000, 0),
+            getCapabilities(1000, 0),
+            getTools(1000, 0),
+            getStrategies(1000, 0),
+            getKnowledge(1, 1000).catch(() => ({ results: [] })),
+          ]);
+          data = treeRes;
+          reqs = reqRes.results || [];
+          caps = capRes.results || [];
+          tools = toolRes.results || [];
+          procs = procRes.strategies || [];
+          know = knowRes.results || [];
+        }
+
+        // Fetch Static Asset Patterns
+        Promise.allSettled([
+          getItemsetsAll(),
+          getRequirementsPlanB()
+        ]).then(([itemsetsRes, planBRes]) => {
+          if (itemsetsRes.status === 'fulfilled') setAllItemsets(itemsetsRes.value.itemsets || []);
+          if (planBRes.status === 'fulfilled') setReqPlanBData(planBRes.value);
+        });
+
+        // Mapping Logic
+        const nameToNode: Record<string, any> = {};
+        const idToNode: Record<string, any> = {};
+        const buildNameMap = (nodes: any[]) => {
+          nodes.forEach(n => {
+            nameToNode[n.name] = n;
+            if (n.id !== undefined && n.id !== null) idToNode[String(n.id)] = n;
+            if (n.children) buildNameMap(n.children);
+          });
+        };
+        buildNameMap([data]);
+        
+        // Tree processing
+        const leaves = getLeafNodes([data]);
+        const nodeToReqs: Record<string, any[]> = {};
+        leaves.forEach(l => { nodeToReqs[l.name] = []; });
+
+        reqs.forEach((r: any) => {
+          (r.source_nodes || []).forEach((sn: any) => {
+            const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
+            if (nodeName && nameToNode[nodeName]) {
+              getLeafNodes([nameToNode[nodeName]]).forEach(ml => {
+                if (nodeToReqs[ml.name]) nodeToReqs[ml.name].push(r);
+              });
+            }
+          });
+        });
+
+        leaves.forEach(l => {
+          const attachedReqs = nodeToReqs[l.name];
+          l.has_requirement = !!(attachedReqs && attachedReqs.length > 0);
+          if (!l.has_requirement) { l.node_status = 0; }
+          else {
+            const rIds = new Set(attachedReqs.map(r => r.id));
+            const relCaps = caps.filter((c: any) => (c.requirement_ids || []).some((rid: string) => rIds.has(rid)));
+            const reqsWithCaps = attachedReqs.filter((r: any) => caps.some((c: any) => (c.requirement_ids || []).includes(r.id)));
+            if (reqsWithCaps.length < attachedReqs.length) { l.node_status = 1; }
+            else {
+              const cIds = new Set(relCaps.map((c: any) => c.id));
+              const relTools = tools.filter((t: any) => (t.capability_ids || []).some((cid: string) => cIds.has(cid)));
+              if (relTools.length === 0) { l.node_status = 2; }
+              else {
+                const hasDisconnected = relTools.some((t: any) => t.status !== '已接入' && t.status !== '正常' && t.status !== '已上线' && t.status !== 'active');
+                l.node_status = hasDisconnected ? 2 : 3;
+              }
+            }
+          }
+        });
+
+        const finalTreeData = { ...data };
+        
+        // Final state updates
+        setTreeData(finalTreeData);
+        setDbData({ reqs, caps, tools, know, procs });
+        setNameToNodeMap(nameToNode);
+        setIdToNodeMap(idToNode);
+
+        // Update Globals
+        globalCacheTreeData = finalTreeData;
+        globalCacheDbData = { reqs, caps, tools, know, procs };
+        globalCacheNameToNodeMap = nameToNode;
+        globalCacheIdToNodeMap = idToNode;
+        globalCacheLoaded = true;
+
+      } catch (err) {
+        console.error("Failed to load dashboard stats", err);
+        setLoadingText('获取底座数据失败或操作超时!请检查数据连接。');
+      } finally {
+        setLoadingText((prev) => {
+          if (prev && prev.includes('失败')) return prev;
+          return null;
+        });
+      }
+    }
+    loadStats();
+  }, []);
+
+  // Compute Virtual Capabilities from Strategies
+  const virtualCaps = useMemo(() => {
+    let result: any[] = [];
+    dbData.procs.forEach((w: any) => {
+       try {
+         const bodyObj = typeof w.body === 'string' ? JSON.parse(w.body) : w.body;
+         const phases = bodyObj.phases || [];
+         const processedW = { ...w, body: bodyObj };
+         phases.forEach((p: any) => {
+            const steps = p.steps || [];
+            steps.forEach((s: any) => {
+               if (s.name && !s.capability_id) {
+                 const reqIds = Array.isArray(w.requirement_ids) ? w.requirement_ids : (w.requirement_id ? [w.requirement_id] : []);
+                 result.push({
+                   id: `virtual-cap-${processedW.id}-${p.name}-${s.name}`,
+                   name: s.name,
+                   isVirtual: true,
+                   description: s.description || '',
+                   requirement_ids: reqIds,
+                   _source_workflow: processedW
+                 });
+               }
+            });
+         });
+       } catch (e) {
+         // ignore
+       }
+    });
+    return result;
+  }, [dbData.procs]);
+
+  return {
+    loadingText,
+    treeData,
+    dbData,
+    allItemsets,
+    reqPlanBData,
+    nameToNodeMap,
+    idToNodeMap,
+    virtualCaps,
+  };
+};

+ 148 - 0
knowhub/frontend/src/hooks/useDashboardFilter.ts

@@ -0,0 +1,148 @@
+import { useState, useMemo } from 'react';
+import { DashboardGraph, type NodeType } from '../lib/dashboardGraph';
+
+export type SelectionState = Record<NodeType, Set<string>>;
+export type MultiSelectMode = Record<NodeType, 'AND' | 'OR'>;
+
+export interface ItemState {
+  id: string;
+  type: NodeType;
+  status: 'active' | 'matched' | 'dimmed';
+}
+
+export function useDashboardFilter(graph: DashboardGraph | null) {
+  const [selections, setSelections] = useState<SelectionState>({
+    tree: new Set(),
+    pattern: new Set(),
+    req: new Set(),
+    proc: new Set(),
+    cap: new Set(),
+    tool: new Set(),
+  });
+
+  const [multiMode, setMultiMode] = useState<MultiSelectMode>({
+    tree: 'AND',
+    pattern: 'AND',
+    req: 'AND',
+    proc: 'AND',
+    cap: 'AND',
+    tool: 'AND',
+  });
+
+  // Action helpers
+  const toggleSelection = (type: NodeType, id: string) => {
+    setSelections(prev => {
+      const newSet = new Set(prev[type]);
+      if (newSet.has(id)) newSet.delete(id);
+      else newSet.add(id);
+      return { ...prev, [type]: newSet };
+    });
+  };
+
+  const clearAll = () => {
+    setSelections({
+      tree: new Set(),
+      pattern: new Set(),
+      req: new Set(),
+      proc: new Set(),
+      cap: new Set(),
+      tool: new Set(),
+    });
+  };
+
+  const toggleMultiMode = (type: NodeType) => {
+    setMultiMode(prev => ({
+      ...prev,
+      [type]: prev[type] === 'AND' ? 'OR' : 'AND'
+    }));
+  };
+
+  // The engine
+  const filterResults = useMemo(() => {
+    if (!graph) return { active: new Set<string>(), matched: new Set<string>() };
+
+    let hasAnySelection = false;
+    const categoryReachability: Set<string>[] = [];
+    const activeRefs = new Set<string>();
+
+    for (const [type, activeSet] of Object.entries(selections)) {
+      if (activeSet.size === 0) continue;
+      hasAnySelection = true;
+      const t = type as NodeType;
+      const mode = multiMode[t];
+
+      const itemReachabilitySets: Set<string>[] = [];
+
+      for (const id of activeSet) {
+        const ref = DashboardGraph.makeRef(t, id);
+        activeRefs.add(ref);
+        
+        // Find reachability for this specific selected item (Up + Down)
+        const leftReach = graph.explore(new Set([ref]), 'upstream');
+        const rightReach = graph.explore(new Set([ref]), 'downstream');
+        
+        const combined = new Set([...leftReach, ...rightReach]);
+        itemReachabilitySets.push(combined);
+      }
+
+      // Combine items within the same category (AND vs OR)
+      if (itemReachabilitySets.length > 0) {
+        let catR: Set<string>;
+        if (mode === 'OR') {
+          catR = new Set();
+          itemReachabilitySets.forEach(s => s.forEach(ref => catR.add(ref)));
+        } else {
+          // AND mode (intersection)
+          catR = new Set(itemReachabilitySets[0]);
+          for (let i = 1; i < itemReachabilitySets.length; i++) {
+            const currentObj = itemReachabilitySets[i];
+            catR = new Set([...catR].filter(x => currentObj.has(x)));
+          }
+        }
+        categoryReachability.push(catR);
+      }
+    }
+
+    if (!hasAnySelection) {
+      // Return ALL as matched if no selection
+      return {
+        active: new Set<string>(),
+        matched: new Set<string>(['ALL']), // Special flag indicating everything is matched
+      };
+    }
+
+    // Now cross-category is ALWAYS intersection
+    let validGraph = new Set(categoryReachability[0]);
+    for (let i = 1; i < categoryReachability.length; i++) {
+      const catR = categoryReachability[i];
+      validGraph = new Set([...validGraph].filter(x => catR.has(x)));
+    }
+
+    // The active items MUST be in the valid graph to be valid, but we inject them automatically
+    // or maybe they shouldn't be if paths cross differently. 
+    // Usually active UI components stay active by definition.
+    
+    return {
+      active: activeRefs,
+      matched: validGraph,
+    };
+  }, [graph, selections, multiMode]);
+
+  const getItemState = (type: NodeType, id: string): ItemState['status'] => {
+    if (!graph || filterResults.matched.has('ALL')) return 'matched';
+    const ref = DashboardGraph.makeRef(type, id);
+    if (filterResults.active.has(ref)) return 'active';
+    if (filterResults.matched.has(ref)) return 'matched';
+    return 'dimmed';
+  };
+
+  return {
+    selections,
+    multiMode,
+    toggleSelection,
+    clearAll,
+    toggleMultiMode,
+    getItemState,
+    hasActiveFilters: filterResults.active.size > 0
+  };
+}

+ 82 - 0
knowhub/frontend/src/lib/dashboard-theme.ts

@@ -0,0 +1,82 @@
+export const DASHBOARD_COLUMN_THEME = {
+  pattern: {
+    metric: 'bg-blue-500 text-white',
+    chip: 'bg-blue-100 text-blue-700 hover:bg-blue-200',
+    tagBg: 'bg-blue-50',
+    tagText: 'text-blue-700',
+    tagBorder: 'border-blue-200',
+    headerBorder: 'border-t-2 border-t-blue-500',
+    cardAccent: 'border-l-blue-400',
+    flowText: 'text-blue-700',
+    flowBg: 'bg-blue-50',
+    flowBorder: 'border-blue-200',
+    flowDarkBg: 'bg-blue-500',
+    flowRing: 'ring-blue-400',
+    flowGlow: 'shadow-blue-200/80',
+    stroke: '#2563eb',
+  },
+  req: {
+    metric: 'bg-cyan-500 text-white',
+    chip: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200',
+    tagBg: 'bg-cyan-50',
+    tagText: 'text-cyan-700',
+    tagBorder: 'border-cyan-200',
+    headerBorder: 'border-t-2 border-t-cyan-500',
+    cardAccent: 'border-l-cyan-400',
+    flowText: 'text-cyan-700',
+    flowBg: 'bg-cyan-50',
+    flowBorder: 'border-cyan-200',
+    flowDarkBg: 'bg-cyan-500',
+    flowRing: 'ring-cyan-400',
+    flowGlow: 'shadow-cyan-200/80',
+    stroke: '#06b6d4',
+  },
+  proc: {
+    metric: 'bg-green-500 text-white',
+    chip: 'bg-green-100 text-green-700 hover:bg-green-200',
+    tagBg: 'bg-green-50',
+    tagText: 'text-green-700',
+    tagBorder: 'border-green-200',
+    headerBorder: 'border-t-2 border-t-green-500',
+    cardAccent: 'border-l-green-400',
+    flowText: 'text-green-700',
+    flowBg: 'bg-green-50',
+    flowBorder: 'border-green-200',
+    flowDarkBg: 'bg-green-500',
+    flowRing: 'ring-green-400',
+    flowGlow: 'shadow-green-200/80',
+    stroke: '#22c55e',
+  },
+  cap: {
+    metric: 'bg-amber-400 text-white',
+    chip: 'bg-amber-100 text-amber-700 hover:bg-amber-200',
+    tagBg: 'bg-amber-50',
+    tagText: 'text-amber-700',
+    tagBorder: 'border-amber-200',
+    headerBorder: 'border-t-2 border-t-amber-400',
+    cardAccent: 'border-l-amber-400',
+    flowText: 'text-amber-700',
+    flowBg: 'bg-amber-50',
+    flowBorder: 'border-amber-200',
+    flowDarkBg: 'bg-amber-400',
+    flowRing: 'ring-amber-400',
+    flowGlow: 'shadow-amber-200/80',
+    stroke: '#facc15',
+  },
+  tool: {
+    metric: 'bg-orange-500 text-white',
+    chip: 'bg-orange-100 text-orange-700 hover:bg-orange-200',
+    tagBg: 'bg-orange-50',
+    tagText: 'text-orange-700',
+    tagBorder: 'border-orange-200',
+    headerBorder: 'border-t-2 border-t-orange-500',
+    cardAccent: 'border-l-orange-400',
+    flowText: 'text-orange-700',
+    flowBg: 'bg-orange-50',
+    flowBorder: 'border-orange-200',
+    flowDarkBg: 'bg-orange-500',
+    flowRing: 'ring-orange-400',
+    flowGlow: 'shadow-orange-200/80',
+    stroke: '#f97316',
+  },
+} as const;

+ 253 - 0
knowhub/frontend/src/lib/dashboardGraph.ts

@@ -0,0 +1,253 @@
+export type NodeType = 'tree' | 'pattern' | 'req' | 'proc' | 'cap' | 'tool';
+
+export interface GraphNode {
+  type: NodeType;
+  id: string;
+}
+
+// 采用严格的层级标量,禁止跨级 U-Turn 检索
+export const NODE_RANK: Record<NodeType, number> = {
+  tree: 0,
+  pattern: 1,
+  req: 2,
+  proc: 3,
+  cap: 4,
+  tool: 5,
+};
+
+export class DashboardGraph {
+  // graph representation: sourceId -> targetId -> true
+  private edges: Map<string, Set<string>> = new Map();
+  private nodeTypes: Map<string, NodeType> = new Map();
+
+  // Helper to standard format IDs just in case they collide (e.g. tool "GPT-4" vs cap "GPT-4")
+  static makeRef(type: NodeType, id: string): string {
+    return `${type}:::${id}`;
+  }
+
+  static parseRef(ref: string): { type: NodeType; id: string } {
+    const [type, id] = ref.split(':::');
+    return { type: type as NodeType, id };
+  }
+
+  addNode(type: NodeType, id: string) {
+    const ref = DashboardGraph.makeRef(type, id);
+    this.nodeTypes.set(ref, type);
+    if (!this.edges.has(ref)) {
+      this.edges.set(ref, new Set());
+    }
+    return ref;
+  }
+
+  // Add an undirected edge (which is conceptually bidirectional for exploration)
+  addEdge(refA: string, refB: string) {
+    if (!this.edges.has(refA)) this.edges.set(refA, new Set());
+    if (!this.edges.has(refB)) this.edges.set(refB, new Set());
+    this.edges.get(refA)!.add(refB);
+    this.edges.get(refB)!.add(refA);
+  }
+
+  getType(ref: string): NodeType | undefined {
+    return this.nodeTypes.get(ref);
+  }
+
+  getRank(ref: string): number {
+    const type = this.getType(ref);
+    return type ? NODE_RANK[type] : -1;
+  }
+
+  /**
+   * Explores the graph from a starting pivot without U-Turns.
+   * direction: 'upstream' (rank strictly decreasing) or 'downstream' (rank strictly increasing)
+   */
+  explore(startRefs: Set<string>, direction: 'upstream' | 'downstream'): Set<string> {
+    const visited = new Set<string>();
+    const queue = Array.from(startRefs);
+
+    // Initial fill
+    for (const start of startRefs) {
+      visited.add(start);
+    }
+
+    while (queue.length > 0) {
+      const current = queue.shift()!;
+      const currentRank = this.getRank(current);
+      const neighbors = this.edges.get(current) || new Set();
+
+      for (const neighbor of neighbors) {
+        if (visited.has(neighbor)) continue;
+
+        const neighborRank = this.getRank(neighbor);
+
+        // strictly directional
+        if (direction === 'upstream' && neighborRank < currentRank) {
+          visited.add(neighbor);
+          queue.push(neighbor);
+        } else if (direction === 'downstream' && neighborRank > currentRank) {
+          visited.add(neighbor);
+          queue.push(neighbor);
+        }
+      }
+    }
+    return visited;
+  }
+}
+
+/**
+ * Parses the raw database and dashboard states into our strictly constrained Graph Engine.
+ */
+export function buildDashboardGraph(
+  dbData: { reqs: any[]; caps: any[]; tools: any[]; procs: any[] },
+  allItemsets: any[],
+  reqPlanBData: any, // or null
+  virtualCaps: any[],
+  pathToNodesMap: Record<string, any[]>
+): DashboardGraph {
+  const G = new DashboardGraph();
+
+  // 1. Register all nodes
+  const allCaps = [...(dbData.caps || []), ...(virtualCaps || [])];
+  
+  dbData.reqs?.forEach((r) => G.addNode('req', String(r.id)));
+  allCaps.forEach((c) => G.addNode('cap', String(c.id)));
+  dbData.tools?.forEach((t) => G.addNode('tool', String(t.id)));
+  dbData.procs?.forEach((p) => G.addNode('proc', String(p.id)));
+
+  // Patterns
+  allItemsets?.forEach((it) => G.addNode('pattern', String(it.id || it.hash)));
+
+  // Trees (Only Register nodes that actually have requirements mapped to them)
+  const treeNodes = new Set<string>();
+
+  // 2. Build Edges
+  
+  // -- Req <-> Cap
+  dbData.reqs?.forEach(req => {
+    const reqRef = G.addNode('req', String(req.id));
+    (req.capability_ids || []).forEach((capId: string) => {
+      const capRef = G.addNode('cap', String(capId));
+      G.addEdge(reqRef, capRef);
+    });
+  });
+  // Virtual Caps backwards map
+  virtualCaps.forEach(vCap => {
+    const capRef = G.addNode('cap', String(vCap.id));
+    (vCap.requirement_ids || []).forEach((reqId: string) => {
+      const reqRef = G.addNode('req', String(reqId));
+      G.addEdge(capRef, reqRef);
+    });
+  });
+
+  // -- Proc <-> Req
+  dbData.procs?.forEach(proc => {
+    const procRef = G.addNode('proc', String(proc.id));
+    const procReqs = Array.isArray(proc.requirement_ids) 
+        ? proc.requirement_ids 
+        : (proc.requirement_id ? [proc.requirement_id] : []);
+    
+    procReqs.forEach((reqId: string) => {
+      const reqRef = G.addNode('req', String(reqId));
+      G.addEdge(procRef, reqRef);
+    });
+  });
+
+  // -- Proc <-> Cap (Using body step resolution)
+  dbData.procs?.forEach(proc => {
+    const procRef = G.addNode('proc', String(proc.id));
+    (proc.capability_ids || []).forEach((capId: string) => {
+      const capRef = G.addNode('cap', String(capId));
+      G.addEdge(procRef, capRef);
+    });
+    // Virtual caps defined inside proc
+    virtualCaps.forEach(vCap => {
+      if (vCap._source_workflow?.id === proc.id) {
+         G.addEdge(procRef, G.addNode('cap', String(vCap.id)));
+      }
+    });
+  });
+
+  // -- Cap <-> Tool
+  dbData.tools?.forEach(tool => {
+    const toolRef = G.addNode('tool', String(tool.id));
+    (tool.capability_ids || []).forEach((capId: string) => {
+      const capRef = G.addNode('cap', String(capId));
+      G.addEdge(toolRef, capRef);
+    });
+  });
+
+  // -- Req <-> Tree
+  dbData.reqs?.forEach(req => {
+    const reqRef = G.addNode('req', String(req.id));
+    if (req.node_ids && req.node_ids.length > 0) {
+      req.node_ids.forEach((nId: any) => {
+        if (nId) {
+          const treeRef = G.addNode('tree', String(nId));
+          G.addEdge(reqRef, treeRef);
+        }
+      });
+    } else {
+      (req.source_nodes || []).forEach((sn: any) => {
+        const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
+        if (nodeName) {
+          const treeRef = G.addNode('tree', String(nodeName));
+          G.addEdge(reqRef, treeRef);
+        }
+      });
+    }
+  });
+
+  // -- Req <-> Pattern
+  dbData.reqs?.forEach(req => {
+    const reqRef = G.addNode('req', String(req.id));
+    
+    if (req.pattern_ids && req.pattern_ids.length > 0) {
+      req.pattern_ids.forEach((pId: any) => {
+        if (pId) {
+          const patternRef = G.addNode('pattern', String(pId));
+          G.addEdge(reqRef, patternRef);
+        }
+      });
+    } else if (reqPlanBData && reqPlanBData.requirements) {
+      // Legacy fallback via PlanB
+      const planReq = reqPlanBData.requirements.find((p: any) => String(p.id) === String(req.id));
+      if (planReq) {
+        allItemsets?.forEach(it => {
+            const patternRef = G.addNode('pattern', String(it.id || it.hash));
+            let matched = false;
+            if (it.judgments && Array.isArray(it.judgments)) {
+                matched = it.judgments.some((j: any) => j.represents && (planReq.source_nodes || []).some((sn: any) => {
+                    const nname = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
+                    return nname === j.node;
+                }));
+            } else if (it.leaf_names && Array.isArray(it.leaf_names)) {
+                matched = (planReq.source_nodes || []).some((sn: any) => {
+                    const nname = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
+                    return it.leaf_names.includes(nname);
+                });
+            }
+            if (matched) G.addEdge(reqRef, patternRef);
+        });
+      }
+    } else {
+      // Legacy fallback via __meta__
+      let extractionNodes: string[] = [];
+      try {
+        const meta = typeof req.__meta__ === 'string' ? JSON.parse(req.__meta__) : req.__meta__;
+        if (meta?.extraction_context) {
+          const sNs = meta.extraction_context.substance?.nodes || [];
+          const fNs = meta.extraction_context.form?.nodes || [];
+          extractionNodes = [...sNs.map((n:any)=>n.name), ...fNs.map((n:any)=>n.name)];
+        }
+      } catch (e) {}
+
+      allItemsets?.forEach(it => {
+        if (!it.leaf_names) return;
+        const patternRef = G.addNode('pattern', String(it.id || it.hash));
+        const matched = extractionNodes.some(n => it.leaf_names.includes(n));
+        if (matched) G.addEdge(reqRef, patternRef);
+      });
+    }
+  });
+
+  return G;
+}

Разлика између датотеке није приказан због своје велике величине
+ 245 - 1486
knowhub/frontend/src/pages/Dashboard.tsx


+ 20 - 0
knowhub/frontend/src/services/api.ts

@@ -117,4 +117,24 @@ export const batchGetPosts = async (postIds: string[]): Promise<Record<string, a
   return map;
 };
 
+// --- Static Asset Fetchers for Dashboard (Temporary until DB implementation) ---
+
+export const getCategoryTree = async () => {
+  const resp = await fetch('/category_tree.json');
+  if (!resp.ok) throw new Error('Failed to fetch category_tree.json');
+  return resp.json();
+};
+
+export const getItemsetsAll = async () => {
+  const resp = await fetch('/api/pattern/itemsets?execution_id=56');
+  if (!resp.ok) throw new Error('Failed to fetch itemsets');
+  return resp.json();
+};
+
+export const getRequirementsPlanB = async () => {
+  const resp = await fetch(`/requirements_planb.json?t=${Date.now()}`);
+  if (!resp.ok) throw new Error('Failed to fetch requirements_planb.json');
+  return resp.json();
+};
+
 export default api;

+ 6 - 2
knowhub/knowhub_db/pg_requirement_store.py

@@ -28,7 +28,11 @@ _REL_SUBQUERY = """
     (SELECT COALESCE(json_agg(rr.resource_id), '[]'::json)
      FROM requirement_resource rr WHERE rr.requirement_id = requirement.id) AS resource_ids,
     (SELECT COALESCE(json_agg(rs.strategy_id), '[]'::json)
-     FROM requirement_strategy rs WHERE rs.requirement_id = requirement.id) AS strategy_ids
+     FROM requirement_strategy rs WHERE rs.requirement_id = requirement.id) AS strategy_ids,
+    (SELECT COALESCE(json_agg(rp.itemset_id), '[]'::json)
+     FROM requirement_pattern rp WHERE rp.requirement_id = requirement.id) AS pattern_ids,
+    (SELECT COALESCE(json_agg(rn.node_id), '[]'::json)
+     FROM requirement_node rn WHERE rn.requirement_id = requirement.id) AS node_ids
 """
 
 _BASE_FIELDS = "id, description, source_nodes, status, match_result, version"
@@ -318,7 +322,7 @@ class PostgreSQLRequirementStore:
         if 'source_nodes' in result and isinstance(result['source_nodes'], str):
             result['source_nodes'] = json.loads(result['source_nodes'])
         # 关联字段(来自 junction table 子查询)
-        for field in ('capability_ids', 'knowledge_ids', 'resource_ids', 'strategy_ids', 'knowledge_links'):
+        for field in ('capability_ids', 'knowledge_ids', 'resource_ids', 'strategy_ids', 'knowledge_links', 'pattern_ids', 'node_ids'):
             if field in result and isinstance(result[field], str):
                 result[field] = json.loads(result[field])
             elif field in result and result[field] is None:

+ 330 - 0
knowhub/knowhub_db/pg_requirement_store.py.orig

@@ -0,0 +1,330 @@
+"""
+PostgreSQL requirement 存储封装
+
+用于存储和检索需求数据,支持向量检索。
+表名:requirement(从 requirement_table 迁移)
+"""
+
+import os
+import json
+import psycopg2
+from psycopg2.extras import RealDictCursor
+from typing import List, Dict, Optional
+from dotenv import load_dotenv
+from knowhub.knowhub_db.cascade import cascade_delete
+
+load_dotenv()
+
+# 关联字段子查询。knowledge 边暴露两种视图:knowledge_ids(扁平)+ knowledge_links(含 type)
+_REL_SUBQUERY = """
+    (SELECT COALESCE(json_agg(rc.capability_id), '[]'::json)
+     FROM requirement_capability rc WHERE rc.requirement_id = requirement.id) AS capability_ids,
+    (SELECT COALESCE(json_agg(rk.knowledge_id), '[]'::json)
+     FROM requirement_knowledge rk WHERE rk.requirement_id = requirement.id) AS knowledge_ids,
+    (SELECT COALESCE(json_agg(json_build_object(
+        'id', rk2.knowledge_id, 'relation_type', rk2.relation_type
+     )), '[]'::json)
+     FROM requirement_knowledge rk2 WHERE rk2.requirement_id = requirement.id) AS knowledge_links,
+    (SELECT COALESCE(json_agg(rr.resource_id), '[]'::json)
+     FROM requirement_resource rr WHERE rr.requirement_id = requirement.id) AS resource_ids,
+    (SELECT COALESCE(json_agg(rs.strategy_id), '[]'::json)
+     FROM requirement_strategy rs WHERE rs.requirement_id = requirement.id) AS strategy_ids
+"""
+
+_BASE_FIELDS = "id, description, source_nodes, status, match_result, version"
+
+_SELECT_FIELDS = f"{_BASE_FIELDS}, {_REL_SUBQUERY}"
+
+
+def _normalize_links(data: Dict, links_key: str, ids_key: str, default_type: str):
+    """两种输入格式统一:{links_key: [{id, relation_type}]} 或 {ids_key: [id]}"""
+    if links_key in data and data[links_key] is not None:
+        out = []
+        for item in data[links_key]:
+            if isinstance(item, dict):
+                out.append((item['id'], item.get('relation_type', default_type)))
+            else:
+                out.append((item, default_type))
+        return out
+    if ids_key in data and data[ids_key] is not None:
+        return [(i, default_type) for i in data[ids_key]]
+    return None
+
+
+class PostgreSQLRequirementStore:
+    def __init__(self):
+        """初始化 PostgreSQL 连接"""
+        self.conn = psycopg2.connect(
+            host=os.getenv('KNOWHUB_DB'),
+            port=int(os.getenv('KNOWHUB_PORT', 5432)),
+            user=os.getenv('KNOWHUB_USER'),
+            password=os.getenv('KNOWHUB_PASSWORD'),
+            database=os.getenv('KNOWHUB_DB_NAME')
+        )
+        self.conn.autocommit = True
+        print(f"[PostgreSQL Requirement] 已连接到远程数据库: {os.getenv('KNOWHUB_DB')}")
+
+    def _reconnect(self):
+        self.conn = psycopg2.connect(
+            host=os.getenv('KNOWHUB_DB'),
+            port=int(os.getenv('KNOWHUB_PORT', 5432)),
+            user=os.getenv('KNOWHUB_USER'),
+            password=os.getenv('KNOWHUB_PASSWORD'),
+            database=os.getenv('KNOWHUB_DB_NAME')
+        )
+        self.conn.autocommit = True
+
+    def _ensure_connection(self):
+        if self.conn.closed != 0:
+            self._reconnect()
+        else:
+            try:
+                c = self.conn.cursor()
+                c.execute("SELECT 1")
+                c.close()
+            except (psycopg2.OperationalError, psycopg2.InterfaceError):
+                self._reconnect()
+
+    def _get_cursor(self):
+        self._ensure_connection()
+        return self.conn.cursor(cursor_factory=RealDictCursor)
+
+    def insert_or_update(self, requirement: Dict):
+        """插入或更新需求记录。AnalyticDB beam 表不支持 ON CONFLICT UPDATE 当含 ALTER 新增列,改用 DELETE+INSERT。"""
+        cursor = self._get_cursor()
+        try:
+            cursor.execute("DELETE FROM requirement WHERE id = %s", (requirement['id'],))
+            cursor.execute("""
+                INSERT INTO requirement (
+                    id, description, source_nodes, status, match_result, embedding, version
+                ) VALUES (%s, %s, %s, %s, %s, %s, %s)
+            """, (
+                requirement['id'],
+                requirement.get('description', ''),
+                json.dumps(requirement.get('source_nodes', [])),
+                requirement.get('status', '未满足'),
+                requirement.get('match_result', ''),
+                requirement.get('embedding'),
+                requirement.get('version', 'v0'),
+            ))
+            # 写入关联表
+            req_id = requirement['id']
+            if 'capability_ids' in requirement:
+                cursor.execute("DELETE FROM requirement_capability WHERE requirement_id = %s", (req_id,))
+                for cap_id in requirement['capability_ids']:
+                    cursor.execute(
+                        "INSERT INTO requirement_capability (requirement_id, capability_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                        (req_id, cap_id))
+            k_links = _normalize_links(requirement, 'knowledge_links', 'knowledge_ids', 'related')
+            if k_links is not None:
+                cursor.execute("DELETE FROM requirement_knowledge WHERE requirement_id = %s", (req_id,))
+                for kid, rtype in k_links:
+                    cursor.execute(
+                        "INSERT INTO requirement_knowledge (requirement_id, knowledge_id, relation_type) "
+                        "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                        (req_id, kid, rtype))
+            if 'resource_ids' in requirement and requirement['resource_ids'] is not None:
+                cursor.execute("DELETE FROM requirement_resource WHERE requirement_id = %s", (req_id,))
+                for rid in requirement['resource_ids']:
+                    cursor.execute(
+                        "INSERT INTO requirement_resource (requirement_id, resource_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                        (req_id, rid))
+            if 'strategy_ids' in requirement and requirement['strategy_ids'] is not None:
+                cursor.execute("DELETE FROM requirement_strategy WHERE requirement_id = %s", (req_id,))
+                for sid in requirement['strategy_ids']:
+                    cursor.execute(
+                        "INSERT INTO requirement_strategy (requirement_id, strategy_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                        (req_id, sid))
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    def get_by_id(self, req_id: str) -> Optional[Dict]:
+        """根据 ID 获取需求"""
+        cursor = self._get_cursor()
+        try:
+            cursor.execute(f"""
+                SELECT {_SELECT_FIELDS}
+                FROM requirement WHERE id = %s
+            """, (req_id,))
+            result = cursor.fetchone()
+            return self._format_result(result) if result else None
+        finally:
+            cursor.close()
+
+    def search(self, query_embedding: List[float], limit: int = 10) -> List[Dict]:
+        """向量检索需求"""
+        cursor = self._get_cursor()
+        try:
+            cursor.execute(f"""
+                SELECT {_SELECT_FIELDS},
+                       1 - (embedding <=> %s::real[]) as score
+                FROM requirement
+                WHERE embedding IS NOT NULL
+                ORDER BY embedding <=> %s::real[]
+                LIMIT %s
+            """, (query_embedding, query_embedding, limit))
+            results = cursor.fetchall()
+            return [self._format_result(r) for r in results]
+        finally:
+            cursor.close()
+
+    def list_all(self, limit: int = 100, offset: int = 0, status: Optional[str] = None) -> List[Dict]:
+        """列出需求"""
+        cursor = self._get_cursor()
+        try:
+            if status:
+                cursor.execute(f"""
+                    SELECT {_SELECT_FIELDS}
+                    FROM requirement
+                    WHERE status = %s
+                    ORDER BY id
+                    LIMIT %s OFFSET %s
+                """, (status, limit, offset))
+            else:
+                cursor.execute(f"""
+                    SELECT {_SELECT_FIELDS}
+                    FROM requirement
+                    ORDER BY id
+                    LIMIT %s OFFSET %s
+                """, (limit, offset))
+            results = cursor.fetchall()
+            return [self._format_result(r) for r in results]
+        finally:
+            cursor.close()
+
+    def update(self, req_id: str, updates: Dict):
+        """更新需求字段"""
+        cursor = self._get_cursor()
+        try:
+            # 分离关联字段
+            cap_ids = updates.pop('capability_ids', None)
+            strategy_ids = updates.pop('strategy_ids', None)
+            rel_data = {}
+            for k in ('knowledge_ids', 'knowledge_links', 'resource_ids'):
+                if k in updates:
+                    rel_data[k] = updates.pop(k)
+
+            if updates:
+                set_parts = []
+                params = []
+                json_fields = ('source_nodes',)
+                for key, value in updates.items():
+                    set_parts.append(f"{key} = %s")
+                    if key in json_fields:
+                        params.append(json.dumps(value))
+                    else:
+                        params.append(value)
+                params.append(req_id)
+                cursor.execute(
+                    f"UPDATE requirement SET {', '.join(set_parts)} WHERE id = %s",
+                    params
+                )
+
+            if cap_ids is not None:
+                cursor.execute("DELETE FROM requirement_capability WHERE requirement_id = %s", (req_id,))
+                for cap_id in cap_ids:
+                    cursor.execute(
+                        "INSERT INTO requirement_capability (requirement_id, capability_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                        (req_id, cap_id))
+
+            k_links = _normalize_links(rel_data, 'knowledge_links', 'knowledge_ids', 'related')
+            if k_links is not None:
+                cursor.execute("DELETE FROM requirement_knowledge WHERE requirement_id = %s", (req_id,))
+                for kid, rtype in k_links:
+                    cursor.execute(
+                        "INSERT INTO requirement_knowledge (requirement_id, knowledge_id, relation_type) "
+                        "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                        (req_id, kid, rtype))
+
+            if 'resource_ids' in rel_data and rel_data['resource_ids'] is not None:
+                cursor.execute("DELETE FROM requirement_resource WHERE requirement_id = %s", (req_id,))
+                for rid in rel_data['resource_ids']:
+                    cursor.execute(
+                        "INSERT INTO requirement_resource (requirement_id, resource_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                        (req_id, rid))
+
+            if strategy_ids is not None:
+                cursor.execute("DELETE FROM requirement_strategy WHERE requirement_id = %s", (req_id,))
+                for sid in strategy_ids:
+                    cursor.execute(
+                        "INSERT INTO requirement_strategy (requirement_id, strategy_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                        (req_id, sid))
+
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    def add_knowledge(self, req_id: str, knowledge_id: str, relation_type: str = 'related'):
+        """增量挂接 requirement-knowledge 边"""
+        cursor = self._get_cursor()
+        try:
+            cursor.execute(
+                "INSERT INTO requirement_knowledge (requirement_id, knowledge_id, relation_type) "
+                "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                (req_id, knowledge_id, relation_type))
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    def add_resource(self, req_id: str, resource_id: str):
+        """增量挂接 requirement-resource 边"""
+        cursor = self._get_cursor()
+        try:
+            cursor.execute(
+                "INSERT INTO requirement_resource (requirement_id, resource_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                (req_id, resource_id))
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    def add_strategy(self, req_id: str, strategy_id: str):
+        """增量挂接 requirement-strategy 边(该 strategy 满足此 requirement)"""
+        cursor = self._get_cursor()
+        try:
+            cursor.execute(
+                "INSERT INTO requirement_strategy (requirement_id, strategy_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                (req_id, strategy_id))
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    def delete(self, req_id: str):
+        """删除需求及其关联表记录"""
+        cursor = self._get_cursor()
+        try:
+            cascade_delete(cursor, 'requirement', req_id)
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    def count(self, status: Optional[str] = None) -> int:
+        """统计需求总数"""
+        cursor = self._get_cursor()
+        try:
+            if status:
+                cursor.execute("SELECT COUNT(*) as count FROM requirement WHERE status = %s", (status,))
+            else:
+                cursor.execute("SELECT COUNT(*) as count FROM requirement")
+            return cursor.fetchone()['count']
+        finally:
+            cursor.close()
+
+    def _format_result(self, row: Dict) -> Dict:
+        """格式化查询结果"""
+        if not row:
+            return None
+        result = dict(row)
+        if 'source_nodes' in result and isinstance(result['source_nodes'], str):
+            result['source_nodes'] = json.loads(result['source_nodes'])
+        # 关联字段(来自 junction table 子查询)
+        for field in ('capability_ids', 'knowledge_ids', 'resource_ids', 'strategy_ids', 'knowledge_links'):
+            if field in result and isinstance(result[field], str):
+                result[field] = json.loads(result[field])
+            elif field in result and result[field] is None:
+                result[field] = []
+        return result
+
+    def close(self):
+        if self.conn:
+            self.conn.close()

+ 400 - 0
knowhub/scripts/abstract_patterns.py

@@ -0,0 +1,400 @@
+"""
+27 个抽象 strategy pattern 定义 + 193 条具体 strategy 的分类映射。
+
+用 (req_id, is_selected) 作为主键;REQ_034 / REQ_059 有多 alt,用 strategy_id 消歧。
+"""
+
+# ═══════════════════════════════════════════════════════════
+# Pattern 定义
+# ═══════════════════════════════════════════════════════════
+
+PATTERNS = [
+    # A 类:生成驱动
+    ('P01', '结构化提示词单步直出套路', 'A',
+     '精心构造单一 prompt(五维度/JSON/分段结构),一次性生成完整产出。无多节点工作流,依赖模型的深度语义理解。'),
+    ('P02', 'Midjourney 风格码参数驱动套路', 'A',
+     '利用 Midjourney 的 --sref / --oref / --cref 等风格参考码锚定画面风格或主体,批量生成变体。'),
+    ('P03', 'Nano Banana / Gemini 多模态单模型直出套路', 'A',
+     'Nano Banana Pro 或 Gemini 3 的多模态能力,在单模型内完成图像理解+生成+编辑。'),
+
+    # B 类:工作流/管线
+    ('P04', 'Coze 工作流编排全自动套路', 'B',
+     'Coze 平台可视化节点串联 LLM→文案→批量生图→抠图→拼合,零代码全自动。'),
+    ('P05', 'ComfyUI 节点链精控套路', 'B',
+     'ComfyUI 里 ControlNet / IP-Adapter / LoRA / KSampler 节点组合,高度可定制。'),
+    ('P06', 'Lovart / 一站式 AI 设计平台套路', 'B',
+     'Lovart、Canva AI 等一站式 AI 设计平台的 end-to-end 方案,低门槛模板化产出。'),
+    ('P07', 'QA 闭环自动优化套路', 'B',
+     'LLM 生成 → QA Agent 自动评分 → 迭代优化到满分,典型 HTML/CSS 输出 + Puppeteer 截图。'),
+
+    # C 类:参考图/素材驱动
+    ('P08', '参考图垫图控制套路', 'C',
+     '上传参考图作为 AI 生成的锚点(风格/主体/构图),保留参考图关键特征。'),
+    ('P09', '双图融合虚拟试穿套路', 'C',
+     '双图输入(主体图 + 目标图),AI 完成融合/替换/换装。典型 CatVTON / IDM-VTON 工作流。'),
+
+    # D 类:主体一致 + 变装/变景
+    ('P10', 'IP 系列化角色一致性套路', 'D',
+     '锁定角色 IP(毛色/服饰/特征),批量产出同角色在不同场景/情绪/姿态下的图片。'),
+    ('P11', '图生图局部重绘套路', 'D',
+     'Inpaint 蒙版重绘局部区域,主体其他部分保持;特别用于手部、服饰、物品替换。'),
+    ('P12', 'Character Sheet 多视角参考表套路', 'D',
+     '一次生成正背侧多视角参考表,作为后续多场景生成的 visual anchor。'),
+
+    # E 类:分层合成/拼贴
+    ('P13', '多图层分层合成套路', 'E',
+     '分别生成底图/人物/光效/文字,再在画板/PS/ComfyUI 逐层合成。'),
+    ('P14', '智能抠图 + 拼贴排版套路', 'E',
+     'BiRefNet / RMBG 自动抠主体 → 多主体按布局拼贴为画板/长图/九宫格。'),
+    ('P15', '网格/分镜一次性直出套路', 'E',
+     '一个 prompt 直接生成九宫格/25宫格等完整网格图,而非分开生成再拼。'),
+
+    # F 类:视频/动态
+    ('P16', '图生视频动态化套路', 'F',
+     '静态图作为首帧/尾帧,用 Runway/Kling/Seedance 等生成动态视频。'),
+    ('P17', '多镜头故事视频全链路套路', 'F',
+     'LLM 写脚本 → 分镜 → 批量生图 → 图转视频 → 拼接成完整故事视频。'),
+    ('P18', '唇形同步视频套路', 'F',
+     '静态人像 + 音频 → 唇形同步动画视频(Hedra, Wav2Lip, HeyGen, LivePortrait)。'),
+
+    # G 类:光影色调
+    ('P19', '戏剧性光影与 Chiaroscuro 套路', 'G',
+     '低调光、明暗对照、单光源聚焦,Rembrandt/Butterfly 等专业布光。'),
+    ('P20', '色调锚定与胶片质感套路', 'G',
+     '锁定主色调(暖调/冷调/单色),叠加胶片颗粒/柔焦/LUT 等光学质感。'),
+    ('P21', '多光源分层打光套路', 'G',
+     '主光/辅光/轮廓光/氛围光分层规划,模拟摄影棚或电影级布光。'),
+
+    # H 类:空间/透视
+    ('P22', '空间透视与广角畸变套路', 'H',
+     '消失点、广角/鱼眼、仰拍俯拍、荷兰角等特殊透视控制。'),
+    ('P23', '360 全景/VR 生成套路', 'H',
+     'equirectangular 投影、2:1 比例、无缝球形全景。'),
+
+    # I 类:材质/质感
+    ('P24', '专业摄影级材质质感套路', 'I',
+     '摄影参数模拟 + 材质关键词(毛绒/石材/木纹/金属/玻璃)精准描述。'),
+    ('P25', '人像皮肤 + 去 AI 感套路', 'I',
+     '皮肤纹理/瑕疵/毛孔/次表面散射,消除塑料感,达到真实摄影级自然质感。'),
+
+    # J 类:文字/排版
+    ('P26', 'AI 图内文字渲染 + 排版套路', 'J',
+     '利用 GPT-Image / Nano Banana 直接在画面中渲染文字,无需后期叠加。'),
+    ('P27', '数据驱动模板化套版套路', 'J',
+     '数据表(CSV/JSON/Excel)作为输入源,填入预设模板,批量产出同结构异内容。'),
+]
+
+PATTERN_NAME = {pid: name for pid, name, _, _ in PATTERNS}
+PATTERN_CAT = {pid: cat for pid, _, cat, _ in PATTERNS}
+PATTERN_DESC = {pid: desc for pid, _, _, desc in PATTERNS}
+
+
+# ═══════════════════════════════════════════════════════════
+# 具体 strategy 到 pattern 的映射
+# ═══════════════════════════════════════════════════════════
+
+# 第一层:用 strategy_id 直接映射(针对多 alt req 或需特殊归类的)
+STRATEGY_ID_OVERRIDES = {
+    # REQ_034 的 3 条(selected + 2 alts)
+    'strategy-3d9f49e4': 'P01',   # 纯提示词驱动路线
+    'strategy-b328e670': 'P08',   # 参考图复刻路线
+    'strategy-d7adce07': 'P13',   # 混合素材增强路线(实景+AI分层合成)
+    # REQ_059 的 3 条
+    'strategy-3d86771b': 'P22',   # 鱼眼镜头夸张变形
+    'strategy-dfca9a9b': 'P22',   # 极端仰拍透视冲击
+    'strategy-99acc1d0': 'P23',   # 球形全景360度
+}
+
+
+# 第二层:(req_id, is_selected) -> pattern
+# 覆盖单 alt reqs。多 alt reqs 的 entries 会在 classifier 里先走 STRATEGY_ID_OVERRIDES
+REQ_SEL_MAPPING = {
+    # ────── P01 结构化提示词单步直出 ──────
+    ('REQ_001', True): 'P01',   # 3D夸张风格路线
+    ('REQ_006', True): 'P01',   # 提示词驱动一步直出
+    ('REQ_015', True): 'P01',   # 多阶段精准控制流派
+    ('REQ_015', False): 'P01',  # 单阶段精准提示词文生图
+    ('REQ_024', True): 'P01',   # 直接生成路线
+    ('REQ_025', True): 'P01',   # 提示词工程直接生成
+    ('REQ_033', True): 'P01',   # 极简单步 AIGC 叠加
+    ('REQ_043', True): 'P01',   # 蓝图A:纯AI自动生成
+    ('REQ_055', True): 'P01',   # 提示词驱动极端特写
+    ('REQ_058', True): 'P01',   # 结构化Prompt五维度直出
+    ('REQ_064', True): 'P01',   # 高水准氛围插画
+    ('REQ_083', True): 'P01',   # 提示词工程驱动
+    ('REQ_085', True): 'P01',   # 提示词驱动单色调场景
+    ('REQ_086', True): 'P01',   # AI提示词驱动多色并置
+    ('REQ_087', True): 'P01',   # 禅意极简水墨
+    ('REQ_088', True): 'P01',   # 诗意提示词驱动·单图超现实
+    ('REQ_098', True): 'P01',   # 纯AI图文一体生成
+
+    # ────── P02 Midjourney 风格码 ──────
+    ('REQ_061', True): 'P02',   # 风格锁定直出路线 --sref
+    ('REQ_067', False): 'P02',  # 提示工程驱动的梦境逻辑路线 MJ
+    ('REQ_002', True): 'P02',   # 人物×道具精准绑定路线(MJ --oref)
+    ('REQ_037', True): 'P02',   # 多姿态拼贴路线 JSON × Nano Banana Pro
+
+    # ────── P03 Nano Banana 多模态单模型 ──────
+    ('REQ_009', False): 'P03',  # AI生成式拼贴工作流
+    ('REQ_017', True): 'P03',   # 基于ControlNet的多人姿态迁移
+    ('REQ_031', True): 'P03',   # Strategy-031 (真人→蟑螂人 via Nano Banana)
+    ('REQ_033', False): 'P03',  # 双工具协作精细化(Nano Banana + Firefly)
+    ('REQ_049', True): 'P03',   # 提示词直出网格路线(NanoBananaPro/即梦)
+    ('REQ_084', True): 'P03',   # 文本直驱全景生成(DiT360/混元3D)
+
+    # ────── P04 Coze 工作流 ──────
+    ('REQ_010', True): 'P04',   # 工作流驱动模块化组装法
+    ('REQ_030', False): 'P04',  # 工作流自动化批量(Coze+DeepSeek)
+    ('REQ_045', True): 'P04',   # AI脚本驱动·批量生图·文字嵌入·宫格自动排版
+    ('REQ_046', True): 'P04',   # 全自动 Coze 工作流
+    ('REQ_074', True): 'P04',   # AI提示词驱动全自动食材百科
+    ('REQ_079', False): 'P04',  # 豆包Seedream提示词生图流派
+    ('REQ_080', True): 'P04',   # Canva AI 数据驱动全链路自动化
+
+    # ────── P05 ComfyUI 节点链精控 ──────
+    ('REQ_007', True): 'P05',   # 高保真写实路线 realisticVisionV51
+    ('REQ_017', False): 'P05',  # 基于3D姿态参考的多人动态生成
+    ('REQ_049', False): 'P05',  # ComfyUI工作流精控网格 FLUX-Klein
+    ('REQ_051', True): 'P05',   # 工业化路线:角色资产先行+分镜批量
+    ('REQ_055', False): 'P05',  # 垫图+LoRA精准控制
+    ('REQ_058', False): 'P05',  # 3D白模参考图ControlNet引导
+    ('REQ_062', False): 'P05',  # ComfyUI 节点化工作流精准控制
+    ('REQ_073', True): 'P05',   # 高精度全自动 ComfyUI Flux 深度控制
+    ('REQ_080', False): 'P05',  # ComfyUI 工作流批处理
+    ('REQ_094', False): 'P05',  # ControlNet精准光影约束
+
+    # ────── P06 Lovart 一站式 ──────
+    ('REQ_021', False): 'P06',  # Lovart AI 全流程一站式
+    ('REQ_040', True): 'P06',   # 全自动批量语义驱动 Lovart+Nano Banana
+    ('REQ_050', True): 'P06',   # 结构化信息图混排全自动化
+    ('REQ_050', False): 'P06',  # AI提示词驱动混合媒体拼贴
+
+    # ────── P07 QA 闭环 ──────
+    ('REQ_081', True): 'P07',   # AI全自动图层合成流派(品牌规则+QA闭环)
+    ('REQ_092', True): 'P07',   # AI驱动HTML信息图全自动生成
+    ('REQ_099', False): 'P07',  # 结构化提示词驱动--变量锚点+字体层次
+    ('REQ_099', True): 'P07',   # 全自动化 AI 管线--品牌规则+QA 闭环
+
+    # ────── P08 参考图垫图控制 ──────
+    ('REQ_025', False): 'P08',  # 人物固定+多角度换装
+    ('REQ_042', True): 'P08',   # 参考图引导生成(版式迁移)
+    ('REQ_056', False): 'P08',  # 参考图驱动型人脸一致性近景
+    ('REQ_063', False): 'P08',  # HEX 色板精准定调 + LUT 模板
+    ('REQ_065', True): 'P08',   # 极速直出路线:精准配色锚点
+    ('REQ_065', False): 'P08',  # 参考图驱动:GPT反向读图
+    ('REQ_082', True): 'P08',   # 参考图像锁定(Nano Banana Pro/Qwen)
+
+    # ────── P09 双图融合虚拟试穿 ──────
+    ('REQ_016', True): 'P09',   # 双图垫图融合 + 手部专项修复
+    ('REQ_022', True): 'P09',   # 静态写真精准换装
+    ('REQ_023', False): 'P09',  # 纯图像超现实错位穿戴
+    ('REQ_028', True): 'P09',   # AI人宠合照专业模型
+    ('REQ_029', True): 'P09',   # 精准叠合:AI主体抠图+姿态锚点+局部重绘
+    ('REQ_032', True): 'P09',   # CatVTON局部重绘保面部
+
+    # ────── P10 IP 系列化 ──────
+    ('REQ_001', False): 'P10',  # 写实高精度路线:人脸身份锁定+情绪矩阵prompt库
+    ('REQ_027', True): 'P10',   # 系统化三要素精细控制
+    ('REQ_027', False): 'P10',  # 轻量级提示词驱动路线
+    ('REQ_068', True): 'P10',   # 高精度拟人化四层结构流派
+    ('REQ_068', False): 'P10',  # 快速批量出图(场景×情绪矩阵)
+    ('REQ_071', True): 'P10',   # 统一IP系列化批量生产
+    ('REQ_071', False): 'P10',  # 单图精品直出流派
+
+    # ────── P11 图生图局部重绘 ──────
+    ('REQ_024', False): 'P11',  # 分步合成路线(生成底图后局部重绘头部)
+    ('REQ_026', False): 'P11',  # AI换装路线(真实宠物照片,局部重绘)
+    ('REQ_026', True): 'P11',   # AI文生图路线(从零生成) — 按方法归 P11
+    ('REQ_029', False): 'P11',  # 风格统一 IP-Adapter + 图生图重绘
+    ('REQ_072', False): 'P11',  # 高一致性图生图局部重绘
+
+    # ────── P12 Character Sheet 多视角 ──────
+    ('REQ_037', False): 'P12',  # 动态抓拍 MJ V7 Character Sheet
+    ('REQ_051', False): 'P12',  # 轻量直出:单Prompt多格叙事
+    ('REQ_082', False): 'P12',  # 结构化提示词(专业分镜术语)
+    ('REQ_083', False): 'P12',  # LoRA精确角度控制坐标系量化
+    ('REQ_091', True): 'P12',   # 3D场景叙事表情包
+    ('REQ_091', False): 'P12',  # 多表情一致性表情包
+
+    # ────── P13 多图层分层合成 ──────
+    ('REQ_005', True): 'P13',   # 沉浸式花卉穹顶婚礼场景--三层立体空间
+    ('REQ_005', False): 'P13',  # 中式节日庆典喜庆场景--红金配色书法字牌
+    ('REQ_008', True): 'P13',   # 纯AI一键合成:人物→微缩场景融合
+    ('REQ_008', False): 'P13',  # 场景定制增强路线
+    ('REQ_019', True): 'P13',   # 直接融合生成路线
+    ('REQ_019', False): 'P13',  # 分层合成精修路线
+    ('REQ_021', True): 'P13',   # AI文生图 + 多元素分层合成
+    ('REQ_041', True): 'P13',   # AI 智能拼贴叙事流派(主线)
+
+    # ────── P14 智能抠图 + 拼贴 ──────
+    ('REQ_009', True): 'P14',   # 模板化自动拼贴工作流
+    ('REQ_028', False): 'P14',  # AI图生图场景融合路线
+    ('REQ_039', True): 'P14',   # 多视角生活拼贴自动化
+    ('REQ_041', False): 'P14',  # 结构化叙事长图流派
+    ('REQ_044', True): 'P14',   # 路线A:提示词驱动的图文一体化
+    ('REQ_044', False): 'P14',  # 路线B:AI智能后处理叠加
+    ('REQ_052', True): 'P14',   # 结构化网格拼贴流派
+    ('REQ_052', False): 'P14',  # 叙事性场景拼贴 Bento-grid
+
+    # ────── P15 网格/分镜一次性直出 ──────
+    ('REQ_030', True): 'P15',   # 单图驱动九宫格全自动生成
+    ('REQ_010', False): 'P15',  # 提示词驱动一步生成法
+    ('REQ_032', False): 'P15',  # 轻量提示词驱动 Google Whisk AI
+    ('REQ_067', True): 'P15',   # 极简符号化超现实路线:五维咒语 × 多主体场景
+    ('REQ_070', True): 'P15',   # Strategy-070 (25 宫格高密度)
+    ('REQ_045', False): 'P15',  # 长文自动分页为图片序列
+
+    # ────── P16 图生视频动态化 ──────
+    ('REQ_022', False): 'P16',  # 动态故事动画流派
+    ('REQ_023', True): 'P16',   # 图转视频动态错位穿搭
+    ('REQ_038', False): 'P16',  # 动态视频生成 AI 文生视频
+    ('REQ_088', False): 'P16',  # 宇宙视角缩放·人物分层合成(含动态)
+
+    # ────── P17 多镜头故事视频 ──────
+    ('REQ_003', True): 'P17',   # 多镜头拟人化故事视频全链路
+    ('REQ_003', False): 'P17',  # 精品单图拟人化角色生成
+
+    # ────── P18 唇形同步(本次数据稀少,归并入 P17 也可;先独立保留)──────
+    # (本次数据里没有单独唇形同步 strategy,此 pattern 保留作为未来占位)
+
+    # ────── P19 戏剧性光影 Chiaroscuro ──────
+    ('REQ_014', True): 'P19',   # 分层精控路线--深色底图×霓虹光效
+    ('REQ_014', False): 'P19',  # 提示词直出路线--配色锁定
+    ('REQ_062', True): 'P19',   # Strategy-062(暗调点睛)
+    ('REQ_089', True): 'P19',   # 场景主导型科技活动海报(霓虹强化)
+    ('REQ_094', True): 'P19',   # 戏剧性光影对比(侧光/逆光/硬光)
+
+    # ────── P20 色调锚定与胶片质感 ──────
+    ('REQ_013', True): 'P20',   # AI图文生成+色调控制
+    ('REQ_013', False): 'P20',  # AI插画生成+排版合成
+    ('REQ_063', True): 'P20',   # 主色调锚定+色彩脚本分层+胶片氛围
+    ('REQ_064', False): 'P20',  # 快速全自动管线路线(胶片质感)
+    ('REQ_076', True): 'P20',   # MidJourney 颗粒质感 + 纸张底图
+    ('REQ_076', False): 'P20',  # Risograph 孔版印刷风格
+
+    # ────── P21 多光源分层打光 ──────
+    ('REQ_095', True): 'P21',   # 多光源分层打光三阶段
+    ('REQ_095', False): 'P21',  # AI提示词直出策略
+    ('REQ_096', True): 'P21',   # 体积光+霓虹双色对比
+
+    # ────── P22 空间透视与广角畸变 ──────
+    ('REQ_060', True): 'P22',   # 水面倒影嵌套镜像
+    ('REQ_060', False): 'P22',  # 悬空矩形框画中画
+    ('REQ_084', False): 'P22',  # 超宽画幅空间营造
+
+    # ────── P23 360 全景 ──────
+    ('REQ_093', True): 'P23',   # 结构锁定 x 风格迁移一体化路线
+    ('REQ_093', False): 'P23',  # 从零构建:平面图到3D空间
+
+    # ────── P24 专业摄影级材质质感 ──────
+    ('REQ_036', True): 'P24',   # 工作室级产品摄影
+    ('REQ_036', False): 'P24',  # 高端产品爆炸图信息图
+    ('REQ_057', True): 'P24',   # 精准材质特写:镜头×材质×质量校验
+    ('REQ_057', False): 'P24',  # 高端产品广告级特写
+    ('REQ_066', True): 'P24',   # Strategy-066 (暖色调室内材质)
+    ('REQ_077', True): 'P24',   # 照片级材质生成(PBR技术)
+    ('REQ_077', False): 'P24',  # 场景级材质替换路线(AI图像生成+风格迁移)
+    ('REQ_090', True): 'P24',   # 精准还原--三要素全命中五阶段
+    ('REQ_090', False): 'P24',  # 氛围优先路线--禅意基底
+
+    # ────── P25 人像皮肤 + 去 AI 感 ──────
+    ('REQ_007', False): 'P25',  # 结构化提示词抓拍路线
+    ('REQ_018', True): 'P25',   # 精准嘴唇衔花特写
+    ('REQ_018', False): 'P25',  # 创意身体部位融合多变体批量
+    ('REQ_056', True): 'P25',   # 情绪驱动型近景肖像
+
+    # ────── P26 AI 图内文字渲染 + 排版 ──────
+    ('REQ_011', True): 'P26',   # 多模态AI指令驱动路线
+    ('REQ_011', False): 'P26',  # AI视觉理解 + 自动标注生成
+    ('REQ_042', False): 'P26',  # 提示词直接生成路线(从零快速起稿)
+    ('REQ_043', False): 'P26',  # 蓝图B:智能修复增强
+    ('REQ_078', True): 'P26',   # 在线工具自动化文字叠加(Canva/CapCut)
+    ('REQ_089', False): 'P26',  # 文字主导型科技活动海报(GPT-Image 2)
+    ('REQ_097', True): 'P26',   # 暴力美学电商大字报全自动生成
+    ('REQ_097', False): 'P26',  # AI提示词驱动全自动图文排版
+
+    # ────── P27 数据驱动模板套版 ──────
+    ('REQ_020', True): 'P27',   # AI文案生成+模板组件+数据驱动批量
+    ('REQ_020', False): 'P27',  # 视觉规范先行+AI素材生成+参数化模板
+    ('REQ_047', True): 'P27',   # 数据驱动型全自动报告生成
+    ('REQ_047', False): 'P27',  # 视觉优先型 AI 图表美化
+    ('REQ_048', True): 'P27',   # AI全自动:自然语言→语义识别→SVG/HTML
+    ('REQ_048', False): 'P27',  # 参考图逆向复刻--草图→AI→可编辑图表
+    ('REQ_098', False): 'P27',  # AI底图生成+专项文字渲染
+
+    # ─── 补余 ───
+    # 占位 strategies(Phase 1 正规化的)
+    ('REQ_004', True): 'P01',   # Strategy-004: 提示词直出为主 + 细节强化
+    ('REQ_053', True): 'P13',   # Strategy-053: 三层空间分层生成
+    ('REQ_066', True): 'P24',   # 已归入 P24 材质类(上面已有)
+    ('REQ_070', True): 'P15',   # 已归入 P15(上面已有)
+
+    # 单例余项(从 /tmp/strategy_clusters_v2.md 单例列表)
+    ('REQ_012', True): 'P06',   # 杂志报告风格图文混排--用 Lovart 类工具
+    ('REQ_012', False): 'P06',  # 产品展示型长图生成(电商)
+    ('REQ_027', True): 'P10',   # 已有
+    ('REQ_035', True): 'P01',   # AI图文生成+色调控制 — 归 P01 (提示词)
+    ('REQ_035', False): 'P01',  # 参数公式直出路线
+    ('REQ_038', True): 'P25',   # 静态高动态图像流派(人像肖像)
+    ('REQ_048', True): 'P27',   # 已有
+    ('REQ_055', False): 'P05',  # 已有
+    ('REQ_059', True): 'P22',   # will be overridden by strategy_id
+    ('REQ_067', True): 'P15',   # 已有
+    ('REQ_072', True): 'P11',   # 已有(将 False 改 True)  — 等等,REQ_072 sel 是 P10/P11?查
+    ('REQ_081', False): 'P07',  # 模板驱动批量生成流派 — 归 P07 (或 P13)
+    ('REQ_092', False): 'P06',  # 科普长图模板化生成路线
+    ('REQ_096', False): 'P19',  # 霓虹粒子风暴+生物发光路线
+
+    # REQ_006 alt
+    ('REQ_006', False): 'P05',  # 结构控制+风格迁移精准路线(ControlNet 风格)
+
+    # REQ_002 alt
+    ('REQ_002', False): 'P13',  # 人物×场景深度融合路线
+
+    # REQ_061 alt
+    ('REQ_061', False): 'P01',  # 多风格分层融合--风格基底×双色分层
+
+    # REQ_034 alts by strategy_id override(在上方 STRATEGY_ID_OVERRIDES)
+
+    # 单例补充:REQ_072 sel
+    ('REQ_072', True): 'P11',   # 高一致性图生图局部重绘(实际是 P11)
+
+    # REQ_030 sel 已有(P15)
+    # REQ_049 sel 已有(P03)
+    # REQ_079 sel 已有(P04)
+
+    # REQ_079 sel
+    ('REQ_079', True): 'P04',   # DeepSeek + MD2Card 文本生成排版
+
+    # REQ_069
+    ('REQ_069', True): 'P13',   # 3D卡通机器人主体+悬浮全息数据图表(分层合成)
+    ('REQ_069', False): 'P13',  # 插画场景叙事+机器人协作+数据可视化
+
+    # REQ_054(0-cap 未分配占位)
+    ('REQ_054', True): 'P17',   # Gemini分镜逻辑驱动的AI多格视频分镜
+    ('REQ_054', False): 'P15',  # ChatGPT代码解释器驱动的精准网格图文
+
+    # REQ_062 alt 已有
+    # REQ_075
+    ('REQ_075', True): 'P19',   # 戏剧性体积光四阶段流派
+    ('REQ_075', False): 'P21',  # 冷暖双极对立光晕流派(多光源)
+
+    # REQ_067 alt 已有
+
+    # ─── 最终补余(分类器验证后发现的 8 个 unmapped)───
+    ('REQ_016', False): 'P01',  # 结构化提示词多模型并行生成(零素材快速)
+    ('REQ_040', False): 'P10',  # 精品单卡语义匹配(Midjourney 图集 + 逐张匹配)- IP 系列化
+    ('REQ_046', False): 'P26',  # AI 提示词直出信息图:图文一体直出 (图内文字)
+    ('REQ_073', False): 'P01',  # 轻量快速路线:Midjourney提示词驱动蓝紫色调
+    ('REQ_074', False): 'P27',  # 放射状布局+系列模板混合(模板套版)
+    ('REQ_085', False): 'P27',  # 参数化单色模板批量生成
+    ('REQ_086', False): 'P14',  # 风格化拼贴构图路线
+    ('REQ_087', False): 'P20',  # 莫兰迪素雅插画(色调锚定)
+}
+
+
+def classify(strategy_id: str, req_id: str, is_selected: bool) -> str:
+    """Return pattern_id or None if unmapped."""
+    if strategy_id in STRATEGY_ID_OVERRIDES:
+        return STRATEGY_ID_OVERRIDES[strategy_id]
+    return REQ_SEL_MAPPING.get((req_id, is_selected))

+ 98 - 0
knowhub/scripts/backup_before_strategy_refactor.py

@@ -0,0 +1,98 @@
+#!/usr/bin/env python3
+"""
+备份 strategy 抽象化重构前的 DB 状态。
+在 DB 内建 bk_20260422_<table> 表,快照以下 6 张表:
+  strategy / requirement_strategy / strategy_capability
+  strategy_resource / strategy_knowledge / knowledge
+
+回滚时用:
+  DELETE FROM <table>; INSERT INTO <table> SELECT * FROM bk_20260422_<table>;
+  (或按 version 过滤回滚 knowledge 表)
+"""
+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
+
+TABLES = [
+    'strategy',
+    'requirement_strategy',
+    'strategy_capability',
+    'strategy_resource',
+    'strategy_knowledge',
+    'knowledge',
+]
+BK_PREFIX = 'bk_20260422_'
+
+
+def main():
+    s = PostgreSQLCapabilityStore()
+    cur = s._get_cursor()
+    try:
+        print(f'{"="*60}', flush=True)
+        print(f'Backup strategy refactor prep — 2026-04-22', flush=True)
+        print(f'{"="*60}\n', flush=True)
+
+        results = []
+        for t in TABLES:
+            bk = f'{BK_PREFIX}{t}'
+            # 检查 bk 表是否已存在
+            cur.execute("""SELECT EXISTS (
+              SELECT 1 FROM information_schema.tables WHERE table_name=%s)""", (bk,))
+            exists = cur.fetchone()['exists']
+            if exists:
+                cur.execute(f'SELECT COUNT(*) c FROM {bk}')
+                bk_count = cur.fetchone()['c']
+                print(f'⚠️  {bk} 已存在({bk_count} 行),跳过 CREATE', flush=True)
+                # still count source
+                cur.execute(f'SELECT COUNT(*) c FROM {t}')
+                src_count = cur.fetchone()['c']
+                results.append((t, src_count, bk_count, 'existed'))
+                continue
+
+            # 计数
+            cur.execute(f'SELECT COUNT(*) c FROM {t}')
+            src_count = cur.fetchone()['c']
+            print(f'backing up {t} ({src_count} rows) → {bk}', flush=True)
+
+            # CREATE TABLE AS (AnalyticDB 的 greenplum 分支支持)
+            cur.execute(f'CREATE TABLE {bk} AS SELECT * FROM {t}')
+
+            # 验证
+            cur.execute(f'SELECT COUNT(*) c FROM {bk}')
+            bk_count = cur.fetchone()['c']
+            ok = '✓' if bk_count == src_count else '❌'
+            print(f'  {ok} {bk}: {bk_count} rows', flush=True)
+            results.append((t, src_count, bk_count, 'created'))
+
+        print(f'\n{"="*60}', flush=True)
+        print(f'{"Table":32s} {"src":>8s} {"bk":>8s} {"status":>10s}', flush=True)
+        print(f'{"-"*60}', flush=True)
+        all_ok = True
+        for t, src, bk_n, status in results:
+            ok = '✓' if src == bk_n else '❌'
+            if src != bk_n: all_ok = False
+            print(f'{t:32s} {src:>8d} {bk_n:>8d} {status:>10s} {ok}', flush=True)
+        print(f'{"="*60}', flush=True)
+        if all_ok:
+            print('\n✅ Backup complete. All counts match.', flush=True)
+        else:
+            print('\n❌ Counts mismatch. INVESTIGATE before proceeding.', flush=True)
+
+        # 显示回滚指令
+        print('\n回滚指令(当需要时):', flush=True)
+        for t in TABLES:
+            bk = f'{BK_PREFIX}{t}'
+            if t == 'knowledge':
+                print(f"  DELETE FROM {t} WHERE version = 'howard_strategy_instance';", flush=True)
+                print(f"  -- 不删 v0 老数据;新 version 删除后相当于恢复到 1046 行状态", flush=True)
+            else:
+                print(f'  DELETE FROM {t}; INSERT INTO {t} SELECT * FROM {bk};', flush=True)
+    finally:
+        cur.close()
+        s.close()
+
+
+if __name__ == '__main__':
+    main()

+ 286 - 0
knowhub/scripts/cluster_strategies.py

@@ -0,0 +1,286 @@
+#!/usr/bin/env python3
+"""
+Phase 3:对 193 条具体 strategy 做聚类,准备抽象化。
+
+特征:
+  - cap_signature: set of canonical capability_ids (去重后)
+  - phase_count: int
+  - tool_set: set of primary tool IDs from workflow_outline[*].capabilities[*].implements
+  - name_bigrams: char 2-grams of strategy name
+
+聚类策略(多信号组合):
+  1. 建立 pairwise composite similarity:
+     composite = 0.6 * cap_jaccard
+                + 0.2 * tool_jaccard
+                + 0.1 * name_bigram_jaccard
+                + 0.1 * phase_count_proximity
+  2. 粗筛阈值:composite >= 0.35(粗筛 → recall 优先)
+  3. 传递闭包得到候选簇
+
+输出 /tmp/strategy_clusters.md 含:
+  - 每簇 N 条 strategy(req_id / name / is_selected / coverage_score)
+  - 簇内 cap 交集(majority 50%+ 成员持有)
+  - 簇内常见 tools
+  - 簇内常见 name 2-grams
+  - 建议抽象名 + 判断提示
+"""
+import json
+import math
+import re
+import sys
+from collections import defaultdict, Counter
+from itertools import combinations
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+from knowhub.knowhub_db.pg_capability_store import PostgreSQLCapabilityStore
+
+
+CAP_WEIGHT = 0.55
+TOOL_WEIGHT = 0.25
+NAME_WEIGHT = 0.15
+PHASE_WEIGHT = 0.05
+COMPOSITE_THRESHOLD = 0.40    # 粗筛
+STRONG_LINK_THRESHOLD = 0.55  # 传递闭包只走强边
+
+
+def norm(s):
+    return re.sub(r'\s+', '', (s or '').strip().lower())
+
+
+def ch_bigrams(s):
+    s = re.sub(r'\s+', '', s or '')
+    return set(s[i:i+2] for i in range(len(s) - 1))
+
+
+def jaccard(a, b):
+    if not a or not b: return 0
+    return len(a & b) / len(a | b)
+
+
+def phase_proximity(a, b):
+    """Phase 数量差越小越相似"""
+    if a == 0 and b == 0: return 0  # both empty = no signal
+    d = abs(a - b)
+    if d == 0: return 1.0
+    if d == 1: return 0.7
+    if d == 2: return 0.4
+    return 0.1
+
+
+def extract_strategy_features(cur):
+    """Return list of {id, req_id, name, is_selected, coverage_score, caps, tools, phases}.
+    caps 从 strategy_capability 表读取(已经过 alias 解析,准确);
+    tools + phases 从 body.workflow_outline 提取。
+    """
+    db_caps = {}
+    cur.execute('SELECT id, name FROM capability')
+    for r in cur.fetchall(): db_caps[r['id']] = r['name']
+
+    # strategy_id → set of cap_ids
+    strat_caps_map = defaultdict(set)
+    cur.execute('SELECT strategy_id, capability_id FROM strategy_capability')
+    for r in cur.fetchall():
+        strat_caps_map[r['strategy_id']].add(r['capability_id'])
+
+    cur.execute("""
+      SELECT s.id, s.name, s.body,
+             rs.requirement_id, rs.is_selected, rs.coverage_score
+      FROM strategy s
+      JOIN requirement_strategy rs ON rs.strategy_id=s.id
+      ORDER BY rs.requirement_id, rs.is_selected DESC""")
+    rows = cur.fetchall()
+
+    features = []
+    for row in rows:
+        body = row['body'] if isinstance(row['body'], dict) else json.loads(row['body'] or '{}')
+        wo = body.get('workflow_outline') or []
+        tools = set(); phases = 0
+        if isinstance(wo, list):
+            phases = len(wo)
+            for ph in wo:
+                if not isinstance(ph, dict): continue
+                for c in ph.get('capabilities', []) or []:
+                    if isinstance(c, dict):
+                        impl = c.get('implements')
+                        if isinstance(impl, dict):
+                            for t in impl.keys(): tools.add(t)
+                        elif isinstance(impl, list):
+                            for t in impl:
+                                if isinstance(t, str): tools.add(t)
+        features.append({
+            'id': row['id'],
+            'req_id': row['requirement_id'],
+            'name': row['name'],
+            'is_selected': row['is_selected'],
+            'coverage_score': row['coverage_score'],
+            'caps': strat_caps_map[row['id']],  # 从 junction 读,准确
+            'tools': tools,
+            'phases': phases,
+            'name_bigrams': ch_bigrams(row['name']),
+        })
+    return features, db_caps
+
+
+def compute_cap_idf(features):
+    """log(N / df(cap)) — rare caps get higher weight."""
+    N = len(features)
+    df = Counter()
+    for s in features:
+        for c in s['caps']: df[c] += 1
+    idf = {c: math.log(N / n) for c, n in df.items()}
+    return idf, df
+
+
+def weighted_jaccard(a, b, idf):
+    """Sum of IDF weights over intersection / union."""
+    inter = a & b; union = a | b
+    if not union: return 0
+    w_inter = sum(idf.get(c, 0) for c in inter)
+    w_union = sum(idf.get(c, 0) for c in union)
+    return w_inter / w_union if w_union > 0 else 0
+
+
+def composite_sim(a, b, cap_idf):
+    """Weighted cap similarity with IDF + other signals."""
+    cap_j = weighted_jaccard(a['caps'], b['caps'], cap_idf)
+    tool_j = jaccard(a['tools'], b['tools'])
+    name_j = jaccard(a['name_bigrams'], b['name_bigrams'])
+    phase_p = phase_proximity(a['phases'], b['phases'])
+    sim = (CAP_WEIGHT * cap_j + TOOL_WEIGHT * tool_j +
+           NAME_WEIGHT * name_j + PHASE_WEIGHT * phase_p)
+    return sim, cap_j, tool_j, name_j, phase_p
+
+
+def cluster_with_strong_links(features, candidate_threshold, strong_threshold, cap_idf):
+    """
+    两层:候选边(做参考)+ 强边(做簇)。
+    只用强边做传递闭包,避免弱边链条化。
+    """
+    n = len(features)
+    strong_adj = defaultdict(set)
+    all_edges = []
+    for i, j in combinations(range(n), 2):
+        sim, cap_j, tool_j, name_j, phase_p = composite_sim(features[i], features[j], cap_idf)
+        if sim >= candidate_threshold:
+            all_edges.append((i, j, sim, cap_j, tool_j, name_j, phase_p))
+            if sim >= strong_threshold:
+                strong_adj[i].add(j); strong_adj[j].add(i)
+
+    visited = set()
+    clusters = []
+    for i in range(n):
+        if i in visited: continue
+        if i not in strong_adj:
+            clusters.append([i]); visited.add(i); continue
+        comp = []; stack = [i]
+        while stack:
+            x = stack.pop()
+            if x in visited: continue
+            visited.add(x); comp.append(x)
+            for y in strong_adj[x]:
+                if y not in visited: stack.append(y)
+        clusters.append(comp)
+    return clusters, all_edges
+
+
+def summarize_cluster(idx, members, features, db_caps, out):
+    """Write cluster summary to markdown file out."""
+    strats = [features[i] for i in members]
+    out.write(f'\n## 簇 {idx}({len(strats)} 条 strategies)\n\n')
+
+    # Stats
+    cap_count = Counter()
+    for s in strats:
+        for c in s['caps']: cap_count[c] += 1
+    tool_count = Counter()
+    for s in strats:
+        for t in s['tools']: tool_count[t] += 1
+    name_bigram_count = Counter()
+    for s in strats:
+        for g in s['name_bigrams']: name_bigram_count[g] += 1
+
+    total = len(strats)
+    threshold_majority = max(2, total * 0.5)
+
+    majority_caps = [(c, n) for c, n in cap_count.most_common() if n >= threshold_majority]
+    common_tools = [(t, n) for t, n in tool_count.most_common(8)]
+    common_bigrams = [(g, n) for g, n in name_bigram_count.most_common(6) if n >= 2]
+
+    # Members table
+    out.write('| req | is_sel | cov | phases | name |\n')
+    out.write('|---|---|---|---|---|\n')
+    for s in strats:
+        cov = f'{s["coverage_score"]:.2f}' if s['coverage_score'] is not None else '—'
+        is_sel = '✓' if s['is_selected'] else ' '
+        out.write(f'| {s["req_id"]} | {is_sel} | {cov} | {s["phases"]} | {s["name"][:60]} |\n')
+
+    out.write(f'\n**Majority caps**(≥{threshold_majority:.0f}/{total}成员持有):\n')
+    for cid, n in majority_caps[:15]:
+        out.write(f'- {n}/{total}: `{cid}` {db_caps.get(cid, "?")}\n')
+
+    if common_tools:
+        out.write(f'\n**Common tools**:\n')
+        for t, n in common_tools:
+            out.write(f'- {n}/{total}: `{t}`\n')
+
+    if common_bigrams:
+        out.write(f'\n**Common name bigrams** (≥2): ')
+        out.write(' '.join(f'`{g}`({n})' for g, n in common_bigrams) + '\n')
+
+
+def main():
+    s = PostgreSQLCapabilityStore()
+    cur = s._get_cursor()
+    try:
+        features, db_caps = extract_strategy_features(cur)
+        print(f'Total strategies: {len(features)}', flush=True)
+
+        cap_idf, cap_df = compute_cap_idf(features)
+        # Print top non-distinctive caps (high df)
+        print('\nTop 10 most common caps (low IDF):')
+        for c, df in sorted(cap_df.items(), key=lambda x: -x[1])[:10]:
+            print(f'  df={df:3d} idf={cap_idf[c]:.2f} {c} {db_caps.get(c,"?")[:40]}')
+
+        clusters, edges = cluster_with_strong_links(
+            features, COMPOSITE_THRESHOLD, STRONG_LINK_THRESHOLD, cap_idf)
+        # Sort clusters by size desc
+        clusters.sort(key=lambda c: -len(c))
+
+        multi = [c for c in clusters if len(c) >= 2]
+        singleton = [c for c in clusters if len(c) == 1]
+        print(f'Clusters: {len(multi)} multi-strategy + {len(singleton)} singletons', flush=True)
+
+        out_path = Path('/tmp/strategy_clusters.md')
+        with out_path.open('w') as out:
+            out.write(f'# Strategy Clusters (composite >= {COMPOSITE_THRESHOLD})\n\n')
+            out.write(f'Total strategies: {len(features)}\n')
+            out.write(f'Multi-member clusters: {len(multi)}(覆盖 {sum(len(c) for c in multi)} strategies)\n')
+            out.write(f'Singletons: {len(singleton)}\n\n')
+            out.write(f'Weights: cap={CAP_WEIGHT}, tool={TOOL_WEIGHT}, name={NAME_WEIGHT}, phase={PHASE_WEIGHT}\n\n')
+
+            out.write('---\n\n# Multi-member clusters\n')
+            for idx, members in enumerate(multi, 1):
+                summarize_cluster(idx, members, features, db_caps, out)
+
+            out.write('\n\n---\n\n# Singletons(独立 strategy)\n\n')
+            out.write(f'{len(singleton)} strategies 没找到相似伙伴。\n\n')
+            for c in singleton:
+                s_ = features[c[0]]
+                cov = f'{s_["coverage_score"]:.2f}' if s_['coverage_score'] is not None else '—'
+                out.write(f'- [{s_["req_id"]}] caps={len(s_["caps"])}, phases={s_["phases"]}, cov={cov}: {s_["name"][:60]}\n')
+
+        print(f'Written: {out_path}', flush=True)
+        # Print summary
+        print('\n== Multi-member clusters ==')
+        for idx, members in enumerate(multi[:20], 1):
+            req_ids = [features[i]['req_id'] for i in members]
+            print(f'  [{idx}] {len(members)} members: {", ".join(sorted(set(req_ids)))}')
+        if len(multi) > 20: print(f'  ... ({len(multi)-20} more)')
+    finally:
+        cur.close()
+        s.close()
+
+
+if __name__ == '__main__':
+    main()

+ 391 - 0
knowhub/scripts/ingest_all_strategies.py

@@ -0,0 +1,391 @@
+#!/usr/bin/env python3
+"""
+Phase 2:入库全部 strategy(含备选),填入 coverage 数据。
+
+源数据:
+  - /Users/sunlit/Downloads/output 2/ 中 94 个标准 folder(不含 5 rerun)
+  - /Users/sunlit/Downloads/5/  中 5 个 rerun folder
+  - /Users/sunlit/Downloads/coverage_scores.json  coverage 评分
+
+行为:
+  1. 对每个 folder,读 strategy.json 全部 strategies(不过滤 is_selected)
+  2. 计算 strategy_id = hash8(req_text + "|" + strategy_name)
+  3. 已存在则更新 body(保留原 body,加 coverage_score / coverage_explanation 字段)
+     不存在则新建 strategy 行(是 alt)
+  4. 写/更新 requirement_strategy 行:is_selected / coverage_score / coverage_explanation
+  5. 为新建的 alt 写 strategy_capability
+
+注意:5 个占位 folder(004/031/053/066/070)已被 Phase 1 正规化,不在此脚本扫描范围
+(它们的 strategy.json 非标准,只有 selected 那一条能救;其 alt blueprint 难以可靠提取,
+本阶段暂不尝试)。
+
+coverage 匹配:
+  - selected:按 (req_id, is_selected=true) 唯一对齐
+  - alt:名字精确 > 2-gram 字符重合度 > 顺序兜底
+"""
+import hashlib
+import json
+import re
+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.merge_capabilities import MERGE_CLUSTERS
+from knowhub.scripts.rename_merged_capabilities import RENAMES
+from knowhub.scripts.llm_renames import LLM_RENAMES
+
+OUTPUT_DIR = Path('/Users/sunlit/Downloads/output 2')
+RERUN_DIR = Path('/Users/sunlit/Downloads/5')
+COVERAGE_FILE = Path('/Users/sunlit/Downloads/coverage_scores.json')
+RERUN_FOLDERS = {'032', '046', '069', '085', '097'}
+PLACEHOLDER_FOLDERS = {'004', '031', '053', '066', '070'}  # 已 Phase 1 处理,跳过
+DEDUP_VERSION = 'howard_dedup'
+
+
+def norm(s):
+    return (s or '').strip().lower()
+
+
+def hash8(text):
+    return hashlib.sha256(text.encode('utf-8')).hexdigest()[:8]
+
+
+def gen_strategy_id(req_text, strategy_name):
+    return f'strategy-{hash8((req_text or "") + "|" + (strategy_name or ""))}'
+
+
+def ch_bigrams(s):
+    """Character 2-grams — Chinese 名字相似度用。"""
+    s = re.sub(r'\s+', '', s or '')
+    return set(s[i:i+2] for i in range(len(s) - 1))
+
+
+def name_similarity(a, b):
+    """Jaccard on char 2-grams."""
+    if not a or not b: return 0
+    ba, bb = ch_bigrams(a), ch_bigrams(b)
+    if not ba or not bb: return 0
+    return len(ba & bb) / len(ba | bb)
+
+
+# ═══════════════════════════════════════════════════════════
+def build_alias_and_member(cur):
+    m2c = {}
+    for canonical, members in MERGE_CLUSTERS.items():
+        for m in members:
+            m2c[m] = canonical
+
+    def final(cid, limit=10):
+        seen = set()
+        while cid in m2c and cid not in seen and limit > 0:
+            seen.add(cid); cid = m2c[cid]; limit -= 1
+        return cid
+    for m in list(m2c.keys()):
+        m2c[m] = final(m)
+
+    alias = {}
+    cur.execute('SELECT id, name FROM capability')
+    db_caps = {r['id']: r['name'] for r in cur.fetchall()}
+    for cid, name in db_caps.items():
+        alias[norm(name)] = cid
+    for cid, (new_name, _) in RENAMES.items():
+        alias[norm(new_name)] = final(cid)
+    for llm_name, canonical in LLM_RENAMES.items():
+        alias[norm(llm_name)] = final(canonical)
+    return alias, db_caps
+
+
+def resolve_cap_ref(cap_ref, alias, db_caps):
+    """cap_ref = {id, name} dict 或 string。"""
+    if not cap_ref: return None
+    if isinstance(cap_ref, dict):
+        cid = cap_ref.get('id')
+        if cid and cid in db_caps: return cid
+        name = cap_ref.get('name', '')
+        if name:
+            cand = alias.get(norm(name))
+            if cand and cand in db_caps: return cand
+        return None
+    if isinstance(cap_ref, str):
+        # "CAP-xxx name" or pure name
+        m = re.match(r'^(CAP-[\w\-]+)', cap_ref)
+        if m and m.group(1) in db_caps: return m.group(1)
+        cand = alias.get(norm(cap_ref))
+        if cand and cand in db_caps: return cand
+    return None
+
+
+def extract_strat_caps(strategy_dict, alias, db_caps):
+    """workflow_outline 里的 caps → set of canonical ids。"""
+    wo = strategy_dict.get('workflow_outline') or []
+    cap_ids = set()
+    if isinstance(wo, list):
+        for ph in wo:
+            if not isinstance(ph, dict): continue
+            for c in ph.get('capabilities', []) or []:
+                r = resolve_cap_ref(c, alias, db_caps)
+                if r: cap_ids.add(r)
+    return cap_ids
+
+
+# ═══════════════════════════════════════════════════════════
+def match_coverage_for_req(db_strats_with_is_sel, coverage_strats):
+    """
+    Return dict: db_strat_idx → coverage entry.
+    不再按 is_selected 分区,因为 coverage 文件有时所有条目都标 is_selected=True。
+    改为:exact name → fuzzy name → selected 兜底 → 顺序兜底
+    """
+    matched = {}
+    if not coverage_strats: return matched
+
+    remaining_cov = list(coverage_strats)
+
+    # 1. exact name match(最强信号)
+    for i, db_s in enumerate(db_strats_with_is_sel):
+        db_name = db_s.get('name', '')
+        if not db_name: continue
+        m = next((c for c in remaining_cov if c.get('strategy_name') == db_name), None)
+        if m:
+            matched[i] = m
+            remaining_cov.remove(m)
+
+    # 2. fuzzy name match(2-gram Jaccard >= 0.25)
+    for i, db_s in enumerate(db_strats_with_is_sel):
+        if i in matched: continue
+        db_name = db_s.get('name', '')
+        if not db_name or not remaining_cov: continue
+        best = None; best_sim = 0
+        for c in remaining_cov:
+            sim = name_similarity(c.get('strategy_name', ''), db_name)
+            if sim > best_sim:
+                best = c; best_sim = sim
+        if best and best_sim >= 0.25:
+            matched[i] = best
+            remaining_cov.remove(best)
+
+    # 3. selected 特殊对齐:db 和 cov 各剩 1 条 selected,互相对齐
+    db_unsel_sel = [(i, s) for i, s in enumerate(db_strats_with_is_sel)
+                    if s.get('is_selected') and i not in matched]
+    cov_sel_rem = [c for c in remaining_cov if c.get('is_selected')]
+    if len(db_unsel_sel) == 1 and len(cov_sel_rem) >= 1:
+        matched[db_unsel_sel[0][0]] = cov_sel_rem[0]
+        remaining_cov.remove(cov_sel_rem[0])
+
+    # 4. 顺序兜底
+    unmatched_db_idx = [i for i, _ in enumerate(db_strats_with_is_sel) if i not in matched]
+    for j, c in enumerate(remaining_cov):
+        if j < len(unmatched_db_idx):
+            matched[unmatched_db_idx[j]] = c
+    return matched
+
+
+# ═══════════════════════════════════════════════════════════
+def ingest_folder(folder, cur, alias, db_caps, coverage_map, stats):
+    """处理一个 folder,返回 {strategy_id: {inserted, updated, is_selected, ...}} """
+    folder_key = folder.name
+    # 1. req_text + req_id
+    req_text = ''
+    for fn in ['blueprint.json', 'strategy.json', 'capabilities_extracted.json']:
+        fp = folder / fn
+        if not fp.exists(): continue
+        try:
+            d = json.loads(fp.read_text(encoding='utf-8'))
+            if isinstance(d, dict):
+                rt = d.get('requirement', '')
+                if rt:
+                    req_text = rt
+                    break
+        except Exception:
+            continue
+    if not req_text:
+        stats['no_req_text'].append(folder_key)
+        return
+    cur.execute('SELECT id FROM requirement WHERE description = %s LIMIT 1', (req_text,))
+    row = cur.fetchone()
+    if not row:
+        stats['no_req_match'].append(folder_key)
+        return
+    req_id = row['id']
+
+    # 2. load strategy.json
+    strat_path = folder / 'strategy.json'
+    try:
+        sd = json.loads(strat_path.read_text(encoding='utf-8'))
+    except Exception as e:
+        stats['bad_strat_file'].append(folder_key)
+        return
+    if not isinstance(sd, dict):
+        stats['bad_strat_file'].append(folder_key)
+        return
+    all_strats = sd.get('strategies', [])
+    if not isinstance(all_strats, list) or not all_strats:
+        stats['no_strats_list'].append(folder_key)
+        return
+
+    # 3. coverage for this req
+    cov_entry = coverage_map.get(req_id)
+    cov_strats = cov_entry.get('strategies', []) if cov_entry else []
+    match_map = match_coverage_for_req(all_strats, cov_strats)
+
+    # 4. ingest each strategy
+    now = int(time.time())
+    for idx, s_data in enumerate(all_strats):
+        if not isinstance(s_data, dict): continue
+        s_name = s_data.get('name') or f'Strategy-{folder_key}-{idx}'
+        is_sel = bool(s_data.get('is_selected'))
+        s_id = gen_strategy_id(req_text, s_name)
+
+        # coverage
+        cov = match_map.get(idx)
+        cov_score = cov.get('coverage_score') if cov else None
+        cov_expl = cov.get('explanation') if cov else None
+
+        # body 增强:保留原 body 内所有字段,加上 coverage 数据
+        body = dict(s_data)
+        body['coverage_score'] = cov_score
+        body['coverage_explanation'] = cov_expl
+
+        # 4a. strategy 表:存在则 UPDATE,不存在则 INSERT
+        cur.execute('SELECT id FROM strategy WHERE id = %s', (s_id,))
+        exists = cur.fetchone() is not None
+        if exists:
+            cur.execute("""UPDATE strategy SET
+                           name = %s, description = %s, body = %s, updated_at = %s
+                           WHERE id = %s""",
+                        (s_name, (s_data.get('reasoning') or '')[:2000],
+                         json.dumps(body, ensure_ascii=False), now, s_id))
+            stats['strat_updated'] += 1
+        else:
+            cur.execute("""INSERT INTO strategy (id, name, description, body,
+                              status, created_at, updated_at, version)
+                           VALUES (%s, %s, %s, %s, %s, %s, %s, %s)""",
+                        (s_id, s_name, (s_data.get('reasoning') or '')[:2000],
+                         json.dumps(body, ensure_ascii=False),
+                         'draft', now, now, DEDUP_VERSION))
+            stats['strat_inserted'] += 1
+
+        # 4b. requirement_strategy:DELETE + INSERT
+        cur.execute("""DELETE FROM requirement_strategy
+                       WHERE requirement_id = %s AND strategy_id = %s""", (req_id, s_id))
+        cur.execute("""INSERT INTO requirement_strategy
+                       (requirement_id, strategy_id, is_selected,
+                        coverage_score, coverage_explanation)
+                       VALUES (%s, %s, %s, %s, %s)""",
+                    (req_id, s_id, is_sel, cov_score, cov_expl))
+        stats['req_strat_rows'] += 1
+
+        # 4c. strategy_capability:清空重建(防止旧 junction 不准)
+        cur.execute('DELETE FROM strategy_capability WHERE strategy_id = %s', (s_id,))
+        strat_caps = extract_strat_caps(s_data, alias, db_caps)
+        for cid in strat_caps:
+            cur.execute("""INSERT INTO strategy_capability
+                           (strategy_id, capability_id, relation_type)
+                           VALUES (%s, %s, 'compose') ON CONFLICT DO NOTHING""",
+                        (s_id, cid))
+        stats['strat_cap_rows'] += len(strat_caps)
+
+        # 4d. req_cap superset: 备选的 caps 也算研究发现的,并入 req_cap
+        for cid in strat_caps:
+            cur.execute("""INSERT INTO requirement_capability
+                           (requirement_id, capability_id) VALUES (%s, %s)
+                           ON CONFLICT DO NOTHING""", (req_id, cid))
+
+    print(f'[{folder_key}] req={req_id} strategies={len(all_strats)} '
+          f'cov_matched={sum(1 for m in match_map.values() if m is not None)}/{len(cov_strats)}',
+          flush=True)
+
+
+# ═══════════════════════════════════════════════════════════
+def fill_placeholder_req_strats(cur, coverage_map, stats):
+    """为 5 个 placeholder folder 的已有 strategy 填充 requirement_strategy 的新字段。"""
+    for req_id in ['REQ_004', 'REQ_031', 'REQ_053', 'REQ_066', 'REQ_070']:
+        cur.execute("""SELECT s.id, s.name FROM strategy s
+                       JOIN requirement_strategy rs ON rs.strategy_id = s.id
+                       WHERE rs.requirement_id = %s""", (req_id,))
+        row = cur.fetchone()
+        if not row: continue
+        # placeholder folder 本来就是 is_selected = true(只有这一条)
+        cur.execute("""DELETE FROM requirement_strategy
+                       WHERE requirement_id = %s AND strategy_id = %s""",
+                    (req_id, row['id']))
+        cur.execute("""INSERT INTO requirement_strategy
+                       (requirement_id, strategy_id, is_selected,
+                        coverage_score, coverage_explanation)
+                       VALUES (%s, %s, %s, NULL, NULL)""",
+                    (req_id, row['id'], True))
+        stats['placeholder_filled'] += 1
+
+
+# ═══════════════════════════════════════════════════════════
+def main():
+    coverage_map = json.loads(COVERAGE_FILE.read_text(encoding='utf-8'))
+    print(f'Loaded coverage for {len(coverage_map)} reqs', flush=True)
+
+    s = PostgreSQLCapabilityStore()
+    cur = s._get_cursor()
+    try:
+        alias, db_caps = build_alias_and_member(cur)
+        print(f'Alias entries: {len(alias)}, DB caps: {len(db_caps)}', flush=True)
+
+        stats = {'strat_inserted': 0, 'strat_updated': 0,
+                 'req_strat_rows': 0, 'strat_cap_rows': 0,
+                 'placeholder_filled': 0,
+                 'no_req_text': [], 'no_req_match': [], 'bad_strat_file': [],
+                 'no_strats_list': []}
+
+        # 处理 output 2/ 所有 folder(除 rerun 5 用 downloads/5/ 替代;5 placeholder 跳过)
+        folders = []
+        for d in sorted(OUTPUT_DIR.iterdir()):
+            if not d.is_dir(): continue
+            if d.name in PLACEHOLDER_FOLDERS:
+                continue
+            if d.name in RERUN_FOLDERS:
+                folders.append(RERUN_DIR / d.name)
+            else:
+                folders.append(d)
+        print(f'\nProcessing {len(folders)} folders...', flush=True)
+
+        for folder in folders:
+            ingest_folder(folder, cur, alias, db_caps, coverage_map, stats)
+
+        # placeholder folder 的 strategy 只填 requirement_strategy 新字段
+        print('\n=== 填充 5 个 placeholder req 的 requirement_strategy 新字段 ===', flush=True)
+        fill_placeholder_req_strats(cur, coverage_map, stats)
+
+        # ═════ 验证输出 ═════
+        print(f'\n{"="*60}\nStats:', flush=True)
+        for k, v in stats.items():
+            if isinstance(v, list):
+                print(f'  {k}: {len(v)} {v[:5]}', flush=True)
+            else:
+                print(f'  {k}: {v}', flush=True)
+
+        cur.execute("""SELECT
+          (SELECT COUNT(*) FROM strategy) s_total,
+          (SELECT COUNT(*) FROM requirement_strategy) rs_total,
+          (SELECT COUNT(*) FROM requirement_strategy WHERE is_selected=TRUE) rs_sel,
+          (SELECT COUNT(*) FROM requirement_strategy WHERE is_selected=FALSE) rs_alt,
+          (SELECT COUNT(*) FROM requirement_strategy WHERE coverage_score IS NOT NULL) rs_covered,
+          (SELECT COUNT(*) FROM strategy_capability) sc_total,
+          (SELECT COUNT(*) FROM requirement_capability) rc_total""")
+        r = cur.fetchone()
+        print(f'\n最终:', flush=True)
+        for k, v in dict(r).items(): print(f'  {k}: {v}', flush=True)
+
+        # 每 req 的 strategy 数分布
+        cur.execute("""SELECT strat_count, COUNT(*) reqs FROM
+          (SELECT requirement_id, COUNT(*) strat_count FROM requirement_strategy
+           GROUP BY requirement_id) t
+          GROUP BY strat_count ORDER BY strat_count""")
+        print(f'\n每 req 的 strategy 数分布:', flush=True)
+        for r in cur.fetchall():
+            print(f'  {r["strat_count"]} strategies → {r["reqs"]} reqs', flush=True)
+    finally:
+        cur.close()
+        s.close()
+
+
+if __name__ == '__main__':
+    main()

+ 176 - 0
knowhub/scripts/migrate_add_external_refs.py

@@ -0,0 +1,176 @@
+#!/usr/bin/env python3
+"""
+Migration: add requirement_node / requirement_pattern junction tables.
+
+- DDL: create both tables + reverse-lookup indexes
+- Backfill: requirement_node only (pattern 表先空着)
+  - execution_id 固定为 56
+  - 从每条 requirement.source_nodes 里取 node_name 路径
+  - 去掉 /root 前缀后在 category_tree?execution_id=56 里查 category.id
+  - 跳过 __meta__ / __abstract__ 伪节点
+
+规范:autocommit=True、statement_timeout=30s、print flush=True。
+参考 knowhub/docs/db-operations.md。
+"""
+import json
+import sys
+import time
+import urllib.request
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+from knowhub.knowhub_db.pg_capability_store import PostgreSQLCapabilityStore
+
+EXECUTION_ID = 56
+CATEGORY_TREE_URL = f'https://pattern.aiddit.com/api/pattern/category_tree?execution_id={EXECUTION_ID}'
+ROOT_PREFIX = '/root'
+
+DDL_REQUIREMENT_NODE = """
+CREATE TABLE IF NOT EXISTS requirement_node (
+    requirement_id VARCHAR  NOT NULL,
+    node_id        INTEGER  NOT NULL,
+    execution_id   INTEGER  NOT NULL,
+    node_path      TEXT,
+    PRIMARY KEY (requirement_id, node_id, execution_id)
+)
+"""
+
+DDL_REQUIREMENT_PATTERN = """
+CREATE TABLE IF NOT EXISTS requirement_pattern (
+    requirement_id VARCHAR  NOT NULL,
+    itemset_id     INTEGER  NOT NULL,
+    execution_id   INTEGER  NOT NULL,
+    PRIMARY KEY (requirement_id, itemset_id, execution_id)
+)
+"""
+
+DDL_IDX_NODE_REV     = 'CREATE INDEX IF NOT EXISTS idx_req_node_rev ON requirement_node (node_id, execution_id)'
+DDL_IDX_PATTERN_REV  = 'CREATE INDEX IF NOT EXISTS idx_req_pattern_rev ON requirement_pattern (itemset_id, execution_id)'
+
+
+def log(msg):
+    print(f'[{time.strftime("%H:%M:%S")}] {msg}', flush=True)
+
+
+def fetch_category_tree():
+    log(f'Fetching category_tree (execution_id={EXECUTION_ID})...')
+    with urllib.request.urlopen(CATEGORY_TREE_URL, timeout=30) as resp:
+        data = json.loads(resp.read())
+    cats = data.get('categories', [])
+    log(f'  got {len(cats)} categories')
+    return {c['path']: c['id'] for c in cats}
+
+
+def kill_idle_in_tx(cur):
+    cur.execute("""
+        SELECT pid FROM pg_stat_activity
+         WHERE state='idle in transaction'
+           AND pid != pg_backend_pid()
+           AND datname = current_database()
+    """)
+    pids = [r['pid'] for r in cur.fetchall()]
+    for pid in pids:
+        cur.execute('SELECT pg_terminate_backend(%s)', (pid,))
+    if pids:
+        log(f'  killed {len(pids)} idle-in-tx sessions: {pids}')
+    else:
+        log('  no idle-in-tx sessions to clean')
+
+
+def run_ddl(cur):
+    for label, sql in [
+        ('requirement_node',         DDL_REQUIREMENT_NODE),
+        ('requirement_pattern',      DDL_REQUIREMENT_PATTERN),
+        ('idx_req_node_rev',         DDL_IDX_NODE_REV),
+        ('idx_req_pattern_rev',      DDL_IDX_PATTERN_REV),
+    ]:
+        log(f'DDL: {label}')
+        cur.execute(sql)
+        log(f'  ✓ done')
+
+
+def normalize_path(p):
+    if not isinstance(p, str):
+        return None
+    if not p.startswith('/'):
+        return None
+    if p == '__meta__' or p == '__abstract__':
+        return None
+    if p.startswith(ROOT_PREFIX + '/'):
+        return p[len(ROOT_PREFIX):]  # '/root/a/b' → '/a/b'
+    return p
+
+
+def backfill_requirement_node(cur, path2id):
+    cur.execute('SELECT id, source_nodes FROM requirement')
+    rows = cur.fetchall()
+    log(f'Scanning {len(rows)} requirements...')
+
+    inserted = 0
+    skipped_meta = 0
+    unresolved_paths = set()
+    reqs_with_rows = 0
+
+    for r in rows:
+        req_id = r['id']
+        nodes = r['source_nodes'] or []
+        pairs = set()
+        for n in nodes:
+            name = (n or {}).get('node_name') if isinstance(n, dict) else None
+            norm = normalize_path(name)
+            if norm is None:
+                skipped_meta += 1
+                continue
+            node_id = path2id.get(norm)
+            if node_id is None:
+                unresolved_paths.add(norm)
+                continue
+            pairs.add((node_id, norm))
+
+        if pairs:
+            reqs_with_rows += 1
+        for node_id, norm in pairs:
+            cur.execute("""
+                INSERT INTO requirement_node (requirement_id, node_id, execution_id, node_path)
+                VALUES (%s, %s, %s, %s)
+                ON CONFLICT DO NOTHING
+            """, (req_id, node_id, EXECUTION_ID, norm))
+            inserted += cur.rowcount or 0
+
+    log(f'Backfill complete:')
+    log(f'  reqs with ≥1 row: {reqs_with_rows} / {len(rows)}')
+    log(f'  rows inserted:    {inserted}')
+    log(f'  meta/abstract skipped: {skipped_meta}')
+    if unresolved_paths:
+        log(f'  unresolved paths ({len(unresolved_paths)}): {sorted(unresolved_paths)[:10]}')
+    else:
+        log(f'  unresolved paths: 0')
+
+
+def verify(cur):
+    cur.execute('SELECT COUNT(*) c FROM requirement_node')
+    log(f'Post-check requirement_node rows:    {cur.fetchone()["c"]}')
+    cur.execute('SELECT COUNT(*) c FROM requirement_pattern')
+    log(f'Post-check requirement_pattern rows: {cur.fetchone()["c"]}')
+    cur.execute('SELECT COUNT(DISTINCT requirement_id) c FROM requirement_node')
+    log(f'Distinct reqs with node refs:        {cur.fetchone()["c"]}')
+
+
+def main():
+    path2id = fetch_category_tree()
+
+    s = PostgreSQLCapabilityStore()
+    cur = s._get_cursor()
+    try:
+        cur.execute("SET statement_timeout = '30s'")
+        kill_idle_in_tx(cur)
+        run_ddl(cur)
+        backfill_requirement_node(cur, path2id)
+        verify(cur)
+    finally:
+        cur.close()
+        s.close()
+
+
+if __name__ == '__main__':
+    main()

+ 144 - 0
knowhub/scripts/migrate_add_version_and_patterns.py

@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+"""
+Migration 续:
+1. 给 requirement_node / requirement_pattern 加 `version` 列,并把 version 并入 PK
+   - 老数据(requirement_node 210 行)默认 version='v0'
+2. 从 frontend/public/requirements_planb.json 回填 requirement_pattern
+   - version='ruotian'
+   - execution_id=56
+
+按 db-operations.md:
+- autocommit=True
+- SET statement_timeout='30s'
+- ADD COLUMN NOT NULL DEFAULT 拆两步:先 DEFAULT(补齐数据),再 SET NOT NULL
+- DROP CONSTRAINT + ADD PRIMARY KEY 替换主键(不使用 RENAME / DROP COLUMN)
+"""
+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
+
+EXECUTION_ID = 56
+PATTERN_VERSION = 'ruotian'
+PLANB_PATH = Path('/Users/sunlit/Code/Agent/knowhub/frontend/public/requirements_planb.json')
+
+
+def log(msg):
+    print(f'[{time.strftime("%H:%M:%S")}] {msg}', flush=True)
+
+
+def kill_idle_in_tx(cur):
+    cur.execute("""
+        SELECT pid FROM pg_stat_activity
+         WHERE state='idle in transaction'
+           AND pid != pg_backend_pid()
+           AND datname = current_database()
+    """)
+    pids = [r['pid'] for r in cur.fetchall()]
+    for pid in pids:
+        cur.execute('SELECT pg_terminate_backend(%s)', (pid,))
+    log(f'  killed idle-in-tx sessions: {pids or "none"}')
+
+
+def add_version_and_rebuild_pk(cur, table, pk_cols):
+    log(f'[{table}] ADD COLUMN version VARCHAR(32) DEFAULT v0')
+    cur.execute(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS version VARCHAR(32) DEFAULT 'v0'")
+    log(f'  ✓ done')
+
+    # 补齐空值(幂等)
+    cur.execute(f"UPDATE {table} SET version = 'v0' WHERE version IS NULL")
+    log(f'  filled-in NULL -> v0: {cur.rowcount or 0} rows')
+
+    log(f'[{table}] ALTER COLUMN version SET NOT NULL')
+    cur.execute(f"ALTER TABLE {table} ALTER COLUMN version SET NOT NULL")
+    log(f'  ✓ done')
+
+    log(f'[{table}] DROP existing PK constraint')
+    cur.execute(f"ALTER TABLE {table} DROP CONSTRAINT IF EXISTS {table}_pkey")
+    log(f'  ✓ done')
+
+    cols = ', '.join(pk_cols)
+    log(f'[{table}] ADD PRIMARY KEY ({cols})')
+    cur.execute(f"ALTER TABLE {table} ADD PRIMARY KEY ({cols})")
+    log(f'  ✓ done')
+
+
+def backfill_requirement_pattern(cur):
+    data = json.loads(PLANB_PATH.read_text(encoding='utf-8'))
+    reqs = data['requirements']
+    log(f'Loaded planb: {len(reqs)} requirements (source: {data["source"]})')
+
+    # 筛已在 DB 的 requirement_id
+    cur.execute('SELECT id FROM requirement')
+    valid_ids = {r['id'] for r in cur.fetchall()}
+
+    inserted = 0
+    missing_reqs = []
+    total_pairs = 0
+    reqs_with_rows = 0
+
+    for r in reqs:
+        rid = r.get('requirement_id')
+        if rid not in valid_ids:
+            missing_reqs.append(rid)
+            continue
+        patterns = r.get('patterns') or []
+        pattern_ids = {p.get('pattern_id') for p in patterns if p.get('pattern_id') is not None}
+        if pattern_ids:
+            reqs_with_rows += 1
+        total_pairs += len(pattern_ids)
+        for pid in pattern_ids:
+            cur.execute("""
+                INSERT INTO requirement_pattern (requirement_id, itemset_id, execution_id, version)
+                VALUES (%s, %s, %s, %s)
+                ON CONFLICT DO NOTHING
+            """, (rid, pid, EXECUTION_ID, PATTERN_VERSION))
+            inserted += cur.rowcount or 0
+
+    log(f'Pattern backfill complete:')
+    log(f'  reqs in planb:                 {len(reqs)}')
+    log(f'  reqs present in DB:            {len(reqs) - len(missing_reqs)}')
+    log(f'  reqs with ≥1 pattern row:      {reqs_with_rows}')
+    log(f'  total (req,pattern) distinct:  {total_pairs}')
+    log(f'  rows inserted (after conflict):{inserted}')
+    if missing_reqs:
+        log(f'  ⚠️  reqs in planb not in DB ({len(missing_reqs)}): {missing_reqs[:10]}')
+
+
+def verify(cur):
+    for t in ('requirement_node', 'requirement_pattern'):
+        cur.execute(f'SELECT COUNT(*) c FROM {t}')
+        total = cur.fetchone()['c']
+        cur.execute(f'SELECT version, COUNT(*) c FROM {t} GROUP BY version ORDER BY version')
+        by_ver = [(r['version'], r['c']) for r in cur.fetchall()]
+        log(f'{t}: total={total}, by version: {by_ver}')
+
+
+def main():
+    s = PostgreSQLCapabilityStore()
+    cur = s._get_cursor()
+    try:
+        cur.execute("SET statement_timeout = '30s'")
+        kill_idle_in_tx(cur)
+
+        add_version_and_rebuild_pk(
+            cur, 'requirement_node',
+            ['requirement_id', 'node_id', 'execution_id', 'version'],
+        )
+        add_version_and_rebuild_pk(
+            cur, 'requirement_pattern',
+            ['requirement_id', 'itemset_id', 'execution_id', 'version'],
+        )
+
+        backfill_requirement_pattern(cur)
+        verify(cur)
+    finally:
+        cur.close()
+        s.close()
+
+
+if __name__ == '__main__':
+    main()

+ 98 - 0
knowhub/scripts/phase2_schema_migration.py

@@ -0,0 +1,98 @@
+#!/usr/bin/env python3
+"""
+Phase 2 schema migration:
+  1. ALTER requirement_strategy 加 3 列(is_selected / coverage_score / coverage_explanation)
+  2. CREATE TABLE requirement_knowledge(带 metadata)
+  3. CREATE TABLE knowledge_resource(纯 junction)
+
+幂等:IF NOT EXISTS / 检查列存在。autocommit=True 安全。
+"""
+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
+
+
+def column_exists(cur, table, col):
+    cur.execute("""SELECT EXISTS (SELECT 1 FROM information_schema.columns
+                   WHERE table_name=%s AND column_name=%s)""", (table, col))
+    return cur.fetchone()['exists']
+
+
+def table_exists(cur, table):
+    cur.execute("""SELECT EXISTS (SELECT 1 FROM information_schema.tables
+                   WHERE table_name=%s)""", (table,))
+    return cur.fetchone()['exists']
+
+
+def main():
+    s = PostgreSQLCapabilityStore()
+    cur = s._get_cursor()
+    try:
+        # ─────────────────────────────────────────────────
+        # Step 1: ALTER requirement_strategy
+        print('=== Step 1: ALTER requirement_strategy ===', flush=True)
+        for col, typ in [('is_selected', 'BOOLEAN'),
+                         ('coverage_score', 'FLOAT'),
+                         ('coverage_explanation', 'TEXT')]:
+            if column_exists(cur, 'requirement_strategy', col):
+                print(f'  {col} 已存在,跳过', flush=True)
+            else:
+                cur.execute(f'ALTER TABLE requirement_strategy ADD COLUMN {col} {typ}')
+                print(f'  ✓ 添加 {col} {typ}', flush=True)
+
+        # ─────────────────────────────────────────────────
+        # Step 2: CREATE requirement_knowledge
+        print('\n=== Step 2: CREATE requirement_knowledge ===', flush=True)
+        if table_exists(cur, 'requirement_knowledge'):
+            print('  已存在,跳过', flush=True)
+        else:
+            cur.execute("""
+                CREATE TABLE requirement_knowledge (
+                    requirement_id        VARCHAR NOT NULL,
+                    knowledge_id          VARCHAR NOT NULL,
+                    is_selected           BOOLEAN,
+                    coverage_score        FLOAT,
+                    coverage_explanation  TEXT,
+                    PRIMARY KEY (requirement_id, knowledge_id)
+                ) DISTRIBUTED BY (requirement_id)
+            """)
+            print('  ✓ 已建表', flush=True)
+
+        # ─────────────────────────────────────────────────
+        # Step 3: CREATE knowledge_resource
+        print('\n=== Step 3: CREATE knowledge_resource ===', flush=True)
+        if table_exists(cur, 'knowledge_resource'):
+            print('  已存在,跳过', flush=True)
+        else:
+            cur.execute("""
+                CREATE TABLE knowledge_resource (
+                    knowledge_id  VARCHAR NOT NULL,
+                    resource_id   VARCHAR NOT NULL,
+                    PRIMARY KEY (knowledge_id, resource_id)
+                ) DISTRIBUTED BY (knowledge_id)
+            """)
+            print('  ✓ 已建表', flush=True)
+
+        # ─────────────────────────────────────────────────
+        # 验证
+        print('\n=== 验证 ===', flush=True)
+        cur.execute("""SELECT column_name, data_type FROM information_schema.columns
+                       WHERE table_name='requirement_strategy' ORDER BY ordinal_position""")
+        print('requirement_strategy 列:')
+        for r in cur.fetchall():
+            print(f'  {r["column_name"]:28s} {r["data_type"]}')
+        for t in ['requirement_knowledge', 'knowledge_resource']:
+            cur.execute("""SELECT column_name, data_type FROM information_schema.columns
+                           WHERE table_name=%s ORDER BY ordinal_position""", (t,))
+            print(f'\n{t} 列:')
+            for r in cur.fetchall():
+                print(f'  {r["column_name"]:28s} {r["data_type"]}')
+    finally:
+        cur.close()
+        s.close()
+
+
+if __name__ == '__main__':
+    main()

+ 467 - 0
knowhub/scripts/phase4_5_migrate.py

@@ -0,0 +1,467 @@
+#!/usr/bin/env python3
+"""
+Phase 4 + Phase 5 迁移:具体 strategy → 抽象 pattern + knowledge 实例
+
+步骤:
+  1. Preflight:classifier 全覆盖验证
+  2. 插入 26 个抽象 strategy(P01..P27,P18 跳过因 0 成员)
+  3. 为每条具体 strategy 生成 knowledge 实例 + 写 4 类 junction
+     - requirement_knowledge (req, knowledge_id, is_selected, coverage_score, coverage_explanation)
+     - strategy_knowledge (abstract_strategy_id, knowledge_id)
+     - knowledge_resource (knowledge_id, resource_id) ← 从 req 的 resource 联合获取
+  4. 重建 strategy_capability:抽象 strategy ↔ 成员 cap 联合
+  5. 重建 strategy_resource:抽象 strategy ↔ 成员 resource 联合
+  6. 重建 requirement_strategy:req ↔ 抽象 strategy(含 metadata,dedup)
+  7. 删除具体 strategy 及其残余 junction
+
+幂等与安全:
+  - bk_20260422_* 快照已在
+  - autocommit=True
+  - 每阶段独立完成 + 验证,失败可重跑(已处理的会被 DELETE 前检查)
+  - version='howard_strategy_instance' 是唯一标记,与老 knowledge (v0) 物理隔离
+"""
+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.abstract_patterns import (
+    PATTERNS, PATTERN_NAME, PATTERN_DESC, classify,
+)
+
+NEW_VERSION = 'howard_strategy_instance'
+MIGRATION_DATE = '2026-04-22'
+
+
+def hash8(text):
+    return hashlib.sha256(text.encode('utf-8')).hexdigest()[:8]
+
+
+def abstract_strategy_id(pid):
+    """P01 -> strategy-abstract-p01"""
+    return f'strategy-abstract-{pid.lower()}'
+
+
+def gen_knowledge_id(req_id, original_strat_id):
+    h = hash8(f'{req_id}|{original_strat_id}')
+    return f'knowledge-stratinst-{h}'
+
+
+# ═══════════════════════════════════════════════════════════
+def coverage_to_eval_score(cov):
+    """coverage_score → eval.score"""
+    if cov is None: return None
+    if cov >= 0.9: return 6
+    if cov >= 0.8: return 5
+    if cov >= 0.6: return 4
+    if cov >= 0.4: return 3
+    return 2
+
+
+# ═══════════════════════════════════════════════════════════
+def build_knowledge_content(strat_row, req_desc, pattern_id, pattern_name):
+    """拼出 req-specific knowledge 的 content 文本"""
+    body = strat_row['body'] if isinstance(strat_row['body'], dict) else json.loads(strat_row['body'] or '{}')
+    parts = []
+    parts.append(f'## 需求\n{req_desc}\n')
+    parts.append(f'## 本具体方案\n**{strat_row["name"]}**')
+    parts.append(f'(归属抽象套路:{pattern_id} · {pattern_name})\n')
+
+    if body.get('reasoning'):
+        parts.append(f'## 选择理由\n{body["reasoning"]}\n')
+    if body.get('why_not'):
+        parts.append(f'## 为何不选备选\n{body["why_not"]}\n')
+    if body.get('could_switch_if'):
+        parts.append(f'## 可切换到备选的条件\n{body["could_switch_if"]}\n')
+
+    hl = body.get('highlight_coverage')
+    if isinstance(hl, list) and hl:
+        parts.append('## 独特优势(highlight_coverage)')
+        for x in hl: parts.append(f'- {x}')
+        parts.append('')
+    bl = body.get('baseline_coverage')
+    if isinstance(bl, list) and bl:
+        parts.append('## 基础覆盖(baseline_coverage)')
+        for x in bl: parts.append(f'- {x}')
+        parts.append('')
+
+    wo = body.get('workflow_outline')
+    if isinstance(wo, list) and wo:
+        parts.append('## 执行步骤(req-specific phases)')
+        for i, ph in enumerate(wo, 1):
+            if not isinstance(ph, dict): continue
+            parts.append(f'**Phase {i}:{ph.get("phase","")}**')
+            parts.append(f'{ph.get("description","")}')
+            caps = ph.get('capabilities', [])
+            if caps:
+                names = []
+                for c in caps:
+                    if isinstance(c, dict):
+                        names.append(f'{c.get("id","?")} {c.get("name","")}')
+                if names:
+                    parts.append(f'_能力使用:{"、".join(names)}_')
+            parts.append('')
+
+    src = body.get('source')
+    if isinstance(src, list) and src:
+        parts.append('## 源案例引用')
+        for s in src[:12]:
+            if isinstance(s, dict):
+                parts.append(f'- {s.get("id","")}: {s.get("title","")}')
+            elif isinstance(s, str):
+                parts.append(f'- {s}')
+        parts.append('')
+
+    cov = body.get('coverage_score')
+    cov_exp = body.get('coverage_explanation')
+    if cov is not None:
+        parts.append(f'## 覆盖度评分\ncoverage_score = **{cov}**')
+        if cov_exp:
+            parts.append(f'\nLLM 解释:{cov_exp}')
+    return '\n'.join(parts)
+
+
+# ═══════════════════════════════════════════════════════════
+def step1_insert_abstract_strategies(cur, stats):
+    print('\n=== Step 1: 插入 26 个抽象 strategy ===', flush=True)
+    now = int(time.time())
+    for pid, name, cat, desc in PATTERNS:
+        # skip P18 (0 members)
+        if pid == 'P18': continue
+        aid = abstract_strategy_id(pid)
+        # idempotent: DELETE + INSERT
+        cur.execute('DELETE FROM strategy WHERE id = %s', (aid,))
+        body = {
+            'pattern_id': pid,
+            'pattern_category': cat,
+            'abstract_name': name,
+            'description': desc,
+            'migration_note': f'抽象套路 pattern; 在 Phase 4/5 迁移中从具体 strategy 聚合而来',
+            'migrated_at': MIGRATION_DATE,
+        }
+        cur.execute("""INSERT INTO strategy (id, name, description, body, status,
+                       created_at, updated_at, version)
+                       VALUES (%s, %s, %s, %s, %s, %s, %s, %s)""",
+                    (aid, name, desc,
+                     json.dumps(body, ensure_ascii=False),
+                     'approved', now, now, NEW_VERSION))
+        stats['abstract_inserted'] += 1
+    print(f'  inserted: {stats["abstract_inserted"]}', flush=True)
+
+
+def step2_create_knowledge_and_junctions(cur, stats):
+    print('\n=== Step 2: 为每条具体 strategy 生成 knowledge + junctions ===', flush=True)
+    cur.execute("""SELECT s.id, s.name, s.body, rs.requirement_id,
+                          rs.is_selected, rs.coverage_score, rs.coverage_explanation,
+                          r.description AS req_desc
+                   FROM strategy s
+                   JOIN requirement_strategy rs ON rs.strategy_id = s.id
+                   JOIN requirement r ON r.id = rs.requirement_id
+                   WHERE s.version = 'howard_dedup'""")  # 只处理具体 strategy(abstract 的 version 是 new_version)
+    rows = cur.fetchall()
+    print(f'  concrete strategies to migrate: {len(rows)}', flush=True)
+
+    now = int(time.time())
+    for i, row in enumerate(rows):
+        sid = row['id']; req_id = row['requirement_id']
+        is_sel = row['is_selected']; cov = row['coverage_score']; cov_exp = row['coverage_explanation']
+        # classify
+        pid = classify(sid, req_id, is_sel)
+        if pid is None:
+            stats['unmapped'].append(sid)
+            continue
+        aid = abstract_strategy_id(pid)
+        kid = gen_knowledge_id(req_id, sid)
+
+        # build content
+        content = build_knowledge_content(row, row['req_desc'], pid, PATTERN_NAME[pid])
+        task = f'{row["req_desc"][:80]} — {PATTERN_NAME[pid]}'
+
+        tags = {
+            'kind': 'req_specific_strategy_execution',
+            'original_strategy_name': row['name'],
+            'pattern_id': pid,
+            'pattern_name': PATTERN_NAME[pid],
+        }
+        source = {
+            'category': 'strategy_migration_req_specific',
+            'agent_id': 'strategy_migration_2026-04-22',
+            'timestamp': f'{MIGRATION_DATE}T00:00:00+00:00',
+            'original_strategy_id': sid,
+        }
+        es_score = coverage_to_eval_score(cov)
+        eval_obj = {'score': es_score, 'harmful': 0, 'helpful': 1, 'confidence': cov or 0.5}
+
+        # Insert knowledge
+        cur.execute('DELETE FROM knowledge WHERE id = %s', (kid,))
+        cur.execute("""INSERT INTO knowledge
+                       (id, message_id, task, content, types, tags, tag_keys,
+                        scopes, owner, source, eval, created_at, updated_at,
+                        status, version)
+                       VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
+                               %s, %s, %s, %s)""",
+                    (kid, '', task, content,
+                     ['strategy', 'execution_instance'],
+                     json.dumps(tags, ensure_ascii=False),
+                     list(tags.keys()),
+                     ['org:cybertogether'],
+                     'agent:strategy_migration',
+                     json.dumps(source, ensure_ascii=False),
+                     json.dumps(eval_obj, ensure_ascii=False),
+                     now, now, 'approved', NEW_VERSION))
+        stats['knowledge_inserted'] += 1
+
+        # requirement_knowledge
+        cur.execute("""DELETE FROM requirement_knowledge
+                       WHERE requirement_id = %s AND knowledge_id = %s""", (req_id, kid))
+        cur.execute("""INSERT INTO requirement_knowledge
+                       (requirement_id, knowledge_id, is_selected,
+                        coverage_score, coverage_explanation)
+                       VALUES (%s, %s, %s, %s, %s)""",
+                    (req_id, kid, is_sel, cov, cov_exp))
+        stats['rk_inserted'] += 1
+
+        # strategy_knowledge (abstract -> knowledge)
+        cur.execute("""DELETE FROM strategy_knowledge
+                       WHERE strategy_id = %s AND knowledge_id = %s""", (aid, kid))
+        cur.execute("""INSERT INTO strategy_knowledge
+                       (strategy_id, knowledge_id, relation_type)
+                       VALUES (%s, %s, 'execution_instance')""", (aid, kid))
+        stats['sk_inserted'] += 1
+
+        # knowledge_resource: 该 knowledge 的 req 关联的所有 resource
+        # 注:这是 req 级别的联合,非严格的 knowledge-specific 引用。
+        # 更精细的引用可在 Phase 6 从 body.source 逐案匹配(后续优化)
+        cur.execute("""SELECT resource_id FROM requirement_resource
+                       WHERE requirement_id = %s""", (req_id,))
+        res_ids = [r['resource_id'] for r in cur.fetchall()]
+        for rid in res_ids:
+            cur.execute("""INSERT INTO knowledge_resource (knowledge_id, resource_id)
+                           VALUES (%s, %s) ON CONFLICT DO NOTHING""", (kid, rid))
+        stats['kr_inserted'] += len(res_ids)
+
+        if (i + 1) % 20 == 0:
+            print(f'  processed {i+1}/{len(rows)}', flush=True)
+
+
+def step3_rebuild_strategy_capability(cur, stats):
+    print('\n=== Step 3: 重建 strategy_capability(抽象 strategy ↔ cap 联合)===', flush=True)
+    # 先收集 each pattern 的 cap union
+    cur.execute("""SELECT sc.capability_id, sc.strategy_id, rs.requirement_id, rs.is_selected, s.id AS sid
+                   FROM strategy_capability sc
+                   JOIN strategy s ON s.id = sc.strategy_id
+                   JOIN requirement_strategy rs ON rs.strategy_id = s.id
+                   WHERE s.version = 'howard_dedup'""")
+    rows = cur.fetchall()
+    pattern_caps = {}  # pid -> set(cap_id)
+    for r in rows:
+        pid = classify(r['sid'], r['requirement_id'], r['is_selected'])
+        if not pid: continue
+        pattern_caps.setdefault(pid, set()).add(r['capability_id'])
+
+    # 删除老的 strategy_capability (concrete)
+    cur.execute("""DELETE FROM strategy_capability
+                   WHERE strategy_id IN (SELECT id FROM strategy WHERE version='howard_dedup')""")
+    print(f'  deleted concrete strategy_capability', flush=True)
+
+    # 插入 abstract strategy_capability
+    total_ins = 0
+    for pid, caps in pattern_caps.items():
+        if pid == 'P18': continue
+        aid = abstract_strategy_id(pid)
+        for cap_id in caps:
+            cur.execute("""INSERT INTO strategy_capability
+                           (strategy_id, capability_id, relation_type)
+                           VALUES (%s, %s, 'compose') ON CONFLICT DO NOTHING""",
+                        (aid, cap_id))
+            total_ins += 1
+    stats['abstract_strat_cap'] = total_ins
+    print(f'  inserted abstract strategy_capability: {total_ins}', flush=True)
+
+
+def step4_rebuild_strategy_resource(cur, stats):
+    print('\n=== Step 4: 重建 strategy_resource(抽象 strategy ↔ resource 联合)===', flush=True)
+    cur.execute("""SELECT sr.resource_id, s.id AS sid, rs.requirement_id, rs.is_selected
+                   FROM strategy_resource sr
+                   JOIN strategy s ON s.id = sr.strategy_id
+                   JOIN requirement_strategy rs ON rs.strategy_id = s.id
+                   WHERE s.version = 'howard_dedup'""")
+    rows = cur.fetchall()
+    pattern_res = {}
+    for r in rows:
+        pid = classify(r['sid'], r['requirement_id'], r['is_selected'])
+        if not pid: continue
+        pattern_res.setdefault(pid, set()).add(r['resource_id'])
+
+    cur.execute("""DELETE FROM strategy_resource
+                   WHERE strategy_id IN (SELECT id FROM strategy WHERE version='howard_dedup')""")
+    print(f'  deleted concrete strategy_resource', flush=True)
+
+    total_ins = 0
+    for pid, res_set in pattern_res.items():
+        if pid == 'P18': continue
+        aid = abstract_strategy_id(pid)
+        for rid in res_set:
+            cur.execute("""INSERT INTO strategy_resource (strategy_id, resource_id)
+                           VALUES (%s, %s) ON CONFLICT DO NOTHING""", (aid, rid))
+            total_ins += 1
+    stats['abstract_strat_res'] = total_ins
+    print(f'  inserted abstract strategy_resource: {total_ins}', flush=True)
+
+
+def step5_rebuild_requirement_strategy(cur, stats):
+    print('\n=== Step 5: 重建 requirement_strategy (req ↔ abstract, dedup) ===', flush=True)
+    # 先抓取所有具体 req_strat 记录
+    cur.execute("""SELECT rs.requirement_id, rs.strategy_id, rs.is_selected,
+                          rs.coverage_score, rs.coverage_explanation,
+                          s.version
+                   FROM requirement_strategy rs
+                   JOIN strategy s ON s.id = rs.strategy_id
+                   WHERE s.version = 'howard_dedup'""")
+    concrete_rows = cur.fetchall()
+
+    # 聚合到 (req, abstract_id):is_selected=ANY 成员 selected;coverage 取 max
+    agg = {}  # (req, abstract_id) -> {is_selected, coverage_score, coverage_explanation}
+    for r in concrete_rows:
+        pid = classify(r['strategy_id'], r['requirement_id'], r['is_selected'])
+        if not pid: continue
+        aid = abstract_strategy_id(pid)
+        key = (r['requirement_id'], aid)
+        cur_data = agg.get(key, {'is_selected': False, 'coverage_score': None,
+                                  'coverage_explanation': None})
+        if r['is_selected']:
+            cur_data['is_selected'] = True
+        # coverage: 取最高分的
+        if r['coverage_score'] is not None:
+            if cur_data['coverage_score'] is None or r['coverage_score'] > cur_data['coverage_score']:
+                cur_data['coverage_score'] = r['coverage_score']
+                cur_data['coverage_explanation'] = r['coverage_explanation']
+        agg[key] = cur_data
+
+    # 删除老的具体 req_strategy 行
+    cur.execute("""DELETE FROM requirement_strategy
+                   WHERE strategy_id IN (SELECT id FROM strategy WHERE version='howard_dedup')""")
+    print(f'  deleted concrete requirement_strategy rows', flush=True)
+
+    # 插入抽象 req_strategy 行
+    for (req_id, aid), m in agg.items():
+        cur.execute("""DELETE FROM requirement_strategy
+                       WHERE requirement_id = %s AND strategy_id = %s""", (req_id, aid))
+        cur.execute("""INSERT INTO requirement_strategy
+                       (requirement_id, strategy_id, is_selected,
+                        coverage_score, coverage_explanation)
+                       VALUES (%s, %s, %s, %s, %s)""",
+                    (req_id, aid, m['is_selected'],
+                     m['coverage_score'], m['coverage_explanation']))
+    stats['abstract_req_strat'] = len(agg)
+    print(f'  inserted abstract requirement_strategy: {len(agg)}', flush=True)
+
+
+def step6_delete_concrete_strategies(cur, stats):
+    print('\n=== Step 6: 删除具体 strategy 行(howard_dedup version)===', flush=True)
+    # 先确认没有 junction 残余
+    for t in ['strategy_capability', 'strategy_resource', 'strategy_knowledge',
+              'requirement_strategy']:
+        cur.execute(f"""SELECT COUNT(*) c FROM {t}
+                        WHERE strategy_id IN (SELECT id FROM strategy WHERE version='howard_dedup')""")
+        n = cur.fetchone()['c']
+        if n > 0:
+            print(f'  ⚠️  {t} 还有 {n} 行指向具体 strategy,可能不干净', flush=True)
+    cur.execute("DELETE FROM strategy WHERE version = 'howard_dedup'")
+    stats['concrete_deleted'] = cur.rowcount
+    print(f'  deleted concrete strategies: {stats["concrete_deleted"]}', flush=True)
+
+
+# ═══════════════════════════════════════════════════════════
+def verify(cur):
+    print('\n=== 最终验证 ===', flush=True)
+    cur.execute("""SELECT
+      (SELECT COUNT(*) FROM strategy) s_total,
+      (SELECT COUNT(*) FROM strategy WHERE version='howard_dedup') s_concrete,
+      (SELECT COUNT(*) FROM strategy WHERE version=%s) s_abstract,
+      (SELECT COUNT(*) FROM knowledge WHERE version='v0') k_v0,
+      (SELECT COUNT(*) FROM knowledge WHERE version=%s) k_new,
+      (SELECT COUNT(*) FROM requirement_strategy) rs_total,
+      (SELECT COUNT(*) FROM requirement_knowledge
+       WHERE knowledge_id IN (SELECT id FROM knowledge WHERE version=%s)) rk_new,
+      (SELECT COUNT(*) FROM strategy_knowledge
+       WHERE knowledge_id IN (SELECT id FROM knowledge WHERE version=%s)) sk_new,
+      (SELECT COUNT(*) FROM knowledge_resource
+       WHERE knowledge_id IN (SELECT id FROM knowledge WHERE version=%s)) kr_new,
+      (SELECT COUNT(*) FROM strategy_capability
+       WHERE strategy_id IN (SELECT id FROM strategy WHERE version=%s)) sc_new,
+      (SELECT COUNT(*) FROM strategy_resource
+       WHERE strategy_id IN (SELECT id FROM strategy WHERE version=%s)) sr_new
+    """, (NEW_VERSION,)*7)
+    r = cur.fetchone()
+    for k, v in dict(r).items(): print(f'  {k}: {v}', flush=True)
+
+    # Constraint: strat_cap ⊆ req_cap
+    cur.execute("""SELECT COUNT(*) c FROM strategy_capability sc
+                   JOIN requirement_strategy rs ON rs.strategy_id = sc.strategy_id
+                   LEFT JOIN requirement_capability rc
+                     ON rc.requirement_id=rs.requirement_id AND rc.capability_id=sc.capability_id
+                   WHERE rc.capability_id IS NULL""")
+    print(f'\n  strat_cap ⊄ req_cap violations: {cur.fetchone()["c"]}', flush=True)
+
+
+# ═══════════════════════════════════════════════════════════
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--execute', action='store_true')
+    ap.add_argument('--dry-run', action='store_true')
+    args = ap.parse_args()
+    if not (args.execute or args.dry_run):
+        print('need --execute or --dry-run'); sys.exit(1)
+
+    s = PostgreSQLCapabilityStore()
+    cur = s._get_cursor()
+    try:
+        # Preflight
+        cur.execute("""SELECT s.id, rs.requirement_id, rs.is_selected
+                       FROM strategy s JOIN requirement_strategy rs ON rs.strategy_id=s.id
+                       WHERE s.version='howard_dedup'""")
+        all_rows = cur.fetchall()
+        unmapped = [r for r in all_rows if classify(r['id'], r['requirement_id'], r['is_selected']) is None]
+        print(f'Preflight: {len(all_rows)} concrete strategies, {len(unmapped)} unmapped')
+        if unmapped:
+            for r in unmapped[:10]: print(f'  unmapped: {r}')
+            print('Abort.')
+            return
+
+        if args.dry_run:
+            print('DRY RUN OK — use --execute to run')
+            return
+
+        stats = {'abstract_inserted': 0, 'knowledge_inserted': 0,
+                 'rk_inserted': 0, 'sk_inserted': 0, 'kr_inserted': 0,
+                 'abstract_strat_cap': 0, 'abstract_strat_res': 0,
+                 'abstract_req_strat': 0, 'concrete_deleted': 0,
+                 'unmapped': []}
+
+        step1_insert_abstract_strategies(cur, stats)
+        step2_create_knowledge_and_junctions(cur, stats)
+        step3_rebuild_strategy_capability(cur, stats)
+        step4_rebuild_strategy_resource(cur, stats)
+        step5_rebuild_requirement_strategy(cur, stats)
+        step6_delete_concrete_strategies(cur, stats)
+
+        print(f'\n{"="*60}\nStats:')
+        for k, v in stats.items():
+            if isinstance(v, list):
+                print(f'  {k}: {len(v)}')
+            else:
+                print(f'  {k}: {v}')
+
+        verify(cur)
+    finally:
+        cur.close()
+        s.close()
+
+
+if __name__ == '__main__':
+    main()

+ 112 - 0
knowhub/scripts/phase5_add_knowledge_capability.py

@@ -0,0 +1,112 @@
+#!/usr/bin/env python3
+"""
+Phase 5 补丁:新建 knowledge_capability junction,保留"req-specific 执行用了哪些 cap"的粒度。
+
+背景:Phase 4/5 迁移后,strategy_capability 是抽象 pattern 的 cap 联合集,
+     不再与某 req 的 research 强一致。原 strat_cap ⊆ req_cap 约束失效,
+     需要在 knowledge 层重建同等约束:knowledge_cap ⊆ req_cap(via requirement_knowledge)。
+
+数据源:bk_20260422_strategy_capability(具体 strategy 迁移前的快照)+
+     knowledge.source.original_strategy_id(指向原具体 strategy)
+
+验证:所有新建的 knowledge_capability 必须满足
+     (knowledge.req_id, cap_id) ∈ requirement_capability
+"""
+import json
+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
+
+
+def main():
+    s = PostgreSQLCapabilityStore()
+    cur = s._get_cursor()
+    try:
+        # 1. 检查 knowledge_capability 是否存在(可能已是空表)
+        cur.execute("""SELECT EXISTS (SELECT 1 FROM information_schema.tables
+                       WHERE table_name='knowledge_capability')""")
+        if not cur.fetchone()['exists']:
+            print('创建 knowledge_capability 表...', flush=True)
+            cur.execute("""
+                CREATE TABLE knowledge_capability (
+                    knowledge_id   VARCHAR NOT NULL,
+                    capability_id  VARCHAR NOT NULL,
+                    PRIMARY KEY (knowledge_id, capability_id)
+                ) DISTRIBUTED BY (knowledge_id)
+            """)
+            print('  ✓ 已建表', flush=True)
+        else:
+            cur.execute('SELECT COUNT(*) c FROM knowledge_capability')
+            print(f'knowledge_capability 已存在 ({cur.fetchone()["c"]} rows)', flush=True)
+
+        # 2. 从备份表 + knowledge.source 重建 knowledge ↔ cap
+        print('\n=== 从备份还原 knowledge-cap 关系 ===', flush=True)
+        cur.execute("""SELECT id, source FROM knowledge
+                       WHERE version='howard_strategy_instance'""")
+        knowledge_list = cur.fetchall()
+        print(f'  new knowledge: {len(knowledge_list)}', flush=True)
+
+        total_inserted = 0
+        for k in knowledge_list:
+            src = k['source'] if isinstance(k['source'], dict) else json.loads(k['source'] or '{}')
+            orig_sid = src.get('original_strategy_id')
+            if not orig_sid: continue
+            # 查原具体 strategy 的 cap(从备份)
+            cur.execute("""SELECT capability_id FROM bk_20260422_strategy_capability
+                           WHERE strategy_id = %s""", (orig_sid,))
+            caps = [r['capability_id'] for r in cur.fetchall()]
+            for cap_id in caps:
+                cur.execute("""INSERT INTO knowledge_capability
+                               (knowledge_id, capability_id) VALUES (%s, %s)
+                               ON CONFLICT DO NOTHING""", (k['id'], cap_id))
+                total_inserted += 1
+        print(f'  inserted knowledge_capability: {total_inserted}', flush=True)
+
+        # 3. 验证约束:knowledge_cap ⊆ req_cap(via requirement_knowledge)
+        print('\n=== 验证 knowledge_cap ⊆ req_cap ===', flush=True)
+        cur.execute("""SELECT COUNT(*) c FROM knowledge_capability kc
+                       JOIN requirement_knowledge rk ON rk.knowledge_id = kc.knowledge_id
+                       LEFT JOIN requirement_capability rc
+                         ON rc.requirement_id = rk.requirement_id
+                         AND rc.capability_id = kc.capability_id
+                       WHERE rc.capability_id IS NULL""")
+        violations = cur.fetchone()['c']
+        print(f'  违规行数: {violations} {"❌" if violations>0 else "✓"}', flush=True)
+
+        # 4. 展示:每个 knowledge 的 cap 数量分布
+        cur.execute("""SELECT n_caps, COUNT(*) knowledge_n FROM
+          (SELECT knowledge_id, COUNT(*) n_caps FROM knowledge_capability
+           WHERE knowledge_id IN (SELECT id FROM knowledge WHERE version='howard_strategy_instance')
+           GROUP BY knowledge_id) t
+          GROUP BY n_caps ORDER BY n_caps""")
+        print('\n  每 knowledge cap 数量分布:', flush=True)
+        for r in cur.fetchall():
+            print(f'    {r["n_caps"]} caps: {r["knowledge_n"]} knowledge', flush=True)
+
+        # 5. 汇总最终 DB 状态
+        print('\n=== 最终汇总 ===', flush=True)
+        cur.execute("""SELECT
+          (SELECT COUNT(*) FROM strategy) s_total,
+          (SELECT COUNT(*) FROM knowledge WHERE version='howard_strategy_instance') new_k,
+          (SELECT COUNT(*) FROM requirement_strategy) rs,
+          (SELECT COUNT(*) FROM requirement_knowledge
+           WHERE knowledge_id IN (SELECT id FROM knowledge WHERE version='howard_strategy_instance')) new_rk,
+          (SELECT COUNT(*) FROM strategy_knowledge
+           WHERE knowledge_id IN (SELECT id FROM knowledge WHERE version='howard_strategy_instance')) new_sk,
+          (SELECT COUNT(*) FROM knowledge_resource
+           WHERE knowledge_id IN (SELECT id FROM knowledge WHERE version='howard_strategy_instance')) new_kr,
+          (SELECT COUNT(*) FROM knowledge_capability
+           WHERE knowledge_id IN (SELECT id FROM knowledge WHERE version='howard_strategy_instance')) new_kc,
+          (SELECT COUNT(*) FROM strategy_capability) sc,
+          (SELECT COUNT(*) FROM strategy_resource) sr,
+          (SELECT COUNT(*) FROM requirement_capability) req_cap""")
+        for k, v in dict(cur.fetchone()).items(): print(f'  {k}: {v}', flush=True)
+    finally:
+        cur.close()
+        s.close()
+
+
+if __name__ == '__main__':
+    main()

+ 205 - 0
knowhub/scripts/phase5_fill_alt_knowledge_capability.py

@@ -0,0 +1,205 @@
+#!/usr/bin/env python3
+"""
+补齐 94 个 alt knowledge 的 knowledge_capability 行。
+
+原因:bk_20260422_strategy_capability 是 Phase 2 之前的快照,只有 99 个 selected strategy 的 cap。
+Phase 2 插入了 94 个 alt 的 cap 进 strategy_capability,Phase 4 迁移时读的是 live(包括 alt),
+但 phase5_add_knowledge_capability.py 用的是 backup(丢 alt)。
+
+修复方式:重新从源 strategy.json 解析每条 alt 的 workflow_outline caps,
+然后用 (req_id, strategy_name) 匹配 knowledge,INSERT knowledge_capability。
+"""
+import hashlib
+import json
+import re
+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
+from knowhub.scripts.merge_capabilities import MERGE_CLUSTERS
+from knowhub.scripts.rename_merged_capabilities import RENAMES
+from knowhub.scripts.llm_renames import LLM_RENAMES
+from knowhub.scripts.salvage_placeholder_strategies import LEGACY_REFS
+
+OUTPUT_DIR = Path('/Users/sunlit/Downloads/output 2')
+RERUN_DIR = Path('/Users/sunlit/Downloads/5')
+RERUN_FOLDERS = {'032', '046', '069', '085', '097'}
+
+
+def norm(s): return (s or '').strip().lower()
+
+
+def build_alias_and_member(cur):
+    m2c = {}
+    for canonical, members in MERGE_CLUSTERS.items():
+        for m in members: m2c[m] = canonical
+    def final(cid, limit=10):
+        seen = set()
+        while cid in m2c and cid not in seen and limit > 0:
+            seen.add(cid); cid = m2c[cid]; limit -= 1
+        return cid
+    for m in list(m2c.keys()): m2c[m] = final(m)
+    alias = {}
+    cur.execute('SELECT id, name FROM capability')
+    db_caps = {r['id']: r['name'] for r in cur.fetchall()}
+    for cid, name in db_caps.items():
+        alias[norm(name)] = cid
+    for cid, (new_name, _) in RENAMES.items():
+        alias[norm(new_name)] = final(cid)
+    for llm_name, canonical in LLM_RENAMES.items():
+        alias[norm(llm_name)] = final(canonical)
+    return alias, m2c, db_caps
+
+
+def resolve_cap_ref(cap_ref, alias, m2c, db_caps):
+    if not cap_ref: return None
+    if isinstance(cap_ref, dict):
+        cid = cap_ref.get('id')
+        if cid and cid in db_caps: return cid
+        if cid and cid in LEGACY_REFS: return LEGACY_REFS[cid]
+        if cid in m2c: return m2c[cid]
+        name = cap_ref.get('name', '')
+        if name:
+            cand = alias.get(norm(name))
+            if cand and cand in db_caps: return cand
+        return None
+    if isinstance(cap_ref, str):
+        if cap_ref in LEGACY_REFS: return LEGACY_REFS[cap_ref]
+        m = re.match(r'^(CAP-[\w\-]+)', cap_ref)
+        if m:
+            if m.group(1) in db_caps: return m.group(1)
+            if m.group(1) in LEGACY_REFS: return LEGACY_REFS[m.group(1)]
+        cand = alias.get(norm(cap_ref))
+        if cand and cand in db_caps: return cand
+    return None
+
+
+def extract_strat_caps(s_data, alias, m2c, db_caps):
+    wo = s_data.get('workflow_outline') or []
+    caps = set()
+    if isinstance(wo, list):
+        for ph in wo:
+            if not isinstance(ph, dict): continue
+            for c in ph.get('capabilities', []) or []:
+                r = resolve_cap_ref(c, alias, m2c, db_caps)
+                if r: caps.add(r)
+    return caps
+
+
+def main():
+    s = PostgreSQLCapabilityStore()
+    cur = s._get_cursor()
+    try:
+        alias, m2c, db_caps = build_alias_and_member(cur)
+        print(f'alias={len(alias)}, m2c={len(m2c)}, db_caps={len(db_caps)}', flush=True)
+
+        # 先识别 0-cap 的 knowledge(绝大多是 alt strategies)
+        cur.execute("""SELECT k.id, k.source FROM knowledge k
+                       WHERE k.version='howard_strategy_instance'
+                         AND NOT EXISTS (SELECT 1 FROM knowledge_capability kc
+                                         WHERE kc.knowledge_id = k.id)""")
+        zero_cap_knowledge = cur.fetchall()
+        print(f'knowledge with 0 cap links: {len(zero_cap_knowledge)}', flush=True)
+
+        # 为这些 knowledge 重新解析原 strategy 的 caps
+        # knowledge.source.original_strategy_id → 老具体 strategy id(已被删除)
+        # 但 knowledge.tags.original_strategy_name 保留了名字
+        # 可以通过 req_id + name 在源 strategy.json 里定位
+
+        # 1) 从源文件读所有 (req_id, strategy_name) → caps
+        source_cap_map = {}  # (req_id, strategy_name) → set(cap_id)
+
+        # 获取 req_id → req_text 的映射,再 req_text → folder
+        cur.execute('SELECT id, description FROM requirement')
+        req_by_id = {r['id']: r['description'] for r in cur.fetchall()}
+
+        folders = []
+        for d in sorted(OUTPUT_DIR.iterdir()):
+            if not d.is_dir(): continue
+            if d.name in RERUN_FOLDERS:
+                folders.append(RERUN_DIR / d.name)
+            else:
+                folders.append(d)
+
+        for folder in folders:
+            strat_path = folder / 'strategy.json'
+            if not strat_path.exists(): continue
+            try:
+                sd = json.loads(strat_path.read_text(encoding='utf-8'))
+            except Exception:
+                continue
+            if not isinstance(sd, dict): continue
+            req_text = sd.get('requirement', '')
+            if not req_text: continue
+            # Find req_id by exact match
+            matched_req = None
+            for rid, rtext in req_by_id.items():
+                if rtext == req_text:
+                    matched_req = rid; break
+            if not matched_req: continue
+            for s_data in sd.get('strategies', []):
+                if not isinstance(s_data, dict): continue
+                name = s_data.get('name', '')
+                caps = extract_strat_caps(s_data, alias, m2c, db_caps)
+                source_cap_map[(matched_req, name)] = caps
+
+        print(f'source (req, name) → caps 映射共 {len(source_cap_map)} 条', flush=True)
+
+        # 2) 匹配 0-cap knowledge 到 source,插入 knowledge_capability
+        total_ins = 0
+        not_matched = []
+        for k in zero_cap_knowledge:
+            src = k['source'] if isinstance(k['source'], dict) else json.loads(k['source'] or '{}')
+            # tags 里有 original_strategy_name
+            # 我们从 knowledge 里额外查
+            cur.execute('SELECT tags FROM knowledge WHERE id = %s', (k['id'],))
+            tags = cur.fetchone()['tags']
+            tags = tags if isinstance(tags, dict) else json.loads(tags or '{}')
+            orig_name = tags.get('original_strategy_name', '')
+            # req_id 从 requirement_knowledge 查
+            cur.execute("""SELECT requirement_id FROM requirement_knowledge
+                           WHERE knowledge_id = %s""", (k['id'],))
+            row = cur.fetchone()
+            if not row: continue
+            req_id = row['requirement_id']
+            caps = source_cap_map.get((req_id, orig_name), set())
+            if not caps:
+                not_matched.append((k['id'], req_id, orig_name))
+                continue
+            for cap_id in caps:
+                cur.execute("""INSERT INTO knowledge_capability (knowledge_id, capability_id)
+                               VALUES (%s, %s) ON CONFLICT DO NOTHING""", (k['id'], cap_id))
+                total_ins += 1
+
+        print(f'\ninserted: {total_ins}', flush=True)
+        print(f'still not matched: {len(not_matched)}', flush=True)
+        for item in not_matched[:8]:
+            print(f'  {item}', flush=True)
+
+        # 3) 验证
+        cur.execute("""SELECT COUNT(*) c FROM knowledge
+                       WHERE version='howard_strategy_instance'
+                         AND NOT EXISTS (SELECT 1 FROM knowledge_capability kc
+                                         WHERE kc.knowledge_id = knowledge.id)""")
+        still_zero = cur.fetchone()['c']
+        print(f'\n仍 0 cap 的 knowledge: {still_zero}', flush=True)
+
+        # 约束验证
+        cur.execute("""SELECT COUNT(*) c FROM knowledge_capability kc
+                       JOIN requirement_knowledge rk ON rk.knowledge_id = kc.knowledge_id
+                       LEFT JOIN requirement_capability rc
+                         ON rc.requirement_id = rk.requirement_id
+                         AND rc.capability_id = kc.capability_id
+                       WHERE rc.capability_id IS NULL""")
+        print(f'knowledge_cap ⊄ req_cap 违规: {cur.fetchone()["c"]}', flush=True)
+
+        cur.execute('SELECT COUNT(*) c FROM knowledge_capability')
+        print(f'knowledge_capability 总行数: {cur.fetchone()["c"]}', flush=True)
+    finally:
+        cur.close()
+        s.close()
+
+
+if __name__ == '__main__':
+    main()

+ 140 - 0
knowhub/scripts/phase5_fuzzy_fill_remaining.py

@@ -0,0 +1,140 @@
+#!/usr/bin/env python3
+"""
+用 Chinese 2-gram fuzzy 匹配补齐最后 12 条 0-cap knowledge 的 knowledge_capability。
+这些 knowledge 的源 strategy 用了 LLM 自造的 cap ID(如 aigc_color_unification),
+canonical alias 解析失败,改用名字 2-gram Jaccard + 阈值 0.3 做 best-effort。
+
+打印 match 决策供审核,然后 INSERT。
+"""
+import json
+import re
+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
+
+OUTPUT_DIR = Path('/Users/sunlit/Downloads/output 2')
+RERUN_DIR = Path('/Users/sunlit/Downloads/5')
+RERUN_FOLDERS = {'032', '046', '069', '085', '097'}
+THRESHOLD = 0.3
+
+
+def ch_bigrams(s):
+    s = re.sub(r'\s+', '', s or '')
+    return set(s[i:i+2] for i in range(len(s)-1))
+
+
+def name_sim(a, b):
+    ba, bb = ch_bigrams(a), ch_bigrams(b)
+    if not ba or not bb: return 0
+    return len(ba & bb) / len(ba | bb)
+
+
+def main():
+    s = PostgreSQLCapabilityStore()
+    cur = s._get_cursor()
+    try:
+        cur.execute('SELECT id, name FROM capability')
+        db_caps = [(r['id'], r['name']) for r in cur.fetchall()]
+        print(f'db_caps: {len(db_caps)}', flush=True)
+
+        # 找 0-cap 的 howard_strategy_instance knowledge
+        cur.execute("""SELECT k.id, k.tags FROM knowledge k
+                       WHERE k.version='howard_strategy_instance'
+                         AND NOT EXISTS (SELECT 1 FROM knowledge_capability kc WHERE kc.knowledge_id=k.id)""")
+        zero_cap = cur.fetchall()
+        print(f'0-cap knowledge: {len(zero_cap)}', flush=True)
+
+        # 找 req_text
+        cur.execute('SELECT id, description FROM requirement')
+        req_desc = {r['id']: r['description'] for r in cur.fetchall()}
+
+        # 收集 req_id → folder
+        req_to_folder = {}
+        for folder in sorted(OUTPUT_DIR.iterdir()) + sorted(RERUN_DIR.iterdir()):
+            if not folder.is_dir(): continue
+            if folder.name in RERUN_FOLDERS and folder.parent == OUTPUT_DIR:
+                continue  # 跳过 output 2/ 的 rerun 坏版本
+            strat_path = folder / 'strategy.json'
+            if not strat_path.exists(): continue
+            try: sd = json.loads(strat_path.read_text(encoding='utf-8'))
+            except: continue
+            if not isinstance(sd, dict): continue
+            rt = sd.get('requirement')
+            for rid, d in req_desc.items():
+                if d == rt:
+                    req_to_folder[rid] = folder
+                    break
+
+        total_ins = 0
+        for k in zero_cap:
+            tags = k['tags'] if isinstance(k['tags'], dict) else json.loads(k['tags'] or '{}')
+            orig_name = tags.get('original_strategy_name','')
+            # req_id
+            cur.execute('SELECT requirement_id FROM requirement_knowledge WHERE knowledge_id=%s', (k['id'],))
+            r = cur.fetchone()
+            if not r: continue
+            req_id = r['requirement_id']
+            folder = req_to_folder.get(req_id)
+            if not folder:
+                print(f'  [{req_id}] no folder', flush=True); continue
+            # 读源
+            try:
+                sd = json.loads((folder/'strategy.json').read_text(encoding='utf-8'))
+            except:
+                continue
+            target_strat = None
+            for s_data in sd.get('strategies', []):
+                if isinstance(s_data, dict) and s_data.get('name','') == orig_name:
+                    target_strat = s_data; break
+            if not target_strat:
+                print(f'  [{req_id}] no match for {orig_name!r}', flush=True); continue
+
+            # 提取 workflow_outline caps 的名字(因 id 无效),逐个 fuzzy 匹配到 canonical
+            wo = target_strat.get('workflow_outline', [])
+            print(f'\n[{k["id"]}] {req_id} / {orig_name[:50]}', flush=True)
+            matched_caps = set()
+            for ph in wo:
+                if not isinstance(ph, dict): continue
+                for c in ph.get('capabilities', []):
+                    if not isinstance(c, dict): continue
+                    src_name = c.get('name','')
+                    if not src_name: continue
+                    # fuzzy match
+                    best_id = None; best_name = None; best_sim = 0
+                    for cid, cname in db_caps:
+                        sim = name_sim(src_name, cname)
+                        if sim > best_sim:
+                            best_sim = sim; best_id = cid; best_name = cname
+                    if best_sim >= THRESHOLD:
+                        print(f'    "{src_name}" → {best_id} "{best_name}" (sim={best_sim:.2f})', flush=True)
+                        matched_caps.add(best_id)
+                    else:
+                        print(f'    "{src_name}" → NO MATCH (best={best_name} sim={best_sim:.2f})', flush=True)
+
+            # constraint check: 必须 ∈ req_cap
+            for cid in matched_caps:
+                cur.execute('SELECT 1 FROM requirement_capability WHERE requirement_id=%s AND capability_id=%s',
+                            (req_id, cid))
+                if cur.fetchone():
+                    cur.execute("""INSERT INTO knowledge_capability (knowledge_id, capability_id)
+                                   VALUES (%s, %s) ON CONFLICT DO NOTHING""", (k['id'], cid))
+                    total_ins += cur.rowcount or 0
+                else:
+                    print(f'    skip {cid}: not in req_cap for {req_id}', flush=True)
+
+        print(f'\nTotal inserted: {total_ins}', flush=True)
+        # Re-check
+        cur.execute("""SELECT COUNT(*) c FROM knowledge
+                       WHERE version='howard_strategy_instance'
+                         AND NOT EXISTS (SELECT 1 FROM knowledge_capability kc WHERE kc.knowledge_id=knowledge.id)""")
+        print(f'still 0-cap: {cur.fetchone()["c"]}', flush=True)
+        cur.execute('SELECT COUNT(*) c FROM knowledge_capability')
+        print(f'knowledge_capability total: {cur.fetchone()["c"]}', flush=True)
+    finally:
+        cur.close(); s.close()
+
+
+if __name__ == '__main__':
+    main()

+ 353 - 0
knowhub/scripts/salvage_placeholder_strategies.py

@@ -0,0 +1,353 @@
+#!/usr/bin/env python3
+"""
+修复 5 个占位 strategy(REQ_004 / 031 / 053 / 066 / 070):
+  把各自的非标准 body schema 正规化成标准 workflow_outline 结构,
+  并写 strategy_capability junction(之前 0 条)。
+
+不改 strategy.id / strategy.name / strategy.description(保留原调研的 rich 描述)。
+body 里原 schema 字段也保留(作为 salvage_original),只是增加 workflow_outline。
+
+各 folder 的 schema 变体:
+  REQ_004: body.strategy.phases + body.capability_mapping (cap by name, phase_id 映射)
+  REQ_031: body.strategy.phases[*].capabilities_used (含 'CAP-XXX 名字' 组合字符串)
+  REQ_053: body.phases[*].capabilities (dict with id/name) + body.core_workflow
+  REQ_066: body.execution_phases[*].capabilities_used (混合 id 和 name)
+  REQ_070: body.selected_blueprint.phases[*].capabilities_used
+
+Cap 引用处理:
+  1. 若是 'CAP-XXX' 形式且 XXX 存在于 DB → 直接用
+  2. 若是 'CAP-tao_dev_1-XX-YY' 老 ID → 通过 MERGE_CLUSTERS 传递闭包解析
+  3. 若是纯名字 → 通过 alias map(含 LLM_RENAMES)解析
+  4. 解析不到 → 记录到 unresolved,不写 junction
+"""
+import hashlib
+import json
+import re
+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
+from knowhub.scripts.merge_capabilities import MERGE_CLUSTERS
+from knowhub.scripts.rename_merged_capabilities import RENAMES
+from knowhub.scripts.llm_renames import LLM_RENAMES
+
+TARGET_REQS = ['REQ_004', 'REQ_031', 'REQ_053', 'REQ_066', 'REQ_070']
+
+# 占位 strategy 里特有的老 ID / 含混名字 → canonical
+# 这些是 MERGE_CLUSTERS 和 LLM_RENAMES 都没覆盖的 salvage-specific refs
+LEGACY_REFS = {
+    # 老 tao_dev ID
+    'CAP-tao_dev_1-02-03': 'CAP-792fd807',   # 景深虚化光学模拟
+    'CAP-tao_dev_1-03-01': 'CAP-1649b549',   # 戏剧性明暗对比
+    'CAP-tao_dev_1-00-02': 'CAP-008ee6c9',   # 真实感提示词注入
+    # REQ_004 的名字变体
+    '结构化提示词工程(PROMPT MASTER)': 'CAP-5b000814',
+    '参考图融合控制(Omni-Reference)': 'CAP-017',
+    '手持道具细节强化': 'CAP-d043d289',
+    # REQ_031 的 is_new cap(现已有 canonical)
+    '跨物种形态融合生成(is_new)': 'CAP-24dd762b',
+    '跨物种形态融合生成': 'CAP-24dd762b',
+    # REQ_053 的变体
+    '景深虚化光学模拟(前景虚化)': 'CAP-792fd807',
+}
+
+
+def norm(s):
+    return (s or '').strip().lower()
+
+
+def build_alias_and_member(cur):
+    """Return (alias_name→canonical, member_id→canonical) with transitive closure."""
+    m2c = {}
+    for canonical, members in MERGE_CLUSTERS.items():
+        for m in members:
+            m2c[m] = canonical
+
+    def final(cid, limit=10):
+        seen = set()
+        while cid in m2c and cid not in seen and limit > 0:
+            seen.add(cid); cid = m2c[cid]; limit -= 1
+        return cid
+    for m in list(m2c.keys()):
+        m2c[m] = final(m)
+
+    alias = {}
+    cur.execute('SELECT id, name FROM capability')
+    db_caps = {r['id']: r['name'] for r in cur.fetchall()}
+    for cid, name in db_caps.items():
+        alias[norm(name)] = cid
+    for cid, (new_name, _) in RENAMES.items():
+        alias[norm(new_name)] = final(cid)
+    for llm_name, canonical in LLM_RENAMES.items():
+        alias[norm(llm_name)] = final(canonical)
+    return alias, m2c, db_caps
+
+
+def resolve_cap(cap_ref, alias, m2c, db_caps, unresolved):
+    """cap_ref can be: an id 'CAP-XXX', a name, or 'CAP-XXX 名字' combo."""
+    if not cap_ref:
+        return None
+    cap_ref = str(cap_ref).strip()
+
+    # 0. LEGACY_REFS 优先(占位 strategy 特有的老 ID 和 含混名字)
+    if cap_ref in LEGACY_REFS:
+        cand = LEGACY_REFS[cap_ref]
+        if cand in db_caps:
+            return cand
+
+    # Extract leading CAP-... if present
+    id_match = re.match(r'^(CAP-[\w\-]+)', cap_ref)
+    candidate_id = id_match.group(1) if id_match else None
+    # Extract name part
+    if id_match:
+        name_part = cap_ref[id_match.end():].strip()
+    else:
+        name_part = cap_ref
+
+    # 1. Direct ID if exists in DB
+    if candidate_id and candidate_id in db_caps:
+        return candidate_id
+    # 2. LEGACY tao_dev_id
+    if candidate_id and candidate_id in LEGACY_REFS:
+        return LEGACY_REFS[candidate_id]
+    # 3. tao_dev old ID through member->canonical transitive closure
+    if candidate_id and candidate_id in m2c:
+        return m2c[candidate_id]
+    # 4. Name alias
+    if name_part:
+        cand = alias.get(norm(name_part))
+        if cand and cand in db_caps:
+            return cand
+    # 5. Try whole ref as name (for cases where no prefix)
+    cand = alias.get(norm(cap_ref))
+    if cand and cand in db_caps:
+        return cand
+
+    unresolved.append(cap_ref)
+    return None
+
+
+# ═══════════════════════════════════════════════════════════
+# Each folder's schema normalizer
+def salvage_req_004(body, alias, m2c, db_caps, unresolved):
+    """body.strategy.phases + body.capability_mapping (phase_id -> caps)."""
+    strat = body.get('strategy', {})
+    phases = strat.get('phases', []) if isinstance(strat, dict) else []
+    cap_map = body.get('capability_mapping', [])
+    # Build phase_id -> [cap_name, ...]
+    pid_to_caps = {}
+    for cm in cap_map:
+        if not isinstance(cm, dict): continue
+        cap_name = cm.get('capability')
+        for pid in cm.get('used_in_phases', []):
+            # pid can be like 'P1', 'P2(备选)' — take prefix P[digit]
+            m = re.match(r'(P\d+)', str(pid))
+            if m:
+                pid_to_caps.setdefault(m.group(1), []).append(cap_name)
+    wo = []
+    for ph in phases:
+        if not isinstance(ph, dict): continue
+        pid = ph.get('phase_id', '')
+        caps_names = pid_to_caps.get(pid, [])
+        resolved = []
+        seen = set()
+        for n in caps_names:
+            r = resolve_cap(n, alias, m2c, db_caps, unresolved)
+            if r and r not in seen:
+                resolved.append({'id': r, 'name': db_caps.get(r, n)})
+                seen.add(r)
+        wo.append({
+            'phase': ph.get('phase', ''),
+            'description': ph.get('description', ''),
+            'capabilities': resolved,
+        })
+    return wo
+
+
+def salvage_req_031(body, alias, m2c, db_caps, unresolved):
+    """body.strategy.phases[*].capabilities_used (strings like 'CAP-003 图像主体一致性保持')."""
+    strat = body.get('strategy', {})
+    phases = strat.get('phases', []) if isinstance(strat, dict) else []
+    wo = []
+    for ph in phases:
+        if not isinstance(ph, dict): continue
+        caps_used = ph.get('capabilities_used', [])
+        resolved = []
+        seen = set()
+        for cu in caps_used:
+            r = resolve_cap(cu, alias, m2c, db_caps, unresolved)
+            if r and r not in seen:
+                resolved.append({'id': r, 'name': db_caps.get(r, cu)})
+                seen.add(r)
+        wo.append({
+            'phase': ph.get('phase', ''),
+            'description': ph.get('description', ''),
+            'capabilities': resolved,
+        })
+    return wo
+
+
+def salvage_req_053(body, alias, m2c, db_caps, unresolved):
+    """body.phases (top-level) with capabilities[*] dict."""
+    phases = body.get('phases', [])
+    wo = []
+    for ph in phases:
+        if not isinstance(ph, dict): continue
+        caps_list = ph.get('capabilities', [])
+        resolved = []
+        seen = set()
+        for c in caps_list:
+            if not isinstance(c, dict): continue
+            cid = c.get('id')
+            name = c.get('name', '')
+            # combined ref
+            ref = cid if cid else name
+            r = resolve_cap(ref, alias, m2c, db_caps, unresolved)
+            if not r and name:
+                r = resolve_cap(name, alias, m2c, db_caps, unresolved)
+            if r and r not in seen:
+                resolved.append({'id': r, 'name': db_caps.get(r, name)})
+                seen.add(r)
+        wo.append({
+            'phase': ph.get('phase', ''),
+            'description': ph.get('description', ''),
+            'capabilities': resolved,
+        })
+    return wo
+
+
+def salvage_req_066(body, alias, m2c, db_caps, unresolved):
+    """body.execution_phases[*].capabilities_used (mixed id/name strings)."""
+    phases = body.get('execution_phases', [])
+    wo = []
+    for ph in phases:
+        if not isinstance(ph, dict): continue
+        caps_used = ph.get('capabilities_used', [])
+        resolved = []
+        seen = set()
+        for cu in caps_used:
+            r = resolve_cap(cu, alias, m2c, db_caps, unresolved)
+            if r and r not in seen:
+                resolved.append({'id': r, 'name': db_caps.get(r, cu)})
+                seen.add(r)
+        wo.append({
+            'phase': ph.get('phase', ''),
+            'description': ph.get('description', ''),
+            'capabilities': resolved,
+        })
+    return wo
+
+
+def salvage_req_070(body, alias, m2c, db_caps, unresolved):
+    """body.selected_blueprint.phases[*].capabilities_used."""
+    sbp = body.get('selected_blueprint', {})
+    if isinstance(sbp, str):
+        try: sbp = json.loads(sbp)
+        except: sbp = {}
+    phases = sbp.get('phases', []) if isinstance(sbp, dict) else []
+    wo = []
+    for ph in phases:
+        if not isinstance(ph, dict): continue
+        caps_used = ph.get('capabilities_used', [])
+        resolved = []
+        seen = set()
+        for cu in caps_used:
+            r = resolve_cap(cu, alias, m2c, db_caps, unresolved)
+            if r and r not in seen:
+                resolved.append({'id': r, 'name': db_caps.get(r, cu)})
+                seen.add(r)
+        wo.append({
+            'phase': ph.get('phase', ''),
+            'description': ph.get('description', ''),
+            'capabilities': resolved,
+        })
+    return wo
+
+
+SALVAGERS = {
+    'REQ_004': salvage_req_004,
+    'REQ_031': salvage_req_031,
+    'REQ_053': salvage_req_053,
+    'REQ_066': salvage_req_066,
+    'REQ_070': salvage_req_070,
+}
+
+
+# ═══════════════════════════════════════════════════════════
+def main():
+    s = PostgreSQLCapabilityStore()
+    cur = s._get_cursor()
+    try:
+        alias, m2c, db_caps = build_alias_and_member(cur)
+        print(f'alias entries: {len(alias)}, members: {len(m2c)}, db caps: {len(db_caps)}', flush=True)
+
+        for req_id in TARGET_REQS:
+            print(f'\n=== {req_id} ===', flush=True)
+            cur.execute("""SELECT s.id, s.name, s.body FROM strategy s
+                           JOIN requirement_strategy rs ON rs.strategy_id=s.id
+                           WHERE rs.requirement_id=%s""", (req_id,))
+            row = cur.fetchone()
+            if not row:
+                print(f'  ⚠️ no strategy found for {req_id}', flush=True)
+                continue
+            strat_id, strat_name = row['id'], row['name']
+            body = row['body'] if isinstance(row['body'], dict) else json.loads(row['body'] or '{}')
+
+            unresolved = []
+            salvager = SALVAGERS[req_id]
+            wo = salvager(body, alias, m2c, db_caps, unresolved)
+            phase_count = len(wo)
+            cap_total = sum(len(ph['capabilities']) for ph in wo)
+            unique_caps = set()
+            for ph in wo:
+                for c in ph['capabilities']:
+                    unique_caps.add(c['id'])
+
+            print(f'  strategy: {strat_id} ({strat_name})', flush=True)
+            print(f'  produced: {phase_count} phases, {cap_total} cap slots ({len(unique_caps)} unique)', flush=True)
+            if unresolved:
+                print(f'  unresolved refs: {len(unresolved)}', flush=True)
+                for u in unresolved[:5]: print(f'    - {u!r}', flush=True)
+
+            # Add workflow_outline to body (preserve all original fields)
+            body['workflow_outline'] = wo
+            body['_salvaged_at'] = '2026-04-22'
+            body['_salvage_source'] = 'salvage_placeholder_strategies.py'
+
+            # Update strategy.body
+            cur.execute('UPDATE strategy SET body = %s WHERE id = %s',
+                        (json.dumps(body, ensure_ascii=False), strat_id))
+
+            # Write strategy_capability junction
+            # First remove existing (should be 0 but be safe)
+            cur.execute('DELETE FROM strategy_capability WHERE strategy_id = %s', (strat_id,))
+            for cap_id in unique_caps:
+                cur.execute("""INSERT INTO strategy_capability (strategy_id, capability_id, relation_type)
+                               VALUES (%s, %s, 'compose') ON CONFLICT DO NOTHING""", (strat_id, cap_id))
+
+            print(f'  wrote strategy_capability: {len(unique_caps)} rows', flush=True)
+
+            # Verify: count strat_cap rows
+            cur.execute('SELECT COUNT(*) c FROM strategy_capability WHERE strategy_id=%s', (strat_id,))
+            print(f'  strategy_capability after: {cur.fetchone()["c"]}', flush=True)
+
+        # Final verification
+        print(f'\n{"="*60}', flush=True)
+        print('All 5 placeholder strategies after salvage:', flush=True)
+        for req_id in TARGET_REQS:
+            cur.execute("""SELECT s.id, s.name,
+                             (SELECT COUNT(*) FROM strategy_capability sc WHERE sc.strategy_id=s.id) cap_n
+                           FROM strategy s
+                           JOIN requirement_strategy rs ON rs.strategy_id=s.id
+                           WHERE rs.requirement_id=%s""", (req_id,))
+            r = cur.fetchone()
+            if r:
+                print(f'  [{req_id}] {r["id"]} ({r["name"]}): strat_cap={r["cap_n"]}', flush=True)
+    finally:
+        cur.close()
+        s.close()
+
+
+if __name__ == '__main__':
+    main()

+ 14 - 0
knowhub/server.py

@@ -2529,6 +2529,20 @@ async def proxy_pattern_posts_batch(payload: PostBatchRequest):
     except Exception as e:
         raise HTTPException(status_code=502, detail=f"Failed to fetch pattern posts: {e}")
 
+@app.get("/api/pattern/itemsets")
+async def proxy_pattern_itemsets(execution_id: int):
+    """代理获取 itemsets,避免前端直接请求外部域名"""
+    try:
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            resp = await client.get(
+                f"https://pattern.aiddit.com/api/pattern/itemsets?execution_id={execution_id}&page_size=1000",
+            )
+        resp.raise_for_status()
+        return resp.json()
+    except httpx.HTTPStatusError as e:
+        raise HTTPException(status_code=e.response.status_code, detail="Pattern itemsets API returned an error")
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=f"Failed to fetch pattern itemsets: {e}")
 
 @app.get("/")
 def frontend():

+ 23 - 0
knowhub/update_server.patch

@@ -0,0 +1,23 @@
+--- server.py
++++ server.py
+@@ -2529,6 +2529,20 @@
+     except Exception as e:
+         raise HTTPException(status_code=502, detail=f"Failed to fetch pattern posts: {e}")
+ 
++@app.get("/api/pattern/itemsets")
++async def proxy_pattern_itemsets(execution_id: int):
++    """代理获取 itemsets,避免前端直接请求外部域名"""
++    try:
++        async with httpx.AsyncClient(timeout=30.0) as client:
++            resp = await client.get(
++                f"https://pattern.aiddit.com/api/pattern/itemsets?execution_id={execution_id}&page_size=1000",
++            )
++        resp.raise_for_status()
++        return resp.json()
++    except httpx.HTTPStatusError as e:
++        raise HTTPException(status_code=e.response.status_code, detail="Pattern itemsets API returned an error")
++    except Exception as e:
++        raise HTTPException(status_code=502, detail=f"Failed to fetch pattern itemsets: {e}")
+ 
+ @app.get("/")
+ def frontend():

+ 15 - 0
knowhub/update_subqueries.patch

@@ -0,0 +1,15 @@
+--- knowhub_db/pg_requirement_store.py
++++ knowhub_db/pg_requirement_store.py
+@@ -29,7 +29,11 @@
+     (SELECT COALESCE(json_agg(rr.resource_id), '[]'::json)
+      FROM requirement_resource rr WHERE rr.requirement_id = requirement.id) AS resource_ids,
+     (SELECT COALESCE(json_agg(rs.strategy_id), '[]'::json)
+-     FROM requirement_strategy rs WHERE rs.requirement_id = requirement.id) AS strategy_ids
++     FROM requirement_strategy rs WHERE rs.requirement_id = requirement.id) AS strategy_ids,
++    (SELECT COALESCE(json_agg(rp.itemset_id), '[]'::json)
++     FROM requirement_pattern rp WHERE rp.requirement_id = requirement.id) AS pattern_ids,
++    (SELECT COALESCE(json_agg(rn.node_id), '[]'::json)
++     FROM requirement_node rn WHERE rn.requirement_id = requirement.id) AS node_ids
+ """
+ 
+ _BASE_FIELDS = "id, description, source_nodes, status, match_result, version"

Неке датотеке нису приказане због велике количине промена