Jelajahi Sumber

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

guantao 2 hari lalu
induk
melakukan
1911c34cc8
40 mengubah file dengan 3847 tambahan dan 734 penghapusan
  1. 1 1
      .gitignore
  2. 211 4
      agent/core/runner.py
  3. 10 0
      agent/tools/builtin/feishu/chat_history/chat_谭景玉.json
  4. 160 0
      agent/trace/api.py
  5. 21 2
      agent/trace/goal_tool.py
  6. 2 2
      agent/trace/models.py
  7. 116 0
      agent/trace/store.py
  8. 2 0
      api_server.py
  9. 50 0
      examples/content_tree_analyst/analyst.prompt
  10. 34 0
      examples/content_tree_analyst/config.py
  11. 39 0
      examples/content_tree_analyst/docs/requirements.md
  12. 740 0
      examples/content_tree_analyst/docs/内容树API.md
  13. 15 0
      examples/content_tree_analyst/docs/频繁项集API.md
  14. 9 0
      examples/content_tree_analyst/presets.json
  15. 221 0
      examples/content_tree_analyst/run.py
  16. 1 0
      examples/content_tree_analyst/tools/__init__.py
  17. 167 0
      examples/content_tree_analyst/tools/content_tree.py
  18. 99 0
      examples/content_tree_analyst/tools/frequent_itemsets.py
  19. 0 67
      examples/research/config.py
  20. 0 14
      examples/research/presets.json
  21. 0 42
      examples/research/requirement.prompt
  22. 0 68
      examples/research/research.prompt
  23. 0 378
      examples/research/run.py
  24. 0 139
      examples/research/tools/reflect.py
  25. 1 1
      frontend/react-template/package.json
  26. 20 0
      frontend/react-template/src/App.tsx
  27. 29 0
      frontend/react-template/src/api/traceApi.ts
  28. 138 0
      frontend/react-template/src/components/CreateKnowledgeModal/CreateKnowledgeModal.module.css
  29. 153 0
      frontend/react-template/src/components/CreateKnowledgeModal/CreateKnowledgeModal.tsx
  30. 24 0
      frontend/react-template/src/components/DetailPanel/DetailPanel.module.css
  31. 65 0
      frontend/react-template/src/components/DetailPanel/DetailPanel.tsx
  32. 28 4
      frontend/react-template/src/components/FlowChart/FlowChart.tsx
  33. 255 0
      frontend/react-template/src/components/KnowledgeFeedbackModal/KnowledgeFeedbackModal.module.css
  34. 305 0
      frontend/react-template/src/components/KnowledgeFeedbackModal/KnowledgeFeedbackModal.tsx
  35. 5 2
      frontend/react-template/src/components/MainContent/MainContent.tsx
  36. 54 0
      frontend/react-template/src/components/TopBar/TopBar.tsx
  37. 33 0
      frontend/react-template/src/types/goal.ts
  38. 392 0
      knowhub/docs/dedup-design.md
  39. 269 0
      knowhub/docs/feedback-timing-design.md
  40. 178 10
      knowhub/server.py

+ 1 - 1
.gitignore

