Protect transcript refinement resume semantics

This commit is contained in:
2026-08-30 13:03:13 +00:00
parent 4d6086fefb
commit 7ee637803d
12 changed files with 512 additions and 16 deletions

View File

@@ -130,7 +130,7 @@ func TestRunArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
seed.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
}
seed.MarkStageSkipped("extract", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), "notarius_disabled")
seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), seed, "prepare", "transcribe", "merge")
seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), seed, "prepare", "transcribe", "merge", "polish", "normalize", "trim", "render")
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
t.Fatalf("save manifest: %v", err)
}

View File

@@ -61,7 +61,7 @@ func TestRunNoRemainingStagesRecordsSkippedStages(t *testing.T) {
m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
}
m.MarkStageSkipped("extract", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), "notarius_disabled")
seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), m, "prepare", "transcribe", "merge")
seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), m, "prepare", "transcribe", "merge", "polish", "normalize", "trim", "render")
if err := store.Save(context.Background(), manifestPath, m); err != nil {
t.Fatalf("save manifest: %v", err)
}
@@ -174,6 +174,7 @@ func TestRunStageSkipAndForce(t *testing.T) {
store := &manifest.LocalStore{}
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
m.MarkStageSucceeded("polish", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), m, "polish")
if err := store.Save(context.Background(), manifestPath, m); err != nil {
t.Fatalf("save manifest: %v", err)
}

View File

