字数要求修改

This commit is contained in:
董宏凯 committed 2026-05-17 22:33:43 +08:00
1 parent 166016a741
commit 52ba178c4e
6 files changed
+121 -18

No files matched your search

@@ -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";
@@ -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),
};
}
@@ -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;
}
@@ -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:
"""流式 <think>...</think> 标签解析器。
@@ -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", "文章"),
@@ -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} 字。
要求:补充更多来自素材的事实、数据与分析,论述更充分、更具体;直接返回扩充后的完整章节正文,不要章节标题、不要解释。"""