@@ -71,7 +71,7 @@ knowhub/knowhub.db
 knowhub/knowhub.db-shm
 knowhub/knowhub.db-shm
 knowhub/knowhub.db-wal
 knowhub/knowhub.db-wal
 examples/archive/*
 examples/archive/*
-examples/research
+examples/research/
 
 
 # Milvus data
 # Milvus data
 knowhub/milvus_data/
 knowhub/milvus_data/

+ 211 - 4
agent/core/runner.py

@@ -67,8 +67,8 @@ class ContextUsage:
 
 
 @dataclass
 @dataclass
 class SideBranchContext:
 class SideBranchContext:
-    """侧分支上下文(压缩/反思)"""
-    type: Literal["compression", "reflection"]
+    """侧分支上下文(压缩/反思/知识评估)"""
+    type: Literal["compression", "reflection", "knowledge_eval"]
     branch_id: str
     branch_id: str
     start_head_seq: int          # 侧分支起点的 head_seq
     start_head_seq: int          # 侧分支起点的 head_seq
     start_sequence: int          # 侧分支第一条消息的 sequence
     start_sequence: int          # 侧分支第一条消息的 sequence
@@ -783,6 +783,28 @@ class AgentRunner:
         if not needs_compression:
         if not needs_compression:
             return history, head_seq, sequence, False
             return history, head_seq, sequence, False
 
 
+        # 检查是否有待评估知识(压缩前必须先评估)
+        if self.trace_store and not config.force_side_branch:
+            pending = await self.trace_store.get_pending_knowledge_entries(trace_id)
+            if pending:
+                # 设置侧分支队列:反思 → 知识评估 → 压缩
+                # 反思放在前面,确保反思期间完成的 goal 产生的新知识也能在压缩前被评估
+                if config.knowledge.enable_extraction:
+                    config.force_side_branch = ["reflection", "knowledge_eval", "compression"]
+                else:
+                    config.force_side_branch = ["knowledge_eval", "compression"]
+
+                # 在 trace.context 中设置触发事件
+                trace = await self.trace_store.get_trace(trace_id)
+                if trace:
+                    if not trace.context:
+                        trace.context = {}
+                    trace.context["knowledge_eval_trigger"] = "compression"
+                    await self.trace_store.update_trace(trace_id, context=trace.context)
+
+                logger.info(f"[Knowledge Eval] 压缩前触发知识评估,待评估: {len(pending)} 条")
+                return history, head_seq, sequence, True
+
         # 知识提取:在任何压缩发生前,用完整 history 做反思(进入反思侧分支)
         # 知识提取:在任何压缩发生前,用完整 history 做反思(进入反思侧分支)
         if config.knowledge.enable_extraction and not config.force_side_branch:
         if config.knowledge.enable_extraction and not config.force_side_branch:
             # 设置侧分支队列:先反思,再压缩
             # 设置侧分支队列:先反思,再压缩
@@ -846,6 +868,74 @@ class AgentRunner:
 
 
         return history, head_seq, sequence, False
         return history, head_seq, sequence, False
 
 
+    async def _build_knowledge_eval_prompt(
+        self,
+        trace_id: str,
+        goal_tree: Optional[GoalTree]
+    ) -> str:
+        """构建知识评估 prompt"""
+        if not self.trace_store:
+            return ""
+
+        pending = await self.trace_store.get_pending_knowledge_entries(trace_id)
+        if not pending:
+            return ""
+
+        # 获取mission
+        trace = await self.trace_store.get_trace(trace_id)
+        mission = trace.task if trace else "未知任务"
+
+        # 获取当前Goal
+        current_goal = goal_tree.find(goal_tree.current_id) if goal_tree and goal_tree.current_id else None
+        goal_desc = current_goal.description if current_goal else "无当前目标"
+
+        # 构建知识列表
+        knowledge_list = []
+        for idx, entry in enumerate(pending, 1):
+            knowledge_list.append(
+                f"### 知识 {idx}: {entry['knowledge_id']}\n"
+                f"- task: {entry['task']}\n"
+                f"- content: {entry['content']}\n"
+                f"- 注入于: sequence {entry['injected_at_sequence']}, goal {entry['goal_id']}"
+            )
+
+        prompt = f"""你是知识评估助手。请评估以下知识在本次任务执行中的实际效果。
+
+## 当前任务(Mission)
+{mission}
+
+## 当前 Goal
+{goal_desc}
+
+## 待评估知识列表
+{chr(10).join(knowledge_list)}
+
+## 评估维度
+1. **helpfulness**: 知识内容是否对完成任务有实质帮助?
+2. **relevance**: 执行过程中是否体现了该知识的内容?
+
+## 评估分类
+- irrelevant: task与当前任务无关
+- unused: 相关但未使用
+- helpful: 有帮助
+- harmful: 有负面作用
+- neutral: 无明显作用
+
+## 输出格式
+请直接输出评估结果,使用JSON格式:
+
+{{
+  "evaluations": [
+    {{
+      "knowledge_id": "knowledge-xxx",
+      "eval_status": "helpful",
+      "reason": "1-2句评估理由"
+    }}
+  ]
+}}
+"""
+        return prompt
+
     async def _single_turn_compress(
     async def _single_turn_compress(
         self,
         self,
         trace_id: str,
         trace_id: str,
@@ -1052,6 +1142,17 @@ class AgentRunner:
                         yield trace_obj
                         yield trace_obj
                 return
                 return
 
 
+            # 检查Goal完成触发的知识评估
+            if not side_branch_ctx and self.trace_store:
+                trace = await self.trace_store.get_trace(trace_id)
+                if trace and trace.context and trace.context.get("pending_knowledge_eval"):
+                    # 清除标志
+                    trace.context.pop("pending_knowledge_eval", None)
+                    await self.trace_store.update_trace(trace_id, context=trace.context)
+                    # 设置侧分支队列
+                    config.force_side_branch = ["knowledge_eval"]
+                    logger.info("[Knowledge Eval] 检测到Goal完成触发,进入知识评估侧分支")
+
             # Context 管理(仅主路径)
             # Context 管理(仅主路径)
             needs_enter_side_branch = False
             needs_enter_side_branch = False
             if not side_branch_ctx:
             if not side_branch_ctx:
@@ -1071,9 +1172,15 @@ class AgentRunner:
 
 
             # 进入侧分支
             # 进入侧分支
             if needs_enter_side_branch and not side_branch_ctx:
             if needs_enter_side_branch and not side_branch_ctx:
+                # 刷新 trace,获取 _manage_context_usage 可能写入 DB 的 knowledge_eval_trigger
+                if self.trace_store:
+                    fresh = await self.trace_store.get_trace(trace_id)
+                    if fresh:
+                        trace = fresh
                 # 从队列中取出第一个侧分支类型
                 # 从队列中取出第一个侧分支类型
+                branch_type: Literal["compression", "reflection", "knowledge_eval"]
                 if config.force_side_branch and isinstance(config.force_side_branch, list) and len(config.force_side_branch) > 0:
                 if config.force_side_branch and isinstance(config.force_side_branch, list) and len(config.force_side_branch) > 0:
-                    branch_type = config.force_side_branch.pop(0)
+                    branch_type = config.force_side_branch.pop(0)  # type: ignore
                     logger.info(f"从队列取出侧分支: {branch_type}, 剩余队列: {config.force_side_branch}")
                     logger.info(f"从队列取出侧分支: {branch_type}, 剩余队列: {config.force_side_branch}")
                 elif config.knowledge.enable_extraction:
                 elif config.knowledge.enable_extraction:
                     # 兼容旧的单值模式(如果 force_side_branch 是字符串)
                     # 兼容旧的单值模式(如果 force_side_branch 是字符串)
@@ -1096,6 +1203,9 @@ class AgentRunner:
 
 
                 # 持久化侧分支状态
                 # 持久化侧分支状态
                 if self.trace_store:
                 if self.trace_store:
+                    # 获取触发事件(如果是 knowledge_eval 分支)
+                    trigger_event = trace.context.get("knowledge_eval_trigger", "unknown") if branch_type == "knowledge_eval" else None
+
                     trace.context["active_side_branch"] = {
                     trace.context["active_side_branch"] = {
                         "type": side_branch_ctx.type,
                         "type": side_branch_ctx.type,
                         "branch_id": side_branch_ctx.branch_id,
                         "branch_id": side_branch_ctx.branch_id,
@@ -1105,6 +1215,13 @@ class AgentRunner:
                         "max_turns": side_branch_ctx.max_turns,
                         "max_turns": side_branch_ctx.max_turns,
                         "started_at": datetime.now().isoformat(),
                         "started_at": datetime.now().isoformat(),
                     }
                     }
+
+                    # 如果是 knowledge_eval 分支,添加 trigger_event
+                    if trigger_event:
+                        trace.context["active_side_branch"]["trigger_event"] = trigger_event
+                        # 清除触发事件标记
+                        trace.context.pop("knowledge_eval_trigger", None)
+
                     await self.trace_store.update_trace(
                     await self.trace_store.update_trace(
                         trace_id,
                         trace_id,
                         context=trace.context
                         context=trace.context
@@ -1113,6 +1230,8 @@ class AgentRunner:
                 # 追加侧分支 prompt
                 # 追加侧分支 prompt
                 if branch_type == "reflection":
                 if branch_type == "reflection":
                     prompt = config.knowledge.get_reflect_prompt()
                     prompt = config.knowledge.get_reflect_prompt()
+                elif branch_type == "knowledge_eval":
+                    prompt = await self._build_knowledge_eval_prompt(trace_id, goal_tree)
                 else:  # compression
                 else:  # compression
                     from agent.trace.compaction import build_compression_prompt
                     from agent.trace.compaction import build_compression_prompt
                     prompt = build_compression_prompt(goal_tree)
                     prompt = build_compression_prompt(goal_tree)
@@ -1249,6 +1368,48 @@ class AgentRunner:
                     cache_read_tokens=cache_read_tokens or 0,
                     cache_read_tokens=cache_read_tokens or 0,
                 )
                 )
 
 
+            # 知识评估侧分支:即时检测并写入评估结果
+            if side_branch_ctx and side_branch_ctx.type == "knowledge_eval":
+                text = response_content if isinstance(response_content, str) else ""
+                eval_results = None
+
+                try:
+                    eval_results = json.loads(text.strip())
+                    if "evaluations" not in eval_results:
+                        eval_results = None
+                except json.JSONDecodeError:
+                    import re
+                    json_match = re.search(r'```json\s*(\{.*?\})\s*```', text, re.DOTALL)
+                    if json_match:
+                        try:
+                            eval_results = json.loads(json_match.group(1))
+                        except json.JSONDecodeError:
+                            pass
+
+                    if not eval_results:
+                        json_match = re.search(r'\{[^{]*"evaluations"[^}]*\[[^\]]*\][^}]*\}', text, re.DOTALL)
+                        if json_match:
+                            try:
+                                eval_results = json.loads(json_match.group(0))
+                            except json.JSONDecodeError:
+                                pass
+
+                if eval_results and self.trace_store:
+                    current_trace = await self.trace_store.get_trace(trace_id)
+                    trigger_event = current_trace.context.get("active_side_branch", {}).get("trigger_event", "unknown")
+
+                    for eval_item in eval_results.get("evaluations", []):
+                        await self.trace_store.update_knowledge_evaluation(
+                            trace_id=trace_id,
+                            knowledge_id=eval_item["knowledge_id"],
+                            eval_result={
+                                "eval_status": eval_item["eval_status"],
+                                "reason": eval_item.get("reason", "")
+                            },
+                            trigger_event=trigger_event
+                        )
+                    logger.info(f"[Knowledge Eval] 已写入 {len(eval_results.get('evaluations', []))} 条评估结果")
+
             # 如果在侧分支,记录到 assistant_msg(已持久化,不需要额外维护)
             # 如果在侧分支,记录到 assistant_msg(已持久化,不需要额外维护)
 
 
             yield assistant_msg
             yield assistant_msg
@@ -1386,6 +1547,30 @@ class AgentRunner:
                     side_branch_ctx = None
                     side_branch_ctx = None
                     continue
                     continue
 
 
+                elif should_exit and side_branch_ctx.type == "knowledge_eval":
+                    # === 知识评估侧分支退出 ===
+                    logger.info("知识评估侧分支退出")
+
+                    # 恢复主路径
+                    if self.trace_store:
+                        main_path_messages = await self.trace_store.get_main_path_messages(
+                            trace_id, side_branch_ctx.start_head_seq
+                        )
+                        history = [m.to_llm_dict() for m in main_path_messages]
+                        head_seq = side_branch_ctx.start_head_seq
+
+                    # 清理
+                    trace.context.pop("active_side_branch", None)
+                    if not config.force_side_branch or len(config.force_side_branch) == 0:
+                        config.force_side_branch = None
+                        logger.info("知识评估完成,队列为空")
+                    if self.trace_store:
+                        await self.trace_store.update_trace(
+                            trace_id, context=trace.context, head_sequence=head_seq,
+                        )
+                    side_branch_ctx = None
+                    continue
+
             # 处理工具调用
             # 处理工具调用
             # 截断兜底:finish_reason == "length" 说明响应被 max_tokens 截断,
             # 截断兜底:finish_reason == "length" 说明响应被 max_tokens 截断,
             # tool call 参数很可能不完整,不应执行,改为提示模型分批操作
             # tool call 参数很可能不完整,不应执行,改为提示模型分批操作
@@ -1451,6 +1636,13 @@ class AgentRunner:
                     args_display = args_str[:100] + "..." if len(args_str) > 100 else args_str
                     args_display = args_str[:100] + "..." if len(args_str) > 100 else args_str
                     logger.info(f"[Tool Call] {tool_name}({args_display})")
                     logger.info(f"[Tool Call] {tool_name}({args_display})")
 
 
+                    # 获取trigger_event(如果在knowledge_eval侧分支中)
+                    trigger_event_for_tool = None
+                    if side_branch_ctx and side_branch_ctx.type == "knowledge_eval" and self.trace_store:
+                        current_trace = await self.trace_store.get_trace(trace_id)
+                        if current_trace:
+                            trigger_event_for_tool = current_trace.context.get("active_side_branch", {}).get("trigger_event", "unknown")
+
                     tool_result = await self.tools.execute(
                     tool_result = await self.tools.execute(
                         tool_name,
                         tool_name,
                         tool_args,
                         tool_args,
@@ -1462,12 +1654,14 @@ class AgentRunner:
                             "runner": self,
                             "runner": self,
                             "goal_tree": goal_tree,
                             "goal_tree": goal_tree,
                             "knowledge_config": config.knowledge,
                             "knowledge_config": config.knowledge,
+                            "sequence": sequence,  # 添加sequence用于知识注入记录
                             # 新增:侧分支信息
                             # 新增:侧分支信息
                             "side_branch": {
                             "side_branch": {
                                 "type": side_branch_ctx.type,
                                 "type": side_branch_ctx.type,
                                 "branch_id": side_branch_ctx.branch_id,
                                 "branch_id": side_branch_ctx.branch_id,
                                 "is_side_branch": True,
                                 "is_side_branch": True,
                                 "max_turns": side_branch_ctx.max_turns,
                                 "max_turns": side_branch_ctx.max_turns,
+                                "trigger_event": trigger_event_for_tool,
                             } if side_branch_ctx else None,
                             } if side_branch_ctx else None,
                         },
                         },
                     )
                     )
@@ -1609,7 +1803,20 @@ class AgentRunner:
 
 
             # 无工具调用
             # 无工具调用
             # 如果在侧分支中,已经在上面处理过了(不会走到这里)
             # 如果在侧分支中,已经在上面处理过了(不会走到这里)
-            # 主路径无工具调用 → 任务完成,检查是否需要完成后反思
+            # 主路径无工具调用 → 任务完成,检查是否需要完成后反思或知识评估
+
+            # 检查是否有待评估的知识
+            if not side_branch_ctx and self.trace_store:
+                pending = await self.trace_store.get_pending_knowledge_entries(trace_id)
+                if pending:
+                    logger.info(f"任务即将结束,但仍有 {len(pending)} 条知识未评估,强制触发评估")
+                    config.force_side_branch = ["knowledge_eval"]
+                    trace = await self.trace_store.get_trace(trace_id)
+                    if trace:
+                        trace.context["knowledge_eval_trigger"] = "task_completion"
+                        await self.trace_store.update_trace(trace_id, context=trace.context)
+                    continue
+
             if not side_branch_ctx and config.knowledge.enable_completion_extraction and not break_after_side_branch:
             if not side_branch_ctx and config.knowledge.enable_completion_extraction and not break_after_side_branch:
                 config.force_side_branch = ["reflection"]
                 config.force_side_branch = ["reflection"]
                 break_after_side_branch = True
                 break_after_side_branch = True

+ 10 - 0
agent/tools/builtin/feishu/chat_history/chat_谭景玉.json

@@ -8,5 +8,15 @@
         "text": "你好!我需要登录小红书来完成搜索摄影主题的任务,但是没有找到保存的cookie。\n\n请点击以下链接在浏览器中完成小红书登录:\nhttps://live.browser-use.com?wss=wss%3A//4599a061-1830-4cb0-99fc-fffb5503e99a.cdp1.browser-use.com/devtools/browser/f77323a4-3759-4558-85e0-f4eb3eb04368\n\n登录完成后请告诉我,我会保存登录状态。谢谢!"
         "text": "你好!我需要登录小红书来完成搜索摄影主题的任务,但是没有找到保存的cookie。\n\n请点击以下链接在浏览器中完成小红书登录:\nhttps://live.browser-use.com?wss=wss%3A//4599a061-1830-4cb0-99fc-fffb5503e99a.cdp1.browser-use.com/devtools/browser/f77323a4-3759-4558-85e0-f4eb3eb04368\n\n登录完成后请告诉我,我会保存登录状态。谢谢!"
       }
       }
     ]
     ]
+  },
+  {
+    "role": "assistant",
+    "message_id": "om_x100b5488244594a4c4d3c52f961965f",
+    "content": [
+      {
+        "type": "text",
+        "text": "需要协助登录小红书进行调研。\n\n请打开云浏览器链接完成小红书登录:\n(云浏览器链接需要先初始化)\n\n任务:搜索\"AI角色连载\"\"AI虚拟人日常\"\"AI短剧连载\"相关内容,找出持续更新同一角色故事的账号\n\n请登录后回复确认,我将保存cookie继续调研。"
+      }
+    ]
   }
   }
 ]
 ]

+ 160 - 0
agent/trace/api.py

@@ -4,9 +4,14 @@ Trace RESTful API
 提供 Trace、GoalTree、Message 的查询接口
 提供 Trace、GoalTree、Message 的查询接口
 """
 """
 
 
+import os
+import json
+import httpx
+from datetime import datetime, timezone
 from typing import List, Optional, Dict, Any
 from typing import List, Optional, Dict, Any
 from fastapi import APIRouter, HTTPException, Query
 from fastapi import APIRouter, HTTPException, Query
 from pydantic import BaseModel
 from pydantic import BaseModel
+from agent.llm.openrouter import openrouter_llm_call
 
 
 from .protocols import TraceStore
 from .protocols import TraceStore
 
 
@@ -171,3 +176,158 @@ async def get_messages(
     return MessagesResponse(
     return MessagesResponse(
         messages=[m.to_dict() for m in messages]
         messages=[m.to_dict() for m in messages]
     )
     )
+
+
+# ===== 知识反馈 =====
+
+
+class KnowledgeFeedbackItem(BaseModel):
+    knowledge_id: str
+    action: str  # "confirm" | "override" | "skip"
+    eval_status: Optional[str] = None  # helpful | harmful | unused | irrelevant | neutral
+    feedback_text: Optional[str] = None
+    source: Dict[str, Any] = {}  # {trace_id, goal_id, sequence, feedback_by, feedback_at}
+
+
+class KnowledgeFeedbackRequest(BaseModel):
+    feedback_list: List[KnowledgeFeedbackItem]
+
+
+@router.get("/{trace_id}/knowledge_log")
+async def get_knowledge_log(trace_id: str):
+    """获取 Trace 的知识注入日志"""
+    store = get_trace_store()
+    trace = await store.get_trace(trace_id)
+    if not trace:
+        raise HTTPException(status_code=404, detail="Trace not found")
+    return await store.get_knowledge_log(trace_id)
+
+
+@router.post("/{trace_id}/knowledge_feedback")
+async def submit_knowledge_feedback(trace_id: str, req: KnowledgeFeedbackRequest):
+    """提交知识使用反馈,同步更新 knowledge_log.json 并转发到 KnowHub"""
+    store = get_trace_store()
+    trace = await store.get_trace(trace_id)
+    if not trace:
+        raise HTTPException(status_code=404, detail="Trace not found")
+
+    knowhub_url = os.getenv("KNOWHUB_API") or os.getenv("KNOWHUB_URL", "http://localhost:9999")
+    updated_count = 0
+    now_iso = datetime.now(timezone.utc).isoformat()
+
+    async with httpx.AsyncClient(timeout=10.0) as client:
+        for item in req.feedback_list:
+            if item.action == "skip":
+                continue
+
+            # 1. 记录到 knowledge_log.json
+            feedback_record = {
+                "action": item.action,
+                "eval_status": item.eval_status,
+                "feedback_text": item.feedback_text,
+                "feedback_by": item.source.get("feedback_by", "user"),
+                "feedback_at": item.source.get("feedback_at", now_iso),
+            }
+            await store.update_user_feedback(trace_id, item.knowledge_id, feedback_record)
+
+            # 2. 构建 history 条目(含完整溯源信息)
+            history_entry = {
+                "source": "user",
+                "action": item.action,
+                "eval_status": item.eval_status,
+                "feedback_by": item.source.get("feedback_by", "user"),
+                "feedback_at": now_iso,
+                "trace_id": trace_id,
+                "goal_id": item.source.get("goal_id"),
+                "sequence": item.source.get("sequence"),
+                "feedback_text": item.feedback_text,
+            }
+
+            # 3. 根据 action 和 eval_status 决定调用 KnowHub 的哪个字段
+            if item.action == "confirm":
+                payload = {"add_helpful_case": history_entry}
+            elif item.action == "override":
+                if item.eval_status == "harmful":
+                    payload = {"add_harmful_case": history_entry}
+                else:
+                    # helpful / unused / irrelevant / neutral → 记为 helpful_case,history 内保留完整 eval_status
+                    payload = {"add_helpful_case": history_entry}
+            else:
+                continue
+
+            try:
+                await client.put(
+                    f"{knowhub_url}/api/knowledge/{item.knowledge_id}",
+                    json=payload
+                )
+                updated_count += 1
+            except Exception as e:
+                # 记录警告但不中断整体提交
+                print(f"[KnowledgeFeedback] KnowHub 更新失败 {item.knowledge_id}: {e}")
+
+    return {"status": "ok", "updated": updated_count}
+
+
+@router.post("/extract_comment", status_code=201)
+async def extract_comment_proxy(req: Dict[str, Any]):
+    """调用 LLM 从评论提取结构化知识,再 POST 到远端 KnowHub /api/knowledge"""
+    comment = (req.get("comment") or "").strip()
+    if not comment:
+        raise HTTPException(status_code=400, detail="comment is required")
+
+    context = req.get("context") or ""
+    prompt = f"""你是知识提取专家。根据用户的评论和 Agent 执行上下文,提取一条结构化知识。
+
+【上下文(Agent 执行内容)】:
+{context or "(无上下文)"}
+
+【用户评论】:
+{comment}
+
+【输出格式】(严格 JSON,不要其他内容):
+{{
+  "task": "任务场景描述(一句话,描述在什么情况下要完成什么目标)",
+  "content": "核心知识内容(具体可操作的方法、注意事项)"
+}}"""
+
+    try:
+        response = await openrouter_llm_call(
+            messages=[{"role": "user", "content": prompt}],
+            model="google/gemini-2.5-flash-lite",
+        )
+        raw = response.get("content", "").strip()
+        if "```" in raw:
+            for part in raw.split("```"):
+                part = part.strip().lstrip("json").strip()
+                try:
+                    parsed = json.loads(part)
+                    if "task" in parsed and "content" in parsed:
+                        raw = part
+                        break
+                except Exception:
+                    continue
+        extracted = json.loads(raw)
+        task = extracted.get("task", "").strip()
+        content = extracted.get("content", "").strip()
+        if not task or not content:
+            raise ValueError("missing task or content")
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"LLM 提取失败: {e}")
+
+    knowhub_url = os.getenv("KNOWHUB_API") or os.getenv("KNOWHUB_URL", "http://localhost:9999")
+    payload = {
+        "task": task,
+        "content": content,
+        "types": req.get("types", ["strategy"]),
+        "scopes": req.get("scopes", ["org:cybertogether"]),
+        "owner": req.get("owner", "user"),
+        "source": req.get("source", {}),
+    }
+    async with httpx.AsyncClient(timeout=15.0) as client:
+        try:
+            resp = await client.post(f"{knowhub_url}/api/knowledge", json=payload)
+            resp.raise_for_status()
+            data = resp.json()
+            return {"status": "pending", "knowledge_id": data.get("id", ""), "task": task, "content": content}
+        except Exception as e:
+            raise HTTPException(status_code=502, detail=f"KnowHub 写入失败: {e}")

+ 21 - 2
agent/trace/goal_tool.py

@@ -24,6 +24,7 @@ async def inject_knowledge_for_goal(
     store: Optional["TraceStore"] = None,
     store: Optional["TraceStore"] = None,
     trace_id: Optional[str] = None,
     trace_id: Optional[str] = None,
     knowledge_config: Optional[dict] = None,
     knowledge_config: Optional[dict] = None,
+    sequence: Optional[int] = None,
 ) -> Optional[str]:
 ) -> Optional[str]:
     """
     """
     为指定 goal 注入相关知识。
     为指定 goal 注入相关知识。
@@ -34,6 +35,7 @@ async def inject_knowledge_for_goal(
         store: TraceStore(用于持久化)
         store: TraceStore(用于持久化)
         trace_id: Trace ID
         trace_id: Trace ID
         knowledge_config: 知识管理配置(KnowledgeConfig 对象)
         knowledge_config: 知识管理配置(KnowledgeConfig 对象)
+        sequence: 当前消息序列号(用于记录注入时机)
 
 
     Returns:
     Returns:
         注入结果描述(如 "📚 已注入 3 条相关知识"),无结果返回 None
         注入结果描述(如 "📚 已注入 3 条相关知识"),无结果返回 None
@@ -74,6 +76,19 @@ async def inject_knowledge_for_goal(
             if store and trace_id:
             if store and trace_id:
                 await store.update_goal_tree(trace_id, tree)
                 await store.update_goal_tree(trace_id, tree)
 
 
+                # 写入 knowledge_log
+                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")
+
             return f"📚 已注入 {knowledge_count} 条相关知识"
             return f"📚 已注入 {knowledge_count} 条相关知识"
         else:
         else:
             goal.knowledge = []
             goal.knowledge = []
@@ -136,7 +151,8 @@ async def goal(
         done=done,
         done=done,
         abandon=abandon,
         abandon=abandon,
         focus=focus,
         focus=focus,
-        knowledge_config=knowledge_config
+        knowledge_config=knowledge_config,
+        context=context
     )
     )
 
 
 
 
@@ -155,6 +171,7 @@ async def goal_tool(
     abandon: Optional[str] = None,
     abandon: Optional[str] = None,
     focus: Optional[str] = None,
     focus: Optional[str] = None,
     knowledge_config: Optional[object] = None,
     knowledge_config: Optional[object] = None,
+    context: Optional[dict] = None,
 ) -> str:
 ) -> str:
     """
     """
     管理执行计划。
     管理执行计划。
@@ -213,7 +230,9 @@ async def goal_tool(
         changes.append(f"切换焦点: {display_id}. {goal.description}")
         changes.append(f"切换焦点: {display_id}. {goal.description}")
 
 
         # 自动注入知识
         # 自动注入知识
-        inject_msg = await inject_knowledge_for_goal(goal, tree, store, trace_id, knowledge_config)
+        inject_msg = await inject_knowledge_for_goal(
+            goal, tree, store, trace_id, knowledge_config, sequence=context.get("sequence")
+        )
         if inject_msg:
         if inject_msg:
             changes.append(inject_msg)
             changes.append(inject_msg)
 
 

+ 2 - 2
agent/trace/models.py

@@ -178,7 +178,7 @@ class Message:
     content: Any = None                  # 消息内容(和 LLM API 格式一致)
     content: Any = None                  # 消息内容(和 LLM API 格式一致)
 
 
     # 侧分支标记
     # 侧分支标记
-    branch_type: Optional[Literal["compression", "reflection"]] = None  # 侧分支类型(None = 主路径)
+    branch_type: Optional[Literal["compression", "reflection", "knowledge_eval"]] = None  # 侧分支类型(None = 主路径)
     branch_id: Optional[str] = None      # 侧分支 ID(同一侧分支的消息共享)
     branch_id: Optional[str] = None      # 侧分支 ID(同一侧分支的消息共享)
 
 
     # 元数据
     # 元数据
@@ -316,7 +316,7 @@ class Message:
         content: Any = None,
         content: Any = None,
         tool_call_id: Optional[str] = None,
         tool_call_id: Optional[str] = None,
         parent_sequence: Optional[int] = None,
         parent_sequence: Optional[int] = None,
-        branch_type: Optional[Literal["compression", "reflection"]] = None,
+        branch_type: Optional[Literal["compression", "reflection", "knowledge_eval"]] = None,
         branch_id: Optional[str] = None,
         branch_id: Optional[str] = None,
         prompt_tokens: Optional[int] = None,
         prompt_tokens: Optional[int] = None,
         completion_tokens: Optional[int] = None,
         completion_tokens: Optional[int] = None,

+ 116 - 0
agent/trace/store.py

@@ -249,6 +249,20 @@ class FileSystemTraceStore:
         })
         })
         print(f"[DEBUG] Pushed goal_updated event: goal_id={goal_id}, updates={updates}, affected={len(affected_goals)}")
         print(f"[DEBUG] Pushed goal_updated event: goal_id={goal_id}, updates={updates}, affected={len(affected_goals)}")
 
 
+        # Goal 完成时触发知识评估
+        if updates.get("status") in ["completed", "abandoned"]:
+            pending = await self.get_pending_knowledge_entries(trace_id)
+            if pending:
+                # 在trace.context中设置标志,由runner主循环检查
+                trace = await self.get_trace(trace_id)
+                if trace:
+                    if not trace.context:
+                        trace.context = {}
+                    trace.context["pending_knowledge_eval"] = True
+                    trace.context["knowledge_eval_trigger"] = "goal_completion"
+                    await self.update_trace(trace_id, context=trace.context)
+                    logger.info(f"[Knowledge Eval] Goal {goal_id} 完成,设置评估标志,待评估知识: {len(pending)} 条")
+
     async def _check_cascade_completion(
     async def _check_cascade_completion(
         self,
         self,
         trace_id: str,
         trace_id: str,
@@ -750,3 +764,105 @@ class FileSystemTraceStore:
             f.write(json.dumps(event, ensure_ascii=False) + '\n')
             f.write(json.dumps(event, ensure_ascii=False) + '\n')
 
 
         return event_id
         return event_id
+
+    # ===== Knowledge Log 管理 =====
+
+    def _get_knowledge_log_file(self, trace_id: str) -> Path:
+        """获取 knowledge_log.json 文件路径"""
+        return self._get_trace_dir(trace_id) / "knowledge_log.json"
+
+    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"))
+
+    async def append_knowledge_entry(
+        self,
+        trace_id: str,
+        knowledge_id: str,
+        goal_id: str,
+        injected_at_sequence: int,
+        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")
+
+    async def update_knowledge_evaluation(
+        self,
+        trace_id: str,
+        knowledge_id: str,
+        eval_result: Dict[str, Any],
+        trigger_event: str
+    ) -> None:
+        """更新知识评估结果
+
+        当同一个knowledge_id在不同goal中被多次注入时,
+        优先更新最近一个未评估的条目(按injected_at_sequence倒序)
+        """
+        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
+        ]
+
+        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")
+
+    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]
+
+    async def update_user_feedback(
+        self,
+        trace_id: str,
+        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
+
+        log_file = self._get_knowledge_log_file(trace_id)
+        log_file.write_text(json.dumps(log, indent=2, ensure_ascii=False), encoding="utf-8")

+ 2 - 0
api_server.py

@@ -11,6 +11,8 @@ API Server - FastAPI 应用入口
 import logging
 import logging
 import json
 import json
 import os
 import os
+from dotenv import load_dotenv
+load_dotenv()
 from fastapi import FastAPI, Request, WebSocket
 from fastapi import FastAPI, Request, WebSocket
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.middleware.cors import CORSMiddleware
 import uvicorn
 import uvicorn

+ 50 - 0
examples/content_tree_analyst/analyst.prompt

@@ -0,0 +1,50 @@
+---
+model: qwen3.5-plus
+temperature: 0.3
+---
+
+$system$
+
+## 角色
+你是内容制作需求分析专家,擅长从内容树节点结构中归纳出有价值的图文内容制作需求。
+
+## 工作流程
+
+### 第一步:获取节点局部结构
+给定一个内容树节点 id(category 或 element),调用 `search_content_tree` 或 `get_category_tree` 获取:
+- **祖先路径**(`include_ancestors=true`):了解该节点的上下文和所属维度
+- **同级节点**:搜索同名关键词或父节点的直接子节点,了解同类
+- **子孙节点**(`descendant_depth=2`):了解该节点的细分方向
+
+### 第二步:判断与图文制作的相关性
+结合节点的名称、描述、所属维度(实质/形式/意图),判断哪些节点与**图文内容制作**直接相关:
+- **保留**:与视觉呈现、角色设计、场景构图、风格表达、情感传达等制作行为直接相关的节点
+- **过滤**:纯语义/主题分类节点(如"节日"、"品牌"等不涉及制作手法的节点)
+
+### 第三步:获取关联频繁项集
+对筛选出的重要节点,调用 `get_frequent_itemsets` 获取关联要素:
+- 传入节点的 `entity_id`(搜索接口返回的 `entity_id` 字段)
+- 频繁项集揭示了在优质内容中经常与该节点共同出现的要素
+- 用这些关联要素扩展需求的覆盖范围(如"动作姿态"→"夸张"、"运动"等)
+
+### 第四步:归纳制作需求
+对每组相关节点,归纳出若干条制作能力或工具需求:
+- **粒度适中**:不能太细("生成猫咪"),也不能太粗("生成图像")
+- **正确示例**:"需要能够生成保持角色一致性的人物图像的能力"
+- **同批需求不重叠**:不同需求应覆盖不同的制作维度,而且最好是对应到不同的工具
+
+### 第五步:输出结构化需求
+将归纳结果写入 `%output_dir%/requirements.md`,每条需求包含:
+- 需求描述(自然语言)
+- 来源节点 id 列表
+- 相关频繁项集 id(若有)
+- 所属维度(实质/形式/意图)
+
+$user$
+请对以下内容树节点进行制作需求归纳分析:
+
+stable_id:334
+source_type:形式
+
+请按照工作流程,逐步分析该节点及其周边结构,最终将结构化的制作需求列表输出到 %output_dir%/requirements.md。
+注意分析出来的需求不可以彼此之间有显著重叠;最好是有所区分的不同能力、需要不同工具支撑的能力。

+ 34 - 0
examples/content_tree_analyst/config.py

@@ -0,0 +1,34 @@
+"""
+项目配置
+"""
+
+from agent.core.runner import RunConfig
+from agent.tools.builtin.knowledge import KnowledgeConfig
+
+
+RUN_CONFIG = RunConfig(
+    model="qwen3.5-plus",
+    temperature=0.3,
+    max_iterations=500,
+    extra_llm_params={"extra_body": {"enable_thinking": True}},
+    agent_type="analyst",
+    name="内容树需求归纳",
+
+    knowledge=KnowledgeConfig(
+        enable_extraction=False,
+        enable_completion_extraction=False,
+        enable_injection=False,
+        owner="sunlit.howard@gmail.com",
+        default_tags={"project": "gen_query_from_content_tree"},
+        default_scopes=["org:cybertogether"],
+        # default_search_types=["strategy"],
+        default_search_owner="sunlit.howard@gmail.com",
+    ),
+)
+
+OUTPUT_DIR = "examples/content_tree_analyst/output"
+SKILLS_DIR = "./skills"
+TRACE_STORE_PATH = ".trace"
+DEBUG = True
+LOG_LEVEL = "INFO"
+LOG_FILE = None

+ 39 - 0
examples/content_tree_analyst/docs/requirements.md

@@ -0,0 +1,39 @@
+任务1:从内容树节点归纳制作需求                                                                      
+                                                                                               
+  - 输入:内容树中某个节点的 id(category 或 element)
+  - 过程:                                
+    a. 调用搜索 API
+  获取该节点的局部结构:祖先路径(了解上下文)、同级节点(了解同类)、子孙节点(了解细分)             
+    b. 结合节点的名称、描述、所属维度(实质/形式/意图),判断哪些节点与图文内容制作直接相关(过滤掉与制
+  作无关的纯语义/主题类节点)                                                                          
+    c. 对筛选出的重要节点,进一步获取其关联的频繁项集(通过get_frequent_itemsets接口,每次传入一个category_ids;频繁项集中可以看到经常与指定节点在优质内容中共同出现的要素,比如能给“动作姿态”节点扩展到“夸张”“运动”等关联要素,从而能提出更准的需求)
+    d. 对每一组节点,归纳出一个制作能力或工具需求(如"需要能够生成保持角色一致性的人物图像的能力")
+  - 输出:若干条结构化的制作需求,每条包含:  
+    - 需求描述(自然语言)                
+    - 来源节点 id 列表(支撑这条需求的节点)
+    - 相关频繁项集id(若有)
+    - 所属维度(实质/形式/意图)                                                                       
+  - 约束:                                
+    - 需求粒度不能太细(不是"生成猫咪"),也不能太粗(不是"生成图像")                                 
+    - 同一批输出的需求之间应尽量不重叠                                                                 
+                                          
+  ---                                                                                                  
+  任务2:从制作能力/工具关联内容树节点                                                                 
+   
+  - 输入:一条制作能力或工具知识(包含名称、描述、适用场景)                                           
+  - 过程:                                                        
+    a. 从知识的名称和适用场景中提取关键词,调用搜索 API 在内容树中检索相关节点
+    b. 对返回的节点按相关度评分,过滤掉低相关节点
+    c. 对保留的节点,标注关联类型:
+        - 直接关联:该能力/工具直接用于制作包含此节点的内容
+        - 间接关联:该能力/工具是制作此类内容的前置步骤或辅助手段
+    d. 将 节点 id - 知识 id - 关联类型 - 置信度 写入映射表
+  - 输出:一批映射记录,每条包含:
+    - 节点 id + 节点名称                      
+    - 知识 id                             
+    - 关联类型(直接/间接)
+    - 置信度(高/中/低)                                                                               
+  - 约束:
+    - 低置信度的映射需标记,待后续实验验证后升级或删除                                                 
+    - 同一个节点可以关联多条知识,需支持后续按置信度排序检索      
+                                          

+ 740 - 0
examples/content_tree_analyst/docs/内容树API.md

@@ -0,0 +1,740 @@
+# 搜索 API 文档
+
+本文档包含三个搜索相关的 API 接口:
+
+1. **关键词搜索** - 根据关键词搜索分类和元素
+2. **获取分类树** - 获取指定分类的完整路径和子树
+3. **获取全量元素** - 分页浏览元素,支持排序和筛选
+
+---
+
+## 接口 1:关键词搜索
+
+### 接口地址
+
+```
+GET http://8.147.104.190:8001/api/search
+```
+
+### 功能说明
+
+搜索全局分类库中的分类(Category)和元素(Element),支持文本匹配、上下文扩展、平台筛选等功能。
+
+## 请求参数
+
+| 参数名 | 类型 | 必填 | 默认值 | 说明 |
+|--------|------|------|--------|------|
+| q | string | ✓ | - | 搜索关键词 |
+| source_type | string | ✓ | - | 元素类型:`实质` / `形式` / `意图` |
+| entity_type | string | ✗ | all | 搜索对象类型:`category`(分类)/ `element`(元素)/ `all`(全部) |
+| top_k | integer | ✗ | 20 | 返回结果数量,范围 1-100 |
+| use_description | boolean | ✗ | false | 是否在描述字段中搜索(true=搜索名称+描述,false=仅搜索名称) |
+| mode | string | ✗ | text | 搜索模式:`text`(文本匹配)/ `vector`(向量搜索)/ `hybrid`(混合),当前仅支持 text |
+| include_ancestors | boolean | ✗ | false | 是否返回祖先路径(从根节点到父节点的完整路径) |
+| descendant_depth | integer | ✗ | 0 | 返回 N 代以内的子孙节点,0=不返回,1=直接子节点,2=子节点+孙节点... |
+| platform | string | ✗ | null | 按平台筛选,仅对元素有效(如:`小红书`、`抖音`、`微博` 等) |
+
+## 返回格式
+
+```json
+{
+  "success": true,
+  "query": "搜索关键词",
+  "source_type": "实质",
+  "entity_type": "all",
+  "count": 3,
+  "results": [
+    {
+      "entity_type": "category",
+      "entity_id": 123,
+      "stable_id": 456,
+      "name": "分类名称",
+      "description": "分类描述",
+      "path": "/一级分类/二级分类",
+      "category_nature": "领域层级",
+      "level": 2,
+      "score": 0.95,
+      "scores": {
+        "text": 0.95,
+        "vector": 0.0
+      },
+      "ancestors": [...],
+      "descendants": [...]
+    },
+    {
+      "entity_type": "element",
+      "entity_id": 789,
+      "name": "元素名称",
+      "description": "元素描述",
+      "belong_category_stable_id": 456,
+      "occurrence_count": 25,
+      "score": 0.88,
+      "scores": {
+        "text": 0.88,
+        "vector": 0.0
+      }
+    }
+  ]
+}
+```
+
+### 返回字段说明
+
+#### 通用字段
+- `success`: 请求是否成功
+- `query`: 搜索关键词
+- `source_type`: 元素类型
+- `entity_type`: 搜索对象类型
+- `count`: 返回结果数量
+- `results`: 结果列表
+
+#### 结果对象字段(分类 Category)
+- `entity_type`: 固定为 "category"
+- `entity_id`: 分类数据库 ID
+- `stable_id`: 分类稳定 ID(用于跨版本引用)
+- `name`: 分类名称
+- `description`: 分类描述
+- `path`: 分类路径(如 `/主体/角色类型/人物角色`)
+- `category_nature`: 分类性质(领域层级/元描述层级)
+- `level`: 层级深度(1=根节点)
+- `score`: 综合相似度分数(0-1)
+- `scores`: 各维度分数
+- `ancestors`: 祖先路径(当 `include_ancestors=true` 时返回)
+- `descendants`: 子孙节点(当 `descendant_depth>0` 时返回)
+
+#### 结果对象字段(元素 Element)
+- `entity_type`: 固定为 "element"
+- `entity_id`: 元素数据库 ID
+- `name`: 元素名称
+- `description`: 元素描述
+- `belong_category_stable_id`: 所属分类的 stable_id
+- `occurrence_count`: 出现次数
+- `score`: 综合相似度分数(0-1)
+- `scores`: 各维度分数
+
+#### ancestors 字段结构
+```json
+[
+  {
+    "stable_id": 1,
+    "name": "主体",
+    "level": 1
+  },
+  {
+    "stable_id": 10,
+    "name": "角色类型",
+    "level": 2
+  }
+]
+```
+
+#### descendants 字段结构
+```json
+[
+  {
+    "stable_id": 500,
+    "name": "人物角色",
+    "level": 3,
+    "depth_from_parent": 1,
+    "is_leaf": false
+  },
+  {
+    "stable_id": 501,
+    "name": "动物角色",
+    "level": 3,
+    "depth_from_parent": 1,
+    "is_leaf": true
+  }
+]
+```
+
+---
+
+## 使用示例
+
+### 1. 基础搜索
+
+#### 搜索所有(分类+元素)
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=角色" \
+  --data-urlencode "source_type=实质"
+```
+
+#### 只搜索分类
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=主体" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "entity_type=category"
+```
+
+#### 只搜索元素
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=猫咪" \
+  --data-urlencode "source_type=形式" \
+  --data-urlencode "entity_type=element"
+```
+
+#### 限制返回数量
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=角色" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "top_k=5"
+```
+
+### 2. 扩展搜索范围
+
+#### 搜索名称+描述
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=拟人" \
+  --data-urlencode "source_type=形式" \
+  --data-urlencode "use_description=true"
+```
+
+### 3. 获取上下文信息
+
+#### 返回祖先路径
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=人物角色" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "entity_type=category" \
+  --data-urlencode "include_ancestors=true"
+```
+
+#### 返回1代子孙节点
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=主体" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "entity_type=category" \
+  --data-urlencode "descendant_depth=1"
+```
+
+#### 返回2代子孙节点
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=主体" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "entity_type=category" \
+  --data-urlencode "descendant_depth=2"
+```
+
+#### 同时返回祖先+子孙
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=角色类型" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "entity_type=category" \
+  --data-urlencode "include_ancestors=true" \
+  --data-urlencode "descendant_depth=2"
+```
+
+### 4. 平台筛选
+
+#### 只搜索小红书平台的元素
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=角色" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "entity_type=element" \
+  --data-urlencode "platform=小红书"
+```
+
+#### 搜索抖音平台的元素
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=主体" \
+  --data-urlencode "source_type=形式" \
+  --data-urlencode "platform=抖音"
+```
+
+#### 平台筛选+描述搜索
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=拟人" \
+  --data-urlencode "source_type=形式" \
+  --data-urlencode "platform=小红书" \
+  --data-urlencode "use_description=true"
+```
+
+### 5. 组合查询
+
+#### 全功能组合
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=角色" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "entity_type=all" \
+  --data-urlencode "top_k=10" \
+  --data-urlencode "use_description=true" \
+  --data-urlencode "include_ancestors=true" \
+  --data-urlencode "descendant_depth=1" \
+  --data-urlencode "platform=小红书"
+```
+
+#### 不同 source_type 的搜索
+```bash
+# 搜索意图维度
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=情感" \
+  --data-urlencode "source_type=意图"
+
+# 搜索形式维度
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=视觉" \
+  --data-urlencode "source_type=形式"
+```
+
+### 6. 浏览器直接访问
+
+```
+http://8.147.104.190:8001/api/search?q=角色&source_type=实质
+http://8.147.104.190:8001/api/search?q=主体&source_type=实质&entity_type=category&include_ancestors=true&descendant_depth=2
+http://8.147.104.190:8001/api/search?q=猫咪&source_type=形式&platform=小红书
+```
+
+### 7. FastAPI 交互式文档
+
+访问以下地址可以在网页上直接测试 API:
+
+```
+http://8.147.104.190:8001/docs
+```
+
+在文档页面找到 `/api/search` 接口,点击 "Try it out" 按钮,填写参数后点击 "Execute" 即可测试。
+
+---
+
+## 返回示例
+
+### 示例 1:基础搜索
+
+**请求:**
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=角色" \
+  --data-urlencode "source_type=实质"
+```
+
+**响应:**
+```json
+{
+  "success": true,
+  "query": "角色",
+  "source_type": "实质",
+  "entity_type": "all",
+  "count": 2,
+  "results": [
+    {
+      "entity_type": "category",
+      "entity_id": 123,
+      "stable_id": 456,
+      "name": "角色类型",
+      "description": "内容主体的角色分类",
+      "path": "/主体/角色类型",
+      "category_nature": "领域层级",
+      "level": 2,
+      "score": 0.95,
+      "scores": {
+        "text": 0.95,
+        "vector": 0.0
+      }
+    },
+    {
+      "entity_type": "element",
+      "entity_id": 789,
+      "name": "人物角色",
+      "description": "真实或虚拟的人物形象",
+      "belong_category_stable_id": 456,
+      "occurrence_count": 25,
+      "score": 0.88,
+      "scores": {
+        "text": 0.88,
+        "vector": 0.0
+      }
+    }
+  ]
+}
+```
+
+### 示例 2:带祖先和子孙的搜索
+
+**请求:**
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=角色类型" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "entity_type=category" \
+  --data-urlencode "include_ancestors=true" \
+  --data-urlencode "descendant_depth=2"
+```
+
+**响应:**
+```json
+{
+  "success": true,
+  "query": "角色类型",
+  "source_type": "实质",
+  "entity_type": "category",
+  "count": 1,
+  "results": [
+    {
+      "entity_type": "category",
+      "entity_id": 123,
+      "stable_id": 456,
+      "name": "角色类型",
+      "description": "内容主体的角色分类",
+      "path": "/主体/角色类型",
+      "category_nature": "领域层级",
+      "level": 2,
+      "score": 1.0,
+      "scores": {
+        "text": 1.0,
+        "vector": 0.0
+      },
+      "ancestors": [
+        {
+          "stable_id": 1,
+          "name": "主体",
+          "level": 1
+        }
+      ],
+      "descendants": [
+        {
+          "stable_id": 500,
+          "name": "人物角色",
+          "level": 3,
+          "depth_from_parent": 1,
+          "is_leaf": false
+        },
+        {
+          "stable_id": 501,
+          "name": "动物角色",
+          "level": 3,
+          "depth_from_parent": 1,
+          "is_leaf": false
+        },
+        {
+          "stable_id": 600,
+          "name": "真人角色",
+          "level": 4,
+          "depth_from_parent": 2,
+          "is_leaf": true
+        },
+        {
+          "stable_id": 601,
+          "name": "虚拟角色",
+          "level": 4,
+          "depth_from_parent": 2,
+          "is_leaf": true
+        }
+      ]
+    }
+  ]
+}
+```
+
+### 示例 3:平台筛选
+
+**请求:**
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=猫咪" \
+  --data-urlencode "source_type=形式" \
+  --data-urlencode "entity_type=element" \
+  --data-urlencode "platform=小红书"
+```
+
+**响应:**
+```json
+{
+  "success": true,
+  "query": "猫咪",
+  "source_type": "形式",
+  "entity_type": "element",
+  "count": 3,
+  "results": [
+    {
+      "entity_type": "element",
+      "entity_id": 1001,
+      "name": "猫咪",
+      "description": "可爱的猫咪形象",
+      "belong_category_stable_id": 200,
+      "occurrence_count": 15,
+      "score": 1.0,
+      "scores": {
+        "text": 1.0,
+        "vector": 0.0
+      }
+    },
+    {
+      "entity_type": "element",
+      "entity_id": 1002,
+      "name": "猫咪拟人",
+      "description": "拟人化的猫咪角色",
+      "belong_category_stable_id": 201,
+      "occurrence_count": 8,
+      "score": 0.85,
+      "scores": {
+        "text": 0.85,
+        "vector": 0.0
+      }
+    }
+  ]
+}
+```
+
+---
+
+## 错误处理
+
+### 错误响应格式
+
+```json
+{
+  "detail": "错误信息描述"
+}
+```
+
+### 常见错误
+
+| HTTP 状态码 | 错误原因 | 解决方法 |
+|------------|---------|---------|
+| 400 | 缺少必填参数(q 或 source_type) | 检查请求参数 |
+| 400 | source_type 值不合法 | 使用 `实质`、`形式` 或 `意图` |
+| 400 | entity_type 值不合法 | 使用 `category`、`element` 或 `all` |
+| 400 | mode 不支持 | 当前仅支持 `text` 模式 |
+| 500 | 服务器内部错误 | 联系技术支持 |
+
+---
+
+## 注意事项
+
+1. **中文参数编码**:使用 curl 时,中文参数需要用 `--data-urlencode` 进行 URL 编码
+2. **平台筛选限制**:`platform` 参数仅对元素(element)有效,对分类(category)无效
+3. **性能考虑**:
+   - `use_description=true` 会增加搜索范围,可能降低精确度
+   - `descendant_depth` 越大,返回数据越多,响应时间越长
+   - 建议 `top_k` 不超过 50
+4. **搜索模式**:当前仅支持 `text` 模式,`vector` 和 `hybrid` 模式将在后续版本中支持
+5. **平台名称**:`platform` 参数值需要与数据库中的平台名称完全一致(区分大小写)
+
+---
+
+## 接口 2:获取分类树
+
+### 接口地址
+
+```
+GET http://8.147.104.190:8001/api/search/category/{stable_id}
+```
+
+### 功能说明
+
+获取指定分类的完整路径和子树结构,用于导航和展示分类层级关系。
+
+### 请求参数
+
+| 参数名 | 类型 | 必填 | 默认值 | 说明 |
+|--------|------|------|--------|------|
+| stable_id | integer | ✓ | - | 分类的 stable_id(路径参数) |
+| source_type | string | ✓ | - | 元素类型:`实质` / `形式` / `意图` |
+| include_ancestors | boolean | ✗ | true | 是否返回祖先路径(从根节点到当前节点) |
+| descendant_depth | integer | ✗ | -1 | 返回子孙深度,-1=全部,0=仅当前节点,1=子节点,2=子+孙... |
+
+### 返回格式
+
+```json
+{
+  "success": true,
+  "current": {
+    "stable_id": 125,
+    "name": "吉祥用语",
+    "description": "通用的吉祥话语和祝福词句",
+    "path": "/表象/符号/表达符号/祝福语/吉祥用语",
+    "level": 5
+  },
+  "ancestors": [
+    {
+      "stable_id": 38,
+      "name": "表象",
+      "path": "/表象",
+      "level": 1
+    },
+    {
+      "stable_id": 40,
+      "name": "符号",
+      "path": "/表象/符号",
+      "level": 2
+    }
+  ],
+  "descendants": [
+    {
+      "stable_id": 126,
+      "name": "新年祝福",
+      "description": "新年相关的祝福语",
+      "path": "/表象/符号/表达符号/祝福语/吉祥用语/新年祝福",
+      "level": 6,
+      "children": []
+    }
+  ]
+}
+```
+
+### 使用示例
+
+#### 获取分类的完整路径和所有子孙
+```bash
+curl -G "http://8.147.104.190:8001/api/search/category/125" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "include_ancestors=true" \
+  --data-urlencode "descendant_depth=-1"
+```
+
+#### 只获取当前节点和祖先路径
+```bash
+curl -G "http://8.147.104.190:8001/api/search/category/125" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "include_ancestors=true" \
+  --data-urlencode "descendant_depth=0"
+```
+
+#### 获取2代子孙节点
+```bash
+curl -G "http://8.147.104.190:8001/api/search/category/125" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "descendant_depth=2"
+```
+
+---
+
+## 接口 3:获取全量元素数据
+
+### 接口地址
+
+```
+GET http://8.147.104.190:8001/api/search/elements
+```
+
+### 功能说明
+
+获取全量元素数据,支持分页、排序、筛选。主要用于获取高频元素、按分类浏览元素等场景。
+
+### 请求参数
+
+| 参数名 | 类型 | 必填 | 默认值 | 说明 |
+|--------|------|------|--------|------|
+| source_type | string | ✓ | - | 元素类型:`实质` / `形式` / `意图` |
+| page | integer | ✗ | 1 | 页码(从1开始) |
+| page_size | integer | ✗ | 50 | 每页数量(1-200) |
+| sort_by | string | ✗ | occurrence_count | 排序字段:`occurrence_count` / `name` / `id` |
+| order | string | ✗ | desc | 排序方向:`asc` / `desc` |
+| category_stable_id | integer | ✗ | null | 按分类筛选(可选) |
+| platform | string | ✗ | null | 按平台筛选(可选) |
+| min_occurrence | integer | ✗ | null | 最小出现次数(可选) |
+
+### 返回格式
+
+```json
+{
+  "success": true,
+  "source_type": "实质",
+  "page": 1,
+  "page_size": 50,
+  "total": 4082,
+  "total_pages": 82,
+  "results": [
+    {
+      "id": 45,
+      "name": "祝福语",
+      "description": "'吉祥如意'、'幸福安康'等文字内容",
+      "occurrence_count": 974,
+      "element_sub_type": "具象概念",
+      "category": {
+        "stable_id": 125,
+        "name": "吉祥用语",
+        "path": "/表象/符号/表达符号/祝福语/吉祥用语"
+      }
+    }
+  ]
+}
+```
+
+### 使用示例
+
+#### 获取高频元素前50(默认)
+```bash
+curl -G "http://8.147.104.190:8001/api/search/elements" \
+  --data-urlencode "source_type=实质"
+```
+
+#### 获取高频元素前10
+```bash
+curl -G "http://8.147.104.190:8001/api/search/elements" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "page=1" \
+  --data-urlencode "page_size=10" \
+  --data-urlencode "sort_by=occurrence_count" \
+  --data-urlencode "order=desc"
+```
+
+#### 按名称排序
+```bash
+curl -G "http://8.147.104.190:8001/api/search/elements" \
+  --data-urlencode "source_type=形式" \
+  --data-urlencode "sort_by=name" \
+  --data-urlencode "order=asc"
+```
+
+#### 筛选指定分类下的元素
+```bash
+curl -G "http://8.147.104.190:8001/api/search/elements" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "category_stable_id=125"
+```
+
+#### 筛选出现次数>=10的元素
+```bash
+curl -G "http://8.147.104.190:8001/api/search/elements" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "min_occurrence=10"
+```
+
+#### 按平台筛选元素
+```bash
+curl -G "http://8.147.104.190:8001/api/search/elements" \
+  --data-urlencode "source_type=形式" \
+  --data-urlencode "platform=小红书"
+```
+
+#### 组合筛选:指定分类+最小出现次数
+```bash
+curl -G "http://8.147.104.190:8001/api/search/elements" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "category_stable_id=125" \
+  --data-urlencode "min_occurrence=50" \
+  --data-urlencode "page_size=20"
+```
+
+#### 分页浏览
+```bash
+# 第1页
+curl -G "http://8.147.104.190:8001/api/search/elements" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "page=1" \
+  --data-urlencode "page_size=50"
+
+# 第2页
+curl -G "http://8.147.104.190:8001/api/search/elements" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "page=2" \
+  --data-urlencode "page_size=50"
+```
+
+---
+

+ 15 - 0
examples/content_tree_analyst/docs/频繁项集API.md

@@ -0,0 +1,15 @@
+  curl 'https://pattern.aiddit.com/api/pattern/tools/get_frequent_itemsets/execute' \
+  -H 'Accept: */*' \
+  -H 'Accept-Language: zh-CN,zh;q=0.9' \
+  -H 'Connection: keep-alive' \
+  -H 'Content-Type: application/json' \
+  -H 'Origin: https://pattern.aiddit.com' \
+  -H 'Referer: https://pattern.aiddit.com/execution/33' \
+  -H 'Sec-Fetch-Dest: empty' \
+  -H 'Sec-Fetch-Mode: cors' \
+  -H 'Sec-Fetch-Site: same-origin' \
+  -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36' \
+  -H 'sec-ch-ua: "Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"' \
+  -H 'sec-ch-ua-mobile: ?0' \
+  -H 'sec-ch-ua-platform: "macOS"' \
+  --data-raw '{"execution_id":33,"args":{"top_n":20,"category_ids":[378],"sort_by":"absolute_support"}}'

