deerflow-code/offline-backend-20260512/backend/app/gateway/routers/recommend.py
2026-09-07 18:24:55 +08:00

350 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""圆桌规划 · Step 1→2 之间「智能体推荐」Gateway 路由。
前端页面:推荐弹窗 ``RecommendAgentsDialog``
前端模块:``frontend-web/src/roundtable-planning/api/recommend.ts``
职责
----
根据 Step 1 的 ``IntentSummary`` 与前端过滤后的候选池,驱动内置
``roundtable-recommender``(SOUL 见 ``.deer-flow/agents/roundtable-recommender/``),
输出 2–6 个 ``{agent_id, reason}`` 推荐席位。
设计为**无状态单次调用**:
- **不暴露** ``/init``:每次请求内部 ``_create_thread``,推荐 agent 不需要历史轮次。
- 候选池过滤由前端完成(剔除 ``roundtable-intent``、``roundtable-recommender``、
``roundtable-coordinator-*``),本路由只做校验与 clamp。
SSE 协议
--------
1. 透传上游 LangGraph 流(前端用 ``parseRecommendStreamContent`` 把正文拆成
``[RECOMMEND_READY]`` 前的 ``rationale`` 与 JSON 里的 ``picks``,实现卡片增量渲染)。
2. 末尾状态帧::
{ "status": "done"|"asking"|"error", "content": "<全文>", "picks": [...]|null }
- ``done``:``picks`` 经 ``_validate_picks`` 后 ≥2 条且 id 均在候选池内;
- ``asking``:未匹配 ``[RECOMMEND_READY]`` 或校验后不足 2 条(前端走 FALLBACK 默认 6 席);
- ``error``:HTTP/未捕获异常。
与 SOUL 的契约
--------------
模型须在自然语言思路后输出::
[RECOMMEND_READY]
```json
{"picks": [{"agent_id": "...", "reason": "..."}, ...]}
```
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any, AsyncIterator
import httpx
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import StreamingResponse
from app.gateway.roundtable_diag import record_foreground_diag
from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents
from app.gateway.routers.multi_agent import (
_ROUNDTABLE_THREAD_METADATA,
_auth_headers,
_create_thread,
_flatten_content,
_loopback_base,
_make_loopback_client,
_parse_last_messages,
_run_payload,
_stream_upstream,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/recommend", tags=["recommend"])
RECOMMENDER_AGENT_ID = "roundtable-recommender"
# 从模型最终 AIMessage 全文提取 picks;非贪婪 ``.*?`` 只匹配第一个 json 块。
_RECOMMEND_READY_PATTERN = re.compile(
r"\[RECOMMEND_READY\]\s*```json\s*(\{.*?\})\s*```",
re.DOTALL,
)
# 与 SOUL / 前端 ``RecommendAgentsDialog`` 一致:少于 2 无法成桌,多于 6 由网关截断。
# 前端确认按钮另限制 2≤N≤8(用户可手动多选),后端此处只负责模型输出校验。
_MIN_PICKS = 2
_MAX_PICKS = 6
def _build_prompt(intent: dict[str, Any], candidates: list[dict[str, Any]]) -> str:
"""Render the intent + candidate pool into the single user message the
recommender agent reads. Keep the format close to what the SOUL example
shows so the model has a clean pattern to mirror."""
objective = str(intent.get("objective") or "").strip() or "(未提供)"
constraints = intent.get("constraints") or []
assumptions = intent.get("assumptions") or []
constraint_lines = "; ".join(str(c).strip() for c in constraints if str(c).strip()) or "(无)"
assumption_lines = "; ".join(str(a).strip() for a in assumptions if str(a).strip()) or "(无)"
candidate_lines: list[str] = []
for c in candidates:
cid = str(c.get("agent_id") or c.get("id") or "").strip()
cname = str(c.get("name") or "").strip()
cdesc = str(c.get("description") or "").strip()
if not cid:
continue
# Format: "- <id> (<name>): <description>" — matches the SOUL example.
suffix = f" ({cname})" if cname else ""
desc_part = f": {cdesc}" if cdesc else ""
candidate_lines.append(f"- {cid}{suffix}{desc_part}")
candidate_block = "\n".join(candidate_lines) if candidate_lines else "(候选池为空)"
return (
f"任务目标:{objective}\n"
f"约束:{constraint_lines}\n"
f"关键假设:{assumption_lines}\n\n"
f"候选智能体池:\n{candidate_block}\n\n"
f"请按 SOUL 中规定的格式输出推荐结果(自然语言思路 + [RECOMMEND_READY] + json 块)。"
)
def _validate_picks(
raw_picks: Any,
candidates: list[dict[str, Any]],
) -> list[dict[str, str]]:
"""Filter the model's picks against the candidate pool and clamp to 2-6.
Drops picks whose ``agent_id`` isn't in the candidate set (guards against
model hallucination), de-duplicates, and trims to ``_MAX_PICKS`` if the
model over-picked. Returns ``[]`` if validation leaves us with fewer than
``_MIN_PICKS`` valid picks — the caller treats that as "fall back to
defaults".
容错策略(2026-XX 修复 "用户自建 agent 被推荐丢失" 的问题):
模型有时把候选池里的 ``name`` 当成 ``agent_id`` 回填(尤其当 id 是 UUID
形式时, LLM 倾向用人类可读名),或者顺便也回了 ``name`` 字段。本函数:
1. 先按 agent_id 命中候选池(严格匹配,原行为);
2. 否则按 ``agent_id`` 的字面值反查 name → 找回真实 id;
3. 否则按 ``name`` 字段反查 name → 找回真实 id。
只要能定位到候选池里**唯一**一个 agent,就算 pick 成功。
"""
if not isinstance(raw_picks, list):
return []
id_set: set[str] = set()
name_to_id: dict[str, str] = {}
for c in candidates:
aid = str(c.get("agent_id") or "").strip()
if not aid:
continue
id_set.add(aid)
nm = str(c.get("name") or "").strip()
if nm:
# 大小写不敏感反查 —— 模型可能用空格 / 不同大小写 echo 中文名。
name_to_id.setdefault(nm.lower(), aid)
picked: list[dict[str, str]] = []
seen: set[str] = set()
for item in raw_picks:
if not isinstance(item, dict):
continue
raw_id = str(item.get("agent_id") or "").strip()
raw_name = str(item.get("name") or "").strip()
reason = str(item.get("reason") or "").strip()
aid = ""
if raw_id and raw_id in id_set:
aid = raw_id
elif raw_id and raw_id.lower() in name_to_id:
# 模型把"广告设计智能体"这种 name 当成 agent_id 回填了
aid = name_to_id[raw_id.lower()]
elif raw_name and raw_name.lower() in name_to_id:
# 模型 agent_id 字段乱了,但 name 字段对得上
aid = name_to_id[raw_name.lower()]
if not aid or aid in seen:
continue
picked.append({"agent_id": aid, "reason": reason})
seen.add(aid)
if len(picked) >= _MAX_PICKS:
break
if len(picked) < _MIN_PICKS:
return []
return picked
@router.post("/stream")
async def stream_recommend(request: Request, payload: dict = Body(...)) -> StreamingResponse:
"""Drive one recommendation turn via SSE.
Body::
{
"intent": {"objective": "...", "constraints": [...], "assumptions": [...]},
"candidates": [{"agent_id": "...", "name": "...", "description": "..."}, ...],
"model": "<可选>" # optional;不传由 lead_agent 回退到 config.yaml models[0]
}
Stream: transparent upstream LangGraph frames, followed by one status
frame::
{
"status": "done" | "asking" | "error",
"content": "<final AI text>",
"picks": [{"agent_id": "...", "reason": "..."}] | null
}
``done`` means picks were parsed and validated; ``asking`` means the
marker was missing or validation left fewer than 2 valid picks (caller
should apply its fallback list); ``error`` is upstream failure.
"""
intent = payload.get("intent") or {}
candidates = payload.get("candidates") or []
# 不写死任何默认模型;空串会让 lead_agent._resolve_model_name() 回退到
# config.yaml 的 models[0]。内网部署可能没有 deepseek-chat,前端传当前选中的
# model 名即可,缺失也不会硬错。
model_name = str(payload.get("model") or "")
if not isinstance(intent, dict) or not str(intent.get("objective") or "").strip():
raise HTTPException(status_code=400, detail="intent.objective is required")
if not isinstance(candidates, list) or not candidates:
raise HTTPException(status_code=400, detail="candidates must be a non-empty array")
candidate_ids = {
str(c.get("agent_id") or c.get("id") or "").strip()
for c in candidates
if isinstance(c, dict)
}
candidate_ids.discard("")
if not candidate_ids:
raise HTTPException(status_code=400, detail="candidates must contain at least one valid agent_id")
# 调用前自检:内网部署 .deer-flow/ 被 gitignore,roundtable-recommender 目录可能不存在,
# 缺失则用嵌入模板补建,避免下游 lead_agent 解析失败抛 500。
ensure_roundtable_functional_agents()
prompt = _build_prompt(intent, candidates)
base = _loopback_base(request)
headers = _auth_headers(request)
async def stream_generator() -> AsyncIterator[str]:
# TCP loopback 到 127.0.0.1:<gateway port>(详见 multi_agent._make_loopback_client)。
# 不要在这里替换成 httpx.ASGITransport——曾试过,会整段 buffer SSE 流导致前端
# 看不到逐 token 增量。
async with _make_loopback_client(request) as client:
try:
# 每次推荐独立 thread;prompt 已包含完整 intent + 候选列表,无需多轮。
thread_id = await _create_thread(client, base, headers, metadata=_ROUNDTABLE_THREAD_METADATA)
body = _run_payload(
agent_name=RECOMMENDER_AGENT_ID,
model_name=model_name,
thread_id=thread_id,
new_message=prompt,
# 推荐只做判断:禁止澄清、派活、文件等,避免跑题或二次追问。
excluded_tools=[
"web_search",
"present_files",
"view_image",
"agent_orchestration",
"ask_clarification",
],
skill_stop_names=[],
# 推荐是"从候选池挑 2-6 个"的短分类任务,不需要 CoT。
# 关掉 thinking + 用 low reasoning,避免模型先吐数千 reasoning
# tokens 才开始输出 [RECOMMEND_READY],前端"等待推荐结果..."
# 从 10-30s 降到 2-5s。
thinking_enabled=False,
reasoning_effort="low",
)
captured: list[str] = []
async for line, done in _stream_upstream(client, base, headers, thread_id, body):
if line is not None:
yield line if line.endswith("\n") else line + "\n"
elif done is not None:
captured = done
messages = _parse_last_messages(captured)
last_ai_content = ""
for msg in reversed(messages):
if not isinstance(msg, dict):
continue
if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
continue
text = _flatten_content(msg.get("content", "")).strip()
if text:
last_ai_content = text
break
# 只扫描「最后一条非空 AIMessage」:推荐 agent 单轮输出,无多轮 READY 问题。
picks: list[dict[str, str]] = []
match = _RECOMMEND_READY_PATTERN.search(last_ai_content)
# 诊断:打印模型输出末尾(含 RECOMMEND_READY 块)。grep "recommend stream debug"
# 即可看到 LLM 实际推荐了什么、_validate_picks 解析前/后的差异。
logger.info(
"recommend stream debug | last_ai_tail=%r | match=%s | candidate_count=%d",
last_ai_content[-600:] if last_ai_content else "",
bool(match),
len(candidates),
)
if match:
try:
parsed = json.loads(match.group(1))
except json.JSONDecodeError as exc:
logger.warning("recommend picks JSON parse failed: %s | raw=%r", exc, match.group(1)[:300])
parsed = {}
if isinstance(parsed, dict):
raw_picks = parsed.get("picks")
# 传完整 candidates 列表(含 name) —— _validate_picks 容错按 name 反查 id,
# 修复 LLM 把"广告设计智能体"这种 name 当 agent_id 回填导致全丢的问题。
picks = _validate_picks(raw_picks, candidates)
logger.info(
"recommend stream debug | raw_picks=%r | validated_picks=%r",
raw_picks,
picks,
)
# asking ≠ 模型在追问用户,而是「网关未能产出合法 picks」,由前端 fallback。
status_value = "done" if picks else "asking"
final_frame: dict[str, Any] = {
"status": status_value,
"content": last_ai_content,
"picks": picks if picks else None,
}
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
except HTTPException as exc:
logger.warning("recommend stream HTTPException: %s %s", exc.status_code, exc.detail)
await record_foreground_diag(
request, stage="step2_recommend", level="error", event="recommend_http_error",
message=f"智能体推荐失败 [{exc.status_code}]:{exc.detail}",
detail={"status_code": exc.status_code, "detail": str(exc.detail)},
)
err = {"status": "error", "content": f"[{exc.status_code}] {exc.detail}", "picks": None}
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
except Exception as exc:
logger.exception("recommend stream unexpected failure")
await record_foreground_diag(
request, stage="step2_recommend", level="error", event="recommend_unexpected",
message=f"智能体推荐未预期异常:{exc!r}",
detail={"type": type(exc).__name__, "error": str(exc)},
)
err = {"status": "error", "content": f"unexpected: {exc!r}", "picks": None}
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
return StreamingResponse(
stream_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)