Parcourir la source

Merge branch 'main' of https://git.yishihui.com/howard/Agent

guantao il y a 7 heures
Parent
commit
df03e2a4dd
87 fichiers modifiés avec 5007 ajouts et 2240 suppressions
  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. 296 0
      agent/docs/cognition-log-plan.md
  7. 277 0
      agent/docs/context-management.md
  8. 1 1
      agent/docs/decisions.md
  9. 0 201
      agent/docs/knowledge.md
  10. 416 0
      agent/docs/memory-plan.md
  11. 189 0
      agent/docs/prompt-guidelines.md
  12. 1 1
      agent/docs/trace-api.md
  13. 1 1
      agent/tools/builtin/__init__.py
  14. 0 209
      agent/tools/builtin/knowledge_manager.py
  15. 180 0
      agent/tools/builtin/librarian.py
  16. 29 29
      agent/trace/goal_tool.py
  17. 1 1
      agent/trace/models.py
  18. 152 66
      agent/trace/store.py
  19. 1 1
      docs/README.md
  20. 77 0
      examples/production_plan/generation-agent-architecture.md
  21. 1 1
      frontend/API.md
  22. 67 200
      knowhub/README.md
  23. 0 341
      knowhub/agents/knowledge_manager.py
  24. 0 64
      knowhub/agents/librarian.prompt
  25. 329 0
      knowhub/agents/librarian.py
  26. 48 0
      knowhub/agents/librarian_agent.prompt
  27. 0 0
      knowhub/agents/librarian_agent.prompt.bak
  28. 0 61
      knowhub/agents/run_knowledge_manager.py
  29. 26 0
      knowhub/agents/skills/ask_strategy.md
  30. 34 0
      knowhub/agents/skills/upload_strategy.md
  31. 203 0
      knowhub/docs/api.md
  32. 0 0
      knowhub/docs/archive/agents-readme.md
  33. 0 0
      knowhub/docs/archive/dedup-design.md
  34. 103 53
      knowhub/docs/archive/knowledge-management.md
  35. 0 0
      knowhub/docs/archive/migration-guide.md
  36. 0 0
      knowhub/docs/archive/resource-storage.md
  37. 2 0
      knowhub/docs/dashboard-plan.md
  38. 0 269
      knowhub/docs/feedback-timing-design.md
  39. 4 2
      knowhub/docs/frontend-restructure-plan.md
  40. 128 0
      knowhub/docs/knowhub-code-review-feedback.md
  41. 87 0
      knowhub/docs/librarian-agent.md
  42. 112 0
      knowhub/docs/processing-pipeline.md
  43. 95 0
      knowhub/docs/schema-migration-plan.md
  44. 254 0
      knowhub/docs/schema.md
  45. 0 0
      knowhub/docs/user-feedback-plan.md
  46. 13 16
      knowhub/frontend/src/pages/Capabilities.tsx
  47. 16 16
      knowhub/frontend/src/pages/Dashboard.tsx
  48. 2 2
      knowhub/frontend/src/pages/Knowledge.tsx
  49. 2 2
      knowhub/frontend/src/pages/Requirements.tsx
  50. 7 9
      knowhub/frontend/src/pages/Tools.tsx
  51. 0 2
      knowhub/internal_tools/cache_manager.py
  52. 31 233
      knowhub/knowhub_db/README.md
  53. 47 0
      knowhub/knowhub_db/cascade.py
  54. 0 0
      knowhub/knowhub_db/migrations/create_tables.py
  55. 0 0
      knowhub/knowhub_db/migrations/fill_cap_relations.py
  56. 0 0
      knowhub/knowhub_db/migrations/fill_cap_tools.py
  57. 0 0
      knowhub/knowhub_db/migrations/fill_embeddings.py
  58. 0 0
      knowhub/knowhub_db/migrations/fill_knowledge_tools.py
  59. 0 0
      knowhub/knowhub_db/migrations/fix_embedding_migration.py
  60. 0 0
      knowhub/knowhub_db/migrations/import_initial_data.py
  61. 0 0
      knowhub/knowhub_db/migrations/import_tool_research_data.py
  62. 0 0
      knowhub/knowhub_db/migrations/migrate_add_implemented_tools.py
  63. 0 0
      knowhub/knowhub_db/migrations/migrate_knowledge.py
  64. 0 0
      knowhub/knowhub_db/migrations/migrate_resources.py
  65. 0 0
      knowhub/knowhub_db/migrations/migrate_tables_v2.py
  66. 471 0
      knowhub/knowhub_db/migrations/migrate_to_new_db.py
  67. 0 0
      knowhub/knowhub_db/migrations/migrate_tools.py
  68. 431 0
      knowhub/knowhub_db/migrations/migrate_v3_junction_tables.py
  69. 0 0
      knowhub/knowhub_db/migrations/reclassify_tool_knowledge.py
  70. 0 0
      knowhub/knowhub_db/migrations/update_case_knowledge.py
  71. 99 43
      knowhub/knowhub_db/pg_capability_store.py
  72. 89 38
      knowhub/knowhub_db/pg_requirement_store.py
  73. 9 8
      knowhub/knowhub_db/pg_resource_store.py
  74. 162 51
      knowhub/knowhub_db/pg_store.py
  75. 121 64
      knowhub/knowhub_db/pg_tool_store.py
  76. 0 0
      knowhub/knowhub_db/scripts/check_extensions.py
  77. 0 0
      knowhub/knowhub_db/scripts/check_fastann.py
  78. 0 0
      knowhub/knowhub_db/scripts/check_table_structure.py
  79. 0 0
      knowhub/knowhub_db/scripts/clean_invalid_knowledge_refs.py
  80. 0 0
      knowhub/knowhub_db/scripts/clean_resource_knowledge_refs.py
  81. 0 0
      knowhub/knowhub_db/scripts/clean_tool_research_data.py
  82. 0 0
      knowhub/knowhub_db/scripts/clear_locks.py
  83. 0 0
      knowhub/knowhub_db/scripts/kill_db_locks.py
  84. 0 0
      knowhub/knowhub_db/scripts/list_databases.py
  85. 0 0
      knowhub/knowhub_db/scripts/test_imports.py
  86. 0 0
      knowhub/knowhub_db/scripts/test_pg_connection.py
  87. 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:
@@ -1793,8 +1822,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
@@ -2163,6 +2207,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 处理                  |

+ 296 - 0
agent/docs/cognition-log-plan.md

