feat(ai写作): 草稿局部修改只改命中章节,支持改章节标题,修改时右侧流式重写

用户在草稿确认填修改意见(user_revise)时,按章节定位只重写命中章节、
其余字节级保留;新增范围识别区分「改标题」与「改正文」,改标题同步回写
outline 使其持久生效(修复改章节标题不生效);整体性/结构性意见自动回退
整篇重写。前端新增 isRevising:修改时右侧先恢复初始占位(清空旧稿),
重写流到达后实时展示被改章节的流式重写。

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 19:45:00 +08:00
1 parent 42f55251f2
commit c117e296cb
6 files changed
+125 -51

No files matched your search

@@ -41,10 +41,23 @@ export function AIWritingDraftPanel() {
const showCompletedDraftView = isDraftEditable || isHistoryMode; const showCompletedDraftView = isDraftEditable || isHistoryMode;
const writingStatus = writing?.state.status; const writingStatus = writing?.state.status;
const streamingSectionCount = writing?.state.streamingDraftSections.length ?? 0;
// 按用户意见修改中、且重写流还没开始(无任何流式章节):右侧先恢复初始空占位,
// 把旧稿清掉;一旦重写流式 chunk 到达(streamingDraftSections 非空)就走下面的
// 流式预览,实时展示重写过程;draft_ready 后 isRevising 置回 false 显示新成稿。
if (writing?.state.isRevising && streamingSectionCount === 0) {
return (
<div className="h-full bg-muted/10 border-l">
<DraftWaitingPlaceholder />
</div>
);
}
// 仅当章节仍在累积时走流式 Markdown;draft_ready 清空 sections 后即使 status 尚未切走也进画布 // 仅当章节仍在累积时走流式 Markdown;draft_ready 清空 sections 后即使 status 尚未切走也进画布
const isActivelyStreamingDraft = const isActivelyStreamingDraft =
(writingStatus === 'writing_draft' || writingStatus === 'revising') && (writingStatus === 'writing_draft' || writingStatus === 'revising') &&
(writing?.state.streamingDraftSections.length ?? 0) > 0; streamingSectionCount > 0;
const streamingMarkdown = isActivelyStreamingDraft && writing const streamingMarkdown = isActivelyStreamingDraft && writing
? assembleStreamingDraftMarkdown( ? assembleStreamingDraftMarkdown(
@@ -70,6 +70,7 @@ const INITIAL_STATE: AIWritingState = {
streamingReviewItems: [], streamingReviewItems: [],
revisionSummary: null, revisionSummary: null,
pendingRevisionContext: null, pendingRevisionContext: null,
isRevising: false,
}; };
export { INITIAL_STATE }; export { INITIAL_STATE };
@@ -118,7 +119,8 @@ export function reducer(prev: AIWritingState, action: Action): AIWritingState {
...(ns === 'writing_outline' ? { outlineStreamingText: '', outlineThinkingText: '' } : {}), ...(ns === 'writing_outline' ? { outlineStreamingText: '', outlineThinkingText: '' } : {}),
...(ns === 'writing_draft' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}), ...(ns === 'writing_draft' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
...(ns === 'reviewing' ? { streamingReviewItems: [] } : {}), ...(ns === 'reviewing' ? { streamingReviewItems: [] } : {}),
...(ns === 'revising' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}), // 进入「修改」:右侧画布恢复初始空占位(isRevising),不再显示旧稿/流式重写
...(ns === 'revising' ? { streamingDraftSections: [], streamingDraftTitle: '', isRevising: true } : {}),
completedInterventions: [...prev.completedInterventions, action.record], completedInterventions: [...prev.completedInterventions, action.record],
...(action.pendingRevisionContext ? { pendingRevisionContext: action.pendingRevisionContext } : {}), ...(action.pendingRevisionContext ? { pendingRevisionContext: action.pendingRevisionContext } : {}),
}; };
@@ -357,6 +359,8 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
streamingDraftTitle: '', streamingDraftTitle: '',
revisionSummary: summary, revisionSummary: summary,
pendingRevisionContext: null, pendingRevisionContext: null,
// 修改完成:解除右侧空占位,展示新成稿
isRevising: false,
}; };
} }
@@ -389,4 +389,7 @@ export interface AIWritingState {
skippedIssues: ReviewIssue[]; skippedIssues: ReviewIssue[];
oldWordCount: number; oldWordCount: number;
} | null; } | null;
/** 是否正按用户意见修改中(revising 提交起 → draft_ready 止)。
* 为 true 时右侧画布恢复初始空占位,不展示旧稿 / 流式重写过程。 */
isRevising: boolean;
} }
+1 -1
View File
@@ -278,7 +278,7 @@ Seeding mirrors the roundtable pattern: verified `config.yaml` + `SOUL.md` ship
**Agentic skill execution (`ai_writing.researcher_skill_agent`, default false).** By default a configured non-native skill is *not* run agent-style — the node just turns its `description` into a query for the generic search provider. With this flag on, the directed-skill branch instead runs `nodes/_skill_agent.py::run_skill_search_agent`: it injects the skill's **SKILL.md** (read from `skill.skill_file`) alongside the researcher SOUL as the system prompt, binds a `search` tool to the researcher model, and runs a bounded (`_MAX_TURNS=4`) tool-call loop so the model drives the retrieval per the skill's instructions; the `SearchResult`s the tool returns are harvested directly as materials (no fuzzy text extraction). Requires a tool-calling model; if the model can't tool-call (returns no `tool_calls`) or the agent yields nothing, `_execute_skill` falls back to the legacy description→query search so materials are never empty. The built-in `knowledge-base-search` native concurrent path is unchanged. Tests: `tests/test_ai_writing_skill_agent.py`. **Agentic skill execution (`ai_writing.researcher_skill_agent`, default false).** By default a configured non-native skill is *not* run agent-style — the node just turns its `description` into a query for the generic search provider. With this flag on, the directed-skill branch instead runs `nodes/_skill_agent.py::run_skill_search_agent`: it injects the skill's **SKILL.md** (read from `skill.skill_file`) alongside the researcher SOUL as the system prompt, binds a `search` tool to the researcher model, and runs a bounded (`_MAX_TURNS=4`) tool-call loop so the model drives the retrieval per the skill's instructions; the `SearchResult`s the tool returns are harvested directly as materials (no fuzzy text extraction). Requires a tool-calling model; if the model can't tool-call (returns no `tool_calls`) or the agent yields nothing, `_execute_skill` falls back to the legacy description→query search so materials are never empty. The built-in `knowledge-base-search` native concurrent path is unchanged. Tests: `tests/test_ai_writing_skill_agent.py`.
**Targeted user revision (局部修改 — 只改命中章节).** When the user submits a free-text 修改意见 on the draft-confirm card (`action == "user_revise"`), `writer_draft_node` no longer always rewrites the whole article section-by-section. Before the full-rewrite loop it tries `_targeted_user_revision` (`nodes/writer_draft.py`): `_split_draft_sections` parses the previous `current_draft.full_markdown` into `(article_title, [(section_title, body)], references_block)` (level-1 `#` title, level-2 `##` sections, trailing `## 参考文献` pulled out and kept verbatim), then `_detect_revision_scope` runs a small `llm_json` classifier (prompts `DRAFT_REVISE_SCOPE_{SYSTEM,USER}`) that maps the note to the section indices it targets ("第一段/开头/引言"→0, "结尾/结论"→last, by title/topic, etc.). **Only the hit sections are rewritten** via `_stream_revise_section` (prompt `DRAFT_REVISE_SECTION_USER`, streamed as `draft_chunk` so the user watches the edit); **every unhit section is preserved byte-for-byte** and emitted to the stream as one full-text `draft_chunk` + `is_section_done` so the frontend's `streamingDraftSections` rebuilds the complete article (it was reset to `[]` on entering `revising`). The classifier returns `whole_article=true` (整体性意见 like 「整体语气更正式」/「全文都改」, or any structure-changing ask) → `_detect_revision_scope` yields `None` → `_targeted_user_revision` returns `None` → the node **falls back to the existing whole-article rewrite** (which still runs `_revise_outline_with_notes` for title/structure changes). Also falls back when there is no prior draft, an empty note, or scope detection errors. Editor-review revisions (`accept_review`) are unaffected — they keep the holistic rewrite. Reassembly preserves the article title + section structure and re-appends the original references block; word count is computed on the body (excluding references), matching the main path. Tests: `tests/test_ai_writing_targeted_revision.py`. **Targeted user revision (局部修改 — 只改命中章节).** When the user submits a free-text 修改意见 on the draft-confirm card (`action == "user_revise"`), `writer_draft_node` no longer always rewrites the whole article section-by-section. Before the full-rewrite loop it tries `_targeted_user_revision` (`nodes/writer_draft.py`): `_split_draft_sections` parses the previous `current_draft.full_markdown` into `(article_title, [(section_title, body)], references_block)` (level-1 `#` title, level-2 `##` sections, trailing `## 参考文献` pulled out and kept verbatim), then `_detect_revision_scope` runs a small `llm_json` classifier (prompts `DRAFT_REVISE_SCOPE_{SYSTEM,USER}`) returning **per-section targets** `[{index, new_title, rewrite_body}]`: it maps the note to the section indices it targets ("第一段/开头/引言"→0, "结尾/结论"→last, by title/topic) **and** distinguishes a **章节标题改名** (`new_title` set, `rewrite_body=false` → rename only, body untouched) from a **正文修改** (`rewrite_body=true`). Title-only renames apply directly to the assembled `## {new_title}` heading **and are written back into `current_outline.sections[i].section_title`** (so the renamed title persists through confirm/finalize/history — this fixes "改章节标题不生效"). Body rewrites run `_stream_revise_section` (prompt `DRAFT_REVISE_SECTION_USER`, streamed as `draft_chunk`). **Only hit sections are touched; unhit sections are preserved byte-for-byte and are NOT re-emitted to the stream** — the frontend clears the right canvas to its initial placeholder on entering `revising` and shows only the live rewrite of the changed section(s), so the user sees「清空 → 流式重写 → 终稿」rather than the whole article rebuilding. The classifier returns `whole_article=true` (整体性意见 like 「整体语气更正式」/「全文都改」, **structure changes** 加章/删章/调整顺序, or a **文章总标题** change) → `_detect_revision_scope` yields `None` → `_targeted_user_revision` returns `None` → the node **falls back to the whole-article rewrite** (which still runs `_revise_outline_with_notes` for title/structure changes). Also falls back when there is no prior draft, an empty note, or scope-detection errors. Editor-review revisions (`accept_review`) keep the holistic rewrite. Reassembly preserves the article title + structure and re-appends the original references block; word count is computed on the body (excluding references). Frontend: `isRevising` in `AIWritingState` (`types/ai-writing.ts`) is set in `useAIWritingStream.ts`'s `intervention_submitted` (`ns === 'revising'`) and cleared on `draft_ready`; `AIWritingDraftPanel.tsx` shows the initial `DraftWaitingPlaceholder` while `isRevising && streamingDraftSections.length === 0`, then the streaming preview once chunks arrive. Tests: `tests/test_ai_writing_targeted_revision.py`.
**History draft editing (历史回看可编辑成稿).** The right-side draft in 历史回看 is no longer read-only — the user can edit it like the live 写作完成态 (Tiptap + 目录 + 格式工具栏 + AI 润色) and **persist** the change back to the session. `PUT /api/ai-writing/sessions/{id}/draft` `{draft_markdown, draft_title?}` (owner-checked → 404 otherwise; `draft_title` omitted ⇒ keep原标题) updates the row via the existing `AIWritingSessionRepository.update`. Frontend: `AIWritingDraftPanel` now passes `readOnly={false}` + an `onSaveDraft` handler (only in history mode) → `TextArtifactRenderer` → `WritingToolbar` 「保存」button (loading + toast); the handler is `AIWritingHistoryContext.saveHistoryDraft(markdown, title?)` → `updateAIWritingDraft` (`api/ai-writing-sessions.ts`), which write-backs the returned summary to `historySession`. Edits to the **live** completed draft stay ephemeral (export-only) as before — only history mode shows 保存. Tests: `tests/test_ai_writing_draft_update.py`. **History draft editing (历史回看可编辑成稿).** The right-side draft in 历史回看 is no longer read-only — the user can edit it like the live 写作完成态 (Tiptap + 目录 + 格式工具栏 + AI 润色) and **persist** the change back to the session. `PUT /api/ai-writing/sessions/{id}/draft` `{draft_markdown, draft_title?}` (owner-checked → 404 otherwise; `draft_title` omitted ⇒ keep原标题) updates the row via the existing `AIWritingSessionRepository.update`. Frontend: `AIWritingDraftPanel` now passes `readOnly={false}` + an `onSaveDraft` handler (only in history mode) → `TextArtifactRenderer` → `WritingToolbar` 「保存」button (loading + toast); the handler is `AIWritingHistoryContext.saveHistoryDraft(markdown, title?)` → `updateAIWritingDraft` (`api/ai-writing-sessions.ts`), which write-backs the returned summary to `historySession`. Edits to the **live** completed draft stay ephemeral (export-only) as before — only history mode shows 保存. Tests: `tests/test_ai_writing_draft_update.py`.
@@ -406,15 +406,20 @@ def _split_draft_sections(markdown: str) -> tuple[str, list[tuple[str, str]], st
async def _detect_revision_scope( async def _detect_revision_scope(
state: AIWritingState, config: RunnableConfig, state: AIWritingState, config: RunnableConfig,
parsed_sections: list[tuple[str, str]], note: str, parsed_sections: list[tuple[str, str]], note: str,
) -> set[int] | None: ) -> list[dict] | None:
"""LLM 识别修改意见指向哪些章节编号;整体性/无法定位/异常一律返回 None(回退整篇重写)。""" """LLM 识别意见指向哪些章节、各自怎么改(改标题 / 改正文)。
返回 ``[{"index": int, "new_title": str | None, "rewrite_body": bool}, ...]``,
整体性 / 改变结构 / 无法定位 / 异常一律返回 None(回退整篇重写)。
"""
listing = "\n".join(f"{i}. {t}" for i, (t, _b) in enumerate(parsed_sections)) listing = "\n".join(f"{i}. {t}" for i, (t, _b) in enumerate(parsed_sections))
model_name = (state.get("model_name") or "").strip() or None model_name = (state.get("model_name") or "").strip() or None
n = len(parsed_sections)
try: try:
result = await llm_json( result = await llm_json(
DRAFT_REVISE_SCOPE_SYSTEM, DRAFT_REVISE_SCOPE_SYSTEM,
DRAFT_REVISE_SCOPE_USER.format(n=len(parsed_sections), sections=listing, note=note), DRAFT_REVISE_SCOPE_USER.format(n=n, sections=listing, note=note),
{"whole_article": True, "target_indexes": []}, {"whole_article": True, "targets": []},
config, config,
model_name=model_name, model_name=model_name,
) )
@@ -423,18 +428,41 @@ async def _detect_revision_scope(
return None return None
if not isinstance(result, dict) or result.get("whole_article"): if not isinstance(result, dict) or result.get("whole_article"):
return None return None
raw = result.get("target_indexes") or result.get("targetIndexes") or []
if not isinstance(raw, list): by_idx: dict[int, dict] = {}
return None raw_targets = result.get("targets")
idxs: set[int] = set() if isinstance(raw_targets, list) and raw_targets:
for x in raw: for item in raw_targets:
try: if not isinstance(item, dict):
k = int(x) continue
except (ValueError, TypeError): try:
continue idx = int(item.get("index"))
if 0 <= k < len(parsed_sections): except (ValueError, TypeError):
idxs.add(k) continue
return idxs or None if not (0 <= idx < n):
continue
nt = item.get("new_title")
new_title = nt.strip() if isinstance(nt, str) and nt.strip() else None
rewrite_body = item.get("rewrite_body")
# 缺省:模型没给 rewrite_body 时——有新标题就当「只改标题」,否则当「改正文」
if rewrite_body is None:
rewrite_body = new_title is None
by_idx[idx] = {"index": idx, "new_title": new_title, "rewrite_body": bool(rewrite_body)}
else:
# 兼容旧结构 target_indexes:纯正文重写
raw_idx = result.get("target_indexes") or result.get("targetIndexes") or []
if isinstance(raw_idx, list):
for x in raw_idx:
try:
idx = int(x)
except (ValueError, TypeError):
continue
if 0 <= idx < n:
by_idx[idx] = {"index": idx, "new_title": None, "rewrite_body": True}
# 丢弃既不改标题也不改正文的空操作项
targets = [t for t in by_idx.values() if t["new_title"] is not None or t["rewrite_body"]]
return targets or None
async def _stream_revise_section( async def _stream_revise_section(
@@ -516,14 +544,27 @@ async def _targeted_user_revision(
targets = await _detect_revision_scope(state, config, parsed, note) targets = await _detect_revision_scope(state, config, parsed, note)
if not targets: if not targets:
return None return None
by_idx = {t["index"]: t for t in targets}
outline = state.get("current_outline") or {"title": article_title, "sections": []} outline = state.get("current_outline") or {"title": article_title, "sections": []}
out_sections = outline.get("sections", []) out_sections = outline.get("sections", [])
revision_count = state.get("revision_count", 0) revision_count = state.get("revision_count", 0)
new_titles: list[str] = []
new_bodies: list[str] = [] new_bodies: list[str] = []
changed_labels: list[str] = []
title_changed = False
for idx, (sec_title, body) in enumerate(parsed): for idx, (sec_title, body) in enumerate(parsed):
if idx in targets: t = by_idx.get(idx)
if t is None:
# 未命中章节:标题、正文字节级原样保留(不重写、不下发流式)
new_titles.append(sec_title)
new_bodies.append(body)
continue
final_title = t["new_title"] or sec_title
if t["new_title"] and t["new_title"] != sec_title:
title_changed = True
if t["rewrite_body"]:
out_sec = out_sections[idx] if idx < len(out_sections) else {} out_sec = out_sections[idx] if idx < len(out_sections) else {}
key_points_str = _render_key_points_for_prompt( key_points_str = _render_key_points_for_prompt(
out_sec.get("key_points") or out_sec.get("keyPoints") or [] out_sec.get("key_points") or out_sec.get("keyPoints") or []
@@ -532,49 +573,54 @@ async def _targeted_user_revision(
out_sec.get("material_ids") or out_sec.get("materialIds") or [], pkg, cite_map out_sec.get("material_ids") or out_sec.get("materialIds") or [], pkg, cite_map
) )
new_body = await _stream_revise_section( new_body = await _stream_revise_section(
state, config, article_title=article_title, idx=idx, section_title=sec_title, existing_text=body, state, config, article_title=article_title, idx=idx, section_title=final_title,
key_points_str=key_points_str, section_materials=section_materials, existing_text=body, key_points_str=key_points_str, section_materials=section_materials,
note=note, add_references=add_references, writer=writer, note=note, add_references=add_references, writer=writer,
use_agent=use_agent, legacy_model_name=legacy_model_name, use_agent=use_agent, legacy_model_name=legacy_model_name,
) )
new_bodies.append(new_body)
else: else:
# 未改动章节:整段瞬时下发 + 收尾,让前端草稿视图完整重建(其余字节级原样保留) # 只改标题、不动正文:正文原样保留
writer({ new_body = body
"type": "draft_chunk", "section_index": idx, "section_title": sec_title, new_titles.append(final_title)
"text": body, "is_thinking": False, "is_section_done": False, new_bodies.append(new_body)
}) changed_labels.append(final_title)
writer({
"type": "draft_chunk", "section_index": idx, "section_title": sec_title,
"text": "", "is_section_done": True,
})
new_bodies.append(body)
body_md = "\n\n".join( body_md = "\n\n".join(
f"## {t}\n\n{_normalize_section_headings(b)}" f"## {t}\n\n{_normalize_section_headings(b)}"
for (t, _old), b in zip(parsed, new_bodies) for t, b in zip(new_titles, new_bodies)
) )
full_markdown = f"# {article_title}\n\n{body_md}" full_markdown = f"# {article_title}\n\n{body_md}"
word_count = count_words(full_markdown) word_count = count_words(full_markdown)
if references_block: if references_block:
full_markdown = f"{full_markdown}\n\n{references_block}" full_markdown = f"{full_markdown}\n\n{references_block}"
# 章节标题被改 → 同步回 outline(按 index 改 section_title),使后续确认/审核/历史结构一致
updated_outline = outline
if title_changed and out_sections:
updated_outline = {
**outline,
"sections": [
({**s, "section_title": new_titles[i]} if i < len(new_titles) else s)
for i, s in enumerate(out_sections)
],
}
draft = DraftArticle( draft = DraftArticle(
title=article_title or outline.get("title", "文章"), title=article_title or updated_outline.get("title", "文章"),
outline=outline, outline=updated_outline,
full_markdown=full_markdown, full_markdown=full_markdown,
word_count=word_count, word_count=word_count,
revision_count=revision_count, revision_count=revision_count,
) )
await _save_draft_to_repo(config, draft) await _save_draft_to_repo(config, draft)
changed_names = "、".join(parsed[i][0] for i in sorted(targets)) changed_names = "、".join(changed_labels) if changed_labels else "相关章节"
notices: list[dict] = list(extra_notices or []) notices: list[dict] = list(extra_notices or [])
notices.append({ notices.append({
"type": "notice", "agent_name": agent_label, "title": "局部修改", "type": "notice", "agent_name": agent_label, "title": "局部修改",
"detail": f"已按你的意见只修改了 {len(targets)} 个章节({changed_names}),其余内容原样保留。", "detail": f"已按你的意见只改动了 {len(by_idx)} 个章节({changed_names}),其余内容原样保留。",
}) })
return { updates: dict = {
"current_draft": draft, "current_draft": draft,
"drafts": [draft], "drafts": [draft],
"status": "awaiting_draft_confirm", "status": "awaiting_draft_confirm",
@@ -584,12 +630,15 @@ async def _targeted_user_revision(
{ {
"type": "draft_ready", "agent_name": agent_label, "type": "draft_ready", "agent_name": agent_label,
"message": f"修改完成,共 {word_count} 字", "message": f"修改完成,共 {word_count} 字",
"draft_title": article_title or outline.get("title", "文章"), "draft_title": article_title or updated_outline.get("title", "文章"),
"draft_markdown": full_markdown, "draft_markdown": full_markdown,
"revision_count": revision_count, "revision_count": revision_count,
}, },
], ],
} }
if title_changed:
updates["current_outline"] = updated_outline
return updates
async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> dict: async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> dict:
@@ -231,32 +231,37 @@ DRAFT_SECTION_USER = """文章标题:{article_title}
# ── 用户局部修改:识别意见指向的章节范围 ─────────────────────────────────────── # ── 用户局部修改:识别意见指向的章节范围 ───────────────────────────────────────
DRAFT_REVISE_SCOPE_SYSTEM = """你是一名编辑助理。用户对一篇已成稿的文章提出了一句修改意见,你要判断这条意见究竟要改文章里的哪些「章节」。 DRAFT_REVISE_SCOPE_SYSTEM = """你是一名编辑助理。用户对一篇已成稿的文章提出了一句修改意见,你要判断这条意见要改文章里的哪些「章节」,以及每个章节具体改什么。
文章由若干带编号的章节组成(编号从 0 开始)。 文章由若干带编号的章节组成(编号从 0 开始)。
【判断规则】(最高优先级) 【判断规则】(最高优先级)
- 仔细理解意见指向,可能是: 1. 定位意见指向的章节:
- 序号:「第一段 / 第一部分 / 第1章」→ 编号 0,「第二段」→ 编号 1,依此类推; - 序号:「第一段 / 第一部分 / 第1章」→ 编号 0,「第二段」→ 编号 1,依此类推;
- 位置:「开头 / 引言 / 导语」→ 第一个章节,「结尾 / 结论 / 最后一段」→ 最后一个章节; - 位置:「开头 / 引言 / 导语」→ 第一个章节,「结尾 / 结论 / 最后一段」→ 最后一个章节;
- 直接点名某个章节的标题或主题。 - 直接点名某个章节的标题或主题。
- 若意见只针对个别章节,输出这些章节的编号列表,并令 whole_article=false。 2. 对每个命中的章节,判断用户要改什么:
- 若意见是**整体性**的(如「全文都改」「整体语气更正式」「每一部分都精简」「通篇润色」), - 若要改这一章节的**标题**(如「把第一章的标题改为简介」「第二部分改名叫…」),把新标题文字放进 new_title;否则 new_title 写 null。
或会**改变文章结构**(如「标题改成…」「加一章 / 删一章 / 调整章节顺序」),令 whole_article=true、target_indexes 留空。 - 若要改这一章节的**正文内容**(润色 / 补充 / 精简 / 重写等),rewrite_body 写 true;若只改标题、不动正文,rewrite_body 写 false。
- 拿不准、无法明确定位到具体章节时,一律 whole_article=true(交给整篇重写更安全)。 3. 下列情况属于整体性、或会改变文章结构,一律令 whole_article=true、targets 留空:
- 整体性:「全文都改」「通篇润色」「整体语气更正式」「每一部分都精简」;
- 结构性:新增章节、删除章节、调整章节顺序、合并 / 拆分章节;
- 改**文章总标题**(注意:是整篇文章的标题,不是某个章节的标题)。
4. 拿不准、无法明确定位到具体章节时,一律 whole_article=true(交给整篇重写更安全)。
【严禁思考与表态】 【严禁思考与表态】
- 不要输出任何思考、解释或对用户说的话;收到输入后立即直接输出最终 JSON。 - 不要输出任何思考、解释或对用户说的话;收到输入后立即直接输出最终 JSON。
【输出要求】 【输出要求】
- 只输出一个 JSON 对象,不要解释文字,不要 markdown 代码块,必须用英文双引号。 - 只输出一个 JSON 对象,不要解释文字,不要 markdown 代码块,必须用英文双引号。
- 结构:{"whole_article": false, "target_indexes": [0]}""" - new_title 没有就写 null;targets 为空数组表示走整篇重写。
- 结构:{"whole_article": false, "targets": [{"index": 0, "new_title": "简介", "rewrite_body": false}]}"""
DRAFT_REVISE_SCOPE_USER = """文章共有 {n} 个章节,编号与标题如下: DRAFT_REVISE_SCOPE_USER = """文章共有 {n} 个章节,编号与标题如下:
{sections} {sections}
用户的修改意见:{note} 用户的修改意见:{note}
请判断这条意见要改哪些章节,按要求只输出 JSON。""" 请判断这条意见要改哪些章节、各自怎么改(标题 / 正文),按要求只输出 JSON。"""
# ── 用户局部修改:只重写命中的单个章节(保留其余内容)───────────────────────── # ── 用户局部修改:只重写命中的单个章节(保留其余内容)─────────────────────────