diff --git a/docs/internal/llm-runtime.md b/docs/internal/llm-runtime.md index d222a3f..bec8409 100644 --- a/docs/internal/llm-runtime.md +++ b/docs/internal/llm-runtime.md @@ -16,6 +16,8 @@ credential source names for artifact redaction without inspecting unrelated environment values. See the [integration contract](../integrations/openai-compatible-llm.md) for failure behavior and the [configuration reference](../config.md) for sources. +Proposal calls can retain the original prompt, effective variables, prepared messages, and final response for section-local repair replay. The adapter renders the repair definition with the selected profile, appends branch history to the original prompt, and checks the prepared prefix before execution. The requested repair definition is retained in call attribution alongside the native base-prompt metadata. + The observation collector tracks logical calls and execution events. Ordinary reports use compact safe outcomes; request and response evidence is optional debug material. The runner preserves ordered pipeline traversal and deterministic diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 50aa3cb..5b3a649 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -93,6 +93,8 @@ validator responses remains [deferred](future.md#domain-aware-response-recovery) ## Stage 3 — Carry the original exchange through proposal generation and the adapter +**Completed.** + 1. Use `internal/framework/structuredoutput` for internal conversation data shared by the role client and runner. Extend the existing structured request with optional repair replay data. Keep the repair definition as the diff --git a/internal/framework/contracts/contracts.go b/internal/framework/contracts/contracts.go index ac2869c..f5163c0 100644 --- a/internal/framework/contracts/contracts.go +++ b/internal/framework/contracts/contracts.go @@ -37,16 +37,17 @@ type StructuredCompletionResponse = structuredoutput.Response // ModuleRunSpec identifies one resolved module instance in a pipeline. type ModuleRunSpec struct { - ModuleKey string `json:"module_key"` - InstanceName string `json:"instance_name"` - ReplacementPolicy proposals.ReplacementPolicy `json:"replacement_policy"` - Index int `json:"-"` - ID *string `json:"-"` - Settings config.Stage `json:"-"` - Glossary []glossary.Entry `json:"-"` - ProtectedTerms []glossary.Term `json:"-"` - ProposalClient StructuredLLMClient `json:"-"` - ValidationClient StructuredLLMClient `json:"-"` + ModuleKey string `json:"module_key"` + InstanceName string `json:"instance_name"` + ReplacementPolicy proposals.ReplacementPolicy `json:"replacement_policy"` + Index int `json:"-"` + ID *string `json:"-"` + Settings config.Stage `json:"-"` + Glossary []glossary.Entry `json:"-"` + ProtectedTerms []glossary.Term `json:"-"` + CaptureProposalExchange bool `json:"-"` + ProposalClient StructuredLLMClient `json:"-"` + ValidationClient StructuredLLMClient `json:"-"` } // SectionMetadata captures chunk-level metadata without embedding full segment payloads. @@ -92,6 +93,7 @@ type ProposalRequest struct { } type ProposalResult struct { + Exchange *structuredoutput.Exchange `json:"-"` Proposals []proposals.CorrectionProposal `json:"proposals,omitempty"` Operations []outcomes.Operation `json:"operations,omitempty"` } diff --git a/internal/framework/llm/adapter.go b/internal/framework/llm/adapter.go index df350e8..d5d9a5b 100644 --- a/internal/framework/llm/adapter.go +++ b/internal/framework/llm/adapter.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "reflect" "sync" "time" @@ -72,6 +73,7 @@ func (c *RoleClient) CompleteStructured(parent context.Context, req structuredou var record structuredoutput.CallRecord if c.hooks.OnCall != nil || c.hooks.OnEventWithCall != nil { record.CallID = c.runtime.nextCallID.Add(1) + record.RequestedPromptID = req.PromptID record.Context = copyContext(req.Context) record.Context.Role = c.role } @@ -106,8 +108,27 @@ func (c *RoleClient) CompleteStructured(parent context.Context, req structuredou for name, value := range prompts.SharedVariables() { vars[name] = value } + baseID, baseVars := req.PromptID, vars + var suffix []pk.RenderedMessage + if req.Replay != nil { + if req.Replay.Exchange == nil { + return response, &structuredoutput.Failure{Class: structuredoutput.FailureFatal, Reason: outcomes.ReasonSetupFailure, Message: "repair exchange is unavailable"} + } + instruction, renderErr := c.runtime.Engine.Prepare(ctx, pk.RunRequest{PromptID: req.PromptID, ProfileID: c.profileID, Vars: vars}) + if renderErr != nil { + return response, classifiedFailure(parent, ctx, renderErr, nil) + } + response.RepairInstruction = convertMessages(instruction.Messages) + exchange := req.Replay.Exchange + baseID, baseVars = exchange.PromptID, exchange.Variables + suffix = append(suffix, pk.RenderedMessage{Role: pk.RoleAssistant, Content: exchange.FinalContent}) + for _, message := range req.Replay.Turns { + suffix = append(suffix, pk.RenderedMessage{Role: message.Role, Content: message.Content}) + } + suffix = append(suffix, instruction.Messages...) + } handle, prepErr := c.runtime.Engine.PrepareExecution(ctx, pk.RunRequest{ - PromptID: req.PromptID, ProfileID: c.profileID, Vars: vars, + PromptID: baseID, ProfileID: c.profileID, Vars: baseVars, AppendedMessages: suffix, TransientRetries: &c.settings.TransientRetries, Validation: contract, Observer: func(event pk.ExecutionEvent) { @@ -130,6 +151,12 @@ func (c *RoleClient) CompleteStructured(parent context.Context, req structuredou } defer handle.Discard() details := handle.Details() + if req.Replay != nil { + prefix := req.Replay.Exchange.Messages + if len(details.Messages) < len(prefix) || !reflect.DeepEqual(convertMessages(details.Messages[:len(prefix)]), prefix) { + return response, &structuredoutput.Failure{Class: structuredoutput.FailureFatal, Reason: outcomes.ReasonInternalFailure, Message: "repair conversation prefix changed"} + } + } if name := details.EffectiveModelParams.APIKeyEnv; name != "" { c.credentialMu.Lock() if c.credentialNames == nil { @@ -168,6 +195,13 @@ func (c *RoleClient) CompleteStructured(parent context.Context, req structuredou } return response, &structuredoutput.Failure{Class: structuredoutput.FailureFatal, Reason: outcomes.ReasonInternalFailure, Message: "validated structured output could not be decoded", Cause: decodeErr} } + if req.CaptureExchange && req.Replay == nil { + frozen := make(map[string]string, len(vars)) + for key, value := range vars { + frozen[key] = value + } + response.Exchange = &structuredoutput.Exchange{PromptID: req.PromptID, Variables: frozen, Messages: convertMessages(details.Messages), FinalContent: result.RawOutput} + } response.Content = json.RawMessage(result.RawOutput) response.Provider = "openai-compatible" return response, nil @@ -277,3 +311,11 @@ func (c *RoleClient) ConfiguredSecrets(ctx context.Context) []string { c.credentialMu.Unlock() return c.runtime.ConfiguredSecrets(ctx, names...) } + +func convertMessages(messages []pk.RenderedMessage) []structuredoutput.Message { + result := make([]structuredoutput.Message, len(messages)) + for i, message := range messages { + result[i] = structuredoutput.Message{Role: string(message.Role), Content: message.Content} + } + return result +} diff --git a/internal/framework/llm/conversation_test.go b/internal/framework/llm/conversation_test.go index e90e236..d1eaf70 100644 --- a/internal/framework/llm/conversation_test.go +++ b/internal/framework/llm/conversation_test.go @@ -3,6 +3,7 @@ package llm import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -11,9 +12,12 @@ import ( "reflect" "strings" "testing" + "time" "gitea.maximumdirect.net/eric/audita/v3/internal/core/config" "gitea.maximumdirect.net/eric/audita/v3/internal/framework/responseschema" + "gitea.maximumdirect.net/eric/audita/v3/internal/framework/structuredoutput" + "gitea.maximumdirect.net/eric/audita/v3/internal/prompts" ck "go.maximumdirect.net/configkit" pk "go.maximumdirect.net/promptkit" ) @@ -148,3 +152,140 @@ prompts: } } } + +func TestRoleClientCapturesAndReplaysRepairConversation(t *testing.T) { + dir := t.TempDir() + for name, content := range map[string]string{ + "config.yml": "version: 3\n", + "prompts.yml": "profiles:\n - id: proposal\n endpoint: https://example.invalid/v1\n model: proposal\n - id: repair\n endpoint: https://example.invalid/v1\n model: repair\n", + } { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + loaded, err := config.Load(ck.Input{Source: ck.File(filepath.Join(dir, "config.yml"))}, "") + if err != nil { + t.Fatal(err) + } + var messages [][]structuredoutput.Message + var models []string + outputs := []string{`{"corrections":[]}`, `{"proposal_ref":1,"action":"revise","correction":{"id":1,"original_text":"a","corrected_text":"b","confidence":0.8}}`, `{"proposal_ref":1,"action":"withdraw","correction":null}`, `{"proposal_ref":1,"action":"withdraw","correction":null}`} + transport := transportFunc(func(r *http.Request) (*http.Response, error) { + var req struct { + Model string `json:"model"` + Messages []structuredoutput.Message `json:"messages"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + models = append(models, req.Model) + messages = append(messages, req.Messages) + content, _ := json.Marshal(outputs[len(messages)-1]) + body := fmt.Sprintf(`{"model":%q,"choices":[{"message":{"content":%s}}]}`, req.Model, content) + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil + }) + runtime, err := NewRuntime(context.Background(), loaded, &http.Client{Transport: transport}) + if err != nil { + t.Fatal(err) + } + settings := config.LLM{OperationTimeout: 10 * time.Second} + proposal, err := runtime.NewRoleClient("proposal", "proposal", settings, CallHooks{}) + if err != nil { + t.Fatal(err) + } + repair, err := runtime.NewRoleClient("repair", "repair", settings, CallHooks{}) + if err != nil { + t.Fatal(err) + } + vars := map[string]string{"TranscriptDescriptionBlock": "", "GlossaryJSON": "[]", "SectionJSON": "[]"} + initial, err := proposal.CompleteStructured(context.Background(), structuredoutput.Request{PromptID: prompts.PromptIDModuleGrammarProposal, Schema: responseschema.CorrectionSetKey, Variables: vars, CaptureExchange: true}, &map[string]any{}) + if err != nil || initial.Exchange == nil { + t.Fatalf("capture: %+v %v", initial, err) + } + vars["SectionJSON"] = "changed" + replay := &structuredoutput.Replay{Exchange: initial.Exchange} + for _, finding := range []string{"first", "second"} { + result, err := repair.CompleteStructured(context.Background(), structuredoutput.Request{PromptID: prompts.PromptIDModuleGrammarRepair, Schema: responseschema.CorrectionRepairKey, Variables: map[string]string{"RepairRequestJSON": finding}, Replay: replay}, &map[string]any{}) + if err != nil { + t.Fatal(err) + } + if len(result.RepairInstruction) != 1 { + t.Fatalf("instruction: %+v", result) + } + replay.Turns = append(replay.Turns, result.RepairInstruction...) + replay.Turns = append(replay.Turns, structuredoutput.Message{Role: "assistant", Content: result.FinalContent}) + } + if !reflect.DeepEqual(models, []string{"proposal", "repair", "repair"}) { + t.Fatalf("models = %v", models) + } + prefix := initial.Exchange.Messages + if len(messages) != 3 || !reflect.DeepEqual(messages[0], prefix) || !reflect.DeepEqual(messages[1][:len(prefix)], prefix) || !reflect.DeepEqual(messages[2][:len(prefix)], prefix) { + t.Fatalf("prefixes: %+v", messages) + } + if len(messages[1]) != len(prefix)+2 || len(messages[2]) != len(prefix)+4 || messages[2][len(prefix)+2].Content != outputs[1] { + t.Fatalf("turns: %+v", messages) + } + _, err = repair.CompleteStructured(context.Background(), structuredoutput.Request{PromptID: prompts.PromptIDModuleGrammarRepair, Schema: responseschema.CorrectionRepairKey, Variables: map[string]string{"RepairRequestJSON": "independent"}, Replay: &structuredoutput.Replay{Exchange: initial.Exchange}}, &map[string]any{}) + if err != nil || len(messages) != 4 || len(messages[3]) != len(prefix)+2 || !strings.Contains(messages[3][len(prefix)+1].Content, "independent") { + t.Fatalf("independent branch: %v %+v", err, messages) + } + bad := *initial.Exchange + bad.Messages = append([]structuredoutput.Message(nil), bad.Messages...) + bad.Messages[0].Content = "changed" + _, err = repair.CompleteStructured(context.Background(), structuredoutput.Request{PromptID: prompts.PromptIDModuleGrammarRepair, Schema: responseschema.CorrectionRepairKey, Variables: map[string]string{"RepairRequestJSON": "failure"}, Replay: &structuredoutput.Replay{Exchange: &bad}}, &map[string]any{}) + var failure *structuredoutput.Failure + if !errors.As(err, &failure) || failure.Class != structuredoutput.FailureFatal || len(messages) != 4 { + t.Fatalf("prefix failure: %v, calls %d", err, len(messages)) + } +} + +func TestRepairReplayUsesReplacementAndCountsStructuralRepair(t *testing.T) { + dir := t.TempDir() + files := map[string]string{ + "config.yml": "version: 3\n", + "prompts.yml": "profiles:\n - id: fixture\n endpoint: https://example.invalid/v1\n model: fixture\ncollections:\n audita:\n prompts:\n - id: modules.grammar.repair\n messages:\n - role: user\n content_file: repair.txt\n output:\n format: json\n validation_mode: json_schema\n schema_path: correction_repair.v1.json\n", + "repair.txt": "Replace {{.RepairRequestJSON}}", + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + loaded, err := config.Load(ck.Input{Source: ck.File(filepath.Join(dir, "config.yml"))}, "") + if err != nil { + t.Fatal(err) + } + outputs := []string{`{"corrections":[]}`, `{"bad":true}`, `{"proposal_ref":1,"action":"withdraw","correction":null}`} + var requests [][]structuredoutput.Message + transport := transportFunc(func(r *http.Request) (*http.Response, error) { + var req struct { + Messages []structuredoutput.Message `json:"messages"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + requests = append(requests, req.Messages) + content, _ := json.Marshal(outputs[len(requests)-1]) + body := fmt.Sprintf(`{"model":"fixture","choices":[{"message":{"content":%s}}]}`, content) + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil + }) + runtime, err := NewRuntime(context.Background(), loaded, &http.Client{Transport: transport}) + if err != nil { + t.Fatal(err) + } + client, err := runtime.NewRoleClient("repair", "fixture", config.LLM{OperationTimeout: 10 * time.Second, RepairAttempts: 1}, CallHooks{}) + if err != nil { + t.Fatal(err) + } + base, err := client.CompleteStructured(context.Background(), structuredoutput.Request{PromptID: prompts.PromptIDModuleGrammarProposal, Schema: responseschema.CorrectionSetKey, Variables: map[string]string{"TranscriptDescriptionBlock": "", "GlossaryJSON": "[]", "SectionJSON": "[]"}, CaptureExchange: true}, &map[string]any{}) + if err != nil { + t.Fatal(err) + } + result, err := client.CompleteStructured(context.Background(), structuredoutput.Request{PromptID: prompts.PromptIDModuleGrammarRepair, Schema: responseschema.CorrectionRepairKey, Variables: map[string]string{"RepairRequestJSON": "fixture"}, Replay: &structuredoutput.Replay{Exchange: base.Exchange}}, &map[string]any{}) + if err != nil { + t.Fatal(err) + } + if len(requests) != 3 || result.Execution.ProviderAttempts != 2 || result.Execution.StructuralRepairs != 1 || result.FinalContent != outputs[2] || len(result.RepairInstruction) != 1 || result.RepairInstruction[0].Content != "Replace fixture" { + t.Fatalf("result=%+v calls=%d", result, len(requests)) + } +} diff --git a/internal/framework/proposal_generation/generate.go b/internal/framework/proposal_generation/generate.go index a3c0923..422afe8 100644 --- a/internal/framework/proposal_generation/generate.go +++ b/internal/framework/proposal_generation/generate.go @@ -62,6 +62,7 @@ type Request struct { Variables map[string]string `json:"variables"` StageName string `json:"stage_name,omitempty"` StartIndex int `json:"start_index"` + CaptureExchange bool LLMClient contracts.StructuredLLMClient DiagnosticsDir string DiagnosticsWriter InteractionDiagnosticsWriter @@ -69,6 +70,7 @@ type Request struct { // Result contains generated candidate proposals and optional diagnostics paths. type Result struct { + Exchange *structuredoutput.Exchange `json:"-"` Corrections []proposals.CorrectionProposal `json:"corrections"` Enriched []proposals.EnrichedCorrectionProposal `json:"enriched"` Operations []outcomes.Operation `json:"operations,omitempty"` @@ -106,8 +108,9 @@ func GenerateCandidates(ctx context.Context, req Request) (Result, error) { artifacts InteractionArtifacts ) completion, callErr = req.LLMClient.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ - PromptID: req.PromptID, - Variables: req.Variables, Schema: responseschema.CorrectionSetKey, + CaptureExchange: req.CaptureExchange, + PromptID: req.PromptID, + Variables: req.Variables, Schema: responseschema.CorrectionSetKey, Context: structuredoutput.DiagnosticContext{StageIndex: req.StageIndex, StageID: req.StageID, Role: "proposal", ModuleKey: req.ModuleKey, ModuleInstance: req.ModuleInstance, SectionIndex: sectionIndexPtr(req.Section)}, }, &response) sectionIndex := 0 @@ -216,6 +219,7 @@ func GenerateCandidates(ctx context.Context, req Request) (Result, error) { return Result{ Corrections: corrections, + Exchange: completion.Exchange, Enriched: enriched, Operations: []outcomes.Operation{operation}, Artifacts: artifacts, diff --git a/internal/framework/proposal_generation/module_proposal.go b/internal/framework/proposal_generation/module_proposal.go index c0fbdd7..6638ce7 100644 --- a/internal/framework/proposal_generation/module_proposal.go +++ b/internal/framework/proposal_generation/module_proposal.go @@ -63,6 +63,7 @@ func ExecuteModuleProposal(ctx context.Context, req ModuleProposalRequest) (cont Variables: variables, StageName: stagename.ModuleProposal(req.ProposalRequest.RunSpec.InstanceName, sectionIndexPtr(req.ProposalRequest.Section)), StartIndex: 0, + CaptureExchange: req.ProposalRequest.RunSpec.CaptureProposalExchange, LLMClient: req.ProposalRequest.LLMClient, DiagnosticsDir: req.ProposalRequest.DiagnosticsDir, ReportDiagnosticError: req.ProposalRequest.ReportDiagnosticError, @@ -73,6 +74,7 @@ func ExecuteModuleProposal(ctx context.Context, req ModuleProposalRequest) (cont return contracts.ProposalResult{ Proposals: generated.Corrections, + Exchange: generated.Exchange, Operations: generated.Operations, }, nil } diff --git a/internal/framework/proposal_generation/module_proposal_test.go b/internal/framework/proposal_generation/module_proposal_test.go index 7ab9fa1..5918750 100644 --- a/internal/framework/proposal_generation/module_proposal_test.go +++ b/internal/framework/proposal_generation/module_proposal_test.go @@ -18,7 +18,9 @@ import ( ) func TestExecuteModuleProposalBuildsMessagesFromSectionAndDescription(t *testing.T) { + exchange := &structuredoutput.Exchange{PromptID: "captured", FinalContent: "response"} client := &fakeStructuredClient{ + completion: structuredoutput.Response{Exchange: exchange}, responses: []StructuredCorrectionSet{ {Corrections: []StructuredCorrectionProposal{{SegmentReference: 1, OriginalText: "teh", CorrectedText: "the", Confidence: 0.9}}}, }, @@ -39,9 +41,10 @@ func TestExecuteModuleProposalBuildsMessagesFromSectionAndDescription(t *testing Section: §ion, }, RunSpec: contracts.ModuleRunSpec{ - ModuleKey: "grammar", - InstanceName: "grammar", - ReplacementPolicy: proposals.ReplacementPolicyRequireUnique, + CaptureProposalExchange: true, + ModuleKey: "grammar", + InstanceName: "grammar", + ReplacementPolicy: proposals.ReplacementPolicyRequireUnique, }, LLMClient: client, }, @@ -53,6 +56,9 @@ func TestExecuteModuleProposalBuildsMessagesFromSectionAndDescription(t *testing 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) } + if !client.calls[0].CaptureExchange || out.Exchange != exchange { + t.Fatalf("exchange was not passed through: %+v", out.Exchange) + } variables := client.calls[0].Variables if !strings.Contains(variables["SectionJSON"], `"section_index": 7`) || !strings.Contains(variables["SectionJSON"], "teh") || diff --git a/internal/framework/runner/runner.go b/internal/framework/runner/runner.go index 3785c84..42c6b61 100644 --- a/internal/framework/runner/runner.go +++ b/internal/framework/runner/runner.go @@ -17,6 +17,7 @@ import ( "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" validatormetadata "gitea.maximumdirect.net/eric/audita/v3/internal/validators/metadata" ) @@ -309,6 +310,7 @@ type sectionProposalResult struct { sectionPos int section chunking.Section corrected []proposals.CorrectionProposal + exchange *structuredoutput.Exchange operations []outcomes.Operation started bool err error @@ -366,9 +368,10 @@ func collectSectionProposals(ctx context.Context, input collectSectionProposalsI ReportDiagnosticError: input.ReportDiagnosticError, }, RunSpec: contracts.ModuleRunSpec{ - ModuleKey: input.Spec.ModuleKey, - InstanceName: input.Spec.InstanceName, - ReplacementPolicy: input.Policy, + ModuleKey: input.Spec.ModuleKey, + InstanceName: input.Spec.InstanceName, + ReplacementPolicy: input.Policy, + CaptureProposalExchange: input.Spec.CaptureProposalExchange, }, LLMClient: input.ProposalClient, }) @@ -376,6 +379,7 @@ func collectSectionProposals(ctx context.Context, input collectSectionProposalsI sectionPos: sectionPos, section: section, corrected: proposalResult.Proposals, + exchange: proposalResult.Exchange, operations: proposalResult.Operations, started: true, err: err, @@ -504,6 +508,7 @@ func runModulePipeline(ctx context.Context, input collectSectionProposalsInput) SectionIndex: §ionMetadata.Index, Validators: validatorsOrdered, SectionEnriched: enriched, + Exchange: sectionResult.exchange, ValidationLLMClient: input.ValidationClient, DiagnosticsDir: input.ValidationDiagnosticsDir, GlossaryView: input.GlossaryView, @@ -580,6 +585,7 @@ func runModulePipeline(ctx context.Context, input collectSectionProposalsInput) } type validateSectionCandidatesInput struct { + Exchange *structuredoutput.Exchange // ReportDiagnosticError may be called concurrently; it must not affect processing. ReportDiagnosticError func(error) `json:"-"` Spec contracts.ModuleRunSpec diff --git a/internal/framework/structuredoutput/call.go b/internal/framework/structuredoutput/call.go index 7d50197..bc41432 100644 --- a/internal/framework/structuredoutput/call.go +++ b/internal/framework/structuredoutput/call.go @@ -24,10 +24,12 @@ type DiagnosticContext struct { // Request names an embedded prompt and response schema. Profile selection and // credentials are supplied by the role-bound runtime client. type Request struct { - PromptID string `json:"prompt_id"` - Variables map[string]string `json:"variables"` - Schema responseschema.Key `json:"schema"` - Context DiagnosticContext `json:"context"` + PromptID string `json:"prompt_id"` + Variables map[string]string `json:"variables"` + Schema responseschema.Key `json:"schema"` + Context DiagnosticContext `json:"context"` + CaptureExchange bool `json:"-"` + Replay *Replay `json:"-"` } // PromptMetadata is the execution snapshot returned by the runtime when available. @@ -40,17 +42,33 @@ type PromptMetadata struct { Model string `json:"model,omitempty"` } +// Exchange holds a section's original prepared conversation for read-only replay. +type Exchange struct { + PromptID string + Variables map[string]string + Messages []Message + FinalContent string +} + +// Replay carries a section exchange and the prior turns of one repair branch. +type Replay struct { + Exchange *Exchange + Turns []Message +} + type Response struct { - Execution outcomes.ExecutionCounts `json:"execution"` - Content json.RawMessage `json:"content,omitempty"` - Provider string `json:"provider,omitempty"` - Model string `json:"model,omitempty"` - PromptTokens int `json:"prompt_tokens,omitempty"` - CompletionTokens int `json:"completion_tokens,omitempty"` - TotalTokens int `json:"total_tokens,omitempty"` - Prompt PromptMetadata `json:"prompt"` - Prepared *PreparedSnapshot `json:"prepared,omitempty"` - FinalContent string `json:"final_content,omitempty"` + Exchange *Exchange `json:"-"` + RepairInstruction []Message `json:"-"` + Execution outcomes.ExecutionCounts `json:"execution"` + Content json.RawMessage `json:"content,omitempty"` + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` + PromptTokens int `json:"prompt_tokens,omitempty"` + CompletionTokens int `json:"completion_tokens,omitempty"` + TotalTokens int `json:"total_tokens,omitempty"` + Prompt PromptMetadata `json:"prompt"` + Prepared *PreparedSnapshot `json:"prepared,omitempty"` + FinalContent string `json:"final_content,omitempty"` } // PreparedSnapshot is the exact rendered request snapshot. Its messages may @@ -68,14 +86,15 @@ type Message struct { // CallRecord brackets one logical operation, including preparation. Content // is present only when a model returned it; consumers must protect it. type CallRecord struct { - CallID uint64 `json:"call_id"` - Context DiagnosticContext `json:"context"` - Prompt PromptMetadata `json:"prompt"` - Started time.Time `json:"started"` - Finished time.Time `json:"finished,omitempty"` - Prepared *PreparedSnapshot `json:"prepared,omitempty"` - FinalContent string `json:"final_content,omitempty"` - Failure FailureKind `json:"failure,omitempty"` + RequestedPromptID string `json:"requested_prompt_id,omitempty"` + CallID uint64 `json:"call_id"` + Context DiagnosticContext `json:"context"` + Prompt PromptMetadata `json:"prompt"` + Started time.Time `json:"started"` + Finished time.Time `json:"finished,omitempty"` + Prepared *PreparedSnapshot `json:"prepared,omitempty"` + FinalContent string `json:"final_content,omitempty"` + Failure FailureKind `json:"failure,omitempty"` } type Client interface {