From 5be5531722155eb5d8bb77bb34651eb42232c83f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Mon, 18 May 2026 17:41:51 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=B9=E9=94=99=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-web/docs/素材专家检索.md | 188 ++++++++++++++++++ frontend-web/src/core/config/index.ts | 13 ++ .../src/open-canvas/api/ai-writing.ts | 2 +- .../ai-writing/AIWritingTimeline.tsx | 1 + .../ai-writing/CompletedInterventionCard.tsx | 8 +- .../components/ai-writing/MaterialItem.tsx | 14 +- .../components/ai-writing/TimelineStep.tsx | 60 ++++-- .../src/open-canvas/hooks/useAIWriting.ts | 13 +- .../src/open-canvas/types/ai-writing.ts | 4 + .../backend/app/gateway/routers/ai_writing.py | 15 +- .../agents/ai_writing/nodes/editor.py | 15 ++ .../agents/ai_writing/nodes/researcher.py | 23 +++ .../agents/ai_writing/nodes/writer_draft.py | 13 ++ .../agents/ai_writing/nodes/writer_outline.py | 8 +- .../agents/ai_writing/search/__init__.py | 15 +- .../ai_writing/search/duckduckgo_search.py | 5 + .../agents/ai_writing/search/intranet_stub.py | 112 +++++++---- 17 files changed, 433 insertions(+), 76 deletions(-) create mode 100644 frontend-web/docs/素材专家检索.md diff --git a/frontend-web/docs/素材专家检索.md b/frontend-web/docs/素材专家检索.md new file mode 100644 index 00000000..d4ddb29b --- /dev/null +++ b/frontend-web/docs/素材专家检索.md @@ -0,0 +1,188 @@ +# 素材专家检索 —— 内网接口对接说明 + +本文档说明 AI 智能写作中「素材收集专家」的检索机制、内网知识库接口的对接逻辑、 +涉及的代码文件,以及常见错误的排查方式。 + +--- + +## 1. 检索来源切换 + +素材专家支持多种检索来源,通过**后端配置文件**统一切换,**无需改动任何代码**。 + +配置位置:`offline-backend-20260512/backend/config.yaml` + +```yaml +ai_writing: + # 检索来源开关:internet=互联网检索(默认)/ intranet=内网知识库 / tavily / demo + search_provider: internet + # 以下两项仅在 search_provider=intranet 时生效 + intranet_search_url: http://10.106.57.180:7863/knowledge_base/search_docs_es + intranet_knowledge_base: samples +``` + +| `search_provider` 取值 | 含义 | +|---|---| +| `internet` | 互联网检索(DuckDuckGo),失败时回退到 demo 演示素材。**当前默认值** | +| `intranet` | 内网知识库检索(调用 `search_docs_es` 接口) | +| `tavily` | Tavily 互联网检索 | +| `demo` | 内置演示素材,仅用于本地调试 | + +> 内网接口联通后,把 `search_provider` 改为 `intranet` 即可切换。 +> 配置文件支持热加载(`get_app_config()` 按 mtime 自动重载),改完一般无需重启后端。 + +--- + +## 2. 内网接口规格 + +- **地址**:`http://10.106.57.180:7863/knowledge_base/search_docs_es` +- **方法**:`POST` +- **请求体**: + + ```json + { + "query": "检索关键词", + "knowledge_base_name": "samples", + "ts": "yes" + } + ``` + +- **响应体**:一个数组,每个元素结构如下: + + ```json + [ + { + "page_content": "正文片段", + "metadata": { + "m_title": "标题", + "content": "内容", + "recUuid": "原文id", + "m_publish":"发布时间", + "source1": "来源" + } + } + ] + ``` + + 我们需要的字段都在 `metadata` 里。 + +### 字段映射 + +后端把内网响应映射为统一的 `SearchResult` 结构: + +| 内网响应字段 | SearchResult 字段 | 说明 | +|--------------------------|--------------------|------| +| `metadata.m_title` | `title` | 标题,缺省为「(无标题)」 | +| `metadata.content` | `content` | 内容,截断到 500 字 | +| `metadata.source1` | `source` | 来源,缺省为「内网知识库」 | +| `metadata.m_publish` | `published_at` | 发布时间 | +| `metadata.recUuid` | `recUuid` | 原文 id,用于前端拼接「查看原文」链接 | +| — | `url` | 固定为空,原文地址由前端依据 `recUuid` 拼接 | +| — | `relevance_score` | 接口不返回分值,按返回顺序赋递减分以保留 ES 排序 | +| — | `source_type` | 固定为 `"intranet"` | + +--- + +## 3. 对接逻辑与涉及文件 + +### 后端 + +| 文件 | 作用 | +|------|------| +| `packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py` | **内网检索适配器** `IntranetSearchProvider`。发起 POST 请求、解析响应、映射为 `SearchResult` | +| `packages/harness/deerflow/agents/ai_writing/search/__init__.py` | `get_search_provider()` 工厂函数,依据 `search_provider` 配置返回对应 provider | +| `packages/harness/deerflow/agents/ai_writing/search/base.py` | `SearchResult` 数据结构 + `BaseSearchProvider` 抽象基类 | +| `packages/harness/deerflow/agents/ai_writing/nodes/researcher.py` | 素材收集专家节点,调用 provider 检索并把结果合并、排序为素材包 | +| `app/gateway/routers/ai_writing.py` | `_ai_writing_config()` 读取 `config.yaml` 的 `ai_writing` 段,经 `_make_config()` 注入运行时 | +| `config.yaml` | `ai_writing` 段,检索来源开关与内网接口地址 | + +调用链: + +``` +ai_writing.py(_make_config 注入 ai_writing_config) + → researcher_node + → get_search_provider(ai_writing_config) # 依据 search_provider 选择 + → IntranetSearchProvider.search() # POST search_docs_es + → SearchResult[] # 字段映射 +``` + +### 前端 + +| 文件 | 作用 | +|------|------| +| `src/core/config/index.ts` | `INTRANET_SOURCE_URL_BASE` 原文详情页地址前缀 + `buildIntranetSourceUrl()` 构造函数 | +| `src/open-canvas/components/ai-writing/MaterialItem.tsx` | 素材卡片,`recUuid` 非空时显示「查看原文」按钮 | +| `src/open-canvas/components/ai-writing/CompletedInterventionCard.tsx` | 历史回显的素材卡,「原文」链接同样基于 `recUuid` | +| `src/open-canvas/api/ai-writing.ts` | `normalizeMaterial()` 把后端 snake_case 字段转为前端 camelCase(含 `recUuid`) | + +「查看原文」链接拼接规则: + +``` +INTRANET_SOURCE_URL_BASE + recUuid += https://chlib.b.uat.4.cn/knowlibrary/#/knowledge/mid_detail?recUuid={recUuid} +``` + +- 仅当素材的 `recUuid` **非空**时才显示「查看原文」按钮。 +- 点击直接 `window.open(...)` 新标签页打开,不做站内跳转。 + +--- + +## 4. 错误处理与排查 + +### 4.1 内网检索返回空 / 调不通 + +**现象**:切到 `intranet` 后,素材专家时间线出现琥珀色告警「ES 波动导致检索异常」, +下方显示真实报错原因;若完全无结果,再提示「未检索到任何素材」。 + +`IntranetSearchProvider.search()` 对所有异常(连接失败、超时、非 2xx、JSON 解析失败) +都做了兜底:**捕获后记录 warning 日志、把原因收集到 `provider.errors`,并返回空列表**, +不会让整个写作流程崩溃。`researcher_node` 在检索结束后取出 `provider.errors`, +以非致命的 `notice` 事件推送到前端时间线(标题「ES 波动导致检索异常」+ 真实原因), +**接口报错不影响写作流程继续**。 + +> 容错相关代码: +> - 检索异常收集:`intranet_stub.py` / `duckduckgo_search.py` 的 `errors` 列表 +> - 告警事件发出:`researcher.py` 的 `_notice()` 与 `progress_events` +> - 模型调用异常:`researcher.py`(检索词/排序)、`writer_outline.py` / `writer_draft.py` / +> `editor.py` 均会发出「模型错误」事件(致命错误用 `error`,可兜底的用 `notice`) +> - 前端展示:`TimelineStep.tsx` 渲染 `notice`(琥珀)/ `error`(红色)的标题 + 真实原因 + +排查步骤: + +1. **确认网络可达**:在后端所在机器执行 + `curl -X POST http://10.106.57.180:7863/knowledge_base/search_docs_es -H "Content-Type: application/json" -d "{\"query\":\"测试\",\"knowledge_base_name\":\"samples\",\"ts\":\"yes\"}"` +2. **查看后端日志**:搜索关键字 `intranet search failed`,会打印具体异常(连接超时 / DNS / 401 等)。 +3. **确认配置**:`config.yaml` 的 `intranet_search_url`、`intranet_knowledge_base` 是否正确。 + +空结果时前端已有兜底:素材确认卡会弹出「未检索到任何素材,是否在无素材的情况下继续写作」, +用户可以选择「重新搜索」或「无数据继续写作」。 + +### 4.2 字段缺失 / 响应结构变化 + +`search()` 对每个字段都用 `or` 兜底(如 `m_title` 缺失时回退为「(无标题)」), +单条数据字段缺失不会导致整批失败。 + +若内网接口改为「数组外包一层 `data`/`results`」的结构,适配器已兼容; +若改成其它结构,需修改 `intranet_stub.py` 中解析 `items` 的逻辑。 + +### 4.3 「查看原文」按钮不显示 + +**原因**:该素材的 `recUuid` 为空。这是预期行为 —— 只有内网知识库素材才带 `recUuid`, +互联网检索(`internet`/`tavily`/`demo`)的素材没有 `recUuid`,不显示「查看原文」。 + +如需让互联网素材也能跳转,需另行调整 `MaterialItem.tsx` 的显示条件。 + +### 4.4 原文链接打不开 / 地址不对 + +「查看原文」地址前缀写在 `src/core/config/index.ts` 的 `INTRANET_SOURCE_URL_BASE`。 +若内网原文详情页地址有变,只改这一个常量即可。 + +--- + +## 5. 联调上线步骤 + +1. 确认后端机器能访问 `10.106.57.180:7863`。 +2. 把 `config.yaml` 的 `ai_writing.search_provider` 改为 `intranet`。 +3. 后端配置热加载生效(或重启后端)。 +4. 进入 AI 智能写作页面,发起一次写作,观察素材收集专家是否返回内网素材。 +5. 校验素材卡片的标题/来源/时间显示正确,「查看原文」能跳到内网详情页。 +6. 如检索为空,按 [4.1](#41-内网检索返回空--调不通) 排查。 diff --git a/frontend-web/src/core/config/index.ts b/frontend-web/src/core/config/index.ts index 4d23bb10..928a1339 100644 --- a/frontend-web/src/core/config/index.ts +++ b/frontend-web/src/core/config/index.ts @@ -1,5 +1,18 @@ import { env } from "@/env"; +/** + * 内网知识库「查看原文」详情页地址前缀。 + * 素材的 recUuid 直接拼接在其后即为完整原文链接。 + */ +export const INTRANET_SOURCE_URL_BASE = + "https://chlib.b.uat.4.cn/knowlibrary/#/knowledge/mid_detail?recUuid="; + +/** 由 recUuid 构造内网原文详情页地址;recUuid 为空时返回空串。 */ +export function buildIntranetSourceUrl(recUuid?: string | null): string { + if (!recUuid) return ""; + return `${INTRANET_SOURCE_URL_BASE}${encodeURIComponent(recUuid)}`; +} + function getBaseOrigin() { if (typeof window !== "undefined") { return window.location.origin; diff --git a/frontend-web/src/open-canvas/api/ai-writing.ts b/frontend-web/src/open-canvas/api/ai-writing.ts index 31b4f4b5..0305eecb 100644 --- a/frontend-web/src/open-canvas/api/ai-writing.ts +++ b/frontend-web/src/open-canvas/api/ai-writing.ts @@ -33,7 +33,7 @@ const SSE_EVENT_TYPES: ProgressEventType[] = [ 'materials_ready', 'outline_chunk', 'outline_ready', 'draft_chunk', 'draft_ready', 'review_chunk', 'review_item_done', 'review_ready', - 'await_user', 'resumed', 'done', 'error', + 'await_user', 'resumed', 'notice', 'done', 'error', ]; // ── 后端返回 snake_case,前端类型是 camelCase,统一在此转换 ────────────────── diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingTimeline.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingTimeline.tsx index e8de81e6..eedf8a69 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingTimeline.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingTimeline.tsx @@ -18,6 +18,7 @@ const DISPLAYED_TYPES = new Set([ 'review_ready', 'await_user', 'resumed', + 'notice', 'done', 'error', ]); diff --git a/frontend-web/src/open-canvas/components/ai-writing/CompletedInterventionCard.tsx b/frontend-web/src/open-canvas/components/ai-writing/CompletedInterventionCard.tsx index 94f9c682..70619177 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/CompletedInterventionCard.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/CompletedInterventionCard.tsx @@ -4,6 +4,7 @@ import { ExternalLink, ListTree, FileText, } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { buildIntranetSourceUrl } from '@/core/config'; import type { CompletedIntervention } from '../../types/ai-writing'; import { OutlineCard } from './OutlineCard'; import { ReviewCard } from './ReviewCard'; @@ -82,11 +83,12 @@ function MaterialCompleted({ record, expanded, onToggle }: { record: CompletedIn {pct}% - {m.url && ( - window.open(buildIntranetSourceUrl(m.recUuid), '_blank', 'noopener,noreferrer')} className="shrink-0 text-primary hover:underline inline-flex items-center gap-0.5"> 原文 - + )} diff --git a/frontend-web/src/open-canvas/components/ai-writing/MaterialItem.tsx b/frontend-web/src/open-canvas/components/ai-writing/MaterialItem.tsx index acc85352..ba30e5f3 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/MaterialItem.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/MaterialItem.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { ChevronRight, ChevronDown, ExternalLink } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { buildIntranetSourceUrl } from '@/core/config'; import type { AIMaterial } from '../../types/ai-writing'; interface MaterialItemProps { @@ -88,16 +89,17 @@ export function MaterialItem({ material, checked, onToggle }: MaterialItemProps) {material.content.slice(0, 300)} {material.content.length > 300 && '…'}

