feat(ai-writing): 素材收集技能调用失败时容错回退到其他技能

默认/选中技能调用失败(超时、报错、全部检索词失败)时不再直接报错,
改用其他技能继续检索:节点 emit skill_error 步骤(带真实原因),配置技能
全部失败且无新素材时跑 fallback 兜底(内置知识库检索优先,再到目录其它已
启用技能)。前端 SkillStepsCard 渲染 skill_error(玫红)/ fallback(琥珀),
时间线展示"技能报错→改用其他技能搜集"。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
董宏凯andClaude Opus 4.8 committed 2026-06-16 14:15:49 +08:00
1 parent c7dccf8c0b
commit da3d38013e
4 files changed
+126 -27

No files matched your search

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { ChevronRight, Sparkles, ListChecks, Zap, Loader2, CheckCircle2, CircleStop, Search } from 'lucide-react';
import { ChevronRight, Sparkles, ListChecks, Zap, Loader2, CheckCircle2, CircleStop, Search, AlertTriangle, Shuffle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { SkillSearchStep } from '../../types/ai-writing';
import { CollapseTransition } from './CollapseTransition';
@@ -16,6 +16,8 @@ const STEP_ICON: Record<SkillSearchStep['type'], typeof Sparkles> = {
select_skills: Sparkles,
invoke_skill: Zap,
skill_result: CheckCircle2,
skill_error: AlertTriangle,
fallback: Shuffle,
stop_early: CircleStop,
};
@@ -25,6 +27,8 @@ const STEP_LABEL: Record<SkillSearchStep['type'], string> = {
select_skills: '筛选相关技能',
invoke_skill: '调用技能',
skill_result: '检索结果',
skill_error: '技能调用失败',
fallback: '改用其他技能',
stop_early: '提前停止',
};
@@ -71,6 +75,9 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
const label = STEP_LABEL[step.type] ?? step.type;
const isStopEarly = step.type === 'stop_early';
const isResult = step.type === 'skill_result';
// 容错:技能调用失败标红,回退其它技能标琥珀,提示用户"出错了但已自动改用其他技能"
const isError = step.type === 'skill_error';
const isFallback = step.type === 'fallback';
const isActive = idx === activeIdx;
return (
<li key={idx} className="flex items-start gap-2 text-[11px]">
@@ -79,11 +86,13 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
'shrink-0 mt-0.5 inline-flex items-center justify-center size-4 rounded-full text-[10px] font-medium',
isActive
? 'bg-violet-500 text-white'
: isStopEarly
? 'bg-amber-100 dark:bg-amber-900/60 text-amber-600 dark:text-amber-300'
: isResult
? 'bg-emerald-100 dark:bg-emerald-900/60 text-emerald-600 dark:text-emerald-300'
: 'bg-violet-100 dark:bg-violet-900/60 text-violet-600 dark:text-violet-300',
: isError
? 'bg-rose-100 dark:bg-rose-900/60 text-rose-600 dark:text-rose-300'
: isStopEarly || isFallback
? 'bg-amber-100 dark:bg-amber-900/60 text-amber-600 dark:text-amber-300'
: isResult
? 'bg-emerald-100 dark:bg-emerald-900/60 text-emerald-600 dark:text-emerald-300'
: 'bg-violet-100 dark:bg-violet-900/60 text-violet-600 dark:text-violet-300',
)}
>
{isActive ? <Loader2 className="size-2.5 animate-spin" /> : idx + 1}
@@ -92,11 +101,13 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
<div
className={cn(
'flex items-center gap-1 font-medium',
isStopEarly
? 'text-amber-700 dark:text-amber-300'
: isResult
? 'text-emerald-700 dark:text-emerald-300'
: 'text-violet-700 dark:text-violet-300',
isError
? 'text-rose-700 dark:text-rose-300'
: isStopEarly || isFallback
? 'text-amber-700 dark:text-amber-300'
: isResult
? 'text-emerald-700 dark:text-emerald-300'
: 'text-violet-700 dark:text-violet-300',
)}
>
<Icon className="size-3 shrink-0" />
@@ -20,6 +20,8 @@ export interface AIMaterial {
// select_skills:LLM 按相关度排序的候选
// invoke_skill:开始调用某个技能去检索
// skill_result:某个技能检索返回了 N 条素材
// skill_error:某个技能调用失败(超时/报错),将改用其他技能(容错)
// fallback:配置技能全部失败,回退到其他技能继续搜集(容错)
// stop_early:累计素材达到上限,停止后续技能
export interface SkillSearchStep {
type:
@@ -28,6 +30,8 @@ export interface SkillSearchStep {
| "select_skills"
| "invoke_skill"
| "skill_result"
| "skill_error"
| "fallback"
| "stop_early";
message: string;
skillNames?: string[];
+1 -1
View File
@@ -265,7 +265,7 @@ The AI-writing graph (`packages/harness/deerflow/agents/ai_writing/`) has four s
Seeding mirrors the roundtable pattern: verified `config.yaml` + `SOUL.md` ship in `app/gateway/routers/_ai_writing_seed_assets/<id>/`; `_ai_writing_seed.py::ensure_ai_writing_functional_agents()` copies them to `.deer-flow/agents/` when missing (never overwrites local edits), called from the startup lifespan (before `_sync_legacy_agents`, so the agents appear in `/api/agents` on first boot) and self-heals from `GET /api/ai-writing/sessions` + `GET /api/ai-writing/article-types`. Tests: `tests/test_ai_writing_seed.py`, `tests/test_ai_writing_agent_runner.py`.
**Skill-driven retrieval (检索类型).** `material_source` has exactly two values: `general` (通用检索, default) and `notebook` (我的空间); legacy `knowledge_base`/`skill` are normalized to `general` by the researcher node. General retrieval is orchestrated from the **skills configured on the `ai-writing-researcher` agent** (`config.yaml → skills`, editable from the agent management page): the built-in `knowledge-base-search` skill (知识库检索) runs the native keyword fan-out web/intranet search (the old default path), while any other configured skill searches with a `description`-targeted query; skills run in configured order with early-stop at `max_materials`, and an empty/unresolvable skills config falls back to the built-in knowledge search so retrieval always works. The skill itself is seeded from `_ai_writing_seed_assets/skills/knowledge-base-search/SKILL.md` into `skills/public/` (never overwrites), and a pre-existing researcher `config.yaml` lacking the `skills` key is patched in place with the default (explicit `skills: []` is respected). **PAUSE-1 input append flow**: when the user types natural language into the material-confirm input (`re_search`/legacy `enable_skill_search` + `user_query`), the node matches the most relevant configured skill via `SkillPicker(candidates=…)` (skipped when only one skill is configured), searches with the user's words, and **appends** the new materials to the existing package (URL-dedup, original order preserved, no LLM re-rank/truncation, 100-item hard cap). Tests: `tests/test_ai_writing_material_source.py`.
**Skill-driven retrieval (检索类型).** `material_source` has exactly two values: `general` (通用检索, default) and `notebook` (我的空间); legacy `knowledge_base`/`skill` are normalized to `general` by the researcher node. General retrieval is orchestrated from the **skills configured on the `ai-writing-researcher` agent** (`config.yaml → skills`, editable from the agent management page): the built-in `knowledge-base-search` skill (知识库检索) runs the native keyword fan-out web/intranet search (the old default path), while any other configured skill searches with a `description`-targeted query; skills run in configured order with early-stop at `max_materials`, and an empty/unresolvable skills config falls back to the built-in knowledge search so retrieval always works. The skill itself is seeded from `_ai_writing_seed_assets/skills/knowledge-base-search/SKILL.md` into `skills/public/` (never overwrites), and a pre-existing researcher `config.yaml` lacking the `skills` key is patched in place with the default (explicit `skills: []` is respected). **PAUSE-1 input append flow**: when the user types natural language into the material-confirm input (`re_search`/legacy `enable_skill_search` + `user_query`), the node matches the most relevant configured skill via `SkillPicker(candidates=…)` (skipped when only one skill is configured), searches with the user's words, and **appends** the new materials to the existing package (URL-dedup, original order preserved, no LLM re-rank/truncation, 100-item hard cap). **Skill-failure fault tolerance**: a configured skill that times out / raises / has all its keyword searches fail (vs. legitimately returning 0 results) no longer just silently moves on — the researcher node emits a `skill_search_step` of type `skill_error` (carrying the error `reason`), and if the configured skills collected **no** new materials at all it runs a `fallback` pass over other available skills (`_fallback_candidate_skills`: the built-in `knowledge-base-search` native path first, then other enabled catalog skills not yet tried) instead of erroring out. The frontend `SkillStepsCard` renders `skill_error` (rose) and `fallback` (amber) so the timeline shows "技能报错 → 改用其他技能搜集". Tests: `tests/test_ai_writing_material_source.py`.
### Admin Leaderboard Snapshots (`/api/admin/users/leaderboard*`)
@@ -141,6 +141,30 @@ def _load_configured_skills() -> list:
return resolved or [_fallback_knowledge_skill()]
def _fallback_candidate_skills(tried: set[str]) -> list:
"""容错兜底:从技能目录里挑出**尚未尝试过**的可用技能,内置「知识库检索」优先。
当配置技能调用全部失败、且没收集到任何新素材时,researcher 用这些技能再试一轮,
而不是直接报错。内置知识库检索(原生多关键词并发,最稳)排在最前,其后是技能目录里
其它已启用的技能。技能目录加载失败时只回退内置知识库检索,保证兜底链永不断。
"""
candidates: list = []
seen: set[str] = set(tried)
if KNOWLEDGE_SEARCH_SKILL_NAME not in seen:
candidates.append(_fallback_knowledge_skill())
seen.add(KNOWLEDGE_SEARCH_SKILL_NAME)
try:
from deerflow.skills.storage import get_or_new_skill_storage
for s in get_or_new_skill_storage().load_skills(enabled_only=True):
if getattr(s, "name", None) and s.name not in seen:
candidates.append(s)
seen.add(s.name)
except Exception:
logger.warning("researcher: 兜底加载技能目录失败", exc_info=True)
return candidates
def _resolve_keyword_count(raw: object) -> int:
"""把用户传入的检索关键词数量规整到 [1, _MAX_KEYWORD_COUNT],非法值回退默认 6。"""
try:
@@ -511,24 +535,35 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
added += 1
return added
async def _timed_search(skill_name: str, query: str, limit: int) -> list[SearchResult]:
async def _timed_search(skill_name: str, query: str, limit: int) -> tuple[list[SearchResult], str]:
# 加超时兜底:单个技能检索最多等 _SKILL_SEARCH_TIMEOUT_SECONDS 秒,
# 超时不再阻塞后续技能,避免整段过程看起来"冻结"。
# 返回 (结果, 错误详情):错误详情非空 = 该技能调用失败(触发兜底)。
try:
return await asyncio.wait_for(
results = await asyncio.wait_for(
provider.search(query, max_results=limit),
timeout=_SKILL_SEARCH_TIMEOUT_SECONDS,
) or []
)
return (results or []), ""
except asyncio.TimeoutError:
msg = f"检索超时(>{_SKILL_SEARCH_TIMEOUT_SECONDS}s)"
logger.warning("skill[%s] search timed out after %ss", skill_name, _SKILL_SEARCH_TIMEOUT_SECONDS)
search_errors.append(f"skill[{skill_name}]: 检索超时(>{_SKILL_SEARCH_TIMEOUT_SECONDS}s)")
return []
search_errors.append(f"skill[{skill_name}]: {msg}")
return [], msg
except Exception as e:
logger.warning("skill[%s] search failed: %s", skill_name, e)
search_errors.append(f"skill[{skill_name}]: {e}")
return []
return [], str(e)
for sk in ordered:
def _skill_display(sk) -> str:
return _KNOWLEDGE_SKILL_DISPLAY if sk.name == KNOWLEDGE_SEARCH_SKILL_NAME else sk.name
async def _execute_skill(sk) -> tuple[int, str]:
"""运行单个技能检索,返回 (新增素材数, 错误详情)。
错误详情非空 = 该技能整体调用失败(超时 / 抛异常 / 全部检索词失败),
调用方据此 emit ``skill_error`` 步骤并触发"改用其它技能"的兜底逻辑。
"""
is_knowledge = sk.name == KNOWLEDGE_SEARCH_SKILL_NAME
if is_knowledge and not is_user_query_search:
# 原生知识库检索:对全部检索词并发检索(旧版默认路径)。
@@ -557,13 +592,20 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
gathered = await asyncio.gather(*[_search_one(kw) for kw in search_keywords], return_exceptions=True)
added = 0
errs: list[str] = []
for results in gathered:
if isinstance(results, Exception):
logger.warning("search failed: %s", results)
search_errors.append(f"{type(results).__name__}: {results}")
errs.append(f"{type(results).__name__}: {results}")
continue
added += _collect(results)
search_errors.extend(getattr(provider, "errors", []))
search_errors.extend(errs)
# 全部检索词都抛异常且 0 新增 → 视为该技能整体调用失败,触发兜底
error_detail = ""
if added == 0 and errs and len(errs) == total:
error_detail = ";".join(dict.fromkeys(errs))[:200]
return added, error_detail
else:
# 定向技能检索:user_query 模式优先用用户原话;非知识库技能再
# 附上 description 让 query 贴合该技能的领域。
@@ -576,14 +618,35 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
"message": f"使用技能「{sk.name}」检索:{skill_query[:60]}",
"skill_name": sk.name,
})
added = _collect(await _timed_search(sk.name, skill_query, per_skill))
results, error_detail = await _timed_search(sk.name, skill_query, per_skill)
return _collect(results), error_detail
_emit_skill_step({
"type": "skill_result",
"message": f"技能「{_KNOWLEDGE_SKILL_DISPLAY if is_knowledge else sk.name}」检索到 {added} 条新素材",
"skill_name": sk.name,
"matched_count": added,
})
# 失败技能名(去重后用于兜底提示):记录配置技能里调用失败的那些
failed_skill_names: list[str] = []
tried_names: set[str] = set()
def _emit_skill_outcome(sk, added: int, error_detail: str) -> None:
"""单个技能跑完后,按成功 / 失败 emit 对应步骤事件。"""
if error_detail:
failed_skill_names.append(_skill_display(sk))
_emit_skill_step({
"type": "skill_error",
"message": f"技能「{_skill_display(sk)}」调用失败,尝试使用其他技能检索",
"skill_name": sk.name,
"reason": error_detail,
})
else:
_emit_skill_step({
"type": "skill_result",
"message": f"技能「{_skill_display(sk)}」检索到 {added} 条新素材",
"skill_name": sk.name,
"matched_count": added,
})
for sk in ordered:
added, error_detail = await _execute_skill(sk)
tried_names.add(sk.name)
_emit_skill_outcome(sk, added, error_detail)
# 早停:累积素材已达上限
if len(new_results) >= max_materials:
@@ -594,6 +657,27 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
})
break
# ── 容错兜底:配置技能调用失败且没收集到任何新素材 → 改用其它可用技能继续检索 ──
# (不直接报错;前端时间线靠 skill_error / fallback 步骤展示"技能报错,改用其它技能搜集")
if not new_results and failed_skill_names:
fallback_skills = _fallback_candidate_skills(tried_names)
if fallback_skills:
_emit_skill_step({
"type": "fallback",
"message": (
f"已配置技能({'、'.join(dict.fromkeys(failed_skill_names))})检索失败,"
"改用其他技能继续搜集素材"
),
"skill_names": [_skill_display(s) for s in fallback_skills],
})
for sk in fallback_skills:
added, error_detail = await _execute_skill(sk)
tried_names.add(sk.name)
_emit_skill_outcome(sk, added, error_detail)
if new_results:
# 兜底已拿到素材即止,不再无谓尝试后续技能
break
# 合并去重
# - 首次检索 / 普通 re_search(无输入,用户只想重出):旧素材丢弃,覆盖式替换
# - 素材不足求助 supplement_search:保留旧素材 + 追加新检索结果