+ 9 - 0
examples/content_tree_analyst/presets.json

@@ -0,0 +1,9 @@
+{
+  "analyst": {
+    "system_prompt_file": "analyst.prompt",
+    "max_iterations": 200,
+    "temperature": 0.3,
+    "skills": ["planning"],
+    "description": "内容树需求归纳 Agent"
+  }
+}

+ 221 - 0
examples/content_tree_analyst/run.py

@@ -0,0 +1,221 @@
+"""
+内容树需求归纳 Agent
+
+从内容树节点归纳制作需求(任务1)。
+
+用法:
+  python run.py                    # 直接运行(任务在 analyst.prompt 中配置)
+  python run.py --trace <TRACE_ID> # 恢复已有 trace
+"""
+
+import argparse
+import os
+import sys
+import asyncio
+from pathlib import Path
+
+os.environ.setdefault("no_proxy", "*")
+
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+
+from dotenv import load_dotenv
+load_dotenv()
+
+from agent.llm.prompts import SimplePrompt
+from agent.core.runner import AgentRunner, RunConfig
+from agent.trace import FileSystemTraceStore, Trace, Message
+from agent.llm import create_qwen_llm_call
+from agent.cli import InteractiveController
+from agent.utils import setup_logging
+
+# 注册自定义工具
+from tools.content_tree import search_content_tree, get_category_tree
+from tools.frequent_itemsets import get_frequent_itemsets
+
+from config import RUN_CONFIG, SKILLS_DIR, TRACE_STORE_PATH, DEBUG, LOG_LEVEL, LOG_FILE, OUTPUT_DIR
+
+
+async def main():
+    parser = argparse.ArgumentParser(description="内容树需求归纳 Agent")
+    parser.add_argument("--trace", type=str, default=None, help="已有 Trace ID,用于恢复继续执行")
+    args = parser.parse_args()
+
+    base_dir = Path(__file__).parent
+    project_root = base_dir.parent.parent
+    output_dir = project_root / OUTPUT_DIR
+    output_dir.mkdir(parents=True, exist_ok=True)
+
+    setup_logging(level=LOG_LEVEL, file=LOG_FILE)
+
+    # 加载 presets
+    presets_path = base_dir / "presets.json"
+    if presets_path.exists():
+        from agent.core.presets import load_presets_from_json
+        load_presets_from_json(str(presets_path))
+        print("已加载 presets")
+
+    # 构建任务消息(直接从 analyst.prompt 加载,无变量替换)
+    if not args.trace:
+        prompt = SimplePrompt(base_dir / "analyst.prompt")
+        messages = prompt.build_messages(output_dir=OUTPUT_DIR)
+    else:
+        messages = None
+
+    # 创建 Runner
+    store = FileSystemTraceStore(base_path=TRACE_STORE_PATH)
+    runner = AgentRunner(
+        trace_store=store,
+        llm_call=create_qwen_llm_call(model=RUN_CONFIG.model),
+        skills_dir=SKILLS_DIR,
+        debug=DEBUG,
+    )
+
+    interactive = InteractiveController(runner=runner, store=store, enable_stdin_check=True)
+    runner.stdin_check = interactive.check_stdin
+
+    print("=" * 60)
+    print("内容树需求归纳 Agent")
+    print("=" * 60)
+    print("💡 输入 'p' 暂停,'q' 退出")
+    print("=" * 60)
+
+    run_config = RUN_CONFIG
+    resume_trace_id = args.trace
+    current_trace_id = resume_trace_id
+    current_sequence = 0
+    should_exit = False
+    final_response = ""
+
+    try:
+        if resume_trace_id:
+            existing = await store.get_trace(resume_trace_id)
+            if not existing:
+                print(f"错误: Trace 不存在: {resume_trace_id}")
+                sys.exit(1)
+            run_config.trace_id = resume_trace_id
+            print(f"恢复 Trace: {resume_trace_id[:8]}...")
+
+        while not should_exit:
+            if current_trace_id:
+                run_config.trace_id = current_trace_id
+
+            # 恢复模式:先进交互菜单
+            if current_trace_id and messages is None:
+                check_trace = await store.get_trace(current_trace_id)
+                if check_trace:
+                    current_sequence = check_trace.head_sequence
+                    menu_result = await interactive.show_menu(current_trace_id, current_sequence)
+                    if menu_result["action"] == "stop":
+                        break
+                    elif menu_result["action"] == "continue":
+                        new_msgs = menu_result.get("messages", [])
+                        messages = new_msgs if new_msgs else []
+                        run_config.after_sequence = menu_result.get("after_sequence")
+                        continue
+                break
+
+            if messages is None:
+                messages = []
+
+            print("▶️ 开始执行...")
+            paused = False
+
+            try:
+                async for item in runner.run(messages=messages, config=run_config):
+                    cmd = interactive.check_stdin()
+                    if cmd == "pause":
+                        print("\n⏸️ 暂停中...")
+                        if current_trace_id:
+                            await runner.stop(current_trace_id)
+                        await asyncio.sleep(0.5)
+                        menu_result = await interactive.show_menu(current_trace_id, current_sequence)
+                        if menu_result["action"] == "stop":
+                            should_exit = True
+                            paused = True
+                            break
+                        elif menu_result["action"] == "continue":
+                            new_msgs = menu_result.get("messages", [])
+                            messages = new_msgs if new_msgs else []
+                            run_config.after_sequence = menu_result.get("after_sequence")
+                            paused = True
+                            break
+                    elif cmd == "quit":
+                        print("\n🛑 停止...")
+                        if current_trace_id:
+                            await runner.stop(current_trace_id)
+                        should_exit = True
+                        break
+
+                    if isinstance(item, Trace):
+                        current_trace_id = item.trace_id
+                        if item.status == "running":
+                            print(f"[Trace] 开始: {item.trace_id[:8]}...")
+                        elif item.status == "completed":
+                            print(f"\n[Trace] ✅ 完成 | messages={item.total_messages} | cost=${item.total_cost:.4f}")
+                        elif item.status == "failed":
+                            print(f"\n[Trace] ❌ 失败: {item.error_message}")
+
+                    elif isinstance(item, Message):
+                        current_sequence = item.sequence
+                        if item.role == "assistant":
+                            content = item.content
+                            if isinstance(content, dict):
+                                text = content.get("text", "")
+                                tool_calls = content.get("tool_calls")
+                                if text and not tool_calls:
+                                    final_response = text
+                                    print(f"\n[Response]\n{text}")
+                                elif text:
+                                    preview = text[:150] + "..." if len(text) > 150 else text
+                                    print(f"[Assistant] {preview}")
+                        elif item.role == "tool":
+                            content = item.content
+                            tool_name = content.get("tool_name", "unknown") if isinstance(content, dict) else "unknown"
+                            desc = item.description or ""
+                            if desc and desc != tool_name:
+                                print(f"[Tool] ✅ {tool_name}: {desc[:80]}")
+                            else:
+                                print(f"[Tool] ✅ {tool_name}")
+
+            except Exception as e:
+                print(f"\n执行出错: {e}")
+                import traceback
+                traceback.print_exc()
+
+            if paused:
+                if should_exit:
+                    break
+                continue
+
+            if should_exit:
+                break
+
+            if current_trace_id:
+                menu_result = await interactive.show_menu(current_trace_id, current_sequence)
+                if menu_result["action"] == "stop":
+                    break
+                elif menu_result["action"] == "continue":
+                    new_msgs = menu_result.get("messages", [])
+                    messages = new_msgs if new_msgs else []
+                    run_config.after_sequence = menu_result.get("after_sequence")
+                    continue
+            break
+
+    except KeyboardInterrupt:
+        print("\n用户中断 (Ctrl+C)")
+        if current_trace_id:
+            await runner.stop(current_trace_id)
+
+    # 保存最终结果
+    if final_response:
+        result_file = output_dir / "result.txt"
+        result_file.write_text(final_response, encoding="utf-8")
+        print(f"\n✓ 结果已保存: {result_file}")
+
+    if current_trace_id:
+        print(f"\nTrace ID: {current_trace_id}")
+        print("可视化: python3 api_server.py → http://localhost:8000/api/traces")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 1 - 0
examples/content_tree_analyst/tools/__init__.py