@@ -0,0 +1,296 @@
+# Cognition Log 与知识反馈设计
+
+## 文档维护规范
+
+0. **先改文档,再动代码** - 新功能或重大修改需先完成文档更新、并完成审阅后,再进行代码实现;除非改动较小、不被文档涵盖
+1. **文档分层,链接代码** - 重要或复杂设计可以另有详细文档;关键实现需标注代码文件路径;格式:`module/file.py:function_name`
+2. **简洁快照,日志分离** - 只记录最重要的、与代码准确对应的或者明确的已完成的设计的信息,避免推测、建议、决策历史、修改日志、大量代码;决策依据或修改日志若有必要,可在 `knowhub/docs/decisions.md` 另行记录
+
+---
+
+## 概述
+
+每个 trace 维护一个 `cognition_log.json`,按时间顺序记录所有认知事件(知识查询、评估、提取、反思),为知识质量反馈和 Memory 系统的 dream 操作(详见 `agent/docs/memory-plan.md`)提供数据。
+
+> 此文件原名 `knowledge_log.json`,扩展为统一事件流后更名。
+
+---
+
+## Cognition Log 数据结构
+
+**位置**:`.trace/{trace_id}/cognition_log.json`
+
+```json
+{
+  "trace_id": "trace-xxx",
+  "events": [
+    { "type": "query", "sequence": 42, ... },
+    { "type": "evaluation", "sequence": 66, ... },
+    { "type": "extraction", "sequence": 88, ... },
+    { "type": "reflection", "sequence": 120, ... }
+  ]
+}
+```
+
+所有事件按 `sequence` 排列,保持在 trace 中的时间顺序。
+
+---
+
+## 事件类型
+
+### `query`:知识查询
+
+Agent 通过 `POST /api/knowledge/ask` 查询知识时记录。一次查询返回 KM Agent 的整合回答及引用的各 source,作为一个整体记录。
+
+```json
+{
+  "type": "query",
+  "sequence": 42,
+  "goal_id": "1",
+  "query": "goal 的描述文本",
+  "response": "KM Agent 整合后的回答...",
+  "source_ids": ["knowledge-a1b2", "knowledge-c3d4", "knowledge-e5f6"],
+  "sources": [
+    {"id": "knowledge-a1b2", "task": "...", "content": "...(截断500字)"},
+    {"id": "knowledge-c3d4", "task": "...", "content": "..."}
+  ],
+  "timestamp": "2026-03-20T10:00:00"
+}
+```
+
+**写入时机**:`agent/trace/goal_tool.py:inject_knowledge_for_goal`,`POST /api/knowledge/ask` 返回后。
+
+### `evaluation`:知识评估
+
+对某次 query 中各 source 的使用效果评估。通过 `query_sequence` 关联到对应的 query 事件。
+
+```json
+{
+  "type": "evaluation",
+  "sequence": 66,
+  "query_sequence": 42,
+  "trigger": "goal_completion",
+  "assessments": [
+    {"source_id": "knowledge-a1b2", "status": "helpful", "reason": "准确定位了问题"},
+    {"source_id": "knowledge-c3d4", "status": "irrelevant", "reason": "与当前任务无关"},
+    {"source_id": "knowledge-e5f6", "status": "harmful", "reason": "建议的方法已过时"}
+  ],
+  "timestamp": "2026-03-20T10:05:00"
+}
+```
+
+**`status` 可能的值**:
+
+| 状态 | 含义 |
+|---|---|
+| `irrelevant` | 知识与当前任务无关 |
+| `unused` | 知识相关但未被使用 |
+| `helpful` | 知识对任务有实质帮助 |
+| `harmful` | 知识对任务产生负面作用 |
+| `neutral` | 知识相关但无明显影响 |
+
+### `extraction`:知识提取
+
+Agent 通过 reflection 侧分支将知识上传到 KnowHub 时记录。
+
+```json
+{
+  "type": "extraction",
+  "sequence": 88,
+  "trigger": "compression",
+  "items": [
+    {"knowledge_id": "knowledge-new-1", "type": "experience", "task": "...", "content": "...(截断500字)"}
+  ],
+  "timestamp": "2026-03-20T10:10:00"
+}
+```
+
+**写入时机**:reflection 侧分支中 `upload_knowledge` 调用成功后。
+
+### `reflection`:记忆反思
+
+仅 memory-bearing Agent 使用(详见 `agent/docs/memory-plan.md`)。Dream 操作触发的 per-trace 记忆反思。
+
+```json
+{
+  "type": "reflection",
+  "sequence": 120,
+  "reflected_range": [43, 120],
+  "summary": "这次执行中发现用户偏好XX方向...",
+  "timestamp": "2026-04-07T20:00:00"
+}
+```
+
+**写入时机**:dream 操作中 per-trace 反思完成后。
+
+---
+
+## 评估触发机制
+
+评估针对的是未评估的 query 事件(即存在 query 事件但没有对应 evaluation 事件的)。
+
+**判断待评估条件**:查找 cognition_log 中所有 `type: "query"` 事件,检查是否存在 `query_sequence` 指向该 query 的 `type: "evaluation"` 事件。
+
+### 触发点 1:Goal 完成
+
+**时机**:Goal status 变为 `completed` 或 `abandoned`
+
+**触发逻辑**(`agent/trace/store.py:update_goal`):
+
+```
+Goal 完成
+  ↓
+查询 cognition_log 中未评估的 query 事件
+  ↓
+如果有待评估
+  → 设置 trace.context["pending_knowledge_eval"] = true
+  → 设置 trace.context["knowledge_eval_trigger"] = "goal_completion"
+  ↓
+Runner 主循环下一次迭代开头检测到标志(agent/core/runner.py:_agent_loop)
+  → 清除标志
+  → 将 "knowledge_eval" 加入 force_side_branch 队列
+```
+
+### 触发点 2:压缩
+
+**时机**:上下文 token 数超过阈值,即将执行压缩
+
+**触发逻辑**(`agent/core/runner.py:_manage_context_usage`):
+
+```
+压缩条件触发
+  ↓
+查询 cognition_log 中未评估的 query 事件
+  ↓
+如果有待评估
+  → 设置 trace.context["knowledge_eval_trigger"] = "compression"
+  → 侧分支队列:["reflection", "knowledge_eval", "compression"](启用知识提取时)
+  →            或 ["knowledge_eval", "compression"](未启用时)
+  → 返回"需要进入侧分支"信号,暂缓压缩
+```
+
+压缩会删除消息历史,必须在压缩前完成评估。
+
+### 触发点 3:任务结束(兜底)
+
+**时机**:主路径无工具调用,Agent 即将结束
+
+**触发逻辑**(`agent/core/runner.py:_agent_loop`):
+
+```
+任务即将结束
+  ↓
+查询 cognition_log 中未评估的 query 事件
+  ↓
+如果有待评估
+  → 设置 trace.context["knowledge_eval_trigger"] = "task_completion"
+  → 将 ["knowledge_eval"] 加入 force_side_branch 队列
+  → continue(不 break,下一轮执行评估)
+```
+
+---
+
+## 侧分支评估流程
+
+### 侧分支类型
+
+复用 `SideBranchContext` 机制,类型 `"knowledge_eval"`(`agent/trace/models.py:Message.branch_type`)。
+
+### 评估 Prompt 结构
+
+完整实现:`agent/core/runner.py:_build_knowledge_eval_prompt`
+
+```
+你是知识评估助手。请评估以下知识查询结果在本次任务执行中的实际效果。
+
+## 当前任务(Mission)       ← trace.task
+## 当前 Goal                ← goal_tree.current 的 description
+## 待评估知识查询            ← 未评估的 query 事件列表
+  对每个 query:展示 query 文本、整合回答、各 source 的 id/task/content
+## 评估要求                  ← 按 source_id 逐一评估
+## 评估分类                  ← 5 个 status 选项
+## 输出格式                  ← JSON
+```
+
+Prompt 中**不包含消息历史**。LLM 依据对话上下文中已有的执行过程作出判断。
+
+### 评估输出格式
+
+LLM 直接输出 JSON:
+
+```json
+{
+  "evaluations": [
+    {
+      "query_sequence": 42,
+      "assessments": [
+        {"source_id": "knowledge-a1b2", "status": "helpful", "reason": "..."},
+        {"source_id": "knowledge-c3d4", "status": "irrelevant", "reason": "..."}
+      ]
+    }
+  ]
+}
+```
+
+### 即时写入
+
+每次 LLM 回复后立即解析,三种策略降级:整体 JSON → ` ```json ` 代码块 → 正则裸对象。
+
+解析成功 → 为每个 query 写入对应的 `evaluation` 事件到 cognition_log。解析失败记日志,不中断。
+
+---
+
+## 数据流
+
+```
+知识查询(agent/trace/goal_tool.py:inject_knowledge_for_goal)
+  ↓
+POST /api/knowledge/ask → KM Agent 整合回答
+  ↓
+写入 cognition_log: type="query"(含 response + source_ids)
+  ↓
+  ┌─────────────────────────────────────────────┐
+  │  触发点 A:Goal 完成(goal_completion)       │
+  │  触发点 B:压缩执行前(compression)          │
+  │  触发点 C:任务自然结束(task_completion)    │
+  └─────────────────────────────────────────────┘
+  ↓
+Runner 进入 knowledge_eval 侧分支
+  ↓
+LLM 按 query 维度、逐 source 评估,输出 JSON
+  ↓
+写入 cognition_log: type="evaluation"(含 assessments per source)
+  ↓
+侧分支退出 → 恢复主路径
+
+                    ···
+
+知识提取(reflection 侧分支中 upload_knowledge 成功后)
+  ↓
+写入 cognition_log: type="extraction"
+
+                    ···
+
+Dream 触发(memory-bearing Agent,详见 agent/docs/memory-plan.md)
+  ↓
+读取 cognition_log 全部事件 → per-trace 记忆反思
+  ↓
+写入 cognition_log: type="reflection"
+```
+
+---
+
+## 与现有系统的集成点
+
+| 集成位置 | 文件 | 说明 |
+|---|---|---|
+| 知识查询时写 log | `agent/trace/goal_tool.py:inject_knowledge_for_goal` | `goal(focus=...)` 触发 ask → 写入 `query` 事件 |
+| Goal 完成时设置标志 | `agent/trace/store.py:update_goal` | 设置 `trace.context["pending_knowledge_eval"]` |
+| 主循环检测标志 | `agent/core/runner.py:_agent_loop` | 每轮迭代开头检测,触发 `["knowledge_eval"]` |
+| 压缩前触发评估 | `agent/core/runner.py:_manage_context_usage` | 压缩前检查 pending,先评估再压缩 |
+| 任务结束兜底 | `agent/core/runner.py:_agent_loop` | 退出前检查 pending,强制触发评估 |
+| 侧分支类型 | `agent/trace/models.py:Message.branch_type` | Literal 中包含 `"knowledge_eval"` |
+| 即时写入评估 | `agent/core/runner.py:_agent_loop` | 解析 JSON 后写入 `evaluation` 事件 |
+| 知识提取记录 | `agent/core/runner.py` | reflection 侧分支中 upload 成功后写入 `extraction` 事件 |
+| 记忆反思记录 | dream 操作 | per-trace 反思后写入 `reflection` 事件 |
+| Log 文件管理 | `agent/trace/store.py` | 待重构:从 entries[] 改为 events[] |

+ 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)
 
 ---
 

+ 0 - 201
agent/docs/knowledge.md

@@ -1,201 +0,0 @@
-- 产生知识时区分:人、场景(例如飞书群,可以对应到tags)、业务(还有子业务)
-
-**知识结构**(单条知识)
-    id: 知识唯一标识
-    task: 任务描述,什么场景、在做什么
-
-    type: 知识类型(单选)
-        user_profile:用户画像(偏好、习惯、背景、约束)
-        strategy:具体执行经验,从执行过程中反思获得
-        tool:工具名称,简介,使用方法,优缺点对比,代码示例
-        usecase:用户背景,采用方案,实现步骤,问题,效果
-        definition:概念定义,技术原理,应用场景
-        plan:流程步骤,决策点,某个问题的总结方法论
-
-    tags: 业务标签(可有多个)
-        category: 子类别(如 preference, background, habit, constraint)
-        domain: 领域(如 coding_style, architecture)
-        其他自定义标签
-        scenario
-
-    scopes: 可见范围(可有多个,格式:{entity_type}:{entity_id})
-        user:{user_id}:用户级(个人可见)
-        agent:{agent_id}:Agent 级(特定 Agent 可见)
-        project:{project_id}:项目级(项目组可见)
-        team:{team_id}:团队级(部门可见)
-        org:{org_id}:组织级(全公司可见)
-        public:公开(所有人可见)
-
-    owner: 所有者(格式:{entity_type}:{entity_id},唯一)
-        谁创建的,谁有权修改/删除
-
-    content:
-        基于类型的具体内容,相对完整的一条知识
-
-    source:
-        name: 工具/资源名称(若有)
-        category:paper/exp/skill/book...
-        urls:知识来源的网站
-        agent_id:调用知识查询的agent名称
-        submitted_by:创建本条目的agent负责人名称
-        timestamp: 知识生成时的时间戳
-        trace_id: 来源 Trace ID(若有)
-
-    eval:基于使用反馈
-        helpful: 好用的次数
-        harmful:不好用的次数
-        confidence: 置信度(0-1)
-        helpful_history: [(query+trace_id+outcome), ]用于记录反馈时的调用总结
-        harmful_history: []
-
-知识检索机制
-    检索流程
-        1. 构建可见范围
-            根据执行上下文(user_id, agent_id, project_id, team_id, org_id)
-            构建用户的所有可见 scopes:
-                - user:{user_id}
-                - agent:{agent_id}
-                - project:{project_id}
-                - team:{team_id}
-                - org:{org_id}
-                - public
-
-        2. 向量检索 + 过滤
-            查询条件:
-                - 语义匹配(向量检索)
-                - scopes 过滤(知识的 scopes 与用户的 visible_scopes 有交集)
-                - type 过滤(可选,按知识类型过滤)
-
-        3. 按优先级排序
-            优先级:user > project > agent > team > org > public
-            取知识的 scopes 中优先级最高的进行排序
-
-    触发时机
-        - Agent 启动时:自动检索相关知识
-        - Goal focus 时:检索与当前目标相关的知识
-        - 主动调用:通过 get_knowledge 工具主动查询
-
-    实现位置
-        - agent/tools/builtin/knowledge.py: 知识管理工具(KnowHub API 封装)+ KnowledgeConfig
-        - agent/tools/builtin/knowledge.py: knowledge_search / knowledge_save 工具
-知识提取机制
-    触发时机
-        - 主动表达:用户明确表达偏好、纠正、提供背景信息
-        - 任务完成:任务完成后的反思总结
-        - 压缩消息:在压缩消息节点提取经验
-        - 用户反馈:用户对结果的 helpful/harmful 评价
-
-    提取流程
-        1. 识别知识类型
-            根据内容判断是 user_profile / strategy / tool / definition / plan / usecase
-
-        2. 结构化提取
-            - 提取核心内容
-            - 生成标签(category, domain 等)
-            - 确定 scopes(基于执行上下文)
-            - 设置 owner 和 visibility
-
-        3. 持久化存储
-            - 存入知识库(向量数据库 + 元数据)
-            - 记录来源(trace_id, agent_id, timestamp)
-            - 初始化评价(helpful=1, confidence=0.5)
-
-    实现位置(规划)
-        - agent/trace/compaction.py: 压缩时提取经验
-        - agent/tools/builtin/knowledge.py: knowledge_save 工具
-用户画像特殊处理
-    用户画像是一种特殊的知识类型(type=user_profile),具有以下特点:
-
-    获取方式
-        - 持续性:在整个对话过程中持续积累
-        - 多触发点:主动表达 > 任务完成 > 周期性总结
-        - 渐进式:逐步完善,置信度逐渐提高
-
-    存储特点
-        - type: user_profile
-        - scopes: 通常包含 user:{user_id},可能包含 project:{project_id}
-        - owner: user:{user_id}
-        - tags.category: preference | background | habit | constraint
-
-    检索优先级
-        - 在 Agent 启动时自动加载
-        - 优先级高于其他类型的知识
-        - 按 confidence 和 helpful 评分排序
-
-    示例
-        {
-            "id": "profile_001",
-            "type": "user_profile",
-            "task": "用户编码风格偏好",
-            "tags": {
-                "category": "preference",
-                "domain": "coding_style"
-            },
-            "scopes": ["user:123", "project:456"],
-            "owner": "user:123",
-            "visibility": "shared",
-            "content": "用户偏好使用 TypeScript 而非 JavaScript,注重类型安全",
-            "source": {
-                "agent_id": "general_assistant",
-                "trace_id": "trace-xxx",
-                "timestamp": "2026-03-03"
-            },
-            "eval": {
-                "helpful": 5,
-                "harmful": 0,
-                "confidence": 0.9
-            }
-        }
-存储结构(规划)
-    数据库表结构
-        CREATE TABLE knowledge (
-            id TEXT PRIMARY KEY,
-            type TEXT NOT NULL,              -- user_profile | strategy | tool | ...
-            task TEXT,
-            tags JSON,
-            scopes JSON,                     -- ["user:123", "project:456", ...]
-            owner TEXT NOT NULL,             -- "user:123" | "agent:xxx"
-            visibility TEXT,                 -- private | shared | org | public
-            content TEXT,
-            source JSON,
-            eval JSON,
-            embedding VECTOR(1536),          -- 向量检索
-            created_at TIMESTAMP,
-            updated_at TIMESTAMP
-        );
-
-        -- 索引
-        CREATE INDEX idx_type ON knowledge(type);
-        CREATE INDEX idx_owner ON knowledge(owner);
-        CREATE INDEX idx_visibility ON knowledge(visibility);
-        CREATE INDEX idx_scopes ON knowledge USING GIN(scopes);
-
-    文件系统结构
-        /workspace/knowledge/
-        ├── global/                   # 全局知识
-        │   ├── tools.json
-        │   ├── definitions.json
-        │   └── plans.json
-        │
-        ├── agents/                   # Agent 经验
-        │   ├── general_assistant/
-        │   ├── crawler_ops/
-        │   └── ...
-        │
-        └── users/                    # 用户画像
-            ├── user_123/
-            │   ├── profile.json      # 基础信息
-            │   ├── preferences.json  # 偏好
-            │   ├── habits.json       # 习惯
-            │   └── constraints.json  # 约束
-            └── ...
-
-TODO
-    1. ~~实现知识存储接口~~ → 已通过 KnowHub API 实现(agent/tools/builtin/knowledge.py)
-    2. ~~实现知识检索逻辑~~ → knowledge_search 工具
-    3. ~~实现 knowledge_save 工具~~ → 已实现
-    4. ~~实现 knowledge_search 工具~~ → 已实现
-    5. ~~在 Agent 启动时集成知识检索~~ → 已实现(goal focus 时注入)
-    6. ~~在 Goal focus 时集成知识检索~~ → 已实现(agent/trace/goal_tool.py:inject_knowledge_for_goal)
-    7. ~~在压缩消息时集成知识提取~~ → 已实现(agent/core/runner.py:_run_reflect)
-    8. 实现用户画像的特殊处理逻辑

+ 416 - 0
agent/docs/memory-plan.md

@@ -0,0 +1,416 @@
+# Memory 系统与元思考机制设计
+
+> 状态:设计讨论中,未实现
+
+---
+
+## 概述
+
+本文档设计两个紧密关联的能力:
+
+1. **Memory**:为需要跨任务维持身份的 Agent 提供持久化记忆(Markdown 文件)
+2. **统一元思考机制**:整合现有的知识提取(reflection)、知识评估(knowledge_eval)和新增的记忆反思(dream),形成完整的"Agent 自我认知"能力
+
+---
+
+## 一、现有元思考机制梳理
+
+当前框架已实现两种元思考能力,都基于侧分支(side branch)机制:
+
+### 1.1 知识提取(reflection 侧分支)
+
+**目的**:从执行过程中提取客观知识,保存到 KnowHub(全局共享知识库)。
+
+**触发时机**:
+- 压缩前(`_manage_context_usage`,`runner.py:825-829`)
+- 任务完成后(`runner.py:1834-1838`,`enable_completion_extraction`)
+
+**输出**:调用 `upload_knowledge` 工具,保存 experience/tool/strategy/case 到 KnowHub。
+
+**Prompt**:`REFLECT_PROMPT`(压缩时)和 `COMPLETION_REFLECT_PROMPT`(任务完成后),定义在 `agent/core/prompts/knowledge.py`。
+
+**已知问题**:任务完成时触发的 reflection 使用 `config.knowledge.get_reflect_prompt()`(`runner.py:1249`),没有区分压缩场景和完成场景。应该在完成场景使用 `get_completion_reflect_prompt()`。
+
+### 1.2 知识评估(knowledge_eval 侧分支)
+
+**目的**:评估被注入的知识是否有用,记录到本地 `knowledge_log.json`。
+
+**触发时机**(详见 `knowhub/docs/cognition-log-plan.md`):
+- Goal 完成时(`store.py:update_goal`,设置 `pending_knowledge_eval` 标志)
+- 压缩前(必须在压缩前完成评估,否则执行上下文丢失)
+- 任务结束时(兜底)
+
+**输出**:每条被注入的知识获得评估(irrelevant / unused / helpful / harmful / neutral),写入 `knowledge_log.json`。
+
+**当前局限**:评估结果只存本地,不自动回传 KnowHub。只有用户通过 API 手动反馈才同步。
+
+### 1.3 侧分支队列机制
+
+两种元思考通过 `force_side_branch` 队列协调执行顺序(`runner.py:1198-1207`):
+
+```
+压缩触发时的典型队列:
+  ["reflection", "knowledge_eval", "compression"]
+  
+任务完成时:
+  ["knowledge_eval"](先评估)→ ["reflection"](再提取)
+```
+
+每个侧分支执行完后 pop 队首,继续下一个,直到队列清空回到主路径。
+
+---
+
+## 二、缺失的能力:个人记忆
+
+### 问题
+
+现有的两种元思考都面向**全局共享知识**(KnowHub)。但有一类 Agent 需要维护**主观的、属于自己的长期记忆**:
+
+- 品味偏好、策略判断、风格积累、用户反馈
+- 属于特定 Agent 身份,不是公共知识
+- 需要人类能直接阅读和修改
+- 跨多次 trace 持续积累和演化
+- 可能被同一身份下的多个 Agent run 共享读写
+
+KnowHub 不适合承担这个职责——它是"大众点评",不是"个人日记"。
+
+### 记忆文件
+
+记忆以 Markdown 文件存储在指定目录下。每个文件覆盖一个语义维度。
+
+```
+{base_path}/
+├── taste.md        # 偏好判断
+├── strategy.md     # 当前策略
+├── skills.md       # 积累的技巧
+└── ...
+```
+
+**为什么是 Markdown 文件**:
+- 人类可直接阅读和修改(vim/VS Code 打开就能改)
+- Git 版本控制
+- Agent 用 read_file/write_file 工具即可操作,无需新增工具
+- 文件数量少(几个到十几个),不需要检索能力
+
+**共享读写**:同一身份下的多个 Agent run 可以读写同一组记忆文件。哪个 Agent 该关注哪些文件、怎么更新,由 dream prompt 来定义。
+
+---
+
+## 三、统一元思考模型
+
+### 三种元思考及其定位
+
+| | 知识提取(已有) | 知识评估(已有) | 记忆反思(新增) |
+|---|---|---|---|
+| **回答的问题** | 我学到了什么客观知识? | 给我的知识有没有用? | 这些经历对我的偏好/策略意味着什么? |
+| **输出目的地** | KnowHub(全局共享) | knowledge_log.json(本地) | 记忆文件(Agent 身份私有) |
+| **触发时机** | 压缩前、任务完成后 | Goal 完成、压缩前、任务结束 | Dream 时(见下文) |
+| **时效性要求** | 高(压缩会丢上下文) | 高(压缩会丢上下文) | 低(可以延迟处理) |
+| **实现机制** | reflection 侧分支 | knowledge_eval 侧分支 | dream 操作(新增) |
+
+**关键区分**:知识提取和知识评估必须在上下文丢失前完成(压缩前/任务结束时),所以是侧分支、即时触发。记忆反思可以延迟——甚至应该延迟,因为用户可能还有反馈、Agent 可能继续执行。
+
+### 为什么记忆反思不在 trace 结束时做
+
+trace 结束只意味着 Agent 行动完一个轮次。后续可能发生:
+- 用户在飞书里说"这个方向不对"
+- Agent 被唤醒在同一任务下继续执行
+- 新的 trace 产生了推翻前一个 trace 结论的信息
+
+如果 trace 一结束就做记忆反思,这些后续信息会被忽略。记忆反思的价值在于**综合一段时间的经历**,不是记录每次行动的即时感受。
+
+### 但知识提取仍然在压缩/完成时做
+
+这不矛盾。知识提取保存的是**客观知识**(工具用法、调研结果),这些不会因为后续反馈而失效。而且压缩会删除历史,如果不在压缩前提取,知识就永久丢失了。
+
+---
+
+## 四、Dream:记忆反思的触发与流程
+
+### 什么是 Dream
+
+Dream 是 memory-bearing Agent 的记忆整理操作。它不是一个侧分支(不在某个 trace 的执行过程中插入),而是一个**独立的顶层操作**,回顾多个 trace 的执行历史,更新记忆文件。
+
+### 触发方式
+
+- Agent 主动调用 `dream` 工具("我觉得该整理一下了")
+- 外部调度触发(定时、或人工 CLI 触发)
+- 框架可以在 run 启动时检测距上次 dream 的时间/trace 数量,建议 Agent dream
+
+### 反思状态追踪
+
+```
+Trace 模型新增字段:
+- reflected_at_sequence: Optional[int]    # 上次记忆反思时的最新 message sequence
+                                           # None = 从未被记忆反思处理
+```
+
+反思摘要不存在 Trace 模型中,而是作为 `reflection` 事件写入 `cognition_log.json`(详见 `knowhub/docs/cognition-log-plan.md`)。
+
+- Agent run 产生新 message → `reflected_at_sequence` 自然落后于实际 sequence
+- 记忆反思完成 → 更新 `reflected_at_sequence` 为当前最新 sequence
+- Dream 扫描 `reflected_at_sequence < latest_sequence` 的 trace
+
+### Dream 流程
+
+```
+Dream 触发
+  │
+  ├─ Step 1: 扫描该 Agent 身份下所有 trace
+  │   找到 reflected_at_sequence < latest_sequence 的 trace
+  │
+  ├─ Step 2: Per-trace 记忆反思(逐个 trace)
+  │   对每个需要反思的 trace:
+  │   a. 加载 reflected_at_sequence 之后的消息(增量)
+  │   b. 同时加载该 trace 的 cognition_log.json(查询、评估、提取事件)
+  │   c. 用 reflect_prompt 生成反思摘要
+  │   d. 摘要作为 reflection 事件写入 cognition_log.json
+  │   e. 更新 reflected_at_sequence
+  │
+  ├─ Step 3: 跨 trace 整合
+  │   a. 收集各 trace 的 reflection 事件(cognition_log 中 type="reflection")
+  │   b. 读取当前记忆文件
+  │   c. 汇总 cognition_log 中的评估趋势(多次 harmful/unused 的 source 模式)
+  │   d. 用 dream_prompt 指导 LLM 更新记忆文件
+  │   e. 标记 reflection 事件为已消化
+  │
+  └─ 完成
+```
+
+### Per-trace 反思的输入
+
+反思 prompt 看到的不只是"发生了什么",还包括知识评估结果:
+
+```
+## 执行过程
+[该 trace 中 reflected_at_sequence 之后的消息]
+
+## 知识使用情况(来自 cognition_log.json)
+查询 1(sequence 42):"ControlNet 相关工具知识"
+  → source knowledge-a1b2: helpful — "准确定位了问题"
+  → source knowledge-c3d4: irrelevant — "与当前任务无关"
+  → source knowledge-e5f6: harmful — "建议的方法已过时"
+
+## 请反思
+1. 这次执行中有什么值得记住的经验?
+2. 哪些知识的评估结果反映了我的判断需要调整?
+3. 用户的反馈(如果有)说明了什么?
+```
+
+这样,已有的 knowledge_eval 结果直接成为记忆反思的输入,不需要重复评估。
+
+### Dream 整合的输入
+
+Dream prompt 看到的是:
+
+```
+## 最近的反思摘要
+[各 trace 的 reflect_summary,每份几百 token]
+
+## 知识评估趋势(汇总自各 trace 的 cognition_log)
+- 最近 N 个 trace 中,被评为 harmful 的 source:[列表]
+- 被评为 unused 的高频 source 类型:[统计]
+- 被评为 helpful 的查询模式:[统计]
+
+## 当前记忆文件
+[各文件内容]
+
+## 请更新记忆
+[dream_prompt 的具体指导]
+```
+
+---
+
+## 五、与现有实现的集成
+
+### 不改动的部分
+
+| 现有机制 | 保持不变 | 原因 |
+|---|---|---|
+| reflection 侧分支 | ✅ | 知识提取到 KnowHub,时效性要求高,必须在压缩前做 |
+| knowledge_eval 侧分支 | ✅ | 知识评估,时效性要求高,必须在压缩前做 |
+| force_side_branch 队列 | ✅ | 侧分支排序机制,成熟可靠 |
+| cognition_log.json | ✅ | 统一事件流存储(原 knowledge_log.json 扩展),dream 直接读取 |
+| 三个评估触发点 | ✅ | Goal 完成/压缩前/任务结束 |
+
+### 需要修改的部分
+
+**1. 任务完成时的 reflection prompt 选择**
+
+当前 `runner.py:1249` 始终使用 `get_reflect_prompt()`。应区分场景:
+
+```python
+# runner.py:1248-1249 修改
+if branch_type == "reflection":
+    if break_after_side_branch:  # 任务完成后的反思
+        prompt = config.knowledge.get_completion_reflect_prompt()
+    else:  # 压缩前的反思
+        prompt = config.knowledge.get_reflect_prompt()
+```
+
+这是一个独立的 bug fix,不依赖 Memory 系统。
+
+**2. Trace 模型扩展**
+
+`agent/trace/models.py:Trace` 新增字段:
+
+```python
+reflected_at_sequence: Optional[int] = None    # 上次记忆反思的 sequence
+# 反思摘要存在 cognition_log.json 中(type="reflection" 事件),不在 Trace 模型中
+```
+
+**3. RunConfig 扩展**
+
+`agent/core/runner.py:RunConfig` 新增可选字段:
+
+```python
+memory: Optional[MemoryConfig] = None
+```
+
+### 新增的部分
+
+**1. MemoryConfig**
+
+```python
+@dataclass
+class MemoryConfig:
+    """持久化记忆配置"""
+
+    base_path: str = ""                          # 记忆文件目录
+    files: Optional[Dict[str, str]] = None       # {文件名: 用途说明}
+    dream_prompt: str = ""                       # Dream 整合 prompt(空用默认)
+    reflect_prompt: str = ""                     # Per-trace 反思 prompt(空用默认)
+```
+
+**2. Run 启动时记忆加载**
+
+Memory-bearing Agent 的 run 启动时,框架读取 `base_path` 下所有 `files` 中声明的文件,注入上下文。
+
+**3. Dream 操作**
+
+以 `dream` 工具形式提供,Agent 可主动调用:
+
+```python
+@tool
+async def dream() -> ToolResult:
+    """整理长期记忆。回顾最近的执行历史,更新记忆文件。"""
+    # 1. 扫描需要反思的 trace
+    # 2. 逐个 per-trace 反思
+    # 3. 跨 trace 整合,更新记忆文件
+```
+
+也可以作为 `AgentRunner` 的方法暴露,供外部调度直接调用。
+
+---
+
+## 六、完整的元思考数据流
+
+```
+Agent 执行任务(Trace)
+  │
+  ├─ 知识查询(ask)→ cognition_log: type="query"(含整合回答 + source_ids)
+  │
+  ├─ Goal 完成 → 触发 knowledge_eval 侧分支 → cognition_log: type="evaluation"
+  │
+  ├─ 压缩触发 →
+  │   队列: [reflection, knowledge_eval, compression]
+  │   reflection: 提取客观知识 → upload → KnowHub + cognition_log: type="extraction"
+  │   knowledge_eval: 评估各 source → cognition_log: type="evaluation"
+  │   compression: 压缩上下文
+  │
+  ├─ 任务完成 →
+  │   knowledge_eval(如有 pending)→ cognition_log: type="evaluation"
+  │   reflection → upload → KnowHub + cognition_log: type="extraction"
+  │
+  └─ Trace 状态更新(新消息使 reflected_at_sequence 落后)
+
+         ···时间流逝,可能有多个 trace···
+
+Dream 触发(Agent 主动调用 / 外部调度)
+  │
+  ├─ Per-trace 记忆反思
+  │   输入: 未反思的消息 + cognition_log 中的 query/evaluation/extraction 事件
+  │   输出: cognition_log: type="reflection"
+  │
+  ├─ 跨 trace 整合
+  │   输入: 各 trace 的 reflection 事件 + evaluation 趋势 + 当前记忆文件
+  │   输出: 更新后的记忆文件(taste.md, strategy.md, ...)
+  │
+  └─ 记忆文件被下次 run 加载 → 影响 Agent 行为 → 新的 Trace → ...
+```
+
+### 三种元思考的时间线
+
+```
+Trace 执行中:
+  ──[Goal完成]──knowledge_eval──[压缩]──reflection→knowledge_eval→compression──
+
+Trace 结束后:
+  ──knowledge_eval──reflection(completion)──
+
+之后某个时刻:
+  ──dream──per-trace记忆反思──跨trace整合──更新记忆文件──
+```
+
+即时的元思考(knowledge_eval、reflection)保护信息不被压缩丢失。
+延迟的元思考(dream)在全局视角下更新个人记忆。两者互补。
+
+---
+
+## 七、记忆模型全景
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ Layer 3: Skills(技能库)                                     │
+│ - Markdown 文件,领域知识和能力描述                            │
+└─────────────────────────────────────────────────────────────┘
+                              ▲
+                              │ 归纳
+┌─────────────────────────────────────────────────────────────┐
+│ Layer 2: Knowledge(知识库)— 全局共享                         │
+│ - KnowHub 数据库,客观知识 + 向量索引                         │
+│ - 来源:reflection 侧分支提取                                │
+│ - 质量信号:knowledge_eval 评估结果                           │
+└─────────────────────────────────────────────────────────────┘
+                              ▲
+                              │ 提取(reflection)/ 评估(knowledge_eval)
+┌─────────────────────────────────────────────────────────────┐
+│ Layer 1.5: Memory(个人记忆)— Agent 身份私有                  │
+│ - Markdown 文件,主观记忆(偏好/策略/反思)                    │
+│ - 来源:dream 操作(per-trace 反思 + 跨 trace 整合)          │
+│ - 人类可直接编辑                                              │
+└─────────────────────────────────────────────────────────────┘
+                              ▲
+                              │ dream 反思
+┌─────────────────────────────────────────────────────────────┐
+│ Layer 1: Trace(任务状态)                                    │
+│ - 当前任务的工作记忆                                          │
+│ - Messages + Goals + cognition_log                           │
+└─────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## 八、两类 Agent
+
+| | 默认 Agent | Memory-bearing Agent |
+|---|---|---|
+| 知识提取(reflection) | ✅ 配置 KnowledgeConfig | ✅ 配置 KnowledgeConfig |
+| 知识评估(knowledge_eval) | ✅ 自动 | ✅ 自动 |
+| 个人记忆 | ❌ | ✅ 配置 MemoryConfig |
+| Dream | ❌ | ✅ 可调用 dream 工具 |
+| Run 启动加载记忆 | ❌ | ✅ 自动注入 |
+
+默认行为不变。Memory 是 opt-in 的增量能力。
+
+---
+
+## 九、待定问题
+
+1. **记忆注入方式**:system prompt 追加 vs 首条消息前插入 vs 作为工具结果注入?需要实验对比效果。
+2. **并发写冲突**:多个 Agent run 同时写同一个记忆文件怎么办?文件锁?还是 dream 统一写、其他 run 只读?
+3. **记忆膨胀**:记忆文件越来越长怎么办?dream prompt 应该包含精简逻辑,但需要观察实际效果。
+4. **Per-trace 反思的成本控制**:很短的 trace 不值得反思。阈值由框架设定(消息数/token数)还是让 dream 过程自己判断?
+5. **Knowledge eval 结果回传 KnowHub**:是否应该自动同步?自动回传可能影响其他 Agent 的检索。
+6. **Dream 中 knowledge_log 趋势的呈现方式**:在 dream prompt 中注入预计算的统计 vs 让 LLM 自己读原始 log?
+7. **Dream 操作的实现形式**:作为 Agent 工具(`dream()`)vs AgentRunner 方法 vs 两者都提供?

+ 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, image_uploader, image_downloader
 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) - 后端实现细节

+ 67 - 200
knowhub/README.md

@@ -1,241 +1,108 @@
-# KnowHub 设计
+# KnowHub
 
 ## 文档维护规范
 
 0. **先改文档,再动代码** - 新功能或重大修改需先完成文档更新、并完成审阅后,再进行代码实现;除非改动较小、不被文档涵盖
 1. **文档分层,链接代码** - 重要或复杂设计可以另有详细文档;关键实现需标注代码文件路径;格式:`module/file.py:function_name`
-2. **简洁快照,日志分离** - 只记录最重要的、与代码准确对应的或者明确的已完成的设计的信息,避免推测、建议、决策历史、修改日志、大量代码;决策依据或修改日志若有必要,可在`docs/decisions.md`另行记录
+2. **简洁快照,日志分离** - 只记录最重要的、与代码准确对应的或者明确的已完成的设计的信息,避免推测、建议、决策历史、修改日志、大量代码;决策依据或修改日志若有必要,可在 `docs/decisions.md` 另行记录
 
 ---
 
 ## 定位
 
-Agent 集体记忆平台。收集和检索 Agent 的真实使用经验,覆盖工具、知识资源等各类资源。资源发现交给现有生态(Glama、Smithery、PyPI 等),KnowHub 专注于"用了之后怎么样"和按需积累的资源内容。
+Agent 集体记忆平台。收集和检索 Agent 的真实使用经验,覆盖工具、知识资源等各类资源。
 
 核心原则:
 1. 汇总不同 Agent 的真实使用经验(Agent 版大众点评)
-2. 端侧 Agent 负责搜索、评估、总结、提取内容;Server 只做存取和简单聚合
-
-详见 [decisions.md](docs/decisions.md) 的定位推演。
-
-### 数据类型
-
-KnowHub Server 管理两类数据:
-
-| 数据类型 | 内容 | 用途 |
-|---------|------|------|
-| **experiences** | 工具使用经验(任务+资源+结果+建议) | 工具评价、经验分享 |
-| **knowledge** | 任务知识、策略、定义 | 知识积累、检索、进化 |
-
-详见:
-- [知识管理文档](docs/knowledge-management.md) - 知识结构、API、集成方式
+2. 端侧 Agent 负责搜索、评估、总结、提取内容;Server 做存取、去重和简单聚合
 
 ---
 
 ## 架构
 
 ```
