Browse Source

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

guantao 2 days ago
parent
commit
1911c34cc8
40 changed files with 3847 additions and 734 deletions
  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-wal
 examples/archive/*
-examples/research
+examples/research/
 
 # Milvus data
 knowhub/milvus_data/

+ 211 - 4
agent/core/runner.py

@@ -67,8 +67,8 @@ class ContextUsage:
 
 @dataclass
 class SideBranchContext:
-    """侧分支上下文(压缩/反思)"""
-    type: Literal["compression", "reflection"]
+    """侧分支上下文(压缩/反思/知识评估)"""
+    type: Literal["compression", "reflection", "knowledge_eval"]
     branch_id: str
     start_head_seq: int          # 侧分支起点的 head_seq
     start_sequence: int          # 侧分支第一条消息的 sequence
@@ -783,6 +783,28 @@ class AgentRunner:
         if not needs_compression:
             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 做反思(进入反思侧分支)
         if config.knowledge.enable_extraction and not config.force_side_branch:
             # 设置侧分支队列:先反思,再压缩
@@ -846,6 +868,74 @@ class AgentRunner:
 
         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(
         self,
         trace_id: str,
@@ -1052,6 +1142,17 @@ class AgentRunner:
                         yield trace_obj
                 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 管理(仅主路径)
             needs_enter_side_branch = False
             if not side_branch_ctx:
@@ -1071,9 +1172,15 @@ class AgentRunner:
 
             # 进入侧分支
             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:
-                    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}")
                 elif config.knowledge.enable_extraction:
                     # 兼容旧的单值模式(如果 force_side_branch 是字符串)
@@ -1096,6 +1203,9 @@ class AgentRunner:
 
                 # 持久化侧分支状态
                 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"] = {
                         "type": side_branch_ctx.type,
                         "branch_id": side_branch_ctx.branch_id,
@@ -1105,6 +1215,13 @@ class AgentRunner:
                         "max_turns": side_branch_ctx.max_turns,
                         "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(
                         trace_id,
                         context=trace.context
@@ -1113,6 +1230,8 @@ class AgentRunner:
                 # 追加侧分支 prompt
                 if branch_type == "reflection":
                     prompt = config.knowledge.get_reflect_prompt()
+                elif branch_type == "knowledge_eval":
+                    prompt = await self._build_knowledge_eval_prompt(trace_id, goal_tree)
                 else:  # compression
                     from agent.trace.compaction import build_compression_prompt
                     prompt = build_compression_prompt(goal_tree)
@@ -1249,6 +1368,48 @@ class AgentRunner:
                     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(已持久化,不需要额外维护)
 
             yield assistant_msg
@@ -1386,6 +1547,30 @@ class AgentRunner:
                     side_branch_ctx = None
                     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 截断,
             # tool call 参数很可能不完整,不应执行,改为提示模型分批操作
@@ -1451,6 +1636,13 @@ class AgentRunner:
                     args_display = args_str[:100] + "..." if len(args_str) > 100 else args_str
                     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_name,
                         tool_args,
@@ -1462,12 +1654,14 @@ class AgentRunner:
                             "runner": self,
                             "goal_tree": goal_tree,
                             "knowledge_config": config.knowledge,
+                            "sequence": sequence,  # 添加sequence用于知识注入记录
                             # 新增:侧分支信息
                             "side_branch": {
                                 "type": side_branch_ctx.type,
                                 "branch_id": side_branch_ctx.branch_id,
                                 "is_side_branch": True,
                                 "max_turns": side_branch_ctx.max_turns,
+                                "trigger_event": trigger_event_for_tool,
                             } 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:
                 config.force_side_branch = ["reflection"]
                 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登录完成后请告诉我,我会保存登录状态。谢谢!"
       }
     ]
+  },
+  {
+    "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 的查询接口
 """
 
+import os
+import json
+import httpx
+from datetime import datetime, timezone
 from typing import List, Optional, Dict, Any
 from fastapi import APIRouter, HTTPException, Query
 from pydantic import BaseModel
+from agent.llm.openrouter import openrouter_llm_call
 
 from .protocols import TraceStore
 
@@ -171,3 +176,158 @@ async def get_messages(
     return MessagesResponse(
         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,
     trace_id: Optional[str] = None,
     knowledge_config: Optional[dict] = None,
+    sequence: Optional[int] = None,
 ) -> Optional[str]:
     """
     为指定 goal 注入相关知识。
@@ -34,6 +35,7 @@ async def inject_knowledge_for_goal(
         store: TraceStore(用于持久化)
         trace_id: Trace ID
         knowledge_config: 知识管理配置(KnowledgeConfig 对象)
+        sequence: 当前消息序列号(用于记录注入时机)
 
     Returns:
         注入结果描述(如 "📚 已注入 3 条相关知识"),无结果返回 None
@@ -74,6 +76,19 @@ async def inject_knowledge_for_goal(
             if store and trace_id:
                 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} 条相关知识"
         else:
             goal.knowledge = []
@@ -136,7 +151,8 @@ async def goal(
         done=done,
         abandon=abandon,
         focus=focus,
-        knowledge_config=knowledge_config
+        knowledge_config=knowledge_config,
+        context=context
     )
 
 
@@ -155,6 +171,7 @@ async def goal_tool(
     abandon: Optional[str] = None,
     focus: Optional[str] = None,
     knowledge_config: Optional[object] = None,
+    context: Optional[dict] = None,
 ) -> str:
     """
     管理执行计划。
@@ -213,7 +230,9 @@ async def goal_tool(
         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:
             changes.append(inject_msg)
 

+ 2 - 2
agent/trace/models.py

@@ -178,7 +178,7 @@ class Message:
     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(同一侧分支的消息共享)
 
     # 元数据
@@ -316,7 +316,7 @@ class Message:
         content: Any = None,
         tool_call_id: Optional[str] = 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,
         prompt_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)}")
 
+        # 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(
         self,
         trace_id: str,
@@ -750,3 +764,105 @@ class FileSystemTraceStore:
             f.write(json.dumps(event, ensure_ascii=False) + '\n')
 
         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 json
 import os
+from dotenv import load_dotenv
+load_dotenv()
 from fastapi import FastAPI, Request, WebSocket
 from fastapi.middleware.cors import CORSMiddleware
 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": {
     "@douyinfe/semi-icons": "^2.56.0",
-    "@douyinfe/semi-ui": "^2.56.0",
+    "@douyinfe/semi-ui": "^2.92.2",
     "axios": "^1.6.0",
     "d3": "^7.8.5",
     "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 { Edge, Message } from "./types/message";
+import type { FlowChartRef } from "./components/FlowChart/FlowChart";
 import { useFlowChartData } from "./components/FlowChart/hooks/useFlowChartData";
 import "./styles/global.css";
 
@@ -19,6 +20,7 @@ function App() {
   const [refreshTrigger, setRefreshTrigger] = useState(0);
   const [messageRefreshTrigger, setMessageRefreshTrigger] = useState(0);
   const bodyRef = useRef<HTMLDivElement | null>(null);
+  const flowChartRef = useRef<FlowChartRef>(null);
 
   // 获取数据以传递给 DetailPanel
   const { msgGroups } = useFlowChartData(selectedTraceId, messageRefreshTrigger);
@@ -33,6 +35,21 @@ function App() {
     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;
 
   // 根据选中的节点获取对应的消息
@@ -84,6 +101,7 @@ function App() {
           }}
           onTraceCreated={() => setRefreshTrigger((t) => t + 1)}
           onMessageInserted={() => setMessageRefreshTrigger((t) => t + 1)}
+          onNavigateToMessage={handleNavigateToMessage}
         />
       </div>
       <div
@@ -101,6 +119,7 @@ function App() {
             }}
             refreshTrigger={refreshTrigger}
             messageRefreshTrigger={messageRefreshTrigger}
+            flowChartRef={flowChartRef}
           />
         </div>
         {(selectedNode || selectedEdge) && (
@@ -123,6 +142,7 @@ function App() {
                 edge={selectedEdge}
                 messages={selectedMessages as Message[]}
                 onClose={handleCloseDetail}
+                traceId={selectedTraceId ?? undefined}
               />
             </div>
           </>

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

@@ -1,5 +1,20 @@
 import { request } from "./client";
 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 = {
   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 {
   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 { ImagePreviewModal } from "../ImagePreview/ImagePreviewModal";
 import { extractImagesFromMessage } from "../../utils/imageExtraction";
+import { CreateKnowledgeModal } from "../CreateKnowledgeModal/CreateKnowledgeModal";
 
 interface DetailPanelProps {
   node: Goal | Message | null;
   edge: Edge | null;
   messages?: Message[];
   onClose: () => void;
+  traceId?: string;
 }
 
 export const DetailPanel = ({
@@ -18,9 +20,11 @@ export const DetailPanel = ({
   edge,
   messages = [],
   onClose,
+  traceId,
 }: DetailPanelProps) => {
   const [previewImage, setPreviewImage] = useState<string | null>(null);
   const [expandedReasoning, setExpandedReasoning] = useState<Set<string>>(new Set());
+  const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
 
   console.log("DetailPanel - node:", node);
   console.log("DetailPanel - edge:", edge);
@@ -188,6 +192,48 @@ export const DetailPanel = ({
   const isMessageNode = (node: Goal | Message): node is Message =>
     "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"]) => {
     if (!knowledge || knowledge.length === 0) return null;
 
@@ -355,6 +401,25 @@ export const DetailPanel = ({
         onClose={() => setPreviewImage(null)}
         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>
   );
 };

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

@@ -18,6 +18,7 @@ interface FlowChartProps {
 export interface FlowChartRef {
   expandAll: () => void;
   collapseAll: () => void;
+  scrollToSequence: (sequence: number) => void;
 }
 
 export type SubTraceEntry = { id: string; mission?: string };
@@ -662,10 +663,6 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
     return ids;
   }, [displayGoals]);
 
-  useImperativeHandle(ref, () => ({
-    expandAll: () => setCollapsedGoals(new Set()),
-    collapseAll: () => setCollapsedGoals(new Set(allGoalIds)),
-  }), [allGoalIds]);
 
   const visibleData = layoutData;
 
@@ -703,6 +700,33 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
     };
   }, [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 = () => {
     if (viewMode === "scroll") {
       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 type { FC } from "react";
+import type { FC, RefObject } from "react";
 import { Select } from "@douyinfe/semi-ui";
 import { FlowChart } from "../FlowChart/FlowChart";
 import type { FlowChartRef } from "../FlowChart/FlowChart";
@@ -16,6 +16,7 @@ interface MainContentProps {
   onTraceChange?: (traceId: string, title?: string) => void;
   refreshTrigger?: number;
   messageRefreshTrigger?: number;
+  flowChartRef?: RefObject<FlowChartRef>;
 }
 
 interface ConnectionStatusProps {
@@ -40,8 +41,10 @@ export const MainContent: FC<MainContentProps> = ({
   onTraceChange,
   refreshTrigger,
   messageRefreshTrigger,
+  flowChartRef: externalFlowChartRef,
 }) => {
-  const flowChartRef = useRef<FlowChartRef>(null);
+  const internalFlowChartRef = useRef<FlowChartRef>(null);
+  const flowChartRef = externalFlowChartRef ?? internalFlowChartRef;
   const [isAllExpanded, setIsAllExpanded] = useState(true);
   const [traceList, setTraceList] = useState<TraceListItem[]>([]);
   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 { Message } from "../../types/message";
 import { AgentControlPanel } from "../AgentControlPanel/AgentControlPanel";
+import { KnowledgeFeedbackModal } from "../KnowledgeFeedbackModal/KnowledgeFeedbackModal";
 import styles from "./TopBar.module.css";
 
 interface TopBarProps {
@@ -16,6 +17,7 @@ interface TopBarProps {
   onTraceSelect: (traceId: string, title?: string) => void;
   onTraceCreated?: () => void;
   onMessageInserted?: () => void;
+  onNavigateToMessage?: (goalId: string, sequence: number) => void;
 }
 
 export const TopBar: FC<TopBarProps> = ({
@@ -25,6 +27,7 @@ export const TopBar: FC<TopBarProps> = ({
   onTraceSelect,
   onTraceCreated,
   onMessageInserted,
+  onNavigateToMessage,
 }) => {
   const [isModalVisible, setIsModalVisible] = useState(false);
   const [isInsertModalVisible, setIsInsertModalVisible] = useState(false);
@@ -37,6 +40,10 @@ export const TopBar: FC<TopBarProps> = ({
   const [isUploading, setIsUploading] = 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<{
     getValues: () => { system_prompt?: string; user_prompt?: string; example_project?: string };
@@ -80,6 +87,29 @@ export const TopBar: FC<TopBarProps> = ({
     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 () => {
     setIsModalVisible(true);
     // 加载 example 项目列表
@@ -346,6 +376,20 @@ export const TopBar: FC<TopBarProps> = ({
           >
             经验
           </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' }}>
             {isUploading ? "上传中..." : "📤 导入"}
             <input
@@ -458,6 +502,16 @@ export const TopBar: FC<TopBarProps> = ({
             {experienceContent ? <ReactMarkdown>{experienceContent}</ReactMarkdown> : "暂无经验数据"}
           </div>
         </Modal>
+        <KnowledgeFeedbackModal
+          visible={isKnowledgeFeedbackVisible}
+          traceId={selectedTraceId || ""}
+          onClose={() => setIsKnowledgeFeedbackVisible(false)}
+          onSubmitted={() => {
+            setFeedbackSubmitted(true);
+            setPendingFeedbackCount(0);
+          }}
+          onNavigateToMessage={onNavigateToMessage}
+        />
       </header>
       {
         isControlPanelVisible && (

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

@@ -46,3 +46,36 @@ export interface BranchContext {
   created_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]
 
 
+
+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):
     results: list[dict]
     count: int
@@ -542,12 +554,14 @@ class KnowledgeProcessor:
             final_decision = "rejected"
 
         if final_decision == "rejected":
-            milvus_store.update(kid, {"status": "rejected", "updated_at": now})
+            # 记录 rejected 知识的关系(便于溯源为什么被拒绝)
+            rejected_relationships = []
             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:
                         old = milvus_store.get_by_id(old_id)
                         if not old:
@@ -558,13 +572,14 @@ class KnowledgeProcessor:
                         helpful_history.append({
                             "source": "dedup",
                             "related_id": kid,
-                            "relation_type": rel["type"],
+                            "relation_type": rel_type,
                             "timestamp": now
                         })
                         eval_data["helpful_history"] = helpful_history
                         milvus_store.update(old_id, {"eval": eval_data, "updated_at": now})
                     except Exception as e:
                         print(f"[Apply Decision] 更新旧知识 {old_id} helpful 失败: {e}")
+            milvus_store.update(kid, {"status": "rejected", "relationships": json.dumps(rejected_relationships), "updated_at": now})
         else:
             new_relationships = []
             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))
 
 
+@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")
 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>
                     删除选中 (<span id="selectedCount">0</span>)
                 </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>
@@ -2206,6 +2295,16 @@ def frontend():
                             <span>${new Date(k.created_at).toLocaleDateString()}</span>
                         </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>
             `;
             }).join('');
@@ -2236,7 +2335,9 @@ def frontend():
         function updateBatchDeleteButton() {
             const count = selectedIds.size;
             document.getElementById('selectedCount').textContent = count;
+            document.getElementById('verifyCount').textContent = count;
             document.getElementById('batchDeleteBtn').disabled = count === 0;
+            document.getElementById('batchVerifyBtn').disabled = count === 0;
             document.getElementById('selectAllBtn').textContent =
                 selectedIds.size === allKnowledge.length ? '取消全选' : '全选';
         }
@@ -2287,8 +2388,15 @@ def frontend():
         }
 
         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('editId').value = k.id;
@@ -2308,8 +2416,13 @@ def frontend():
                 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');
             if (rels.length > 0) {
                 const typeColor = {
@@ -2387,6 +2500,61 @@ def frontend():
             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) {
             const div = document.createElement('div');
             div.textContent = text;