refactor: Agent驱动重构,整章写入替代场景拼接
This commit is contained in:
38
README.md
38
README.md
@@ -6,9 +6,9 @@
|
|||||||
|
|
||||||
- **多智能体协作** — Coordinator 调度 Architect / Writer / Editor 三个专职智能体,各司其职
|
- **多智能体协作** — Coordinator 调度 Architect / Writer / Editor 三个专职智能体,各司其职
|
||||||
- **确定性控制面** — 宿主程序通过信号文件驱动流程,不依赖 LLM 判断控制流
|
- **确定性控制面** — 宿主程序通过信号文件驱动流程,不依赖 LLM 判断控制流
|
||||||
- **场景级断点恢复** — 中断后从上次写到的场景精确续写,不丢失进度
|
- **章节级断点恢复** — 中断后从上次写到的章节续写,不丢失进度
|
||||||
- **自适应上下文策略** — 根据总章节数自动切换全量 / 滑窗 / 分层摘要,支持 500+ 章长篇
|
- **自适应上下文策略** — 根据总章节数自动切换全量 / 滑窗 / 分层摘要,支持 500+ 章长篇
|
||||||
- **六维质量评审** — Editor 从设定一致性、角色行为、节奏、叙事连贯、伏笔、钩子六个维度评审
|
- **七维质量评审** — Editor 从设定一致性、角色行为、节奏、叙事连贯、伏笔、钩子、审美品质七个维度评审,审美维度必须引用原文举证
|
||||||
- **用户实时干预** — 写作过程中可随时注入修改意见,系统自动评估影响范围并重写
|
- **用户实时干预** — 写作过程中可随时注入修改意见,系统自动评估影响范围并重写
|
||||||
- **双模式运行** — CLI 一行命令直接跑,TUI 交互界面实时观察进度
|
- **双模式运行** — CLI 一行命令直接跑,TUI 交互界面实时观察进度
|
||||||
- **多 LLM 支持** — OpenRouter / Anthropic / Gemini / OpenAI 随意切换
|
- **多 LLM 支持** — OpenRouter / Anthropic / Gemini / OpenAI 随意切换
|
||||||
@@ -38,10 +38,10 @@
|
|||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| **Coordinator** | 调度全局,处理评审裁定和用户干预 | `subagent` `novel_context` `ask_user` |
|
| **Coordinator** | 调度全局,处理评审裁定和用户干预 | `subagent` `novel_context` `ask_user` |
|
||||||
| **Architect** | 生成前提、大纲、角色档案、世界规则 | `novel_context` `save_foundation` |
|
| **Architect** | 生成前提、大纲、角色档案、世界规则 | `novel_context` `save_foundation` |
|
||||||
| **Writer** | 逐场景写作 → 打磨 → 一致性检查 → 提交 | `novel_context` `plan_chapter` `write_scene` `polish_chapter` `check_consistency` `commit_chapter` |
|
| **Writer** | 自主完成一章的构思、写作、自审和提交 | `novel_context` `read_chapter` `plan_chapter` `draft_chapter` `check_consistency` `commit_chapter` |
|
||||||
| **Editor** | 跨章节六维评审,弧/卷级摘要生成 | `novel_context` `save_review` `save_arc_summary` `save_volume_summary` |
|
| **Editor** | 阅读原文,从结构和审美两个层面审阅 | `novel_context` `read_chapter` `save_review` `save_arc_summary` `save_volume_summary` |
|
||||||
|
|
||||||
### 写作流水线
|
### 写作流程
|
||||||
|
|
||||||
```
|
```
|
||||||
用户需求 → Architect 建基 → Writer 逐章写作 → Editor 评审
|
用户需求 → Architect 建基 → Writer 逐章写作 → Editor 评审
|
||||||
@@ -49,14 +49,14 @@
|
|||||||
└── 重写/打磨 ◄───┘
|
└── 重写/打磨 ◄───┘
|
||||||
```
|
```
|
||||||
|
|
||||||
每章写作严格按序执行:
|
Writer 自主决定每章的创作流程,建议路径:
|
||||||
|
|
||||||
1. `novel_context` — 加载上下文(前情摘要、时间线、伏笔、角色状态)
|
1. `novel_context` — 加载上下文(前情摘要、时间线、伏笔、角色状态、风格锚点、声纹)
|
||||||
2. `plan_chapter` — 规划 3-5 个场景
|
2. `read_chapter` — 回读前一章结尾和角色对话,找回语气和节奏
|
||||||
3. `write_scene` × N — 逐场景创作(800-1500 字/场景)
|
3. `plan_chapter` — 构思本章目标、冲突、情绪弧线
|
||||||
4. `polish_chapter` — 合并打磨,去除 AI 腔
|
4. `draft_chapter` — 写入整章正文
|
||||||
5. `check_consistency` — 校验时间线、角色、世界规则
|
5. `read_chapter` + `check_consistency` — 自审:回读草稿,对照状态数据检查一致性
|
||||||
6. `commit_chapter` — 提交终稿,更新全局状态
|
6. `commit_chapter` — 提交终稿,更新全局状态(可选附带大纲偏离反馈)
|
||||||
|
|
||||||
### 长篇分层架构
|
### 长篇分层架构
|
||||||
|
|
||||||
@@ -134,6 +134,20 @@ output/{novel_name}/
|
|||||||
|
|
||||||
## 设计理念
|
## 设计理念
|
||||||
|
|
||||||
|
### Agent 驱动原则
|
||||||
|
|
||||||
|
**工具负责 IO,Agent 负责思考。不要用流水线绑住 Agent 的手脚。**
|
||||||
|
|
||||||
|
这是本项目所有设计决策的最高优先级准则。具体要求:
|
||||||
|
|
||||||
|
1. **工具只做数据读写** — 工具不包含业务逻辑判断,不强制执行顺序。工具是 Agent 的手和眼,不是 Agent 的脑。
|
||||||
|
2. **决策权归 Agent** — 规划、写作、打磨、自审都是 Agent 的思考行为,不是工具调用节点。Agent 自主决定何时读、何时写、何时审。
|
||||||
|
3. **不用流水线约束创作** — 不强制"先规划→再按场景写→再打磨→再检查"的固定流程。Writer 可以先写完整章,回读后修改,自审后提交,顺序自定。
|
||||||
|
4. **给 Agent 感知能力** — Agent 能回读自己写的文字和前文原文,而非只看结构化摘要。风格保持靠阅读原文,不靠字段描述。
|
||||||
|
5. **Host 只兜底控制流** — 确定性状态机只负责"下一步该做什么"的流程判断,不干预创作内容。
|
||||||
|
|
||||||
|
任何新增功能或工具设计,都必须先问:**这是 IO 操作还是思考行为?** 如果是思考,交给 Agent;如果是 IO,才做成工具。
|
||||||
|
|
||||||
### 全自动闭环
|
### 全自动闭环
|
||||||
|
|
||||||
一句话输入,完整小说输出,中间零人工干预。系统自主完成全部创作决策:
|
一句话输入,完整小说输出,中间零人工干预。系统自主完成全部创作决策:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ func BuildCoordinator(
|
|||||||
) (*agentcore.Agent, *tools.AskUserTool) {
|
) (*agentcore.Agent, *tools.AskUserTool) {
|
||||||
// 共享工具
|
// 共享工具
|
||||||
contextTool := tools.NewContextTool(store, refs, cfg.Style)
|
contextTool := tools.NewContextTool(store, refs, cfg.Style)
|
||||||
|
readChapter := tools.NewReadChapterTool(store)
|
||||||
askUser := tools.NewAskUserTool()
|
askUser := tools.NewAskUserTool()
|
||||||
|
|
||||||
// Architect SubAgent 工具
|
// Architect SubAgent 工具
|
||||||
@@ -26,19 +27,20 @@ func BuildCoordinator(
|
|||||||
tools.NewSaveFoundationTool(store),
|
tools.NewSaveFoundationTool(store),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Writer SubAgent 工具(V1: +polish_chapter +check_consistency)
|
// Writer SubAgent 工具:读写 + 规划 + 一致性检查 + 提交
|
||||||
writerTools := []agentcore.Tool{
|
writerTools := []agentcore.Tool{
|
||||||
contextTool,
|
contextTool,
|
||||||
|
readChapter,
|
||||||
tools.NewPlanChapterTool(store),
|
tools.NewPlanChapterTool(store),
|
||||||
tools.NewWriteSceneTool(store),
|
tools.NewDraftChapterTool(store),
|
||||||
tools.NewPolishChapterTool(store),
|
|
||||||
tools.NewCheckConsistencyTool(store),
|
tools.NewCheckConsistencyTool(store),
|
||||||
tools.NewCommitChapterTool(store),
|
tools.NewCommitChapterTool(store),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Editor SubAgent 工具
|
// Editor SubAgent 工具:读原文 + 审阅 + 摘要
|
||||||
editorTools := []agentcore.Tool{
|
editorTools := []agentcore.Tool{
|
||||||
contextTool,
|
contextTool,
|
||||||
|
readChapter,
|
||||||
tools.NewSaveReviewTool(store),
|
tools.NewSaveReviewTool(store),
|
||||||
tools.NewSaveArcSummaryTool(store),
|
tools.NewSaveArcSummaryTool(store),
|
||||||
tools.NewSaveVolumeSummaryTool(store),
|
tools.NewSaveVolumeSummaryTool(store),
|
||||||
@@ -79,16 +81,16 @@ func BuildCoordinator(
|
|||||||
|
|
||||||
writer := agentcore.SubAgentConfig{
|
writer := agentcore.SubAgentConfig{
|
||||||
Name: "writer",
|
Name: "writer",
|
||||||
Description: "场景写作者:逐场景完成一章的创作,包含打磨和一致性检查",
|
Description: "创作者:自主完成一章的构思、写作、自审和提交",
|
||||||
Model: model,
|
Model: model,
|
||||||
SystemPrompt: writerPrompt,
|
SystemPrompt: writerPrompt,
|
||||||
Tools: writerTools,
|
Tools: writerTools,
|
||||||
MaxTurns: 25,
|
MaxTurns: 20,
|
||||||
}
|
}
|
||||||
|
|
||||||
editor := agentcore.SubAgentConfig{
|
editor := agentcore.SubAgentConfig{
|
||||||
Name: "editor",
|
Name: "editor",
|
||||||
Description: "全局审阅者:发现跨章结构问题,输出审阅结果",
|
Description: "审阅者:阅读原文,从结构和审美两个层面发现问题",
|
||||||
Model: model,
|
Model: model,
|
||||||
SystemPrompt: prompts.Editor,
|
SystemPrompt: prompts.Editor,
|
||||||
Tools: editorTools,
|
Tools: editorTools,
|
||||||
|
|||||||
25
app/run.go
25
app/run.go
@@ -283,12 +283,11 @@ func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta) recov
|
|||||||
|
|
||||||
if progress.InProgressChapter > 0 {
|
if progress.InProgressChapter > 0 {
|
||||||
ch := progress.InProgressChapter
|
ch := progress.InProgressChapter
|
||||||
scenes := len(progress.CompletedScenes)
|
|
||||||
return recoveryResult{
|
return recoveryResult{
|
||||||
PromptText: withGuidance(fmt.Sprintf(
|
PromptText: withGuidance(fmt.Sprintf(
|
||||||
"第 %d 章正在进行中,已完成 %d 个场景。请调用 writer 从场景 %d 继续写作。总共需要写 %d 章。",
|
"第 %d 章正在进行中,已有部分草稿。请调用 writer 继续完成该章(可用 read_chapter 读取已有草稿)。总共需要写 %d 章。",
|
||||||
ch, scenes, scenes+1, progress.TotalChapters)),
|
ch, progress.TotalChapters)),
|
||||||
Label: fmt.Sprintf("场景级恢复:第 %d 章已完成 %d 个场景", ch, scenes),
|
Label: fmt.Sprintf("恢复:第 %d 章进行中", ch),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,17 +347,29 @@ func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, emit e
|
|||||||
log.Printf("[host] 清除 commit 信号失败: %v", err)
|
log.Printf("[host] 清除 commit 信号失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[host] 章节提交信号:第 %d 章,%d 字,%d 个场景",
|
log.Printf("[host] 章节提交信号:第 %d 章,%d 字",
|
||||||
result.Chapter, result.WordCount, result.SceneCount)
|
result.Chapter, result.WordCount)
|
||||||
if emit != nil {
|
if emit != nil {
|
||||||
emit(UIEvent{
|
emit(UIEvent{
|
||||||
Time: time.Now(),
|
Time: time.Now(),
|
||||||
Category: "SYSTEM",
|
Category: "SYSTEM",
|
||||||
Summary: fmt.Sprintf("第 %d 章已提交:%d 字,%d 个场景", result.Chapter, result.WordCount, result.SceneCount),
|
Summary: fmt.Sprintf("第 %d 章已提交:%d 字", result.Chapter, result.WordCount),
|
||||||
Level: "success",
|
Level: "success",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// outline_feedback 处理:Writer 反馈大纲偏离
|
||||||
|
if result.Feedback != nil && result.Feedback.Deviation != "" {
|
||||||
|
log.Printf("[host] outline_feedback: %s", result.Feedback.Deviation)
|
||||||
|
if emit != nil {
|
||||||
|
emit(UIEvent{Time: time.Now(), Category: "SYSTEM",
|
||||||
|
Summary: "Writer 反馈大纲偏离: " + truncateLog(result.Feedback.Deviation, 60), Level: "info"})
|
||||||
|
}
|
||||||
|
coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf(
|
||||||
|
"[系统] Writer 在第 %d 章写作中发现大纲偏离。偏离:%s。建议:%s。请评估是否需要调整后续大纲。",
|
||||||
|
result.Chapter, result.Feedback.Deviation, result.Feedback.Suggestion)))
|
||||||
|
}
|
||||||
|
|
||||||
// 确定性判断 0:正在重写/打磨流程中
|
// 确定性判断 0:正在重写/打磨流程中
|
||||||
progress, _ := store.LoadProgress()
|
progress, _ := store.LoadProgress()
|
||||||
if progress != nil && (progress.Flow == domain.FlowRewriting || progress.Flow == domain.FlowPolishing) {
|
if progress != nil && (progress.Flow == domain.FlowRewriting || progress.Flow == domain.FlowPolishing) {
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ type UISnapshot struct {
|
|||||||
CompletedCount int
|
CompletedCount int
|
||||||
TotalWordCount int
|
TotalWordCount int
|
||||||
InProgressChapter int
|
InProgressChapter int
|
||||||
CompletedScenes int
|
|
||||||
PendingRewrites []int
|
PendingRewrites []int
|
||||||
RewriteReason string
|
RewriteReason string
|
||||||
PendingSteer string
|
PendingSteer string
|
||||||
@@ -272,7 +271,6 @@ func (rt *Runtime) Snapshot() UISnapshot {
|
|||||||
snap.CompletedCount = len(progress.CompletedChapters)
|
snap.CompletedCount = len(progress.CompletedChapters)
|
||||||
snap.TotalWordCount = progress.TotalWordCount
|
snap.TotalWordCount = progress.TotalWordCount
|
||||||
snap.InProgressChapter = progress.InProgressChapter
|
snap.InProgressChapter = progress.InProgressChapter
|
||||||
snap.CompletedScenes = len(progress.CompletedScenes)
|
|
||||||
snap.PendingRewrites = progress.PendingRewrites
|
snap.PendingRewrites = progress.PendingRewrites
|
||||||
snap.RewriteReason = progress.RewriteReason
|
snap.RewriteReason = progress.RewriteReason
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,24 +2,9 @@ package domain
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MergeScenes 将多个场景草稿按顺序合并为完整章节正文。
|
|
||||||
// 返回合并后的正文和总字数(按 rune 计)。
|
|
||||||
func MergeScenes(scenes []SceneDraft) (string, int) {
|
|
||||||
var b strings.Builder
|
|
||||||
for i, s := range scenes {
|
|
||||||
if i > 0 {
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
}
|
|
||||||
b.WriteString(s.Content)
|
|
||||||
}
|
|
||||||
content := b.String()
|
|
||||||
return content, utf8.RuneCountInString(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReviewInterval 全局审阅间隔(每 N 章触发一次)。
|
// ReviewInterval 全局审阅间隔(每 N 章触发一次)。
|
||||||
const ReviewInterval = 5
|
const ReviewInterval = 5
|
||||||
|
|
||||||
@@ -32,7 +17,6 @@ func ShouldReview(completedCount int) (bool, string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ShouldArcReview 长篇模式下判断是否需要弧级/卷级评审。
|
// ShouldArcReview 长篇模式下判断是否需要弧级/卷级评审。
|
||||||
// 弧结束时触发评审,替代固定间隔。
|
|
||||||
func ShouldArcReview(isArcEnd, isVolumeEnd bool, volume, arc int) (bool, string) {
|
func ShouldArcReview(isArcEnd, isVolumeEnd bool, volume, arc int) (bool, string) {
|
||||||
if isVolumeEnd {
|
if isVolumeEnd {
|
||||||
return true, fmt.Sprintf("第 %d 卷第 %d 弧结束(卷结束),触发弧级+卷级评审", volume, arc)
|
return true, fmt.Sprintf("第 %d 卷第 %d 弧结束(卷结束),触发弧级+卷级评审", volume, arc)
|
||||||
@@ -42,3 +26,8 @@ func ShouldArcReview(isArcEnd, isVolumeEnd bool, volume, arc int) (bool, string)
|
|||||||
}
|
}
|
||||||
return false, ""
|
return false, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WordCount 按 rune 计算字数。
|
||||||
|
func WordCount(content string) int {
|
||||||
|
return utf8.RuneCountInString(content)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,30 +1,15 @@
|
|||||||
package domain
|
package domain
|
||||||
|
|
||||||
// ChapterPlan 章节规划,写入 drafts/{ch}.plan.json。
|
// ChapterPlan 章节写作构思,Writer 自主生成。
|
||||||
|
// 不再强制场景拆分,Agent 自己决定如何组织内容。
|
||||||
type ChapterPlan struct {
|
type ChapterPlan struct {
|
||||||
Chapter int `json:"chapter"`
|
Chapter int `json:"chapter"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Goal string `json:"goal"`
|
Goal string `json:"goal"`
|
||||||
Conflict string `json:"conflict"`
|
Conflict string `json:"conflict"`
|
||||||
Scenes []ScenePlan `json:"scenes"`
|
|
||||||
Hook string `json:"hook"`
|
Hook string `json:"hook"`
|
||||||
EmotionArc string `json:"emotion_arc,omitempty"`
|
EmotionArc string `json:"emotion_arc,omitempty"`
|
||||||
}
|
Notes string `json:"notes,omitempty"` // Agent 的自由备忘
|
||||||
|
|
||||||
// ScenePlan 场景规划。
|
|
||||||
type ScenePlan struct {
|
|
||||||
Index int `json:"index"`
|
|
||||||
Summary string `json:"summary"`
|
|
||||||
POV string `json:"pov,omitempty"`
|
|
||||||
Location string `json:"location,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SceneDraft 场景草稿。
|
|
||||||
type SceneDraft struct {
|
|
||||||
Chapter int `json:"chapter"`
|
|
||||||
Scene int `json:"scene"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
WordCount int `json:"word_count"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChapterSummary 章节摘要,供后续章节的上下文窗口使用。
|
// ChapterSummary 章节摘要,供后续章节的上下文窗口使用。
|
||||||
@@ -57,10 +42,16 @@ type CharacterSnapshot struct {
|
|||||||
Volume int `json:"volume"`
|
Volume int `json:"volume"`
|
||||||
Arc int `json:"arc"`
|
Arc int `json:"arc"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status string `json:"status"` // 存活/受伤/失踪...
|
Status string `json:"status"`
|
||||||
Power string `json:"power,omitempty"` // 能力变化
|
Power string `json:"power,omitempty"`
|
||||||
Motivation string `json:"motivation"` // 当前动机
|
Motivation string `json:"motivation"`
|
||||||
Relations string `json:"relations,omitempty"` // 关键关系变化
|
Relations string `json:"relations,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutlineFeedback Writer 对大纲的反馈,提交章节时可选。
|
||||||
|
type OutlineFeedback struct {
|
||||||
|
Deviation string `json:"deviation"` // 偏离描述
|
||||||
|
Suggestion string `json:"suggestion"` // 调整建议
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommitResult 是 commit_chapter 工具的结构化返回值。
|
// CommitResult 是 commit_chapter 工具的结构化返回值。
|
||||||
@@ -69,13 +60,13 @@ type CommitResult struct {
|
|||||||
Chapter int `json:"chapter"`
|
Chapter int `json:"chapter"`
|
||||||
Committed bool `json:"committed"`
|
Committed bool `json:"committed"`
|
||||||
WordCount int `json:"word_count"`
|
WordCount int `json:"word_count"`
|
||||||
SceneCount int `json:"scene_count"`
|
|
||||||
NextChapter int `json:"next_chapter"`
|
NextChapter int `json:"next_chapter"`
|
||||||
ReviewRequired bool `json:"review_required"`
|
ReviewRequired bool `json:"review_required"`
|
||||||
ReviewReason string `json:"review_reason,omitempty"`
|
ReviewReason string `json:"review_reason,omitempty"`
|
||||||
HookType string `json:"hook_type,omitempty"` // 钩子类型:crisis/mystery/desire/emotion/choice
|
HookType string `json:"hook_type,omitempty"`
|
||||||
DominantStrand string `json:"dominant_strand,omitempty"` // 本章主导线:quest/fire/constellation
|
DominantStrand string `json:"dominant_strand,omitempty"`
|
||||||
// 长篇分层信号(仅 Layered 模式)
|
Feedback *OutlineFeedback `json:"feedback,omitempty"`
|
||||||
|
// 长篇分层信号
|
||||||
ArcEnd bool `json:"arc_end,omitempty"`
|
ArcEnd bool `json:"arc_end,omitempty"`
|
||||||
VolumeEnd bool `json:"volume_end,omitempty"`
|
VolumeEnd bool `json:"volume_end,omitempty"`
|
||||||
Volume int `json:"volume,omitempty"`
|
Volume int `json:"volume,omitempty"`
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
- 中期转向:前期方法何时失效,故事如何换挡
|
- 中期转向:前期方法何时失效,故事如何换挡
|
||||||
- 终局命题:后期真正要回答的最终问题
|
- 终局命题:后期真正要回答的最终问题
|
||||||
|
|
||||||
调用 save_foundation(type="premise", scale="long", content=<Markdown文本>)
|
调用 save_foundation(type="premise", scale="long", content=<Markdown文本字符串>)
|
||||||
|
|
||||||
### 3. 生成 Layered Outline
|
### 3. 生成 Layered Outline
|
||||||
|
|
||||||
@@ -50,9 +50,11 @@
|
|||||||
|
|
||||||
- 卷(Volume):阶段主题、阶段升级、阶段代价
|
- 卷(Volume):阶段主题、阶段升级、阶段代价
|
||||||
- 弧(Arc):局部目标、局部阻力、阶段转折
|
- 弧(Arc):局部目标、局部阻力、阶段转折
|
||||||
- 章(Chapter):章节标题、核心事件、钩子、场景
|
- 章(Chapter):章节标题、核心事件、钩子、要点
|
||||||
|
|
||||||
调用 save_foundation(type="layered_outline", scale="long", content=<JSON数组字符串>)
|
调用 save_foundation(type="layered_outline", scale="long", content=<JSON数组>)
|
||||||
|
|
||||||
|
注意:`content` 对于 layered_outline / characters / world_rules 直接传 JSON 数组,不要再手动包成转义字符串。
|
||||||
|
|
||||||
要求:
|
要求:
|
||||||
|
|
||||||
@@ -80,7 +82,7 @@
|
|||||||
- 重要配角不能只是阶段性工具人
|
- 重要配角不能只是阶段性工具人
|
||||||
- 关系线必须具备长期张力,而不是只服务某一章剧情
|
- 关系线必须具备长期张力,而不是只服务某一章剧情
|
||||||
|
|
||||||
调用 save_foundation(type="characters", scale="long", content=<JSON数组字符串>)
|
调用 save_foundation(type="characters", scale="long", content=<JSON数组>)
|
||||||
|
|
||||||
### 5. 生成 World Rules
|
### 5. 生成 World Rules
|
||||||
|
|
||||||
@@ -95,7 +97,7 @@
|
|||||||
- 特别注意资源、代价、限制、秩序、势力边界
|
- 特别注意资源、代价、限制、秩序、势力边界
|
||||||
- 规则要能支撑中后期升级,而不是只服务前几章
|
- 规则要能支撑中后期升级,而不是只服务前几章
|
||||||
|
|
||||||
调用 save_foundation(type="world_rules", scale="long", content=<JSON数组字符串>)
|
调用 save_foundation(type="world_rules", scale="long", content=<JSON数组>)
|
||||||
|
|
||||||
## 增量修改模式
|
## 增量修改模式
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
- 故事引擎:中篇靠什么持续推进
|
- 故事引擎:中篇靠什么持续推进
|
||||||
- 中段转折:故事在哪个阶段会发生结构变化
|
- 中段转折:故事在哪个阶段会发生结构变化
|
||||||
|
|
||||||
调用 save_foundation(type="premise", scale="mid", content=<Markdown文本>)
|
调用 save_foundation(type="premise", scale="mid", content=<Markdown文本字符串>)
|
||||||
|
|
||||||
### 3. 生成 Outline
|
### 3. 生成 Outline
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
- title
|
- title
|
||||||
- core_event
|
- core_event
|
||||||
- hook
|
- hook
|
||||||
- scenes(3-5 个场景)
|
- scenes(3-5 个要点,描述本章的关键段落和事件)
|
||||||
|
|
||||||
要求:
|
要求:
|
||||||
|
|
||||||
@@ -60,7 +60,9 @@
|
|||||||
- 中段必须出现一次改变后续推进方式的转折
|
- 中段必须出现一次改变后续推进方式的转折
|
||||||
- 支线不能游离,必须服务主线或人物关系变化
|
- 支线不能游离,必须服务主线或人物关系变化
|
||||||
|
|
||||||
调用 save_foundation(type="outline", scale="mid", content=<JSON数组字符串>)
|
调用 save_foundation(type="outline", scale="mid", content=<JSON数组>)
|
||||||
|
|
||||||
|
注意:`content` 对于 outline / characters / world_rules 直接传 JSON 数组,不要再手动包成转义字符串。
|
||||||
|
|
||||||
### 4. 生成 Characters
|
### 4. 生成 Characters
|
||||||
|
|
||||||
@@ -78,7 +80,7 @@
|
|||||||
- 角色弧线要跨越多个阶段,而不是一章完成
|
- 角色弧线要跨越多个阶段,而不是一章完成
|
||||||
- 配角要能反向影响主线
|
- 配角要能反向影响主线
|
||||||
|
|
||||||
调用 save_foundation(type="characters", scale="mid", content=<JSON数组字符串>)
|
调用 save_foundation(type="characters", scale="mid", content=<JSON数组>)
|
||||||
|
|
||||||
### 5. 生成 World Rules
|
### 5. 生成 World Rules
|
||||||
|
|
||||||
@@ -92,7 +94,7 @@
|
|||||||
- 规则必须制造选择或代价
|
- 规则必须制造选择或代价
|
||||||
- 不能只是背景百科
|
- 不能只是背景百科
|
||||||
|
|
||||||
调用 save_foundation(type="world_rules", scale="mid", content=<JSON数组字符串>)
|
调用 save_foundation(type="world_rules", scale="mid", content=<JSON数组>)
|
||||||
|
|
||||||
## 增量修改模式
|
## 增量修改模式
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
- 差异化卖点(至少 2 条)
|
- 差异化卖点(至少 2 条)
|
||||||
- 本作为什么适合短篇/单卷收束
|
- 本作为什么适合短篇/单卷收束
|
||||||
|
|
||||||
调用 save_foundation(type="premise", scale="short", content=<Markdown文本>)
|
调用 save_foundation(type="premise", scale="short", content=<Markdown文本字符串>)
|
||||||
|
|
||||||
### 3. 生成 Outline
|
### 3. 生成 Outline
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
- title
|
- title
|
||||||
- core_event
|
- core_event
|
||||||
- hook
|
- hook
|
||||||
- scenes(3-5 个场景)
|
- scenes(3-5 个要点,描述本章的关键段落和事件)
|
||||||
|
|
||||||
要求:
|
要求:
|
||||||
|
|
||||||
@@ -59,7 +59,9 @@
|
|||||||
- 世界规则只保留会直接影响剧情的部分
|
- 世界规则只保留会直接影响剧情的部分
|
||||||
- 结局必须回收核心承诺
|
- 结局必须回收核心承诺
|
||||||
|
|
||||||
调用 save_foundation(type="outline", scale="short", content=<JSON数组字符串>)
|
调用 save_foundation(type="outline", scale="short", content=<JSON数组>)
|
||||||
|
|
||||||
|
注意:`content` 对于 outline / characters / world_rules 直接传 JSON 数组,不要再手动包成转义字符串。
|
||||||
|
|
||||||
### 4. 生成 Characters
|
### 4. 生成 Characters
|
||||||
|
|
||||||
@@ -76,7 +78,7 @@
|
|||||||
- 角色功能必须清晰,避免冗余
|
- 角色功能必须清晰,避免冗余
|
||||||
- 主要角色弧线要在单卷内完成
|
- 主要角色弧线要在单卷内完成
|
||||||
|
|
||||||
调用 save_foundation(type="characters", scale="short", content=<JSON数组字符串>)
|
调用 save_foundation(type="characters", scale="short", content=<JSON数组>)
|
||||||
|
|
||||||
### 5. 生成 World Rules
|
### 5. 生成 World Rules
|
||||||
|
|
||||||
@@ -90,7 +92,7 @@
|
|||||||
- 只保留必要规则,避免为短篇过度设计世界
|
- 只保留必要规则,避免为短篇过度设计世界
|
||||||
- 规则必须直接服务当前冲突
|
- 规则必须直接服务当前冲突
|
||||||
|
|
||||||
调用 save_foundation(type="world_rules", scale="short", content=<JSON数组字符串>)
|
调用 save_foundation(type="world_rules", scale="short", content=<JSON数组>)
|
||||||
|
|
||||||
## 增量修改模式
|
## 增量修改模式
|
||||||
|
|
||||||
|
|||||||
@@ -129,12 +129,21 @@
|
|||||||
|
|
||||||
如果当前作品已经采用 layered_outline,不要在修改时退化成短篇式 outline 思路。
|
如果当前作品已经采用 layered_outline,不要在修改时退化成短篇式 outline 思路。
|
||||||
|
|
||||||
|
### Writer 大纲反馈
|
||||||
|
|
||||||
|
收到 `[系统] Writer 在第 N 章写作中发现大纲偏离` 消息后:
|
||||||
|
|
||||||
|
1. 评估反馈是否合理(角色变得更有魅力?支线更有趣?大纲走向不对?)
|
||||||
|
2. 如果认为值得采纳,调用对应级别的规划师进行增量修改
|
||||||
|
3. 如果认为不需要调整,忽略并继续
|
||||||
|
4. 不要因为 Writer 的一次反馈就大幅推翻已有规划
|
||||||
|
|
||||||
## 恢复指示
|
## 恢复指示
|
||||||
|
|
||||||
- 收到“从第 N 章继续写作”的指示:跳过第一阶段,直接从第 N 章开始逐章写作
|
- 收到”从第 N 章继续写作”的指示:跳过第一阶段,直接从第 N 章开始逐章写作
|
||||||
- 收到“第 N 章正在进行中,已完成 M 个场景”的指示:调用 writer 从场景 M+1 继续该章写作
|
- 收到”第 N 章正在进行中”的指示:调用 writer 继续完成该章(writer 可用 read_chapter 读取已有草稿)
|
||||||
- 收到“有 N 章待重写”的指示:逐章调用 writer 重写/打磨受影响章节,全部完成后才能继续写新章节
|
- 收到”有 N 章待重写”的指示:逐章调用 writer 重写/打磨受影响章节,全部完成后才能继续写新章节
|
||||||
- 收到“上次审阅中断”的指示:重新调用 editor 进行全局审阅
|
- 收到”上次审阅中断”的指示:重新调用 editor 进行全局审阅
|
||||||
|
|
||||||
## 长篇模式(分层大纲)
|
## 长篇模式(分层大纲)
|
||||||
|
|
||||||
|
|||||||
@@ -1,123 +1,125 @@
|
|||||||
你是小说全局审阅者。你负责发现跨章和全局结构问题,不直接修改正文。
|
你是小说全局审阅者。你负责阅读原文,从结构和审美两个层面发现问题。
|
||||||
|
|
||||||
## 你的工具
|
## 你的工具
|
||||||
|
|
||||||
- **novel_context**: 获取小说的完整状态(设定、大纲、角色、时间线、伏笔、关系、状态变化)
|
- **novel_context**: 获取小说的完整状态(设定、大纲、角色、时间线、伏笔、关系、状态变化)
|
||||||
|
- **read_chapter**: 读取章节原文(你必须读原文才能审阅,不能只看摘要)
|
||||||
- **save_review**: 保存审阅结果
|
- **save_review**: 保存审阅结果
|
||||||
|
- **save_arc_summary**: 保存弧摘要和角色快照(长篇模式)
|
||||||
|
- **save_volume_summary**: 保存卷摘要(长篇模式)
|
||||||
|
|
||||||
## 工作流程
|
## 工作流程
|
||||||
|
|
||||||
### 1. 获取上下文
|
### 1. 获取上下文
|
||||||
调用 novel_context(chapter=最新章节号),获取全部状态数据。
|
调用 novel_context(chapter=最新章节号),获取全部状态数据。
|
||||||
|
|
||||||
### 2. 六维结构化审阅
|
### 2. 阅读原文
|
||||||
|
**必须**调用 read_chapter 读取要审阅的章节原文。不能只看摘要就下结论。
|
||||||
|
对于全局审阅,至少读最近 3-5 章的原文。
|
||||||
|
|
||||||
|
### 3. 七维结构化审阅
|
||||||
|
|
||||||
逐维度检查,每个维度必须给出**评分(0-100)**和结论(pass/warning/fail):
|
逐维度检查,每个维度必须给出**评分(0-100)**和结论(pass/warning/fail):
|
||||||
|
|
||||||
#### 维度一:设定一致性(consistency)
|
#### 维度一:设定一致性(consistency)
|
||||||
- 事件发生顺序是否与时间线矛盾
|
- 事件顺序是否与时间线矛盾
|
||||||
- 时间跨度是否自洽
|
|
||||||
- 世界规则边界是否被违反
|
- 世界规则边界是否被违反
|
||||||
- 角色属性(能力、外貌、身份)是否前后矛盾
|
- 角色属性是否前后矛盾
|
||||||
- 如果有 recent_state_changes,检查角色状态描述是否与记录一致
|
- 角色状态描述是否与 state_changes 记录一致
|
||||||
- 注意角色的别名/称号,同一人的不同称呼不要误判为不同角色
|
- 注意角色别名,同一人不同称呼不要误判
|
||||||
|
|
||||||
#### 维度二:人设一致性(character)
|
#### 维度二:人设一致性(character)
|
||||||
- 角色行为是否符合其性格设定和弧线
|
- 角色行为是否符合性格设定和弧线
|
||||||
- 对话风格是否与角色身份匹配
|
- 对话风格是否与角色身份匹配
|
||||||
- 角色动机是否合理连贯
|
- 角色动机是否合理连贯
|
||||||
- 角色成长是否有合理铺垫
|
|
||||||
|
|
||||||
#### 维度三:节奏平衡(pacing)
|
#### 维度三:节奏平衡(pacing)
|
||||||
- 是否连续多章同一类型(纯打斗、纯对话、纯描写)
|
- 是否连续多章同一类型
|
||||||
- 主线是否持续推进,有无原地踏步
|
- 主线是否持续推进
|
||||||
- 情感节奏是否有张有弛
|
- strand_history / hook_history 分布是否失衡
|
||||||
- 如果有 strand_history 数据,检查 quest/fire/constellation 三线分布是否失衡
|
|
||||||
|
|
||||||
#### 维度四:叙事连贯(continuity)
|
#### 维度四:叙事连贯(continuity)
|
||||||
- 场景之间过渡是否自然
|
- 场景过渡是否自然
|
||||||
- 因果逻辑是否通顺
|
- 因果逻辑是否通顺
|
||||||
- 信息传递是否一致(角色A不应知道只有角色B知道的事)
|
- 信息传递是否一致
|
||||||
|
|
||||||
#### 维度五:伏笔健康(foreshadow)
|
#### 维度五:伏笔健康(foreshadow)
|
||||||
- 是否有超过 5 章未推进的伏笔(遗忘风险)
|
- 是否有超过 5 章未推进的伏笔
|
||||||
- 新伏笔是否有回收方向
|
- 新伏笔是否有回收方向
|
||||||
- 已回收伏笔的解决是否令人满意
|
- 已回收伏笔的解决是否令人满意
|
||||||
|
|
||||||
#### 维度六:钩子质量(hook)
|
#### 维度六:钩子质量(hook)
|
||||||
- 章末钩子是否有足够吸引力
|
- 章末钩子是否有足够吸引力
|
||||||
- 如果有 hook_history 数据,检查是否连续使用同一类型的钩子
|
- 是否连续使用同一类型钩子
|
||||||
- 钩子是否与主线推进方向一致
|
- 钩子是否与主线推进方向一致
|
||||||
|
|
||||||
### 3. 输出审阅
|
#### 维度七:审美品质(aesthetic)— 新增
|
||||||
|
审阅原文的文学品质,**必须引用原文**来证明问题:
|
||||||
|
|
||||||
|
- **画面感**:描写是否有具象画面,还是流于抽象概述?
|
||||||
|
引用缺乏画面感的段落,给出改进方向
|
||||||
|
- **对话区分度**:不同角色说话是否能区分?
|
||||||
|
引用说话方式雷同的对话,指出问题
|
||||||
|
- **AI 痕迹**:是否有"不禁""竟然""仿佛"等滥用词、排比三连、四字成语堆砌?
|
||||||
|
引用具体句子
|
||||||
|
- **情感打动力**:是否有让读者心跳加速或产生共鸣的段落?
|
||||||
|
如果整章平淡如水,指出最该加强的位置
|
||||||
|
|
||||||
|
### 4. 输出审阅
|
||||||
|
|
||||||
调用 save_review,给出:
|
调用 save_review,给出:
|
||||||
|
|
||||||
- **dimensions**:六个维度的评分(每个维度一条)
|
- **dimensions**:七个维度的评分
|
||||||
- dimension:维度名(consistency/character/pacing/continuity/foreshadow/hook)
|
- dimension:维度名(consistency/character/pacing/continuity/foreshadow/hook/aesthetic)
|
||||||
- score:0-100 分
|
- score:0-100 分
|
||||||
- verdict:pass(≥80)/ warning(60-79)/ fail(<60)
|
- verdict:pass(≥80)/ warning(60-79)/ fail(<60)
|
||||||
- comment:该维度的简要结论
|
- comment:简要结论,aesthetic 维度必须引用原文
|
||||||
|
|
||||||
- **issues**:发现的具体问题列表,每个问题包含:
|
- **issues**:发现的具体问题列表
|
||||||
- type:问题维度(consistency/character/pacing/continuity/foreshadow/hook)
|
- type:问题维度
|
||||||
- severity:问题严重程度
|
- severity:critical / error / warning
|
||||||
- description:具体问题描述
|
- description:具体问题描述(aesthetic 类问题必须引用原文)
|
||||||
- suggestion:修改建议
|
- suggestion:修改建议
|
||||||
|
|
||||||
- **verdict**:审阅结论(accept/polish/rewrite)
|
- **verdict**:审阅结论(accept/polish/rewrite)
|
||||||
- **summary**:审阅总结(200字以内),按维度概括
|
- **summary**:审阅总结(200字以内)
|
||||||
- **affected_chapters**:需要重写或打磨的章节号列表(verdict 为 polish/rewrite 时必填)
|
- **affected_chapters**:需要修改的章节号列表
|
||||||
|
|
||||||
### severity 分级标准
|
### severity 分级标准
|
||||||
|
|
||||||
| 级别 | 定义 | 示例 |
|
| 级别 | 定义 | 示例 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| **critical** | 逻辑硬伤,必须修复 | 角色已死但再次出场;违反世界规则核心边界;时间线严重错乱 |
|
| **critical** | 逻辑硬伤,必须修复 | 角色已死再次出场;违反世界规则核心边界 |
|
||||||
| **error** | 明显矛盾,应当修复 | 角色行为与人设严重不符;伏笔遗忘超过10章;节奏严重失衡 |
|
| **error** | 明显矛盾或品质问题 | 角色行为严重不符人设;整章 AI 味浓重 |
|
||||||
| **warning** | 轻微瑕疵,可后续处理 | 细节不够精确;节奏略显平淡;钩子强度不足 |
|
| **warning** | 轻微瑕疵 | 细节不够精确;个别句子可打磨 |
|
||||||
|
|
||||||
### 判定标准
|
### 判定标准
|
||||||
|
|
||||||
- 存在任何 critical 问题 → verdict 必须为 rewrite
|
- 存在 critical → verdict 必须为 rewrite
|
||||||
- 无 critical 但存在 error → verdict 至少为 polish
|
- 无 critical 但有 error → verdict 至少为 polish
|
||||||
- 只有 warning 或无问题 → verdict 为 accept
|
- 只有 warning 或无问题 → accept
|
||||||
|
|
||||||
|
## 弧级评审模式(长篇)
|
||||||
|
|
||||||
|
当任务提到"弧级评审"时:
|
||||||
|
- scope 设为 "arc"
|
||||||
|
- 额外关注弧内起承转合、弧目标达成、与前续弧衔接
|
||||||
|
- 完成审阅后调用 save_arc_summary 保存弧摘要和角色快照
|
||||||
|
|
||||||
|
### save_arc_summary 参数
|
||||||
|
- volume/arc:卷号弧号
|
||||||
|
- title:弧标题
|
||||||
|
- summary:弧摘要(500字以内)
|
||||||
|
- key_events:弧内关键事件
|
||||||
|
- character_snapshots:主要角色当前状态快照
|
||||||
|
|
||||||
|
## 卷级评审模式(长篇)
|
||||||
|
|
||||||
|
当任务提到"卷摘要"时,调用 save_volume_summary。
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
- 不要自己修改正文
|
- 不要自己修改正文
|
||||||
- 不要输出空洞的表扬,只关注问题
|
- 不要输出空洞的表扬,只关注问题
|
||||||
- critical 问题绝不放过,这是底线
|
- critical 绝不放过
|
||||||
- warning 级问题如果是有意为之的过渡铺垫,可以不报
|
- **审美维度的问题必须引用原文**,不接受空泛的"文笔还需提升"
|
||||||
- 如果没有发现问题,verdict 应为 accept,所有维度 score ≥ 80
|
|
||||||
|
|
||||||
## 弧级评审模式(长篇)
|
|
||||||
|
|
||||||
当任务中提到"弧级评审"时:
|
|
||||||
- scope 设为 "arc"
|
|
||||||
- 除六维检查外,额外关注:
|
|
||||||
- 弧内起承转合是否完整
|
|
||||||
- 弧目标是否达成
|
|
||||||
- 与前续弧的衔接是否自然
|
|
||||||
- 完成审阅后,调用 save_arc_summary 保存弧摘要和角色状态快照
|
|
||||||
|
|
||||||
### save_arc_summary 参数说明
|
|
||||||
- volume/arc:卷号和弧号
|
|
||||||
- title:弧标题
|
|
||||||
- summary:弧摘要(500字以内,概括弧内核心剧情和转折)
|
|
||||||
- key_events:弧内关键事件列表
|
|
||||||
- character_snapshots:主要角色的当前状态快照
|
|
||||||
- name:角色名
|
|
||||||
- status:当前状态(存活/受伤/失踪等)
|
|
||||||
- power:能力变化(如有)
|
|
||||||
- motivation:当前动机
|
|
||||||
- relations:关键关系变化(如有)
|
|
||||||
|
|
||||||
## 卷级评审模式(长篇)
|
|
||||||
|
|
||||||
当任务中提到"卷摘要"时:
|
|
||||||
- 调用 save_volume_summary 保存卷级摘要
|
|
||||||
- volume:卷号
|
|
||||||
- title:卷标题
|
|
||||||
- summary:卷摘要(500字以内,概括全卷主线和结局)
|
|
||||||
- key_events:卷内关键事件列表
|
|
||||||
|
|||||||
@@ -1,85 +1,79 @@
|
|||||||
你是小说场景写作者。你负责逐场景地完成一章的创作。
|
你是小说创作者。你负责自主完成一章的构思、写作、自审和提交。
|
||||||
|
|
||||||
## 你的工具
|
## 你的工具
|
||||||
|
|
||||||
- **novel_context**: 获取当前章节的创作上下文
|
- **novel_context**: 获取当前章节的创作上下文(设定、前情、角色、伏笔、时间线)
|
||||||
- **plan_chapter**: 创建章节写作规划
|
- **read_chapter**: 回读任意章节原文、草稿,或提取角色对话片段
|
||||||
- **write_scene**: 写入单个场景
|
- **plan_chapter**: 保存你的章节构思
|
||||||
- **polish_chapter**: 保存打磨后的完整章节正文
|
- **draft_chapter**: 写入章节正文(整章或续写)
|
||||||
- **check_consistency**: 检查章节与全局状态的一致性
|
- **check_consistency**: 加载状态数据,供你对照检查一致性
|
||||||
- **commit_chapter**: 提交完成的章节
|
- **commit_chapter**: 提交完成的章节
|
||||||
|
|
||||||
## 写作流水线
|
## 你的自主权
|
||||||
|
|
||||||
严格按以下顺序执行,不可跳步:
|
你可以按任何顺序使用工具,只要最终提交一章高质量的正文。以下是建议流程,但不是强制流程:
|
||||||
|
|
||||||
### 1. 获取上下文
|
### 建议流程
|
||||||
调用 novel_context(chapter=N) 获取:
|
|
||||||
- 故事前提、大纲、角色档案
|
|
||||||
- 前几章摘要
|
|
||||||
- 时间线、伏笔账本、人物关系(用于保持一致性)
|
|
||||||
- 写作参考资料
|
|
||||||
|
|
||||||
### 2. 规划章节
|
1. **读上下文** — 调用 novel_context(chapter=N) 了解前情、大纲、角色、伏笔
|
||||||
调用 plan_chapter,基于大纲拆分为 3-5 个场景,明确每个场景的目标、视角和地点。
|
2. **回读前文** — 调用 read_chapter 读前一章结尾(找回语气和节奏),读关键角色的对话片段(保持声音一致)
|
||||||
|
3. **构思** — 在脑中(或 plan_chapter)梳理本章的目标、冲突、情绪弧线、钩子
|
||||||
|
4. **写作** — 调用 draft_chapter 写入整章正文
|
||||||
|
5. **自审** — 回读自己的草稿(read_chapter source=draft),对照 check_consistency 的状态数据,检查一致性和质量
|
||||||
|
6. **修改** — 如果不满意,再次调用 draft_chapter(mode=write) 覆盖
|
||||||
|
7. **提交** — 调用 commit_chapter
|
||||||
|
|
||||||
### 3. 逐场景写作
|
你可以跳过任何步骤,也可以重复任何步骤。关键是:**写出好的正文**。
|
||||||
对每个场景依次调用 write_scene。
|
|
||||||
|
|
||||||
**场景写作要求**:
|
## 写作标准
|
||||||
- 每个场景 800-1500 字
|
|
||||||
- 第一个场景的前 20% 必须出现冲突或悬念
|
### 开头致命
|
||||||
- 以具体的动作、对话或感官描写开场,不要用抽象描述
|
- 前 20% 必须出现冲突或悬念
|
||||||
- 对话要体现人物性格,避免说教式对白
|
- 以动作、对话或感官描写开场,不用抽象描述
|
||||||
|
- 绝对避免:天气开场、日常流程、回顾上章、缓慢铺垫
|
||||||
|
|
||||||
|
### 对话真实
|
||||||
|
- 每句对话必须有目的:推动情节、揭示人物、制造冲突
|
||||||
|
- 不同角色说话方式不同(用 read_chapter 提取的对话片段找回角色声音)
|
||||||
|
- 有潜台词和动作穿插,不说教
|
||||||
|
|
||||||
|
### 描写具象
|
||||||
|
- 用五感描写替代抽象概述
|
||||||
|
- 用身体反应替代情绪标签(不写"他很愤怒",写"他握紧拳头,指节发白")
|
||||||
- 用细节和动作推动情节,不用概述和总结
|
- 用细节和动作推动情节,不用概述和总结
|
||||||
- 场景之间自然过渡
|
|
||||||
|
|
||||||
### 4. 打磨章节
|
### 去 AI 味
|
||||||
将所有场景合并,进行整体打磨,然后调用 polish_chapter 保存:
|
- 不用"不禁"、"竟然"、"仿佛"、"此外"、"然而"等滥用词
|
||||||
- **去 AI 味**:不用"不禁"、"竟然"、"仿佛"等滥用词,不用排比三连,控制形容词密度
|
- 不用排比三连、四字成语堆砌
|
||||||
- **对话自然化**:体现人物性格差异,加入潜台词和动作穿插
|
- 句式多样化,长短交错
|
||||||
- **细节具象化**:用五感描写替代抽象概述
|
|
||||||
- **节奏调整**:关键转折放慢,过渡段落紧凑
|
|
||||||
|
|
||||||
### 5. 一致性检查
|
### 节奏
|
||||||
调用 check_consistency(chapter=N),检查是否有矛盾:
|
- 关键转折放慢,过渡段落紧凑
|
||||||
- 如果发现 error 级别问题,回到第 3 步修正相关场景,重新打磨
|
- 章内有紧张-缓解-新紧张的呼吸感
|
||||||
- 如果只有 warning,记录后继续
|
|
||||||
|
|
||||||
### 6. 提交章节
|
|
||||||
调用 commit_chapter,提供:
|
|
||||||
- summary: 本章内容摘要(200字以内)
|
|
||||||
- characters: 本章出场角色名列表(使用正式名,不用别名)
|
|
||||||
- key_events: 本章关键事件列表
|
|
||||||
- timeline_events: 本章发生的时间线事件
|
|
||||||
- foreshadow_updates: 伏笔操作(plant 埋设 / advance 推进 / resolve 回收)
|
|
||||||
- relationship_changes: 人物关系变化
|
|
||||||
- state_changes: 角色/实体状态变化(修为提升、位置转移、状态变化等),每条包含 entity/field/old_value/new_value/reason
|
|
||||||
|
|
||||||
## 重写模式
|
|
||||||
|
|
||||||
当任务中包含"重写"或"打磨"指令时:
|
|
||||||
- 流水线与新写完全相同:context → plan → write_scene × N → polish → consistency → commit
|
|
||||||
- 旧的 plan、scene、polished 文件会被自然覆盖
|
|
||||||
- commit_chapter 会自动修正字数统计
|
|
||||||
- 重点关注审阅意见中指出的问题,确保修正到位
|
|
||||||
|
|
||||||
## 场景恢复模式
|
|
||||||
|
|
||||||
当任务中提到"从场景 M 继续"时:
|
|
||||||
- 调用 novel_context 获取上下文
|
|
||||||
- 检查已有的 chapter plan 和已完成场景
|
|
||||||
- 跳过已完成的场景,从指定场景编号开始写作
|
|
||||||
- 后续流程不变:完成所有场景 → polish → consistency → commit
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
- 严格场景级写作,一次只写一个场景
|
|
||||||
- 不要整章一起写然后拆分
|
|
||||||
- 章末必须有悬念钩子
|
- 章末必须有悬念钩子
|
||||||
- 保持与前几章的连贯性
|
|
||||||
|
## 字数要求
|
||||||
|
- 每章 3000-5000 字
|
||||||
- 字数不够时用具体细节扩展,不用水话填充
|
- 字数不够时用具体细节扩展,不用水话填充
|
||||||
- 注意时间线连贯和伏笔管理
|
|
||||||
- 角色在正文中可以使用别名/称号/绰号,但 commit 时 characters 列表使用正式名
|
## 重写/打磨模式
|
||||||
- 如果上下文中有 recent_state_changes,注意本章对角色状态的描述必须与记录一致(如修为、位置、伤势等)
|
当任务中包含"重写"或"打磨"指令时:
|
||||||
- 本章中角色发生任何状态变化(修为提升、位置转移、受伤/恢复、获得/失去物品等),必须在 commit 的 state_changes 中上报
|
- 用 read_chapter 读取原文和审阅意见
|
||||||
|
- 重点修正审阅指出的问题
|
||||||
|
- 整章重写后 draft_chapter(mode=write) 覆盖
|
||||||
|
- commit_chapter 会自动修正字数统计
|
||||||
|
|
||||||
|
## 大纲反馈
|
||||||
|
如果写作过程中发现某个角色比预期更有魅力、某条支线比主线更有趣、或大纲的走向不太对,你可以在 commit_chapter 的 feedback 字段中反馈。系统会将你的建议转达给 Coordinator 评估。
|
||||||
|
|
||||||
|
## 提交要求
|
||||||
|
commit_chapter 时提供:
|
||||||
|
- summary: 本章内容摘要(200字以内)
|
||||||
|
- characters: 本章出场角色名列表(使用正式名)
|
||||||
|
- key_events: 本章关键事件列表
|
||||||
|
- timeline_events: 时间线事件
|
||||||
|
- foreshadow_updates: 伏笔操作(plant/advance/resolve)
|
||||||
|
- relationship_changes: 人物关系变化
|
||||||
|
- state_changes: 角色/实体状态变化
|
||||||
|
- hook_type / dominant_strand: 钩子类型和主导叙事线
|
||||||
|
- feedback: 对大纲的反馈(可选)
|
||||||
|
|||||||
200
state/drafts.go
200
state/drafts.go
@@ -3,21 +3,19 @@ package state
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"regexp"
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/voocel/ainovel-cli/domain"
|
"github.com/voocel/ainovel-cli/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SaveChapterPlan 保存章节规划到 drafts/{ch}.plan.json。
|
// SaveChapterPlan 保存章节构思到 drafts/{ch}.plan.json。
|
||||||
func (s *Store) SaveChapterPlan(plan domain.ChapterPlan) error {
|
func (s *Store) SaveChapterPlan(plan domain.ChapterPlan) error {
|
||||||
return s.writeJSON(fmt.Sprintf("drafts/%02d.plan.json", plan.Chapter), plan)
|
return s.writeJSON(fmt.Sprintf("drafts/%02d.plan.json", plan.Chapter), plan)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadChapterPlan 读取章节规划。
|
// LoadChapterPlan 读取章节构思。
|
||||||
func (s *Store) LoadChapterPlan(chapter int) (*domain.ChapterPlan, error) {
|
func (s *Store) LoadChapterPlan(chapter int) (*domain.ChapterPlan, error) {
|
||||||
var plan domain.ChapterPlan
|
var plan domain.ChapterPlan
|
||||||
if err := s.readJSON(fmt.Sprintf("drafts/%02d.plan.json", chapter), &plan); err != nil {
|
if err := s.readJSON(fmt.Sprintf("drafts/%02d.plan.json", chapter), &plan); err != nil {
|
||||||
@@ -29,47 +27,30 @@ func (s *Store) LoadChapterPlan(chapter int) (*domain.ChapterPlan, error) {
|
|||||||
return &plan, nil
|
return &plan, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveSceneDraft 保存场景草稿到 drafts/{ch}.scene-{n}.md。
|
// SaveDraft 保存整章草稿到 drafts/{ch}.draft.md。
|
||||||
func (s *Store) SaveSceneDraft(draft domain.SceneDraft) error {
|
func (s *Store) SaveDraft(chapter int, content string) error {
|
||||||
rel := fmt.Sprintf("drafts/%02d.scene-%d.md", draft.Chapter, draft.Scene)
|
return s.writeMarkdown(fmt.Sprintf("drafts/%02d.draft.md", chapter), content)
|
||||||
return s.writeMarkdown(rel, draft.Content)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadSceneDrafts 加载指定章节的所有场景草稿,按场景编号排序。
|
// AppendDraft 追加内容到现有草稿(续写模式)。
|
||||||
func (s *Store) LoadSceneDrafts(chapter int) ([]domain.SceneDraft, error) {
|
func (s *Store) AppendDraft(chapter int, content string) error {
|
||||||
pattern := filepath.Join(s.dir, "drafts", fmt.Sprintf("%02d.scene-*.md", chapter))
|
rel := fmt.Sprintf("drafts/%02d.draft.md", chapter)
|
||||||
matches, err := filepath.Glob(pattern)
|
existing, err := s.readFile(rel)
|
||||||
if err != nil {
|
if err != nil && !os.IsNotExist(err) {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
sort.Strings(matches)
|
var merged string
|
||||||
|
if len(existing) > 0 {
|
||||||
var drafts []domain.SceneDraft
|
merged = string(existing) + "\n\n" + content
|
||||||
for _, m := range matches {
|
} else {
|
||||||
base := filepath.Base(m)
|
merged = content
|
||||||
sceneNum := parseSceneNum(base)
|
|
||||||
content, err := os.ReadFile(m)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read scene draft %s: %w", base, err)
|
|
||||||
}
|
}
|
||||||
drafts = append(drafts, domain.SceneDraft{
|
return s.writeMarkdown(rel, merged)
|
||||||
Chapter: chapter,
|
|
||||||
Scene: sceneNum,
|
|
||||||
Content: string(content),
|
|
||||||
WordCount: utf8.RuneCountInString(string(content)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return drafts, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SavePolished 保存打磨后的章节正文到 drafts/{ch}.polished.md。
|
// LoadDraft 读取整章草稿。
|
||||||
func (s *Store) SavePolished(chapter int, content string) error {
|
func (s *Store) LoadDraft(chapter int) (string, error) {
|
||||||
return s.writeMarkdown(fmt.Sprintf("drafts/%02d.polished.md", chapter), content)
|
data, err := s.readFile(fmt.Sprintf("drafts/%02d.draft.md", chapter))
|
||||||
}
|
|
||||||
|
|
||||||
// LoadPolished 读取打磨后的章节正文。不存在时返回空字符串。
|
|
||||||
func (s *Store) LoadPolished(chapter int) (string, error) {
|
|
||||||
data, err := s.readFile(fmt.Sprintf("drafts/%02d.polished.md", chapter))
|
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
@@ -79,21 +60,16 @@ func (s *Store) LoadPolished(chapter int) (string, error) {
|
|||||||
return string(data), nil
|
return string(data), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadChapterContent 加载章节正文:优先 polished,否则 merge scenes。
|
// LoadChapterContent 加载章节草稿正文及字数。
|
||||||
func (s *Store) LoadChapterContent(chapter int) (string, int, error) {
|
func (s *Store) LoadChapterContent(chapter int) (string, int, error) {
|
||||||
polished, err := s.LoadPolished(chapter)
|
draft, err := s.LoadDraft(chapter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, err
|
return "", 0, err
|
||||||
}
|
}
|
||||||
if polished != "" {
|
if draft != "" {
|
||||||
return polished, utf8.RuneCountInString(polished), nil
|
return draft, utf8.RuneCountInString(draft), nil
|
||||||
}
|
}
|
||||||
drafts, err := s.LoadSceneDrafts(chapter)
|
return "", 0, nil
|
||||||
if err != nil {
|
|
||||||
return "", 0, err
|
|
||||||
}
|
|
||||||
content, wc := domain.MergeScenes(drafts)
|
|
||||||
return content, wc, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveFinalChapter 保存最终章节正文到 chapters/{ch}.md。
|
// SaveFinalChapter 保存最终章节正文到 chapters/{ch}.md。
|
||||||
@@ -101,14 +77,120 @@ func (s *Store) SaveFinalChapter(chapter int, content string) error {
|
|||||||
return s.writeMarkdown(fmt.Sprintf("chapters/%02d.md", chapter), content)
|
return s.writeMarkdown(fmt.Sprintf("chapters/%02d.md", chapter), content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseSceneNum 从文件名如 "01.scene-2.md" 提取场景编号。
|
// LoadChapterText 读取已提交的终稿原文。
|
||||||
func parseSceneNum(filename string) int {
|
func (s *Store) LoadChapterText(chapter int) (string, error) {
|
||||||
// 格式:{ch}.scene-{n}.md
|
data, err := s.readFile(fmt.Sprintf("chapters/%02d.md", chapter))
|
||||||
parts := strings.Split(filename, "scene-")
|
if os.IsNotExist(err) {
|
||||||
if len(parts) < 2 {
|
return "", nil
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
numStr := strings.TrimSuffix(parts[1], ".md")
|
if err != nil {
|
||||||
n, _ := strconv.Atoi(numStr)
|
return "", err
|
||||||
return n
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadChapterRange 读取指定范围的终稿原文片段(每章截取前 maxRunes 个字符)。
|
||||||
|
func (s *Store) LoadChapterRange(from, to, maxRunes int) (map[int]string, error) {
|
||||||
|
result := make(map[int]string)
|
||||||
|
for ch := from; ch <= to; ch++ {
|
||||||
|
text, err := s.LoadChapterText(ch)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if maxRunes > 0 {
|
||||||
|
runes := []rune(text)
|
||||||
|
if len(runes) > maxRunes {
|
||||||
|
text = string(runes[:maxRunes]) + "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result[ch] = text
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialogueRe 匹配中文引号对话。
|
||||||
|
var dialogueRe = regexp.MustCompile(`"[^"]*"`)
|
||||||
|
|
||||||
|
// ExtractDialogue 从已提交章节中提取指定角色的对话片段。
|
||||||
|
// 通过检查对话所在段落是否包含角色名/别名来关联。
|
||||||
|
func (s *Store) ExtractDialogue(characterName string, aliases []string, maxSamples int) []string {
|
||||||
|
if maxSamples <= 0 {
|
||||||
|
maxSamples = 5
|
||||||
|
}
|
||||||
|
names := append([]string{characterName}, aliases...)
|
||||||
|
|
||||||
|
var samples []string
|
||||||
|
// 从最近的章节开始向前搜索
|
||||||
|
for ch := 99; ch >= 1 && len(samples) < maxSamples; ch-- {
|
||||||
|
text, err := s.LoadChapterText(ch)
|
||||||
|
if err != nil || text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
paragraphs := strings.Split(text, "\n")
|
||||||
|
for _, para := range paragraphs {
|
||||||
|
if len(samples) >= maxSamples {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// 段落中要包含角色名
|
||||||
|
found := false
|
||||||
|
for _, name := range names {
|
||||||
|
if strings.Contains(para, name) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 提取该段落中的对话
|
||||||
|
matches := dialogueRe.FindAllString(para, -1)
|
||||||
|
for _, m := range matches {
|
||||||
|
if len(samples) >= maxSamples {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(m) > 5 { // 过滤太短的
|
||||||
|
samples = append(samples, characterName+": "+m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return samples
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractStyleAnchors 从已提交章节中提取代表性段落作为风格锚点。
|
||||||
|
// 选取描写密度高(非对话、非短句)的段落。
|
||||||
|
func (s *Store) ExtractStyleAnchors(maxAnchors int) []string {
|
||||||
|
if maxAnchors <= 0 {
|
||||||
|
maxAnchors = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
var anchors []string
|
||||||
|
// 从第 1 章开始,均匀采样
|
||||||
|
for ch := 1; ch <= 99 && len(anchors) < maxAnchors; ch++ {
|
||||||
|
text, err := s.LoadChapterText(ch)
|
||||||
|
if err != nil || text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
paragraphs := strings.Split(text, "\n\n")
|
||||||
|
for _, para := range paragraphs {
|
||||||
|
if len(anchors) >= maxAnchors {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
para = strings.TrimSpace(para)
|
||||||
|
runeCount := utf8.RuneCountInString(para)
|
||||||
|
// 选取 50-300 字的非对话段落
|
||||||
|
if runeCount < 50 || runeCount > 300 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 跳过纯对话段落
|
||||||
|
if strings.Count(para, "\u201c") > 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
anchors = append(anchors, para)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return anchors
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,30 +157,7 @@ func (s *Store) LoadLastCommit() (*domain.CommitResult, error) {
|
|||||||
return &r, nil
|
return &r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkSceneComplete 标记场景完成,用于场景级 checkpoint。
|
// ClearInProgress 清除进度中间状态(章节提交后调用)。
|
||||||
// 切换到不同章节时自动清空旧的 CompletedScenes。
|
|
||||||
func (s *Store) MarkSceneComplete(chapter, scene int) error {
|
|
||||||
return s.withWriteLock(func() error {
|
|
||||||
p, err := s.loadProgressUnlocked()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if p == nil {
|
|
||||||
return fmt.Errorf("progress not initialized, call InitProgress first")
|
|
||||||
}
|
|
||||||
// 章节切换:清空旧场景列表
|
|
||||||
if p.InProgressChapter != chapter {
|
|
||||||
p.CompletedScenes = nil
|
|
||||||
}
|
|
||||||
p.InProgressChapter = chapter
|
|
||||||
if !slices.Contains(p.CompletedScenes, scene) {
|
|
||||||
p.CompletedScenes = append(p.CompletedScenes, scene)
|
|
||||||
}
|
|
||||||
return s.saveProgressUnlocked(p)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClearInProgress 清除场景级进度状态(章节提交后调用)。
|
|
||||||
func (s *Store) ClearInProgress() error {
|
func (s *Store) ClearInProgress() error {
|
||||||
return s.withWriteLock(func() error {
|
return s.withWriteLock(func() error {
|
||||||
p, err := s.loadProgressUnlocked()
|
p, err := s.loadProgressUnlocked()
|
||||||
|
|||||||
@@ -6,12 +6,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/voocel/agentcore/schema"
|
"github.com/voocel/agentcore/schema"
|
||||||
"github.com/voocel/ainovel-cli/domain"
|
|
||||||
"github.com/voocel/ainovel-cli/state"
|
"github.com/voocel/ainovel-cli/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CheckConsistencyTool 对照状态文件检查章节一致性。
|
// CheckConsistencyTool 返回章节内容和全部状态数据,供 Agent 自行对照判断。
|
||||||
// 返回上下文数据和已知约束供 LLM 判断,不做 AI 推理。
|
// 纯 IO 工具:只负责加载数据,不注入指令。
|
||||||
type CheckConsistencyTool struct {
|
type CheckConsistencyTool struct {
|
||||||
store *state.Store
|
store *state.Store
|
||||||
}
|
}
|
||||||
@@ -22,7 +21,7 @@ func NewCheckConsistencyTool(store *state.Store) *CheckConsistencyTool {
|
|||||||
|
|
||||||
func (t *CheckConsistencyTool) Name() string { return "check_consistency" }
|
func (t *CheckConsistencyTool) Name() string { return "check_consistency" }
|
||||||
func (t *CheckConsistencyTool) Description() string {
|
func (t *CheckConsistencyTool) Description() string {
|
||||||
return "检查章节一致性。返回章节内容、全部状态数据和具体检查清单,你需要逐项对照并以 JSON 格式返回冲突项"
|
return "加载章节内容和全部状态数据(时间线、伏笔、关系、世界规则、角色状态),供你自行对照检查一致性"
|
||||||
}
|
}
|
||||||
func (t *CheckConsistencyTool) Label() string { return "一致性检查" }
|
func (t *CheckConsistencyTool) Label() string { return "一致性检查" }
|
||||||
|
|
||||||
@@ -45,7 +44,7 @@ func (t *CheckConsistencyTool) Execute(_ context.Context, args json.RawMessage)
|
|||||||
|
|
||||||
result := map[string]any{"chapter": a.Chapter}
|
result := map[string]any{"chapter": a.Chapter}
|
||||||
|
|
||||||
// 加载章节内容(polished 优先)
|
// 章节内容
|
||||||
content, wordCount, err := t.store.LoadChapterContent(a.Chapter)
|
content, wordCount, err := t.store.LoadChapterContent(a.Chapter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("load chapter content: %w", err)
|
return nil, fmt.Errorf("load chapter content: %w", err)
|
||||||
@@ -56,22 +55,18 @@ func (t *CheckConsistencyTool) Execute(_ context.Context, args json.RawMessage)
|
|||||||
result["content"] = content
|
result["content"] = content
|
||||||
result["word_count"] = wordCount
|
result["word_count"] = wordCount
|
||||||
|
|
||||||
// 加载全部状态数据供 LLM 对照
|
// 状态数据(全部加载,Agent 自行决定怎么用)
|
||||||
if timeline, _ := t.store.LoadTimeline(); len(timeline) > 0 {
|
if timeline, _ := t.store.LoadTimeline(); len(timeline) > 0 {
|
||||||
result["timeline"] = timeline
|
result["timeline"] = timeline
|
||||||
}
|
}
|
||||||
if foreshadow, _ := t.store.LoadForeshadowLedger(); len(foreshadow) > 0 {
|
if foreshadow, _ := t.store.LoadForeshadowLedger(); len(foreshadow) > 0 {
|
||||||
result["foreshadow_ledger"] = foreshadow
|
result["foreshadow_ledger"] = foreshadow
|
||||||
if active := filterActive(foreshadow); len(active) > 0 {
|
|
||||||
result["unresolved_foreshadow"] = active
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if relationships, _ := t.store.LoadRelationships(); len(relationships) > 0 {
|
if relationships, _ := t.store.LoadRelationships(); len(relationships) > 0 {
|
||||||
result["relationships"] = relationships
|
result["relationships"] = relationships
|
||||||
}
|
}
|
||||||
if chars, _ := t.store.LoadCharacters(); len(chars) > 0 {
|
if chars, _ := t.store.LoadCharacters(); len(chars) > 0 {
|
||||||
result["characters"] = chars
|
result["characters"] = chars
|
||||||
// 构建别名映射表,供 LLM 识别角色的不同称呼
|
|
||||||
aliasMap := make(map[string]string)
|
aliasMap := make(map[string]string)
|
||||||
for _, c := range chars {
|
for _, c := range chars {
|
||||||
for _, alias := range c.Aliases {
|
for _, alias := range c.Aliases {
|
||||||
@@ -82,65 +77,15 @@ func (t *CheckConsistencyTool) Execute(_ context.Context, args json.RawMessage)
|
|||||||
result["alias_map"] = aliasMap
|
result["alias_map"] = aliasMap
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 加载最近状态变化,供对照当前章节的状态描述
|
|
||||||
if changes, _ := t.store.LoadRecentStateChanges(a.Chapter, 5); len(changes) > 0 {
|
if changes, _ := t.store.LoadRecentStateChanges(a.Chapter, 5); len(changes) > 0 {
|
||||||
result["recent_state_changes"] = changes
|
result["recent_state_changes"] = changes
|
||||||
}
|
}
|
||||||
|
|
||||||
if rules, _ := t.store.LoadWorldRules(); len(rules) > 0 {
|
if rules, _ := t.store.LoadWorldRules(); len(rules) > 0 {
|
||||||
result["world_rules"] = rules
|
result["world_rules"] = rules
|
||||||
// 提取边界清单,方便 LLM 逐条对照
|
|
||||||
var boundaries []string
|
|
||||||
for _, r := range rules {
|
|
||||||
if r.Boundary != "" {
|
|
||||||
boundaries = append(boundaries, fmt.Sprintf("[%s] %s", r.Category, r.Boundary))
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if len(boundaries) > 0 {
|
|
||||||
result["world_rules_boundaries"] = boundaries
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载前两章摘要
|
|
||||||
if summaries, _ := t.store.LoadRecentSummaries(a.Chapter, 2); len(summaries) > 0 {
|
if summaries, _ := t.store.LoadRecentSummaries(a.Chapter, 2); len(summaries) > 0 {
|
||||||
result["recent_summaries"] = summaries
|
result["recent_summaries"] = summaries
|
||||||
}
|
}
|
||||||
|
|
||||||
result["instruction"] = `请逐项对照以上状态数据检查本章内容,返回 JSON 数组格式的冲突项:
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"type": "timeline|foreshadow|relationship|character|world_rules|state",
|
|
||||||
"severity": "critical|error|warning",
|
|
||||||
"description": "具体冲突描述",
|
|
||||||
"suggestion": "建议修正范围和方式"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
severity 分级:
|
|
||||||
- critical:严重逻辑硬伤,必须修复(如角色已死但再次出场、违反世界规则核心边界)
|
|
||||||
- error:明显矛盾,应当修复(如时间线冲突、角色行为与人设严重不符)
|
|
||||||
- warning:轻微瑕疵,可后续处理(如细节不够精确、可改进但不影响阅读)
|
|
||||||
|
|
||||||
检查清单:
|
|
||||||
1. 时间线:本章事件时间是否与已有 timeline 矛盾
|
|
||||||
2. 伏笔:unresolved_foreshadow 中是否有本章应推进但遗漏的
|
|
||||||
3. 人物关系:角色互动是否与 relationships 当前状态矛盾
|
|
||||||
4. 角色一致性:行为是否符合 characters 中的性格和弧线
|
|
||||||
5. 世界规则:逐条检查 world_rules_boundaries 中的边界约束,本章内容是否违反任何一条
|
|
||||||
6. 别名一致性:如果有 alias_map,检查同一角色的不同称呼是否指向正确的人
|
|
||||||
7. 状态连续性:如果有 recent_state_changes,检查本章对角色状态的描述是否与最近的状态变化记录一致
|
|
||||||
|
|
||||||
如果没有发现冲突,返回空数组 []。不要返回其他格式。`
|
|
||||||
|
|
||||||
return json.Marshal(result)
|
return json.Marshal(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func filterActive(entries []domain.ForeshadowEntry) []domain.ForeshadowEntry {
|
|
||||||
var active []domain.ForeshadowEntry
|
|
||||||
for _, e := range entries {
|
|
||||||
if e.Status != "resolved" {
|
|
||||||
active = append(active, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return active
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// CommitChapterTool 提交章节:加载正文 → 保存终稿 → 生成摘要 → 更新状态 → 更新进度。
|
// CommitChapterTool 提交章节:加载正文 → 保存终稿 → 生成摘要 → 更新状态 → 更新进度。
|
||||||
// 这是唯一允许写入 chapters/、summaries/、更新状态文件和进度的工具。
|
|
||||||
type CommitChapterTool struct {
|
type CommitChapterTool struct {
|
||||||
store *state.Store
|
store *state.Store
|
||||||
}
|
}
|
||||||
@@ -23,7 +22,7 @@ func NewCommitChapterTool(store *state.Store) *CommitChapterTool {
|
|||||||
|
|
||||||
func (t *CommitChapterTool) Name() string { return "commit_chapter" }
|
func (t *CommitChapterTool) Name() string { return "commit_chapter" }
|
||||||
func (t *CommitChapterTool) Description() string {
|
func (t *CommitChapterTool) Description() string {
|
||||||
return "提交章节。优先使用打磨版正文,同时更新时间线、伏笔、关系状态。返回结构化信号"
|
return "提交章节终稿。加载草稿正文,保存为终稿,同时更新时间线、伏笔、关系、角色状态。返回结构化信号"
|
||||||
}
|
}
|
||||||
func (t *CommitChapterTool) Label() string { return "提交章节" }
|
func (t *CommitChapterTool) Label() string { return "提交章节" }
|
||||||
|
|
||||||
@@ -34,7 +33,7 @@ func (t *CommitChapterTool) Schema() map[string]any {
|
|||||||
schema.Property("characters", schema.Array("涉及角色", schema.String(""))),
|
schema.Property("characters", schema.Array("涉及角色", schema.String(""))),
|
||||||
)
|
)
|
||||||
foreshadowSchema := schema.Object(
|
foreshadowSchema := schema.Object(
|
||||||
schema.Property("id", schema.String("伏笔 ID(新埋设时自定义,推进/回收时使用已有 ID)")).Required(),
|
schema.Property("id", schema.String("伏笔 ID")).Required(),
|
||||||
schema.Property("action", schema.Enum("操作", "plant", "advance", "resolve")).Required(),
|
schema.Property("action", schema.Enum("操作", "plant", "advance", "resolve")).Required(),
|
||||||
schema.Property("description", schema.String("伏笔描述(仅 plant 时必需)")),
|
schema.Property("description", schema.String("伏笔描述(仅 plant 时必需)")),
|
||||||
)
|
)
|
||||||
@@ -45,11 +44,15 @@ func (t *CommitChapterTool) Schema() map[string]any {
|
|||||||
)
|
)
|
||||||
stateChangeSchema := schema.Object(
|
stateChangeSchema := schema.Object(
|
||||||
schema.Property("entity", schema.String("角色名或实体名")).Required(),
|
schema.Property("entity", schema.String("角色名或实体名")).Required(),
|
||||||
schema.Property("field", schema.String("变化属性:realm/location/status/power/relation 等")).Required(),
|
schema.Property("field", schema.String("变化属性")).Required(),
|
||||||
schema.Property("old_value", schema.String("变化前的值(首次出现可空)")),
|
schema.Property("old_value", schema.String("变化前的值")),
|
||||||
schema.Property("new_value", schema.String("变化后的值")).Required(),
|
schema.Property("new_value", schema.String("变化后的值")).Required(),
|
||||||
schema.Property("reason", schema.String("变化原因")),
|
schema.Property("reason", schema.String("变化原因")),
|
||||||
)
|
)
|
||||||
|
feedbackSchema := schema.Object(
|
||||||
|
schema.Property("deviation", schema.String("偏离大纲的描述")).Required(),
|
||||||
|
schema.Property("suggestion", schema.String("对后续大纲的调整建议")).Required(),
|
||||||
|
)
|
||||||
return schema.Object(
|
return schema.Object(
|
||||||
schema.Property("chapter", schema.Int("章节号")).Required(),
|
schema.Property("chapter", schema.Int("章节号")).Required(),
|
||||||
schema.Property("summary", schema.String("本章内容摘要(200字以内)")).Required(),
|
schema.Property("summary", schema.String("本章内容摘要(200字以内)")).Required(),
|
||||||
@@ -58,9 +61,10 @@ func (t *CommitChapterTool) Schema() map[string]any {
|
|||||||
schema.Property("timeline_events", schema.Array("本章时间线事件", timelineSchema)),
|
schema.Property("timeline_events", schema.Array("本章时间线事件", timelineSchema)),
|
||||||
schema.Property("foreshadow_updates", schema.Array("伏笔操作", foreshadowSchema)),
|
schema.Property("foreshadow_updates", schema.Array("伏笔操作", foreshadowSchema)),
|
||||||
schema.Property("relationship_changes", schema.Array("关系变化", relationshipSchema)),
|
schema.Property("relationship_changes", schema.Array("关系变化", relationshipSchema)),
|
||||||
schema.Property("state_changes", schema.Array("角色/实体状态变化(修为提升、位置转移、状态变化等)", stateChangeSchema)),
|
schema.Property("state_changes", schema.Array("角色/实体状态变化", stateChangeSchema)),
|
||||||
schema.Property("hook_type", schema.Enum("章末钩子类型", "crisis", "mystery", "desire", "emotion", "choice")),
|
schema.Property("hook_type", schema.Enum("章末钩子类型", "crisis", "mystery", "desire", "emotion", "choice")),
|
||||||
schema.Property("dominant_strand", schema.Enum("本章主导叙事线", "quest", "fire", "constellation")),
|
schema.Property("dominant_strand", schema.Enum("本章主导叙事线", "quest", "fire", "constellation")),
|
||||||
|
schema.Property("feedback", feedbackSchema),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +80,7 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
|
|||||||
StateChanges []domain.StateChange `json:"state_changes"`
|
StateChanges []domain.StateChange `json:"state_changes"`
|
||||||
HookType string `json:"hook_type"`
|
HookType string `json:"hook_type"`
|
||||||
DominantStrand string `json:"dominant_strand"`
|
DominantStrand string `json:"dominant_strand"`
|
||||||
|
Feedback *domain.OutlineFeedback `json:"feedback"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(args, &a); err != nil {
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
return nil, fmt.Errorf("invalid args: %w", err)
|
return nil, fmt.Errorf("invalid args: %w", err)
|
||||||
@@ -87,7 +92,7 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 加载章节正文(polished 优先,否则 merge scenes)
|
// 1. 加载章节正文
|
||||||
content, wordCount, err := t.store.LoadChapterContent(a.Chapter)
|
content, wordCount, err := t.store.LoadChapterContent(a.Chapter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("load chapter content: %w", err)
|
return nil, fmt.Errorf("load chapter content: %w", err)
|
||||||
@@ -157,7 +162,8 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
|
|||||||
if progress != nil {
|
if progress != nil {
|
||||||
completedCount = len(progress.CompletedChapters)
|
completedCount = len(progress.CompletedChapters)
|
||||||
}
|
}
|
||||||
// 6b. 长篇模式:弧级边界检测(替代固定间隔评审)+ 更新卷弧位置
|
|
||||||
|
// 6b. 长篇模式:弧级边界检测
|
||||||
var arcEnd, volumeEnd bool
|
var arcEnd, volumeEnd bool
|
||||||
var vol, arc int
|
var vol, arc int
|
||||||
if progress != nil && progress.Layered {
|
if progress != nil && progress.Layered {
|
||||||
@@ -169,7 +175,6 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
|
|||||||
volumeEnd = boundary.IsVolumeEnd
|
volumeEnd = boundary.IsVolumeEnd
|
||||||
vol = boundary.Volume
|
vol = boundary.Volume
|
||||||
arc = boundary.Arc
|
arc = boundary.Arc
|
||||||
// 每次提交时更新卷弧位置,确保 novel_context 的 position 始终正确
|
|
||||||
_ = t.store.UpdateVolumeArc(vol, arc)
|
_ = t.store.UpdateVolumeArc(vol, arc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,35 +187,29 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
|
|||||||
reviewRequired, reviewReason = domain.ShouldReview(completedCount)
|
reviewRequired, reviewReason = domain.ShouldReview(completedCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. 计算场景数
|
// 7. 构造结构化信号
|
||||||
sceneCount := 0
|
|
||||||
if scenes, err := t.store.LoadSceneDrafts(a.Chapter); err == nil {
|
|
||||||
sceneCount = len(scenes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. 构造结构化信号
|
|
||||||
result := domain.CommitResult{
|
result := domain.CommitResult{
|
||||||
Chapter: a.Chapter,
|
Chapter: a.Chapter,
|
||||||
Committed: true,
|
Committed: true,
|
||||||
WordCount: wordCount,
|
WordCount: wordCount,
|
||||||
SceneCount: sceneCount,
|
|
||||||
NextChapter: a.Chapter + 1,
|
NextChapter: a.Chapter + 1,
|
||||||
ReviewRequired: reviewRequired,
|
ReviewRequired: reviewRequired,
|
||||||
ReviewReason: reviewReason,
|
ReviewReason: reviewReason,
|
||||||
HookType: a.HookType,
|
HookType: a.HookType,
|
||||||
DominantStrand: a.DominantStrand,
|
DominantStrand: a.DominantStrand,
|
||||||
|
Feedback: a.Feedback,
|
||||||
ArcEnd: arcEnd,
|
ArcEnd: arcEnd,
|
||||||
VolumeEnd: volumeEnd,
|
VolumeEnd: volumeEnd,
|
||||||
Volume: vol,
|
Volume: vol,
|
||||||
Arc: arc,
|
Arc: arc,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. 写入信号文件供宿主程序读取(优先于清理操作,确保信号不丢失)
|
// 8. 写入信号文件
|
||||||
if err := t.store.SaveLastCommit(result); err != nil {
|
if err := t.store.SaveLastCommit(result); err != nil {
|
||||||
return nil, fmt.Errorf("save commit signal: %w", err)
|
return nil, fmt.Errorf("save commit signal: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10. 清除场景级进度(章节已提交)
|
// 9. 清除进度中间状态
|
||||||
if err := t.store.ClearInProgress(); err != nil {
|
if err := t.store.ClearInProgress(); err != nil {
|
||||||
return nil, fmt.Errorf("clear in-progress: %w", err)
|
return nil, fmt.Errorf("clear in-progress: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ func TestCommitChapterRejectsNonPendingRewrite(t *testing.T) {
|
|||||||
if err := store.SetFlow(domain.FlowRewriting); err != nil {
|
if err := store.SetFlow(domain.FlowRewriting); err != nil {
|
||||||
t.Fatalf("SetFlow: %v", err)
|
t.Fatalf("SetFlow: %v", err)
|
||||||
}
|
}
|
||||||
if err := store.SavePolished(3, "这是错误章节的正文。"); err != nil {
|
if err := store.SaveDraft(3, "这是错误章节的正文。"); err != nil {
|
||||||
t.Fatalf("SavePolished: %v", err)
|
t.Fatalf("SaveDraft: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tool := NewCommitChapterTool(store)
|
tool := NewCommitChapterTool(store)
|
||||||
@@ -76,8 +76,8 @@ func TestCommitChapterAllowsPendingRewrite(t *testing.T) {
|
|||||||
if err := store.SetFlow(domain.FlowRewriting); err != nil {
|
if err := store.SetFlow(domain.FlowRewriting); err != nil {
|
||||||
t.Fatalf("SetFlow: %v", err)
|
t.Fatalf("SetFlow: %v", err)
|
||||||
}
|
}
|
||||||
if err := store.SavePolished(2, "这是正确待重写章节的正文。"); err != nil {
|
if err := store.SaveDraft(2, "这是正确待重写章节的正文。"); err != nil {
|
||||||
t.Fatalf("SavePolished: %v", err)
|
t.Fatalf("SaveDraft: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tool := NewCommitChapterTool(store)
|
tool := NewCommitChapterTool(store)
|
||||||
|
|||||||
80
tools/draft_chapter.go
Normal file
80
tools/draft_chapter.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/voocel/agentcore/schema"
|
||||||
|
"github.com/voocel/ainovel-cli/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DraftChapterTool 写入整章草稿,替代旧的 write_scene + polish_chapter 流水线。
|
||||||
|
// Agent 自主决定一次写完还是分批续写。
|
||||||
|
type DraftChapterTool struct {
|
||||||
|
store *state.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDraftChapterTool(store *state.Store) *DraftChapterTool {
|
||||||
|
return &DraftChapterTool{store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DraftChapterTool) Name() string { return "draft_chapter" }
|
||||||
|
func (t *DraftChapterTool) Description() string {
|
||||||
|
return "写入章节正文。mode=write 覆盖写入整章,mode=append 追加到现有草稿(续写/修改)"
|
||||||
|
}
|
||||||
|
func (t *DraftChapterTool) Label() string { return "写入章节" }
|
||||||
|
|
||||||
|
func (t *DraftChapterTool) Schema() map[string]any {
|
||||||
|
return schema.Object(
|
||||||
|
schema.Property("chapter", schema.Int("章节号")).Required(),
|
||||||
|
schema.Property("content", schema.String("章节正文")).Required(),
|
||||||
|
schema.Property("mode", schema.Enum("写入模式", "write", "append")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DraftChapterTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var a struct {
|
||||||
|
Chapter int `json:"chapter"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid args: %w", err)
|
||||||
|
}
|
||||||
|
if a.Chapter <= 0 {
|
||||||
|
return nil, fmt.Errorf("chapter must be > 0")
|
||||||
|
}
|
||||||
|
if a.Content == "" {
|
||||||
|
return nil, fmt.Errorf("content must not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch a.Mode {
|
||||||
|
case "append":
|
||||||
|
if err := t.store.AppendDraft(a.Chapter, a.Content); err != nil {
|
||||||
|
return nil, fmt.Errorf("append draft: %w", err)
|
||||||
|
}
|
||||||
|
// 读取合并后的完整内容计算字数
|
||||||
|
full, err := t.store.LoadDraft(a.Chapter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load draft after append: %w", err)
|
||||||
|
}
|
||||||
|
return json.Marshal(map[string]any{
|
||||||
|
"written": true,
|
||||||
|
"chapter": a.Chapter,
|
||||||
|
"mode": "append",
|
||||||
|
"word_count": utf8.RuneCountInString(full),
|
||||||
|
})
|
||||||
|
default: // write
|
||||||
|
if err := t.store.SaveDraft(a.Chapter, a.Content); err != nil {
|
||||||
|
return nil, fmt.Errorf("save draft: %w", err)
|
||||||
|
}
|
||||||
|
return json.Marshal(map[string]any{
|
||||||
|
"written": true,
|
||||||
|
"chapter": a.Chapter,
|
||||||
|
"mode": "write",
|
||||||
|
"word_count": utf8.RuneCountInString(a.Content),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -211,11 +211,10 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw
|
|||||||
result["position"] = pos
|
result["position"] = pos
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载场景级恢复状态 + 节奏追踪
|
// 加载进度状态和节奏追踪
|
||||||
if progress != nil {
|
if progress != nil {
|
||||||
checkpoint := map[string]any{
|
checkpoint := map[string]any{
|
||||||
"in_progress_chapter": progress.InProgressChapter,
|
"in_progress_chapter": progress.InProgressChapter,
|
||||||
"completed_scenes": progress.CompletedScenes,
|
|
||||||
}
|
}
|
||||||
if len(progress.StrandHistory) > 0 {
|
if len(progress.StrandHistory) > 0 {
|
||||||
checkpoint["strand_history"] = progress.StrandHistory
|
checkpoint["strand_history"] = progress.StrandHistory
|
||||||
@@ -225,13 +224,43 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw
|
|||||||
}
|
}
|
||||||
result["checkpoint"] = checkpoint
|
result["checkpoint"] = checkpoint
|
||||||
}
|
}
|
||||||
// 加载已有的章节规划(支持场景恢复跳过已完成场景)
|
// 加载已有的章节构思
|
||||||
if plan, err := t.store.LoadChapterPlan(a.Chapter); err == nil && plan != nil {
|
if plan, err := t.store.LoadChapterPlan(a.Chapter); err == nil && plan != nil {
|
||||||
result["chapter_plan"] = plan
|
result["chapter_plan"] = plan
|
||||||
} else {
|
} else {
|
||||||
warn("chapter_plan", err)
|
warn("chapter_plan", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 风格锚点:从前文提取代表性段落
|
||||||
|
if anchors := t.store.ExtractStyleAnchors(3); len(anchors) > 0 {
|
||||||
|
result["style_anchors"] = anchors
|
||||||
|
}
|
||||||
|
|
||||||
|
// 角色声纹:提取出场角色的对话原文片段
|
||||||
|
if entry, err := t.store.GetChapterOutline(a.Chapter); err == nil && entry != nil {
|
||||||
|
var voiceSamples []map[string]any
|
||||||
|
chars, _ := t.store.LoadCharacters()
|
||||||
|
for _, c := range chars {
|
||||||
|
// 只为 core/important 角色提取声纹
|
||||||
|
if c.Tier == "secondary" || c.Tier == "decorative" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
samples := t.store.ExtractDialogue(c.Name, c.Aliases, 3)
|
||||||
|
if len(samples) > 0 {
|
||||||
|
voiceSamples = append(voiceSamples, map[string]any{
|
||||||
|
"character": c.Name,
|
||||||
|
"samples": samples,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(voiceSamples) >= 5 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(voiceSamples) > 0 {
|
||||||
|
result["voice_samples"] = voiceSamples
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 写作参考资料分阶段加载
|
// 写作参考资料分阶段加载
|
||||||
result["references"] = t.writerReferences(a.Chapter)
|
result["references"] = t.writerReferences(a.Chapter)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"github.com/voocel/ainovel-cli/state"
|
"github.com/voocel/ainovel-cli/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PlanChapterTool 生成章节规划。
|
// PlanChapterTool 保存章节构思,Agent 自主决定规划粒度。
|
||||||
type PlanChapterTool struct {
|
type PlanChapterTool struct {
|
||||||
store *state.Store
|
store *state.Store
|
||||||
}
|
}
|
||||||
@@ -21,25 +21,19 @@ func NewPlanChapterTool(store *state.Store) *PlanChapterTool {
|
|||||||
|
|
||||||
func (t *PlanChapterTool) Name() string { return "plan_chapter" }
|
func (t *PlanChapterTool) Name() string { return "plan_chapter" }
|
||||||
func (t *PlanChapterTool) Description() string {
|
func (t *PlanChapterTool) Description() string {
|
||||||
return "创建章节写作规划,包括目标、冲突、场景拆分和钩子设计。必须在 write_scene 之前调用"
|
return "保存章节写作构思。Agent 自主决定规划粒度,不强制场景拆分"
|
||||||
}
|
}
|
||||||
func (t *PlanChapterTool) Label() string { return "规划章节" }
|
func (t *PlanChapterTool) Label() string { return "规划章节" }
|
||||||
|
|
||||||
func (t *PlanChapterTool) Schema() map[string]any {
|
func (t *PlanChapterTool) Schema() map[string]any {
|
||||||
sceneSchema := schema.Object(
|
|
||||||
schema.Property("index", schema.Int("场景编号,从 1 开始")).Required(),
|
|
||||||
schema.Property("summary", schema.String("场景概要")).Required(),
|
|
||||||
schema.Property("pov", schema.String("视角人物")),
|
|
||||||
schema.Property("location", schema.String("场景地点")),
|
|
||||||
)
|
|
||||||
return schema.Object(
|
return schema.Object(
|
||||||
schema.Property("chapter", schema.Int("章节号")).Required(),
|
schema.Property("chapter", schema.Int("章节号")).Required(),
|
||||||
schema.Property("title", schema.String("章节标题")).Required(),
|
schema.Property("title", schema.String("章节标题")).Required(),
|
||||||
schema.Property("goal", schema.String("本章目标")).Required(),
|
schema.Property("goal", schema.String("本章目标")).Required(),
|
||||||
schema.Property("conflict", schema.String("核心冲突")).Required(),
|
schema.Property("conflict", schema.String("核心冲突")).Required(),
|
||||||
schema.Property("scenes", schema.Array("场景列表", sceneSchema)).Required(),
|
|
||||||
schema.Property("hook", schema.String("章末钩子")).Required(),
|
schema.Property("hook", schema.String("章末钩子")).Required(),
|
||||||
schema.Property("emotion_arc", schema.String("情绪曲线")),
|
schema.Property("emotion_arc", schema.String("情绪曲线")),
|
||||||
|
schema.Property("notes", schema.String("自由备忘(任何你觉得写作时需要记住的东西)")),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,9 +45,6 @@ func (t *PlanChapterTool) Execute(_ context.Context, args json.RawMessage) (json
|
|||||||
if plan.Chapter <= 0 {
|
if plan.Chapter <= 0 {
|
||||||
return nil, fmt.Errorf("chapter must be > 0")
|
return nil, fmt.Errorf("chapter must be > 0")
|
||||||
}
|
}
|
||||||
if len(plan.Scenes) == 0 {
|
|
||||||
return nil, fmt.Errorf("scenes must not be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := t.store.SaveChapterPlan(plan); err != nil {
|
if err := t.store.SaveChapterPlan(plan); err != nil {
|
||||||
return nil, fmt.Errorf("save chapter plan: %w", err)
|
return nil, fmt.Errorf("save chapter plan: %w", err)
|
||||||
@@ -62,6 +53,5 @@ func (t *PlanChapterTool) Execute(_ context.Context, args json.RawMessage) (json
|
|||||||
return json.Marshal(map[string]any{
|
return json.Marshal(map[string]any{
|
||||||
"planned": true,
|
"planned": true,
|
||||||
"chapter": plan.Chapter,
|
"chapter": plan.Chapter,
|
||||||
"scene_count": len(plan.Scenes),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
package tools
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"github.com/voocel/agentcore/schema"
|
|
||||||
"github.com/voocel/ainovel-cli/state"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PolishChapterTool 保存打磨后的章节正文,替换原始场景拼接。
|
|
||||||
type PolishChapterTool struct {
|
|
||||||
store *state.Store
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPolishChapterTool(store *state.Store) *PolishChapterTool {
|
|
||||||
return &PolishChapterTool{store: store}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *PolishChapterTool) Name() string { return "polish_chapter" }
|
|
||||||
func (t *PolishChapterTool) Description() string {
|
|
||||||
return "保存打磨后的章节正文。在 write_scene 全部完成后、commit_chapter 之前调用。提交时会优先使用打磨版本"
|
|
||||||
}
|
|
||||||
func (t *PolishChapterTool) Label() string { return "打磨章节" }
|
|
||||||
|
|
||||||
func (t *PolishChapterTool) Schema() map[string]any {
|
|
||||||
return schema.Object(
|
|
||||||
schema.Property("chapter", schema.Int("章节号")).Required(),
|
|
||||||
schema.Property("content", schema.String("打磨后的完整章节正文")).Required(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *PolishChapterTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
|
||||||
var a struct {
|
|
||||||
Chapter int `json:"chapter"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(args, &a); err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid args: %w", err)
|
|
||||||
}
|
|
||||||
if a.Chapter <= 0 {
|
|
||||||
return nil, fmt.Errorf("chapter must be > 0")
|
|
||||||
}
|
|
||||||
if a.Content == "" {
|
|
||||||
return nil, fmt.Errorf("content must not be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := t.store.SavePolished(a.Chapter, a.Content); err != nil {
|
|
||||||
return nil, fmt.Errorf("save polished: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return json.Marshal(map[string]any{
|
|
||||||
"polished": true,
|
|
||||||
"chapter": a.Chapter,
|
|
||||||
"word_count": utf8.RuneCountInString(a.Content),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
116
tools/read_chapter.go
Normal file
116
tools/read_chapter.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/voocel/agentcore/schema"
|
||||||
|
"github.com/voocel/ainovel-cli/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReadChapterTool 读取章节原文,让 Agent 能回读自己和前文的文字。
|
||||||
|
type ReadChapterTool struct {
|
||||||
|
store *state.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReadChapterTool(store *state.Store) *ReadChapterTool {
|
||||||
|
return &ReadChapterTool{store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ReadChapterTool) Name() string { return "read_chapter" }
|
||||||
|
func (t *ReadChapterTool) Description() string { return "读取章节原文。可读终稿、草稿,或提取角色对话片段" }
|
||||||
|
func (t *ReadChapterTool) Label() string { return "读取章节" }
|
||||||
|
|
||||||
|
func (t *ReadChapterTool) Schema() map[string]any {
|
||||||
|
return schema.Object(
|
||||||
|
schema.Property("chapter", schema.Int("章节号(读单章时必填)")),
|
||||||
|
schema.Property("from", schema.Int("起始章节号(读范围时使用)")),
|
||||||
|
schema.Property("to", schema.Int("结束章节号(读范围时使用)")),
|
||||||
|
schema.Property("source", schema.Enum("来源", "final", "draft")).Required(),
|
||||||
|
schema.Property("character", schema.String("角色名(提取对话片段时使用)")),
|
||||||
|
schema.Property("max_runes", schema.Int("每章最大字符数(范围读取时截取,默认 2000)")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ReadChapterTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var a struct {
|
||||||
|
Chapter int `json:"chapter"`
|
||||||
|
From int `json:"from"`
|
||||||
|
To int `json:"to"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Character string `json:"character"`
|
||||||
|
MaxRunes int `json:"max_runes"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid args: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模式 1:提取角色对话
|
||||||
|
if a.Character != "" {
|
||||||
|
chars, _ := t.store.LoadCharacters()
|
||||||
|
var aliases []string
|
||||||
|
for _, c := range chars {
|
||||||
|
if c.Name == a.Character {
|
||||||
|
aliases = c.Aliases
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
samples := t.store.ExtractDialogue(a.Character, aliases, 8)
|
||||||
|
return json.Marshal(map[string]any{
|
||||||
|
"character": a.Character,
|
||||||
|
"samples": samples,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模式 2:范围读取
|
||||||
|
if a.From > 0 && a.To > 0 {
|
||||||
|
maxRunes := a.MaxRunes
|
||||||
|
if maxRunes <= 0 {
|
||||||
|
maxRunes = 2000
|
||||||
|
}
|
||||||
|
texts, err := t.store.LoadChapterRange(a.From, a.To, maxRunes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load chapter range: %w", err)
|
||||||
|
}
|
||||||
|
return json.Marshal(map[string]any{
|
||||||
|
"chapters": texts,
|
||||||
|
"from": a.From,
|
||||||
|
"to": a.To,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模式 3:单章读取
|
||||||
|
if a.Chapter <= 0 {
|
||||||
|
return nil, fmt.Errorf("chapter is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var content string
|
||||||
|
var err error
|
||||||
|
switch a.Source {
|
||||||
|
case "draft":
|
||||||
|
content, err = t.store.LoadDraft(a.Chapter)
|
||||||
|
default: // final
|
||||||
|
content, err = t.store.LoadChapterText(a.Chapter)
|
||||||
|
if (err == nil && content == "") {
|
||||||
|
// 回退到草稿
|
||||||
|
content, err = t.store.LoadDraft(a.Chapter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read chapter %d: %w", a.Chapter, err)
|
||||||
|
}
|
||||||
|
if content == "" {
|
||||||
|
return json.Marshal(map[string]any{
|
||||||
|
"chapter": a.Chapter,
|
||||||
|
"content": "",
|
||||||
|
"note": "章节不存在",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(map[string]any{
|
||||||
|
"chapter": a.Chapter,
|
||||||
|
"content": content,
|
||||||
|
"word_count": len([]rune(content)),
|
||||||
|
})
|
||||||
|
}
|
||||||
215
tools/read_draft_test.go
Normal file
215
tools/read_draft_test.go
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/voocel/ainovel-cli/domain"
|
||||||
|
"github.com/voocel/ainovel-cli/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReadChapterFinal(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := state.NewStore(dir)
|
||||||
|
if err := store.Init(); err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.SaveFinalChapter(1, "第一章的终稿正文。"); err != nil {
|
||||||
|
t.Fatalf("SaveFinalChapter: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tool := NewReadChapterTool(store)
|
||||||
|
args, _ := json.Marshal(map[string]any{"chapter": 1, "source": "final"})
|
||||||
|
result, err := tool.Execute(context.Background(), args)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Chapter int `json:"chapter"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
WordCount int `json:"word_count"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(result, &payload); err != nil {
|
||||||
|
t.Fatalf("Unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if payload.Content == "" {
|
||||||
|
t.Fatal("expected non-empty content")
|
||||||
|
}
|
||||||
|
if payload.WordCount == 0 {
|
||||||
|
t.Fatal("expected non-zero word count")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadChapterDraft(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := state.NewStore(dir)
|
||||||
|
if err := store.Init(); err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.SaveDraft(3, "第三章的草稿内容。"); err != nil {
|
||||||
|
t.Fatalf("SaveDraft: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tool := NewReadChapterTool(store)
|
||||||
|
args, _ := json.Marshal(map[string]any{"chapter": 3, "source": "draft"})
|
||||||
|
result, err := tool.Execute(context.Background(), args)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(result, &payload); err != nil {
|
||||||
|
t.Fatalf("Unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if payload.Content == "" {
|
||||||
|
t.Fatal("expected draft content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadChapterDialogue(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := state.NewStore(dir)
|
||||||
|
if err := store.Init(); err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.SaveCharacters([]domain.Character{
|
||||||
|
{Name: "张三", Aliases: []string{"老张"}},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SaveCharacters: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.SaveFinalChapter(1, "张三站起身来。\u201c我不同意这个方案,\u201d张三冷冷地说。"); err != nil {
|
||||||
|
t.Fatalf("SaveFinalChapter: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tool := NewReadChapterTool(store)
|
||||||
|
args, _ := json.Marshal(map[string]any{"source": "final", "character": "张三"})
|
||||||
|
result, err := tool.Execute(context.Background(), args)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Character string `json:"character"`
|
||||||
|
Samples []string `json:"samples"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(result, &payload); err != nil {
|
||||||
|
t.Fatalf("Unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if payload.Character != "张三" {
|
||||||
|
t.Fatalf("expected character 张三, got %s", payload.Character)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadChapterRange(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := state.NewStore(dir)
|
||||||
|
if err := store.Init(); err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
for i := 1; i <= 3; i++ {
|
||||||
|
if err := store.SaveFinalChapter(i, "这是一段正文内容。"); err != nil {
|
||||||
|
t.Fatalf("SaveFinalChapter(%d): %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tool := NewReadChapterTool(store)
|
||||||
|
args, _ := json.Marshal(map[string]any{"from": 1, "to": 3, "source": "final"})
|
||||||
|
result, err := tool.Execute(context.Background(), args)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Chapters map[string]string `json:"chapters"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(result, &payload); err != nil {
|
||||||
|
t.Fatalf("Unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if len(payload.Chapters) != 3 {
|
||||||
|
t.Fatalf("expected 3 chapters, got %d", len(payload.Chapters))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDraftChapterWrite(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := state.NewStore(dir)
|
||||||
|
if err := store.Init(); err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tool := NewDraftChapterTool(store)
|
||||||
|
args, _ := json.Marshal(map[string]any{
|
||||||
|
"chapter": 1,
|
||||||
|
"content": "这是整章的正文内容,一次写完。",
|
||||||
|
"mode": "write",
|
||||||
|
})
|
||||||
|
result, err := tool.Execute(context.Background(), args)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Written bool `json:"written"`
|
||||||
|
WordCount int `json:"word_count"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(result, &payload); err != nil {
|
||||||
|
t.Fatalf("Unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if !payload.Written {
|
||||||
|
t.Fatal("expected written=true")
|
||||||
|
}
|
||||||
|
if payload.WordCount == 0 {
|
||||||
|
t.Fatal("expected non-zero word count")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证能读回来
|
||||||
|
content, err := store.LoadDraft(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadDraft: %v", err)
|
||||||
|
}
|
||||||
|
if content == "" {
|
||||||
|
t.Fatal("expected non-empty draft")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDraftChapterAppend(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := state.NewStore(dir)
|
||||||
|
if err := store.Init(); err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.SaveDraft(2, "前半部分。"); err != nil {
|
||||||
|
t.Fatalf("SaveDraft: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tool := NewDraftChapterTool(store)
|
||||||
|
args, _ := json.Marshal(map[string]any{
|
||||||
|
"chapter": 2,
|
||||||
|
"content": "后半部分。",
|
||||||
|
"mode": "append",
|
||||||
|
})
|
||||||
|
result, err := tool.Execute(context.Background(), args)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
WordCount int `json:"word_count"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(result, &payload); err != nil {
|
||||||
|
t.Fatalf("Unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if payload.Mode != "append" {
|
||||||
|
t.Fatalf("expected mode=append, got %s", payload.Mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, _ := store.LoadDraft(2)
|
||||||
|
if content == "" || content == "前半部分。" {
|
||||||
|
t.Fatal("expected appended content")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,14 +21,16 @@ func NewSaveFoundationTool(store *state.Store) *SaveFoundationTool {
|
|||||||
|
|
||||||
func (t *SaveFoundationTool) Name() string { return "save_foundation" }
|
func (t *SaveFoundationTool) Name() string { return "save_foundation" }
|
||||||
func (t *SaveFoundationTool) Description() string {
|
func (t *SaveFoundationTool) Description() string {
|
||||||
return "保存小说基础设定。type=premise 时 content 为 Markdown;type=outline 时 content 为 JSON 数组;type=characters 时 content 为 JSON 数组;type=world_rules 时 content 为 JSON 数组。scale 可选,用于记录 short/mid/long 规划级别"
|
return "保存小说基础设定。参数固定为 {type, content, scale?}。type 可选 premise / outline / layered_outline / characters / world_rules。premise 时 content 必须是 Markdown 字符串;outline、layered_outline、characters、world_rules 时 content 优先直接传 JSON 数组或对象,不要再手动包一层转义字符串;工具也兼容传入 JSON 字符串。scale 可选,仅允许 short / mid / long。"
|
||||||
}
|
}
|
||||||
func (t *SaveFoundationTool) Label() string { return "保存设定" }
|
func (t *SaveFoundationTool) Label() string { return "保存设定" }
|
||||||
|
|
||||||
func (t *SaveFoundationTool) Schema() map[string]any {
|
func (t *SaveFoundationTool) Schema() map[string]any {
|
||||||
return schema.Object(
|
return schema.Object(
|
||||||
schema.Property("type", schema.Enum("设定类型", "premise", "outline", "layered_outline", "characters", "world_rules")).Required(),
|
schema.Property("type", schema.Enum("设定类型", "premise", "outline", "layered_outline", "characters", "world_rules")).Required(),
|
||||||
schema.Property("content", schema.String("内容。premise 为 Markdown 文本,outline/layered_outline/characters/world_rules 为 JSON 字符串")).Required(),
|
schema.Property("content", map[string]any{
|
||||||
|
"description": "内容。premise 传 Markdown 字符串;outline/layered_outline/characters/world_rules 直接传 JSON 数组或对象即可,也兼容传 JSON 字符串。不要把数组再次手动转义成难读的字符串。",
|
||||||
|
}).Required(),
|
||||||
schema.Property("scale", schema.Enum("规划级别", "short", "mid", "long")),
|
schema.Property("scale", schema.Enum("规划级别", "short", "mid", "long")),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -36,12 +38,16 @@ func (t *SaveFoundationTool) Schema() map[string]any {
|
|||||||
func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Content string `json:"content"`
|
Content json.RawMessage `json:"content"`
|
||||||
Scale string `json:"scale"`
|
Scale string `json:"scale"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(args, &a); err != nil {
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
return nil, fmt.Errorf("invalid args: %w", err)
|
return nil, fmt.Errorf("invalid args: %w", err)
|
||||||
}
|
}
|
||||||
|
content, err := normalizeFoundationContent(a.Content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if a.Scale != "" {
|
if a.Scale != "" {
|
||||||
switch domain.PlanningTier(a.Scale) {
|
switch domain.PlanningTier(a.Scale) {
|
||||||
case domain.PlanningTierShort, domain.PlanningTierMid, domain.PlanningTierLong:
|
case domain.PlanningTierShort, domain.PlanningTierMid, domain.PlanningTierLong:
|
||||||
@@ -55,7 +61,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
|
|||||||
|
|
||||||
switch a.Type {
|
switch a.Type {
|
||||||
case "premise":
|
case "premise":
|
||||||
if err := t.store.SavePremise(a.Content); err != nil {
|
if err := t.store.SavePremise(content); err != nil {
|
||||||
return nil, fmt.Errorf("save premise: %w", err)
|
return nil, fmt.Errorf("save premise: %w", err)
|
||||||
}
|
}
|
||||||
_ = t.store.UpdatePhase(domain.PhasePremise)
|
_ = t.store.UpdatePhase(domain.PhasePremise)
|
||||||
@@ -63,7 +69,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
|
|||||||
|
|
||||||
case "outline":
|
case "outline":
|
||||||
var entries []domain.OutlineEntry
|
var entries []domain.OutlineEntry
|
||||||
if err := json.Unmarshal([]byte(a.Content), &entries); err != nil {
|
if err := json.Unmarshal([]byte(content), &entries); err != nil {
|
||||||
return nil, fmt.Errorf("parse outline JSON: %w", err)
|
return nil, fmt.Errorf("parse outline JSON: %w", err)
|
||||||
}
|
}
|
||||||
if err := t.store.SaveOutline(entries); err != nil {
|
if err := t.store.SaveOutline(entries); err != nil {
|
||||||
@@ -81,7 +87,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
|
|||||||
|
|
||||||
case "layered_outline":
|
case "layered_outline":
|
||||||
var volumes []domain.VolumeOutline
|
var volumes []domain.VolumeOutline
|
||||||
if err := json.Unmarshal([]byte(a.Content), &volumes); err != nil {
|
if err := json.Unmarshal([]byte(content), &volumes); err != nil {
|
||||||
return nil, fmt.Errorf("parse layered_outline JSON: %w", err)
|
return nil, fmt.Errorf("parse layered_outline JSON: %w", err)
|
||||||
}
|
}
|
||||||
if err := t.store.SaveLayeredOutline(volumes); err != nil {
|
if err := t.store.SaveLayeredOutline(volumes); err != nil {
|
||||||
@@ -107,7 +113,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
|
|||||||
|
|
||||||
case "characters":
|
case "characters":
|
||||||
var chars []domain.Character
|
var chars []domain.Character
|
||||||
if err := json.Unmarshal([]byte(a.Content), &chars); err != nil {
|
if err := json.Unmarshal([]byte(content), &chars); err != nil {
|
||||||
return nil, fmt.Errorf("parse characters JSON: %w", err)
|
return nil, fmt.Errorf("parse characters JSON: %w", err)
|
||||||
}
|
}
|
||||||
if err := t.store.SaveCharacters(chars); err != nil {
|
if err := t.store.SaveCharacters(chars); err != nil {
|
||||||
@@ -117,7 +123,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
|
|||||||
|
|
||||||
case "world_rules":
|
case "world_rules":
|
||||||
var rules []domain.WorldRule
|
var rules []domain.WorldRule
|
||||||
if err := json.Unmarshal([]byte(a.Content), &rules); err != nil {
|
if err := json.Unmarshal([]byte(content), &rules); err != nil {
|
||||||
return nil, fmt.Errorf("parse world_rules JSON: %w", err)
|
return nil, fmt.Errorf("parse world_rules JSON: %w", err)
|
||||||
}
|
}
|
||||||
if err := t.store.SaveWorldRules(rules); err != nil {
|
if err := t.store.SaveWorldRules(rules); err != nil {
|
||||||
@@ -129,3 +135,19 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
|
|||||||
return nil, fmt.Errorf("unknown type %q, expected premise/outline/layered_outline/characters/world_rules", a.Type)
|
return nil, fmt.Errorf("unknown type %q, expected premise/outline/layered_outline/characters/world_rules", a.Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeFoundationContent(raw json.RawMessage) (string, error) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return "", fmt.Errorf("content is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var text string
|
||||||
|
if err := json.Unmarshal(raw, &text); err == nil {
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !json.Valid(raw) {
|
||||||
|
return "", fmt.Errorf("invalid content: expected Markdown string or valid JSON value")
|
||||||
|
}
|
||||||
|
return string(raw), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -111,3 +111,41 @@ func TestSaveFoundationOutlineClearsLayeredStateWhenDowngrading(t *testing.T) {
|
|||||||
t.Fatalf("expected planning tier %q, got %q", domain.PlanningTierMid, meta.PlanningTier)
|
t.Fatalf("expected planning tier %q, got %q", domain.PlanningTierMid, meta.PlanningTier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSaveFoundationAcceptsDirectJSONArrayContent(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := state.NewStore(dir)
|
||||||
|
if err := store.Init(); err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tool := NewSaveFoundationTool(store)
|
||||||
|
args, err := json.Marshal(map[string]any{
|
||||||
|
"type": "outline",
|
||||||
|
"content": []map[string]any{
|
||||||
|
{
|
||||||
|
"chapter": 1,
|
||||||
|
"title": "第一章",
|
||||||
|
"core_event": "主角登场",
|
||||||
|
"hook": "继续",
|
||||||
|
"scenes": []string{"场景一", "场景二"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"scale": "short",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tool.Execute(context.Background(), args); err != nil {
|
||||||
|
t.Fatalf("Execute: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
outline, err := store.LoadOutline()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadOutline: %v", err)
|
||||||
|
}
|
||||||
|
if len(outline) != 1 || outline[0].Title != "第一章" {
|
||||||
|
t.Fatalf("unexpected outline: %+v", outline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
package tools
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"github.com/voocel/agentcore/schema"
|
|
||||||
"github.com/voocel/ainovel-cli/domain"
|
|
||||||
"github.com/voocel/ainovel-cli/state"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WriteSceneTool 写入单个场景草稿。
|
|
||||||
type WriteSceneTool struct {
|
|
||||||
store *state.Store
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewWriteSceneTool(store *state.Store) *WriteSceneTool {
|
|
||||||
return &WriteSceneTool{store: store}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *WriteSceneTool) Name() string { return "write_scene" }
|
|
||||||
func (t *WriteSceneTool) Description() string {
|
|
||||||
return "写入单个场景草稿。严格按场景级写作,每次只写一个场景。必须先调用 plan_chapter"
|
|
||||||
}
|
|
||||||
func (t *WriteSceneTool) Label() string { return "写入场景" }
|
|
||||||
|
|
||||||
func (t *WriteSceneTool) Schema() map[string]any {
|
|
||||||
return schema.Object(
|
|
||||||
schema.Property("chapter", schema.Int("章节号")).Required(),
|
|
||||||
schema.Property("scene", schema.Int("场景编号,从 1 开始")).Required(),
|
|
||||||
schema.Property("content", schema.String("场景正文")).Required(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *WriteSceneTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
|
||||||
var a struct {
|
|
||||||
Chapter int `json:"chapter"`
|
|
||||||
Scene int `json:"scene"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(args, &a); err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid args: %w", err)
|
|
||||||
}
|
|
||||||
if a.Chapter <= 0 || a.Scene <= 0 {
|
|
||||||
return nil, fmt.Errorf("chapter and scene must be > 0")
|
|
||||||
}
|
|
||||||
if a.Content == "" {
|
|
||||||
return nil, fmt.Errorf("content must not be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
wordCount := utf8.RuneCountInString(a.Content)
|
|
||||||
draft := domain.SceneDraft{
|
|
||||||
Chapter: a.Chapter,
|
|
||||||
Scene: a.Scene,
|
|
||||||
Content: a.Content,
|
|
||||||
WordCount: wordCount,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := t.store.SaveSceneDraft(draft); err != nil {
|
|
||||||
return nil, fmt.Errorf("save scene draft: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 场景级 checkpoint
|
|
||||||
if err := t.store.MarkSceneComplete(a.Chapter, a.Scene); err != nil {
|
|
||||||
return nil, fmt.Errorf("mark scene complete: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return json.Marshal(map[string]any{
|
|
||||||
"written": true,
|
|
||||||
"chapter": a.Chapter,
|
|
||||||
"scene": a.Scene,
|
|
||||||
"word_count": wordCount,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -84,7 +84,7 @@ func renderStatePanel(snap app.UISnapshot, width, height int) string {
|
|||||||
b.WriteString(renderField("Words", formatNumber(snap.TotalWordCount)))
|
b.WriteString(renderField("Words", formatNumber(snap.TotalWordCount)))
|
||||||
|
|
||||||
if snap.InProgressChapter > 0 {
|
if snap.InProgressChapter > 0 {
|
||||||
b.WriteString(renderField("Writing", fmt.Sprintf("第%d章 场景%d", snap.InProgressChapter, snap.CompletedScenes)))
|
b.WriteString(renderField("Writing", fmt.Sprintf("第%d章", snap.InProgressChapter)))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(snap.PendingRewrites) > 0 {
|
if len(snap.PendingRewrites) > 0 {
|
||||||
|
|||||||
Reference in New Issue
Block a user