api.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. """
  2. Trace RESTful API
  3. 提供 Trace、GoalTree、Message 的查询接口
  4. """
  5. import os
  6. import json
  7. import httpx
  8. from datetime import datetime, timezone
  9. from typing import List, Optional, Dict, Any
  10. from fastapi import APIRouter, HTTPException, Query
  11. from pydantic import BaseModel
  12. from cyber_agent.llm.openrouter import openrouter_llm_call
  13. from cyber_agent.core.agent_mode import (
  14. RECURSIVE_REVISION_READ_ONLY_ERROR,
  15. require_mutable_trace_policy,
  16. )
  17. from .protocols import TraceStore
  18. router = APIRouter(prefix="/api/traces", tags=["traces"])
  19. # ===== Response 模型 =====
  20. class TraceListResponse(BaseModel):
  21. """Trace 列表响应"""
  22. traces: List[Dict[str, Any]]
  23. class TraceDetailResponse(BaseModel):
  24. """Trace 详情响应(包含 GoalTree 和 Sub-Traces 元数据)"""
  25. trace: Dict[str, Any]
  26. goal_tree: Optional[Dict[str, Any]] = None
  27. sub_traces: Dict[str, Dict[str, Any]] = {}
  28. class MessagesResponse(BaseModel):
  29. """Messages 响应"""
  30. messages: List[Dict[str, Any]]
  31. # ===== 全局 TraceStore(由 api_server.py 注入)=====
  32. _trace_store: Optional[TraceStore] = None
  33. def set_trace_store(store: TraceStore):
  34. """设置 TraceStore 实例"""
  35. global _trace_store
  36. _trace_store = store
  37. def get_trace_store() -> TraceStore:
  38. """获取 TraceStore 实例"""
  39. if _trace_store is None:
  40. raise RuntimeError("TraceStore not initialized")
  41. return _trace_store
  42. def _require_mutable_trace(trace) -> None:
  43. try:
  44. require_mutable_trace_policy(trace.context)
  45. except ValueError as exc:
  46. if str(exc) == RECURSIVE_REVISION_READ_ONLY_ERROR:
  47. raise HTTPException(status_code=409, detail=str(exc)) from exc
  48. raise
  49. # ===== 路由 =====
  50. @router.get("", response_model=TraceListResponse)
  51. async def list_traces(
  52. mode: Optional[str] = None,
  53. agent_type: Optional[str] = None,
  54. uid: Optional[str] = None,
  55. status: Optional[str] = None,
  56. limit: int = Query(20, le=100)
  57. ):
  58. """
  59. 列出 Traces
  60. Args:
  61. mode: 模式过滤(call/agent)
  62. agent_type: Agent 类型过滤
  63. uid: 用户 ID 过滤
  64. status: 状态过滤(running/completed/failed)
  65. limit: 最大返回数量
  66. """
  67. store = get_trace_store()
  68. traces = await store.list_traces(
  69. mode=mode,
  70. agent_type=agent_type,
  71. uid=uid,
  72. status=status,
  73. limit=limit
  74. )
  75. return TraceListResponse(
  76. traces=[t.to_dict() for t in traces]
  77. )
  78. @router.get("/{trace_id}", response_model=TraceDetailResponse)
  79. async def get_trace(trace_id: str):
  80. """
  81. 获取 Trace 详情
  82. 返回 Trace 元数据、GoalTree、Sub-Traces 元数据(不含 Sub-Trace 内 GoalTree)
  83. Args:
  84. trace_id: Trace ID
  85. """
  86. store = get_trace_store()
  87. # 获取 Trace
  88. trace = await store.get_trace(trace_id)
  89. if not trace:
  90. raise HTTPException(status_code=404, detail="Trace not found")
  91. # 获取 GoalTree
  92. goal_tree = await store.get_goal_tree(trace_id)
  93. # 获取所有 Sub-Traces(通过 parent_trace_id 查询)
  94. sub_traces = {}
  95. all_traces = await store.list_traces(limit=1000) # 获取所有 traces
  96. for t in all_traces:
  97. if t.parent_trace_id == trace_id:
  98. sub_traces[t.trace_id] = t.to_dict()
  99. return TraceDetailResponse(
  100. trace=trace.to_dict(),
  101. goal_tree=goal_tree.to_dict() if goal_tree else None,
  102. sub_traces=sub_traces
  103. )
  104. @router.get("/{trace_id}/messages", response_model=MessagesResponse)
  105. async def get_messages(
  106. trace_id: str,
  107. mode: str = Query("main_path", description="查询模式:main_path(当前主路径消息)或 all(全部消息含所有分支)"),
  108. head: Optional[int] = Query(None, description="主路径的 head sequence(仅 mode=main_path 有效,默认用 trace.head_sequence)"),
  109. goal_id: Optional[str] = Query(None, description="过滤指定 Goal 的消息。使用 '_init' 查询初始阶段(goal_id=None)的消息"),
  110. ):
  111. """
  112. 获取 Messages
  113. Args:
  114. trace_id: Trace ID
  115. mode: 查询模式
  116. - "main_path"(默认): 从 head 沿 parent_sequence 链回溯的主路径消息
  117. - "all": 返回所有消息(包含所有分支)
  118. head: 可选,指定主路径的 head sequence(仅 mode=main_path 有效)
  119. goal_id: 可选,过滤指定 Goal 的消息
  120. - 不指定: 不按 goal 过滤
  121. - "_init" 或 "null": 返回初始阶段(goal_id=None)的消息
  122. - 其他值: 返回指定 Goal 的消息
  123. """
  124. store = get_trace_store()
  125. # 验证 Trace 存在
  126. trace = await store.get_trace(trace_id)
  127. if not trace:
  128. raise HTTPException(status_code=404, detail="Trace not found")
  129. # 获取 Messages
  130. if goal_id and goal_id not in ("_init", "null"):
  131. # 按 Goal 过滤(独立查询)
  132. messages = await store.get_messages_by_goal(trace_id, goal_id)
  133. elif mode == "main_path":
  134. # 主路径模式
  135. head_seq = head if head is not None else trace.head_sequence
  136. if head_seq > 0:
  137. messages = await store.get_main_path_messages(trace_id, head_seq)
  138. else:
  139. messages = []
  140. else:
  141. # all 模式:返回所有消息
  142. messages = await store.get_trace_messages(trace_id)
  143. # goal_id 过滤(_init 表示 goal_id=None 的消息)
  144. if goal_id in ("_init", "null"):
  145. messages = [m for m in messages if m.goal_id is None]
  146. return MessagesResponse(
  147. messages=[m.to_dict() for m in messages]
  148. )
  149. # ===== 知识反馈 =====
  150. class KnowledgeFeedbackItem(BaseModel):
  151. knowledge_id: str
  152. action: str # "confirm" | "override" | "skip"
  153. eval_status: Optional[str] = None # helpful | harmful | unused | irrelevant | neutral
  154. feedback_text: Optional[str] = None
  155. source: Dict[str, Any] = {} # {trace_id, goal_id, sequence, feedback_by, feedback_at}
  156. class KnowledgeFeedbackRequest(BaseModel):
  157. feedback_list: List[KnowledgeFeedbackItem]
  158. @router.get("/{trace_id}/knowledge_log")
  159. async def get_knowledge_log(trace_id: str):
  160. """获取 Trace 的知识注入日志"""
  161. store = get_trace_store()
  162. trace = await store.get_trace(trace_id)
  163. if not trace:
  164. raise HTTPException(status_code=404, detail="Trace not found")
  165. return await store.get_knowledge_log(trace_id)
  166. @router.post("/{trace_id}/knowledge_feedback")
  167. async def submit_knowledge_feedback(trace_id: str, req: KnowledgeFeedbackRequest):
  168. """提交知识使用反馈,同步更新 knowledge_log.json 并转发到 KnowHub"""
  169. store = get_trace_store()
  170. trace = await store.get_trace(trace_id)
  171. if not trace:
  172. raise HTTPException(status_code=404, detail="Trace not found")
  173. _require_mutable_trace(trace)
  174. knowhub_url = os.getenv("KNOWHUB_API") or os.getenv("KNOWHUB_URL", "http://localhost:9999")
  175. updated_count = 0
  176. now_iso = datetime.now(timezone.utc).isoformat()
  177. async with httpx.AsyncClient(timeout=10.0) as client:
  178. for item in req.feedback_list:
  179. if item.action == "skip":
  180. continue
  181. # 1. 记录到 knowledge_log.json
  182. feedback_record = {
  183. "action": item.action,
  184. "eval_status": item.eval_status,
  185. "feedback_text": item.feedback_text,
  186. "feedback_by": item.source.get("feedback_by", "user"),
  187. "feedback_at": item.source.get("feedback_at", now_iso),
  188. }
  189. await store.update_user_feedback(trace_id, item.knowledge_id, feedback_record)
  190. # 2. 构建 history 条目(含完整溯源信息)
  191. history_entry = {
  192. "source": "user",
  193. "action": item.action,
  194. "eval_status": item.eval_status,
  195. "feedback_by": item.source.get("feedback_by", "user"),
  196. "feedback_at": now_iso,
  197. "trace_id": trace_id,
  198. "goal_id": item.source.get("goal_id"),
  199. "sequence": item.source.get("sequence"),
  200. "feedback_text": item.feedback_text,
  201. }
  202. # 3. 根据 action 和 eval_status 决定调用 KnowHub 的哪个字段
  203. if item.action == "confirm":
  204. payload = {"add_helpful_case": history_entry}
  205. elif item.action == "override":
  206. if item.eval_status == "harmful":
  207. payload = {"add_harmful_case": history_entry}
  208. else:
  209. # helpful / unused / irrelevant / neutral → 记为 helpful_case,history 内保留完整 eval_status
  210. payload = {"add_helpful_case": history_entry}
  211. else:
  212. continue
  213. try:
  214. await client.put(
  215. f"{knowhub_url}/api/knowledge/{item.knowledge_id}",
  216. json=payload
  217. )
  218. updated_count += 1
  219. except Exception as e:
  220. # 记录警告但不中断整体提交
  221. print(f"[KnowledgeFeedback] KnowHub 更新失败 {item.knowledge_id}: {e}")
  222. return {"status": "ok", "updated": updated_count}
  223. @router.post("/extract_comment", status_code=201)
  224. async def extract_comment_proxy(req: Dict[str, Any]):
  225. """调用 LLM 从评论提取结构化知识,再 POST 到远端 KnowHub /api/knowledge"""
  226. comment = (req.get("comment") or "").strip()
  227. if not comment:
  228. raise HTTPException(status_code=400, detail="comment is required")
  229. context = req.get("context") or ""
  230. prompt = f"""你是知识提取专家。根据用户的评论和 Agent 执行上下文,提取一条结构化知识。
  231. 【上下文(Agent 执行内容)】:
  232. {context or "(无上下文)"}
  233. 【用户评论】:
  234. {comment}
  235. 【输出格式】(严格 JSON,不要其他内容):
  236. {{
  237. "task": "任务场景描述(一句话,描述在什么情况下要完成什么目标)",
  238. "content": "核心知识内容(具体可操作的方法、注意事项)"
  239. }}"""
  240. try:
  241. response = await openrouter_llm_call(
  242. messages=[{"role": "user", "content": prompt}],
  243. model="google/gemini-2.5-flash-lite",
  244. )
  245. raw = response.get("content", "").strip()
  246. if "```" in raw:
  247. for part in raw.split("```"):
  248. part = part.strip().lstrip("json").strip()
  249. try:
  250. parsed = json.loads(part)
  251. if "task" in parsed and "content" in parsed:
  252. raw = part
  253. break
  254. except Exception:
  255. continue
  256. extracted = json.loads(raw)
  257. task = extracted.get("task", "").strip()
  258. content = extracted.get("content", "").strip()
  259. if not task or not content:
  260. raise ValueError("missing task or content")
  261. except Exception as e:
  262. raise HTTPException(status_code=500, detail=f"LLM 提取失败: {e}")
  263. knowhub_url = os.getenv("KNOWHUB_API") or os.getenv("KNOWHUB_URL", "http://localhost:9999")
  264. payload = {
  265. "task": task,
  266. "content": content,
  267. "types": req.get("types", ["strategy"]),
  268. "scopes": req.get("scopes", ["org:cybertogether"]),
  269. "owner": req.get("owner", "user"),
  270. "source": req.get("source", {}),
  271. }
  272. async with httpx.AsyncClient(timeout=15.0) as client:
  273. try:
  274. resp = await client.post(f"{knowhub_url}/api/knowledge", json=payload)
  275. resp.raise_for_status()
  276. data = resp.json()
  277. return {"status": "pending", "knowledge_id": data.get("id", ""), "task": task, "content": content}
  278. except Exception as e:
  279. raise HTTPException(status_code=502, detail=f"KnowHub 写入失败: {e}")