@@ -0,0 +1 @@
+# tools/__init__.py

+ 167 - 0
examples/content_tree_analyst/tools/content_tree.py

@@ -0,0 +1,167 @@
+"""
+内容树 API 工具
+
+封装内容树搜索接口:
+1. search_content_tree - 关键词搜索分类和元素
+2. get_category_tree - 获取指定分类的完整路径和子树
+"""
+
+import logging
+from typing import Optional
+
+import httpx
+
+from agent.tools import tool
+from agent.tools.models import ToolResult
+
+logger = logging.getLogger(__name__)
+
+BASE_URL = "http://8.147.104.190:8001"
+
+
+@tool(description="在内容树中搜索分类(category)和元素(element),支持获取祖先路径和子孙节点")
+async def search_content_tree(
+    q: str,
+    source_type: str,
+    entity_type: str = "all",
+    top_k: int = 20,
+    use_description: bool = False,
+    include_ancestors: bool = False,
+    descendant_depth: int = 0,
+) -> ToolResult:
+    """
+    关键词搜索内容树中的分类和元素。
+
+    Args:
+        q: 搜索关键词
+        source_type: 维度,必须是 "实质" / "形式" / "意图" 之一
+        entity_type: 搜索对象类型,"category" / "element" / "all"(默认)
+        top_k: 返回结果数量,1-100(默认20)
+        use_description: 是否同时搜索描述字段(默认仅搜索名称)
+        include_ancestors: 是否返回祖先路径
+        descendant_depth: 返回子孙节点深度,0=不返回,1=直接子节点,2=子+孙...
+    """
+    params = {
+        "q": q,
+        "source_type": source_type,
+        "entity_type": entity_type,
+        "top_k": top_k,
+        "use_description": str(use_description).lower(),
+        "include_ancestors": str(include_ancestors).lower(),
+        "descendant_depth": descendant_depth,
+    }
+
+    try:
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            resp = await client.get(f"{BASE_URL}/api/search", params=params)
+            resp.raise_for_status()
+            data = resp.json()
+
+        count = data.get("count", 0)
+        results = data.get("results", [])
+
+        # 格式化输出
+        lines = [f"搜索「{q}」({source_type}维度)共找到 {count} 条结果:\n"]
+        for r in results:
+            etype = r.get("entity_type", "")
+            name = r.get("name", "")
+            score = r.get("score", 0)
+            if etype == "category":
+                sid = r.get("stable_id", "")
+                path = r.get("path", "")
+                desc = r.get("description", "")
+                lines.append(f"[分类] stable_id={sid} | {path} | score={score:.2f}")
+                if desc:
+                    lines.append(f"  描述: {desc}")
+                ancestors = r.get("ancestors", [])
+                if ancestors:
+                    anc_names = " > ".join(a["name"] for a in ancestors)
+                    lines.append(f"  祖先: {anc_names}")
+                descendants = r.get("descendants", [])
+                if descendants:
+                    desc_names = ", ".join(d["name"] for d in descendants[:10])
+                    lines.append(f"  子孙({len(descendants)}): {desc_names}")
+            else:
+                eid = r.get("entity_id", "")
+                belong = r.get("belong_category_stable_id", "")
+                occ = r.get("occurrence_count", 0)
+                lines.append(f"[元素] entity_id={eid} | {name} | belong_category={belong} | 出现次数={occ} | score={score:.2f}")
+                edesc = r.get("description", "")
+                if edesc:
+                    lines.append(f"  描述: {edesc}")
+            lines.append("")
+
+        return ToolResult(
+            title=f"内容树搜索: {q} ({source_type}) → {count} 条",
+            output="\n".join(lines),
+        )
+
+    except httpx.HTTPError as e:
+        return ToolResult(title="内容树搜索失败", output=f"HTTP 错误: {e}")
+    except Exception as e:
+        logger.exception("search_content_tree error")
+        return ToolResult(title="内容树搜索失败", output=f"错误: {e}")
+
+
+@tool(description="获取指定分类节点的完整路径、祖先和子孙结构(通过 stable_id 精确查询)")
+async def get_category_tree(
+    stable_id: int,
+    source_type: str,
+    include_ancestors: bool = True,
+    descendant_depth: int = -1,
+) -> ToolResult:
+    """
+    获取指定分类的完整路径和子树结构。
+
+    Args:
+        stable_id: 分类的 stable_id
+        source_type: 维度,"实质" / "形式" / "意图"
+        include_ancestors: 是否返回祖先路径(默认 True)
+        descendant_depth: 子孙深度,-1=全部,0=仅当前,1=子节点,2=子+孙...
+    """
+    params = {
+        "source_type": source_type,
+        "include_ancestors": str(include_ancestors).lower(),
+        "descendant_depth": descendant_depth,
+    }
+
+    try:
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            resp = await client.get(f"{BASE_URL}/api/search/category/{stable_id}", params=params)
+            resp.raise_for_status()
+            data = resp.json()
+
+        current = data.get("current", {})
+        ancestors = data.get("ancestors", [])
+        descendants = data.get("descendants", [])
+
+        lines = []
+        lines.append(f"分类节点: {current.get('name', '')} (stable_id={stable_id})")
+        lines.append(f"路径: {current.get('path', '')}")
+        if current.get("description"):
+            lines.append(f"描述: {current['description']}")
+        lines.append("")
+
+        if ancestors:
+            lines.append("祖先路径:")
+            for a in ancestors:
+                lines.append(f"  L{a.get('level', '?')} {a.get('name', '')} (stable_id={a.get('stable_id', '')})")
+            lines.append("")
+
+        if descendants:
+            lines.append(f"子孙节点 ({len(descendants)} 个):")
+            for d in descendants:
+                indent = "  " * d.get("depth_from_parent", 1)
+                leaf_mark = " [叶]" if d.get("is_leaf") else ""
+                lines.append(f"{indent}L{d.get('level', '?')} {d.get('name', '')} (stable_id={d.get('stable_id', '')}){leaf_mark}")
+
+        return ToolResult(
+            title=f"分类树: {current.get('name', stable_id)} (stable_id={stable_id})",
+            output="\n".join(lines),
+        )
+
+    except httpx.HTTPError as e:
+        return ToolResult(title="获取分类树失败", output=f"HTTP 错误: {e}")
+    except Exception as e:
+        logger.exception("get_category_tree error")
+        return ToolResult(title="获取分类树失败", output=f"错误: {e}")

+ 99 - 0
examples/content_tree_analyst/tools/frequent_itemsets.py

@@ -0,0 +1,99 @@
+"""
+频繁项集 API 工具
+
+封装 pattern.aiddit.com 的频繁项集接口,用于查询与指定分类节点
+在优质内容中共同出现的关联要素。
+"""
+
+import logging
+
+import httpx
+
+from agent.tools import tool
+from agent.tools.models import ToolResult
+
+logger = logging.getLogger(__name__)
+
+ITEMSETS_URL = "https://pattern.aiddit.com/api/pattern/tools/get_frequent_itemsets/execute"
+
+HEADERS = {
+    "Accept": "*/*",
+    "Accept-Language": "zh-CN,zh;q=0.9",
+    "Connection": "keep-alive",
+    "Content-Type": "application/json",
+    "Origin": "https://pattern.aiddit.com",
+    "Referer": "https://pattern.aiddit.com/execution/33",
+    "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
+}
+
+
+@tool(description="查询与指定分类节点在优质内容中共同出现的频繁项集(关联要素),用于扩展制作需求的关联维度")
+async def get_frequent_itemsets(
+    entity_ids: list,
+    top_n: int = 20,
+    execution_id: int = 33,
+    sort_by: str = "absolute_support",
+) -> ToolResult:
+    """
+    获取与指定分类节点关联的频繁项集。
+
+    Args:
+        entity_ids: 分类节点的 entity_id 列表(即搜索接口返回的 entity_id 字段,非 stable_id)
+        top_n: 返回前 N 个项集,默认 20
+        execution_id: 执行 ID,默认 33
+        sort_by: 排序字段,默认 "absolute_support"
+    """
+    payload = {
+        "execution_id": execution_id,
+        "args": {
+            "top_n": top_n,
+            "category_ids": entity_ids,
+            "sort_by": sort_by,
+        },
+    }
+
+    try:
+        import json as _json
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            resp = await client.post(ITEMSETS_URL, json=payload, headers=HEADERS)
+            resp.raise_for_status()
+            outer = resp.json()
+
+        # result 字段是 JSON 字符串,需要二次解析
+        data = _json.loads(outer["result"])
+        total = data.get("total", 0)
+        groups = data.get("groups", {})
+
+        # 收集所有 group 下的 itemsets
+        all_itemsets = []
+        for group_key, group in groups.items():
+            for itemset in group.get("itemsets", []):
+                itemset["_group"] = group_key
+                all_itemsets.append(itemset)
+
+        lines = [f"频繁项集查询 entity_ids={entity_ids},共 {total} 条,返回 {len(all_itemsets)} 条:\n"]
+        for i, itemset in enumerate(all_itemsets, 1):
+            itemset_id = itemset.get("id", "")
+            item_count = itemset.get("item_count", "")
+            support = itemset.get("support", 0)
+            abs_support = itemset.get("absolute_support", "")
+            lines.append(f"{i}. 项集ID={itemset_id} | 项数={item_count} | support={support:.4f} | abs={abs_support}")
+            for elem in itemset.get("items", []):
+                dim = elem.get("dimension", "")
+                path = elem.get("category_path", "")
+                ename = elem.get("element_name") or ""
+                label = f"{path}({ename})" if ename else path
+                lines.append(f"   [{dim}] {label}")
+            lines.append("")
+
+        return ToolResult(
+            title=f"频繁项集: entity_ids={entity_ids} → {total} 条",
+            output="\n".join(lines),
+        )
+        return ToolResult(
+            title="频繁项集查询失败",
+            output=f"HTTP {e.response.status_code}: {e.response.text[:200]}",
+        )
+    except Exception as e:
+        logger.exception("get_frequent_itemsets error")
+        return ToolResult(title="频繁项集查询失败", output=f"错误: {e}")

+ 0 - 67
examples/research/config.py

@@ -1,67 +0,0 @@
-"""
-项目配置
-
-定义项目的运行配置。
-"""
-
-from agent.core.runner import KnowledgeConfig, RunConfig
-
-
-# ===== Agent 运行配置 =====
-
-RUN_CONFIG = RunConfig(
-    # 模型配置
-    model="qwen3.5-plus",
-    temperature=0.3,
-    max_iterations=1000,
-
-    # 启用 thinking 模式
-    extra_llm_params={"extra_body": {"enable_thinking": True}},
-
-    # 主 Agent 预设(对应 presets.json 中的 "main")
-    agent_type="main",
-
-    # 任务名称
-    name="调研",
-
-    # 知识管理配置
-    knowledge=KnowledgeConfig(
-        # 压缩时提取(消息量超阈值触发压缩时,用完整 history 反思)
-        enable_extraction=False,
-        reflect_prompt="",  # 自定义反思 prompt;空则使用默认,见 agent/core/prompts/knowledge.py:REFLECT_PROMPT
-
-        # agent运行完成后提取(不代表任务完成,agent 可能中途退出等待人工评估)
-        enable_completion_extraction=False,
-        completion_reflect_prompt="",  # 自定义复盘 prompt;空则使用默认,见 agent/core/prompts/knowledge.py:COMPLETION_REFLECT_PROMPT
-
-        # 知识注入(agent切换当前工作的goal时,自动注入相关知识)
-        enable_injection=False,
-
-        # 默认字段(保存/搜索时自动注入)
-        owner="sunlit.howard@gmail.com",  # 所有者(空则尝试从 git config user.email 获取,再空则用 agent:{agent_id})
-        default_tags={"project": "research", "domain": "ai_agent"},  # 默认 tags(会与工具调用参数合并)
-        default_scopes=["org:cybertogether"],  # 默认 scopes
-        default_search_types=["strategy"],  # 默认搜索类型过滤
-        default_search_owner="sunlit.howard@gmail.com"  # 默认搜索 owner 过滤(空则不过滤)
-    )
-)
-
-
-# ===== 任务配置 =====
-
-# INPUT_DIR = "examples/research/huahua"       # 输入素材目录
-# OUTPUT_DIR = "examples/research/outputs/huahua_3"                   # 输出目录 ID,输出保存在 examples/plan/outputs/{OUTPUT_DIR}/
-
-
-# ===== 基础设施配置 =====
-
-SKILLS_DIR = "./skills"
-TRACE_STORE_PATH = ".trace"
-DEBUG = True
-LOG_LEVEL = "INFO"
-LOG_FILE = None  # 设置为文件路径可以同时输出到文件
-
-# ===== 浏览器配置 =====
-# 可选值: "cloud" (云浏览器) 或 "local" (本地浏览器) 或 "container" (容器浏览器,支持预配置账户)
-BROWSER_TYPE = "local"
-HEADLESS = False

+ 0 - 14
examples/research/presets.json

@@ -1,14 +0,0 @@
-{
-  "main": {
-    "max_iterations": 1000,
-    "skills": ["planning"],
-    "description": "主 Agent - 调研任务管理与协调"
-  },
-  "research": {
-    "system_prompt_file": "research.prompt",
-    "max_iterations": 200,
-    "temperature": 0.3,
-    "skills": ["planning", "research", "browser"],
-    "description": "调研 Agent - 根据指令搜索策略、工具、方法论等信息"
-  }
-}

+ 0 - 42
examples/research/requirement.prompt