-Agent(端侧)                          KnowHub Server
-├── curl GET /api/search 查经验   →    LIKE 检索 + SQL 聚合
-├── curl GET /api/content/{id} 取内容 → SQLite 读取 + 导航计算
-├── 去外部平台找资源(fallback)
-├── 使用资源、总结经验
-├── curl POST /api/experience    →    SQLite 写入
-└── curl POST /api/content       →    SQLite 写入(按需提交内容)
+Agent(端侧)
+├── ask_knowledge 工具     →  POST /api/knowledge/ask(设计中,未实现)
+├── upload_knowledge 工具  →  POST /api/knowledge/upload(设计中,未实现)
+├── knowledge_search 工具  →  GET /api/knowledge/search(当前实现)
+├── knowledge_save 工具    →  POST /api/knowledge(当前实现)
+├── goal focus 自动注入    →  GET /api/knowledge/search(当前实现)
+└── resource 资源引用       →  GET /api/resource/{id}
+                                    │
+                              KnowHub Server(FastAPI)
+                              ├── REST API(36个端点)
+                              ├── 知识处理流水线(去重 + 工具关联分析)
+                              └── Librarian Agent(知识管理Agent,当前通过IM独立运行)
+                                    │
+                              PostgreSQL + pgvector
+                              ├── knowledge, resource, tool, capability, requirement(5 实体表)
+                              └── 8 张关联表(详见 schema.md)
 ```
 
-Agent 通过 curl 直接调用 REST API,无需安装客户端。skill.md 提供 API 地址和调用模板。
-
-| 职责 | 执行方 |
-|------|--------|
-| 搜索资源、评估质量、总结经验、提取内容 | Agent(端侧) |
-| 存储 experience 和 content、关键词检索、聚合分数 | Server |
-
-Server 无 LLM 调用、无 embedding、无向量数据库。
-
 ---
 
-## 数据模型
-
-### experiences
-
-一条经验 = 任务 + 资源 + 结果 + 建议。
-
-```sql
-CREATE TABLE experiences (
-    id            INTEGER PRIMARY KEY AUTOINCREMENT,
-    name          TEXT NOT NULL,           -- 资源名称
-    url           TEXT DEFAULT '',         -- 资源规范来源地址
-    category      TEXT DEFAULT '',         -- 资源类型
-    task          TEXT NOT NULL,           -- 具体任务场景
-    score         INTEGER CHECK(score BETWEEN 1 AND 5),
-    outcome       TEXT DEFAULT '',         -- 结果(含优缺点)
-    tips          TEXT DEFAULT '',         -- 最关键的建议
-    content_id    TEXT DEFAULT '',         -- 可选,关联到 contents
-    submitted_by  TEXT DEFAULT '',         -- 可选,提交者标识
-    created_at    TEXT NOT NULL
-);
-```
-
-**字段填写规范**:
-- **name** — 资源的通用名称。工具填包名(`pymupdf`),论文填标题(`Attention Is All You Need`),博客填博客名或文章标题
-- **url** — 资源的规范来源地址(GitHub repo / arXiv / 官方文档),不是随意搜索结果链接
-- **category** — 建议值:`mcp | skill | library | api | paper | blog | book | course`,自由文本
-- **content_id** — 可选,指向 contents 中的任意节点(可以是资源根,也可以是具体章节)
-- **submitted_by** — 可选,建议使用 `git config user.email` 的值
-
-**多资源协作场景**:
-一个任务常需要多个工具/资源配合(如 pymupdf + langchain + openai)。处理方式:为每个关键资源单独提交一条 experience,在 task/tips/outcome 中提及配合使用的其他资源。这样每个资源都能被搜索到和聚合,同时通过文本保留协作信息。
-
-搜索使用 LIKE 拆词匹配 task + tips + outcome + name,结果按 name 分组返回。
+## 文档索引
 
-### contents
+### 代码快照(与代码一致)
 
-按需积累的资源内容。Agent 提交,Markdown 格式。
+| 文档 | 内容 |
+|------|------|
+| [数据模型](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) | 数据库访问层封装类和运维脚本 |
 
-```sql
-CREATE TABLE contents (
-    id            TEXT PRIMARY KEY,        -- 路径式 ID
-    title         TEXT DEFAULT '',
-    body          TEXT NOT NULL,           -- Markdown 格式
-    sort_order    INT DEFAULT 0,           -- 同级排序
-    submitted_by  TEXT DEFAULT '',
-    created_at    TEXT NOT NULL
-);
-```
-
-**ID 约定**:路径式,如 `pymupdf` 或 `attention-paper/section-3-2`。`/` 分隔表示层级关系。
+### 方案文档(`*-plan.md`,未实现)
 
-**内容层级**:扁平二级结构。
-- 根节点(ID 不含 `/`):资源概要或目录。有 children 时 body 为目录/概要,无 children 时 body 为完整内容。
-- 子节点(ID 含 `/`):具体内容段落,body 为实质内容。
+| 文档 | 内容 |
+|------|------|
+| [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、数据模型 |
 
-Children 通过 ID 前缀查询:`WHERE id LIKE '{root_id}/%' ORDER BY sort_order`。
+### 其他
 
-**按需生长**:content 由 agent 在需要时提交。没有 content 的资源只有 experience 层。高频使用的资源自然积累出完整内容。
-
----
-
-## API
-
-基础地址通过环境变量 `KNOWHUB_API` 配置。
-
-### `GET /api/search?q=...&category=...&limit=10`
-
-搜索经验。LIKE 拆词匹配 task + tips + outcome + name,按 name 分组返回。
-
-响应示例:
-
-```json
-{
-  "results": [
-    {
-      "name": "pymupdf",
-      "url": "https://github.com/pymupdf/PyMuPDF",
-      "relevant_experiences": [
-        {"task": "从学术论文提取表格", "score": 4, "tips": "用 page.find_tables()", "content_id": ""}
-      ],
-      "avg_score": 4.5,
-      "experience_count": 2
-    }
-  ]
-}
-```
-
-### `POST /api/experience`
-
-提交经验。
-
-```json
-{
-  "name": "pymupdf",
-  "url": "https://github.com/pymupdf/PyMuPDF",
-  "category": "library",
-  "task": "从学术论文 PDF 提取结构化表格",
-  "score": 4,
-  "outcome": "速度快,标准排版准确,复杂双栏偶尔丢失顺序",
-  "tips": "用 page.find_tables(),双栏论文先 get_text(sort=True)",
-  "content_id": "",
-  "submitted_by": "user@example.com"
-}
-```
-
-### `GET /api/resource/{name}`
-
-查看某资源的所有经验及关联内容。
-
-### `GET /api/content/{content_id:path}`
-
-获取内容节点。响应自动包含导航上下文:
-
-```json
-{
-  "id": "attention-paper/section-3-2",
-  "title": "3.2 Scaled Dot-Product Attention",
-  "body": "## Scaled Dot-Product Attention\n\nAttention(Q,K,V) = softmax(QK^T / √d_k)V\n\n...",
-  "toc": {"id": "attention-paper", "title": "Attention Is All You Need"},
-  "children": [],
-  "prev": {"id": "attention-paper/section-3-1", "title": "3.1 Encoder and Decoder Stacks"},
-  "next": {"id": "attention-paper/section-3-3", "title": "3.3 Multi-Head Attention"}
-}
-```
-
-- 有 children → body 为目录/概要,children 列出子节点(id + title)
-- 无 children → body 为完整内容
-- toc / prev / next 由 server 从 ID 前缀和 sort_order 计算
-
-### `POST /api/content`
-
-提交内容节点。
-
-```json
-{
-  "id": "attention-paper/section-3-2",
-  "title": "3.2 Scaled Dot-Product Attention",
-  "body": "Markdown 内容...",
-  "sort_order": 2
-}
-```
-
----
-
-## skill.md 流程
-
-触发条件:遇到复杂任务、可能超出自身能力、多次失败、可能需要外部资源时。以及使用资源完成任务后。
-
-```
-curl GET /api/search
-  → 有可用结果 → 直接参考(经验里有资源名、URL、使用建议)
-  → 需要深入 → GET /api/content/{id} 获取详细内容
-  → 无可用结果 → 去外部平台自行寻找 → 用完后 POST /api/experience 回报
-```
-
-发现渠道(Smithery、PyPI 等)仅作为无经验时的 fallback。
-
-完整内容见 `skill/knowhub.md`。
+| 文档 | 内容 |
+|------|------|
+| [决策记录](docs/decisions.md) | 设计决策的依据和权衡 |
 
 ---
 
 ## 项目结构
 
 ```
