Просмотр исходного кода

feat: librarian & dynamic skills

Talegorithm 9 часов назад
Родитель
Сommit
99b154c30c
54 измененных файлов с 3670 добавлено и 1489 удалено
  1. 1 1
      .env.template
  2. 73 35
      agent/README.md
  3. 5 6
      agent/core/presets.py
  4. 107 3
      agent/core/runner.py
  5. 16 92
      agent/docs/architecture.md
  6. 3 3
      agent/docs/cognition-log-plan.md
  7. 277 0
      agent/docs/context-management.md
  8. 1 1
      agent/docs/decisions.md
  9. 2 2
      agent/docs/memory-plan.md
  10. 189 0
      agent/docs/prompt-guidelines.md
  11. 1 1
      agent/docs/trace-api.md
  12. 1 1
      agent/tools/builtin/__init__.py
  13. 0 209
      agent/tools/builtin/knowledge_manager.py
  14. 180 0
      agent/tools/builtin/librarian.py
  15. 29 29
      agent/trace/goal_tool.py
  16. 1 1
      agent/trace/models.py
  17. 152 66
      agent/trace/store.py
  18. 1 1
      docs/README.md
  19. 77 0
      examples/production_plan/generation-agent-architecture.md
  20. 1 1
      frontend/API.md
  21. 14 20
      knowhub/README.md
  22. 0 341
      knowhub/agents/knowledge_manager.py
  23. 0 64
      knowhub/agents/librarian.prompt
  24. 329 0
      knowhub/agents/librarian.py
  25. 48 0
      knowhub/agents/librarian_agent.prompt
  26. 0 0
      knowhub/agents/librarian_agent.prompt.bak
  27. 0 61
      knowhub/agents/run_knowledge_manager.py
  28. 26 0
      knowhub/agents/skills/ask_strategy.md
  29. 34 0
      knowhub/agents/skills/upload_strategy.md
  30. 15 9
      knowhub/docs/api.md
  31. 2 0
      knowhub/docs/dashboard-plan.md
  32. 4 2
      knowhub/docs/frontend-restructure-plan.md
  33. 128 0
      knowhub/docs/knowhub-code-review-feedback.md
  34. 24 89
      knowhub/docs/librarian-agent.md
  35. 4 4
      knowhub/docs/processing-pipeline.md
  36. 95 0
      knowhub/docs/schema-migration-plan.md
  37. 161 70
      knowhub/docs/schema.md
  38. 0 0
      knowhub/docs/user-feedback-plan.md
  39. 13 16
      knowhub/frontend/src/pages/Capabilities.tsx
  40. 16 16
      knowhub/frontend/src/pages/Dashboard.tsx
  41. 2 2
      knowhub/frontend/src/pages/Knowledge.tsx
  42. 2 2
      knowhub/frontend/src/pages/Requirements.tsx
  43. 7 9
      knowhub/frontend/src/pages/Tools.tsx
  44. 0 2
      knowhub/internal_tools/cache_manager.py
  45. 9 8
      knowhub/knowhub_db/README.md
  46. 47 0
      knowhub/knowhub_db/cascade.py
  47. 471 0
      knowhub/knowhub_db/migrations/migrate_to_new_db.py
  48. 431 0
      knowhub/knowhub_db/migrations/migrate_v3_junction_tables.py
  49. 99 43
      knowhub/knowhub_db/pg_capability_store.py
  50. 89 38
      knowhub/knowhub_db/pg_requirement_store.py
  51. 9 8
      knowhub/knowhub_db/pg_resource_store.py
  52. 162 51
      knowhub/knowhub_db/pg_store.py
  53. 121 64
      knowhub/knowhub_db/pg_tool_store.py
  54. 191 118
      knowhub/server.py

+ 1 - 1
.env.template

@@ -12,4 +12,4 @@ KNOWHUB_DB=gp-t4n72471pkmt4b9q7o-master.gpdbmaster.singapore.rds.aliyuncs.com
 KNOWHUB_PORT=5432
 KNOWHUB_USER=aiddit_aigc
 KNOWHUB_PASSWORD=%a&&yqNxg^V1$toJ*WOa^-b^X=QJ
-KNOWHUB_DB_NAME=knowledge_hub
+KNOWHUB_DB_NAME=knowhub

+ 73 - 35
agent/README.md

@@ -93,61 +93,105 @@ agent/
 
 ```python
 from agent.core.runner import AgentRunner, RunConfig
+from agent.trace import FileSystemTraceStore, Trace, Message
+from agent.llm import create_qwen_llm_call
 
-# 创建 Runner
 runner = AgentRunner(
-    llm_call=create_llm_call(),
-    trace_store=FileSystemTraceStore()
+    llm_call=create_qwen_llm_call(model="qwen3.5-plus"),
+    trace_store=FileSystemTraceStore(base_path=".trace"),
+    skills_dir="./skills",      # 项目 skills 目录(可选)
 )
 
-# 运行 Agent
 async for item in runner.run(
     messages=[{"role": "user", "content": "分析项目架构"}],
-    config=RunConfig(model="gpt-4o")
+    config=RunConfig(model="qwen3.5-plus"),
 ):
     if isinstance(item, Trace):
         print(f"Trace: {item.trace_id}")
-    elif isinstance(item, Message):
-        print(f"Message: {item.content}")
+    elif isinstance(item, Message) and item.role == "assistant":
+        print(item.content)
 ```
 
