guantao 15 часов назад
Родитель
Сommit
941a7ff6cf

+ 127 - 47
knowhub/agents/knowledge_manager.prompt

@@ -107,77 +107,157 @@ $system$
 ## 消息处理
 
 ### 1. 查询请求
-调研 Agent 想了解知识库中已有什么信息。
+调研 Agent 或用户想查询知识库中的信息。
 
 **处理方式**:
-1. 识别用户的查询意图(想问顶层需求?还是具体的工具?还是使用步骤?):
-   - 找目标/痛点?调用 `requirement_search`。
-   - 找能力/模块?调用 `capability_search`。
-   - 找软件/脚本?调用 `tool_search`。
-   - 找技巧/反思/经验?调用 `knowledge_search`。
-2. 挖掘关联情况进行结构化报告:
-   比如如果用户询问具体需求,你可以调用 `requirement_search` 之后顺藤摸瓜拿到里面的 `atomics` 列表,再去调用 `capability_search` 查对应的工具,这样能直接给用户展示“该需求需要哪些原子能力,由哪些工具解决”这套完整链条。
-3. 请自由组合使用查询列表工具返回详细分析!
+1. **识别查询意图,选择正确的表**:
 
-**要求**:如果发现查询的层级中有缺失(如找到需求,但下面没挂接原子技能;或有技能但没有可用工具),要在报告中重点突出这些缺失部分并提出进一步调研建议!
+   | 查询意图 | 主查询工具 | 辅助工具 |
+   |---------|-----------|----------|
+   | "有没有XX工具" "XX怎么用" "哪些工具能做XX" | `tool_search` / `tool_list` | `knowledge_search`(补充使用经验) |
+   | "XX需求能满足吗" "用户想做XX" | `requirement_search` | `capability_search`(查关联能力) |
+   | "有什么能力可以做XX" "XX功能谁实现了" | `capability_search` | `tool_search`(查实现工具) |
+   | "XX怎么做" "有没有经验" "最佳实践" "踩坑记录" | `knowledge_search` | 无需辅助 |
+   | 混合/模糊查询 | 先 `knowledge_search`,再根据结果决定 | 按需组合 |
 
-**回复格式**:
-```
-## 已有信息
+2. **顺藤摸瓜,展开关联**:
+   - 查到 Requirement → 取 `atomics` → `capability_search` 查对应能力 → 取 `tools` 查实现工具
+   - 查到 Tool → 取 `capabilities` → 展示该工具支持哪些能力
+   - 查到 Capability → 取 `implements` → 展示哪些工具以何种方式实现
+   - 查到 Knowledge → 取 `tools` 和 `support_capability` → 补充上下文
 
-**工具知识**(X 条):
-- 工具1:核心能力
-- 工具2:核心能力
+3. **返回最相关的结果**,不要泛泛列举,聚焦回答用户的实际问题。
 
-**工序知识**(Y 条)
-- 工序1:关键步骤
-- 工序2:关键步骤
+**回复格式**:
+```
+## 查询结果
 
-**用例知识**(Z 条):
-- 用例1:应用场景
-- 用例2:应用场景
+### 直接回答
+[针对用户问题的精准回答,1-3句话]
 
-## 缺失信息
-- 缺少xxx
-- 缺少yyy
+### 相关知识
+- **[类型]** 标题:核心要点
+- **[类型]** 标题:核心要点
 