-KnowHub/
-├── docs/
-│   ├── 
-│   ├── decisions.md       # 决策记录
-│   └── knowledge/         # 调研资料(不发布)
-├── skill/
-│   └── knowhub.md         # Agent 策略指南
-├── CLAUDE.md              # 本文档(架构快照)
-└── server.py              # FastAPI + SQLite(单文件)
+knowhub/
+├── server.py              # FastAPI Server(API + 知识处理流水线)
+├── embeddings.py          # Embedding 生成(OpenRouter)
+├── kb_manage_prompts.py   # LLM Prompt(去重判断、工具分析)
+├── agents/                # Librarian Agent(独立进程)
+├── knowhub_db/            # PostgreSQL 数据库访问层
+│   ├── pg_store.py        # knowledge 表
+│   ├── pg_resource_store.py
+│   ├── pg_tool_store.py
+│   ├── pg_capability_store.py
+│   ├── pg_requirement_store.py
+│   └── *.py               # 迁移和运维脚本
+├── frontend/              # 管理前端
+├── docs/                  # 设计文档
+└── skill/                 # Agent调用指南
 ```
 
 ---
 
-## MVP 边界
+## 环境变量
 
-**做**:经验收集与检索、按需内容积累与导航、LIKE 拆词搜索、按资源聚合、Markdown 内容、skill.md 策略指南
+```bash
+# 数据库
+KNOWHUB_DB          # PostgreSQL 主机
+KNOWHUB_PORT        # 端口(默认 5432)
+KNOWHUB_USER        # 用户名
+KNOWHUB_PASSWORD    # 密码
+KNOWHUB_DB_NAME     # 数据库名
 
-**不做**:CLI 客户端、资源目录、语义搜索/向量/embedding、用户认证、Web 界面、LLM 聚合摘要、内容层与 Resonote 整合
-
-**后续方向**:CLI 客户端(mpk 命令)、MCP Server 接入、LLM 经验摘要(Agent 端)、submitted_by 信誉权重、语义搜索(embedding)、与 Resonote 内容层桥接
+# Embedding
+OPENROUTER_API_KEY  # OpenRouter API Key(用于生成 embedding)
+```

+ 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", ...)` 完整覆写
+
+### 回复要求
+
+汇报:复用了哪些已有实体、新建了哪些、做了哪些去重。

+ 203 - 0
knowhub/docs/api.md