-### 使用工具
+### RunConfig 关键参数
+
+```python
+RunConfig(
+    model="qwen3.5-plus",
+    temperature=0.3,
+    max_iterations=200,
+    agent_type="default",           # Agent 预设(对应 presets.json)
+    name="任务名称",
+    tools=["read_file", "bash"],    # 限制可用工具(None=全部)
+    goal_compression="on_overflow", # Goal 压缩:"none" / "on_complete" / "on_overflow"
+    knowledge=KnowledgeConfig(...), # 知识提取配置
+)
+```
+
+### 续跑已有 Trace
+
+```python
+config = RunConfig(model="qwen3.5-plus", trace_id="existing-trace-id")
+async for item in runner.run(messages=[{"role": "user", "content": "继续"}], config=config):
+    ...
+```
+
+### Skill 指定注入(设计中)
+
+同一个长期续跑的 agent,不同调用可以注入不同 skill:
+
+```python
+# 不同调用场景注入不同策略
+async for item in runner.run(
+    messages=[...], config=config,
+    inject_skills=["ask_strategy"],     # 本次需要的 skill
+    skill_recency_threshold=10,         # 最近 N 条消息内有就不重复注入
+):
+    ...
+```
+
+详见 [Context 管理 § Skill 指定注入](./docs/context-management.md#3-skill-指定注入设计中未实现)
+
+### 自定义工具
 
 ```python
 from agent.tools import tool, ToolContext, ToolResult
 
 @tool(description="自定义工具")
 async def my_tool(arg: str, ctx: ToolContext) -> ToolResult:
-    return ToolResult(
-        title="成功",
-        output=f"处理结果: {arg}"
-    )
+    return ToolResult(title="成功", output=f"处理结果: {arg}")
 ```
 
----
+### 完整示例
 
-## 文档
+参考 `examples/research/` — 一个完整的调研 agent 项目:
 
-### 模块文档(agent/docs/)
+```
+examples/research/
+├── run.py              # 入口(含交互控制、续跑、暂停)
+├── config.py           # RunConfig + KnowledgeConfig 配置
+├── presets.json        # Agent 预设(工具权限、skills 过滤)
+├── requirement.prompt  # 任务 prompt($system$ + $user$ 段)
+├── skills/             # 项目自定义 skills
+└── tools/              # 项目自定义工具
+```
 
-- [架构设计](./docs/architecture.md):Agent Core 完整架构设计
-- [工具系统](./docs/tools.md):工具定义、注册、双层记忆
-- [Skills 指南](./docs/skills.md):Skill 分类、编写、加载
-- [Trace API](./docs/trace-api.md):Trace 模块 REST API 和 WebSocket 接口
-- [多模态支持](./docs/multimodal.md):图片、PDF 处理
-- [设计决策](./docs/decisions.md):架构决策记录
+---
 
-### 项目级文档(../docs/)
+## 文档
 
-- [项目总览](../docs/README.md):完整的架构设计文档
-- [A2A IM 系统](../docs/a2a-im.md):Agent 间通讯
-- [Enterprise 层](../gateway/docs/enterprise/overview.md):组织级功能
+| 文档 | 内容 |
+|------|------|
+| [架构设计](./docs/architecture.md) | 框架完整架构 |
+| [Context 管理](./docs/context-management.md) | 注入机制、压缩策略、Skill 指定注入 |
+| [工具系统](./docs/tools.md) | 工具定义、注册、参数注入 |
+| [Skills 指南](./docs/skills.md) | Skill 分类、编写、加载 |
+| [Prompt 规范](./docs/prompt-guidelines.md) | Prompt 撰写原则 |
+| [Trace API](./docs/trace-api.md) | REST API 和 WebSocket 接口 |
+| [设计决策](./docs/decisions.md) | 架构决策记录 |
 
 ---
 
-## API
-
-### REST API
+## REST API
 
 | 方法 | 路径 | 说明 |
 |------|------|------|
@@ -158,10 +202,4 @@ async def my_tool(arg: str, ctx: ToolContext) -> ToolResult:
 | POST | `/api/traces/{id}/run` | 续跑或回溯 |
 | POST | `/api/traces/{id}/stop` | 停止运行 |
 
-**实现位置**:`agent/trace/api.py`, `agent/trace/run_api.py`
-
----
-
-## 相关项目
-
-- [Gateway](../gateway/README.md):A2A IM Gateway,提供 Agent 间通讯能力
+**实现**:`agent/trace/api.py`, `agent/trace/run_api.py`

+ 5 - 6
agent/core/presets.py

@@ -33,32 +33,31 @@ class AgentPreset:
 
 
 # 内置预设
-_DEFAULT_SKILLS = ["planning", "research", "browser"]
-
+# 默认不预置 skills,项目按需在 presets.json 或 RunConfig.skills 中指定
 AGENT_PRESETS = {
     "default": AgentPreset(
         allowed_tools=None,
         max_iterations=30,
-        skills=_DEFAULT_SKILLS,
+        skills=[],
         description="默认 Agent,拥有全部工具权限",
     ),
     "delegate": AgentPreset(
         allowed_tools=None,
         max_iterations=30,
-        skills=_DEFAULT_SKILLS,
+        skills=[],
         description="委托子 Agent,拥有全部工具权限(由 agent 工具创建)",
     ),
     "explore": AgentPreset(
         allowed_tools=["read", "glob", "grep", "list_files"],
         denied_tools=["write", "edit", "bash", "task"],
         max_iterations=15,
-        skills=["planning"],
+        skills=[],
         description="探索型 Agent,只读权限,用于代码分析",
     ),
     "evaluate": AgentPreset(
         allowed_tools=["read_file", "grep_content", "glob_files", "goal"],
         max_iterations=10,
-        skills=["planning"],
+        skills=[],
         description="评估型 Agent,只读权限,用于结果评估",
     ),
 }

+ 107 - 3
agent/core/runner.py

@@ -315,6 +315,8 @@ class AgentRunner:
         self,
         messages: List[Dict],
         config: Optional[RunConfig] = None,
+        inject_skills: Optional[List[str]] = None,
+        skill_recency_threshold: int = 10,
     ) -> AsyncIterator[Union[Trace, Message]]:
         """
         Agent 模式执行(核心方法)
@@ -325,6 +327,8 @@ class AgentRunner:
                 续跑: 追加的新消息
                 回溯: 在插入点之后追加的消息
             config: 运行配置
+            inject_skills: 本次调用需要指定注入的 skill 列表(skill 名称)
+            skill_recency_threshold: 最近 N 条消息内有该 skill 就不重复注入
 
         Yields:
             Union[Trace, Message]: Trace 对象(状态变化)或 Message 对象(执行过程)
@@ -368,7 +372,11 @@ class AgentRunner:
                 yield msg
 
             # Phase 3: AGENT LOOP
-            async for event in self._agent_loop(trace, history, goal_tree, config, sequence):
+            async for event in self._agent_loop(
+                trace, history, goal_tree, config, sequence,
+                inject_skills=inject_skills,
+                skill_recency_threshold=skill_recency_threshold,
+            ):
                 yield event
 
         except Exception as e:
@@ -1079,6 +1087,8 @@ class AgentRunner:
         goal_tree: Optional[GoalTree],
         config: RunConfig,
         sequence: int,
+        inject_skills: Optional[List[str]] = None,
+        skill_recency_threshold: int = 10,
     ) -> AsyncIterator[Union[Trace, Message]]:
         """ReAct 循环"""
         trace_id = trace.trace_id
@@ -1274,8 +1284,8 @@ class AgentRunner:
                 self.log.info(f"进入侧分支: {branch_type}, branch_id={branch_id}")
                 continue  # 跳过本轮,下一轮开始侧分支
 
-            # 构建 LLM messages(注入上下文)
-            llm_messages = list(history)
+            # 构建 LLM messages(注入上下文,移除内部字段
+            llm_messages = [{k: v for k, v in msg.items() if not k.startswith("_")} for msg in history]
 
             # 优化已处理的图片(分级处理:保留/压缩/描述)
             llm_messages = await self._optimize_images(llm_messages, config.model)
@@ -1328,6 +1338,25 @@ class AgentRunner:
                     })
                     self.log.info(f"[周期性注入] 自动添加 get_current_context 工具调用 (iteration={iteration})")
 
+            # Skill 指定注入(仅主路径,首轮 iteration==0 时执行)
+            if not side_branch_ctx and inject_skills and iteration == 0:
+                skills_to_inject = self._check_skills_need_injection(
+                    trace, inject_skills, history, skill_recency_threshold
+                )
+                if skills_to_inject:
+                    if not tool_calls:
+                        tool_calls = []
+                    for skill_name in skills_to_inject:
+                        skill_call_id = f"call_skill_{skill_name}_{uuid.uuid4().hex[:8]}"
+                        tool_calls.append({
+                            "id": skill_call_id,
+                            "type": "function",
+                            "function": {
+                                "name": "skill",
+                                "arguments": json.dumps({"skill_name": skill_name})
+                            }
+                        })
+                        self.log.info(f"[Skill 指定注入] 自动添加 skill(\"{skill_name}\") 工具调用")
 
             # 按需自动创建 root goal(仅主路径)
             if not side_branch_ctx and goal_tree and not goal_tree.goals and tool_calls:
@@ -1779,8 +1808,23 @@ class AgentRunner:
                         "tool_call_id": tc["id"],
                         "name": tool_name,
                         "content": tool_content_for_llm,
+                        "_message_id": tool_msg.message_id,
                     })
 
+                    # 更新 skill 注入追踪记录
+                    if tool_name == "skill" and tc["id"].startswith("call_skill_"):
+                        try:
+                            skill_args = json.loads(tc["function"]["arguments"]) if isinstance(tc["function"]["arguments"], str) else tc["function"]["arguments"]
+                            injected_skill_name = skill_args.get("skill_name", "")
+                            if injected_skill_name:
+                                await self._update_skill_injection_record(
+                                    trace_id, trace, injected_skill_name,
+                                    tool_msg.message_id, tool_msg.sequence,
+                                )
+                                self.log.info(f"[Skill 指定注入] 已记录 {injected_skill_name} → msg={tool_msg.message_id}")
+                        except Exception as e:
+                            self.log.warning(f"[Skill 指定注入] 记录追踪失败: {e}")
+
                 # on_complete 模式:goal(done=...) 后立即压缩该 goal 的消息
                 if (
                     not side_branch_ctx
@@ -2149,6 +2193,66 @@ class AgentRunner:
 
     # ===== 上下文注入 =====
 
+    # ===== Skill 指定注入 =====
+
+    def _check_skills_need_injection(
+        self,
+        trace: Trace,
+        inject_skills: List[str],
+        history: List[Dict],
+        recency_threshold: int,
+    ) -> List[str]:
+        """
+        检查哪些 skill 需要注入。
+
+        通过 trace.context["injected_skills"] 中记录的 message_id
+        检查是否仍在当前 history 的最近 recency_threshold 条消息中。
+
+        Returns:
+            需要注入的 skill 名称列表
+        """
+        injected = (trace.context or {}).get("injected_skills", {})
+        # 收集 history 中最近 recency_threshold 条消息的 message_id
+        recent_msgs = history[-recency_threshold:] if recency_threshold > 0 else []
+        recent_ids = set()
+        for msg in recent_msgs:
+            mid = msg.get("message_id") or msg.get("_message_id")
+            if mid:
+                recent_ids.add(mid)
+
+        needs_inject = []
+        for skill_name in inject_skills:
+            record = injected.get(skill_name)
+            if not record:
+                needs_inject.append(skill_name)
+                continue
+            if record.get("message_id") not in recent_ids:
+                needs_inject.append(skill_name)
+
+        return needs_inject
+
+    async def _update_skill_injection_record(
+        self,
+        trace_id: str,
+        trace: Trace,
+        skill_name: str,
+        message_id: str,
+        sequence: int,
+    ):
+        """更新 trace.context 中的 skill 注入记录"""
+        if not trace.context:
+            trace.context = {}
+        if "injected_skills" not in trace.context:
+            trace.context["injected_skills"] = {}
+        trace.context["injected_skills"][skill_name] = {
+            "message_id": message_id,
+            "sequence": sequence,
+        }
+        if self.trace_store:
+            await self.trace_store.update_trace(trace_id, context=trace.context)
+
+    # ===== 上下文注入 =====
+
     def _build_context_injection(
         self,
         trace: Trace,

+ 16 - 92
agent/docs/architecture.md

@@ -1271,106 +1271,30 @@ async def resource_save(
 
 ---
 
-## Context 压缩
+## Context 管理
 
-### 压缩策略概述
+Context 管理涵盖注入(向模型上下文添加信息)和压缩(在 token 预算内去除冗余)。
 
-Context 压缩分为两级,通过 `RunConfig` 中的 `goal_compression` 参数控制 Level 1 的行为:
+### 注入机制
 
-| 模式       | 值              | Level 1 行为                           | Level 2 行为                   |
-| ---------- | --------------- | -------------------------------------- | ------------------------------ |
-| 不压缩     | `"none"`        | 跳过 Level 1                           | 超限时直接进入 Level 2         |
-| 完成后压缩 | `"on_complete"` | 每个 goal 完成时立刻压缩该 goal 的消息 | 超限时进入 Level 2             |
-| 超长时压缩 | `"on_overflow"` | 超限时遍历所有 completed goal 逐个压缩 | Level 1 后仍超限则进入 Level 2 |
+| 机制 | 时机 | 内容 |
+|------|------|------|
+| System Prompt | trace 创建时一次性 | 角色 prompt + 内置 skills |
+| Context 周期注入 | 每 N 轮自动 | GoalTree + Collaborators + IM + Hooks |
+| Skill 动态注入 | 按需(设计中) | 按任务类型注入的 skill 策略 |
 
-默认值:`"on_overflow"`
+周期注入和 skill 注入形态统一:框架自动合成 tool_call + tool_result 消息对。
 
-```python
-RunConfig(
-    goal_compression="on_overflow",  # "none" | "on_complete" | "on_overflow"
-)
-```
-
-### Level 1:Goal 完成压缩(确定性,零 LLM 成本)
-
-对单个 completed goal 的压缩逻辑:
-
-1. **识别目标消息**:找到该 goal 关联的所有消息(`msg.goal_id == goal.id`)
-2. **区分 goal 工具消息和非 goal 消息**:检查 assistant 消息的 tool_calls 中是否调用了 `goal` 工具(实际场景中 goal 调用通常是单独一条 assistant 消息,不考虑混合情况)
-3. **保留 goal 工具消息**:保留所有调用 `goal(...)` 的 assistant 消息及其对应的 tool result(包括 add、focus、under、done 等操作)
-4. **删除非 goal 消息**:从 history 中移除该 goal 的其他 assistant 消息及其 tool result(read_file、bash、search 等中间工具调用)
-5. **替换 done 的 tool result**:将 `goal(done=...)` 的 tool result 内容替换为:"具体执行过程已清理"
-6. **纯内存操作**:压缩仅操作内存中的 history 列表,不涉及新增消息或持久化变更,原始消息永远保留在存储层
-
-压缩后的 history 片段示例:
-
-```
-... (前面的消息)
-[assistant] tool_calls: [goal(focus="1.1")]
-[tool] goal result: "## 更新\n- 焦点切换到: 1.1\n\n## Current Plan\n..."
-[assistant] tool_calls: [goal(done="1.1", summary="前端使用 React...")]
-[tool] goal result: "具体执行过程已清理"
-... (后面的消息)
-```
-
-#### `on_complete` 模式
-
-在 goal 工具执行 `done` 操作后,立刻对该 goal 执行压缩。优点是 history 始终保持精简,缺点是如果后续需要回溯到该 goal 的中间过程,信息已丢失(存储层仍保留原始消息)。
-
-**触发点**:`agent/trace/goal_tool.py` 中 done 操作完成后
-
-#### `on_overflow` 模式
-
-在 `_manage_context_usage` 检测到超限时,遍历所有 completed goal(按完成时间排序),逐个执行压缩,直到 token 数降到阈值以下或所有 completed goal 都已压缩。如果仍超限,进入 Level 2。
-
-**触发点**:`agent/core/runner.py:_manage_context_usage`
-
-**实现**:`agent/trace/compaction.py:compress_completed_goal`, `agent/trace/compaction.py:compress_all_completed_goals`
-
-### Level 2:LLM 总结(仅在 Level 1 后仍超限时触发)
-
-触发条件:Level 1 之后 token 数仍超过阈值(默认 `context_window × 0.5`)。
-
-通过侧分支队列机制执行,`force_side_branch` 为列表类型:
-
-1. **反思**(可选,由 `knowledge.enable_extraction` 控制):进入 `reflection` 侧分支,LLM 可多轮调用 knowledge_search、resource_save、knowledge_save 等工具提取知识
-2. **压缩**:进入 `compression` 侧分支,LLM 生成 summary
-
-侧分支队列示例:
-
-- 启用知识提取:`force_side_branch = ["reflection", "compression"]`
-- 仅压缩:`force_side_branch = ["compression"]`
-
-压缩完成后重建 history 为:`system prompt + 第一条 user message + summary(含详细 GoalTree)`
-
-**实现**:`agent/core/runner.py:_agent_loop`(侧分支状态机), `agent/core/runner.py:_rebuild_history_after_compression`
-
-### 任务完成后反思
-
-主路径无工具调用(任务完成)时,如果 `knowledge.enable_completion_extraction` 为 True,通过侧分支机制进入反思:
-
-1. 设置 `force_side_branch = ["reflection"]` 和 `break_after_side_branch = True`
-2. 反思侧分支完成后回到主路径
-3. 检测到 `break_after_side_branch` 标志,直接 break 退出循环
-
-**实现**:`agent/core/runner.py:_agent_loop`
-
-### GoalTree 双视图
-
-`to_prompt()` 支持两种模式:
+### 压缩策略
 
-- `include_summary=False`(默认):精简视图,用于日常周期性注入
-- `include_summary=True`:含所有 completed goals 的 summary,用于 Level 2 压缩时提供上下文
+两级压缩,通过 `RunConfig.goal_compression` 控制:
 
-### 压缩存储
+- **Level 1**:Goal 完成压缩(确定性,零 LLM 成本)— 保留 goal 工具消息,移除执行细节
+- **Level 2**:LLM 摘要(Level 1 后仍超限时触发)— 通过侧分支生成 summary 重建 history
 
-- 原始消息永远保留在 `messages/`
-- 压缩 summary 作为普通 Message 存储
-- 侧分支消息通过 `branch_type` 和 `branch_id` 标记,查询主路径时自动过滤
-- 通过 `parent_sequence` 树结构实现跳过,无需 compression events 或 skip list
-- Rewind 到压缩区域内时,summary 脱离主路径,原始消息自动恢复
+**实现**:`agent/trace/compaction.py`, `agent/core/runner.py:_manage_context_usage`
 
-**实现**:`agent/core/runner.py:_agent_loop`, `agent/trace/compaction.py`, `agent/trace/goal_models.py`
+**详细设计**:[Context 管理](./context-management.md)
 
 ---
 
@@ -1488,7 +1412,7 @@ class TraceStore(Protocol):
 
 | 文档                                                            | 内容                            |
 | --------------------------------------------------------------- | ------------------------------- |
-| [Context 管理](./context-management.md)                         | Goals、压缩、Plan 注入策略      |
+| [Context 管理](./context-management.md)                         | 注入机制、压缩策略、Skill 指定注入 |
 | [工具系统](../agent/docs/tools.md)                              | 工具定义、注册、双层记忆        |
 | [Skills 指南](../agent/docs/skills.md)                          | Skill 分类、编写、加载          |
 | [多模态支持](../agent/docs/multimodal.md)                       | 图片、PDF 处理                  |

+ 3 - 3
agent/docs/cognition-log.md → agent/docs/cognition-log-plan.md

@@ -10,7 +10,7 @@
 
 ## 概述
 
-每个 trace 维护一个 `cognition_log.json`,按时间顺序记录所有认知事件(知识查询、评估、提取、反思),为知识质量反馈和 Memory 系统的 dream 操作(详见 `agent/docs/memory.md`)提供数据。
+每个 trace 维护一个 `cognition_log.json`,按时间顺序记录所有认知事件(知识查询、评估、提取、反思),为知识质量反馈和 Memory 系统的 dream 操作(详见 `agent/docs/memory-plan.md`)提供数据。
 
 > 此文件原名 `knowledge_log.json`,扩展为统一事件流后更名。
 
@@ -109,7 +109,7 @@ Agent 通过 reflection 侧分支将知识上传到 KnowHub 时记录。
 
 ### `reflection`:记忆反思
 
-仅 memory-bearing Agent 使用(详见 `agent/docs/memory.md`)。Dream 操作触发的 per-trace 记忆反思。
+仅 memory-bearing Agent 使用(详见 `agent/docs/memory-plan.md`)。Dream 操作触发的 per-trace 记忆反思。
 
 ```json
 {
@@ -271,7 +271,7 @@ LLM 按 query 维度、逐 source 评估,输出 JSON
 
                     ···
 
-Dream 触发(memory-bearing Agent,详见 agent/docs/memory.md)
+Dream 触发(memory-bearing Agent,详见 agent/docs/memory-plan.md)
 读取 cognition_log 全部事件 → per-trace 记忆反思

+ 277 - 0
agent/docs/context-management.md

@@ -0,0 +1,277 @@
+# Context 管理
+
+## 文档维护规范
+
+0. **先改文档,再动代码** - 新功能或重大修改需先完成文档更新、并完成审阅后,再进行代码实现;除非改动较小、不被文档涵盖
+1. **文档分层,链接代码** - 重要或复杂设计可以另有详细文档;关键实现需标注代码文件路径;格式:`module/file.py:function_name`
+2. **简洁快照,日志分离** - 只记录最重要的、与代码准确对应的或者明确的已完成的设计的信息,避免推测、建议、决策历史、修改日志、大量代码;决策依据或修改日志若有必要,可在 `docs/decisions.md` 另行记录
+
+---
+
+## 概述
+
+Context = 每次 LLM 调用时发送的完整消息列表。管理目标:
+
+1. **注入**:确保模型在正确的时间看到正确的信息(system prompt、plan、skill 策略)
+2. **压缩**:在 token 预算内去除冗余,保留核心上下文
+
+两者互相影响:压缩可能清掉已注入的内容,需要重新注入。
+
+---
+
+## Context 构成
+
+```
+[system]     System Prompt(角色 + 内置 skills)        ← 静态注入,trace 创建时
+[user]       第一条用户消息
+[assistant]  ...
+[tool]       ...                                         ← 可能包含周期注入的 context
+[assistant]  ...
+[tool]       ...                                         ← 可能包含动态注入的 skill
+[user]       第 N 条用户消息
+[assistant]  待模型生成
+```
+
+---
+
+## 注入机制
+
+### 1. System Prompt(静态注入)
+
+Trace 创建时构建一次,后续续跑不重复发送。
+
+内容:角色 prompt + 按 preset/call-site 过滤的内置/项目 skills。
+
+优先级:`messages 中的 system message` > `config.system_prompt` > `preset.system_prompt` > `DEFAULT_SYSTEM_PREFIX`
+
+**实现**:`agent/core/runner.py:_build_system_prompt`
+
+---
+
+### 2. Context 周期注入(当前实现)
+
+每 `CONTEXT_INJECTION_INTERVAL`(当前=5)轮迭代,框架自动向模型的 tool_calls 中追加一条 `get_current_context` 调用(如果模型本轮未主动调用)。
+
+注入内容(`_build_context_injection` 构建):
+- 当前时间
+- GoalTree(精简视图)+ focus 提醒
+- Active Collaborators 状态
+- IM 消息通知(如有)
+- Context Injection Hooks 注册的自定义内容
+
+注入形态:**合成的 tool_call + tool_result 消息对**。模型看到的效果等同于"我之前主动调用了 get_current_context"。
+
+```
+[assistant]  tool_calls: [..., get_current_context()]   ← 框架自动追加
+[tool]       { 当前时间, GoalTree, Collaborators... }   ← 工具正常执行
+```
+
+**特点**:
+- 固定间隔,不做去重(每次内容都是最新状态快照)
+- 仅在主路径执行(侧分支中跳过)
+- 检查模型是否已主动调用,避免重复
+
+**实现**:`agent/core/runner.py`(`CONTEXT_INJECTION_INTERVAL`, 工具执行后的自动注入逻辑)
+
+**详细文档**:[架构设计 § Context Injection Hooks](./architecture.md#context-injection-hooks上下文注入钩子)
+
+---
+
+### 3. Skill 指定注入(设计中,未实现)
+
+区别于模型通过 `skill()` 工具主动加载 skill,指定注入是**调用方在 `runner.run()` 时声明**需要哪些 skill,由框架自动注入。
+
+#### 动机
+
+System Prompt 中的内置 skills 是启动时一次性注入的。但某些 skill(如 Librarian 的查询策略/上传策略):
+- 不是每次都需要(按任务类型按需加载)
+- 在长期续跑的 agent 中会被压缩掉,需要重新注入
+- 应该和现有的 `skill` 工具统一管理
+
+#### 启用方式
+
+调用方通过 `runner.run()` 的 `inject_skills` 参数声明(不放在 RunConfig 中,因为同一个 agent 的不同调用可能需要不同的 skill):
+
+```python
+# Librarian ask 场景
+async for item in runner.run(
+    messages=[{"role": "user", "content": "[ASK] 查询内容"}],
+    config=config,
+    inject_skills=["ask_strategy"],         # 本次调用需要的 skill
+    skill_recency_threshold=10,             # 最近 N 条消息内有就不重复注入
+):
+    ...
+
+# Librarian upload 场景(同一个 agent,不同 skill)
+async for item in runner.run(
+    messages=[{"role": "user", "content": "[UPLOAD:BATCH] 数据"}],
+    config=config,
+    inject_skills=["upload_strategy"],
+    skill_recency_threshold=10,
+):
+    ...
+```
+
+#### 注入机制
+
+框架在工具执行阶段自动注入(与 context 周期注入同级处理)。
+
+**注入形态**:合成的 `skill("xxx")` tool_call + tool_result 消息对,复用现有 `skill` 工具。
+
+```
+[user]       [ASK] 有没有工具能做人物姿态控制?
+[assistant]  tool_calls: [skill("ask_strategy")]        ← 框架自动生成
+[tool]       { ask_strategy skill 完整内容 }             ← skill 工具正常执行
+[assistant]  让我来检索...                               ← 模型真实输出
+```
+
+#### 防重复:message ID 追踪
+
+在 `trace.context` 中维护已注入 skill 的记录:
+
+```python
+trace.context["injected_skills"] = {
+    "ask_strategy": {
+        "message_id": "msg_abc123",   # tool_result 消息的 ID
+        "sequence": 42                # 最新注入的消息序列号
+    }
+}
+```
+
+注入前检测流程:
+
+1. 查 `injected_skills[skill_id].message_id`
+2. 检查该 message_id 是否仍在当前提交给模型的消息列表中
+3. 在且 sequence 在最近 `recency_threshold` 条消息内(从当前提交给模型的列表中查,不按编号计算) → 跳过
+4. 不在(被压缩掉)或超过阈值 → 重新注入,更新记录
+
+#### Skill 的两种加载方式
+
+| 方式 | 触发者 | 场景 |
+|---|---|---|
+| 指定注入(本节) | 调用方,通过 `run(inject_skills=...)` | 已知任务类型,确保策略在场 |
+| 主动加载 | 模型自己调 `skill()` 工具 | 遇到未预见场景,自主判断需要什么 |
+
+两者都走 `skill` 工具,消息格式一致。
+
+#### Skill 文件位置
+
+指定注入的 skill 文件遵循现有 skill 体系(Markdown + frontmatter),按项目组织:
+
+```
+knowhub/agents/skills/         # KnowHub Librarian 的 skills
+├── ask_strategy.md            # 查询检索策略
+└── upload_strategy.md         # 上传编排策略
+```
+
+---
+
+## 压缩机制
+
+压缩分两级,通过 `RunConfig.goal_compression` 控制 Level 1 行为:
+
+| 模式 | 值 | Level 1 行为 | Level 2 行为 |
+|------|---|---|---|
+| 不压缩 | `"none"` | 跳过 Level 1 | 超限时直接进入 Level 2 |
+| 完成后压缩 | `"on_complete"` | 每个 goal 完成时立刻压缩该 goal 的消息 | 超限时进入 Level 2 |
+| 超长时压缩 | `"on_overflow"` | 超限时遍历所有 completed goal 逐个压缩 | Level 1 后仍超限则进入 Level 2 |
+
+默认值:`"on_overflow"`
+
+### Level 1:Goal 完成压缩(确定性,零 LLM 成本)
+
+对单个 completed/abandoned goal 的压缩逻辑:
+
+1. **识别目标消息**:找到该 goal 关联的所有消息(`msg.goal_id == goal.id`)
+2. **区分 goal 工具消息和非 goal 消息**:检查 assistant 消息的 tool_calls 中是否调用了 `goal` 工具
+3. **保留 goal 工具消息**:保留所有调用 `goal(...)` 的 assistant 消息及其对应的 tool result(包括 add、focus、under、done 等操作)
+4. **删除非 goal 消息**:从 history 中移除该 goal 的其他 assistant 消息及其 tool result(read_file、bash、search 等中间工具调用)
+5. **替换 done 的 tool result**:将 `goal(done=...)` 的 tool result 内容替换为 `"具体执行过程已清理"`
+6. **纯内存操作**:压缩仅操作内存中的 history 列表,不涉及新增消息或持久化变更,原始消息永远保留在存储层
+
+压缩后的 history 片段示例:
+
+```
+... (前面的消息)
+[assistant] tool_calls: [goal(focus="1.1")]
+[tool] goal result: "## 更新\n- 焦点切换到: 1.1\n\n## Current Plan\n..."
+[assistant] tool_calls: [goal(done="1.1", summary="前端使用 React...")]
+[tool] goal result: "具体执行过程已清理"
+... (后面的消息)
+```
+
+#### `on_complete` 模式
+
+在 goal 工具执行 `done` 操作后,立刻对该 goal 执行压缩。优点是 history 始终保持精简,缺点是如果后续需要回溯到该 goal 的中间过程,信息已丢失(存储层仍保留原始消息)。
+
+**触发点**:`agent/core/runner.py`(工具执行后检测 `goal(done=...)` 调用)
+
+#### `on_overflow` 模式
+
+在 `_manage_context_usage` 检测到 token 超限时,遍历所有 completed goal,逐个执行压缩,直到 token 数降到阈值以下或所有 completed goal 都已压缩。如果仍超限,进入 Level 2。
+
+**触发点**:`agent/core/runner.py:_manage_context_usage`
+
+**实现**:`agent/trace/compaction.py:compress_completed_goals`
+
+### Level 2:LLM 摘要压缩
+
+触发条件:Level 1 之后 token 数仍超过阈值(默认 `context_window × 0.5`)。
+
+通过侧分支队列机制执行,`force_side_branch` 为列表类型:
+
+1. **反思**(可选,由 `knowledge.enable_extraction` 控制):进入 `reflection` 侧分支,LLM 可多轮调用工具提取知识
+2. **知识评估**(可选):进入 `knowledge_eval` 侧分支,评估待评估知识
+3. **压缩**:进入 `compression` 侧分支,LLM 生成 summary
+
+侧分支队列示例:
+- 启用知识提取:`["reflection", "compression"]`
+- 有待评估知识:`["knowledge_eval", "compression"]`
+- 仅压缩:`["compression"]`
+
+压缩完成后重建 history 为:`system prompt + 第一条 user message + summary(含详细 GoalTree)`
+
+**实现**:`agent/core/runner.py:_agent_loop`(侧分支状态机), `agent/core/runner.py:_rebuild_history_after_compression`
+
+### 任务完成后反思
+
+主路径无工具调用(任务完成)时,如果 `knowledge.enable_completion_extraction` 为 True,通过侧分支进入反思,完成后退出循环。
+
+### GoalTree 双视图
+
+`to_prompt()` 支持两种模式:
+- `include_summary=False`(默认):精简视图,用于日常周期性注入
+- `include_summary=True`:含所有 completed goals 的 summary,用于 Level 2 压缩时提供上下文
+
+### 压缩存储
+
+- 原始消息永远保留在 `messages/`
+- 压缩 summary 作为普通 Message 存储
+- 侧分支消息通过 `branch_type` 和 `branch_id` 标记,查询主路径时自动过滤
+- 通过 `parent_sequence` 树结构实现跳过,无需 compression events 或 skip list
+- Rewind 到压缩区域内时,summary 脱离主路径,原始消息自动恢复
+
+---
+
+## 注入与压缩的交互
+
+压缩会移除消息,包括之前注入的 skill 和 context 内容。不同注入类型的应对策略:
+
+| 注入类型 | 压缩影响 | 恢复策略 |
+|---------|---------|---------|
+| System Prompt | Level 2 重建时保留 | 无需额外处理 |
+| Context 周期注入 | 可能被 Level 1/2 移除 | 固定间隔自动重注入(下一个 interval 即恢复) |
+| Skill 动态注入 | 可能被 Level 1/2 移除 | message ID 检测 → 自动重注入 |
+
+Skill 重注入的触发点:每次 runner 准备构建 LLM 调用前,检查 `injected_skills` 中记录的 message_id 是否仍在当前 history 中。不在则标记为需要重新注入,在下一轮工具执行阶段合成 tool_call。
+
+---
+
+## 相关文档
+
+| 文档 | 内容 |
+|------|------|
+| [架构设计](./architecture.md) | Agent 框架完整架构 |
+| [Skills 指南](./skills.md) | Skill 文件格式、分类、加载机制 |
+| [Prompt 规范](./prompt-guidelines.md) | 信息分层、条件注入原则 |
+| [Trace API](./trace-api.md) | 压缩和反思的 REST API |

+ 1 - 1
agent/docs/decisions.md

@@ -369,7 +369,7 @@ async def advanced_search(
 - 未执行的步骤:直接修改 plan
 - 已执行的步骤:移除原始信息,替换为简短 Summary
 
-**详细设计**:见 [`docs/context-management.md`](./context-management.md)
+**详细设计**:见 [`context-management.md`](./context-management.md)
 
 ---
 

+ 2 - 2
agent/docs/memory.md → agent/docs/memory-plan.md

@@ -35,7 +35,7 @@
 
 **目的**:评估被注入的知识是否有用,记录到本地 `knowledge_log.json`。
 
-**触发时机**(详见 `knowhub/docs/cognition-log.md`):
+**触发时机**(详见 `knowhub/docs/cognition-log-plan.md`):
 - Goal 完成时(`store.py:update_goal`,设置 `pending_knowledge_eval` 标志)
 - 压缩前(必须在压缩前完成评估,否则执行上下文丢失)
 - 任务结束时(兜底)
@@ -145,7 +145,7 @@ Trace 模型新增字段:
                                            # None = 从未被记忆反思处理
 ```
 
-反思摘要不存在 Trace 模型中,而是作为 `reflection` 事件写入 `cognition_log.json`(详见 `knowhub/docs/cognition-log.md`)。
+反思摘要不存在 Trace 模型中,而是作为 `reflection` 事件写入 `cognition_log.json`(详见 `knowhub/docs/cognition-log-plan.md`)。
 
 - Agent run 产生新 message → `reflected_at_sequence` 自然落后于实际 sequence
 - 记忆反思完成 → 更新 `reflected_at_sequence` 为当前最新 sequence

+ 189 - 0
agent/docs/prompt-guidelines.md

@@ -0,0 +1,189 @@
+# Prompt 撰写规范
+
+## 核心原则
+
+Prompt 是写给 LLM 的行为指令,不是写给人看的系统文档。判断标准:**这句话删掉后,LLM 的行为会变差吗?** 不会就删。
+
+---
+
+## 一、角色定位
+
+### 描述能力,不要给标签
+
+LLM 不需要知道自己"叫什么",需要知道**怎么行动**。好的角色定位激活训练数据中的行为模式。
+
+| 差 | 好 | 原因 |
+|---|---|---|
+| 你是 Librarian Agent | 你是一个知识库管理员,擅长从碎片信息中识别关联、去重和结构化整理 | 标签不激活行为;能力描述激活 |
+| 你是后端服务 X | (删掉) | 架构角色对行为无帮助 |
+
+### 角色三要素
+
+1. **我擅长什么?** — 激活能力("擅长从碎片信息中提炼结构")
+2. **我的核心任务是什么?** — 聚焦目标("确保新信息与已有知识正确关联")
+3. **我的行为边界是什么?** — 防止漂移("只整理和检索,不自行创造内容")
+
+---
+
+## 二、实现细节:不写
+
+LLM 不需要知道自己通过什么协议被调用、运行在什么框架上、调用方是谁、自己的代号。这些只消耗 token。
+
+唯一例外:实现细节**影响行为**时。比如"你的回复会被程序解析为 JSON"——这不是架构说明,是输出约束。
+
+---
+
+## 三、信息分层
+
+### 静态 vs 动态
+
+把**不变的基础指令**和**随上下文变化的信息**分开。不变的放 system prompt 缓存;变化的按需注入。
+
+参考 Claude Code 的做法:~25 个静态段落 + 若干条件段落(通过 feature flag 控制)。用户不需要某能力时,相关段落不注入。
+
+### 三层结构
+
+1. **角色和目标** — 做什么、怎么做的总纲
+2. **知识** — 完成任务所需的背景信息(schema、字段含义、关联关系)
+3. **操作策略** — 遇到 X 做 Y 的判断标准和输出格式
+
+**知识和策略必须分开。** 混在一起会导致 LLM 在需要行动时回顾 schema、在理解数据时翻操作步骤。
+
+### 按职责域分组,不按任务顺序
+
+把相关信息放在一起(所有检索工具在一节、所有写入工具在一节),不要按"先检索再判断再写入"的执行顺序排列。LLM 需要的是参考手册,不是 step-by-step 教程。
+
+---
+
+## 四、约束表达
+
+### 分层约束
+
+从绝对到灵活,分三层:
+
+1. **硬边界** — 绝对不可逾越:"不要直接入库,只写草稿"
+2. **条件规则** — 特定情况下的约束:"新建 Capability 需同时满足三个条件"
+3. **启发式** — 偏好和倾向:"优先复用已有实体"
+
+### 正面描述优于负面清单
+
+"不要做X,不要做Y,不要做Z"太多时适得其反。改为正面描述期望行为。但**硬边界**用负面表述更安全("绝对不要...")。
+
+### 预防合理化漂移
+
+对关键约束,预判 LLM 会找什么借口绕过,然后提前堵住:
+
+```
+# 差:
+不要跳过去重检查。
+
+# 好:
+收到新工具时必须先 tool_search 检查。不要因为"看起来是新的"就跳过检索——
+名称不同但功能相同的工具很常见。
+```
+
+参考 Claude Code 的 verification agent:直接列出 LLM 会用的借口("代码看起来是对的""测试已经通过了"),然后逐一反驳。
+
+---
+
+## 五、操作策略
+
+### 写判断标准,不写流程
+
+LLM 擅长在判断标准下自行组织行动。给决策规则,不给流程图:
+
+```
+# 差:
+步骤1:检索。步骤2:判断。步骤3:写入。
+
+# 好:
+收到新工具时:已有→复用ID;全新→放入草稿。
+新建 Capability 的条件:①有已验证工具 ②有真实用例 ③描述具体可操作
+```
+
+### 用例子代替抽象规则
+
+一个好例子胜过三段解释:
+
+```
+# 差:
+能力描述应该具体可操作,不要过于宽泛。
+
+# 好:
+能力描述要具体:不要写"图像处理",要写"使用 ControlNet 进行人物姿态控制"。
+```
+
+### 提供逃生通道
+
+告诉 LLM "不能做X"时,同时告诉它"改做Y"。否则 LLM 会自己发明绕过方案:
+
+```
+# 差:
+不能直接入库。
+
+# 好:
+不能直接入库。写入 .cache/.knowledge/pre_upload_list.json 草稿池,等待人工确认后入库。
+```
+
+---
+
+## 六、输出格式
+
+### 只在必要时约束格式
+
+程序解析的输出(JSON)→ 严格约束格式。人看的输出 → 不要过度约束,让 LLM 自然组织。
+
+### 格式模板放在策略末尾
+
+不要开头就给模板。LLM 会过早锁定结构而忽略内容质量。先描述任务和判断标准,最后给格式。
+
+### 结构化输出模板
+
+对复杂输出,提供带占位符的模板。这防止 LLM 臆造输出、方便程序解析、强制 LLM 真正执行而非编造结果:
+
+```
+### Check: [what you're verifying]
+**Command:** [exact command]
+**Output:** [actual output]
+**Result: PASS / FAIL**
+```
+
+---
+
+## 七、Token 效率
+
+### 选择性详略
+
+- **详写**:歧义代价高的地方(安全约束、关键判断标准)
+- **略写**:意图明确的地方(工具简介、通用行为)
+- **用表格**:多个相似项有多个属性时,表格比段落高效得多
+
+### 条件注入
+
+不是所有 Agent 都需要所有指令。用条件段落(类似 feature flag)控制注入,保持基础 prompt 精练:
+
+```
+# 只在需要入库能力时注入
+commit_to_database 工具:将草稿提交入库...
+```
+
+### 上下文窗口意识
+
+明确告诉 LLM 上下文会被管理(压缩、截断)。让它主动保存关键信息:
+
+> 工具调用的结果可能在后续被清理。如果结果中有你后面需要用到的信息,在回复中记录下来。
+
+---
+
+## 八、常见反模式
+
+| 反模式 | 问题 | 修正 |
+|---|---|---|
+| "你是 XX Agent" | 标签无行为激活效果 | 描述具体能力和行为特征 |
+| 实现细节(协议、框架、架构) | 噪音 | 删掉,除非影响行为 |
+| "请注意""重要""务必"满天飞 | 反复强调降低所有内容的权重 | 只强调一两个真正关键的点 |
+| 详尽 step-by-step 流程 | 机械跟随而非灵活判断 | 给判断标准和决策规则 |
+| 长负面清单 | 适得其反 | 正面描述期望行为 |
+| 所有信息塞 system prompt | 注意力稀释 | 分层:始终需要的(system)+ 按需加载的(skill) |
+| 开头就给输出模板 | 过早锁定结构 | 先给任务和标准,最后给格式 |
+| "不能做X"但不给替代方案 | LLM 自行发明绕过方式 | 同时说明"改做Y" |

+ 1 - 1
agent/docs/trace-api.md

@@ -409,6 +409,6 @@ sub_traces = [t for t in all_traces if t.parent_trace_id == main_trace_id]
 ## 相关文档
 
 - [frontend/API.md](../frontend/API.md) - 前端对接 API 文档
-- [docs/context-management.md](./context-management.md) - Context 管理完整设计
+- [context-management.md](./context-management.md) - Context 管理完整设计
 - [agent/goal/models.py](../agent/goal/models.py) - GoalTree 模型定义
 - [docs/REFACTOR_SUMMARY.md](./REFACTOR_SUMMARY.md) - 重构总结

+ 1 - 1
agent/tools/builtin/__init__.py

@@ -19,7 +19,7 @@ from agent.tools.builtin.search import search_posts, get_search_suggestions
 from agent.tools.builtin.sandbox import (sandbox_create_environment, sandbox_run_shell,
                                          sandbox_rebuild_with_ports,sandbox_destroy_environment)
 from agent.tools.builtin.knowledge import(knowledge_search,knowledge_save,knowledge_list,knowledge_update,knowledge_batch_update,knowledge_slim)
-from agent.tools.builtin.knowledge_manager import ask_knowledge, upload_knowledge
+from agent.tools.builtin.librarian import ask_knowledge, upload_knowledge
 from agent.tools.builtin.context import get_current_context
 from agent.tools.builtin.toolhub import toolhub_health, toolhub_search, toolhub_call, toolhub_create
 from agent.tools.builtin.resource import resource_list_tools, resource_get_tool

+ 0 - 209
agent/tools/builtin/knowledge_manager.py

@@ -1,209 +0,0 @@
-"""
-Knowledge Manager 工具 - 通过 IM 与知识库管理 Agent 交互
-
-提供两个工具:
-- ask_knowledge: 查询知识库(同步,等待回复)
-- upload_knowledge: 上传调研结果(异步,立即返回)
-
-依赖:IM Client 已初始化(im_setup + im_open_window)
-"""
-
-import asyncio
-import json
-import logging
-from typing import Optional, Dict, Any, List
-from agent.tools import tool, ToolResult, ToolContext
-
-logger = logging.getLogger(__name__)
-
-# IM 工具的全局引用(延迟导入)
-_im_chat = None
-
-def _get_im_chat():
-    global _im_chat
-    if _im_chat is None:
-        from agent.tools.builtin.im import chat as im_chat_module
-        _im_chat = im_chat_module
-    return _im_chat
-
-
-def _get_client_and_chat_id(contact_id: str):
-    """获取 IM Client 实例和当前窗口的 chat_id"""
-    im = _get_im_chat()
-    client = im._clients.get(contact_id)
-    if client is None:
-        return None, None
-    # 取第一个打开的窗口
-    windows = client.list_windows()
-    chat_id = windows[0] if windows else None
-    return client, chat_id
-
-
-def _clear_notifications(contact_id: str, chat_id: str):
-    """清空 IM 通知计数"""
-    im = _get_im_chat()
-    im._notifications.pop((contact_id, chat_id), None)
-
-
-@tool(
-    hidden_params=["context"],
-    inject_params={
-        "contact_id": {"mode": "default", "key": "im_contact_id"},
-    }
-)
-async def ask_knowledge(
-    query: str,
-    contact_id: str = "agent_research",
-    context: Optional[ToolContext] = None,
-) -> ToolResult:
-    """
-    向 Knowledge Manager 查询知识库信息(同步,等待回复)
-
-    Args:
-        query: 查询内容(如:"查询 ControlNet 相关信息")
-        contact_id: 当前 Agent 的 IM contact_id
-        context: 工具上下文
-
-    Returns:
-        查询结果(已有工具、资源、知识及建议)
-    """
-    try:
-        client, chat_id = _get_client_and_chat_id(contact_id)
-        if client is None or chat_id is None:
-            return ToolResult(
-                title="❌ 查询失败",
-                output="IM Client 未初始化,请先调用 im_setup",
-                error="im_not_initialized"
-            )
-
-        # 发送查询(带类型标记)
-        message = f"[ASK] {query}"
-        client.send_message(
-            chat_id=chat_id,
-            receiver="knowledge_manager",
-            content=message
-        )
-
-        # 等待回复(最多 30 秒)
-        for _ in range(30):
-            await asyncio.sleep(1)
-
-            pending = client.read_pending(chat_id)
-            for msg in pending:
-                if msg.get("sender") == "knowledge_manager":
-                    content = msg.get("content", "")
-                    # 清空 IM 通知计数,防止 notifier 反复提醒
-                    _clear_notifications(contact_id, chat_id)
-                    return ToolResult(
-                        title="📚 知识库查询结果",
-                        output=content,
-                        metadata={"source": "knowledge_manager"}
-                    )
-
-        # 超时保底:直接调用 knowledge_search 返回原始结果
-        logger.warning("Knowledge Manager 超时,fallback 到 knowledge_search")
-        from agent.tools.builtin.knowledge import knowledge_search
-        fallback_result = await knowledge_search(query=query, top_k=5, min_score=3)
-
-        return ToolResult(
-            title="📚 知识库查询结果(直连)",
-            output=fallback_result.output,
-            metadata={"source": "fallback", "raw": fallback_result.metadata}
-        )
-
-    except Exception as e:
-        logger.error(f"查询知识库失败: {e}")
-        return ToolResult(
-            title="❌ 查询失败",
-            output=f"错误: {str(e)}",
-            error=str(e)
-        )
-
-
-@tool(
-    hidden_params=["context"],
-    inject_params={
-        "contact_id": {"mode": "default", "key": "im_contact_id"},
-    }
-)
-async def upload_knowledge(
-    data: Dict[str, Any],
-    source_type: str = "research",
-    finalize: bool = False,
-    contact_id: str = "agent_research",
-    context: Optional[ToolContext] = None,
-) -> ToolResult:
-    """
-    上传调研结果或执行经验到知识库(异步,立即返回)
-
-    Args:
-        data: 结构化数据,包含:
-            - tools: 工具列表
-            - resources: 资源列表
-            - knowledge: 知识列表
-        source_type: 数据来源分类。调研结果填 "research",执行任务时产生的经验请填 "execution"。
-        finalize: 是否最终提交(True=入库,False=仅缓冲)
-        contact_id: 当前 Agent 的 IM contact_id
-        context: 工具上下文
-
-    Returns:
-        上传确认(立即返回,不等待处理完成)
-    """
-    try:
-        client, chat_id = _get_client_and_chat_id(contact_id)
-        if client is None or chat_id is None:
-            return ToolResult(
-                title="❌ 上传失败",
-                output="IM Client 未初始化,请先调用 im_setup",
-                error="im_not_initialized"
-            )
-
-        # 将来源类型标记灌入每条 knowledge 的 source 字段
-        if "knowledge" in data and isinstance(data["knowledge"], list):
-            for k in data["knowledge"]:
-                if "source" not in k:
-                    k["source"] = {}
-                if "category" not in k["source"]:
-                    k["source"]["category"] = source_type
-
-        # 构造消息(带类型标记)
-        if finalize:
-            action = "最终提交"
-            message = f"[UPLOAD:FINALIZE] {json.dumps(data, ensure_ascii=False)}"
-        else:
-            action = f"增量上传({source_type})"
-            message = f"[UPLOAD] {json.dumps(data, ensure_ascii=False)}"
-
-        # 发送(不等待回复)
-        client.send_message(
-            chat_id=chat_id,
-            receiver="knowledge_manager",
-            content=message
-        )
-
-        # 等待一小段时间让 KM 回复,然后清空 pending(避免 notifier 反复通知)
-        await asyncio.sleep(0.5)
-        client.read_pending(chat_id)  # 清空回复,不处理内容
-
-        summary = []
-        if data.get("tools"):
-            summary.append(f"工具: {len(data['tools'])} 个")
-        if data.get("resources"):
-            summary.append(f"资源: {len(data['resources'])} 个")
-        if data.get("knowledge"):
-            summary.append(f"知识: {len(data['knowledge'])} 个")
-
-        return ToolResult(
-            title=f"✅ {action}成功",
-            output=f"已发送到 Knowledge Manager\n\n" + "\n".join(f"- {s}" for s in summary),
-            long_term_memory=f"{action}: {', '.join(summary)}",
-            metadata={"finalize": finalize}
-        )
-
-    except Exception as e:
-        logger.error(f"上传知识失败: {e}")
-        return ToolResult(
-            title="❌ 上传失败",
-            output=f"错误: {str(e)}",
-            error=str(e)
-        )

+ 180 - 0
agent/tools/builtin/librarian.py

@@ -0,0 +1,180 @@
+"""
+Knowledge Manager 工具 - 通过 HTTP API 与 KnowHub 交互
+
+提供两个工具:
+- ask_knowledge: 查询知识库(同步阻塞,等待 Librarian Agent 整合回答)
+- upload_knowledge: 上传调研结果(异步,校验后立即返回)
+
+通过 KnowHub HTTP API 调用,不依赖 IM。
+"""
+
+import os
+import json
+import logging
+from typing import Optional, Dict, Any
+import httpx
+from agent.tools import tool, ToolResult, ToolContext
+
+logger = logging.getLogger(__name__)
+
+KNOWHUB_API = os.getenv("KNOWHUB_API", "http://localhost:8000").rstrip("/")
+
+
+@tool(
+    hidden_params=["context"],
+    inject_params={
+        "trace_id": {"mode": "default", "key": "trace_id"},
+    }
+)
+async def ask_knowledge(
+    query: str,
+    trace_id: str = "",
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    向知识库查询信息(同步阻塞,等待整合回答)
+
+    KnowHub 内部使用 Librarian Agent 整合检索结果,返回带引用的回答。
+    同一 trace_id 的多次查询复用同一个 Librarian Agent,积累任务理解。
+
+    Args:
+        query: 查询内容(如:"ControlNet 相关的工具知识")
+        trace_id: 调用方的 trace_id,用于 Librarian Agent 续跑
+        context: 工具上下文
+
+    Returns:
+        整合回答 + source_ids + 各 source 摘要
+    """
+    try:
+        async with httpx.AsyncClient(timeout=60.0) as client:
+            response = await client.post(
+                f"{KNOWHUB_API}/api/knowledge/ask",
+                json={
+                    "query": query,
+                    "trace_id": trace_id,
+                }
+            )
+            response.raise_for_status()
+            result = response.json()
+
+        source_ids = result.get("source_ids", [])
+        sources = result.get("sources", [])
+        resp_text = result.get("response", "")
+
+        return ToolResult(
+            title=f"📚 知识库查询结果({len(source_ids)} 条来源)",
+            output=resp_text,
+            metadata={
+                "source_ids": source_ids,
+                "sources": sources,
+            }
+        )
+
+    except httpx.HTTPStatusError as e:
+        # ask 端点不可用时降级到直接搜索
+        if e.response.status_code == 404:
+            logger.warning("ask 端点不可用,降级到 knowledge_search")
+            from agent.tools.builtin.knowledge import knowledge_search
+            fallback = await knowledge_search(query=query, top_k=5, min_score=3)
+            return ToolResult(
+                title="📚 知识库查询结果(直连)",
+                output=fallback.output,
+                metadata={"source": "fallback", "raw": fallback.metadata}
+            )
+        raise
+
+    except Exception as e:
+        logger.error(f"查询知识库失败: {e}")
+        # 网络错误也降级
+        logger.warning("ask 请求失败,降级到 knowledge_search")
+        try:
+            from agent.tools.builtin.knowledge import knowledge_search
+            fallback = await knowledge_search(query=query, top_k=5, min_score=3)
+            return ToolResult(
+                title="📚 知识库查询结果(直连)",
+                output=fallback.output,
+                metadata={"source": "fallback", "raw": fallback.metadata}
+            )
+        except Exception as e2:
+            return ToolResult(
+                title="❌ 查询失败",
+                output=f"错误: {str(e)}(降级也失败: {str(e2)})",
+                error=str(e)
+            )
+
+
+@tool(
+    hidden_params=["context"],
+    inject_params={
+        "trace_id": {"mode": "default", "key": "trace_id"},
+    }
+)
+async def upload_knowledge(
+    data: Dict[str, Any],
+    source_type: str = "research",
+    finalize: bool = False,
+    trace_id: str = "",
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    上传调研结果或执行经验到知识库(异步,校验后立即返回)
+
+    KnowHub 校验格式后立即返回,后台队列处理去重和入库。
+
+    Args:
+        data: 结构化数据,包含:
+            - tools: 工具列表
+            - resources: 资源列表
+            - knowledge: 知识列表
+        source_type: 数据来源分类。调研结果填 "research",执行经验填 "execution"。
+        finalize: 是否最终提交(True=入库,False=仅缓冲)
+        trace_id: 调用方的 trace_id
+        context: 工具上下文
+
+    Returns:
+        上传确认(立即返回,不等待处理完成)
+    """
+    try:
+        # 标记来源类型
+        if "knowledge" in data and isinstance(data["knowledge"], list):
+            for k in data["knowledge"]:
+                if "source" not in k:
+                    k["source"] = {}
+                if "category" not in k["source"]:
+                    k["source"]["category"] = source_type
+
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            response = await client.post(
+                f"{KNOWHUB_API}/api/knowledge/upload",
+                json={
+                    "data": data,
+                    "trace_id": trace_id,
+                    "finalize": finalize,
+                }
+            )
+            response.raise_for_status()
+
+        summary = []
+        if data.get("tools"):
+            summary.append(f"工具: {len(data['tools'])} 个")
+        if data.get("resources"):
+            summary.append(f"资源: {len(data['resources'])} 个")
+        if data.get("knowledge"):
+            summary.append(f"知识: {len(data['knowledge'])} 个")
+
+        action = "最终提交" if finalize else f"增量上传({source_type})"
+
+        return ToolResult(
+            title=f"✅ {action}成功",
+            output=f"已提交到 KnowHub\n\n" + "\n".join(f"- {s}" for s in summary),
+            long_term_memory=f"{action}: {', '.join(summary)}",
+            metadata={"finalize": finalize}
+        )
+
+    except Exception as e:
+        logger.error(f"上传知识失败: {e}")
+        return ToolResult(
+            title="❌ 上传失败",
+            output=f"错误: {str(e)}",
+            error=str(e)
+        )

+ 29 - 29
agent/trace/goal_tool.py

@@ -46,48 +46,48 @@ async def inject_knowledge_for_goal(
         return None
 
     try:
-        from agent.tools.builtin.knowledge import knowledge_search
+        from agent.tools.builtin.librarian import ask_knowledge
 
         logger.info(f"[Knowledge Inject] goal: {goal.id}, query: {goal.description[:80]}")
 
-        # 从配置中获取搜索参数
-        search_types = None
-        search_owner = None
-        if knowledge_config:
-            search_types = getattr(knowledge_config, 'default_search_types', None)
-            search_owner = getattr(knowledge_config, 'default_search_owner', None) or None
-
-        knowledge_result = await knowledge_search(
+        # 通过 ask 接口获取整合回答
+        ask_result = await ask_knowledge(
             query=goal.description,
-            top_k=3,
-            min_score=3,
-            types=search_types,
-            owner=search_owner,
-            context=None
+            trace_id=trace_id or "",
         )
 
-        logger.debug(f"[Knowledge Inject] result type: {type(knowledge_result)}, metadata: {getattr(knowledge_result, 'metadata', None)}")
+        metadata = ask_result.metadata or {}
+        source_ids = metadata.get("source_ids", [])
+        sources = metadata.get("sources", [])
+        response_text = ask_result.output or ""
 
-        if knowledge_result.metadata and knowledge_result.metadata.get("items"):
-            goal.knowledge = knowledge_result.metadata["items"]
-            knowledge_count = len(goal.knowledge)
+        if source_ids:
+            # 构建 goal.knowledge(兼容现有格式)
+            goal.knowledge = sources if sources else [{"id": sid} for sid in source_ids]
+            knowledge_count = len(source_ids)
             logger.info(f"[Knowledge Inject] 注入 {knowledge_count} 条知识到 goal {goal.id}")
 
             if store and trace_id:
                 await store.update_goal_tree(trace_id, tree)
 
-                # 写入 knowledge_log
+                # 写入 cognition_log: query 事件
                 if sequence is not None:
-                    for item in goal.knowledge:
-                        await store.append_knowledge_entry(
-                            trace_id=trace_id,
-                            knowledge_id=item.get("id", ""),
-                            goal_id=goal.id,
-                            injected_at_sequence=sequence,
-                            task=item.get("task", ""),
-                            content=item.get("content", "")
-                        )
-                    logger.info(f"[Knowledge Inject] 已记录 {knowledge_count} 条知识到 knowledge_log")
+                    await store.append_cognition_event(
+                        trace_id=trace_id,
+                        event={
+                            "type": "query",
+                            "sequence": sequence,
+                            "goal_id": goal.id,
+                            "query": goal.description,
+                            "response": response_text[:2000],
+                            "source_ids": source_ids,
+                            "sources": [
+                                {"id": s.get("id", ""), "task": s.get("task", ""), "content": s.get("content", "")[:500]}
+                                for s in sources
+                            ],
+                        }
+                    )
+                    logger.info(f"[Knowledge Inject] 已记录 query 事件到 cognition_log")
 
             return f"📚 已注入 {knowledge_count} 条相关知识"
         else:

+ 1 - 1
agent/trace/models.py

@@ -218,7 +218,7 @@ class Message:
 
     def to_llm_dict(self) -> Dict[str, Any]:
         """转换为 OpenAI SDK 格式的消息字典(用于 LLM 调用)"""
-        msg: Dict[str, Any] = {"role": self.role}
+        msg: Dict[str, Any] = {"role": self.role, "_message_id": self.message_id}
 
         if self.role == "tool":
             # tool message: tool_call_id + name + content

+ 152 - 66
agent/trace/store.py

@@ -765,18 +765,54 @@ class FileSystemTraceStore:
 
         return event_id
 
-    # ===== Knowledge Log 管理 =====
+    # ===== Cognition Log 管理 =====
+
+    def _get_cognition_log_file(self, trace_id: str) -> Path:
+        """获取 cognition_log.json 文件路径"""
+        return self._get_trace_dir(trace_id) / "cognition_log.json"
 
     def _get_knowledge_log_file(self, trace_id: str) -> Path:
-        """获取 knowledge_log.json 文件路径"""
-        return self._get_trace_dir(trace_id) / "knowledge_log.json"
+        """兼容旧接口:优先使用 cognition_log,回退到 knowledge_log"""
+        cognition_file = self._get_cognition_log_file(trace_id)
+        if cognition_file.exists():
+            return cognition_file
+        legacy_file = self._get_trace_dir(trace_id) / "knowledge_log.json"
+        if legacy_file.exists():
+            return legacy_file
+        return cognition_file  # 新建时用 cognition_log
+
+    async def get_cognition_log(self, trace_id: str) -> Dict[str, Any]:
+        """读取认知日志"""
+        log_file = self._get_cognition_log_file(trace_id)
+        if log_file.exists():
+            return json.loads(log_file.read_text(encoding="utf-8"))
+        # 兼容旧格式:如果只有 knowledge_log.json,读取并转换
+        legacy_file = self._get_trace_dir(trace_id) / "knowledge_log.json"
+        if legacy_file.exists():
+            return json.loads(legacy_file.read_text(encoding="utf-8"))
+        return {"trace_id": trace_id, "events": []}
 
     async def get_knowledge_log(self, trace_id: str) -> Dict[str, Any]:
-        """读取知识日志"""
-        log_file = self._get_knowledge_log_file(trace_id)
-        if not log_file.exists():
-            return {"trace_id": trace_id, "entries": []}
-        return json.loads(log_file.read_text(encoding="utf-8"))
+        """兼容旧接口"""
+        log = await self.get_cognition_log(trace_id)
+        # 旧格式用 entries,新格式用 events
+        if "entries" not in log and "events" in log:
+            log["entries"] = log["events"]
+        return log
+
+    async def append_cognition_event(
+        self,
+        trace_id: str,
+        event: Dict[str, Any],
+    ) -> None:
+        """追加认知事件(query/evaluation/extraction/reflection)"""
+        log = await self.get_cognition_log(trace_id)
+        if "events" not in log:
+            log["events"] = log.pop("entries", [])
+        event["timestamp"] = datetime.now().isoformat()
+        log["events"].append(event)
+        log_file = self._get_cognition_log_file(trace_id)
+        log_file.write_text(json.dumps(log, indent=2, ensure_ascii=False), encoding="utf-8")
 
     async def append_knowledge_entry(
         self,
@@ -787,21 +823,19 @@ class FileSystemTraceStore:
         task: str,
         content: str
     ) -> None:
-        """追加知识注入记录"""
-        log = await self.get_knowledge_log(trace_id)
-        log["entries"].append({
-            "knowledge_id": knowledge_id,
-            "goal_id": goal_id,
-            "injected_at_sequence": injected_at_sequence,
-            "injected_at": datetime.now().isoformat(),
-            "task": task,
-            "content": content[:500],  # 限制长度
-            "eval_result": None,
-            "evaluated_at": None,
-            "evaluated_at_trigger": None
-        })
-        log_file = self._get_knowledge_log_file(trace_id)
-        log_file.write_text(json.dumps(log, indent=2, ensure_ascii=False), encoding="utf-8")
+        """兼容旧接口:追加知识注入记录(转换为 query 事件)"""
+        await self.append_cognition_event(
+            trace_id=trace_id,
+            event={
+                "type": "query",
+                "sequence": injected_at_sequence,
+                "goal_id": goal_id,
+                "query": task,
+                "response": "",
+                "source_ids": [knowledge_id],
+                "sources": [{"id": knowledge_id, "task": task, "content": content[:500]}],
+            }
+        )
 
     async def update_knowledge_evaluation(
         self,
@@ -810,35 +844,81 @@ class FileSystemTraceStore:
         eval_result: Dict[str, Any],
         trigger_event: str
     ) -> None:
-        """更新知识评估结果
+        """更新知识评估结果(兼容旧格式 + 新 cognition_log 格式)
 
-        当同一个knowledge_id在不同goal中被多次注入时,
-        优先更新最近一个未评估的条目(按injected_at_sequence倒序)
+        旧格式:更新 entries[] 中匹配 knowledge_id 的条目的 eval_result
+        新格式:追加 evaluation 事件到 events[]
         """
-        log = await self.get_knowledge_log(trace_id)
-
-        # 找到所有匹配且未评估的条目
-        matching_entries = [
-            (i, entry) for i, entry in enumerate(log["entries"])
-            if entry["knowledge_id"] == knowledge_id and entry["eval_result"] is None
+        log = await self.get_cognition_log(trace_id)
+        events = log.get("events", log.get("entries", []))
+
+        # 旧格式兼容:直接更新 entries 中的 eval_result 字段
+        if "entries" in log:
+            matching = [
+                (i, e) for i, e in enumerate(log["entries"])
+                if e.get("knowledge_id") == knowledge_id and e.get("eval_result") is None
+            ]
+            if matching:
+                matching.sort(key=lambda x: x[1].get("injected_at_sequence", 0), reverse=True)
+                _, entry = matching[0]
+                entry["eval_result"] = eval_result
+                entry["evaluated_at"] = datetime.now().isoformat()
+                entry["evaluated_at_trigger"] = trigger_event
+                log_file = self._get_knowledge_log_file(trace_id)
+                log_file.write_text(json.dumps(log, indent=2, ensure_ascii=False), encoding="utf-8")
+                return
+
+        # 新格式:追加 evaluation 事件
+        # 找到包含该 knowledge_id 的最近 query 事件
+        query_events = [
+            e for e in events
+            if e.get("type") == "query" and knowledge_id in e.get("source_ids", [])
         ]
-
-        if matching_entries:
-            # 按injected_at_sequence倒序排序,取最近的一个
-            matching_entries.sort(key=lambda x: x[1]["injected_at_sequence"], reverse=True)
-            idx, entry = matching_entries[0]
-
-            entry["eval_result"] = eval_result
-            entry["evaluated_at"] = datetime.now().isoformat()
-            entry["evaluated_at_trigger"] = trigger_event
-
-        log_file = self._get_knowledge_log_file(trace_id)
-        log_file.write_text(json.dumps(log, indent=2, ensure_ascii=False), encoding="utf-8")
+        query_sequence = query_events[-1]["sequence"] if query_events else None
+
+        await self.append_cognition_event(
+            trace_id=trace_id,
+            event={
+                "type": "evaluation",
+                "sequence": max((e.get("sequence", 0) for e in events), default=0) + 1,
+                "query_sequence": query_sequence,
+                "trigger": trigger_event,
+                "assessments": [
+                    {"source_id": knowledge_id, "status": eval_result.get("eval_status", ""), "reason": eval_result.get("reason", "")}
+                ],
+            }
+        )
 
     async def get_pending_knowledge_entries(self, trace_id: str) -> List[Dict[str, Any]]:
-        """获取所有待评估的知识条目"""
-        log = await self.get_knowledge_log(trace_id)
-        return [e for e in log["entries"] if e["eval_result"] is None]
+        """获取所有待评估的知识条目(兼容旧格式 + 新格式)"""
+        log = await self.get_cognition_log(trace_id)
+
+        # 旧格式
+        if "entries" in log:
+            return [e for e in log["entries"] if e.get("eval_result") is None]
+
+        # 新格式:找没有对应 evaluation 事件的 query 事件
+        events = log.get("events", [])
+        query_events = [e for e in events if e.get("type") == "query"]
+        eval_events = [e for e in events if e.get("type") == "evaluation"]
+
+        # 已评估的 query sequences
+        evaluated_sequences = {e.get("query_sequence") for e in eval_events}
+
+        pending = []
+        for qe in query_events:
+            if qe.get("sequence") not in evaluated_sequences:
+                # 转为旧格式兼容(runner 中的评估逻辑期望此格式)
+                for source in qe.get("sources", []):
+                    pending.append({
+                        "knowledge_id": source.get("id", ""),
+                        "goal_id": qe.get("goal_id", ""),
+                        "injected_at_sequence": qe.get("sequence", 0),
+                        "task": source.get("task", ""),
+                        "content": source.get("content", ""),
+                        "query_sequence": qe.get("sequence"),
+                    })
+        return pending
 
     async def update_user_feedback(
         self,
@@ -846,23 +926,29 @@ class FileSystemTraceStore:
         knowledge_id: str,
         user_feedback: Dict[str, Any]
     ) -> None:
-        """记录用户对知识的反馈(confirm/override),不覆盖 agent 的 eval_result
-
-        当同一个 knowledge_id 被多次注入时,更新最近一次注入的条目。
-        """
-        log = await self.get_knowledge_log(trace_id)
-
-        # 找到所有匹配的条目(不限 eval_result 是否为 None)
-        matching_entries = [
-            (i, entry) for i, entry in enumerate(log["entries"])
-            if entry["knowledge_id"] == knowledge_id
-        ]
-
-        if matching_entries:
-            # 按 injected_at_sequence 倒序,取最近一次注入的条目
-            matching_entries.sort(key=lambda x: x[1]["injected_at_sequence"], reverse=True)
-            idx, entry = matching_entries[0]
-            entry["user_feedback"] = user_feedback
+        """记录用户对知识的反馈(confirm/override)"""
+        log = await self.get_cognition_log(trace_id)
+
+        # 旧格式
+        if "entries" in log:
+            matching = [
+                (i, e) for i, e in enumerate(log["entries"])
+                if e.get("knowledge_id") == knowledge_id
+            ]
+            if matching:
+                matching.sort(key=lambda x: x[1].get("injected_at_sequence", 0), reverse=True)
+                _, entry = matching[0]
+                entry["user_feedback"] = user_feedback
+            log_file = self._get_knowledge_log_file(trace_id)
+            log_file.write_text(json.dumps(log, indent=2, ensure_ascii=False), encoding="utf-8")
+            return
 
-        log_file = self._get_knowledge_log_file(trace_id)
-        log_file.write_text(json.dumps(log, indent=2, ensure_ascii=False), encoding="utf-8")
+        # 新格式:追加 user_feedback 事件(或直接记录在 evaluation 上)
+        await self.append_cognition_event(
+            trace_id=trace_id,
+            event={
+                "type": "user_feedback",
+                "knowledge_id": knowledge_id,
+                "feedback": user_feedback,
+            }
+        )

+ 1 - 1
docs/README.md

@@ -26,7 +26,7 @@
 
 - [A2A IM 系统](./a2a-im.md) - Agent 间即时通讯系统架构
 - [知识管理](../knowhub/docs/knowledge-management.md) - 知识结构、API、集成方式
-- [Context 管理](./context-management.md) - Goals、压缩、Plan 注入策略
+- [Context 管理](../agent/docs/context-management.md) - 注入机制、压缩策略、Skill 指定注入
 
 ### 研究文档
 

+ 77 - 0
examples/production_plan/generation-agent-architecture.md

@@ -0,0 +1,77 @@
+# 生成任务 Agent 架构
+
+## 设计原则
+
+按 context 内聚划分 agent:把解决特定子任务必须的信息交给一个 agent,每个 agent 的 context 保持小且聚焦。
+
+## 角色
+
+### Business Agent(决策循环)
+
+薄编排器。驱动 goal → evaluate → decide → dispatch 循环。
+
+- context:目标需求 + 当前结果 + 当前评估(不累积历史)
+- 做:提出目标、评估结果(通过 evaluate 工具)、决定迭代或接受、派发任务
+- 调度:Librarian(问策)、Craftsman(执行)、Researcher(调研)
+
+### Librarian(内部知识顾问)
+
+基于 KnowHub 已有知识给出方案建议。被动响应,不主动执行。
+
+- context:KnowHub 知识(需求、能力、工具、历史经验)
+- 做:接收当前状态 + 评估反馈 → 建议下一步行动
+- 不做:不执行工具、不做外部调研、不输出固定步骤序列
+- 回写:方案选择经验("这类需求用 X 方案效果最好")
+
+### Researcher(外部知识获取)
+
+当已有知识不足时,去外部获取新信息。
+
+- context:待调研的具体问题 + 外部信息源(网页、目录、试跑)
+- 做:搜索、对比、试验 → 返回调研结果 → 存入 KnowHub
+- 触发:Business Agent 根据 Librarian 的"知识不足"反馈决定派发
+
+### Craftsman(单步执行)
+
+接收一个具体的执行任务(非整个方案),完成并返回结果。
+
+- context:当前步骤的需求 + 候选工具详情(由 Librarian 提供)
+- 做:选具体工具/模型、配参、调用工具库执行、返回结果
+- 回写:参数和工具组合经验("这组参数出图最稳")
+- provider 选择在此层处理,不暴露给业务
+
+### evaluate 工具(质量评估)
+
+不是 agent,是 Business Agent 调用的工具。隔离 context 保证评估干净。
+
+- 输入:原始需求 + 输出结果 + 质量标准
+- 输出:评分 + 反馈(如"姿态准确但风格偏写实")
+- 内部可调用多模态 LLM 看图评估
+
+## 协作流程
+
+```
+Business: "动漫风姿态控制图"
+  → Librarian: "推荐 ControlNet + 动漫模型,但缺模型对比数据"
+  → Business 决定调研
+  → Researcher: 对比动漫模型 → 结果存入 KnowHub
+  → Librarian(有知识了): "推荐 AnimagineXL,先提取姿态再合成"
+  → Craftsman(提取姿态) → 姿态图
+  → Craftsman(合成生图) → 结果图
+  → evaluate(需求, 结果图) → "7/10,风格偏写实"
+  → Librarian: "换 Counterfeit 模型或加风格 LoRA"
+  → Craftsman(重新合成) → 新结果
+  → evaluate → "9/10,符合要求"
+  → Business: 接受
+```
+
+## 知识回流
+
+每个角色从自己的视角向 KnowHub 贡献知识:
+
+| 角色 | 回写的知识类型 |
+|------|--------------|
+| Librarian | 方案策略经验(哪种方案适合哪类需求) |
+| Craftsman | 工具参数经验(什么配置效果最好) |
+| Researcher | 调研发现(工具对比、新工具信息) |
+| evaluate | 质量评估记录(方案 A vs B 的效果对比) |

+ 1 - 1
frontend/API.md

@@ -1194,5 +1194,5 @@ function connect(traceId) {
 
 ## 相关文档
 
-- [Context 管理设计](../docs/context-management.md) - Goal 机制完整设计
+- [Context 管理设计](../agent/docs/context-management.md) - Goal 机制完整设计
 - [Trace 模块说明](../docs/trace-api.md) - 后端实现细节

+ 14 - 20
knowhub/README.md

@@ -35,45 +35,39 @@ Agent(端侧)
                               └── Librarian Agent(知识管理Agent,当前通过IM独立运行)
                               PostgreSQL + pgvector
-                              ├── knowledge(知识表,双向量)
-                              ├── resources(资源表,加密存储)
-                              ├── tool_table(工具表)
-                              ├── atomic_capability(原子能力表)
-                              └── requirement_table(需求表)
+                              ├── knowledge, resource, tool, capability, requirement(5 实体表)
+                              └── 8 张关联表(详见 schema.md)
 ```
 
 ---
 
 ## 文档索引
 
-### 核心设计
+### 代码快照(与代码一致)
 
 | 文档 | 内容 |
 |------|------|
-| [数据模型](docs/schema.md) | 5张表结构、字段定义、表间关联、向量策略 |
-| [REST API](docs/api.md) | 36个API端点的完整参考 |
-| [Librarian Agent](docs/librarian-agent.md) | 知识管理Agent:当前IM架构 + 未来HTTP ask/upload设计 |
+| [数据模型](docs/schema.md) | 13张表(5实体+8关联)、字段定义、向量策略 |
+| [REST API](docs/api.md) | API 端点参考 |
+| [Librarian Agent](docs/librarian-agent.md) | 知识管理 Agent 架构和接口 |
+| [知识处理流水线](docs/processing-pipeline.md) | 去重、工具关联分析、状态流转 |
+| [DB层](knowhub_db/README.md) | 数据库访问层封装类和运维脚本 |
 
-### 处理机制
+### 方案文档(`*-plan.md`,未实现)
 
 | 文档 | 内容 |
 |------|------|
-| [知识处理流水线](docs/processing-pipeline.md) | 去重、工具关联分析、状态流转、LLM关系判断 |
-| [Cognition Log](../agent/docs/cognition-log.md) | Agent侧的认知日志:查询/评估/提取/反思事件流(在 agent/docs/ 中) |
-
-### 前端与用户交互
-
-| 文档 | 内容 |
-|------|------|
-| [用户反馈设计](docs/user-feedback-design.md) | 用户反馈UI、API、数据模型 |
-| [Dashboard设计](docs/dashboard-design.md) | 知识库可视化Dashboard |
+| [Schema 迁移](docs/schema-migration-plan.md) | JSONB 软关联 → 关联表 |
+| [Cognition Log](../agent/docs/cognition-log-plan.md) | Agent 侧认知日志事件流(在 agent/docs/ 中) |
+| [前端重构](docs/frontend-restructure-plan.md) | 原子能力为中心的前端重构 |
+| [Dashboard](docs/dashboard-plan.md) | 知识库可视化 Dashboard |
+| [用户反馈](docs/user-feedback-plan.md) | 用户反馈 UI、API、数据模型 |
 
 ### 其他
 
 | 文档 | 内容 |
 |------|------|
 | [决策记录](docs/decisions.md) | 设计决策的依据和权衡 |
-| [DB层README](knowhub_db/README.md) | 数据库访问层的封装类和运维脚本 |
 
 ---
 

+ 0 - 341
knowhub/agents/knowledge_manager.py

@@ -1,341 +0,0 @@
-"""
-Knowledge Manager Agent
-
-基于 AgentRunner 驱动,有完整的 trace 记录。
-通过 IM Client 事件驱动,收到消息时传给 AgentRunner 处理。
-
-架构:
-  IM 消息 → user message → AgentRunner → LLM 自主决策 → 工具调用 → trace 记录
-"""
-
-import asyncio
-import json
-import logging
-import sys
-from datetime import datetime
-from pathlib import Path
-from typing import Optional
-
-# 确保项目路径可用
-sys.path.insert(0, str(Path(__file__).parent.parent.parent))
-
-from agent.core.runner import AgentRunner, RunConfig
-from agent.trace import FileSystemTraceStore, Trace, Message
-from agent.llm import create_qwen_llm_call
-from agent.llm.prompts import SimplePrompt
-from agent.tools.builtin.knowledge import KnowledgeConfig
-
-logger = logging.getLogger("agents.knowledge_manager")
-
-# ===== 配置项 =====
-ENABLE_DATABASE_COMMIT = False  # 是否允许入库(False=只缓存,True=可入库)
-
-# Knowledge Manager Agent 配置
-def get_knowledge_manager_config(enable_db_commit: bool = ENABLE_DATABASE_COMMIT) -> RunConfig:
-    """获取 Knowledge Manager 配置(根据是否允许入库动态调整工具列表)"""
-    tools = [
-        # 只读查询工具(用于跨表检索和关联分析)
-        "knowledge_search",
-        "knowledge_list",
-        "tool_search",
-        "tool_list",
-        "capability_search",
-        "capability_list",
-        "requirement_search",
-        "requirement_list",
-        # 文件工具(用于维护 pre_upload_list.json 草稿)
-        "read_file",
-        "write_file",
-        # 本地缓存工具
-        "list_cache_status",
-        # 树节点匹配工具(需求 → 内容树挂载)
-        "match_tree_nodes",
-    ]
-
-    # 只有启用入库时才开放 commit_to_database 工具
-    if enable_db_commit:
-        tools.append("commit_to_database")
-
-    return RunConfig(
-        model="qwen3.5-plus",
-        temperature=0.2,
-        max_iterations=50,
-        agent_type="knowledge_manager",
-        name="知识库管理",
-        goal_compression="none",
-        # 禁用所有知识提取和反思
-        knowledge=KnowledgeConfig(
-            enable_extraction=False,
-            enable_completion_extraction=False,
-            enable_injection=False,
-        ),
-        tools=tools,
-    )
-
-
-async def start_knowledge_manager(
-    contact_id: str = "knowledge_manager",
-    server_url: str = "ws://43.106.118.91:8105",
-    chat_id: str = "main",
-    enable_db_commit: bool = ENABLE_DATABASE_COMMIT
-):
-    """
-    启动 Knowledge Manager(AgentRunner 驱动 + 事件驱动)
-
-    收到 IM 消息时,将消息作为 user message 传给 AgentRunner。
-    LLM 自主决策调用什么工具,所有操作记录到 trace。
-
-    Args:
-        contact_id: IM 身份 ID
-        server_url: IM Server 地址
-        chat_id: 聊天窗口 ID
-        enable_db_commit: 是否允许入库(False=只缓存,True=可入库)
-    """
-    logger.info(f"正在启动 Knowledge Manager...")
-    logger.info(f"  - Contact ID: {contact_id}")
-    logger.info(f"  - Server: {server_url}")
-    logger.info(f"  - 入库功能: {'启用' if enable_db_commit else '禁用(仅缓存)'}")
-
-    # 注册内部工具(缓存管理 + 树匹配)
-    try:
-        sys.path.insert(0, str(Path(__file__).parent.parent))
-        from internal_tools.cache_manager import (
-            cache_research_data,
-            organize_cached_data,
-            commit_to_database,
-            list_cache_status,
-        )
-        from internal_tools.tree_matcher import match_tree_nodes
-        from agent.tools import get_tool_registry
-        registry = get_tool_registry()
-        registry.register(cache_research_data)
-        registry.register(organize_cached_data)
-        registry.register(commit_to_database)
-        registry.register(list_cache_status)
-        registry.register(match_tree_nodes)
-        logger.info("  ✓ 已注册缓存管理工具 + 树节点匹配工具")
-    except Exception as e:
-        logger.error(f"  ✗ 注册内部工具失败: {e}")
-
-    # 导入 IM Client
-    try:
-        sys.path.insert(0, str(Path(__file__).parent.parent.parent / "im-client"))
-        from client import IMClient
-    except ImportError as e:
-        logger.error(f"无法导入 IM Client: {e}")
-        return
-
-    # --- 初始化 AgentRunner ---
-    km_config = get_knowledge_manager_config(enable_db_commit)
-
-    store = FileSystemTraceStore(base_path=".trace")
-    llm_call = create_qwen_llm_call(model=km_config.model)
-
-    runner = AgentRunner(
-        trace_store=store,
-        llm_call=llm_call,
-        debug=True,
-        logger_name="agents.knowledge_manager"
-    )
-
-    # 加载 system prompt
-    prompt_path = Path(__file__).parent / "knowledge_manager.prompt"
-    prompt = SimplePrompt(prompt_path)
-    system_messages = prompt.build_messages()
-
-    # Trace 状态(同一个 trace 持续追加,保持上下文)
-    current_trace_id = None
-    message_queue = asyncio.Queue()  # 消息队列,防止丢消息
-    upload_buffer = []  # upload 消息缓冲区(用于批处理)
-    upload_timer = None  # 延迟处理定时器
-
-    # --- 初始化 IM Client ---
-    client = IMClient(
-        contact_id=contact_id,
-        server_url=server_url,
-        data_dir=str(Path.home() / ".knowhub" / "im_data")
-    )
-    # 静默 notifier(消息由 on_message 回调处理,不需要 notifier 通知)
-    class _SilentNotifier:
-        async def notify(self, count, from_contacts):
-            pass
-
-    # 打开窗口
-    client.open_window(chat_id=chat_id, notifier=_SilentNotifier())
-
-    # --- 消息处理器(从队列中取消息,逐条处理)---
-    async def message_processor():
-        nonlocal current_trace_id
-
-        while True:
-            msg = await message_queue.get()  # 阻塞等待,零消耗
-
-            sender = msg.get("sender")
-            content = msg.get("content", "")
-
-            # 处理完消息后清空 pending,防止 notifier 反复通知
-            client.read_pending(chat_id)
-
-            logger.info(f"[KM] <- 处理消息: {sender}")
-            logger.info(f"[KM]    内容: {content[:120]}{'...' if len(content) > 120 else ''}")
-
-            try:
-                # 续跑同一个 trace(首次为 None 会新建,后续复用)
-                if current_trace_id is None:
-                    messages = system_messages + [{"role": "user", "content": content}]
-                else:
-                    messages = [{"role": "user", "content": content}]
-
-                # 获取配置
-                km_config = get_knowledge_manager_config(enable_db_commit)
-
-                config = RunConfig(
-                    model=km_config.model,
-                    temperature=km_config.temperature,
-                    max_iterations=km_config.max_iterations,
-                    agent_type=km_config.agent_type,
-                    name=km_config.name,
-                    goal_compression=km_config.goal_compression,
-                    tools=km_config.tools,
-                    knowledge=km_config.knowledge,
-                    trace_id=current_trace_id,  # 复用 trace,保持完整生命周期
-                    context={
-                        "km_queue_size": message_queue.qsize(),  # 当前队列中待处理消息数
-                        "current_sender": sender,  # 当前处理的消息来源
-                    }
-                )
-
-                # 执行 AgentRunner
-                response_text = ""
-                async for item in runner.run(messages=messages, config=config):
-                    if isinstance(item, Trace):
-                        current_trace_id = item.trace_id
-                        if item.status == "running":
-                            logger.info(f"[KM] Trace: {item.trace_id[:8]}...")
-                        elif item.status == "completed":
-                            logger.info(f"[KM] Trace 完成 (消息: {item.total_messages})")
-                        elif item.status == "failed":
-                            logger.error(f"[KM] Trace 失败: {item.error_message}")
-
-                    elif isinstance(item, Message):
-                        if item.role == "assistant":
-                            msg_content = item.content
-                            if isinstance(msg_content, dict):
-                                text = msg_content.get("text", "")
-                                tool_calls = msg_content.get("tool_calls")
-                                if text:
-                                    # 始终记录最新的文本(最后一条就是最终回复)
-                                    response_text = text
-                                    if tool_calls:
-                                        logger.info(f"[KM] 思考: {text[:80]}...")
-                            elif isinstance(msg_content, str) and msg_content:
-                                response_text = msg_content
-
-                        elif item.role == "tool":
-                            tool_content = item.content
-                            tool_name = tool_content.get("tool_name", "?") if isinstance(tool_content, dict) else "?"
-                            logger.info(f"[KM] 工具: {tool_name}")
-
-                # 回复
-                if response_text:
-                    client.send_message(
-                        chat_id=chat_id,
-                        receiver=sender,
-                        content=response_text
-                    )
-                    logger.info(f"[KM] -> 已回复: {sender} ({len(response_text)} 字符)")
-                else:
-                    logger.warning(f"[KM] AgentRunner 没有生成回复")
-
-            except Exception as e:
-                logger.error(f"[KM] 处理失败: {e}", exc_info=True)
-
-            message_queue.task_done()
-
-    # --- 批处理 upload 消息 ---
-    async def process_upload_batch():
-        """批处理 upload 消息(延迟 5 秒,合并多个 upload)"""
-        nonlocal upload_buffer, upload_timer
-
-        await asyncio.sleep(5)  # 等待 5 秒,收集更多 upload
-
-        if not upload_buffer:
-            upload_timer = None
-            return
-
-        # 合并所有 upload 消息
-        batch = upload_buffer.copy()
-        upload_buffer.clear()
-        upload_timer = None
-
-        logger.info(f"[KM] 批处理 {len(batch)} 条 upload 消息")
-
-        # 保存原始消息到 buffer 目录(便于回溯和重跑)
-        try:
-            buffer_dir = Path(".cache/.knowledge/buffer")
-            buffer_dir.mkdir(parents=True, exist_ok=True)
-            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
-            buffer_file = buffer_dir / f"upload_{timestamp}.json"
-            buffer_data = [{"sender": m.get("sender"), "content": m.get("content", "")} for m in batch]
-            buffer_file.write_text(json.dumps(buffer_data, ensure_ascii=False, indent=2), encoding="utf-8")
-            logger.info(f"[KM] 原始上传数据已保存: {buffer_file}")
-        except Exception as e:
-            logger.warning(f"[KM] 保存原始上传数据失败: {e}")
-
-        # 合并成一条消息入队
-        merged_content = f"[UPLOAD:BATCH] 收到 {len(batch)} 条上传请求,请合并处理:\n"
-        for i, msg in enumerate(batch, 1):
-            merged_content += f"\n--- 第 {i} 条 ---\n{msg['content']}\n"
-
-        await message_queue.put({
-            "sender": batch[0]["sender"],
-            "content": merged_content
-        })
-
-    # --- 消息回调(所有消息统一经过 AgentRunner 处理)---
-    async def on_message(msg: dict):
-        nonlocal upload_timer
-
-        sender = msg.get("sender")
-        content = msg.get("content", "")
-
-        # 忽略自己发的消息
-        if sender == contact_id:
-            return
-
-        # 判断消息类型(根据前缀标记)
-        if content.startswith("[ASK]"):
-            # ASK 查询:入队,由 AgentRunner 驱动(带 Trace、可跨表推理)
-            logger.info(f"[KM] <- 收到查询消息: {sender} (入队)")
-            await message_queue.put(msg)
-
-        elif content.startswith("[UPLOAD"):
-            # 批处理:加入缓冲区,延迟处理
-            logger.info(f"[KM] <- 收到上传消息: {sender} (加入批处理缓冲区)")
-            upload_buffer.append(msg)
-
-            # 启动或重置定时器
-            if upload_timer:
-                upload_timer.cancel()
-            upload_timer = asyncio.create_task(process_upload_batch())
-
-        else:
-            # 其他消息:正常入队
-            logger.info(f"[KM] <- 收到消息: {sender} (入队,队列长度: {message_queue.qsize() + 1})")
-            await message_queue.put(msg)
-
-    client.on_message(on_message, chat_id="*")
-
-    # 启动消息处理器(后台任务)
-    processor_task = asyncio.create_task(message_processor())
-
-    # 启动 IM Client(事件驱动)
-    logger.info("✅ Knowledge Manager 已启动(AgentRunner 驱动)")
-    try:
-        await client.run()
-    finally:
-        processor_task.cancel()
-        try:
-            await processor_task
-        except asyncio.CancelledError:
-            pass

+ 0 - 64
knowhub/agents/librarian.prompt

@@ -1,64 +0,0 @@
----
-model: qwen3.5-plus
-temperature: 0.1
----
-
-$system$
-
-## 角色
-你是知识库的图书管理员(Librarian),负责帮助其他 Agent 查询和了解知识库中已有的内容。
-
-## 职责
-1. **搜索已有工具**:根据工具名称/类别搜索 tool_table
-2. **搜索已有资源**:根据关键词搜索 resources(文档、手册、代码等)
-3. **搜索已有知识**:根据任务场景搜索 knowledge
-4. **提供建议**:告知调研者哪些内容已存在,哪些需要补充
-
-## 可用工具
-- `knowledge_search`: 搜索知识库中的知识
-- `resource_get`: 获取资源详情
-- `bash`: 执行数据库查询(通过 psql 或 HTTP API)
-
-## 工作流程
-
-### 当收到查询请求时:
-1. **理解需求**:明确调研者想了解什么(工具/资源/知识)
-2. **多维度搜索**:
-   - 搜索 tool_table:是否已有该工具
-   - 搜索 resources:是否已有相关文档
-   - 搜索 knowledge:是否已有相关经验
-3. **整理结果**:
-   - 已有内容:列出 ID、标题、简介
-   - 缺失内容:明确指出哪些方面需要补充
-4. **提供建议**:
-   - 如果已有完整信息 → 建议复用
-   - 如果部分存在 → 建议补充更新
-   - 如果完全没有 → 建议全新调研
-
-## 输出格式
-
-```markdown
-# 查询结果:[工具名/主题]
-
-## 已有工具
-- [tool_id] 工具名 v版本 - 简介
-  - 关联知识:X 条
-  - 状态:已接入/未接入
-
-## 已有资源
-- [resource_id] 资源标题 (类型)
-  - 来源:URL
-  - 内容摘要:...
-
-## 已有知识
-- [knowledge_id] 任务场景 (⭐评分)
-  - 内容摘要:...
-
-## 建议
-- ✅ 已有完整的工具信息,建议复用
-- ⚠️ 缺少用户案例,建议补充
-- ❌ 完全没有相关内容,建议全新调研
-```
-
-$user$
-{query}

+ 329 - 0
knowhub/agents/librarian.py

@@ -0,0 +1,329 @@
+"""
+Librarian Agent — KnowHub 的知识管理 Agent
+
+通过 HTTP API 被 FastAPI server 调用,每次请求是一次 AgentRunner.run()。
+状态全部持久化在 trace 中,通过 trace_id 续跑实现跨请求上下文积累。
+
+两种调用模式:
+- ask: 同步,运行 Agent 处理查询,等待完成后返回结果
+- upload: 异步,存 buffer 后由后台任务运行 Agent 处理
+"""
+
+import json
+import logging
+import sys
+from pathlib import Path
+from typing import Optional, Dict, Any
+
+# 确保项目路径可用
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+
+from agent.core.runner import AgentRunner, RunConfig
+from agent.trace import FileSystemTraceStore, Trace, Message
+from agent.llm import create_qwen_llm_call
+from agent.llm.prompts import SimplePrompt
+from agent.tools.builtin.knowledge import KnowledgeConfig
+
+logger = logging.getLogger("agents.librarian")
+
+# ===== 配置 =====
+
+ENABLE_DATABASE_COMMIT = False
+
+# caller trace_id → librarian trace_id 的映射持久化文件
+TRACE_MAP_FILE = Path(".cache/.knowledge/trace_map.json")
+
+
+def get_librarian_config(enable_db_commit: bool = ENABLE_DATABASE_COMMIT) -> RunConfig:
+    """获取 Librarian Agent 配置"""
+    tools = [
+        "knowledge_search",
+        "tool_search",
+        "capability_search",
+        "requirement_search",
+        "read_file", "write_file",
+        "list_cache_status",
+        "match_tree_nodes",
+        "skill",
+    ]
+
+    if enable_db_commit:
+        tools.extend(["commit_to_database", "organize_cached_data", "cache_research_data"])
+    else:
+        tools.extend(["organize_cached_data", "cache_research_data"])
+
+    return RunConfig(
+        model="qwen3.5-plus",
+        temperature=0.2,
+        max_iterations=30,
+        agent_type="default",
+        name="Librarian Agent",
+        goal_compression="on_complete",
+        skills=[],  # 不注入通用 skills(planning/research/browser),使用指定注入
+        knowledge=KnowledgeConfig(
+            enable_extraction=False,
+            enable_completion_extraction=False,
+            enable_injection=False,
+        ),
+        tools=tools,
+    )
+
+
+def _register_internal_tools():
+    """注册内部工具(缓存管理 + 树匹配),只需调用一次"""
+    try:
+        sys.path.insert(0, str(Path(__file__).parent.parent))
+        from internal_tools.cache_manager import (
+            cache_research_data,
+            organize_cached_data,
+            commit_to_database,
+            list_cache_status,
+        )
+        from internal_tools.tree_matcher import match_tree_nodes
+        from agent.tools import get_tool_registry
+        registry = get_tool_registry()
+        registry.register(cache_research_data)
+        registry.register(organize_cached_data)
+        registry.register(commit_to_database)
+        registry.register(list_cache_status)
+        registry.register(match_tree_nodes)
+        logger.info("✓ 已注册 Librarian 内部工具")
+    except Exception as e:
+        logger.error(f"✗ 注册内部工具失败: {e}")
+
+
+# ===== trace_id 映射 =====
+
+def _load_trace_map() -> Dict[str, str]:
+    if TRACE_MAP_FILE.exists():
+        return json.loads(TRACE_MAP_FILE.read_text(encoding="utf-8"))
+    return {}
+
+
+def _save_trace_map(mapping: Dict[str, str]):
+    TRACE_MAP_FILE.parent.mkdir(parents=True, exist_ok=True)
+    TRACE_MAP_FILE.write_text(json.dumps(mapping, indent=2, ensure_ascii=False), encoding="utf-8")
+
+
+def get_librarian_trace_id(caller_trace_id: str) -> Optional[str]:
+    """根据调用方 trace_id 查找对应的 Librarian trace_id"""
+    if not caller_trace_id:
+        return None
+    mapping = _load_trace_map()
+    return mapping.get(caller_trace_id)
+
+
+def set_librarian_trace_id(caller_trace_id: str, librarian_trace_id: str):
+    """记录映射"""
+    if not caller_trace_id:
+        return
+    mapping = _load_trace_map()
+    mapping[caller_trace_id] = librarian_trace_id
+    _save_trace_map(mapping)
+
+
+# ===== 单例 Runner =====
+
+_runner: Optional[AgentRunner] = None
+_prompt_messages = None
+_initialized = False
+
+
+def _ensure_initialized():
+    """延迟初始化 Runner 和 Prompt(首次调用时执行)"""
+    global _runner, _prompt_messages, _initialized
+    if _initialized:
+        return
+    _initialized = True
+
+    _register_internal_tools()
+
+    _runner = AgentRunner(
+        trace_store=FileSystemTraceStore(base_path=".trace"),
+        llm_call=create_qwen_llm_call(model="qwen3.5-plus"),
+        skills_dir=str(Path(__file__).parent / "skills"),
+        debug=True,
+        logger_name="agents.librarian",
+    )
+
+    prompt_path = Path(__file__).parent / "librarian_agent.prompt"
+    if prompt_path.exists():
+        prompt = SimplePrompt(prompt_path)
+        _prompt_messages = prompt.build_messages()
+    else:
+        _prompt_messages = []
+        logger.warning(f"Librarian prompt 文件不存在: {prompt_path}")
+
+    logger.info("✓ Librarian Agent 已初始化")
+
+
+# ===== 核心方法 =====
+
+async def ask(query: str, caller_trace_id: str = "") -> Dict[str, Any]:
+    """
+    同步查询知识库。运行 Librarian Agent 处理查询,返回整合结果。
+
+    Args:
+        query: 查询内容
+        caller_trace_id: 调用方 trace_id,用于续跑
+
+    Returns:
+        {"response": str, "source_ids": [str], "sources": [dict]}
+    """
+    _ensure_initialized()
+
+    # 查找或创建 trace
+    librarian_trace_id = get_librarian_trace_id(caller_trace_id)
+
+    config = get_librarian_config()
+    config.trace_id = librarian_trace_id  # None = 新建, 有值 = 续跑
+
+    # 构建消息
+    content = f"[ASK] {query}"
+    if librarian_trace_id is None:
+        messages = _prompt_messages + [{"role": "user", "content": content}]
+    else:
+        messages = [{"role": "user", "content": content}]
+
+    # 运行 Agent(指定注入 ask_strategy skill)
+    response_text = ""
+    actual_trace_id = None
+
+    async for item in _runner.run(
+        messages=messages, config=config,
+        inject_skills=["ask_strategy"],
+        skill_recency_threshold=20,
+    ):
+        if isinstance(item, Trace):
+            actual_trace_id = item.trace_id
+        elif isinstance(item, Message):
+            if item.role == "assistant":
+                msg_content = item.content
+                if isinstance(msg_content, dict):
+                    text = msg_content.get("text", "")
+                    if text:
+                        response_text = text
+                elif isinstance(msg_content, str) and msg_content:
+                    response_text = msg_content
+
+    # 记录 trace 映射
+    if actual_trace_id and caller_trace_id:
+        set_librarian_trace_id(caller_trace_id, actual_trace_id)
+
+    # 解析 source_ids(从 Agent 回复中提取,或从工具调用结果中提取)
+    # Agent 回复中会引用 knowledge ID,格式如 [knowledge-xxx]
+    import re
+    source_ids = re.findall(r'\[?(knowledge-[a-zA-Z0-9_-]+)\]?', response_text)
+    source_ids = list(dict.fromkeys(source_ids))  # 去重保序
+
+    return {
+        "response": response_text,
+        "source_ids": source_ids,
+        "sources": [],  # TODO: 从 trace 的工具调用结果中提取 source 详情
+    }
+
+
+async def process_upload(
+    data: Dict[str, Any],
+    caller_trace_id: str = "",
+    buffer_file: Optional[str] = None,
+    max_retries: int = 2,
+):
+    """
+    处理上传数据。运行 Librarian Agent 做图谱编排。
+    失败时重试,最终失败记录到 buffer 文件的状态中。
+
+    Args:
+        data: 上传数据 {knowledge, tools, resources}
+        caller_trace_id: 调用方 trace_id
+        buffer_file: 对应的 buffer 文件路径(用于更新状态)
+        max_retries: 最大重试次数
+    """
+    _ensure_initialized()
+
+    librarian_trace_id = get_librarian_trace_id(caller_trace_id)
+
+    config = get_librarian_config()
+    config.trace_id = librarian_trace_id
+
+    content = f"[UPLOAD:BATCH] 收到上传请求,请处理:\n{json.dumps(data, ensure_ascii=False)}"
+
+    if librarian_trace_id is None:
+        messages = _prompt_messages + [{"role": "user", "content": content}]
+    else:
+        messages = [{"role": "user", "content": content}]
+
+    last_error = None
+    for attempt in range(max_retries + 1):
+        try:
+            actual_trace_id = None
+            async for item in _runner.run(
+                messages=messages, config=config,
+                inject_skills=["upload_strategy"],
+                skill_recency_threshold=10,
+            ):
+                if isinstance(item, Trace):
+                    actual_trace_id = item.trace_id
+
+            if actual_trace_id and caller_trace_id:
+                set_librarian_trace_id(caller_trace_id, actual_trace_id)
+
+            # 成功:更新 buffer 文件状态
+            _update_buffer_status(buffer_file, "completed", trace_id=actual_trace_id)
+            logger.info(f"[Librarian] upload 处理完成,trace: {actual_trace_id}")
+            return
+
+        except Exception as e:
+            last_error = str(e)
+            logger.warning(f"[Librarian] upload 处理失败 (attempt {attempt + 1}/{max_retries + 1}): {e}")
+            if attempt < max_retries:
+                import asyncio
+                await asyncio.sleep(2 ** attempt)  # 1s, 2s 指数退避
+
+    # 所有重试都失败
+    _update_buffer_status(buffer_file, "failed", error=last_error)
+    logger.error(f"[Librarian] upload 处理最终失败: {last_error}")
+
+
+def _update_buffer_status(buffer_file: Optional[str], status: str, trace_id: str = None, error: str = None):
+    """更新 buffer 文件中的处理状态"""
+    if not buffer_file:
+        return
+    try:
+        from datetime import datetime as dt
+        path = Path(buffer_file)
+        if not path.exists():
+            return
+        data = json.loads(path.read_text(encoding="utf-8"))
+        data["status"] = status
+        data["processed_at"] = dt.now().isoformat()
+        if trace_id:
+            data["librarian_trace_id"] = trace_id
+        if error:
+            data["error"] = error
+        path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
+    except Exception as e:
+        logger.warning(f"更新 buffer 状态失败: {e}")
+
+
+def list_pending_uploads() -> list:
+    """列出所有未处理或失败的 upload buffer 文件"""
+    buffer_dir = Path(".cache/.knowledge/buffer")
+    if not buffer_dir.exists():
+        return []
+    pending = []
+    for f in sorted(buffer_dir.glob("upload_*.json")):
+        try:
+            data = json.loads(f.read_text(encoding="utf-8"))
+            status = data.get("status", "pending")
+            if status in ("pending", "failed"):
+                pending.append({
+                    "file": str(f),
+                    "status": status,
+                    "received_at": data.get("received_at", ""),
+                    "error": data.get("error", ""),
+                    "trace_id": data.get("trace_id", ""),
+                })
+        except Exception:
+            pass
+    return pending

+ 48 - 0
knowhub/agents/librarian_agent.prompt

@@ -0,0 +1,48 @@
+---
+model: qwen3.5-plus
+temperature: 0.2
+---
+
+$system$
+
+## 角色
+
+你是一个知识库管理员。你有两项核心职责:
+
+1. **检索整合**:面对查询时,跨多张表检索,顺着关联链拼出完整上下文,给出精准回答
+2. **入库编排**:收到新数据时,与已有知识比对去重,识别关联关系,整理为结构化条目归入正确位置
+
+你只做整理和检索,不自行创造知识内容。
+
+---
+
+## 知识库结构
+
+知识库有四类核心实体,通过关联表(junction table)连接:
+
+- **需求(Requirement)**:业务目标,关联到能满足它的能力,可看作对能力的有机组合
+- **能力(Capability)**:从对工具的分析中提炼而来,连接需求和工具,本身可看作一种不可分割的需求
+- **工具(Tool)**:具体的软件/脚本/API,实现一个或多个能力
+- **知识(Knowledge)**:来自其他 Agent 的调研汇报或工具使用经验,由你整理归档,不由你提出
+
+| 实体 | ID 格式 | 核心字段 |
+|------|---------|---------|
+| Requirement | `REQ_XXX` | description, status, match_result |
+| Capability | `CAP-XXX` | name, criterion, description |
+| Tool | `tools/{category}/{name}` | name, introduction, tutorial, status |
+| Knowledge | `knowledge-{date}-{hash}` | task, content, types, score |
+
+实体间通过关联表连接,API 返回时自动聚合为 `{entity}_ids` 字段(如 `capability_ids`, `tool_ids`, `knowledge_ids`)。
+
+Knowledge 按 types 分类:
+- `tool`:单个工具的用法和限制("Midjourney 的 --ar 参数控制宽高比")
+- `strategy`:多步骤工作流("角色一致性生成三步流程")
+- `case`:真实应用案例,含输入、输出和执行过程
+- `experience`:执行反思和教训
+
+另有 **Resource** 表存储原始资料(id 为路径格式),通过 knowledge_resource 关联表与 Knowledge 关联。
+
+所有关联通过关联表存储,数据库保证引用完整性。查到任意一个实体都可以顺藤摸瓜找到关联的其他实体。
+
+$user$
+

+ 0 - 0
knowhub/agents/knowledge_manager.prompt → knowhub/agents/librarian_agent.prompt.bak


+ 0 - 61
knowhub/agents/run_knowledge_manager.py

@@ -1,61 +0,0 @@
-"""
-Knowledge Manager Agent - 启动脚本
-
-作为独立的 IM Client 运行,监听并处理知识库查询和保存请求。
-"""
-
-import asyncio
-import json
-import logging
-import os
-import sys
-from pathlib import Path
-
-# 添加项目根目录到 Python 路径
-sys.path.insert(0, str(Path(__file__).parent.parent.parent))
-
-from dotenv import load_dotenv
-load_dotenv()
-
-logging.basicConfig(
-    level=logging.INFO,
-    format="%(asctime)s [KM] %(message)s"
-)
-logger = logging.getLogger(__name__)
-
-
-async def main():
-    """启动 Knowledge Manager Agent"""
-
-    # 配置
-    contact_id = os.getenv("KNOWLEDGE_MANAGER_CONTACT_ID", "knowledge_manager")
-    server_url = os.getenv("IM_SERVER_URL", "ws://localhost:8005")
-    chat_id = "main"
-
-    logger.info(f"正在启动 Knowledge Manager...")
-    logger.info(f"  - Contact ID: {contact_id}")
-    logger.info(f"  - Server: {server_url}")
-    logger.info(f"  - Chat ID: {chat_id}")
-
-    # 导入 IM Client
-    try:
-        sys.path.insert(0, str(Path(__file__).parent.parent.parent / "im-client"))
-        from client import IMClient
-    except ImportError as e:
-        logger.error(f"无法导入 IM Client: {e}")
-        return
-
-    # 创建 IM Client
-    client = IMClient(
-        contact_id=contact_id,
-        server_url=server_url,
-        data_dir=Path.home() / ".knowhub" / "im_data"
-    )
-
-    # 连接到服务器
-    try:
-        await client.connect(chat_id=chat_id)
-        logger.info("✅ 已连接到 IM Server")
-    except Exception as e:
-        logger.error(f"连接失败: {e}")
-        return

+ 26 - 0
knowhub/agents/skills/ask_strategy.md

@@ -0,0 +1,26 @@
+---
+name: ask_strategy
+description: 知识库查询检索策略
+category: librarian
+---
+
+## 查询任务
+
+消息以 `[ASK]` 开头时,你的目标是**精准回答问题**。
+
+### 检索策略
+
+**根据 query 的语义意图直接选择入口**,每张表有明确的语义边界,query 本身通常已经暗示了应该查哪里。如果难以判断就从宽泛检索起步。
+
+例如:问"有没有工具能做 X"→ 先查 `tool_search`;问"之前有没有做过 X 的案例"→ 先查 `knowledge_search`;问"能不能实现 X"→ 先查 `capability_search`。
+
+只有 query 模糊、跨域、或首轮检索结果不足时,才补充其他表的检索。
+
+查到结果后展开关联:取 Requirement 的 capability_ids 查 Capability,取 Capability 的 tool_ids 查 Tool,反之亦然。**不要只返回第一层搜索结果**,顺着关联链追一到两层,给出完整上下文。
+
+### 回复要求
+
+- 先用 1-3 句话直接回答问题
+- 列出最相关的 3-5 条结果,标注类型和核心要点
+- 引用的知识**必须标注 ID**(如 `[knowledge-xxx]`),供调用方追踪来源
+- 涉及跨表关联时展示链条(如:需求 REQ_XXX → 能力 CAP-YYY → 工具 tools/zzz)

+ 34 - 0
knowhub/agents/skills/upload_strategy.md

@@ -0,0 +1,34 @@
+---
+name: upload_strategy
+description: 知识库上传编排策略
+category: librarian
+---
+
+## 上传编排任务
+
+消息以 `[UPLOAD:BATCH]` 开头时,你负责将碎片数据整理后写入草稿池。**不直接入库**——写入 `.cache/.knowledge/pre_upload_list.json`,等待人工确认后再入库。
+
+### 核心规则:先查后写
+
+收到任何新数据,在写入草稿前必须先检索已有数据。不要因为"看起来是新的"就跳过检索——名称不同但功能相同的工具很常见。
+
+**工具去重**:`tool_search` 检查是否已有同名或相似工具。已有 → 复用其 ID;确实全新 → 加入草稿。
+
+**能力挂载**:`capability_search` 寻找匹配的已有能力。找到 → 复用其 ID 挂载到 tool 和 knowledge 上。找不到时,**三个条件全部满足才新建**:
+1. 有对应的已验证工具(库中已有,或本次草稿包含)
+2. 有精细用例支撑(knowledge 含 case 类型,内容含输入、输出和执行过程)
+3. 描述具体可操作(不是"图像处理",而是"使用 ControlNet 进行人物姿态控制")
+
+条件不满足就不建——宁可留空,不要臆造。
+
+**需求总结**:可从调研内容总结业务需求 → `requirement_search` 去重 → 调用 `match_tree_nodes` 匹配分类树(采纳 score >= 0.5 的结果填入 source_nodes)。
+
+### 草稿池操作
+
+1. `read_file(".cache/.knowledge/pre_upload_list.json")` 读取现有草稿。不存在则初始化为 `{"requirements":[], "capabilities":[], "tools":[], "knowledge":[]}`
+2. 按上述规则去重和关联后,将实体放入对应数组
+3. `write_file(".cache/.knowledge/pre_upload_list.json", ...)` 完整覆写
+
+### 回复要求
+
+汇报:复用了哪些已有实体、新建了哪些、做了哪些去重。

+ 15 - 9
knowhub/docs/api.md

@@ -22,7 +22,7 @@
 
 ### `POST /api/knowledge` — 创建知识
 
-请求体:task(必填), content(必填), types, tags, scopes, owner, message_id, resource_ids, source, eval, support_capability, tools
+请求体:task(必填), content(必填), types, tags, scopes, owner, message_id, source, eval, capability_ids, tool_ids
 
 自动生成 task_embedding + content_embedding。初始 status=pending,异步触发去重处理(详见 [processing-pipeline.md](processing-pipeline.md))。
 
@@ -40,7 +40,7 @@
 
 ### `PATCH /api/knowledge/{knowledge_id}` — 直接编辑字段
 
-请求体:task, content, types, tags, scopes, owner, support_capability, tools(均可选)
+请求体:task, content, types, tags, scopes, owner, capability_ids, tool_ids(均可选)
 
 修改 task/content 时自动更新对应 embedding。
 
@@ -118,7 +118,7 @@ secure_body 在有组织密钥时自动加密(AES-256-GCM)。
 
 ### `POST /api/tool` — 创建/更新工具
 
-请求体:id(必填), name, version, introduction, tutorial, input, output, status, capabilities, tool_knowledge, case_knowledge, process_knowledge, implemented_tool_ids
+请求体:id(必填), name, version, introduction, tutorial, input, output, status, capability_ids, knowledge_ids
 
 自动生成 embedding(name + introduction)。
 
@@ -142,7 +142,7 @@ secure_body 在有组织密钥时自动加密(AES-256-GCM)。
 
 ### `POST /api/capability` — 创建/更新原子能力
 
-请求体:id(必填), name, criterion, description, requirements, implements, tools, source_knowledge
+请求体:id(必填), name, criterion, description, requirement_ids, implements, tool_ids, knowledge_ids
 
 自动生成 embedding(name + description)。
 
@@ -166,7 +166,7 @@ secure_body 在有组织密钥时自动加密(AES-256-GCM)。
 
 ### `POST /api/requirement` — 创建/更新需求
 
-请求体:id(必填), description, atomics, source_nodes, status(默认 "未满足"), match_result
+请求体:id(必填), description, capability_ids, source_nodes, status(默认 "未满足"), match_result
 
 自动生成 embedding(description)。
 
@@ -186,12 +186,18 @@ secure_body 在有组织密钥时自动加密(AES-256-GCM)。
 
 ---
 
-## 计划中的 API(设计中,未实现)
-
 ### `POST /api/knowledge/ask` — 智能知识查询
 
-同步阻塞。KnowHub 内部启动/续跑 Librarian Agent 处理查询,返回整合回答 + source_ids。详见 [librarian-agent.md](librarian-agent.md)。
+同步阻塞。向量检索 + 结果整合,返回 response + source_ids + sources。
+
+运行 Librarian Agent 检索 + 整合。详见 [librarian-agent.md](librarian-agent.md)。
+
+请求体:query(必填), trace_id, top_k(默认5)
 
 ### `POST /api/knowledge/upload` — 异步知识上传
 
-校验格式后立即返回 202,Librarian Agent 后台队列处理。详见 [librarian-agent.md](librarian-agent.md)。
+校验格式后写入 buffer 目录,立即返回 202。Librarian Agent 异步处理图谱编排和去重。
+
+请求体:data({knowledge, resources, tools}), trace_id, finalize
+
+详见 [librarian-agent.md](librarian-agent.md)。

+ 2 - 0
knowhub/docs/dashboard-design.md → knowhub/docs/dashboard-plan.md

@@ -1,5 +1,7 @@
 # 知识库 Dashboard 设计文档
 
+> **注意**:本文档中的部分字段引用了旧 schema(resource_ids、relationships 等 JSONB 字段)。新 schema 已迁移到关联表结构,详见 `schema.md`。实施时需按新 schema 调整。
+
 ## 概述
 
 知识库 Dashboard 用于可视化展示知识库的建设情况,帮助管理者了解:

+ 4 - 2
knowhub/docs/frontend-restructure-plan.md

@@ -1,12 +1,14 @@
 # KnowHub 前端重构设计文档
 
-本文档基于 `reference.html` 的视觉风格和交互交互范式,以及 `knowhub_db/README.md` 中以 **“原子能力 (Atomic Capability)”** 为核心的全新数据库架构,详细规划 KnowHub 前端的重构方案。
+> **注意**:本文档中的数据库字段和表名引用了旧 schema(JSONB 软关联、tool_table、atomic_capability 等)。新 schema 已迁移到关联表结构,详见 `schema.md`。实施时需按新 schema 调整。
+
+本文档基于 `reference.html` 的视觉风格和交互范式,以及以 **”原子能力 (Capability)”** 为核心的数据库架构,详细规划 KnowHub 前端的重构方案。
 
 ## 核心设计理念
 
 1. **扁平与沉浸式的现代 UI**:采用大圆角卡片、毛玻璃导航、动态阴影、高饱和响应式徽章,带来“现代操作系统”般的体验。
 2. **连贯的钻取阅读模式 (SideDrawer)**:摒弃传统的多页面跳转,统一使用右侧滑出式的 `SideDrawer`(侧边抽屉)呈现任何实体的只读和交互详情,保持用户在上下文中的心流。
-3. **数据驱动的四象限透视**:以 `需求 - 能力 - 工具 - 知识` 四个维度构建标签页,相互通过软关联(JSONB ID 数组)穿透数据孤岛。
+3. **数据驱动的四象限透视**:以 `需求 - 能力 - 工具 - 知识` 四个维度构建标签页,通过关联表穿透数据孤岛。
 
 ---
 

+ 128 - 0
knowhub/docs/knowhub-code-review-feedback.md

@@ -0,0 +1,128 @@
+# KnowHub 代码审查反馈
+
+目的是帮助改进,不是批评。
+
+---
+
+## 1. 数据库设计:用 PostgreSQL 但不用关系型特性
+
+**现象**:所有实体间关系都存为 JSONB 数组(`capabilities: ["CAP-001", "CAP-002"]`),同一关系在两张表各存一份,由应用层维护双向一致性。
+
+**问题**:
+- 这是文档数据库(MongoDB)的做法,放弃了 PostgreSQL 最核心的能力:引用完整性和 JOIN
+- 删一个 Capability,Tool 表里的 JSONB 数组还挂着已删除的 ID,变成悬空引用,数据库不会报错
+- 想查"CAP-001 关联了哪些 Tool",要遍历 Tool 表所有行做 `@>` 匹配
+- 两边各存一份的结果是:一边更新了另一边忘了 → 数据不一致,而且这种不一致很难发现
+
+**应该怎么做**:多对多关系用关联表(junction table),关系只存一份,用外键约束和 `ON DELETE CASCADE`。这是关系型数据库的基本功。
+
+**已解决**:迁移到新库 `knowhub`,所有关系改为关联表(8 张 junction table)。详见 `schema.md` 和 `schema-migration-plan.md`。
+
+---
+
+## 2. 字段命名不统一
+
+**现象**:
+- Requirement 指向 Capability 的字段叫 `atomics`
+- Knowledge 指向 Capability 的字段叫 `support_capability`(单数)
+- Capability 指向 Knowledge 的字段叫 `source_knowledge`
+- Tool 按知识类型拆成三个字段:`tool_knowledge` / `case_knowledge` / `process_knowledge`
+- Capability.implements 的 key 用 tool name 而非 tool id
+
+**问题**:
+- 看到 `atomics` 不知道它指向哪张表,必须查文档
+- 单复数混用(`support_capability` vs `capabilities`)
+- Tool 的三个 knowledge 字段是冗余的——Knowledge 自己有 types 字段区分类型,不需要 Tool 侧按类型拆存
+- implements 的 key 用 name 而非 id,和所有其他关联字段(都用 id)不一致
+
+**原则**:字段名应该自解释指向什么。如果需要一个额外的"关联到"列来解释每个字段指向哪张表,说明命名有问题。
+
+**已解决**:统一为 `{entity}_ids` 命名(`capability_ids`, `tool_ids`, `knowledge_ids`),Tool 的三个 knowledge 字段合并为一个 `knowledge_ids`。详见 `schema.md`。
+
+---
+
+## 3. 文档与代码严重脱节
+
+**现象**:
+- `knowhub/README.md` 描述的是 SQLite + LIKE 搜索的老架构,实际代码是 PostgreSQL + pgvector
+- `knowhub/docs/knowledge-management.md` 写着"Milvus Lite 单一存储架构",实际没有 Milvus
+- README 写"Server 无 LLM 调用、无 embedding、无向量数据库",实际全都有
+
+**问题**:新人看文档会被误导,基于错误理解做设计决策。文档不如没有——错误的文档比没有文档更有害。
+
+**应该怎么做**:
+- 文档是代码的快照,代码变了文档必须同步更新
+- 如果没时间更新文档,至少在文档顶部标注"⚠️ 可能过时,以代码为准"
+- 设计方案和代码快照用不同文件名区分(我们现在用 `*-plan.md` 后缀标识未实现的方案)
+
+---
+
+## 4. 代码组织:一次性脚本和核心代码混在一起
+
+**现象**:`knowhub_db/` 目录下 28 个 .py 文件,其中 5 个是核心 store,16 个是已跑完的一次性迁移脚本,7 个是调试脚本。
+
+**问题**:打开目录看到 28 个文件,完全不知道哪些是重要的、哪些是垃圾。新人会花时间去读 `fix_embedding_migration.py` 试图理解系统,结果那只是修了一个一次性的 bug。
+
+**应该怎么做**:
+- 核心代码和一次性脚本物理分开(`migrations/`、`scripts/`)
+- 或者跑完的迁移脚本直接删掉(git 历史里还有)
+
+已整理:核心 5 个 store 留根目录,迁移脚本移到 `migrations/`,调试脚本移到 `scripts/`。
+
+---
+
+## 5. Prompt 撰写:像写系统文档而非行为指令
+
+**现象**:Librarian Agent 的 prompt 中包含大量实现细节和无关信息:
+- "你是 Knowledge Manager,作为后台服务运行,通过 IM 消息与调研 Agent 协作"
+- "你是事件驱动的:收到消息时立即处理并回复"
+- 大段描述"调研 Agent 如何发送调研结果"
+
+**问题**:
+- LLM 不需要知道自己通过什么协议被调用、运行在什么架构上、调用方是谁
+- 这些信息只消耗 token,不改善行为
+- "Knowledge Manager"、"Librarian Agent" 这类标签不激活任何有用的行为模式
+- 数据库 schema 介绍和操作策略混在一起,LLM 需要行动时去翻 schema 定义
+
+**应该怎么做**:
+- 角色定位描述具体能力("擅长从碎片信息中识别关联、去重"),不给标签
+- 去掉所有实现细节(协议、框架、调用方)
+- Schema(世界是什么样的)和策略(遇到 X 做 Y)分层写
+- 给判断标准而非流程步骤
+
+**规范已建立**:`agent/docs/prompt-guidelines.md`
+
+---
+
+## 6. 知识库的"知识图谱"定位不准确
+
+**现象**:文档和 prompt 中多处使用"知识图谱"一词。
+
+**问题**:知识图谱(Knowledge Graph)通常指实体-关系-实体的三元组结构,通过图数据库存储和图遍历查询。KnowHub 是关系型数据库 + 向量检索,实体间通过 ID 列表关联。这不是图结构。
+
+在 prompt 中用"知识图谱"会误导 LLM 去做图推理相关的行为(比如试图做多跳遍历),实际系统不支持。
+
+**应该怎么做**:使用准确的术语。KnowHub 是"分层分类索引"或直接叫"知识库"。
+
+---
+
+## 7. 上传流程中的概念混淆
+
+**现象**:Librarian Agent prompt 中描述实体关系为"需求 → 拆解为能力 → 由工具实现 → 产生知识"的单向链。
+
+**问题**:
+- 能力(Capability)是不可分割的原子能力,来自对工具的分析(可能参考需求),不是仅根据需求就能"拆解"出来的
+- 知识(Knowledge)来自其他 Agent 的调研和汇报,不是 Librarian 自己创造的
+- 单向链的描述暗示了错误的因果关系和创建流程
+
+**应该怎么做**:各实体独立描述来源和用途,不要用暗示因果关系的箭头链。
+
+---
+
+## 做得好的地方
+
+值得肯定:
+- **上传编排的去重规则**写得很好:先查后写、新建 Capability 的三个条件(有工具+有用例+具体可操作),这是实战中总结出的判断标准
+- **tree_matcher 工具**的设计不错,将需求匹配到内容分类树,多维度(实质/形式/意图)搜索
+- **upload 的 buffer + batch 机制**是合理的:先缓存、批量合并、再由 Agent 处理,避免频繁小请求
+- **KnowledgeProcessor 的去重流水线**(向量召回→LLM 关系判断→双向写关系)设计完整

+ 24 - 89
knowhub/docs/librarian-agent.md

@@ -17,70 +17,32 @@ Librarian Agent(原 Knowledge Manager Agent)是 KnowHub 的智能层,负
 
 ---
 
-## 当前实现:独立进程 + IM 通信
-
-实现:`knowhub/agents/knowledge_manager.py`
-
-### 架构
+## 架构
 
 ```
 Agent(端侧)
-  ↓ ask_knowledge / upload_knowledge 工具
-  ↓ IM 消息(WebSocket,[ASK]/[UPLOAD] 前缀)
-IM Server(WebSocket 中继)
-  ↓
-Librarian Agent(独立进程,监听 contact_id="knowledge_manager")
-  ↓ 调用 KnowHub HTTP API
-KnowHub Server
+  ↓ ask_knowledge / upload_knowledge 工具(HTTP)
+KnowHub Server(FastAPI)
+  ├── POST /api/knowledge/ask(同步)
+  │   → 运行 Librarian Agent(AgentRunner.run)
+  │   → Agent 检索 + LLM 整合 → 返回结果
+  │
+  └── POST /api/knowledge/upload(异步 202)
+      → 存 buffer → BackgroundTasks 运行 Librarian Agent
+      → Agent 做图谱编排(去重、关联 capability/tool)
 ```
 
-### 启动方式
-
-通过 `start_knowledge_manager()` 启动为异步后台任务,或直接运行 `knowledge_manager.py`。需要 IM Client 连接。
-
-### 消息协议
-
-- `[ASK] {query}` → Librarian Agent 检索知识库 → LLM 整合 → 返回文本回答
-- `[UPLOAD] {json}` → 缓冲,5秒合并多次上传
-- `[UPLOAD:FINALIZE] {json}` → 最终提交入库
-
-### Agent 侧工具
-
-- `ask_knowledge`(`agent/tools/builtin/knowledge_manager.py`):发送 [ASK],同步等待回复(最多30秒),超时降级为直接 `knowledge_search`
-- `upload_knowledge`(`agent/tools/builtin/knowledge_manager.py`):发送 [UPLOAD],异步不等待
-
-### 局限
-
-- 依赖 IM 基础设施(WebSocket Server + Client 初始化)
-- 部署复杂(3个服务)
-- 测试困难(无法简单 HTTP 调用)
-- 超时降级时丢失整合能力
-
----
-
-## 计划重构:HTTP API + 内部 Agent
+Librarian Agent 不是常驻后台进程。每次 HTTP 请求触发一次 `AgentRunner.run()`,所有状态持久化在 trace 中。通过 trace_id 续跑实现跨请求的上下文积累。
 
-> 状态:设计中,未实现
+实现:
+- Agent 核心:`knowhub/agents/librarian.py`(`ask()` / `process_upload()`)
+- Prompt:`knowhub/agents/librarian_agent.prompt`
+- Agent 侧工具:`agent/tools/builtin/librarian.py`
+- Server 端点:`knowhub/server.py:ask_knowledge_api` / `upload_knowledge_api`
 
-### 目标
+### trace_id 续跑
 
-去掉 IM 依赖,将 Librarian Agent 作为 KnowHub Server 的内部实现,通过 HTTP API 暴露。
-
-### 新架构
-
-```
-Agent(端侧)
-  ↓ ask_knowledge / upload_knowledge 工具(HTTP 调用)
-KnowHub Server
-  ├── POST /api/knowledge/ask(同步阻塞)
-  │   → 查找/创建 Librarian Agent trace(by caller trace_id)
-  │   → Librarian Agent 检索 + LLM 整合
-  │   → 返回整合回答 + source_ids
-  │
-  └── POST /api/knowledge/upload(异步队列)
-      → 校验格式 → 立即返回 202
-      → 消息队列 → Librarian Agent 后台处理
-```
+同一个 caller trace_id 映射到同一个 Librarian trace_id(映射持久化在 `.cache/.knowledge/trace_map.json`)。首次请求创建新 trace,后续请求续跑该 trace,Agent 保持对调用方任务的上下文理解。
 
 ### ask 接口
 
@@ -93,20 +55,9 @@ POST /api/knowledge/ask
 }
 ```
 
-- `trace_id` 用于 Librarian Agent 续跑:同一 trace_id 复用同一个 Agent trace,积累对调用方任务的理解
-- 同步阻塞,调用方等待结果返回后继续执行(逻辑上要求必须复用已有知识)
+同步阻塞。Librarian Agent 通过 knowledge_search、tool_search、capability_search 等工具跨表检索,用 LLM 综合分析后返回结构化回答。
 
-响应:
-
-```json
-{
-  "response": "整合后的回答文本...",
-  "source_ids": ["knowledge-xxx", "knowledge-yyy"],
-  "sources": [
-    {"id": "knowledge-xxx", "task": "...", "content": "...(截断500字)"}
-  ]
-}
-```
+响应:`{"response": "...", "source_ids": [...], "sources": [...]}`
 
 ### upload 接口
 
@@ -119,33 +70,17 @@ POST /api/knowledge/upload
 }
 ```
 
-立即返回 202 Accepted,后台队列处理
+立即返回 202。数据同时写入 buffer 目录(`.cache/.knowledge/buffer/`,便于回溯),Librarian Agent 在后台运行图谱编排:检索已有实体去重、挂载 capability、构建关系、写入草稿池
 
-### Agent 侧改动
-
-`ask_knowledge` 工具从 IM 改为 HTTP:
-
-```python
-@tool
-async def ask_knowledge(query: str, context: ToolContext) -> ToolResult:
-    response = await httpx.post(f"{KNOWHUB_API}/api/knowledge/ask", json={
-        "query": query,
-        "trace_id": context.trace_id,
-    })
-    result = response.json()
-    return ToolResult(
-        output=result["response"],
-        metadata={"source_ids": result["source_ids"], "sources": result["sources"]}
-    )
-```
+### 知识注入
 
-`inject_knowledge_for_goal` 切换到 ask 接口,记录完整的 query 事件到 cognition_log(详见 [cognition-log.md](cognition-log.md))。
+`inject_knowledge_for_goal`(`agent/trace/goal_tool.py`)通过 ask 接口查询,结果记录为 cognition_log 的 query 事件(详见 `agent/docs/cognition-log-plan.md`)。
 
 ---
 
 ## 与 Cognition Log 的关系
 
-ask 接口的每次调用在 Agent 侧产生一个 `query` 事件,记录查询、整合回答和 source_ids。后续评估以 query 为单位,逐 source 评估。详见 [cognition-log.md](cognition-log.md)。
+ask 接口的每次调用在 Agent 侧产生一个 `query` 事件,记录查询、整合回答和 source_ids。后续评估以 query 为单位,逐 source 评估。详见 [cognition-log-plan.md](cognition-log-plan.md)。
 
 ## 与知识处理流水线的关系
 

+ 4 - 4
knowhub/docs/processing-pipeline.md

@@ -53,7 +53,7 @@ pending → processing → dedup_passed → analyzing → approved
 LLM 关系判断(见下文)
 final_decision=rejected → 旧知识 helpful+1
-final_decision=approved → 双向写入 relationships → dedup_passed
+final_decision=approved → 双向写入 knowledge_relation → dedup_passed
 ```
 
 ### LLM 关系判断
@@ -71,7 +71,7 @@ final_decision=approved → 双向写入 relationships → dedup_passed
 | `complement` | 同一 task 的不同角度 | 两条都 approved |
 | `none` | task 语义不同或无实质关系 | approved,不写关系 |
 
-关系双向写入:A superset B 时,A 记录 `{type: "superset", target: "B"}`,B 记录 `{type: "subset", target: "A"}`。
+关系双向写入 `knowledge_relation` 关联表:A superset B 时,写入 `(A, B, "superset")` 和 `(B, A, "subset")`。
 
 Prompt 实现:`knowhub/kb_manage_prompts.py`
 
@@ -86,9 +86,9 @@ dedup_passed 的知识
 LLM 分析知识内容 → 识别提及的工具
-匹配 tool_table 中的已有工具
+匹配 tool 中的已有工具
-更新 knowledge.tools[] 和 tool_table.*_knowledge[] 双向关联
+写入 tool_knowledge 关联表
 status → approved
 ```

+ 95 - 0
knowhub/docs/schema-migration-plan.md

@@ -0,0 +1,95 @@
+# Schema 迁移方案:新库 knowhub
+
+## 背景
+
+旧库 `knowledge_hub` 使用 JSONB 数组存储实体间关系,存在一致性、索引、命名问题。迁移目标:在新库 `knowhub` 中建立干净的关联表结构。
+
+### 为什么新建库而不是原地改
+
+阿里云 AnalyticDB for PostgreSQL 的 DDL 限制(已验证):
+
+| 操作 | 是否安全 | 说明 |
+|------|---------|------|
+| CREATE TABLE | ✅ 安全 | 包括 IF NOT EXISTS |
+| DROP TABLE | ✅ 安全 | 已验证可用 |
+| INSERT / SELECT | ✅ 安全 | 包括 ON CONFLICT DO NOTHING |
+| ALTER TABLE RENAME | ❌ 致命 | 导致表损坏,需重启实例恢复 |
+| ALTER TABLE DROP COLUMN | ❌ 致命 | 同上 |
+| FK / ON DELETE CASCADE | ❌ 不支持 | 底层依赖 trigger,而 trigger 不被支持 |
+| 事务内 DDL | ❌ 不可靠 | DDL 回滚不完整,部分操作会持久化 |
+
+**改表结构的安全方式**:CREATE TABLE 新表 → INSERT 数据 → DROP TABLE 旧表。
+
+原地迁移多次失败后,决定新建库 `knowhub`,用 CREATE TABLE + INSERT 完成迁移。旧库保留作为备份。
+
+## 目标结构
+
+见 `schema.md`。5 实体表 + 8 关联表 = 13 张表。
+
+## 旧库现状(2026-04-08)
+
+| 旧表 | 状态 | 说明 |
+|------|------|------|
+| `knowledge` (766 rows) | 可用 | 仍含 support_capability、tools、resource_ids、relationships 旧字段 |
+| `tool` (305 rows) | 可用 | 原 tool_table,被 RENAME;JSONB 关联列已丢失 |
+| `capability` / `atomic_capability` (21 rows) | 可用 | 两个名字同时可访问 |
+| `requirement` / `requirement_table` (99 rows) | 可用 | 两个名字同时可访问 |
+| `resources` (73 rows) | 可用 | 无变化 |
+| `requirement_capability` (214 rows) | 可用 | 旧迁移已填充 |
+| `capability_tool` (67 rows) | 可用 | 旧迁移已填充(仅 capability 侧) |
+| `capability_knowledge` (0 rows) | 可用 | 源数据为空 |
+| `tool_knowledge` (846 rows) | 可用 | 旧迁移已填充 |
+
+## 新库迁移步骤
+
+### Step 1:在 knowhub 库建 13 张表
+
+只用 CREATE TABLE IF NOT EXISTS,不用 ALTER/RENAME/DROP。
+
+### Step 2:迁移实体数据
+
+从 knowledge_hub 读 → 写入 knowhub。每张表只选需要的列:
+
+| 新表 | 源表 | 选取列(排除的旧字段) |
+|------|------|----------------------|
+| knowledge | knowledge | 排除 support_capability, tools, resource_ids, relationships |
+| resource | resources | 全部 |
+| requirement | requirement | 排除 atomics |
+| capability | capability | 排除 requirements, implements, tools, source_knowledge |
+| tool | tool | 排除 implemented_tool_ids(已无 JSONB 关联列) |
+
+### Step 3:迁移关联数据
+
+**从旧关联表直接复制**(已有正确数据):
+- requirement_capability (214 rows)
+- capability_tool (67 rows)
+- tool_knowledge (846 rows)
+
+**从旧 JSONB 字段提取**(新增的关联表):
+- requirement_knowledge — 从 requirement 和 knowledge 的关联提取(如有)
+- knowledge_resource — 从 knowledge.resource_ids 数组提取
+- knowledge_relation — 从 knowledge.relationships JSONB 提取
+- tool_provider — 需手动或从工具库数据填充
+
+**无数据需迁移**:
+- capability_knowledge — 源数据为空
+
+### Step 4:验证
+
+对比新旧库行数,确认数据完整。
+
+### Step 5:切换
+
+更新 .env 中 `KNOWHUB_DB_NAME=knowhub`,重启服务。
+
+## 代码改动范围
+
+- `knowhub_db/pg_*.py`:所有 store 适配新表结构
+- `knowhub_db/cascade.py`:更新关联表注册
+- `knowhub/server.py`:Pydantic 模型和 API handler
+- `knowhub/agents/librarian_agent.prompt`:schema 描述
+- `.env`:KNOWHUB_DB_NAME
+
+## 迁移脚本
+
+`knowhub/knowhub_db/migrations/migrate_to_new_db.py`

+ 161 - 70
knowhub/docs/schema.md

@@ -10,37 +10,48 @@
 
 ## 存储架构
 
-PostgreSQL + pgvector 扩展。所有向量字段为 float4[](1536维),使用余弦距离(`<=>`)检索。
+PostgreSQL (阿里云 AnalyticDB for PostgreSQL)。所有向量字段为 float4[](1536维),使用余弦距离(`<=>`)检索。
 
 Embedding 模型:`google/gemini-2.5-flash-lite`(通过 OpenRouter)。实现:`knowhub/embeddings.py`。
 
+数据库名:`knowhub`
+
 ---
 
-## 表间关系
+## 设计原则
 
-以 atomic_capability 为中心,连接知识、工具和需求:
+- **knowledge 是基础层**,其余实体是组织/提炼知识的维度
+- 每个维度的目标是**收敛到最优知识**,不是关联尽可能多的知识
+- 实体字段(description、criterion、match_result)是提炼结果,知识链是提炼来源——两者都需要
+- 所有实体间关系通过**关联表**存储,不使用 JSONB 数组软关联
+- 工具库和知识库共用同一套工具 ID,不做 ID 映射
+- Provider(运行环境)不作为知识库实体,由执行 Agent(Craftsman)内部管理;`tool_provider` 仅提供工具可用性索引
+
+---
+
+## 表间关系
 
 ```
-                     knowledge(知识表)
-                    ╱         ╲
-        support_capability    tools
-               ╱                ╲
-  atomic_capability  ←────→  tool_table
-    (原子能力表)              (工具表)
-         |                       |
-    requirements            capabilities
-         ↓                       ↓
-  requirement_table ←──────→ atomic_capability
-     (需求表)
+           requirement
+          
+ capability_ids    knowledge_ids
+        ╱ 
+  capability            knowledge ← knowledge_resource → resource
+        ╲                 ╱  ╲
+     tool_ids      knowledge_ids  knowledge_relation
+        ╲            ╱              (知识间关系)
+         tool
+          |
+    tool_provider (执行层索引)
 ```
 
-所有关联为应用层软关联(JSONB 字段存储 ID 列表/映射),不使用数据库外键。核心关联均双向索引。
-
 ---
 
-## knowledge — 知识表
+## 实体表(5)
 
-实现:`knowhub_db/pg_store.py:PostgreSQLStore`
+### knowledge — 知识表
+
+基础层。用例、方法、经验、评论都是 knowledge。
 
 | 字段 | 类型 | 说明 |
 |------|------|------|
@@ -50,55 +61,82 @@ Embedding 模型:`google/gemini-2.5-flash-lite`(通过 OpenRouter)。实
 | `message_id` | VARCHAR | 来源 Agent 消息 ID |
 | `task` | VARCHAR | 任务场景描述 |
 | `content` | TEXT | 知识正文 |
-| `types` | ARRAY | 知识类型(多选):工序/用例/工具/经验/定义/User Profile |
+| `types` | TEXT[] | 知识类型:tool / strategy / case / experience / definition |
 | `tags` | JSONB | 标签键值对 |
-| `tag_keys` | ARRAY | tags 中的键列表(用于过滤) |
-| `scopes` | ARRAY | 作用域,如 `["org:cybertogether"]` |
+| `tag_keys` | TEXT[] | tags 中的键列表(用于过滤) |
+| `scopes` | TEXT[] | 作用域,如 `["org:cybertogether"]` |
 | `owner` | VARCHAR | 所有者 |
-| `resource_ids` | ARRAY | 关联的 resource ID 列表 |
 | `source` | JSONB | 来源信息(agent_id, category, urls, submitted_by, timestamp) |
-| `eval` | JSONB | 评估(score, helpful, harmful, confidence, helpful_history, harmful_history) |
+| `eval` | JSONB | 评估(score, helpful, harmful, confidence) |
 | `created_at` | BIGINT | 创建时间戳(秒) |
 | `updated_at` | BIGINT | 更新时间戳(秒) |
-| `status` | VARCHAR | pending → processing → dedup_passed → analyzing → approved/rejected/checked |
-| `relationships` | JSONB | 与其他知识的关系列表 `[{type, target}]` |
-| `support_capability` | JSONB | 支撑的原子能力 ID 列表 → `atomic_capability.id` |
-| `tools` | JSONB | 关联的工具 ID 列表 → `tool_table.id` |
-
-索引:`knowledge_pkey (id)`、`idx_knowledge_owner`、`idx_knowledge_status`
+| `status` | VARCHAR | pending → approved / rejected / checked |
 
----
+关联(通过关联表,API 返回时聚合为 `{entity}_ids`):
+- `requirement_ids` ← requirement_knowledge
+- `capability_ids` ← capability_knowledge
+- `tool_ids` ← tool_knowledge
+- `resource_ids` ← knowledge_resource
+- `relations` ← knowledge_relation
 
-## resources — 资源表
+### resource — 资源表
 
-实现:`knowhub_db/pg_resource_store.py:PostgreSQLResourceStore`
+知识的原始来源。文档、代码、凭证等。
 
 | 字段 | 类型 | 说明 |
 |------|------|------|
 | `id` | TEXT PK | 路径格式,如 `"tools/selenium/login"` |
 | `title` | TEXT | 标题 |
-| `body` | TEXT | 公开内容(Markdown/代码) |
+| `body` | TEXT | 公开内容 |
 | `secure_body` | TEXT | 敏感内容(AES-256-GCM 加密) |
 | `content_type` | TEXT | text / code / credential / cookie |
-| `metadata` | JSONB | 附加元数据(language, acquired_at, expires_at) |
+| `metadata` | JSONB | 附加元数据 |
 | `sort_order` | INTEGER | 同级排序 |
 | `submitted_by` | TEXT | 提交者 |
 | `created_at` | BIGINT | 创建时间戳 |
 | `updated_at` | BIGINT | 更新时间戳 |
 
-路径格式 ID 支持层级导航:根节点(无 `/`)为目录/概要,子节点(含 `/`)为具体内容。API 返回时自动计算 toc/children/prev/next 导航上下文。
+### requirement — 需求表
 
-加密:`secure_body` 使用 AES-256-GCM,密钥从环境变量 `ORG_KEY_{org}` 读取。加密格式:`encrypted:AES256-GCM:{base64}`
+业务目标。分解为能力的组合,以保持稳定性
 
----
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | VARCHAR PK | `"REQ_XXX"` |
+| `description` | TEXT | 需求描述 |
+| `source_nodes` | JSONB | 来源节点 `[{node_name, posts}]` |
+| `status` | VARCHAR | 已满足 / 未满足 |
+| `match_result` | TEXT | 满足方案描述(提炼结果) |
+| `embedding` | float4[] | description 的向量 |
+
+关联:
+- `capability_ids` ← requirement_capability
+- `knowledge_ids` ← requirement_knowledge
 
-## tool_table — 工具表
+### capability — 原子能力
 
-实现:`knowhub_db/pg_tool_store.py:PostgreSQLToolStore`
+需求的稳定分解锚点。从知识中提炼,连接需求与工具。
 
 | 字段 | 类型 | 说明 |
 |------|------|------|
-| `id` | VARCHAR PK | 路径格式,如 `"tools/image_gen/midjourney"` |
+| `id` | VARCHAR PK | `"CAP-XXX"` |
+| `name` | VARCHAR | 能力名称 |
+| `criterion` | TEXT | 评估标准(提炼结果) |
+| `description` | TEXT | 能力描述(提炼结果) |
+| `embedding` | float4[] | name + description 的向量 |
+
+关联:
+- `requirement_ids` ← requirement_capability
+- `tool_ids` ← capability_tool
+- `knowledge_ids` ← capability_knowledge
+
+### tool — 工具表
+
+具体的技术/模型/软件。工具库和知识库共用同一套 ID。
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | VARCHAR PK | 路径格式,如 `"tools/image_gen/controlnet_openpose"` |
 | `name` | VARCHAR | 工具名称 |
 | `version` | VARCHAR | 版本号 |
 | `introduction` | TEXT | 功能介绍 |
@@ -107,48 +145,100 @@ Embedding 模型:`google/gemini-2.5-flash-lite`(通过 OpenRouter)。实
 | `output` | JSONB | 输出规格 |
 | `updated_time` | BIGINT | 更新时间戳 |
 | `status` | VARCHAR | 未接入 / 可用 / 异常 |
-| `capabilities` | JSONB | 关联的原子能力 ID 列表 → `atomic_capability.id` |
-| `tool_knowledge` | JSONB | 工具知识 ID 列表 → `knowledge.id` |
-| `case_knowledge` | JSONB | 用例知识 ID 列表 → `knowledge.id` |
-| `process_knowledge` | JSONB | 工序知识 ID 列表 → `knowledge.id` |
 | `embedding` | float4[] | name + introduction 的向量 |
-| `implemented_tool_ids` | JSONB | 已实现的工具 ID 列表 |
+
+关联:
+- `capability_ids` ← capability_tool
+- `knowledge_ids` ← tool_knowledge
+- `provider_ids` ← tool_provider
 
 ---
 
-## atomic_capability — 原子能力表
+## 关联表(8)
 
-实现:`knowhub_db/pg_capability_store.py:PostgreSQLCapabilityStore`
+### 实体链(2)
 
-原子能力是从知识中提炼的核心概念,连接工具实现与需求匹配。
+**requirement_capability** — 需求分解为能力
 
 | 字段 | 类型 | 说明 |
 |------|------|------|
-| `id` | VARCHAR PK | `"ac-image-gen-001"` |
-| `name` | VARCHAR | 能力名称 |
-| `criterion` | TEXT | 评估标准 |
-| `description` | TEXT | 能力描述 |
-| `requirements` | JSONB | 关联需求 ID 列表 → `requirement_table.id` |
-| `implements` | JSONB | 工具实现映射 `{tool_name: 使用方式描述}` |
-| `tools` | JSONB | 关联工具 ID 列表 → `tool_table.id` |
-| `source_knowledge` | JSONB | 提炼自哪些知识 → `knowledge.id` |
-| `embedding` | float4[] | name + description 的向量 |
+| `requirement_id` | VARCHAR | → requirement.id |
+| `capability_id` | VARCHAR | → capability.id |
 
----
+PK: (requirement_id, capability_id)
+
+**capability_tool** — 能力由工具实现
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `capability_id` | VARCHAR | → capability.id |
+| `tool_id` | VARCHAR | → tool.id |
+| `description` | TEXT | 该工具如何实现此能力 |
+
+PK: (capability_id, tool_id)
 
-## requirement_table — 需求表
+### 知识链(3)
 
-实现:`knowhub_db/pg_requirement_store.py:PostgreSQLRequirementStore`
+**requirement_knowledge** — 需求的方案策略、完成方法
 
 | 字段 | 类型 | 说明 |
 |------|------|------|
-| `id` | VARCHAR PK | `"req-001"` |
-| `description` | TEXT | 需求描述 |
-| `atomics` | JSONB | 关联的原子能力 ID 列表 → `atomic_capability.id` |
-| `source_nodes` | JSONB | 来源节点 `[{node_name, posts}]` |
-| `status` | VARCHAR | 已满足 / 未满足 |
-| `match_result` | TEXT | 满足/不足的描述 |
-| `embedding` | float4[] | description 的向量 |
+| `requirement_id` | VARCHAR | → requirement.id |
+| `knowledge_id` | VARCHAR | → knowledge.id |
+
+PK: (requirement_id, knowledge_id)
+
+**capability_knowledge** — 能力的原理依据、评判来源
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `capability_id` | VARCHAR | → capability.id |
+| `knowledge_id` | VARCHAR | → knowledge.id |
+
+PK: (capability_id, knowledge_id)
+
+**tool_knowledge** — 工具的用法、案例、经验
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `tool_id` | VARCHAR | → tool.id |
+| `knowledge_id` | VARCHAR | → knowledge.id |
+
+PK: (tool_id, knowledge_id)
+
+### 来源链(1)
+
+**knowledge_resource** — 知识的原始来源
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `knowledge_id` | VARCHAR | → knowledge.id |
+| `resource_id` | VARCHAR | → resource.id |
+
+PK: (knowledge_id, resource_id)
+
+### 知识间关系(1)
+
+**knowledge_relation** — 知识之间的关系(替代、扩展、矛盾等)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `source_id` | VARCHAR | → knowledge.id |
+| `target_id` | VARCHAR | → knowledge.id |
+| `relation_type` | VARCHAR | supersedes / extends / contradicts / ... |
+
+PK: (source_id, target_id, relation_type)
+
+### 执行层索引(1)
+
+**tool_provider** — 工具在哪些运行环境可用(供 Craftsman 查询)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `tool_id` | VARCHAR | → tool.id |
+| `provider_id` | VARCHAR | 运行环境标识(如 "comfyui"、"liblib") |
+
+PK: (tool_id, provider_id)
 
 ---
 
@@ -158,6 +248,7 @@ Embedding 模型:`google/gemini-2.5-flash-lite`(通过 OpenRouter)。实
 |---|---|---|
 | knowledge | `task_embedding` | task |
 | knowledge | `content_embedding` | content |
-| tool_table | `embedding` | name + introduction |
-| atomic_capability | `embedding` | name + description |
-| requirement_table | `embedding` | description |
+| tool | `embedding` | name + introduction |
+| capability | `embedding` | name + description |
+| requirement | `embedding` | description |
+

+ 0 - 0
knowhub/docs/user-feedback-design.md → knowhub/docs/user-feedback-plan.md


+ 13 - 16
knowhub/frontend/src/pages/Capabilities.tsx

@@ -36,8 +36,8 @@ function ExpandableTool({ tool }: { tool: any }) {
 
 function CapabilityDetails({ capability, allTools, allKnow, allReqs, onClose }: { capability: any, allTools: any[], allKnow: any[], allReqs: any[], onClose: () => void }) {
   if (!capability) return null;
-  const currentTools = allTools.filter(t => (capability.tools || []).includes(t.id));
-  const currentReqs = allReqs.filter(r => (capability.requirements || []).includes(r.id));
+  const currentTools = allTools.filter(t => (capability.tool_ids || []).includes(t.id));
+  const currentReqs = allReqs.filter(r => (capability.requirement_ids || []).includes(r.id));
 
   const renderKnowledgeList = (title: string, kIds: string[], defaultColor: string) => {
     if (!kIds || kIds.length === 0) return null;
@@ -73,7 +73,7 @@ function CapabilityDetails({ capability, allTools, allKnow, allReqs, onClose }:
 
       <div className="space-y-6">
         <div className="flex gap-2">
-          <StatusBadge status={capability.tools?.length > 0 ? "Ready" : "In Progress"} />
+          <StatusBadge status={capability.tool_ids?.length > 0 ? "Ready" : "In Progress"} />
           <span className="text-sm text-slate-500">ID: {capability.id?.substring(0,8)}...</span>
         </div>
         
@@ -127,22 +127,19 @@ function CapabilityDetails({ capability, allTools, allKnow, allReqs, onClose }:
             <FileText size={16} className="text-violet-600" /> 沉淀知识图谱
           </h3>
           <div className="bg-slate-50 p-4 rounded-2xl border border-slate-200">
-             {renderKnowledgeList("来源关联知识 (Source Knowledge)", capability.source_knowledge, "text-violet-700")}
+             {renderKnowledgeList("关联知识", capability.knowledge_ids, "text-violet-700")}
 
              {currentTools.map(t => {
-               const hasKnow = (t.process_knowledge?.length || 0) + (t.case_knowledge?.length || 0) + (t.tool_knowledge?.length || 0) > 0;
-               if (!hasKnow) return null;
+               if (!t.knowledge_ids?.length) return null;
                return (
                 <div key={t.id} className="mt-5 pt-4 border-t border-slate-200 border-dashed">
                   <div className="text-[11px] font-black text-slate-400 mb-3 uppercase tracking-wider">从工具继承知识: {t.name || t.id}</div>
-                  {renderKnowledgeList("工序知识 (Process)", t.process_knowledge, "text-emerald-700")}
-                  {renderKnowledgeList("用例知识 (Case)", t.case_knowledge, "text-amber-700")}
-                  {renderKnowledgeList("工具知识 (Tool)", t.tool_knowledge, "text-indigo-700")}
+                  {renderKnowledgeList("工具关联知识", t.knowledge_ids, "text-indigo-700")}
                 </div>
                );
              })}
 
-             {(!(capability.source_knowledge?.length) && currentTools.length === 0) && (
+             {(!(capability.knowledge_ids?.length) && currentTools.length === 0) && (
                 <p className="text-xs text-slate-400 my-2">该能力及下挂工具均暂无专属沉淀知识数据。</p>
              )}
           </div>
@@ -202,8 +199,8 @@ export function Capabilities() {
     setSearchQuery(searchInput);
   };
 
-  const readyCaps = capabilities.filter(c => c.tools?.length > 0);
-  const wipCaps = capabilities.filter(c => !c.tools || c.tools.length === 0);
+  const readyCaps = capabilities.filter(c => c.tool_ids?.length > 0);
+  const wipCaps = capabilities.filter(c => !c.tool_ids || c.tool_ids.length === 0);
 
   return (
     <div className="space-y-8 animate-in fade-in duration-500 pb-12">
@@ -231,7 +228,7 @@ export function Capabilities() {
         />
         <StatCard 
           title="响应业务矩阵" 
-          value={new Set(capabilities.flatMap(c => c.requirements || [])).size} 
+          value={new Set(capabilities.flatMap(c => c.requirement_ids || [])).size} 
           subtext="关联到特定需求" 
           icon={Hammer} 
           iconBgColor="bg-indigo-50" 
@@ -288,8 +285,8 @@ export function Capabilities() {
                     </div>
                     <p className="text-sm text-slate-500 line-clamp-2 mb-4">{cap.description}</p>
                     <div className="flex flex-wrap gap-2">
-                      <EntityTag type="tool" label="Tools" count={cap.tools?.length || 0} />
-                      <EntityTag type="requirement" label="Reqs" count={cap.requirements?.length || 0} />
+                      <EntityTag type="tool" label="Tools" count={cap.tool_ids?.length || 0} />
+                      <EntityTag type="requirement" label="Reqs" count={cap.requirement_ids?.length || 0} />
                     </div>
                   </div>
                 ))}
@@ -324,7 +321,7 @@ export function Capabilities() {
                     <p className="text-sm text-slate-500 line-clamp-2 mb-4">{cap.description}</p>
                     <div className="flex items-center justify-between">
                       <div className="flex flex-wrap gap-2">
-                        <EntityTag type="requirement" label="Reqs" count={cap.requirements?.length || 0} />
+                        <EntityTag type="requirement" label="Reqs" count={cap.requirement_ids?.length || 0} />
                       </div>
                       <div className="text-amber-600 flex items-center gap-1 text-xs font-bold">
                         <Clock size={14} /> Awaiting Tools

+ 16 - 16
knowhub/frontend/src/pages/Dashboard.tsx

@@ -186,15 +186,15 @@ export function Dashboard() {
              l.node_status = 0; // 灰色 (没有需求)
           } else {
              const rIds = new Set(attachedReqs.map(r => r.id));
-             const relCaps = caps.filter((c: any) => (c.requirements || []).some((rid: string) => rIds.has(rid)));
+             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.requirements || []).includes(r.id)));
+             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.capabilities || []).some((cid: string) => cIds.has(cid)));
+                const relTools = tools.filter((t: any) => (t.capability_ids || []).some((cid: string) => cIds.has(cid)));
                 
                 if (relTools.length === 0) {
                    l.node_status = 2; // 黄色 (有需求且全部被能力满足,但工具未接入/没有工具)
@@ -411,31 +411,31 @@ export function Dashboard() {
                     (r.source_nodes || []).some((sn: any) => leafNames.includes(typeof sn === 'object' ? (sn.node_name || sn.name) : sn))
                   );
                   const relReqIds = new Set(relReqs.map(r => r.id));
-                  relCaps = dbData.caps.filter((c: any) => (c.requirements || []).some((rid: string) => relReqIds.has(rid)));
+                  relCaps = dbData.caps.filter((c: any) => (c.requirement_ids || []).some((rid: string) => relReqIds.has(rid)));
                   const relCapIds = new Set(relCaps.map(c => c.id));
-                  relTools = dbData.tools.filter((t: any) => (t.capabilities || []).some((cid: string) => relCapIds.has(cid)));
+                  relTools = dbData.tools.filter((t: any) => (t.capability_ids || []).some((cid: string) => relCapIds.has(cid)));
                   const relToolIds = new Set(relTools.map(t => t.id));
                   relKnow = dbData.know.filter((k: any) => {
-                    const hasCap = (k.support_capability || []).some((cid: string) => relCapIds.has(cid));
-                    const hasTool = (k.tools || []).some((tid: string) => relToolIds.has(tid));
+                    const hasCap = (k.capability_ids || []).some((cid: string) => relCapIds.has(cid));
+                    const hasTool = (k.tool_ids || []).some((tid: string) => relToolIds.has(tid));
                     return hasCap || hasTool;
                   });
-               } 
+               }
                else if (currentItem.type === 'req') {
-                  relCaps = dbData.caps.filter((c: any) => (c.requirements || []).includes(d.id));
+                  relCaps = dbData.caps.filter((c: any) => (c.requirement_ids || []).includes(d.id));
                }
                else if (currentItem.type === 'cap') {
-                  relReqs = dbData.reqs.filter((r: any) => (d.requirements || []).includes(r.id));
-                  relTools = dbData.tools.filter((t: any) => (t.capabilities || []).includes(d.id));
-                  relKnow = dbData.know.filter((k: any) => (k.support_capability || []).includes(d.id));
+                  relReqs = dbData.reqs.filter((r: any) => (d.requirement_ids || []).includes(r.id));
+                  relTools = dbData.tools.filter((t: any) => (t.capability_ids || []).includes(d.id));
+                  relKnow = dbData.know.filter((k: any) => (k.capability_ids || []).includes(d.id));
                }
                else if (currentItem.type === 'tool') {
-                  relCaps = dbData.caps.filter((c: any) => (d.capabilities || []).includes(c.id));
-                  relKnow = dbData.know.filter((k: any) => (k.tools || []).includes(d.id));
+                  relCaps = dbData.caps.filter((c: any) => (d.capability_ids || []).includes(c.id));
+                  relKnow = dbData.know.filter((k: any) => (k.tool_ids || []).includes(d.id));
                }
                else if (currentItem.type === 'know') {
-                  relCaps = dbData.caps.filter((c: any) => (d.support_capability || []).includes(c.id));
-                  relTools = dbData.tools.filter((t: any) => (d.tools || []).includes(t.id));
+                  relCaps = dbData.caps.filter((c: any) => (d.capability_ids || []).includes(c.id));
+                  relTools = dbData.tools.filter((t: any) => (d.tool_ids || []).includes(t.id));
                }
 
                return (

+ 2 - 2
knowhub/frontend/src/pages/Knowledge.tsx

@@ -39,10 +39,10 @@ function ExpandableRelatedItem({ type, data }: { type: 'tool' | 'cap', data: any
 function KnowledgeDetails({ obj, allTools, allCaps, allReqs, onClose }: { obj: any, allTools: any[], allCaps: any[], allReqs: any[], onClose: () => void }) {
   if (!obj) return null;
 
-  const rawTools = obj.tools || [];
+  const rawTools = obj.tool_ids || [];
   const validTools = rawTools.map((tid: string) => allTools.find(t => t.id === tid) || { id: tid, name: tid });
 
-  const rawCaps = obj.capabilities || obj.support_capability || [];
+  const rawCaps = obj.capability_ids || [];
   const validCaps = rawCaps.map((cid: string) => allCaps.find(c => c.id === cid) || { id: cid, name: cid });
 
   const rawReqs = obj.tasks || [];

+ 2 - 2
knowhub/frontend/src/pages/Requirements.tsx

@@ -10,7 +10,7 @@ function RequirementDetails({ req, allCaps, onClose }: { req: any, allCaps: any[
   if (!req) return null;
   
   // Find related capabilities
-  const relCaps = allCaps.filter(c => (c.requirements || []).includes(req.id));
+  const relCaps = allCaps.filter(c => (c.requirement_ids || []).includes(req.id));
 
   return (
     <div className="bg-white p-6 rounded-3xl border border-slate-100 shadow-sm sticky top-24 max-h-[calc(100vh-100px)] overflow-y-auto custom-scrollbar">
@@ -101,7 +101,7 @@ export function Requirements() {
     });
   }, [searchQuery]);
 
-  const uniqueCapsUsed = new Set(data.flatMap(r => r.atomics || [])).size;
+  const uniqueCapsUsed = new Set(data.flatMap(r => r.capability_ids || [])).size;
   const coveragePerc = capsTotal > 0 ? Math.round((uniqueCapsUsed / capsTotal) * 100) : 0;
 
   const handleSearch = (e: FormEvent) => {

+ 7 - 9
knowhub/frontend/src/pages/Tools.tsx

@@ -29,7 +29,7 @@ function KnowledgeItem({ label, content }: any) {
 function ToolDetails({ tool, allKnow, allCaps, onClose }: { tool: any, allKnow: any[], allCaps: any[], onClose: () => void }) {
   if (!tool) return null;
 
-  const currentCaps = allCaps.filter(c => (tool.capabilities || []).includes(c.id) || c.id === tool.capabilities?.[0]);
+  const currentCaps = allCaps.filter(c => (tool.capability_ids || []).includes(c.id));
 
   const renderExpandableKnowledgeList = (title: string, kIds: string[], defaultColor: string) => {
     if (!kIds || kIds.length === 0) return null;
@@ -108,11 +108,9 @@ function ToolDetails({ tool, allKnow, allCaps, onClose }: { tool: any, allKnow:
             <FileText size={16} className="text-violet-600" /> 沉淀知识图谱
           </h3>
           <div className="bg-slate-50 p-4 rounded-2xl border border-slate-200">
-             {renderExpandableKnowledgeList("工序知识 (Process)", tool.process_knowledge, "text-emerald-700")}
-             {renderExpandableKnowledgeList("用例知识 (Case)", tool.case_knowledge, "text-amber-700")}
-             {renderExpandableKnowledgeList("工具知识 (Tool)", tool.tool_knowledge, "text-indigo-700")}
-             
-             {(!(tool.process_knowledge?.length) && !(tool.case_knowledge?.length) && !(tool.tool_knowledge?.length)) && (
+             {renderExpandableKnowledgeList("关联知识", tool.knowledge_ids, "text-indigo-700")}
+
+             {!(tool.knowledge_ids?.length) && (
                 <p className="text-xs text-slate-400 my-2">该工具暂未挂载知识点数据。</p>
              )}
           </div>
@@ -167,7 +165,7 @@ export function Tools() {
   const activeTools = filteredData.filter(t => t.status === '已接入' || t.status === '正常' || t.status === '已上线' || t.status === 'active');
   const toolsAvailability = filteredData.length > 0 ? Math.round((activeTools.length / filteredData.length) * 100) : 0;
   
-  const matureTools = filteredData.filter(t => t.case_knowledge && t.case_knowledge.length > 0);
+  const matureTools = filteredData.filter(t => t.knowledge_ids && t.knowledge_ids.length > 0);
   const toolMaturity = filteredData.length > 0 ? Math.round((matureTools.length / filteredData.length) * 100) : 0;
 
   return (
@@ -257,10 +255,10 @@ export function Tools() {
                 </div>
                 <p className="text-sm text-slate-500 line-clamp-3 mb-4 flex-1">{tool.introduction || '暂无介绍'}</p>
                 <div className="flex flex-wrap gap-2 mt-auto">
-                  {(tool.capabilities || []).slice(0, 2).map((c: string) => (
+                  {(tool.capability_ids || []).slice(0, 2).map((c: string) => (
                     <EntityTag key={c} type="capability" label={c} />
                   ))}
-                  {(tool.capabilities || []).length > 2 && <span className="text-xs text-slate-400 self-center">+{tool.capabilities.length - 2}</span>}
+                  {(tool.capability_ids || []).length > 2 && <span className="text-xs text-slate-400 self-center">+{tool.capability_ids.length - 2}</span>}
                 </div>
               </div>
             ))}

+ 0 - 2
knowhub/internal_tools/cache_manager.py

@@ -108,8 +108,6 @@ async def commit_to_database() -> ToolResult:
                     content=k.get("content", ""),
                     types=k.get("types", []),
                     score=k.get("score", 3),
-                    tools=k.get("tools", []),
-                    support_capability=k.get("support_capability", []),
                     source_category=k.get("source", {}).get("category", "exp")
                 )
                 saved_knows += 1

+ 9 - 8
knowhub/knowhub_db/README.md

@@ -21,7 +21,7 @@ knowledge 表的 CRUD + 向量检索:
 | `count()` | 统计总数 |
 
 ### `PostgreSQLResourceStore` (`pg_resource_store.py`)
-resources 表的 CRUD + 层级导航:
+resource 表的 CRUD + 层级导航:
 
 | 方法 | 功能 |
 |------|------|
@@ -33,13 +33,13 @@ resources 表的 CRUD + 层级导航:
 | `get_siblings(id)` | 获取前后同级节点 |
 
 ### `PostgreSQLToolStore` (`pg_tool_store.py`)
-tool_table 的 CRUD + 向量检索。
+tool 的 CRUD + 向量检索。关联表:capability_tool, tool_knowledge。
 
 ### `PostgreSQLCapabilityStore` (`pg_capability_store.py`)
-atomic_capability 表的 CRUD + 向量检索。
+capability 表的 CRUD + 向量检索。关联表:requirement_capability, capability_tool, capability_knowledge。
 
 ### `PostgreSQLRequirementStore` (`pg_requirement_store.py`)
-requirement_table 的 CRUD + 向量检索:
+requirement 的 CRUD + 向量检索。关联表requirement_capability, requirement_knowledge。
 
 | 方法 | 功能 |
 |------|------|
@@ -56,10 +56,11 @@ requirement_table 的 CRUD + 向量检索:
 ```
 knowhub_db/
 ├── pg_store.py                # knowledge 表
-├── pg_resource_store.py       # resources 表
-├── pg_tool_store.py           # tool_table
-├── pg_capability_store.py     # atomic_capability 表
-├── pg_requirement_store.py    # requirement_table
+├── pg_resource_store.py       # resource 表
+├── cascade.py                 # 级联删除(应用层,替代 FK ON DELETE CASCADE)
+├── pg_tool_store.py           # tool 表
+├── pg_capability_store.py     # capability 表
+├── pg_requirement_store.py    # requirement 表
 ├── README.md
 ├── migrations/                # 一次性迁移脚本(已执行,保留备查)
 └── scripts/                   # 诊断和运维脚本

+ 47 - 0
knowhub/knowhub_db/cascade.py

@@ -0,0 +1,47 @@
+"""
+级联删除:Greenplum 不支持 FK ON DELETE CASCADE,由应用层保证。
+
+用法:
+    from knowhub.knowhub_db.cascade import cascade_delete
+    cascade_delete(cursor, 'knowledge', knowledge_id)
+"""
+
+# 每个实体表涉及的关联表及其外键列名
+_JUNCTIONS = {
+    'knowledge': [
+        ('requirement_knowledge', 'knowledge_id'),
+        ('capability_knowledge', 'knowledge_id'),
+        ('tool_knowledge', 'knowledge_id'),
+        ('knowledge_resource', 'knowledge_id'),
+        ('knowledge_relation', 'source_id'),
+        ('knowledge_relation', 'target_id'),
+    ],
+    'tool': [
+        ('capability_tool', 'tool_id'),
+        ('tool_knowledge', 'tool_id'),
+        ('tool_provider', 'tool_id'),
+    ],
+    'capability': [
+        ('requirement_capability', 'capability_id'),
+        ('capability_tool', 'capability_id'),
+        ('capability_knowledge', 'capability_id'),
+    ],
+    'requirement': [
+        ('requirement_capability', 'requirement_id'),
+        ('requirement_knowledge', 'requirement_id'),
+    ],
+    'resource': [
+        ('knowledge_resource', 'resource_id'),
+    ],
+}
+
+
+def cascade_delete(cursor, entity_table: str, entity_id: str):
+    """先删除关联表中的引用行,再删除实体本身"""
+    for junction_table, fk_column in _JUNCTIONS.get(entity_table, []):
+        cursor.execute(
+            f"DELETE FROM {junction_table} WHERE {fk_column} = %s",
+            (entity_id,))
+    cursor.execute(
+        f"DELETE FROM {entity_table} WHERE id = %s",
+        (entity_id,))

+ 471 - 0
knowhub/knowhub_db/migrations/migrate_to_new_db.py

@@ -0,0 +1,471 @@
+#!/usr/bin/env python3
+"""
+迁移到新数据库 knowhub:从 knowledge_hub 读数据,写入 knowhub。
+13 张表:5 实体 + 8 关联。
+
+只使用 CREATE TABLE / INSERT / DROP TABLE,不执行 ALTER。
+每步幂等,可安全重跑。
+"""
+
+import os
+import json
+import psycopg2
+from psycopg2.extras import RealDictCursor, Json
+from dotenv import load_dotenv
+
+_script_dir = os.path.dirname(os.path.abspath(__file__))
+_project_root = os.path.normpath(os.path.join(_script_dir, '..', '..', '..'))
+load_dotenv(os.path.join(_project_root, '.env'))
+
+
+def connect(db_name):
+    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=db_name,
+        connect_timeout=10
+    )
+    conn.autocommit = True
+    return conn
+
+
+# ─── 13 张表的 CREATE 语句 ────────────────────────────────────────────────────
+
+CREATE_TABLES = [
+    # === 实体表 (5) ===
+    """
+    CREATE TABLE IF NOT EXISTS knowledge (
+        id                VARCHAR PRIMARY KEY,
+        task_embedding    float4[],
+        content_embedding float4[],
+        message_id        VARCHAR,
+        task              VARCHAR,
+        content           TEXT,
+        types             TEXT[],
+        tags              JSONB DEFAULT '{}',
+        tag_keys          TEXT[],
+        scopes            TEXT[],
+        owner             VARCHAR,
+        source            JSONB DEFAULT '{}',
+        eval              JSONB DEFAULT '{}',
+        created_at        BIGINT,
+        updated_at        BIGINT,
+        status            VARCHAR DEFAULT 'approved'
+    )
+    """,
+    """
+    CREATE TABLE IF NOT EXISTS resource (
+        id           TEXT PRIMARY KEY,
+        title        TEXT,
+        body         TEXT,
+        secure_body  TEXT,
+        content_type TEXT,
+        metadata     JSONB DEFAULT '{}',
+        sort_order   INTEGER DEFAULT 0,
+        submitted_by TEXT,
+        created_at   BIGINT,
+        updated_at   BIGINT
+    )
+    """,
+    """
+    CREATE TABLE IF NOT EXISTS requirement (
+        id           VARCHAR PRIMARY KEY,
+        description  TEXT,
+        source_nodes JSONB DEFAULT '[]',
+        status       VARCHAR DEFAULT '未满足',
+        match_result TEXT,
+        embedding    float4[]
+    )
+    """,
+    """
+    CREATE TABLE IF NOT EXISTS capability (
+        id          VARCHAR PRIMARY KEY,
+        name        VARCHAR,
+        criterion   TEXT,
+        description TEXT,
+        embedding   float4[]
+    )
+    """,
+    """
+    CREATE TABLE IF NOT EXISTS tool (
+        id           VARCHAR PRIMARY KEY,
+        name         VARCHAR,
+        version      VARCHAR,
+        introduction TEXT,
+        tutorial     TEXT,
+        input        JSONB DEFAULT '""',
+        output       JSONB DEFAULT '""',
+        updated_time BIGINT,
+        status       VARCHAR DEFAULT '未接入',
+        embedding    float4[]
+    )
+    """,
+    # === 实体链 (2) ===
+    """
+    CREATE TABLE IF NOT EXISTS requirement_capability (
+        requirement_id VARCHAR NOT NULL,
+        capability_id  VARCHAR NOT NULL,
+        PRIMARY KEY (requirement_id, capability_id)
+    )
+    """,
+    """
+    CREATE TABLE IF NOT EXISTS capability_tool (
+        capability_id VARCHAR NOT NULL,
+        tool_id       VARCHAR NOT NULL,
+        description   TEXT DEFAULT '',
+        PRIMARY KEY (capability_id, tool_id)
+    )
+    """,
+    # === 知识链 (3) ===
+    """
+    CREATE TABLE IF NOT EXISTS requirement_knowledge (
+        requirement_id VARCHAR NOT NULL,
+        knowledge_id   VARCHAR NOT NULL,
+        PRIMARY KEY (requirement_id, knowledge_id)
+    )
+    """,
+    """
+    CREATE TABLE IF NOT EXISTS capability_knowledge (
+        capability_id VARCHAR NOT NULL,
+        knowledge_id  VARCHAR NOT NULL,
+        PRIMARY KEY (capability_id, knowledge_id)
+    )
+    """,
+    """
+    CREATE TABLE IF NOT EXISTS tool_knowledge (
+        tool_id      VARCHAR NOT NULL,
+        knowledge_id VARCHAR NOT NULL,
+        PRIMARY KEY (tool_id, knowledge_id)
+    )
+    """,
+    # === 来源链 (1) ===
+    """
+    CREATE TABLE IF NOT EXISTS knowledge_resource (
+        knowledge_id VARCHAR NOT NULL,
+        resource_id  VARCHAR NOT NULL,
+        PRIMARY KEY (knowledge_id, resource_id)
+    )
+    """,
+    # === 知识间关系 (1) ===
+    """
+    CREATE TABLE IF NOT EXISTS knowledge_relation (
+        source_id     VARCHAR NOT NULL,
+        target_id     VARCHAR NOT NULL,
+        relation_type VARCHAR NOT NULL,
+        PRIMARY KEY (source_id, target_id, relation_type)
+    )
+    """,
+    # === 执行层索引 (1) ===
+    """
+    CREATE TABLE IF NOT EXISTS tool_provider (
+        tool_id     VARCHAR NOT NULL,
+        provider_id VARCHAR NOT NULL,
+        PRIMARY KEY (tool_id, provider_id)
+    )
+    """,
+]
+
+
+# ─── 数据迁移 ─────────────────────────────────────────────────────────────────
+
+def _parse_json(val):
+    if val is None:
+        return [] if not isinstance(val, dict) else {}
+    if isinstance(val, (list, dict)):
+        return val
+    try:
+        return json.loads(val)
+    except (json.JSONDecodeError, TypeError):
+        return []
+
+
+# JSONB 列名集合(值需要 Json() 包装,否则 psycopg2 当纯文本发送导致类型错误)
+_JSONB_COLS = {'tags', 'source', 'eval', 'metadata', 'source_nodes', 'input', 'output'}
+
+
+def _adapt(col_name, v):
+    """psycopg2 序列化适配:JSONB 列 → Json(),数组列 → 原生 list"""
+    if v is None:
+        return None
+    if col_name in _JSONB_COLS:
+        return Json(v)
+    if isinstance(v, dict):
+        return Json(v)  # 未在 _JSONB_COLS 中的 dict(兜底)
+    return v  # float4[] / TEXT[] / 标量
+
+
+def migrate_entities(old_cur, new_cur):
+    """迁移 5 张实体表"""
+
+    # knowledge: 排除 resource_ids, relationships, support_capability, tools
+    KNOWLEDGE_COLS = (
+        'id, task_embedding, content_embedding, message_id, task, content, '
+        'types, tags, tag_keys, scopes, owner, source, eval, '
+        'created_at, updated_at, status'
+    )
+    _migrate_table(old_cur, new_cur, 'knowledge', 'knowledge', KNOWLEDGE_COLS)
+
+    # resource: 旧表叫 resources,新表叫 resource
+    RESOURCE_COLS = (
+        'id, title, body, secure_body, content_type, metadata, '
+        'sort_order, submitted_by, created_at, updated_at'
+    )
+    _migrate_table(old_cur, new_cur, 'resource', 'resources', RESOURCE_COLS)
+
+    # requirement: 排除 atomics
+    REQ_COLS = 'id, description, source_nodes, status, match_result, embedding'
+    _migrate_table(old_cur, new_cur, 'requirement', 'requirement', REQ_COLS)
+
+    # capability: 排除 requirements, implements, tools, source_knowledge
+    CAP_COLS = 'id, name, criterion, description, embedding'
+    _migrate_table(old_cur, new_cur, 'capability', 'capability', CAP_COLS)
+
+    # tool: 排除 implemented_tool_ids (旧表已无 JSONB 关联列)
+    TOOL_COLS = 'id, name, version, introduction, tutorial, input, output, updated_time, status, embedding'
+    _migrate_table(old_cur, new_cur, 'tool', 'tool', TOOL_COLS)
+
+
+def _migrate_table(old_cur, new_cur, new_table, old_table, columns):
+    """通用表迁移:从旧表读指定列,写入新表"""
+    new_cur.execute(f"SELECT COUNT(*) FROM {new_table}")
+    existing = list(new_cur.fetchone().values())[0]
+    if existing > 0:
+        print(f"  {new_table}: 已有 {existing} 行,跳过", flush=True)
+        return
+
+    old_cur.execute(f"SELECT {columns} FROM {old_table}")
+    rows = old_cur.fetchall()
+    if not rows:
+        print(f"  {new_table}: 源表为空", flush=True)
+        return
+
+    col_names = [c.strip() for c in columns.split(',')]
+    placeholders = ', '.join(['%s'] * len(col_names))
+    total = len(rows)
+    count = 0
+    errors = 0
+    for i, row in enumerate(rows):
+        values = [_adapt(c, row[c]) for c in col_names]
+        try:
+            new_cur.execute(
+                f"INSERT INTO {new_table} ({columns}) VALUES ({placeholders})",
+                values)
+            count += 1
+        except Exception as e:
+            if 'duplicate key' not in str(e):
+                errors += 1
+                if errors <= 3:  # 只打印前 3 个错误
+                    print(f"    [!] {new_table} row {row.get('id','?')}: {e}", flush=True)
+        if (i + 1) % 50 == 0 or i + 1 == total:
+            print(f"  {new_table}: {i+1}/{total} ({count} ok, {errors} err)", flush=True)
+    print(f"  {new_table}: 完成 {count}/{total} 行" + (f" ({errors} 错误)" if errors else ""), flush=True)
+
+
+def migrate_junctions(old_cur, new_cur):
+    """迁移关联表数据"""
+
+    # 直接复制旧关联表(已有正确数据)
+    for table, cols in [
+        ('requirement_capability', 'requirement_id, capability_id'),
+        ('capability_tool', 'capability_id, tool_id, description'),
+        ('capability_knowledge', 'capability_id, knowledge_id'),
+        ('tool_knowledge', 'tool_id, knowledge_id'),
+    ]:
+        _migrate_junction(old_cur, new_cur, table, table, cols)
+
+    # knowledge_resource: 从 knowledge.resource_ids 数组提取
+    _migrate_knowledge_resource(old_cur, new_cur)
+
+    # knowledge_relation: 从 knowledge.relationships JSONB 提取
+    _migrate_knowledge_relation(old_cur, new_cur)
+
+    # requirement_knowledge: 旧库没有这张表,跳过
+    print("  requirement_knowledge: 旧库无数据(新增表)", flush=True)
+
+    # tool_provider: 旧库没有这张表,跳过
+    print("  tool_provider: 旧库无数据(新增表)", flush=True)
+
+
+def _migrate_junction(old_cur, new_cur, new_table, old_table, cols):
+    """复制关联表"""
+    new_cur.execute(f"SELECT COUNT(*) FROM {new_table}")
+    existing = list(new_cur.fetchone().values())[0]
+    if existing > 0:
+        print(f"  {new_table}: 已有 {existing} 行,跳过", flush=True)
+        return
+
+    try:
+        old_cur.execute(f"SELECT {cols} FROM {old_table}")
+    except Exception as e:
+        print(f"  {new_table}: 旧表 {old_table} 不可读 ({e}),跳过", flush=True)
+        return
+
+    rows = old_cur.fetchall()
+    if not rows:
+        print(f"  {new_table}: 源表为空", flush=True)
+        return
+
+    col_names = [c.strip() for c in cols.split(',')]
+    placeholders = ', '.join(['%s'] * len(col_names))
+    count = 0
+    for row in rows:
+        values = [_adapt(c, row[c]) for c in col_names]
+        try:
+            new_cur.execute(
+                f"INSERT INTO {new_table} ({cols}) VALUES ({placeholders}) ON CONFLICT DO NOTHING",
+                values)
+            count += 1
+        except Exception as e:
+            print(f"    [!] {new_table}: {e}", flush=True)
+    print(f"  {new_table}: {count}/{len(rows)} 行", flush=True)
+
+
+def _migrate_knowledge_resource(old_cur, new_cur):
+    """从 knowledge.resource_ids 数组提取到 knowledge_resource 关联表"""
+    new_cur.execute("SELECT COUNT(*) FROM knowledge_resource")
+    if list(new_cur.fetchone().values())[0] > 0:
+        print("  knowledge_resource: 已有数据,跳过", flush=True)
+        return
+
+    # 预加载有效 resource ID
+    new_cur.execute("SELECT id FROM resource")
+    valid_resources = {list(r.values())[0] for r in new_cur.fetchall()}
+
+    old_cur.execute("SELECT id, resource_ids FROM knowledge WHERE resource_ids IS NOT NULL")
+    rows = old_cur.fetchall()
+    count = 0
+    skipped = 0
+    for row in rows:
+        rids = _parse_json(row['resource_ids'])
+        if not isinstance(rids, list):
+            continue
+        for rid in rids:
+            if rid in valid_resources:
+                try:
+                    new_cur.execute(
+                        "INSERT INTO knowledge_resource (knowledge_id, resource_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                        (row['id'], rid))
+                    count += 1
+                except Exception as e:
+                    print(f"    [!] knowledge_resource: {e}", flush=True)
+            else:
+                skipped += 1
+    print(f"  knowledge_resource: {count} 行 (跳过悬空引用 {skipped})", flush=True)
+
+
+def _migrate_knowledge_relation(old_cur, new_cur):
+    """从 knowledge.relationships JSONB 提取到 knowledge_relation 关联表"""
+    new_cur.execute("SELECT COUNT(*) FROM knowledge_relation")
+    if list(new_cur.fetchone().values())[0] > 0:
+        print("  knowledge_relation: 已有数据,跳过", flush=True)
+        return
+
+    old_cur.execute("SELECT id, relationships FROM knowledge WHERE relationships IS NOT NULL")
+    rows = old_cur.fetchall()
+    count = 0
+    for row in rows:
+        rels = _parse_json(row['relationships'])
+        if not isinstance(rels, list):
+            continue
+        for rel in rels:
+            if isinstance(rel, dict) and 'type' in rel and 'target' in rel:
+                try:
+                    new_cur.execute(
+                        "INSERT INTO knowledge_relation (source_id, target_id, relation_type) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                        (row['id'], rel['target'], rel['type']))
+                    count += 1
+                except Exception as e:
+                    print(f"    [!] knowledge_relation: {e}", flush=True)
+    print(f"  knowledge_relation: {count} 行", flush=True)
+
+
+# ─── 主流程 ───────────────────────────────────────────────────────────────────
+
+def main():
+    OLD_DB = os.getenv('KNOWHUB_DB_NAME')
+    NEW_DB = 'knowhub'
+
+    print("=" * 60, flush=True)
+    print(f"迁移: {OLD_DB} → {NEW_DB}", flush=True)
+    print("=" * 60, flush=True)
+
+    # Step 1: 建表
+    print(f"\n[1/3] 在 {NEW_DB} 建表...", flush=True)
+    new_conn = connect(NEW_DB)
+    new_cur = new_conn.cursor()
+    for sql in CREATE_TABLES:
+        new_cur.execute(sql)
+    new_cur.close()
+
+    # 验证
+    new_cur = new_conn.cursor()
+    new_cur.execute("SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename")
+    tables = [r[0] for r in new_cur.fetchall()]
+    print(f"  {len(tables)} 张表: {', '.join(tables)}", flush=True)
+    new_cur.close()
+
+    # Step 2: 迁移实体数据(每次新建旧库连接,避免长连接超时)
+    print(f"\n[2/3] 迁移实体数据...", flush=True)
+    old_conn = connect(OLD_DB)
+    old_cur = old_conn.cursor(cursor_factory=RealDictCursor)
+    new_cur = new_conn.cursor(cursor_factory=RealDictCursor)
+    migrate_entities(old_cur, new_cur)
+    old_cur.close()
+    old_conn.close()
+
+    # Step 3: 迁移关联数据(每批独立连接旧库,防止长连接超时)
+    print(f"\n[3/3] 迁移关联数据...", flush=True)
+
+    # 3a: 复制旧关联表
+    old_conn = connect(OLD_DB)
+    old_cur = old_conn.cursor(cursor_factory=RealDictCursor)
+    for table, cols in [
+        ('requirement_capability', 'requirement_id, capability_id'),
+        ('capability_tool', 'capability_id, tool_id, description'),
+        ('capability_knowledge', 'capability_id, knowledge_id'),
+        ('tool_knowledge', 'tool_id, knowledge_id'),
+    ]:
+        _migrate_junction(old_cur, new_cur, table, table, cols)
+    old_cur.close()
+    old_conn.close()
+
+    # 3b: knowledge_resource(重新连接)
+    old_conn = connect(OLD_DB)
+    old_cur = old_conn.cursor(cursor_factory=RealDictCursor)
+    _migrate_knowledge_resource(old_cur, new_cur)
+    old_cur.close()
+    old_conn.close()
+
+    # 3c: knowledge_relation(重新连接)
+    old_conn = connect(OLD_DB)
+    old_cur = old_conn.cursor(cursor_factory=RealDictCursor)
+    _migrate_knowledge_relation(old_cur, new_cur)
+    old_cur.close()
+    old_conn.close()
+
+    # 新增表无旧数据
+    print("  requirement_knowledge: 新增表,无旧数据", flush=True)
+    print("  tool_provider: 新增表,无旧数据", flush=True)
+
+    # 验证
+    print(f"\n{'=' * 60}", flush=True)
+    print(f"验证 {NEW_DB}:", flush=True)
+    print(f"{'=' * 60}", flush=True)
+    for t in tables:
+        new_cur.execute(f"SELECT COUNT(*) as c FROM {t}")
+        cnt = new_cur.fetchone()['c']
+        print(f"  {t}: {cnt} rows", flush=True)
+
+    print(f"\n{'=' * 60}", flush=True)
+    print("迁移完成!", flush=True)
+    print(f"下一步:将 .env 中 KNOWHUB_DB_NAME 改为 {NEW_DB}", flush=True)
+    print(f"{'=' * 60}", flush=True)
+
+    new_cur.close()
+    new_conn.close()
+
+
+if __name__ == '__main__':
+    main()

+ 431 - 0
knowhub/knowhub_db/migrations/migrate_v3_junction_tables.py

@@ -0,0 +1,431 @@
+#!/usr/bin/env python3
+"""
+数据库迁移 v3:JSONB 软关联 → 关联表(junction tables)
+
+兼容当前混合状态:
+- tool_table 已被 RENAME 为 tool(之前的 DDL 部分回滚导致)
+- atomic_capability、requirement_table 仍为旧名
+- 4 张关联表已创建但为空
+
+步骤:
+1. 创建 4 张关联表(幂等,已存在则跳过)
+2. 从现有表的 JSONB 字段迁移数据到关联表
+3. 用 CREATE TABLE AS SELECT 创建 capability 和 requirement(变相重命名)
+4. 删除 knowledge 表的 JSONB 关联字段
+5. 删除旧表
+"""
+
+import os
+import json
+import psycopg2
+from psycopg2.extras import RealDictCursor
+from dotenv import load_dotenv
+
+_script_dir = os.path.dirname(os.path.abspath(__file__))
+_project_root = os.path.normpath(os.path.join(_script_dir, '..', '..', '..'))
+load_dotenv(os.path.join(_project_root, '.env'))
+
+
+def get_connection():
+    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'),
+        connect_timeout=10
+    )
+    conn.autocommit = True
+    return conn
+
+
+def table_exists(cursor, name):
+    cursor.execute("SELECT 1 FROM information_schema.tables WHERE table_name = %s", (name,))
+    return cursor.fetchone() is not None
+
+
+def column_exists(cursor, table, column):
+    cursor.execute(
+        "SELECT 1 FROM information_schema.columns WHERE table_name = %s AND column_name = %s",
+        (table, column))
+    return cursor.fetchone() is not None
+
+
+def resolve_table(cursor, new_name, old_name):
+    """找到实际可用的表名(处理混合重命名状态)"""
+    if table_exists(cursor, new_name):
+        return new_name
+    if table_exists(cursor, old_name):
+        return old_name
+    raise RuntimeError(f"Neither {new_name} nor {old_name} exists!")
+
+
+# ─── Step 1: 创建关联表 ──────────────────────────────────────────────────────
+
+CREATE_JUNCTION_TABLES = [
+    """
+    CREATE TABLE IF NOT EXISTS requirement_capability (
+        requirement_id VARCHAR NOT NULL,
+        capability_id  VARCHAR NOT NULL,
+        PRIMARY KEY (requirement_id, capability_id)
+    )
+    """,
+    """
+    CREATE TABLE IF NOT EXISTS capability_tool (
+        capability_id VARCHAR NOT NULL,
+        tool_id       VARCHAR NOT NULL,
+        description   TEXT DEFAULT '',
+        PRIMARY KEY (capability_id, tool_id)
+    )
+    """,
+    """
+    CREATE TABLE IF NOT EXISTS capability_knowledge (
+        capability_id VARCHAR NOT NULL,
+        knowledge_id  VARCHAR NOT NULL,
+        PRIMARY KEY (capability_id, knowledge_id)
+    )
+    """,
+    """
+    CREATE TABLE IF NOT EXISTS tool_knowledge (
+        tool_id      VARCHAR NOT NULL,
+        knowledge_id VARCHAR NOT NULL,
+        PRIMARY KEY (tool_id, knowledge_id)
+    )
+    """,
+]
+
+
+# ─── Step 2: 数据迁移 ────────────────────────────────────────────────────────
+
+def _parse_json(val):
+    if val is None:
+        return []
+    if isinstance(val, (list, dict)):
+        return val
+    try:
+        return json.loads(val)
+    except (json.JSONDecodeError, TypeError):
+        return []
+
+
+def _insert_junction(cursor, table, col_a, col_b, val_a, val_b, extra_cols=None):
+    if extra_cols:
+        cols = f"{col_a}, {col_b}, {', '.join(extra_cols.keys())}"
+        placeholders = ', '.join(['%s'] * (2 + len(extra_cols)))
+        values = [val_a, val_b] + list(extra_cols.values())
+    else:
+        cols = f"{col_a}, {col_b}"
+        placeholders = '%s, %s'
+        values = [val_a, val_b]
+    cursor.execute(
+        f"INSERT INTO {table} ({cols}) VALUES ({placeholders}) ON CONFLICT DO NOTHING",
+        values
+    )
+
+
+def migrate_data(cursor, tool_tbl, cap_tbl, req_tbl):
+    """从 JSONB 字段迁移数据到关联表。使用实际表名。"""
+    skipped = {'dangling': [], 'implements_unmatched': []}
+
+    # 预加载有效 ID
+    print("    加载 ID...", flush=True)
+    cursor.execute(f"SELECT id FROM {req_tbl}")
+    valid_reqs = {r['id'] for r in cursor.fetchall()}
+    cursor.execute(f"SELECT id FROM {cap_tbl}")
+    valid_caps = {r['id'] for r in cursor.fetchall()}
+    cursor.execute(f"SELECT id, name FROM {tool_tbl}")
+    tool_rows = cursor.fetchall()
+    valid_tools = {r['id'] for r in tool_rows}
+    cursor.execute("SELECT id FROM knowledge")
+    valid_knowledge = {r['id'] for r in cursor.fetchall()}
+    print(f"    OK: {len(valid_reqs)} reqs, {len(valid_caps)} caps, {len(valid_tools)} tools, {len(valid_knowledge)} knowledge", flush=True)
+
+    tool_name_to_id = {}
+    for r in tool_rows:
+        if r['name']:
+            tool_name_to_id[r['name'].lower().strip()] = r['id']
+
+    # ── requirement_capability ──
+    print("    requirement_capability...", flush=True)
+    cursor.execute(f"SELECT id, atomics FROM {req_tbl}")
+    for row in cursor.fetchall():
+        for cap_id in _parse_json(row['atomics']):
+            if cap_id in valid_caps:
+                _insert_junction(cursor, 'requirement_capability',
+                                 'requirement_id', 'capability_id', row['id'], cap_id)
+            else:
+                skipped['dangling'].append(f"req_cap: req={row['id']} → cap={cap_id}")
+
+    cursor.execute(f"SELECT id, requirements FROM {cap_tbl}")
+    for row in cursor.fetchall():
+        for req_id in _parse_json(row['requirements']):
+            if req_id in valid_reqs:
+                _insert_junction(cursor, 'requirement_capability',
+                                 'requirement_id', 'capability_id', req_id, row['id'])
+            else:
+                skipped['dangling'].append(f"req_cap: req={req_id} → cap={row['id']}")
+
+    # ── capability_tool ──
+    print("    capability_tool...", flush=True)
+    cursor.execute(f"SELECT id, tools, implements FROM {cap_tbl}")
+    for row in cursor.fetchall():
+        tools_list = _parse_json(row['tools'])
+        implements_dict = _parse_json(row['implements'])
+        if not isinstance(implements_dict, dict):
+            implements_dict = {}
+
+        impl_by_tool_id = {}
+        for tool_name, desc in implements_dict.items():
+            key = tool_name.lower().strip()
+            matched_id = tool_name_to_id.get(key)
+            if not matched_id:
+                for stored_name, stored_id in tool_name_to_id.items():
+                    if key in stored_name or stored_name in key:
+                        matched_id = stored_id
+                        break
+            if matched_id:
+                impl_by_tool_id[matched_id] = desc
+            else:
+                skipped['implements_unmatched'].append(
+                    f"cap={row['id']}: {tool_name} = {desc[:80]}")
+
+        for tool_id in tools_list:
+            if tool_id in valid_tools:
+                desc = impl_by_tool_id.pop(tool_id, '')
+                _insert_junction(cursor, 'capability_tool',
+                                 'capability_id', 'tool_id', row['id'], tool_id,
+                                 extra_cols={'description': desc})
+            else:
+                skipped['dangling'].append(f"cap_tool: cap={row['id']} → tool={tool_id}")
+
+        for tool_id, desc in impl_by_tool_id.items():
+            if tool_id in valid_tools:
+                _insert_junction(cursor, 'capability_tool',
+                                 'capability_id', 'tool_id', row['id'], tool_id,
+                                 extra_cols={'description': desc})
+
+    # 反向:tool.capabilities(如果列还在)
+    if column_exists(cursor, tool_tbl, 'capabilities'):
+        cursor.execute(f"SELECT id, capabilities FROM {tool_tbl}")
+        for row in cursor.fetchall():
+            for cap_id in _parse_json(row['capabilities']):
+                if cap_id in valid_caps:
+                    _insert_junction(cursor, 'capability_tool',
+                                     'capability_id', 'tool_id', cap_id, row['id'])
+                else:
+                    skipped['dangling'].append(f"cap_tool: cap={cap_id} → tool={row['id']}")
+    else:
+        print("      (tool.capabilities 列已丢失,仅从 capability 侧迁移)", flush=True)
+
+    # ── capability_knowledge ──
+    print("    capability_knowledge...", flush=True)
+    cursor.execute(f"SELECT id, source_knowledge FROM {cap_tbl}")
+    for row in cursor.fetchall():
+        for kid in _parse_json(row['source_knowledge']):
+            if kid in valid_knowledge:
+                _insert_junction(cursor, 'capability_knowledge',
+                                 'capability_id', 'knowledge_id', row['id'], kid)
+            else:
+                skipped['dangling'].append(f"cap_know: cap={row['id']} → k={kid}")
+
+    cursor.execute("SELECT id, support_capability FROM knowledge")
+    for row in cursor.fetchall():
+        for cap_id in _parse_json(row['support_capability']):
+            if cap_id in valid_caps:
+                _insert_junction(cursor, 'capability_knowledge',
+                                 'capability_id', 'knowledge_id', cap_id, row['id'])
+            else:
+                skipped['dangling'].append(f"cap_know: cap={cap_id} → k={row['id']}")
+
+    # ── tool_knowledge ──
+    print("    tool_knowledge...", flush=True)
+    # 正向:tool.*_knowledge(如果列还在)
+    if column_exists(cursor, tool_tbl, 'tool_knowledge'):
+        cursor.execute(f"SELECT id, tool_knowledge, case_knowledge, process_knowledge FROM {tool_tbl}")
+        for row in cursor.fetchall():
+            all_kids = set()
+            for field in ('tool_knowledge', 'case_knowledge', 'process_knowledge'):
+                all_kids.update(_parse_json(row[field]))
+            for kid in all_kids:
+                if kid in valid_knowledge:
+                    _insert_junction(cursor, 'tool_knowledge',
+                                     'tool_id', 'knowledge_id', row['id'], kid)
+                else:
+                    skipped['dangling'].append(f"tool_know: tool={row['id']} → k={kid}")
+    else:
+        print("      (tool.*_knowledge 列已丢失,仅从 knowledge 侧迁移)", flush=True)
+
+    # 反向:knowledge.tools
+    cursor.execute("SELECT id, tools FROM knowledge")
+    for row in cursor.fetchall():
+        for tool_id in _parse_json(row['tools']):
+            if tool_id in valid_tools:
+                _insert_junction(cursor, 'tool_knowledge',
+                                 'tool_id', 'knowledge_id', tool_id, row['id'])
+            else:
+                skipped['dangling'].append(f"tool_know: tool={tool_id} → k={row['id']}")
+
+    return skipped
+
+
+# ─── 主流程 ───────────────────────────────────────────────────────────────────
+
+def main():
+    print("=" * 60)
+    print("KnowHub 数据库迁移 v3: JSONB 软关联 → 关联表")
+    print("=" * 60)
+
+    conn = get_connection()
+    cursor = conn.cursor(cursor_factory=RealDictCursor)
+
+    # 探测实际表名(处理混合重命名状态)
+    print("\n[0] 探测表名...")
+    tool_tbl = resolve_table(cursor, 'tool', 'tool_table')
+    cap_tbl = resolve_table(cursor, 'capability', 'atomic_capability')
+    req_tbl = resolve_table(cursor, 'requirement', 'requirement_table')
+    print(f"  tool: {tool_tbl}")
+    print(f"  capability: {cap_tbl}")
+    print(f"  requirement: {req_tbl}")
+
+    # Step 1: 创建关联表
+    print("\n[1/5] 创建关联表...")
+    for sql in CREATE_JUNCTION_TABLES:
+        cursor.execute(sql)
+    print("  OK")
+
+    # Step 2: 迁移 JSONB 数据
+    print("\n[2/5] 迁移 JSONB 数据到关联表...")
+    skipped = migrate_data(cursor, tool_tbl, cap_tbl, req_tbl)
+
+    if skipped['dangling']:
+        print(f"\n  [WARN] 跳过悬空引用 {len(skipped['dangling'])} 条:")
+        for s in skipped['dangling'][:30]:
+            print(f"    - {s}")
+        if len(skipped['dangling']) > 30:
+            print(f"    ... 还有 {len(skipped['dangling']) - 30} 条")
+    if skipped['implements_unmatched']:
+        print(f"\n  [WARN] implements 未匹配 {len(skipped['implements_unmatched'])} 条:")
+        for s in skipped['implements_unmatched']:
+            print(f"    - {s}")
+
+    print("\n  关联表行数:")
+    for t in ('requirement_capability', 'capability_tool', 'capability_knowledge', 'tool_knowledge'):
+        cursor.execute(f"SELECT COUNT(*) as count FROM {t}")
+        print(f"    {t}: {cursor.fetchone()['count']}")
+
+    # Step 3: 创建新表(对需要重命名的表用 CREATE TABLE AS SELECT)
+    print("\n[3/5] 创建新表...")
+
+    # tool 已经是新名了,只需删除 JSONB 关联列
+    if tool_tbl == 'tool':
+        print("  tool: 已是新名,删除 JSONB 列...")
+        for col in ('capabilities', 'tool_knowledge', 'case_knowledge', 'process_knowledge'):
+            if column_exists(cursor, 'tool', col):
+                cursor.execute(f"ALTER TABLE tool DROP COLUMN {col}")
+                print(f"    DROP tool.{col}")
+    else:
+        # tool_table → tool via copy
+        if not table_exists(cursor, 'tool'):
+            cursor.execute(f"""
+                CREATE TABLE tool AS SELECT
+                    id, name, version, introduction, tutorial, input, output,
+                    updated_time, status, embedding, implemented_tool_ids
+                FROM tool_table
+            """)
+            cursor.execute("ALTER TABLE tool ADD PRIMARY KEY (id)")
+            cursor.execute("SELECT COUNT(*) as count FROM tool")
+            print(f"  tool_table → tool: {cursor.fetchone()['count']} rows")
+        else:
+            print("  tool: 已存在,跳过")
+
+    # atomic_capability → capability
+    if cap_tbl == 'capability':
+        print("  capability: 已是新名,删除 JSONB 列...")
+        for col in ('requirements', 'implements', 'tools', 'source_knowledge'):
+            if column_exists(cursor, 'capability', col):
+                cursor.execute(f"ALTER TABLE capability DROP COLUMN {col}")
+                print(f"    DROP capability.{col}")
+    else:
+        if not table_exists(cursor, 'capability'):
+            cursor.execute(f"""
+                CREATE TABLE capability AS SELECT
+                    id, name, criterion, description, embedding
+                FROM atomic_capability
+            """)
+            cursor.execute("ALTER TABLE capability ADD PRIMARY KEY (id)")
+            cursor.execute("SELECT COUNT(*) as count FROM capability")
+            print(f"  atomic_capability → capability: {cursor.fetchone()['count']} rows")
+        else:
+            print("  capability: 已存在,跳过")
+
+    # requirement_table → requirement
+    if req_tbl == 'requirement':
+        print("  requirement: 已是新名,删除 JSONB 列...")
+        for col in ('atomics',):
+            if column_exists(cursor, 'requirement', col):
+                cursor.execute(f"ALTER TABLE requirement DROP COLUMN {col}")
+                print(f"    DROP requirement.{col}")
+    else:
+        if not table_exists(cursor, 'requirement'):
+            cursor.execute(f"""
+                CREATE TABLE requirement AS SELECT
+                    id, description, source_nodes, status, match_result, embedding
+                FROM requirement_table
+            """)
+            cursor.execute("ALTER TABLE requirement ADD PRIMARY KEY (id)")
+            cursor.execute("SELECT COUNT(*) as count FROM requirement")
+            print(f"  requirement_table → requirement: {cursor.fetchone()['count']} rows")
+        else:
+            print("  requirement: 已存在,跳过")
+
+    # Step 4: 删除 knowledge 的 JSONB 关联字段
+    print("\n[4/5] 删除 knowledge 表的 JSONB 关联字段...")
+    for col in ('support_capability', 'tools'):
+        if column_exists(cursor, 'knowledge', col):
+            cursor.execute(f"ALTER TABLE knowledge DROP COLUMN {col}")
+            print(f"  DROP knowledge.{col}")
+        else:
+            print(f"  knowledge.{col} 已不存在")
+
+    # Step 5: 删除旧表
+    print("\n[5/5] 删除旧表...")
+    for old_name, new_name in [('tool_table', 'tool'), ('atomic_capability', 'capability'), ('requirement_table', 'requirement')]:
+        if old_name == new_name:
+            continue
+        if table_exists(cursor, old_name) and table_exists(cursor, new_name):
+            cursor.execute(f"DROP TABLE {old_name}")
+            print(f"  DROP {old_name}")
+        elif not table_exists(cursor, old_name):
+            print(f"  {old_name} 已不存在")
+        else:
+            print(f"  [!] {new_name} 不存在,保留 {old_name}")
+
+    # 最终验证
+    print("\n" + "=" * 60)
+    print("最终表结构:")
+    print("=" * 60)
+    for t in ['knowledge', 'tool', 'capability', 'requirement', 'resources',
+              'requirement_capability', 'capability_tool', 'capability_knowledge', 'tool_knowledge']:
+        try:
+            cursor.execute(f"""
+                SELECT column_name FROM information_schema.columns
+                WHERE table_name = %s ORDER BY ordinal_position
+            """, (t,))
+            cols = [r['column_name'] for r in cursor.fetchall()]
+            cursor.execute(f"SELECT COUNT(*) as count FROM {t}")
+            count = cursor.fetchone()['count']
+            print(f"\n  {t} ({count} rows)")
+            print(f"    {', '.join(cols)}")
+        except Exception as e:
+            print(f"\n  {t}: ERROR - {e}")
+
+    print("\n" + "=" * 60)
+    print("迁移成功!")
+    print("=" * 60)
+
+    cursor.close()
+    conn.close()
+
+
+if __name__ == '__main__':
+    main()

+ 99 - 43
knowhub/knowhub_db/pg_capability_store.py

@@ -1,7 +1,8 @@
 """
-PostgreSQL atomic_capability 存储封装
+PostgreSQL capability 存储封装
 
-用于存储和检索原子能力数据,支持向量检索
+用于存储和检索原子能力数据,支持向量检索。
+表名:capability(从 atomic_capability 迁移)
 """
 
 import os
@@ -10,9 +11,27 @@ 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()
 
+# 关联字段子查询
+_REL_SUBQUERIES = """
+    (SELECT COALESCE(json_agg(rc.requirement_id), '[]'::json)
+     FROM requirement_capability rc WHERE rc.capability_id = capability.id) AS requirement_ids,
+    (SELECT COALESCE(json_agg(ct.tool_id), '[]'::json)
+     FROM capability_tool ct WHERE ct.capability_id = capability.id) AS tool_ids,
+    (SELECT COALESCE(
+        json_object_agg(ct2.tool_id, ct2.description), '{}'::json)
+     FROM capability_tool ct2 WHERE ct2.capability_id = capability.id AND ct2.description != '') AS implements,
+    (SELECT COALESCE(json_agg(ck.knowledge_id), '[]'::json)
+     FROM capability_knowledge ck WHERE ck.capability_id = capability.id) AS knowledge_ids
+"""
+
+_BASE_FIELDS = "id, name, criterion, description"
+
+_SELECT_FIELDS = f"{_BASE_FIELDS}, {_REL_SUBQUERIES}"
+
 
 class PostgreSQLCapabilityStore:
     def __init__(self):
@@ -52,35 +71,61 @@ class PostgreSQLCapabilityStore:
         self._ensure_connection()
         return self.conn.cursor(cursor_factory=RealDictCursor)
 
+    def _save_relations(self, cursor, cap_id: str, data: Dict):
+        """保存 capability 的关联表数据"""
+        if 'requirement_ids' in data:
+            cursor.execute("DELETE FROM requirement_capability WHERE capability_id = %s", (cap_id,))
+            for req_id in data['requirement_ids']:
+                cursor.execute(
+                    "INSERT INTO requirement_capability (requirement_id, capability_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                    (req_id, cap_id))
+
+        # tool_ids + implements 合并写入 capability_tool
+        if 'tool_ids' in data or 'implements' in data:
+            cursor.execute("DELETE FROM capability_tool WHERE capability_id = %s", (cap_id,))
+            implements = data.get('implements', {})
+            tool_ids = set(data.get('tool_ids', []))
+            # 先写 tool_ids 列表中的(附带 implements 的 description)
+            for tool_id in tool_ids:
+                desc = implements.get(tool_id, '')
+                cursor.execute(
+                    "INSERT INTO capability_tool (capability_id, tool_id, description) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                    (cap_id, tool_id, desc))
+            # 再写 implements 中有但 tool_ids 列表没有的
+            for tool_id, desc in implements.items():
+                if tool_id not in tool_ids:
+                    cursor.execute(
+                        "INSERT INTO capability_tool (capability_id, tool_id, description) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                        (cap_id, tool_id, desc))
+
+        if 'knowledge_ids' in data:
+            cursor.execute("DELETE FROM capability_knowledge WHERE capability_id = %s", (cap_id,))
+            for kid in data['knowledge_ids']:
+                cursor.execute(
+                    "INSERT INTO capability_knowledge (capability_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                    (cap_id, kid))
+
     def insert_or_update(self, cap: Dict):
         """插入或更新原子能力"""
         cursor = self._get_cursor()
         try:
             cursor.execute("""
-                INSERT INTO atomic_capability (
-                    id, name, criterion, description, requirements,
-                    implements, tools, source_knowledge, embedding
-                ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
+                INSERT INTO capability (
+                    id, name, criterion, description, embedding
+                ) VALUES (%s, %s, %s, %s, %s)
                 ON CONFLICT (id) DO UPDATE SET
                     name = EXCLUDED.name,
                     criterion = EXCLUDED.criterion,
                     description = EXCLUDED.description,
-                    requirements = EXCLUDED.requirements,
-                    implements = EXCLUDED.implements,
-                    tools = EXCLUDED.tools,
-                    source_knowledge = EXCLUDED.source_knowledge,
                     embedding = EXCLUDED.embedding
             """, (
                 cap['id'],
                 cap.get('name', ''),
                 cap.get('criterion', ''),
                 cap.get('description', ''),
-                json.dumps(cap.get('requirements', [])),
-                json.dumps(cap.get('implements', {})),
-                json.dumps(cap.get('tools', [])),
-                json.dumps(cap.get('source_knowledge', [])),
                 cap.get('embedding'),
             ))
+            self._save_relations(cursor, cap['id'], cap)
             self.conn.commit()
         finally:
             cursor.close()
@@ -89,10 +134,9 @@ class PostgreSQLCapabilityStore:
         """根据 ID 获取原子能力"""
         cursor = self._get_cursor()
         try:
-            cursor.execute("""
-                SELECT id, name, criterion, description, requirements,
-                       implements, tools, source_knowledge
-                FROM atomic_capability WHERE id = %s
+            cursor.execute(f"""
+                SELECT {_SELECT_FIELDS}
+                FROM capability WHERE id = %s
             """, (cap_id,))
             result = cursor.fetchone()
             return self._format_result(result) if result else None
@@ -103,11 +147,10 @@ class PostgreSQLCapabilityStore:
         """向量检索原子能力"""
         cursor = self._get_cursor()
         try:
-            cursor.execute("""
-                SELECT id, name, criterion, description, requirements,
-                       implements, tools, source_knowledge,
+            cursor.execute(f"""
+                SELECT {_SELECT_FIELDS},
                        1 - (embedding <=> %s::real[]) as score
-                FROM atomic_capability
+                FROM capability
                 WHERE embedding IS NOT NULL
                 ORDER BY embedding <=> %s::real[]
                 LIMIT %s
@@ -121,10 +164,9 @@ class PostgreSQLCapabilityStore:
         """列出原子能力"""
         cursor = self._get_cursor()
         try:
-            cursor.execute("""
-                SELECT id, name, criterion, description, requirements,
-                       implements, tools, source_knowledge
-                FROM atomic_capability
+            cursor.execute(f"""
+                SELECT {_SELECT_FIELDS}
+                FROM capability
                 ORDER BY id
                 LIMIT %s OFFSET %s
             """, (limit, offset))
@@ -137,29 +179,36 @@ class PostgreSQLCapabilityStore:
         """更新原子能力字段"""
         cursor = self._get_cursor()
         try:
-            set_parts = []
-            params = []
-            json_fields = ('requirements', 'implements', 'tools', 'source_knowledge')
-            for key, value in updates.items():
-                set_parts.append(f"{key} = %s")
-                if key in json_fields:
-                    params.append(json.dumps(value))
-                else:
+            # 分离关联字段
+            rel_fields = {}
+            for key in ('requirement_ids', 'implements', 'tool_ids', 'knowledge_ids'):
+                if key in updates:
+                    rel_fields[key] = updates.pop(key)
+
+            if updates:
+                set_parts = []
+                params = []
+                for key, value in updates.items():
+                    set_parts.append(f"{key} = %s")
                     params.append(value)
-            params.append(cap_id)
-            cursor.execute(
-                f"UPDATE atomic_capability SET {', '.join(set_parts)} WHERE id = %s",
-                params
-            )
+                params.append(cap_id)
+                cursor.execute(
+                    f"UPDATE capability SET {', '.join(set_parts)} WHERE id = %s",
+                    params
+                )
+
+            if rel_fields:
+                self._save_relations(cursor, cap_id, rel_fields)
+
             self.conn.commit()
         finally:
             cursor.close()
 
     def delete(self, cap_id: str):
-        """删除原子能力"""
+        """删除原子能力及其关联表记录"""
         cursor = self._get_cursor()
         try:
-            cursor.execute("DELETE FROM atomic_capability WHERE id = %s", (cap_id,))
+            cascade_delete(cursor, 'capability', cap_id)
             self.conn.commit()
         finally:
             cursor.close()
@@ -168,7 +217,7 @@ class PostgreSQLCapabilityStore:
         """统计原子能力总数"""
         cursor = self._get_cursor()
         try:
-            cursor.execute("SELECT COUNT(*) as count FROM atomic_capability")
+            cursor.execute("SELECT COUNT(*) as count FROM capability")
             return cursor.fetchone()['count']
         finally:
             cursor.close()
@@ -178,9 +227,16 @@ class PostgreSQLCapabilityStore:
         if not row:
             return None
         result = dict(row)
-        for field in ('requirements', 'implements', 'tools', 'source_knowledge'):
+        for field in ('requirement_ids', 'tool_ids', 'knowledge_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:
+                result[field] = []
+        if 'implements' in result:
+            if isinstance(result['implements'], str):
+                result['implements'] = json.loads(result['implements'])
+            elif result['implements'] is None:
+                result['implements'] = {}
         return result
 
     def close(self):

+ 89 - 38
knowhub/knowhub_db/pg_requirement_store.py

@@ -1,7 +1,8 @@
 """
-PostgreSQL requirement_table 存储封装(v2 新 schema)
+PostgreSQL requirement 存储封装
 
-字段:id, description, atomics, source_nodes, status, match_result, embedding
+用于存储和检索需求数据,支持向量检索。
+表名:requirement(从 requirement_table 迁移)
 """
 
 import os
@@ -10,9 +11,22 @@ 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()
 
+# 关联字段子查询
+_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
+"""
+
+_BASE_FIELDS = "id, description, source_nodes, status, match_result"
+
+_SELECT_FIELDS = f"{_BASE_FIELDS}, {_REL_SUBQUERY}"
+
 
 class PostgreSQLRequirementStore:
     def __init__(self):
@@ -57,12 +71,11 @@ class PostgreSQLRequirementStore:
         cursor = self._get_cursor()
         try:
             cursor.execute("""
-                INSERT INTO requirement_table (
-                    id, description, atomics, source_nodes, status, match_result, embedding
-                ) VALUES (%s, %s, %s, %s, %s, %s, %s)
+                INSERT INTO requirement (
+                    id, description, source_nodes, status, match_result, embedding
+                ) VALUES (%s, %s, %s, %s, %s, %s)
                 ON CONFLICT (id) DO UPDATE SET
                     description = EXCLUDED.description,
-                    atomics = EXCLUDED.atomics,
                     source_nodes = EXCLUDED.source_nodes,
                     status = EXCLUDED.status,
                     match_result = EXCLUDED.match_result,
@@ -70,12 +83,25 @@ class PostgreSQLRequirementStore:
             """, (
                 requirement['id'],
                 requirement.get('description', ''),
-                json.dumps(requirement.get('atomics', [])),
                 json.dumps(requirement.get('source_nodes', [])),
                 requirement.get('status', '未满足'),
                 requirement.get('match_result', ''),
                 requirement.get('embedding'),
             ))
+            # 写入关联表
+            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))
+            if 'knowledge_ids' in requirement:
+                cursor.execute("DELETE FROM requirement_knowledge WHERE requirement_id = %s", (req_id,))
+                for kid in requirement['knowledge_ids']:
+                    cursor.execute(
+                        "INSERT INTO requirement_knowledge (requirement_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                        (req_id, kid))
             self.conn.commit()
         finally:
             cursor.close()
@@ -84,9 +110,9 @@ class PostgreSQLRequirementStore:
         """根据 ID 获取需求"""
         cursor = self._get_cursor()
         try:
-            cursor.execute("""
-                SELECT id, description, atomics, source_nodes, status, match_result
-                FROM requirement_table WHERE id = %s
+            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
@@ -97,10 +123,10 @@ class PostgreSQLRequirementStore:
         """向量检索需求"""
         cursor = self._get_cursor()
         try:
-            cursor.execute("""
-                SELECT id, description, atomics, source_nodes, status, match_result,
+            cursor.execute(f"""
+                SELECT {_SELECT_FIELDS},
                        1 - (embedding <=> %s::real[]) as score
-                FROM requirement_table
+                FROM requirement
                 WHERE embedding IS NOT NULL
                 ORDER BY embedding <=> %s::real[]
                 LIMIT %s
@@ -115,17 +141,17 @@ class PostgreSQLRequirementStore:
         cursor = self._get_cursor()
         try:
             if status:
-                cursor.execute("""
-                    SELECT id, description, atomics, source_nodes, status, match_result
-                    FROM requirement_table
+                cursor.execute(f"""
+                    SELECT {_SELECT_FIELDS}
+                    FROM requirement
                     WHERE status = %s
                     ORDER BY id
                     LIMIT %s OFFSET %s
                 """, (status, limit, offset))
             else:
-                cursor.execute("""
-                    SELECT id, description, atomics, source_nodes, status, match_result
-                    FROM requirement_table
+                cursor.execute(f"""
+                    SELECT {_SELECT_FIELDS}
+                    FROM requirement
                     ORDER BY id
                     LIMIT %s OFFSET %s
                 """, (limit, offset))
@@ -138,29 +164,49 @@ class PostgreSQLRequirementStore:
         """更新需求字段"""
         cursor = self._get_cursor()
         try:
-            set_parts = []
-            params = []
-            json_fields = ('atomics', '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_table SET {', '.join(set_parts)} WHERE id = %s",
-                params
-            )
+            # 分离关联字段
+            cap_ids = updates.pop('capability_ids', None)
+            knowledge_ids = updates.pop('knowledge_ids', None)
+
+            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))
+
+            if knowledge_ids is not None:
+                cursor.execute("DELETE FROM requirement_knowledge WHERE requirement_id = %s", (req_id,))
+                for kid in knowledge_ids:
+                    cursor.execute(
+                        "INSERT INTO requirement_knowledge (requirement_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                        (req_id, kid))
+
             self.conn.commit()
         finally:
             cursor.close()
 
     def delete(self, req_id: str):
-        """删除需求"""
+        """删除需求及其关联表记录"""
         cursor = self._get_cursor()
         try:
-            cursor.execute("DELETE FROM requirement_table WHERE id = %s", (req_id,))
+            cascade_delete(cursor, 'requirement', req_id)
             self.conn.commit()
         finally:
             cursor.close()
@@ -170,9 +216,9 @@ class PostgreSQLRequirementStore:
         cursor = self._get_cursor()
         try:
             if status:
-                cursor.execute("SELECT COUNT(*) as count FROM requirement_table WHERE status = %s", (status,))
+                cursor.execute("SELECT COUNT(*) as count FROM requirement WHERE status = %s", (status,))
             else:
-                cursor.execute("SELECT COUNT(*) as count FROM requirement_table")
+                cursor.execute("SELECT COUNT(*) as count FROM requirement")
             return cursor.fetchone()['count']
         finally:
             cursor.close()
@@ -182,9 +228,14 @@ class PostgreSQLRequirementStore:
         if not row:
             return None
         result = dict(row)
-        for field in ('atomics', 'source_nodes'):
+        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'):
             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):

+ 9 - 8
knowhub/knowhub_db/pg_resource_store.py

@@ -10,6 +10,7 @@ import psycopg2
 from psycopg2.extras import RealDictCursor
 from typing import Optional, List, Dict
 from dotenv import load_dotenv
+from knowhub.knowhub_db.cascade import cascade_delete
 
 load_dotenv()
 
@@ -58,7 +59,7 @@ class PostgreSQLResourceStore:
         try:
             now_ts = int(time.time())
             cursor.execute("""
-                INSERT INTO resources (id, title, body, secure_body, content_type,
+                INSERT INTO resource (id, title, body, secure_body, content_type,
                                        metadata, sort_order, submitted_by, created_at, updated_at)
                 VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
                 ON CONFLICT (id) DO UPDATE SET
@@ -92,7 +93,7 @@ class PostgreSQLResourceStore:
             cursor.execute("""
                 SELECT id, title, body, secure_body, content_type, metadata, sort_order,
                        created_at, updated_at
-                FROM resources WHERE id = %s
+                FROM resource WHERE id = %s
             """, (resource_id,))
             row = cursor.fetchone()
             if not row:
@@ -122,7 +123,7 @@ class PostgreSQLResourceStore:
             where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
             sql = f"""
                 SELECT id, title, content_type, metadata, created_at, updated_at
-                FROM resources
+                FROM resource
                 {where_clause}
                 ORDER BY sort_order, id
                 LIMIT %s OFFSET %s
@@ -153,17 +154,17 @@ class PostgreSQLResourceStore:
             params.append(int(time.time()))
             params.append(resource_id)
 
-            sql = f"UPDATE resources SET {', '.join(set_parts)} WHERE id = %s"
+            sql = f"UPDATE resource SET {', '.join(set_parts)} WHERE id = %s"
             cursor.execute(sql, params)
             self.conn.commit()
         finally:
             cursor.close()
 
     def delete(self, resource_id: str):
-        """删除资源"""
+        """删除资源及其关联表记录"""
         cursor = self._get_cursor()
         try:
-            cursor.execute("DELETE FROM resources WHERE id = %s", (resource_id,))
+            cascade_delete(cursor, 'resource', resource_id)
             self.conn.commit()
         finally:
             cursor.close()
@@ -181,7 +182,7 @@ class PostgreSQLResourceStore:
 
             # 获取前一个
             cursor.execute("""
-                SELECT id, title FROM resources
+                SELECT id, title FROM resource
                 WHERE id LIKE %s AND id < %s
                 ORDER BY id DESC LIMIT 1
             """, (f"{parent_prefix}/%", resource_id))
@@ -189,7 +190,7 @@ class PostgreSQLResourceStore:
 
             # 获取后一个
             cursor.execute("""
-                SELECT id, title FROM resources
+                SELECT id, title FROM resource
                 WHERE id LIKE %s AND id > %s
                 ORDER BY id ASC LIMIT 1
             """, (f"{parent_prefix}/%", resource_id))

+ 162 - 51
knowhub/knowhub_db/pg_store.py

@@ -10,9 +10,39 @@ import psycopg2
 from psycopg2.extras import RealDictCursor, execute_batch
 from typing import List, Dict, Optional
 from dotenv import load_dotenv
+from knowhub.knowhub_db.cascade import cascade_delete
 
 load_dotenv()
 
+# 关联字段的子查询(从 junction table 读取,返回 JSON 数组)
+_REL_SUBQUERIES = """
+    (SELECT COALESCE(json_agg(rk.requirement_id), '[]'::json)
+     FROM requirement_knowledge rk WHERE rk.knowledge_id = knowledge.id) AS requirement_ids,
+    (SELECT COALESCE(json_agg(ck.capability_id), '[]'::json)
+     FROM capability_knowledge ck WHERE ck.knowledge_id = knowledge.id) AS capability_ids,
+    (SELECT COALESCE(json_agg(tk.tool_id), '[]'::json)
+     FROM tool_knowledge tk WHERE tk.knowledge_id = knowledge.id) AS tool_ids,
+    (SELECT COALESCE(json_agg(kr.resource_id), '[]'::json)
+     FROM knowledge_resource kr WHERE kr.knowledge_id = knowledge.id) AS resource_ids,
+    (SELECT COALESCE(json_agg(json_build_object(
+        'target_id', krel.target_id, 'relation_type', krel.relation_type
+     )), '[]'::json)
+     FROM knowledge_relation krel WHERE krel.source_id = knowledge.id) AS relations
+"""
+
+# 基础字段(不含 embedding)
+_BASE_FIELDS = (
+    "id, message_id, task, content, types, tags, tag_keys, "
+    "scopes, owner, source, eval, "
+    "created_at, updated_at, status"
+)
+
+# 完整 SELECT(含关联子查询)
+_SELECT_FIELDS = f"{_BASE_FIELDS}, {_REL_SUBQUERIES}"
+
+# 含 embedding 的 SELECT
+_SELECT_FIELDS_WITH_EMB = f"task_embedding, content_embedding, {_SELECT_FIELDS}"
+
 
 class PostgreSQLStore:
     def __init__(self):
@@ -60,10 +90,9 @@ class PostgreSQLStore:
             cursor.execute("""
                 INSERT INTO knowledge (
                     id, task_embedding, content_embedding, message_id, task, content, types, tags,
-                    tag_keys, scopes, owner, resource_ids, source, eval,
-                    created_at, updated_at, status, relationships,
-                    support_capability, tools
-                ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+                    tag_keys, scopes, owner, source, eval,
+                    created_at, updated_at, status
+                ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
             """, (
                 knowledge['id'],
                 knowledge.get('task_embedding') or knowledge.get('embedding'),
@@ -76,16 +105,30 @@ class PostgreSQLStore:
                 knowledge.get('tag_keys', []),
                 knowledge.get('scopes', []),
                 knowledge['owner'],
-                knowledge.get('resource_ids', []),
                 json.dumps(knowledge.get('source', {})),
                 json.dumps(knowledge.get('eval', {})),
                 knowledge['created_at'],
                 knowledge['updated_at'],
                 knowledge.get('status', 'approved'),
-                json.dumps(knowledge.get('relationships', [])),
-                json.dumps(knowledge.get('support_capability', [])),
-                json.dumps(knowledge.get('tools', [])),
             ))
+            # 写入关联表
+            kid = knowledge['id']
+            for req_id in knowledge.get('requirement_ids', []):
+                cursor.execute(
+                    "INSERT INTO requirement_knowledge (requirement_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                    (req_id, kid))
+            for cap_id in knowledge.get('capability_ids', []):
+                cursor.execute(
+                    "INSERT INTO capability_knowledge (capability_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                    (cap_id, kid))
+            for tool_id in knowledge.get('tool_ids', []):
+                cursor.execute(
+                    "INSERT INTO tool_knowledge (tool_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                    (tool_id, kid))
+            for res_id in knowledge.get('resource_ids', []):
+                cursor.execute(
+                    "INSERT INTO knowledge_resource (knowledge_id, resource_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                    (kid, res_id))
             self.conn.commit()
         finally:
             cursor.close()
@@ -96,9 +139,7 @@ class PostgreSQLStore:
         try:
             where_clause = self._build_where_clause(filters) if filters else ""
             sql = f"""
-                SELECT id, message_id, task, content, types, tags, tag_keys,
-                       scopes, owner, resource_ids, source, eval, created_at,
-                       updated_at, status, relationships, support_capability, tools,
+                SELECT {_SELECT_FIELDS},
                        1 - (task_embedding <=> %s::real[]) as score
                 FROM knowledge
                 {where_clause}
@@ -117,9 +158,7 @@ class PostgreSQLStore:
         try:
             where_clause = self._build_where_clause(filters)
             sql = f"""
-                SELECT id, message_id, task, content, types, tags, tag_keys,
-                       scopes, owner, resource_ids, source, eval, created_at,
-                       updated_at, status, relationships, support_capability, tools
+                SELECT {_SELECT_FIELDS}
                 FROM knowledge
                 {where_clause}
                 LIMIT %s
@@ -134,12 +173,7 @@ class PostgreSQLStore:
         """根据ID获取知识(默认不返回embedding以提升性能)"""
         cursor = self._get_cursor()
         try:
-            # 默认不返回embedding(1536维向量太大,详情页不需要)
-            if include_embedding:
-                fields = "id, task_embedding, content_embedding, message_id, task, content, types, tags, tag_keys, scopes, owner, resource_ids, source, eval, created_at, updated_at, status, relationships, support_capability, tools"
-            else:
-                fields = "id, message_id, task, content, types, tags, tag_keys, scopes, owner, resource_ids, source, eval, created_at, updated_at, status, relationships, support_capability, tools"
-
+            fields = _SELECT_FIELDS_WITH_EMB if include_embedding else _SELECT_FIELDS
             cursor.execute(f"""
                 SELECT {fields}
                 FROM knowledge WHERE id = %s
@@ -153,30 +187,86 @@ class PostgreSQLStore:
         """更新知识"""
         cursor = self._get_cursor()
         try:
-            set_parts = []
-            params = []
-            for key, value in updates.items():
-                if key in ('tags', 'source', 'eval', 'support_capability', 'tools'):
-                    set_parts.append(f"{key} = %s")
-                    params.append(json.dumps(value))
-                elif key == 'relationships':
-                    set_parts.append(f"{key} = %s")
-                    params.append(json.dumps(value) if isinstance(value, list) else value)
-                else:
-                    set_parts.append(f"{key} = %s")
-                    params.append(value)
-            params.append(knowledge_id)
-            sql = f"UPDATE knowledge SET {', '.join(set_parts)} WHERE id = %s"
-            cursor.execute(sql, params)
+            # 分离关联字段和实体字段
+            req_ids = updates.pop('requirement_ids', None)
+            cap_ids = updates.pop('capability_ids', None)
+            tool_ids = updates.pop('tool_ids', None)
+            resource_ids = updates.pop('resource_ids', None)
+
+            if updates:
+                set_parts = []
+                params = []
+                for key, value in updates.items():
+                    if key in ('tags', 'source', 'eval'):
+                        set_parts.append(f"{key} = %s")
+                        params.append(json.dumps(value))
+                    else:
+                        set_parts.append(f"{key} = %s")
+                        params.append(value)
+                params.append(knowledge_id)
+                sql = f"UPDATE knowledge SET {', '.join(set_parts)} WHERE id = %s"
+                cursor.execute(sql, params)
+
+            # 更新关联表(全量替换)
+            if req_ids is not None:
+                cursor.execute("DELETE FROM requirement_knowledge WHERE knowledge_id = %s", (knowledge_id,))
+                for req_id in req_ids:
+                    cursor.execute(
+                        "INSERT INTO requirement_knowledge (requirement_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                        (req_id, knowledge_id))
+
+            if cap_ids is not None:
+                cursor.execute("DELETE FROM capability_knowledge WHERE knowledge_id = %s", (knowledge_id,))
+                for cap_id in cap_ids:
+                    cursor.execute(
+                        "INSERT INTO capability_knowledge (capability_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                        (cap_id, knowledge_id))
+
+            if tool_ids is not None:
+                cursor.execute("DELETE FROM tool_knowledge WHERE knowledge_id = %s", (knowledge_id,))
+                for tool_id in tool_ids:
+                    cursor.execute(
+                        "INSERT INTO tool_knowledge (tool_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                        (tool_id, knowledge_id))
+
+            if resource_ids is not None:
+                cursor.execute("DELETE FROM knowledge_resource WHERE knowledge_id = %s", (knowledge_id,))
+                for res_id in resource_ids:
+                    cursor.execute(
+                        "INSERT INTO knowledge_resource (knowledge_id, resource_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                        (knowledge_id, res_id))
+
             self.conn.commit()
         finally:
             cursor.close()
 
     def delete(self, knowledge_id: str):
-        """删除知识"""
+        """删除知识及其关联表记录"""
         cursor = self._get_cursor()
         try:
-            cursor.execute("DELETE FROM knowledge WHERE id = %s", (knowledge_id,))
+            cascade_delete(cursor, 'knowledge', knowledge_id)
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    def add_relation(self, source_id: str, target_id: str, relation_type: str):
+        """添加一条知识间关系(不删除已有关系)"""
+        cursor = self._get_cursor()
+        try:
+            cursor.execute(
+                "INSERT INTO knowledge_relation (source_id, target_id, relation_type) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                (source_id, target_id, relation_type))
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    def add_resource(self, knowledge_id: str, resource_id: str):
+        """添加一条知识-资源关联(不删除已有关联)"""
+        cursor = self._get_cursor()
+        try:
+            cursor.execute(
+                "INSERT INTO knowledge_resource (knowledge_id, resource_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                (knowledge_id, resource_id))
             self.conn.commit()
         finally:
             cursor.close()
@@ -226,12 +316,16 @@ class PostgreSQLStore:
             result['source'] = json.loads(result['source'])
         if 'eval' in result and isinstance(result['eval'], str):
             result['eval'] = json.loads(result['eval'])
-        if 'relationships' in result and isinstance(result['relationships'], str):
-            result['relationships'] = json.loads(result['relationships'])
-        if 'support_capability' in result and isinstance(result['support_capability'], str):
-            result['support_capability'] = json.loads(result['support_capability'])
-        if 'tools' in result and isinstance(result['tools'], str):
-            result['tools'] = json.loads(result['tools'])
+        # 关联字段(来自 junction table 子查询,可能是 JSON 字符串或已解析的列表)
+        for field in ('requirement_ids', 'capability_ids', 'tool_ids', 'resource_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:
+                result[field] = []
+        if 'relations' in result and isinstance(result['relations'], str):
+            result['relations'] = json.loads(result['relations'])
+        elif 'relations' in result and result['relations'] is None:
+            result['relations'] = []
         if 'created_at' in result and result['created_at']:
             result['created_at'] = result['created_at'] * 1000
         if 'updated_at' in result and result['updated_at']:
@@ -258,22 +352,39 @@ class PostgreSQLStore:
                     k['message_id'], k['task'],
                     k['content'], k.get('types', []),
                     json.dumps(k.get('tags', {})), k.get('tag_keys', []),
-                    k.get('scopes', []), k['owner'], k.get('resource_ids', []),
+                    k.get('scopes', []), k['owner'],
                     json.dumps(k.get('source', {})), json.dumps(k.get('eval', {})),
                     k['created_at'], k['updated_at'], k.get('status', 'approved'),
-                    json.dumps(k.get('relationships', [])),
-                    json.dumps(k.get('support_capability', [])),
-                    json.dumps(k.get('tools', [])),
                 ))
 
             execute_batch(cursor, """
                 INSERT INTO knowledge (
                     id, task_embedding, content_embedding, message_id, task, content, types, tags,
-                    tag_keys, scopes, owner, resource_ids, source, eval,
-                    created_at, updated_at, status, relationships,
-                    support_capability, tools
-                ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+                    tag_keys, scopes, owner, source, eval,
+                    created_at, updated_at, status
+                ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
             """, data)
+
+            # 批量写入关联表
+            for k in knowledge_list:
+                kid = k['id']
+                for req_id in k.get('requirement_ids', []):
+                    cursor.execute(
+                        "INSERT INTO requirement_knowledge (requirement_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                        (req_id, kid))
+                for cap_id in k.get('capability_ids', []):
+                    cursor.execute(
+                        "INSERT INTO capability_knowledge (capability_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                        (cap_id, kid))
+                for tool_id in k.get('tool_ids', []):
+                    cursor.execute(
+                        "INSERT INTO tool_knowledge (tool_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                        (tool_id, kid))
+                for res_id in k.get('resource_ids', []):
+                    cursor.execute(
+                        "INSERT INTO knowledge_resource (knowledge_id, resource_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                        (kid, res_id))
+
             self.conn.commit()
         finally:
             cursor.close()

+ 121 - 64
knowhub/knowhub_db/pg_tool_store.py

@@ -1,7 +1,8 @@
 """
-PostgreSQL tool_table 存储封装
+PostgreSQL tool 存储封装
 
-用于存储和检索工具数据,支持向量检索
+用于存储和检索工具数据,支持向量检索。
+表名:tool(从 tool_table 迁移)
 """
 
 import os
@@ -10,9 +11,24 @@ 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()
 
+# 关联字段子查询
+_REL_SUBQUERIES = """
+    (SELECT COALESCE(json_agg(ct.capability_id), '[]'::json)
+     FROM capability_tool ct WHERE ct.tool_id = tool.id) AS capability_ids,
+    (SELECT COALESCE(json_agg(tk.knowledge_id), '[]'::json)
+     FROM tool_knowledge tk WHERE tk.tool_id = tool.id) AS knowledge_ids,
+    (SELECT COALESCE(json_agg(tp.provider_id), '[]'::json)
+     FROM tool_provider tp WHERE tp.tool_id = tool.id) AS provider_ids
+"""
+
+_BASE_FIELDS = "id, name, version, introduction, tutorial, input, output, updated_time, status"
+
+_SELECT_FIELDS = f"{_BASE_FIELDS}, {_REL_SUBQUERIES}"
+
 
 class PostgreSQLToolStore:
     def __init__(self):
@@ -57,11 +73,10 @@ class PostgreSQLToolStore:
         cursor = self._get_cursor()
         try:
             cursor.execute("""
-                INSERT INTO tool_table (
+                INSERT INTO tool (
                     id, name, version, introduction, tutorial, input, output,
-                    updated_time, status, capabilities, tool_knowledge,
-                    case_knowledge, process_knowledge, embedding, implemented_tool_ids
-                ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+                    updated_time, status, embedding
+                ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
                 ON CONFLICT (id) DO UPDATE SET
                     name = EXCLUDED.name,
                     version = EXCLUDED.version,
@@ -71,12 +86,7 @@ class PostgreSQLToolStore:
                     output = EXCLUDED.output,
                     updated_time = EXCLUDED.updated_time,
                     status = EXCLUDED.status,
-                    capabilities = EXCLUDED.capabilities,
-                    tool_knowledge = EXCLUDED.tool_knowledge,
-                    case_knowledge = EXCLUDED.case_knowledge,
-                    process_knowledge = EXCLUDED.process_knowledge,
-                    embedding = EXCLUDED.embedding,
-                    implemented_tool_ids = EXCLUDED.implemented_tool_ids
+                    embedding = EXCLUDED.embedding
             """, (
                 tool['id'],
                 tool.get('name', ''),
@@ -87,13 +97,28 @@ class PostgreSQLToolStore:
                 json.dumps(tool.get('output', '')),
                 tool.get('updated_time', 0),
                 tool.get('status', '未接入'),
-                json.dumps(tool.get('capabilities', [])),
-                json.dumps(tool.get('tool_knowledge', [])),
-                json.dumps(tool.get('case_knowledge', [])),
-                json.dumps(tool.get('process_knowledge', [])),
                 tool.get('embedding'),
-                json.dumps(tool.get('implemented_tool_ids', [])),
             ))
+            # 写入关联表
+            tool_id = tool['id']
+            if 'capability_ids' in tool:
+                cursor.execute("DELETE FROM capability_tool WHERE tool_id = %s", (tool_id,))
+                for cap_id in tool['capability_ids']:
+                    cursor.execute(
+                        "INSERT INTO capability_tool (capability_id, tool_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                        (cap_id, tool_id))
+            if 'knowledge_ids' in tool:
+                cursor.execute("DELETE FROM tool_knowledge WHERE tool_id = %s", (tool_id,))
+                for kid in tool['knowledge_ids']:
+                    cursor.execute(
+                        "INSERT INTO tool_knowledge (tool_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                        (tool_id, kid))
+            if 'provider_ids' in tool:
+                cursor.execute("DELETE FROM tool_provider WHERE tool_id = %s", (tool_id,))
+                for pid in tool['provider_ids']:
+                    cursor.execute(
+                        "INSERT INTO tool_provider (tool_id, provider_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                        (tool_id, pid))
             self.conn.commit()
         finally:
             cursor.close()
@@ -102,11 +127,9 @@ class PostgreSQLToolStore:
         """根据 ID 获取工具"""
         cursor = self._get_cursor()
         try:
-            cursor.execute("""
-                SELECT id, name, version, introduction, tutorial, input, output,
-                       updated_time, status, capabilities, tool_knowledge,
-                       case_knowledge, process_knowledge, implemented_tool_ids
-                FROM tool_table WHERE id = %s
+            cursor.execute(f"""
+                SELECT {_SELECT_FIELDS}
+                FROM tool WHERE id = %s
             """, (tool_id,))
             result = cursor.fetchone()
             return self._format_result(result) if result else None
@@ -117,32 +140,26 @@ class PostgreSQLToolStore:
         """向量检索工具"""
         cursor = self._get_cursor()
         try:
-            where = "WHERE embedding IS NOT NULL"
-            params = [query_embedding, query_embedding, limit]
             if status:
-                where += " AND status = %s"
-                params = [query_embedding, status, query_embedding, limit]
                 sql = f"""
-                    SELECT id, name, version, introduction, tutorial, input, output,
-                           updated_time, status, capabilities, tool_knowledge,
-                           case_knowledge, process_knowledge, implemented_tool_ids,
+                    SELECT {_SELECT_FIELDS},
                            1 - (embedding <=> %s::real[]) as score
-                    FROM tool_table
+                    FROM tool
                     WHERE embedding IS NOT NULL AND status = %s
                     ORDER BY embedding <=> %s::real[]
                     LIMIT %s
                 """
+                params = [query_embedding, status, query_embedding, limit]
             else:
                 sql = f"""
-                    SELECT id, name, version, introduction, tutorial, input, output,
-                           updated_time, status, capabilities, tool_knowledge,
-                           case_knowledge, process_knowledge, implemented_tool_ids,
+                    SELECT {_SELECT_FIELDS},
                            1 - (embedding <=> %s::real[]) as score
-                    FROM tool_table
+                    FROM tool
                     WHERE embedding IS NOT NULL
                     ORDER BY embedding <=> %s::real[]
                     LIMIT %s
                 """
+                params = [query_embedding, query_embedding, limit]
             cursor.execute(sql, params)
             results = cursor.fetchall()
             return [self._format_result(r) for r in results]
@@ -154,21 +171,17 @@ class PostgreSQLToolStore:
         cursor = self._get_cursor()
         try:
             if status:
-                cursor.execute("""
-                    SELECT id, name, version, introduction, tutorial, input, output,
-                           updated_time, status, capabilities, tool_knowledge,
-                           case_knowledge, process_knowledge, implemented_tool_ids
-                    FROM tool_table
+                cursor.execute(f"""
+                    SELECT {_SELECT_FIELDS}
+                    FROM tool
                     WHERE status = %s
                     ORDER BY updated_time DESC
                     LIMIT %s OFFSET %s
                 """, (status, limit, offset))
             else:
-                cursor.execute("""
-                    SELECT id, name, version, introduction, tutorial, input, output,
-                           updated_time, status, capabilities, tool_knowledge,
-                           case_knowledge, process_knowledge, implemented_tool_ids
-                    FROM tool_table
+                cursor.execute(f"""
+                    SELECT {_SELECT_FIELDS}
+                    FROM tool
                     ORDER BY updated_time DESC
                     LIMIT %s OFFSET %s
                 """, (limit, offset))
@@ -181,30 +194,69 @@ class PostgreSQLToolStore:
         """更新工具字段"""
         cursor = self._get_cursor()
         try:
-            set_parts = []
-            params = []
-            json_fields = ('input', 'output', 'capabilities', 'tool_knowledge',
-                           'case_knowledge', 'process_knowledge')
-            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(tool_id)
+            # 分离关联字段
+            cap_ids = updates.pop('capability_ids', None)
+            knowledge_ids = updates.pop('knowledge_ids', None)
+            provider_ids = updates.pop('provider_ids', None)
+
+            if updates:
+                set_parts = []
+                params = []
+                json_fields = ('input', 'output')
+                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(tool_id)
+                cursor.execute(
+                    f"UPDATE tool SET {', '.join(set_parts)} WHERE id = %s",
+                    params
+                )
+
+            # 更新关联表
+            if cap_ids is not None:
+                cursor.execute("DELETE FROM capability_tool WHERE tool_id = %s", (tool_id,))
+                for cap_id in cap_ids:
+                    cursor.execute(
+                        "INSERT INTO capability_tool (capability_id, tool_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                        (cap_id, tool_id))
+
+            if knowledge_ids is not None:
+                cursor.execute("DELETE FROM tool_knowledge WHERE tool_id = %s", (tool_id,))
+                for kid in knowledge_ids:
+                    cursor.execute(
+                        "INSERT INTO tool_knowledge (tool_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                        (tool_id, kid))
+
+            if provider_ids is not None:
+                cursor.execute("DELETE FROM tool_provider WHERE tool_id = %s", (tool_id,))
+                for pid in provider_ids:
+                    cursor.execute(
+                        "INSERT INTO tool_provider (tool_id, provider_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                        (tool_id, pid))
+
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    def add_knowledge(self, tool_id: str, knowledge_id: str):
+        """向工具添加一条知识关联(不删除已有关联)"""
+        cursor = self._get_cursor()
+        try:
             cursor.execute(
-                f"UPDATE tool_table SET {', '.join(set_parts)} WHERE id = %s",
-                params
-            )
+                "INSERT INTO tool_knowledge (tool_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                (tool_id, knowledge_id))
             self.conn.commit()
         finally:
             cursor.close()
 
     def delete(self, tool_id: str):
-        """删除工具"""
+        """删除工具及其关联表记录"""
         cursor = self._get_cursor()
         try:
-            cursor.execute("DELETE FROM tool_table WHERE id = %s", (tool_id,))
+            cascade_delete(cursor, 'tool', tool_id)
             self.conn.commit()
         finally:
             cursor.close()
@@ -214,9 +266,9 @@ class PostgreSQLToolStore:
         cursor = self._get_cursor()
         try:
             if status:
-                cursor.execute("SELECT COUNT(*) as count FROM tool_table WHERE status = %s", (status,))
+                cursor.execute("SELECT COUNT(*) as count FROM tool WHERE status = %s", (status,))
             else:
-                cursor.execute("SELECT COUNT(*) as count FROM tool_table")
+                cursor.execute("SELECT COUNT(*) as count FROM tool")
             return cursor.fetchone()['count']
         finally:
             cursor.close()
@@ -226,13 +278,18 @@ class PostgreSQLToolStore:
         if not row:
             return None
         result = dict(row)
-        for field in ('input', 'output', 'capabilities', 'tool_knowledge',
-                       'case_knowledge', 'process_knowledge', 'implemented_tool_ids'):
+        for field in ('input', 'output'):
             if field in result and isinstance(result[field], str):
                 try:
                     result[field] = json.loads(result[field]) if result[field].strip() else None
                 except json.JSONDecodeError:
                     result[field] = None
+        # 关联字段(来自 junction table 子查询)
+        for field in ('capability_ids', 'knowledge_ids', 'provider_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:
+                result[field] = []
         return result
 
     def close(self):

+ 191 - 118
knowhub/server.py

@@ -223,8 +223,8 @@ class KnowledgeIn(BaseModel):
     resource_ids: list[str] = []
     source: dict = {}  # {name, category, urls, agent_id, submitted_by, timestamp}
     eval: dict = {}    # {score, helpful, harmful, confidence}
-    support_capability: list[str] = []
-    tools: list[str] = []
+    capability_ids: list[str] = []
+    tool_ids: list[str] = []
 
 
 class KnowledgeOut(BaseModel):
@@ -236,7 +236,6 @@ class KnowledgeOut(BaseModel):
     scopes: list[str]
     owner: str
     content: str
-    resource_ids: list[str]
     source: dict
     eval: dict
     created_at: str
@@ -258,8 +257,8 @@ class KnowledgePatchIn(BaseModel):
     tags: Optional[dict] = None
     scopes: Optional[list[str]] = None
     owner: Optional[str] = None
-    support_capability: Optional[list[str]] = None
-    tools: Optional[list[str]] = None
+    capability_ids: Optional[list[str]] = None
+    tool_ids: Optional[list[str]] = None
 
 
 class MessageExtractIn(BaseModel):
@@ -302,11 +301,9 @@ class ToolIn(BaseModel):
     input: dict | str = ""
     output: dict | str = ""
     status: str = "未接入"
-    capabilities: list[str] = []
-    tool_knowledge: list[str] = []
-    case_knowledge: list[str] = []
-    process_knowledge: list[str] = []
-    implemented_tool_ids: list[str] = []
+    capability_ids: list[str] = []
+    knowledge_ids: list[str] = []
+    provider_ids: list[str] = []
 
 
 class ToolPatchIn(BaseModel):
@@ -317,11 +314,9 @@ class ToolPatchIn(BaseModel):
     input: Optional[dict | str] = None
     output: Optional[dict | str] = None
     status: Optional[str] = None
-    capabilities: Optional[list[str]] = None
-    tool_knowledge: Optional[list[str]] = None
-    case_knowledge: Optional[list[str]] = None
-    process_knowledge: Optional[list[str]] = None
-    implemented_tool_ids: Optional[list[str]] = None
+    capability_ids: Optional[list[str]] = None
+    knowledge_ids: Optional[list[str]] = None
+    provider_ids: Optional[list[str]] = None
 
 
 # --- Capability Models ---
@@ -331,20 +326,20 @@ class CapabilityIn(BaseModel):
     name: str = ""
     criterion: str = ""
     description: str = ""
-    requirements: list[str] = []
+    requirement_ids: list[str] = []
     implements: dict = {}
-    tools: list[str] = []
-    source_knowledge: list[str] = []
+    tool_ids: list[str] = []
+    knowledge_ids: list[str] = []
 
 
 class CapabilityPatchIn(BaseModel):
     name: Optional[str] = None
     criterion: Optional[str] = None
     description: Optional[str] = None
-    requirements: Optional[list[str]] = None
+    requirement_ids: Optional[list[str]] = None
     implements: Optional[dict] = None
-    tools: Optional[list[str]] = None
-    source_knowledge: Optional[list[str]] = None
+    tool_ids: Optional[list[str]] = None
+    knowledge_ids: Optional[list[str]] = None
 
 
 # --- Requirement Models ---
@@ -352,7 +347,8 @@ class CapabilityPatchIn(BaseModel):
 class RequirementIn(BaseModel):
     id: str
     description: str = ""
-    atomics: list[str] = []
+    capability_ids: list[str] = []
+    knowledge_ids: list[str] = []
     source_nodes: list[dict] = []
     status: str = "未满足"
     match_result: str = ""
@@ -360,7 +356,8 @@ class RequirementIn(BaseModel):
 
 class RequirementPatchIn(BaseModel):
     description: Optional[str] = None
-    atomics: Optional[list[str]] = None
+    capability_ids: Optional[list[str]] = None
+    knowledge_ids: Optional[list[str]] = None
     source_nodes: Optional[list[dict]] = None
     status: Optional[str] = None
     match_result: Optional[str] = None
@@ -532,13 +529,12 @@ class KnowledgeProcessor:
             final_decision = "rejected"
 
         if final_decision == "rejected":
-            # 记录 rejected 知识的关系(便于溯源为什么被拒绝)
-            rejected_relationships = []
+            # 记录 rejected 知识的关系到 knowledge_relation 表
             for rel in relations:
                 old_id = rel.get("old_id")
                 rel_type = rel.get("type", "none")
                 if old_id and rel_type != "none":
-                    rejected_relationships.append({"type": rel_type, "target": old_id})
+                    pg_store.add_relation(kid, old_id, rel_type)
                 if rel_type in ("duplicate", "subset") and old_id:
                     try:
                         old = pg_store.get_by_id(old_id)
@@ -557,32 +553,26 @@ class KnowledgeProcessor:
                         pg_store.update(old_id, {"eval": eval_data, "updated_at": now})
                     except Exception as e:
                         print(f"[Apply Decision] 更新旧知识 {old_id} helpful 失败: {e}")
-            pg_store.update(kid, {"status": "rejected", "relationships": json.dumps(rejected_relationships), "updated_at": now})
+            pg_store.update(kid, {"status": "rejected", "updated_at": now})
         else:
-            new_relationships = []
             for rel in relations:
                 rel_type = rel.get("type", "none")
                 reverse_type = rel.get("reverse_type", "none")
                 old_id = rel.get("old_id")
                 if not old_id or rel_type == "none":
                     continue
-                new_relationships.append({"type": rel_type, "target": old_id})
+                pg_store.add_relation(kid, old_id, rel_type)
                 self._relation_cache.add_relation(rel_type, kid)
                 self._relation_cache.add_relation(rel_type, old_id)
                 if reverse_type and reverse_type != "none":
                     try:
-                        old = pg_store.get_by_id(old_id)
-                        if old:
-                            old_rels = old.get("relationships") or []
-                            old_rels.append({"type": reverse_type, "target": kid})
-                            pg_store.update(old_id, {"relationships": json.dumps(old_rels), "updated_at": now})
-                            self._relation_cache.add_relation(reverse_type, old_id)
-                            self._relation_cache.add_relation(reverse_type, kid)
+                        pg_store.add_relation(old_id, kid, reverse_type)
+                        self._relation_cache.add_relation(reverse_type, old_id)
+                        self._relation_cache.add_relation(reverse_type, kid)
                     except Exception as e:
-                        print(f"[Apply Decision] 更新旧知识关系 {old_id} 失败: {e}")
+                        print(f"[Apply Decision] 写入反向关系 {old_id} 失败: {e}")
             pg_store.update(kid, {
                 "status": "dedup_passed",
-                "relationships": json.dumps(new_relationships),
                 "updated_at": now
             })
 
@@ -615,7 +605,7 @@ class KnowledgeProcessor:
             raise
 
     async def _create_or_get_tool_resource(self, tool_info: dict) -> Optional[str]:
-        """创建或获取工具资源(存入 PostgreSQL tool_table)"""
+        """创建或获取工具资源(存入 PostgreSQL tool)"""
         category = tool_info.get("category", "other")
         slug = tool_info.get("slug", "")
         if not slug:
@@ -624,13 +614,13 @@ class KnowledgeProcessor:
         now_ts = int(time.time())
         cursor = pg_store._get_cursor()
         try:
-            cursor.execute("SELECT id FROM tool_table WHERE id = %s", (tool_id,))
+            cursor.execute("SELECT id FROM tool WHERE id = %s", (tool_id,))
             if cursor.fetchone():
                 return tool_id
             cursor.execute("""
-                INSERT INTO tool_table (id, name, version, introduction, tutorial, input, output,
-                                        updated_time, status, tool_knowledge, case_knowledge, process_knowledge)
-                VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+                INSERT INTO tool (id, name, version, introduction, tutorial, input, output,
+                                  updated_time, status)
+                VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
             """, (
                 tool_id,
                 tool_info.get("name", slug),
@@ -641,9 +631,6 @@ class KnowledgeProcessor:
                 json.dumps(tool_info.get("output", "")),
                 now_ts,
                 tool_info.get("status", "未接入"),
-                json.dumps([]),
-                json.dumps([]),
-                json.dumps([]),
             ))
             pg_store.conn.commit()
             print(f"[Tool Resource] 创建新工具: {tool_id}")
@@ -651,33 +638,9 @@ class KnowledgeProcessor:
         finally:
             cursor.close()
 
-    async def _update_tool_knowledge_index(self, tool_id: str, knowledge_id: str, knowledge_types: list = None):
-        """根据知识类型更新工具的关联索引(tool_knowledge / case_knowledge / process_knowledge)"""
-        # 确定目标字段
-        if knowledge_types and 'plan' in knowledge_types:
-            target_field = 'process_knowledge'
-        elif knowledge_types and 'usecase' in knowledge_types:
-            target_field = 'case_knowledge'
-        else:
-            target_field = 'tool_knowledge'
-
-        now_ts = int(time.time())
-        cursor = pg_store._get_cursor()
-        try:
-            cursor.execute(f"SELECT {target_field} FROM tool_table WHERE id = %s", (tool_id,))
-            row = cursor.fetchone()
-            if not row:
-                return
-            knowledge_ids = row[target_field] if isinstance(row[target_field], list) else json.loads(row[target_field] or "[]")
-            if knowledge_id not in knowledge_ids:
-                knowledge_ids.append(knowledge_id)
-                cursor.execute(
-                    f"UPDATE tool_table SET {target_field} = %s, updated_time = %s WHERE id = %s",
-                    (json.dumps(knowledge_ids), now_ts, tool_id)
-                )
-                pg_store.conn.commit()
-        finally:
-            cursor.close()
+    async def _update_tool_knowledge_index(self, tool_id: str, knowledge_id: str):
+        """向工具添加知识关联(写入 tool_knowledge 关联表)"""
+        pg_tool_store.add_knowledge(tool_id, knowledge_id)
 
     async def _analyze_tool_relation(self, knowledge: dict):
         """分析知识与工具的关联关系"""
@@ -712,19 +675,15 @@ class KnowledgeProcessor:
                 pg_store.update(kid, {"status": "approved", "updated_at": now})
                 return
 
-            # 情况3/4:有工具 → 创建资源并关联
+            # 情况3/4:有工具 → 创建工具并关联
             tool_ids = []
             for tool_info in (tool_analysis.get("tools") or []):
                 tool_id = await self._create_or_get_tool_resource(tool_info)
                 if tool_id:
                     tool_ids.append(tool_id)
 
-            existing_resource_ids = knowledge.get("resource_ids") or []
-            updated_resource_ids = list(set(existing_resource_ids + tool_ids))
-
             updates: dict = {
                 "status": "approved",
-                "resource_ids": updated_resource_ids,
                 "updated_at": now
             }
             # 有工具但无 tool tag → 添加 tag
@@ -736,9 +695,9 @@ class KnowledgeProcessor:
 
             pg_store.update(kid, updates)
 
-            k_types = knowledge.get("types") or []
+            # 写入 tool_knowledge 关联
             for tool_id in tool_ids:
-                await self._update_tool_knowledge_index(tool_id, kid, k_types)
+                await self._update_tool_knowledge_index(tool_id, kid)
 
             print(f"[Tool Analysis] {kid} 关联了 {len(tool_ids)} 个工具")
 
@@ -1007,6 +966,128 @@ async def _llm_rerank(query: str, candidates: list[dict], top_k: int) -> list[st
         return []
 
 
+# --- Knowledge Ask / Upload API (Librarian Agent HTTP 接口) ---
+
+
+class KnowledgeAskRequest(BaseModel):
+    query: str
+    trace_id: str  # 必填:调用方的 trace_id,用于 Librarian 续跑
+
+
+class KnowledgeAskResponse(BaseModel):
+    response: str  # 整合后的回答
+    source_ids: list[str] = []
+    sources: list[dict] = []  # [{id, task, content}]
+
+
+class KnowledgeUploadRequest(BaseModel):
+    data: dict  # {tools, resources, knowledge}
+    trace_id: str  # 必填:调用方的 trace_id
+    finalize: bool = False
+
+
+@app.post("/api/knowledge/ask")
+async def ask_knowledge_api(req: KnowledgeAskRequest):
+    """
+    智能知识查询。运行 Librarian Agent 检索 + LLM 整合,返回带引用的结构化结果。
+
+    同步阻塞:Agent 运行完成后返回。
+    trace_id 用于续跑:同一 caller trace_id 复用同一个 Librarian trace,积累上下文。
+    """
+    try:
+        from agents.librarian import ask
+        result = await ask(query=req.query, caller_trace_id=req.trace_id)
+        return KnowledgeAskResponse(**result)
+
+    except Exception as e:
+        print(f"[Knowledge Ask] 错误: {e}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@app.post("/api/knowledge/upload", status_code=202)
+async def upload_knowledge_api(req: KnowledgeUploadRequest, background_tasks: BackgroundTasks):
+    """
+    异步知识上传。校验后立即返回 202,后台运行 Librarian Agent 处理。
+
+    Librarian Agent 负责图谱编排:去重、关联已有 capability/tool、构建关系。
+    """
+    try:
+        data = req.data
+        knowledge_list = data.get("knowledge", [])
+        tools_list = data.get("tools", [])
+        resources_list = data.get("resources", [])
+        total_items = len(knowledge_list) + len(tools_list) + len(resources_list)
+
+        if total_items == 0:
+            raise HTTPException(status_code=400, detail="data 中无有效条目")
+
+        # 存 buffer(便于回溯)
+        from datetime import datetime as dt
+        buffer_dir = Path(".cache/.knowledge/buffer")
+        buffer_dir.mkdir(parents=True, exist_ok=True)
+        timestamp = dt.now().strftime("%Y%m%d_%H%M%S")
+        trace_suffix = f"_{req.trace_id[:8]}" if req.trace_id else ""
+        buffer_file = buffer_dir / f"upload_{timestamp}{trace_suffix}.json"
+        buffer_file.write_text(json.dumps({
+            "data": data, "trace_id": req.trace_id, "finalize": req.finalize,
+            "received_at": dt.now().isoformat(),
+        }, ensure_ascii=False, indent=2), encoding="utf-8")
+
+        # 后台运行 Librarian Agent 处理
+        from agents.librarian import process_upload
+        background_tasks.add_task(
+            process_upload,
+            data=data,
+            caller_trace_id=req.trace_id,
+            buffer_file=str(buffer_file),
+        )
+
+        summary = []
+        if tools_list: summary.append(f"工具: {len(tools_list)}")
+        if resources_list: summary.append(f"资源: {len(resources_list)}")
+        if knowledge_list: summary.append(f"知识: {len(knowledge_list)}")
+
+        return {
+            "status": "accepted",
+            "message": f"已接收 {', '.join(summary)},Librarian Agent 后台处理中",
+        }
+
+    except HTTPException:
+        raise
+    except Exception as e:
+        print(f"[Knowledge Upload] 错误: {e}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@app.get("/api/knowledge/upload/pending")
+async def list_pending_uploads_api():
+    """列出所有未处理或失败的 upload 任务(用于排查和重跑)"""
+    from agents.librarian import list_pending_uploads
+    pending = list_pending_uploads()
+    return {"pending": pending, "count": len(pending)}
+
+
+@app.post("/api/knowledge/upload/retry")
+async def retry_pending_uploads_api(background_tasks: BackgroundTasks):
+    """重跑所有失败的 upload 任务"""
+    from agents.librarian import list_pending_uploads, process_upload
+
+    pending = list_pending_uploads()
+    failed = [p for p in pending if p["status"] == "failed"]
+
+    for item in failed:
+        buffer_file = item["file"]
+        data = json.loads(Path(buffer_file).read_text(encoding="utf-8"))
+        background_tasks.add_task(
+            process_upload,
+            data=data.get("data", {}),
+            caller_trace_id=data.get("trace_id", ""),
+            buffer_file=buffer_file,
+        )
+
+    return {"retried": len(failed), "message": f"已触发 {len(failed)} 个失败任务的重跑"}
+
+
 @app.get("/api/knowledge/search")
 async def search_knowledge_api(
     q: str = Query(..., description="查询文本"),
@@ -1120,15 +1201,14 @@ async def save_knowledge(knowledge: KnowledgeIn, background_tasks: BackgroundTas
             "tag_keys": tag_keys,
             "scopes": knowledge.scopes,
             "owner": owner,
-            "resource_ids": knowledge.resource_ids,
             "source": source,
             "eval": eval_data,
             "created_at": now,
             "updated_at": now,
             "status": "pending",
-            "relationships": json.dumps([]),
-            "support_capability": knowledge.support_capability,
-            "tools": knowledge.tools,
+            "capability_ids": knowledge.capability_ids,
+            "tool_ids": knowledge.tool_ids,
+            "resource_ids": knowledge.resource_ids,
         }
 
         print(f"[Save Knowledge] 插入数据: {json.dumps({k: v for k, v in insert_data.items() if k != 'embedding'}, ensure_ascii=False)}")
@@ -1301,7 +1381,7 @@ def get_knowledge_status(knowledge_id: str):
         return {
             "id": knowledge_id,
             "status": serialized.get("status", "approved"),
-            "relationships": serialized.get("relationships", []),
+            "relations": serialized.get("relations", []),
             "created_at": serialized.get("created_at"),
             "updated_at": serialized.get("updated_at"),
         }
@@ -1444,11 +1524,11 @@ async def patch_knowledge(knowledge_id: str, patch: KnowledgePatchIn):
         if patch.owner is not None:
             updates["owner"] = patch.owner
 
-        if patch.support_capability is not None:
-            updates["support_capability"] = patch.support_capability
+        if patch.capability_ids is not None:
+            updates["capability_ids"] = patch.capability_ids
 
-        if patch.tools is not None:
-            updates["tools"] = patch.tools
+        if patch.tool_ids is not None:
+            updates["tool_ids"] = patch.tool_ids
 
         if not updates:
             return {"status": "ok", "knowledge_id": knowledge_id}
@@ -1506,19 +1586,12 @@ def batch_delete_knowledge(knowledge_ids: List[str] = Body(...)):
         if not knowledge_ids:
             raise HTTPException(status_code=400, detail="knowledge_ids cannot be empty")
 
-        # 批量删除
-        cursor = pg_store._get_cursor()
-        try:
-            cursor.execute(
-                "DELETE FROM knowledge WHERE id = ANY(%s)",
-                (knowledge_ids,)
-            )
-            pg_store.conn.commit()
-            deleted_count = cursor.rowcount
-            print(f"[Batch Delete] 已删除 {deleted_count} 条知识")
-            return {"status": "ok", "deleted_count": deleted_count}
-        finally:
-            cursor.close()
+        deleted_count = 0
+        for kid in knowledge_ids:
+            pg_store.delete(kid)
+            deleted_count += 1
+        print(f"[Batch Delete] 已删除 {deleted_count} 条知识")
+        return {"status": "ok", "deleted_count": deleted_count}
 
     except HTTPException:
         raise
@@ -1771,6 +1844,10 @@ async def slim_knowledge(model: str = "google/gemini-2.5-flash-lite"):
         # 清空并重建(PostgreSQL使用TRUNCATE)
         cursor = pg_store._get_cursor()
         try:
+            # 先清关联表再清主表
+            for jt in ('requirement_knowledge', 'capability_knowledge', 'tool_knowledge',
+                        'knowledge_resource', 'knowledge_relation'):
+                cursor.execute(f"TRUNCATE TABLE {jt}")
             cursor.execute("TRUNCATE TABLE knowledge")
             pg_store.conn.commit()
         finally:
@@ -1805,13 +1882,11 @@ async def slim_knowledge(model: str = "google/gemini-2.5-flash-lite"):
                 "tag_keys": [],
                 "scopes": ["org:cybertogether"],
                 "owner": "agent:slim",
-                "resource_ids": [],
                 "source": source,
                 "eval": eval_data,
                 "created_at": now,
                 "updated_at": now,
                 "status": "approved",
-                "relationships": json.dumps([])
             })
 
         pg_store.insert_batch(knowledge_list)
@@ -1931,13 +2006,11 @@ async def extract_knowledge_from_messages(extract_req: MessageExtractIn, backgro
                 "tag_keys": [],
                 "scopes": ["org:cybertogether"],
                 "owner": extract_req.submitted_by,
-                "resource_ids": [],
                 "source": source,
                 "eval": eval_data,
                 "created_at": now,
                 "updated_at": now,
                 "status": "pending",
-                "relationships": json.dumps([]),
             })
             knowledge_ids.append(knowledge_id)
 
@@ -1981,10 +2054,9 @@ async def create_tool(tool: ToolIn):
             'output': tool.output,
             'updated_time': now,
             'status': tool.status,
-            'capabilities': tool.capabilities,
-            'tool_knowledge': tool.tool_knowledge,
-            'case_knowledge': tool.case_knowledge,
-            'process_knowledge': tool.process_knowledge,
+            'capability_ids': tool.capability_ids,
+            'knowledge_ids': tool.knowledge_ids,
+            'provider_ids': tool.provider_ids,
             'embedding': embedding,
         })
         return {"status": "ok", "id": tool.id}
@@ -2047,7 +2119,7 @@ async def patch_tool(tool_id: str, patch: ToolPatchIn):
         need_reembed = False
 
         for field in ('name', 'version', 'introduction', 'tutorial', 'input', 'output',
-                       'status', 'capabilities', 'tool_knowledge', 'case_knowledge', 'process_knowledge'):
+                       'status', 'capability_ids', 'knowledge_ids', 'provider_ids'):
             value = getattr(patch, field)
             if value is not None:
                 updates[field] = value
@@ -2099,10 +2171,10 @@ async def create_capability(cap: CapabilityIn):
             'name': cap.name,
             'criterion': cap.criterion,
             'description': cap.description,
-            'requirements': cap.requirements,
+            'requirement_ids': cap.requirement_ids,
             'implements': cap.implements,
-            'tools': cap.tools,
-            'source_knowledge': cap.source_knowledge,
+            'tool_ids': cap.tool_ids,
+            'knowledge_ids': cap.knowledge_ids,
             'embedding': embedding,
         })
         return {"status": "ok", "id": cap.id}
@@ -2163,8 +2235,8 @@ async def patch_capability(cap_id: str, patch: CapabilityPatchIn):
         updates = {}
         need_reembed = False
 
-        for field in ('name', 'criterion', 'description', 'requirements',
-                       'implements', 'tools', 'source_knowledge'):
+        for field in ('name', 'criterion', 'description', 'requirement_ids',
+                       'implements', 'tool_ids', 'knowledge_ids'):
             value = getattr(patch, field)
             if value is not None:
                 updates[field] = value
@@ -2211,7 +2283,8 @@ async def create_requirement(req: RequirementIn):
         pg_requirement_store.insert_or_update({
             'id': req.id,
             'description': req.description,
-            'atomics': req.atomics,
+            'capability_ids': req.capability_ids,
+            'knowledge_ids': req.knowledge_ids,
             'source_nodes': req.source_nodes,
             'status': req.status,
             'match_result': req.match_result,
@@ -2276,7 +2349,7 @@ async def patch_requirement(req_id: str, patch: RequirementPatchIn):
         updates = {}
         need_reembed = False
 
-        for field in ('description', 'atomics', 'source_nodes', 'status', 'match_result'):
+        for field in ('description', 'capability_ids', 'knowledge_ids', 'source_nodes', 'status', 'match_result'):
             value = getattr(patch, field)
             if value is not None:
                 updates[field] = value