feat: add AI writing chat workspace

This commit is contained in:
董宏凯 committed 2026-07-02 13:56:46 +08:00
1 parent 54190d7a83
commit 845c87e3ee
13 files changed
+2622

No files matched your search

@@ -264,6 +264,10 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
Proxied through nginx: `/api/langgraph/*` ? LangGraph, all other `/api/*` ? Gateway.
### AI Writing Intent Router (对话驱动写作台意图路由)
Backs the frontend「AI 写作·对话版」page's unified bottom composer. `POST /api/ai-writing/sessions/{session_id}/intent` (owner-checked; in `routers/ai_writing.py`) reads the session's graph checkpoint via the shared `app.state.checkpointer` — the **pending interrupt is the authoritative pause point** (the frontend's claimed `pause_point` is only a fallback when the checkpoint can't be read) — then routes the user's free text through `deerflow/agents/ai_writing/intent_router.py::parse_user_intent`: an LLM call (session's `model_name` + `fallback_chain` model fault-tolerance) that returns `{intent, pause_point, action, payload, need_research, research_query, answer, confidence, clarify}`. Safety boundaries in `normalize_intent_result`: **per-pause action whitelist strictly aligned to graph.py's real resume actions** (`ALLOWED_ACTIONS`), no pause → no action, high-cost actions (`finalize` / `force_finalize` / query-less覆盖式 `re_search`) require confidence ≥ 0.85 else downgrade to `clarify`, payload key-whitelist cleaning (material-id filtering, review-issue index bounds, section-decision back-fill with the majority decision). QA intents return `answer` directly from the context snapshot (`build_context_snapshot` — stage-tailored: materials with ids / outline / draft body capped / review issues with indices). Model failures never 5xx — they degrade to clarify. Prompts in `agents/ai_writing/prompts/intent_router_prompts.py`; tests `tests/test_ai_writing_intent.py`.
### AI Writing Built-in Functional Agents (????????)
The AI-writing graph (`packages/harness/deerflow/agents/ai_writing/`) has four stage "brains" ? ?????? / ????? / ?? / ?? ? that can each run in one of two modes, switched per-stage by `config.yaml ? ai_writing.use_builtin_agents.{researcher,outliner,writer,editor}` (default **false**, mtime hot-reloaded, new sessions pick up changes immediately):
@@ -327,6 +327,91 @@ async def save_transcript(session_id: str, body: SaveTranscriptRequest, request:
return {"success": True}
# ── 意图解析(对话驱动写作台) ────────────────────────────────────────────────
class IntentParseRequest(BaseModel):
# 用户在底部智能输入框发的自由文本
text: str
# 前端当前 WritingStatus(仅参考;暂停点以 checkpoint 里真实 interrupt 为准)
status: Optional[str] = None
# 前端认为的当前暂停点(checkpoint 读取失败时的兜底)
pause_point: Optional[str] = None
class IntentParseResponse(BaseModel):
intent: str
pause_point: Optional[str] = None
action: Optional[str] = None
# 驼峰键(approvedMaterialIds / userQuery / outlineFeedback / userRevisionNotes /
# overrideVerdict / selectedIssueIndices / sectionDecisions),前端可直接喂 submitIntervention
payload: dict = Field(default_factory=dict)
need_research: bool = False
research_query: str = ""
answer: str = ""
confidence: float = 0.0
clarify: str = ""
@router.post("/sessions/{session_id}/intent", response_model=IntentParseResponse)
async def parse_session_intent(session_id: str, body: IntentParseRequest, request: Request):
"""对话驱动写作台:把用户自由文本翻译成结构化写作指令(或问答/澄清)。
读该会话 graph checkpoint 的素材/大纲/草稿/审核摘要 + 当前 pending interrupt
(权威暂停点),交给 ``deerflow.agents.ai_writing.intent_router`` 的 LLM 意图
路由。动作白名单 / 高代价置信度门槛 / payload 清洗都在 intent_router 内完成,
这里只做鉴权与 state 读取。模型失败不抛 5xx —— intent_router 会降级成 clarify。
"""
from app.gateway.ai_writing_job_executor import _pending_interrupt
from app.gateway.deps import get_ai_writing_session_store, get_current_user
from deerflow.agents.ai_writing.intent_router import parse_user_intent
text = (body.text or "").strip()
if not text:
raise HTTPException(status_code=400, detail="text 不能为空")
repo = get_ai_writing_session_store(request)
if repo is None:
raise HTTPException(status_code=503, detail="Session store not available")
user_id = await get_current_user(request)
record = await repo.get(session_id, user_id=user_id)
if record is None:
raise HTTPException(status_code=404, detail="Session not found")
# 读 checkpoint:暂停点以 pending interrupt 为权威(frontend 声明只作读取失败兜底)。
values: dict = {}
interrupt_value: Optional[dict] = None
pause_point: Optional[str] = None
checkpoint_ok = False
_app_state = getattr(getattr(request, "app", None), "state", None)
checkpointer = getattr(_app_state, "checkpointer", None)
if checkpointer is not None:
from deerflow.agents.ai_writing.graph import make_ai_writing_graph
graph = make_ai_writing_graph()
graph.checkpointer = checkpointer
try:
snapshot = await graph.aget_state({"configurable": {"thread_id": session_id}})
values = getattr(snapshot, "values", None) or {}
interrupt_value = _pending_interrupt(snapshot)
checkpoint_ok = bool(values)
if interrupt_value:
pause_point = interrupt_value.get("pause_point")
except Exception:
logger.warning("ai_writing intent: read checkpoint failed for %s", session_id, exc_info=True)
if pause_point is None and not checkpoint_ok:
pause_point = (body.pause_point or "").strip() or None
result = await parse_user_intent(
text=text,
pause_point=pause_point,
values=values,
interrupt_value=interrupt_value,
model_name=str(values.get("model_name") or "") or None,
)
return IntentParseResponse(**result)
class BackgroundResponse(BaseModel):
# 是否新拉起了后台任务(False = 已在后台跑,幂等返回)
started: bool
@@ -0,0 +1,409 @@
"""对话驱动写作台的意图路由:用户自由文本 → 结构化 action/payload。
供 Gateway 的 ``POST /api/ai-writing/sessions/{id}/intent`` 调用:读该会话的
graph checkpoint state(素材/大纲/草稿/审核摘要)+ 当前暂停点,用 LLM 把用户的
一句话翻译成 ``UserInterventionPayload`` 可直接消费的指令;或识别为提问(直接
带回 answer)、控制指令、需澄清。
核心安全边界(避免"原逻辑不支持硬套用"):
- action 白名单严格对齐 graph.py 各 pause 节点真实接受的动作(见
``ALLOWED_ACTIONS``),LLM 输出不在白名单内 → 一律降级为 clarify;
- 高代价动作(finalize / force_finalize / 无查询词的覆盖式 re_search)要求
confidence ≥ 0.85,不够 → 降级 clarify;
- 无暂停点(运行中/已结束)时不允许输出任何 action;
- payload 逐 key 白名单清洗,杜绝 LLM 编造字段污染 resume 载荷。
"""
from __future__ import annotations
import logging
import re
from typing import Any
from .prompts.intent_router_prompts import (
INTENT_ROUTER_SYSTEM,
INTENT_ROUTER_USER_TEMPLATE,
NO_PAUSE_GUIDE,
STAGE_ACTION_GUIDES,
)
logger = logging.getLogger(__name__)
# 各暂停点合法动作 —— 与 graph.py 的 pause_material_confirm / pause_outline_confirm /
# pause_draft_confirm / pause_review_confirm / pause_section_help 严格对齐。
# 注意 material_confirm 的 enable_skill_search 是旧按钮兼容入口,语义 = 带
# userQuery 的 re_search,意图层统一输出 re_search,不再暴露旧动作。
ALLOWED_ACTIONS: dict[str, set[str]] = {
"material_confirm": {"confirm", "re_search"},
"outline_confirm": {"confirm", "re_outline"},
"draft_confirm": {"to_editor", "finalize", "user_revise"},
"review_confirm": {"accept_review", "force_finalize"},
"section_help": {"section_help"},
}
# 高代价动作的置信度下限(misfire 代价大:结束流程 / 丢弃现有素材)。
HIGH_COST_CONFIDENCE = 0.85
# 普通动作的置信度下限。
ACTION_CONFIDENCE = 0.5
_SECTION_DECISIONS = {"supplement", "loose", "delete"}
_THINK_RE = re.compile(r"<think>.*?</think>", re.S)
# ── 上下文快照 ────────────────────────────────────────────────────────────────
def _shorten(text: str, limit: int) -> str:
text = (text or "").strip().replace("\n", " ")
return text if len(text) <= limit else text[: limit - 1] + "…"
def _materials_block(values: dict, limit: int = 30) -> str:
pkg = values.get("material_package") or {}
materials = pkg.get("materials") or []
if not materials:
return "(当前没有素材)"
lines = []
for i, m in enumerate(materials[:limit]):
mid = m.get("id", "")
title = _shorten(str(m.get("title") or ""), 60)
source = _shorten(str(m.get("source") or ""), 20)
excerpt = _shorten(str(m.get("content") or ""), 80)
lines.append(f"{i + 1}. [id={mid}] {title}(来源:{source}){excerpt}")
if len(materials) > limit:
lines.append(f"…共 {len(materials)} 条,仅列出前 {limit} 条")
keywords = pkg.get("keywords") or []
head = f"检索关键词:{'、'.join(str(k) for k in keywords[:10])}\n" if keywords else ""
return head + "\n".join(lines)
def _outline_block(values: dict) -> str:
outline = values.get("current_outline") or {}
sections = outline.get("sections") or []
if not sections:
return "(尚无大纲)"
lines = [f"文章标题:{outline.get('title') or ''}"]
for i, s in enumerate(sections):
title = s.get("section_title") or s.get("sectionTitle") or ""
points = s.get("key_points") or s.get("keyPoints") or []
point_titles = []
for p in points[:6]:
if isinstance(p, dict):
point_titles.append(str(p.get("point_title") or p.get("pointTitle") or ""))
else:
point_titles.append(str(p))
suffix = f"(要点:{_shorten(';'.join(t for t in point_titles if t), 120)})" if point_titles else ""
lines.append(f"第{i + 1}章 {title}{suffix}")
return "\n".join(lines)
def _draft_block(values: dict, body_limit: int = 4000) -> str:
draft = values.get("current_draft") or {}
markdown = str(draft.get("full_markdown") or draft.get("fullMarkdown") or "")
if not markdown:
return "(尚无草稿)"
title = draft.get("title") or ""
word_count = draft.get("word_count") or draft.get("wordCount") or ""
revision = draft.get("revision_count") or draft.get("revisionCount") or 0
body = markdown if len(markdown) <= body_limit else markdown[:body_limit] + "\n…(正文过长已截断)"
return f"草稿标题:《{title}》 修改次数:{revision} 字数:{word_count}\n--- 草稿正文 ---\n{body}"
def _review_block(values: dict) -> str:
review = values.get("review_result") or {}
if not review:
return "(尚无编辑审核结果)"
notes = review.get("revision_notes") or review.get("revisionNotes") or []
lines = [
f"编辑结论:{review.get('verdict', '')}(总分 {review.get('overall_score', review.get('overallScore', ''))},"
f"事实 {review.get('fact_score', review.get('factScore', ''))} / 逻辑 {review.get('logic_score', review.get('logicScore', ''))} / "
f"语言 {review.get('language_score', review.get('languageScore', ''))})",
]
for i, n in enumerate(notes):
issue = _shorten(str(n.get("issue") or ""), 80)
suggestion = _shorten(str(n.get("suggestion") or ""), 80)
lines.append(f"意见[index={i}] [{n.get('category', '')}/{n.get('severity', '')}] {issue} → {suggestion}")
return "\n".join(lines)
def _blocked_block(values: dict, interrupt_value: dict | None) -> str:
blocked = (interrupt_value or {}).get("blocked_sections") or values.get("blocked_sections") or []
if not blocked:
return ""
lines = ["受阻章节(素材不足):"]
for b in blocked:
title = b.get("section_title") or b.get("sectionTitle") or ""
reason = _shorten(str(b.get("reason") or ""), 80)
lines.append(f"- {title}:{reason}")
return "\n".join(lines)
def build_context_snapshot(values: dict, pause_point: str | None, interrupt_value: dict | None = None) -> str:
"""把 checkpoint state 压成 LLM 可读的中文上下文快照。
按当前阶段裁剪重点:素材阶段给全量素材清单;大纲阶段给大纲;草稿/审核阶段给
草稿正文(截断)+ 审核意见(带 index,供 selectedIssueIndices 映射)。
"""
status = str(values.get("status") or "")
intent = _shorten(str(values.get("user_intent") or ""), 200)
parts = [f"写作主题:{intent}", f"流程状态:{status or '未知'}"]
if pause_point == "material_confirm":
parts.append("素材清单:\n" + _materials_block(values))
outline = _outline_block(values)
if outline != "(尚无大纲)":
parts.append("已有大纲:\n" + outline)
elif pause_point == "outline_confirm":
parts.append("当前大纲:\n" + _outline_block(values))
parts.append("素材清单(供判断 need_research):\n" + _materials_block(values, limit=20))
elif pause_point in ("draft_confirm", "review_confirm"):
parts.append(_draft_block(values))
parts.append("编辑审核:\n" + _review_block(values))
parts.append("素材清单(供判断 need_research):\n" + _materials_block(values, limit=15))
elif pause_point == "section_help":
blocked = _blocked_block(values, interrupt_value)
if blocked:
parts.append(blocked)
parts.append("当前大纲:\n" + _outline_block(values))
else:
# 运行中 / 已结束:给全景概要,支撑 ask_meta / ask_content
parts.append("素材清单:\n" + _materials_block(values, limit=15))
outline = _outline_block(values)
if outline != "(尚无大纲)":
parts.append("大纲:\n" + outline)
draft = _draft_block(values, body_limit=2500)
if draft != "(尚无草稿)":
parts.append(draft)
review = _review_block(values)
if review != "(尚无编辑审核结果)":
parts.append("编辑审核:\n" + review)
return "\n\n".join(p for p in parts if p)
# ── 结果清洗 ──────────────────────────────────────────────────────────────────
def _clean_payload(raw: Any, action: str, values: dict, interrupt_value: dict | None) -> dict:
"""按动作白名单清洗 payload(键名统一驼峰,供前端直接喂 submitIntervention)。"""
p = raw if isinstance(raw, dict) else {}
out: dict[str, Any] = {}
def _str(key: str) -> str:
v = p.get(key)
return v.strip() if isinstance(v, str) else ""
if action == "confirm":
ids = p.get("approvedMaterialIds") or p.get("approved_material_ids")
if isinstance(ids, list) and ids:
known = {str(m.get("id")) for m in ((values.get("material_package") or {}).get("materials") or [])}
kept = [str(i) for i in ids if not known or str(i) in known]
if kept:
out["approvedMaterialIds"] = kept
elif action == "re_search":
query = _str("userQuery") or _str("user_query")
if query:
out["userQuery"] = query
out["approvedMaterialIds"] = []
elif action == "re_outline":
feedback = _str("outlineFeedback") or _str("outline_feedback")
if feedback:
out["outlineFeedback"] = feedback
elif action in ("user_revise", "to_editor", "accept_review"):
notes = _str("userRevisionNotes") or _str("user_revision_notes")
if notes:
out["userRevisionNotes"] = notes
indices = p.get("selectedIssueIndices") or p.get("selected_issue_indices")
if isinstance(indices, list):
review = values.get("review_result") or {}
total = len(review.get("revision_notes") or review.get("revisionNotes") or [])
cleaned = sorted({int(i) for i in indices if isinstance(i, (int, float)) and 0 <= int(i) < max(total, 0)})
if cleaned:
out["selectedIssueIndices"] = cleaned
# user_revise 必须有意见原文,没有就说明 LLM 输出不完整 → 由上层降级 clarify
elif action == "force_finalize":
out["overrideVerdict"] = True
elif action == "section_help":
blocked = (interrupt_value or {}).get("blocked_sections") or values.get("blocked_sections") or []
blocked_titles = [
str(b.get("section_title") or b.get("sectionTitle") or "") for b in blocked
]
raw_decisions = p.get("sectionDecisions") or p.get("section_decisions") or []
by_title: dict[str, str] = {}
for d in raw_decisions if isinstance(raw_decisions, list) else []:
if not isinstance(d, dict):
continue
title = str(d.get("sectionTitle") or d.get("section_title") or "")
decision = str(d.get("decision") or "")
if title and decision in _SECTION_DECISIONS:
by_title[title] = decision
# 覆盖全部受阻章节;LLM 漏掉的按多数倾向补齐,无倾向用 loose(与后台挂起默认一致)
majority = "loose"
if by_title:
counts: dict[str, int] = {}
for v in by_title.values():
counts[v] = counts.get(v, 0) + 1
majority = max(counts, key=lambda k: counts[k])
out["sectionDecisions"] = [
{"sectionTitle": t, "decision": by_title.get(t, majority)} for t in blocked_titles if t
]
return out
def normalize_intent_result(raw: dict, pause_point: str | None, values: dict, interrupt_value: dict | None = None) -> dict:
"""LLM 原始输出 → 安全的意图契约(动作白名单 / 置信度门槛 / payload 清洗)。"""
intent = str(raw.get("intent") or "unknown").strip() or "unknown"
action = raw.get("action")
action = action.strip() if isinstance(action, str) and action.strip() else None
answer = str(raw.get("answer") or "").strip()
clarify = str(raw.get("clarify") or "").strip()
need_research = bool(raw.get("need_research"))
research_query = str(raw.get("research_query") or "").strip()
try:
confidence = max(0.0, min(1.0, float(raw.get("confidence") or 0.0)))
except (TypeError, ValueError):
confidence = 0.0
def _clarified(message: str) -> dict:
return {
"intent": intent if intent != "unknown" else "clarify",
"pause_point": pause_point,
"action": None,
"payload": {},
"need_research": need_research,
"research_query": research_query,
"answer": answer,
"confidence": confidence,
"clarify": clarify or message,
}
# clarify 非空 / 问答类 → 不执行动作
if clarify:
return _clarified(clarify)
if action is None:
return {
"intent": intent,
"pause_point": pause_point,
"action": None,
"payload": {},
"need_research": need_research,
"research_query": research_query,
"answer": answer,
"confidence": confidence,
"clarify": "",
}
# 无暂停点:图没有可 resume 的 interrupt,任何动作都执行不了
allowed = ALLOWED_ACTIONS.get(pause_point or "")
if not allowed:
return _clarified("当前流程正在自动执行或已结束,没有等待确认的步骤,暂时不能执行该操作。")
if action not in allowed:
return _clarified(
f"这个操作在当前阶段做不了(可用:{'、'.join(sorted(allowed))})。请换一种说法,或等流程走到对应阶段再提。"
)
payload = _clean_payload(raw.get("payload"), action, values, interrupt_value)
# user_revise 没带意见原文 = 输出不完整,宁可反问
if action == "user_revise" and not payload.get("userRevisionNotes"):
return _clarified("你希望怎么修改草稿?请具体说明(例如:第三段太啰嗦,精简一下)。")
# 高代价动作置信度门槛
is_high_cost = action in ("finalize", "force_finalize") or (
action == "re_search" and not payload.get("userQuery")
)
threshold = HIGH_COST_CONFIDENCE if is_high_cost else ACTION_CONFIDENCE
if confidence < threshold:
hint = {
"finalize": "确认要定稿并结束本次写作吗?",
"force_finalize": "确认忽略编辑意见、强制定稿吗?",
"re_search": "重新搜索会重新生成检索词并覆盖现有素材,确认吗?(若只想补充素材,请描述想补充的内容)",
}.get(action, "我理解得不够确定,能再说得具体一点吗?")
return _clarified(hint)
return {
"intent": intent,
"pause_point": pause_point,
"action": action,
"payload": payload,
"need_research": need_research,
"research_query": research_query,
"answer": answer,
"confidence": confidence,
"clarify": "",
}
# ── LLM 调用 ──────────────────────────────────────────────────────────────────
def _build_messages(text: str, pause_point: str | None, values: dict, interrupt_value: dict | None):
from langchain_core.messages import HumanMessage, SystemMessage
stage_guide = STAGE_ACTION_GUIDES.get(pause_point or "", NO_PAUSE_GUIDE)
context = build_context_snapshot(values, pause_point, interrupt_value)
user = INTENT_ROUTER_USER_TEMPLATE.format(stage_guide=stage_guide, context=context, text=text.strip())
return [SystemMessage(content=INTENT_ROUTER_SYSTEM), HumanMessage(content=user)]
async def parse_user_intent(
*,
text: str,
pause_point: str | None,
values: dict,
interrupt_value: dict | None = None,
model_name: str | None = None,
model: Any | None = None,
) -> dict:
"""用户自由文本 → 意图契约 dict(snake_case 顶层字段 + 驼峰 payload)。
``model`` 参数仅供测试注入 fake;生产走 ``create_chat_model`` + 配置模型
容错链(与 intent_parser 同套 fallback_chain)。任何模型层失败都不抛给调用方,
降级为 clarify(意图解析是辅助能力,绝不能把写作页面搞挂)。
"""
from .nodes._utils import _content_to_text, parse_json
fallback = {
"intent": "clarify",
"action": None,
"payload": {},
"need_research": False,
"research_query": "",
"answer": "",
"confidence": 0.0,
"clarify": "我没有理解你的意思,能换个说法再试一次吗?也可以直接点击上方卡片里的按钮操作。",
}
messages = _build_messages(text, pause_point, values, interrupt_value)
raw_text = ""
if model is not None:
try:
resp = await model.ainvoke(messages)
raw_text = _content_to_text(getattr(resp, "content", "") or "")
except Exception:
logger.exception("intent_router: injected model failed")
else:
from deerflow.models import create_chat_model
from .nodes._model_fallback import classify_exception, fallback_chain
chain = fallback_chain((model_name or "").strip() or None) or [None]
for candidate in chain:
try:
m = create_chat_model(name=candidate) if candidate else create_chat_model()
resp = await m.ainvoke(messages)
raw_text = _content_to_text(getattr(resp, "content", "") or "")
break
except Exception as exc: # noqa: BLE001 — 换下一个模型继续
logger.warning(
"intent_router: model=%s failed (reason=%s): %s",
candidate, classify_exception(exc), exc,
)
continue
raw_text = _THINK_RE.sub("", raw_text or "").strip()
if not raw_text:
return normalize_intent_result(fallback, pause_point, values, interrupt_value)
parsed = parse_json(raw_text, fallback)
return normalize_intent_result(parsed, pause_point, values, interrupt_value)
@@ -0,0 +1,104 @@
"""对话驱动写作台的意图路由 prompt。
场景:新版「AI 写作·对话版」页面用底部统一输入框接管全流程交互,用户的自由文本
需要被翻译成现有 ai_writing graph 的 ``Command(resume={action, ...payload})`` 指令,
或被识别为「对内容/流程提问」「控制指令」「需要澄清」。
设计要点(与 graph.py 各 pause 节点严格对齐,绝不发明图不支持的动作):
- material_confirm 只有 confirm / re_search;
- outline_confirm 只有 confirm / re_outline;
- draft_confirm 只有 to_editor / finalize / user_revise;
- review_confirm 只有 accept_review / force_finalize;
- section_help 只有 section_help(带逐章节 decisions)。
高代价动作(finalize / force_finalize / 无查询词的覆盖式 re_search)要求高置信,
否则输出 clarify 反问 —— 防误触发是硬指标。
"""
from __future__ import annotations
INTENT_ROUTER_SYSTEM = """你是「AI 智能写作」工作台的意图路由器。
写作流程由多位专家协作:素材收集专家(检索素材)→ 作家(规划大纲、逐章写作)→ 编辑(事实/逻辑/语言三维审核)。流程在每个阶段完成后暂停,等待用户确认后才继续。
你的任务:读懂用户在当前阶段发来的一句话,把它翻译成一条结构化指令 JSON;如果用户是在提问,就直接回答。
【输出要求】
- 只输出一个 JSON 对象,不要任何解释文字,不要 markdown 代码块,必须用英文双引号。
- JSON 结构(所有字段都必须出现):
{"intent": "意图标识", "action": "动作或null", "payload": {}, "need_research": false, "research_query": "", "answer": "", "confidence": 0.0, "clarify": ""}
【字段说明】
- intent:你判定的意图标识,如 confirm_materials / add_material / select_materials / research_again / confirm_outline / revise_outline / reoutline / to_editor / finalize / revise_draft / accept_review / partial_accept_review / add_review_opinion / force_finalize / section_decision / ask_content / ask_meta / run_background / pause_writing / clarify。
- action:要执行的图动作,只能从下面「当前阶段可用动作」中选择;不执行动作时为 null。
- payload:动作参数(键名用驼峰):
approvedMaterialIds(素材 id 数组,确认时保留哪些素材;不筛选就省略)
userQuery(补充检索的自然语言检索意图,配合 re_search 表示"追加检索")
outlineFeedback(大纲修改意见原文,配合 re_outline)
userRevisionNotes(草稿修改意见/给编辑的关注点/补充审核意见,配合 user_revise / to_editor / accept_review)
selectedIssueIndices(采纳的审核意见序号数组,从 0 开始,配合 user_revise / accept_review)
overrideVerdict(true,仅配合 force_finalize)
sectionDecisions([{"sectionTitle": "...", "decision": "supplement|loose|delete"}],仅配合 section_help,必须覆盖全部受阻章节)
- need_research:用户的要求需要「当前素材列表里没有的信息」才能完成时置 true,并在 research_query 给出简短检索词(不需要就置 false + 空串)。
- answer:用户在提问(ask_content 对文章内容提问 / ask_meta 问进度、用了哪些素材等)时,直接根据上下文用中文简洁回答;答案必须基于给定上下文,不得编造。提问类 action 必须为 null。
- confidence:0~1,你对「动作映射正确」的把握。
- clarify:拿不准用户想干什么、或该操作在当前阶段做不了时,写一句具体的中文反问/解释(可给出可选项);clarify 非空时 action 必须为 null。
【硬规则】
1. action 只能从「当前阶段可用动作」里选。用户要求的事在当前阶段做不了(例如写作阶段要求重新搜索素材),action 置 null,用 clarify 解释当前能做什么、该要求何时能做。
2. 高代价动作必须用户意图非常明确才输出,且 confidence ≥ 0.85,否则改用 clarify 反问确认:
- finalize(定稿,结束流程)
- force_finalize(无视编辑意见强制定稿)
- 不带 userQuery 的 re_search(覆盖式重搜,会丢掉现有素材重来)
注意:带 userQuery 的 re_search 是"追加检索"(保留现有素材,把新素材追加进列表),不算高代价。
3. 用户说"只用第 X、Y 条素材"→ action=confirm + approvedMaterialIds(按素材清单序号找对应 id);"去掉第 X 条"→ confirm + 反选后的 approvedMaterialIds。
4. 用户给出修改意见时把**原文完整**放进对应字段(outlineFeedback / userRevisionNotes),不要概括缩写。
5. 用户说"只接受第 X 条意见"→ selectedIssueIndices 用审核意见清单的序号(从 0 开始)。用户补充自己的审核/修改要求 → 并入 userRevisionNotes。
6. 控制类:用户要"转后台/后台跑完"→ intent=run_background;"暂停"→ intent=pause_writing。二者 action 都为 null(由前端执行)。
7. 完全无法判断 → intent=clarify,confidence ≤ 0.3,clarify 给一个具体问题。"""
# 每个暂停点的「可用动作说明」——注入 user prompt,让模型只在合法动作里选。
STAGE_ACTION_GUIDES: dict[str, str] = {
"material_confirm": """当前阶段:素材确认(素材收集专家已完成搜集,等你处理素材)。
可用动作:
- confirm:确认素材、进入大纲规划。可带 approvedMaterialIds 只保留部分素材。
- re_search:重新检索。带 userQuery = 按用户描述追加检索(保留现有素材);不带 userQuery = 覆盖式重搜(高代价,需高置信)。
典型说法 → 动作:
「可以,继续」→ confirm;「补充一些关于X的素材」「再查查Y」→ re_search + userQuery;「只用第1、3条」→ confirm + approvedMaterialIds;「重新搜索」→ re_search(覆盖式,需高置信)。""",
"outline_confirm": """当前阶段:大纲确认(作家已完成大纲规划,等你确认或提修改要求)。
可用动作:
- confirm:确认大纲、开始写作。
- re_outline:按意见重新规划大纲,outlineFeedback 放用户意见原文(可空 = 完全重来)。
典型说法 → 动作:
「大纲可以」→ confirm;「第二章拆成两节」「把X挪到前面」「加一章讲Y」→ re_outline + outlineFeedback;「重写大纲」→ re_outline(feedback 空)。
注意:本阶段不能检索素材。用户想补素材时若其本质是"大纲里加上X内容",可映射 re_outline + outlineFeedback 并视情况置 need_research。""",
"draft_confirm": """当前阶段:草稿确认(作家已完成草稿,右侧可预览,等你处理)。
可用动作:
- to_editor:交给编辑审核。可带 userRevisionNotes = 让编辑重点核查的方面。
- user_revise:按用户意见修改草稿,userRevisionNotes 放意见原文(必填)。若已有编辑审核意见且用户说按意见改,可带 selectedIssueIndices。
- finalize:确认定稿,结束流程(高代价,需高置信)。
典型说法 → 动作:
「交给编辑」「审核一下」→ to_editor;「第三段精简」「补个案例」「更口语一点」「重写第二章」→ user_revise + userRevisionNotes;「就这样定稿」→ finalize(需高置信);「让编辑重点看数据准确性」→ to_editor + userRevisionNotes。""",
"review_confirm": """当前阶段:编辑打回处理(编辑认为文章需要修改,列出了审核意见,等你决定)。
可用动作:
- accept_review:按编辑意见修改。可带 selectedIssueIndices 只采纳部分意见;用户自己补充的修改要求并入 userRevisionNotes。
- force_finalize:忽略编辑意见强制定稿,payload 带 {"overrideVerdict": true}(高代价,需高置信)。
典型说法 → 动作:
「按编辑意见改」→ accept_review;「只接受第1、2条」→ accept_review + selectedIssueIndices;「再补一条:核查数据来源」→ accept_review + userRevisionNotes;「不改了,定稿」→ force_finalize(需高置信)。""",
"section_help": """当前阶段:素材不足求助(严格模式下部分章节缺素材,需逐章节决定处理方式)。
可用动作:
- section_help:sectionDecisions 必须覆盖全部受阻章节,每章 decision ∈ supplement(补充检索素材)/ loose(按通用知识续写)/ delete(删除该章节)。
典型说法 → 动作:
「都补充检索」→ 全部 supplement;「都按通用知识写」→ 全部 loose;「第一个删掉,其他补检索」→ 对应组合。用户没说全 → 未提到的章节按用户整体倾向补齐,倾向不明则用 loose。""",
}
NO_PAUSE_GUIDE = """当前没有等待确认的步骤(流程正在自动执行中,或已结束)。
不能执行任何图动作(action 必须为 null)。用户提问 → 正常回答(answer);用户提出操作要求 → 用 clarify 告知「当前阶段正在进行中,完成后会暂停等确认,届时再提出即可」,并简述现在进行到哪一步。控制类(run_background / pause_writing)照常输出 intent。"""
INTENT_ROUTER_USER_TEMPLATE = """{stage_guide}
【写作上下文】
{context}
【用户这句话】
{text}
请输出意图 JSON:"""