diff --git a/docs/consumers/pkg-promptkit.md b/docs/consumers/pkg-promptkit.md index 7b5e93d..61b13a7 100644 --- a/docs/consumers/pkg-promptkit.md +++ b/docs/consumers/pkg-promptkit.md @@ -884,6 +884,12 @@ secret-handling policies. ## Extension Interfaces +JSON Schema validation and native structured output use a self-contained root +document. Internal references are supported; references to other files or +network resources fail during preparation. `WithSchemaFile` selects one root +file, while `WithSchemaFS` and `SchemaDir` select a root source. Prepared +executions retain their captured schema even if the source later changes. + Inject an [`LLMClient` or `ArtifactReader`](../../types.go) when the built-in behavior does not fit the application. Their GoDoc defines concurrent use, context handling, ownership of copied values, nil responses, and preservation diff --git a/docs/formats.md b/docs/formats.md index ad7f38e..c173467 100644 --- a/docs/formats.md +++ b/docs/formats.md @@ -418,8 +418,9 @@ generic inheritance and layer mechanics, consult ## Schemas Schemas are JSON documents selected by a prompt or request `schema_path`. -Directory and `fs.FS` schema paths resolve within the configured root; -referenced nested schemas resolve relative to the owning schema document. +Directory and `fs.FS` schema paths resolve within the configured root. Each +schema must be self-contained: in-document references, IDs, and anchors are +supported, but references requiring another file or network resource fail. `WithSchemaFile` exposes one schema under its base name. An unreadable, invalid, or unresolvable schema causes an operational validation error. Valid JSON that fails the schema instead yields a failed validation result. diff --git a/docs/internal/sources.md b/docs/internal/sources.md index ead6dbf..0e8f060 100644 --- a/docs/internal/sources.md +++ b/docs/internal/sources.md @@ -216,10 +216,10 @@ instead of first making a response-sized string copy. Every preparation operation creates one operation-local validation plan. None, basic, and JSON modes retain the effective output contract without source -access. JSON Schema mode loads the root document once, resolves and compiles -each transitive reference, and retains the compiled validator. Schema compiler -resources use canonical escaped file or private-scheme URLs; loaders decode -their paths once and enforce the configured source boundary. The +access. JSON Schema mode loads the root document once, compiles its declared +schema locations, and retains the compiled root validator. External resources +are rejected without file or network loading; in-document references remain +supported. Root resources use canonical escaped file or private-scheme URLs. The provider-facing structured-output metadata uses the root document captured by the same plan. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 16c638f..cb915b0 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -375,6 +375,10 @@ recursive/dynamic references, embedded dialects, and independent instances. validate/engine tests finish the schema transition. No local-only mode, bundler, new dialect, or release/publication prerequisite is introduced. +Completion: schema-file option, frozen preparation, native submission, and +external-reference rejection are covered through public APIs. Root, validation, +and engine tests pass on Go 1.27.3; current schema guidance is aligned. + ## Stage 14: Merge Provider Maps Into One Owned Lower Copy **Catalog:** 19. **Files:** configuration targets and focused overlay tests. diff --git a/engine.go b/engine.go index a8e5649..4f57a74 100644 --- a/engine.go +++ b/engine.go @@ -213,7 +213,8 @@ func WithSchemaFS(fsys fs.FS, root string) Option { // // Prompt schema_path values refer to the file's base name. path must name an // existing non-directory file when NewEngine applies the option. This option -// replaces Config.SchemaDir and earlier schema-source options. +// replaces Config.SchemaDir and earlier schema-source options. The selected +// schema must be self-contained; external resources are not loaded. func WithSchemaFile(path string) Option { return optionFunc(func(options *engineOptions) error { return options.internal.SetSchemaFile(path) diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 3369564..a6e2a70 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -73,7 +73,7 @@ func (o *Options) SetSchemaFS(fsys fs.FS, root string) error { // SetSchemaFile selects a single schema file. func (o *Options) SetSchemaFile(path string) error { - fsys, root, _, err := fileSource(path) + fsys, root, err := fileSource(path) if err != nil { return err } @@ -208,23 +208,23 @@ func validateSourceFS(fsys fs.FS, root string) error { return nil } -func fileSource(name string) (fs.FS, string, string, error) { +func fileSource(name string) (fs.FS, string, error) { if strings.TrimSpace(name) == "" { - return nil, "", "", fmt.Errorf("source path is required") + return nil, "", fmt.Errorf("source path is required") } directory := filepath.Dir(name) base := filepath.Base(name) if base == "." || base == string(filepath.Separator) { - return nil, "", "", fmt.Errorf("source path must name a file") + return nil, "", fmt.Errorf("source path must name a file") } info, err := os.Stat(name) if err != nil { - return nil, "", "", fmt.Errorf("failed to access source file %q: %w", name, err) + return nil, "", fmt.Errorf("failed to access source file %q: %w", name, err) } if info.IsDir() { - return nil, "", "", fmt.Errorf("source path %q must be a file", name) + return nil, "", fmt.Errorf("source path %q must be a file", name) } - return os.DirFS(directory), filepath.ToSlash(base), directory, nil + return os.DirFS(directory), filepath.ToSlash(base), nil } func (e *Engine) InspectPrompt(ctx context.Context, id string) (*domain.PromptInspection, error) { diff --git a/prepared_execution_contract_test.go b/prepared_execution_contract_test.go index fc53369..70ff4cd 100644 --- a/prepared_execution_contract_test.go +++ b/prepared_execution_contract_test.go @@ -813,8 +813,9 @@ func preparedSchemaSource() fstest.MapFS { "title": "original root", "type": "object", "required": ["value"], + "$defs": {"value": {"type":"integer", "minimum":2}}, "properties": { - "value": {"$ref": "value.json"} + "value": {"$ref": "#/$defs/value"} } }`)}, "value.json": &fstest.MapFile{Data: []byte(`{ diff --git a/schema_file_contract_test.go b/schema_file_contract_test.go new file mode 100644 index 0000000..c221918 --- /dev/null +++ b/schema_file_contract_test.go @@ -0,0 +1,90 @@ +package promptkit_test + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "go.maximumdirect.net/configkit" + "go.maximumdirect.net/promptkit" +) + +func TestSchemaFilePreparationAndNativeSubmission(t *testing.T) { + firstDir, secondDir := t.TempDir(), t.TempDir() + first, second := filepath.Join(firstDir, "schema.json"), filepath.Join(secondDir, "schema.json") + if err := os.WriteFile(first, []byte(`{"type":"string"}`), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(second, []byte(`{"$defs":{"value":{"type":"integer"}},"$ref":"#/$defs/value"}`), 0600); err != nil { + t.Fatal(err) + } + input := configkit.Input{Source: configkit.Mapping("schema test", map[string]any{ + "profiles": []any{map[string]any{"id": "profile", "endpoint": "https://example.invalid/v1", "model": "model"}}, + "prompts": []any{map[string]any{"id": "prompt", "default_profile": "profile", "messages": []any{map[string]any{"role": "user", "content": "hello"}}, "output": map[string]any{"format": "json", "validation_mode": "json_schema", "schema_path": "schema.json"}}}, + })} + var payload map[string]any + client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"content":"3"}}]}`))}, nil + })} + engine, err := promptkit.NewEngine(promptkit.Config{HTTPClient: client, Sources: promptkit.ConfigurationSources{Main: &input}}, promptkit.WithSchemaFile(first), promptkit.WithSchemaFile(second)) + if err != nil { + t.Fatal(err) + } + if err := engine.ValidateRequest(context.Background(), promptkit.RunRequest{PromptID: "prompt"}); err != nil { + t.Fatalf("validate request: %v", err) + } + prepared, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prompt"}) + if err != nil || prepared.Details().StructuredOutput == nil { + t.Fatalf("prepared = %+v, %v", prepared, err) + } + if err := os.Remove(second); err != nil { + t.Fatal(err) + } + result, err := engine.RunPrepared(context.Background(), prepared) + if err != nil || result.Validation.Status != promptkit.ValidationPassed { + t.Fatalf("run = %+v, %v", result, err) + } + encoded, err := json.Marshal(payload["response_format"]) + if err != nil || !strings.Contains(string(encoded), `"$defs"`) || !strings.Contains(string(encoded), `#/$defs/value`) { + t.Fatalf("wire schema = %s, %v", encoded, err) + } + if _, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prompt"}); !errors.Is(err, promptkit.ErrValidation) { + t.Fatalf("missing root error = %v", err) + } +} + +func TestSchemaFileRejectsInvalidSourcesAndExternalReferences(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{filepath.Join(dir, "missing.json"), dir} { + if _, err := promptkit.NewEngine(promptkit.Config{}, promptkit.WithSchemaFile(name)); !errors.Is(err, promptkit.ErrInvalidConfig) { + t.Fatalf("source %q = %v", name, err) + } + } + root := filepath.Join(dir, "schema.json") + if err := os.WriteFile(root, []byte(`{"$defs":{"unused":{"$ref":"other.json"}}}`), 0600); err != nil { + t.Fatal(err) + } + input := configkit.Input{Source: configkit.Mapping("schema test", map[string]any{ + "profiles": []any{map[string]any{"id": "profile", "endpoint": "https://example.invalid/v1", "model": "model"}}, + "prompts": []any{map[string]any{"id": "prompt", "default_profile": "profile", "messages": []any{map[string]any{"role": "user", "content": "hello"}}, "output": map[string]any{"format": "json", "validation_mode": "json_schema", "schema_path": "schema.json"}}}, + })} + calls := 0 + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { calls++; return nil, errors.New("unexpected dispatch") })} + engine, err := promptkit.NewEngine(promptkit.Config{HTTPClient: client, Sources: promptkit.ConfigurationSources{Main: &input}}, promptkit.WithSchemaFile(root)) + if err != nil { + t.Fatal(err) + } + _, err = engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt"}) + if !errors.Is(err, promptkit.ErrValidation) || calls != 0 { + t.Fatalf("external reference = %v, calls = %d", err, calls) + } +}