Extend composed consumer with request validation
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# PromptKit request validation implementation plan
|
# PromptKit request validation implementation plan
|
||||||
|
|
||||||
Status: Stages 1–6 complete; implementation is ongoing.
|
Status: Stages 1–7 complete; implementation is ongoing.
|
||||||
|
|
||||||
## Authority, scope, and baseline
|
## Authority, scope, and baseline
|
||||||
|
|
||||||
@@ -411,6 +411,17 @@ making the intentionally failing example a failed completion check.
|
|||||||
**Complete when:** the maintained consumer demonstrates the accepted feature,
|
**Complete when:** the maintained consumer demonstrates the accepted feature,
|
||||||
typed selection, shared state, and application-owned defaults without execution.
|
typed selection, shared state, and application-owned defaults without execution.
|
||||||
|
|
||||||
|
**Completed:** 2026-10-12. Composed `WithRequestValidation` with the existing
|
||||||
|
engine loader, campaign selector, input/state factories, and invocation cache.
|
||||||
|
The typed builder loads the selected campaign through that cache and defaults
|
||||||
|
only absent `topic` and `mode` variables, preserving explicit empty values and
|
||||||
|
operator bindings. Added offline topic and notes fixtures and extended the
|
||||||
|
consumer README with both successful file-backed commands and the intentional
|
||||||
|
missing-topic failure. Composed tests passed with
|
||||||
|
`GOTOOLCHAIN=local go test -count=1 .`; both successful README commands emitted
|
||||||
|
the expected two-field YAML confirmation, and the missing-topic command exited
|
||||||
|
with status 1 for the native required-artifact error.
|
||||||
|
|
||||||
## Stage 8: Update canonical documentation for implemented behavior
|
## Stage 8: Update canonical documentation for implemented behavior
|
||||||
|
|
||||||
**Primary files:** GoDoc, `docs/cli.md`, `docs/consumers/guide.md`, README,
|
**Primary files:** GoDoc, `docs/cli.md`, `docs/consumers/guide.md`, README,
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ go run . promptkit show prompt briefing --campaign spring --format yaml
|
|||||||
go run . promptkit show profile spring --campaign spring
|
go run . promptkit show profile spring --campaign spring
|
||||||
go run . promptkit requirements show briefing --campaign spring
|
go run . promptkit requirements show briefing --campaign spring
|
||||||
go run . promptkit requirements show briefing --campaign spring --var mode=full --format yaml
|
go run . promptkit requirements show briefing --campaign spring --var mode=full --format yaml
|
||||||
|
go run . promptkit requirements validate briefing --campaign spring --input topic=fixtures/topic.txt
|
||||||
|
go run . promptkit requirements validate briefing --campaign spring --input topic=fixtures/topic.txt --var mode=full --input notes=fixtures/notes.txt --format yaml
|
||||||
|
go run . promptkit requirements validate briefing --campaign spring
|
||||||
go run . session restore session-42 --campaign spring
|
go run . session restore session-42 --campaign spring
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -48,3 +51,13 @@ completes discovery. Both commands return successful reports; the result does
|
|||||||
not execute the prompt or establish input, credential, or execution readiness.
|
not execute the prompt or establish input, credential, or execution readiness.
|
||||||
See the [CLI reference](../../../../docs/cli.md#promptkit-requirements) for the
|
See the [CLI reference](../../../../docs/cli.md#promptkit-requirements) for the
|
||||||
command and report contract.
|
command and report contract.
|
||||||
|
|
||||||
|
The validation commands exercise concrete PromptKit request preparation.
|
||||||
|
The first uses the builder's campaign-specific default topic variable and the
|
||||||
|
operator's topic file. The second also selects the notes branch and supplies its
|
||||||
|
file. Both succeed and report only `prompt_id` and `valid: true`; they do not
|
||||||
|
execute the prompt. The final command intentionally fails because the prompt's
|
||||||
|
required `topic` artifact is not supplied. The builder defaults variables from
|
||||||
|
the selected campaign but leaves artifact bindings to the operator. The
|
||||||
|
`requirements show` commands remain discovery reports: partial discovery can
|
||||||
|
succeed even when a concrete request would fail validation.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Keep the briefing concise and focus on practical next steps.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Spring planning priorities
|
||||||
@@ -141,6 +141,31 @@ func selectedCampaign(configuration appConfiguration, supplied commandkit.Suppli
|
|||||||
return name, chosen, nil
|
return name, chosen, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildValidationRequest(ctx context.Context, seed ckprompt.ValidationRequest, input *inspectionInput, cache *runCache) (promptkit.RunRequest, error) {
|
||||||
|
application, err := loadApplicationConfig(ctx, seed.Selection, input, cache)
|
||||||
|
if err != nil {
|
||||||
|
return promptkit.RunRequest{}, err
|
||||||
|
}
|
||||||
|
name, _, err := selectedCampaign(application.Config, input.Campaign)
|
||||||
|
if err != nil {
|
||||||
|
return promptkit.RunRequest{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
request := seed.Request
|
||||||
|
vars := make(map[string]string, len(request.Vars)+2)
|
||||||
|
for key, value := range request.Vars {
|
||||||
|
vars[key] = value
|
||||||
|
}
|
||||||
|
if _, supplied := vars["topic"]; !supplied {
|
||||||
|
vars["topic"] = name + " campaign planning"
|
||||||
|
}
|
||||||
|
if _, supplied := vars["mode"]; !supplied {
|
||||||
|
vars["mode"] = ""
|
||||||
|
}
|
||||||
|
request.Vars = vars
|
||||||
|
return request, nil
|
||||||
|
}
|
||||||
|
|
||||||
func newApp() (*commandkit.App, error) {
|
func newApp() (*commandkit.App, error) {
|
||||||
limitDefault := 0
|
limitDefault := 0
|
||||||
limitFlag := commandkit.ValueFlag(commandkit.FlagMeta{Name: "limit", Usage: "Override the application item limit."}, &limitDefault, strconv.Atoi,
|
limitFlag := commandkit.ValueFlag(commandkit.FlagMeta{Name: "limit", Usage: "Override the application item limit."}, &limitDefault, strconv.Atoi,
|
||||||
@@ -170,6 +195,7 @@ func newApp() (*commandkit.App, error) {
|
|||||||
definitions.NewInput = inputFactory
|
definitions.NewInput = inputFactory
|
||||||
definitions.NewState = stateFactory
|
definitions.NewState = stateFactory
|
||||||
definitions.Selectors = []commandkit.Flag[*inspectionInput]{campaignSelector()}
|
definitions.Selectors = []commandkit.Flag[*inspectionInput]{campaignSelector()}
|
||||||
|
definitions = ckprompt.WithRequestValidation(definitions, loadPromptEngine, buildValidationRequest)
|
||||||
definitionCommand := ckprompt.Command(definitions)
|
definitionCommand := ckprompt.Command(definitions)
|
||||||
|
|
||||||
store := map[string]session{
|
store := map[string]session{
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
||||||
|
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt"
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -211,6 +214,106 @@ func TestComposedRequirementsDiscoveryUsesSelectedCampaign(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestComposedRequestValidationUsesFileBindings(t *testing.T) {
|
||||||
|
app, err := newApp()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "default mode",
|
||||||
|
args: []string{"promptkit", "requirements", "validate", "briefing", "--campaign", "spring", "--input", "topic=fixtures/topic.txt"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "conditional notes",
|
||||||
|
args: []string{"promptkit", "requirements", "validate", "briefing", "--campaign", "spring", "--input", "topic=fixtures/topic.txt", "--var", "mode=full", "--input", "notes=fixtures/notes.txt", "--format", "yaml"},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), test.args, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||||
|
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Status != 0 {
|
||||||
|
t.Fatalf("request validation = %#v, report %q", outcome, output.String())
|
||||||
|
}
|
||||||
|
var report map[string]any
|
||||||
|
if err := yaml.Unmarshal(output.Bytes(), &report); err != nil {
|
||||||
|
t.Fatalf("decode validation confirmation %q: %v", output.String(), err)
|
||||||
|
}
|
||||||
|
if len(report) != 2 || report["prompt_id"] != "briefing" || report["valid"] != true {
|
||||||
|
t.Fatalf("validation confirmation = %#v", report)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposedRequestValidationRequiresTopicArtifact(t *testing.T) {
|
||||||
|
app, err := newApp()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var output bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing", "--campaign", "spring"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||||
|
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || outcome.Cause == nil || output.Len() != 0 {
|
||||||
|
t.Fatalf("missing topic artifact outcome = %#v, report %q", outcome, output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildValidationRequestDefaultsOnlyAbsentVariables(t *testing.T) {
|
||||||
|
input := &inspectionInput{Campaign: commandkit.Supplied[string]{Value: "autumn", Set: true}}
|
||||||
|
cache := &runCache{}
|
||||||
|
inputs := map[string]promptkit.ArtifactRef{"topic": promptkit.File("fixtures/topic.txt")}
|
||||||
|
explicit := map[string]string{"topic": "", "mode": "", "audience": ""}
|
||||||
|
seed := ckprompt.ValidationRequest{Request: promptkit.RunRequest{
|
||||||
|
PromptID: "briefing",
|
||||||
|
PromptVersion: "2",
|
||||||
|
ProfileID: "custom-profile",
|
||||||
|
SessionID: "session-9",
|
||||||
|
Vars: explicit,
|
||||||
|
Inputs: inputs,
|
||||||
|
}}
|
||||||
|
|
||||||
|
got, err := buildValidationRequest(context.Background(), seed, input, cache)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.PromptID != seed.Request.PromptID || got.PromptVersion != seed.Request.PromptVersion || got.ProfileID != seed.Request.ProfileID || got.SessionID != seed.Request.SessionID {
|
||||||
|
t.Fatalf("request identity/context changed: %#v", got)
|
||||||
|
}
|
||||||
|
if got.Vars["topic"] != "" || got.Vars["mode"] != "" || got.Vars["audience"] != "" {
|
||||||
|
t.Fatalf("explicit empty values were replaced: %#v", got.Vars)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got.Inputs, inputs) || !reflect.DeepEqual(seed.Request.Vars, explicit) {
|
||||||
|
t.Fatalf("seed bindings changed: built=%#v seed=%#v", got.Inputs, explicit)
|
||||||
|
}
|
||||||
|
key := applicationCacheKey{selection: seed.Selection, campaign: input.Campaign}
|
||||||
|
cached := cache.applications[key]
|
||||||
|
if cached == nil {
|
||||||
|
t.Fatal("builder did not load application configuration into invocation state")
|
||||||
|
}
|
||||||
|
if _, err := loadPromptEngine(context.Background(), seed.Selection, input, cache); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cache.applications[key] != cached {
|
||||||
|
t.Fatal("engine loader did not reuse the builder's application configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
missing := ckprompt.ValidationRequest{Request: promptkit.RunRequest{PromptID: "briefing", Vars: map[string]string{"audience": "internal"}}}
|
||||||
|
defaulted, err := buildValidationRequest(context.Background(), missing, input, cache)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if defaulted.Vars["topic"] != "autumn campaign planning" || defaulted.Vars["mode"] != "" || defaulted.Vars["audience"] != "internal" {
|
||||||
|
t.Fatalf("missing variables were not defaulted while retaining supplied values: %#v", defaulted.Vars)
|
||||||
|
}
|
||||||
|
if _, ok := missing.Request.Vars["topic"]; ok {
|
||||||
|
t.Fatalf("builder modified the seed variable map: %#v", missing.Request.Vars)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPromptKitScopeDiffAndCatalogsUseSelectedCampaign(t *testing.T) {
|
func TestPromptKitScopeDiffAndCatalogsUseSelectedCampaign(t *testing.T) {
|
||||||
app, err := newApp()
|
app, err := newApp()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user