From 49173d9c28b6a9274a735ca681856071046e021a Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Wed, 24 Jun 2026 17:22:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai=E5=86=99=E4=BD=9C):=20=E5=A4=A7?= =?UTF-8?q?=E7=BA=B2=E6=95=B4=E7=90=86=E5=8F=AF=E8=A7=81=E5=8C=96=20+=20?= =?UTF-8?q?=E6=A3=80=E7=B4=A2=E8=AF=8D=E7=BB=93=E5=90=88=E5=A4=A7=E7=BA=B2?= =?UTF-8?q?=20+=20=E7=B4=A0=E6=9D=90/=E5=A4=A7=E7=BA=B2=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=E5=85=B3=E6=80=9D=E8=80=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 草稿确认卡:「给出修改意见」「让编辑继续审核」移到「确认定稿」上方。 - 大纲整理阶段文案:用户自带大纲时显示「整理你的大纲」而非「规划大纲」 (OutlineStreamingView / 骨架屏 / 状态栏,按 request.userOutline 切换)。 - 素材收集专家:用户自带大纲时把大纲原文带入检索词生成,让关键词结合写作 意图覆盖各章节主题(_keyword_outline_section,agent + legacy 两路)。 - 素材收集(检索词/排序)与大纲整理/生成阶段强制关闭思考 (create_chat_model force_disable_thinking:GLM thinking.disabled + Qwen/vLLM enable_thinking=false),关不掉的模型仍由 ThinkTagParser 把 分流到思考框正常展示。disable_thinking 串入 _utils.get_model/llm_json 与 _agent_runner.stream/run_writing_agent。 - 含草稿「局部修改」WIP(writer_draft 只重写命中章节)及外观控件改动。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workspace/appearance-controls.tsx | 7 +- .../components/ai-writing/AIWritingPanel.tsx | 18 +- .../ai-writing/InterventionCard.tsx | 14 +- .../ai-writing/OutlineStreamingView.tsx | 6 +- .../ai-writing/WritingSkeletonCard.tsx | 16 +- offline-backend-20260512/backend/CLAUDE.md | 2 + .../agents/ai_writing/nodes/_agent_runner.py | 10 +- .../agents/ai_writing/nodes/_utils.py | 16 +- .../agents/ai_writing/nodes/researcher.py | 116 ++++++++- .../agents/ai_writing/nodes/writer_draft.py | 246 ++++++++++++++++++ .../agents/ai_writing/nodes/writer_outline.py | 10 +- .../ai_writing/prompts/researcher_prompts.py | 10 +- .../ai_writing/prompts/writer_prompts.py | 50 ++++ 13 files changed, 485 insertions(+), 36 deletions(-) diff --git a/frontend-web/src/components/workspace/appearance-controls.tsx b/frontend-web/src/components/workspace/appearance-controls.tsx index 02efa7c8..bc11af42 100644 --- a/frontend-web/src/components/workspace/appearance-controls.tsx +++ b/frontend-web/src/components/workspace/appearance-controls.tsx @@ -2,6 +2,7 @@ import { Check, LayoutIcon, PanelLeftIcon, Shirt } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { @@ -140,6 +141,7 @@ export function LayoutModeSwitcher({ fallback?: "full" | "compact"; }) { const [mode, setLayoutMode] = useLayoutMode(); + const navigate = useNavigate(); const current = mode ?? fallback; const TriggerIcon = current === "full" ? PanelLeftIcon : LayoutIcon; @@ -166,7 +168,10 @@ export function LayoutModeSwitcher({ return ( setLayoutMode(option.id)} + onClick={() => { + setLayoutMode(option.id); + navigate("/page/workspace/chats/new"); + }} className="gap-2" > diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx index 683b79f5..8dc166b8 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx @@ -496,9 +496,13 @@ export function AIWritingPanel({ /> )} - {/* 大纲流式思考框 */} + {/* 大纲流式思考框(用户自带大纲时文案显示「整理」) */} {state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText) && ( - + )} {/* 编辑审核流式结果 */} @@ -560,7 +564,11 @@ export function AIWritingPanel({ /* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */ !state.intentStreamingText && !state.intentThinkingText && (
- +
)} @@ -605,7 +613,9 @@ export function AIWritingPanel({ )} {state.status === 'researching' && state.researchProgress ? state.researchProgress - : STATUS_LABEL[state.status] ?? state.status} + : state.status === 'writing_outline' && state.request?.userOutline + ? '作家整理你的大纲中…' + : STATUS_LABEL[state.status] ?? state.status} )} 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 b75f199f..696cdf5a 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/InterventionCard.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/InterventionCard.tsx @@ -455,13 +455,6 @@ function DraftConfirmCard({ draftTitle, draftWordCount, wordCountTarget, revisio )}
- - {/* 按编辑审核意见修改(有审核结果时始终显示) */} {hasReview && (
+ + ); diff --git a/frontend-web/src/open-canvas/components/ai-writing/OutlineStreamingView.tsx b/frontend-web/src/open-canvas/components/ai-writing/OutlineStreamingView.tsx index 4ac3de84..c7d10526 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/OutlineStreamingView.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/OutlineStreamingView.tsx @@ -4,9 +4,11 @@ import { FileText, ChevronDown, ChevronRight, Brain } from 'lucide-react'; interface OutlineStreamingViewProps { text: string; thinkingText?: string; + /** 用户自带大纲时为 true:文案显示「整理你的大纲」而非「规划大纲」。 */ + organizing?: boolean; } -export function OutlineStreamingView({ text, thinkingText }: OutlineStreamingViewProps) { +export function OutlineStreamingView({ text, thinkingText, organizing }: OutlineStreamingViewProps) { const preRef = useRef(null); const thinkRef = useRef(null); const [thinkOpen, setThinkOpen] = useState(true); @@ -66,7 +68,7 @@ export function OutlineStreamingView({ text, thinkingText }: OutlineStreamingVie
- 作家正在规划大纲… + {organizing ? '作家正在整理你的大纲…' : '作家正在规划大纲…'} {[0, 1, 2].map((i) => ( diff --git a/frontend-web/src/open-canvas/components/ai-writing/WritingSkeletonCard.tsx b/frontend-web/src/open-canvas/components/ai-writing/WritingSkeletonCard.tsx index d68414f4..efaf33a9 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/WritingSkeletonCard.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/WritingSkeletonCard.tsx @@ -5,6 +5,8 @@ interface WritingSkeletonCardProps { status: WritingStatus; /** 素材收集阶段的实时进度文案(仅 researching 阶段使用) */ researchProgress?: string; + /** 用户自带大纲时为 true:大纲阶段文案显示「整理你的大纲」而非「规划大纲」。 */ + organizing?: boolean; } // ── 基础骨架元素 ────────────────────────────────────────────────────────────── @@ -80,12 +82,12 @@ function MaterialsSkeleton({ researchProgress }: { researchProgress?: string }) // ── 大纲规划骨架屏 ──────────────────────────────────────────────────────────── -function OutlineSkeleton() { +function OutlineSkeleton({ organizing }: { organizing?: boolean } = {}) { return (
- 作家规划大纲中… + {organizing ? '作家整理你的大纲中…' : '作家规划大纲中…'}
{/* 文章标题占位 */} @@ -213,7 +215,7 @@ const SKELETON_MAP: Partial> = { awaiting_review_confirm: RevisingSkeleton, // 采纳编辑意见后 }; -export function WritingSkeletonCard({ status, researchProgress }: WritingSkeletonCardProps) { +export function WritingSkeletonCard({ status, researchProgress, organizing }: WritingSkeletonCardProps) { // 素材收集阶段单独渲染,以便透传实时进度文案 if (status === 'researching' || status === 'awaiting_material_confirm') { return ( @@ -222,6 +224,14 @@ export function WritingSkeletonCard({ status, researchProgress }: WritingSkeleto
); } + // 大纲阶段单独渲染,以便透传「整理 / 规划」文案(用户自带大纲走「整理」) + if (status === 'writing_outline' || status === 'awaiting_outline_confirm') { + return ( +
+ +
+ ); + } const Skeleton = SKELETON_MAP[status]; if (!Skeleton) return null; return ( diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index fd12f906..f48af212 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -278,6 +278,8 @@ Seeding mirrors the roundtable pattern: verified `config.yaml` + `SOUL.md` ship **Agentic skill execution (`ai_writing.researcher_skill_agent`, default false).** By default a configured non-native skill is *not* run agent-style — the node just turns its `description` into a query for the generic search provider. With this flag on, the directed-skill branch instead runs `nodes/_skill_agent.py::run_skill_search_agent`: it injects the skill's **SKILL.md** (read from `skill.skill_file`) alongside the researcher SOUL as the system prompt, binds a `search` tool to the researcher model, and runs a bounded (`_MAX_TURNS=4`) tool-call loop so the model drives the retrieval per the skill's instructions; the `SearchResult`s the tool returns are harvested directly as materials (no fuzzy text extraction). Requires a tool-calling model; if the model can't tool-call (returns no `tool_calls`) or the agent yields nothing, `_execute_skill` falls back to the legacy description→query search so materials are never empty. The built-in `knowledge-base-search` native concurrent path is unchanged. Tests: `tests/test_ai_writing_skill_agent.py`. +**Targeted user revision (局部修改 — 只改命中章节).** When the user submits a free-text 修改意见 on the draft-confirm card (`action == "user_revise"`), `writer_draft_node` no longer always rewrites the whole article section-by-section. Before the full-rewrite loop it tries `_targeted_user_revision` (`nodes/writer_draft.py`): `_split_draft_sections` parses the previous `current_draft.full_markdown` into `(article_title, [(section_title, body)], references_block)` (level-1 `#` title, level-2 `##` sections, trailing `## 参考文献` pulled out and kept verbatim), then `_detect_revision_scope` runs a small `llm_json` classifier (prompts `DRAFT_REVISE_SCOPE_{SYSTEM,USER}`) that maps the note to the section indices it targets ("第一段/开头/引言"→0, "结尾/结论"→last, by title/topic, etc.). **Only the hit sections are rewritten** via `_stream_revise_section` (prompt `DRAFT_REVISE_SECTION_USER`, streamed as `draft_chunk` so the user watches the edit); **every unhit section is preserved byte-for-byte** and emitted to the stream as one full-text `draft_chunk` + `is_section_done` so the frontend's `streamingDraftSections` rebuilds the complete article (it was reset to `[]` on entering `revising`). The classifier returns `whole_article=true` (整体性意见 like 「整体语气更正式」/「全文都改」, or any structure-changing ask) → `_detect_revision_scope` yields `None` → `_targeted_user_revision` returns `None` → the node **falls back to the existing whole-article rewrite** (which still runs `_revise_outline_with_notes` for title/structure changes). Also falls back when there is no prior draft, an empty note, or scope detection errors. Editor-review revisions (`accept_review`) are unaffected — they keep the holistic rewrite. Reassembly preserves the article title + section structure and re-appends the original references block; word count is computed on the body (excluding references), matching the main path. Tests: `tests/test_ai_writing_targeted_revision.py`. + **Background suspend (后台挂起).** A writing session can be handed off to the server to auto-drive the remaining flow to finalize, surviving page leave / refresh / account switch. `POST /api/ai-writing/sessions/{id}/background` (owner-checked, idempotent) starts `AIWritingJobExecutor` (`app/gateway/ai_writing_job_executor.py`), wired in `deps.py` as `app.state.ai_writing_job_executor`. The executor **drives the same `ai_writing` graph in-process** — it builds `make_ai_writing_graph()`, attaches the shared `app.state.checkpointer` (the very saver the runtime worker uses, so it continues the real thread state the frontend already wrote), sets the owning user via `set_current_user`, and loops `aget_state` → `ainvoke(Command(resume=…))` until `status==done`. **Auto-action per interrupt**: material/outline confirm → `confirm`; section_help → every blocked section `loose` (continue with general knowledge, never re-loop to research); draft_confirm → `to_editor` until reviewed, then `finalize` (on pass, or when `revision_count >= max_revisions`); review_confirm → `accept_review` (revise & re-review until pass or budget exhausted, then forced finalize). To avoid racing a still-running runtime worker (suspend mid-node), the driver **waits for an actionable state** (`_wait_for_actionable`): it only acts when the graph is at an interrupt / done, or when the checkpoint stops advancing (no worker → abandoned/restart). No new table — the graph checkpoint + `ai_writing_sessions` already hold all state; `status="background"` marks 挂起中 (surfaced in the history dropdown), nodes write `draft`/`review_result`/`status=done` as usual, so the user reopens the finished article from history. Per-user concurrency cap `AI_WRITING_BACKGROUND_SLOTS_PER_USER` (default 2, same SQLite-lock rationale as the roundtable executor). Startup `reconcile` (in `deps.py`) relaunches drivers for sessions left `background` after a restart (`AIWritingSessionRepository.list_by_status`). **Progress flowchart popup**: `GET /api/ai-writing/sessions/{id}/progress` (owner-checked) reads the graph checkpoint via `executor.get_progress` and derives a canonical pipeline `stage` key (`intent/research/material_confirm/outline/outline_confirm/draft/draft_confirm/review/done`, from the pending interrupt's `pause_point` or `snapshot.next` node) + `note` (素材不足求助 / 编辑打回) + review progress + `isRunning`. Frontend: 「后台挂起」 button + auto-suspend on route unmount / `beforeunload` (`AIWritingPanel.tsx`, `suspendAIWritingToBackground[Keepalive]` in `api/ai-writing-sessions.ts`); 挂起中 badge in `AIWritingHistoryDropdown.tsx`, and **clicking a 挂起中 history record opens a flowchart dialog** (`AIWritingFlowDialog.tsx` polls `getAIWritingProgress` every 2.5s, renders the fixed vertical pipeline `AIWritingFlowChart.tsx` lit by the live `stage`) so the user sees how far the background run has gotten without reopening/taking over the session. **Transcript consistency**: the rich history timeline (`AIWritingHistoryView` → `AIWritingTimeline`) replays `transcript = {progressEvents, completedInterventions}`, normally assembled+saved client-side. A background run has no client, so the executor rebuilds it at the end (`_save_transcript`/`_build_transcript_from_checkpoint`) **entirely from the final checkpoint** — both `progressEvents` and `completedInterventions` are derived from the same set of `resumed` events (every pause-resume emits exactly one), so they are always 1:1 aligned. For each `resumed`: classify its message → `(pausePoint, action)` (`_classify_resumed`, matched against `graph.py`'s resume messages), insert an `await_user` (with the pause prompt) before it, and build a **rich** intervention card enriched from final state (material_confirm→`materials`, outline_confirm→`outline`, draft_confirm→`draftTitle`/`draftWordCount`/`reviewResult`, review_confirm→`reviewResult`); `materials_ready` is inserted before the first material-confirm. (Earlier code merged the frontend's pre-suspend `completedInterventions` with auto ones and aligned by index — but the frontend's last save can predate its most recent decision, so the counts drifted and cards rendered off-by-one with empty data; deriving everything from the checkpoint root-fixes both.) Raw (snake_case) events **and** intervention data are normalized on load by the **exported** `normalizeProgressDict` / `normalize{Material,Outline,ReviewResult}` (idempotent on already-camelCase live transcripts — one normalize path for both sources), so reopening a background-completed session shows the same cards as a frontend-driven one. Tests: `tests/test_ai_writing_background.py`. ### Admin Leaderboard Snapshots (`/api/admin/users/leaderboard*`) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_agent_runner.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_agent_runner.py index 0fee6a2b..74516df7 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_agent_runner.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_agent_runner.py @@ -184,6 +184,7 @@ async def stream_writing_agent( on_text: Callable[[str, bool], None] | None = None, explicit_model_fallback: str | None = None, strict_model: bool = True, + disable_thinking: bool = False, ) -> str: """在「SOUL 作 system prompt + 单条 Human 输入」契约下流式运行一个写作 agent。 @@ -194,6 +195,9 @@ async def stream_writing_agent( chunk 包装成本阶段的 custom 事件(outline_chunk / draft_chunk / review_chunk ...)推给前端;None = 不透传(如 researcher 的辅助任务)。 explicit_model_fallback / strict_model: 见 resolve_agent_model_name。 + disable_thinking: True 时强制关闭模型思考(force_disable_thinking),用于 + 素材收集/大纲整理这类不需要思考的阶段(GLM5 等爱出 )。关不掉的 + 模型仍由下方 ThinkTagParser 把思考分流到思考框,不会泄进正文。 Returns: 累积的**正文**文本( 思考与 reasoning_content 已剥离),供 @@ -212,7 +216,7 @@ async def stream_writing_agent( from deerflow.models import create_chat_model - model = create_chat_model(name=model_name) + model = create_chat_model(name=model_name, force_disable_thinking=disable_thinking) full_text = "" parser = ThinkTagParser() @@ -246,12 +250,15 @@ async def run_writing_agent( on_text: Callable[[str, bool], None] | None = None, explicit_model_fallback: str | None = None, strict_model: bool = True, + disable_thinking: bool = False, ) -> dict: """跑一个写作 agent 并按 marker 契约解析其结构化输出(失败回 fallback)。 模型调用异常时与 ``_utils.llm_json`` 同口径:返回 ``{**fallback, "_llm_error": str(e)}``,不上抛(SOUL 缺失除外—— WritingAgentUnavailable 原样上抛,由节点切回旧 prompt 路径)。 + + disable_thinking: 透传给 stream_writing_agent,强制关闭思考(见其文档)。 """ try: full_text = await stream_writing_agent( @@ -262,6 +269,7 @@ async def run_writing_agent( on_text=on_text, explicit_model_fallback=explicit_model_fallback, strict_model=strict_model, + disable_thinking=disable_thinking, ) except WritingAgentUnavailable: raise diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_utils.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_utils.py index 95afab36..6c023132 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_utils.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_utils.py @@ -63,15 +63,21 @@ def safe_get_stream_writer(): return lambda _payload: None -def get_model(config: RunnableConfig, model_name: str | None = None): +def get_model(config: RunnableConfig, model_name: str | None = None, *, disable_thinking: bool = False): """创建对话模型。 model_name 显式传入时优先使用(如素材收集专家的检索词/排序专用快模型), 否则回退到 configurable.model_name(用户在写作表单里选的主模型)。 + + disable_thinking=True 时强制关闭模型的思考/推理(force_disable_thinking): + 对 OpenAI 兼容网关注入 GLM ``thinking.type=disabled`` / Qwen·vLLM + ``enable_thinking=False`` 三种禁用形态,用于素材收集、大纲整理这类不需要思考的 + 结构化任务(GLM5 等内网模型尤其爱出 )。关不掉的模型由各调用方的 + ThinkTagParser 兜底,照常把思考分流到思考框。 """ from deerflow.models import create_chat_model name = model_name or config.get("configurable", {}).get("model_name", "") or None - return create_chat_model(name=name) + return create_chat_model(name=name, force_disable_thinking=disable_thinking) def resolve_writing_model_name(config: RunnableConfig, state: dict) -> tuple[str | None, bool]: @@ -316,16 +322,18 @@ class ThinkTagParser: return 0 -async def llm_json(system: str, user: str, fallback: dict, config: RunnableConfig, model_name: str | None = None) -> dict: +async def llm_json(system: str, user: str, fallback: dict, config: RunnableConfig, model_name: str | None = None, *, disable_thinking: bool = False) -> dict: """调用 LLM,解析 JSON 输出,失败用 fallback。 model_name 可覆盖默认模型(如检索词/排序使用配置的专用快模型)。 + disable_thinking=True 时强制关闭思考(见 get_model),用于检索词/素材排序这类 + 结构化抽取任务,省掉 GLM5 等模型的 思考开销。 """ messages = [SystemMessage(content=system), HumanMessage(content=user)] try: # get_model 放进 try:模型名在 config.yaml 找不到时 create_chat_model 会抛 # ValueError,若留在 try 外会逐层上抛使整个图硬崩溃。 - model = get_model(config, model_name=model_name) + model = get_model(config, model_name=model_name, disable_thinking=disable_thinking) resp = await model.ainvoke(messages) return parse_json(_content_to_text(resp.content), fallback) except Exception as e: 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 26047a51..e84631ca 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 @@ -52,13 +52,31 @@ _AGENT_KEYWORDS_USER = """任务:提取检索词 写作意图:{user_intent} 文章类型:{article_type} 目标读者:{target_audience} - +{outline_section} 请提取 {keyword_count} 个搜索关键词,用于搜集相关素材。 -关键词数量必须为 {keyword_count} 个,不多不少;必须依据上述写作意图提炼真实检索词。 +关键词数量必须为 {keyword_count} 个,不多不少;必须依据上述写作意图(及大纲,若有)提炼真实检索词。 严格按 SOUL 约定输出 [KEYWORDS_READY] + JSON。 /no_think""" +# 用户自带大纲时,检索词生成的大纲提示片段做长度截断,防止 token 溢出。 +_OUTLINE_KEYWORD_SNIPPET_CHARS = 1000 + + +def _keyword_outline_section(state: AIWritingState) -> str: + """用户自带大纲时,给检索词生成附上大纲原文,让关键词覆盖各章节主题。 + + 无大纲返回空串(提示词里该行变空行,行为与原来一致)。 + """ + raw = (state.get("user_outline_raw") or "").strip() + if not raw: + return "" + snippet = raw[:_OUTLINE_KEYWORD_SNIPPET_CHARS] + return ( + "用户已提供文章大纲,请结合写作意图,让检索词覆盖大纲涉及的各章节主题:\n" + f"{snippet}" + ) + _AGENT_RANK_USER = """任务:素材排序 写作意图:{user_intent} @@ -225,6 +243,59 @@ def _per_keyword_limit(max_materials: int, total_keywords: int) -> int: return (max_materials + total_keywords - 1) // total_keywords +# 「再追加 N 条素材」之类的数量指令:动词 + 数字(+ 量词 + 名词)。 +_APPEND_COUNT_VERBS = "追加|新增|增加|补充|再来|多来|多给|多找|多搜|多检索|再给|再要|再找|再搜|再加" +_APPEND_COUNT_RE = re.compile( + rf"(?:再|又|继续|另外?|额外)?\s*(?:{_APPEND_COUNT_VERBS})\s*" + r"(?:我|你|您|咱|大家|一下|一些|些|到)?\s*" + r"([0-9一二三四五六七八九十两]+)\s*(?:条|个|篇|份)?\s*(?:素材|资料|内容|结果|数据|文献)?" +) +_CN_DIGITS = {"零": 0, "一": 1, "两": 2, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9} + + +def _cn_to_int(s: str) -> int | None: + """把数量字符串解析成整数:阿拉伯数字直接转,常见中文数字(0~99)兜底。""" + if s.isdigit(): + try: + return int(s) + except ValueError: + return None + if "十" in s: # 十 / 十X / X十 / X十Y + left, _, right = s.partition("十") + tens = _CN_DIGITS.get(left, 1) if left else 1 + ones = _CN_DIGITS.get(right, 0) if right else 0 + return tens * 10 + ones + if len(s) == 1 and s in _CN_DIGITS: + return _CN_DIGITS[s] + return None + + +def _parse_append_request(text: str) -> tuple[int | None, str]: + """从用户的追加输入里解析「再追加 N 条素材」这类显式数量诉求。 + + 返回 ``(count, residual_topic)``: + - ``count``:用户要求新增的素材条数(1~100),未显式说明 → ``None``。 + - ``residual_topic``:剔除数量指令后剩下的检索主题。为空表示用户只给了数量、 + 没给新主题,调用方应回退到写作意图(而不是把「再追加10条素材」当检索词)。 + """ + raw = (text or "").strip() + if not raw: + return None, "" + m = _APPEND_COUNT_RE.search(raw) + if not m: + return None, raw + count = _cn_to_int(m.group(1)) + if count is None or count <= 0: + return None, raw + count = min(count, _MAX_MAX_MATERIALS) + residual = (raw[: m.start()] + raw[m.end():]).strip() + # 去掉残留的纯标点 / 语气词后,判断是否还剩真正的主题 + residual_core = re.sub(r"[,,。.、;;::!!??\s的了吧呢啊]+", "", residual) + if len(residual_core) < 2: + residual = "" + return count, residual + + def _notice(title: str, detail: str, raw: str = "", agent_name: str = "素材收集专家") -> dict: """构造一个非致命告警事件,前端在时间线显示「标题 + 真实原因」,不打断写作流程。 @@ -361,6 +432,15 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict is_user_query_search = action in ("re_search", "enable_skill_search") and bool(user_query) # 兼容旧版"追加关键词"调用方(如果还有 client 传 extra_keywords 数组而非 user_query) is_append_only = action == "re_search" and not user_query and bool(extra_keywords) + + # 用户在追加输入里显式说「再追加 N 条素材」:尊重这个数量。若只给了数量、没给新主题, + # 就按原写作意图继续检索,而不是把「再追加10条素材」这串话本身当成检索词。 + requested_append_count: int | None = None + effective_user_query: str = user_query + if is_user_query_search: + requested_append_count, residual_topic = _parse_append_request(user_query) + if requested_append_count is not None: + effective_user_query = residual_topic or intent # 用户在写作表单选择的检索类型:general(通用检索,按 agent 配置技能编排)/ # notebook(我的空间)。旧值 knowledge_base / skill 一律按 general 兼容。 raw_source = (state.get("material_source") or "general").strip() @@ -418,15 +498,18 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict # Step 1: 提取关键词 kw_result: dict = {} # 走"用户输入 / 追加"分支时跳过 LLM 调用,notice 拼接需要兜底值 if is_user_query_search: - # 用户输入分支:把整段自然语言作为唯一检索词,尊重原意,不再做关键词拆分 - keywords: list[str] = [user_query] - _emit(f"按你的输入「{user_query[:30]}」匹配技能检索…") + # 用户输入分支:把整段自然语言作为唯一检索词,尊重原意,不再做关键词拆分。 + # 若用户只说了「再追加 N 条素材」,effective_user_query 已回退为写作意图。 + keywords: list[str] = [effective_user_query] + _emit(f"按你的输入「{effective_user_query[:30]}」匹配技能检索…") elif is_append_only: # 兼容旧版追加模式:直接用用户填的关键词,不再调 LLM keywords = _clean_keywords(extra_keywords, intent) _emit(f"追加检索 {len(keywords)} 个关键词…") else: _emit("正在生成检索词…") + # 用户自带大纲时,把大纲原文带进检索词生成,让关键词结合意图覆盖各章节主题 + outline_section = _keyword_outline_section(state) if use_agent: kw_result = await run_writing_agent( agent_id=RESEARCHER_AGENT_ID, @@ -435,6 +518,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict article_type=article_type, target_audience=target_audience, keyword_count=keyword_count, + outline_section=outline_section, ), marker=KEYWORDS_READY_MARKER, fallback={"keywords": [intent]}, @@ -442,6 +526,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict config=config, explicit_model_fallback=keyword_model, strict_model=False, + disable_thinking=True, ) else: kw_result = await llm_json( @@ -451,10 +536,12 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict article_type=article_type, target_audience=target_audience, keyword_count=keyword_count, + outline_section=outline_section, ), fallback={"keywords": [intent]}, config=config, model_name=keyword_model, + disable_thinking=True, ) # 清洗关键词:剔除占位符 / 空值,全空时回退写作意图 keywords = _clean_keywords(kw_result.get("keywords"), intent) @@ -527,6 +614,9 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict configured = _load_configured_skills() provider = get_search_provider(ai_writing_config) per_skill = max(1, max_materials) + # 用户显式要「再追加 N 条」时,单技能至少检索 N 条,保证能凑够数量(末尾再截断到 N) + if requested_append_count: + per_skill = max(per_skill, requested_append_count) ordered = configured if is_user_query_search and len(configured) > 1: @@ -537,7 +627,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict model_name=keyword_model, emit_step=_emit_skill_step, max_select=5, - user_query=user_query, + user_query=effective_user_query, candidates=configured, ) ranked = await picker.pick(user_intent=intent) @@ -649,7 +739,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict skill=sk, intent=intent, keywords=keywords, - user_query=user_query if is_user_query_search else "", + user_query=effective_user_query if is_user_query_search else "", config=config, state=state, model_name_fallback=keyword_model, @@ -664,7 +754,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict # 定向技能检索(默认路径 / 智能体模式回退):user_query 模式优先用用户原话; # 非知识库技能再附上 description 让 query 贴合该技能的领域。 if is_user_query_search: - skill_query = user_query if is_knowledge else f"{user_query} {sk.description or ''}".strip() + skill_query = effective_user_query if is_knowledge else f"{effective_user_query} {sk.description or ''}".strip() else: skill_query = f"{intent} {sk.description or ''}".strip() or intent _emit_skill_step({ @@ -697,13 +787,16 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict "matched_count": added, }) + # 早停上限:通常为 max_materials;用户显式要「再追加 N 条」时放宽到 N,确保凑够 + collect_limit = max(max_materials, requested_append_count or 0) + 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: + if len(new_results) >= collect_limit: _emit_skill_step({ "type": "stop_early", "message": f"已获取 {len(new_results)} 条素材,达到上限,停止调用后续技能", @@ -754,6 +847,9 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict appended_count = 0 for r in new_results: + # 用户显式要「再追加 N 条」时,凑够 N 条新素材即止,严格按用户要求的数量给 + if requested_append_count and appended_count >= requested_append_count: + break if r.url and r.url in seen_urls: continue if r.url: @@ -799,6 +895,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict config=config, explicit_model_fallback=rank_model, strict_model=False, + disable_thinking=True, ) else: rank_result = await llm_json( @@ -807,6 +904,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict fallback=rank_fallback, config=config, model_name=rank_model, + disable_thinking=True, ) rank_error = rank_result.get("_llm_error", "") ranked_ids: list[str] = rank_result.get("ranked_ids", [m["id"] for m in raw_materials]) 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 d84f770c..e44f45d0 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 @@ -11,6 +11,7 @@ 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, DRAFT_COMPRESS_USER, + DRAFT_REVISE_SCOPE_SYSTEM, DRAFT_REVISE_SCOPE_USER, DRAFT_REVISE_SECTION_USER, OUTLINE_REVISE_SYSTEM, OUTLINE_REVISE_USER, WRITER_MODE_LOOSE, WRITER_MODE_STRICT, SECTION_BLOCKED_MARKER, ) @@ -363,6 +364,234 @@ async def _save_draft_to_repo(config: RunnableConfig, draft: DraftArticle) -> No logger.exception("writer_draft: save_draft failed (non-fatal) thread_id=%s", thread_id) +# ── 用户局部修改(只改命中章节,其余原样保留)──────────────────────────────── +# +# 用户在草稿确认卡填「修改意见」(action=user_revise)时,若意见只指向部分章节 +# (如「帮我修改一下第一段」),不再整篇逐节重写——先用 LLM 识别意见指向的章节, +# 只重写命中章节、其余章节字节级原样保留。意见偏整体性 / 无法定位时返回 None,由 +# writer_draft_node 回退到既有的整篇重写路径。 + +# 文章标题(单 #)与章节标题(双 ##)的行级匹配。`^#[ \t]+` 不会命中 `## `(第 2 字符是 +# `#` 非空白);`^##[ \t]+` 也不会命中 `### / ####`,互不串扰。 +_ARTICLE_TITLE_RE = re.compile(r"^#[ \t]+(.+?)[ \t]*$", re.MULTILINE) +_SECTION_HEADING_RE = re.compile(r"^##[ \t]+(.+?)[ \t]*$", re.MULTILINE) +_REFERENCES_TITLE = "参考文献" + + +def _split_draft_sections(markdown: str) -> tuple[str, list[tuple[str, str]], str | None]: + """把成稿 Markdown 拆成 (文章标题, [(章节标题, 章节正文), ...], 文末参考文献块或 None)。 + + 章节正文不含 `## 标题` 行(与写作循环里的 section_bufs 语义一致)。文末「## 参考文献」 + 单独抽出原样保留,不计入可编辑章节。 + """ + matches = list(_SECTION_HEADING_RE.finditer(markdown)) + head = markdown[: matches[0].start()] if matches else markdown + m_title = _ARTICLE_TITLE_RE.search(head) + article_title = (m_title.group(1).strip() if m_title else "").strip() + + sections: list[tuple[str, str]] = [] + references_block: str | None = None + for i, m in enumerate(matches): + title = m.group(1).strip() + body_start = m.end() + body_end = matches[i + 1].start() if i + 1 < len(matches) else len(markdown) + if title == _REFERENCES_TITLE: + references_block = markdown[m.start():body_end].strip("\n") + continue + body = markdown[body_start:body_end].strip("\n") + sections.append((title, body)) + return article_title, sections, references_block + + +async def _detect_revision_scope( + state: AIWritingState, config: RunnableConfig, + parsed_sections: list[tuple[str, str]], note: str, +) -> set[int] | None: + """LLM 识别修改意见指向哪些章节编号;整体性/无法定位/异常一律返回 None(回退整篇重写)。""" + listing = "\n".join(f"{i}. {t}" for i, (t, _b) in enumerate(parsed_sections)) + model_name = (state.get("model_name") or "").strip() or None + try: + result = await llm_json( + DRAFT_REVISE_SCOPE_SYSTEM, + DRAFT_REVISE_SCOPE_USER.format(n=len(parsed_sections), sections=listing, note=note), + {"whole_article": True, "target_indexes": []}, + config, + model_name=model_name, + ) + except Exception as e: + logger.warning("draft targeted-revise: scope detection failed: %s", e) + return None + if not isinstance(result, dict) or result.get("whole_article"): + return None + raw = result.get("target_indexes") or result.get("targetIndexes") or [] + if not isinstance(raw, list): + return None + idxs: set[int] = set() + for x in raw: + try: + k = int(x) + except (ValueError, TypeError): + continue + if 0 <= k < len(parsed_sections): + idxs.add(k) + return idxs or None + + +async def _stream_revise_section( + state: AIWritingState, config: RunnableConfig, *, + article_title: str, idx: int, section_title: str, existing_text: str, key_points_str: str, + section_materials: str, note: str, add_references: bool, + writer, use_agent: bool, legacy_model_name: str | None, +) -> str: + """流式重写单个命中章节并返回新正文(失败/为空时回退原文)。""" + msg = DRAFT_REVISE_SECTION_USER.format( + article_title=article_title or "文章", + section_title=section_title, + key_points=key_points_str, + section_materials=section_materials, + existing_text=existing_text, + note=note, + ) + if add_references: + msg += "\n\n注意:保留原文中已有的引用角标(形如 [1]、[2]),不要删除或改动它们。" + + def _on_text(text: str, is_thinking: bool, _i: int = idx, _t: str = section_title) -> None: + writer({ + "type": "draft_chunk", "section_index": _i, "section_title": _t, + "text": text, "is_thinking": is_thinking, "is_section_done": False, + }) + + try: + if use_agent: + buf = await stream_writing_agent( + agent_id=WRITER_AGENT_ID, user_payload=msg, + state=state, config=config, on_text=_on_text, + ) + else: + from deerflow.models import create_chat_model + buf = "" + parser = ThinkTagParser() + m = create_chat_model(name=legacy_model_name) if legacy_model_name else create_chat_model() + async for chunk in m.astream([ + SystemMessage(content=DRAFT_SECTION_SYSTEM), + HumanMessage(content=msg), + ]): + reasoning = reasoning_delta(chunk) + if reasoning: + _on_text(reasoning, True) + delta = _content_to_text(chunk.content) + if not delta: + continue + for is_thinking, text in parser.feed(delta): + if not is_thinking: + buf += text + _on_text(text, is_thinking) + except Exception as e: + logger.warning("draft targeted-revise: section [%s] rewrite failed, keep original: %s", section_title, e) + buf = "" + finally: + writer({ + "type": "draft_chunk", "section_index": idx, "section_title": section_title, + "text": "", "is_section_done": True, + }) + cleaned = (buf or "").strip() + return cleaned or existing_text + + +async def _targeted_user_revision( + state: AIWritingState, config: RunnableConfig, *, + pkg: dict, cite_map: dict | None, add_references: bool, note: str, + writer, agent_label: str, use_agent: bool, legacy_model_name: str | None, + extra_notices: list[dict] | None = None, +) -> dict | None: + """尝试只重写意见命中的章节;不适用(无旧稿/意见偏整体/解析失败)时返回 None 走整篇重写。""" + prev_draft = state.get("current_draft") or {} + prev_md = (prev_draft.get("full_markdown") or "").strip() + if not prev_md or not note.strip(): + return None + article_title, parsed, references_block = _split_draft_sections(prev_md) + if not parsed: + return None + + targets = await _detect_revision_scope(state, config, parsed, note) + if not targets: + return None + + outline = state.get("current_outline") or {"title": article_title, "sections": []} + out_sections = outline.get("sections", []) + revision_count = state.get("revision_count", 0) + + new_bodies: list[str] = [] + for idx, (sec_title, body) in enumerate(parsed): + if idx in targets: + out_sec = out_sections[idx] if idx < len(out_sections) else {} + key_points_str = _render_key_points_for_prompt( + out_sec.get("key_points") or out_sec.get("keyPoints") or [] + ) + section_materials = _get_section_materials( + out_sec.get("material_ids") or out_sec.get("materialIds") or [], pkg, cite_map + ) + new_body = await _stream_revise_section( + state, config, article_title=article_title, idx=idx, section_title=sec_title, existing_text=body, + key_points_str=key_points_str, section_materials=section_materials, + note=note, add_references=add_references, writer=writer, + use_agent=use_agent, legacy_model_name=legacy_model_name, + ) + new_bodies.append(new_body) + else: + # 未改动章节:整段瞬时下发 + 收尾,让前端草稿视图完整重建(其余字节级原样保留) + writer({ + "type": "draft_chunk", "section_index": idx, "section_title": sec_title, + "text": body, "is_thinking": False, "is_section_done": False, + }) + writer({ + "type": "draft_chunk", "section_index": idx, "section_title": sec_title, + "text": "", "is_section_done": True, + }) + new_bodies.append(body) + + body_md = "\n\n".join( + f"## {t}\n\n{_normalize_section_headings(b)}" + for (t, _old), b in zip(parsed, new_bodies) + ) + full_markdown = f"# {article_title}\n\n{body_md}" + word_count = count_words(full_markdown) + if references_block: + full_markdown = f"{full_markdown}\n\n{references_block}" + + draft = DraftArticle( + title=article_title or outline.get("title", "文章"), + outline=outline, + full_markdown=full_markdown, + word_count=word_count, + revision_count=revision_count, + ) + await _save_draft_to_repo(config, draft) + + changed_names = "、".join(parsed[i][0] for i in sorted(targets)) + notices: list[dict] = list(extra_notices or []) + notices.append({ + "type": "notice", "agent_name": agent_label, "title": "局部修改", + "detail": f"已按你的意见只修改了 {len(targets)} 个章节({changed_names}),其余内容原样保留。", + }) + return { + "current_draft": draft, + "drafts": [draft], + "status": "awaiting_draft_confirm", + "progress_events": [ + {"type": "step_start", "agent_name": agent_label, "message": f"开始局部修改(第{revision_count+1}稿)"}, + *notices, + { + "type": "draft_ready", "agent_name": agent_label, + "message": f"修改完成,共 {word_count} 字", + "draft_title": article_title or outline.get("title", "文章"), + "draft_markdown": full_markdown, + "revision_count": revision_count, + }, + ], + } + + async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> dict: outline = state.get("current_outline") if not outline: @@ -390,9 +619,11 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di # 修改意见(来自用户或编辑) revision_notes = "" + user_note_raw = "" # 用户原话(不含「用户修改意见:」前缀),供局部修改的范围识别与单节重写用 if intervention.get("action") == "user_revise": notes = intervention.get("_user_notes") or intervention.get("user_revision_notes", "") if notes: + user_note_raw = notes revision_notes = f"\n用户修改意见:{notes}" elif intervention.get("action") in ("accept_review", "custom_revise"): review = state.get("review_result") or {} @@ -472,6 +703,21 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di } draft_chain = fallback_chain(initial_name) from deerflow.models import create_chat_model + + # 用户局部修改:意见只指向部分章节时,只重写命中章节、其余字节级原样保留(不整篇重写)。 + # 偏整体性 / 无法定位 / 无旧稿时 _targeted_user_revision 返回 None,继续走下面的整篇重写路径。 + if intervention.get("action") == "user_revise" and user_note_raw.strip(): + legacy_model_name = draft_chain[0] if (not use_agent and draft_chain) else None + targeted = await _targeted_user_revision( + state, config, + pkg=pkg, cite_map=cite_map, add_references=add_references, + note=user_note_raw, writer=writer, agent_label=agent_label, + use_agent=use_agent, legacy_model_name=legacy_model_name, + extra_notices=[model_default_notice] if model_default_notice else None, + ) + if targeted is not None: + return targeted + section_titles: list[str] = [] section_bufs: list[str] = [] # 各章节正文(不含 ## 标题),供扩写兜底逐节替换 section_errors: list[str] = [] # 分节写作的模型异常,供完成后非致命提示 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 ad59d9a5..fa8e73cb 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 @@ -229,7 +229,9 @@ async def _stream_normalize_user_outline( try: from deerflow.models import create_chat_model - model = create_chat_model(name=name) + # 大纲整理是结构化保真任务,强制关思考(GLM5 等爱出 ); + # 关不掉的模型仍由下方 ThinkTagParser 把思考分流到思考框实时展示。 + model = create_chat_model(name=name, force_disable_thinking=True) parser = ThinkTagParser() async for chunk in model.astream([ SystemMessage(content=OUTLINE_NORMALIZE_SYSTEM), @@ -391,6 +393,7 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) -> full_text = await stream_writing_agent( agent_id=OUTLINER_AGENT_ID, user_payload=agent_payload, + disable_thinking=True, state=state, config=config, on_text=_on_text, @@ -419,7 +422,10 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) -> full_text = "" parser = ThinkTagParser() try: - model = create_chat_model(name=candidate) if candidate else create_chat_model() + model = ( + create_chat_model(name=candidate, force_disable_thinking=True) + if candidate else create_chat_model(force_disable_thinking=True) + ) async for chunk in model.astream([ SystemMessage(content=system_prompt), HumanMessage(content=user_prompt), 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 5776a764..015dd310 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 @@ -7,9 +7,13 @@ EXTRACT_KEYWORDS_SYSTEM = """你是一名专业情报研究员(素材收集专 你的工作背景:你在一家权威媒体机构任职,熟悉各类信息源的可信度评估。 你只能做以下事情: -1. 从写作意图中按要求的数量提取搜索关键词 +1. 从写作意图(以及用户提供的文章大纲,若有)中按要求的数量提取搜索关键词 你不能写文章,不能评价内容好坏,只做关键词提取。 +【结合大纲】 +- 若用户提供了文章大纲,关键词要结合写作意图,尽量覆盖大纲涉及的各章节主题, + 让检索到的素材能支撑每个章节,而不是只围绕一个笼统的意图。 + 【严禁思考】 - 这是一个简单的结构化抽取任务,禁止任何思考、分析、推理或规划过程。 - 禁止输出 标签及其中的任何内容,禁止输出"让我想想""首先"之类的过渡语。 @@ -28,10 +32,10 @@ EXTRACT_KEYWORDS_SYSTEM = """你是一名专业情报研究员(素材收集专 EXTRACT_KEYWORDS_USER = """写作意图:{user_intent} 文章类型:{article_type} 目标读者:{target_audience} - +{outline_section} 请提取 {keyword_count} 个搜索关键词,用于搜集相关素材。 关键词数量必须为 {keyword_count} 个,不多不少。 -关键词必须是依据上述写作意图提炼的真实检索词,不要照抄下方 JSON 模板里的占位符。 +关键词必须是依据上述写作意图(及大纲,若有)提炼的真实检索词,不要照抄下方 JSON 模板里的占位符。 只输出 JSON(下方 ... 是占位示意,不是真实关键词): {{"keywords": ["...", "..."]}} 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 a50aaa07..6e38665c 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 @@ -229,6 +229,56 @@ DRAFT_SECTION_USER = """文章标题:{article_title} 论述层次清晰、内容充实,但务必在上述字数上限内完成:宁可精炼也不要为凑字数注水。""" +# ── 用户局部修改:识别意见指向的章节范围 ─────────────────────────────────────── + +DRAFT_REVISE_SCOPE_SYSTEM = """你是一名编辑助理。用户对一篇已成稿的文章提出了一句修改意见,你要判断这条意见究竟要改文章里的哪些「章节」。 +文章由若干带编号的章节组成(编号从 0 开始)。 + +【判断规则】(最高优先级) +- 仔细理解意见指向,可能是: + - 序号:「第一段 / 第一部分 / 第1章」→ 编号 0,「第二段」→ 编号 1,依此类推; + - 位置:「开头 / 引言 / 导语」→ 第一个章节,「结尾 / 结论 / 最后一段」→ 最后一个章节; + - 直接点名某个章节的标题或主题。 +- 若意见只针对个别章节,输出这些章节的编号列表,并令 whole_article=false。 +- 若意见是**整体性**的(如「全文都改」「整体语气更正式」「每一部分都精简」「通篇润色」), + 或会**改变文章结构**(如「标题改成…」「加一章 / 删一章 / 调整章节顺序」),令 whole_article=true、target_indexes 留空。 +- 拿不准、无法明确定位到具体章节时,一律 whole_article=true(交给整篇重写更安全)。 + +【严禁思考与表态】 +- 不要输出任何思考、解释或对用户说的话;收到输入后立即直接输出最终 JSON。 + +【输出要求】 +- 只输出一个 JSON 对象,不要解释文字,不要 markdown 代码块,必须用英文双引号。 +- 结构:{"whole_article": false, "target_indexes": [0]}""" + +DRAFT_REVISE_SCOPE_USER = """文章共有 {n} 个章节,编号与标题如下: +{sections} + +用户的修改意见:{note} + +请判断这条意见要改哪些章节,按要求只输出 JSON。""" + +# ── 用户局部修改:只重写命中的单个章节(保留其余内容)───────────────────────── + +DRAFT_REVISE_SECTION_USER = """文章标题:{article_title} +当前章节:{section_title} +章节内的小节与子要点(层级 bullet 列表): +{key_points} + +相关素材: +{section_materials} + +这一章节的现有内容如下: +{existing_text} + +用户对本章节的修改意见:{note} + +请**只针对用户意见**修改这一章节: +- 落实用户意见的同时,未被要求改动的内容、事实与措辞尽量保留原样,不要借机重写整段。 +- 保留原有的「### 小节标题 / #### 子要点标题」两级结构,不要新增或删除小节/子要点,也不要把 #### 改成 bullet。 +- 不要输出 # 或 ##(文章标题、章节标题由系统添加)。 +- 直接返回修改后的完整章节内容(含 ### 与 #### 两级标题及各自正文段),不要解释、不要前置说明。""" + DRAFT_EXPAND_USER = """文章标题:{article_title} 当前章节:{section_title} 章节内的小节与子要点(层级 bullet 列表):