@@ -1,42 +0,0 @@
----
-model: qwen3.5-plus
-temperature: 0.3
----
-
-$system$
-
-## 角色
-你是有空杯心态的搜索调研专家。
-
-## 工作流程
-
-### 第一步:分析梳理调研需求
-根据制作需求,判断需要调研哪些方向的信息。
-
-### 第二步:循环迭代地向调研agent提问、评估调研结果、更新调研计划
-1. **提问**:向 subagent 提出调研问题
-   - MUST 调用工具 `agent(task="string - 一句话描述调研需求", agent_type="research")`
-   - **严格禁止**在启动调研时在 task 中预设猜想的具体示例
-2. **评估**:subagent 返回后(可能是阶段性结果),读取调研结果并评估:
-   - **相关性**:找到的内容是不是我要的方向?
-   - **可靠性**:找到的信息是否经过交叉验证?是否是可靠信源?是否很可能是广告营销软文?
-   - **时效性**:找到的信息是不是过时了?(AI行业迭代很快,6个月前的信息都大概率过时了)
-   - **信息完整性**:找到的信息是否足够支撑后续选择?(信息够不够)
-3. **追问或结束**:
-   - 结论为"需补充"→ 用 `continue_from` 调用同一个 subagent,**在 task 中明确告知**:
-     - 还缺什么:缺少哪些必需/建议信息,或需要补充哪些方向的调研
-     - 建议搜索方向:给出具体的搜索建议(如"搜索XXX的用户评价")
-   - 结论为"通过"→ 进入下一个问题或结束调研
-4. **基于最新信息思考**:
-   - 最新信息是否带来了新的思路?
-   - 是否需要更新原来的调研需求分析、提出新的调研问题?
-5. **创建或更新调研计划**
-   - 根据最新信息,撰写或更新调研思路(路径:%output_dir%/research_thinking.md")
-循环1-5的步骤,直到你对获取到的信息感到充分和满意。预期每个调研方向会经历 2-3 轮追问。
-
-### 第四步:输出调研报告
-综合所有调研思路和调研结果,确定最终的调研报告。
-
-**输出**:`%output_dir%/research.md`
-
-$user$

+ 0 - 68
examples/research/research.prompt

@@ -1,68 +0,0 @@
----
-model: sonnet-4.6
-temperature: 0.3
----
-
-$system$
-## 角色
-你是一个调研专家,负责根据指令搜索、反思并如实记录调研发现。
-
-## 执行流程
-
-### 第一步:理解调研目标
-
-### 第二步:执行搜索
-
-**搜索优先级**:
-1. **知识库优先**:用 `knowledge_search` 按需求关键词搜索,查看已有策略经验、工具评估、工作流总结
-2. **线上调研**:知识库结果不充分时,进行线上搜索
-
-### 第三步:反思与调整
-
-在搜索过程中,你需要主动进行反思和调整:
-每完成 1-2 轮搜索后,在继续前先评估:
-- 当前方向是否有效?是否偏离需求?
-- 结果质量如何?下一轮应该调整 query 还是换角度?
-- 可选调用 `reflect` 工具辅助判断
-根据反思结果调整后续搜索策略,直到你认为信息充分或遇到明确的阻塞。
-
-### 第四步:结束与输出
-
-**何时结束**:
-- 信息已充分覆盖调研目标
-- 搜索结果开始重复,无新信息
-- 方向不明确,需要用户指导
-
-**如何结束**:
-输出一条纯文本消息(不带 tool_call),概括:发现了什么、还缺什么
-
-
-## 输出格式
-
-**Schema**:
-
-```jsonschema
-{
-  "搜索主题": "string — 本次搜索主题",
-  "搜索轨迹": "string — 搜索过程:尝试了哪些 query、如何调整方向等",
-  "调研发现": [
-    {
-      "名称": "string — 发现项名称",
-      "来源": ["string — 来源(knowledge_id / URL / 帖子链接)", ...],
-      "核心描述": "string — 核心思路或能力描述",
-      "具体信息": {
-        ...(这部分要根据具体调研问题和发现,自行设计汇报格式)
-      },
-      "外部评价": {
-        "专家或KOL推荐": ["string — 来源 + 评价摘要"],
-        "社区反馈": ["string — 来源 + 反馈摘要"],
-        "热度指标": "string — 提及次数、榜单排名、帖子热度等"
-      }
-    }
-  ]
-}
-```
-
-## 注意事项
-- `search_posts` 不好用时改用 `browser-use`
-- 如果调研过程中遇到不确定的问题,要停下来询问用户

+ 0 - 378
examples/research/run.py

@@ -1,378 +0,0 @@
-"""
-示例(简化版 - 使用框架交互功能)
-
-使用 Agent 模式 + Skills + 框架交互控制器
-
-新功能:
-1. 使用框架提供的 InteractiveController
-2. 使用配置文件管理运行参数
-3. 支持命令行随时打断(输入 'p' 暂停,'q' 退出)
-4. 暂停后可插入干预消息
-5. 支持触发经验总结
-6. 查看当前 GoalTree
-7. 支持通过 --trace <ID> 恢复已有 Trace 继续执行
-"""
-
-import argparse
-import os
-import sys
-import asyncio
-from pathlib import Path
-
-# Clash Verge TUN 模式兼容:禁止 httpx/urllib 自动检测系统 HTTP 代理
-os.environ.setdefault("no_proxy", "*")
-
-# 添加项目根目录到 Python 路径
-sys.path.insert(0, str(Path(__file__).parent.parent.parent))
-
-from dotenv import load_dotenv
-load_dotenv()
-
-from agent.llm.prompts import SimplePrompt
-from agent.core.runner import AgentRunner, RunConfig
-from agent.trace import (
-    FileSystemTraceStore,
-    Trace,
-    Message,
-)
-from agent.llm import create_qwen_llm_call
-from agent.cli import InteractiveController
-from agent.utils import setup_logging
-from agent.tools.builtin.browser.baseClass import init_browser_session, kill_browser_session
-
-# 导入自定义工具(触发 @tool 注册)
-from tools.reflect import reflect
-
-# 导入项目配置
-from config import RUN_CONFIG, SKILLS_DIR, TRACE_STORE_PATH, DEBUG, LOG_LEVEL, LOG_FILE, BROWSER_TYPE, HEADLESS, INPUT_DIR, OUTPUT_DIR
-
-
-async def main():
-    # 解析命令行参数
-    parser = argparse.ArgumentParser(description="任务 (Agent 模式 + 交互增强)")
-    parser.add_argument(
-        "--trace", type=str, default=None,
-        help="已有的 Trace ID,用于恢复继续执行(不指定则新建)",
-    )
-    args = parser.parse_args()
-
-    # 路径配置
-    base_dir = Path(__file__).parent
-    project_root = base_dir.parent.parent
-    prompt_path = base_dir / "requirement.prompt"
-    output_dir = project_root / OUTPUT_DIR
-    output_dir.mkdir(parents=True, exist_ok=True)
-
-    # 1. 配置日志
-    setup_logging(level=LOG_LEVEL, file=LOG_FILE)
-
-    # 2. 加载项目级 presets
-    print("2. 加载 presets...")
-    presets_path = base_dir / "presets.json"
-    if presets_path.exists():
-        from agent.core.presets import load_presets_from_json
-        load_presets_from_json(str(presets_path))
-        print(f"   - 已加载项目 presets")
-    else:
-        print(f"   - 未找到 presets.json,跳过")
-
-    # 3. 加载 prompt
-    print("3. 加载 prompt...")
-    prompt = SimplePrompt(prompt_path)
-
-    # 4. 构建任务消息
-    print("4. 构建任务消息...")
-    print(f"   - 输入目录: {INPUT_DIR}")
-    print(f"   - 输出 ID: {OUTPUT_DIR}")
-    messages = prompt.build_messages(input_dir=INPUT_DIR, output_dir=OUTPUT_DIR)
-
-    # 5. 初始化浏览器
-    browser_mode_names = {"cloud": "云浏览器", "local": "本地浏览器", "container": "容器浏览器"}
-    browser_mode_name = browser_mode_names.get(BROWSER_TYPE, BROWSER_TYPE)
-    print(f"5. 正在初始化{browser_mode_name}...")
-    await init_browser_session(
-        browser_type=BROWSER_TYPE,
-        headless=HEADLESS,
-        url="https://www.google.com/",
-        profile_name=""
-    )
-    print(f"   ✅ {browser_mode_name}初始化完成\n")
-
-    # 6. 创建 Agent Runner
-    print("6. 创建 Agent Runner...")
-    print(f"   - Skills 目录: {SKILLS_DIR}")
-
-    # 从 prompt 的 frontmatter 中提取模型配置(优先于 config.py)
-    prompt_model = prompt.config.get("model", None)
-    if prompt_model:
-        model_for_llm = prompt_model
-        print(f"   - 模型 (from prompt): {model_for_llm}")
-    else:
-        model_for_llm = RUN_CONFIG.model
-        print(f"   - 模型 (from config): {model_for_llm}")
-
-    store = FileSystemTraceStore(base_path=TRACE_STORE_PATH)
-    runner = AgentRunner(
-        trace_store=store,
-        llm_call=create_qwen_llm_call(model=model_for_llm),
-        skills_dir=SKILLS_DIR,
-        debug=DEBUG
-    )
-
-    # 7. 创建交互控制器
-    interactive = InteractiveController(
-        runner=runner,
-        store=store,
-        enable_stdin_check=True
-    )
-    # 将 stdin 检查回调注入 runner,供子 agent 执行期间使用
-    runner.stdin_check = interactive.check_stdin
-
-    # 8. 任务信息
-    task_name = RUN_CONFIG.name or base_dir.name
-    print("=" * 60)
-    print(f"{task_name}")
-    print("=" * 60)
-    print("💡 交互提示:")
-    print("   - 执行过程中输入 'p' 或 'pause' 暂停并进入交互模式")
-    print("   - 执行过程中输入 'q' 或 'quit' 停止执行")
-    print("=" * 60)
-    print()
-
-    # 9. 判断是新建还是恢复
-    resume_trace_id = args.trace
-    if resume_trace_id:
-        existing_trace = await store.get_trace(resume_trace_id)
-        if not existing_trace:
-            print(f"\n错误: Trace 不存在: {resume_trace_id}")
-            sys.exit(1)
-        print(f"恢复已有 Trace: {resume_trace_id[:8]}...")
-        print(f"   - 状态: {existing_trace.status}")
-        print(f"   - 消息数: {existing_trace.total_messages}")
-        print(f"\n💡 提示:恢复 Trace 时会先进入交互菜单,您可以选择从指定消息续跑")
-    else:
-        print(f"启动新 Agent...")
-
-    print()
-
-    final_response = ""
-    current_trace_id = resume_trace_id
-    current_sequence = 0
-    should_exit = False
-
-    try:
-        # 配置
-        run_config = RUN_CONFIG
-        if resume_trace_id:
-            initial_messages = None
-            run_config.trace_id = resume_trace_id
-        else:
-            initial_messages = messages
-            run_config.name = f"{task_name}:调研任务"
-
-        while not should_exit:
-            if current_trace_id:
-                run_config.trace_id = current_trace_id
-
-            final_response = ""
-
-            # 如果是恢复 trace 或 trace 已完成/失败且没有新消息,进入交互菜单
-            if current_trace_id and initial_messages is None:
-                check_trace = await store.get_trace(current_trace_id)
-                if check_trace:
-                    # 显示 trace 状态
-                    if check_trace.status == "completed":
-                        print(f"\n[Trace] ✅ 已完成")
-                        print(f"  - Total messages: {check_trace.total_messages}")
-                        print(f"  - Total cost: ${check_trace.total_cost:.4f}")
-                    elif check_trace.status == "failed":
-                        print(f"\n[Trace] ❌ 已失败: {check_trace.error_message}")
-                    elif check_trace.status == "stopped":
-                        print(f"\n[Trace] ⏸️ 已停止")
-                        print(f"  - Total messages: {check_trace.total_messages}")
-                    else:
-                        print(f"\n[Trace] 📊 状态: {check_trace.status}")
-                        print(f"  - Total messages: {check_trace.total_messages}")
-
-                    current_sequence = check_trace.head_sequence
-
-                    menu_result = await interactive.show_menu(current_trace_id, current_sequence)
-
-                    if menu_result["action"] == "stop":
-                        break
-                    elif menu_result["action"] == "continue":
-                        new_messages = menu_result.get("messages", [])
-                        if new_messages:
-                            initial_messages = new_messages
-                            run_config.after_sequence = menu_result.get("after_sequence")
-                        else:
-                            initial_messages = []
-                            run_config.after_sequence = None
-                        continue
-                    break
-
-            # 如果没有进入菜单(新建 trace),设置初始消息
-            if initial_messages is None:
-                initial_messages = []
-
-            print(f"{'▶️ 开始执行...' if not current_trace_id else '▶️ 继续执行...'}")
-
-            # 执行 Agent
-            paused = False
-            try:
-                async for item in runner.run(messages=initial_messages, config=run_config):
-                    # 检查用户中断
-                    cmd = interactive.check_stdin()
-                    if cmd == 'pause':
-                        print("\n⏸️ 正在暂停执行...")
-                        if current_trace_id:
-                            await runner.stop(current_trace_id)
-                        await asyncio.sleep(0.5)
-
-                        menu_result = await interactive.show_menu(current_trace_id, current_sequence)
-
-                        if menu_result["action"] == "stop":
-                            should_exit = True
-                            paused = True
-                            break
-                        elif menu_result["action"] == "continue":
-                            new_messages = menu_result.get("messages", [])
-                            if new_messages:
-                                initial_messages = new_messages
-                                after_seq = menu_result.get("after_sequence")
-                                if after_seq is not None:
-                                    run_config.after_sequence = after_seq
-                                paused = True
-                                break
-                            else:
-                                initial_messages = []
-                                run_config.after_sequence = None
-                                paused = True
-                                break
-
-                    elif cmd == 'quit':
-                        print("\n🛑 用户请求停止...")
-                        if current_trace_id:
-                            await runner.stop(current_trace_id)
-                        should_exit = True
-                        break
-
-                    # 处理 Trace 对象
-                    if isinstance(item, Trace):
-                        current_trace_id = item.trace_id
-                        if item.status == "running":
-                            print(f"[Trace] 开始: {item.trace_id[:8]}...")
-                        elif item.status == "completed":
-                            print(f"\n[Trace] ✅ 完成")
-                            print(f"  - Total messages: {item.total_messages}")
-                            print(f"  - Total cost: ${item.total_cost:.4f}")
-                        elif item.status == "failed":
-                            print(f"\n[Trace] ❌ 失败: {item.error_message}")
-                        elif item.status == "stopped":
-                            print(f"\n[Trace] ⏸️ 已停止")
-
-                    # 处理 Message 对象
-                    elif isinstance(item, Message):
-                        current_sequence = item.sequence
-
-                        if item.role == "assistant":
-                            content = item.content
-                            if isinstance(content, dict):
-                                text = content.get("text", "")
-                                tool_calls = content.get("tool_calls")
-
-                                if text and not tool_calls:
-                                    final_response = text
-                                    print(f"\n[Response] Agent 回复:")
-                                    print(text)
-                                elif text:
-                                    preview = text[:150] + "..." if len(text) > 150 else text
-                                    print(f"[Assistant] {preview}")
-
-                        elif item.role == "tool":
-                            content = item.content
-                            tool_name = "unknown"
-                            if isinstance(content, dict):
-                                tool_name = content.get("tool_name", "unknown")
-
-                            if item.description and item.description != tool_name:
-                                desc = item.description[:80] if len(item.description) > 80 else item.description
-                                print(f"[Tool Result] ✅ {tool_name}: {desc}...")
-                            else:
-                                print(f"[Tool Result] ✅ {tool_name}")
-
-            except Exception as e:
-                print(f"\n执行出错: {e}")
-                import traceback
-                traceback.print_exc()
-
-            if paused:
-                if should_exit:
-                    break
-                continue
-
-            if should_exit:
-                break
-
-            # Runner 退出后显示交互菜单
-            if current_trace_id:
-                menu_result = await interactive.show_menu(current_trace_id, current_sequence)
-
-                if menu_result["action"] == "stop":
-                    break
-                elif menu_result["action"] == "continue":
-                    new_messages = menu_result.get("messages", [])
-                    if new_messages:
-                        initial_messages = new_messages
-                        run_config.after_sequence = menu_result.get("after_sequence")
-                    else:
-                        initial_messages = []
-                        run_config.after_sequence = None
-                    continue
-            break
-
-    except KeyboardInterrupt:
-        print("\n\n用户中断 (Ctrl+C)")
-        if current_trace_id:
-            await runner.stop(current_trace_id)
-    finally:
-        # 清理浏览器会话
-        try:
-            await kill_browser_session()
-        except Exception:
-            pass
-
-    # 7. 输出结果
-    if final_response:
-        print()
-        print("=" * 60)
-        print("Agent 响应:")
-        print("=" * 60)
-        print(final_response)
-        print("=" * 60)
-        print()
-
-        output_file = output_dir / "result.txt"
-        with open(output_file, 'w', encoding='utf-8') as f:
-            f.write(final_response)
-
-        print(f"✓ 结果已保存到: {output_file}")
-        print()
-
-    # 可视化提示
-    if current_trace_id:
-        print("=" * 60)
-        print("可视化 Step Tree:")
-        print("=" * 60)
-        print("1. 启动 API Server:")
-        print("   python3 api_server.py")
-        print()
-        print("2. 浏览器访问:")
-        print("   http://localhost:8000/api/traces")
-        print()
-        print(f"3. Trace ID: {current_trace_id}")
-        print("=" * 60)
-
-
-if __name__ == "__main__":
-    asyncio.run(main())

+ 0 - 139
examples/research/tools/reflect.py

@@ -1,139 +0,0 @@
-"""
-reflect 工具 — 轻量反思
-
-用普通模型快速评估当前搜索结果,输出简短思路和下一步建议。
-继承调用者的完整对话历史作为上下文。
-"""
-
-import logging
-import os
-from typing import Any, Dict, List, Optional
-
-from agent.llm.qwen import qwen_llm_call
-from agent.tools import tool
-from agent.tools.models import ToolContext, ToolResult
-
-
-logger = logging.getLogger(__name__)
-
-# 反思模型配置(普通模式,非 thinking)
-REFLECT_MODEL = os.getenv("REFLECT_MODEL", "qwen-plus")
-
-REFLECT_SYSTEM_PROMPT = """你是调研助手。根据对话历史和当前搜索结果,简要回答:
-1. 本轮搜到了什么有价值的信息?缺了什么?
-2. 下一轮搜什么?给出 1-3 个具体 query 词
-3. 是否需要换搜索渠道或角度?
-
-要求:直接输出思路,不要分析框架,不要长篇大论。3-5 句话即可。"""
-
-REFLECT_USER_TEMPLATE = """需求:{question}
-
-本轮搜索结果:
-{findings}
-
-简要反思,给出下一步 query。"""
-
-
-async def _fetch_caller_history(context: Optional[Dict[str, Any]]) -> List[Dict]:
-    """从 context 中获取调用者的历史消息队列"""
-    if not context:
-        return []
-
-    store = context.get("store")
-    trace_id = context.get("trace_id")
-    if not store or not trace_id:
-        return []
-
-    try:
-        trace = await store.get_trace(trace_id)
-        if not trace:
-            return []
-
-        messages = await store.get_main_path_messages(
-            trace_id, trace.head_sequence
-        )
-
-        # 转为 LLM 消息格式
-        history = []
-        for msg in messages:
-            llm_dict = msg.to_llm_dict()
-            if llm_dict:
-                history.append(llm_dict)
-
-        logger.info(f"reflect: 获取到 {len(history)} 条历史消息")
-        return history
-
-    except Exception as e:
-        logger.warning(f"reflect: 获取历史消息失败: {e}")
-        return []
-
-
-@tool(
-    description="轻量反思:快速评估本轮搜索结果,输出简短思路和下一步 query 建议",
-    hidden_params=["context"],
-)
-async def reflect(
-    question: str,
-    findings: str,
-    context: Optional[ToolContext] = None,
-) -> ToolResult:
-    """
-    对本轮搜索结果进行轻量反思,给出下一步搜索思路。
-
-    Args:
-        question: 原始调研问题/需求描述
-        findings: 本轮搜索结果摘要
-    """
-    # 获取调用者的历史消息
-    # context 可能是 ToolContextImpl(有 .context 属性)或直接是 dict
-    if context is None:
-        caller_context = None
-    elif isinstance(context, dict):
-        caller_context = context
-    else:
-        caller_context = getattr(context, 'context', None)
-    caller_history = await _fetch_caller_history(caller_context)
-
-    # 构建消息:system + 调用者历史 + 反思请求
-    messages = [{"role": "system", "content": REFLECT_SYSTEM_PROMPT}]
-
-    if caller_history:
-        messages.extend(caller_history)
-
-    messages.append({
-        "role": "user",
-        "content": REFLECT_USER_TEMPLATE.format(
-            question=question,
-            findings=findings,
-        ),
-    })
-
-    try:
-        result = await qwen_llm_call(
-            messages=messages,
-            model=REFLECT_MODEL,
-            temperature=0.2,
-        )
-
-        content = result["content"]
-        msg_count = len(caller_history)
-        cost = result.get("cost", 0.0)
-        reasoning_tokens = result.get("reasoning_tokens", 0)
-
-        return ToolResult(
-            title=f"反思完成 (model: {REFLECT_MODEL}, 继承 {msg_count} 条历史)",
-            output=content,
-            tool_usage={
-                "model": REFLECT_MODEL,
-                "prompt_tokens": result.get("prompt_tokens", 0),
-                "completion_tokens": result.get("completion_tokens", 0),
-                "reasoning_tokens": reasoning_tokens,
-                "cost": cost,
-            },
-        )
-
-    except Exception as e:
-        return ToolResult(
-            title="reflect 失败",
-            output=f"调用反思模型出错: {str(e)}",
-        )

+ 1 - 1
frontend/react-template/package.json

@@ -11,7 +11,7 @@
   },
   },
   "dependencies": {
   "dependencies": {
     "@douyinfe/semi-icons": "^2.56.0",
     "@douyinfe/semi-icons": "^2.56.0",
-    "@douyinfe/semi-ui": "^2.56.0",
+    "@douyinfe/semi-ui": "^2.92.2",
     "axios": "^1.6.0",
     "axios": "^1.6.0",
     "d3": "^7.8.5",
     "d3": "^7.8.5",
     "jszip": "^3.10.1",
     "jszip": "^3.10.1",

+ 20 - 0
frontend/react-template/src/App.tsx

@@ -5,6 +5,7 @@ import { DetailPanel } from "./components/DetailPanel/DetailPanel";
 
 
 import type { Goal } from "./types/goal";
 import type { Goal } from "./types/goal";
 import type { Edge, Message } from "./types/message";
 import type { Edge, Message } from "./types/message";
+import type { FlowChartRef } from "./components/FlowChart/FlowChart";
 import { useFlowChartData } from "./components/FlowChart/hooks/useFlowChartData";
 import { useFlowChartData } from "./components/FlowChart/hooks/useFlowChartData";
 import "./styles/global.css";
 import "./styles/global.css";
 
 
@@ -19,6 +20,7 @@ function App() {
   const [refreshTrigger, setRefreshTrigger] = useState(0);
   const [refreshTrigger, setRefreshTrigger] = useState(0);
   const [messageRefreshTrigger, setMessageRefreshTrigger] = useState(0);
   const [messageRefreshTrigger, setMessageRefreshTrigger] = useState(0);
   const bodyRef = useRef<HTMLDivElement | null>(null);
   const bodyRef = useRef<HTMLDivElement | null>(null);
+  const flowChartRef = useRef<FlowChartRef>(null);
 
 
   // 获取数据以传递给 DetailPanel
   // 获取数据以传递给 DetailPanel
   const { msgGroups } = useFlowChartData(selectedTraceId, messageRefreshTrigger);
   const { msgGroups } = useFlowChartData(selectedTraceId, messageRefreshTrigger);
@@ -33,6 +35,21 @@ function App() {
     setSelectedEdge(null);
     setSelectedEdge(null);
   };
   };
 
 
+  const handleNavigateToMessage = (goalId: string, sequence: number) => {
+    // 先在对应 goal 的 group 里找
+    let target = (msgGroups[goalId] ?? []).find((m) => m.sequence === sequence);
+    // 兜底:跨所有 group 搜索
+    if (!target) {
+      for (const msgs of Object.values(msgGroups)) {
+        target = msgs.find((m) => m.sequence === sequence);
+        if (target) break;
+      }
+    }
+    if (target) handleNodeClick(target);
+    // 顺滑滚动到节点
+    flowChartRef.current?.scrollToSequence(sequence);
+  };
+
   const isGoalNode = (node: Goal | Message): node is Goal => "status" in node && "created_at" in node;
   const isGoalNode = (node: Goal | Message): node is Goal => "status" in node && "created_at" in node;
 
 
   // 根据选中的节点获取对应的消息
   // 根据选中的节点获取对应的消息
@@ -84,6 +101,7 @@ function App() {
           }}
           }}
           onTraceCreated={() => setRefreshTrigger((t) => t + 1)}
           onTraceCreated={() => setRefreshTrigger((t) => t + 1)}
           onMessageInserted={() => setMessageRefreshTrigger((t) => t + 1)}
           onMessageInserted={() => setMessageRefreshTrigger((t) => t + 1)}
+          onNavigateToMessage={handleNavigateToMessage}
         />
         />
       </div>
       </div>
       <div
       <div
@@ -101,6 +119,7 @@ function App() {
             }}
             }}
             refreshTrigger={refreshTrigger}
             refreshTrigger={refreshTrigger}
             messageRefreshTrigger={messageRefreshTrigger}
             messageRefreshTrigger={messageRefreshTrigger}
+            flowChartRef={flowChartRef}
           />
           />
         </div>
         </div>
         {(selectedNode || selectedEdge) && (
         {(selectedNode || selectedEdge) && (
@@ -123,6 +142,7 @@ function App() {
                 edge={selectedEdge}
                 edge={selectedEdge}
                 messages={selectedMessages as Message[]}
                 messages={selectedMessages as Message[]}
                 onClose={handleCloseDetail}
                 onClose={handleCloseDetail}
+                traceId={selectedTraceId ?? undefined}
               />
               />
             </div>
             </div>
           </>
           </>

+ 29 - 0
frontend/react-template/src/api/traceApi.ts

@@ -1,5 +1,20 @@
 import { request } from "./client";
 import { request } from "./client";
 import type { TraceDetailResponse, TraceListResponse } from "../types/trace";
 import type { TraceDetailResponse, TraceListResponse } from "../types/trace";
+import type { KnowledgeLogEntry, FeedbackAction } from "../types/goal";
+
+export interface KnowledgeFeedbackListItem {
+  knowledge_id: string;
+  action: FeedbackAction;
+  eval_status?: string;
+  feedback_text?: string;
+  source: {
+    trace_id: string;
+    goal_id?: string;
+    sequence?: number;
+    feedback_by: string;
+    feedback_at: string;
+  };
+}
 
 
 export const traceApi = {
 export const traceApi = {
   fetchTraces(params?: { status?: string; mode?: string; limit?: number }) {
   fetchTraces(params?: { status?: string; mode?: string; limit?: number }) {
@@ -86,4 +101,18 @@ export const traceApi = {
       },
       },
     });
     });
   },
   },
+  fetchKnowledgeLog(traceId: string) {
+    return request<{ trace_id: string; entries: KnowledgeLogEntry[] }>(
+      `/api/traces/${traceId}/knowledge_log`
+    );
+  },
+  submitKnowledgeFeedback(
+    traceId: string,
+    data: { feedback_list: KnowledgeFeedbackListItem[] }
+  ) {
+    return request<{ status: string; updated: number }>(
+      `/api/traces/${traceId}/knowledge_feedback`,
+      { method: "POST", data }
+    );
+  },
 };
 };

+ 138 - 0
frontend/react-template/src/components/CreateKnowledgeModal/CreateKnowledgeModal.module.css

@@ -0,0 +1,138 @@
+.body {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+  padding: 4px 0;
+}
+
+.contextBlock {
+  background: #f9fafb;
+  border: 1px solid #e5e7eb;
+  border-radius: 6px;
+  padding: 10px 12px;
+}
+
+.expandToggle {
+  font-size: 12px;
+  color: #3b82f6;
+  cursor: pointer;
+  user-select: none;
+}
+
+.expandToggle:hover {
+  text-decoration: underline;
+}
+
+.contextContent {
+  margin-top: 8px;
+  font-size: 12px;
+  line-height: 1.6;
+  white-space: pre-wrap;
+  word-break: break-word;
+  color: #6b7280;
+  max-height: 200px;
+  overflow-y: auto;
+}
+
+.field {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.label {
+  font-size: 13px;
+  font-weight: 500;
+  color: #374151;
+}
+
+.required {
+  color: #ef4444;
+}
+
+.typeList {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+}
+
+.typeItem {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+  font-size: 13px;
+  color: #374151;
+  cursor: pointer;
+  user-select: none;
+}
+
+.textarea {
+  width: 100%;
+  border: 1px solid #d1d5db;
+  border-radius: 6px;
+  padding: 8px 12px;
+  font-size: 14px;
+  line-height: 1.6;
+  resize: vertical;
+  outline: none;
+  font-family: inherit;
+  color: #374151;
+  box-sizing: border-box;
+}
+
+.textarea:focus {
+  border-color: #3b82f6;
+}
+
+.textarea:disabled {
+  background: #f9fafb;
+  color: #9ca3af;
+}
+
+.footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 10px;
+  padding: 0 4px;
+}
+
+.cancelBtn {
+  height: 36px;
+  padding: 0 16px;
+  border-radius: 6px;
+  border: 1px solid #d1d5db;
+  background: #fff;
+  color: #374151;
+  font-size: 14px;
+  cursor: pointer;
+}
+
+.cancelBtn:hover:not(:disabled) {
+  background: #f9fafb;
+}
+
+.cancelBtn:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+.submitBtn {
+  height: 36px;
+  padding: 0 20px;
+  border-radius: 6px;
+  border: none;
+  background: #3b82f6;
+  color: #fff;
+  font-size: 14px;
+  font-weight: 500;
+  cursor: pointer;
+}
+
+.submitBtn:hover:not(:disabled) {
+  background: #2563eb;
+}
+
+.submitBtn:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}