@@ -0,0 +1,203 @@
+# REST API 参考
+
+## 文档维护规范
+
+0. **先改文档,再动代码**
+1. **文档分层,链接代码** — 格式:`module/file.py:function_name`
+2. **简洁快照,日志分离** — 决策依据记录在 `knowhub/docs/decisions.md`
+
+---
+
+实现:`knowhub/server.py`
+
+## Knowledge API
+
+### `GET /api/knowledge/search` — 向量搜索知识
+
+参数:`q`(必填), `top_k`(默认5, 1-20), `min_score`(默认3, 1-5), `types`(逗号分隔, AND), `owner`(逗号分隔, OR)
+
+只返回 status=approved/checked 的知识。向量召回 3×top_k 候选,按余弦距离排序取 top_k。当前跳过 LLM 精排。
+
+响应:`{"results": [...], "count": int, "reranked": false}`
+
+### `POST /api/knowledge` — 创建知识
+
+请求体: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))。
+
+### `GET /api/knowledge` — 列表(分页+过滤)
+
+参数:page, page_size(默认20), types, scopes, owner, tags, status(默认 approved,checked)
+
+### `GET /api/knowledge/{knowledge_id}` — 获取单条
+
+### `PUT /api/knowledge/{knowledge_id}` — 更新(含知识进化)
+
+请求体:add_helpful_case, add_harmful_case, update_score, evolve_feedback
+
+`evolve_feedback` 非空时触发 LLM 重写知识内容并更新 task_embedding。
+
+### `PATCH /api/knowledge/{knowledge_id}` — 直接编辑字段
+
+请求体:task, content, types, tags, scopes, owner, capability_ids, tool_ids(均可选)
+
+修改 task/content 时自动更新对应 embedding。
+
+### `DELETE /api/knowledge/{knowledge_id}` — 删除
+
+### `POST /api/knowledge/batch_delete` — 批量删除
+
+请求体:`List[str]`(knowledge_ids)
+
+### `POST /api/knowledge/{knowledge_id}/verify` — 验证
+
+请求体:action("approve"/"reject"), verified_by
+
+approve 在 approved ↔ checked 之间切换。reject 设为 rejected。
+
+### `POST /api/knowledge/batch_verify` — 批量验证
+
+请求体:knowledge_ids, action(必须 "approve"), verified_by
+
+### `POST /api/knowledge/batch_update` — 批量反馈
+
+请求体:feedback_list `[{knowledge_id, is_effective, feedback?}]`
+
+is_effective=true + feedback 时触发并发 LLM 知识进化。
+
+### `POST /api/knowledge/slim` — 知识库瘦身
+
+参数:model(默认 gemini-2.5-flash-lite)
+
+LLM 识别可合并项 → 合并 → 重建 embedding。
+
+### `POST /api/extract` — 从对话提取知识
+
+请求体:messages(`[{role, content}]`), agent_id, submitted_by(必填), session_key
+
+LLM 提取结构化知识 → 批量生成 embedding → 入库(pending) → 触发去重。
+
+### `GET /api/knowledge/pending` — 处理队列
+
+返回 status 为 pending/processing/dedup_passed/analyzing 的知识。
+
+### `POST /api/knowledge/process` — 手动触发处理
+
+参数:force(重置卡住的 processing/analyzing 状态)
+
+### `GET /api/knowledge/status/{knowledge_id}` — 查看处理状态
+
+### `GET /api/knowledge/meta/tags` — 获取所有标签
+
+---
+
+## Resource API
+
+### `POST /api/resource` — 创建/更新资源
+
+请求体:id(必填, 路径格式), title, body, secure_body, content_type, metadata, sort_order, submitted_by
+
+secure_body 在有组织密钥时自动加密(AES-256-GCM)。
+
+### `GET /api/resource/{resource_id}` — 获取资源(含导航)
+
+响应包含:toc(根节点), children(子节点), prev/next(同级前后节点)。
+
+### `PATCH /api/resource/{resource_id}` — 更新字段
+
+### `GET /api/resource` — 列表
+
+参数:content_type, limit(默认100)
+
+### `DELETE /api/resource/{resource_id}` — 删除
+
+---
+
+## Tool API
+
+### `POST /api/tool` — 创建/更新工具
+
+请求体:id(必填), name, version, introduction, tutorial, input, output, status, capability_ids, knowledge_ids
+
+自动生成 embedding(name + introduction)。
+
+### `GET /api/tool` — 列表
+
+参数:status, limit(默认100), offset
+
+### `GET /api/tool/search` — 向量搜索工具
+
+参数:q(必填), top_k(默认5), status
+
+### `GET /api/tool/{tool_id}` — 获取单个
+
+### `PATCH /api/tool/{tool_id}` — 更新字段
+
+### `DELETE /api/tool/{tool_id}` — 删除
+
+---
+
+## Capability API
+
+### `POST /api/capability` — 创建/更新原子能力
+
+请求体:id(必填), name, criterion, description, requirement_ids, implements, tool_ids, knowledge_ids
+
+自动生成 embedding(name + description)。
+
+### `GET /api/capability` — 列表
+
+参数:limit(默认100), offset
+
+### `GET /api/capability/search` — 向量搜索能力
+
+参数:q(必填), top_k(默认5)
+
+### `GET /api/capability/{cap_id}` — 获取单个
+
+### `PATCH /api/capability/{cap_id}` — 更新字段
+
+### `DELETE /api/capability/{cap_id}` — 删除
+
+---
+
+## Requirement API
+
+### `POST /api/requirement` — 创建/更新需求
+
+请求体:id(必填), description, capability_ids, source_nodes, status(默认 "未满足"), match_result
+
+自动生成 embedding(description)。
+
+### `GET /api/requirement` — 列表
+
+参数:status, limit(默认100), offset
+
+### `GET /api/requirement/search` — 向量搜索需求
+
+参数:q(必填), top_k(默认5)
+
+### `GET /api/requirement/{req_id}` — 获取单个
+
+### `PATCH /api/requirement/{req_id}` — 更新字段
+
+### `DELETE /api/requirement/{req_id}` — 删除
+
+---
+
+### `POST /api/knowledge/ask` — 智能知识查询
+
+同步阻塞。向量检索 + 结果整合,返回 response + source_ids + sources。
+
+运行 Librarian Agent 检索 + 整合。详见 [librarian-agent.md](librarian-agent.md)。
+
+请求体:query(必填), trace_id, top_k(默认5)
+
+### `POST /api/knowledge/upload` — 异步知识上传
+
+校验格式后写入 buffer 目录,立即返回 202。Librarian Agent 异步处理图谱编排和去重。
+
+请求体:data({knowledge, resources, tools}), trace_id, finalize
+
+详见 [librarian-agent.md](librarian-agent.md)。

+ 0 - 0
knowhub/agents/README.md → knowhub/docs/archive/agents-readme.md


+ 0 - 0
knowhub/docs/dedup-design.md → knowhub/docs/archive/dedup-design.md


+ 103 - 53
knowhub/docs/knowledge-management.md → knowhub/docs/archive/knowledge-management.md

@@ -17,13 +17,20 @@ KnowHub Server 是统一的知识管理服务,包含两个子系统:
 
 ```
 Agent                           KnowHub Server
-├── knowledge_search 工具   →   GET /api/knowledge/search
-├── knowledge_save 工具     →   POST /api/knowledge
+├── ask_knowledge 工具      →   POST /api/knowledge/ask(同步,KM Agent 整合后返回)
+├── upload_knowledge 工具   →   POST /api/knowledge/upload(异步,校验后立即返回,队列处理)
 ├── knowledge_update 工具   →   PUT /api/knowledge/{id}
-├── goal focus 自动注入     →   GET /api/knowledge/search
+├── goal focus 自动注入     →   POST /api/knowledge/ask
 └── resource 资源引用        →   GET /api/resource/{id}
 ```
 
+### Knowledge Manager Agent
+
+KnowHub 内部运行 Knowledge Manager (KM) Agent,负责知识的智能检索整合和上传处理。KM Agent 不再通过 IM 通信,而是作为 KnowHub 的内部实现:
+
+- **ask 请求**:KnowHub 收到 ask 请求后,用调用方的 `trace_id` 查找或创建对应的 KM Agent trace。同一 `trace_id` 的多次 ask 复用同一个 KM Agent(resume trace),使 KM Agent 积累对调用方任务的理解。KM Agent 检索知识库、用 LLM 整合后返回结果。
+- **upload 请求**:KnowHub 校验格式后立即返回,将请求放入消息队列。KM Agent 后台处理去重、整合、入库。
+
 ### 存储架构
 
 KnowHub 采用 Milvus Lite 单一存储架构(详见 `knowhub/docs/decisions.md#13`):
