diff --git a/frontend-web/docs/素材专家检索.md b/frontend-web/docs/素材专家检索.md index d4ddb29b..2d1f89a8 100644 --- a/frontend-web/docs/素材专家检索.md +++ b/frontend-web/docs/素材专家检索.md @@ -32,6 +32,50 @@ ai_writing: --- +## 1.5 检索关键词数量 + +素材专家的检索词数量由**用户在写作表单中指定**(「检索关键词数量」输入框, +范围 `1~10`,默认 `6`)。数量越多素材越全面,但检索与排序更耗时。 + +- 该参数随 `POST /api/ai-writing/start` 的 `keyword_count` 字段传入,写入 `AIWritingState.keyword_count`。 +- `researcher_node` 用它决定让 LLM 生成多少个检索词、并发检索多少个方向 + (`EXTRACT_KEYWORDS_USER` prompt 按该数量格式化,`search_keywords = keywords[:keyword_count]`)。 +- 非法值(缺省 / 越界 / 非数字)由前后端双重兜底:后端 `_resolve_keyword_count()` + 规整到 `[1, 10]`,越界回退默认 `6`。 +- 补充搜索(`re_search`)时用户额外添加的关键词不会被该上限截断。 + +--- + +## 1.6 素材总量上限 + +素材收集专家最终保留的素材**总条数**由后端配置文件统一控制,并按检索词数**均分**到每个词。 + +配置位置:`offline-backend-20260512/backend/config.yaml` 的 `ai_writing` 段: + +```yaml +ai_writing: + # 素材总量上限,范围 1~100,非法值回退 20 + max_materials: 20 +``` + +均分规则 —— `每个检索词的检索条数 = ⌈ max_materials / 实际检索词数 ⌉`(向上取整): + +| `max_materials` | 检索词数 | 每词检索条数 | +|---|---|---| +| 20 | 1 | 20 | +| 20 | 2 | 10 | +| 20 | 3 | 7(共 21,合并后截断到 20) | +| 20 | 5 | 4 | + +- 各检索词按上表条数检索 → 合并去重 → 最终统一截断到 `max_materials`。 +- 向上取整是为了保证去重后素材数仍能接近上限;去重后实际条数可能略少于上限(不同词命中同一篇),属正常。 +- 与 [1.5](#15-检索关键词数量) 的关系:`keyword_count` 决定**搜几个词**,`max_materials` 决定**总共要多少条**,两者相除得到每词条数。 +- 涉及代码:`researcher.py` 的 `_resolve_max_materials()`(读配置 + 规整)与 `_per_keyword_limit()`(均分计算)。 + +> `config.yaml` 支持热加载,改完一般无需重启后端。 + +--- + ## 2. 内网接口规格 - **地址**:`http://10.106.57.180:7863/knowledge_base/search_docs_es` @@ -176,6 +220,17 @@ INTRANET_SOURCE_URL_BASE + recUuid 「查看原文」地址前缀写在 `src/core/config/index.ts` 的 `INTRANET_SOURCE_URL_BASE`。 若内网原文详情页地址有变,只改这一个常量即可。 +### 4.5 检索到的素材全是无关内容 + +**现象**:素材卡片内容与写作意图完全不沾边。 + +**排查**:检查后端日志里素材专家实际用的检索词。常见原因是模型把关键词提取 +提示词模板里的占位符(如「关键词1」「...」)当成真关键词去检索了。 + +`researcher.py` 的 `_clean_keywords()` 已对此做防御:剔除 `关键词\d*` / `keyword\d*` / +`...` 等占位符,全部被滤掉时回退为写作意图本身。若仍出现,检查 +`researcher_prompts.py` 的 `EXTRACT_KEYWORDS` 提示词是否又引入了易被误抄的示例占位符。 + --- ## 5. 联调上线步骤 diff --git a/frontend-web/src/open-canvas/api/ai-writing.ts b/frontend-web/src/open-canvas/api/ai-writing.ts index 0305eecb..2a68d81a 100644 --- a/frontend-web/src/open-canvas/api/ai-writing.ts +++ b/frontend-web/src/open-canvas/api/ai-writing.ts @@ -21,6 +21,8 @@ export async function startWritingSession(req: AIWritingRequest): Promise): Partial // outline if (out.outline) out.outline = normalizeOutline(out.outline); + // blocked_sections(section_help await_user) + if (Array.isArray(out.blocked_sections)) { + out.blockedSections = out.blocked_sections.map((b: unknown) => { + const o = b as Record; + return { + sectionTitle: o.section_title ?? o.sectionTitle ?? '', + reason: o.reason ?? '', + }; + }); + } + // draft_ready: 后端发 "title"/"markdown",前端期望 draftTitle/draftMarkdown if ('markdown' in out && !out.draftMarkdown) out.draftMarkdown = out.markdown; if ('title' in out && !out.draftTitle) out.draftTitle = out.title; @@ -202,6 +215,10 @@ export async function resumeWriting( user_revision_notes: payload.userRevisionNotes ?? '', override_verdict: payload.overrideVerdict ?? false, selected_issue_indices: payload.selectedIssueIndices ?? null, + section_decisions: (payload.sectionDecisions ?? []).map((d) => ({ + section_title: d.sectionTitle, + decision: d.decision, + })), }), }); if (!resp.ok) throw new Error(`提交干预失败: ${resp.status}`); diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx index 3beecdcd..db0d3ecd 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx @@ -36,6 +36,8 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) { const [articleType, setArticleType] = useState('analysis'); const [audience, setAudience] = useState('普通读者'); const [wordCountInput, setWordCountInput] = useState('800'); + const [keywordCountInput, setKeywordCountInput] = useState('6'); + const [strictMode, setStrictMode] = useState(false); const [modelName, setModelName] = useState(''); const [models, setModels] = useState([]); @@ -94,15 +96,28 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) { } } + // 检索关键词数量限定 1~10,非法值回退默认 6 + function handleKeywordCountBlur() { + const num = parseInt(keywordCountInput, 10); + if (isNaN(num)) { + setKeywordCountInput('6'); + } else { + setKeywordCountInput(String(Math.max(1, Math.min(10, num)))); + } + } + function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!intent.trim()) return; const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800); + const keywordCount = Math.max(1, Math.min(10, parseInt(keywordCountInput, 10) || 6)); onSubmit({ userIntent: intent.trim(), articleType, targetAudience: audience.trim() || '普通读者', wordCountTarget: wordCount, + keywordCount, + writingMode: strictMode ? 'strict' : 'loose', modelName: modelName || undefined, }); } @@ -158,6 +173,42 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) { +
+ + setKeywordCountInput(e.target.value)} + onBlur={handleKeywordCountBlur} + disabled={disabled} + /> +

+ 素材收集专家用几个关键词检索,越多素材越全面但耗时更长。 +

