Files
ainovel-clients/tools/novel_context.go

640 lines
19 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package tools
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"github.com/voocel/agentcore/schema"
"github.com/voocel/ainovel-cli/domain"
"github.com/voocel/ainovel-cli/state"
)
// References 嵌入的参考资料。
type References struct {
// V0
ChapterGuide string
HookTechniques string
QualityChecklist string
OutlineTemplate string
CharacterTemplate string
ChapterTemplate string
// V1
Consistency string
ContentExpansion string
DialogueWriting string
// V2
StyleReference string // 风格补充参考(可为空)
LongformPlanning string // 通用长篇规划参考
Differentiation string // 通用差异化设计参考
}
// ContextTool 组装当前章节所需上下文。
type ContextTool struct {
store *state.Store
refs References
style string
}
func NewContextTool(store *state.Store, refs References, style string) *ContextTool {
return &ContextTool{store: store, refs: refs, style: style}
}
func (t *ContextTool) Name() string { return "novel_context" }
func (t *ContextTool) Description() string {
return "获取小说创作上下文,包括基础设定、状态数据、前情摘要和写作参考资料"
}
func (t *ContextTool) Label() string { return "加载上下文" }
func (t *ContextTool) Schema() map[string]any {
return schema.Object(
schema.Property("chapter", schema.Int("章节号。不传则返回基础设定和模板(供 Architect 使用)")),
)
}
func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
var a struct {
Chapter int `json:"chapter"`
}
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("invalid args: %w", err)
}
result := make(map[string]any)
var warnings []string
seenWarnings := make(map[string]struct{})
warn := func(scope string, err error) {
if err == nil || os.IsNotExist(err) {
return
}
msg := fmt.Sprintf("%s 读取失败: %v", scope, err)
if _, ok := seenWarnings[msg]; ok {
return
}
seenWarnings[msg] = struct{}{}
warnings = append(warnings, msg)
}
// 加载基础设定
if premise, err := t.store.LoadPremise(); err == nil && premise != "" {
result["premise"] = premise
} else {
warn("premise", err)
}
if outline, err := t.store.LoadOutline(); err == nil && outline != nil {
result["outline"] = outline
} else {
warn("outline", err)
}
if rules, err := t.store.LoadWorldRules(); err == nil && len(rules) > 0 {
result["world_rules"] = rules
} else {
warn("world_rules", err)
}
if a.Chapter > 0 {
// 根据总章节数计算上下文策略
profile := domain.NewContextProfile(0)
progress, err := t.store.LoadProgress()
warn("progress", err)
runMeta, err := t.store.LoadRunMeta()
warn("run_meta", err)
if runMeta != nil && runMeta.PlanningTier != "" {
result["planning_tier"] = runMeta.PlanningTier
}
if progress != nil && progress.TotalChapters > 0 {
profile = domain.NewContextProfile(progress.TotalChapters)
}
// Layered 以 Progress 的显式标志为准,而非章节数推断
if progress == nil || !progress.Layered {
profile.Layered = false
}
// 角色加载Layered 模式优先用快照,回退到原始设定
if profile.Layered {
t.loadLayeredCharacters(result, a.Chapter, warn)
} else {
t.loadFilteredCharacters(result, a.Chapter, warn)
}
// Writer/Editor 模式:加载章节相关上下文
if entry, err := t.store.GetChapterOutline(a.Chapter); err == nil {
result["current_chapter_outline"] = entry
} else {
warn("current_chapter_outline", err)
}
// 摘要加载:分层 vs 扁平窗口
if profile.Layered {
t.loadLayeredSummaries(result, a.Chapter, profile.SummaryWindow, warn)
} else {
if summaries, err := t.store.LoadRecentSummaries(a.Chapter, profile.SummaryWindow); err == nil && len(summaries) > 0 {
result["recent_summaries"] = summaries
} else {
warn("recent_summaries", err)
}
}
// 时间线:窗口加载
if timeline, err := t.store.LoadRecentTimeline(a.Chapter, profile.TimelineWindow); err == nil && len(timeline) > 0 {
result["timeline"] = timeline
} else {
warn("timeline", err)
}
// foreshadow只取未回收条目
if foreshadow, err := t.store.LoadActiveForeshadow(); err == nil && len(foreshadow) > 0 {
result["foreshadow_ledger"] = foreshadow
} else {
warn("foreshadow_ledger", err)
}
// relationships保持全量pair-key 去重,数据量天然可控)
if relationships, err := t.store.LoadRelationships(); err == nil && len(relationships) > 0 {
result["relationship_state"] = relationships
} else {
warn("relationship_state", err)
}
// 状态变化:最近 2 章
if changes, err := t.store.LoadRecentStateChanges(a.Chapter, 2); err == nil && len(changes) > 0 {
result["recent_state_changes"] = changes
} else {
warn("recent_state_changes", err)
}
// Layered 模式:注入当前卷弧位置 + 弧目标/卷主题
if profile.Layered && progress != nil {
pos := map[string]any{
"volume": progress.CurrentVolume,
"arc": progress.CurrentArc,
}
if volumes, err := t.store.LoadLayeredOutline(); err == nil {
for _, v := range volumes {
if v.Index == progress.CurrentVolume {
pos["volume_title"] = v.Title
pos["volume_theme"] = v.Theme
for _, arc := range v.Arcs {
if arc.Index == progress.CurrentArc {
pos["arc_title"] = arc.Title
pos["arc_goal"] = arc.Goal
break
}
}
break
}
}
} else {
warn("layered_outline", err)
}
result["position"] = pos
}
// 加载进度状态和节奏追踪
if progress != nil {
checkpoint := map[string]any{
"in_progress_chapter": progress.InProgressChapter,
}
if len(progress.StrandHistory) > 0 {
checkpoint["strand_history"] = progress.StrandHistory
}
if len(progress.HookHistory) > 0 {
checkpoint["hook_history"] = progress.HookHistory
}
result["checkpoint"] = checkpoint
}
// 加载已有的章节构思
if plan, err := t.store.LoadChapterPlan(a.Chapter); err == nil && plan != nil {
result["chapter_plan"] = plan
} else {
warn("chapter_plan", err)
}
// 前章尾部:嵌入前一章末尾 ~800 字Writer 无需额外调用 read_chapter 获取衔接上文
if a.Chapter > 1 {
if prevText, err := t.store.LoadChapterText(a.Chapter - 1); err == nil && prevText != "" {
runes := []rune(prevText)
if len(runes) > 800 {
runes = runes[len(runes)-800:]
}
result["previous_tail"] = string(runes)
}
}
// 风格锚点:从前文提取代表性段落
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 {
runMeta, err := t.store.LoadRunMeta()
warn("run_meta", err)
if runMeta != nil && runMeta.PlanningTier != "" {
result["planning_tier"] = runMeta.PlanningTier
}
// Architect 模式:全量角色 + 模板
if chars, err := t.store.LoadCharacters(); err == nil && chars != nil {
result["characters"] = chars
} else {
warn("characters", err)
}
// Architect 模式下也加载分层大纲(弧级规划需要看全貌)
if layered, err := t.store.LoadLayeredOutline(); err == nil && len(layered) > 0 {
result["layered_outline"] = layered
} else {
warn("layered_outline", err)
}
// 加载已有的弧摘要(弧级规划时需要参考前续弧的内容)
if volSummaries, err := t.store.LoadAllVolumeSummaries(); err == nil && len(volSummaries) > 0 {
result["volume_summaries"] = volSummaries
} else {
warn("volume_summaries", err)
}
result["references"] = t.architectReferences()
// 基础设定完备性检查
result["foundation_status"] = t.foundationStatus()
}
if len(warnings) > 0 {
result["_warnings"] = warnings
}
// 优先级预算:总大小超过阈值时自动裁剪低优先级数据
if a.Chapter > 0 {
trimByBudget(result, 100*1024) // 100KB 预算
}
result["_loading_summary"] = buildLoadingSummary(result, a.Chapter)
return json.Marshal(result)
}
// buildLoadingSummary 从已组装的 result 中统计各项数据量,生成一行可读摘要。
func buildLoadingSummary(result map[string]any, chapter int) string {
var parts []string
if chapter > 0 {
parts = append(parts, fmt.Sprintf("ch=%d", chapter))
} else {
parts = append(parts, "architect")
}
if tier, ok := result["planning_tier"].(domain.PlanningTier); ok && tier != "" {
parts = append(parts, fmt.Sprintf("tier=%s", tier))
}
// 卷弧位置
if pos, ok := result["position"].(map[string]any); ok {
parts = append(parts, fmt.Sprintf("V%dA%d", pos["volume"], pos["arc"]))
}
var items []string
countSlice := func(key string) int {
if v, ok := result[key]; ok {
if s, ok := v.([]domain.Character); ok {
return len(s)
}
// 通用 slice 反射
return sliceLen(v)
}
return 0
}
// 角色
if n := countSlice("character_snapshots"); n > 0 {
items = append(items, fmt.Sprintf("角色:%d(快照)", n))
} else if n := countSlice("characters"); n > 0 {
items = append(items, fmt.Sprintf("角色:%d", n))
}
// 分层摘要
if n := countSlice("volume_summaries"); n > 0 {
items = append(items, fmt.Sprintf("卷摘要:%d", n))
}
if n := countSlice("arc_summaries"); n > 0 {
items = append(items, fmt.Sprintf("弧摘要:%d", n))
}
if n := countSlice("recent_summaries"); n > 0 {
items = append(items, fmt.Sprintf("章摘要:%d", n))
}
// 分层大纲
if n := countSlice("layered_outline"); n > 0 {
items = append(items, fmt.Sprintf("分层大纲:%d卷", n))
}
// 状态数据
if n := countSlice("timeline"); n > 0 {
items = append(items, fmt.Sprintf("时间线:%d", n))
}
if n := countSlice("foreshadow_ledger"); n > 0 {
items = append(items, fmt.Sprintf("伏笔:%d", n))
}
if n := countSlice("relationship_state"); n > 0 {
items = append(items, fmt.Sprintf("关系:%d", n))
}
if n := countSlice("recent_state_changes"); n > 0 {
items = append(items, fmt.Sprintf("状态变化:%d", n))
}
if _, ok := result["previous_tail"]; ok {
items = append(items, "前章尾部:ok")
}
// 参考资料
if refs, ok := result["references"].(map[string]string); ok && len(refs) > 0 {
items = append(items, fmt.Sprintf("参考:%d项", len(refs)))
}
if warnings, ok := result["_warnings"].([]string); ok && len(warnings) > 0 {
items = append(items, fmt.Sprintf("告警:%d", len(warnings)))
}
if trimmed, ok := result["_trimmed"].([]string); ok && len(trimmed) > 0 {
items = append(items, fmt.Sprintf("裁剪:%s", strings.Join(trimmed, ",")))
}
if len(items) > 0 {
parts = append(parts, strings.Join(items, " "))
}
return strings.Join(parts, " | ")
}
// sliceLen 对 any 类型尝试取 slice 长度。
func sliceLen(v any) int {
switch s := v.(type) {
case []domain.ChapterSummary:
return len(s)
case []domain.ArcSummary:
return len(s)
case []domain.VolumeSummary:
return len(s)
case []domain.CharacterSnapshot:
return len(s)
case []domain.TimelineEvent:
return len(s)
case []domain.ForeshadowEntry:
return len(s)
case []domain.RelationshipEntry:
return len(s)
case []domain.StateChange:
return len(s)
case []domain.VolumeOutline:
return len(s)
case []domain.Character:
return len(s)
default:
return 0
}
}
// loadFilteredCharacters 按 Tier 和场景出场过滤角色。
// core/important 始终返回secondary/decorative 只在当前章节大纲提及时返回。
func (t *ContextTool) loadFilteredCharacters(result map[string]any, chapter int, warn func(string, error)) {
chars, err := t.store.LoadCharacters()
if err != nil {
warn("characters", err)
return
}
if len(chars) == 0 {
return
}
// 获取当前章节大纲的场景描述,用于匹配次要角色
entry, err := t.store.GetChapterOutline(chapter)
if err != nil {
warn("current_chapter_outline", err)
result["characters"] = chars
return
}
sceneText := strings.Join(entry.Scenes, " ") + " " + entry.CoreEvent + " " + entry.Title
var filtered []domain.Character
for _, c := range chars {
switch c.Tier {
case "secondary", "decorative":
if matchCharacter(sceneText, c) {
filtered = append(filtered, c)
}
default: // core, important, 或未设置
filtered = append(filtered, c)
}
}
result["characters"] = filtered
}
// matchCharacter 检查场景文本中是否包含角色的正式名或任一别名。
func matchCharacter(text string, c domain.Character) bool {
if strings.Contains(text, c.Name) {
return true
}
for _, alias := range c.Aliases {
if strings.Contains(text, alias) {
return true
}
}
return false
}
// loadLayeredSummaries 分层摘要加载:卷摘要 + 当前卷弧摘要 + 弧内章摘要。
func (t *ContextTool) loadLayeredSummaries(result map[string]any, chapter, summaryWindow int, warn func(string, error)) {
vol, arc, err := t.store.LocateChapter(chapter)
if err != nil {
warn("layered_outline_position", err)
// 回退到扁平模式
if summaries, err := t.store.LoadRecentSummaries(chapter, summaryWindow); err == nil && len(summaries) > 0 {
result["recent_summaries"] = summaries
} else {
warn("recent_summaries", err)
}
return
}
// 1. 已完成卷的卷摘要
if volSummaries, err := t.store.LoadAllVolumeSummaries(); err == nil && len(volSummaries) > 0 {
result["volume_summaries"] = volSummaries
} else {
warn("volume_summaries", err)
}
// 2. 当前卷内已完成弧的弧摘要(不含当前弧)
if arcSummaries, err := t.store.LoadArcSummaries(vol); err == nil && len(arcSummaries) > 0 {
var prior []domain.ArcSummary
for _, s := range arcSummaries {
if s.Arc < arc {
prior = append(prior, s)
}
}
if len(prior) > 0 {
result["arc_summaries"] = prior
}
} else {
warn("arc_summaries", err)
}
// 3. 当前弧内最近 N 章的章摘要
if summaries, err := t.store.LoadRecentSummaries(chapter, summaryWindow); err == nil && len(summaries) > 0 {
result["recent_summaries"] = summaries
} else {
warn("recent_summaries", err)
}
}
// loadLayeredCharacters Layered 模式下的角色加载:优先用最近快照,回退到原始设定 + Tier 过滤。
func (t *ContextTool) loadLayeredCharacters(result map[string]any, chapter int, warn func(string, error)) {
snapshots, err := t.store.LoadLatestSnapshots()
if err == nil && len(snapshots) > 0 {
result["character_snapshots"] = snapshots
// 同时保留原始设定中的 core/important 角色(快照可能不含新登场角色)
t.loadFilteredCharacters(result, chapter, warn)
return
}
warn("character_snapshots", err)
// 无快照时回退到原始设定
t.loadFilteredCharacters(result, chapter, warn)
}
// writerReferences 返回写作参考资料。章节 1 返回全量,后续章节裁剪掉不再需要的模板。
func (t *ContextTool) writerReferences(chapter int) map[string]string {
refs := map[string]string{}
add := func(k, v string) {
if v != "" {
refs[k] = v
}
}
// 渐进式加载:始终保留核心参考,前 3 章额外加载完整写作指南
add("consistency", t.refs.Consistency)
add("hook_techniques", t.refs.HookTechniques)
add("quality_checklist", t.refs.QualityChecklist)
if chapter <= 3 {
add("chapter_guide", t.refs.ChapterGuide)
add("dialogue_writing", t.refs.DialogueWriting)
add("style_reference", t.refs.StyleReference)
}
// 仅首章加载的补充参考
if chapter <= 1 {
add("chapter_template", t.refs.ChapterTemplate)
add("content_expansion", t.refs.ContentExpansion)
}
return refs
}
func (t *ContextTool) architectReferences() map[string]string {
refs := map[string]string{}
add := func(k, v string) {
if v != "" {
refs[k] = v
}
}
add("outline_template", t.refs.OutlineTemplate)
add("character_template", t.refs.CharacterTemplate)
add("longform_planning", t.refs.LongformPlanning)
add("differentiation", t.refs.Differentiation)
add("style_reference", t.refs.StyleReference)
return refs
}
// foundationStatus 检查基础设定的完备性,返回缺失项列表。
func (t *ContextTool) foundationStatus() map[string]any {
status := map[string]any{"ready": true}
var missing []string
if p, _ := t.store.LoadPremise(); p == "" {
missing = append(missing, "premise")
}
if o, _ := t.store.LoadOutline(); len(o) == 0 {
missing = append(missing, "outline")
}
if c, _ := t.store.LoadCharacters(); len(c) == 0 {
missing = append(missing, "characters")
}
if r, _ := t.store.LoadWorldRules(); len(r) == 0 {
missing = append(missing, "world_rules")
}
if len(missing) > 0 {
status["ready"] = false
status["missing"] = missing
}
return status
}
// ContextSummary 返回当前状态的简要摘要(供日志使用)。
func (t *ContextTool) ContextSummary() string {
var parts []string
if p, _ := t.store.LoadPremise(); p != "" {
parts = append(parts, "premise:ok")
}
if o, _ := t.store.LoadOutline(); o != nil {
parts = append(parts, fmt.Sprintf("outline:%d chapters", len(o)))
}
if c, _ := t.store.LoadCharacters(); c != nil {
parts = append(parts, fmt.Sprintf("characters:%d", len(c)))
}
if len(parts) == 0 {
return "empty"
}
return strings.Join(parts, ", ")
}
// trimByBudget 按优先级裁剪 result使 JSON 总大小不超过 budget 字节。
// 优先级从低到高references < voice_samples < style_anchors < previous_tail < timeline
// < recent_state_changes < foreshadow_ledger < relationship_state < 其余(不裁剪)
// 裁剪的 key 会记录到 result["_trimmed"] 供日志排查。
func trimByBudget(result map[string]any, budget int) {
// 先测量当前大小
data, err := json.Marshal(result)
if err != nil || len(data) <= budget {
return
}
// 按优先级从低到高列出可裁剪的 key
trimOrder := []string{
"references",
"voice_samples",
"style_anchors",
"previous_tail",
"timeline",
"recent_state_changes",
"foreshadow_ledger",
"relationship_state",
}
var trimmed []string
for _, key := range trimOrder {
if _, ok := result[key]; !ok {
continue
}
delete(result, key)
trimmed = append(trimmed, key)
data, err = json.Marshal(result)
if err != nil || len(data) <= budget {
break
}
}
if len(trimmed) > 0 {
result["_trimmed"] = trimmed
}
}