api.py 13 KB

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