Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 108 additions & 4 deletions packages/genkit/lib/src/ai/prompt_loader_io.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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/<name>`); Picoschema looks
// schemas up by bare name, so strip the prefix.
final schemas = {
for (final entry
in registry.listValues<Map<String, dynamic>>('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,
});
}

Expand All @@ -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,
Expand All @@ -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<String, dynamic>? _toJsonSchema(
Map<String, dynamic>? schema,
Map<String, Map<String, dynamic>> 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<String, dynamic> 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']);
}
Comment thread
chrisraygill marked this conversation as resolved.

/// 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<Map<String, dynamic>>? _toInputSchema(
Map<String, dynamic>? schema,
Map<String, Map<String, dynamic>> schemas,
) {
final jsonSchema = _toJsonSchema(schema, schemas);
if (jsonSchema == null) return null;
return SchemanticType.from<Map<String, dynamic>>(
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<String, dynamic>() : <String, dynamic>{},
);
}
Comment thread
chrisraygill marked this conversation as resolved.
179 changes: 179 additions & 0 deletions packages/genkit/test/ai/prompt_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, dynamic>;
expect(outputSchema['type'], equals('object'));
final properties = outputSchema['properties'] as Map<String, dynamic>;
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', () {
Expand Down
Loading