字数要求修改
This commit is contained in:
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;
|
||||
}
|
||||
+19
@@ -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> 标签解析器。
|
||||
|
||||
|
||||
+65
-11
@@ -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", "文章"),
|
||||
|
||||
+20
-2
@@ -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} 字。
|
||||
要求:补充更多来自素材的事实、数据与分析,论述更充分、更具体;直接返回扩充后的完整章节正文,不要章节标题、不要解释。"""
|
||||
Reference in new issue
Block a user