From ebbd2c8a63f2653ea84a240fe1daeb4e943f6127 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 13 May 2026 12:13:32 +0000 Subject: [PATCH] Add transcript description prompt context --- README.md | 22 ++ docs/architecture.md | 20 ++ docs/roadmap.md | 7 + internal/cli/run.go | 17 +- internal/cli/run_test.go | 231 +++++++++++++++++- internal/core/config/config.go | 34 +-- internal/core/config/config_test.go | 26 ++ internal/core/config/flags.go | 9 +- internal/core/config/validation.go | 3 + internal/core/diagnostics/run_dir.go | 17 +- .../promptcontext/transcript_description.go | 21 ++ .../transcript_description_test.go | 29 +++ .../validators/llm_prompt_builders.go | 19 +- .../framework/validators/llm_validators.go | 8 +- .../validators/llm_validators_test.go | 25 +- internal/modules/glossary/module.go | 6 +- internal/modules/glossary/module_test.go | 20 +- internal/modules/glossary/prompt.go | 4 +- internal/modules/grammar/module.go | 6 +- internal/modules/grammar/module_test.go | 16 +- internal/modules/grammar/prompt.go | 4 +- internal/modules/homophones/module.go | 6 +- internal/modules/homophones/module_test.go | 27 +- internal/modules/homophones/prompt.go | 4 +- internal/modules/spoken_word/module.go | 6 +- internal/modules/spoken_word/module_test.go | 16 +- internal/modules/spoken_word/prompt.go | 4 +- 27 files changed, 542 insertions(+), 65 deletions(-) create mode 100644 internal/framework/promptcontext/transcript_description.go create mode 100644 internal/framework/promptcontext/transcript_description_test.go diff --git a/README.md b/README.md index ea5686a..f06cb93 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,17 @@ audita process transcript.json \ --report-json report.json ``` +Optional transcript background context: + +```sh +audita process transcript.json \ + --glossary glossary.yaml \ + --transcript-description "Brief context that may help resolve ambiguous terms." \ + --output corrected.json +``` + +The transcript description is background context only and does not override transcript content. + Write transcript JSON to stdout (no `--output`): ```sh @@ -113,6 +124,17 @@ Precedence: - `AUDITA_MODULES` (CSV) - CLI: `--modules` +### Transcript Description + +CLI: +- `--transcript-description` + +Behavior: +- optional background context for proposal and LLM-validator prompts; +- trimmed and length-limited by CLI validation; +- does not override transcript content; +- no `AUDITA_*` environment variable is currently defined for this setting. + ### Primary LLM Environment: diff --git a/docs/architecture.md b/docs/architecture.md index bca20b4..fb9421e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -221,6 +221,7 @@ Implemented config surfaces include: - module list - primary and validation LLM settings - total/proposal/validation LLM concurrency controls +- transcript description context (`--transcript-description`) - section token controls and target sections - confidence thresholds - normalization controls @@ -229,6 +230,25 @@ Implemented config surfaces include: Current caveat: - LLM/module-related settings are active for default and explicit module-run paths. +Transcript description behavior: +- `--transcript-description` is a process-flag input for optional user-supplied background context. +- runtime config stores this value in `Config.TranscriptDescription` after CLI trimming and length validation. +- default value is empty; empty values produce no prompt context section. +- this value is intentionally non-secret and appears in effective config and invocation metadata artifacts. + +## Implemented transcript description prompt context +Transcript description context is wired through production prompt paths: +- proposal prompts for `glossary`, `homophones`, `spoken_word`, and `grammar`; +- LLM-backed validator prompts for spoken-form plausibility, meaning reversal, editorial review, grammar review, and spoken-word review. + +Prompt guardrail semantics are consistent across modules and validators: +- transcript description is labeled as "background context only"; +- it may help interpret ambiguous terms; +- it must not override transcript content; +- the model must not invent corrections, facts, names, events, motivations, or speaker intent from this description. + +Generated transcript descriptions remain deferred and are not implemented in the current runtime. + ## Implemented structured LLM infrastructure `internal/framework/contracts` now defines a typed structured-completion contract: - `StructuredLLMClient.CompleteStructured(ctx, req, out)` diff --git a/docs/roadmap.md b/docs/roadmap.md index 8b55ec0..fea25b0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -691,6 +691,13 @@ The description should be included in every proposal and validator prompt as bac The prompt should clearly state that the description may help interpret ambiguous terms but must not override the transcript. +Implementation status (2026-05-13): +- implemented via `audita process --transcript-description `; +- stored in runtime config as transcript description context and propagated through proposal-generation and LLM-validator prompt builders; +- prompt text explicitly marks this context as background-only and non-authoritative; +- prompt text explicitly forbids inventing corrections, facts, names, events, motivations, or speaker intent from the description; +- empty descriptions do not add blank context sections. + Generated transcript descriptions should remain opt-in or deferred. If implemented before 1.0, they should be: - explicitly requested; diff --git a/internal/cli/run.go b/internal/cli/run.go index 08549f3..90c75f4 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -74,12 +74,13 @@ var processRunner = func(inv processInvocation, stdout io.Writer) (*normalizatio } if err := runDir.WriteInvocationMetadata(diagnostics.InvocationMetadata{ - Operation: "process", - TranscriptPath: inv.TranscriptPath, - GlossaryPath: inv.GlossaryPath, - OutputPath: inv.OutputPath, - ReportJSONPath: inv.ReportJSONPath, - Modules: append([]string(nil), inv.Config.Modules...), + Operation: "process", + TranscriptPath: inv.TranscriptPath, + GlossaryPath: inv.GlossaryPath, + OutputPath: inv.OutputPath, + ReportJSONPath: inv.ReportJSONPath, + TranscriptDescription: inv.Config.TranscriptDescription, + Modules: append([]string(nil), inv.Config.Modules...), }); err != nil { _ = runDir.WriteErrorLog(fmt.Sprintf("invocation_metadata: %v", err)) } @@ -435,6 +436,8 @@ func runProcess(args []string, stdout, stderr io.Writer) int { overrides.NormalizeMaxSegmentDuration = pFlags.normalizeMaxSegmentDuration case "normalize-max-segment-tokens": overrides.NormalizeMaxSegmentTokens = pFlags.normalizeMaxSegmentTokens + case "transcript-description": + overrides.TranscriptDescription = pFlags.transcriptDescription case "work-dir": overrides.WorkDir = pFlags.workDir case "work-dir-retention": @@ -710,6 +713,7 @@ type processFlags struct { normalizeEllipsisGap *float64 normalizeMaxSegmentDuration *float64 normalizeMaxSegmentTokens *int + transcriptDescription *string workDir *string workDirRetention *string } @@ -769,6 +773,7 @@ func newProcessFlagSet(cfg config.Config, stderr io.Writer) (*flag.FlagSet, proc normalizeEllipsisGap: fs.Float64("normalize-ellipsis-gap", cfg.Normalization.EllipsisGap, "Gap threshold for ellipsis insertion"), normalizeMaxSegmentDuration: fs.Float64("normalize-max-segment-duration", cfg.Normalization.MaxSegmentDuration, "Maximum merged segment duration"), normalizeMaxSegmentTokens: fs.Int("normalize-max-segment-tokens", cfg.Normalization.MaxSegmentTokens, "Maximum merged segment token estimate"), + transcriptDescription: fs.String("transcript-description", cfg.TranscriptDescription, "Brief background context for LLM prompts; does not override transcript content"), workDir: fs.String("work-dir", cfg.WorkDir, "Per-run work directory"), workDirRetention: fs.String("work-dir-retention", string(cfg.WorkDirRetention), "Work-dir retention policy: auto|always|never"), } diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 307cd25..03573ac 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -15,6 +15,7 @@ import ( "testing" "time" + "gitea.maximumdirect.net/eric/audita/internal/core/config" "gitea.maximumdirect.net/eric/audita/internal/core/normalization" "gitea.maximumdirect.net/eric/audita/internal/core/reporting" "gitea.maximumdirect.net/eric/audita/internal/core/schema" @@ -83,6 +84,7 @@ func TestRunProcessHelpListsExpectedFlags(t *testing.T) { "--normalize-ellipsis-gap", "--normalize-max-segment-duration", "--normalize-max-segment-tokens", + "--transcript-description", "--work-dir", "--work-dir-retention", } { @@ -249,6 +251,105 @@ func TestRunProcessCLIOverridesEnvironment(t *testing.T) { } } +func TestRunProcessTranscriptDescriptionDefaultEmpty(t *testing.T) { + processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{ + "m": fakeModule{ + key: "m", + policy: proposals.ReplacementPolicyRequireUnique, + validators: []contracts.Validator{ + fakeValidator{name: "capture-config", validateF: func(req contracts.ValidationRequest) (validators.Result, error) { + if req.Config == nil { + t.Fatal("expected config in validation request") + } + if req.Config.TranscriptDescription != "" { + t.Fatalf("expected default transcript description to be empty, got %q", req.Config.TranscriptDescription) + } + return validators.Result{ValidatorName: "capture-config", Decisions: nil}, nil + }}, + }, + proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) { return nil, nil }, + }, + }} + t.Cleanup(func() { processModuleFactory = nil }) + + var stdout bytes.Buffer + var stderr bytes.Buffer + transcriptPath := writeFile(t, "transcript.json", `[ + {"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"} +]`) + + exitCode := Run([]string{ + "process", transcriptPath, + "--glossary", fixturePath("tiny_glossary.yaml"), + "--modules", "m", + }, &stdout, &stderr) + if exitCode != 0 { + t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String()) + } +} + +func TestRunProcessTranscriptDescriptionCLIOverrideAndTrim(t *testing.T) { + processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{ + "m": fakeModule{ + key: "m", + policy: proposals.ReplacementPolicyRequireUnique, + validators: []contracts.Validator{ + fakeValidator{name: "capture-config", validateF: func(req contracts.ValidationRequest) (validators.Result, error) { + if req.Config == nil { + t.Fatal("expected config in validation request") + } + if req.Config.TranscriptDescription != "speaker background context" { + t.Fatalf("expected trimmed transcript description, got %q", req.Config.TranscriptDescription) + } + return validators.Result{ValidatorName: "capture-config", Decisions: nil}, nil + }}, + }, + proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) { return nil, nil }, + }, + }} + t.Cleanup(func() { processModuleFactory = nil }) + + var stdout bytes.Buffer + var stderr bytes.Buffer + transcriptPath := writeFile(t, "transcript.json", `[ + {"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"} +]`) + + exitCode := Run([]string{ + "process", transcriptPath, + "--glossary", fixturePath("tiny_glossary.yaml"), + "--modules", "m", + "--transcript-description", " speaker background context ", + }, &stdout, &stderr) + if exitCode != 0 { + t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String()) + } +} + +func TestRunProcessRejectsOverlyLongTranscriptDescription(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + transcriptPath := writeFile(t, "transcript.json", `[ + {"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"} +]`) + tooLong := strings.Repeat("a", config.DefaultTranscriptDescriptionMaxChars+1) + + exitCode := Run([]string{ + "process", transcriptPath, + "--glossary", fixturePath("tiny_glossary.yaml"), + "--transcript-description", tooLong, + }, &stdout, &stderr) + if exitCode == 0 { + t.Fatalf("expected nonzero exit code for overly long transcript description") + } + if stdout.Len() != 0 { + t.Fatalf("expected empty stdout, got %q", stdout.String()) + } + if !strings.Contains(stderr.String(), "transcript description must be 500 characters or fewer") { + t.Fatalf("expected transcript description length validation error, got %q", stderr.String()) + } +} + func TestRunProcessRejectsValidationConcurrencyAboveTotalConcurrency(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -1091,6 +1192,35 @@ func (f *fakeStructuredLLMClient) CompleteStructured(ctx context.Context, req co } } +type capturePromptStructuredLLMClient struct { + proposalResponses []proposal_generation.StructuredCorrectionSet + validationResponses []validators.LLMValidationResponse + requests []contracts.StructuredCompletionRequest +} + +func (c *capturePromptStructuredLLMClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) { + _ = ctx + c.requests = append(c.requests, req) + switch target := out.(type) { + case *proposal_generation.StructuredCorrectionSet: + if len(c.proposalResponses) == 0 { + return contracts.StructuredCompletionResponse{}, errors.New("unexpected proposal llm call") + } + *target = c.proposalResponses[0] + c.proposalResponses = c.proposalResponses[1:] + return contracts.StructuredCompletionResponse{}, nil + case *validators.LLMValidationResponse: + if len(c.validationResponses) == 0 { + return contracts.StructuredCompletionResponse{}, errors.New("unexpected validation llm call") + } + *target = c.validationResponses[0] + c.validationResponses = c.validationResponses[1:] + return contracts.StructuredCompletionResponse{}, nil + default: + return contracts.StructuredCompletionResponse{}, errors.New("unexpected llm output type") + } +} + func TestRunProcessInjectedFactoryExecutesRunnerAndReportsModules(t *testing.T) { allow := fakeValidator{name: "allow", validateF: func(req contracts.ValidationRequest) (validators.Result, error) { decisions := make([]validators.Decision, len(req.CandidateProposal)) @@ -1466,6 +1596,75 @@ func TestRunProcessExplicitGrammarAppliesCorrectionAndReportsDiagnostics(t *test } } +func TestRunProcessTranscriptDescriptionReachesProposalAndValidatorPrompts(t *testing.T) { + proposalClient := &capturePromptStructuredLLMClient{ + proposalResponses: []proposal_generation.StructuredCorrectionSet{ + {Corrections: []proposal_generation.StructuredCorrectionProposal{ + {TargetSegmentID: 1, OriginalText: "hello ,world", CorrectedText: "Hello, world", Confidence: 0.95}, + }}, + }, + } + validationClient := &capturePromptStructuredLLMClient{ + validationResponses: []validators.LLMValidationResponse{ + {Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}}, + {Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}}, + }, + } + processProposalLLMClient = proposalClient + processValidationLLMClient = validationClient + t.Cleanup(func() { + processProposalLLMClient = nil + processValidationLLMClient = nil + processProposalLLMScheduler = nil + processValidationLLMScheduler = nil + }) + + var stdout, stderr bytes.Buffer + transcriptPath := writeFile(t, "transcript.json", `[ + {"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"hello ,world"} +]`) + description := "Hearing transcript where speakers reference proper nouns." + exitCode := Run([]string{ + "process", transcriptPath, + "--glossary", fixturePath("tiny_glossary.yaml"), + "--modules", "grammar", + "--transcript-description", description, + }, &stdout, &stderr) + if exitCode != 0 { + t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String()) + } + + if len(proposalClient.requests) == 0 { + t.Fatalf("expected proposal LLM requests") + } + if len(validationClient.requests) == 0 { + t.Fatalf("expected validation LLM requests") + } + + proposalPrompt := combinedPrompt(proposalClient.requests[0].Messages) + validatorPrompt := combinedPrompt(validationClient.requests[0].Messages) + for _, prompt := range []string{proposalPrompt, validatorPrompt} { + for _, want := range []string{ + "Transcript description (background context only):", + description, + "must not override the transcript content", + "Do not invent corrections, facts, names, events, motivations, or speaker intent based on this description.", + } { + if !strings.Contains(prompt, want) { + t.Fatalf("expected prompt to contain %q, got: %q", want, prompt) + } + } + } +} + +func combinedPrompt(messages []contracts.LLMMessage) string { + parts := make([]string, 0, len(messages)) + for _, m := range messages { + parts = append(parts, m.Content) + } + return strings.Join(parts, "\n") +} + func TestRunProcessExplicitGrammarRejectedAndApplicationSkipAreDistinct(t *testing.T) { processProposalLLMClient = &fakeStructuredLLMClient{ proposalResponses: []proposal_generation.StructuredCorrectionSet{ @@ -2957,6 +3156,8 @@ func TestRunProcessWritesRedactedRunMetadataArtifacts(t *testing.T) { fixturePath("tiny_transcript.json"), "--glossary", fixturePath("tiny_glossary.yaml"), + "--transcript-description", + " scene takes place during a council hearing ", "--total-llm-concurrency", "3", "--proposal-llm-concurrency", @@ -3006,9 +3207,10 @@ func TestRunProcessWritesRedactedRunMetadataArtifacts(t *testing.T) { } var effectiveConfig struct { - TotalLLMConcurrency int `json:"TotalLLMConcurrency"` - ProposalLLMConcurrency int `json:"ProposalLLMConcurrency"` - ValidationLLMConcurrency *int `json:"ValidationLLMConcurrency"` + TotalLLMConcurrency int `json:"TotalLLMConcurrency"` + ProposalLLMConcurrency int `json:"ProposalLLMConcurrency"` + ValidationLLMConcurrency *int `json:"ValidationLLMConcurrency"` + TranscriptDescription string `json:"TranscriptDescription"` } if err := json.Unmarshal(configBytes, &effectiveConfig); err != nil { t.Fatalf("failed to parse effective config metadata: %v", err) @@ -3022,16 +3224,20 @@ func TestRunProcessWritesRedactedRunMetadataArtifacts(t *testing.T) { if effectiveConfig.ValidationLLMConcurrency == nil || *effectiveConfig.ValidationLLMConcurrency != 1 { t.Fatalf("expected validation_llm_concurrency=1 in effective config, got %#v", effectiveConfig.ValidationLLMConcurrency) } + if effectiveConfig.TranscriptDescription != "scene takes place during a council hearing" { + t.Fatalf("expected transcript description in effective config, got %q", effectiveConfig.TranscriptDescription) + } var invocation struct { - Operation string `json:"operation"` - TranscriptPath string `json:"transcript_path"` - GlossaryPath string `json:"glossary_path"` - OutputPath string `json:"output_path"` - ReportJSONPath string `json:"report_json_path"` - Modules []string `json:"modules"` - RunID string `json:"run_id"` - StartedAt string `json:"started_at"` + Operation string `json:"operation"` + TranscriptPath string `json:"transcript_path"` + GlossaryPath string `json:"glossary_path"` + OutputPath string `json:"output_path"` + ReportJSONPath string `json:"report_json_path"` + TranscriptDescription string `json:"transcript_description"` + Modules []string `json:"modules"` + RunID string `json:"run_id"` + StartedAt string `json:"started_at"` } if err := json.Unmarshal(invocationBytes, &invocation); err != nil { t.Fatalf("failed to parse invocation metadata: %v", err) @@ -3051,6 +3257,9 @@ func TestRunProcessWritesRedactedRunMetadataArtifacts(t *testing.T) { if invocation.ReportJSONPath != reportPath { t.Fatalf("unexpected report_json_path: %q", invocation.ReportJSONPath) } + if invocation.TranscriptDescription != "scene takes place during a council hearing" { + t.Fatalf("unexpected transcript_description: %q", invocation.TranscriptDescription) + } if len(invocation.Modules) == 0 { t.Fatalf("expected non-empty modules list in invocation metadata") } diff --git a/internal/core/config/config.go b/internal/core/config/config.go index ad00094..4677bae 100644 --- a/internal/core/config/config.go +++ b/internal/core/config/config.go @@ -14,22 +14,23 @@ const ( ) const ( - DefaultModulesCSV = "glossary,homophones,glossary,spoken_word,grammar" - DefaultPrimaryModel = "openrouter/google/gemma-4-31b-it" - DefaultPrimaryBaseURL = "https://openrouter.ai/api/v1" - DefaultPrimaryLLMTimeoutSeconds = 600 - DefaultMaxRetries = 3 - DefaultLLMConcurrency = 1 - DefaultValidationMaxPromptTokens = 2048 - DefaultMaxSectionTokens = 8192 - DefaultMinSectionTokens = 2048 - DefaultConfidenceThreshold = 0.8 - DefaultNormalizeMaxSegmentGap = 4.0 - DefaultNormalizeEllipsisGap = 3.5 - DefaultNormalizeMaxSegmentDuration = 60.0 - DefaultNormalizeMaxSegmentTokens = 2048 - DefaultWorkDir = "/tmp/audita" - DefaultWorkDirRetention WorkDirRetention = WorkDirRetentionAuto + DefaultModulesCSV = "glossary,homophones,glossary,spoken_word,grammar" + DefaultPrimaryModel = "openrouter/google/gemma-4-31b-it" + DefaultPrimaryBaseURL = "https://openrouter.ai/api/v1" + DefaultPrimaryLLMTimeoutSeconds = 600 + DefaultMaxRetries = 3 + DefaultLLMConcurrency = 1 + DefaultValidationMaxPromptTokens = 2048 + DefaultMaxSectionTokens = 8192 + DefaultMinSectionTokens = 2048 + DefaultConfidenceThreshold = 0.8 + DefaultNormalizeMaxSegmentGap = 4.0 + DefaultNormalizeEllipsisGap = 3.5 + DefaultNormalizeMaxSegmentDuration = 60.0 + DefaultNormalizeMaxSegmentTokens = 2048 + DefaultTranscriptDescriptionMaxChars = 500 + DefaultWorkDir = "/tmp/audita" + DefaultWorkDirRetention WorkDirRetention = WorkDirRetentionAuto ) type Config struct { @@ -45,6 +46,7 @@ type Config struct { TargetSections *int Thresholds ConfidenceThresholds Normalization NormalizationConfig + TranscriptDescription string WorkDir string WorkDirRetention WorkDirRetention } diff --git a/internal/core/config/config_test.go b/internal/core/config/config_test.go index 75c22e4..d9036db 100644 --- a/internal/core/config/config_test.go +++ b/internal/core/config/config_test.go @@ -51,6 +51,9 @@ func TestDefaultConfigValues(t *testing.T) { if cfg.WorkDir != DefaultWorkDir { t.Fatalf("unexpected default work dir: %q", cfg.WorkDir) } + if cfg.TranscriptDescription != "" { + t.Fatalf("expected default transcript description to be empty, got %q", cfg.TranscriptDescription) + } if cfg.WorkDirRetention != DefaultWorkDirRetention { t.Fatalf("unexpected default work dir retention: %q", cfg.WorkDirRetention) } @@ -214,6 +217,29 @@ func TestApplyCLIOverridesPrecedence(t *testing.T) { } } +func TestApplyCLIOverridesTrimsTranscriptDescription(t *testing.T) { + cfg := Default() + description := " background context about speakers " + if err := cfg.ApplyCLIOverrides(CLIOverrides{TranscriptDescription: &description}); err != nil { + t.Fatalf("ApplyCLIOverrides failed: %v", err) + } + if cfg.TranscriptDescription != "background context about speakers" { + t.Fatalf("unexpected transcript description trim result: %q", cfg.TranscriptDescription) + } +} + +func TestValidationRejectsOverlyLongTranscriptDescription(t *testing.T) { + cfg := Default() + cfg.TranscriptDescription = strings.Repeat("a", DefaultTranscriptDescriptionMaxChars+1) + err := cfg.Validate() + if err == nil { + t.Fatalf("expected transcript description length validation error") + } + if !strings.Contains(err.Error(), "transcript description must be 500 characters or fewer") { + t.Fatalf("unexpected validation error: %v", err) + } +} + func TestApplyCLIOverridesLegacyLLMConcurrencyAlias(t *testing.T) { cfg := Default() aliasConcurrency := 6 diff --git a/internal/core/config/flags.go b/internal/core/config/flags.go index 4add17c..8e55875 100644 --- a/internal/core/config/flags.go +++ b/internal/core/config/flags.go @@ -1,6 +1,9 @@ package config -import "fmt" +import ( + "fmt" + "strings" +) type CLIOverrides struct { ModulesCSV *string @@ -30,6 +33,7 @@ type CLIOverrides struct { NormalizeEllipsisGap *float64 NormalizeMaxSegmentDuration *float64 NormalizeMaxSegmentTokens *int + TranscriptDescription *string WorkDir *string WorkDirRetention *string } @@ -135,6 +139,9 @@ func (c *Config) ApplyCLIOverrides(overrides CLIOverrides) error { if overrides.NormalizeMaxSegmentTokens != nil { c.Normalization.MaxSegmentTokens = *overrides.NormalizeMaxSegmentTokens } + if overrides.TranscriptDescription != nil { + c.TranscriptDescription = strings.TrimSpace(*overrides.TranscriptDescription) + } if overrides.WorkDir != nil { c.WorkDir = *overrides.WorkDir } diff --git a/internal/core/config/validation.go b/internal/core/config/validation.go index 92c509c..9f90c6c 100644 --- a/internal/core/config/validation.go +++ b/internal/core/config/validation.go @@ -88,6 +88,9 @@ func (c Config) Validate() error { if c.Normalization.MaxSegmentTokens <= 0 { issues = append(issues, "normalize max segment tokens must be greater than zero") } + if len(strings.TrimSpace(c.TranscriptDescription)) > DefaultTranscriptDescriptionMaxChars { + issues = append(issues, fmt.Sprintf("transcript description must be %d characters or fewer", DefaultTranscriptDescriptionMaxChars)) + } if strings.TrimSpace(c.WorkDir) == "" { issues = append(issues, "work dir must not be empty") diff --git a/internal/core/diagnostics/run_dir.go b/internal/core/diagnostics/run_dir.go index c9ac1a1..10355fe 100644 --- a/internal/core/diagnostics/run_dir.go +++ b/internal/core/diagnostics/run_dir.go @@ -48,14 +48,15 @@ func ShouldRetainRunDirectory(input RetentionDecisionInput) bool { // InvocationMetadata captures non-secret invocation details for diagnostics. type InvocationMetadata struct { - Operation string `json:"operation"` - TranscriptPath string `json:"transcript_path"` - GlossaryPath string `json:"glossary_path"` - OutputPath string `json:"output_path,omitempty"` - ReportJSONPath string `json:"report_json_path,omitempty"` - Modules []string `json:"modules"` - RunID string `json:"run_id"` - StartedAt time.Time `json:"started_at"` + Operation string `json:"operation"` + TranscriptPath string `json:"transcript_path"` + GlossaryPath string `json:"glossary_path"` + OutputPath string `json:"output_path,omitempty"` + ReportJSONPath string `json:"report_json_path,omitempty"` + TranscriptDescription string `json:"transcript_description,omitempty"` + Modules []string `json:"modules"` + RunID string `json:"run_id"` + StartedAt time.Time `json:"started_at"` } // NewRunDirectory creates a new run directory under the configured work dir diff --git a/internal/framework/promptcontext/transcript_description.go b/internal/framework/promptcontext/transcript_description.go new file mode 100644 index 0000000..e01d0c5 --- /dev/null +++ b/internal/framework/promptcontext/transcript_description.go @@ -0,0 +1,21 @@ +package promptcontext + +import ( + "fmt" + "strings" +) + +// TranscriptDescriptionBlock returns standardized background-only context +// guidance for prompt builders when a user-supplied description is present. +func TranscriptDescriptionBlock(transcriptDescription string) string { + description := strings.TrimSpace(transcriptDescription) + if description == "" { + return "" + } + + return "Transcript description (background context only):\n" + + fmt.Sprintf("%s\n\n", description) + + "Use this description only as optional background to interpret ambiguous terms. " + + "It must not override the transcript content. " + + "Do not invent corrections, facts, names, events, motivations, or speaker intent based on this description.\n\n" +} diff --git a/internal/framework/promptcontext/transcript_description_test.go b/internal/framework/promptcontext/transcript_description_test.go new file mode 100644 index 0000000..bd7f194 --- /dev/null +++ b/internal/framework/promptcontext/transcript_description_test.go @@ -0,0 +1,29 @@ +package promptcontext + +import ( + "strings" + "testing" +) + +func TestTranscriptDescriptionBlockEmpty(t *testing.T) { + if got := TranscriptDescriptionBlock(""); got != "" { + t.Fatalf("expected empty block for empty description, got %q", got) + } + if got := TranscriptDescriptionBlock(" "); got != "" { + t.Fatalf("expected empty block for whitespace description, got %q", got) + } +} + +func TestTranscriptDescriptionBlockIncludesGuardrails(t *testing.T) { + got := TranscriptDescriptionBlock("Council hearing with multiple speakers.") + for _, want := range []string{ + "Transcript description (background context only):", + "Council hearing with multiple speakers.", + "must not override the transcript content", + "Do not invent corrections, facts, names, events, motivations, or speaker intent based on this description.", + } { + if !strings.Contains(got, want) { + t.Fatalf("expected block to contain %q", want) + } + } +} diff --git a/internal/framework/validators/llm_prompt_builders.go b/internal/framework/validators/llm_prompt_builders.go index 6aa1c1f..950de11 100644 --- a/internal/framework/validators/llm_prompt_builders.go +++ b/internal/framework/validators/llm_prompt_builders.go @@ -3,9 +3,11 @@ package validators import ( "encoding/json" "fmt" + + "gitea.maximumdirect.net/eric/audita/internal/framework/promptcontext" ) -func BuildSpokenFormPlausibilityMessages(validationPayload []LLMValidationItem) ([]LLMMessage, error) { +func BuildSpokenFormPlausibilityMessages(validationPayload []LLMValidationItem, transcriptDescription string) ([]LLMMessage, error) { payloadJSON, err := marshalPromptPayload(validationPayload) if err != nil { return nil, err @@ -22,11 +24,12 @@ func BuildSpokenFormPlausibilityMessages(validationPayload []LLMValidationItem) "- If a correction includes categories, treat them as additional segment context.\n" + "- Each returned validation must contain only correction_index, approved, confidence, and reason.\n" + "- confidence must be between 0.0 and 1.0.\n\n" + + promptcontext.TranscriptDescriptionBlock(transcriptDescription) + fmt.Sprintf("Corrections to validate:\n%s", payloadJSON) return []LLMMessage{{Role: "system", Content: system}, {Role: "user", Content: user}}, nil } -func BuildMeaningReversalMessages(validationPayload []LLMValidationItem) ([]LLMMessage, error) { +func BuildMeaningReversalMessages(validationPayload []LLMValidationItem, transcriptDescription string) ([]LLMMessage, error) { payloadJSON, err := marshalPromptPayload(validationPayload) if err != nil { return nil, err @@ -43,11 +46,12 @@ func BuildMeaningReversalMessages(validationPayload []LLMValidationItem) ([]LLMM "- If a correction includes categories, treat them as additional segment context.\n" + "- Each returned validation must contain only correction_index, approved, confidence, and reason.\n" + "- confidence must be between 0.0 and 1.0.\n\n" + + promptcontext.TranscriptDescriptionBlock(transcriptDescription) + fmt.Sprintf("Corrections to validate:\n%s", payloadJSON) return []LLMMessage{{Role: "system", Content: system}, {Role: "user", Content: user}}, nil } -func BuildEditorialMessages(validationPayload []LLMValidationItem) ([]LLMMessage, error) { +func BuildEditorialMessages(validationPayload []LLMValidationItem, transcriptDescription string) ([]LLMMessage, error) { payloadJSON, err := marshalPromptPayload(validationPayload) if err != nil { return nil, err @@ -69,16 +73,17 @@ func BuildEditorialMessages(validationPayload []LLMValidationItem) ([]LLMMessage "- If a correction includes categories, treat them as additional segment context.\n" + "- Each returned validation must contain only correction_index, approved, confidence, and reason.\n" + "- confidence must be between 0.0 and 1.0.\n\n" + + promptcontext.TranscriptDescriptionBlock(transcriptDescription) + fmt.Sprintf("Corrections to validate:\n%s", payloadJSON) return []LLMMessage{{Role: "system", Content: system}, {Role: "user", Content: user}}, nil } -func BuildGrammarReviewMessages(validationPayload []LLMValidationItem) ([]LLMMessage, error) { - return BuildEditorialMessages(validationPayload) +func BuildGrammarReviewMessages(validationPayload []LLMValidationItem, transcriptDescription string) ([]LLMMessage, error) { + return BuildEditorialMessages(validationPayload, transcriptDescription) } -func BuildSpokenWordReviewMessages(validationPayload []LLMValidationItem) ([]LLMMessage, error) { - return BuildEditorialMessages(validationPayload) +func BuildSpokenWordReviewMessages(validationPayload []LLMValidationItem, transcriptDescription string) ([]LLMMessage, error) { + return BuildEditorialMessages(validationPayload, transcriptDescription) } func marshalPromptPayload(validationPayload []LLMValidationItem) (string, error) { diff --git a/internal/framework/validators/llm_validators.go b/internal/framework/validators/llm_validators.go index 96a024d..7612763 100644 --- a/internal/framework/validators/llm_validators.go +++ b/internal/framework/validators/llm_validators.go @@ -13,7 +13,7 @@ import ( "gitea.maximumdirect.net/eric/audita/internal/framework/responseschema" ) -type LLMPromptBuilder func(validationPayload []LLMValidationItem) ([]LLMMessage, error) +type LLMPromptBuilder func(validationPayload []LLMValidationItem, transcriptDescription string) ([]LLMMessage, error) type LLMBackedValidator struct { name string @@ -84,7 +84,11 @@ func (v *LLMBackedValidator) Validate(ctx context.Context, req Request) (Result, llmDecisions := make([]Decision, 0) for _, batch := range batches { - messages, err := v.promptBuilder(batch.Items) + transcriptDescription := "" + if req.Config != nil { + transcriptDescription = req.Config.TranscriptDescription + } + messages, err := v.promptBuilder(batch.Items, transcriptDescription) if err != nil { return Result{}, err } diff --git a/internal/framework/validators/llm_validators_test.go b/internal/framework/validators/llm_validators_test.go index 2e7511d..4880076 100644 --- a/internal/framework/validators/llm_validators_test.go +++ b/internal/framework/validators/llm_validators_test.go @@ -167,7 +167,7 @@ func TestPromptBuildersContainRequiredContextAndInstructions(t *testing.T) { payload := []LLMValidationItem{{CorrectionIndex: 0, SegmentID: 1, OriginalText: "gestures", CorrectedText: "Jesters", OriginalSegmentText: "There were gestures", CorrectedSegmentText: "There were Jesters", Categories: []string{"narration"}}} tests := []struct { name string - build func([]LLMValidationItem) ([]LLMMessage, error) + build func([]LLMValidationItem, string) ([]LLMMessage, error) mustHas []string }{ {"spoken_form", BuildSpokenFormPlausibilityMessages, []string{"plausible spoken-form", "correction_index", "original_segment_text", "corrected_segment_text"}}, @@ -179,7 +179,7 @@ func TestPromptBuildersContainRequiredContextAndInstructions(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - msgs, err := tt.build(payload) + msgs, err := tt.build(payload, "Discussion among party members in a dungeon.") if err != nil { t.Fatalf("build err: %v", err) } @@ -192,10 +192,31 @@ func TestPromptBuildersContainRequiredContextAndInstructions(t *testing.T) { t.Fatalf("expected prompt to contain %q", needle) } } + for _, needle := range []string{ + "Transcript description (background context only):", + "must not override the transcript content", + "Do not invent corrections, facts, names, events, motivations, or speaker intent based on this description.", + } { + if !strings.Contains(combined, needle) { + t.Fatalf("expected prompt to contain %q", needle) + } + } }) } } +func TestPromptBuildersOmitTranscriptDescriptionSectionWhenEmpty(t *testing.T) { + payload := []LLMValidationItem{{CorrectionIndex: 0, SegmentID: 1, OriginalText: "gestures", CorrectedText: "Jesters", OriginalSegmentText: "There were gestures", CorrectedSegmentText: "There were Jesters"}} + msgs, err := BuildSpokenFormPlausibilityMessages(payload, " ") + if err != nil { + t.Fatalf("build err: %v", err) + } + combined := msgs[0].Content + "\n" + msgs[1].Content + if strings.Contains(combined, "Transcript description (background context only):") { + t.Fatalf("did not expect empty transcript description section in prompt") + } +} + func TestLLMBackedValidatorApprovalAndRejection(t *testing.T) { client := &fakeStructuredLLMClient{responses: []LLMValidationResponse{{Validations: []LLMValidationDecision{ {CorrectionIndex: 0, Approved: true, Confidence: 0.9, Reason: "ok"}, diff --git a/internal/modules/glossary/module.go b/internal/modules/glossary/module.go index 355dfd8..1430dbb 100644 --- a/internal/modules/glossary/module.go +++ b/internal/modules/glossary/module.go @@ -55,7 +55,11 @@ func (m *Module) Propose(ctx context.Context, req contracts.ProposalRequest) ([] if req.Section != nil { sectionIndex = req.Section.Index } - messages, err := BuildProposalMessages(sectionTranscript, req.Glossary, sectionIndex) + transcriptDescription := "" + if req.Config != nil { + transcriptDescription = req.Config.TranscriptDescription + } + messages, err := BuildProposalMessages(sectionTranscript, req.Glossary, sectionIndex, transcriptDescription) if err != nil { return nil, err } diff --git a/internal/modules/glossary/module_test.go b/internal/modules/glossary/module_test.go index 11eae52..77dda1f 100644 --- a/internal/modules/glossary/module_test.go +++ b/internal/modules/glossary/module_test.go @@ -61,7 +61,7 @@ func tinyGlossary() *schema.Glossary { } func TestBuildProposalMessagesContainsGlossaryContextAndConstraints(t *testing.T) { - msgs, err := BuildProposalMessages(tinyTranscript(), tinyGlossary(), 0) + msgs, err := BuildProposalMessages(tinyTranscript(), tinyGlossary(), 0, "") if err != nil { t.Fatalf("BuildProposalMessages error: %v", err) } @@ -91,6 +91,24 @@ func TestBuildProposalMessagesContainsGlossaryContextAndConstraints(t *testing.T } } +func TestBuildProposalMessagesIncludesTranscriptDescriptionGuidance(t *testing.T) { + msgs, err := BuildProposalMessages(tinyTranscript(), tinyGlossary(), 0, "Campaign scene in a crowded harbor.") + if err != nil { + t.Fatalf("BuildProposalMessages error: %v", err) + } + combined := msgs[0].Content + "\n" + msgs[1].Content + for _, want := range []string{ + "Transcript description (background context only):", + "Campaign scene in a crowded harbor.", + "must not override the transcript content", + "Do not invent corrections, facts, names, events, motivations, or speaker intent based on this description.", + } { + if !strings.Contains(combined, want) { + t.Fatalf("expected prompt to contain %q", want) + } + } +} + func TestGlossaryModuleReplacementPolicy(t *testing.T) { m, err := New() if err != nil { diff --git a/internal/modules/glossary/prompt.go b/internal/modules/glossary/prompt.go index b626072..da168ed 100644 --- a/internal/modules/glossary/prompt.go +++ b/internal/modules/glossary/prompt.go @@ -6,6 +6,7 @@ import ( "gitea.maximumdirect.net/eric/audita/internal/core/schema" "gitea.maximumdirect.net/eric/audita/internal/framework/contracts" + "gitea.maximumdirect.net/eric/audita/internal/framework/promptcontext" ) type promptSegment struct { @@ -22,7 +23,7 @@ type promptTranscriptSection struct { Segments []promptSegment `json:"segments"` } -func BuildProposalMessages(transcript *schema.Transcript, glossary *schema.Glossary, sectionIndex int) ([]contracts.LLMMessage, error) { +func BuildProposalMessages(transcript *schema.Transcript, glossary *schema.Glossary, sectionIndex int, transcriptDescription string) ([]contracts.LLMMessage, error) { glossaryJSON, err := json.MarshalIndent(glossary, "", " ") if err != nil { return nil, fmt.Errorf("marshal glossary prompt context: %w", err) @@ -71,6 +72,7 @@ func BuildProposalMessages(transcript *schema.Transcript, glossary *schema.Gloss "- Return only changed segments; do not return entries for unchanged segments.\n" + "- confidence must be between 0.0 and 1.0.\n" + "- If no corrections are needed, return an empty corrections list.\n\n" + + promptcontext.TranscriptDescriptionBlock(transcriptDescription) + fmt.Sprintf("Glossary:\n%s\n\nTranscript section:\n%s", string(glossaryJSON), string(sectionJSON)) return []contracts.LLMMessage{ diff --git a/internal/modules/grammar/module.go b/internal/modules/grammar/module.go index 522fad2..7c0770f 100644 --- a/internal/modules/grammar/module.go +++ b/internal/modules/grammar/module.go @@ -55,7 +55,11 @@ func (m *Module) Propose(ctx context.Context, req contracts.ProposalRequest) ([] if req.Section != nil { sectionIndex = req.Section.Index } - messages, err := BuildProposalMessages(sectionTranscript, req.Glossary, sectionIndex) + transcriptDescription := "" + if req.Config != nil { + transcriptDescription = req.Config.TranscriptDescription + } + messages, err := BuildProposalMessages(sectionTranscript, req.Glossary, sectionIndex, transcriptDescription) if err != nil { return nil, err } diff --git a/internal/modules/grammar/module_test.go b/internal/modules/grammar/module_test.go index 18a78be..90b3b2c 100644 --- a/internal/modules/grammar/module_test.go +++ b/internal/modules/grammar/module_test.go @@ -71,7 +71,7 @@ func tinyGlossary() *schema.Glossary { } func TestBuildProposalMessagesConstraints(t *testing.T) { - msgs, err := BuildProposalMessages(tinyTranscript(), tinyGlossary(), 0) + msgs, err := BuildProposalMessages(tinyTranscript(), tinyGlossary(), 0, "") if err != nil { t.Fatalf("BuildProposalMessages error: %v", err) } @@ -97,6 +97,20 @@ func TestBuildProposalMessagesConstraints(t *testing.T) { } } +func TestBuildProposalMessagesIncludesTranscriptDescriptionGuidance(t *testing.T) { + msgs, err := BuildProposalMessages(tinyTranscript(), tinyGlossary(), 0, "Courtroom exchange with formal titles.") + if err != nil { + t.Fatalf("BuildProposalMessages error: %v", err) + } + combined := msgs[0].Content + "\n" + msgs[1].Content + if !strings.Contains(combined, "Transcript description (background context only):") { + t.Fatalf("expected transcript description section in prompt") + } + if !strings.Contains(combined, "must not override the transcript content") { + t.Fatalf("expected no-override guidance in prompt") + } +} + func TestGrammarModuleReplacementPolicy(t *testing.T) { m, err := New() if err != nil { diff --git a/internal/modules/grammar/prompt.go b/internal/modules/grammar/prompt.go index 2318dff..d21c493 100644 --- a/internal/modules/grammar/prompt.go +++ b/internal/modules/grammar/prompt.go @@ -6,6 +6,7 @@ import ( "gitea.maximumdirect.net/eric/audita/internal/core/schema" "gitea.maximumdirect.net/eric/audita/internal/framework/contracts" + "gitea.maximumdirect.net/eric/audita/internal/framework/promptcontext" ) type promptSegment struct { @@ -24,7 +25,7 @@ type promptTranscriptSection struct { // BuildProposalMessages constrains corrections to punctuation/capitalization/ // spacing cleanup with strict meaning guards. -func BuildProposalMessages(transcript *schema.Transcript, glossary *schema.Glossary, sectionIndex int) ([]contracts.LLMMessage, error) { +func BuildProposalMessages(transcript *schema.Transcript, glossary *schema.Glossary, sectionIndex int, transcriptDescription string) ([]contracts.LLMMessage, error) { glossaryJSON, err := json.MarshalIndent(glossary, "", " ") if err != nil { return nil, fmt.Errorf("marshal glossary prompt context: %w", err) @@ -76,6 +77,7 @@ func BuildProposalMessages(transcript *schema.Transcript, glossary *schema.Gloss "- Return only changed segments; do not return entries for unchanged segments.\n" + "- confidence must be between 0.0 and 1.0.\n" + "- If no corrections are needed, return an empty corrections list.\n\n" + + promptcontext.TranscriptDescriptionBlock(transcriptDescription) + fmt.Sprintf("Protected glossary/context:\n%s\n\nTranscript section:\n%s", string(glossaryJSON), string(sectionJSON)) return []contracts.LLMMessage{ diff --git a/internal/modules/homophones/module.go b/internal/modules/homophones/module.go index 4aadc44..489084b 100644 --- a/internal/modules/homophones/module.go +++ b/internal/modules/homophones/module.go @@ -55,7 +55,11 @@ func (m *Module) Propose(ctx context.Context, req contracts.ProposalRequest) ([] if req.Section != nil { sectionIndex = req.Section.Index } - messages, err := BuildProposalMessages(sectionTranscript, req.Glossary, sectionIndex) + transcriptDescription := "" + if req.Config != nil { + transcriptDescription = req.Config.TranscriptDescription + } + messages, err := BuildProposalMessages(sectionTranscript, req.Glossary, sectionIndex, transcriptDescription) if err != nil { return nil, err } diff --git a/internal/modules/homophones/module_test.go b/internal/modules/homophones/module_test.go index 1a7700d..4ce29df 100644 --- a/internal/modules/homophones/module_test.go +++ b/internal/modules/homophones/module_test.go @@ -62,7 +62,7 @@ func tinyGlossary() *schema.Glossary { } func TestBuildProposalMessagesContainsContextAndConservativeConstraints(t *testing.T) { - msgs, err := BuildProposalMessages(tinyTranscript(), tinyGlossary(), 0) + msgs, err := BuildProposalMessages(tinyTranscript(), tinyGlossary(), 0, "") if err != nil { t.Fatalf("BuildProposalMessages error: %v", err) } @@ -100,6 +100,31 @@ func TestBuildProposalMessagesContainsContextAndConservativeConstraints(t *testi } } +func TestBuildProposalMessagesIncludesTranscriptDescriptionGuidance(t *testing.T) { + msgs, err := BuildProposalMessages(tinyTranscript(), tinyGlossary(), 0, "Tabletop session with fantasy names.") + if err != nil { + t.Fatalf("BuildProposalMessages error: %v", err) + } + combined := msgs[0].Content + "\n" + msgs[1].Content + if !strings.Contains(combined, "Transcript description (background context only):") { + t.Fatalf("expected transcript description section in prompt") + } + if !strings.Contains(combined, "must not override the transcript content") { + t.Fatalf("expected no-override guidance in prompt") + } +} + +func TestBuildProposalMessagesOmitsTranscriptDescriptionSectionWhenEmpty(t *testing.T) { + msgs, err := BuildProposalMessages(tinyTranscript(), tinyGlossary(), 0, " ") + if err != nil { + t.Fatalf("BuildProposalMessages error: %v", err) + } + combined := msgs[0].Content + "\n" + msgs[1].Content + if strings.Contains(combined, "Transcript description (background context only):") { + t.Fatalf("did not expect empty transcript description section in prompt") + } +} + func TestHomophonesModuleReplacementPolicy(t *testing.T) { m, err := New() if err != nil { diff --git a/internal/modules/homophones/prompt.go b/internal/modules/homophones/prompt.go index a59394f..f98e9ea 100644 --- a/internal/modules/homophones/prompt.go +++ b/internal/modules/homophones/prompt.go @@ -6,6 +6,7 @@ import ( "gitea.maximumdirect.net/eric/audita/internal/core/schema" "gitea.maximumdirect.net/eric/audita/internal/framework/contracts" + "gitea.maximumdirect.net/eric/audita/internal/framework/promptcontext" ) type promptSegment struct { @@ -24,7 +25,7 @@ type promptTranscriptSection struct { // BuildProposalMessages constrains corrections to conservative homophone and // mistranscription updates. -func BuildProposalMessages(transcript *schema.Transcript, glossary *schema.Glossary, sectionIndex int) ([]contracts.LLMMessage, error) { +func BuildProposalMessages(transcript *schema.Transcript, glossary *schema.Glossary, sectionIndex int, transcriptDescription string) ([]contracts.LLMMessage, error) { glossaryJSON, err := json.MarshalIndent(glossary, "", " ") if err != nil { return nil, fmt.Errorf("marshal glossary prompt context: %w", err) @@ -74,6 +75,7 @@ func BuildProposalMessages(transcript *schema.Transcript, glossary *schema.Gloss "- Return only changed segments; do not return entries for unchanged segments.\n" + "- confidence must be between 0.0 and 1.0.\n" + "- If no corrections are needed, return an empty corrections list.\n\n" + + promptcontext.TranscriptDescriptionBlock(transcriptDescription) + fmt.Sprintf("Protected glossary/context:\n%s\n\nTranscript section:\n%s", string(glossaryJSON), string(sectionJSON)) return []contracts.LLMMessage{ diff --git a/internal/modules/spoken_word/module.go b/internal/modules/spoken_word/module.go index 3d2a2ec..c4c5008 100644 --- a/internal/modules/spoken_word/module.go +++ b/internal/modules/spoken_word/module.go @@ -55,7 +55,11 @@ func (m *Module) Propose(ctx context.Context, req contracts.ProposalRequest) ([] if req.Section != nil { sectionIndex = req.Section.Index } - messages, err := BuildProposalMessages(sectionTranscript, req.Glossary, sectionIndex) + transcriptDescription := "" + if req.Config != nil { + transcriptDescription = req.Config.TranscriptDescription + } + messages, err := BuildProposalMessages(sectionTranscript, req.Glossary, sectionIndex, transcriptDescription) if err != nil { return nil, err } diff --git a/internal/modules/spoken_word/module_test.go b/internal/modules/spoken_word/module_test.go index 197f8e9..b88212f 100644 --- a/internal/modules/spoken_word/module_test.go +++ b/internal/modules/spoken_word/module_test.go @@ -62,7 +62,7 @@ func tinyGlossary() *schema.Glossary { } func TestBuildProposalMessagesContainsContextAndMeaningGuardrails(t *testing.T) { - msgs, err := BuildProposalMessages(tinyTranscript(), tinyGlossary(), 0) + msgs, err := BuildProposalMessages(tinyTranscript(), tinyGlossary(), 0, "") if err != nil { t.Fatalf("BuildProposalMessages error: %v", err) } @@ -99,6 +99,20 @@ func TestBuildProposalMessagesContainsContextAndMeaningGuardrails(t *testing.T) } } +func TestBuildProposalMessagesIncludesTranscriptDescriptionGuidance(t *testing.T) { + msgs, err := BuildProposalMessages(tinyTranscript(), tinyGlossary(), 0, "Participants are discussing raid logistics.") + if err != nil { + t.Fatalf("BuildProposalMessages error: %v", err) + } + combined := msgs[0].Content + "\n" + msgs[1].Content + if !strings.Contains(combined, "Transcript description (background context only):") { + t.Fatalf("expected transcript description section in prompt") + } + if !strings.Contains(combined, "Do not invent corrections, facts, names, events, motivations, or speaker intent based on this description.") { + t.Fatalf("expected anti-invention guidance in prompt") + } +} + func TestSpokenWordModuleReplacementPolicy(t *testing.T) { m, err := New() if err != nil { diff --git a/internal/modules/spoken_word/prompt.go b/internal/modules/spoken_word/prompt.go index 8ca5e59..35e17c5 100644 --- a/internal/modules/spoken_word/prompt.go +++ b/internal/modules/spoken_word/prompt.go @@ -6,6 +6,7 @@ import ( "gitea.maximumdirect.net/eric/audita/internal/core/schema" "gitea.maximumdirect.net/eric/audita/internal/framework/contracts" + "gitea.maximumdirect.net/eric/audita/internal/framework/promptcontext" ) type promptSegment struct { @@ -24,7 +25,7 @@ type promptTranscriptSection struct { // BuildProposalMessages constrains corrections to conservative dysfluency // cleanup with strict semantic preservation. -func BuildProposalMessages(transcript *schema.Transcript, glossary *schema.Glossary, sectionIndex int) ([]contracts.LLMMessage, error) { +func BuildProposalMessages(transcript *schema.Transcript, glossary *schema.Glossary, sectionIndex int, transcriptDescription string) ([]contracts.LLMMessage, error) { glossaryJSON, err := json.MarshalIndent(glossary, "", " ") if err != nil { return nil, fmt.Errorf("marshal glossary prompt context: %w", err) @@ -77,6 +78,7 @@ func BuildProposalMessages(transcript *schema.Transcript, glossary *schema.Gloss "- Return only changed segments; do not return entries for unchanged segments.\n" + "- confidence must be between 0.0 and 1.0.\n" + "- If no corrections are needed, return an empty corrections list.\n\n" + + promptcontext.TranscriptDescriptionBlock(transcriptDescription) + fmt.Sprintf("Protected glossary/context:\n%s\n\nTranscript section:\n%s", string(glossaryJSON), string(sectionJSON)) return []contracts.LLMMessage{