Add artifact-aware analyze resume planning
This commit is contained in:
94
internal/stage/analyze_resume.go
Normal file
94
internal/stage/analyze_resume.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package stage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
func (analyzeStage) ValidateResume(_ context.Context, env *Env, m *manifest.Manifest) (ResumeValidation, error) {
|
||||
if env == nil || env.Config == nil || env.Config.Pipeline == nil || env.Config.Session == nil {
|
||||
return ResumeValidation{}, fmt.Errorf("analyze resume: resolved stage environment config is required")
|
||||
}
|
||||
cfg := env.Config.Pipeline.Scriptorium
|
||||
if cfg == nil || len(cfg.Artifacts) == 0 {
|
||||
return ResumeValidation{Resumable: true, Analyze: &AnalyzeResumeSummary{}}, nil
|
||||
}
|
||||
sessionID := ""
|
||||
if m != nil {
|
||||
sessionID = strings.TrimSpace(m.SessionID)
|
||||
}
|
||||
if sessionID == "" {
|
||||
sessionID = strings.TrimSpace(env.Config.Session.SessionID)
|
||||
}
|
||||
if sessionID == "" {
|
||||
return ResumeValidation{}, fmt.Errorf("analyze resume: session id is required")
|
||||
}
|
||||
effective := env.EffectiveArtifacts
|
||||
var err error
|
||||
if !effective.Resolved() {
|
||||
effective, err = artifacts.ResolveEffectiveArtifactSet(
|
||||
artifacts.ConfiguredArtifactDefinitions(cfg.Artifacts),
|
||||
env.SelectedArtifactKeys,
|
||||
)
|
||||
if err != nil {
|
||||
return ResumeValidation{}, fmt.Errorf("analyze resume: resolve effective artifacts: %w", err)
|
||||
}
|
||||
}
|
||||
if len(effective.Keys()) == 0 {
|
||||
return ResumeValidation{Resumable: true, Analyze: &AnalyzeResumeSummary{}}, nil
|
||||
}
|
||||
paths := sessionPathsForEnv(env, sessionID)
|
||||
catalog, err := buildAnalyzeRuntimeArtifactCatalog(
|
||||
paths, m, cfg, env.Config.Pipeline.Notarius, effective,
|
||||
)
|
||||
if err != nil {
|
||||
return ResumeValidation{}, fmt.Errorf("analyze resume: build runtime artifact catalog: %w", err)
|
||||
}
|
||||
execution := analyzeExecutionContext{
|
||||
Env: env, Manifest: m, Paths: paths, SessionID: sessionID,
|
||||
TranscriptRefs: discoverAnalyzeTranscriptRefs(m, paths), Catalog: catalog,
|
||||
}
|
||||
reconciliation, err := reconcileAnalyzeArtifacts(cfg, execution)
|
||||
if err != nil {
|
||||
return ResumeValidation{}, fmt.Errorf("analyze resume: reconcile configured artifacts: %w", err)
|
||||
}
|
||||
plan, err := planAnalyzeWork(cfg, env.SelectedArtifactKeys, env.Force, reconciliation)
|
||||
if err != nil {
|
||||
return ResumeValidation{}, fmt.Errorf("analyze resume: plan configured artifacts: %w", err)
|
||||
}
|
||||
summary := analyzeResumeSummary(plan)
|
||||
if len(plan.ExecutionOrder) == 0 {
|
||||
return ResumeValidation{Resumable: true, Analyze: summary}, nil
|
||||
}
|
||||
keys := analyzePlanKeysForMetadata(plan.ExecutionOrder)
|
||||
return ResumeValidation{
|
||||
Reason: "analysis artifacts require execution: " + strings.Join(keys, ", "),
|
||||
Analyze: summary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func analyzeResumeSummary(plan analyzeWorkPlan) *AnalyzeResumeSummary {
|
||||
return &AnalyzeResumeSummary{
|
||||
ExplicitTargets: append([]string(nil), plan.ExplicitTargets...),
|
||||
PrerequisiteWork: exportAnalyzeResumeItems(plan.PrerequisiteWork),
|
||||
ExecutionOrder: exportAnalyzeResumeItems(plan.ExecutionOrder),
|
||||
ReusedCurrent: exportAnalyzeResumeItems(plan.ReusedCurrent),
|
||||
}
|
||||
}
|
||||
|
||||
func exportAnalyzeResumeItems(items []analyzePlanItem) []AnalyzeResumeArtifact {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]AnalyzeResumeArtifact, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, AnalyzeResumeArtifact{
|
||||
Key: item.Key, Role: string(item.Role), Reason: string(item.Reason), Forced: item.Forced,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
160
internal/stage/analyze_resume_test.go
Normal file
160
internal/stage/analyze_resume_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package stage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
func TestAnalyzeResumeValidationHonorsFullAndPartialSelections(t *testing.T) {
|
||||
env, m, _ := currentAnalyzeFixture(t, true)
|
||||
record := m.Stages["analyze"].AnalyzeArtifacts["player_handout"]
|
||||
record.Status = manifest.AnalyzeArtifactStale
|
||||
record.Output = nil
|
||||
record.OutputSize = 0
|
||||
m.Stages["analyze"].AnalyzeArtifacts["player_handout"] = record
|
||||
|
||||
env.SelectedArtifactKeys = []string{"session_recap"}
|
||||
partial, err := (analyzeStage{}).ValidateResume(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !partial.Resumable || !reflect.DeepEqual(partial.Analyze.ExplicitTargets, []string{"session_recap"}) {
|
||||
t.Fatalf("partial validation = %#v, want resumable recap selection", partial)
|
||||
}
|
||||
|
||||
env.SelectedArtifactKeys = nil
|
||||
full, err := (analyzeStage{}).ValidateResume(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if full.Resumable || !resumeArtifactKeysEqual(full.Analyze.ExecutionOrder, []string{"player_handout"}) {
|
||||
t.Fatalf("full validation = %#v, want stale handout execution", full)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeResumeValidationReportsForceAndCurrentPrerequisites(t *testing.T) {
|
||||
env, m, _ := currentAnalyzeFixture(t, true)
|
||||
env.SelectedArtifactKeys = []string{"session_recap"}
|
||||
env.Force = true
|
||||
|
||||
forced, err := (analyzeStage{}).ValidateResume(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if forced.Resumable || len(forced.Analyze.ExecutionOrder) != 1 ||
|
||||
forced.Analyze.ExecutionOrder[0].Key != "session_recap" || !forced.Analyze.ExecutionOrder[0].Forced {
|
||||
t.Fatalf("forced validation = %#v, want only forced recap", forced)
|
||||
}
|
||||
|
||||
env.Force = false
|
||||
env.SelectedArtifactKeys = []string{"player_handout"}
|
||||
dependent := env.Config.Pipeline.Scriptorium.Artifacts["player_handout"]
|
||||
dependent.PromptID = "dnd.player_handout.revised"
|
||||
env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = dependent
|
||||
changed, err := (analyzeStage{}).ValidateResume(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changed.Resumable || !resumeArtifactKeysEqual(changed.Analyze.ExecutionOrder, []string{"player_handout"}) {
|
||||
t.Fatalf("changed dependent validation = %#v", changed)
|
||||
}
|
||||
if len(changed.Analyze.ReusedCurrent) != 1 || changed.Analyze.ReusedCurrent[0].Key != "session_recap" ||
|
||||
changed.Analyze.ReusedCurrent[0].Role != "prerequisite" {
|
||||
t.Fatalf("reused current = %#v, want recap prerequisite", changed.Analyze.ReusedCurrent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeResumeValidationRejectsChangedInputsTamperedOutputsAndLegacyState(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(t *testing.T, env *Env, m *manifest.Manifest)
|
||||
}{
|
||||
{
|
||||
name: "changed input",
|
||||
mutate: func(t *testing.T, env *Env, m *manifest.Manifest) {
|
||||
paths := sessionPathsForEnv(env, m.SessionID)
|
||||
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[{"text":"changed"}]}`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tampered output",
|
||||
mutate: func(t *testing.T, env *Env, m *manifest.Manifest) {
|
||||
paths := sessionPathsForEnv(env, m.SessionID)
|
||||
writeAnalyzeFile(t, filepath.Join(paths.ArtifactsDir, "session_recap.md"), "tampered\n")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "legacy state",
|
||||
mutate: func(_ *testing.T, _ *Env, m *manifest.Manifest) {
|
||||
m.Stages["analyze"].AnalyzeStateVersion = 0
|
||||
m.Stages["analyze"].AnalyzeArtifacts = nil
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
env, m, _ := currentAnalyzeFixture(t, false)
|
||||
test.mutate(t, env, m)
|
||||
validation, err := (analyzeStage{}).ValidateResume(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if validation.Resumable || !resumeArtifactKeysEqual(validation.Analyze.ExecutionOrder, []string{"session_recap"}) {
|
||||
t.Fatalf("validation = %#v, want recap execution", validation)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeStaleAggregateRestoresSuccessWithoutAdapterWork(t *testing.T) {
|
||||
env, m, _ := currentAnalyzeFixture(t, false)
|
||||
m.Stages["analyze"].Status = manifest.StatusStale
|
||||
|
||||
validation, err := (analyzeStage{}).ValidateResume(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !validation.Resumable || !resumeArtifactKeysEqual(validation.Analyze.ReusedCurrent, []string{"session_recap"}) {
|
||||
t.Fatalf("validation = %#v, want current recap reuse", validation)
|
||||
}
|
||||
|
||||
env.Scriptorium = nil
|
||||
result, err := (analyzeStage{}).Run(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() with no adapter = %v", err)
|
||||
}
|
||||
if got, _ := result.Metadata["executed_artifacts"].([]string); len(got) != 0 {
|
||||
t.Fatalf("executed artifacts = %#v, want none", got)
|
||||
}
|
||||
if result.AnalyzeState.Session["session_recap"].Status != manifest.AnalyzeArtifactCurrent {
|
||||
t.Fatalf("session projection = %#v, want current recap", result.AnalyzeState.Session)
|
||||
}
|
||||
}
|
||||
|
||||
func currentAnalyzeFixture(t *testing.T, dependent bool) (*Env, *manifest.Manifest, int) {
|
||||
t.Helper()
|
||||
env, m, fake := setupAnalyzeEnv(t)
|
||||
paths := sessionPathsForEnv(env, m.SessionID)
|
||||
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
|
||||
if dependent {
|
||||
addAnalyzeDependentArtifact(env)
|
||||
}
|
||||
result, err := (analyzeStage{}).Run(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installAnalyzeProjection(m, result.AnalyzeState)
|
||||
m.Stages["analyze"].Status = manifest.StatusSucceeded
|
||||
return env, m, len(fake.RunRequests)
|
||||
}
|
||||
|
||||
func resumeArtifactKeysEqual(items []AnalyzeResumeArtifact, want []string) bool {
|
||||
got := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
got = append(got, item.Key)
|
||||
}
|
||||
return reflect.DeepEqual(got, want)
|
||||
}
|
||||
@@ -53,14 +53,35 @@ const maxResumeReasonLength = 512
|
||||
type ResumeValidation struct {
|
||||
Resumable bool
|
||||
Reason string
|
||||
Analyze *AnalyzeResumeSummary
|
||||
}
|
||||
|
||||
// AnalyzeResumeSummary describes the artifact-level decision behind an
|
||||
// aggregate analyze resume result.
|
||||
type AnalyzeResumeSummary struct {
|
||||
ExplicitTargets []string
|
||||
PrerequisiteWork []AnalyzeResumeArtifact
|
||||
ExecutionOrder []AnalyzeResumeArtifact
|
||||
ReusedCurrent []AnalyzeResumeArtifact
|
||||
}
|
||||
|
||||
// AnalyzeResumeArtifact is one deterministic artifact-level plan entry.
|
||||
type AnalyzeResumeArtifact struct {
|
||||
Key string
|
||||
Role string
|
||||
Reason string
|
||||
Forced bool
|
||||
}
|
||||
|
||||
// Normalized returns a result with a bounded reason and no reason on success.
|
||||
func (r ResumeValidation) Normalized() ResumeValidation {
|
||||
if r.Resumable {
|
||||
return Resumable()
|
||||
r.Reason = ""
|
||||
return r
|
||||
}
|
||||
return NonResumable(r.Reason)
|
||||
normalized := NonResumable(r.Reason)
|
||||
normalized.Analyze = r.Analyze
|
||||
return normalized
|
||||
}
|
||||
|
||||
// ResumeValidator is implemented by stages that validate persisted success before reuse.
|
||||
|
||||
Reference in New Issue
Block a user