@@ -367,6 +367,74 @@ func TestInitialPipelineSemanticChangesRerunOnlyAffectedLineage(t *testing.T) {
}
}
func TestRefinementSemanticChangesRespectDependencyBranches(t *testing.T) {
names := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render"}
for _, test := range []struct {
name string
mutate func(*config.Config)
wantRuns [7]int
wantExtractStatus manifest.StageStatus
}{
{name: "polish model", mutate: func(cfg *config.Config) {
cfg.Pipeline.Audita.Model = "production"
}, wantRuns: [7]int{0, 0, 0, 1, 1, 1, 1}, wantExtractStatus: manifest.StatusStale},
{name: "normalize schema", mutate: func(cfg *config.Config) {
cfg.Pipeline.Normalize = &config.NormalizeConfig{OutputSchema: "seriatim.transcript.v2"}
}, wantRuns: [7]int{0, 0, 0, 0, 1, 1, 1}, wantExtractStatus: manifest.StatusStale},
{name: "trim prompt", mutate: func(cfg *config.Config) {
enabled := true
cfg.Pipeline.Trim = &config.TrimConfig{
Enabled: &enabled,
Bounds: config.TrimBoundsConfig{PromptID: "session-bounds-v2"},
}
}, wantRuns: [7]int{0, 0, 0, 0, 0, 1, 1}, wantExtractStatus: manifest.StatusStale},
{name: "render format", mutate: func(cfg *config.Config) {
cfg.Pipeline.Render = &config.RenderConfig{Format: "html"}
}, wantRuns: [7]int{0, 0, 0, 0, 0, 0, 1}, wantExtractStatus: manifest.StatusSucceeded},
} {
t.Run(test.name, func(t *testing.T) {
cfg := testConfig(t)
providers := make([]stage.SemanticConfigFingerprinter, len(names))
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
for _, candidate := range stage.All() {
seed.MarkStageSucceeded(candidate.Name(), time.Now().UTC(), nil)
}
for index, name := range names {
providers[index] = canonicalSemanticProvider(t, name)
fingerprint, err := providers[index].SemanticConfigFingerprint(&stage.Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
seed.Stages[name].SemanticConfig = &fingerprint
}
store := &manifest.LocalStore{}
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
t.Fatal(err)
}
test.mutate(cfg)
runs := [7]int{}
selected := make([]stage.Stage, 0, len(names))
for index, name := range names {
selected = append(selected, semanticContractRunStub{name: name, provider: providers[index], runs: &runs[index]})
}
if _, err := executeStages(context.Background(), cfg, selected, RunOptions{}); err != nil {
t.Fatal(err)
}
if runs != test.wantRuns {
t.Fatalf("runs = %v, want %v", runs, test.wantRuns)
}
loaded, err := store.Load(context.Background(), manifestPathFor(cfg))
if err != nil {
t.Fatal(err)
}
if got := loaded.Stages["extract"].Status; got != test.wantExtractStatus {
t.Fatalf("extract status = %q, want %q", got, test.wantExtractStatus)
}
})
}
}
func canonicalSemanticProvider(t *testing.T, name string) stage.SemanticConfigFingerprinter {
t.Helper()
for _, candidate := range stage.All() {

View File

@@ -0,0 +1,168 @@
package stage
import (
"fmt"
"sort"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
const (
polishSemanticConfigVersion = 1
normalizeSemanticConfigVersion = 1
trimSemanticConfigVersion = 1
renderSemanticConfigVersion = 1
)
type polishSemanticConfig struct {
ServiceURL string `json:"service_url"`
Model string `json:"model"`
ValidationModel string `json:"validation_model"`
Modules []string `json:"modules"`
TranscriptDescription string `json:"transcript_description"`
OutputSchema string `json:"output_schema"`
ExternalConfig string `json:"external_config"`
OutputIdentity string `json:"output_identity"`
}
type normalizeSemanticConfig struct {
Operation string `json:"operation"`
OutputSchema string `json:"output_schema"`
OutputIdentity string `json:"output_identity"`
CoalesceGap *float64 `json:"coalesce_gap,omitempty"`
OverlapWordRunGap *float64 `json:"overlap_word_run_gap,omitempty"`
OverlapWordRunReorderWindow *float64 `json:"overlap_word_run_reorder_window,omitempty"`
BackchannelMaxDuration *float64 `json:"backchannel_max_duration,omitempty"`
FillerMaxDuration *float64 `json:"filler_max_duration,omitempty"`
}
type trimSemanticConfig struct {
Enabled bool `json:"enabled"`
PromptID string `json:"prompt_id,omitempty"`
ProfileID string `json:"profile_id,omitempty"`
ExternalConfig string `json:"external_config,omitempty"`
TranscriptInputName string `json:"transcript_input_name,omitempty"`
InputIdentity string `json:"input_identity"`
BoundsOutput string `json:"bounds_output,omitempty"`
TrimmedOutput string `json:"trimmed_output"`
StickySessionID string `json:"sticky_session_id,omitempty"`
SeriatimOperation string `json:"seriatim_operation,omitempty"`
}
type renderSemanticConfig struct {
Enabled bool `json:"enabled"`
Format string `json:"format"`
Title string `json:"title"`
IncludeTimestamps bool `json:"include_timestamps"`
IncludeSegmentIDs bool `json:"include_segment_ids"`
IncludeMetadata bool `json:"include_metadata"`
InputIdentities []string `json:"input_identities"`
OutputIdentities []string `json:"output_identities"`
}
func (polishStage) SemanticConfigFingerprint(env *Env) (manifest.SemanticConfigFingerprint, error) {
if env == nil || env.Config == nil || env.Config.Pipeline == nil {
return manifest.SemanticConfigFingerprint{}, fmt.Errorf("polish semantic configuration requires resolved pipeline config")
}
audita := env.Config.Pipeline.Audita
modules := append([]string(nil), audita.Modules...)
for index := range modules {
modules[index] = strings.TrimSpace(modules[index])
}
sort.Strings(modules)
return FingerprintSemanticConfig(polishSemanticConfigVersion, polishSemanticConfig{
ServiceURL: strings.TrimSpace(audita.BaseURL),
Model: strings.TrimSpace(audita.Model),
ValidationModel: strings.TrimSpace(audita.ValidationModel),
Modules: modules,
TranscriptDescription: strings.TrimSpace(audita.TranscriptDescription),
OutputSchema: strings.TrimSpace(audita.OutputSchema),
ExternalConfig: strings.TrimSpace(audita.ConfigPath),
OutputIdentity: artifacts.ArtifactTranscriptPolished,
})
}
func (normalizeStage) SemanticConfigFingerprint(env *Env) (manifest.SemanticConfigFingerprint, error) {
if env == nil || env.Config == nil || env.Config.Pipeline == nil {
return manifest.SemanticConfigFingerprint{}, fmt.Errorf("normalize semantic configuration requires resolved pipeline config")
}
seriatim := env.Config.Pipeline.Seriatim
normalize := normalizeConfigOrDefault(env.Config.Pipeline.Normalize)
return FingerprintSemanticConfig(normalizeSemanticConfigVersion, normalizeSemanticConfig{
Operation: "normalize",
OutputSchema: strings.TrimSpace(normalize.OutputSchema),
OutputIdentity: strings.TrimSpace(normalize.OutputPath),
CoalesceGap: cloneFloat64(seriatim.CoalesceGap),
OverlapWordRunGap: cloneFloat64(seriatim.Env.OverlapWordRunGap),
OverlapWordRunReorderWindow: cloneFloat64(seriatim.Env.OverlapWordRunReorderWindow),
BackchannelMaxDuration: cloneFloat64(seriatim.Env.BackchannelMaxDuration),
FillerMaxDuration: cloneFloat64(seriatim.Env.FillerMaxDuration),
})
}
func (trimStage) SemanticConfigFingerprint(env *Env) (manifest.SemanticConfigFingerprint, error) {
if env == nil || env.Config == nil || env.Config.Pipeline == nil || env.Config.Session == nil {
return manifest.SemanticConfigFingerprint{}, fmt.Errorf("trim semantic configuration requires resolved pipeline and session config")
}
trim := env.Config.Pipeline.Trim
enabled := trim != nil && trim.Enabled != nil && *trim.Enabled
payload := trimSemanticConfig{
Enabled: enabled,
InputIdentity: artifacts.ArtifactTranscriptFinal,
TrimmedOutput: artifacts.ArtifactTranscriptFinalTrimmed,
StickySessionID: strings.TrimSpace(env.Config.Session.SessionID),
}
if trim != nil {
payload.TrimmedOutput = strings.TrimSpace(trim.OutputPath)
if payload.TrimmedOutput == "" {
payload.TrimmedOutput = artifacts.TranscriptPathFinalTrimmed
}
}
if enabled {
bounds := trim.Bounds
payload.PromptID = strings.TrimSpace(bounds.PromptID)
payload.ProfileID = strings.TrimSpace(bounds.ProfileID)
payload.TranscriptInputName = strings.TrimSpace(bounds.TranscriptInputName)
payload.BoundsOutput = strings.TrimSpace(bounds.OutputPath)
payload.SeriatimOperation = "trim"
if env.Config.Pipeline.Scriptorium != nil {
payload.ExternalConfig = strings.TrimSpace(env.Config.Pipeline.Scriptorium.ConfigPath)
}
}
return FingerprintSemanticConfig(trimSemanticConfigVersion, payload)
}
func (renderStage) SemanticConfigFingerprint(env *Env) (manifest.SemanticConfigFingerprint, error) {
if env == nil || env.Config == nil || env.Config.Pipeline == nil || env.Config.Session == nil {
return manifest.SemanticConfigFingerprint{}, fmt.Errorf("render semantic configuration requires resolved pipeline and session config")
}
render := renderConfigOrDefault(env.Config.Pipeline.Render)
enabled := render.Enabled == nil || *render.Enabled
format := strings.TrimSpace(render.Format)
if format == "" {
format = config.DefaultRenderFormat
}
includeTimestamps := render.IncludeTimestamps == nil || *render.IncludeTimestamps
includeSegmentIDs := config.DefaultRenderSegmentIDs
if render.IncludeSegmentIDs != nil {
includeSegmentIDs = *render.IncludeSegmentIDs
}
return FingerprintSemanticConfig(renderSemanticConfigVersion, renderSemanticConfig{
Enabled: enabled,
Format: format,
Title: resolveRenderTitle(render, env.Config.Session),
IncludeTimestamps: includeTimestamps,
IncludeSegmentIDs: includeSegmentIDs,
IncludeMetadata: render.IncludeMetadata,
InputIdentities: []string{artifacts.ArtifactTranscriptFinal, artifacts.ArtifactTranscriptFinalTrimmed},
OutputIdentities: []string{artifacts.TranscriptPathFinalMarkdown, artifacts.TranscriptPathFinalTrimmedMarkdown},
})
}
var _ SemanticConfigFingerprinter = polishStage{}
var _ SemanticConfigFingerprinter = normalizeStage{}
var _ SemanticConfigFingerprinter = trimStage{}
var _ SemanticConfigFingerprinter = renderStage{}

View File

@@ -0,0 +1,196 @@
package stage
import (
"testing"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
func TestPolishSemanticConfigSensitivity(t *testing.T) {
semantic := []struct {
name string
mutate func(*Env)
}{
{name: "model", mutate: func(env *Env) { env.Config.Pipeline.Audita.Model = "production" }},
{name: "validation model", mutate: func(env *Env) { env.Config.Pipeline.Audita.ValidationModel = "validator-v2" }},
{name: "modules", mutate: func(env *Env) { env.Config.Pipeline.Audita.Modules = []string{"terms", "speakers", "style"} }},
{name: "description", mutate: func(env *Env) { env.Config.Pipeline.Audita.TranscriptDescription = "live campaign" }},
{name: "schema", mutate: func(env *Env) { env.Config.Pipeline.Audita.OutputSchema = "audita.transcript.v2" }},
{name: "external config", mutate: func(env *Env) { env.Config.Pipeline.Audita.ConfigPath = "configs/other.yml" }},
{name: "service", mutate: func(env *Env) { env.Config.Pipeline.Audita.BaseURL = "https://other.example" }},
}
assertRefinementChanges(t, polishStage{}, semantic)
assertRefinementUnchanged(t, polishStage{}, []struct {
name string
mutate func(*Env)
}{
{name: "module order", mutate: func(env *Env) { env.Config.Pipeline.Audita.Modules = []string{"terms", "speakers"} }},
{name: "runner settings", mutate: func(env *Env) {
env.Config.Pipeline.Audita.Binary = "/opt/audita"
env.Config.Pipeline.Audita.Timeout = "1h"
env.Config.Pipeline.Audita.TotalLLMConcurrency = intPointer(20)
env.Config.Pipeline.Audita.ProposalLLMConcurrency = intPointer(10)
env.Config.Pipeline.Audita.ValidationLLMConcurrency = intPointer(10)
env.Config.Pipeline.Audita.LLMAPIKeyEnv = "OTHER_KEY"
env.Config.Pipeline.Audita.WorkDirRetention = "always"
env.Config.Pipeline.Audita.Report = boolPointer(false)
}},
})
}
func TestNormalizeSemanticConfigSensitivity(t *testing.T) {
assertRefinementChanges(t, normalizeStage{}, []struct {
name string
mutate func(*Env)
}{
{name: "schema", mutate: func(env *Env) { env.Config.Pipeline.Normalize.OutputSchema = "seriatim.transcript.v2" }},
{name: "output", mutate: func(env *Env) { env.Config.Pipeline.Normalize.OutputPath = "transcripts/alternate.json" }},
{name: "transformation", mutate: func(env *Env) { env.Config.Pipeline.Seriatim.Env.FillerMaxDuration = floatPointer(2.5) }},
})
assertRefinementUnchanged(t, normalizeStage{}, []struct {
name string
mutate func(*Env)
}{
{name: "runner settings", mutate: func(env *Env) {
env.Config.Pipeline.Seriatim.Binary = "/opt/seriatim"
env.Config.Pipeline.Seriatim.Timeout = "1h"
env.Config.Pipeline.Normalize.Report = boolPointer(false)
}},
})
}
func TestTrimSemanticConfigSensitivity(t *testing.T) {
assertRefinementChanges(t, trimStage{}, []struct {
name string
mutate func(*Env)
}{
{name: "enablement", mutate: func(env *Env) { env.Config.Pipeline.Trim.Enabled = boolPointer(false) }},
{name: "prompt", mutate: func(env *Env) { env.Config.Pipeline.Trim.Bounds.PromptID = "bounds-v2" }},
{name: "profile", mutate: func(env *Env) { env.Config.Pipeline.Trim.Bounds.ProfileID = "strict" }},
{name: "input identity", mutate: func(env *Env) { env.Config.Pipeline.Trim.Bounds.TranscriptInputName = "source_transcript" }},
{name: "bounds output", mutate: func(env *Env) { env.Config.Pipeline.Trim.Bounds.OutputPath = "artifacts/other-bounds.json" }},
{name: "trimmed output", mutate: func(env *Env) { env.Config.Pipeline.Trim.OutputPath = "transcripts/other-trimmed.json" }},
{name: "external config", mutate: func(env *Env) { env.Config.Pipeline.Scriptorium.ConfigPath = "scriptorium/other.yml" }},
})
assertRefinementUnchanged(t, trimStage{}, []struct {
name string
mutate func(*Env)
}{
{name: "diagnostic render", mutate: func(env *Env) {
env.Config.Pipeline.Trim.Bounds.RenderDebug = true
env.Config.Pipeline.Trim.Bounds.RenderOutputPath = "debug/other.json"
}},
{name: "runner settings", mutate: func(env *Env) {
env.Config.Pipeline.Trim.Bounds.Timeout = "1h"
env.Config.Pipeline.Trim.Seriatim.Report = boolPointer(false)
env.Config.Pipeline.Scriptorium.Binary = "/opt/scriptorium"
env.Config.Pipeline.Scriptorium.Timeout = "2h"
env.Config.Pipeline.Seriatim.Binary = "/opt/seriatim"
}},
})
}
func TestRenderSemanticConfigSensitivity(t *testing.T) {
assertRefinementChanges(t, renderStage{}, []struct {
name string
mutate func(*Env)
}{
{name: "enablement", mutate: func(env *Env) { env.Config.Pipeline.Render.Enabled = boolPointer(false) }},
{name: "format", mutate: func(env *Env) { env.Config.Pipeline.Render.Format = "html" }},
{name: "title", mutate: func(env *Env) { env.Config.Pipeline.Render.Title = "Explicit title" }},
{name: "session title fallback", mutate: func(env *Env) { env.Config.Session.Title = "Changed session" }},
{name: "timestamps", mutate: func(env *Env) { env.Config.Pipeline.Render.IncludeTimestamps = boolPointer(false) }},
{name: "segment ids", mutate: func(env *Env) { env.Config.Pipeline.Render.IncludeSegmentIDs = boolPointer(true) }},
{name: "metadata", mutate: func(env *Env) { env.Config.Pipeline.Render.IncludeMetadata = true }},
})
assertRefinementUnchanged(t, renderStage{}, []struct {
name string
mutate func(*Env)
}{
{name: "runner settings", mutate: func(env *Env) {
env.Config.Pipeline.Seriatim.Binary = "/opt/seriatim"
env.Config.Pipeline.Seriatim.Timeout = "1h"
env.Config.Pipeline.Seriatim.Report = boolPointer(false)
}},
})
}
func refinementSemanticEnv() *Env {
env := initialSemanticEnv()
env.Config.Session.Title = "Session title"
env.Config.Pipeline.Audita = config.AuditaConfig{
Binary: "audita", Timeout: "10m", LLMAPIKeyEnv: "AUDITA_KEY",
Modules: []string{"speakers", "terms"}, BaseURL: "https://audita.example",
Model: "testing", ValidationModel: "validator", TranscriptDescription: "tabletop session",
ConfigPath: "configs/audita.yml", OutputSchema: "audita.transcript.v1",
TotalLLMConcurrency: intPointer(4), ProposalLLMConcurrency: intPointer(2),
ValidationLLMConcurrency: intPointer(2), WorkDirRetention: "failure", Report: boolPointer(true),
}
env.Config.Pipeline.Normalize = &config.NormalizeConfig{
OutputPath: "transcripts/final.json", OutputSchema: "seriatim.transcript.v1", Report: boolPointer(true),
}
env.Config.Pipeline.Trim = &config.TrimConfig{
Enabled: boolPointer(true), OutputPath: "transcripts/final.trimmed.json",
Bounds: config.TrimBoundsConfig{
PromptID: "bounds", ProfileID: "default", TranscriptInputName: "transcript",
OutputPath: "artifacts/session-bounds.json", Timeout: "10m",
RenderOutputPath: "debug/bounds.json",
},
}
env.Config.Pipeline.Render = &config.RenderConfig{
Enabled: boolPointer(true), Format: "markdown", IncludeTimestamps: boolPointer(true),
IncludeSegmentIDs: boolPointer(false),
}
env.Config.Pipeline.Scriptorium = &config.ScriptoriumConfig{
Binary: "scriptorium", ConfigPath: "scriptorium/config.yml", Timeout: "10m",
}
return env
}
func assertRefinementChanges(t *testing.T, provider semanticFingerprintProvider, tests []struct {
name string
mutate func(*Env)
}) {
t.Helper()
want, err := provider.SemanticConfigFingerprint(refinementSemanticEnv())
if err != nil {
t.Fatal(err)
}
for _, test := range tests {
t.Run("semantic "+test.name, func(t *testing.T) {
env := refinementSemanticEnv()
test.mutate(env)
got, err := provider.SemanticConfigFingerprint(env)
if err != nil {
t.Fatal(err)
}
if want.Equal(got) {
t.Fatalf("semantic change %q retained fingerprint", test.name)
}
})
}
}
func assertRefinementUnchanged(t *testing.T, provider semanticFingerprintProvider, tests []struct {
name string
mutate func(*Env)
}) {
t.Helper()
want, err := provider.SemanticConfigFingerprint(refinementSemanticEnv())
if err != nil {
t.Fatal(err)
}
for _, test := range tests {
t.Run("operational "+test.name, func(t *testing.T) {
env := refinementSemanticEnv()
test.mutate(env)
got, err := provider.SemanticConfigFingerprint(env)
if err != nil {
t.Fatal(err)
}
if !want.Equal(got) {
t.Fatalf("operational change %q changed fingerprint", test.name)
}
})
}
}