@@ -173,48 +180,45 @@ KnowHub 采用 Milvus Lite 单一存储架构(详见 `knowhub/docs/decisions.m
 
 Agent 通过以下工具与 KnowHub Server 交互。工具只是 API 调用的封装,核心逻辑在 Server 实现。
 
-实现位置:`agent/tools/builtin/knowledge.py`
+实现位置:`agent/tools/builtin/knowledge.py`(待迁移到新 API)
 
-### `knowledge_search`
+### `ask_knowledge`
 
-检索知识
+查询知识,返回 KM Agent 整合后的回答。同步阻塞,调用方等待结果返回后继续
 
 ```python
 @tool()
-async def knowledge_search(
+async def ask_knowledge(
     query: str,
+    trace_id: str,       # 调用方的 trace_id,用于 KM Agent 续跑
     top_k: int = 5,
-    min_score: int = 3,
-    types: Optional[List[str]] = None
 ) -> ToolResult
 ```
 
-调用 `GET /api/knowledge/search?q={query}&top_k={top_k}&min_score={min_score}&types={types}`
+调用 `POST /api/knowledge/ask`
 
-### `knowledge_save`
+返回:整合回答 + source_ids 列表 + 各 source 的原始内容摘要。
 
-保存新知识。
+### `upload_knowledge`
+
+上传知识到知识库。异步,校验格式后立即返回。
 
 ```python
 @tool()
-async def knowledge_save(
-    task: str,
-    content: str,
-    types: List[str],
-    tags: Optional[Dict[str, str]] = None,
-    scopes: Optional[List[str]] = None,
-    owner: Optional[str] = None,
-    source_name: str = "",
-    source_category: str = "exp",
-    urls: List[str] = None,
-    agent_id: str = "research_agent",
-    submitted_by: str = "",
-    score: int = 3,
-    message_id: str = ""
+async def upload_knowledge(
+    data: Dict[str, Any],
+    trace_id: str,       # 调用方的 trace_id
+    finalize: bool = False,
 ) -> ToolResult
 ```
 
-调用 `POST /api/knowledge`
+调用 `POST /api/knowledge/upload`
+
+### 废弃工具
+
+以下工具已废弃,由上述两个工具替代:
+- `knowledge_search` → `ask_knowledge`
+- `knowledge_save` → `upload_knowledge`
 
 **默认值**(在 agent 代码中设置):
 - `scopes`: `["org:cybertogether"]`
@@ -405,30 +409,19 @@ Knowledge 可以通过 `resource_id` 和 `secure_resource_id` 引用 Resource 
 
 ### 注入流程
 
-```python
-# 1. LLM 调用 goal 工具
-goal(action="focus", goal_id="goal-123")
-
-# 2. goal 工具内部自动执行
-current_goal = goal_tree.find("goal-123")
-knowledge_results = await knowledge_search(
-    query=current_goal.description,
-    top_k=3,
-    min_score=3
-)
-
-# 3. 保存到 goal 对象
-current_goal.knowledge = knowledge_results.metadata["items"]
-
-# 4. 持久化到 trace store
-await trace_store.update_goal_tree(trace_id, goal_tree)
-
-# 5. 返回给 LLM
-return ToolResult(
-    title="✅ Goal 已聚焦",
-    output=f"当前 Goal: {current_goal.description}\n\n📚 相关知识 ({len(knowledge_results)} 条):\n..."
-)
 ```
+1. LLM 调用 goal(action="focus", goal_id="goal-123")
+2. goal 工具内部调用 POST /api/knowledge/ask
+   - query = goal.description
+   - trace_id = 调用方的 trace_id(KM Agent 续跑,积累任务理解)
+3. KM Agent 返回整合回答 + source_ids
+4. 保存到 goal.knowledge + 写入 cognition_log
+5. 返回给 LLM
+```
+
+### Cognition Log 记录
+
+每次注入产生一个 `query` 事件,记录完整的问答对(详见 `knowhub/docs/feedback-timing-design.md`)。
 
 ### 知识展示
 
@@ -481,9 +474,65 @@ return ToolResult(
 
 ## KnowHub Server API
 
+### `POST /api/knowledge/ask`
+
+> 状态:设计中,未实现
+
+智能知识查询。KnowHub 内部启动/续跑 KM Agent 处理查询,返回整合回答。同步阻塞。
+
+**请求体**:
+
+```json
+{
+  "query": "ControlNet 相关的工具知识",
+  "trace_id": "caller-trace-xxx",
+  "top_k": 5
+}
+```
+
+**行为**:
+- `trace_id` 首次出现 → 创建新的 KM Agent trace
+- `trace_id` 已存在 → resume 之前的 KM Agent trace(保持上下文连续性)
+- KM Agent 检索知识库 → LLM 整合 → 返回结果
+
+**响应**:
+
+```json
+{
+  "response": "KM Agent 整合后的回答文本...",
+  "source_ids": ["knowledge-xxx", "knowledge-yyy"],
+  "sources": [
+    {"id": "knowledge-xxx", "task": "...", "content": "...(截断500字)"},
+    {"id": "knowledge-yyy", "task": "...", "content": "..."}
+  ]
+}
+```
+
+### `POST /api/knowledge/upload`
+
+> 状态:设计中,未实现
+
+异步知识上传。校验格式后立即返回 202,KM Agent 后台队列处理。
+
+**请求体**:
+
+```json
+{
+  "data": {
+    "knowledge": [...],
+    "resources": [...],
+    "tools": [...]
+  },
+  "trace_id": "caller-trace-xxx",
+  "finalize": false
+}
+```
+
+**响应**:`202 Accepted`
+
 ### `GET /api/knowledge/search`
 
-检索知识。使用向量召回 + LLM 精排策略。
+检索知识。使用向量召回 + LLM 精排策略。直接返回原始结果,不经过 KM Agent 整合。
 
 **参数**:
 - `q`: 查询文本
@@ -648,11 +697,12 @@ return ToolResult(
 | 组件 | 实现位置 |
 |------|---------|
 | KnowHub Server | `knowhub/server.py` |
-| Agent 工具 | `agent/tools/builtin/knowledge.py` |
+| KM Agent | `knowhub/agents/knowledge_manager.py` |
+| Agent 工具 | `agent/tools/builtin/knowledge.py`(待迁移到新 ask/upload API) |
 | goal 工具(知识注入) | `agent/trace/goal_tool.py:focus_goal` |
 | 调研 skill | `agent/skill/skills/research.md` |
 | 去重机制 | `knowhub/server.py:KnowledgeProcessor` → 详见 `dedup-design.md` |
-| 反馈时机 | `agent/trace/store.py`, `agent/core/runner.py` → 详见 `feedback-timing-design.md` |
+| Cognition Log | `agent/trace/store.py` → 详见 `feedback-timing-design.md` |
 
 ---
 

+ 0 - 0
knowhub/docs/migration-guide.md → knowhub/docs/archive/migration-guide.md


+ 0 - 0
knowhub/docs/resource-storage.md → knowhub/docs/archive/resource-storage.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 用于可视化展示知识库的建设情况,帮助管理者了解:

+ 0 - 269
knowhub/docs/feedback-timing-design.md

@@ -1,269 +0,0 @@
-# 知识反馈时机设计文档
-
-## 文档维护规范
-
-0. **先改文档,再动代码** - 新功能或重大修改需先完成文档更新、并完成审阅后,再进行代码实现;除非改动较小、不被文档涵盖
-1. **文档分层,链接代码** - 重要或复杂设计可以另有详细文档;关键实现需标注代码文件路径;格式:`module/file.py:function_name`
-2. **简洁快照,日志分离** - 只记录最重要的、与代码准确对应的或者明确的已完成的设计的信息,避免推测、建议、决策历史、修改日志、大量代码;决策依据或修改日志若有必要,可在 `knowhub/docs/decisions.md` 另行记录
-
----
-
-## 背景
-
-### 现有反馈机制的缺陷
-
-当前的知识反馈存在以下问题(来自 `feedback-optimization-proposal.md`):
-
-- **反馈时机不明确**:没有明确定义何时、由谁来评估知识的有效性
-- **缺少使用状态追踪**:知识被注入后,无法知道它是否真的被用到了
-- **评估粒度粗糙**:只有 helpful/harmful 计数,缺少"为什么有用/无用"的上下文
-
-### 设计目标
-
-1. 记录每条知识的完整生命周期(注入 → 使用 → 评估)
-2. 在自然的执行节点(Goal 完成、压缩、任务结束)触发评估,不打断主流程
-3. 为后续上报 KnowHub 提供结构化的评估数据
-
----
-
-## 核心概念
-
-### Knowledge Log(知识注入日志)
-
-每个 trace 维护一个 `knowledge_log.json`,记录该 trace 中所有被注入的知识及其评估状态。
-
-**位置**:`.trace/{trace_id}/knowledge_log.json`
-
-**数据结构**:
-
-```json
-{
-  "trace_id": "trace-xxx",
-  "entries": [
-    {
-      "knowledge_id": "knowledge-20260305-a1b2",
-      "goal_id": "1",
-      "injected_at_sequence": 42,
-      "injected_at": "2026-03-20T10:00:00.000000",
-      "task": "知识的原始task描述",
-      "content": "知识内容摘要(截断至500字符)",
-      "eval_result": {
-        "eval_status": "helpful",
-        "reason": "评估理由"
-      },
-      "evaluated_at": "2026-03-20T10:05:00.000000",
-      "evaluated_at_trigger": "goal_completion"
-    }
-  ]
-}
-```
-
-**字段说明**:
-
-| 字段 | 类型 | 说明 |
-|---|---|---|
-| `knowledge_id` | string | KnowHub 中的知识 ID |
-| `goal_id` | string | 注入时的 Goal ID(如 `"1"`, `"2.1"`) |
-| `injected_at_sequence` | int | 注入时的消息序列号 |
-| `injected_at` | datetime | 注入时间(ISO 格式,含毫秒) |
-| `task` | string | 知识的原始 task 描述 |
-| `content` | string | 知识内容(写入时截断至 500 字符) |
-| `eval_result` | object/null | 评估结果对象;未评估时为 `null` |
-| `evaluated_at` | datetime/null | 评估时间;未评估时为 `null` |
-| `evaluated_at_trigger` | string/null | 触发评估的事件(见下表);未评估时为 `null` |
-
-**`evaluated_at_trigger` 可能的值**:
-
-| 值 | 含义 |
-|---|---|
-| `"goal_completion"` | 由 Goal 完成(`completed` 或 `abandoned`)触发 |
-| `"compression"` | 由上下文压缩触发(压缩前必须先评估) |
-| `"task_completion"` | 由任务自然结束触发(主路径无工具调用退出时兜底) |
-
-> 注意:同一个 `knowledge_id` 可能在不同 Goal 中被多次注入,每次产生独立 entry。评估时优先更新最近注入(`injected_at_sequence` 最大)的未评估条目。
-
----
-
-## 评估触发机制
-
-### 触发点 1:Goal 完成
-
-**时机**:Goal status 变为 `completed` 或 `abandoned`
-
-**触发逻辑**(`agent/trace/store.py:update_goal`):
-
-```
-Goal 完成
-  ↓
-查询 knowledge_log 中 eval_result == null 的条目
-  ↓
-如果有待评估条目
-  → 在 trace.context 中设置标志:
-      pending_knowledge_eval = true
-      knowledge_eval_trigger = "goal_completion"
-  ↓
-Runner 主循环下一次迭代开头检测到标志(agent/core/runner.py:_agent_loop)
-  → 清除标志
-  → 将 "knowledge_eval" 加入 force_side_branch 队列
-```
-
-### 触发点 2:压缩(Compression)
-
-**时机**:上下文 token 数超过阈值,即将执行压缩
-
-**触发逻辑**(`agent/core/runner.py:_manage_context_usage`):
-
-```
-压缩条件触发
-  ↓
-查询 knowledge_log 中 eval_result == null 的条目
-  ↓
-如果有待评估条目
-  → 在 trace.context 中设置:
-      knowledge_eval_trigger = "compression"
-  → 将侧分支队列设为:
-      ["reflection", "knowledge_eval", "compression"](启用知识提取时)
-      ["knowledge_eval", "compression"](未启用知识提取时)
-  → 返回"需要进入侧分支"信号,暂缓压缩
-  ↓
-依次执行侧分支队列后再压缩
-```
-
-**原因**:压缩会删除消息历史,必须在压缩前完成评估,否则执行上下文永久丢失。
-
-### 触发点 3:任务结束(兜底)
-
-**时机**:主路径出现无工具调用的回复,Agent 即将结束任务
-
-**触发逻辑**(`agent/core/runner.py:_agent_loop`,无工具调用分支):
-
-```
-主路径无工具调用(任务即将结束)
-  ↓
-查询 knowledge_log 中 eval_result == null 的条目
-  ↓
-如果有待评估条目
-  → 在 trace.context 中设置:
-      knowledge_eval_trigger = "task_completion"
-  → 将 ["knowledge_eval"] 加入 force_side_branch 队列
-  → continue(不 break,下一轮执行评估侧分支)
-  ↓
-评估完成后再退出
-```
-
----
-
-## 评估分类(eval_status)
-
-| 状态 | 含义 |
-|---|---|
-| `irrelevant` | 知识的 task 与当前任务无关 |
-| `unused` | 知识与任务相关,但执行过程中没有被使用 |
-| `helpful` | 知识对当前任务有实质帮助 |
-| `harmful` | 知识对当前任务产生了负面作用 |
-| `neutral` | 知识与任务相关但无明显影响 |
-
----
-
-## 侧分支评估流程
-
-### 侧分支类型
-
-复用现有 `SideBranchContext` 机制,新增 `"knowledge_eval"` 类型(`agent/trace/models.py:Message.branch_type`):
-
-```python
-SideBranchContext(
-    type="knowledge_eval",
-    branch_id=f"knowledge_eval_{uuid.uuid4().hex[:8]}",  # 如 "knowledge_eval_1c5fffaf"
-    max_turns=config.side_branch_max_turns               # 默认 5
-)
-```
-
-`trigger_event` 记录在 `trace.context["active_side_branch"]["trigger_event"]` 中,侧分支退出后写入 `evaluated_at_trigger`。
-
-### 评估 Prompt 结构
-
-完整实现见 `agent/core/runner.py:_build_knowledge_eval_prompt`,结构如下:
-
-```
-你是知识评估助手。请评估以下知识在本次任务执行中的实际效果。
-
-## 当前任务(Mission)       ← trace.task
-## 当前 Goal                ← goal_tree.current 的 description
-## 待评估知识列表            ← 所有 eval_result == null 的条目
-  - knowledge_id / task / content / injected_at_sequence / goal_id
-## 评估维度                  ← helpfulness + relevance
-## 评估分类                  ← 5 个 eval_status 选项
-## 输出格式                  ← JSON
-```
-
-> Prompt 中**不包含消息历史**。LLM 依据对话上下文中已有的执行过程作出判断。
-
-### 评估输出格式
-
-LLM 直接输出 JSON,**无需调用工具**:
-
-```json
-{
-  "evaluations": [
-    {
-      "knowledge_id": "knowledge-20260305-a1b2",
-      "eval_status": "helpful",
-      "reason": "1-2句评估理由"
-    }
-  ]
-}
-```
-
-### 即时写入机制(`agent/core/runner.py:_agent_loop`)
-
-每次 LLM 回复后立即尝试解析,三种策略依次降级:整体解析 → ` ```json ` 代码块 → 正则裸对象。
-
-```
-LLM 输出评估 JSON
-  ↓
-解析成功 → 立即调用 store.update_knowledge_evaluation() 写入每条评估结果
-  ↓
-侧分支达到退出条件(无工具调用 或 超过 max_turns)→ 恢复主路径
-```
-
-解析失败时记录日志,不中断主流程。
-
----
-
-## 数据流
-
-```
-知识注入(agent/trace/goal_tool.py:inject_knowledge_for_goal)
-  ↓
-写入 knowledge_log.json(eval_result=null)
-  ↓
-  ┌─────────────────────────────────────────────┐
-  │  触发点 A:Goal 完成(goal_completion)       │
-  │  触发点 B:压缩执行前(compression)          │
-  │  触发点 C:任务自然结束(task_completion)    │
-  └─────────────────────────────────────────────┘
-  ↓
-Runner 进入 knowledge_eval 侧分支
-  ↓
-LLM 直接输出 JSON 评估结果(无工具调用)
-  ↓
-Runner 每轮即时解析并写入 knowledge_log.json
-  ↓
-侧分支退出 → 恢复主路径
-```
-
----
-
-## 与现有系统的集成点
-
-| 集成位置 | 文件 | 说明 |
-|---|---|---|
-| 知识注入时写 log | `agent/trace/goal_tool.py:inject_knowledge_for_goal` | `goal(focus=...)` 触发知识搜索后写入 `knowledge_log.json` |
-| Goal 完成时设置标志 | `agent/trace/store.py:update_goal` | 设置 `trace.context["pending_knowledge_eval"]` 标志 |
-| 主循环检测 Goal 完成标志 | `agent/core/runner.py:_agent_loop` | 每轮迭代开头检测标志,触发 `["knowledge_eval"]` 侧分支 |
-| 压缩前触发评估 | `agent/core/runner.py:_manage_context_usage` | 压缩前检查 pending,先评估再压缩 |
-| 任务结束兜底 | `agent/core/runner.py:_agent_loop` | 任务退出前检查 pending,强制触发评估 |
-| 侧分支类型扩展 | `agent/trace/models.py:Message.branch_type` | Literal 中包含 `"knowledge_eval"` |
-| 即时写入评估结果 | `agent/core/runner.py:_agent_loop` | 存储 assistant 消息后即时解析 JSON 并写入 |
-| Log 文件管理 | `agent/trace/store.py` | `append_knowledge_entry` / `update_knowledge_evaluation` / `get_pending_knowledge_entries` |

+ 4 - 2
knowhub/knowhub_db/plan.md → 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 关系判断→双向写关系)设计完整

+ 87 - 0
knowhub/docs/librarian-agent.md

@@ -0,0 +1,87 @@
+# Librarian Agent
+
+## 文档维护规范
+
+0. **先改文档,再动代码**
+1. **文档分层,链接代码** — 格式:`module/file.py:function_name`
+2. **简洁快照,日志分离** — 决策依据记录在 `knowhub/docs/decisions.md`
+
+---
+
+## 定位
+
+Librarian Agent(原 Knowledge Manager Agent)是 KnowHub 的智能层,负责:
+- 知识查询的整合回答(将散点搜索结果用 LLM 整合为带引用的回答)
+- 知识上传的去重和处理
+- 工具与知识的关联分析
+
+---
+
+## 架构
+
+```
+Agent(端侧)
+  ↓ 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)
+```
+
+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 续跑
+
+同一个 caller trace_id 映射到同一个 Librarian trace_id(映射持久化在 `.cache/.knowledge/trace_map.json`)。首次请求创建新 trace,后续请求续跑该 trace,Agent 保持对调用方任务的上下文理解。
+
+### ask 接口
+
+```
+POST /api/knowledge/ask
+{
+  "query": "ControlNet 相关的工具知识",
+  "trace_id": "caller-trace-xxx",
+  "top_k": 5
+}
+```
+
+同步阻塞。Librarian Agent 通过 knowledge_search、tool_search、capability_search 等工具跨表检索,用 LLM 综合分析后返回结构化回答。
+
+响应:`{"response": "...", "source_ids": [...], "sources": [...]}`
+
+### upload 接口
+
+```
+POST /api/knowledge/upload
+{
+  "data": {"knowledge": [...], "resources": [...], "tools": [...]},
+  "trace_id": "caller-trace-xxx",
+  "finalize": false
+}
+```
+
+立即返回 202。数据同时写入 buffer 目录(`.cache/.knowledge/buffer/`,便于回溯),Librarian Agent 在后台运行图谱编排:检索已有实体去重、挂载 capability、构建关系、写入草稿池。
+
+### 知识注入
+
+`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-plan.md](cognition-log-plan.md)。
+
+## 与知识处理流水线的关系
+
+upload 提交的知识进入处理流水线(去重、工具关联分析)。当前这部分逻辑在 `server.py:KnowledgeProcessor` 中。详见 [processing-pipeline.md](processing-pipeline.md)。

+ 112 - 0
knowhub/docs/processing-pipeline.md

@@ -0,0 +1,112 @@
+# 知识处理流水线
+
+## 文档维护规范
+
+0. **先改文档,再动代码**
+1. **文档分层,链接代码** — 格式:`module/file.py:function_name`
+2. **简洁快照,日志分离** — 决策依据记录在 `knowhub/docs/decisions.md`
+
+---
+
+## 概述
+
+知识通过 `POST /api/knowledge` 提交后,经过自动化处理流水线完成去重和工具关联分析,最终变为可检索的知识。
+
+实现:`knowhub/server.py:KnowledgeProcessor`
+
+---
+
+## 状态流转
+
+```
+pending → processing → dedup_passed → analyzing → approved
+                ↓                         ↓
+             rejected                  approved
+                                      (跳过分析)
+```
+
+| 状态 | 含义 |
+|---|---|
+| `pending` | 等待处理 |
+| `processing` | 去重判断中(乐观锁,超时自动重置) |
+| `dedup_passed` | 去重通过,等待工具关联分析 |
+| `analyzing` | 工具关联分析中 |
+| `approved` | 通过,可被检索 |
+| `rejected` | 被判定为重复(duplicate/subset) |
+| `checked` | 人工已验证(approved ↔ checked 切换) |
+
+---
+
+## 阶段一:去重判断
+
+实现:`knowhub/server.py:KnowledgeProcessor._process_one`
+
+```
+新知识(status=pending)
+  ↓
+复用入库时已生成的 embedding(不重复调用)
+  ↓
+向量召回 top-10 相似知识(filter: approved/checked)
+  ↓
+相似度预过滤(阈值 0.75)
+  ↓ 无候选 → 直接 dedup_passed
+LLM 关系判断(见下文)
+  ↓
+final_decision=rejected → 旧知识 helpful+1
+final_decision=approved → 双向写入 knowledge_relation → dedup_passed
+```
+
+### LLM 关系判断
+
+使用 `google/gemini-2.5-flash-lite` 判断新知识与候选的关系。
+
+**关系类型**(开放,LLM 可自定义):
+
+| 类型 | 含义 | 处理 |
+|---|---|---|
+| `duplicate` | task 和 content 语义完全相同 | rejected,旧知识 helpful+1 |
+| `subset` | 新知识信息被旧知识覆盖 | rejected |
+| `superset` | 新知识比旧知识更全面 | 两条都 approved |
+| `conflict` | 同一 task 下结论矛盾 | 两条都 approved |
+| `complement` | 同一 task 的不同角度 | 两条都 approved |
+| `none` | task 语义不同或无实质关系 | approved,不写关系 |
+
+关系双向写入 `knowledge_relation` 关联表:A superset B 时,写入 `(A, B, "superset")` 和 `(B, A, "subset")`。
+
+Prompt 实现:`knowhub/kb_manage_prompts.py`
+
+---
+
+## 阶段二:工具关联分析
+
+实现:`knowhub/server.py:KnowledgeProcessor._analyze_tool_relation`
+
+```
+dedup_passed 的知识
+  ↓
+LLM 分析知识内容 → 识别提及的工具
+  ↓
+匹配 tool 表中的已有工具
+  ↓
+写入 tool_knowledge 关联表
+  ↓
+status → approved
+```
+
+使用 `qwen3.5-plus` 模型分析。
+
+---
+
+## 并发控制
+
+- `process_pending()` 使用 asyncio.Lock 防止并发执行
+- 乐观锁:processing 状态通过 updated_at 时间戳锁定,超时(60秒)自动重置为 pending
+- `POST /api/knowledge/process?force=true` 可强制重置卡住的状态
+
+---
+
+## 触发时机
+
+- `POST /api/knowledge` 成功后自动触发(异步后台任务)
+- `POST /api/extract` 成功后自动触发
+- `POST /api/knowledge/process` 手动触发

+ 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`

+ 254 - 0
knowhub/docs/schema.md

@@ -0,0 +1,254 @@
+# 数据模型
+
+## 文档维护规范
+
+0. **先改文档,再动代码** - 新功能或重大修改需先完成文档更新、并完成审阅后,再进行代码实现;除非改动较小、不被文档涵盖
+1. **文档分层,链接代码** - 重要或复杂设计可以另有详细文档;关键实现需标注代码文件路径;格式:`module/file.py:function_name`
+2. **简洁快照,日志分离** - 只记录最重要的、与代码准确对应的或者明确的已完成的设计的信息,避免推测、建议、决策历史、修改日志、大量代码;决策依据或修改日志若有必要,可在 `knowhub/docs/decisions.md` 另行记录
+
+---
+
+## 存储架构
+
+PostgreSQL (阿里云 AnalyticDB for PostgreSQL)。所有向量字段为 float4[](1536维),使用余弦距离(`<=>`)检索。
+
+Embedding 模型:`google/gemini-2.5-flash-lite`(通过 OpenRouter)。实现:`knowhub/embeddings.py`。
+
+数据库名:`knowhub`
+
+---
+
+## 设计原则
+
+- **knowledge 是基础层**,其余实体是组织/提炼知识的维度
+- 每个维度的目标是**收敛到最优知识**,不是关联尽可能多的知识
+- 实体字段(description、criterion、match_result)是提炼结果,知识链是提炼来源——两者都需要
+- 所有实体间关系通过**关联表**存储,不使用 JSONB 数组软关联
+- 工具库和知识库共用同一套工具 ID,不做 ID 映射
+- Provider(运行环境)不作为知识库实体,由执行 Agent(Craftsman)内部管理;`tool_provider` 仅提供工具可用性索引
+
+---
+
+## 表间关系
+
+```
+           requirement
+          ╱           ╲
+ capability_ids    knowledge_ids
+        ╱                 ╲
+  capability            knowledge ← knowledge_resource → resource
+        ╲                 ╱  ╲
+     tool_ids      knowledge_ids  knowledge_relation
+        ╲            ╱              (知识间关系)
+         tool
+          |
+    tool_provider (执行层索引)
+```
+
+---
+
+## 实体表(5)
+
+### knowledge — 知识表
+
+基础层。用例、方法、经验、评论都是 knowledge。
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | VARCHAR PK | `"knowledge-{timestamp}-{random}"` |
+| `task_embedding` | float4[] | task 的向量 |
+| `content_embedding` | float4[] | content 的向量 |
+| `message_id` | VARCHAR | 来源 Agent 消息 ID |
+| `task` | VARCHAR | 任务场景描述 |
+| `content` | TEXT | 知识正文 |
+| `types` | TEXT[] | 知识类型:tool / strategy / case / experience / definition |
+| `tags` | JSONB | 标签键值对 |
+| `tag_keys` | TEXT[] | tags 中的键列表(用于过滤) |
+| `scopes` | TEXT[] | 作用域,如 `["org:cybertogether"]` |
+| `owner` | VARCHAR | 所有者 |
+| `source` | JSONB | 来源信息(agent_id, category, urls, submitted_by, timestamp) |
+| `eval` | JSONB | 评估(score, helpful, harmful, confidence) |
+| `created_at` | BIGINT | 创建时间戳(秒) |
+| `updated_at` | BIGINT | 更新时间戳(秒) |
+| `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
+
+### resource — 资源表
+
+知识的原始来源。文档、代码、凭证等。
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | TEXT PK | 路径格式,如 `"tools/selenium/login"` |
+| `title` | TEXT | 标题 |
+| `body` | TEXT | 公开内容 |
+| `secure_body` | TEXT | 敏感内容(AES-256-GCM 加密) |
+| `content_type` | TEXT | text / code / credential / cookie |
+| `metadata` | JSONB | 附加元数据 |
+| `sort_order` | INTEGER | 同级排序 |
+| `submitted_by` | TEXT | 提交者 |
+| `created_at` | BIGINT | 创建时间戳 |
+| `updated_at` | BIGINT | 更新时间戳 |
+
+### requirement — 需求表
+
+业务目标。分解为能力的组合,以保持稳定性。
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `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
+
+### capability — 原子能力表
+
+需求的稳定分解锚点。从知识中提炼,连接需求与工具。
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `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 | 功能介绍 |
+| `tutorial` | TEXT | 使用指南 |
+| `input` | JSONB | 输入规格 |
+| `output` | JSONB | 输出规格 |
+| `updated_time` | BIGINT | 更新时间戳 |
+| `status` | VARCHAR | 未接入 / 可用 / 异常 |
+| `embedding` | float4[] | name + introduction 的向量 |
+
+关联:
+- `capability_ids` ← capability_tool
+- `knowledge_ids` ← tool_knowledge
+- `provider_ids` ← tool_provider
+
+---
+
+## 关联表(8)
+
+### 实体链(2)
+
+**requirement_capability** — 需求分解为能力
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `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)
+
+### 知识链(3)
+
+**requirement_knowledge** — 需求的方案策略、完成方法
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `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)
+
+---
+
+## Embedding 策略
+
+| 表 | 向量字段 | Embedding 内容 |
+|---|---|---|
+| knowledge | `task_embedding` | task |
+| knowledge | `content_embedding` | content |
+| 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

+ 31 - 233
knowhub/knowhub_db/README.md

@@ -1,206 +1,13 @@
-# knowhub/knowhub_db 数据库设计文档
+# knowhub_db — 数据库访问层
 
-## 概述
-
-`knowhub_db` 是 KnowHub 系统的 **PostgreSQL 数据库访问层**,使用远程 PostgreSQL + **fastann/pgvector** 扩展同时支持向量检索和关系型查询。
-
-系统围绕四张核心表构建,以 **原子能力(atomic_capability)** 为中心,连接知识、工具和需求:
-
-```
-                     knowledge(知识表)
-                    ╱         ╲
-        support_capability    tools
-               ╱                ╲
-  atomic_capability  ←────→  tool_table
-    (原子能力表)              (工具表)
-         |                       |
-    requirements            capabilities
-         ↓                       ↓
-  requirement_table ←──────→ atomic_capability
-     (需求表)
-```
-
----
-
-## 数据库表结构
-
-### 1. `atomic_capability` — 原子能力表(新增)
-
-原子能力是系统的核心概念,从知识中提炼而来,连接工具实现与需求匹配。
-
-索引:`atomic_capability_pkey (id)`
-
-| 字段 | 类型 | 说明 | 示例值 |
-|------|------|------|--------|
-| `id` | VARCHAR | 主键 | `"ac-image-gen-001"` |
-| `name` | VARCHAR | 能力名称 | `"文生图"` |
-| `criterion` | TEXT | 评估标准,用于判断该能力是否达成 | `"能根据文本提示生成符合描述的图像"` |
-| `description` | TEXT | 能力描述 | `"通过自然语言描述生成对应视觉内容的能力"` |
-| `requirements` | JSONB | 关联的需求 ID 列表 | `["req-001", "req-002"]` |
-| `implements` | JSONB | 工具实现映射,key 为 tool_name,value 为描述(使用方式、效果、局限) | `{"Midjourney": "通过 /imagine 命令生成图像,效果优秀但不支持中文提示词"}` |
-| `tools` | JSONB | 关联的工具 ID 列表 | `["tools/image_gen/midjourney"]` |
-| `source_knowledge` | JSONB | 该能力提炼自哪些知识条目 | `["knowledge-20260330-030758-8611"]` |
-| `embedding` | float4[] | 1536 维向量,对 name + description 做 embedding | — |
-
-**关联关系:**
-- `tools` → `tool_table.id`
-- `implements` 的 key 为 `tool_table.name`
-- `requirements` → `requirement_table.id`
-- `source_knowledge` → `knowledge.id`
-
----
-
-### 2. `tool_table` — 工具表(变更)
-
-> 相对原表的变更:新增 `capabilities` 字段;知识关联字段重命名为 `tool_knowledge`、`case_knowledge`、`process_knowledge`。
-
-索引:`tool_table_pkey (id)`
-
-| 字段 | 类型 | 说明 | 示例值 |
-|------|------|------|--------|
-| `id` | VARCHAR | 主键,路径格式 | `"tools/image_gen/midjourney"` |
-| `name` | VARCHAR | 工具名称 | `"Midjourney"` |
-| `version` | VARCHAR | 版本号 | `"v5.2"` |
-| `introduction` | TEXT | 功能介绍 | `"AI 图像生成工具,支持文生图、图生图"` |
-| `tutorial` | TEXT | 使用指南 | `"通过 Discord 输入 /imagine 命令..."` |
-| `input` | JSONB | 输入规格 | `"文本提示、参数配置"` |
-| `output` | JSONB | 输出规格 | `"生成图像"` |
-| `updated_time` | BIGINT | 更新时间戳(秒) | `1774570091` |
-| `status` | VARCHAR | 接入状态 | `"未接入"` / `"可用"` / `"异常"` |
-| `capabilities` | JSONB | 关联的原子能力 ID 列表 | `["ac-image-gen-001", "ac-image-edit-002"]` |
-| `tool_knowledge` | JSONB | 关联的工具知识 ID 列表 | `["knowledge-20260327-150915-44a4"]` |
-| `case_knowledge` | JSONB | 关联的用例知识 ID 列表 | `[]` |
-| `process_knowledge` | JSONB | 关联的工序知识 ID 列表 | `[]` |
-| `embedding` | float4[] | 1536 维向量,对 name + introduction 做 embedding | — |
-
-**关联关系:**
-- `capabilities` → `atomic_capability.id`
-- `tool_knowledge` / `case_knowledge` / `process_knowledge` → `knowledge.id`
-
-**相对原表变更说明:**
-- 新增 `capabilities` 字段(JSONB 数组)
-- 新增 `embedding` 字段(float4[],对 name + introduction 做 embedding)
-- 原 `knowledge` 字段重命名为 `tool_knowledge`
-
----
-
-### 3. `knowledge` — 知识表(变更)
-
-> 相对原表的变更:新增 `support_capability`、`tools` 字段;原 `embedding` 拆分为 `task_embedding` + `content_embedding` 双向量;`types` 扩展为六种分类。其余原有字段全部保留。
-
-索引:`knowledge_pkey (id)`、`idx_knowledge_owner`、`idx_knowledge_status`
-
-| 字段 | 类型 | 说明 | 示例值 |
-|------|------|------|--------|
-| `id` | VARCHAR | 主键 | `"knowledge-20260330-030758-8611"` |
-| `task_embedding` | float4[] | 1536 维向量,对 task 做 embedding | — |
-| `content_embedding` | float4[] | 1536 维向量,对 content 做 embedding | — |
-| `message_id` | VARCHAR | 来源的 agent 任务消息 ID | `""` |
-| `task` | VARCHAR | 完成什么任务 | `"调研 AI 图像生成工具的功能特性"` |
-| `content` | TEXT | 知识正文 | `"当调研新兴 AI 工具时..."` |
-| `types` | ARRAY | 知识类型(多选):工序、用例、工具、经验、定义、User Profile | `['工具', '经验']` |
-| `tags` | JSONB | 标签键值对 | `{"intent": "信息调研", "project": "feature_extract"}` |
-| `tag_keys` | ARRAY | tags 中的键列表(用于过滤) | `['intent', 'state', 'project']` |
-| `scopes` | ARRAY | 作用域(组织/用户) | `['org:cybertogether']` |
-| `owner` | VARCHAR | 所有者 | `"srt_feature_extract_1"` |
-| `resource_ids` | ARRAY | 关联的 resource ID 列表 | `['tools/image_gen/midjourney']` |
-| `source` | JSONB | 来源信息 | `{"agent_id": "research_agent", "category": "exp"}` |
-| `eval` | JSONB | 评估信息 | `{"score": 5, "harmful": 0, "helpful": 1, "confidence": 0.5}` |
-| `created_at` | BIGINT | 创建时间戳(秒) | `1774811278` |
-| `updated_at` | BIGINT | 更新时间戳(秒) | `1774943256` |
-| `status` | VARCHAR | 状态 | `"approved"` |
-| `relationships` | JSONB | 关联关系列表 | `[{"type": "complement", "target": "knowledge-..."}]` |
-| `support_capability` | JSONB | 支撑的原子能力 ID 列表(可多个) | `["ac-image-gen-001", "ac-style-transfer-003"]` |
-| `tools` | JSONB | 关联的工具 ID 列表 | `["tools/image_gen/midjourney"]` |
-
-**关联关系:**
-- `support_capability` → `atomic_capability.id`
-- `tools` → `tool_table.id`
-
-**相对原表变更说明:**
-- 新增字段:`support_capability`(JSONB)、`tools`(JSONB)
-- 原 `embedding`(float4[])拆分为 `task_embedding` + `content_embedding` 两个向量字段
-- `types` 可选值更新为:工序、用例、工具、经验、定义、User Profile
-- 所有原有字段保留不变
-
----
-
-### 4. `requirement_table` — 需求表(变更)
-
-> 相对原表的变更:大幅简化,以原子能力 pattern 为核心,替代原来的 source_items/tools/trace/embedding 等字段。
-
-索引:`requirement_table_pkey (id)`
-
-| 字段 | 类型 | 说明 | 示例值 |
-|------|------|------|--------|
-| `id` | VARCHAR | 主键 | `"req-001"` |
-| `description` | TEXT | 需求描述 | `"能够根据用户文本描述生成高质量图像"` |
-| `atomics` | JSONB | 关联的原子能力 ID 列表 | `["ac-image-gen-001", "ac-style-transfer-003"]` |
-| `source_nodes` | JSONB | 来源于内容树的节点,对象数组 | `[{"node_name": "image_gen", "posts": ["post-001", "post-002"]}]` |
-| `status` | VARCHAR | 满足状态 | `"已满足"` / `"未满足"` |
-| `match_result` | TEXT | 满足时描述如何满足;不足时描述缺什么,提供调研方向 | `"通过 Midjourney + ComfyUI 组合可满足,但中文提示词支持不足,需调研替代方案"` |
-| `embedding` | float4[] | 1536 维向量,对 description 做 embedding | — |
-
-**关联关系:**
-- `atomics` → `atomic_capability.id`
-
-**相对原表变更说明:**
-- 删除字段:`task`、`type`、`source_type`、`source_itemset_id`、`source_items`、`tools`、`knowledge`、`case_knowledge`、`process_knowledge`、`trace`、`body`、`embedding`
-- 新增字段:`description`(TEXT)、`atomics`(JSONB)、`source_nodes`(JSONB)、`status`(VARCHAR)、`match_result`(TEXT)
-
----
-
-### 5. `resources` — 文档资源表(未变更)
-
-索引:`resources_pkey (id)`
-
-| 字段 | 类型 | 说明 | 示例值 |
-|------|------|------|--------|
-| `id` | TEXT | 主键,`/` 分隔路径格式 | `"references/python_type_hints"` |
-| `title` | TEXT | 标题 | `"Python Type Hints 权威资源列表"` |
-| `body` | TEXT | 公开内容(Markdown 或代码) | `"Python 类型提示调研的三个核心权威资源..."` |
-| `secure_body` | TEXT | 私有/加密内容 | `""` |
-| `content_type` | TEXT | 内容类型 | `"text"` |
-| `metadata` | JSONB | 附加元数据 | `{"topic": "type_hints", "category": "python"}` |
-| `sort_order` | INTEGER | 排序权重 | `0` |
-| `submitted_by` | TEXT | 提交者 | `""` |
-| `created_at` | BIGINT | 创建时间戳(秒) | `1774259301` |
-| `updated_at` | BIGINT | 更新时间戳(秒) | `1774259301` |
-
----
-
-## 表间关联关系总览
-
-所有关联均为 **应用层软关联**(JSONB 字段存储 ID 列表/映射),不使用数据库外键约束。
-
-```
-atomic_capability.tools[]             ──→  tool_table.id
-atomic_capability.implements{key}     ──→  tool_table.name
-atomic_capability.requirements[]      ──→  requirement_table.id
-atomic_capability.source_knowledge[]  ──→  knowledge.id
-
-tool_table.capabilities[]          ──→  atomic_capability.id
-tool_table.tool_knowledge[]        ──→  knowledge.id
-tool_table.case_knowledge[]        ──→  knowledge.id
-tool_table.process_knowledge[]     ──→  knowledge.id
-
-knowledge.support_capability[]     ──→  atomic_capability.id
-knowledge.tools[]                  ──→  tool_table.id
-
-requirement_table.atomics[]        ──→  atomic_capability.id
-```
-
-**双向关联说明:**
-- `atomic_capability` ↔ `tool_table`:能力的 `implements` 和工具的 `capabilities` 互为反向索引
-- `atomic_capability` ↔ `knowledge`:能力的 `source_knowledge` 和知识的 `support_capability` 互为反向索引
-- `atomic_capability` ↔ `requirement_table`:能力的 `requirements` 和需求的 `atomics` 互为反向索引
+PostgreSQL 数据库的封装层。表结构和数据模型详见 [docs/schema.md](../docs/schema.md)。
 
 ---
 
-## 核心封装类
+## 封装类
 
 ### `PostgreSQLStore` (`pg_store.py`)
-知识条目的 CRUD + 向量检索:
+knowledge 表的 CRUD + 向量检索:
 
 | 方法 | 功能 |
 |------|------|
@@ -214,7 +21,7 @@ requirement_table.atomics[]        ──→  atomic_capability.id
 | `count()` | 统计总数 |
 
 ### `PostgreSQLResourceStore` (`pg_resource_store.py`)
-资源文档的 CRUD + 导航:
+resource 表的 CRUD + 层级导航:
 
 | 方法 | 功能 |
 |------|------|
@@ -225,8 +32,14 @@ requirement_table.atomics[]        ──→  atomic_capability.id
 | `delete(id)` | 删除 |
 | `get_siblings(id)` | 获取前后同级节点 |
 
+### `PostgreSQLToolStore` (`pg_tool_store.py`)
+tool 表的 CRUD + 向量检索。关联表:capability_tool, tool_knowledge。
+
+### `PostgreSQLCapabilityStore` (`pg_capability_store.py`)
+capability 表的 CRUD + 向量检索。关联表:requirement_capability, capability_tool, capability_knowledge。
+
 ### `PostgreSQLRequirementStore` (`pg_requirement_store.py`)
-需求的 CRUD + 向量检索:
+requirement 表的 CRUD + 向量检索。关联表:requirement_capability, requirement_knowledge。
 
 | 方法 | 功能 |
 |------|------|
@@ -238,23 +51,29 @@ requirement_table.atomics[]        ──→  atomic_capability.id
 
 ---
 
-## 运维脚本
+## 目录结构
 
-| 脚本 | 功能 |
-|------|------|
-| `create_tables.py` | 创建/变更数据库表结构 |
-| `migrate_resources.py` | 从 SQLite 迁移资源到 PostgreSQL |
-| `migrate_tools.py` | 从 SQLite 迁移工具数据 |
-| `clean_invalid_knowledge_refs.py` | 清理失效的 knowledge ID 引用 |
-| `clean_resource_knowledge_refs.py` | 清理 resources 中失效的引用 |
-| `test_pg_connection.py` | 测试数据库连接 |
-| `check_extensions.py` | 查看向量扩展 |
-| `check_fastann.py` | 查看 fastann 函数和操作符 |
-| `list_databases.py` | 列出所有数据库 |
+```
+knowhub_db/
+├── pg_store.py                # knowledge 表
+├── 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/                   # 诊断和运维脚本
+    ├── check_table_structure.py   # 查看表结构和行数
+    ├── check_extensions.py        # 查看 PostgreSQL 扩展
+    ├── clear_locks.py             # 清理数据库锁
+    ├── clean_invalid_knowledge_refs.py  # 清理失效引用
+    └── ...
+```
 
 ---
 
-## 环境变量配置
+## 环境变量
 
 ```bash
 KNOWHUB_DB        # 数据库主机
@@ -263,24 +82,3 @@ KNOWHUB_USER      # 用户名
 KNOWHUB_PASSWORD  # 密码
 KNOWHUB_DB_NAME   # 数据库名
 ```
-
----
-
-## 架构特点
-
-1. **原子能力为核心**:`atomic_capability` 作为知识、工具、需求三者之间的桥梁,实现能力的提炼、实现映射和需求匹配。
-2. **全表向量检索**:四张核心表均支持 1536 维 embedding,其中 knowledge 表有双向量(`task_embedding` + `content_embedding`)分别支持任务语义和内容语义检索。
-3. **应用层软关联**:所有表间关系通过 JSONB 字段存储 ID 列表/映射,保持灵活性,需应用层维护一致性。
-4. **双向索引**:核心关联关系均有正反两个方向的索引,方便从任意一端查询关联数据。
-5. **三类工具知识**:工具关联的知识分为工具知识(tool_knowledge)、工序知识(process_knowledge)、用例知识(case_knowledge)三类,形成细粒度的知识图谱。
-6. **资源路径 ID**:`resources.id` 和 `tool_table.id` 均支持 `/` 分隔路径格式,便于层级组织和导航。
-
-### Embedding 策略
-
-| 表 | 向量字段 | Embedding 内容 |
-|------|------|------|
-| `atomic_capability` | `embedding` | name + description |
-| `tool_table` | `embedding` | name + introduction |
-| `knowledge` | `task_embedding` | task |
-| `knowledge` | `content_embedding` | content |
-| `requirement_table` | `embedding` | description |

+ 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,))