+
+ +
+ setStrictMode(e.target.checked)} + disabled={disabled} + className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer" + /> + +
+
= { writing_outline: '作家规划大纲中…', awaiting_outline_confirm: '等待确认大纲', writing_draft: '作家写作中…', + awaiting_section_help: '等待处理素材不足的章节', awaiting_draft_confirm: '等待确认草稿', reviewing: '编辑审核中…', awaiting_review_confirm: '等待处理编辑意见', @@ -425,6 +426,14 @@ export function AIWritingPanel() { {state.request.wordCountTarget} 字 + + {state.request.keywordCount} 个 + + + + {state.request.writingMode === 'strict' ? '严格依据素材' : '宽松(可结合通用知识)'} + + {state.request.modelName && ( {state.request.modelName} diff --git a/frontend-web/src/open-canvas/components/ai-writing/DraftStreamingView.tsx b/frontend-web/src/open-canvas/components/ai-writing/DraftStreamingView.tsx index 11fb4704..15507259 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/DraftStreamingView.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/DraftStreamingView.tsx @@ -58,6 +58,37 @@ function SectionThinking({ thinkingText, isDone }: { thinkingText: string; isDon ); } +/** 章节正文:把 `### 要点` 行渲染成小标题,其余按段落显示(流式纯文本,非完整 markdown 解析) */ +function SectionBody({ text, showCursor }: { text: string; showCursor: boolean }) { + const lines = text.split('\n'); + return ( +
+ {lines.map((line, i) => { + // 严格模式下作家声明素材不足的哨兵行,不展示给用户 + if (line.includes('[[SECTION_BLOCKED]]')) return null; + const heading = /^[ \t]*#{1,6}[ \t]+/.exec(line); + if (heading) { + return ( +

+ {line.slice(heading[0].length)} +

+ ); + } + if (!line.trim()) return null; // 跳过空行,避免大段空白 + return ( +

+ {line} +

+ ); + })} + {showCursor && } +
+ ); +} + export function DraftStreamingView({ title, sections }: DraftStreamingViewProps) { const bottomRef = useRef(null); @@ -91,11 +122,11 @@ export function DraftStreamingView({ title, sections }: DraftStreamingViewProps) {/* 该章节的思考过程(可折叠) */} - {/* 正文内容 */} -

- {sec.text} - {sec.sectionIndex === activeSectionIdx && !sec.isDone && } -

+ {/* 正文内容(### 要点行渲染为小标题) */} +
))} diff --git a/frontend-web/src/open-canvas/components/ai-writing/InterventionCard.tsx b/frontend-web/src/open-canvas/components/ai-writing/InterventionCard.tsx index 48738c37..6f652103 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/InterventionCard.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/InterventionCard.tsx @@ -2,11 +2,14 @@ import { useRef, useState } from 'react'; import { PauseCircle, CheckCircle2, RotateCcw, Search, RefreshCw, PenLine, ArrowRight, ShieldAlert, ClipboardEdit, SendHorizonal, + Trash2, BookOpen, } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; import type { AIMaterial, AIOutline, ReviewResult, ReviewIssue, ProgressEvent, UserInterventionPayload, + BlockedSection, SectionDecisionKind, } from '../../types/ai-writing'; import { MaterialsCard } from './MaterialsCard'; import { MaterialsToolbar } from './MaterialsToolbar'; @@ -56,6 +59,16 @@ export function InterventionCard({ ); } + if (pausePoint === 'section_help') { + return ( + + ); + } + if (pausePoint === 'review_confirm') { return ( ); } + +// ── 素材不足求助 ────────────────────────────────────────────────────────────── + +interface SectionHelpCardProps { + blockedSections: BlockedSection[]; + onSubmit: (payload: UserInterventionPayload) => void; + disabled?: boolean; +} + +const DECISION_OPTIONS: { value: SectionDecisionKind; label: string; icon: typeof Search }[] = [ + { value: 'supplement', label: '补充检索素材', icon: Search }, + { value: 'loose', label: '按通用知识续写', icon: BookOpen }, + { value: 'delete', label: '删除该章节', icon: Trash2 }, +]; + +function SectionHelpCard({ blockedSections, onSubmit, disabled }: SectionHelpCardProps) { + const [decisions, setDecisions] = useState>( + () => Object.fromEntries(blockedSections.map((b) => [b.sectionTitle, 'supplement'])), + ); + const [submitting, setSubmitting] = useState(false); + + const hasSupplement = Object.values(decisions).some((d) => d === 'supplement'); + + async function handleSubmit() { + setSubmitting(true); + await onSubmit({ + action: 'section_help', + sectionDecisions: blockedSections.map((b) => ({ + sectionTitle: b.sectionTitle, + decision: decisions[b.sectionTitle] ?? 'supplement', + })), + }); + setSubmitting(false); + } + + return ( +
+
+ +
+

部分章节素材不足,需要你决定

+

+ 严格依据素材模式下,以下 {blockedSections.length} 个章节缺少足够素材支撑写作。请为每个章节选择处理方式。 +

