perf(ai写作): 用户自带大纲整理提速并流式化

用户自带大纲时「整理大纲」原有两个阻塞式非流式 LLM 调用,内网模型
<think> 关不掉导致用户长时间干等:

- 素材关联(每次都调)改为确定性词重叠 _assign_materials_heuristic,
  零 LLM、瞬时;有编号/标题/缩进的大纲整理几乎零等待。
- 散文化大纲的 LLM 归一改为流式 _stream_normalize_user_outline:
  ThinkTagParser 把思考推思考框、正文实时打字机展示并累积解析;
  并在 OUTLINE_NORMALIZE_USER 末尾追加 /no_think。
- researcher 路径只做确定性解析,解析不出返回 None,交给 writer_outline
  流式归一(outline_chunk 在该阶段天然映射大纲思考框)。
- 移除废弃的 OUTLINE_MATERIAL_ASSIGN_* 提示词与 llm_json 死引用。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
fengxiangdebaoziandClaude Opus 4.8 committed 2026-06-24 16:15:39 +08:00
1 parent 5a2441cc9f
commit d2c76100f2
2 files changed
+140 -84

No files matched your search

@@ -3,14 +3,13 @@ from __future__ import annotations
import json
import logging
import re
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.runnables import RunnableConfig
from ..outline_parser import parse_user_outline
from ..prompts.writer_prompts import (
OUTLINE_MATERIAL_ASSIGN_SYSTEM,
OUTLINE_MATERIAL_ASSIGN_USER,
OUTLINE_NORMALIZE_SYSTEM,
OUTLINE_NORMALIZE_USER,
OUTLINE_REVISE_SYSTEM,
@@ -35,7 +34,7 @@ from ._model_fallback import (
emit_model_switch_event,
fallback_chain,
)
from ._utils import ThinkTagParser, _content_to_text, llm_json, parse_json, reasoning_delta, resolve_writing_model_name, safe_get_stream_writer
from ._utils import ThinkTagParser, _content_to_text, parse_json, reasoning_delta, resolve_writing_model_name, safe_get_stream_writer
logger = logging.getLogger(__name__)
@@ -143,12 +142,81 @@ def _derive_title(state: AIWritingState) -> str:
return intent[:30].rstrip() + ("…" if len(intent) > 30 else "")
async def _normalize_user_outline_via_llm(state: AIWritingState, config: RunnableConfig, raw: str) -> Outline:
"""确定性解析失败时,调 LLM 把用户大纲原文归一成三级结构(严格保真、不改写)。
# ── 素材关联:确定性词重叠(替代 LLM,零延迟)────────────────────────────────────
_ALNUM_TOKEN_RE = re.compile(r"[a-z0-9]+")
_CJK_CHAR_RE = re.compile(r"[一-鿿]")
再失败则把每个非空行当作一个章节,保证永远产出一份可用大纲、不丢用户内容。
def _text_tokens(text: str) -> set[str]:
"""把文本切成可比较的 token 集合:CJK 取相邻 bigram、英文/数字取整词(均小写)。
bigram 让「新能源汽车出口」这类短标题与素材标题/正文做稳定的词重叠匹配,
无需任何模型调用。
"""
if not text:
return set()
text = text.lower()
tokens: set[str] = {w for w in _ALNUM_TOKEN_RE.findall(text) if len(w) >= 2}
cjk = _CJK_CHAR_RE.findall(text)
tokens.update(cjk[i] + cjk[i + 1] for i in range(len(cjk) - 1))
return tokens
def _section_query_text(section: dict) -> str:
"""章节用于匹配素材的文本:章节标题 + 各小节标题 + 子要点,越全匹配越准。"""
parts = [section.get("section_title") or ""]
for kp in section.get("key_points", []) or []:
if isinstance(kp, dict):
parts.append(kp.get("point_title") or "")
parts.extend(s for s in (kp.get("sub_points") or []) if isinstance(s, str))
elif isinstance(kp, str):
parts.append(kp)
return " ".join(p for p in parts if p)
def _assign_materials_heuristic(outline: Outline, pkg: dict) -> Outline:
"""确定性地把素材按词重叠归类到各章节(替代 LLM 调用,零延迟、零 <think> 等待)。
每条素材分配给与其标题/正文词重叠最高的章节(必须有重叠才分配);与所有章节都不
重叠的素材不强行塞入(writer_draft 自带「取前 2 条素材」兜底)。只改 material_ids,
绝不动标题与层级。
"""
sections = outline.get("sections", [])
materials = pkg.get("materials") or []
if not sections or not materials:
return outline
section_tokens = [_text_tokens(_section_query_text(s)) for s in sections]
assigned: list[list[str]] = [[] for _ in sections]
for m in materials:
mid = m.get("id")
if not mid:
continue
mat_tok = _text_tokens(f"{m.get('title', '')} {m.get('content', '')}")
if not mat_tok:
continue
best_idx, best_score = -1, 0
for i, sec_tok in enumerate(section_tokens):
score = len(mat_tok & sec_tok)
if score > best_score:
best_idx, best_score = i, score
if best_idx >= 0:
assigned[best_idx].append(mid)
for section, ids in zip(sections, assigned):
section["material_ids"] = ids
return outline
async def _stream_normalize_user_outline(
state: AIWritingState, config: RunnableConfig, raw: str, writer
) -> Outline:
"""散文化大纲:确定性解析失败时流式调 LLM 归一成三级结构,实时展示给前端。
内网模型 <think> 关不掉 → 用 ThinkTagParser 把思考与正文分流:思考推 is_thinking
思考框、正文累积后解析 JSON,用户全程看得到进度而非干等。再失败则把每个非空行当作
一个章节兜底,保证永远产出可用大纲、不丢用户内容。
"""
model_name = (state.get("model_name") or "").strip() or None
line_fallback = {
"title": "",
"sections": [
@@ -156,75 +224,84 @@ async def _normalize_user_outline_via_llm(state: AIWritingState, config: Runnabl
for ln in raw.splitlines() if ln.strip()
] or [{"section_title": "正文", "key_points": [], "material_ids": []}],
}
result = await llm_json(
OUTLINE_NORMALIZE_SYSTEM,
OUTLINE_NORMALIZE_USER.format(raw_outline=raw),
line_fallback,
config,
model_name=model_name,
)
return _parse_outline(result)
name, _ = resolve_writing_model_name(config, state)
full_text = ""
try:
from deerflow.models import create_chat_model
model = create_chat_model(name=name)
parser = ThinkTagParser()
async for chunk in model.astream([
SystemMessage(content=OUTLINE_NORMALIZE_SYSTEM),
HumanMessage(content=OUTLINE_NORMALIZE_USER.format(raw_outline=raw)),
]):
reasoning = reasoning_delta(chunk)
if reasoning:
writer({"type": "outline_chunk", "text": reasoning, "is_thinking": True})
delta = _content_to_text(chunk.content)
if not delta:
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
full_text += text
writer({"type": "outline_chunk", "text": text, "is_thinking": is_thinking})
except Exception as e:
logger.error("stream normalize user outline failed: %s", e, exc_info=True)
return _parse_outline(line_fallback)
return _parse_outline(parse_json(full_text, line_fallback))
async def _assign_materials_to_outline(
state: AIWritingState, config: RunnableConfig, outline: Outline, pkg: dict
) -> Outline:
"""为用户大纲的各章节回填 material_ids(结构锁定:只取编号、按章节序号嫁接)。
async def build_user_outline_with_materials(
state: AIWritingState, config: RunnableConfig, pkg: dict
) -> Outline | None:
"""搜集素材阶段调用:用户自带大纲时,解析其大纲并把已检索素材归类到各章节。
LLM 只负责「哪个章节配哪些素材」,标题与层级一律以解析结果为准,杜绝改写。失败则
保持 material_ids 为空(writer_draft 自带「取前 2 条素材」兜底)。
复用本模块的解析/归类逻辑,让 researcher 能在「搜集素材」阶段顺便整理素材,
从而**跳过 writer_outline 的大纲生成步**(见 graph 路由)。无用户大纲返回 None。
已有结构化大纲(如 re_search 二次检索)则复用结构、仅重新归类素材,不重复解析。
只做**确定性**解析与素材关联(零 LLM、瞬时):散文化、解析不出层级的大纲返回
None —— 不在 researcher 阶段阻塞式调 LLM 归一,交给 writer_outline 流式归一并实时
展示(见 graph after_material_pause 路由)。
"""
sections = outline.get("sections", [])
materials = pkg.get("materials") or []
if not sections or not materials:
return outline
valid_ids = {m["id"] for m in materials}
sections_list = "\n".join(
f"{i + 1}. {s.get('section_title', '')}" for i, s in enumerate(sections)
)
model_name = (state.get("model_name") or "").strip() or None
result = await llm_json(
OUTLINE_MATERIAL_ASSIGN_SYSTEM,
OUTLINE_MATERIAL_ASSIGN_USER.format(
user_intent=state.get("user_intent", ""),
sections_list=sections_list,
materials_summary=_materials_summary(pkg),
),
{"assignments": []},
config,
model_name=model_name,
)
assignments = result.get("assignments")
if not isinstance(assignments, list):
return outline
for i, section in enumerate(sections):
if i >= len(assignments):
break
ids = assignments[i]
if isinstance(ids, list):
section["material_ids"] = [mid for mid in ids if mid in valid_ids]
return outline
raw = (state.get("user_outline_raw") or "").strip()
if not raw:
return None
existing = state.get("current_outline")
if existing and existing.get("sections"):
outline: Outline = existing
else:
outline = parse_user_outline(raw)
if outline is None:
return None
if not outline.get("title"):
outline = {**outline, "title": _derive_title(state)}
return _assign_materials_heuristic(outline, pkg)
async def _build_outline_from_user(
state: AIWritingState, config: RunnableConfig, pkg: dict, raw: str
) -> dict:
"""用户自带大纲分支:解析用户大纲 → 关联素材 → 直接进确认,不调 LLM 重新规划。"""
"""用户自带大纲分支:解析用户大纲 → 关联素材 → 直接进确认,不调 LLM 重新规划。
可确定性解析的大纲(带编号/标题/缩进)零 LLM、瞬时完成;只有散文化、解析不出层级
的大纲才流式调一次 LLM 归一(实时展示思考/正文,应对内网模型关不掉的 <think>),
素材关联一律走确定性词重叠。
"""
writer = safe_get_stream_writer()
writer({"type": "outline_chunk", "text": "正在整理你提供的大纲…\n", "is_thinking": True})
outline = parse_user_outline(raw)
if outline is None:
# 确定性解析拿不准(散文化/格式混乱)→ LLM 兜底归一
outline = await _normalize_user_outline_via_llm(state, config, raw)
# 确定性解析拿不准(散文化/格式混乱)→ 流式 LLM 兜底归一,全程实时展示
outline = await _stream_normalize_user_outline(state, config, raw, writer)
else:
writer({"type": "outline_chunk", "text": "正在整理你提供的大纲…\n", "is_thinking": True})
if not outline.get("title"):
outline = {**outline, "title": _derive_title(state)}
# 仍走了检索 → 把已检索素材关联到用户章节(结构锁定)
outline = await _assign_materials_to_outline(state, config, outline, pkg)
# 仍走了检索 → 把已检索素材关联到用户章节(确定性词重叠,零 LLM)
outline = _assign_materials_heuristic(outline, pkg)
section_count = len(outline.get("sections", []))
return {
@@ -149,33 +149,12 @@ OUTLINE_NORMALIZE_SYSTEM = """你是一名大纲格式整理助手。
OUTLINE_NORMALIZE_USER = """用户粘贴的大纲原文:
{raw_outline}
请逐条保留原文,仅判断层级,输出结构化大纲 JSON。"""
请逐条保留原文,仅判断层级,输出结构化大纲 JSON。
# ── 用户自带大纲:素材关联(结构锁定,只挑 material_ids)──────────────────────────
/no_think"""
OUTLINE_MATERIAL_ASSIGN_SYSTEM = """你是一名资料编辑。
给定一份【已确定的文章大纲】(章节顺序固定)和一批检索到的素材,你的任务是为每个章节
挑选最相关的素材编号。
【规则】
- 不要修改大纲的任何标题或结构,你只输出每个章节对应的素材编号列表。
- 严格按大纲给出的章节顺序输出,一个章节对应一个编号数组;该章节没有相关素材就给 []。
- material_id 必须来自给定素材清单里出现过的编号,不要编造。
- 一条素材可以分配给多个章节,也可以一个都不分配。
【输出要求】
- 只输出一个 JSON 对象,不要解释文字,不要 markdown 代码块,必须用英文双引号。
- 结构:{"assignments": [["m001","m002"], [], ["m003"]]},数组长度等于章节数、顺序一致。"""
OUTLINE_MATERIAL_ASSIGN_USER = """写作意图:{user_intent}
文章大纲(章节按顺序编号):
{sections_list}
可用素材:
{materials_summary}
请按章节顺序,为每个章节挑选相关的 material_id,输出 assignments 数组。"""
# 注:用户自带大纲的「素材关联」已改为确定性词重叠(writer_outline._assign_materials_heuristic),
# 零 LLM、零 <think> 等待,原 OUTLINE_MATERIAL_ASSIGN_* 提示词已废弃移除。
DRAFT_SECTION_SYSTEM = """你是一名专业内容创作者(作家)。
你的目标:根据大纲和素材,写出一段结构清晰、高质量的文章内容。