- {material.url && ( - + window.open(buildIntranetSourceUrl(material.recUuid), '_blank', 'noopener,noreferrer') + } className="inline-flex items-center gap-1 text-primary hover:underline" > 查看原文 - + )} )} diff --git a/frontend-web/src/open-canvas/components/ai-writing/TimelineStep.tsx b/frontend-web/src/open-canvas/components/ai-writing/TimelineStep.tsx index 0e653e2b..3ef0b7e1 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/TimelineStep.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/TimelineStep.tsx @@ -1,8 +1,8 @@ -import { CheckCircle2, Circle, Loader2, PauseCircle } from 'lucide-react'; +import { AlertCircle, AlertTriangle, CheckCircle2, Circle, Loader2, PauseCircle } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { ProgressEvent } from '../../types/ai-writing'; -type StepStatus = 'done' | 'running' | 'paused' | 'waiting'; +type StepStatus = 'done' | 'running' | 'paused' | 'waiting' | 'error' | 'warning'; interface TimelineStepProps { event: ProgressEvent; @@ -11,6 +11,8 @@ interface TimelineStepProps { } function statusOf(event: ProgressEvent, isLast: boolean): StepStatus { + if (event.type === 'error') return 'error'; + if (event.type === 'notice') return 'warning'; if (event.type === 'await_user') return 'paused'; // step_start 只有在列表末尾(后续事件还未到达)时才显示转圈,否则已有后续事件说明该步骤已完成 if (event.type === 'step_start') return isLast ? 'running' : 'done'; @@ -26,6 +28,9 @@ const AGENT_COLOR: Record = { export function TimelineStep({ event, isLast, children }: TimelineStepProps) { const status = statusOf(event, isLast ?? true); + // notice=非致命告警(如 ES 波动、模型兜底),error=致命错误,两者都展示「标题 + 真实原因」 + const isAlert = event.type === 'error' || event.type === 'notice'; + const isError = event.type === 'error'; return (
@@ -37,19 +42,46 @@ export function TimelineStep({ event, isLast, children }: TimelineStepProps) { {/* 内容 */}
-
- {event.agentName && ( - + {event.agentName} + + )} + + {isAlert ? ( +
+

- {event.agentName} - - )} - {event.message} -

+ {event.title || event.message || (isError ? '出错了' : '提示')} +

+ {event.detail && ( +

+ {event.detail} +

+ )} +
+ ) : ( + {event.message} + )} + {children}
@@ -64,6 +96,10 @@ function StatusIcon({ status }: { status: StepStatus }) { return ; case 'paused': return ; + case 'error': + return ; + case 'warning': + return ; default: return ; } diff --git a/frontend-web/src/open-canvas/hooks/useAIWriting.ts b/frontend-web/src/open-canvas/hooks/useAIWriting.ts index a4bf423c..fee1737a 100644 --- a/frontend-web/src/open-canvas/hooks/useAIWriting.ts +++ b/frontend-web/src/open-canvas/hooks/useAIWriting.ts @@ -223,8 +223,17 @@ export function useAIWriting() { case 'done': return { ...base, status: 'done', currentPause: null }; - case 'error': - return { ...base, status: 'error', error: event.message ?? '未知错误' }; + case 'error': { + // error 事件携带 title(如「模型错误」)+ detail(真实原因),拼成底部状态栏文案 + const msg = event.detail + ? `${event.title ?? '出错了'}:${event.detail}` + : (event.message ?? '未知错误'); + return { ...base, status: 'error', error: msg }; + } + + // notice:非致命告警(ES 波动 / 模型兜底),仅追加到时间线,不改变写作状态 + case 'notice': + return base; default: return base; diff --git a/frontend-web/src/open-canvas/types/ai-writing.ts b/frontend-web/src/open-canvas/types/ai-writing.ts index eb051a3f..38ed5564 100644 --- a/frontend-web/src/open-canvas/types/ai-writing.ts +++ b/frontend-web/src/open-canvas/types/ai-writing.ts @@ -78,6 +78,7 @@ export type ProgressEventType = | 'review_ready' | 'await_user' | 'resumed' + | 'notice' | 'done' | 'error'; @@ -86,6 +87,9 @@ export interface ProgressEvent { sessionId: string; agentName?: string; message: string; + // notice(非致命告警)/ error:标题 + 真实原因 + title?: string; + detail?: string; // materials_ready materials?: AIMaterial[]; keywords?: string[]; diff --git a/offline-backend-20260512/backend/app/gateway/routers/ai_writing.py b/offline-backend-20260512/backend/app/gateway/routers/ai_writing.py index 171f1731..6e923f41 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/ai_writing.py +++ b/offline-backend-20260512/backend/app/gateway/routers/ai_writing.py @@ -149,12 +149,25 @@ def _sse(event: str, data: dict) -> str: return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" +def _ai_writing_config() -> dict: + """读取 config.yaml 的 ai_writing 段(含素材检索 provider 配置)。""" + try: + from deerflow.config import get_app_config + + aw = getattr(get_app_config(), "ai_writing", None) + if isinstance(aw, dict): + return aw + return dict(aw) if aw else {} + except Exception: + return {} + + def _make_config(session_id: str, model_name: str = "", valid_article_types: list[dict] | None = None) -> dict: return { "configurable": { "thread_id": session_id, "model_name": model_name or _session_model_names.get(session_id, ""), - "ai_writing_config": {}, + "ai_writing_config": _ai_writing_config(), # 把 SSE 队列注入,供草稿/大纲节点直接推流式 chunk "sse_queue": _event_queues.get(session_id), "sse_session_id": session_id, diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py index c5bc044c..438dac98 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py @@ -91,6 +91,9 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict: model = get_model(config) results: dict[str, dict] = {} + review_errors: list[str] = [] # 分项审查的模型异常,供完成后非致命提示 + + _CATEGORY_LABEL = {"fact": "事实性", "logic": "逻辑性", "language": "语言文字"} for category, user_prompt in REVIEW_TASKS: fallback = {"score": 80, "issues": []} @@ -100,6 +103,7 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict: except Exception as e: logger.warning("editor review [%s] failed: %s", category, e) res = fallback + review_errors.append(f"「{_CATEGORY_LABEL.get(category, category)}审查」{type(e).__name__}: {e}") results[category] = res @@ -140,10 +144,21 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict: revision_notes=all_issues, ) + # 分项审查出现模型异常时,以非致命 notice 提示用户,审核结论仍照常产出 + notices: list[dict] = [] + if review_errors: + notices.append({ + "type": "notice", + "agent_name": "编辑", + "title": "模型错误", + "detail": "部分审查项调用失败,已按默认分兜底。原因:" + ";".join(review_errors[:3]), + }) + return { "review_result": review, "progress_events": [ {"type": "step_start", "agent_name": "编辑", "message": "开始审核"}, + *notices, { "type": "review_ready", "agent_name": "编辑", diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py index 361f3394..3b8f19b4 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py @@ -21,6 +21,11 @@ logger = logging.getLogger(__name__) _MAX_MATERIALS = 12 +def _notice(title: str, detail: str) -> dict: + """构造一个非致命告警事件,前端在时间线显示「标题 + 真实原因」,不打断写作流程。""" + return {"type": "notice", "agent_name": "素材收集专家", "title": title, "detail": detail} + + async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict: intent = state["user_intent"] article_type = state.get("article_type", "analysis") @@ -93,9 +98,11 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict seen_urls.add(m.get("url", "")) raw_materials.append(m) + search_errors: list[str] = [] for results in all_results_nested: if isinstance(results, Exception): logger.warning("search failed: %s", results) + search_errors.append(f"{type(results).__name__}: {results}") continue for r in results: if r.url and r.url in seen_urls: @@ -113,8 +120,12 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict recUuid=r.recUuid, )) + # provider 内部吞掉的检索异常(如内网 ES 连接失败),一并取出用于提示用户 + search_errors.extend(getattr(provider, "errors", [])) + # Step 3: 相关度排序(如素材较多,让 LLM 排序) await _emit("检索完成,正在筛选排序素材…") + rank_error = "" if len(raw_materials) > _MAX_MATERIALS: materials_list = "\n".join( f"- ID: {m['id']} 标题: {m['title']} 摘要: {m['content'][:100]}" @@ -126,6 +137,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict fallback={"ranked_ids": [m["id"] for m in raw_materials[:_MAX_MATERIALS]], "summary": f"共搜索到{len(raw_materials)}条素材"}, config=config, ) + rank_error = rank_result.get("_llm_error", "") ranked_ids: list[str] = rank_result.get("ranked_ids", [m["id"] for m in raw_materials]) id_to_mat = {m["id"]: m for m in raw_materials} sorted_materials = [id_to_mat[mid] for mid in ranked_ids if mid in id_to_mat] @@ -146,11 +158,22 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict pkg = MaterialPackage(keywords=keywords, materials=materials, summary=summary) + # 容错告警:检索接口 / 模型调用出错时以非致命 notice 提示用户,不打断写作流程 + notices: list[dict] = [] + if kw_result.get("_llm_error"): + notices.append(_notice("模型错误", f"检索词生成失败,已用默认关键词兜底。原因:{kw_result['_llm_error']}")) + if rank_error: + notices.append(_notice("模型错误", f"素材排序失败,已按相关度兜底排序。原因:{rank_error}")) + if search_errors: + uniq = list(dict.fromkeys(search_errors)) + notices.append(_notice("ES 波动导致检索异常", ";".join(uniq[:3]))) + return { "material_package": pkg, "status": "awaiting_material_confirm", "progress_events": [ {"type": "step_start", "agent_name": "素材收集专家", "message": f"搜索关键词:{', '.join(keywords[:3])}"}, + *notices, {"type": "materials_ready", "agent_name": "素材收集专家", "message": summary}, ], } diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py index b3aec3a4..bf3da60f 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py @@ -76,6 +76,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di model = get_model(config) section_titles: list[str] = [] section_bufs: list[str] = [] # 各章节正文(不含 ## 标题),供扩写兜底逐节替换 + section_errors: list[str] = [] # 分节写作的模型异常,供完成后非致命提示 for i, section in enumerate(sections): sec_title = _section_title(i, section) @@ -141,6 +142,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di logger.error("draft section [%s] failed: %s", sec_title, e, exc_info=True) section_titles.append(sec_title) section_bufs.append("(内容生成失败,请重试)") + section_errors.append(f"「{sec_title}」{type(e).__name__}: {e}") def _assemble() -> str: body = "\n\n".join(f"## {t}\n\n{b}" for t, b in zip(section_titles, section_bufs)) @@ -191,12 +193,23 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di revision_count=revision_count, ) + # 分节写作出现模型异常时,以非致命 notice 提示用户,草稿仍照常产出 + notices: list[dict] = [] + if section_errors: + notices.append({ + "type": "notice", + "agent_name": "作家", + "title": "模型错误", + "detail": "部分章节生成失败,对应段落已占位。原因:" + ";".join(section_errors[:3]), + }) + return { "current_draft": draft, "drafts": [draft], "status": "awaiting_draft_confirm", "progress_events": [ {"type": "step_start", "agent_name": "作家", "message": f"开始写作(第{revision_count+1}稿)"}, + *notices, { "type": "draft_ready", "agent_name": "作家", diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py index e006c732..404f9d5d 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py @@ -87,7 +87,13 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) -> logger.error("outline streaming failed: %s", e, exc_info=True) return { "status": "error", - "progress_events": [{"type": "error", "agent_name": "作家", "message": f"大纲生成失败:{e}"}], + "progress_events": [{ + "type": "error", + "agent_name": "作家", + "title": "模型错误", + "detail": str(e), + "message": "大纲生成失败", + }], } fallback = { diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/__init__.py index 4d0d44f2..61402670 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/__init__.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/__init__.py @@ -10,11 +10,8 @@ def get_search_provider(ai_writing_config: dict) -> BaseSearchProvider: config.yaml 中配置: ai_writing: search_provider: internet # internet(默认,DDG) / intranet / tavily / demo - intranet_search_url: http://... - intranet_search_api_key: "" - intranet_field_map: - content: "abstract" - source: "system_name" + intranet_search_url: http://10.106.57.180:7863/knowledge_base/search_docs_es + intranet_knowledge_base: samples ddg_region: "cn-zh" # DuckDuckGo 区域,默认 cn-zh internet 模式:DuckDuckGo 为主搜索(免 API Key),失败时 fallback 到 demo 演示素材。 @@ -25,8 +22,7 @@ def get_search_provider(ai_writing_config: dict) -> BaseSearchProvider: if provider == "intranet": return IntranetSearchProvider( base_url=cfg.get("intranet_search_url", ""), - api_key=cfg.get("intranet_search_api_key", ""), - field_map=cfg.get("intranet_field_map", {}), + knowledge_base_name=cfg.get("intranet_knowledge_base", "samples"), ) if provider == "tavily": return _FallbackSearchProvider(InternetSearchProvider(), DemoSearchProvider()) @@ -45,6 +41,11 @@ class _FallbackSearchProvider(BaseSearchProvider): self._primary = primary self._fallback = fallback + @property + def errors(self) -> list[str]: + """聚合主/兜底 provider 的检索异常,供 researcher 节点取用。""" + return list(getattr(self._primary, "errors", [])) + list(getattr(self._fallback, "errors", [])) + async def search(self, query: str, max_results: int = 5, **kwargs): results = await self._primary.search(query, max_results=max_results, **kwargs) if not results: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/duckduckgo_search.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/duckduckgo_search.py index 8e8878c6..6ba6b253 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/duckduckgo_search.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/duckduckgo_search.py @@ -16,6 +16,8 @@ class DuckDuckGoSearchProvider(BaseSearchProvider): def __init__(self, region: str = "cn-zh", max_timeout: int = 30): self.region = region self.max_timeout = max_timeout + # 收集本次运行的检索异常,供 researcher 节点取出并提示用户 + self.errors: list[str] = [] async def search( self, @@ -31,6 +33,7 @@ class DuckDuckGoSearchProvider(BaseSearchProvider): return results except Exception as e: logger.warning("DuckDuckGo search failed for query=%r: %s", query, e) + self.errors.append(f"{type(e).__name__}: {e}") return [] def _sync_search(self, query: str, max_results: int) -> List[SearchResult]: @@ -38,6 +41,7 @@ class DuckDuckGoSearchProvider(BaseSearchProvider): from ddgs import DDGS except ImportError: logger.error("ddgs not installed. Run: uv add ddgs") + self.errors.append("ImportError: ddgs 未安装") return [] ddgs = DDGS(timeout=self.max_timeout) @@ -51,6 +55,7 @@ class DuckDuckGoSearchProvider(BaseSearchProvider): items = list(raw) if raw else [] except Exception as e: logger.warning("DDGS.text failed: %s", e) + self.errors.append(f"{type(e).__name__}: {e}") return [] results = [] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py index ddee56e5..216a96cc 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py @@ -1,57 +1,83 @@ +"""内网知识库搜索适配器 —— 对接 ES 检索接口 search_docs_es。""" +from __future__ import annotations + +import logging from typing import List from .base import BaseSearchProvider, SearchResult +logger = logging.getLogger(__name__) + +# 内网知识库默认检索接口与知识库名(可经 config.yaml 的 ai_writing 段覆盖) +_DEFAULT_ENDPOINT = "http://10.106.57.180:7863/knowledge_base/search_docs_es" +_DEFAULT_KB = "samples" + class IntranetSearchProvider(BaseSearchProvider): - """内网搜索接口适配器。 + """内网知识库搜索接口适配器。 - 内网系统需提供 HTTP 接口,推荐格式: - POST /api/search - 请求体:{"query": "...", "page_size": 5} - 响应体:{"total": 100, "results": [...]} - - 对接步骤: - 1. 取消下方注释代码,调整字段映射 - 2. 在 config.yaml 的 intranet_field_map 中配置字段映射 - 3. 将 search_provider 改为 intranet + 接口:POST {endpoint} + 请求体:{"query": "...", "knowledge_base_name": "samples", "ts": "yes"} + 响应体:[{"page_content": "...", "metadata": {m_title, content, recUuid, m_publish, source1}}] """ - def __init__(self, base_url: str, api_key: str = "", timeout: int = 10, field_map: dict = None): - self.base_url = base_url - self.api_key = api_key + def __init__( + self, + base_url: str = "", + knowledge_base_name: str = _DEFAULT_KB, + timeout: int = 15, + **_ignored, + ): + self.endpoint = base_url or _DEFAULT_ENDPOINT + self.knowledge_base_name = knowledge_base_name or _DEFAULT_KB self.timeout = timeout - self.field_map = field_map or {} - - def _map(self, item: dict, field: str) -> str: - key = self.field_map.get(field, field) - return str(item.get(key, item.get(field, ""))) + # 收集本次运行的检索异常,供 researcher 节点取出并提示用户(ES 波动等) + self.errors: list[str] = [] async def search(self, query: str, max_results: int = 5, **kwargs) -> List[SearchResult]: - # ── 对接内网时,取消以下注释 ────────────────────────────────────── - # import httpx - # headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {} - # async with httpx.AsyncClient(timeout=self.timeout) as client: - # resp = await client.post( - # f"{self.base_url}/api/search", - # json={"query": query, "page_size": max_results}, - # headers=headers, - # ) - # resp.raise_for_status() - # items = resp.json().get("results", []) - # return [ - # SearchResult( - # title=self._map(it, "title"), - # content=self._map(it, "content")[:500], - # source=self._map(it, "source"), - # published_at=self._map(it, "published_at"), - # relevance_score=float(it.get(self.field_map.get("relevance_score", "relevance_score"), 0)), - # recUuid=self._map(it, "recUuid"), - # source_type="intranet", - # ) - # for it in items - # ] - return [] + import httpx + + payload = { + "query": query, + "knowledge_base_name": self.knowledge_base_name, + "ts": "yes", + } + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + resp = await client.post(self.endpoint, json=payload) + resp.raise_for_status() + data = resp.json() + except Exception as e: + logger.warning("intranet search failed for query=%r: %s", query, e) + self.errors.append(f"{type(e).__name__}: {e}") + return [] + + # 接口直接返回数组;兼容个别情况下包了一层 data/results 的情况 + if isinstance(data, dict): + items = data.get("data") or data.get("results") or [] + else: + items = data or [] + + results: List[SearchResult] = [] + for i, item in enumerate(items[:max_results]): + md = (item or {}).get("metadata") or {} + content = md.get("content") or (item or {}).get("page_content") or "" + results.append(SearchResult( + title=str(md.get("m_title") or "").strip() or "(无标题)", + content=str(content)[:500], + source=str(md.get("source1") or "").strip() or "内网知识库", + published_at=str(md.get("m_publish") or "").strip(), + url="", # 内网素材的原文地址由前端依据 recUuid 拼接 + # 接口未返回相关度分值,按 ES 返回顺序赋递减分以保留排序 + relevance_score=max(0.5, 0.95 - i * 0.03), + source_type="intranet", + recUuid=str(md.get("recUuid") or "").strip(), + )) + return results async def health_check(self) -> bool: - return False + try: + await self.search("测试", max_results=1) + return True + except Exception: + return False