diff --git a/docs/internal/modules.md b/docs/internal/modules.md index 2346b5a..00b0a7a 100644 --- a/docs/internal/modules.md +++ b/docs/internal/modules.md @@ -37,9 +37,9 @@ Shared proposal-generation plumbing is centralized in: Module packages own: - prompt selection (`internal/prompts` prompt IDs). -`proposal_generation.BuildProposalVariables` encodes the glossary, selected -transcript section, and trimmed description once for all four modules. PromptKit -renders their embedded definitions from those variables and validates the +Each stage prepares the encoded glossary context and protected spellings once. +Proposal generation adds the selected transcript section and trimmed description +for each request. PromptKit renders the selected definition and validates the correction-set schema before Audita maps proposals. Domain validators still check confidence, applicability, protected glossary terms, and replacement policy. Model-facing segment IDs are one-based positions within the proposal diff --git a/docs/internal/validators.md b/docs/internal/validators.md index edaae24..9e234ab 100644 --- a/docs/internal/validators.md +++ b/docs/internal/validators.md @@ -120,10 +120,11 @@ is covered by [report assembly](diagnostics-reporting.md). ## Supplemental Validation Context -Request construction prepares one segment-position lookup and a matcher from the -selected context entries for all candidates. Context selection remains candidate-specific, -including the proposed replacement text. The independent protection view is used -by the deterministic guard and application check. +Each stage prepares a context matcher and indexes its selected glossary entries +by identity. Request construction uses a segment-position lookup and returns +matched entries in selection order for each candidate, including matches in the +replacement text. The deterministic guard and application check share the +stage's separate protection matcher. Each candidate includes the immediately preceding and following source segments available within its fixed chunk, labeled `preceding` or `following` with speaker, diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 33795ac..89d0047 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -562,6 +562,8 @@ are preserved. Readiness keeps the same late-stage diagnostics. ## R16 — P2: Reuse Prepared Glossary Views and Index Validation Context +**Status:** Completed. Each stage prepares shared protection and context matchers and serializes its proposal glossary variables once. Context gathers matched entry positions in selection order. Tests cover shared identities, ordering, exemptions, and request content. + **Category** Per-section rebuilds and per-candidate glossary scans in Stages 11 and 12. diff --git a/internal/framework/contracts/contracts.go b/internal/framework/contracts/contracts.go index e428d93..ac2869c 100644 --- a/internal/framework/contracts/contracts.go +++ b/internal/framework/contracts/contracts.go @@ -10,6 +10,7 @@ import ( "gitea.maximumdirect.net/eric/audita/v3/internal/core/modulecatalog" "gitea.maximumdirect.net/eric/audita/v3/internal/core/schema" "gitea.maximumdirect.net/eric/audita/v3/internal/framework/outcomes" + "gitea.maximumdirect.net/eric/audita/v3/internal/framework/promptcontext" "gitea.maximumdirect.net/eric/audita/v3/internal/framework/proposals" "gitea.maximumdirect.net/eric/audita/v3/internal/framework/structuredoutput" "gitea.maximumdirect.net/eric/audita/v3/internal/framework/validators" @@ -73,13 +74,14 @@ type ExecutionContext struct { StageIndex *int `json:"stage_index,omitempty"` StageID *string `json:"stage_id,omitempty"` // ReportDiagnosticError may be called concurrently; it must not affect processing. - ReportDiagnosticError func(error) `json:"-"` - Settings config.Stage `json:"-"` - WorkingTranscript *schema.Transcript `json:"-"` - Glossary []glossary.Entry `json:"-"` - ProtectedTerms []glossary.Term `json:"-"` - Section *SectionMetadata `json:"section,omitempty"` - DiagnosticsDir string `json:"diagnostics_dir,omitempty"` + ReportDiagnosticError func(error) `json:"-"` + Settings config.Stage `json:"-"` + WorkingTranscript *schema.Transcript `json:"-"` + Glossary []glossary.Entry `json:"-"` + ProtectedTerms []glossary.Term `json:"-"` + GlossaryVariables *promptcontext.GlossaryVariables `json:"-"` + Section *SectionMetadata `json:"section,omitempty"` + DiagnosticsDir string `json:"diagnostics_dir,omitempty"` } // ProposalRequest is the input to module proposal generation. diff --git a/internal/framework/promptcontext/glossary.go b/internal/framework/promptcontext/glossary.go new file mode 100644 index 0000000..13b55f9 --- /dev/null +++ b/internal/framework/promptcontext/glossary.go @@ -0,0 +1,32 @@ +package promptcontext + +import ( + "encoding/json" + "fmt" + + "gitea.maximumdirect.net/eric/audita/v3/internal/core/glossary" +) + +// GlossaryVariables contains the glossary inputs shared by a stage's proposals. +type GlossaryVariables struct { + ContextJSON string + ProtectionJSON string +} + +func PrepareGlossaryVariables(entries []glossary.Entry, terms []glossary.Term) (GlossaryVariables, error) { + contextJSON, err := json.MarshalIndent(entries, "", " ") + if err != nil { + return GlossaryVariables{}, fmt.Errorf("marshal glossary prompt context: %w", err) + } + protected := make([]string, 0, len(terms)) + for _, term := range terms { + if !term.Exempt { + protected = append(protected, term.Spelling) + } + } + protectionJSON, err := json.Marshal(protected) + if err != nil { + return GlossaryVariables{}, fmt.Errorf("marshal protected spellings: %w", err) + } + return GlossaryVariables{ContextJSON: string(contextJSON), ProtectionJSON: string(protectionJSON)}, nil +} diff --git a/internal/framework/proposal_generation/adapter_integration_test.go b/internal/framework/proposal_generation/adapter_integration_test.go index 1c1fe4a..b320014 100644 --- a/internal/framework/proposal_generation/adapter_integration_test.go +++ b/internal/framework/proposal_generation/adapter_integration_test.go @@ -14,6 +14,7 @@ import ( "gitea.maximumdirect.net/eric/audita/v3/internal/core/config" "gitea.maximumdirect.net/eric/audita/v3/internal/core/schema" "gitea.maximumdirect.net/eric/audita/v3/internal/framework/llm" + "gitea.maximumdirect.net/eric/audita/v3/internal/framework/promptcontext" "gitea.maximumdirect.net/eric/audita/v3/internal/framework/proposals" "gitea.maximumdirect.net/eric/audita/v3/internal/framework/validators" "gitea.maximumdirect.net/eric/audita/v3/internal/prompts" @@ -60,7 +61,11 @@ func TestRepairedProposalStillFacesDomainChecks(t *testing.T) { t.Fatal(err) } transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Speaker: "A", Start: 0, End: 1, Text: "gestures"}}} - vars, err := BuildProposalVariables(transcript, nil, 0, "") + prepared, err := promptcontext.PrepareGlossaryVariables(nil, nil) + if err != nil { + t.Fatal(err) + } + vars, err := buildProposalVariables(transcript, prepared, 0, "") if err != nil { t.Fatal(err) } diff --git a/internal/framework/proposal_generation/glossary_projection_test.go b/internal/framework/proposal_generation/glossary_projection_test.go index 97db8a8..cc516a9 100644 --- a/internal/framework/proposal_generation/glossary_projection_test.go +++ b/internal/framework/proposal_generation/glossary_projection_test.go @@ -6,6 +6,7 @@ import ( "gitea.maximumdirect.net/eric/audita/v3/internal/core/glossary" "gitea.maximumdirect.net/eric/audita/v3/internal/core/schema" + "gitea.maximumdirect.net/eric/audita/v3/internal/framework/promptcontext" ) func TestProposalContextPreservesDistinctEntries(t *testing.T) { @@ -13,7 +14,11 @@ func TestProposalContextPreservesDistinctEntries(t *testing.T) { {Collection: "spells", ID: "shield", Name: "Shield", Category: "spell", Description: "A spell"}, {Collection: "items", ID: "shield", Name: "Shield", Category: "item", Description: "An item"}, } - variables, err := BuildProposalVariables(&schema.Transcript{}, entries, 0, "") + prepared, err := promptcontext.PrepareGlossaryVariables(entries, nil) + if err != nil { + t.Fatal(err) + } + variables, err := buildProposalVariables(&schema.Transcript{}, prepared, 0, "") if err != nil { t.Fatal(err) } diff --git a/internal/framework/proposal_generation/module_proposal.go b/internal/framework/proposal_generation/module_proposal.go index 0d48692..c0fbdd7 100644 --- a/internal/framework/proposal_generation/module_proposal.go +++ b/internal/framework/proposal_generation/module_proposal.go @@ -2,31 +2,22 @@ package proposal_generation import ( "context" - "encoding/json" "fmt" "strings" - "gitea.maximumdirect.net/eric/audita/v3/internal/core/glossary" - "gitea.maximumdirect.net/eric/audita/v3/internal/core/schema" "gitea.maximumdirect.net/eric/audita/v3/internal/framework/contracts" + "gitea.maximumdirect.net/eric/audita/v3/internal/framework/promptcontext" "gitea.maximumdirect.net/eric/audita/v3/internal/framework/stagename" "gitea.maximumdirect.net/eric/audita/v3/internal/prompts" ) -type ProposalVariableBuilder func(transcript *schema.Transcript, entries []glossary.Entry, sectionIndex int, transcriptDescription string) (map[string]string, error) - type ModuleProposalRequest struct { ProposalRequest contracts.ProposalRequest PromptID string - BuildVariables ProposalVariableBuilder } -// ExecuteModuleProposal runs shared proposal generation plumbing for one -// module, leaving only module-specific prompt message building at call sites. +// ExecuteModuleProposal prepares and runs a module's selected prompt. func ExecuteModuleProposal(ctx context.Context, req ModuleProposalRequest) (contracts.ProposalResult, error) { - if req.BuildVariables == nil { - return contracts.ProposalResult{}, fmt.Errorf("proposal variable builder is required") - } if strings.TrimSpace(req.PromptID) == "" { return contracts.ProposalResult{}, fmt.Errorf("prompt ID must not be empty") } @@ -38,9 +29,17 @@ func ExecuteModuleProposal(ctx context.Context, req ModuleProposalRequest) (cont transcriptDescription := req.ProposalRequest.Settings.Context.Description - variables, err := req.BuildVariables( + glossary := req.ProposalRequest.GlossaryVariables + if glossary == nil { + prepared, err := promptcontext.PrepareGlossaryVariables(req.ProposalRequest.Glossary, req.ProposalRequest.ProtectedTerms) + if err != nil { + return contracts.ProposalResult{}, err + } + glossary = &prepared + } + variables, err := buildProposalVariables( req.ProposalRequest.WorkingTranscript, - req.ProposalRequest.Glossary, + *glossary, sectionIndex, transcriptDescription, ) @@ -48,18 +47,6 @@ func ExecuteModuleProposal(ctx context.Context, req ModuleProposalRequest) (cont return contracts.ProposalResult{}, err } - protected := make([]string, 0, len(req.ProposalRequest.ProtectedTerms)) - for _, term := range req.ProposalRequest.ProtectedTerms { - if !term.Exempt { - protected = append(protected, term.Spelling) - } - } - payload, err := json.Marshal(protected) - if err != nil { - return contracts.ProposalResult{}, fmt.Errorf("marshal protected spellings: %w", err) - } - variables["ProtectedTermsJSON"] = string(payload) - promptMetadata, ok := prompts.LookupMetadata(req.PromptID) if !ok { return contracts.ProposalResult{}, fmt.Errorf("unknown prompt ID %q", req.PromptID) diff --git a/internal/framework/proposal_generation/module_proposal_test.go b/internal/framework/proposal_generation/module_proposal_test.go index befa88f..7ab9fa1 100644 --- a/internal/framework/proposal_generation/module_proposal_test.go +++ b/internal/framework/proposal_generation/module_proposal_test.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" "errors" - "reflect" + "strings" "testing" "gitea.maximumdirect.net/eric/audita/v3/internal/core/config" @@ -29,11 +29,6 @@ func TestExecuteModuleProposalBuildsMessagesFromSectionAndDescription(t *testing transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Speaker: "A", Start: 0, End: 1, Text: "teh"}}} glossary := []domain.Entry{{ID: "x", Name: "X"}} - var gotSectionIndex int - var gotDescription string - var gotTranscript *schema.Transcript - var gotGlossary []domain.Entry - out, err := ExecuteModuleProposal(context.Background(), ModuleProposalRequest{ ProposalRequest: contracts.ProposalRequest{ ExecutionContext: contracts.ExecutionContext{ @@ -51,32 +46,20 @@ func TestExecuteModuleProposalBuildsMessagesFromSectionAndDescription(t *testing LLMClient: client, }, PromptID: prompts.PromptIDModuleGrammarProposal, - BuildVariables: func(inTranscript *schema.Transcript, inGlossary []domain.Entry, sectionIndex int, transcriptDescription string) (map[string]string, error) { - gotSectionIndex = sectionIndex - gotDescription = transcriptDescription - gotTranscript = inTranscript - gotGlossary = inGlossary - return map[string]string{"SectionJSON": "[]"}, nil - }, }) if err != nil { t.Fatalf("ExecuteModuleProposal error: %v", err) } - if gotSectionIndex != 7 { - t.Fatalf("section index: got=%d want=%d", gotSectionIndex, 7) - } - if gotDescription != cfg.Context.Description { - t.Fatalf("transcript description: got=%q want=%q", gotDescription, cfg.Context.Description) - } - if gotTranscript != transcript { - t.Fatalf("expected shared transcript pointer") - } - if !reflect.DeepEqual(gotGlossary, glossary) { - t.Fatalf("expected glossary entries") - } if len(client.calls) != 1 || client.calls[0].PromptID != prompts.PromptIDModuleGrammarProposal || client.calls[0].Context.SectionIndex == nil || *client.calls[0].Context.SectionIndex != 7 { t.Fatalf("unexpected stage name calls: %+v", client.calls) } + variables := client.calls[0].Variables + if !strings.Contains(variables["SectionJSON"], `"section_index": 7`) || + !strings.Contains(variables["SectionJSON"], "teh") || + !strings.Contains(variables["TranscriptDescriptionBlock"], cfg.Context.Description) || + !strings.Contains(variables["GlossaryJSON"], `"name": "X"`) { + t.Fatalf("missing section, glossary, or description: %+v", variables) + } if got := client.calls[0].Variables["ProtectedTermsJSON"]; got != `["Separate protection"]` { t.Fatalf("protected spellings = %s", got) } @@ -86,40 +69,10 @@ func TestExecuteModuleProposalBuildsMessagesFromSectionAndDescription(t *testing } func TestExecuteModuleProposalValidatesInputs(t *testing.T) { - if _, err := ExecuteModuleProposal(context.Background(), ModuleProposalRequest{}); err == nil { - t.Fatalf("expected missing message builder error") - } - - _, err := ExecuteModuleProposal(context.Background(), ModuleProposalRequest{ - BuildVariables: func(transcript *schema.Transcript, glossary []domain.Entry, sectionIndex int, transcriptDescription string) (map[string]string, error) { - return nil, nil - }, - }) - if err == nil { - t.Fatalf("expected empty prompt ID error") - } - - _, err = ExecuteModuleProposal(context.Background(), ModuleProposalRequest{ - PromptID: "missing.prompt.id", - BuildVariables: func(transcript *schema.Transcript, glossary []domain.Entry, sectionIndex int, transcriptDescription string) (map[string]string, error) { - return map[string]string{"SectionJSON": "[]"}, nil - }, - }) - if err == nil { - t.Fatalf("expected unknown prompt ID error") - } -} - -func TestExecuteModuleProposalPropagatesBuilderError(t *testing.T) { - wantErr := errors.New("builder failed") - _, err := ExecuteModuleProposal(context.Background(), ModuleProposalRequest{ - PromptID: prompts.PromptIDModuleGlossaryProposal, - BuildVariables: func(transcript *schema.Transcript, glossary []domain.Entry, sectionIndex int, transcriptDescription string) (map[string]string, error) { - return nil, wantErr - }, - }) - if !errors.Is(err, wantErr) { - t.Fatalf("expected builder error, got %v", err) + for _, promptID := range []string{"", "missing.prompt.id"} { + if _, err := ExecuteModuleProposal(context.Background(), ModuleProposalRequest{PromptID: promptID}); err == nil { + t.Fatalf("invalid prompt ID accepted: %q", promptID) + } } } @@ -134,9 +87,6 @@ func TestExecuteModuleProposalPreservesFatalOperation(t *testing.T) { LLMClient: client, }, PromptID: prompts.PromptIDModuleGrammarProposal, - BuildVariables: func(*schema.Transcript, []domain.Entry, int, string) (map[string]string, error) { - return map[string]string{"SectionJSON": "[]"}, nil - }, }) if !errors.Is(err, cause) || len(result.Operations) != 1 || result.Operations[0].Reference.ModuleInstance != "grammar_2" || result.Operations[0].Reference.SectionIndex != 4 { t.Fatalf("fatal operation lost: %+v, %v", result, err) @@ -157,7 +107,7 @@ func TestModuleProposalMapsLocalReferencesWithoutGuessing(t *testing.T) { RunSpec: contracts.ModuleRunSpec{ModuleKey: "grammar", InstanceName: "grammar", ReplacementPolicy: proposals.ReplacementPolicyRequireUnique}, LLMClient: client, }, - PromptID: prompts.PromptIDModuleGrammarProposal, BuildVariables: BuildProposalVariables, + PromptID: prompts.PromptIDModuleGrammarProposal, }) if err != nil { t.Fatal(err) diff --git a/internal/framework/proposal_generation/variables.go b/internal/framework/proposal_generation/variables.go index 53dbcec..48cd547 100644 --- a/internal/framework/proposal_generation/variables.go +++ b/internal/framework/proposal_generation/variables.go @@ -1,28 +1,21 @@ package proposal_generation import ( - "encoding/json" "fmt" - "gitea.maximumdirect.net/eric/audita/v3/internal/core/glossary" "gitea.maximumdirect.net/eric/audita/v3/internal/core/schema" "gitea.maximumdirect.net/eric/audita/v3/internal/framework/promptcontext" ) -// BuildProposalVariables preserves the domain inputs while PromptKit renders -// the selected embedded proposal definition. -func BuildProposalVariables(transcript *schema.Transcript, entries []glossary.Entry, sectionIndex int, transcriptDescription string) (map[string]string, error) { - glossaryJSON, err := json.MarshalIndent(entries, "", " ") - if err != nil { - return nil, fmt.Errorf("marshal glossary prompt context: %w", err) - } +func buildProposalVariables(transcript *schema.Transcript, glossary promptcontext.GlossaryVariables, sectionIndex int, transcriptDescription string) (map[string]string, error) { sectionJSON, err := promptcontext.MarshalTranscriptSectionJSON(transcript, sectionIndex) if err != nil { return nil, fmt.Errorf("marshal transcript prompt context: %w", err) } return map[string]string{ "TranscriptDescriptionBlock": promptcontext.TranscriptDescriptionBlock(transcriptDescription), - "GlossaryJSON": string(glossaryJSON), + "GlossaryJSON": glossary.ContextJSON, + "ProtectedTermsJSON": glossary.ProtectionJSON, "SectionJSON": string(sectionJSON), }, nil } diff --git a/internal/framework/proposal_generation/variables_test.go b/internal/framework/proposal_generation/variables_test.go index 1494ef8..205894a 100644 --- a/internal/framework/proposal_generation/variables_test.go +++ b/internal/framework/proposal_generation/variables_test.go @@ -6,19 +6,24 @@ import ( domain "gitea.maximumdirect.net/eric/audita/v3/internal/core/glossary" "gitea.maximumdirect.net/eric/audita/v3/internal/core/schema" + "gitea.maximumdirect.net/eric/audita/v3/internal/framework/promptcontext" ) func TestBuildProposalVariablesPreservesSectionGlossaryAndDescription(t *testing.T) { transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Speaker: "A", Start: 0, End: 1, Text: "gestures"}}} glossary := []domain.Entry{{ID: "jesters", Name: "Jesters"}} - variables, err := BuildProposalVariables(transcript, glossary, 0, " Fantasy names ") + prepared, err := promptcontext.PrepareGlossaryVariables(glossary, nil) + if err != nil { + t.Fatal(err) + } + variables, err := buildProposalVariables(transcript, prepared, 0, " Fantasy names ") if err != nil { t.Fatal(err) } if !strings.Contains(variables["SectionJSON"], `"section_index": 0`) || !strings.Contains(variables["SectionJSON"], "gestures") || !strings.Contains(variables["GlossaryJSON"], "Jesters") || !strings.Contains(variables["TranscriptDescriptionBlock"], "Fantasy names") { t.Fatalf("encoded variables incomplete: %+v", variables) } - empty, err := BuildProposalVariables(transcript, glossary, 0, " ") + empty, err := buildProposalVariables(transcript, prepared, 0, " ") if err != nil { t.Fatal(err) } diff --git a/internal/framework/runner/runner.go b/internal/framework/runner/runner.go index e913458..d6e52b3 100644 --- a/internal/framework/runner/runner.go +++ b/internal/framework/runner/runner.go @@ -15,6 +15,7 @@ import ( "gitea.maximumdirect.net/eric/audita/v3/internal/framework/contracts" "gitea.maximumdirect.net/eric/audita/v3/internal/framework/llm" "gitea.maximumdirect.net/eric/audita/v3/internal/framework/outcomes" + "gitea.maximumdirect.net/eric/audita/v3/internal/framework/promptcontext" "gitea.maximumdirect.net/eric/audita/v3/internal/framework/proposals" "gitea.maximumdirect.net/eric/audita/v3/internal/framework/validators" validatormetadata "gitea.maximumdirect.net/eric/audita/v3/internal/validators/metadata" @@ -163,6 +164,11 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (RunOutput, error) { } policy := module.ReplacementPolicy() + glossaryVariables, err := promptcontext.PrepareGlossaryVariables(spec.Glossary, spec.ProtectedTerms) + if err != nil { + return snapshot(), fmt.Errorf("module %q glossary preparation: %w", spec.InstanceName, err) + } + glossaryView := validators.PrepareGlossaryView(spec.Glossary, spec.ProtectedTerms) pipelineResult, pipelineErr := runModulePipeline(ctx, collectSectionProposalsInput{ Module: module, Spec: spec, @@ -172,6 +178,8 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (RunOutput, error) { DiagnosticsDir: input.ProposalDiagnosticsDir, ValidationClient: spec.ValidationClient, ValidationDiagnosticsDir: input.ValidationDiagnosticsDir, + GlossaryVariables: &glossaryVariables, + GlossaryView: glossaryView, ReportDiagnosticError: input.ReportDiagnosticError, }) if input.CaptureCandidates { @@ -198,7 +206,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (RunOutput, error) { return snapshot(), fmt.Errorf("module %q failed: %w", spec.InstanceName, pipelineErr) } - updated, applied, skipped, appliedBySection, applyErr := applyWithinSections(sections, pipelineResult.Approved, policy, validators.NewProtectedVocabulary(spec.ProtectedTerms), spec.ModuleKey == modulecatalog.KeySpokenWord) + updated, applied, skipped, appliedBySection, applyErr := applyWithinSections(sections, pipelineResult.Approved, policy, glossaryView.Protection, spec.ModuleKey == modulecatalog.KeySpokenWord) if applyErr != nil { return snapshot(), fmt.Errorf("module %q application failed: %w", spec.InstanceName, applyErr) } @@ -286,6 +294,8 @@ type collectSectionProposalsInput struct { DiagnosticsDir string ValidationClient contracts.StructuredLLMClient ValidationDiagnosticsDir string + GlossaryVariables *promptcontext.GlossaryVariables + GlossaryView *validators.GlossaryView } type sectionProposalResult struct { @@ -343,6 +353,7 @@ func collectSectionProposals(ctx context.Context, input collectSectionProposalsI WorkingTranscript: transcriptFromSection(section), Glossary: input.Spec.Glossary, ProtectedTerms: input.Spec.ProtectedTerms, + GlossaryVariables: input.GlossaryVariables, Section: &meta, DiagnosticsDir: input.DiagnosticsDir, ReportDiagnosticError: input.ReportDiagnosticError, @@ -488,6 +499,7 @@ func runModulePipeline(ctx context.Context, input collectSectionProposalsInput) SectionEnriched: enriched, ValidationLLMClient: input.ValidationClient, DiagnosticsDir: input.ValidationDiagnosticsDir, + GlossaryView: input.GlossaryView, ReportDiagnosticError: input.ReportDiagnosticError, }) setErr(err) @@ -571,6 +583,7 @@ type validateSectionCandidatesInput struct { SectionEnriched []proposals.EnrichedCorrectionProposal ValidationLLMClient contracts.StructuredLLMClient DiagnosticsDir string + GlossaryView *validators.GlossaryView } type validateSectionCandidatesResult struct { @@ -618,6 +631,7 @@ func validateSectionCandidates(ctx context.Context, input validateSectionCandida ReplacementPolicy: input.Policy, Glossary: input.Spec.Glossary, ProtectedTerms: input.Spec.ProtectedTerms, + GlossaryView: input.GlossaryView, Settings: input.Spec.Settings, LLMClient: input.ValidationLLMClient, DiagnosticsWriter: diagnosticsWriter, diff --git a/internal/framework/validators/deterministic.go b/internal/framework/validators/deterministic.go index b1f6b72..7ba69b8 100644 --- a/internal/framework/validators/deterministic.go +++ b/internal/framework/validators/deterministic.go @@ -137,7 +137,12 @@ func (v NoEffectValidator) Validate(_ context.Context, req Request) (Result, err } func ValidateProtectedTerms(req Request) (Result, error) { - vocab := NewProtectedVocabulary(req.ProtectedTerms) + var vocab ProtectedVocabulary + if req.GlossaryView != nil { + vocab = req.GlossaryView.Protection + } else { + vocab = NewProtectedVocabulary(req.ProtectedTerms) + } decisions := make([]Decision, 0, len(req.CandidateProposal)) glossaryStage := req.ModuleKey == modulecatalog.KeyGlossary for _, c := range req.CandidateProposal { diff --git a/internal/framework/validators/llm_context.go b/internal/framework/validators/llm_context.go index 9d631ba..9d961a5 100644 --- a/internal/framework/validators/llm_context.go +++ b/internal/framework/validators/llm_context.go @@ -1,15 +1,43 @@ package validators import ( + "slices" + "gitea.maximumdirect.net/eric/audita/v3/internal/core/chunking" "gitea.maximumdirect.net/eric/audita/v3/internal/core/glossary" "gitea.maximumdirect.net/eric/audita/v3/internal/core/schema" ) +// GlossaryView holds the read-only matching data shared by a stage's validators +// and apply-time checks. +type GlossaryView struct { + Protection ProtectedVocabulary + context glossaryContext +} + +func PrepareGlossaryView(entries []glossary.Entry, protected []glossary.Term) *GlossaryView { + return &GlossaryView{Protection: NewProtectedVocabulary(protected), context: prepareGlossaryContext(entries)} +} + +type glossaryContext struct { + entries []glossary.Entry + positions map[glossary.Identity][]int + vocabulary ProtectedVocabulary +} + +func prepareGlossaryContext(entries []glossary.Entry) glossaryContext { + positions := make(map[glossary.Identity][]int, len(entries)) + for i, entry := range entries { + identity := glossary.Identity{Collection: entry.Collection, ID: entry.ID} + positions[identity] = append(positions[identity], i) + } + return glossaryContext{entries: entries, positions: positions, vocabulary: NewProtectedVocabulary(glossary.Vocabulary(entries, nil))} +} + // validationContext supplies adjacent source segments within the current chunk // and glossary definitions mentioned in the candidate or that local context. // These fields travel in each item so batching accounts for their size. -func validationContext(segments []schema.Segment, position int, selected []glossary.Entry, vocabulary ProtectedVocabulary, corrected string) ([]NeighboringSegment, []glossary.Entry) { +func (view glossaryContext) validationContext(segments []schema.Segment, position int, corrected string) ([]NeighboringSegment, []glossary.Entry) { var neighbors []NeighboringSegment source := segments[position].Text if position > 0 { @@ -23,19 +51,20 @@ func validationContext(segments []schema.Segment, position int, selected []gloss for _, neighbor := range neighbors { texts = append(texts, neighbor.Text) } + mentioned := make(map[glossary.Identity]struct{}) + for _, text := range texts { + for _, occurrence := range view.vocabulary.occurrences(text) { + mentioned[occurrence.identity] = struct{}{} + } + } + var matched []int + for identity := range mentioned { + matched = append(matched, view.positions[identity]...) + } + slices.Sort(matched) var entries []glossary.Entry - if selected != nil { - mentioned := make(map[glossary.Identity]bool) - for _, text := range texts { - for identity := range vocabulary.occurrencesByIdentity(text) { - mentioned[identity] = true - } - } - for _, entry := range selected { - if mentioned[glossary.Identity{Collection: entry.Collection, ID: entry.ID}] { - entries = append(entries, entry) - } - } + for _, index := range matched { + entries = append(entries, view.entries[index]) } return neighbors, entries } diff --git a/internal/framework/validators/llm_validators.go b/internal/framework/validators/llm_validators.go index 853b6fc..66cb22e 100644 --- a/internal/framework/validators/llm_validators.go +++ b/internal/framework/validators/llm_validators.go @@ -9,7 +9,6 @@ import ( "gitea.maximumdirect.net/eric/audita/v3/internal/core/chunking" "gitea.maximumdirect.net/eric/audita/v3/internal/core/config" - "gitea.maximumdirect.net/eric/audita/v3/internal/core/glossary" "gitea.maximumdirect.net/eric/audita/v3/internal/core/schema" "gitea.maximumdirect.net/eric/audita/v3/internal/framework/outcomes" "gitea.maximumdirect.net/eric/audita/v3/internal/framework/proposals" @@ -254,7 +253,12 @@ func BuildLLMValidationRequest(validatorName string, validatorType LLMValidatorT immediate := make([]Decision, 0) var segments []schema.Segment positions := make(map[int]int) - vocabulary := NewProtectedVocabulary(glossary.Vocabulary(req.Glossary, nil)) + var glossary glossaryContext + if req.GlossaryView != nil { + glossary = req.GlossaryView.context + } else { + glossary = prepareGlossaryContext(req.Glossary) + } if req.WorkingTranscript != nil { segments = req.WorkingTranscript.Segments for i, seg := range segments { @@ -276,7 +280,7 @@ func BuildLLMValidationRequest(validatorName string, validatorType LLMValidatorT continue } - neighbors, glossaryEntries := validationContext(segments, position, req.Glossary, vocabulary, preview.CorrectedSegmentText) + neighbors, glossaryEntries := glossary.validationContext(segments, position, preview.CorrectedSegmentText) items = append(items, LLMValidationItem{ CorrectionIndex: p.ProposalIndex, SegmentID: p.TargetSegmentID, diff --git a/internal/framework/validators/models.go b/internal/framework/validators/models.go index fdc061b..88042da 100644 --- a/internal/framework/validators/models.go +++ b/internal/framework/validators/models.go @@ -45,6 +45,7 @@ type Request struct { ReplacementPolicy proposals.ReplacementPolicy `json:"replacement_policy"` Glossary []glossary.Entry `json:"-"` ProtectedTerms []glossary.Term `json:"-"` + GlossaryView *GlossaryView `json:"-"` Settings config.Stage `json:"-"` LLMClient StructuredLLMClient `json:"-"` DiagnosticsWriter InteractionDiagnosticsWriter `json:"-"` diff --git a/internal/framework/validators/vocabulary_test.go b/internal/framework/validators/vocabulary_test.go index b208084..304101c 100644 --- a/internal/framework/validators/vocabulary_test.go +++ b/internal/framework/validators/vocabulary_test.go @@ -110,13 +110,14 @@ func TestExemptGlossarySpellingKeepsLongestMatchBoundary(t *testing.T) { func TestValidationContextIncludesEachSharedDefinition(t *testing.T) { entries := []glossary.Entry{ + {Collection: "items", ID: "sword", Name: "Sword", Category: "item"}, {Collection: "spells", ID: "shield", Name: "Shield", Category: "spell"}, {Collection: "items", ID: "shield", Name: "Shield", Category: "item"}, - {Collection: "items", ID: "sword", Name: "Sword", Category: "item"}, + {Collection: "items", ID: "bow", Name: "Bow", Category: "item"}, } - segments := []schema.Segment{{ID: 1, Text: "Shield"}} - _, selected := validationContext(segments, 0, entries, NewProtectedVocabulary(glossary.Vocabulary(entries, nil)), "Barrier") - if len(selected) != 2 || selected[0].Category != "spell" || selected[1].ID != "shield" || selected[1].Collection != "items" { + segments := []schema.Segment{{ID: 1, Text: "Shield Shield"}} + _, selected := prepareGlossaryContext(entries).validationContext(segments, 0, "Sword") + if len(selected) != 3 || selected[0].ID != "sword" || selected[1].Category != "spell" || selected[2].ID != "shield" || selected[2].Collection != "items" { t.Fatalf("selected=%+v", selected) } } diff --git a/internal/modules/glossary/module.go b/internal/modules/glossary/module.go index 7983307..5e3110c 100644 --- a/internal/modules/glossary/module.go +++ b/internal/modules/glossary/module.go @@ -39,6 +39,5 @@ func (m *Module) Propose(ctx context.Context, req contracts.ProposalRequest) (co return proposal_generation.ExecuteModuleProposal(ctx, proposal_generation.ModuleProposalRequest{ ProposalRequest: req, PromptID: prompts.PromptIDModuleGlossaryProposal, - BuildVariables: proposal_generation.BuildProposalVariables, }) } diff --git a/internal/modules/grammar/module.go b/internal/modules/grammar/module.go index db65354..ee21670 100644 --- a/internal/modules/grammar/module.go +++ b/internal/modules/grammar/module.go @@ -39,6 +39,5 @@ func (m *Module) Propose(ctx context.Context, req contracts.ProposalRequest) (co return proposal_generation.ExecuteModuleProposal(ctx, proposal_generation.ModuleProposalRequest{ ProposalRequest: req, PromptID: prompts.PromptIDModuleGrammarProposal, - BuildVariables: proposal_generation.BuildProposalVariables, }) } diff --git a/internal/modules/homophones/module.go b/internal/modules/homophones/module.go index e6bb129..b82f6cc 100644 --- a/internal/modules/homophones/module.go +++ b/internal/modules/homophones/module.go @@ -39,6 +39,5 @@ func (m *Module) Propose(ctx context.Context, req contracts.ProposalRequest) (co return proposal_generation.ExecuteModuleProposal(ctx, proposal_generation.ModuleProposalRequest{ ProposalRequest: req, PromptID: prompts.PromptIDModuleHomophonesProposal, - BuildVariables: proposal_generation.BuildProposalVariables, }) } diff --git a/internal/modules/spoken_word/module.go b/internal/modules/spoken_word/module.go index b817480..f13b141 100644 --- a/internal/modules/spoken_word/module.go +++ b/internal/modules/spoken_word/module.go @@ -39,6 +39,5 @@ func (m *Module) Propose(ctx context.Context, req contracts.ProposalRequest) (co return proposal_generation.ExecuteModuleProposal(ctx, proposal_generation.ModuleProposalRequest{ ProposalRequest: req, PromptID: prompts.PromptIDModuleSpokenWordProposal, - BuildVariables: proposal_generation.BuildProposalVariables, }) }