+ 153 - 0
frontend/react-template/src/components/CreateKnowledgeModal/CreateKnowledgeModal.tsx

@@ -0,0 +1,153 @@
+import { useState } from "react";
+import type { FC } from "react";
+import { Modal, Toast } from "@douyinfe/semi-ui";
+import { request } from "../../api/client";
+import styles from "./CreateKnowledgeModal.module.css";
+
+const KNOWLEDGE_TYPES = ["strategy", "tool", "user_profile", "usecase", "definition", "plan"];
+
+interface CreateKnowledgeModalProps {
+  visible: boolean;
+  contextText: string;
+  traceId?: string;
+  goalId?: string;
+  messageId?: string;
+  sequence?: number;
+  onClose: () => void;
+  onCreated?: (knowledgeId: string) => void;
+}
+
+export const CreateKnowledgeModal: FC<CreateKnowledgeModalProps> = ({
+  visible,
+  contextText,
+  traceId,
+  goalId,
+  messageId,
+  sequence,
+  onClose,
+  onCreated,
+}) => {
+  const [comment, setComment] = useState("");
+  const [selectedTypes, setSelectedTypes] = useState<string[]>(["strategy"]);
+  const [isSubmitting, setIsSubmitting] = useState(false);
+  const [isContextExpanded, setIsContextExpanded] = useState(false);
+
+  const toggleType = (type: string) => {
+    setSelectedTypes((prev) =>
+      prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]
+    );
+  };
+
+  const handleClose = () => {
+    setComment("");
+    setSelectedTypes(["strategy"]);
+    setIsContextExpanded(false);
+    onClose();
+  };
+
+  const handleSubmit = async () => {
+    if (!comment.trim()) {
+      Toast.warning("请输入评论内容");
+      return;
+    }
+    if (selectedTypes.length === 0) {
+      Toast.warning("请至少选择一个知识类型");
+      return;
+    }
+
+    setIsSubmitting(true);
+    try {
+      const result = await request<{ knowledge_id: string }>("/api/traces/extract_comment", {
+        method: "POST",
+        data: {
+          comment: comment.trim(),
+          context: contextText,
+          types: selectedTypes,
+          source: {
+            name: "user_feedback",
+            category: "exp",
+            trace_id: traceId,
+            goal_id: goalId,
+            message_id: messageId,
+            sequence: sequence ?? null,
+            submitted_by: "user",
+          },
+          scopes: ["org:cybertogether"],
+          owner: "user",
+        },
+      });
+      Toast.success("知识已创建,正在处理去重...");
+      onCreated?.(result.knowledge_id);
+      handleClose();
+    } catch {
+      Toast.error("创建失败,请重试");
+    } finally {
+      setIsSubmitting(false);
+    }
+  };
+
+  const footer = (
+    <div className={styles.footer}>
+      <button className={styles.cancelBtn} onClick={handleClose} disabled={isSubmitting}>
+        取消
+      </button>
+      <button className={styles.submitBtn} onClick={handleSubmit} disabled={isSubmitting || !comment.trim()}>
+        {isSubmitting ? "创建中..." : "创建知识"}
+      </button>
+    </div>
+  );
+
+  return (
+    <Modal
+      title="创建知识"
+      visible={visible}
+      onCancel={handleClose}
+      footer={footer}
+      width={600}
+      maskClosable={false}
+    >
+      <div className={styles.body}>
+        {contextText && (
+          <div className={styles.contextBlock}>
+            <span className={styles.expandToggle} onClick={() => setIsContextExpanded((v) => !v)}>
+              {isContextExpanded ? "▲ 收起上下文" : "▼ 展开上下文"}
+            </span>
+            {isContextExpanded && (
+              <pre className={styles.contextContent}>{contextText}</pre>
+            )}
+          </div>
+        )}
+
+        <div className={styles.field}>
+          <label className={styles.label}>知识类型</label>
+          <div className={styles.typeList}>
+            {KNOWLEDGE_TYPES.map((type) => (
+              <label key={type} className={styles.typeItem}>
+                <input
+                  type="checkbox"
+                  checked={selectedTypes.includes(type)}
+                  onChange={() => toggleType(type)}
+                />
+                <span>{type}</span>
+              </label>
+            ))}
+          </div>
+        </div>
+
+        <div className={styles.field}>
+          <label className={styles.label}>
+            评论 <span className={styles.required}>*</span>
+          </label>
+          <textarea
+            className={styles.textarea}
+            value={comment}
+            onChange={(e) => setComment(e.target.value)}
+            placeholder="描述你的观察、经验或注意事项,LLM 将自动提取结构化知识..."
+            rows={5}
+            disabled={isSubmitting}
+          />
+        </div>
+      </div>
+    </Modal>
+  );
+};

+ 24 - 0
frontend/react-template/src/components/DetailPanel/DetailPanel.module.css

@@ -334,3 +334,27 @@
 .reasoningContent p:last-child {
 .reasoningContent p:last-child {
   margin-bottom: 0;
   margin-bottom: 0;
 }
 }
+
+.createKnowledgeBar {
+  padding: 12px 16px;
+  border-top: 1px solid var(--border-light);
+  background: var(--bg-panel);
+  flex-shrink: 0;
+}
+
+.createKnowledgeBtn {
+  width: 100%;
+  height: 34px;
+  border-radius: 6px;
+  border: 1px solid #d1d5db;
+  background: #fff;
+  color: #374151;
+  font-size: 13px;
+  cursor: pointer;
+  transition: background 0.15s;
+}
+
+.createKnowledgeBtn:hover {
+  background: #f9fafb;
+  border-color: #9ca3af;
+}

+ 65 - 0
frontend/react-template/src/components/DetailPanel/DetailPanel.tsx

@@ -5,12 +5,14 @@ import type { Edge, Message } from "../../types/message";
 import styles from "./DetailPanel.module.css";
 import styles from "./DetailPanel.module.css";
 import { ImagePreviewModal } from "../ImagePreview/ImagePreviewModal";
 import { ImagePreviewModal } from "../ImagePreview/ImagePreviewModal";
 import { extractImagesFromMessage } from "../../utils/imageExtraction";
 import { extractImagesFromMessage } from "../../utils/imageExtraction";
+import { CreateKnowledgeModal } from "../CreateKnowledgeModal/CreateKnowledgeModal";
 
 
 interface DetailPanelProps {
 interface DetailPanelProps {
   node: Goal | Message | null;
   node: Goal | Message | null;
   edge: Edge | null;
   edge: Edge | null;
   messages?: Message[];
   messages?: Message[];
   onClose: () => void;
   onClose: () => void;
+  traceId?: string;
 }
 }
 
 
 export const DetailPanel = ({
 export const DetailPanel = ({
@@ -18,9 +20,11 @@ export const DetailPanel = ({
   edge,
   edge,
   messages = [],
   messages = [],
   onClose,
   onClose,
+  traceId,
 }: DetailPanelProps) => {
 }: DetailPanelProps) => {
   const [previewImage, setPreviewImage] = useState<string | null>(null);
   const [previewImage, setPreviewImage] = useState<string | null>(null);
   const [expandedReasoning, setExpandedReasoning] = useState<Set<string>>(new Set());
   const [expandedReasoning, setExpandedReasoning] = useState<Set<string>>(new Set());
+  const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
 
 
   console.log("DetailPanel - node:", node);
   console.log("DetailPanel - node:", node);
   console.log("DetailPanel - edge:", edge);
   console.log("DetailPanel - edge:", edge);
@@ -188,6 +192,48 @@ export const DetailPanel = ({
   const isMessageNode = (node: Goal | Message): node is Message =>
   const isMessageNode = (node: Goal | Message): node is Message =>
     "message_id" in node || "role" in node || "sequence" in node;
     "message_id" in node || "role" in node || "sequence" in node;
 
 
+  // 构建传给 LLM 的上下文文本
+  const buildContextText = (): string => {
+    if (!node) return "";
+    if (isGoal(node)) {
+      const lines: string[] = [];
+      lines.push(`[Goal] ${node.description}`);
+      if (node.summary) lines.push(`[Summary] ${node.summary}`);
+      for (const msg of messages) {
+        const role = msg.role || "unknown";
+        const seq = msg.sequence ?? "";
+        const content = typeof msg.content === "string"
+          ? msg.content
+          : JSON.stringify(msg.content);
+        lines.push(`[${role}#${seq}] ${content}`);
+      }
+      return lines.join("\n");
+    } else {
+      const msg = node as Message;
+      const role = msg.role || "unknown";
+      const seq = msg.sequence ?? "";
+      const content = typeof msg.content === "string"
+        ? msg.content
+        : JSON.stringify(msg.content);
+      return `[${role}#${seq}] ${content}`;
+    }
+  };
+
+  // 提取 Goal/Message 的 ID 信息用于 source 溯源
+  const getNodeIds = () => {
+    if (!node) return {};
+    if (isGoal(node)) {
+      return { goalId: node.id };
+    } else {
+      const msg = node as Message;
+      return {
+        goalId: msg.goal_id,
+        messageId: msg.message_id || msg.id,
+        sequence: msg.sequence,
+      };
+    }
+  };
+
   const renderKnowledge = (knowledge: Goal["knowledge"]) => {
   const renderKnowledge = (knowledge: Goal["knowledge"]) => {
     if (!knowledge || knowledge.length === 0) return null;
     if (!knowledge || knowledge.length === 0) return null;
 
 
@@ -355,6 +401,25 @@ export const DetailPanel = ({
         onClose={() => setPreviewImage(null)}
         onClose={() => setPreviewImage(null)}
         src={previewImage || ""}
         src={previewImage || ""}
       />
       />
+      {node && (
+        <>
+          <div className={styles.createKnowledgeBar}>
+            <button
+              className={styles.createKnowledgeBtn}
+              onClick={() => setIsCreateModalOpen(true)}
+            >
+              创建知识
+            </button>
+          </div>
+          <CreateKnowledgeModal
+            visible={isCreateModalOpen}
+            contextText={buildContextText()}
+            traceId={traceId}
+            {...getNodeIds()}
+            onClose={() => setIsCreateModalOpen(false)}
+          />
+        </>
+      )}
     </aside>
     </aside>
   );
   );
 };
 };

+ 28 - 4
frontend/react-template/src/components/FlowChart/FlowChart.tsx

@@ -18,6 +18,7 @@ interface FlowChartProps {
 export interface FlowChartRef {
 export interface FlowChartRef {
   expandAll: () => void;
   expandAll: () => void;
   collapseAll: () => void;
   collapseAll: () => void;
+  scrollToSequence: (sequence: number) => void;
 }
 }
 
 
 export type SubTraceEntry = { id: string; mission?: string };
 export type SubTraceEntry = { id: string; mission?: string };
@@ -662,10 +663,6 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
     return ids;
     return ids;
   }, [displayGoals]);
   }, [displayGoals]);
 
 
-  useImperativeHandle(ref, () => ({
-    expandAll: () => setCollapsedGoals(new Set()),
-    collapseAll: () => setCollapsedGoals(new Set(allGoalIds)),
-  }), [allGoalIds]);
 
 
   const visibleData = layoutData;
   const visibleData = layoutData;
 
 
@@ -703,6 +700,33 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
     };
     };
   }, [visibleData, dimensions]);
   }, [visibleData, dimensions]);
 
 