+
+
+ +
+ {blockedSections.map((b) => ( +
+
+

{b.sectionTitle}

+

{b.reason}

+
+
+ {DECISION_OPTIONS.map((opt) => { + const active = (decisions[b.sectionTitle] ?? 'supplement') === opt.value; + const Icon = opt.icon; + return ( + + ); + })} +
+
+ ))} +
+ + {hasSupplement && ( +

+ 含「补充检索素材」的章节将触发重新检索并重新规划大纲,本轮其它选择不生效。 +

+ )} + + +
+ ); +} diff --git a/frontend-web/src/open-canvas/components/artifact/DraftOutlineSidebar.tsx b/frontend-web/src/open-canvas/components/artifact/DraftOutlineSidebar.tsx new file mode 100644 index 00000000..98cf7a85 --- /dev/null +++ b/frontend-web/src/open-canvas/components/artifact/DraftOutlineSidebar.tsx @@ -0,0 +1,192 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { Editor } from "@tiptap/react"; +import { PanelLeftClose, PanelLeftOpen } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { useDocumentOutline } from "../../hooks/useDocumentOutline"; +import type { OutlineHeading } from "../../hooks/useDocumentOutline"; + +interface DraftOutlineSidebarProps { + /** Tiptap 编辑器实例,用于提取标题并解析标题 DOM */ + editor: Editor | null; + /** 文档滚动容器,点击目录项 / scroll-spy 时在其内部滚动与定位 */ + scrollEl: HTMLElement | null; +} + +/** 点击跳转时标题距容器顶部预留的间距(px) */ +const SCROLL_TOP_OFFSET = 16; + +/** scroll-spy:标题顶部进入此阈值内即视为「当前章节」(px) */ +const ACTIVE_THRESHOLD = 80; + +/** + * 目录项的展示层级: + * - 文档 H1 = 文章标题,单独作顶部标题块(不进列表) + * - 文档 H2 = 章节名称 → 列表一级项 + * - 文档 H3 = 要点 → 列表二级项 + */ +function displayLevel(h: OutlineHeading): 1 | 2 { + return h.level >= 3 ? 2 : 1; +} + +/** + * 草稿文档左侧目录栏(仿豆包 / Word 目录窗格): + * 顶部独立标题块 + 章节/要点两级列表,支持点击平滑跳转、滚动高亮、折叠。 + */ +export function DraftOutlineSidebar({ editor, scrollEl }: DraftOutlineSidebarProps) { + const headings = useDocumentOutline(editor); + const [activeId, setActiveId] = useState(null); + const [collapsed, setCollapsed] = useState(false); + + // 第一个 H1 作为文章标题,其余标题进目录列表 + const { titleHeading, items } = useMemo(() => { + const titleIdx = headings.findIndex((h) => h.level === 1); + return { + titleHeading: titleIdx >= 0 ? headings[titleIdx] : null, + items: headings.filter((_, i) => i !== titleIdx), + }; + }, [headings]); + + const scrollToHeading = useCallback( + (pos: number, id: string) => { + if (!editor || !scrollEl) return; + const dom = editor.view.nodeDOM(pos) as HTMLElement | null; + if (!dom) return; + const elTop = dom.getBoundingClientRect().top; + const containerTop = scrollEl.getBoundingClientRect().top; + const target = scrollEl.scrollTop + (elTop - containerTop) - SCROLL_TOP_OFFSET; + scrollEl.scrollTo({ top: Math.max(0, target), behavior: "smooth" }); + setActiveId(id); + }, + [editor, scrollEl], + ); + + // scroll-spy:随文档滚动高亮当前章节(rAF 节流) + useEffect(() => { + if (!editor || !scrollEl || headings.length === 0) return; + + let raf = 0; + const recompute = () => { + raf = 0; + const containerTop = scrollEl.getBoundingClientRect().top; + let current = headings[0].id; + for (const h of headings) { + const dom = editor.view.nodeDOM(h.pos) as HTMLElement | null; + if (!dom) continue; + const relTop = dom.getBoundingClientRect().top - containerTop; + // 标题在阈值内或之上 → 当前章节;首个落在阈值下方时其余皆下方,停止 + if (relTop <= ACTIVE_THRESHOLD) { + current = h.id; + } else { + break; + } + } + setActiveId(current); + }; + + const onScroll = () => { + if (raf) return; + raf = requestAnimationFrame(recompute); + }; + + recompute(); + scrollEl.addEventListener("scroll", onScroll, { passive: true }); + return () => { + if (raf) cancelAnimationFrame(raf); + scrollEl.removeEventListener("scroll", onScroll); + }; + }, [editor, scrollEl, headings]); + + // 没有可导航的章节时不渲染目录栏 + if (items.length === 0) return null; + + // 折叠态:仅留一条窄轨 + 展开按钮 + if (collapsed) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* 头部:目录标签 + 折叠按钮 */} +
+ 目录 + +
+ + {/* 文章标题块 */} + {titleHeading && ( + + )} + + {/* 章节 / 要点列表 */} + +
+ ); +} diff --git a/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx b/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx index 23bca574..a11a70dc 100644 --- a/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx +++ b/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx @@ -13,6 +13,7 @@ import { WritingBubbleMenu } from "./WritingBubbleMenu"; import { WritingParagraphMenu } from "./WritingParagraphMenu"; import { WritingAiMenu } from "./WritingAiMenu"; import { WritingRewritePreview } from "./WritingRewritePreview"; +import { DraftOutlineSidebar } from "./DraftOutlineSidebar"; import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext"; import { useCanvasUIContext } from "../../contexts/CanvasUIContext"; import { applyReplace, applyInsert } from "../../utils/apply-rewrite"; @@ -45,6 +46,9 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende const [showScrollBtn, setShowScrollBtn] = useState(false); const autoScrollRef = useRef(true); + // 编辑模式文档滚动容器:用 state 而非 ref,使目录栏在容器挂载后能拿到它 + const [docScrollEl, setDocScrollEl] = useState(null); + // ── AI rewrite flow state ────────────────────────────────────────────── const [phase, setPhase] = useState("idle"); const [panelPos, setPanelPos] = useState({ x: 0, y: 0 }); @@ -271,15 +275,21 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
-
-
- {editor && ( - <> - - - - )} - +
+ {/* 左侧目录栏(仿豆包):文档标题层级导航 */} + + + {/* 文档滚动区 */} +
+
+ {editor && ( + <> + + + + )} + +
diff --git a/frontend-web/src/open-canvas/hooks/useAIWriting.ts b/frontend-web/src/open-canvas/hooks/useAIWriting.ts index d1c34807..cd13e550 100644 --- a/frontend-web/src/open-canvas/hooks/useAIWriting.ts +++ b/frontend-web/src/open-canvas/hooks/useAIWriting.ts @@ -354,6 +354,11 @@ export function useAIWriting() { else nextStatus = 'revising'; // user_revise } else if (pausePoint === 'review_confirm') { nextStatus = payload.action === 'force_finalize' ? 'done' : 'revising'; // accept_review + } else if (pausePoint === 'section_help') { + // 含「补充检索」→ 回检索;否则(删章节 / 通用知识续写)→ 直接回写作 + nextStatus = payload.sectionDecisions?.some((d) => d.decision === 'supplement') + ? 'researching' + : 'writing_draft'; } try { diff --git a/frontend-web/src/open-canvas/hooks/useDocumentOutline.ts b/frontend-web/src/open-canvas/hooks/useDocumentOutline.ts new file mode 100644 index 00000000..e61ed390 --- /dev/null +++ b/frontend-web/src/open-canvas/hooks/useDocumentOutline.ts @@ -0,0 +1,72 @@ +import { useEffect, useState } from "react"; +import type { Editor } from "@tiptap/react"; + +/** 文档目录中的一个标题项 */ +export interface OutlineHeading { + /** React key + scroll-spy 标识,按 ProseMirror 位置生成 */ + id: string; + /** 标题层级,仅保留 1~3 */ + level: 1 | 2 | 3; + /** 标题纯文本 */ + text: string; + /** 标题节点在 ProseMirror 文档中的位置,用于解析 DOM 元素 */ + pos: number; +} + +/** 仅纳入目录的最大标题层级(H1~H3) */ +const MAX_LEVEL = 3; +/** 内容变化后重建目录的防抖间隔(ms) */ +const DEBOUNCE_MS = 200; + +/** + * 从 Tiptap 编辑器实时提取 H1~H3 标题,构成文档目录。 + * + * 首次挂载立即构建一次;之后监听 `editor.on('update')`,按 DEBOUNCE_MS 防抖重建, + * 避免逐字输入时频繁全量遍历。 + */ +export function useDocumentOutline(editor: Editor | null): OutlineHeading[] { + const [headings, setHeadings] = useState([]); + + useEffect(() => { + if (!editor) { + setHeadings([]); + return; + } + + const compute = () => { + const out: OutlineHeading[] = []; + editor.state.doc.descendants((node, pos) => { + if (node.type.name !== "heading") return true; + const level = node.attrs.level as number; + if (level >= 1 && level <= MAX_LEVEL) { + out.push({ + id: `heading-${pos}`, + level: level as 1 | 2 | 3, + text: node.textContent.trim() || "(无标题)", + pos, + }); + } + return true; + }); + setHeadings(out); + }; + + // 首次立即构建 + compute(); + + // 内容变化时防抖重建 + let timer: ReturnType | undefined; + const onUpdate = () => { + clearTimeout(timer); + timer = setTimeout(compute, DEBOUNCE_MS); + }; + editor.on("update", onUpdate); + + return () => { + clearTimeout(timer); + editor.off("update", onUpdate); + }; + }, [editor]); + + return headings; +} diff --git a/frontend-web/src/open-canvas/types/ai-writing.ts b/frontend-web/src/open-canvas/types/ai-writing.ts index 2e936da3..93ca8760 100644 --- a/frontend-web/src/open-canvas/types/ai-writing.ts +++ b/frontend-web/src/open-canvas/types/ai-writing.ts @@ -25,6 +25,22 @@ export interface AIOutline { sections: OutlineSection[]; } +// ── 素材不足求助 ────────────────────────────────────────────────────────────── + +/** 严格模式下素材不足、待用户决策的章节 */ +export interface BlockedSection { + sectionTitle: string; + reason: string; +} + +/** 单个被阻塞章节的处理决策 */ +export type SectionDecisionKind = 'supplement' | 'delete' | 'loose'; + +export interface SectionDecision { + sectionTitle: string; + decision: SectionDecisionKind; +} + // ── 编辑审核 ────────────────────────────────────────────────────────────────── export interface ReviewIssue { @@ -54,6 +70,7 @@ export type WritingStatus = | 'writing_outline' | 'awaiting_outline_confirm' | 'writing_draft' + | 'awaiting_section_help' | 'awaiting_draft_confirm' | 'reviewing' | 'awaiting_review_confirm' @@ -109,8 +126,10 @@ export interface ProgressEvent { // review_ready reviewResult?: ReviewResult; // await_user - pausePoint?: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm'; + pausePoint?: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm' | 'section_help'; pauseContext?: Record; + // section_help:素材不足、待用户决策的章节 + blockedSections?: BlockedSection[]; } // ── 用户干预提交体 ──────────────────────────────────────────────────────────── @@ -125,6 +144,8 @@ export interface UserInterventionPayload { overrideVerdict?: boolean; // 用户勾选的审核意见索引(基于 reviewResult.revisionNotes),未传则默认全选 selectedIssueIndices?: number[]; + // section_help:每个被阻塞章节的处理决策 + sectionDecisions?: SectionDecision[]; } // ── 写作请求 ────────────────────────────────────────────────────────────────── @@ -135,6 +156,10 @@ export interface AIWritingRequest { targetAudience: string; wordCountTarget: number; modelName?: string; + /** 素材检索关键词数量(1~10,默认 6) */ + keywordCount: number; + /** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */ + writingMode: 'strict' | 'loose'; } // ── 流式审核条目 ────────────────────────────────────────────────────────────── @@ -173,7 +198,7 @@ export interface StreamingDraftSection { // ── 已完成干预记录 ──────────────────────────────────────────────────────────── export interface CompletedIntervention { - pausePoint: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm'; + pausePoint: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm' | 'section_help'; action: string; // material_confirm materials?: AIMaterial[]; 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 6e923f41..2c87e8d4 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/ai_writing.py +++ b/offline-backend-20260512/backend/app/gateway/routers/ai_writing.py @@ -40,6 +40,10 @@ class StartWritingRequest(BaseModel): target_audience: str = "普通读者" word_count_target: int = 800 model_name: str = "" + # 素材检索关键词数量:用户指定用几个关键词检索(1~10,默认 6) + keyword_count: int = 6 + # 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) + writing_mode: str = "loose" class StartWritingResponse(BaseModel): @@ -57,6 +61,9 @@ class ResumeRequest(BaseModel): user_revision_notes: str = "" override_verdict: bool = False selected_issue_indices: Optional[list[int]] = None + # 素材不足求助点:每个被阻塞章节的处理决策 + # [{"sectionTitle": "...", "decision": "supplement|delete|loose"}] + section_decisions: list[dict] = Field(default_factory=list) class RenameSessionRequest(BaseModel): @@ -230,6 +237,10 @@ async def start_writing(body: StartWritingRequest, request: Request): "article_type": body.article_type, "target_audience": body.target_audience, "word_count_target": body.word_count_target, + # 规整到 [1, 10],非法值回退默认 6 + "keyword_count": max(1, min(10, body.keyword_count)), + # 写作模式,非法值回退 loose + "writing_mode": body.writing_mode if body.writing_mode in ("strict", "loose") else "loose", "revision_count": 0, "max_revisions": 3, "status": "researching", @@ -421,6 +432,7 @@ async def resume_writing(session_id: str, body: ResumeRequest, request: Request) "userRevisionNotes": body.user_revision_notes, "overrideVerdict": body.override_verdict, "selectedIssueIndices": body.selected_issue_indices, + "sectionDecisions": body.section_decisions, } # Record this intervention for session history diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/graph.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/graph.py index 5a910c03..cb36382a 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/graph.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/graph.py @@ -61,6 +61,26 @@ def _normalize_outline(outline: dict) -> dict: return {"title": outline.get("title", ""), "sections": sections} +def _check_outline_material_support(outline: dict) -> str: + """轻量启发式:统计未关联任何素材的章节,返回预警文案(空串=无需预警)。 + + 仅做提示、不拦截流程;真正的素材不足会在写作节点被检出并触发求助暂停。 + """ + sections = (outline or {}).get("sections", []) + weak = [ + (s.get("section_title") or s.get("sectionTitle") or "") + for s in sections + if not (s.get("material_ids") or s.get("materialIds")) + ] + weak = [t for t in weak if t] + if not weak: + return "" + return ( + f"有 {len(weak)} 个章节未关联检索素材({('、'.join(weak))})。" + "严格依据素材模式下,这些章节在写作时可能因素材不足而暂停向你求助。" + ) + + async def pause_outline_confirm(state: AIWritingState) -> dict: """PAUSE-2:大纲确认""" user_input: dict = interrupt({ @@ -74,20 +94,34 @@ async def pause_outline_confirm(state: AIWritingState) -> dict: if action == "confirm": raw_outline = user_input.get("editedOutline") or state["current_outline"] edited = _normalize_outline(raw_outline) if raw_outline else state["current_outline"] + events: list[dict] = [{"type": "resumed", "agent_name": "系统", "message": "用户已确认大纲,开始写作"}] + # 阶段四:严格模式下对大纲做轻量素材充分性预警(仅提示,不拦截流程) + if state.get("writing_mode") == "strict": + warning = _check_outline_material_support(edited) + if warning: + events.append({ + "type": "notice", "agent_name": "系统", + "title": "素材充分性提示", "detail": warning, + }) return { "current_outline": edited, "last_user_intervention": user_input, "status": "writing_draft", - "progress_events": [{"type": "resumed", "agent_name": "系统", "message": "用户已确认大纲,开始写作"}], + "progress_events": events, } # re_outline feedback = user_input.get("outlineFeedback", "") - return { + updates: dict = { "last_user_intervention": {**user_input, "_feedback": feedback}, "status": "writing_outline", "progress_events": [{"type": "resumed", "agent_name": "系统", "message": f"重新规划大纲({feedback[:30]})"}], } + # 用户在卡片里手动改过大纲 → 以手动版作为本次重规划的编辑基准 + raw_edited = user_input.get("editedOutline") + if raw_edited: + updates["current_outline"] = _normalize_outline(raw_edited) + return updates async def pause_draft_confirm(state: AIWritingState) -> dict: @@ -127,6 +161,65 @@ async def pause_draft_confirm(state: AIWritingState) -> dict: } +async def pause_section_help(state: AIWritingState) -> dict: + """素材不足求助点:严格模式下作家发现章节缺素材,让用户逐章决定如何处理。 + + 用户对每个被阻塞章节选择: + - supplement:补充检索素材(整体回到检索流程重走) + - delete:删除该章节 + - loose:按通用知识续写该章节 + """ + blocked = state.get("blocked_sections") or [] + user_input: dict = interrupt({ + "pause_point": "section_help", + "message": "部分章节缺少足够素材支撑写作,请决定如何处理。", + "blocked_sections": blocked, + }) + + # sectionDecisions: [{"sectionTitle": "...", "decision": "supplement|delete|loose"}] + by_title: dict = {} + for d in user_input.get("sectionDecisions") or []: + title = d.get("sectionTitle") or d.get("section_title") + if title: + by_title[title] = d.get("decision", "loose") + + # 任一章节选「补充检索」→ 整体回到检索,重走「检索 → 素材确认 → 大纲 → 写作」 + supplement_reasons = [ + b["reason"] for b in blocked if by_title.get(b["section_title"]) == "supplement" + ] + if supplement_reasons: + return { + # supplement_search:补充检索(保留已有素材、追加新素材),完成后直接回写作、不重排大纲 + "last_user_intervention": {**user_input, "action": "supplement_search", "_extra_keywords": supplement_reasons}, + "status": "researching", + "blocked_sections": None, + "progress_events": [{"type": "resumed", "agent_name": "系统", "message": "补充检索素材中…"}], + } + + # 否则:删除标记章节、记录「通用知识续写」章节,直接回写作 + outline = state.get("current_outline") or {"title": "", "sections": []} + kept, loose_titles = [], [] + for s in outline.get("sections", []): + title = s.get("section_title") or s.get("sectionTitle", "") + decision = by_title.get(title) + if decision == "delete": + continue + if decision == "loose": + loose_titles.append(title) + kept.append(s) + # 防御:用户把所有章节都删了 → 保留原大纲并全部按通用知识写,避免空文章 + if not kept: + kept = outline.get("sections", []) + loose_titles = [s.get("section_title") or s.get("sectionTitle", "") for s in kept] + return { + "current_outline": {**outline, "sections": kept}, + "last_user_intervention": {**user_input, "_loose_sections": loose_titles}, + "status": "writing_draft", + "blocked_sections": None, + "progress_events": [{"type": "resumed", "agent_name": "系统", "message": "已按你的选择继续写作…"}], + } + + async def pause_review_confirm(state: AIWritingState) -> dict: """PAUSE-4:编辑打回确认""" user_input: dict = interrupt({ @@ -156,6 +249,11 @@ async def pause_review_confirm(state: AIWritingState) -> dict: # ── 条件路由 ────────────────────────────────────────────────────────────────── +def after_researcher(state: AIWritingState) -> str: + """检索完成后:补充检索(素材不足求助)直接回写作,否则去素材确认。""" + return "writer_draft" if state.get("status") == "writing_draft" else "pause_material" + + def after_material_pause(state: AIWritingState) -> str: return "researcher" if state["status"] == "researching" else "writer_outline" @@ -164,6 +262,16 @@ def after_outline_pause(state: AIWritingState) -> str: return "writer_outline" if state["status"] == "writing_outline" else "writer_draft" +def after_writer_draft(state: AIWritingState) -> str: + """写作完成后:素材不足则去求助暂停点,否则去草稿确认。""" + return "pause_section_help" if state["status"] == "awaiting_section_help" else "pause_draft" + + +def after_section_help_pause(state: AIWritingState) -> str: + """求助暂停点之后:补充检索回 researcher,否则回写作。""" + return "researcher" if state["status"] == "researching" else "writer_draft" + + def after_draft_pause(state: AIWritingState) -> str: status = state["status"] if status == "done": @@ -198,14 +306,16 @@ def build_ai_writing_graph(): g.add_node("pause_material", pause_material_confirm) g.add_node("writer_outline", writer_outline_node) g.add_node("pause_outline", pause_outline_confirm) - g.add_node("writer_draft", writer_draft_node) - g.add_node("pause_draft", pause_draft_confirm) - g.add_node("editor", editor_node) - g.add_node("pause_review", pause_review_confirm) + g.add_node("writer_draft", writer_draft_node) + g.add_node("pause_section_help", pause_section_help) + g.add_node("pause_draft", pause_draft_confirm) + g.add_node("editor", editor_node) + g.add_node("pause_review", pause_review_confirm) g.set_entry_point("intent_parser") g.add_edge("intent_parser", "researcher") - g.add_edge("researcher", "pause_material") + g.add_conditional_edges("researcher", after_researcher, + {"pause_material": "pause_material", "writer_draft": "writer_draft"}) g.add_conditional_edges("pause_material", after_material_pause, {"researcher": "researcher", "writer_outline": "writer_outline"}) @@ -214,7 +324,12 @@ def build_ai_writing_graph(): g.add_conditional_edges("pause_outline", after_outline_pause, {"writer_outline": "writer_outline", "writer_draft": "writer_draft"}) - g.add_edge("writer_draft", "pause_draft") + g.add_conditional_edges("writer_draft", after_writer_draft, + {"pause_section_help": "pause_section_help", "pause_draft": "pause_draft"}) + + g.add_conditional_edges("pause_section_help", after_section_help_pause, + {"researcher": "researcher", "writer_draft": "writer_draft"}) + g.add_conditional_edges("pause_draft", after_draft_pause, {"end": END, "writer_draft": "writer_draft", "editor": "editor"}) 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 d037e6a3..ab4691dd 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 @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import logging +import re import uuid from typing import List @@ -18,7 +19,62 @@ from ._utils import llm_json logger = logging.getLogger(__name__) -_MAX_MATERIALS = 12 +# 素材总量上限边界:config.yaml 的 ai_writing.max_materials,非法值回退默认 20 +_DEFAULT_MAX_MATERIALS = 20 +_MIN_MAX_MATERIALS = 1 +_MAX_MAX_MATERIALS = 100 + +# 素材检索关键词数量边界:用户可指定 1~10 个,非法值回退默认 6 +_DEFAULT_KEYWORD_COUNT = 6 +_MAX_KEYWORD_COUNT = 10 + + +def _resolve_keyword_count(raw: object) -> int: + """把用户传入的检索关键词数量规整到 [1, _MAX_KEYWORD_COUNT],非法值回退默认 6。""" + try: + n = int(raw) # type: ignore[arg-type] + except (TypeError, ValueError): + return _DEFAULT_KEYWORD_COUNT + return max(1, min(_MAX_KEYWORD_COUNT, n)) + + +def _resolve_max_materials(raw: object) -> int: + """把 config.yaml 配置的素材总量上限规整到 [1, 100],非法值回退默认 20。""" + try: + n = int(raw) # type: ignore[arg-type] + except (TypeError, ValueError): + return _DEFAULT_MAX_MATERIALS + return max(_MIN_MAX_MATERIALS, min(_MAX_MAX_MATERIALS, n)) + + +# 占位/示意性关键词(模型误抄提示词模板时产生),检索前剔除 +_PLACEHOLDER_KEYWORD_RE = re.compile(r"^(关键词\d*|keyword\d*|kw\d*|\.+|…+)$", re.IGNORECASE) + + +def _clean_keywords(raw: object, fallback: str) -> list[str]: + """清洗 LLM 产出的关键词:去空白、丢弃占位符(如「关键词1」「...」)、去重。 + + 全部被滤掉时回退为写作意图本身,避免拿占位词检索出一堆无关素材。 + """ + cleaned: list[str] = [] + for kw in raw if isinstance(raw, list) else []: + s = str(kw).strip() + if not s or _PLACEHOLDER_KEYWORD_RE.match(s): + continue + cleaned.append(s) + deduped = list(dict.fromkeys(cleaned)) + return deduped or [fallback] + + +def _per_keyword_limit(max_materials: int, total_keywords: int) -> int: + """把素材总量上限均分到每个检索词上的检索条数(向上取整)。 + + 向上取整保证合并去重后素材数仍能接近总量上限;最终会再统一截断到上限。 + total_keywords <= 0 时回退为总量上限本身。 + """ + if total_keywords <= 0: + return max_materials + return (max_materials + total_keywords - 1) // total_keywords def _notice(title: str, detail: str, raw: str = "") -> dict: @@ -41,6 +97,12 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict intervention = state.get("last_user_intervention") or {} extra_keywords: list[str] = intervention.get("_extra_keywords") or intervention.get("extra_keywords") or [] + # 素材不足求助点触发的「补充检索」:保留原大纲,检索完直接回写作、不走素材确认/大纲 + is_supplement = intervention.get("action") == "supplement_search" + + # 用户指定的检索关键词数量(1~10),决定生成多少个检索词、检索多少个方向 + keyword_count = _resolve_keyword_count(state.get("keyword_count")) + # SSE 实时进度推送(与 outline/draft 节点共用 sse_queue 机制) cfg = config.get("configurable", {}) sse_queue = cfg.get("sse_queue") @@ -51,6 +113,8 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict ai_writing_config = cfg.get("ai_writing_config", {}) or {} keyword_model: str | None = ai_writing_config.get("keyword_model") or None rank_model: str | None = ai_writing_config.get("rank_model") or None + # 素材总量上限(config.yaml: ai_writing.max_materials),按检索词数均分到每个词 + max_materials = _resolve_max_materials(ai_writing_config.get("max_materials")) async def _emit(message: str) -> None: if sse_queue is None: @@ -68,20 +132,28 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict user_intent=intent, article_type=article_type, target_audience=target_audience, + keyword_count=keyword_count, ), fallback={"keywords": [intent]}, config=config, model_name=keyword_model, ) - keywords: list[str] = kw_result.get("keywords", [intent]) + # 清洗关键词:剔除占位符 / 空值,全空时回退写作意图 + keywords: list[str] = _clean_keywords(kw_result.get("keywords"), intent) if extra_keywords: keywords = list(dict.fromkeys(keywords + extra_keywords)) # Step 2: 并发搜索 provider = get_search_provider(ai_writing_config) - search_keywords = keywords[:6] + # 按用户指定的关键词数量截断;补充搜索时不截断用户额外添加的关键词 + search_limit = keyword_count + if extra_keywords: + search_limit = max(search_limit, len(keywords)) + search_keywords = keywords[:search_limit] total = len(search_keywords) + # 每个检索词分到的检索条数 = 总量上限 / 检索词数(向上取整) + per_keyword = _per_keyword_limit(max_materials, total) await _emit(f"检索词已生成,开始检索 {total} 个方向…") # 并发搜索,但每完成一个就推送进度(计数在单线程 asyncio 下无需加锁) @@ -90,7 +162,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict async def _search_one(kw: str) -> list[SearchResult]: nonlocal completed try: - return await provider.search(kw, max_results=3) + return await provider.search(kw, max_results=per_keyword) finally: completed += 1 if completed < total: @@ -138,7 +210,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict # Step 3: 相关度排序(如素材较多,让 LLM 排序) await _emit("检索完成,正在筛选排序素材…") rank_error = "" - if len(raw_materials) > _MAX_MATERIALS: + if len(raw_materials) > max_materials: materials_list = "\n".join( f"- ID: {m['id']} 标题: {m['title']} 摘要: {m['content'][:100]}" for m in raw_materials @@ -146,7 +218,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict rank_result = await llm_json( RANK_MATERIALS_SYSTEM, RANK_MATERIALS_USER.format(user_intent=intent, materials_list=materials_list), - fallback={"ranked_ids": [m["id"] for m in raw_materials[:_MAX_MATERIALS]], "summary": f"共搜索到{len(raw_materials)}条素材"}, + fallback={"ranked_ids": [m["id"] for m in raw_materials[:max_materials]], "summary": f"共搜索到{len(raw_materials)}条素材"}, config=config, model_name=rank_model, ) @@ -159,10 +231,10 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict for m in raw_materials: if m["id"] not in sorted_ids_set: sorted_materials.append(m) - materials = sorted_materials[:_MAX_MATERIALS] + materials = sorted_materials[:max_materials] summary = rank_result.get("summary", f"共搜索到{len(raw_materials)}条素材,精选{len(materials)}条") else: - materials = sorted(raw_materials, key=lambda m: m.get("relevance_score", 0), reverse=True)[:_MAX_MATERIALS] + materials = sorted(raw_materials, key=lambda m: m.get("relevance_score", 0), reverse=True)[:max_materials] summary = f"共搜索到{len(raw_materials)}条素材,精选{len(materials)}条" # 空结果兜底:未检索到任何素材时给出明确文案,前端据此弹出「无数据继续」选项 @@ -189,7 +261,8 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict return { "material_package": pkg, - "status": "awaiting_material_confirm", + # 补充检索:直接回写作(沿用原大纲);常规检索:进入素材确认暂停点 + "status": "writing_draft" if is_supplement else "awaiting_material_confirm", "progress_events": [ {"type": "step_start", "agent_name": "素材收集专家", "message": f"搜索关键词:{', '.join(keywords[:3])}"}, *notices, 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 14013dd1..d3fdcb8c 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 @@ -3,16 +3,53 @@ from __future__ import annotations import asyncio import logging +import re from langchain_core.messages import HumanMessage, SystemMessage from langchain_core.runnables import RunnableConfig from ..state import AIWritingState, DraftArticle -from ..prompts.writer_prompts import DRAFT_SECTION_SYSTEM, DRAFT_SECTION_USER, DRAFT_EXPAND_USER +from ..prompts.writer_prompts import ( + DRAFT_SECTION_SYSTEM, DRAFT_SECTION_USER, DRAFT_EXPAND_USER, + WRITER_MODE_LOOSE, WRITER_MODE_STRICT, SECTION_BLOCKED_MARKER, +) from ._utils import get_model, _content_to_text, count_words, ThinkTagParser logger = logging.getLogger(__name__) +# 章节正文里的 Markdown 标题行(允许前导空白) +_HEADING_PREFIX_RE = re.compile(r"^[ \t]*#{1,6}[ \t]+", re.MULTILINE) + + +def _normalize_section_headings(text: str) -> str: + """把章节正文里所有 Markdown 标题行统一压成三级标题(###)。 + + 作家被要求只用 ### 作为「要点」小标题;模型偶尔误用 #/##/#### 时在此纠正, + 保证文档标题层级符合「文章 # → 章节 ## → 要点 ###」, + 前端目录栏据此才能把要点正确识别为二级标题。 + """ + return _HEADING_PREFIX_RE.sub("### ", text) + + +# 疑似「拒绝 / 元话语」的强特征短语:模型把对用户说的话当成正文输出时出现, +# 这些短语几乎不可能出现在正常文章里,用作安全网识别。 +_REFUSAL_MARKERS = ( + "我无法完成", "无法完成此任务", "无法完成该任务", "无法完成这个任务", + "无法完成本次", "无法写出符合", "无法撰写这篇", "无法基于现有素材", + "无法基于当前素材", "无法基于提供的素材", +) + + +def _looks_like_refusal(text: str) -> bool: + """判断一段章节产出是否疑似「拒绝 / 元话语」而非真正的文章正文。 + + 模型遇到素材与意图不匹配时,可能把「我无法完成此任务」之类对用户说的话 + 当成正文输出。这里用强特征短语做启发式识别(安全网,非强校验)。 + """ + if not text or not text.strip(): + return False + return any(marker in text for marker in _REFUSAL_MARKERS) + def _get_section_materials(material_ids: list[str], pkg: dict) -> str: mat_map = {m["id"]: m for m in pkg.get("materials", [])} @@ -39,6 +76,10 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di intervention = state.get("last_user_intervention") or {} revision_count = state.get("revision_count", 0) + # 写作模式;用户在「素材不足求助」里选「按通用知识续写」的章节强制走宽松 + writing_mode = state.get("writing_mode", "loose") + loose_titles: set[str] = set(intervention.get("_loose_sections") or []) + # SSE 流式推送所需 cfg = config.get("configurable", {}) sse_queue: asyncio.Queue | None = cfg.get("sse_queue") @@ -89,6 +130,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di section_titles: list[str] = [] section_bufs: list[str] = [] # 各章节正文(不含 ## 标题),供扩写兜底逐节替换 section_errors: list[str] = [] # 分节写作的模型异常,供完成后非致命提示 + blocked_sections: list[dict] = [] # 严格模式下素材不足、无法写作的章节 for i, section in enumerate(sections): sec_title = _section_title(i, section) @@ -104,6 +146,10 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di for j, s in enumerate(sections) ) + # 本章节是否走严格模式(全局严格 且 未被用户标记为「通用知识续写」) + section_is_strict = writing_mode == "strict" and sec_title not in loose_titles + mode_section = WRITER_MODE_STRICT if section_is_strict else WRITER_MODE_LOOSE + user_msg = DRAFT_SECTION_USER.format( article_title=article_title, outline_context=outline_context, @@ -111,6 +157,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di key_points=key_points_str, section_materials=section_materials, revision_section=revision_notes, + mode_section=mode_section, target_words=words_per_section, ) @@ -147,8 +194,27 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di "is_section_done": True, })) + cleaned_buf = section_buf.strip() + blocked_reason = "" + # 严格模式:作家用哨兵声明素材不足,或仍输出了拒绝话术 → 标记为待求助章节 + if section_is_strict and SECTION_BLOCKED_MARKER in cleaned_buf: + blocked_reason = ( + cleaned_buf.split(SECTION_BLOCKED_MARKER, 1)[1].strip()[:120] + or "素材不足以支撑本章节写作" + ) + elif _looks_like_refusal(cleaned_buf): + if section_is_strict: + blocked_reason = "素材不足以支撑本章节写作" + else: + # 宽松模式:模型仍拒绝,安全网占位,不打断流程 + logger.warning("draft section [%s] looks like a refusal, replaced with placeholder", sec_title) + section_errors.append(f"「{sec_title}」生成内容疑似拒绝/无关,已占位") + cleaned_buf = "(本节内容生成异常,建议重新生成或补充相关素材)" + if blocked_reason: + blocked_sections.append({"section_title": sec_title, "reason": blocked_reason}) + cleaned_buf = f"(本节素材不足:{blocked_reason})" section_titles.append(sec_title) - section_bufs.append(section_buf.strip()) + section_bufs.append(cleaned_buf) except Exception as e: logger.error("draft section [%s] failed: %s", sec_title, e, exc_info=True) @@ -156,8 +222,26 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di section_bufs.append("(内容生成失败,请重试)") section_errors.append(f"「{sec_title}」{type(e).__name__}: {e}") + # 严格模式下有章节无法基于素材写作 → 暂停求助用户,不产出半成品草稿 + if blocked_sections: + return { + "blocked_sections": blocked_sections, + "status": "awaiting_section_help", + "progress_events": [ + {"type": "step_start", "agent_name": "作家", "message": f"开始写作(第{revision_count+1}稿)"}, + { + "type": "notice", "agent_name": "作家", "title": "素材不足", + "detail": f"有 {len(blocked_sections)} 个章节缺少足够素材支撑写作,需要你决定如何处理。", + }, + ], + } + def _assemble() -> str: - body = "\n\n".join(f"## {t}\n\n{b}" for t, b in zip(section_titles, section_bufs)) + # 章节正文里的标题统一归一到 ###(要点小标题),章节标题用 ##,文章标题用 # + body = "\n\n".join( + f"## {t}\n\n{_normalize_section_headings(b)}" + for t, b in zip(section_titles, section_bufs) + ) return f"# {article_title}\n\n{body}" full_markdown = _assemble() 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 bd3ad1e6..15f149fc 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 @@ -2,13 +2,16 @@ from __future__ import annotations import asyncio +import json import logging from langchain_core.messages import HumanMessage, SystemMessage from langchain_core.runnables import RunnableConfig from ..state import AIWritingState, Outline, OutlineSection -from ..prompts.writer_prompts import OUTLINE_SYSTEM, OUTLINE_USER +from ..prompts.writer_prompts import ( + OUTLINE_SYSTEM, OUTLINE_USER, OUTLINE_REVISE_SYSTEM, OUTLINE_REVISE_USER, +) from ._utils import get_model, parse_json, _content_to_text, ThinkTagParser logger = logging.getLogger(__name__) @@ -42,20 +45,39 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) -> pkg = state.get("material_package") or {"keywords": [], "materials": [], "summary": ""} intervention = state.get("last_user_intervention") or {} - feedback_section = "" - if intervention.get("action") == "re_outline" and intervention.get("_feedback"): - feedback_section = f"\n重新规划要求:{intervention['_feedback']}" + feedback = "" + if intervention.get("action") == "re_outline": + feedback = intervention.get("_feedback") or intervention.get("outline_feedback") or "" elif intervention.get("outline_feedback"): - feedback_section = f"\n重新规划要求:{intervention['outline_feedback']}" + feedback = intervention.get("outline_feedback") or "" - user_prompt = OUTLINE_USER.format( - user_intent=state["user_intent"], - article_type=state.get("article_type", "analysis"), - target_audience=state.get("target_audience", "普通读者"), - word_count_target=state.get("word_count_target", 800), - materials_summary=_materials_summary(pkg), - feedback_section=feedback_section, - ) + # 重新规划且已有大纲 → 走「编辑模式」:把当前大纲喂给模型,按用户要求调整, + # 而非从零重生成(否则「加一节/删一节」这类指令没有可编辑的基准)。 + current_outline = state.get("current_outline") + is_revise = bool(feedback) and bool(current_outline) + + if is_revise: + system_prompt = OUTLINE_REVISE_SYSTEM + user_prompt = OUTLINE_REVISE_USER.format( + user_intent=state["user_intent"], + article_type=state.get("article_type", "analysis"), + target_audience=state.get("target_audience", "普通读者"), + word_count_target=state.get("word_count_target", 800), + current_outline=json.dumps(current_outline, ensure_ascii=False, indent=2), + feedback=feedback, + materials_summary=_materials_summary(pkg), + ) + else: + system_prompt = OUTLINE_SYSTEM + feedback_section = f"\n重新规划要求:{feedback}" if feedback else "" + user_prompt = OUTLINE_USER.format( + user_intent=state["user_intent"], + article_type=state.get("article_type", "analysis"), + target_audience=state.get("target_audience", "普通读者"), + word_count_target=state.get("word_count_target", 800), + materials_summary=_materials_summary(pkg), + feedback_section=feedback_section, + ) # SSE 流式推送 cfg = config.get("configurable", {}) @@ -70,7 +92,7 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) -> # 留在 try 外会硬崩溃;放进来则统一走下方的 status:error 兜底。 model = get_model(config) async for chunk in model.astream([ - SystemMessage(content=OUTLINE_SYSTEM), + SystemMessage(content=system_prompt), HumanMessage(content=user_prompt), ]): delta = _content_to_text(chunk.content) @@ -98,14 +120,18 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) -> }], } - fallback = { - "title": "未命名文章", - "sections": [ - {"section_title": "引言", "key_points": ["背景介绍"], "material_ids": []}, - {"section_title": "正文", "key_points": ["核心内容"], "material_ids": []}, - {"section_title": "结论", "key_points": ["总结"], "material_ids": []}, - ], - } + # 编辑模式解析失败时回退为「当前大纲」,避免把用户已有大纲弄丢 + if is_revise: + fallback = current_outline + else: + fallback = { + "title": "未命名文章", + "sections": [ + {"section_title": "引言", "key_points": ["背景介绍"], "material_ids": []}, + {"section_title": "正文", "key_points": ["核心内容"], "material_ids": []}, + {"section_title": "结论", "key_points": ["总结"], "material_ids": []}, + ], + } result = parse_json(full_text, fallback) outline = _parse_outline(result) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/researcher_prompts.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/researcher_prompts.py index b8f7e576..5776a764 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/researcher_prompts.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/researcher_prompts.py @@ -1,9 +1,13 @@ +# 注:两个 USER 提示词末尾的 `/no_think` 是 Qwen3 系列的"软开关", +# 收到当轮即跳过思考过程;对其它大模型只是一行普通文本、无副作用。 +# 中文「严禁思考」强令块对所有模型生效,保留不动。 + EXTRACT_KEYWORDS_SYSTEM = """你是一名专业情报研究员(素材收集专家)。 你的目标:为写作任务提取搜索关键词。 你的工作背景:你在一家权威媒体机构任职,熟悉各类信息源的可信度评估。 你只能做以下事情: -1. 从写作意图中提取 3-6 个搜索关键词 +1. 从写作意图中按要求的数量提取搜索关键词 你不能写文章,不能评价内容好坏,只做关键词提取。 【严禁思考】 @@ -25,8 +29,13 @@ EXTRACT_KEYWORDS_USER = """写作意图:{user_intent} 文章类型:{article_type} 目标读者:{target_audience} -请提取 3-6 个搜索关键词,用于搜集相关素材。只输出 JSON: -{{"keywords": ["关键词1", "关键词2"]}}""" +请提取 {keyword_count} 个搜索关键词,用于搜集相关素材。 +关键词数量必须为 {keyword_count} 个,不多不少。 +关键词必须是依据上述写作意图提炼的真实检索词,不要照抄下方 JSON 模板里的占位符。 +只输出 JSON(下方 ... 是占位示意,不是真实关键词): +{{"keywords": ["...", "..."]}} + +/no_think""" RANK_MATERIALS_SYSTEM = """你是一名专业情报研究员(素材收集专家)。 你的目标:对搜索结果按相关度排序和筛选。 @@ -56,4 +65,6 @@ RANK_MATERIALS_USER = """写作意图:{user_intent} {materials_list} 请选出最相关的素材ID(最多8条),按相关度从高到低排序。只输出 JSON: -{{"ranked_ids": ["id1", "id2"], "summary": "共搜索到X条素材,精选Y条"}}""" +{{"ranked_ids": ["id1", "id2"], "summary": "共搜索到X条素材,精选Y条"}} + +/no_think""" diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/writer_prompts.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/writer_prompts.py index 4fc06d43..24fdc38a 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/writer_prompts.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/writer_prompts.py @@ -5,7 +5,17 @@ OUTLINE_SYSTEM = """你是一名专业内容创作者(作家)。 你只能做以下事情: 1. 分析素材,提取核心观点 2. 规划文章章节结构和每节要点 -你不能写正文,只写大纲。 +你不能写正文,只写大纲。大纲须紧扣写作意图,并尽量覆盖可用素材中的核心信息。 + +【严禁思考与表态】(最高优先级) +- 禁止输出任何思考、分析、规划过程,禁止 标签及其中内容。 +- 禁止输出任何对用户说的话或对任务的评价(如"素材不足""无法规划大纲");无论素材是否充分,都必须直接产出大纲 JSON。 +- 收到输入后立即、直接输出最终 JSON,不得有任何前置内容。 + +【要点(key_points)的特殊要求】 +- 每个要点都会作为文章里的一个二级小标题呈现,所以要点必须写成"标题式短语"。 +- 用简洁的名词性短语,控制在 15 字以内,不要写成完整句子,不要带句末标点。 +- 每个章节规划 2-4 个要点,不要过多,避免文章被切得过碎。 【输出要求】 - 只输出一个 JSON 对象,不要任何解释文字,不要 markdown 代码块。 @@ -17,9 +27,9 @@ OUTLINE_SYSTEM = """你是一名专业内容创作者(作家)。 { "title": "新能源汽车出口为何高速增长", "sections": [ - {"section_title": "引言:出口数据创新高", "key_points": ["今年出口量同比增长", "在全球市场的排名变化"], "material_ids": ["m001"]}, + {"section_title": "引言:出口数据创新高", "key_points": ["出口量同比增幅", "全球市场排名变化"], "material_ids": ["m001"]}, {"section_title": "增长背后的三大动因", "key_points": ["产业链成本优势", "海外政策利好", "产品竞争力提升"], "material_ids": ["m002", "m003"]}, - {"section_title": "结论与展望", "key_points": ["未来增长面临的挑战", "行业趋势判断"], "material_ids": ["m004"]} + {"section_title": "结论与展望", "key_points": ["未来增长挑战", "行业趋势判断"], "material_ids": ["m004"]} ] }""" @@ -33,21 +43,98 @@ OUTLINE_USER = """写作意图:{user_intent} 请规划文章大纲(3-5个章节),只输出 JSON 格式。""" +# ── 重新规划(基于现有大纲的编辑)───────────────────────────────────────────── + +OUTLINE_REVISE_SYSTEM = """你是一名专业内容创作者(作家)。 +你的目标:根据用户的修改要求,调整一份【已有的文章大纲】。 +你的工作背景:你有十年内容创作经验,擅长精准理解修改意图并落实到大纲结构上。 + +你只能做以下事情: +1. 在「当前大纲」基础上,按「用户修改要求」做调整 +你不能写正文,只输出调整后的大纲。 + +【修改原则】(最高优先级) +- 准确识别并落实用户意图,常见操作:新增章节、删除章节、合并章节、拆分章节、调整章节顺序、修改章节标题、调整章节要点。 +- 只改用户明确提到的部分:用户没要求改动的章节,必须原样保留(标题、要点、material_ids 全部不变)。 +- 新增章节时:紧扣写作意图补充合理章节,并尽量关联可用素材的 material_ids。 +- 章节数量完全由用户要求决定,没有上下限——用户要加就加、要减就减。 + +【严禁思考与表态】(最高优先级) +- 禁止输出任何思考、分析、规划过程,禁止 标签及其中内容。 +- 禁止输出任何对用户说的话;收到输入后立即、直接输出最终 JSON。 + +【要点(key_points)的特殊要求】 +- 每个要点会作为文章里的二级小标题呈现,写成简洁名词性短语,15 字以内,不带句末标点。 +- 每个章节 2-4 个要点。 + +【输出要求】 +- 只输出一个调整后的【完整】大纲 JSON 对象,不要解释文字,不要 markdown 代码块。 +- 必须使用英文双引号 "。 +- material_ids 中的 ID 必须来自给定素材,没有合适素材时填 []。 +- 结构与「当前大纲」一致: +{"title": "...", "sections": [{"section_title": "...", "key_points": ["..."], "material_ids": ["..."]}]}""" + +OUTLINE_REVISE_USER = """写作意图:{user_intent} +文章类型:{article_type} +目标读者:{target_audience} +目标字数:{word_count_target}字 + +当前大纲(JSON): +{current_outline} + +用户修改要求:{feedback} + +可用素材摘要: +{materials_summary} + +请在「当前大纲」基础上落实上述「用户修改要求」,未提到的章节保持原样,输出调整后的完整大纲 JSON。""" + DRAFT_SECTION_SYSTEM = """你是一名专业内容创作者(作家)。 -你的目标:根据大纲和素材,写出一段高质量的文章内容。 +你的目标:根据大纲和素材,写出一段结构清晰、高质量的文章内容。 你的工作背景:你有十年内容创作经验,文字流畅,善于用事实说话。 你只能做以下事情: 1. 根据给定的章节要点和素材写这一段内容 -2. 观点必须来自素材,不编造数据 +2. 优先采用素材中的事实与数据;素材不足时可结合你的通用知识与常识补充论述,但须紧扣写作意图与章节主题,不要脱离主题杜撰具体的数字、人名、机构名与日期 你不能修改大纲,不能写其他章节,只写当前这一段。 +【严禁思考与表态】(最高优先级) +- 禁止输出任何思考、分析、规划过程,禁止"让我想想""首先""我需要""根据写作要求"之类的过渡语。 +- 禁止输出 标签及其中的任何内容。 +- 禁止输出任何对用户说的话或对任务本身的评价,例如"我无法完成此任务""您提供的素材无法支撑写作""建议您提供……素材""素材与意图不匹配"等——这类话绝不允许出现在文章里。 +- 无论素材是否充分,你都必须直接产出文章正文本身;收到输入后立即开始写正文(含 ### 小标题),不要有任何前置说明。 + +【结构要求】 +- 把本章节的每一个「章节要点」写成一个二级小标题,用 Markdown 三级标题语法:### 要点 +- 每个 ### 小标题下面紧跟该要点的论述正文,正文连贯成段。 +- 即使章节只有一个要点,也要为它写出对应的 ### 小标题。 +- 小标题文字简洁,直接沿用给定的要点措辞,不要加序号、不要加标点。 +- 只能使用 ### 这一级标题;绝不要输出 # 或 ##(文章标题、章节标题由系统统一添加)。 + 【输出要求】 -- 直接返回这一段的正文文字,不要 JSON,不要章节标题,不要解释。 +- 直接返回这一段的内容(含 ### 小标题与正文),不要 JSON,不要解释。 - 不要写"以下是正文""本段内容如下"之类的开场白。 -- 段落要连贯成文,不要罗列要点。 - 紧扣本章节主题,不要写「文章整体结构」中其他章节负责的内容,避免重复。 -- 充分展开每个要点:每个要点至少用 2-3 句话论述,结合素材中的事实与数据说理,写满要求的篇幅。""" +- 充分展开每个要点:每个要点至少用 2-3 句话论述,尽量结合素材中的事实与数据说理,写满要求的篇幅。""" + +# ── 写作模式说明:注入 DRAFT_SECTION_USER 的 {mode_section} ─────────────────── + +# 章节无法基于素材写作时,作家在严格模式下输出的哨兵标记 +SECTION_BLOCKED_MARKER = "[[SECTION_BLOCKED]]" + +WRITER_MODE_LOOSE = """ +【写作模式:宽松】 +素材充分时优先采用素材;素材不足时可结合你的通用知识与常识补充论述, +但必须紧扣写作意图与本章节主题,不要脱离主题杜撰具体数字、人名、机构名。""" + +WRITER_MODE_STRICT = """ +【写作模式:严格依据素材】 +本章节论述必须严格基于上方「相关素材」,不得使用素材之外的知识,不得杜撰任何事实与数据。 +如果上方素材确实不足以支撑本章节的写作,不要勉强拼凑、不要写无关内容, +此时只输出一行:{marker} 一句话说明缺什么素材(例:{marker} 缺少关于XX的具体数据与案例), +此外不要输出任何内容。只要素材能支撑哪怕部分要点,就正常写作,不要输出该标记。""".format( + marker=SECTION_BLOCKED_MARKER +) DRAFT_SECTION_USER = """文章标题:{article_title} 文章整体结构: @@ -58,8 +145,9 @@ DRAFT_SECTION_USER = """文章标题:{article_title} 相关素材: {section_materials} -{revision_section} -请写出「{section_title}」这一章节的正文内容,不少于 {target_words} 字,论述充分、内容充实。""" +{revision_section}{mode_section} +请写出「{section_title}」这一章节的内容,不少于 {target_words} 字。 +把上述每个章节要点写成一个 ### 二级小标题,并在标题下展开论述,论述充分、内容充实。""" DRAFT_EXPAND_USER = """文章标题:{article_title} 当前章节:{section_title} @@ -71,5 +159,8 @@ DRAFT_EXPAND_USER = """文章标题:{article_title} 这一章节已有内容如下: {existing_text} -以上内容篇幅不足。请在保留原意和事实的基础上重写并扩充这一章节,使正文不少于 {target_words} 字。 -要求:补充更多来自素材的事实、数据与分析,论述更充分、更具体;直接返回扩充后的完整章节正文,不要章节标题、不要解释。""" +以上内容篇幅不足。请在保留原意、事实和 ### 二级小标题结构的基础上重写并扩充这一章节,使内容不少于 {target_words} 字。 +要求: +- 补充更多来自素材的事实、数据与分析,论述更充分、更具体。 +- 保留并沿用原有的 ### 小标题(每个章节要点对应一个),只用 ### 这一级标题,不要输出 # 或 ##。 +- 直接返回扩充后的完整章节内容(含 ### 小标题),不要解释。""" diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/state.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/state.py index 8d93f1f3..b958e2a7 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/state.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/state.py @@ -73,17 +73,27 @@ class UserInterventionPayload(TypedDict): WritingStatus = Literal[ "researching", "awaiting_material_confirm", "writing_outline", "awaiting_outline_confirm", - "writing_draft", "awaiting_draft_confirm", + "writing_draft", "awaiting_section_help", "awaiting_draft_confirm", "reviewing", "awaiting_review_confirm", "revising", "done", "error", ] + +class BlockedSection(TypedDict): + """严格模式下素材不足、无法写作的章节。""" + section_title: str + reason: str + class AIWritingState(TypedDict): # 写作配置 user_intent: str article_type: str target_audience: str word_count_target: int + # 素材检索关键词数量(用户指定,1~10,默认 6) + keyword_count: int + # 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) + writing_mode: str # Agent A 输出 material_package: Optional[MaterialPackage] @@ -99,6 +109,9 @@ class AIWritingState(TypedDict): # 用户干预数据(每个干预点会覆盖) last_user_intervention: Optional[UserInterventionPayload] + # 严格模式下素材不足、待用户决策的章节(写作节点产出,求助暂停点消费) + blocked_sections: Optional[List[BlockedSection]] + # 流程控制 revision_count: int max_revisions: int