FinDataPilot / app /agent /nodes /skill_router.py
Fin-DataPilot Deploy Bot
ci: da17dfe
ab26c67
Raw
History Blame Contribute Delete
13.4 kB
"""Skill router node: ask the LLM which skill to call next (or stop)."""
from __future__ import annotations
import json
import logging
import re
from typing import Any
from langchain_core.messages import HumanMessage, SystemMessage
from app.agent.prompts.system import render_system_prompt
from app.agent.state import AgentState
from app.config import get_settings
from app.llm import build_chat_model
from app.skills.registry import REGISTRY
logger = logging.getLogger(__name__)
def _result_has_zero_rows(result: Any) -> bool:
"""True when the tool result is OK but the data field has no rows.
Mirrors the reflector's row-counting heuristic (datas / articles /
announcements / reports) so the loop guard agrees with the
"got nothing" verdict the reflector would have emitted.
"""
if not isinstance(result, dict):
return False
data = result.get("data")
if not isinstance(data, dict):
return False
rows = (
data.get("datas")
or data.get("articles")
or data.get("announcements")
or data.get("reports")
or []
)
return isinstance(rows, list) and len(rows) == 0
def _loop_bail(skill: str, reason_detail: str) -> dict[str, Any]:
"""Return the standard "loop detected" final_answer payload."""
return {
"final_answer": (
f"已对 `{skill}` {reason_detail},仍未拿到有效数据。"
"可能的原因:要查的标的名称在数据源里没有完全一致的拼写、"
"或者该 Skill 的数据源没有覆盖这条信息。"
"请试试:\n"
f" - 直接给股票名 / 代码(如「贵州茅台 600519」),我会用它调 `{skill}`\n"
" - 或者把条件换一种说法(去掉「近 N 天」等窗口限制)\n"
" - 或者用 anysearch 联网搜这条信息"
),
"reflection_verdict": "failed",
"error": f"loop on {skill}: {reason_detail}",
}
def _try_parse_tool_call(text: str) -> dict[str, Any] | None:
"""Best-effort extraction of a tool_call JSON from LLM output."""
text = text.strip()
# Direct JSON
try:
obj = json.loads(text)
if isinstance(obj, dict) and "name" in obj and "args" in obj:
return obj
except json.JSONDecodeError:
pass
# First {...} block
m = re.search(r"\{[^{}]*\"name\"[^{}]*\"args\"[^{}]*\{.*?\}\s*\}", text, re.DOTALL)
if m:
try:
return json.loads(m.group(0))
except json.JSONDecodeError:
return None
return None
async def skill_router_node(state: AgentState) -> dict[str, Any]:
"""Decide the next skill to call, or terminate if the answer is ready.
Routing priority:
1. **Reflector's `next_skill_hint`** — if the previous reflection
emitted a valid hint, use it directly. Handles the
"I just realized I need to chain" reactive case.
2. **Plan-driven** — if the planner left a pending step in
`state.plan`, consume it. This is the "pre-decomposed
question" fast path that lets the router advance without
calling the LLM at all.
3. **LLM fallback** — if neither hint nor plan, ask the LLM
to pick the next step.
"""
settings = get_settings()
previous_results = state.get("tool_calls", [])
# ---- Loop guards. Catches three stuck-on-one-thing failure modes:
#
# 1. **Identical-args loop**: last 2 tool_calls are byte-for-byte
# the same → bail immediately.
# 2. **Same-skill loop**: last 3 tool_calls are all the same
# skill (regardless of args). Catches the "LLM keeps
# re-planning the same bad query" case where each re-plan
# has a slightly different args value (e.g. "admin",
# "admin1", "待定").
# 3. **Zero-result retry**: last 3 tool_calls are the same skill
# AND every one of them returned ok=True with 0 rows. We
# never recover by retrying the same skill.
if previous_results:
last = previous_results[-1]
last_name = last.get("name", "")
# (1) byte-identical
if len(previous_results) >= 2:
prev = previous_results[-2]
if (
last_name == prev.get("name")
and json.dumps(last.get("args", {}), sort_keys=True, ensure_ascii=False)
== json.dumps(prev.get("args", {}), sort_keys=True, ensure_ascii=False)
):
logger.warning("router: identical-args loop on %s; bailing", last_name)
return _loop_bail(last_name, "args 完全相同")
# (2) + (3) same skill N times / zero result
if len(previous_results) >= 3:
tail = previous_results[-3:]
if all(c.get("name") == last_name for c in tail):
# Compute how many of the last 3 returned 0 rows.
zero_count = sum(
1 for c in tail
if _result_has_zero_rows(c.get("result"))
)
if zero_count >= 2:
logger.warning(
"router: same-skill(%s) loop, last 3 had %d zero-result calls; bailing",
last_name, zero_count,
)
return _loop_bail(
last_name,
f"连续 3 次都对 {last_name} 调用且都拿到 0 条结果",
)
# ---- Fast path #1: consume reflector's next_skill_hint ----
hint_skill = state.get("next_skill_hint")
hint_args = state.get("next_args_hint")
if (
hint_skill
and isinstance(hint_skill, str)
and REGISTRY.get_spec(hint_skill)
and REGISTRY.is_enabled(hint_skill)
and isinstance(hint_args, dict)
):
return {
"pending_step_index": state.get("pending_step_index", 0),
"tool_calls": previous_results + [
{
"name": hint_skill,
"args": hint_args,
"trace_id": "",
"result": None,
"ok": False,
"duration_ms": 0,
"error": None,
}
],
"next_skill_hint": None,
"next_args_hint": None,
}
# ---- Fast path #2: consume the next plan step ----
plan = state.get("plan") or []
pending_idx = state.get("pending_step_index", 0)
if plan and pending_idx < len(plan):
step = plan[pending_idx]
skill = step.get("target_skill")
# A null skill (whether planner fallback or explicit
# "summarise" step) is treated as "let the LLM router decide
# what to do next". We still advance the index so we don't
# re-encounter the same null step on the next turn. This is
# safer than the old behaviour of immediately emitting a
# final-answer placeholder, which bailed out before any skill
# ran.
if skill is None:
logger.info(
"router: plan step %d has null target_skill (goal=%r); falling through to LLM path",
pending_idx, step.get("goal", ""),
)
return {
"pending_step_index": pending_idx + 1,
# Don't reset plan — other valid steps may follow.
}
if not REGISTRY.get_spec(skill) or not REGISTRY.is_enabled(skill):
logger.warning("router: plan step %d references invalid skill %r, skipping", pending_idx, skill)
return {
"pending_step_index": pending_idx + 1,
}
args = _substitute_placeholders(
step.get("args", {}),
previous_results,
)
return {
"pending_step_index": pending_idx + 1,
"tool_calls": previous_results + [
{
"name": skill,
"args": args,
"trace_id": "",
"result": None,
"ok": False,
"duration_ms": 0,
"error": None,
}
],
}
# ---- LLM path ----
llm = build_chat_model(settings, temperature=0.0)
history = state.get("history", [])
history_text = "\n".join(
f"[{m['role']}] {m['content']}" for m in history[-10:]
)
user_query = state.get("user_query", "")
rounds = state.get("rounds_used", 0)
user_prompt = (
f"对话历史(最近 10 条):\n{history_text or '(无)'}\n\n"
f"用户最新问题:{user_query}\n\n"
f"已完成的工具调用:{len(previous_results)} 次\n"
f"反思轮数:{rounds}/{settings.agent_max_reflect_rounds}\n\n"
"请按 system prompt 中的契约,输出下一步的 tool_call JSON,或者直接输出最终答案。"
)
try:
resp = await llm.ainvoke(
[SystemMessage(content=render_system_prompt()), HumanMessage(content=user_prompt)]
)
except Exception as exc: # noqa: BLE001
logger.exception("skill_router LLM call failed")
return {
"reflection_verdict": "failed",
"error": f"LLM call failed: {exc}",
}
content = resp.content if isinstance(resp.content, str) else str(resp.content)
parsed = _try_parse_tool_call(content)
if parsed is None:
return {
"final_answer": content,
"reflection_verdict": "sufficient",
}
name = parsed.get("name", "")
args = parsed.get("args", {}) or {}
if not REGISTRY.get_spec(name):
return {
"reflection_verdict": "failed",
"error": f"LLM requested unknown skill: {name}",
"final_answer": f"抱歉,AI 选择的工具 `{name}` 不存在或已禁用。请换个问法或启用对应 Skill。",
}
if not REGISTRY.is_enabled(name):
return {
"reflection_verdict": "failed",
"error": f"LLM requested disabled skill: {name}",
"final_answer": f"抱歉,工具 `{name}` 当前已被禁用。请在前端 Skill 管理中启用后再试。",
}
return {
"pending_step_index": state.get("pending_step_index", 0),
"tool_calls": previous_results + [
{
"name": name,
"args": args,
"trace_id": "",
"result": None,
"ok": False,
"duration_ms": 0,
"error": None,
}
],
}
# ---- Plan placeholder substitution ------------------------------------
def _substitute_placeholders(args: dict[str, Any], prior_calls: list[dict[str, Any]]) -> dict[str, Any]:
"""Replace `<step_N_xxx>` placeholders in args with values from
the Nth prior call's result.
Supported placeholders:
<step_N_top_stock> → "name(code)" of the top market-cap row in
step N's result (or top change% for
"涨停" patterns)
<step_N_top_name> → just the name
<step_N_top_code> → just the code
<step_N_first> → the first row, JSON-serialised
"""
if not prior_calls:
return args
pattern = re.compile(r"<step_(\d+)_(top_stock|top_name|top_code|first)>")
def lookup(step_idx: int, key: str) -> str:
if step_idx >= len(prior_calls):
return ""
call = prior_calls[step_idx]
data = (call.get("result") or {}).get("data") or {}
rows: list[dict[str, Any]] = []
if isinstance(data, dict):
rows = data.get("datas") or data.get("articles") or data.get("announcements") or data.get("reports") or []
if not isinstance(rows, list) or not rows:
return ""
# Pick the row with the highest market cap (or first by default).
def _num(r: dict) -> float:
for k in ("总市值", "A股市值", "总市值(亿元)", "market_cap"):
v = r.get(k)
if v is None:
continue
try:
return float(str(v).replace(",", ""))
except (TypeError, ValueError):
continue
return 0.0
rows_sorted = sorted(rows, key=_num, reverse=True)
top = rows_sorted[0]
name = top.get("股票简称") or top.get("name") or top.get("简称") or ""
code = top.get("股票代码") or top.get("code") or top.get("代码") or ""
if key == "top_stock":
return f"{name} {code}".strip()
if key == "top_name":
return str(name)
if key == "top_code":
return str(code)
if key == "first":
return json.dumps(top, ensure_ascii=False)
return ""
def replace(match: re.Match) -> str:
step_idx = int(match.group(1))
key = match.group(2)
return lookup(step_idx, key)
def walk(obj: Any) -> Any:
if isinstance(obj, str):
return pattern.sub(replace, obj)
if isinstance(obj, dict):
return {k: walk(v) for k, v in obj.items()}
if isinstance(obj, list):
return [walk(x) for x in obj]
return obj
return walk(args)