Protect transcript refinement resume semantics
This commit is contained in:
168
internal/stage/semantic_contracts_refinement.go
Normal file
168
internal/stage/semantic_contracts_refinement.go
Normal 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{}
|
||||
196
internal/stage/semantic_contracts_refinement_test.go
Normal file
196
internal/stage/semantic_contracts_refinement_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user