diff --git a/packages/genkit/lib/src/ai/prompt_loader_io.dart b/packages/genkit/lib/src/ai/prompt_loader_io.dart index 3156cf8d..3097df4e 100644 --- a/packages/genkit/lib/src/ai/prompt_loader_io.dart +++ b/packages/genkit/lib/src/ai/prompt_loader_io.dart @@ -14,8 +14,10 @@ import 'dart:io'; +import 'package:dotprompt/dotprompt.dart' show Picoschema; import 'package:logging/logging.dart'; import 'package:path/path.dart' as p; +import 'package:schemantic/schemantic.dart'; import '../core/registry.dart'; import '../types.dart' show GenerateActionOutputConfig; @@ -135,13 +137,34 @@ void _loadPrompt( final config = metadata.config; final tools = metadata.tools; - // Build output config from parsed metadata + // Named schemas registered via `defineSchema`. Picoschema may reference these + // by name (e.g. `schema: MyAddress`), so they are passed through to the + // converter to resolve, mirroring what `_resolveMetadata` does internally. + // `listValues` keys are registry paths (`/schema/`); Picoschema looks + // schemas up by bare name, so strip the prefix. + final schemas = { + for (final entry + in registry.listValues>('schema').entries) + entry.key.split('/').last: entry.value, + }; + + // Build the input schema from the frontmatter `input.schema`. The raw + // metadata from `parse` is not schema-resolved, so Picoschema is converted + // to JSON Schema here (mirroring what `renderMetadata` does internally). + // Without this the action has no input schema, so the Developer UI cannot + // render an input form for the prompt. + final inputSchema = _toInputSchema(metadata.input?.schema, schemas); + + // Build output config from parsed metadata. As with the input schema, the + // output schema may be Picoschema and must be converted to JSON Schema + // before it reaches the model, otherwise the raw Picoschema is sent as the + // response schema and the request fails or is ignored. GenerateActionOutputConfig? outputConfig; if (metadata.output != null) { + final outputSchema = _toJsonSchema(metadata.output!.schema, schemas); outputConfig = GenerateActionOutputConfig.fromJson({ - if (metadata.output!.format != null) 'format': metadata.output!.format, - if (metadata.output!.schema != null) - 'jsonSchema': metadata.output!.schema, + 'format': ?metadata.output!.format, + 'jsonSchema': ?outputSchema, }); } @@ -164,6 +187,7 @@ void _loadPrompt( variant: variant, model: model != null ? modelRef(model) : null, config: config, + inputSchema: inputSchema, toolNames: tools, messagesTemplate: parsedPrompt.template, output: outputConfig, @@ -185,3 +209,83 @@ String _registryDefinitionKey(String name, String? variant, String? ns) { final suffix = variant != null ? '.$variant' : ''; return '$prefix$name$suffix'; } + +/// Converts a frontmatter schema map to a JSON Schema map. +/// +/// A `.prompt` file may declare its schema as Picoschema (the compact form +/// shown in the docs) or as plain JSON Schema. `parse` leaves Picoschema +/// untouched, so it is converted here; values that are already JSON Schema are +/// returned unchanged. Returns `null` when there is no schema. +/// +/// This intentionally does not gate on [Picoschema.isPicoschema], which does +/// not recognize the `type, description` form (e.g. `name: string, the person +/// to greet`) that the docs and examples use. [_isJsonSchema] is used instead +/// so that form is converted rather than passed through raw. +/// +/// [schemas] holds named schemas registered via `defineSchema`, so Picoschema +/// references to them by name can be resolved during conversion. +Map? _toJsonSchema( + Map? schema, + Map> schemas, +) { + if (schema == null) return null; + if (_isJsonSchema(schema)) return schema; + return Picoschema.toJsonSchema(schema, schemas: schemas); +} + +/// Whether [schema] is already a JSON Schema (as opposed to Picoschema). +/// +/// JSON Schema carries a top-level `type` (one of the standard types), a +/// `$schema`/`$ref`/`$defs` key, or a structural keyword such as `properties`, +/// `items`, or a `*Of` combinator. (The top-level `type` is optional in JSON +/// Schema, so the structural keywords are needed to catch schemas that omit +/// it.) Picoschema maps field names to type strings or nested maps and has +/// none of these at the top level. +bool _isJsonSchema(Map schema) { + const jsonSchemaKeywords = { + r'$schema', + r'$ref', + r'$defs', + 'properties', + 'items', + 'anyOf', + 'oneOf', + 'allOf', + }; + if (jsonSchemaKeywords.any(schema.containsKey)) { + return true; + } + const jsonSchemaTypes = { + 'object', + 'array', + 'string', + 'number', + 'integer', + 'boolean', + 'null', + }; + return jsonSchemaTypes.contains(schema['type']); +} + +/// Builds a [SchemanticType] for a prompt's input from its frontmatter +/// `input.schema`, converting Picoschema to JSON Schema as needed. +/// +/// Returns `null` when no input schema is declared, in which case the prompt +/// accepts free-form input as before. +/// +/// [schemas] holds named schemas registered via `defineSchema`, so Picoschema +/// references to them by name can be resolved during conversion. +SchemanticType>? _toInputSchema( + Map? schema, + Map> schemas, +) { + final jsonSchema = _toJsonSchema(schema, schemas); + if (jsonSchema == null) return null; + return SchemanticType.from>( + jsonSchema: jsonSchema, + // `parse` is also called with `null` when a prompt is invoked with no + // input, so guard the cast instead of letting it throw. + parse: (json) => + json is Map ? json.cast() : {}, + ); +} diff --git a/packages/genkit/test/ai/prompt_test.dart b/packages/genkit/test/ai/prompt_test.dart index f3eb7571..a285d071 100644 --- a/packages/genkit/test/ai/prompt_test.dart +++ b/packages/genkit/test/ai/prompt_test.dart @@ -1304,6 +1304,185 @@ Hello {{name}}! ); expect(mdAction, isNull); }); + + test('wires input.schema (picoschema) onto the prompt action', () async { + File(p.join(tempDir.path, 'greet.prompt')).writeAsStringSync(''' +--- +input: + schema: + name: string, the person to greet + formal?: boolean, whether to be formal +--- +Hello {{name}}! +'''); + + loadPromptFolder(registry, dpRegistry, dir: tempDir.path); + + final action = + await registry.lookupAction('executable-prompt', 'greet') + as PromptAction; + expect( + action.inputSchema, + isNotNull, + reason: + 'input.schema in the .prompt file should be surfaced so the ' + 'Developer UI can render a form and input can be validated', + ); + + final jsonSchema = action.inputSchema!.jsonSchema(); + expect(jsonSchema['type'], equals('object')); + expect(jsonSchema['properties'], contains('name')); + expect( + (jsonSchema['properties'] as Map)['name'], + containsPair('type', 'string'), + ); + // Required and optional fields are honored. + expect(jsonSchema['required'], contains('name')); + expect(jsonSchema['required'], isNot(contains('formal'))); + }); + + test('accepts plain JSON Schema for input.schema unchanged', () async { + File(p.join(tempDir.path, 'jsoninput.prompt')).writeAsStringSync(''' +--- +input: + schema: + type: object + properties: + topic: + type: string + required: + - topic +--- +Write about {{topic}}. +'''); + + loadPromptFolder(registry, dpRegistry, dir: tempDir.path); + + final action = + await registry.lookupAction('executable-prompt', 'jsoninput') + as PromptAction; + expect(action.inputSchema, isNotNull); + final jsonSchema = action.inputSchema!.jsonSchema(); + expect(jsonSchema['type'], equals('object')); + expect(jsonSchema['properties'], contains('topic')); + }); + + test('treats JSON Schema without a top-level type as JSON Schema', () async { + // A valid JSON Schema may omit the top-level `type` and rely on + // `properties` (or a combinator like anyOf). It must not be mistaken for + // Picoschema and re-converted. + File(p.join(tempDir.path, 'notype.prompt')).writeAsStringSync(''' +--- +input: + schema: + properties: + topic: + type: string + required: + - topic +--- +Write about {{topic}}. +'''); + + loadPromptFolder(registry, dpRegistry, dir: tempDir.path); + + final action = + await registry.lookupAction('executable-prompt', 'notype') + as PromptAction; + final jsonSchema = action.inputSchema!.jsonSchema(); + expect(jsonSchema['properties'], contains('topic')); + // Picoschema conversion would have wrapped `properties` as a field, so + // its value would no longer be a nested schema map. + expect( + (jsonSchema['properties'] as Map)['topic'], + containsPair('type', 'string'), + ); + }); + + test('input schema parse tolerates null input', () async { + File(p.join(tempDir.path, 'opt.prompt')).writeAsStringSync(''' +--- +input: + schema: + name: string, the name +--- +Hello {{name}}. +'''); + + loadPromptFolder(registry, dpRegistry, dir: tempDir.path); + + final action = + await registry.lookupAction('executable-prompt', 'opt') + as PromptAction; + // A prompt may be invoked with no input; parsing null must not throw. + expect(() => action.inputSchema!.parse(null), returnsNormally); + expect(action.inputSchema!.parse(null), isEmpty); + }); + + test('converts output.schema (picoschema) to JSON Schema', () async { + File(p.join(tempDir.path, 'classify.prompt')).writeAsStringSync(''' +--- +output: + format: json + schema: + category: string, the predicted category + confidence: number, a score from 0 to 1 +--- +Classify: {{text}} +'''); + + loadPromptFolder(registry, dpRegistry, dir: tempDir.path); + + final ep = await registry.lookupAction('executable-prompt', 'classify'); + final options = await (ep as PromptAction).executablePrompt!.render({ + 'text': 'hello', + }); + + // The output schema reaching the model must be JSON Schema, not the raw + // Picoschema strings. + final outputSchema = options.output!.jsonSchema as Map; + expect(outputSchema['type'], equals('object')); + final properties = outputSchema['properties'] as Map; + expect(properties['category'], containsPair('type', 'string')); + expect(properties['confidence'], containsPair('type', 'number')); + expect(options.output!.format, equals('json')); + }); + + test('resolves a named schema referenced in input.schema', () async { + // A schema registered via `defineSchema` is referenced by name in the + // .prompt frontmatter. The loader must pass registered schemas to the + // Picoschema converter so the reference resolves to the real schema. + registry.registerValue('schema', 'Address', { + 'type': 'object', + 'properties': { + 'street': {'type': 'string'}, + 'city': {'type': 'string'}, + }, + 'required': ['street', 'city'], + }); + + File(p.join(tempDir.path, 'shipto.prompt')).writeAsStringSync(''' +--- +input: + schema: + address: Address +--- +Ship to {{address.city}}. +'''); + + loadPromptFolder(registry, dpRegistry, dir: tempDir.path); + + final action = + await registry.lookupAction('executable-prompt', 'shipto') + as PromptAction; + final jsonSchema = action.inputSchema!.jsonSchema(); + final addressSchema = (jsonSchema['properties'] as Map)['address'] as Map; + // If the named reference were not resolved, `address` would not be an + // object schema with the registered properties. + expect(addressSchema['type'], equals('object')); + expect(addressSchema['properties'], contains('street')); + expect(addressSchema['properties'], contains('city')); + }); }); group('DotpromptRegistry', () {