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:
1 parent
5a2441cc9f
commit
d2c76100f2
2 files changed
+140
-84
No files matched your search
+136
-59
@@ -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 {
|
||||
|
||||
+4
-25
@@ -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 = """你是一名专业内容创作者(作家)。
|
||||
你的目标:根据大纲和素材,写出一段结构清晰、高质量的文章内容。
|
||||
|
||||
Reference in new issue
Block a user