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:
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[];
|
||||
|
||||
@@ -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*`)
|
||||
|
||||
|
||||
+99
-15
@@ -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:保留旧素材 + 追加新检索结果
|
||||
|
||||
Reference in new issue
Block a user