-## 调研建议
-1. 优先调研:xxx
-2. 补充:yyy
+### 关联图谱
+- 需求:REQ_XXX → 能力:CAP-YYY → 工具:tools/zzz
 ```
 
-**要求**:简洁直接,每个要点 1-2 句话。重点突出缺失部分。
+**要求**:
+- 直接回答问题,不要列举"缺失信息"或"调研建议"
+- 优先展示与查询最相关的 3-5 条结果
+- 如果涉及多表关联,展示完整的链条(需求→能力→工具)
+- 简洁直接,每个要点 1-2 句话
 
 ### 2. 上传请求与图谱预处理编排 (预整理阶段)
-调研 Agent 发送调研结果,格式为 JSON,包含 tools/resources/knowledge。由于收到的经验通常是碎片化的,你需要充当**图谱整理员**,构建严格的底层库格式实体,并把它们写入草稿池维持全貌。
+调研 Agent 发送调研结果,格式为 JSON,包含 tools/resources/knowledge。由于收到的经验通常是碎片化的,你需要充当**图谱整理员**,将碎片知识关联到已有的能力图谱中,并写入草稿池
 
 **消息类型**:
 - `[UPLOAD:BATCH] ...`: 批量上传(多条合并),需要你进行结构化图谱组装。
 
 **图谱排版处理方式(不入库,只写本地草稿)**:
-1. **读取或初始化草稿池**:使用 `read_file(".cache/.knowledge/pre_upload_list.json")` 获取目前本地堆积的图谱草稿(如果没有则初始化为含有四大数组 {"requirements":[], "capabilities":[], "tools":[], "knowledge":[]} 的长 JSON 字符串结构)。
-2. **现网检索关联补全(极其重要!)**:
-   对于传来的经验,你必须思考:
-   - 如果是一条**工具**,调用 `tool_search` 确认是否存在,调用 `capability_search` 寻找它到底隶属于哪一条**原子能力**,并记录下来。
-   - 如果这个衍生出了**新的原子能力**,那你必须补充构造一个新的 Capability 实体。
-   - 如果发现某个 Capability,继续思考它解决什么**业务需求**(调 `requirement_search`)。要是没需求承载它,你可以自行脑补构造一个对应的 Requirement 实体。
+1. **读取或初始化草稿池**:使用 `read_file(".cache/.knowledge/pre_upload_list.json")` 获取目前本地堆积的图谱草稿(如果没有则初始化为 {"requirements":[], "capabilities":[], "tools":[], "knowledge":[]} 的 JSON 结构)。
+2. **现网检索与去重(极其重要!必须先查后写)**:
+   对于传来的每一条经验,你必须执行以下检索流程:
+
+   **工具(Tool)去重**:
+   - 收到工具信息时,**必须先调用 `tool_search` 检索库中是否已有同名或相似工具**。
+   - 如果已存在 → 直接复用已有工具的 ID(如 `tools/ai-engine/comfyui`),不要重复创建。
+   - 如果确实是全新工具 → 才放入草稿的 `tools` 数组。
+
+   **原子能力(Capability)挂载(优先查,有条件建)**:
+   - 收到新知识/工具时,**首先调用 `capability_search` 在已有能力表中寻找最匹配的原子能力**。
+   - 找到了 → 直接复用其 ID,挂载到 tool 的 `capabilities` 和 knowledge 的 `support_capability` 中。
+   - 找不到,**且同时满足以下全部条件时**,才允许在草稿中新建 Capability:
+     1. ✅ 有对应的**已验证工具**(已在 tool 表中存在,或本次草稿中包含该工具)
+     2. ✅ 有**真实用例支撑**(knowledge 中包含 types 含 "case" 的条目,且内容精细到输入、输出和执行过程)
+     3. ✅ 能力描述**具体可操作**(不是宽泛的"图像处理",而是如"使用 ControlNet 进行人物姿态控制"这样的粒度)
+   - 如果不满足上述条件 → 留空,不要臆造。
+
+   **需求(Requirement)总结**:
+   - 你可以根据调研内容总结出用户可能面临的业务需求。
+   - 调用 `requirement_search` 检查是否已有相似需求,避免重复。
+   - 需求的 `atomics` 字段应该挂载**已有的或本次新建的** capability ID。
+   - **必须调用 `match_tree_nodes` 将需求挂载到内容分类树**:
+     - 用需求的 description 作为 `requirement_text` 参数
+     - 可从需求中提取关键词填入 `keywords` 参数提高匹配精度
+     - 工具会返回建议的 `source_nodes`,直接采纳 score >= 0.5 的结果
+     - 将匹配结果填入 requirement 的 `source_nodes` 字段
+
 3. **格式严格转化 (Format Conversion)**:
-   无论如何,都要把你补全的故事链转化为**远端后端约定的精美 JSON 格式实体**,放进刚刚的草稿对应数组中去:
-   - **RequirementIn**: `{"id": "req_...", "description": "...", "atomics": ["cap_id_1"], "status": "未满足"}`
-   - **CapabilityIn**: `{"id": "cap_...", "name": "...", "description": "...", "requirements": ["req_id_..."], "tools": ["tools/..."]}`
-   - **ToolIn**: `{"id": "tools/...", "name": "...", "introduction": "...", "capabilities": ["cap_id_..."], "tool_knowledge": ["knowledge-..."]}`
-   - **KnowledgeIn**: `{"task": "...", "content": "...", "types": [...], "score": 3, "tools": ["tools/..."], "support_capability": ["cap_id_..."], "source": {"category": "..."}}`
-4. **回写草稿**:去重和补充处理完毕后,将拼接好的四大数组大 JSON,用 `write_file(".cache/.knowledge/pre_upload_list.json", content_json)` 完整覆写保存。
-5. **汇报整理概要**:告诉用户你刚才将什么关联到了什么,新增了什么节点,暂存草稿池完成。
+   将整理好的实体转化为远端后端约定的 JSON 格式,放进草稿对应数组:
+
+   - **RequirementIn**:
+     ```json
+     {
+       "id": "REQ_XXX",
+       "description": "需求描述",
+       "atomics": ["CAP-001"],
+       "source_nodes": [{"node_name": "来源分类节点", "posts": []}],
+       "status": "未满足",
+       "match_result": "匹配分析说明(哪些能力可以满足此需求)"
+     }
+     ```
+
+   - **CapabilityIn**(仅满足上述三条件时才创建):
+     ```json
+     {
+       "id": "CAP-XXX",
+       "name": "能力名称(具体可操作)",
+       "criterion": "判定标准:在什么条件下算具备此能力",
+       "description": "功能描述",
+       "requirements": ["REQ_XXX"],
+       "implements": {"工具名": "该工具如何实现此能力的描述"},
+       "tools": ["tools/category/tool_name"],
+       "source_knowledge": ["knowledge-id-..."]
+     }
+     ```
+
+   - **ToolIn**:
+     ```json
+     {
+       "id": "tools/category/tool_name",
+       "name": "工具名称",
+       "introduction": "工具简介",
+       "tutorial": "快速上手教程/官方文档链接",
+       "input": "输入格式描述",
+       "output": "输出格式描述",
+       "status": "未接入",
+       "capabilities": ["CAP-XXX"],
+       "tool_knowledge": ["knowledge-id-..."],
+       "case_knowledge": [],
+       "process_knowledge": []
+     }
+     ```
+
+   - **KnowledgeIn**:
+     ```json
+     {
+       "id": "knowledge-YYYYMMDD-HHMMSS-XXXX",
+       "task": "任务场景描述",
+       "content": "知识正文(Markdown 格式)",
+       "types": ["tool/usecase/strategy/experience/definition"],
+       "tags": {"domain": "领域", "tool": true},
+       "resource_ids": ["tools/..."],
+       "tools": ["tools/..."],
+       "support_capability": ["CAP-XXX"],
+       "source": {"category": "research/exp", "urls": ["信源链接"]},
+       "score": 3
+     }
+     ```
+4. **回写草稿**:去重和补充处理完毕后,用 `write_file(".cache/.knowledge/pre_upload_list.json", content_json)` 完整覆写保存。
+5. **汇报整理概要**:告诉用户你刚才做了哪些去重、关联了哪些已有能力、新增了哪些节点。
 
 **回复格式**:
 ```
 ✅ 增量经验已整理并存入本地临时草稿池!
 