+ 0 - 0
knowhub/knowhub_db/create_tables.py → knowhub/knowhub_db/migrations/create_tables.py


+ 0 - 0
knowhub/knowhub_db/fill_cap_relations.py → knowhub/knowhub_db/migrations/fill_cap_relations.py


+ 0 - 0
knowhub/knowhub_db/fill_cap_tools.py → knowhub/knowhub_db/migrations/fill_cap_tools.py


+ 0 - 0
knowhub/knowhub_db/fill_embeddings.py → knowhub/knowhub_db/migrations/fill_embeddings.py


+ 0 - 0
knowhub/knowhub_db/fill_knowledge_tools.py → knowhub/knowhub_db/migrations/fill_knowledge_tools.py


+ 0 - 0
knowhub/knowhub_db/fix_embedding_migration.py → knowhub/knowhub_db/migrations/fix_embedding_migration.py


+ 0 - 0
knowhub/knowhub_db/import_initial_data.py → knowhub/knowhub_db/migrations/import_initial_data.py


+ 0 - 0
knowhub/knowhub_db/import_tool_research_data.py → knowhub/knowhub_db/migrations/import_tool_research_data.py


+ 0 - 0
knowhub/knowhub_db/migrate_add_implemented_tools.py → knowhub/knowhub_db/migrations/migrate_add_implemented_tools.py