+  useImperativeHandle(ref, () => ({
+    expandAll: () => setCollapsedGoals(new Set()),
+    collapseAll: () => setCollapsedGoals(new Set(allGoalIds)),
+    scrollToSequence: (sequence: number) => {
+      const targetNode = layoutData?.nodes.find(
+        (n) => n.type === "message" && (n.data as Message).sequence === sequence
+      );
+      if (!targetNode) return;
+      setSelectedNodeId(targetNode.id);
+      onNodeClick?.(targetNode.data as Message);
+      if (viewMode === "scroll" && scrollContainerRef.current) {
+        const rect = scrollContainerRef.current.getBoundingClientRect();
+        scrollContainerRef.current.scrollTo({
+          left: (targetNode.x - contentSize.minX) - rect.width / 2 + 100,
+          top: (targetNode.y - contentSize.minY) - rect.height / 2,
+          behavior: "smooth",
+        });
+      } else if (viewMode === "panzoom" && containerRef.current) {
+        const rect = containerRef.current.getBoundingClientRect();
+        setPanOffset({
+          x: rect.width / 2 - targetNode.x * zoom - 100 * zoom,
+          y: rect.height / 2 - targetNode.y * zoom,
+        });
+      }
+    },
+  }), [allGoalIds, layoutData, contentSize, viewMode, zoom, onNodeClick]);
+
   const toggleView = () => {
   const toggleView = () => {
     if (viewMode === "scroll") {
     if (viewMode === "scroll") {
       setViewMode("panzoom");
       setViewMode("panzoom");

+ 255 - 0
frontend/react-template/src/components/KnowledgeFeedbackModal/KnowledgeFeedbackModal.module.css

@@ -0,0 +1,255 @@
+.hint {
+  background: #fffbeb;
+  border: 1px solid #fde68a;
+  border-radius: 6px;
+  padding: 10px 14px;
+  font-size: 13px;
+  color: #92400e;
+  margin-bottom: 16px;
+}
+
+.loading,
+.empty {
+  text-align: center;
+  color: #9ca3af;
+  padding: 40px 0;
+  font-size: 14px;
+}
+
+.entriesList {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+}
+
+.entryCard {
+  border: 1px solid #e5e7eb;
+  border-radius: 8px;
+  padding: 14px 16px;
+  background: #fafafa;
+}
+
+.entryHeader {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex-wrap: wrap;
+  margin-bottom: 8px;
+}
+
+.entryId {
+  font-family: monospace;
+  font-size: 12px;
+  color: #6b7280;
+}
+
+.entryMeta {
+  font-size: 12px;
+  color: #9ca3af;
+}
+
+.entryMetaLink {
+  color: #3b82f6;
+  cursor: pointer;
+  border-radius: 4px;
+  padding: 1px 4px;
+  transition: background 0.15s;
+}
+
+.entryMetaLink:hover {
+  background: #eff6ff;
+  text-decoration: underline;
+}
+
+.badge {
+  font-size: 11px;
+  padding: 2px 8px;
+  border-radius: 12px;
+  font-weight: 500;
+}
+
+.badgeHelpful {
+  background: #d1fae5;
+  color: #065f46;
+}
+
+.badgeHarmful {
+  background: #fee2e2;
+  color: #991b1b;
+}
+
+.badgeNeutral {
+  background: #f3f4f6;
+  color: #374151;
+}
+
+.entryTask {
+  font-size: 14px;
+  color: #111827;
+  font-weight: 500;
+  margin-bottom: 8px;
+  line-height: 1.5;
+}
+
+.entryEval {
+  display: flex;
+  align-items: baseline;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-bottom: 10px;
+  padding: 8px 10px;
+  background: #f0f9ff;
+  border: 1px solid #bae6fd;
+  border-radius: 6px;
+  font-size: 13px;
+}
+
+.evalScore {
+  font-weight: 600;
+  color: #0369a1;
+  white-space: nowrap;
+}
+
+.evalReasoning {
+  margin: 0;
+  color: #374151;
+  line-height: 1.6;
+  flex: 1;
+  min-width: 0;
+}
+
+.evalNoReasoning {
+  color: #9ca3af;
+  font-style: italic;
+}
+
+.entryContentWrapper {
+  margin-bottom: 12px;
+}
+
+.expandToggle {
+  font-size: 12px;
+  color: #3b82f6;
+  cursor: pointer;
+  user-select: none;
+}
+
+.expandToggle:hover {
+  text-decoration: underline;
+}
+
+.entryContent {
+  margin-top: 8px;
+  background: #f9fafb;
+  border: 1px solid #e5e7eb;
+  border-radius: 6px;
+  padding: 10px 12px;
+  font-size: 13px;
+  line-height: 1.6;
+  white-space: pre-wrap;
+  word-break: break-word;
+  color: #374151;
+}
+
+.feedbackRow {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex-wrap: wrap;
+}
+
+.feedbackLabel {
+  font-size: 13px;
+  color: #6b7280;
+  white-space: nowrap;
+}
+
+.overrideForm {
+  margin-top: 12px;
+  padding: 12px;
+  background: #fff;
+  border: 1px solid #dbeafe;
+  border-radius: 6px;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.overrideRow {
+  display: flex;
+  align-items: flex-start;
+  gap: 10px;
+}
+
+.overrideLabel {
+  font-size: 13px;
+  color: #6b7280;
+  white-space: nowrap;
+  padding-top: 6px;
+  min-width: 80px;
+}
+
+.feedbackTextarea {
+  flex: 1;
+  border: 1px solid #d1d5db;
+  border-radius: 6px;
+  padding: 6px 10px;
+  font-size: 13px;
+  resize: vertical;
+  outline: none;
+  font-family: inherit;
+  color: #374151;
+}
+
+.feedbackTextarea:focus {
+  border-color: #3b82f6;
+}
+
+/* Footer */
+.footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 10px;
+  padding: 0 4px;
+}
+
+.cancelBtn {
+  height: 36px;
+  padding: 0 16px;
+  border-radius: 6px;
+  border: 1px solid #d1d5db;
+  background: #fff;
+  color: #374151;
+  font-size: 14px;
+  cursor: pointer;
+}
+
+.cancelBtn:hover:not(:disabled) {
+  background: #f9fafb;
+}
+
+.cancelBtn:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+.submitBtn {
+  height: 36px;
+  padding: 0 20px;
+  border-radius: 6px;
+  border: none;
+  background: #3b82f6;
+  color: #fff;
+  font-size: 14px;
+  font-weight: 500;
+  cursor: pointer;
+}
+
+.submitBtn:hover:not(:disabled) {
+  background: #2563eb;
+}
+
+.submitBtn:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}

+ 305 - 0
frontend/react-template/src/components/KnowledgeFeedbackModal/KnowledgeFeedbackModal.tsx

@@ -0,0 +1,305 @@
+import { useEffect, useState } from "react";
+import type { FC } from "react";
+import { Modal, RadioGroup, Radio, Select, Toast } from "@douyinfe/semi-ui";
+import { traceApi } from "../../api/traceApi";
+import type { KnowledgeLogEntry, FeedbackAction, KnowledgeFeedbackState } from "../../types/goal";
+import type { KnowledgeFeedbackListItem } from "../../api/traceApi";
+import styles from "./KnowledgeFeedbackModal.module.css";
+
+interface KnowledgeFeedbackModalProps {
+  visible: boolean;
+  traceId: string;
+  onClose: () => void;
+  onSubmitted?: () => void;
+  onNavigateToMessage?: (goalId: string, sequence: number) => void;
+}
+
+const EVAL_STATUS_OPTIONS = [
+  { value: "helpful", label: "✅ helpful(有效)" },
+  { value: "harmful", label: "🚫 harmful(有害)" },
+  { value: "unused", label: "⬜ unused(未使用)" },
+  { value: "irrelevant", label: "⚠️ irrelevant(无关)" },
+  { value: "neutral", label: "➖ neutral(中性)" },
+];
+
+const EVAL_STATUS_BADGE: Record<string, { label: string; cls: string }> = {
+  helpful:    { label: "helpful",    cls: styles.badgeHelpful },
+  harmful:    { label: "harmful",    cls: styles.badgeHarmful },
+  unused:     { label: "unused",     cls: styles.badgeNeutral },
+  irrelevant: { label: "irrelevant", cls: styles.badgeNeutral },
+  neutral:    { label: "neutral",    cls: styles.badgeNeutral },
+};
+
+export const KnowledgeFeedbackModal: FC<KnowledgeFeedbackModalProps> = ({
+  visible,
+  traceId,
+  onClose,
+  onSubmitted,
+  onNavigateToMessage,
+}) => {
+  const [entries, setEntries] = useState<KnowledgeLogEntry[]>([]);
+  const [feedbackMap, setFeedbackMap] = useState<Record<string, KnowledgeFeedbackState>>({});
+  const [isLoading, setIsLoading] = useState(false);
+  const [isSubmitting, setIsSubmitting] = useState(false);
+  const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
+
+  // 加载 knowledge_log,并去重(同一 knowledge_id 取最新注入)
+  useEffect(() => {
+    if (!visible || !traceId) return;
+
+    setIsLoading(true);
+    traceApi
+      .fetchKnowledgeLog(traceId)
+      .then((log) => {
+        // 去重:同一 knowledge_id 保留最新注入的条目
+        const seen = new Map<string, KnowledgeLogEntry>();
+        for (const entry of log.entries) {
+          const existing = seen.get(entry.knowledge_id);
+          if (!existing || entry.injected_at_sequence > existing.injected_at_sequence) {
+            seen.set(entry.knowledge_id, entry);
+          }
+        }
+        const deduped = Array.from(seen.values());
+        setEntries(deduped);
+
+        // 默认所有条目选「跳过」
+        const initial: Record<string, KnowledgeFeedbackState> = {};
+        deduped.forEach((e) => {
+          // 若已有用户反馈,恢复之前的选择
+          if (e.user_feedback) {
+            initial[e.knowledge_id] = {
+              action: e.user_feedback.action as FeedbackAction,
+              eval_status: e.user_feedback.eval_status,
+              feedback_text: e.user_feedback.feedback_text,
+            };
+          } else {
+            initial[e.knowledge_id] = { action: "skip" };
+          }
+        });
+        setFeedbackMap(initial);
+      })
+      .catch(() => {
+        Toast.error("加载知识日志失败");
+      })
+      .finally(() => setIsLoading(false));
+  }, [visible, traceId]);
+
+  const toggleExpand = (id: string) => {
+    setExpandedIds((prev) => {
+      const next = new Set(prev);
+      if (next.has(id)) next.delete(id);
+      else next.add(id);
+      return next;
+    });
+  };
+
+  const setFeedback = (id: string, patch: Partial<KnowledgeFeedbackState>) => {
+    setFeedbackMap((prev) => ({
+      ...prev,
+      [id]: { ...prev[id], ...patch },
+    }));
+  };
+
+  const confirmedCount = Object.values(feedbackMap).filter(
+    (f) => f.action !== "skip"
+  ).length;
+
+  const handleSubmit = async () => {
+    setIsSubmitting(true);
+    const feedbackList: KnowledgeFeedbackListItem[] = entries
+      .filter((e) => feedbackMap[e.knowledge_id]?.action !== "skip")
+      .map((e) => ({
+        knowledge_id: e.knowledge_id,
+        action: feedbackMap[e.knowledge_id].action,
+        eval_status: feedbackMap[e.knowledge_id].eval_status,
+        feedback_text: feedbackMap[e.knowledge_id].feedback_text,
+        source: {
+          trace_id: traceId,
+          goal_id: e.goal_id,
+          sequence: e.injected_at_sequence,
+          feedback_by: "user",
+          feedback_at: new Date().toISOString(),
+        },
+      }));
+
+    try {
+      await traceApi.submitKnowledgeFeedback(traceId, { feedback_list: feedbackList });
+      localStorage.setItem(`knowledge_feedback_submitted_${traceId}`, "true");
+      Toast.success(`反馈提交成功,共反馈 ${feedbackList.length} 条知识`);
+      onSubmitted?.();
+      onClose();
+    } catch {
+      Toast.error("提交失败,请重试");
+    } finally {
+      setIsSubmitting(false);
+    }
+  };
+
+  const footer = (
+    <div className={styles.footer}>
+      <button className={styles.cancelBtn} onClick={onClose} disabled={isSubmitting}>
+        取消
+      </button>
+      <button
+        className={styles.submitBtn}
+        onClick={handleSubmit}
+        disabled={isSubmitting || entries.length === 0}
+      >
+        {isSubmitting ? "提交中..." : `提交反馈(已反馈 ${confirmedCount}/${entries.length} 条)`}
+      </button>
+    </div>
+  );
+
+  return (
+    <Modal
+      title={`知识使用反馈 — ${traceId}`}
+      visible={visible}
+      onCancel={onClose}
+      footer={footer}
+      width={720}
+      style={{ maxHeight: "90vh" }}
+      bodyStyle={{ overflowY: "auto", maxHeight: "calc(90vh - 120px)", padding: "16px 24px" }}
+      maskClosable={false}
+    >
+      {isLoading ? (
+        <div className={styles.loading}>加载中...</div>
+      ) : entries.length === 0 ? (
+        <div className={styles.empty}>本次 Trace 暂无知识注入记录</div>
+      ) : (
+        <>
+          <div className={styles.hint}>
+            💡 提示:只需对有信心的知识做反馈,不确定的可以跳过(默认)
+          </div>
+          <div className={styles.entriesList}>
+            {entries.map((entry) => {
+              const fb = feedbackMap[entry.knowledge_id] ?? { action: "skip" };
+              const isExpanded = expandedIds.has(entry.knowledge_id);
+              const evalBadge = entry.eval_result?.eval_status
+                ? EVAL_STATUS_BADGE[entry.eval_result.eval_status]
+                : null;
+
+              return (
+                <div key={entry.knowledge_id} className={styles.entryCard}>
+                  {/* 卡片头部 */}
+                  <div className={styles.entryHeader}>
+                    <span className={styles.entryId} title={entry.knowledge_id}>
+                      {entry.knowledge_id.slice(0, 28)}…
+                    </span>
+                    <span
+                      className={`${styles.entryMeta} ${onNavigateToMessage ? styles.entryMetaLink : ""}`}
+                      title={onNavigateToMessage ? "点击跳转到该消息" : undefined}
+                      onClick={
+                        onNavigateToMessage
+                          ? () => {
+                              onNavigateToMessage(entry.goal_id, entry.injected_at_sequence);
+                              onClose();
+                            }
+                          : undefined
+                      }
+                    >
+                      <strong>Goal {entry.goal_id}</strong>
+                      {" · "}
+                      <strong>seq {entry.injected_at_sequence}</strong>
+                    </span>
+                    {evalBadge && (
+                      <span className={`${styles.badge} ${evalBadge.cls}`}>
+                        Agent: {evalBadge.label}
+                      </span>
+                    )}
+                    {!entry.eval_result && (
+                      <span className={`${styles.badge} ${styles.badgeNeutral}`}>未评估</span>
+                    )}
+                  </div>
+
+                  {/* 任务场景 */}
+                  <div className={styles.entryTask}>{entry.task}</div>
+
+                  {/* Agent 评估详情 */}
+                  <div className={styles.entryEval}>
+                    {entry.eval_result ? (
+                      <>
+                        {typeof entry.eval_result.score === "number" && (
+                          <span className={styles.evalScore}>
+                            评分 {entry.eval_result.score}/5
+                          </span>
+                        )}
+                        {entry.eval_result.reason ? (
+                          <p className={styles.evalReasoning}>{entry.eval_result.reason}</p>
+                        ) : (
+                          <span className={styles.evalNoReasoning}>(无评估说明)</span>
+                        )}
+                      </>
+                    ) : (
+                      <span className={styles.evalNoReasoning}>Agent 暂未评估此知识</span>
+                    )}
+                  </div>
+
+                  {/* 知识内容(可展开) */}
+                  <div className={styles.entryContentWrapper}>
+                    <span
+                      className={styles.expandToggle}
+                      onClick={() => toggleExpand(entry.knowledge_id)}
+                    >
+                      {isExpanded ? "▲ 收起内容" : "▼ 展开内容"}
+                    </span>
+                    {isExpanded && (
+                      <pre className={styles.entryContent}>{entry.content}</pre>
+                    )}
+                  </div>
+
+                  {/* 用户反馈选择 */}
+                  <div className={styles.feedbackRow}>
+                    <span className={styles.feedbackLabel}>您的反馈:</span>
+                    <RadioGroup
+                      value={fb.action}
+                      onChange={(e) =>
+                        setFeedback(entry.knowledge_id, { action: e.target.value as FeedbackAction })
+                      }
+                      direction="horizontal"
+                    >
+                      <Radio value="skip">跳过(不确定)</Radio>
+                      <Radio value="confirm" disabled={!entry.eval_result}>
+                        同意 Agent 评估
+                      </Radio>
+                      <Radio value="override">不同意,重新评估</Radio>
+                    </RadioGroup>
+                  </div>
+
+                  {/* 重新评估子表单 */}
+                  {fb.action === "override" && (
+                    <div className={styles.overrideForm}>
+                      <div className={styles.overrideRow}>
+                        <span className={styles.overrideLabel}>评估结果:</span>
+                        <Select
+                          value={fb.eval_status}
+                          onChange={(v) =>
+                            setFeedback(entry.knowledge_id, { eval_status: v as string })
+                          }
+                          optionList={EVAL_STATUS_OPTIONS}
+                          style={{ width: 220 }}
+                          placeholder="请选择评估结果"
+                        />
+                      </div>
+                      <div className={styles.overrideRow}>
+                        <span className={styles.overrideLabel}>说明(可选):</span>
+                        <textarea
+                          className={styles.feedbackTextarea}
+                          value={fb.feedback_text ?? ""}
+                          onChange={(e) =>
+                            setFeedback(entry.knowledge_id, { feedback_text: e.target.value })
+                          }
+                          placeholder="请输入反馈说明(可选)"
+                          rows={2}
+                        />
+                      </div>
+                    </div>
+                  )}
+                </div>
+              );
+            })}
+          </div>
+        </>
+      )}
+    </Modal>
+  );
+};

+ 5 - 2
frontend/react-template/src/components/MainContent/MainContent.tsx

@@ -1,5 +1,5 @@
 import { useRef, useState, useEffect } from "react";
 import { useRef, useState, useEffect } from "react";
-import type { FC } from "react";
+import type { FC, RefObject } from "react";
 import { Select } from "@douyinfe/semi-ui";
 import { Select } from "@douyinfe/semi-ui";
 import { FlowChart } from "../FlowChart/FlowChart";
 import { FlowChart } from "../FlowChart/FlowChart";
 import type { FlowChartRef } from "../FlowChart/FlowChart";
 import type { FlowChartRef } from "../FlowChart/FlowChart";
@@ -16,6 +16,7 @@ interface MainContentProps {
   onTraceChange?: (traceId: string, title?: string) => void;
   onTraceChange?: (traceId: string, title?: string) => void;
   refreshTrigger?: number;
   refreshTrigger?: number;
   messageRefreshTrigger?: number;
   messageRefreshTrigger?: number;
+  flowChartRef?: RefObject<FlowChartRef>;
 }
 }
 
 
 interface ConnectionStatusProps {
 interface ConnectionStatusProps {
@@ -40,8 +41,10 @@ export const MainContent: FC<MainContentProps> = ({
   onTraceChange,
   onTraceChange,
   refreshTrigger,
   refreshTrigger,
   messageRefreshTrigger,
   messageRefreshTrigger,
+  flowChartRef: externalFlowChartRef,
 }) => {
 }) => {
-  const flowChartRef = useRef<FlowChartRef>(null);
+  const internalFlowChartRef = useRef<FlowChartRef>(null);
+  const flowChartRef = externalFlowChartRef ?? internalFlowChartRef;
   const [isAllExpanded, setIsAllExpanded] = useState(true);
   const [isAllExpanded, setIsAllExpanded] = useState(true);
   const [traceList, setTraceList] = useState<TraceListItem[]>([]);
   const [traceList, setTraceList] = useState<TraceListItem[]>([]);
   const [cachedGoals, setCachedGoals] = useState<Goal[]>([]);
   const [cachedGoals, setCachedGoals] = useState<Goal[]>([]);

+ 54 - 0
frontend/react-template/src/components/TopBar/TopBar.tsx

@@ -7,6 +7,7 @@ import { traceApi } from "../../api/traceApi";
 import type { Goal } from "../../types/goal";
 import type { Goal } from "../../types/goal";
 import type { Message } from "../../types/message";
 import type { Message } from "../../types/message";
 import { AgentControlPanel } from "../AgentControlPanel/AgentControlPanel";
 import { AgentControlPanel } from "../AgentControlPanel/AgentControlPanel";
+import { KnowledgeFeedbackModal } from "../KnowledgeFeedbackModal/KnowledgeFeedbackModal";
 import styles from "./TopBar.module.css";
 import styles from "./TopBar.module.css";
 
 
 interface TopBarProps {
 interface TopBarProps {
@@ -16,6 +17,7 @@ interface TopBarProps {
   onTraceSelect: (traceId: string, title?: string) => void;
   onTraceSelect: (traceId: string, title?: string) => void;
   onTraceCreated?: () => void;
   onTraceCreated?: () => void;
   onMessageInserted?: () => void;
   onMessageInserted?: () => void;
+  onNavigateToMessage?: (goalId: string, sequence: number) => void;
 }
 }
 
 
 export const TopBar: FC<TopBarProps> = ({
 export const TopBar: FC<TopBarProps> = ({
@@ -25,6 +27,7 @@ export const TopBar: FC<TopBarProps> = ({
   onTraceSelect,
   onTraceSelect,
   onTraceCreated,
   onTraceCreated,
   onMessageInserted,
   onMessageInserted,
+  onNavigateToMessage,
 }) => {
 }) => {
   const [isModalVisible, setIsModalVisible] = useState(false);
   const [isModalVisible, setIsModalVisible] = useState(false);
   const [isInsertModalVisible, setIsInsertModalVisible] = useState(false);
   const [isInsertModalVisible, setIsInsertModalVisible] = useState(false);
@@ -37,6 +40,10 @@ export const TopBar: FC<TopBarProps> = ({
   const [isUploading, setIsUploading] = useState(false);
   const [isUploading, setIsUploading] = useState(false);
   // 控制中心面板
   // 控制中心面板
   const [isControlPanelVisible, setIsControlPanelVisible] = useState(false);
   const [isControlPanelVisible, setIsControlPanelVisible] = useState(false);
+  // 知识反馈
+  const [isKnowledgeFeedbackVisible, setIsKnowledgeFeedbackVisible] = useState(false);
+  const [feedbackSubmitted, setFeedbackSubmitted] = useState(false);
+  const [pendingFeedbackCount, setPendingFeedbackCount] = useState(0);
 
 
   const formApiRef = useRef<{
   const formApiRef = useRef<{
     getValues: () => { system_prompt?: string; user_prompt?: string; example_project?: string };
     getValues: () => { system_prompt?: string; user_prompt?: string; example_project?: string };
@@ -80,6 +87,29 @@ export const TopBar: FC<TopBarProps> = ({
     loadTraces();
     loadTraces();
   }, [loadTraces]);
   }, [loadTraces]);
 
 
+  // 切换 Trace 时更新知识反馈状态
+  useEffect(() => {
+    if (!selectedTraceId) {
+      setPendingFeedbackCount(0);
+      setFeedbackSubmitted(false);
+      return;
+    }
+    const submitted =
+      localStorage.getItem(`knowledge_feedback_submitted_${selectedTraceId}`) === "true";
+    setFeedbackSubmitted(submitted);
+    if (!submitted) {
+      traceApi
+        .fetchKnowledgeLog(selectedTraceId)
+        .then((log) => {
+          const uniqueIds = new Set(log.entries.map((e) => e.knowledge_id));
+          setPendingFeedbackCount(uniqueIds.size);
+        })
+        .catch(() => {});
+    } else {
+      setPendingFeedbackCount(0);
+    }
+  }, [selectedTraceId]);
+
   const handleNewTask = async () => {
   const handleNewTask = async () => {
     setIsModalVisible(true);
     setIsModalVisible(true);
     // 加载 example 项目列表
     // 加载 example 项目列表
@@ -346,6 +376,20 @@ export const TopBar: FC<TopBarProps> = ({
           >
           >
             经验
             经验
           </button>
           </button>
+          <button
+            className={`${styles.button} ${feedbackSubmitted ? styles.success : pendingFeedbackCount > 0 ? styles.warning : ""}`}
+            onClick={() => {
+              if (!selectedTraceId) { Toast.warning("请先选择一个 Trace"); return; }
+              setIsKnowledgeFeedbackVisible(true);
+            }}
+            disabled={!selectedTraceId}
+          >
+            {feedbackSubmitted
+              ? "知识反馈 ✓"
+              : pendingFeedbackCount > 0
+              ? `知识反馈 (${pendingFeedbackCount})`
+              : "知识反馈"}
+          </button>
           <label className={`${styles.button} ${styles.info}`} style={{ cursor: 'pointer' }}>
           <label className={`${styles.button} ${styles.info}`} style={{ cursor: 'pointer' }}>
             {isUploading ? "上传中..." : "📤 导入"}
             {isUploading ? "上传中..." : "📤 导入"}
             <input
             <input
@@ -458,6 +502,16 @@ export const TopBar: FC<TopBarProps> = ({
             {experienceContent ? <ReactMarkdown>{experienceContent}</ReactMarkdown> : "暂无经验数据"}
             {experienceContent ? <ReactMarkdown>{experienceContent}</ReactMarkdown> : "暂无经验数据"}
           </div>
           </div>
         </Modal>
         </Modal>
+        <KnowledgeFeedbackModal
+          visible={isKnowledgeFeedbackVisible}
+          traceId={selectedTraceId || ""}
+          onClose={() => setIsKnowledgeFeedbackVisible(false)}
+          onSubmitted={() => {
+            setFeedbackSubmitted(true);
+            setPendingFeedbackCount(0);
+          }}
+          onNavigateToMessage={onNavigateToMessage}
+        />
       </header>
       </header>
       {
       {
         isControlPanelVisible && (
         isControlPanelVisible && (

+ 33 - 0
frontend/react-template/src/types/goal.ts

@@ -46,3 +46,36 @@ export interface BranchContext {
   created_at: string;
   created_at: string;
   completed_at?: string;
   completed_at?: string;
 }
 }
+
+// ===== Knowledge Feedback Types =====
+
+export interface KnowledgeLogEntry {
+  knowledge_id: string;
+  goal_id: string;
+  injected_at_sequence: number;
+  injected_at: string;
+  task: string;
+  content: string;
+  eval_result: {
+    eval_status?: string;
+    reason?: string;
+    score?: number;
+  } | null;
+  evaluated_at: string | null;
+  evaluated_at_trigger?: string | null;
+  user_feedback: {
+    action: string;
+    eval_status?: string;
+    feedback_by: string;
+    feedback_at: string;
+    feedback_text?: string;
+  } | null;
+}
+
+export type FeedbackAction = "confirm" | "override" | "skip";
+
+export interface KnowledgeFeedbackState {
+  action: FeedbackAction;
+  eval_status?: string;
+  feedback_text?: string;
+}

+ 392 - 0
knowhub/docs/dedup-design.md

@@ -0,0 +1,392 @@
+# 知识入库前智能去重与关系判断系统 — 设计文档
+
+## 文档维护规范
+
+0. **先改文档,再动代码** - 新功能或重大修改需先完成文档更新、并完成审阅后,再进行代码实现;除非改动较小、不被文档涵盖
+1. **文档分层,链接代码** - 重要或复杂设计可以另有详细文档;关键实现需标注代码文件路径;格式:`module/file.py:function_name`
+2. **简洁快照,日志分离** - 只记录最重要的、与代码准确对应的或者明确的已完成的设计的信息,避免推测、建议、决策历史、修改日志、大量代码;决策依据或修改日志若有必要,可在 `knowhub/docs/decisions.md` 另行记录
+
+---
+
+## 可行性结论
+
+**整体可行,无阻塞性问题。**
+
+---
+
+## 一、去重流程
+
+```
+新知识进入 (status=pending)
+         │
+         ▼
+[Step 1] 复用已存储的 embedding(入队时已生成,不重复调用)
+         │
+         ▼
+[Step 2] 向量召回 top-10 相似知识
+         filter: status == "approved" or status == "checked"
+         │
+         ▼
+[Step 2.5] 相似度预过滤(阈值 0.75)
+         过滤掉 COSINE score < 0.75 的候选
+         无候选 → 直接 approved
+         │
+         ▼
+[Step 3] LLM 关系判断(见第五节 Prompt)
+         LLM 自主判断关系类型和 final_decision
+         │
+    ┌────┴──────────────────────────────────┐
+    ▼                                       ▼
+final_decision=rejected              final_decision=approved
+旧知识 helpful+1(记录到 history)    双向写入 relationships
+                                     更新关系缓存表
+```
+
+---
+
+## 二、同 task 下多条知识的处理策略
+
+只拒绝 `duplicate` 和 `subset`,其他关系两条都保留,并**双向写入**关系标注。引入**关系缓存表**管理关系复杂度。
+
+---
+
+## 三、关系类型定义与保存方式
+
+### 关系类型
+
+关系类型是**开放的**,LLM 可以根据实际情况提出新的关系类型,并自行判断对应的处理动作(approved/rejected)。
+
+| type | 含义                                       | 处理动作 |
+|---|------------------------------------------|---|
+| `duplicate` | task 和 content 语义完全相同                    | 新知识 **rejected**,旧知识 helpful+1 |
+| `subset` | task语义一致,新知识信息被旧知识完全覆盖                   | 新知识 **rejected** |
+| `superset` | task语义一致,新知识比旧知识更全面                      | 两条都 **approved** |
+| `conflict` | 同一 task 下结论矛盾                            | 两条都 **approved** |
+| `complement` | 同一 task 的不同角度,互补                         | 两条都 **approved** |
+| `none` | task 语义不同,或无实质关系(**task 不同时必须判定为 none**) | 新知识直接 **approved**,不写入关系 |
+| *(LLM 自定义)* | LLM 发现的其他关系类型                            | 由 LLM 自行判断 |
+
+### 关系的方向性与双向标注
+
+所有关系都是**有向的**,且**双向写入**:两条知识的 `relationships` 字段都会记录对方,但各自记录的是**从自己出发的出边**。
+
+以 A superset B 为例:
+- A 的 relationships 追加:`{type: "superset", target: "B"}` (A 包含 B)
+- B 的 relationships 追加:`{type: "subset", target: "A"}` (B 被 A 包含)
+
+以 A conflict B 为例:
+- A 的 relationships 追加:`{type: "conflict", target: "B"}`
+- B 的 relationships 追加:`{type: "conflict", target: "A"}`
+
+### 写入规则
+
+- `final_decision = "rejected"`:新知识 status=rejected,**不写入任何 relationships**;遍历 relations,对所有 type 为 `duplicate` 或 `subset` 的旧知识 helpful+1,记录到 helpful_history
+- `final_decision = "approved"`:新知识 status=approved;遍历 relations,对所有 type 不是 `none` 的关系双向写入 relationships,同时更新关系缓存表
+- `none`:不写入 relationships,不更新缓存表
+
+### 关系缓存表
+
+实现位置:`knowhub/server.py:RelationCache`
+
+独立于知识条目存储,结构如下:
+
+```json
+{
+  "conflict":    ["knowledge-A", "knowledge-B", "knowledge-C"],
+  "superset":    ["knowledge-D", "knowledge-E"],
+  "complement":  ["knowledge-F", "knowledge-G"],
+  "custom_type": ["knowledge-I"]
+}
+```
+
+每个关系类型对应一个列表,记录**所有参与该关系的知识 ID**(不区分方向)。LLM 提出新关系类型时,自动在缓存表中新增对应字段。
+
+---
+
+## 四、更新后的知识条目数据结构
+
+### 新增 2 个字段
+
+| 字段 | 类型 | 默认值 | 说明 |
+|---|---|---|---|
+| `status` | VARCHAR(20) | `"pending"` | 入库状态:pending / processing / approved / checked / rejected |
+| `relationships` | JSON | `[]` | 与其他知识的关系列表 |
+
+### status 字段语义
+
+| 值 | 含义 | 可被检索 |
+|---|---|---|
+| `pending` | 刚入队,等待处理 | 否 |
+| `processing` | 正在处理(防并发乐观锁) | 否 |
+| `approved` | 已通过去重,正式入库 | 是 |
+| `checked` | 经人类审核确认 | 是 |
+| `rejected` | 被判定为重复,已丢弃 | 否 |
+
+### relationships 字段结构
+
+每条记录代表一条**出边**(从当前知识出发的关系):
+
+```json
+[
+  {
+    "type": "superset",
+    "target": "knowledge-20260305-a1b2"
+  }
+]
+```
+
+### helpful_history / harmful_history 格式
+
+实现位置:`knowhub/server.py:KnowledgeProcessor._apply_decision`
+
+```json
+{
+  "helpful_history": [
+    {
+      "source": "dedup",
+      "related_id": "knowledge-20260317-new-xxxx",
+      "relation_type": "duplicate",
+      "timestamp": 1710000000
+    }
+  ]
+}
+```
+
+- `source: "dedup"`:标识这条反馈来自去重流程
+- `related_id`:触发这次反馈的新知识 ID(被 rejected 的那条)
+- `relation_type`:触发反馈的关系类型
+
+### 完整知识条目结构
+
+```json
+{
+  "id": "knowledge-20260317-143022-a1b2",
+  "embedding": [...],
+  "message_id": "",
+  "task": "...",
+  "content": "...",
+  "types": ["strategy"],
+  "tags": {},
+  "tag_keys": [],
+  "scopes": ["org:cybertogether"],
+  "owner": "agent:runner",
+  "resource_ids": [],
+  "source": {},
+  "eval": {
+    "score": 3,
+    "helpful": 1,
+    "harmful": 0,
+    "confidence": 0.7,
+    "helpful_history": [],
+    "harmful_history": []
+  },
+  "created_at": 1710000000,
+  "updated_at": 1710000000,
+  "status": "pending",
+  "relationships": []
+}
+```
+
+---
+
+## 五、LLM 关系判断 Prompt
+
+实现位置:`knowhub/server.py:KnowledgeProcessor._llm_judge_relations`
+
+```python
+DEDUP_RELATION_PROMPT = """你是知识库管理专家。请判断【新知识】与【相似知识列表】中每条知识的关系。
+
+【新知识】
+Task: {new_task}
+Content: {new_content}
+
+【相似知识列表】(向量召回 top-10,按相似度排序)
+{existing_list}
+格式: [序号] ID: xxx | Task: xxx | Content: xxx
+
+【已知关系类型参考】
+- duplicate: task 和 content 语义完全相同,无新增信息
+- subset: task语义一致,新知识的content信息完全被某条已有知识覆盖
+- superset: task语义一致,新知识包含某条已有知识的全部信息,且有额外内容
+- conflict: 同一 task 下给出相互矛盾的结论
+- complement: 描述同一 task 的不同方面,互补
+- none: task 语义不同,或无实质关系(task 不同时必须判定为 none,只有 task 语义一致才可能存在其他关系)
+
+**重要**:如果以上类型无法准确描述关系,你可以自定义新的关系类型(英文小写下划线命名),并自行判断新知识应该 approved 还是 rejected。
+
+【输出格式】(严格 JSON,不要其他内容)
+{{
+  "final_decision": "approved",
+  "relations": [
+    {{
+      "old_id": "knowledge-xxx",
+      "type": "superset",
+      "reverse_type": "subset"
+    }}
+  ]
+}}
+
+"""
+```
+
+### LLM 输出字段的处理逻辑
+
+实现位置:`knowhub/server.py:KnowledgeProcessor._apply_decision`
+
+**final_decision**: "approved" 或 "rejected"
+- 用途:设置新知识的 status 字段
+- 只要 relations 中有任意一条 type 为 duplicate 或 subset,LLM 应输出 rejected
+
+**relations**: 关系列表
+- **old_id**: 旧知识 ID
+  - 用途:定位需要更新的旧知识记录
+- **type**: 从新知识指向旧知识的关系类型
+  - 如果 final_decision="rejected":仅对 type="duplicate" 或 type="subset" 的旧知识 eval.helpful +1,写入 helpful_history;其余关系忽略
+  - 如果 final_decision="approved" 且 type 不是 "none":新知识的 relationships 追加 `{"type": type, "target": old_id}`,同时更新关系缓存表
+- **reverse_type**: 从旧知识指向新知识的反向关系类型
+  - 仅在 final_decision="approved" 且 reverse_type 不是 "none" 时:旧知识的 relationships 追加 `{"type": reverse_type, "target": new_id}`
+
+---
+
+## 六、异步处理架构
+
+### 整体架构
+
+```
+POST /api/knowledge
+  → 生成 embedding
+  → 插入 Milvus (status=pending)
+  → 立即返回 {"status": "pending", "knowledge_id": "..."}
+  → background_tasks.add_task(processor.process_pending)  ← 非阻塞触发
+
+KnowledgeProcessor(后台处理器)
+  → 查询所有 status=pending 的知识(每批50条)
+  → 逐条处理:pending → processing → approved/rejected
+  → asyncio.Lock 防止并发
+
+定时兜底(每60秒)
+  → asyncio.create_task(_periodic_processor())
+  → 检测超时的 processing 条目(>5分钟)并回滚到 pending
+```
+
+### 错误处理策略
+
+| 场景 | 处理方式 |
+|---|---|
+| LLM 调用失败 | 重试 2 次,仍失败则 status=approved(宁可放行,不丢数据) |
+| LLM 输出无法解析 | 同上,fallback 到 approved |
+| 处理超时(>5分钟) | 定时任务检测 processing 状态并回滚到 pending |
+| 并发写入相同知识 | processing 状态作为乐观锁,第二个处理器跳过 |
+| task 语义不相关(score < 0.75) | 预过滤直接排除,不进入 LLM 判断,视为 none |
+
+---
+
+## 七、API 接口设计
+
+### 新增/改造接口
+
+| 接口 | 变化 |
+|---|---|
+| `POST /api/knowledge` | 插入 status=pending,触发后台任务,立即返回 pending 状态 |
+| `POST /api/extract` | 批量插入时每条 status=pending,插入后触发后台任务 |
+| `POST /api/knowledge/slim` | 重建知识时显式传入 status=approved,跳过去重(已精炼知识) |
+| `GET /api/knowledge` | 追加 `status in ["approved", "checked"]` 过滤 |
+| `GET /api/knowledge/search` | 追加 `status in ["approved", "checked"]` 过滤 |
+| `POST /api/knowledge/migrate` | **新增**:手动触发 schema 迁移(中转 collection 模式),返回迁移条数 |
+| `GET /api/knowledge/pending` | **新增**:查询待处理队列 |
+| `POST /api/knowledge/process` | **新增**:手动触发处理,`force=true` 可回滚卡死的 processing 条目 |
+| `GET /api/knowledge/status/{id}` | **新增**:查询单条知识的处理状态和关系 |
+
+### POST /api/knowledge 响应变化
+
+```json
+{
+  "status": "pending",
+  "knowledge_id": "knowledge-20260317-143022-a1b2",
+  "message": "知识已入队,正在处理去重..."
+}
+```
+
+### 迁移脚本处理
+
+`migrate_knowledge.py`:历史数据迁移,迁移的是已存在的知识,插入时显式传入 `status="approved"`,`relationships=[]`,跳过去重流程。
+
+---
+
+## 八、Milvus 关系筛选可行性
+
+### 可行的查询
+
+```python
+# status 过滤(高效,建议加 Trie 索引)
+'status == "approved"'
+'status == "pending" or status == "processing"'
+
+# relationships 非空(Milvus 2.3+ JSON 查询)
+'json_length(relationships) > 0'
+```
+
+### 关系查询方案
+
+**正向查询**(从知识 A 查询它的所有关系):直接读取 A 的 `relationships` 字段,O(1)。
+
+**反向查询**(查询"哪些知识与 A 有 conflict 关系"):通过**关系缓存表**实现,无需全表扫描。
+
+**复杂查询**(查询"所有存在 conflict 关系的知识对"):直接读取关系缓存表的 `conflict` 字段。
+
+### 性能评估
+
+| 查询类型 | 方案 | 性能 |
+|---|---|---|
+| status 过滤 | Milvus Trie 索引 | 极快 |
+| 向量召回 + status 过滤 | HNSW + 标量过滤 | 快(现有机制) |
+| relationships 正向读取 | 直接读 JSON 字段 | O(1) |
+| relationships 反向/复杂查询 | 关系缓存表 | O(1) |
+
+---
+
+## 九、实现步骤与文件清单
+
+### 关键文件修改清单
+
+| 文件 | 修改内容 |
+| --- | --- |
+| `knowhub/vector_store.py` | 新增 status/relationships 字段;更新所有 output_fields;为 status 添加 Trie 索引 |
+| `knowhub/server.py` | 新增 `KnowledgeProcessor` 类(~200行);改造 `save_knowledge` / `extract_knowledge_from_messages`;改造 list/search 追加 status 过滤;新增 3 个接口;更新 `KnowledgeIn` 模型;实现关系缓存表管理 |
+| `migrate_knowledge.py` | 插入时显式传入 `status="approved"`,`relationships=[]` |
+
+### 实现阶段
+
+**Phase 1 — Schema 扩展**(`knowhub/vector_store.py`)
+1. 新增 2 个字段:status、relationships
+2. 更新 search/query/get_by_id 的 output_fields
+3. 为 status 添加 Trie 标量索引
+4. 初始化关系缓存表存储
+
+**Phase 2 — 处理器核心逻辑**(`knowhub/server.py`)
+1. 实现 `KnowledgeProcessor` 类
+2. 实现 `_llm_judge_relations` 方法(使用上面的 Prompt)
+3. 实现 `_apply_decision` 方法(写入 status 和 relationships,同步更新关系缓存表)
+4. 在 `lifespan` 中初始化处理器实例 + 启动定时任务
+5. 实现关系缓存表的读写接口
+
+**Phase 3 — API 改造**(`knowhub/server.py`)
+1. 改造 `POST /api/knowledge`:status=pending,触发后台任务
+2. 改造 `GET /api/knowledge` 和 `GET /api/knowledge/search`:追加 status 过滤
+3. 新增 3 个接口:pending / process / status/{id}
+
+### 数据迁移方案
+
+Milvus Lite 不支持 ALTER COLLECTION 和 rename_collection,采用**软兼容 + 手动触发迁移接口**策略:
+
+- **平时(软兼容)**:读取时用 `.get("status", "approved")` / `.get("relationships", []) or []` 兼容旧数据,旧数据被视为 approved,不影响检索和去重逻辑
+- **迁移(手动触发 `POST /api/knowledge/migrate`)**:采用"中转 collection"模式(Milvus Lite 不支持 rename):
+  1. 创建 `knowledge_migration`(新 schema)
+  2. 从 `knowledge` 逐条读取,补 `status="approved"`, `relationships=[]`,插入 `knowledge_migration`
+  3. drop `knowledge`
+  4. 创建 `knowledge`(新 schema,空)
+  5. 从 `knowledge_migration` 逐条读取,插入 `knowledge`
+  6. drop `knowledge_migration`
+  7. 更新 `self.collection` 引用
+
+  实现位置:`knowhub/vector_store.py:MilvusStore.migrate_schema`

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

@@ -0,0 +1,269 @@
+# 知识反馈时机设计文档
+
+## 文档维护规范
+
+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` |

+ 178 - 10
knowhub/server.py

@@ -308,6 +308,18 @@ class KnowledgeBatchUpdateIn(BaseModel):
     feedback_list: list[dict]
     feedback_list: list[dict]
 
 
 
 
+
+class KnowledgeVerifyIn(BaseModel):
+    action: str  # "approve" | "reject"
+    verified_by: str = "user"
+
+
+class KnowledgeBatchVerifyIn(BaseModel):
+    knowledge_ids: List[str]
+    action: str  # "approve"
+    verified_by: str
+
+
 class KnowledgeSearchResponse(BaseModel):
 class KnowledgeSearchResponse(BaseModel):
     results: list[dict]
     results: list[dict]
     count: int
     count: int
@@ -542,12 +554,14 @@ class KnowledgeProcessor:
             final_decision = "rejected"
             final_decision = "rejected"
 
 
         if final_decision == "rejected":
         if final_decision == "rejected":
-            milvus_store.update(kid, {"status": "rejected", "updated_at": now})
+            # 记录 rejected 知识的关系(便于溯源为什么被拒绝)
+            rejected_relationships = []
             for rel in relations:
             for rel in relations:
-                if rel.get("type") in ("duplicate", "subset"):
-                    old_id = rel.get("old_id")
-                    if not old_id:
-                        continue
+                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})
+                if rel_type in ("duplicate", "subset") and old_id:
                     try:
                     try:
                         old = milvus_store.get_by_id(old_id)
                         old = milvus_store.get_by_id(old_id)
                         if not old:
                         if not old:
@@ -558,13 +572,14 @@ class KnowledgeProcessor:
                         helpful_history.append({
                         helpful_history.append({
                             "source": "dedup",
                             "source": "dedup",
                             "related_id": kid,
                             "related_id": kid,
-                            "relation_type": rel["type"],
+                            "relation_type": rel_type,
                             "timestamp": now
                             "timestamp": now
                         })
                         })
                         eval_data["helpful_history"] = helpful_history
                         eval_data["helpful_history"] = helpful_history
                         milvus_store.update(old_id, {"eval": eval_data, "updated_at": now})
                         milvus_store.update(old_id, {"eval": eval_data, "updated_at": now})
                     except Exception as e:
                     except Exception as e:
                         print(f"[Apply Decision] 更新旧知识 {old_id} helpful 失败: {e}")
                         print(f"[Apply Decision] 更新旧知识 {old_id} helpful 失败: {e}")
+            milvus_store.update(kid, {"status": "rejected", "relationships": json.dumps(rejected_relationships), "updated_at": now})
         else:
         else:
             new_relationships = []
             new_relationships = []
             for rel in relations:
             for rel in relations:
@@ -1408,6 +1423,77 @@ def batch_delete_knowledge(knowledge_ids: List[str] = Body(...)):
         raise HTTPException(status_code=500, detail=str(e))
         raise HTTPException(status_code=500, detail=str(e))
 
 
 
 
+@app.post("/api/knowledge/batch_verify")
+async def batch_verify_knowledge(batch: KnowledgeBatchVerifyIn):
+    """批量验证通过(approved → checked)"""
+    if not batch.knowledge_ids:
+        return {"status": "ok", "updated": 0}
+
+    try:
+        now_iso = datetime.now(timezone.utc).isoformat()
+        updated_count = 0
+
+        for kid in batch.knowledge_ids:
+            existing = milvus_store.get_by_id(kid)
+            if not existing:
+                continue
+
+            eval_data = existing.get("eval") or {}
+            eval_data["verification"] = {
+                "status": "checked",
+                "verified_by": batch.verified_by,
+                "verified_at": now_iso,
+                "note": None,
+                "issue_type": None,
+                "issue_action": None,
+            }
+            milvus_store.update(kid, {"eval": eval_data, "status": "checked", "updated_at": int(time.time())})
+            updated_count += 1
+
+        return {"status": "ok", "updated": updated_count}
+
+    except Exception as e:
+        print(f"[Batch Verify] 错误: {e}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@app.post("/api/knowledge/{knowledge_id}/verify")
+async def verify_knowledge(knowledge_id: str, verify: KnowledgeVerifyIn):
+    """知识验证:approve 切换 approved↔checked,reject 设为 rejected"""
+    try:
+        existing = milvus_store.get_by_id(knowledge_id)
+        if not existing:
+            raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
+
+        current_status = existing.get("status", "approved")
+
+        if verify.action == "approve":
+            # checked → approved(取消验证),其他 → checked
+            new_status = "approved" if current_status == "checked" else "checked"
+            milvus_store.update(knowledge_id, {
+                "status": new_status,
+                "updated_at": int(time.time())
+            })
+            return {"status": "ok", "new_status": new_status,
+                    "message": "已取消验证" if new_status == "approved" else "验证通过"}
+
+        elif verify.action == "reject":
+            milvus_store.update(knowledge_id, {
+                "status": "rejected",
+                "updated_at": int(time.time())
+            })
+            return {"status": "ok", "new_status": "rejected", "message": "已拒绝"}
+
+        else:
+            raise HTTPException(status_code=400, detail=f"Unknown action: {verify.action}")
+
+    except HTTPException:
+        raise
+    except Exception as e:
+        print(f"[Verify Knowledge] 错误: {e}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+
 @app.post("/api/knowledge/batch_update")
 @app.post("/api/knowledge/batch_update")
 async def batch_update_knowledge(batch: KnowledgeBatchUpdateIn):
 async def batch_update_knowledge(batch: KnowledgeBatchUpdateIn):
     """批量反馈知识有效性"""
     """批量反馈知识有效性"""
@@ -1853,6 +1939,9 @@ def frontend():
                 <button onclick="batchDelete()" id="batchDeleteBtn" class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed" disabled>
                 <button onclick="batchDelete()" id="batchDeleteBtn" class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed" disabled>
                     删除选中 (<span id="selectedCount">0</span>)
                     删除选中 (<span id="selectedCount">0</span>)
                 </button>
                 </button>
+                <button onclick="batchVerify()" id="batchVerifyBtn" class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed" disabled>
+                    ✓ 批量验证通过 (<span id="verifyCount">0</span>)
+                </button>
                 <button onclick="openAddModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg">
                 <button onclick="openAddModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg">
                     + 新增知识
                     + 新增知识
                 </button>
                 </button>
@@ -2206,6 +2295,16 @@ def frontend():
                             <span>${new Date(k.created_at).toLocaleDateString()}</span>
                             <span>${new Date(k.created_at).toLocaleDateString()}</span>
                         </div>
                         </div>
                     </div>
                     </div>
+                    <div class="flex gap-2 mt-3 pt-3 border-t border-gray-100">
+                        <button onclick="event.stopPropagation(); verifyKnowledge('${k.id}', 'approve', this)"
+                                class="${k.status === 'checked' ? 'bg-gray-300 hover:bg-gray-400 text-gray-700' : 'bg-green-400 hover:bg-green-500 text-white'} text-xs px-3 py-1 rounded transition-colors">
+                            ${k.status === 'checked' ? '取消验证' : '✓ 验证通过'}
+                        </button>
+                        <button onclick="event.stopPropagation(); verifyKnowledge('${k.id}', 'reject', this)"
+                                class="bg-red-400 hover:bg-red-500 text-white text-xs px-3 py-1 rounded transition-colors">
+                            ✗ 拒绝
+                        </button>
+                    </div>
                 </div>
                 </div>
             `;
             `;
             }).join('');
             }).join('');
@@ -2236,7 +2335,9 @@ def frontend():
         function updateBatchDeleteButton() {
         function updateBatchDeleteButton() {
             const count = selectedIds.size;
             const count = selectedIds.size;
             document.getElementById('selectedCount').textContent = count;
             document.getElementById('selectedCount').textContent = count;
+            document.getElementById('verifyCount').textContent = count;
             document.getElementById('batchDeleteBtn').disabled = count === 0;
             document.getElementById('batchDeleteBtn').disabled = count === 0;
+            document.getElementById('batchVerifyBtn').disabled = count === 0;
             document.getElementById('selectAllBtn').textContent =
             document.getElementById('selectAllBtn').textContent =
                 selectedIds.size === allKnowledge.length ? '取消全选' : '全选';
                 selectedIds.size === allKnowledge.length ? '取消全选' : '全选';
         }
         }
@@ -2287,8 +2388,15 @@ def frontend():
         }
         }
 
 
         async function openEditModal(id) {
         async function openEditModal(id) {
-            const k = allKnowledge.find(item => item.id === id);
-            if (!k) return;
+            let k = allKnowledge.find(item => item.id === id);
+            if (!k) {
+                // 当前列表中找不到(可能是 rejected/其他状态),通过 API 单独获取
+                try {
+                    const res = await fetch('/api/knowledge/' + encodeURIComponent(id));
+                    if (!res.ok) { alert('知识未找到: ' + id); return; }
+                    k = await res.json();
+                } catch (e) { alert('获取知识失败: ' + e.message); return; }
+            }
 
 
             document.getElementById('modalTitle').textContent = '编辑知识';
             document.getElementById('modalTitle').textContent = '编辑知识';
             document.getElementById('editId').value = k.id;
             document.getElementById('editId').value = k.id;
@@ -2308,8 +2416,13 @@ def frontend():
                 el.checked = types.includes(el.value);
                 el.checked = types.includes(el.value);
             });
             });
 
 
-            // 填充 relationships
-            const rels = Array.isArray(k.relationships) ? k.relationships : [];
+            // 填充 relationships(可能是 JSON 字符串或数组)
+            let rels = [];
+            if (Array.isArray(k.relationships)) {
+                rels = k.relationships;
+            } else if (typeof k.relationships === 'string' && k.relationships.startsWith('[')) {
+                try { rels = JSON.parse(k.relationships); } catch(e) {}
+            }
             const section = document.getElementById('relationshipsSection');
             const section = document.getElementById('relationshipsSection');
             if (rels.length > 0) {
             if (rels.length > 0) {
                 const typeColor = {
                 const typeColor = {
@@ -2387,6 +2500,61 @@ def frontend():
             await loadKnowledge();
             await loadKnowledge();
         });
         });
 
 
+        async function verifyKnowledge(id, action, btn) {
+            if (btn) {
+                btn.disabled = true;
+                btn._origText = btn.textContent;
+                btn.textContent = '处理中...';
+            }
+            try {
+                const res = await fetch('/api/knowledge/' + id + '/verify', {
+                    method: 'POST',
+                    headers: {'Content-Type': 'application/json'},
+                    body: JSON.stringify({ action })
+                });
+                if (!res.ok) throw new Error('请求失败: ' + res.status);
+                if (isSearchMode) {
+                    clearSearch();
+                } else {
+                    loadKnowledge(currentPage);
+                }
+            } catch (error) {
+                console.error('验证错误:', error);
+                alert('操作失败: ' + error.message);
+                if (btn) {
+                    btn.disabled = false;
+                    btn.textContent = btn._origText;
+                }
+            }
+        }
+
+        async function batchVerify() {
+            if (selectedIds.size === 0) return;
+            if (!confirm(`确定要批量验证通过选中的 ${selectedIds.size} 条知识吗?`)) return;
+            const btn = document.getElementById('batchVerifyBtn');
+            if (btn) { btn.disabled = true; btn.textContent = `处理中...`; }
+            try {
+                const ids = Array.from(selectedIds);
+                const res = await fetch('/api/knowledge/batch_verify', {
+                    method: 'POST',
+                    headers: {'Content-Type': 'application/json'},
+                    body: JSON.stringify({ knowledge_ids: ids, action: 'approve', verified_by: 'user' })
+                });
+                if (!res.ok) throw new Error('请求失败: ' + res.status);
+                selectedIds.clear();
+                updateBatchDeleteButton();
+                if (isSearchMode) {
+                    clearSearch();
+                } else {
+                    loadKnowledge(currentPage);
+                }
+            } catch (error) {
+                console.error('批量验证错误:', error);
+                alert('验证失败: ' + error.message);
+                if (btn) { btn.disabled = false; updateBatchDeleteButton(); }
+            }
+        }
+
         function escapeHtml(text) {
         function escapeHtml(text) {
             const div = document.createElement('div');
             const div = document.createElement('div');
             div.textContent = text;
             div.textContent = text;