From 52ba178c4ed4b8d28f54f70c9dcf1ca25c0c0c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sun, 17 May 2026 22:33:43 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AD=97=E6=95=B0=E8=A6=81=E6=B1=82=E4=BF=AE?= =?UTF-8?q?=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../artifact/CodeArtifactRenderer.tsx | 3 +- .../src/open-canvas/hooks/useAIWriting.ts | 7 +- .../src/open-canvas/utils/wordCount.ts | 12 +++ .../agents/ai_writing/nodes/_utils.py | 19 +++++ .../agents/ai_writing/nodes/writer_draft.py | 76 ++++++++++++++++--- .../ai_writing/prompts/writer_prompts.py | 22 +++++- 6 files changed, 121 insertions(+), 18 deletions(-) create mode 100644 frontend-web/src/open-canvas/utils/wordCount.ts diff --git a/frontend-web/src/open-canvas/components/artifact/CodeArtifactRenderer.tsx b/frontend-web/src/open-canvas/components/artifact/CodeArtifactRenderer.tsx index 349722cb..1d720d08 100644 --- a/frontend-web/src/open-canvas/components/artifact/CodeArtifactRenderer.tsx +++ b/frontend-web/src/open-canvas/components/artifact/CodeArtifactRenderer.tsx @@ -3,10 +3,9 @@ import { javascript } from "@codemirror/lang-javascript"; import { json } from "@codemirror/lang-json"; import { python } from "@codemirror/lang-python"; import type { Extension } from "@codemirror/state"; -import type { ViewUpdate } from "@codemirror/view"; import { basicLightInit } from "@uiw/codemirror-theme-basic"; import { monokaiInit } from "@uiw/codemirror-theme-monokai"; -import CodeMirror from "@uiw/react-codemirror"; +import CodeMirror, { type ViewUpdate } from "@uiw/react-codemirror"; import { useTheme } from "next-themes"; import { useCallback, useMemo } from "react"; diff --git a/frontend-web/src/open-canvas/hooks/useAIWriting.ts b/frontend-web/src/open-canvas/hooks/useAIWriting.ts index 994d7d4c..af5fe2eb 100644 --- a/frontend-web/src/open-canvas/hooks/useAIWriting.ts +++ b/frontend-web/src/open-canvas/hooks/useAIWriting.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useCanvasGraphContext } from '../contexts/CanvasGraphContext'; import { startWritingSession, subscribeToProgress, resumeWriting as resumeWritingApi } from '../api/ai-writing'; +import { countWords } from '../utils/wordCount'; import type { AIWritingRequest, AIWritingState, @@ -172,7 +173,7 @@ export function useAIWriting() { case 'draft_ready': { const newMarkdown = event.draftMarkdown ?? ''; - const newWordCount = newMarkdown.replace(/\s/g, '').length; + const newWordCount = countWords(newMarkdown); const summary = prev.pendingRevisionContext ? { revisedIssues: prev.pendingRevisionContext.submittedIssues, @@ -306,7 +307,7 @@ export function useAIWriting() { record.outline = state.outline ?? undefined; } else if (pausePoint === 'draft_confirm') { record.draftTitle = state.currentDraftTitle; - record.draftWordCount = state.currentDraftMarkdown.replace(/\s/g, '').length; + record.draftWordCount = countWords(state.currentDraftMarkdown); record.reviewResult = state.reviewResult ?? undefined; } else if (pausePoint === 'review_confirm') { record.reviewResult = state.reviewResult ?? undefined; @@ -325,7 +326,7 @@ export function useAIWriting() { pendingRevisionContext = { submittedIssues, skippedIssues, - oldWordCount: state.currentDraftMarkdown.replace(/\s/g, '').length, + oldWordCount: countWords(state.currentDraftMarkdown), }; } diff --git a/frontend-web/src/open-canvas/utils/wordCount.ts b/frontend-web/src/open-canvas/utils/wordCount.ts new file mode 100644 index 00000000..47d1ef7e --- /dev/null +++ b/frontend-web/src/open-canvas/utils/wordCount.ts @@ -0,0 +1,12 @@ +/** + * 中文语境下的「字数」统计,与后端 `nodes/_utils.py:count_words` 保持同一口径: + * 汉字逐字计 + 中文标点逐个计 + 连续数字/英文各算 1 字。 + * 不计入空白、markdown 语法符号(# * - 等)与 ASCII 标点。 + */ +export function countWords(text: string): number { + if (!text) return 0; + const cjk = (text.match(/[一-鿿]/g) ?? []).length; + const cjkPunct = (text.match(/[,。、;:?!“”‘’()《》【】「」『』〈〉—…·~]/g) ?? []).length; + const alnumRuns = (text.match(/[A-Za-z0-9]+/g) ?? []).length; + return cjk + cjkPunct + alnumRuns; +} 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 6a57aa3b..f5877ecb 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 @@ -134,6 +134,25 @@ def _content_to_text(content) -> str: return str(content) +# 中文常见标点:参与「字数」统计,与 Word 字数统计 / 用户直觉口径对齐 +_CJK_PUNCTUATION = ",。、;:?!“”‘’()《》【】「」『』〈〉—…·~" + + +def count_words(text: str) -> int: + """统计中文语境下的「字数」。 + + 口径:汉字逐字计 + 中文标点逐个计 + 连续数字/英文各算 1 字。 + 不计入空白、markdown 语法符号(# * - 等)与 ASCII 标点。 + 前后端共用同一口径(前端 utils/wordCount.ts 实现一致)。 + """ + if not text: + return 0 + cjk = len(re.findall(r"[一-鿿]", text)) + cjk_punct = sum(1 for c in text if c in _CJK_PUNCTUATION) + alnum_runs = len(re.findall(r"[A-Za-z0-9]+", text)) + return cjk + cjk_punct + alnum_runs + + class ThinkTagParser: """流式 ... 标签解析器。 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 1636e6c0..b3aec3a4 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 @@ -8,8 +8,8 @@ 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 -from ._utils import get_model, _content_to_text, ThinkTagParser +from ..prompts.writer_prompts import DRAFT_SECTION_SYSTEM, DRAFT_SECTION_USER, DRAFT_EXPAND_USER +from ._utils import get_model, _content_to_text, count_words, ThinkTagParser logger = logging.getLogger(__name__) @@ -64,22 +64,36 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di word_count_target = state.get("word_count_target", 800) sections = outline.get("sections", []) - words_per_section = max(100, word_count_target // max(len(sections), 1)) + n_sections = max(len(sections), 1) + # 单节硬下限:总目标 / 节数,乘 1.1 过量系数补偿模型对短目标的天然欠量;单节至少 150 字 + words_per_section = max(150, int(word_count_target * 1.1 / n_sections)) + article_title = outline.get("title", "文章") + + # 兼容后端 snake_case 与前端编辑后回传的 camelCase + def _section_title(idx: int, sec: dict) -> str: + return sec.get("section_title") or sec.get("sectionTitle") or f"章节{idx + 1}" model = get_model(config) - section_texts: list[str] = [] + section_titles: list[str] = [] + section_bufs: list[str] = [] # 各章节正文(不含 ## 标题),供扩写兜底逐节替换 for i, section in enumerate(sections): - # 兼容后端 snake_case 和前端编辑后回传的 camelCase - sec_title = section.get("section_title") or section.get("sectionTitle", f"章节{i+1}") + sec_title = _section_title(i, section) key_points = section.get("key_points") or section.get("keyPoints") or [] mat_ids = section.get("material_ids") or section.get("materialIds") or [] section_materials = _get_section_materials(mat_ids, pkg) key_points_str = "\n".join(f"- {p}" for p in key_points) + # 文章整体结构:让作家看到全文章节与自己的定位,避免章节间内容重复、引言结论脱节 + outline_context = "\n".join( + f"{j + 1}. {_section_title(j, s)}" + (" ← 当前正在写这一章" if j == i else "") + for j, s in enumerate(sections) + ) + user_msg = DRAFT_SECTION_USER.format( - article_title=outline.get("title", "文章"), + article_title=article_title, + outline_context=outline_context, section_title=sec_title, key_points=key_points_str, section_materials=section_materials, @@ -120,14 +134,54 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di "is_section_done": True, })) - section_texts.append(f"## {sec_title}\n\n{section_buf.strip()}") + section_titles.append(sec_title) + section_bufs.append(section_buf.strip()) except Exception as e: logger.error("draft section [%s] failed: %s", sec_title, e, exc_info=True) - section_texts.append(f"## {sec_title}\n\n(内容生成失败,请重试)") + section_titles.append(sec_title) + section_bufs.append("(内容生成失败,请重试)") - full_markdown = f"# {outline.get('title', '文章')}\n\n" + "\n\n".join(section_texts) - word_count = len([c for c in full_markdown if '一' <= c <= '鿿']) + def _assemble() -> str: + body = "\n\n".join(f"## {t}\n\n{b}" for t, b in zip(section_titles, section_bufs)) + return f"# {article_title}\n\n{body}" + + full_markdown = _assemble() + word_count = count_words(full_markdown) + + # 扩写兜底:总字数不足目标 90% 时,重写并扩充最短的章节(单轮,非流式) + if section_bufs and word_count < word_count_target * 0.9: + idx = min(range(len(section_bufs)), key=lambda k: count_words(section_bufs[k])) + target_sec = sections[idx] + expand_target = count_words(section_bufs[idx]) + (word_count_target - word_count) + try: + expand_msg = DRAFT_EXPAND_USER.format( + article_title=article_title, + section_title=section_titles[idx], + key_points="\n".join( + f"- {p}" for p in (target_sec.get("key_points") or target_sec.get("keyPoints") or []) + ), + section_materials=_get_section_materials( + target_sec.get("material_ids") or target_sec.get("materialIds") or [], pkg + ), + existing_text=section_bufs[idx], + target_words=expand_target, + ) + resp = await model.ainvoke([ + SystemMessage(content=DRAFT_SECTION_SYSTEM), + HumanMessage(content=expand_msg), + ]) + parser = ThinkTagParser() + expanded = "".join( + t for is_thinking, t in parser.feed(_content_to_text(resp.content)) if not is_thinking + ).strip() + # 仅在扩写确实更长时才采用,避免越改越短 + if count_words(expanded) > count_words(section_bufs[idx]): + section_bufs[idx] = expanded + full_markdown = _assemble() + word_count = count_words(full_markdown) + except Exception as e: + logger.warning("draft expand failed for section [%s]: %s", section_titles[idx], e) draft = DraftArticle( title=outline.get("title", "文章"), 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 26f4e0b6..4fc06d43 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 @@ -45,13 +45,31 @@ DRAFT_SECTION_SYSTEM = """你是一名专业内容创作者(作家)。 【输出要求】 - 直接返回这一段的正文文字,不要 JSON,不要章节标题,不要解释。 - 不要写"以下是正文""本段内容如下"之类的开场白。 -- 段落要连贯成文,不要罗列要点。""" +- 段落要连贯成文,不要罗列要点。 +- 紧扣本章节主题,不要写「文章整体结构」中其他章节负责的内容,避免重复。 +- 充分展开每个要点:每个要点至少用 2-3 句话论述,结合素材中的事实与数据说理,写满要求的篇幅。""" DRAFT_SECTION_USER = """文章标题:{article_title} +文章整体结构: +{outline_context} + 当前章节:{section_title} 章节要点:{key_points} 相关素材: {section_materials} {revision_section} -请写出这一章节的正文内容({target_words}字左右)。""" +请写出「{section_title}」这一章节的正文内容,不少于 {target_words} 字,论述充分、内容充实。""" + +DRAFT_EXPAND_USER = """文章标题:{article_title} +当前章节:{section_title} +章节要点:{key_points} + +相关素材: +{section_materials} + +这一章节已有内容如下: +{existing_text} + +以上内容篇幅不足。请在保留原意和事实的基础上重写并扩充这一章节,使正文不少于 {target_words} 字。 +要求:补充更多来自素材的事实、数据与分析,论述更充分、更具体;直接返回扩充后的完整章节正文,不要章节标题、不要解释。"""