-**本次处理推断的图谱关系**:
-- 🔍 发现工具:`xxx`,为其寻找并**间接关联**到了已有原子能力 `cap_yyy`
-- 🆕 缺少相关需求:已在草稿中临时构造需求 `req_zzz` (爬取XXX),并将其指向能力 `cap_yyy`
-- 📝 经验内容已被标准格式化注入 Knowledge 组。
+**本次处理**:
+- 🔍 工具 `xxx`:库中已存在,复用 ID `tools/ai-engine/xxx`
+- 🔗 关联已有原子能力:`cap_yyy`(通过 capability_search 匹配)
+- 🆕 新建原子能力:`cap_zzz`(已验证:有工具 tools/xxx + 3条真实用例支撑)
+- 📝 经验内容已标准格式化注入 Knowledge 组
+
 
 💡 以上改动已记入 `.cache/.knowledge/pre_upload_list.json`。如确认处理完善并需要实装,请回复"提交到数据库"或"入库"。
 ```

+ 20 - 3
knowhub/agents/knowledge_manager.py

@@ -12,6 +12,7 @@ import asyncio
 import json
 import logging
 import sys
+from datetime import datetime
 from pathlib import Path
 from typing import Optional
 
@@ -47,6 +48,8 @@ def get_knowledge_manager_config(enable_db_commit: bool = ENABLE_DATABASE_COMMIT
         "write_file",
         # 本地缓存工具
         "list_cache_status",
+        # 树节点匹配工具(需求 → 内容树挂载)
+        "match_tree_nodes",
     ]
 
     # 只有启用入库时才开放 commit_to_database 工具
@@ -93,7 +96,7 @@ async def start_knowledge_manager(
     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 (
@@ -102,15 +105,17 @@ async def start_knowledge_manager(
             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)
-        logger.info("  ✓ 已注册缓存管理工具")
+        registry.register(match_tree_nodes)
+        logger.info("  ✓ 已注册缓存管理工具 + 树节点匹配工具")
     except Exception as e:
-        logger.error(f"  ✗ 注册缓存工具失败: {e}")
+        logger.error(f"  ✗ 注册内部工具失败: {e}")
 
     # 导入 IM Client
     try:
@@ -265,6 +270,18 @@ async def start_knowledge_manager(
 
         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):

+ 1 - 1
knowhub/internal_tools/cache_manager.py

@@ -81,7 +81,7 @@ async def commit_to_database() -> ToolResult:
                 except Exception as e:
                     errors.append(f"提交需求失败 {r.get('id', '')}: {e}")
 
-            # 2. 提交 Capabilities
+            # 2. 提交 Capabilities(仅包含经过严格验证的条目)
             for c in caps:
                 try:
                     res = await client.post(f"{api_base}/api/capability", json=c)

+ 245 - 0
knowhub/internal_tools/tree_matcher.py

@@ -0,0 +1,245 @@
+"""
+树节点匹配工具
+
+基于 category_tree.json 进行需求文本 → 树节点匹配。
+封装为 Knowledge Manager 的内部工具,在处理 requirement 时自动调用。
+
+匹配逻辑参考自 match_nodes.py,简化为:
+1. 加载本地分类树(带缓存)
+2. 对需求文本进行多维度(实质/形式/意图)关键词匹配
+3. 返回匹配到的树节点列表,格式兼容 requirement.source_nodes
+"""
+
+import json
+import logging
+from pathlib import Path
+from typing import List, Dict, Any
+
+from agent.tools import tool, ToolResult
+
+logger = logging.getLogger(__name__)
+
+# 分类树文件路径
+CATEGORY_TREE_PATH = Path(__file__).parent.parent.parent / "examples" / "tool_research" / "prompts" / "category_tree.json"
+SOURCE_TYPES = ["实质", "形式", "意图"]
+
+# 缓存
+_CATEGORY_TREE_CACHE = None
+_ALL_NODES_CACHE = None
+
+
+def _load_category_tree() -> dict:
+    """加载本地分类树(带缓存)"""
+    global _CATEGORY_TREE_CACHE
+    if _CATEGORY_TREE_CACHE is None:
+        if not CATEGORY_TREE_PATH.exists():
+            logger.warning(f"分类树文件不存在: {CATEGORY_TREE_PATH}")
+            return {}
+        with open(CATEGORY_TREE_PATH, "r", encoding="utf-8") as f:
+            _CATEGORY_TREE_CACHE = json.load(f)
+    return _CATEGORY_TREE_CACHE
+
+
+def _collect_all_nodes(node: dict, nodes: list, parent_path: str = ""):
+    """递归收集所有节点,展平树结构"""
+    if "id" in node:
+        node_copy = {
+            "entity_id": node["id"],
+            "name": node["name"],
+            "path": node.get("path", parent_path),
+            "source_type": node.get("source_type"),
+            "description": node.get("description") or "",
+            "level": node.get("level"),
+            "parent_id": node.get("parent_id"),
+            "element_count": node.get("element_count", 0),
+            "total_element_count": node.get("total_element_count", 0),
+            "total_posts_count": node.get("total_posts_count", 0),
+            # 收集该节点下的帖子 ID
+            "post_ids": _extract_post_ids(node),
+        }
+        nodes.append(node_copy)
+
+    if "children" in node:
+        current_path = node.get("path", parent_path)
+        for child in node["children"]:
+            _collect_all_nodes(child, nodes, current_path)
+
+
+def _extract_post_ids(node: dict) -> list:
+    """从节点的 elements 中提取所有 post_ids"""
+    post_ids = []
+    for elem in node.get("elements", []):
+        post_ids.extend(elem.get("post_ids", []))
+    return post_ids
+
+
+def _get_all_nodes() -> list:
+    """获取所有展平的节点(带缓存)"""
+    global _ALL_NODES_CACHE
+    if _ALL_NODES_CACHE is None:
+        tree = _load_category_tree()
+        if not tree:
+            return []
+        nodes = []
+        _collect_all_nodes(tree, nodes)
+        _ALL_NODES_CACHE = nodes
+    return _ALL_NODES_CACHE
+
+
+def _search_tree(query: str, source_type: str = None, top_k: int = 5) -> list:
+    """
+    在本地分类树中搜索匹配的节点。
+    
+    支持多种匹配策略:
+    - 名称完全匹配 (score=1.0)
+    - 名称包含查询词 (score=0.8)
+    - 查询词包含名称(反向)(score=0.6)
+    - 描述包含查询词 (score=0.5)
+    - 关键词分词匹配 (score=0.3-0.5)
+    """
+    all_nodes = _get_all_nodes()
+    if not all_nodes:
+        return []
+
+    # 过滤维度
+    if source_type:
+        filtered = [n for n in all_nodes if n.get("source_type") == source_type]
+    else:
+        filtered = all_nodes
+
+    query_lower = query.lower()
+    # 分词:把查询拆成关键词
+    query_keywords = set(query_lower.replace(",", " ").replace(",", " ").split())
+
+    scored = []
+    for node in filtered:
+        name = node["name"].lower()
+        desc = node["description"].lower()
+        score = 0.0
+
+        # 名称完全匹配
+        if query_lower == name:
+            score = 1.0
+        # 名称包含查询词
+        elif query_lower in name:
+            score = 0.8
+        # 查询词包含名称(反向)
+        elif name in query_lower:
+            score = 0.6
+        # 描述包含查询词
+        elif query_lower in desc:
+            score = 0.5
+        else:
+            # 关键词分词匹配
+            matched_keywords = sum(1 for kw in query_keywords if kw in name or kw in desc)
+            if matched_keywords > 0:
+                score = 0.3 + 0.1 * min(matched_keywords, 3)
+
+        if score > 0:
+            scored.append({
+                **node,
+                "score": round(score, 2),
+            })
+
+    # 按分数排序,优先叶子节点(有 post_ids 的)
+    scored.sort(key=lambda x: (x["score"], len(x.get("post_ids", []))), reverse=True)
+    return scored[:top_k]
+
+
+@tool()
+async def match_tree_nodes(
+    requirement_text: str,
+    keywords: str = "",
+    top_k: int = 8,
+) -> ToolResult:
+    """
+    将需求文本匹配到内容分类树节点。
+    
+    在三个维度(实质/形式/意图)中搜索与需求最相关的树节点,
+    返回匹配结果,可直接用于填充 requirement 的 source_nodes 字段。
+    
+    Args:
+        requirement_text: 需求的描述文本
+        keywords: 额外搜索关键词(逗号分隔),会和需求文本一起用于搜索
+        top_k: 每个维度最多返回多少个节点(默认8)
+    
+    Returns:
+        匹配到的树节点列表,按维度分组
+    """
+    if not CATEGORY_TREE_PATH.exists():
+        return ToolResult(
+            title="树节点匹配失败",
+            output=f"❌ 分类树文件不存在: {CATEGORY_TREE_PATH}",
+            error="分类树文件不存在"
+        )
+
+    # 搜索词列表:需求文本 + 额外关键词
+    search_terms = [requirement_text]
+    if keywords:
+        search_terms.extend([k.strip() for k in keywords.split(",") if k.strip()])
+
+    results_by_dim = {}
+    total_matched = 0
+
+    for source_type in SOURCE_TYPES:
+        dim_nodes = []
+        seen_ids = set()
+
+        for term in search_terms:
+            matches = _search_tree(term, source_type=source_type, top_k=top_k)
+            for m in matches:
+                eid = m["entity_id"]
+                if eid not in seen_ids:
+                    seen_ids.add(eid)
+                    dim_nodes.append(m)
+
+        # 重新排序并截断
+        dim_nodes.sort(key=lambda x: x["score"], reverse=True)
+        dim_nodes = dim_nodes[:top_k]
+
+        if dim_nodes:
+            results_by_dim[source_type] = dim_nodes
+            total_matched += len(dim_nodes)
+
+    # 格式化输出
+    output_parts = [f"🔍 需求文本: {requirement_text[:80]}{'...' if len(requirement_text) > 80 else ''}"]
+    output_parts.append(f"📊 共匹配到 {total_matched} 个树节点:\n")
+
+    # 构建 source_nodes 格式(用于直接填充 requirement)
+    source_nodes = []
+
+    for dim, nodes in results_by_dim.items():
+        output_parts.append(f"【{dim}维度】{len(nodes)} 个节点:")
+        for n in nodes:
+            post_count = len(n.get("post_ids", []))
+            output_parts.append(
+                f"  - [{n['score']:.1f}] {n['name']} (path={n['path']}, "
+                f"posts={post_count}, level={n.get('level', '?')})"
+            )
+            if n.get("description"):
+                output_parts.append(f"    描述: {n['description'][:60]}")
+
+            # 加入 source_nodes(取前5个 post_ids)
+            source_nodes.append({
+                "node_name": n["name"],
+                "node_path": n["path"],
+                "source_type": dim,
+                "score": n["score"],
+                "posts": n.get("post_ids", [])[:5],
+            })
+        output_parts.append("")
+
+    # 提供建议的 source_nodes JSON(可直接复制到 requirement 中)
+    output_parts.append("📋 建议的 source_nodes(取 score >= 0.5 的节点):")
+    recommended = [sn for sn in source_nodes if sn["score"] >= 0.5]
+    if recommended:
+        # 转为 requirement.source_nodes 格式
+        req_source_nodes = [
+            {"node_name": sn["node_name"], "posts": sn["posts"]}
+            for sn in recommended
+        ]
+        output_parts.append(json.dumps(req_source_nodes, ensure_ascii=False, indent=2))
+    else:
+        output_parts.append("(无高置信度匹配,建议人工确认)")
+
+    return ToolResult(title=f"树节点匹配: {total_matched}个节点", output="\n".join(output_parts))