+ 0 - 0
knowhub/knowhub_db/migrate_knowledge.py → knowhub/knowhub_db/migrations/migrate_knowledge.py


+ 0 - 0
knowhub/knowhub_db/migrate_resources.py → knowhub/knowhub_db/migrations/migrate_resources.py


+ 0 - 0
knowhub/knowhub_db/migrate_tables_v2.py → knowhub/knowhub_db/migrations/migrate_tables_v2.py


+ 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()

+ 0 - 0
knowhub/knowhub_db/migrate_tools.py → knowhub/knowhub_db/migrations/migrate_tools.py


+ 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()

+ 0 - 0
knowhub/knowhub_db/reclassify_tool_knowledge.py → knowhub/knowhub_db/migrations/reclassify_tool_knowledge.py


+ 0 - 0
knowhub/knowhub_db/update_case_knowledge.py → knowhub/knowhub_db/migrations/update_case_knowledge.py


+ 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):

+ 0 - 0
knowhub/knowhub_db/check_extensions.py → knowhub/knowhub_db/scripts/check_extensions.py


+ 0 - 0
knowhub/knowhub_db/check_fastann.py → knowhub/knowhub_db/scripts/check_fastann.py


+ 0 - 0
knowhub/knowhub_db/check_table_structure.py → knowhub/knowhub_db/scripts/check_table_structure.py


+ 0 - 0
knowhub/knowhub_db/clean_invalid_knowledge_refs.py → knowhub/knowhub_db/scripts/clean_invalid_knowledge_refs.py


+ 0 - 0
knowhub/knowhub_db/clean_resource_knowledge_refs.py → knowhub/knowhub_db/scripts/clean_resource_knowledge_refs.py


+ 0 - 0
knowhub/knowhub_db/clean_tool_research_data.py → knowhub/knowhub_db/scripts/clean_tool_research_data.py


+ 0 - 0
knowhub/knowhub_db/clear_locks.py → knowhub/knowhub_db/scripts/clear_locks.py


+ 0 - 0
knowhub/knowhub_db/kill_db_locks.py → knowhub/knowhub_db/scripts/kill_db_locks.py


+ 0 - 0
knowhub/knowhub_db/list_databases.py → knowhub/knowhub_db/scripts/list_databases.py


+ 0 - 0
knowhub/knowhub_db/test_imports.py → knowhub/knowhub_db/scripts/test_imports.py


+ 0 - 0
knowhub/knowhub_db/test_pg_connection.py → knowhub/knowhub_db/scripts/test_pg_connection.py


+ 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