diff --git a/dart/dotprompt/CHANGELOG.md b/dart/dotprompt/CHANGELOG.md index 86223511a..c9da01d6e 100644 --- a/dart/dotprompt/CHANGELOG.md +++ b/dart/dotprompt/CHANGELOG.md @@ -4,6 +4,73 @@ All notable changes to dotprompt-dart will be documented in this file. ## [Unreleased] +Picoschema now follows the spec and behaves like the other runtimes. Schemas +that relied on the old Dart-only syntax, or on unknown names becoming `$ref`, +need updating. + +### Breaking changes + +- Picoschema is strict. These now throw `PicoschemaException`: + + | Before (1.x) | Now | + | ---------------------------------- | ------------------------------------- | + | `email(the email): string` | `email: string, the email` | + | `tags: string[]` | `tags(array): string` | + | `status: a \| b` | `status(enum): [a, b]` | + | `wild(*): string` | `(*): string` | + | `n: int` (also `str`, `bool`, ...) | `n: integer` | + | `a` and `a?` in the same object | pick one | + +- Unknown named schemas throw instead of becoming `{"$ref": name}`. Names are + looked up in `schemas`/`defineSchema`, then `DotpromptOptions.schemaResolver` + (`Dotprompt` and `Picoschema.parse` only; `toJsonSchema` is sync and only + sees `schemas`). Register schemas before converting prompts that use them. +- The `input: Name` / `output: Name` shorthand is parsed as + `{"$type": Name}` (same as `schema: Name`) instead of `{"$ref": Name}`, so + `renderMetadata` resolves it. +- `Picoschema.toJsonSchema`'s `schemas` parameter is now + `Map>?` (was `Map?`). +- `Picoschema.isPicoschema` returns true for anything that is not JSON Schema + (it used to require a bare scalar value). JSON Schema is detected as in the + other runtimes: a top-level `type` naming a JSON Schema type or a + `properties` map, plus list-valued `type`/`anyOf`/`oneOf`/`allOf`/`enum`, + `$schema` and `$ref`. Calling it before `toJsonSchema` is no longer needed. +- `DotpromptOptions.schemas` and `defineSchema` take JSON Schema (as in the + other runtimes), not Picoschema. Registered schemas are inserted as-is; + convert Picoschema with `Picoschema.toJsonSchema` first. + +### Fixed + +- Picoschema now follows the spec and the JavaScript reference implementation + ([genkit-dart#562](https://github.com/genkit-ai/genkit-dart/issues/562)): + - `field(array[, desc]): type`, `field(object[, desc]):` and + `field(enum[, desc]): [...]` produce arrays, objects and enums. Previously + the parenthesized qualifier was treated as a description. + - `(*)` wildcards and every Picoschema form are always converted. Previously + some schemas skipped conversion and were passed through raw. + - Top-level JSON Schema (`type: string`, a bare `properties` map, `anyOf`, + `enum`, `type: [string, "null"]`, etc.) is passed through instead of being + parsed as Picoschema. + - Passed-through JSON Schema and resolved named schemas are deep copies, so + editing the result never changes the input or registered schemas. + - A frontmatter schema with a `$type` field next to other fields is parsed + as a Picoschema object instead of being collapsed to the `$type` value. + - `x?: null` produces `{type: null}` instead of `{type: [null, null]}`. + - `a ?: string` produces a property named `a`, not `a `. + - Named schemas are resolved via `DotpromptOptions.schemaResolver` as well as + `schemas`/`defineSchema`. + - The `input: Name` / `output: Name` shorthand resolves the named schema. + Previously it became a raw `{"$ref": name}`. +- The spec test runner now checks `output` and named `schemas`, so + `spec/picoschema.yaml` is actually enforced. + +### Added + +- `Picoschema.parse(schema, {schemas, schemaResolver})`, an async variant of + `toJsonSchema` that resolves named schemas through a `SchemaResolver`. + +## [1.0.0] - 2026-08-25 + ### Added - `renderMetadata` and `compile` now accept an optional `additionalMetadata` diff --git a/dart/dotprompt/PARITY.md b/dart/dotprompt/PARITY.md index f5dc286d2..c1ba4785f 100644 --- a/dart/dotprompt/PARITY.md +++ b/dart/dotprompt/PARITY.md @@ -124,11 +124,13 @@ This document tracks feature parity between the Dart and JavaScript (canonical) | Type scalars (string, integer, etc.) | ✅ | ✅ | | | Optional fields (`?` suffix) | ✅ | ✅ | | | Descriptions (`, description`) | ✅ | ✅ | | -| Nested objects | ✅ | ✅ | | -| Array types (`type[]` suffix) | ✅ | ✅ | | -| Enum types | ✅ | ✅ | | +| Nested objects (plain and `(object[, desc])`) | ✅ | ✅ | | +| Arrays (`(array[, desc])`) | ✅ | ✅ | | +| Enums (`(enum[, desc])`) | ✅ | ✅ | | +| Wildcards (`(*)`) | ✅ | ✅ | | +| JSON Schema passthrough | ✅ | ✅ | | | Named schema references | ✅ | ✅ | | -| Async schema resolution | ✅ | ✅ | | +| Async schema resolution | ✅ | ✅ | `Picoschema.parse` / `DotpromptOptions.schemaResolver` | ## Templating Engine diff --git a/dart/dotprompt/README.md b/dart/dotprompt/README.md index 772e2edca..545bf6fa0 100644 --- a/dart/dotprompt/README.md +++ b/dart/dotprompt/README.md @@ -34,7 +34,7 @@ void main() async { // Parse and render a prompt final result = await dotprompt.render(''' --- -model: gemini-pro +model: googleai/gemini-flash-latest config: temperature: 0.7 --- @@ -55,18 +55,18 @@ Hello {{name}}! You are a {{role}}. ```dart final parsed = dotprompt.parse(''' --- -model: gemini-pro +model: googleai/gemini-flash-latest input: schema: name: string - age: integer? + age?: integer default: name: User --- Hello {{name}}! '''); -print(parsed.model); // "gemini-pro" +print(parsed.model); // "googleai/gemini-flash-latest" print(parsed.input?.schema); // Schema definition ``` @@ -129,6 +129,52 @@ Please analyze this image: ''', DataArgument()); ``` +### Picoschema + +`input.schema` and `output.schema` accept +[Picoschema](https://google.github.io/dotprompt/reference/picoschema/) and are +converted to JSON Schema by `renderMetadata`/`render`: + +```yaml +output: + schema: + title: string, the article title + subtitle?: string + tags(array, relevant tags): string + status(enum): [DRAFT, PUBLISHED] + author: Author # named schema + labels(object): + (*): string # additionalProperties +``` + +Named schemas are JSON Schema, registered up front or resolved on demand: + +```dart +final dotprompt = Dotprompt(DotpromptOptions( + schemas: {'Author': authorJsonSchema}, + schemaResolver: (name) async => lookupJsonSchema(name), +)); + +// Or convert directly. +final jsonSchema = await Picoschema.parse( + {'author': 'Author', 'tags(array)': 'string'}, + schemaResolver: (name) async => lookupJsonSchema(name), +); +``` + +A schema that is already JSON Schema is passed through as-is. As in the other +runtimes, that means a top-level `type` naming a JSON Schema type or a +`properties` map (plus `anyOf`/`oneOf`/`allOf`/`enum` lists, `$schema` and +`$ref`): + +```yaml +output: + schema: + type: object + properties: + title: {type: string} +``` + ## API Reference ### Core Classes @@ -145,9 +191,9 @@ Please analyze this image: ```dart final dotprompt = Dotprompt(DotpromptOptions( - defaultModel: 'gemini-pro', + defaultModel: 'googleai/gemini-flash-latest', modelConfigs: { - 'gemini-pro': {'temperature': 0.7}, + 'googleai/gemini-flash-latest': {'temperature': 0.7}, }, partials: {'...': '...'}, tools: {'...': ToolDefinition(...)}, diff --git a/dart/dotprompt/lib/dotprompt.dart b/dart/dotprompt/lib/dotprompt.dart index 1888fd30c..8dc283026 100644 --- a/dart/dotprompt/lib/dotprompt.dart +++ b/dart/dotprompt/lib/dotprompt.dart @@ -36,7 +36,7 @@ /// final dotprompt = Dotprompt(); /// final template = ''' /// --- -/// model: gemini-pro +/// model: googleai/gemini-flash-latest /// --- /// Hello {{name}}! /// '''; diff --git a/dart/dotprompt/lib/src/dotprompt.dart b/dart/dotprompt/lib/src/dotprompt.dart index d08798887..13fcfe06b 100644 --- a/dart/dotprompt/lib/src/dotprompt.dart +++ b/dart/dotprompt/lib/src/dotprompt.dart @@ -36,7 +36,7 @@ /// // Parse a template /// final parsed = dotprompt.parse(''' /// --- -/// model: gemini-pro +/// model: googleai/gemini-flash-latest /// --- /// Hello {{name}}! /// '''); @@ -98,7 +98,10 @@ class DotpromptOptions { /// Pre-registered tool definitions. final Map? tools; - /// Pre-registered schemas (Picoschema or JSON Schema). + /// Pre-registered JSON Schemas, referenced by name from Picoschema. + /// + /// Values must already be JSON Schema; they are inserted as-is (same as the + /// other runtimes). Convert Picoschema first with [Picoschema.toJsonSchema]. final Map>? schemas; /// Resolver for loading partial templates dynamically. @@ -155,7 +158,14 @@ class Dotprompt { _tools[definition.name] = definition; } - /// Defines a schema (Picoschema or JSON Schema). + /// Registers a named JSON Schema that Picoschema can reference by name. + /// + /// [schema] must already be JSON Schema; it is inserted as-is. To register a + /// schema written in Picoschema, convert it first: + /// + /// ```dart + /// dotprompt.defineSchema('Address', Picoschema.toJsonSchema({'street': 'string', 'zip': 'integer'})); + /// ``` void defineSchema(String name, Map schema) { _schemas[name] = schema; } @@ -302,27 +312,22 @@ class Dotprompt { } } - // Process schemas (convert Picoschema to JSON Schema) + // Convert Picoschema to JSON Schema. JSON Schema input is passed through by + // the converter itself. var input = effectiveInput; var output = effectiveOutput; - if (input?.schema != null && Picoschema.isPicoschema(input!.schema!)) { - final jsonSchema = Picoschema.toJsonSchema( - input.schema, - schemas: _schemas, - ); + final inputSchema = input?.schema; + if (input != null && inputSchema != null) { input = InputConfig( - schema: jsonSchema, + schema: await _resolveSchema(inputSchema), defaultValues: input.defaultValues, ); } - if (output?.schema != null && Picoschema.isPicoschema(output!.schema!)) { - final jsonSchema = Picoschema.toJsonSchema( - output.schema, - schemas: _schemas, - ); - output = OutputConfig(format: output.format, schema: jsonSchema); + final outputSchema = output?.schema; + if (output != null && outputSchema != null) { + output = OutputConfig(format: output.format, schema: await _resolveSchema(outputSchema)); } return PromptMetadata( @@ -337,6 +342,15 @@ class Dotprompt { ); } + /// Converts a frontmatter schema to JSON Schema, resolving named schemas from + /// [defineSchema]/[DotpromptOptions.schemas] first and then + /// [DotpromptOptions.schemaResolver]. + Future> _resolveSchema(Map schema) => Picoschema.parse( + schema, + schemas: _schemas, + schemaResolver: _options.schemaResolver, + ); + /// Renders a template with the given data. Future _renderInternal( ParsedPrompt parsed, diff --git a/dart/dotprompt/lib/src/models/parsed_prompt.dart b/dart/dotprompt/lib/src/models/parsed_prompt.dart index d156b0060..71d23804d 100644 --- a/dart/dotprompt/lib/src/models/parsed_prompt.dart +++ b/dart/dotprompt/lib/src/models/parsed_prompt.dart @@ -34,7 +34,7 @@ import "prompt_metadata.dart"; /// ```dart /// final source = ''' /// --- -/// model: gemini-pro +/// model: googleai/gemini-flash-latest /// config: /// temperature: 0.7 /// --- @@ -43,7 +43,7 @@ import "prompt_metadata.dart"; /// /// final parsed = Parser.parseDocument(source); /// print(parsed.template); // "Hello {{name}}!" -/// print(parsed.model); // "gemini-pro" +/// print(parsed.model); // "googleai/gemini-flash-latest" /// ``` @immutable class ParsedPrompt { diff --git a/dart/dotprompt/lib/src/models/prompt_metadata.dart b/dart/dotprompt/lib/src/models/prompt_metadata.dart index e64b52e41..06a1e3372 100644 --- a/dart/dotprompt/lib/src/models/prompt_metadata.dart +++ b/dart/dotprompt/lib/src/models/prompt_metadata.dart @@ -45,7 +45,7 @@ import "parsed_prompt.dart"; /// /// ```yaml /// --- -/// model: gemini-pro +/// model: googleai/gemini-flash-latest /// config: /// temperature: 0.7 /// maxOutputTokens: 1024 @@ -200,11 +200,13 @@ class InputConfig { /// Creates an [InputConfig] from a value that can be a String or Map. /// - /// If the value is a String, it's treated as a schema name reference. + /// If the value is a String (`input: MySchema`), it's treated as a Picoschema + /// type string, so named schemas are resolved during metadata resolution. factory InputConfig.fromValue(dynamic value) { if (value is String) { - // String value is a schema name reference - return InputConfig(schema: {r"$ref": value}); + // Same wrapping as `schema: MySchema` in fromJson, so the name is + // resolved (or rejected) instead of passing through as a raw `$ref`. + return InputConfig(schema: {r"$type": value}); } else if (value is Map) { return InputConfig.fromJson(value); } @@ -259,11 +261,14 @@ class OutputConfig { /// Creates an [OutputConfig] from a value that can be a String or Map. /// - /// If the value is a String, it's treated as a schema name reference. + /// If the value is a String (`output: MySchema`), it's treated as a + /// Picoschema type string, so named schemas are resolved during metadata + /// resolution. factory OutputConfig.fromValue(dynamic value) { if (value is String) { - // String value is a schema name reference - return OutputConfig(schema: {r"$ref": value}); + // Same wrapping as `schema: MySchema` in fromJson, so the name is + // resolved (or rejected) instead of passing through as a raw `$ref`. + return OutputConfig(schema: {r"$type": value}); } else if (value is Map) { return OutputConfig.fromJson(value); } diff --git a/dart/dotprompt/lib/src/models/rendered_prompt.dart b/dart/dotprompt/lib/src/models/rendered_prompt.dart index 351356e67..e7b9910e7 100644 --- a/dart/dotprompt/lib/src/models/rendered_prompt.dart +++ b/dart/dotprompt/lib/src/models/rendered_prompt.dart @@ -35,7 +35,7 @@ import "../types.dart"; /// final dotprompt = Dotprompt(); /// final result = dotprompt.render(template, data); /// -/// print(result.config['model']); // "gemini-pro" +/// print(result.config['model']); // "googleai/gemini-flash-latest" /// for (final message in result.messages) { /// print('${message.role}: ${message.content}'); /// } diff --git a/dart/dotprompt/lib/src/parse.dart b/dart/dotprompt/lib/src/parse.dart index a1016f238..fc09cd016 100644 --- a/dart/dotprompt/lib/src/parse.dart +++ b/dart/dotprompt/lib/src/parse.dart @@ -26,7 +26,7 @@ /// /// ``` /// --- -/// model: gemini-pro +/// model: googleai/gemini-flash-latest /// config: /// temperature: 0.7 /// --- @@ -60,13 +60,13 @@ final RegExp _frontmatterPattern = RegExp( /// ```dart /// final source = ''' /// --- -/// model: gemini-pro +/// model: googleai/gemini-flash-latest /// --- /// Hello {{name}}! /// '''; /// /// final parsed = Parser.parseDocument(source); -/// print(parsed.model); // "gemini-pro" +/// print(parsed.model); // "googleai/gemini-flash-latest" /// print(parsed.template); // "Hello {{name}}!" /// ``` class Parser { diff --git a/dart/dotprompt/lib/src/picoschema.dart b/dart/dotprompt/lib/src/picoschema.dart index 6dc0b40fd..3cb1f9218 100644 --- a/dart/dotprompt/lib/src/picoschema.dart +++ b/dart/dotprompt/lib/src/picoschema.dart @@ -16,371 +16,329 @@ /// Picoschema to JSON Schema converter. /// -/// Picoschema is a compact, human-readable schema format that compiles to -/// standard JSON Schema. It's designed to be easy to write in YAML frontmatter. -/// -/// ## Picoschema Syntax +/// Picoschema is a compact, YAML-friendly schema format that compiles to JSON +/// Schema. This implementation follows the JavaScript reference +/// implementation and the spec in `spec/picoschema.yaml`. See +/// https://google.github.io/dotprompt/reference/picoschema/. /// /// ```yaml -/// # Simple types -/// name: string -/// age: integer -/// score: number -/// active: boolean -/// -/// # Arrays -/// tags: string[] -/// -/// # Optional fields (with ?) -/// nickname?: string -/// -/// # Descriptions (in parentheses) -/// email(User's email address): string -/// -/// # Nested objects -/// address: -/// street: string -/// city: string -/// zip: string -/// -/// # Enums -/// status: approved | pending | rejected +/// title: string, the article title # scalar with description +/// subtitle?: string # optional (and nullable) +/// status?(enum, approval status): [PENDING, APPROVED] +/// tags(array, relevant tags): string # array of scalars +/// authors(array): # array of objects +/// name: string +/// email?: string +/// metadata?(object, extra info): # nested object +/// updatedAt?: string, ISO timestamp of last update +/// labels(object): +/// (*): string # wildcard -> additionalProperties +/// address: Address # named schema reference /// ``` /// -/// ## Compiled JSON Schema -/// -/// The above compiles to: -/// ```json -/// { -/// "type": "object", -/// "properties": { -/// "name": {"type": "string"}, -/// "age": {"type": "integer"}, -/// "score": {"type": "number"}, -/// "active": {"type": "boolean"}, -/// "tags": {"type": "array", "items": {"type": "string"}}, -/// "nickname": {"type": "string"}, -/// "email": {"type": "string", "description": "User's email address"}, -/// "address": { -/// "type": "object", -/// "properties": {...}, -/// "required": ["street", "city", "zip"] -/// }, -/// "status": {"type": "string", "enum": ["approved", "pending", "rejected"]} -/// }, -/// "required": ["name", "age", "score", "active", "tags", "email", "address", "status"] -/// } -/// ``` +/// Scalar types are `string`, `number`, `integer`, `boolean`, `null` and +/// `any`. Any other type name is looked up as a named schema. The only +/// parenthetical types are `array`, `object` and `enum`; anything else in +/// parentheses is an error. library; import "error.dart"; +import "store.dart" show SchemaResolver; -/// Converts a Picoschema definition to JSON Schema. -/// -/// Picoschema is a compact schema format designed for ease of use in YAML. -/// -/// ## Example +/// Looks up a named schema, throwing if it is unknown. +typedef _SchemaLookup = Map Function(String name); + +/// Converts Picoschema definitions to JSON Schema. /// /// ```dart -/// final picoschema = { -/// 'name': 'string', -/// 'age': 'integer', -/// 'tags': 'string[]', -/// }; -/// -/// final jsonSchema = Picoschema.toJsonSchema(picoschema); -/// // Returns: +/// final schema = Picoschema.toJsonSchema({ +/// 'name': 'string, the name', +/// 'steps(array)': {'number': 'integer', 'instruction': 'string'}, +/// }); /// // { /// // "type": "object", /// // "properties": { -/// // "name": {"type": "string"}, -/// // "age": {"type": "integer"}, -/// // "tags": {"type": "array", "items": {"type": "string"}} +/// // "name": {"type": "string", "description": "the name"}, +/// // "steps": { +/// // "type": "array", +/// // "items": {"type": "object", "properties": {...}, ...} +/// // } /// // }, -/// // "required": ["name", "age", "tags"] +/// // "additionalProperties": false, +/// // "required": ["name", "steps"] /// // } /// ``` class Picoschema { - /// Private constructor to prevent instantiation. Picoschema._(); - /// Primitive type mappings from Picoschema to JSON Schema. - static const Map _primitiveTypes = { - "string": "string", - "str": "string", - "number": "number", - "num": "number", - "float": "number", - "double": "number", - "integer": "integer", - "int": "integer", - "boolean": "boolean", - "bool": "boolean", - "null": "null", - "any": "object", - "object": "object", - }; + static const Set _scalarTypes = {"any", "boolean", "integer", "null", "number", "string"}; - /// Regex for parsing field names with optional description and optionality. - /// Matches: fieldName(description)? or fieldName?(description)? - static final RegExp _fieldPattern = RegExp( - r"^([a-zA-Z_][a-zA-Z0-9_]*)(\?)?(?:\(([^)]+)\))?$", - ); + /// Top-level `type` values that mark a schema as already being JSON Schema. + static const Set _jsonSchemaTypes = {..._scalarTypes, "object", "array"}; - /// Regex for parsing array types (e.g., "string[]"). - static final RegExp _arrayPattern = RegExp(r"^(.+)\[\]$"); + static const String _wildcardKey = "(*)"; - /// Regex for parsing enum types (e.g., "foo | bar | baz"). - static final RegExp _enumPattern = RegExp(r"^([^|]+(?:\s*\|\s*[^|]+)+)$"); + /// Splits `name?(type, description)` into `name?` and the parenthetical contents. + static final RegExp _parentheticalKey = RegExp(r"^([^()]*)\((.*)\)$"); - /// Converts a Picoschema definition to JSON Schema. - /// - /// The input can be: - /// - A string (primitive type, array type, or enum) - /// - A map (object schema) + /// Converts [picoschema] to JSON Schema synchronously. /// - /// The optional [schemas] parameter provides a map of named schemas that - /// can be referenced by type strings (e.g., `Foo` to reference a `Foo` schema). + /// Named schema references are looked up in [schemas] only. Use [parse] to + /// also consult an async [SchemaResolver]. Registered schemas must already + /// be JSON Schema; they are inserted as-is, matching the other runtimes. /// - /// Returns a JSON Schema object. + /// Values that are already JSON Schema (see [isPicoschema]) are returned + /// unchanged. A `null` input yields `{"type": "object"}`. /// - /// Throws [PicoschemaException] if the schema is invalid. + /// Throws [PicoschemaException] if the schema is invalid or references an + /// unknown named schema. static Map toJsonSchema( - dynamic picoschema, { - Map? schemas, - }) { - if (picoschema == null) { - return {"type": "object"}; - } - - if (picoschema is String) { - return _parseTypeString(picoschema, schemas: schemas); - } - - if (picoschema is Map) { - final schemaMap = picoschema.cast(); - // Handle synthetic $type key (string schema wrapped in a map) - if (schemaMap.containsKey(r"$type")) { - return _parseTypeString( - schemaMap[r"$type"] as String, - schemas: schemas, - ); + Object? picoschema, { + Map>? schemas, + }) => + _convert( + picoschema, + (name) => schemas?[name] ?? (throw _unknownSchema(name, hasSchemaSource: schemas != null)), + ); + + /// Converts [picoschema] to JSON Schema, resolving named schemas from + /// [schemas] first and then [schemaResolver] (same order as the JS + /// implementation). Both must provide JSON Schema; it is inserted as-is. + /// + /// ```dart + /// final schema = await Picoschema.parse( + /// {'address': 'Address, where to ship'}, + /// schemaResolver: (name) async => lookupSchema(name), + /// ); + /// ``` + static Future> parse( + Object? picoschema, { + Map>? schemas, + SchemaResolver? schemaResolver, + }) async { + final resolved = >{...?schemas}; + // The converter is sync, so names needing the async resolver are discovered + // by converting, catching the first miss, resolving it and retrying. Each + // retry resolves one more distinct name and Picoschema documents are small, + // so this is cheaper to maintain than a separate reference-collecting walk. + while (true) { + try { + return _convert(picoschema, (name) => resolved[name] ?? (throw _UnresolvedSchema(name))); + } on _UnresolvedSchema catch (e) { + final schema = await schemaResolver?.call(e.name); + if (schema == null) { + throw _unknownSchema(e.name, hasSchemaSource: schemas != null || schemaResolver != null); + } + resolved[e.name] = schema; } - return _parseObjectSchema(schemaMap, schemas: schemas); } - - throw PicoschemaException( - "Invalid picoschema type: ${picoschema.runtimeType}", - ); } - /// Parses a type string into a JSON Schema. + /// Whether [schema] should be converted as Picoschema, i.e. it is not + /// already JSON Schema. [toJsonSchema] and [parse] apply the same check, so + /// calling this first is optional. /// - /// Handles formats like: - /// - `string` - simple type - /// - `string, the description` - type with description - /// - `string[]` - array type - /// - `foo | bar | baz` - enum type - static Map _parseTypeString( - String typeStr, { - Map? schemas, - }) { - final trimmed = typeStr.trim(); + /// JSON Schema is detected like in the JS and Python runtimes: a top-level + /// `type` naming a JSON Schema type, or a `properties` map. A list-valued + /// `type` or `anyOf`/`oneOf`/`allOf`/`enum`, `$schema` and `$ref` also count, + /// so JSON Schema without a single `type` string is not misparsed. + static bool isPicoschema(Map schema) => _wrappedTypeString(schema) != null || !_isJsonSchema(schema); + + static const Set _jsonSchemaListKeywords = {"anyOf", "oneOf", "allOf", "enum"}; + + // Deliberately shallow, like JS/Python: nothing below the top level is + // inspected. So `{type: object, properties: {a: string}}` is passed through + // as-is, and a Picoschema field named `type` with a JSON Schema type value + // (`{type: string, name: string}`) makes the whole map JSON Schema. + static bool _isJsonSchema(Map schema) { + final type = schema["type"]; + return (type is String && _jsonSchemaTypes.contains(type)) || + (type is List && type.isNotEmpty && type.every(_jsonSchemaTypes.contains)) || + schema["properties"] is Map || + _jsonSchemaListKeywords.any((k) => schema[k] is List) || + schema.containsKey(r"$schema") || + schema.containsKey(r"$ref"); + } - // Check for type with description (type, description) - final commaIndex = trimmed.indexOf(","); - if (commaIndex > 0) { - final typePart = trimmed.substring(0, commaIndex).trim(); - final descPart = trimmed.substring(commaIndex + 1).trim(); - final typeSchema = _parseTypeString(typePart, schemas: schemas); - if (descPart.isNotEmpty) { - typeSchema["description"] = descPart; - } - return typeSchema; - } + /// The type string of frontmatter like `schema: string`, which + /// InputConfig/OutputConfig wrap as `{$type: "string"}`. Only a map whose + /// sole key is `$type` counts, so a Picoschema object that happens to have a + /// `$type` field is still parsed as an object. + static String? _wrappedTypeString(Map schema) { + final wrapped = schema[r"$type"]; + return schema.length == 1 && wrapped is String ? wrapped : null; + } - // Check for array type - final arrayMatch = _arrayPattern.firstMatch(trimmed); - if (arrayMatch != null) { - final itemType = arrayMatch.group(1)!.trim(); - return { - "type": "array", - "items": _parseTypeString(itemType, schemas: schemas), + static Map _deepCopy(Map map) => + map.map((key, value) => MapEntry(key, _deepCopyValue(value))); + + static Object? _deepCopyValue(Object? value) => switch (value) { + final Map m => _deepCopy(m.cast()), + final List l => [for (final e in l) _deepCopyValue(e)], + _ => value, }; - } - // Check for enum type - final enumMatch = _enumPattern.firstMatch(trimmed); - if (enumMatch != null) { - final values = trimmed.split("|").map((s) => s.trim()).toList(); - return {"type": "string", "enum": values}; + static Map _convert(Object? schema, _SchemaLookup lookup) { + if (schema == null) { + return {"type": "object"}; } - - // Check for primitive type - final normalizedType = trimmed.toLowerCase(); - if (normalizedType == "any") { - // 'any' type returns empty schema (allows any value) - return {}; + if (schema is String) { + return _parseTypeString(schema, lookup); } - if (_primitiveTypes.containsKey(normalizedType)) { - return {"type": _primitiveTypes[normalizedType]}; + if (schema is Map) { + final map = schema.cast(); + final wrapped = _wrappedTypeString(map); + if (wrapped != null) { + return _parseTypeString(wrapped, lookup); + } + if (_isJsonSchema(map)) { + // Copied like named schemas, so callers never share nested maps with + // the input. A bare `properties` map implies an object type. + return {..._deepCopy(map), if (map["type"] == null && map["properties"] is Map) "type": "object"}; + } + return _parseObject(map, lookup); } + throw PicoschemaException("Picoschema: only consists of objects and strings. Got: $schema"); + } - // Check for named schema reference - if (schemas != null && schemas.containsKey(trimmed)) { - return Map.from(schemas[trimmed] as Map); + /// Parses `type[, description]`, where `type` is a scalar or a named schema. + static Map _parseTypeString(String input, _SchemaLookup lookup) { + final (type, description) = _extractDescription(input); + final schema = switch (type) { + // JS returns `{type: "any"}` for a top-level `any`, which is not valid + // JSON Schema; `{}` (what JS returns for nested fields) is used everywhere. + "any" => {}, + _ when _scalarTypes.contains(type) => {"type": type}, + // Deep copy so neither this converter nor callers editing the result can + // change registered schemas. + _ => _deepCopy(lookup(type)), + }; + if (description != null) { + schema["description"] = description; } + return schema; + } - // Unknown type - treat as a named schema reference (return it for later resolution) - return {r"$ref": trimmed}; + /// Parses the value side of an object field. + static Map _parseValue(Object? value, String key, _SchemaLookup lookup) { + if (value is String) { + return _parseTypeString(value, lookup); + } + if (value is Map) { + return _parseObject(value.cast(), lookup); + } + // `field:` with no value is an empty object, as in JS. + if (value == null) { + return _parseObject(const {}, lookup); + } + throw PicoschemaException("Picoschema: only consists of objects and strings. Got: $value (in '$key')"); } - /// Parses an object schema definition. - /// - /// Handles: - /// - Regular fields: `fieldName: type` - /// - Optional fields: `fieldName?: type` (adds null to type union) - /// - Descriptions: `fieldName(description): type` - /// - Wildcard: `(*): type` (becomes additionalProperties) - static Map _parseObjectSchema( - Map schema, { - Map? schemas, - }) { + static Map _parseObject(Map obj, _SchemaLookup lookup) { final properties = {}; final required = []; - Map? additionalProperties; - - // Extended field pattern that also matches (*) - final wildcardPattern = RegExp(r"^\(\*\)(?:\(([^)]+)\))?$"); + Object additionalProperties = false; - for (final entry in schema.entries) { - // Check for wildcard field (*) - final wildcardMatch = wildcardPattern.firstMatch(entry.key); - if (wildcardMatch != null) { - final description = wildcardMatch.group(1); - Map wildcardSchema; - if (entry.value is String) { - wildcardSchema = _parseTypeString( - entry.value as String, - schemas: schemas, - ); - } else if (entry.value is Map) { - wildcardSchema = _parseObjectSchema( - (entry.value as Map).cast(), - schemas: schemas, - ); - } else { - wildcardSchema = {}; - } - if (description != null) { - wildcardSchema["description"] = description; - } - additionalProperties = wildcardSchema; + for (final MapEntry(:key, :value) in obj.entries) { + if (key == _wildcardKey) { + additionalProperties = _parseValue(value, key, lookup); continue; } - final fieldMatch = _fieldPattern.firstMatch(entry.key); - if (fieldMatch == null) { - throw PicoschemaException("Invalid field name: ${entry.key}"); + final match = _parentheticalKey.firstMatch(key); + if (match == null && (key.contains("(") || key.contains(")"))) { + throw PicoschemaException("Picoschema: invalid property name '$key'"); } - - final fieldName = fieldMatch.group(1)!; - final isOptional = fieldMatch.group(2) == "?"; - final description = fieldMatch.group(3); - - // Parse the field value - Map fieldSchema; - if (entry.value is String) { - fieldSchema = _parseTypeString(entry.value as String, schemas: schemas); - } else if (entry.value is Map) { - fieldSchema = _parseObjectSchema( - (entry.value as Map).cast(), - schemas: schemas, - ); - } else if (entry.value is List) { - // Enum as list of values - fieldSchema = {"enum": entry.value}; - } else if (entry.value == null) { - fieldSchema = {"type": "object"}; - } else { - throw PicoschemaException( - "Invalid field value for '$fieldName': ${entry.value.runtimeType}", - ); + final name = (match?.group(1) ?? key).trim(); + final isOptional = name.endsWith("?"); + // Trimmed again so `a ?` is `a`, not `a ` (JS keeps the space). + final propertyName = (isOptional ? name.substring(0, name.length - 1) : name).trim(); + if (propertyName.isEmpty) { + throw PicoschemaException("Picoschema: invalid property name '$key'"); } - - // Add description if present in field name - if (description != null) { - fieldSchema["description"] = description; + // `a` and `a?` (or `a(array)`) map to the same property. JS lets the last + // key win and leaves `required` inconsistent; reject it instead. + if (properties.containsKey(propertyName)) { + throw PicoschemaException("Picoschema: duplicate property '$propertyName' (in '$key')"); } - - // Handle optional fields - add null to type union - if (isOptional) { - final existingType = fieldSchema["type"]; - final existingEnum = fieldSchema["enum"]; - if (existingType != null) { - if (existingType is String) { - fieldSchema["type"] = [existingType, "null"]; - } else if (existingType is List && !existingType.contains("null")) { - fieldSchema["type"] = [...existingType, "null"]; - } - } else if (existingEnum != null && existingEnum is List) { - if (!existingEnum.contains(null)) { - fieldSchema["enum"] = [...existingEnum, null]; - } - } + if (!isOptional) { + required.add(propertyName); } - properties[fieldName] = fieldSchema; + final parenthetical = match?.group(2); + if (parenthetical == null) { + final prop = _parseValue(value, key, lookup); + properties[propertyName] = isOptional ? _nullable(prop) : prop; + continue; + } - // Track required fields (non-optional) - if (!isOptional) { - required.add(fieldName); + final (type, description) = _extractDescription(parenthetical); + final prop = switch (type) { + "array" => { + "type": isOptional ? ["array", "null"] : "array", + "items": _parseValue(value, key, lookup), + }, + "object" when isOptional => _nullable(_parseValue(value, key, lookup)), + "object" => _parseValue(value, key, lookup), + "enum" => _enumSchema(value, key, isOptional: isOptional), + _ => throw PicoschemaException( + "Picoschema: parenthetical types must be 'object', 'array' or 'enum', got: '$type' (in '$key')", + ), + }; + if (description != null) { + prop["description"] = description; } + properties[propertyName] = prop; } - final result = { + return { "type": "object", "properties": properties, - "additionalProperties": additionalProperties ?? false, + "additionalProperties": additionalProperties, + if (required.isNotEmpty) "required": required, }; + } - if (required.isNotEmpty) { - result["required"] = required; + static Map _enumSchema(Object? value, String key, {required bool isOptional}) { + if (value is! List) { + throw PicoschemaException("Picoschema: enum values must be a list (in '$key')"); } - - return result; + return { + "enum": [...value, if (isOptional && !value.contains(null)) null], + }; } - /// Checks if the given schema appears to be a Picoschema (vs. JSON Schema). - /// - /// Returns true if the schema looks like Picoschema and should be converted. - static bool isPicoschema(Map schema) { - // JSON Schema typically has "$schema" or "$ref" at the top level - if (schema.containsKey(r"$schema") || schema.containsKey(r"$ref")) { - return false; + /// Optional fields are also nullable. Only a single string `type` is widened, + /// matching the other runtimes. + static Map _nullable(Map schema) { + final type = schema["type"]; + if (type is String && type != "null") { + schema["type"] = [type, "null"]; } + return schema; + } - // Check for our synthetic $type key (string schema wrapped in a map) - if (schema.containsKey(r"$type")) { - return true; + /// Splits `type, description` on the first comma. The description is null + /// when there is no comma or nothing after it. + static (String, String?) _extractDescription(String input) { + final comma = input.indexOf(","); + if (comma < 0) { + return (input.trim(), null); } + final description = input.substring(comma + 1).trim(); + return (input.substring(0, comma).trim(), description.isEmpty ? null : description); + } - // If it has type=object with properties, it's likely already JSON Schema - if (schema["type"] == "object" && schema.containsKey("properties")) { - return false; - } + static PicoschemaException _unknownSchema(String name, {required bool hasSchemaSource}) => PicoschemaException( + hasSchemaSource + ? "Picoschema: could not find schema with name '$name'" + : "Picoschema: unsupported scalar type '$name'.", + ); +} - // If any value is a simple type string, it's Picoschema - for (final value in schema.values) { - if (value is String) { - final normalized = value.toLowerCase(); - if (_primitiveTypes.containsKey(normalized) || _arrayPattern.hasMatch(value) || _enumPattern.hasMatch(value)) { - return true; - } - } - } +/// Signals a named schema that [Picoschema.parse] still has to resolve. +class _UnresolvedSchema implements Exception { + const _UnresolvedSchema(this.name); - // Default: assume it's JSON Schema to avoid corrupting valid schemas - return false; - } + final String name; } diff --git a/dart/dotprompt/lib/src/store.dart b/dart/dotprompt/lib/src/store.dart index e4ef6c5ee..50181fed1 100644 --- a/dart/dotprompt/lib/src/store.dart +++ b/dart/dotprompt/lib/src/store.dart @@ -155,5 +155,6 @@ typedef DotpromptPartialResolver = Future Function(String name); /// Function type for resolving tool definitions. typedef ToolResolver = Future?> Function(String name); -/// Function type for resolving schemas. +/// Function type for resolving named schemas. Returns JSON Schema, or null if +/// the name is unknown. typedef SchemaResolver = Future?> Function(String name); diff --git a/dart/dotprompt/test/picoschema_test.dart b/dart/dotprompt/test/picoschema_test.dart index de3e4ae1b..5096d02fb 100644 --- a/dart/dotprompt/test/picoschema_test.dart +++ b/dart/dotprompt/test/picoschema_test.dart @@ -15,155 +15,855 @@ // SPDX-License-Identifier: Apache-2.0 /// Unit tests for the Picoschema class. +/// +/// Mirrors `js/src/picoschema.test.ts`; cross-runtime behavior is also covered +/// by `spec/picoschema.yaml` via `spec_test.dart`. library; import "package:dotprompt/dotprompt.dart"; -import "package:dotprompt/src/picoschema.dart"; import "package:test/test.dart"; +Map _props(Map schema) => schema["properties"] as Map; + +Matcher _throwsPicoschema(Pattern message) => + throwsA(isA().having((e) => e.message, "message", contains(message))); + void main() { - group("Picoschema", () { - group("toJsonSchema", () { - test("converts simple string type", () { - final result = Picoschema.toJsonSchema("string"); - expect(result, equals({"type": "string"})); + group("Picoschema.toJsonSchema", () { + group("scalars", () { + for (final type in ["string", "number", "integer", "boolean", "null"]) { + test("converts $type", () { + expect(Picoschema.toJsonSchema(type), equals({"type": type})); + }); + } + + test("converts any to an empty schema", () { + expect(Picoschema.toJsonSchema("any"), equals({})); + expect(Picoschema.toJsonSchema("any, anything"), equals({"description": "anything"})); + }); + + test("extracts description after the first comma", () { + expect( + Picoschema.toJsonSchema("number, the description, with commas "), + equals({"type": "number", "description": "the description, with commas"}), + ); + }); + + test("handles null input", () { + expect(Picoschema.toJsonSchema(null), equals({"type": "object"})); }); - test("converts integer type", () { - final result = Picoschema.toJsonSchema("integer"); - expect(result, equals({"type": "integer"})); + test("throws on unknown types without schemas", () { + expect(() => Picoschema.toJsonSchema("UndefinedType"), _throwsPicoschema("unsupported scalar type")); }); - test("converts number type", () { - final result = Picoschema.toJsonSchema("number"); - expect(result, equals({"type": "number"})); + test("throws on non-standard type syntax", () { + for (final type in ["string[]", "a | b", "str", "int", "String"]) { + expect(() => Picoschema.toJsonSchema({"field": type}), throwsA(isA()), reason: type); + } + }); + + test("throws on invalid schema values", () { + expect(() => Picoschema.toJsonSchema(123), _throwsPicoschema("only consists of objects and strings")); + expect( + () => Picoschema.toJsonSchema({ + "field": [1, 2], + }), + _throwsPicoschema("only consists of objects and strings"), + ); }); + }); - test("converts boolean type", () { - final result = Picoschema.toJsonSchema("boolean"); - expect(result, equals({"type": "boolean"})); + group("JSON Schema passthrough", () { + test("returns schemas with a JSON Schema type unchanged", () { + final schema = { + "type": "object", + "properties": { + "name": {"type": "string"}, + }, + }; + expect(Picoschema.toJsonSchema(schema), equals(schema)); + expect(Picoschema.toJsonSchema({"type": "string"}), equals({"type": "string"})); }); - test("converts array type", () { - final result = Picoschema.toJsonSchema("string[]"); + test("adds type object when only properties is present", () { expect( - result, + Picoschema.toJsonSchema({ + "properties": { + "name": {"type": "string"}, + }, + }), equals({ - "type": "array", - "items": {"type": "string"}, + "type": "object", + "properties": { + "name": {"type": "string"}, + }, }), ); }); - test("converts nested array type", () { - final result = Picoschema.toJsonSchema("integer[][]"); + test("returns schemas without a single type string unchanged", () { + final schemas = >[ + { + "anyOf": [ + {"type": "string"}, + {"type": "null"}, + ], + }, + { + "oneOf": [ + {"type": "string"}, + {"type": "integer"}, + ], + }, + { + "allOf": [ + {"type": "object"}, + ], + }, + { + "enum": ["a", "b"], + }, + { + "type": ["string", "null"], + }, + { + r"$defs": { + "A": {"type": "string"}, + }, + r"$ref": r"#/$defs/A", + }, + ]; + for (final schema in schemas) { + expect(Picoschema.toJsonSchema(schema), equals(schema), reason: "$schema"); + } + }); + + test("still parses Picoschema fields named like JSON Schema keywords", () { expect( - result, + Picoschema.toJsonSchema({"items": "string", "enum": "integer"}), equals({ + "type": "object", + "properties": { + "items": {"type": "string"}, + "enum": {"type": "integer"}, + }, + "additionalProperties": false, + "required": ["items", "enum"], + }), + ); + }); + + test("parses map-valued fields named like JSON Schema keywords as nested objects", () { + final expected = { + "type": "object", + "properties": { + "sku": {"type": "string"}, + "qty": {"type": "integer"}, + }, + "additionalProperties": false, + "required": ["sku", "qty"], + }; + for (final key in ["items", r"$defs"]) { + expect( + Picoschema.toJsonSchema({ + key: {"sku": "string", "qty": "integer"}, + }), + equals({ + "type": "object", + "properties": {key: expected}, + "additionalProperties": false, + "required": [key], + }), + reason: key, + ); + } + }); + + test("passes through JSON Schema with annotations and extension keys", () { + final schemas = >[ + { + "type": "object", + "title": "Person", + "description": "a person", + "properties": { + "name": {"type": "string", "x-order": 1}, + "extra": {}, + "anything": true, + }, + "required": ["name"], + "examples": [ + {"name": "Ann"}, + ], + "x-internal": true, + }, + {"type": "string", "format": "email", "minLength": 3, "nullable": true}, + {"type": "string", "description": "a person, really"}, + ]; + for (final schema in schemas) { + expect(Picoschema.toJsonSchema(schema), equals(schema), reason: "$schema"); + } + }); + + test("passes through nested JSON Schema subschemas", () { + final schemas = >[ + { + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": {"type": "string"}, + }, + "nick": { + "anyOf": [ + {"type": "string"}, + {"type": "null"}, + ], + }, + "meta": { + "type": "object", + "additionalProperties": {"type": "number"}, + }, + }, + "required": ["tags"], + }, + { "type": "array", - "items": { - "type": "array", - "items": {"type": "integer"}, + "items": [ + {"type": "string"}, + {"type": "integer"}, + ], + }, + ]; + for (final schema in schemas) { + expect(Picoschema.toJsonSchema(schema), equals(schema), reason: "$schema"); + } + }); + + test("returns a deep copy, not the input map", () { + final schema = { + "type": "object", + "properties": { + "a": {"type": "string"}, + }, + }; + final result = Picoschema.toJsonSchema(schema); + result["description"] = "changed"; + (_props(result)["a"] as Map)["description"] = "changed"; + expect( + schema, + equals({ + "type": "object", + "properties": { + "a": {"type": "string"}, }, }), ); }); - test("converts enum type", () { - final result = Picoschema.toJsonSchema("foo | bar | baz"); + test("passes through JSON Schema with keywords outside the core vocabulary", () { + // OpenAPI and vendor keywords must not cause a fallback to Picoschema. + final schema = { + "type": "object", + "properties": { + "kind": {"type": "string"}, + }, + "discriminator": {"propertyName": "kind"}, + "externalDocs": {"url": "https://example.com"}, + }; + expect(Picoschema.toJsonSchema(schema), equals(schema)); + }); + + test("does not inspect anything below the top level", () { + // Same as JS/Python: once the top level looks like JSON Schema, the + // whole map is passed through as-is. + final schema = { + "type": "object", + "properties": {"a": "string"}, + }; + expect(Picoschema.toJsonSchema(schema), equals(schema)); + }); + }); + + group("fields named like JSON Schema keywords", () { + test("treats a top-level type with a JSON Schema type value as JSON Schema", () { + // Ambiguous with a Picoschema field named `type`; resolved the same way + // as in JS/Python. + final schema = {"type": "string", "title": "string"}; + expect(Picoschema.toJsonSchema(schema), equals(schema)); + }); + + test("parses a type field without a JSON Schema type value as Picoschema", () { + expect( + Picoschema.toJsonSchema({"type": "string, event kind", "name": "string"}), + equals({ + "type": "object", + "properties": { + "type": {"type": "string", "description": "event kind"}, + "name": {"type": "string"}, + }, + "additionalProperties": false, + "required": ["type", "name"], + }), + ); + }); + + test(r"treats a $type field next to other fields as a Picoschema field", () async { + // `schema: string` is wrapped as {$type: string}; only that exact shape + // is unwrapped, so other fields are never dropped. + final metadata = await Dotprompt().renderMetadata(r""" +--- +output: + schema: + $type: string + name: string +--- +hi +"""); + expect(_props(metadata.output!.schema!).keys, equals([r"$type", "name"])); + expect(Picoschema.isPicoschema({r"$type": "string", "name": "string"}), isTrue); + }); + }); + + group("objects", () { + test("converts fields and marks them required", () { + expect( + Picoschema.toJsonSchema({"name": "string", "age": "integer, in years"}), + equals({ + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer", "description": "in years"}, + }, + "additionalProperties": false, + "required": ["name", "age"], + }), + ); + }); + + test("makes optional fields nullable and not required", () { + final result = Picoschema.toJsonSchema({"name": "string", "nickname?": "string"}); + expect( + _props(result)["nickname"], + equals({ + "type": ["string", "null"], + }), + ); + expect(result["required"], equals(["name"])); + }); + + test("omits required when every field is optional", () { + expect(Picoschema.toJsonSchema({"a?": "string"}).containsKey("required"), isFalse); + }); + + test("does not duplicate null for optional null fields", () { + expect(_props(Picoschema.toJsonSchema({"x?": "null"}))["x"], equals({"type": "null"})); + }); + + test("rejects duplicate property names", () { + for (final schema in >[ + {"a": "string", "a?": "number"}, + {"a": "string", "a(array)": "number"}, + { + "a?(enum)": ["X"], + "a": "string", + }, + ]) { + expect(() => Picoschema.toJsonSchema(schema), _throwsPicoschema("duplicate property 'a'"), reason: "$schema"); + } + }); + + test("trims whitespace before the optional marker", () { expect( - result, + Picoschema.toJsonSchema({"a ?": "string", "b ?(array)": "string"}), equals({ - "type": "string", - "enum": ["foo", "bar", "baz"], + "type": "object", + "properties": { + "a": { + "type": ["string", "null"], + }, + "b": { + "type": ["array", "null"], + "items": {"type": "string"}, + }, + }, + "additionalProperties": false, }), ); }); - test("converts object schema", () { + test("converts nested objects without a qualifier", () { final result = Picoschema.toJsonSchema({ - "name": "string", - "age": "integer", + "user": {"name": "string"}, }); expect( - result, + _props(result)["user"], equals({ "type": "object", "properties": { "name": {"type": "string"}, - "age": {"type": "integer"}, }, "additionalProperties": false, - "required": ["name", "age"], + "required": ["name"], }), ); }); - test("handles optional fields", () { + test("converts (object) with description, optional", () { final result = Picoschema.toJsonSchema({ - "name": "string", - "nickname?": "string", + "obj?(object, a nested object)": {"x": "integer"}, }); - expect(result["required"], equals(["name"])); - expect((result["properties"] as Map).containsKey("nickname"), isTrue); + expect( + _props(result)["obj"], + equals({ + "type": ["object", "null"], + "description": "a nested object", + "properties": { + "x": {"type": "integer"}, + }, + "additionalProperties": false, + "required": ["x"], + }), + ); + }); + }); + + group("arrays", () { + test("converts (array) of scalars", () { + expect( + Picoschema.toJsonSchema({"names(array)": "string"}), + equals({ + "type": "object", + "properties": { + "names": { + "type": "array", + "items": {"type": "string"}, + }, + }, + "additionalProperties": false, + "required": ["names"], + }), + ); + }); + + test("puts the qualifier description on the array and the value description on items", () { + final result = Picoschema.toJsonSchema({"tags(array, list of tags)": "string, the tag"}); + expect( + _props(result)["tags"], + equals({ + "type": "array", + "description": "list of tags", + "items": {"type": "string", "description": "the tag"}, + }), + ); + }); + + test("makes optional arrays nullable", () { + final result = Picoschema.toJsonSchema({"items?(array, list of items)": "string"}); + expect( + _props(result)["items"], + equals({ + "type": ["array", "null"], + "description": "list of items", + "items": {"type": "string"}, + }), + ); + expect(result.containsKey("required"), isFalse); }); - test("handles field descriptions", () { + test("converts arrays of objects and nested arrays", () { final result = Picoschema.toJsonSchema({ - "email(User's email address)": "string", + "items(array)": {"props(array)": "string"}, }); + final items = _props(result)["items"] as Map; + expect(items["type"], equals("array")); + final element = items["items"] as Map; + expect(element["type"], equals("object")); expect( - (result["properties"] as Map)["email"], - equals({"type": "string", "description": "User's email address"}), + _props(element)["props"], + equals({ + "type": "array", + "items": {"type": "string"}, + }), ); }); + }); - test("converts nested objects", () { + group("enums", () { + test("converts (enum)", () { final result = Picoschema.toJsonSchema({ - "user": {"name": "string", "email": "string"}, + "status(enum, the status)": ["A", "B"], }); expect( - ((result["properties"] as Map)["user"] as Map)["type"], - equals("object"), + _props(result)["status"], + equals({ + "enum": ["A", "B"], + "description": "the status", + }), + ); + }); + + test("adds null to optional enums once", () { + expect( + _props( + Picoschema.toJsonSchema({ + "c?(enum)": ["A"], + }), + )["c"], + equals({ + "enum": ["A", null], + }), ); expect( - (((result["properties"] as Map)["user"] as Map)["properties"] as Map)["name"], - equals({"type": "string"}), + _props( + Picoschema.toJsonSchema({ + "c?(enum)": ["A", null], + }), + )["c"], + equals({ + "enum": ["A", null], + }), ); }); - test("handles null input", () { - final result = Picoschema.toJsonSchema(null); - expect(result, equals({"type": "object"})); + test("throws when enum values are not a list", () { + expect(() => Picoschema.toJsonSchema({"c(enum)": "A"}), _throwsPicoschema("enum values must be a list")); }); }); - group("isPicoschema", () { - test("returns true for Picoschema", () { - expect(Picoschema.isPicoschema({"name": "string"}), isTrue); + group("wildcards", () { + test("maps (*) to additionalProperties", () { + expect( + Picoschema.toJsonSchema({"other": "string", "(*)": "any, whatever you want"}), + equals({ + "type": "object", + "properties": { + "other": {"type": "string"}, + }, + "additionalProperties": {"description": "whatever you want"}, + "required": ["other"], + }), + ); }); - test(r"returns false for JSON Schema with $schema", () { + test("supports wildcard-only objects", () { expect( - Picoschema.isPicoschema({ - r"$schema": "http://json-schema.org/draft-07/schema#", + Picoschema.toJsonSchema({"(*)": "number, lucky number"}), + equals({ "type": "object", + "properties": {}, + "additionalProperties": {"type": "number", "description": "lucky number"}, }), - isFalse, ); }); + }); + + group("invalid parentheticals", () { + test("rejects free-text descriptions in parentheses", () { + expect( + () => Picoschema.toJsonSchema({"email(User's email address)": "string"}), + _throwsPicoschema("parenthetical types must be 'object', 'array' or 'enum', got: 'User's email address'"), + ); + }); + + test("rejects name(*)", () { + expect(() => Picoschema.toJsonSchema({"wild(*)": "string"}), _throwsPicoschema("got: '*'")); + }); + + test("rejects malformed keys", () { + expect(() => Picoschema.toJsonSchema({"bad(array": "string"}), _throwsPicoschema("invalid property name")); + expect(() => Picoschema.toJsonSchema({"(array)": "string"}), _throwsPicoschema("invalid property name")); + }); + }); + + group("named schemas", () { + final schemas = { + "Foo": {"type": "number", "description": "a foo"}, + }; - test("returns false for JSON Schema with type object and properties", () { + test("resolves from schemas and overrides the description", () { + expect(Picoschema.toJsonSchema("Foo", schemas: schemas), equals({"type": "number", "description": "a foo"})); expect( - Picoschema.isPicoschema({ + Picoschema.toJsonSchema("Foo, an overridden foo", schemas: schemas), + equals({"type": "number", "description": "an overridden foo"}), + ); + }); + + test("returns deep copies of registered schemas", () { + final registered = >{ + "Obj": { "type": "object", "properties": { - "name": {"type": "string"}, + "x": {"type": "string"}, }, + }, + }; + final result = Picoschema.toJsonSchema({"o": "Obj"}, schemas: registered); + _props(_props(result)["o"] as Map).remove("x"); + expect(_props(registered["Obj"]!).keys, equals(["x"])); + }); + + test("makes optional references nullable without mutating the registered schema", () { + final result = Picoschema.toJsonSchema({"foo?": "Foo"}, schemas: schemas); + expect( + _props(result)["foo"], + equals({ + "type": ["number", "null"], + "description": "a foo", }), - isFalse, ); + expect(schemas["Foo"], equals({"type": "number", "description": "a foo"})); + }); + + test("throws when a named schema is missing", () { + expect(() => Picoschema.toJsonSchema("Bar", schemas: schemas), _throwsPicoschema("could not find schema")); }); }); }); + + group("Picoschema.parse", () { + test("resolves named schemas via the async resolver", () async { + final requested = []; + final result = await Picoschema.parse( + {"a": "AsyncType, first", "b?": "AsyncType", "c": "Other"}, + schemaResolver: (name) async { + requested.add(name); + return switch (name) { + "AsyncType" => {"type": "number"}, + "Other" => {"type": "string"}, + _ => null, + }; + }, + ); + expect( + _props(result), + equals({ + "a": {"type": "number", "description": "first"}, + "b": { + "type": ["number", "null"], + }, + "c": {"type": "string"}, + }), + ); + expect(requested, equals(["AsyncType", "Other"])); + }); + + test("prefers schemas over the resolver", () async { + final result = await Picoschema.parse( + "Foo", + schemas: { + "Foo": {"type": "integer"}, + }, + schemaResolver: (_) async => fail("resolver should not be called"), + ); + expect(result, equals({"type": "integer"})); + }); + + test("throws when the resolver returns null", () async { + await expectLater( + Picoschema.parse("Missing", schemaResolver: (_) async => null), + _throwsPicoschema("could not find schema with name 'Missing'"), + ); + }); + + test("throws for unknown types without any schema source", () async { + await expectLater(Picoschema.parse("Missing"), _throwsPicoschema("unsupported scalar type")); + }); + }); + + group("Picoschema.isPicoschema", () { + test("returns true for Picoschema", () { + expect(Picoschema.isPicoschema({"name": "string"}), isTrue); + expect(Picoschema.isPicoschema({"name": "string, the name"}), isTrue); + expect( + Picoschema.isPicoschema({ + "obj(object)": {"x": "integer"}, + }), + isTrue, + ); + expect( + Picoschema.isPicoschema({ + "status(enum)": ["A"], + }), + isTrue, + ); + expect(Picoschema.isPicoschema({"(*)": "string"}), isTrue); + expect(Picoschema.isPicoschema({r"$type": "string"}), isTrue); + }); + + test("returns false for JSON Schema", () { + expect(Picoschema.isPicoschema({"type": "string"}), isFalse); + expect( + Picoschema.isPicoschema({ + "properties": { + "a": {"type": "string"}, + }, + }), + isFalse, + ); + expect(Picoschema.isPicoschema({r"$schema": "http://json-schema.org/draft-07/schema#"}), isFalse); + expect(Picoschema.isPicoschema({r"$ref": "#/defs/Foo"}), isFalse); + expect( + Picoschema.isPicoschema({ + "anyOf": [ + {"type": "string"}, + ], + }), + isFalse, + ); + }); + + test("passes JSON Schema from additionalMetadata through Dotprompt", () async { + final schema = { + "anyOf": [ + {"type": "string"}, + {"type": "integer"}, + ], + }; + final metadata = await Dotprompt().renderMetadata("hi", PromptMetadata(output: OutputConfig(schema: schema))); + expect(metadata.output!.schema, equals(schema)); + }); + }); + + // https://github.com/genkit-ai/genkit-dart/issues/562 + group("genkit-dart#562: parenthetical qualifiers via Dotprompt", () { + test("renders (array), (object), (enum) and (*) per spec", () async { + final metadata = await Dotprompt().renderMetadata(""" +--- +output: + schema: + tags(array): string + tags3(array, the tags): string + obj(object): + x: integer + status(enum): [A, B] + steps(array): + number: integer + instruction: string + (*): string +--- +hi +"""); + expect( + metadata.output?.schema, + equals({ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": {"type": "string"}, + }, + "tags3": { + "type": "array", + "description": "the tags", + "items": {"type": "string"}, + }, + "obj": { + "type": "object", + "properties": { + "x": {"type": "integer"}, + }, + "additionalProperties": false, + "required": ["x"], + }, + "status": { + "enum": ["A", "B"], + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": {"type": "integer"}, + "instruction": {"type": "string"}, + }, + "additionalProperties": false, + "required": ["number", "instruction"], + }, + }, + }, + "additionalProperties": {"type": "string"}, + "required": ["tags", "tags3", "obj", "status", "steps"], + }), + ); + }); + + test("converts a top-level field named items instead of passing it through", () async { + final metadata = await Dotprompt().renderMetadata(""" +--- +output: + schema: + items: + sku: string + qty: integer +--- +hi +"""); + expect( + metadata.output?.schema, + equals({ + "type": "object", + "properties": { + "items": { + "type": "object", + "properties": { + "sku": {"type": "string"}, + "qty": {"type": "integer"}, + }, + "additionalProperties": false, + "required": ["sku", "qty"], + }, + }, + "additionalProperties": false, + "required": ["items"], + }), + ); + }); + + test("resolves named schemas through DotpromptOptions.schemaResolver", () async { + final dotprompt = Dotprompt( + DotpromptOptions( + schemaResolver: (name) async => name == "Address" ? {"type": "object", "description": "an address"} : null, + ), + ); + final metadata = await dotprompt.renderMetadata(""" +--- +input: + schema: + shipTo: Address +--- +hi +"""); + expect( + _props(metadata.input!.schema!)["shipTo"], + equals({"type": "object", "description": "an address"}), + ); + }); + + test("resolves the input: Name / output: Name shorthand", () async { + final metadata = await Dotprompt( + const DotpromptOptions( + schemas: { + "Person": {"type": "object", "description": "a person"}, + }, + ), + ).renderMetadata("---\ninput: Person\noutput: Person\n---\nhi"); + expect(metadata.input?.schema, equals({"type": "object", "description": "a person"})); + expect(metadata.output?.schema, equals({"type": "object", "description": "a person"})); + }); + + test("rejects an unknown name in the output: Name shorthand", () async { + await expectLater( + Dotprompt().renderMetadata("---\noutput: Missing\n---\nhi"), + _throwsPicoschema("could not find schema with name 'Missing'"), + ); + }); + + test("rejects name(*)", () async { + await expectLater( + Dotprompt().renderMetadata("---\noutput:\n schema:\n wild(*): string\n---\nhi"), + throwsA(isA()), + ); + }); + }); } diff --git a/dart/dotprompt/test/spec_test.dart b/dart/dotprompt/test/spec_test.dart index bd417a45d..8cdd747b7 100644 --- a/dart/dotprompt/test/spec_test.dart +++ b/dart/dotprompt/test/spec_test.dart @@ -95,6 +95,9 @@ void main() { final tests = specMap["tests"] as List?; final partials = specMap["partials"] as Map?; final resolverPartials = specMap["resolverPartials"] as Map?; + final schemas = (specMap["schemas"] as Map?)?.map( + (name, schema) => MapEntry(name, schema as Map), + ); if (tests == null) continue; @@ -116,6 +119,7 @@ void main() { final dotpromptOptions = DotpromptOptions( partials: {...?partials?.cast()}, partialResolver: resolverPartials != null ? (name) async => resolverPartials[name] as String? : null, + schemas: schemas, ); final dotprompt = Dotprompt(dotpromptOptions); @@ -250,6 +254,19 @@ void main() { ); } } + + if (expected.containsKey("output")) { + final expectedOutput = expected["output"] as Map; + final actualOutput = result.output; + expect(actualOutput, isNotNull); + for (final entry in expectedOutput.entries) { + expect( + _deepEquals(actualOutput![entry.key], entry.value), + isTrue, + reason: "Output key '${entry.key}' mismatch: got ${actualOutput[entry.key]}", + ); + } + } }); } }); diff --git a/docs/api/dart/index.md b/docs/api/dart/index.md index bf796fc68..43d0d2fcd 100644 --- a/docs/api/dart/index.md +++ b/docs/api/dart/index.md @@ -8,7 +8,7 @@ Add to your `pubspec.yaml`: ```yaml dependencies: - dotprompt: ^0.0.1 + dotprompt: ^1.0.0 ``` ## Quick Start @@ -21,7 +21,7 @@ void main() async { final result = await dotprompt.render(''' --- -model: gemini-pro +model: googleai/gemini-flash-latest input: schema: name: string @@ -250,17 +250,29 @@ abstract interface class PromptStore { ## Picoschema -Convert Picoschema to JSON Schema. +Convert Picoschema to JSON Schema. `Dotprompt` does this automatically for +`input.schema` and `output.schema`; see the +[Picoschema reference](../../extending/picoschema.md) for the syntax. ```dart -import 'package:dotprompt/src/picoschema.dart'; +import 'package:dotprompt/dotprompt.dart'; final schema = { 'name': 'string', - 'age?': 'integer, The person\'s age', + 'age?': 'integer, the person\'s age', + 'tags(array, relevant tags)': 'string', + 'status(enum)': ['ACTIVE', 'INACTIVE'], + 'address': 'Address', }; -final jsonSchema = Picoschema.toJsonSchema(schema); +// Sync: named schemas come from the `schemas` map. +final jsonSchema = Picoschema.toJsonSchema(schema, schemas: {'Address': addressSchema}); + +// Async: `schemas` first, then `schemaResolver`. +final resolved = await Picoschema.parse( + schema, + schemaResolver: (name) async => lookupSchema(name), +); ``` ## Built-in Helpers