refactor: Agent驱动重构,整章写入替代场景拼接

This commit is contained in:
voocel
2026-03-15 14:14:46 +08:00
parent 25e219e934
commit 568ef0b1d1
27 changed files with 942 additions and 568 deletions

View File

@@ -6,12 +6,11 @@ import (
"fmt"
"github.com/voocel/agentcore/schema"
"github.com/voocel/ainovel-cli/domain"
"github.com/voocel/ainovel-cli/state"
)
// CheckConsistencyTool 对照状态文件检查章节一致性
// 返回上下文数据和已知约束供 LLM 判断,不做 AI 推理
// CheckConsistencyTool 返回章节内容和全部状态数据,供 Agent 自行对照判断
// 纯 IO 工具:只负责加载数据,不注入指令
type CheckConsistencyTool struct {
store *state.Store
}
@@ -22,7 +21,7 @@ func NewCheckConsistencyTool(store *state.Store) *CheckConsistencyTool {
func (t *CheckConsistencyTool) Name() string { return "check_consistency" }
func (t *CheckConsistencyTool) Description() string {
return "检查章节一致性。返回章节内容全部状态数据和具体检查清单,你需要逐项对照并以 JSON 格式返回冲突项"
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}
// 加载章节内容polished 优先)
// 章节内容
content, wordCount, err := t.store.LoadChapterContent(a.Chapter)
if err != nil {
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["word_count"] = wordCount
// 加载全部状态数据供 LLM 对照
// 状态数据全部加载Agent 自行决定怎么用)
if timeline, _ := t.store.LoadTimeline(); len(timeline) > 0 {
result["timeline"] = timeline
}
if foreshadow, _ := t.store.LoadForeshadowLedger(); len(foreshadow) > 0 {
result["foreshadow_ledger"] = foreshadow
if active := filterActive(foreshadow); len(active) > 0 {
result["unresolved_foreshadow"] = active
}
}
if relationships, _ := t.store.LoadRelationships(); len(relationships) > 0 {
result["relationships"] = relationships
}
if chars, _ := t.store.LoadCharacters(); len(chars) > 0 {
result["characters"] = chars
// 构建别名映射表,供 LLM 识别角色的不同称呼
aliasMap := make(map[string]string)
for _, c := range chars {
for _, alias := range c.Aliases {
@@ -82,65 +77,15 @@ func (t *CheckConsistencyTool) Execute(_ context.Context, args json.RawMessage)
result["alias_map"] = aliasMap
}
}
// 加载最近状态变化,供对照当前章节的状态描述
if changes, _ := t.store.LoadRecentStateChanges(a.Chapter, 5); len(changes) > 0 {
result["recent_state_changes"] = changes
}
if rules, _ := t.store.LoadWorldRules(); len(rules) > 0 {
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 {
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)
}
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
}

View File

@@ -12,7 +12,6 @@ import (
)
// CommitChapterTool 提交章节:加载正文 → 保存终稿 → 生成摘要 → 更新状态 → 更新进度。
// 这是唯一允许写入 chapters/、summaries/、更新状态文件和进度的工具。
type CommitChapterTool struct {
store *state.Store
}
@@ -23,7 +22,7 @@ func NewCommitChapterTool(store *state.Store) *CommitChapterTool {
func (t *CommitChapterTool) Name() string { return "commit_chapter" }
func (t *CommitChapterTool) Description() string {
return "提交章节。优先使用打磨版正文,同时更新时间线、伏笔、关系状态。返回结构化信号"
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(""))),
)
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("description", schema.String("伏笔描述(仅 plant 时必需)")),
)
@@ -45,11 +44,15 @@ func (t *CommitChapterTool) Schema() map[string]any {
)
stateChangeSchema := schema.Object(
schema.Property("entity", schema.String("角色名或实体名")).Required(),
schema.Property("field", schema.String("变化属性realm/location/status/power/relation 等")).Required(),
schema.Property("old_value", schema.String("变化前的值(首次出现可空)")),
schema.Property("field", schema.String("变化属性")).Required(),
schema.Property("old_value", schema.String("变化前的值")),
schema.Property("new_value", schema.String("变化后的值")).Required(),
schema.Property("reason", schema.String("变化原因")),
)
feedbackSchema := schema.Object(
schema.Property("deviation", schema.String("偏离大纲的描述")).Required(),
schema.Property("suggestion", schema.String("对后续大纲的调整建议")).Required(),
)
return schema.Object(
schema.Property("chapter", schema.Int("章节号")).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("foreshadow_updates", schema.Array("伏笔操作", foreshadowSchema)),
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("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"`
HookType string `json:"hook_type"`
DominantStrand string `json:"dominant_strand"`
Feedback *domain.OutlineFeedback `json:"feedback"`
}
if err := json.Unmarshal(args, &a); err != nil {
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
}
// 1. 加载章节正文polished 优先,否则 merge scenes
// 1. 加载章节正文
content, wordCount, err := t.store.LoadChapterContent(a.Chapter)
if err != nil {
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 {
completedCount = len(progress.CompletedChapters)
}
// 6b. 长篇模式:弧级边界检测(替代固定间隔评审)+ 更新卷弧位置
// 6b. 长篇模式:弧级边界检测
var arcEnd, volumeEnd bool
var vol, arc int
if progress != nil && progress.Layered {
@@ -169,7 +175,6 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
volumeEnd = boundary.IsVolumeEnd
vol = boundary.Volume
arc = boundary.Arc
// 每次提交时更新卷弧位置,确保 novel_context 的 position 始终正确
_ = t.store.UpdateVolumeArc(vol, arc)
}
}
@@ -182,35 +187,29 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
reviewRequired, reviewReason = domain.ShouldReview(completedCount)
}
// 7. 计算场景数
sceneCount := 0
if scenes, err := t.store.LoadSceneDrafts(a.Chapter); err == nil {
sceneCount = len(scenes)
}
// 8. 构造结构化信号
// 7. 构造结构化信号
result := domain.CommitResult{
Chapter: a.Chapter,
Committed: true,
WordCount: wordCount,
SceneCount: sceneCount,
NextChapter: a.Chapter + 1,
ReviewRequired: reviewRequired,
ReviewReason: reviewReason,
HookType: a.HookType,
DominantStrand: a.DominantStrand,
Feedback: a.Feedback,
ArcEnd: arcEnd,
VolumeEnd: volumeEnd,
Volume: vol,
Arc: arc,
}
// 9. 写入信号文件供宿主程序读取(优先于清理操作,确保信号不丢失)
// 8. 写入信号文件
if err := t.store.SaveLastCommit(result); err != nil {
return nil, fmt.Errorf("save commit signal: %w", err)
}
// 10. 清除场景级进度(章节已提交)
// 9. 清除进度中间状态
if err := t.store.ClearInProgress(); err != nil {
return nil, fmt.Errorf("clear in-progress: %w", err)
}

View File

@@ -25,8 +25,8 @@ func TestCommitChapterRejectsNonPendingRewrite(t *testing.T) {
if err := store.SetFlow(domain.FlowRewriting); err != nil {
t.Fatalf("SetFlow: %v", err)
}
if err := store.SavePolished(3, "这是错误章节的正文。"); err != nil {
t.Fatalf("SavePolished: %v", err)
if err := store.SaveDraft(3, "这是错误章节的正文。"); err != nil {
t.Fatalf("SaveDraft: %v", err)
}
tool := NewCommitChapterTool(store)
@@ -76,8 +76,8 @@ func TestCommitChapterAllowsPendingRewrite(t *testing.T) {
if err := store.SetFlow(domain.FlowRewriting); err != nil {
t.Fatalf("SetFlow: %v", err)
}
if err := store.SavePolished(2, "这是正确待重写章节的正文。"); err != nil {
t.Fatalf("SavePolished: %v", err)
if err := store.SaveDraft(2, "这是正确待重写章节的正文。"); err != nil {
t.Fatalf("SaveDraft: %v", err)
}
tool := NewCommitChapterTool(store)

80
tools/draft_chapter.go Normal file
View 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),
})
}
}

View File

@@ -211,11 +211,10 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw
result["position"] = pos
}
// 加载场景级恢复状态 + 节奏追踪
// 加载进度状态和节奏追踪
if progress != nil {
checkpoint := map[string]any{
"in_progress_chapter": progress.InProgressChapter,
"completed_scenes": progress.CompletedScenes,
}
if len(progress.StrandHistory) > 0 {
checkpoint["strand_history"] = progress.StrandHistory
@@ -225,13 +224,43 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw
}
result["checkpoint"] = checkpoint
}
// 加载已有的章节规划(支持场景恢复跳过已完成场景)
// 加载已有的章节构思
if plan, err := t.store.LoadChapterPlan(a.Chapter); err == nil && plan != nil {
result["chapter_plan"] = plan
} else {
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)
} else {

View File

@@ -10,7 +10,7 @@ import (
"github.com/voocel/ainovel-cli/state"
)
// PlanChapterTool 生成章节规划
// PlanChapterTool 保存章节构思Agent 自主决定规划粒度
type PlanChapterTool struct {
store *state.Store
}
@@ -21,25 +21,19 @@ func NewPlanChapterTool(store *state.Store) *PlanChapterTool {
func (t *PlanChapterTool) Name() string { return "plan_chapter" }
func (t *PlanChapterTool) Description() string {
return "创建章节写作规划,包括目标、冲突、场景拆分和钩子设计。必须在 write_scene 之前调用"
return "保存章节写作构思。Agent 自主决定规划粒度,不强制场景拆分"
}
func (t *PlanChapterTool) Label() string { return "规划章节" }
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(
schema.Property("chapter", schema.Int("章节号")).Required(),
schema.Property("title", schema.String("章节标题")).Required(),
schema.Property("goal", schema.String("本章目标")).Required(),
schema.Property("conflict", schema.String("核心冲突")).Required(),
schema.Property("scenes", schema.Array("场景列表", sceneSchema)).Required(),
schema.Property("hook", schema.String("章末钩子")).Required(),
schema.Property("emotion_arc", schema.String("情绪曲线")),
schema.Property("notes", schema.String("自由备忘(任何你觉得写作时需要记住的东西)")),
)
}
@@ -51,17 +45,13 @@ func (t *PlanChapterTool) Execute(_ context.Context, args json.RawMessage) (json
if plan.Chapter <= 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 {
return nil, fmt.Errorf("save chapter plan: %w", err)
}
return json.Marshal(map[string]any{
"planned": true,
"chapter": plan.Chapter,
"scene_count": len(plan.Scenes),
"planned": true,
"chapter": plan.Chapter,
})
}

View File

@@ -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
View 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
View 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")
}
}

View File

@@ -21,27 +21,33 @@ func NewSaveFoundationTool(store *state.Store) *SaveFoundationTool {
func (t *SaveFoundationTool) Name() string { return "save_foundation" }
func (t *SaveFoundationTool) Description() string {
return "保存小说基础设定。type=premise 时 content 为 Markdowntype=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) Schema() map[string]any {
return schema.Object(
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")),
)
}
func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
var a struct {
Type string `json:"type"`
Content string `json:"content"`
Scale string `json:"scale"`
Type string `json:"type"`
Content json.RawMessage `json:"content"`
Scale string `json:"scale"`
}
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("invalid args: %w", err)
}
content, err := normalizeFoundationContent(a.Content)
if err != nil {
return nil, err
}
if a.Scale != "" {
switch domain.PlanningTier(a.Scale) {
case domain.PlanningTierShort, domain.PlanningTierMid, domain.PlanningTierLong:
@@ -55,7 +61,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
switch a.Type {
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)
}
_ = t.store.UpdatePhase(domain.PhasePremise)
@@ -63,7 +69,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
case "outline":
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)
}
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":
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)
}
if err := t.store.SaveLayeredOutline(volumes); err != nil {
@@ -107,7 +113,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
case "characters":
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)
}
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":
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)
}
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)
}
}
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
}

View File

@@ -111,3 +111,41 @@ func TestSaveFoundationOutlineClearsLayeredStateWhenDowngrading(t *testing.T) {
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)
}
}

View File

@@ -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,
})
}