diff --git a/.env-dist b/.env-dist index f76674eb..e7d89bfe 100644 --- a/.env-dist +++ b/.env-dist @@ -2,7 +2,7 @@ # INSTRUCTOR SECRETS ######################################################### -INSTRUCTOR_CONFIG_PATH='/../../config/' +INSTRUCTOR_CONFIG_PATH='../../config/' ######################################################### # LLMS diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..6772c92a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,52 @@ +# Exclude unnecessary files from Composer and Git exports +/docs/ export-ignore +#/evals/ export-ignore # include this one with distribution +#/examples/ export-ignore # include this one with distribution +/notes/ export-ignore +/tests/ export-ignore +/.github/ export-ignore +/.gitignore export-ignore +/.gitattributes export-ignore +/phpunit.xml export-ignore +/phpstan.neon export-ignore +/phpstan.neon.dist export-ignore +/psalm.xml export-ignore +/composer.lock export-ignore +*.md export-ignore +*.mdx export-ignore + +# Line ending normalization +# Ensure PHP, JS, and text files have consistent line endings +*.php text eol=lf +*.js text eol=lf +*.css text eol=lf +*.html text eol=lf +*.txt text eol=lf +*.md text eol=lf +*.mdx text eol=lf +*.xml text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.sh text eol=lf +*.bat text eol=lf + +# Treat binary files as binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary +*.svg binary + +# Linguist overrides (optional for GitHub) +*.md linguist-documentation +*.json linguist-vendored +/vendor/ linguist-vendored + +# Ignore logs and temp files in exports (optional) +*.log export-ignore +/tmp/ export-ignore +/cache/ export-ignore +*.bak export-ignore +*.swp export-ignore \ No newline at end of file diff --git a/.gitignore b/.gitignore index e749bc34..3729ad86 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,48 @@ -.cache/ -.idea/ +# Composer dependency and autoload files +composer.lock + +# Dependencies +/vendor/ + +# env files +.env +.env.local +.env.*.local + +# Logs and debug files +*.log +*.cache + +# PHP server and error logs +error_log +.php_error.log +php_errors.log + +# Node modules and build artifacts (if using Node or frontend tooling) +node_modules/ +dist/ +build/ + +# Python venvs .venv/ -.vscode/ -_docs/ + +# OS generated files +.DS_Store +Thumbs.db + +# IDE files +/.idea/ +/.vscode/ +.cursorignore + +# PHPUnit, Pest, and other test result files +.phpunit.result.cache +/.phpunit.result.cache +/tests/_output/ +/tests/_support/_generated/ +/coverage/ + +# User-specific files (temporary, experimental, to be archived / removed) archived/ experimental/* -vendor/ -.cursorignore -.env -composer.lock -php_errors.log -phpstan.neon -xdebug.log +tmp/ diff --git a/bin/instructor b/bin/instructor new file mode 100755 index 00000000..72833258 --- /dev/null +++ b/bin/instructor @@ -0,0 +1,12 @@ +#!/bin/bash +DIR="$(dirname "$0")" +SCRIPT="$1" + +# Sanity check +if [[ "$SCRIPT" =~ \.\. ]] || [[ ! "$SCRIPT" =~ ^[a-zA-Z0-9_-]+$ ]]; then + echo "Invalid script name." + exit 1 +fi + +shift +php "$DIR/../scripts/$SCRIPT.php" "$@" diff --git a/bin/instructor.bat b/bin/instructor.bat new file mode 100644 index 00000000..1e2a88f9 --- /dev/null +++ b/bin/instructor.bat @@ -0,0 +1,13 @@ +@echo off +set DIR=%~dp0 +set SCRIPT=%1 + +:: Sanity check +echo %SCRIPT% | findstr /R /C:"\.\." /C:"[^a-zA-Z0-9_-]" >nul +if %ERRORLEVEL% neq 1 ( + echo Invalid script name. + exit /b 1 +) + +shift +php "%DIR%..\scripts\%SCRIPT%.php" %* diff --git a/composer.json b/composer.json index ff6e2d2d..5e8ae744 100644 --- a/composer.json +++ b/composer.json @@ -4,6 +4,9 @@ "keywords": ["llm", "language models", "inference", "ai", "genai", "openai", "anthropic", "cohere", "ollama", "structured output", "semantic processing", "automation", "data processing", "data extraction"], "type": "library", "license": "MIT", + "bin": [ + "bin/instructor" + ], "autoload": { "psr-4": { "Cognesy\\Instructor\\": "src/" @@ -15,9 +18,11 @@ "psr-4": { "Tests\\": "tests/", "Examples\\": "examples/", - "Cognesy\\InstructorHub\\": "src-hub/", "Cognesy\\Evals\\": "evals/", - "Cognesy\\Experimental\\": "experimental/" + "Cognesy\\Experimental\\": "experimental/", + "Cognesy\\InstructorHub\\": "src-hub/", + "Cognesy\\Setup\\": "src-setup/", + "Cognesy\\Tell\\": "src-tell/" }, "files": [ "tests/Examples/Call/test_functions.php" @@ -38,7 +43,6 @@ "gioni06/gpt3-tokenizer": "^1.2", "guzzlehttp/psr7": "^2.7", "illuminate/database": "^11.10", - "illuminate/events": "^11.10", "league/html-to-markdown": "^5.1", "mockery/mockery": "^1.6", "nyholm/psr7": "^1.8", @@ -47,10 +51,9 @@ "phpstan/phpstan": "^1.11", "psr/http-factory-implementation": "*", "psy/psysh": "@stable", - "spatie/array-to-xml": "^3.3", "spatie/browsershot": "^4.1", - "spatie/php-structure-discoverer": "^2.1", "symfony/browser-kit": "^7.1", + "symfony/console": "^7.1", "symfony/css-selector": "^7.1", "symfony/dom-crawler": "^7.1", "symfony/http-client": "^7.1", @@ -79,7 +82,6 @@ "phpstan/phpdoc-parser": "^1.29", "psr/event-dispatcher": "^1.0", "psr/log": "^3.0", - "spatie/yaml-front-matter": "^2.0", "symfony/intl": "^7.1", "symfony/property-access": "^6.4 || ^7.0", "symfony/property-info": "^6.4 || ^7.0", @@ -92,6 +94,6 @@ "tests": "@php vendor/bin/pest", "phpstan": "@php vendor/bin/phpstan -c phpstan.neon", "psalm": "@php vendor/bin/psalm", - "hub": "@php ./hub.php" + "instructor": "bash ./bin/instructor" } } diff --git a/config/prompt.php b/config/prompt.php index 757831f0..37ddfed2 100644 --- a/config/prompt.php +++ b/config/prompt.php @@ -1,26 +1,48 @@ - 'twig', - - 'settings' => [ - 'twig' => [ - 'templateType' => 'twig', - 'resourcePath' => '/../../../../prompts/twig', - 'cachePath' => '/tmp/instructor/cache/twig', - 'extension' => '.twig', - 'frontMatterTags' => ['{#---', '---#}'], - 'frontMatterFormat' => 'yaml', - 'metadata' => [ - 'autoReload' => true, - ], - ], - 'blade' => [ - 'templateType' => 'blade', - 'resourcePath' => '/../../../../prompts/blade', - 'cachePath' => '/tmp/instructor/cache/blade', - 'extension' => '.blade.php', - 'frontMatterTags' => ['{{--', '--}}'], - 'frontMatterFormat' => 'yaml', - ], - ] -]; + 'demo-twig', + + 'libraries' => [ + 'system' => [ + 'templateEngine' => 'twig', + 'resourcePath' => '/../../../../prompts/system', + 'cachePath' => '/tmp/instructor/cache/system', + 'extension' => '.twig', + 'frontMatterTags' => ['{#---', '---#}'], + 'frontMatterFormat' => 'yaml', + 'metadata' => [ + 'autoReload' => true, + ], + ], + 'demo-twig' => [ + 'templateEngine' => 'twig', + 'resourcePath' => '/../../../../prompts/demo-twig', + 'cachePath' => '/tmp/instructor/cache/twig', + 'extension' => '.twig', + 'frontMatterTags' => ['{#---', '---#}'], + 'frontMatterFormat' => 'yaml', + 'metadata' => [ + 'autoReload' => true, + ], + ], + 'demo-blade' => [ + 'templateEngine' => 'blade', + 'resourcePath' => '/../../../../prompts/demo-blade', + 'cachePath' => '/tmp/instructor/cache/blade', + 'extension' => '.blade.php', + 'frontMatterTags' => ['{{--', '--}}'], + 'frontMatterFormat' => 'yaml', + ], + 'examples' => [ + 'templateEngine' => 'twig', + 'resourcePath' => '/../../../../prompts/examples', + 'cachePath' => '/tmp/instructor/cache/examples', + 'extension' => '.twig', + 'frontMatterTags' => ['{#---', '---#}'], + 'frontMatterFormat' => 'yaml', + 'metadata' => [ + 'autoReload' => true, + ], + ], + ] +]; diff --git a/docs/advanced/prompts.mdx b/docs/advanced/prompts.mdx index 28d3b3da..bb227613 100644 --- a/docs/advanced/prompts.mdx +++ b/docs/advanced/prompts.mdx @@ -1,573 +1,636 @@ -## Prompts - -`Prompt` class in Instructor provides a powerful and flexible -way to manage prompts for LLM interactions. It supports multiple -template engines (Twig, Blade), front matter, variable injection, -and validation. - -The Prompt class helps manage: -- Template-based prompts using Twig or Blade engines -- Front matter for metadata and schema definitions -- Variable injection and validation -- Chat message formatting -- Prompt validation and error checking - - - - - -### Quick Start - -Use default settings to create a prompt: - -```php -use Cognesy\Instructor\Extras\Prompt\Prompt; - -// Create a simple prompt with variables -$prompt = Prompt::text('hello', ['name' => 'World']); -// Output: "Hello, World!" - -// Create a prompt with chat messages -$messages = Prompt::messages('capital', ['country' => 'France']); -// Output: Array of chat messages asking about France's capital -``` - -Both 'hello' and 'capital' prompts have been defined in the prompts directory, -in '/prompts/twig/hello.twig' and '/prompts/twig/capital.twig' files respectively. - -`Prompt` class will use the default template engine to render -them to text and chat messages. - -Default setting is defined in the `/config/prompt.php` file in the root -directory of Instructor. Default setting uses Twig template engine, but -you can change it to Blade (or, in the future, other supported engine). - - - - -### Supported Template Engines - -#### Twig Templates - -Twig templates offer powerful templating features with clean syntax: - -```twig -{# prompts/twig/hello.twig #} -{#--- -description: Hello world template -variables: - name: - description: Name to greet - type: string - default: World ----#} - -Hello, {{ name }}! -``` -See [Twig documentation](https://twig.symfony.com/doc/3.x/templates.html) for more details. - - - -#### Blade Templates - -Blade templates provide Laravel-style syntax: - -```php -{{-- prompts/blade/hello.blade.php --}} -{{-- -description: Hello world template -variables: - name: - description: Name to greet - type: string - default: World ---}} - -Hello, {{ $name }}! -``` -See [Blade documentation](https://laravel.com/docs/11.x/blade) for more details. - -> NOTE: Instructor Prompt uses BladeOne engine, which is a standalone version -> of Laravel Blade engine. Be aware that some features may differ from Laravel -> Blade, but the syntax is mostly the same. - - - - - -### Configuration - -The prompt engine in Instructor can be configured using a configuration -file or programmatically. This guide explains the configuration options -and how to use them effectively. - -#### Custom Configuration Location - -You can specify a custom configuration location using environment variables: - -```bash -INSTRUCTOR_CONFIG_PATH=/path/to/config -``` - -#### Basic Configuration - -Create a configuration file for your prompts: - -```php -use Cognesy\Instructor\Extras\Prompt\Data\PromptEngineConfig; -use Cognesy\Instructor\Extras\Prompt\Enums\TemplateType; - -$config = new PromptEngineConfig( - templateType: TemplateType::Twig, - resourcePath: '/prompts/twig', - cachePath: '/cache/prompts', - extension: '.twig' -); - -$prompt = new Prompt(config: $config); -``` - -#### Loading Configuration from Settings - -```php -$prompt = Prompt::using('default'); // Loads default settings -``` - - - - - -### Using Prompts - -#### Loading Prompts from Files - -```php -// Load a prompt template -$prompt = Prompt::get('hello'); - -// Render with variables -$result = $prompt->withValues(['name' => 'John'])->toText(); -// Output: "Hello, John!" -``` - -#### Creating Prompts from Strings - -```php -$prompt = new Prompt(); -$content = "Hello, {{ name }}!"; -$result = $prompt - ->withTemplateContent($content) - ->withValues(['name' => 'Jane']) - ->toText(); -// Output: "Hello, Jane!" -``` - -#### Rendering Chat Messages - -```php -// Using a chat template -$prompt = Prompt::get('capital'); -$messages = $prompt - ->withValues(['country' => 'France']) - ->toMessages(); -// Output: ChatMessages object with system/user/assistant messages -``` - - - - - - -### Prompt Info (front matter) - -PromptInfo is a front matter that allows you to define metadata and schema for your prompts: - -```twig -{#--- -description: Capital finder template -variables: - country: - description: Country name - type: string - default: France -schema: - name: capital - properties: - name: - description: Capital city name - type: string - required: [name] ----#} - - You are a helpful assistant. - What is the capital of {{ country }}? - -``` - -#### Accessing PromptInfo Data - -```php -$prompt = Prompt::get('capital'); -$info = $prompt->info(); - -echo $info->field('description'); -// Output: "Capital finder template" - -$variables = $info->variables(); -// Output: Array of variable definitions -``` - - - - - - -### Working with Variables - -#### Defining Variables - -Variables can be defined in front matter: - -```yaml -variables: - name: - description: User's name - type: string - default: Guest - age: - description: User's age - type: integer -``` - -#### Injecting Variables - -```php -$prompt = Prompt::get('user_info'); -$result = $prompt->withValues([ - 'name' => 'John', - 'age' => 25 -])->toText(); -``` - - - - - - - -### Validation - -#### Validating Prompts - -The Prompt class includes built-in validation of prompts content: - -```php -$prompt = Prompt::get('user_info'); -$errors = $prompt->validationErrors(); - -if (!empty($errors)) { - foreach ($errors as $error) { - echo "Validation error: $error\n"; - } -} -``` - -#### Variable Validation - -```php -// Check if all required variables are provided -$prompt = Prompt::get('user_info'); -$prompt = $prompt->withValues(['name' => 'John']); -$errors = $prompt->validationErrors(); -// Will show error if 'age' is required but not provided -``` - - - - - - -### Best Practices - -1. **Organization**: Keep prompts in dedicated directories based on template engine -``` -prompts/ -├── twig/ -│ ├── user_info.twig -│ └── capital.twig -└── blade/ - ├── user_info.blade.php - └── capital.blade.php - ``` - -2. **Front Matter**: Always include descriptions and variable definitions -```yaml -description: Clear description of the prompt's purpose -variables: - name: - description: Clear description of the variable - type: string - default: Default value if applicable - ``` - -3. **Validation**: Always validate prompts before using them in production -```php -$prompt = Prompt::get('user_info'); -if (!empty($prompt->validationErrors())) { - throw new Exception('Prompt validation errors: ' . implode(', ', $prompt->validationErrors())); -} - ``` - -4. **Caching**: Use cache paths in configuration for better performance -```php -$config = new PromptEngineConfig( - cachePath: '/path/to/cache' -); - ``` - - - - - -### Example: Complete Usage - -Here's a complete example showing multiple features: - -```php -// Define a prompt template -$template = << - You are a friendly assistant. - - Hello! My name is {{ name }} and I like {{ preferences|join(', ') }}. - - -TWIG; - -// Create and configure prompt -$prompt = new Prompt(); -$prompt->withTemplateContent($template) - ->withValues([ - 'name' => 'Alice', - 'preferences' => ['reading', 'hiking', 'photography'] - ]); - -// Validate -if (!empty($prompt->validationErrors())) { - throw new Exception('Invalid prompt configuration'); -} - -// Get chat messages -$messages = $prompt->toMessages(); - -// Use with Instructor -$response = (new Instructor)->respond( - messages: $messages, - responseModel: YourResponseModel::class -); -``` - - - - - -### Configuration File - -By default, Instructor looks for prompt configuration in `config/prompt.php`. -Here's how to set up and customize your prompt engine: - -```php - 'twig', - - // Available configuration settings - 'settings' => [ - 'twig' => [ - 'templateType' => 'twig', - 'resourcePath' => '/../../../../prompts/twig', - 'cachePath' => '/tmp/instructor/cache/twig', - 'extension' => '.twig', - 'frontMatterTags' => ['{#---', '---#}'], - 'frontMatterFormat' => 'yaml', - 'metadata' => [ - 'autoReload' => true, - ], - ], - 'blade' => [ - 'templateType' => 'blade', - 'resourcePath' => '/../../../../prompts/blade', - 'cachePath' => '/tmp/instructor/cache/blade', - 'extension' => '.blade.php', - 'frontMatterTags' => ['{{--', '--}}'], - 'frontMatterFormat' => 'yaml', - ], - ] -]; -``` - -#### Configuration Options - -Each configuration setting supports the following options: - -##### Core Settings - -| Option | Description | Example | -|--------|-------------|---------| -| `templateType` | Template engine to use ('twig' or 'blade') | `'twig'` | -| `resourcePath` | Path to prompt template files | `'/prompts/twig'` | -| `cachePath` | Path for compiled templates | `'/tmp/cache/twig'` | -| `extension` | File extension for templates | `'.twig'` | - -##### Front Matter Settings - -| Option | Description | Example | -|--------|-------------|---------| -| `frontMatterTags` | Array of start and end tags for front matter | `['{#---', '---#}']` | -| `frontMatterFormat` | Format of front matter ('yaml', 'json', 'toml') | `'yaml'` | - -##### Engine-Specific Settings - -| Option | Description | Example | -|--------|-------------|---------| -| `metadata` | Engine-specific configuration | `['autoReload' => true]` | - -#### Using Configurations - -##### Default Configuration - -The default configuration is specified by `defaultSetting` and is used when no specific configuration is provided: - -```php -use Cognesy\Instructor\Extras\Prompt\Prompt; - -// Uses default configuration (twig in this case) -$prompt = new Prompt('hello'); -``` - -##### Specific Configuration - -You can specify which configuration to use: - -```php -// Use Blade configuration -$prompt = Prompt::using('blade')->withTemplate('hello'); - -// Or with the constructor -$prompt = new Prompt('hello', setting: 'blade'); -``` - -#### Programmatic Configuration - -You can also create configuration programmatically: - -```php -use Cognesy\Instructor\Extras\Prompt\Data\PromptEngineConfig; -use Cognesy\Instructor\Extras\Prompt\Enums\TemplateType; -use Cognesy\Instructor\Extras\Prompt\Enums\FrontMatterFormat; - -$config = new PromptEngineConfig( - templateType: TemplateType::Twig, - resourcePath: '/path/to/prompts', - cachePath: '/path/to/cache', - extension: '.twig', - frontMatterTags: ['{#---', '---#}'], - frontMatterFormat: FrontMatterFormat::Yaml, - metadata: [ - 'autoReload' => true, - ] -); - -$prompt = new Prompt(config: $config); -``` - -#### Configuration Loading - -When Instructor initializes, it follows this process to load configuration: - -1. Checks for configuration file at the default location -2. Loads the default setting specified by `defaultSetting` -3. Merges any user-provided configuration options - -#### Engine-Specific Features - -##### Twig Configuration - -Twig-specific settings in `metadata`: - -```php -'twig' => [ - // ... other settings ... - 'metadata' => [ - 'autoReload' => true, // Recompile templates when changed - ], -] -``` - -##### Blade Configuration - -Blade-specific settings can be added to metadata: - -```php -'blade' => [ - // ... other settings ... - 'metadata' => [ - 'mode' => 'MODE_AUTO', // BladeOne mode - ], -] -``` - -#### Best Practices - -1. **Environment-Specific Paths**: Use environment variables for paths: - -```php -'cachePath' => env('PROMPT_CACHE_PATH', '/tmp/cache/prompts'), -``` - -2. **Template Organization**: Keep templates organized by engine: - -``` -prompts/ -├── twig/ -│ └── templates.twig -└── blade/ - └── templates.blade.php -``` - -3. **Cache Management**: Implement cache clearing in your deployment: - -```php -// Clear cache programmatically -$cacheDir = config('prompt.settings.twig.cachePath'); -File::cleanDirectory($cacheDir); -``` - -4. **Validation**: Validate configuration during application boot: - -```php -use Cognesy\Instructor\Extras\Prompt\Prompt; - -public function boot() -{ - $prompt = Prompt::using('twig'); - if (!is_dir($prompt->config()->resourcePath)) { - throw new Exception('Prompt resource path not found'); - } -} -``` - -This configuration system provides flexibility while maintaining -ease of use, allowing you to customize the prompt engine's behavior -to match your application's needs. +## Prompts + +As your applications grow in complexity, your prompts may become large and complex, with multiple +variables and metadata. Managing these prompts can become a challenge, especially when you need to +reuse them across different parts of your application. Large prompts are also hard to maintain if +they are part of your codebase. + +`Prompt` addon provides a powerful and flexible way to manage your prompts. It supports +multiple template engines (Twig, Blade), prompt metadata, variable injection, and validation. + + +## Prompts + +Prompts in Instructor are structured text templates that can be rendered to text or series of chat +messages. As many of your prompts will be dynamically generated based on input data, you can +use syntax of one of the supported template engines (Twig, Blade) to define your prompts. + +This document will be using Twig syntax for prompt templates for simplicity and consistency, but +you can use Blade syntax in your prompts if you prefer it. + + - For more information on Twig syntax see [Twig documentation](https://twig.symfony.com/doc/3.x/templates.html). + - For more information on Blade syntax see [Blade documentation](https://laravel.com/docs/11.x/blade). + +### Basic Prompt Template + +Example prompt template in Twig: +``` +Hello, world. +``` + +### Prompt with Variables + +You can define variables in your prompt templates and inject values when rendering the prompt. + +```twig +Hello, {{ name }}! +``` + +### Chat Messages + +You can define chat messages in your prompts, which can be used to generate a sequence of messages +for LLM chat APIs. + +```twig + + You are a helpful assistant. + What is the capital of {{ country }}? + +``` + +### Prompt Metadata + +We recommend to preface each prompt with front matter - a block of metadata that describes the +prompt and its variables. This metadata can be used for validation, documentation, and schema +generation. + +```twig +{#--- +description: Capital finder template +variables: + country: + description: Country name + type: string + default: France +schema: + name: capital + properties: + name: + description: Capital city name + type: string + required: [name] +---#} + + You are a helpful assistant. + What is the capital of {{ country }}? + +``` + + +## Prompt Libraries + +Instructor allows you to define multiple `prompt libraries` in your app. Library is just a collection of +prompts which is stored under a specific directory. Library can have a nested structure, which allows +you to organize your prompts in a way that makes sense for your application. + +Library properties are specified in `config/prompt.php` configuration file. + +where you can define: + - `templateEngine` - template engine used for prompts in this library, + - `resourcePath` - path to prompt templates, + - `cachePath` - path to compiled templates, + - `extension` - file extension for prompt templates, + - `frontMatterTags` - start and end tags for front matter, + - `frontMatterFormat` - format of front matter (yaml, json, toml), + - `metadata` - engine-specific configuration. + +Instructor comes with 3 default prompt libraries: + - `system` - prompts used by Instructor itself, + - `demo-twig` - demo prompts using Twig template engine, + - `demo-blade` - demo prompts using Blade template engine. + +, for example specific to +the modules, or features. + +You can also choose to keep dedicated prompt libraries for various LLMs. +Instructor's Prompt library mechanism does not specify how you should organize or manage your prompts, +but it provides a flexible way to do it in a way that suits your application. + +## Using Prompts + +and can be referred to via `Prompt::using()` method. + + + +# CONSIDER CONTENT BELOW AS DRAFT + +--- + +It supports multiple template engines (Twig, Blade), front matter, variable injection, and +validation. + +The Prompt class helps: +- Template-based prompts using Twig or Blade engines +- Front matter for metadata and schema definitions +- Variable injection and validation +- Chat message formatting +- Prompt validation and error checking + + +### Quick Start + +Use default settings to create a prompt: + +```php +use Cognesy\Instructor\Extras\Prompt\Prompt; + +// Create a simple prompt with variables +$prompt = Prompt::text('hello', ['name' => 'World']); +// Output: "Hello, World!" + +// Create a prompt with chat messages +$messages = Prompt::messages('capital', ['country' => 'France']); +// Output: Array of chat messages asking about France's capital +``` + +Both 'hello' and 'capital' prompts have been defined in the prompts directory, +in '/prompts/twig/hello.twig' and '/prompts/twig/capital.twig' files respectively. + +`Prompt` class will use the default template engine to render +them to text and chat messages. + +Default setting is defined in the `/config/prompt.php` file in the root +directory of Instructor. Default setting uses Twig template engine, but +you can change it to Blade (or, in the future, other supported engine). + + + + +### Supported Template Engines + +#### Twig Templates + +Twig templates offer powerful templating features with clean syntax: + +```twig +{# prompts/twig/hello.twig #} +{#--- +description: Hello world template +variables: + name: + description: Name to greet + type: string + default: World +---#} + +Hello, {{ name }}! +``` +See [Twig documentation](https://twig.symfony.com/doc/3.x/templates.html) for more details. + + + +#### Blade Templates + +Blade templates provide Laravel-style syntax: + +```php +{{-- prompts/blade/hello.blade.php --}} +{{-- +description: Hello world template +variables: + name: + description: Name to greet + type: string + default: World +--}} + +Hello, {{ $name }}! +``` +See [Blade documentation](https://laravel.com/docs/11.x/blade) for more details. + +> NOTE: Instructor Prompt uses BladeOne engine, which is a standalone version +> of Laravel Blade engine. Be aware that some features may differ from Laravel +> Blade, but the syntax is mostly the same. + + + + + +### Configuration + +The prompt engine in Instructor can be configured using a configuration +file or programmatically. This guide explains the configuration options +and how to use them effectively. + +#### Custom Configuration Location + +You can specify a custom configuration location using environment variables: + +```bash +INSTRUCTOR_CONFIG_PATH=/path/to/config +``` + +#### Basic Configuration + +Create a configuration file for your prompts: + +```php +use Cognesy\Instructor\Extras\Prompt\Data\PromptEngineConfig; +use Cognesy\Instructor\Extras\Prompt\Enums\TemplateEngine; + +$config = new PromptEngineConfig( + templateEngine: TemplateEngine::Twig, + resourcePath: '/prompts/twig', + cachePath: '/cache/prompts', + extension: '.twig' +); + +$prompt = new Prompt(config: $config); +``` + +#### Loading Configuration from Settings + +```php +$prompt = Prompt::using('default'); // Loads default settings +``` + + + + + +### Using Prompts + +#### Loading Prompts from Files + +```php +// Load a prompt template +$prompt = Prompt::get('hello'); + +// Render with variables +$result = $prompt->withValues(['name' => 'John'])->toText(); +// Output: "Hello, John!" +``` + +#### Creating Prompts from Strings + +```php +$prompt = new Prompt(); +$content = "Hello, {{ name }}!"; +$result = $prompt + ->withTemplateContent($content) + ->withValues(['name' => 'Jane']) + ->toText(); +// Output: "Hello, Jane!" +``` + +#### Rendering Chat Messages + +```php +// Using a chat template +$prompt = Prompt::get('capital'); +$messages = $prompt + ->withValues(['country' => 'France']) + ->toMessages(); +// Output: ChatMessages object with system/user/assistant messages +``` + + + + + + +### Prompt Info (front matter) + +PromptInfo is a front matter that allows you to define metadata and schema for your prompts: + +```twig +{#--- +description: Capital finder template +variables: + country: + description: Country name + type: string + default: France +schema: + name: capital + properties: + name: + description: Capital city name + type: string + required: [name] +---#} + + You are a helpful assistant. + What is the capital of {{ country }}? + +``` + +#### Accessing PromptInfo Data + +```php +$prompt = Prompt::get('capital'); +$info = $prompt->info(); + +echo $info->field('description'); +// Output: "Capital finder template" + +$variables = $info->variables(); +// Output: Array of variable definitions +``` + + + + + + +### Working with Variables + +#### Defining Variables + +Variables can be defined in front matter: + +```yaml +variables: + name: + description: User's name + type: string + default: Guest + age: + description: User's age + type: integer +``` + +#### Injecting Variables + +```php +$prompt = Prompt::get('user_info'); +$result = $prompt->withValues([ + 'name' => 'John', + 'age' => 25 +])->toText(); +``` + + + + + + + +### Validation + +#### Validating Prompts + +The Prompt class includes built-in validation of prompts content: + +```php +$prompt = Prompt::get('user_info'); +$errors = $prompt->validationErrors(); + +if (!empty($errors)) { + foreach ($errors as $error) { + echo "Validation error: $error\n"; + } +} +``` + +#### Variable Validation + +```php +// Check if all required variables are provided +$prompt = Prompt::get('user_info'); +$prompt = $prompt->withValues(['name' => 'John']); +$errors = $prompt->validationErrors(); +// Will show error if 'age' is required but not provided +``` + + + + + + + + + + + + +### Example: Complete Usage + +Here's a complete example showing multiple features: + +```php +// Define a prompt template +$template = << + You are a friendly assistant. + + Hello! My name is {{ name }} and I like {{ preferences|join(', ') }}. + + +TWIG; + +// Create and configure prompt +$prompt = new Prompt(); +$prompt->withTemplateContent($template) + ->withValues([ + 'name' => 'Alice', + 'preferences' => ['reading', 'hiking', 'photography'] + ]); + +// Validate +if (!empty($prompt->validationErrors())) { + throw new Exception('Invalid prompt configuration'); +} + +// Get chat messages +$messages = $prompt->toMessages(); + +// Use with Instructor +$response = (new Instructor)->respond( + messages: $messages, + responseModel: YourResponseModel::class +); +``` + + + + + +### Configuration File + +By default, Instructor looks for prompt configuration in `config/prompt.php`. +Here's how to set up and customize your prompt engine: + +```php + 'twig', + + // Available configuration settings + 'settings' => [ + 'twig' => [ + 'templateEngine' => 'twig', + 'resourcePath' => '/../../../../prompts/twig', + 'cachePath' => '/tmp/instructor/cache/twig', + 'extension' => '.twig', + 'frontMatterTags' => ['{#---', '---#}'], + 'frontMatterFormat' => 'yaml', + 'metadata' => [ + 'autoReload' => true, + ], + ], + 'blade' => [ + 'templateEngine' => 'blade', + 'resourcePath' => '/../../../../prompts/blade', + 'cachePath' => '/tmp/instructor/cache/blade', + 'extension' => '.blade.php', + 'frontMatterTags' => ['{{--', '--}}'], + 'frontMatterFormat' => 'yaml', + ], + ] +]; +``` + +#### Configuration Options + +Each configuration setting supports the following options: + +##### Core Settings + +| Option | Description | Example | +|--------|-------------|---------| +| `templateEngine` | Template engine to use ('twig' or 'blade') | `'twig'` | +| `resourcePath` | Path to prompt template files | `'/prompts/twig'` | +| `cachePath` | Path for compiled templates | `'/tmp/cache/twig'` | +| `extension` | File extension for templates | `'.twig'` | + +##### Front Matter Settings + +| Option | Description | Example | +|--------|-------------|---------| +| `frontMatterTags` | Array of start and end tags for front matter | `['{#---', '---#}']` | +| `frontMatterFormat` | Format of front matter ('yaml', 'json', 'toml') | `'yaml'` | + +##### Engine-Specific Settings + +| Option | Description | Example | +|--------|-------------|---------| +| `metadata` | Engine-specific configuration | `['autoReload' => true]` | + +#### Using Configurations + +##### Default Configuration + +The default configuration is specified by `defaultSetting` and is used when no specific configuration is provided: + +```php +use Cognesy\Instructor\Extras\Prompt\Prompt; + +// Uses default configuration (twig in this case) +$prompt = new Prompt('hello'); +``` + +##### Specific Configuration + +You can specify which configuration to use: + +```php +// Use Blade configuration +$prompt = Prompt::using('blade')->withTemplate('hello'); + +// Or with the constructor +$prompt = new Prompt('hello', setting: 'blade'); +``` + +#### Programmatic Configuration + +You can also create configuration programmatically: + +```php +use Cognesy\Instructor\Extras\Prompt\Data\PromptEngineConfig; +use Cognesy\Instructor\Extras\Prompt\Enums\TemplateEngine; +use Cognesy\Instructor\Extras\Prompt\Enums\FrontMatterFormat; + +$config = new PromptEngineConfig( + templateEngine: TemplateEngine::Twig, + resourcePath: '/path/to/prompts', + cachePath: '/path/to/cache', + extension: '.twig', + frontMatterTags: ['{#---', '---#}'], + frontMatterFormat: FrontMatterFormat::Yaml, + metadata: [ + 'autoReload' => true, + ] +); + +$prompt = new Prompt(config: $config); +``` + +#### Configuration Loading + +When Instructor initializes, it follows this process to load configuration: + +1. Checks for configuration file at the default location +2. Loads the default setting specified by `defaultSetting` +3. Merges any user-provided configuration options + +#### Engine-Specific Features + +##### Twig Configuration + +Twig-specific settings in `metadata`: + +```php +'twig' => [ + // ... other settings ... + 'metadata' => [ + 'autoReload' => true, // Recompile templates when changed + ], +] +``` + +##### Blade Configuration + +Blade-specific settings can be added to metadata: + +```php +'blade' => [ + // ... other settings ... + 'metadata' => [ + 'mode' => 'MODE_AUTO', // BladeOne mode + ], +] +``` + + + + + + + + + + + + +## Best Practices + + +1. **Prompt metadata**: Include descriptions and variable definitions +```yaml +description: Clear description of the prompt's purpose +variables: + name: + description: Clear description of the variable + type: string + default: Default value if applicable +``` + +2. **Validation**: Validate prompts before using them in production +```php +$prompt = Prompt::get('user_info'); +if (!empty($prompt->validationErrors())) { + throw new Exception('Prompt validation errors: ' . implode(', ', $prompt->validationErrors())); +} +``` diff --git a/docs/configuration.mdx b/docs/configuration.mdx new file mode 100644 index 00000000..3a974679 --- /dev/null +++ b/docs/configuration.mdx @@ -0,0 +1,72 @@ +--- +title: Configuration Path +description: 'How to set up location of Instructor configuration directory for your project' +--- + +> To check how to publish configuration files to your project see [Setup](/setup) section. + + +### Setting Configuration Path via `Settings` Class + +You can set Instructor configuration path using the `Settings::setPath()` method: + +```php + +``` + +### Setting Configuration Path via Environment Variable + +You can set the path to Instructor's configuration directory in your `.env` file: + +```ini +INSTRUCTOR_CONFIG_PATH='/path/to/config/' +``` + + + +## Configuration Location Resolution + +Instructor uses a configuration directory with a set of `.php` files to store its settings, e.g. LLM provider configurations. + +Instructor will look for its configuration location in the following order: +- If static variable value `$path` in `Settings` class is set, it will use it, +- If `INSTRUCTOR_CONFIG_PATH` environment variable is set, it will use its value, +- Finally, it will default to the directory, which is bundled with Instructor package (under `/config`) and contains default set of configuration files. + + + + +## Configuration Groups + +Instructor's configuration is organized into groups. Each group contains a set of settings that are related to a specific aspect of Instructor's functionality. + +> NOTE: Individual configuration files are documented in their related sections. + +Instructor comes with the following default settings groups: +- `debug`: Debugging settings +- `embed`: Embedding provider connections +- `http`: HTTP client configurations +- `llm`: LLM provider connections +- `prompt`: Prompt libraries and their settings +- `web`: Web service providers (e.g. scraper API configurations) + +Each group is stored in a separate file in the configuration directory. The file name corresponds to the group name. + + + +## `Settings` Class + +`Settings` class is the main entry point for telling Instructor where to look for its configuration. It allows you to set up path of Instructor's configuration directory and access Instructor settings. + +`Settings` class provides the following methods: +- `setPath(string $path)`: Set the path to Instructor's configuration directory +- `getPath(): string`: Get current path to Instructor's configuration directory +- `has($group, $key): bool`: Check if a specific setting exists in Instructor's configuration +- `get($group, $key, $default): mixed`: Get a specific setting from Instructor's configuration +- `set($group, $key, $value)`: Set a specific setting in Instructor's configuration + +You won't usually need to use these methods directly, but they are used internally by Instructor to access configuration settings. diff --git a/docs/cookbook/examples/advanced/context_cache_llm.mdx b/docs/cookbook/examples/advanced/context_cache_llm.mdx index 925fcb3e..70dca1fc 100644 --- a/docs/cookbook/examples/advanced/context_cache_llm.mdx +++ b/docs/cookbook/examples/advanced/context_cache_llm.mdx @@ -1,76 +1,76 @@ ---- -title: 'Context caching' -docname: 'context_cache_llm' ---- - -## Overview - -Instructor offers a simplified way to work with LLM providers' APIs supporting caching -(currently only Anthropic API), so you can focus on your business logic while still being -able to take advantage of lower latency and costs. - -> **Note 1:** Instructor supports context caching for Anthropic API and OpenAI API. - -> **Note 2:** Context caching is automatic for all OpenAI API calls. Read more -> in the [OpenAI API documentation](https://platform.openai.com/docs/guides/prompt-caching). - -## Example - -When you need to process multiple requests with the same context, you can use context -caching to improve performance and reduce costs. - -In our example we will be analyzing the README.md file of this Github project and -generating its summary for 2 target audiences. - - -```php -add('Cognesy\\Instructor\\', __DIR__ . '../../src/'); - -use Cognesy\Instructor\Features\LLM\Inference; -use Cognesy\Instructor\Utils\Str; - -$data = file_get_contents(__DIR__ . '/../../../README.md'); - -$inference = (new Inference)->withConnection('anthropic')->withCachedContext( - messages: [ - ['role' => 'user', 'content' => 'Here is content of README.md file'], - ['role' => 'user', 'content' => $data], - ['role' => 'user', 'content' => 'Generate short, very domain specific pitch of the project described in README.md'], - ['role' => 'assistant', 'content' => 'For whom do you want to generate the pitch?'], - ], -); - -$response = $inference->create( - messages: [['role' => 'user', 'content' => 'CTO of lead gen software vendor']], - options: ['max_tokens' => 256], -)->response(); - -print("----------------------------------------\n"); -print("\n# Summary for CTO of lead gen vendor\n"); -print(" ({$response->usage()->cacheReadTokens} tokens read from cache)\n\n"); -print("----------------------------------------\n"); -print($response->content() . "\n"); - -assert(!empty($response->content())); -assert(Str::contains($response->content(), 'Instructor')); -assert(Str::contains($response->content(), 'lead', false)); - -$response2 = $inference->create( - messages: [['role' => 'user', 'content' => 'CIO of insurance company']], - options: ['max_tokens' => 256], -)->response(); - -print("----------------------------------------\n"); -print("\n# Summary for CIO of insurance company\n"); -print(" ({$response2->usage()->cacheReadTokens} tokens read from cache)\n\n"); -print("----------------------------------------\n"); -print($response2->content() . "\n"); - -assert(!empty($response2->content())); -assert(Str::contains($response2->content(), 'Instructor')); -assert(Str::contains($response2->content(), 'insurance', false)); -//assert($response2->cacheReadTokens > 0); -?> -``` +--- +title: 'Context caching' +docname: 'context_cache_llm' +--- + +## Overview + +Instructor offers a simplified way to work with LLM providers' APIs supporting caching +(currently only Anthropic API), so you can focus on your business logic while still being +able to take advantage of lower latency and costs. + +> **Note 1:** Instructor supports context caching for Anthropic API and OpenAI API. + +> **Note 2:** Context caching is automatic for all OpenAI API calls. Read more +> in the [OpenAI API documentation](https://platform.openai.com/docs/guides/prompt-caching). + +## Example + +When you need to process multiple requests with the same context, you can use context +caching to improve performance and reduce costs. + +In our example we will be analyzing the README.md file of this Github project and +generating its summary for 2 target audiences. + + +```php +add('Cognesy\\Instructor\\', __DIR__ . '../../src/'); + +use Cognesy\Instructor\Features\LLM\Inference; +use Cognesy\Instructor\Utils\Str; + +$data = file_get_contents(__DIR__ . '/../../../README.md'); + +$inference = (new Inference)->withConnection('anthropic')->withCachedContext( + messages: [ + ['role' => 'user', 'content' => 'Here is content of README.md file'], + ['role' => 'user', 'content' => $data], + ['role' => 'user', 'content' => 'Generate short, very domain specific pitch of the project described in README.md'], + ['role' => 'assistant', 'content' => 'For whom do you want to generate the pitch?'], + ], +); + +$response = $inference->create( + messages: [['role' => 'user', 'content' => 'CTO of lead gen software vendor']], + options: ['max_tokens' => 256], +)->response(); + +print("----------------------------------------\n"); +print("\n# Summary for CTO of lead gen vendor\n"); +print(" ({$response->usage()->cacheReadTokens} tokens read from cache)\n\n"); +print("----------------------------------------\n"); +print($response->content() . "\n"); + +assert(!empty($response->content())); +assert(Str::contains($response->content(), 'Instructor')); +assert(Str::contains($response->content(), 'lead', false)); + +$response2 = $inference->create( + messages: [['role' => 'user', 'content' => 'CIO of insurance company']], + options: ['max_tokens' => 256], +)->response(); + +print("----------------------------------------\n"); +print("\n# Summary for CIO of insurance company\n"); +print(" ({$response2->usage()->cacheReadTokens} tokens read from cache)\n\n"); +print("----------------------------------------\n"); +print($response2->content() . "\n"); + +assert(!empty($response2->content())); +assert(Str::contains($response2->content(), 'Instructor')); +assert(Str::contains($response2->content(), 'insurance', false)); +//assert($response2->cacheReadTokens > 0); +?> +``` diff --git a/docs/cookbook/examples/extras/image_car_damage.mdx b/docs/cookbook/examples/extras/image_car_damage.mdx index 5f302506..c376758c 100644 --- a/docs/cookbook/examples/extras/image_car_damage.mdx +++ b/docs/cookbook/examples/extras/image_car_damage.mdx @@ -1,83 +1,83 @@ ---- -title: 'Image processing - car damage detection' -docname: 'image_car_damage' ---- - -## Overview - -This is an example of how to extract structured data from an image using -Instructor. The image is loaded from a file and converted to base64 format -before sending it to OpenAI API. - -In this example we will be extracting structured data from an image of a car -with visible damage. The response model will contain information about the -location of the damage and the type of damage. - -## Scanned image - -Here's the image we're going to extract data from. - -![Car Photo](/images/car-damage.jpg) - - -## Example - -```php -add('Cognesy\\Instructor\\', __DIR__ . '../../src/'); - -use Cognesy\Instructor\Extras\Image\Image; -use Cognesy\Instructor\Features\Schema\Attributes\Description; -use Cognesy\Instructor\Utils\Str; - -enum DamageSeverity : string { - case Minor = 'minor'; - case Moderate = 'moderate'; - case Severe = 'severe'; - case Total = 'total'; -} - -enum DamageLocation : string { - case Front = 'front'; - case Rear = 'rear'; - case Left = 'left'; - case Right = 'right'; - case Top = 'top'; - case Bottom = 'bottom'; -} - -class Damage { - #[Description('Identify damaged element')] - public string $element; - /** @var DamageLocation[] */ - public array $locations; - public DamageSeverity $severity; - public string $description; -} - -class DamageAssessment { - public string $make; - public string $model; - public string $bodyColor; - /** @var Damage[] */ - public array $damages = []; - public string $summary; -} - -$assessment = Image::fromFile(__DIR__ . '/car-damage.jpg') - ->toData( - responseModel: DamageAssessment::class, - prompt: 'Identify and assess each car damage location and severity separately.', - connection: 'openai', - model: 'gpt-4o', - options: ['max_tokens' => 4096] - ); - -dump($assessment); -assert(Str::contains($assessment->make, 'Toyota', false)); -assert(Str::contains($assessment->model, 'Prius', false)); -assert(Str::contains($assessment->bodyColor, 'white', false)); -assert(count($assessment->damages) > 0); -?> -``` +--- +title: 'Image processing - car damage detection' +docname: 'image_car_damage' +--- + +## Overview + +This is an example of how to extract structured data from an image using +Instructor. The image is loaded from a file and converted to base64 format +before sending it to OpenAI API. + +In this example we will be extracting structured data from an image of a car +with visible damage. The response model will contain information about the +location of the damage and the type of damage. + +## Scanned image + +Here's the image we're going to extract data from. + +![Car Photo](/images/car-damage.jpg) + + +## Example + +```php +add('Cognesy\\Instructor\\', __DIR__ . '../../src/'); + +use Cognesy\Instructor\Extras\Image\Image; +use Cognesy\Instructor\Features\Schema\Attributes\Description; +use Cognesy\Instructor\Utils\Str; + +enum DamageSeverity : string { + case Minor = 'minor'; + case Moderate = 'moderate'; + case Severe = 'severe'; + case Total = 'total'; +} + +enum DamageLocation : string { + case Front = 'front'; + case Rear = 'rear'; + case Left = 'left'; + case Right = 'right'; + case Top = 'top'; + case Bottom = 'bottom'; +} + +class Damage { + #[Description('Identify damaged element')] + public string $element; + /** @var DamageLocation[] */ + public array $locations; + public DamageSeverity $severity; + public string $description; +} + +class DamageAssessment { + public string $make; + public string $model; + public string $bodyColor; + /** @var Damage[] */ + public array $damages = []; + public string $summary; +} + +$assessment = Image::fromFile(__DIR__ . '/car-damage.jpg') + ->toData( + responseModel: DamageAssessment::class, + prompt: 'Identify and assess each car damage location and severity separately.', + connection: 'openai', + model: 'gpt-4o', + options: ['max_tokens' => 4096] + ); + +dump($assessment); +assert(Str::contains($assessment->make, 'Toyota', false)); +assert(Str::contains($assessment->model, 'Prius', false)); +assert(Str::contains($assessment->bodyColor, 'white', false)); +assert(count($assessment->damages) > 0); +?> +``` diff --git a/docs/cookbook/examples/extras/llm.mdx b/docs/cookbook/examples/extras/llm.mdx index e29ce866..2196a2fe 100644 --- a/docs/cookbook/examples/extras/llm.mdx +++ b/docs/cookbook/examples/extras/llm.mdx @@ -1,69 +1,69 @@ ---- -title: 'Working directly with LLMs' -docname: 'llm' ---- - -## Overview - -`Inference` class offers access to LLM APIs and convenient methods to execute -model inference, incl. chat completions, tool calling or JSON output -generation. - -LLM providers access details can be found and modified via -`/config/llm.php`. - - -## Example - -```php -add('Cognesy\\Instructor\\', __DIR__ . '../../src/'); - -use Cognesy\Instructor\Features\LLM\Inference; -use Cognesy\Instructor\Utils\Str; - - -// EXAMPLE 1: simplified API, default connection for convenient ad-hoc calls -$answer = Inference::text('What is capital of Germany'); - -echo "USER: What is capital of Germany\n"; -echo "ASSISTANT: $answer\n"; -assert(Str::contains($answer, 'Berlin')); - - - - -// EXAMPLE 2: regular API, allows to customize inference options -$answer = (new Inference) - ->withConnection('openai') // optional, default is set in /config/llm.php - ->create( - messages: [['role' => 'user', 'content' => 'What is capital of France']], - options: ['max_tokens' => 64] - ) - ->toText(); - -echo "USER: What is capital of France\n"; -echo "ASSISTANT: $answer\n"; -assert(Str::contains($answer, 'Paris')); - - - - -// EXAMPLE 3: streaming response -$stream = (new Inference) - ->create( - messages: [['role' => 'user', 'content' => 'Describe capital of Brasil']], - options: ['max_tokens' => 128, 'stream' => true] - ) - ->stream() - ->responses(); - -echo "USER: Describe capital of Brasil\n"; -echo "ASSISTANT: "; -foreach ($stream as $partial) { - echo $partial->contentDelta; -} -echo "\n"; -?> -``` +--- +title: 'Working directly with LLMs' +docname: 'llm' +--- + +## Overview + +`Inference` class offers access to LLM APIs and convenient methods to execute +model inference, incl. chat completions, tool calling or JSON output +generation. + +LLM providers access details can be found and modified via +`/config/llm.php`. + + +## Example + +```php +add('Cognesy\\Instructor\\', __DIR__ . '../../src/'); + +use Cognesy\Instructor\Features\LLM\Inference; +use Cognesy\Instructor\Utils\Str; + + +// EXAMPLE 1: simplified API, default connection for convenient ad-hoc calls +$answer = Inference::text('What is capital of Germany'); + +echo "USER: What is capital of Germany\n"; +echo "ASSISTANT: $answer\n"; +assert(Str::contains($answer, 'Berlin')); + + + + +// EXAMPLE 2: regular API, allows to customize inference options +$answer = (new Inference) + ->withConnection('openai') // optional, default is set in /config/llm.php + ->create( + messages: [['role' => 'user', 'content' => 'What is capital of France']], + options: ['max_tokens' => 64] + ) + ->toText(); + +echo "USER: What is capital of France\n"; +echo "ASSISTANT: $answer\n"; +assert(Str::contains($answer, 'Paris')); + + + + +// EXAMPLE 3: streaming response +$stream = (new Inference) + ->create( + messages: [['role' => 'user', 'content' => 'Describe capital of Brasil']], + options: ['max_tokens' => 128, 'stream' => true] + ) + ->stream() + ->responses(); + +echo "USER: Describe capital of Brasil\n"; +echo "ASSISTANT: "; +foreach ($stream as $partial) { + echo $partial->contentDelta; +} +echo "\n"; +?> +``` diff --git a/docs/cookbook/examples/extras/prompt_text.mdx b/docs/cookbook/examples/extras/prompt_text.mdx index a8d3db2e..02fdf7a3 100644 --- a/docs/cookbook/examples/extras/prompt_text.mdx +++ b/docs/cookbook/examples/extras/prompt_text.mdx @@ -1,50 +1,50 @@ ---- -title: 'Prompts' -docname: 'prompt_text' ---- - -## Overview - -`Prompt` class in Instructor PHP provides a way to define and use -prompt templates using Twig or Blade template syntax. - - -## Example - -```php -add('Cognesy\\Instructor\\', __DIR__ . '../../src/'); - -use Cognesy\Instructor\Extras\Prompt\Prompt; -use Cognesy\Instructor\Features\LLM\Inference; -use Cognesy\Instructor\Utils\Str; - -// EXAMPLE 1: Simplfied API - -// use default template language, prompt files are in /prompts/twig/.twig -$prompt = Prompt::text('capital', ['country' => 'Germany']); -$answer = (new Inference)->create(messages: $prompt)->toText(); - -echo "EXAMPLE 1: prompt = $prompt\n"; -echo "ASSISTANT: $answer\n"; -echo "\n"; -assert(Str::contains($answer, 'Berlin')); - -// EXAMPLE 2: Define prompt template inline - -$prompt = Prompt::using('twig') - ->withTemplateContent('What is capital of {{country}}') - ->withValues(['country' => 'Germany']) - ->toText(); -$answer = (new Inference)->create(messages: $prompt)->toText(); - - - -echo "EXAMPLE 2: prompt = $prompt\n"; -echo "ASSISTANT: $answer\n"; -echo "\n"; -assert(Str::contains($answer, 'Berlin')); - -?> -``` +--- +title: 'Prompts' +docname: 'prompt_text' +--- + +## Overview + +`Prompt` class in Instructor PHP provides a way to define and use +prompt templates using Twig or Blade template syntax. + + +## Example + +```php +add('Cognesy\\Instructor\\', __DIR__ . '../../src/'); + +use Cognesy\Instructor\Extras\Prompt\Prompt; +use Cognesy\Instructor\Features\LLM\Inference; +use Cognesy\Instructor\Utils\Str; + +// EXAMPLE 1: Simplfied API + +// use default template language, prompt files are in /prompts/twig/.twig +$prompt = Prompt::text('capital', ['country' => 'Germany']); +$answer = (new Inference)->create(messages: $prompt)->toText(); + +echo "EXAMPLE 1: prompt = $prompt\n"; +echo "ASSISTANT: $answer\n"; +echo "\n"; +assert(Str::contains($answer, 'Berlin')); + +// EXAMPLE 2: Define prompt template inline + +$prompt = Prompt::using('twig') + ->withTemplateContent('What is capital of {{country}}') + ->withValues(['country' => 'Germany']) + ->toText(); +$answer = (new Inference)->create(messages: $prompt)->toText(); + + + +echo "EXAMPLE 2: prompt = $prompt\n"; +echo "ASSISTANT: $answer\n"; +echo "\n"; +assert(Str::contains($answer, 'Berlin')); + +?> +``` diff --git a/docs/environment.mdx b/docs/environment.mdx new file mode 100644 index 00000000..d468f250 --- /dev/null +++ b/docs/environment.mdx @@ -0,0 +1,59 @@ +## Environment Configuration + +Instructor uses environment variables for configuration settings and API keys. The library comes with +a `.env-dist` template file that lists all supported variables. + +You can copy it to `.env` (or merge with your `.env` file) and fill in your values. + +Alternatively, you can set the variables directly in your environment + +Check [setup instructions](/setup) for more details on how to set up your environment and how it can +be done automatically with the Instructor's CLI tool. + + + +### API Keys in Your `.env` File + +Instructor supports multiple LLM providers. + +Configure the ones you plan to use: + +```ini +# OpenAI (default provider) +OPENAI_API_KEY='' + +# Alternative providers +ANTHROPIC_API_KEY='' +ANYSCALE_API_KEY='' +AZURE_OPENAI_API_KEY='' +AZURE_OPENAI_EMBED_API_KEY='' +COHERE_API_KEY='' +FIREWORKS_API_KEY='' +GEMINI_API_KEY='' +GROK_API_KEY='' +GROQ_API_KEY='' +MISTRAL_API_KEY='' +OLLAMA_API_KEY='' +OPENROUTER_API_KEY='' +TOGETHER_API_KEY='' +JINA_API_KEY='' +``` + +Only configure the services you plan to use; others can remain empty. + +> WARNING: Keep your `.env` file secure and never commit it to version control. +> For production, consider using your environment's secrets management system. + + + +### Instructor Configuration Directory Path + +Instructor uses a configuration directory to store its settings, e.g. LLM provider configurations. + +You can set the path to this directory in your `.env` file: +``` +INSTRUCTOR_CONFIG_PATH='/../../config/' +``` + +This tells Instructor where to find its configuration files, if it has not been configured manually +via `Settings` class. The path is relative to the vendor directory where Instructor is installed. diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 377681ce..11ab4281 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -4,72 +4,90 @@ description: 'Start processing your data with LLMs in under 5 minutes' --- -## Setup Your Development Environment +## Install Instructor with Composer -Set up LLM provider API keys - create `.env` file in the root of your project and add the following: +Installing Instructor is simple. Run following command in your terminal, and you're on your way to a smoother data handling experience! -```env -OPENAI_API_KEY=your-openai-api-key +```bash +composer require cognesy/instructor-php ``` -> NOTE: You can get your LLM provider API key from the provider's dashboard, e.g.: -> [OpenAI](https://platform.openai.com/) -You can also use API key directly in your code - see [example](cookbook/examples/advanced/custom_client). +## Publish Instructor Files to Your Project + +Instructor comes with a set of configuration files and prompt templates that you can publish to your project directory. This will allow you to customize the library's behavior and use different prompt templates. + +```bash +./vendor/bin/instructor publish +``` +This will publish following files to your chosen locations: +- `.env`: Template for environment variables that contain API keys for external services, e.g. LLM providers +- `config/`: Directory with Instructor configuration files +- `prompts/`: Directory containing prompt templates -## Install Instructor with Composer -Installing Instructor is simple. Run following command in your terminal, and you're on your way to a smoother data handling experience! +## Set Up LLM Provider API Key(s) -```bash -composer install cognesy/instructor-php +Open the `.env` file in your project directory and set up API keys for the LLM providers you plan to use. You can find the keys in the respective provider's dashboard. + +```ini +# OpenAI (default provider) +OPENAI_API_KEY='your-openai-api-key' ``` -## Basic example +## Set Configuration Location + +Instructor uses a configuration directory to store its settings, e.g. LLM provider configurations. You can set the path to this directory in your `.env` file: -This is a simple example demonstrating how Instructor retrieves structured information from provided text (or chat message sequence). +```ini +INSTRUCTOR_CONFIG_PATH='/path/to/your/config/dir/' +``` -Response model class is a plain PHP class with typehints specifying the types of fields of the object. -Create file `instructor-test.php` with the following content: +## Create a New PHP File -```php +In your project directory, create a new PHP file and require Composer's autoloader: + +```php instructor-test.php add('Cognesy\\Instructor\\', __DIR__ . '../../src/'); use Cognesy\Instructor\Instructor; // Step 1: Define target data structure(s) -class Person { +class City { public string $name; - public int $age; + public string $country; + public int $population; } -// Step 2: Provide content to process -$text = "His name is Jason and he is 28 years old."; - -// Step 3: Use Instructor to run LLM inference -$person = (new Instructor)->respond( - messages: $text, - responseModel: Person::class, +// Step 2: Use Instructor to run LLM inference +$city = (new Instructor)->withConnection('openai')->respond( + messages: 'What is the capital of France?', + responseModel: City::class, ); -// Step 4: Work with structured response data -assert($person instanceof Person); // true -assert($person->name === 'Jason'); // true -assert($person->age === 28); // true - -echo $person->name; // Jason -echo $person->age; // 28 - -var_dump($person); -// Person { -// name: "Jason", -// age: 28 +// Step 3: Work with structured response data +assert($city instanceof City); // true +assert($city->name === 'Paris'); // true +assert($city->country === 'France'); // true +assert(is_int($city->population)); // true + +echo $city->name; // Paris +echo $city->country; // France +echo $city->population; // 2140526 + +var_dump($city); +// City { +// name: "Paris" +// country: "France" +// population: 2140526 // } ``` @@ -79,7 +97,12 @@ Now, you can run you example: php instructor-test.php ``` -> **NOTE:** Instructor supports classes / objects as response models. In case you want to extract -> simple types like strings, integers, float, booleans or enums, you need to wrap them in Scalar adapter. -> See section: Extracting Scalar Values. +## Read More + +In most cases you will want to modify default configuration files distributed with Instructor. + +See following sections for more details: + - [Setup Instructions](setup) for more details on how to publish configuration files and additional Instructor resources + - [Environment Configuration](environment) for more details on setting up environment variables + - [Configuration Files](configuration) for detailed information on configuration options diff --git a/docs/setup.mdx b/docs/setup.mdx new file mode 100644 index 00000000..156b10a9 --- /dev/null +++ b/docs/setup.mdx @@ -0,0 +1,146 @@ +## Setup + +## Installation + +After installing Instructor via Composer, you'll may want to publish the library's configuration files +and resources to your project, so you can modify them according to your needs. You can do this either +automatically using the provided CLI tool or manually by copying the required files. + +## Instructor Configuration Files + +Instructor requires configuration files to set up its behavior and connect to LLM providers. These files +include: +- `.env` - Environment variables for API keys and configuration paths +- `/config` - Configuration files for Instructor modules +- `/prompts` - Prompt templates for generating structured data from text + +## Using the CLI Tool + +Instructor provides a CLI tool to help with this setup: + +```bash +./vendor/bin/instructor publish +``` + +By default, this command will: +1. Copy configuration files from `vendor/cognesy/instructor-php/config` to `config/instructor/` +2. Copy prompt templates from `vendor/cognesy/instructor-php/prompts` to `resources/prompts/` +3. Create or merge `.env` file in `tmp/.env` with required environment variables + +### Command Options + +- `-c, --target-config-dir=DIR` - Custom directory for configuration files (default: `config/instructor`) +- `-p, --target-prompts-dir=DIR` - Custom directory for prompt templates (default: `resources/prompts`) +- `-e, --target-env-file=FILE` - Custom location for .env file (default: `.env`) +- `-l, --log-file=FILE` - Optional log file path to track the publishing process +- `--no-op` - Dry run mode - shows what would be copied without making changes + +### Example Usage + +```bash +# Custom config directory +./vendor/bin/instructor publish -c config/instructor + +# Custom prompts directory and env file location +./vendor/bin/instructor publish -p resources/prompts -e .env + +# Preview changes without applying them +./vendor/bin/instructor publish --no-op + +# Log the publishing process +./vendor/bin/instructor publish -l setup.log +``` + +When merging `.env` files, the tool will only add missing variables, preserving your existing values. + +## Manual Setup + +If you prefer to set up Instructor manually or need more control over the process, you can copy the required files directly: + +1. Configuration Files +```bash +# Create config directory +mkdir -p config/instructor + +# Copy configuration files +cp vendor/cognesy/instructor-php/config/llm.php config/instructor/ +cp vendor/cognesy/instructor-php/config/instructor.php config/instructor/ + ``` +These files contain LLM API connection settings and Instructor's behavior configuration. + +2. Prompt Templates +```bash +# Create prompts directory +mkdir -p resources/prompts + +# Copy prompt templates +cp -r vendor/cognesy/instructor-php/prompts/* resources/prompts/ + ``` +Prompt templates define how Instructor communicates with LLMs for different tasks. + +3. Environment Configuration +```bash +# Copy environment template if .env doesn't exist +[ ! -f .env ] && cp vendor/cognesy/instructor-php/config/.env-dist .env + +# Or manually add required variables to your existing .env: +# OPENAI_API_KEY=your_api_key +# INSTRUCTOR_CONFIG_PATH=config/instructor +# INSTRUCTOR_PROMPTS_PATH=resources/prompts + ``` + +### Required Files + +Key files and their purposes: +- `.env-dist`: Template for environment variables that contain API keys for external services, e.g. LLM providers +- `config/`: Directory with configuration files for Instructor and LLM providers +- `prompts/`: Directory containing prompt templates + + + +## Framework Integration + +### Laravel Projects +For Laravel applications, it's recommended to align with the framework's directory structure: + +```bash +./vendor/bin/instructor publish \ + --target-config-dir=config/instructor \ + --target-prompts-dir=resources/prompts \ + --target-env-file=.env +``` + +This will: +- Place configuration files in Laravel's `config` directory +- Store prompts in Laravel's `resources` directory +- Use Laravel's default `.env` file location + +After publishing, you can load Instructor configuration in your `config/app.php` or create a dedicated service provider. + + +### Symfony Projects +For Symfony applications, use the standard Symfony directory structure: + +```bash +./vendor/bin/instructor publish \ + --target-config-dir=config/packages/instructor \ + --target-prompts-dir=resources/instructor/prompts \ + --target-env-file=.env +``` + +This will: +- Place configuration in Symfony's package configuration directory +- Store prompts in Symfony's `resources` directory +- Use Symfony's default `.env` file location + +For Symfony Flex applications, you may want to create a recipe to automate this setup process. + + +### Custom Framework Location + +You can use environment variables to set default locations: +``` +INSTRUCTOR_CONFIG_PATH=/path/to/config +``` + +This allows you to maintain consistent paths across your application without specifying them in each command. diff --git a/evals/LLMModes/CompanyEval.php b/evals/LLMModes/CompanyEval.php index cce0a26c..b70ef744 100644 --- a/evals/LLMModes/CompanyEval.php +++ b/evals/LLMModes/CompanyEval.php @@ -1,75 +1,75 @@ -key = $key; - $this->expectations = $expectations; - } - - public function accepts(mixed $subject): bool { - return $subject instanceof Execution; - } - - public function observations(mixed $subject): iterable { - yield $this->correctness($subject); - } - - // INTERNAL ///////////////////////////////////////////////// - - public function correctness(Execution $execution): Observation { - $mode = $execution->get('case.mode'); - $isCorrect = match ($mode) { - Mode::Text => $this->validateText($execution), - Mode::Tools => $this->validateToolsData($execution), - default => $this->validateDefault($execution), - }; - return Observation::make( - type: 'metric', - key: $this->key, - value: $isCorrect ? 1 : 0, - metadata: [ - 'executionId' => $execution->id(), - 'unit' => 'boolean', - ], - ); - } - - private function validateToolsData(Execution $execution) : bool { - $data = $execution->get('response')->toolsData[0] ?? []; - return 'store_company' === ($data['name'] ?? '') - && 'ACME' === ($data['arguments']['name'] ?? '') - && 2020 === (int) ($data['arguments']['year'] ?? 0); - } - - private function validateDefault(Execution $execution) : bool { - $decoded = $execution->get('response')?->json()->toArray(); - return $this->expectations['name'] === ($decoded['name'] ?? '') - && $this->expectations['year'] === ($decoded['year'] ?? 0); - } - - private function validateText(Execution $execution) : bool { - $content = $execution->get('response')?->content(); - return Str::contains( - $content, - [ - $this->expectations['name'], - (string) $this->expectations['year'] - ] - ); - } -} +key = $key; + $this->expectations = $expectations; + } + + public function accepts(mixed $subject): bool { + return $subject instanceof Execution; + } + + public function observations(mixed $subject): iterable { + yield $this->correctness($subject); + } + + // INTERNAL ///////////////////////////////////////////////// + + public function correctness(Execution $execution): Observation { + $mode = $execution->get('case.mode'); + $isCorrect = match ($mode) { + Mode::Text => $this->validateText($execution), + Mode::Tools => $this->validateToolsData($execution), + default => $this->validateDefault($execution), + }; + return Observation::make( + type: 'metric', + key: $this->key, + value: $isCorrect ? 1 : 0, + metadata: [ + 'executionId' => $execution->id(), + 'unit' => 'boolean', + ], + ); + } + + private function validateToolsData(Execution $execution) : bool { + $data = $execution->get('response')->toolsData[0] ?? []; + return 'store_company' === ($data['name'] ?? '') + && 'ACME' === ($data['arguments']['name'] ?? '') + && 2020 === (int) ($data['arguments']['year'] ?? 0); + } + + private function validateDefault(Execution $execution) : bool { + $decoded = $execution->get('response')?->json()->toArray(); + return $this->expectations['name'] === ($decoded['name'] ?? '') + && $this->expectations['year'] === ($decoded['year'] ?? 0); + } + + private function validateText(Execution $execution) : bool { + $content = $execution->get('response')?->content(); + return Str::containsAll( + $content, + [ + $this->expectations['name'], + (string) $this->expectations['year'] + ] + ); + } +} diff --git a/examples/A01_Basics/ValidationWithLLM/run.php b/examples/A01_Basics/ValidationWithLLM/run.php index 42cbcdcd..31c229ab 100644 --- a/examples/A01_Basics/ValidationWithLLM/run.php +++ b/examples/A01_Basics/ValidationWithLLM/run.php @@ -1,73 +1,73 @@ ---- -title: 'Validation with LLM' -docname: 'validation_with_llm' ---- - -## Overview - -You can use LLM capability to semantically process the context to validate -the response following natural language instructions. This way you can -implement more complex validation logic that would be difficult (or impossible) -to achieve using traditional, code-based validation. - -## Example - -add('Cognesy\\Instructor\\', __DIR__.'../../src/'); - -use Cognesy\Instructor\Events\Event; -use Cognesy\Instructor\Extras\Scalar\Scalar; -use Cognesy\Instructor\Features\Schema\Attributes\Description; -use Cognesy\Instructor\Features\Validation\Traits\ValidationMixin; -use Cognesy\Instructor\Features\Validation\ValidationResult; -use Cognesy\Instructor\Instructor; -use Cognesy\Instructor\Utils\Str; - -class UserDetails -{ - use ValidationMixin; - - public string $name; - #[Description('User details in format: key=value')] - /** @var string[] */ - public array $details; - - public function validate() : ValidationResult { - return match($this->hasPII()) { - true => ValidationResult::fieldError( - field: 'details', - value: implode('\n', $this->details), - message: "Details contain PII, remove it from the response." - ), - false => ValidationResult::valid(), - }; - } - - private function hasPII() : bool { - $data = implode('\n', $this->details); - return (new Instructor)->respond( - messages: "Context:\n$data\n", - responseModel: Scalar::boolean('hasPII', 'Does the context contain any PII?'), - ); - } -} - -$text = <<wiretap(fn(Event $e) => $e->print()) // let's check the internals of Instructor processing - ->respond( - messages: $text, - responseModel: UserDetails::class, - maxRetries: 2 - ); - -dump($user); - -assert(!Str::contains(implode('\n', $user->details), '123-45-6789')); -?> -``` +--- +title: 'Validation with LLM' +docname: 'validation_with_llm' +--- + +## Overview + +You can use LLM capability to semantically process the context to validate +the response following natural language instructions. This way you can +implement more complex validation logic that would be difficult (or impossible) +to achieve using traditional, code-based validation. + +## Example + +add('Cognesy\\Instructor\\', __DIR__.'../../src/'); + +use Cognesy\Instructor\Events\Event; +use Cognesy\Instructor\Extras\Scalar\Scalar; +use Cognesy\Instructor\Features\Schema\Attributes\Description; +use Cognesy\Instructor\Features\Validation\Traits\ValidationMixin; +use Cognesy\Instructor\Features\Validation\ValidationResult; +use Cognesy\Instructor\Instructor; +use Cognesy\Instructor\Utils\Str; + +class UserDetails +{ + use ValidationMixin; + + public string $name; + #[Description('User details in format: key=value')] + /** @var string[] */ + public array $details; + + public function validate() : ValidationResult { + return match($this->hasPII()) { + true => ValidationResult::fieldError( + field: 'details', + value: implode('\n', $this->details), + message: "Details contain PII, remove it from the response." + ), + false => ValidationResult::valid(), + }; + } + + private function hasPII() : bool { + $data = implode('\n', $this->details); + return (new Instructor)->respond( + messages: "Context:\n$data\n", + responseModel: Scalar::boolean('hasPII', 'Does the context contain any PII?'), + ); + } +} + +$text = <<wiretap(fn(Event $e) => $e->print()) // let's check the internals of Instructor processing + ->respond( + messages: $text, + responseModel: UserDetails::class, + maxRetries: 2 + ); + +dump($user); + +assert(!Str::contains(implode('\n', $user->details), '123-45-6789')); +?> +``` diff --git a/examples/A02_Advanced/ContextCacheLLM/run.php b/examples/A02_Advanced/ContextCacheLLM/run.php index 925fcb3e..70dca1fc 100644 --- a/examples/A02_Advanced/ContextCacheLLM/run.php +++ b/examples/A02_Advanced/ContextCacheLLM/run.php @@ -1,76 +1,76 @@ ---- -title: 'Context caching' -docname: 'context_cache_llm' ---- - -## Overview - -Instructor offers a simplified way to work with LLM providers' APIs supporting caching -(currently only Anthropic API), so you can focus on your business logic while still being -able to take advantage of lower latency and costs. - -> **Note 1:** Instructor supports context caching for Anthropic API and OpenAI API. - -> **Note 2:** Context caching is automatic for all OpenAI API calls. Read more -> in the [OpenAI API documentation](https://platform.openai.com/docs/guides/prompt-caching). - -## Example - -When you need to process multiple requests with the same context, you can use context -caching to improve performance and reduce costs. - -In our example we will be analyzing the README.md file of this Github project and -generating its summary for 2 target audiences. - - -```php -add('Cognesy\\Instructor\\', __DIR__ . '../../src/'); - -use Cognesy\Instructor\Features\LLM\Inference; -use Cognesy\Instructor\Utils\Str; - -$data = file_get_contents(__DIR__ . '/../../../README.md'); - -$inference = (new Inference)->withConnection('anthropic')->withCachedContext( - messages: [ - ['role' => 'user', 'content' => 'Here is content of README.md file'], - ['role' => 'user', 'content' => $data], - ['role' => 'user', 'content' => 'Generate short, very domain specific pitch of the project described in README.md'], - ['role' => 'assistant', 'content' => 'For whom do you want to generate the pitch?'], - ], -); - -$response = $inference->create( - messages: [['role' => 'user', 'content' => 'CTO of lead gen software vendor']], - options: ['max_tokens' => 256], -)->response(); - -print("----------------------------------------\n"); -print("\n# Summary for CTO of lead gen vendor\n"); -print(" ({$response->usage()->cacheReadTokens} tokens read from cache)\n\n"); -print("----------------------------------------\n"); -print($response->content() . "\n"); - -assert(!empty($response->content())); -assert(Str::contains($response->content(), 'Instructor')); -assert(Str::contains($response->content(), 'lead', false)); - -$response2 = $inference->create( - messages: [['role' => 'user', 'content' => 'CIO of insurance company']], - options: ['max_tokens' => 256], -)->response(); - -print("----------------------------------------\n"); -print("\n# Summary for CIO of insurance company\n"); -print(" ({$response2->usage()->cacheReadTokens} tokens read from cache)\n\n"); -print("----------------------------------------\n"); -print($response2->content() . "\n"); - -assert(!empty($response2->content())); -assert(Str::contains($response2->content(), 'Instructor')); -assert(Str::contains($response2->content(), 'insurance', false)); -//assert($response2->cacheReadTokens > 0); -?> -``` +--- +title: 'Context caching' +docname: 'context_cache_llm' +--- + +## Overview + +Instructor offers a simplified way to work with LLM providers' APIs supporting caching +(currently only Anthropic API), so you can focus on your business logic while still being +able to take advantage of lower latency and costs. + +> **Note 1:** Instructor supports context caching for Anthropic API and OpenAI API. + +> **Note 2:** Context caching is automatic for all OpenAI API calls. Read more +> in the [OpenAI API documentation](https://platform.openai.com/docs/guides/prompt-caching). + +## Example + +When you need to process multiple requests with the same context, you can use context +caching to improve performance and reduce costs. + +In our example we will be analyzing the README.md file of this Github project and +generating its summary for 2 target audiences. + + +```php +add('Cognesy\\Instructor\\', __DIR__ . '../../src/'); + +use Cognesy\Instructor\Features\LLM\Inference; +use Cognesy\Instructor\Utils\Str; + +$data = file_get_contents(__DIR__ . '/../../../README.md'); + +$inference = (new Inference)->withConnection('anthropic')->withCachedContext( + messages: [ + ['role' => 'user', 'content' => 'Here is content of README.md file'], + ['role' => 'user', 'content' => $data], + ['role' => 'user', 'content' => 'Generate short, very domain specific pitch of the project described in README.md'], + ['role' => 'assistant', 'content' => 'For whom do you want to generate the pitch?'], + ], +); + +$response = $inference->create( + messages: [['role' => 'user', 'content' => 'CTO of lead gen software vendor']], + options: ['max_tokens' => 256], +)->response(); + +print("----------------------------------------\n"); +print("\n# Summary for CTO of lead gen vendor\n"); +print(" ({$response->usage()->cacheReadTokens} tokens read from cache)\n\n"); +print("----------------------------------------\n"); +print($response->content() . "\n"); + +assert(!empty($response->content())); +assert(Str::contains($response->content(), 'Instructor')); +assert(Str::contains($response->content(), 'lead', false)); + +$response2 = $inference->create( + messages: [['role' => 'user', 'content' => 'CIO of insurance company']], + options: ['max_tokens' => 256], +)->response(); + +print("----------------------------------------\n"); +print("\n# Summary for CIO of insurance company\n"); +print(" ({$response2->usage()->cacheReadTokens} tokens read from cache)\n\n"); +print("----------------------------------------\n"); +print($response2->content() . "\n"); + +assert(!empty($response2->content())); +assert(Str::contains($response2->content(), 'Instructor')); +assert(Str::contains($response2->content(), 'insurance', false)); +//assert($response2->cacheReadTokens > 0); +?> +``` diff --git a/examples/A02_Advanced/ContextCaching/run.php b/examples/A02_Advanced/ContextCaching/run.php index f083de57..1a2c269a 100644 --- a/examples/A02_Advanced/ContextCaching/run.php +++ b/examples/A02_Advanced/ContextCaching/run.php @@ -1,109 +1,109 @@ ---- -title: 'Context caching (Anthropic)' -docname: 'context_cache' ---- - -## Overview - -Instructor offers a simplified way to work with LLM providers' APIs supporting caching -(currently only Anthropic API), so you can focus on your business logic while still being -able to take advantage of lower latency and costs. - -> **Note 1:** Instructor supports context caching for Anthropic API and OpenAI API. - -> **Note 2:** Context caching is automatic for all OpenAI API calls. Read more -> in the [OpenAI API documentation](https://platform.openai.com/docs/guides/prompt-caching). - - -## Example - -When you need to process multiple requests with the same context, you can use context -caching to improve performance and reduce costs. - -In our example we will be analyzing the README.md file of this Github project and -generating its structured description for multiple audiences. - -Let's start by defining the data model for the project details and the properties -that we want to extract or generate based on README file. - -```php -add('Cognesy\\Instructor\\', __DIR__ . '../../src/'); - -use Cognesy\Instructor\Enums\Mode; -use Cognesy\Instructor\Features\Schema\Attributes\Description; -use Cognesy\Instructor\Instructor; -use Cognesy\Instructor\Utils\Str; - -class Project { - public string $name; - public string $targetAudience; - /** @var string[] */ - #[Description('Technology platform and libraries used in the project')] - public array $technologies; - /** @var string[] */ - #[Description('Features and capabilities of the project')] - public array $features; - /** @var string[] */ - #[Description('Applications and potential use cases of the project')] - public array $applications; - #[Description('Explain the purpose of the project and the domain specific problems it solves')] - public string $description; - #[Description('Example code in Markdown demonstrating domain specific application of the library')] - public string $code; -} -?> -``` - -We read the content of the README.md file and cache the context, so it can be reused for -multiple requests. - -```php -withConnection('openai')->withCachedContext( - system: 'Your goal is to respond questions about the project described in the README.md file' - . "\n\n# README.md\n\n" . $content, - prompt: 'Respond to the user with a description of the project with JSON using schema:\n<|json_schema|>', -); -?> -``` -At this point we can use Instructor structured output processing to extract the project -details from the README.md file into the `Project` data model. - -Let's start by asking the user to describe the project for a specific audience: P&C insurance CIOs. - -```php -respond( - messages: 'Describe the project in a way compelling to my audience: P&C insurance CIOs.', - responseModel: Project::class, - options: ['max_tokens' => 4096], - mode: Mode::Json, -); -dump($project); -assert($project instanceof Project); -assert(Str::contains($project->name, 'Instructor')); -?> -``` -Now we can use the same context to ask the user to describe the project for a different -audience: boutique CMS consulting company owner. - -Anthropic API will use the context cached in the previous request to provide the response, -which results in faster processing and lower costs. - -```php -respond( - messages: "Describe the project in a way compelling to my audience: boutique CMS consulting company owner.", - responseModel: Project::class, - options: ['max_tokens' => 4096], - mode: Mode::Json, -); -dump($project); -assert($project instanceof Project); -assert(Str::contains($project->name, 'Instructor')); -?> -``` +--- +title: 'Context caching (Anthropic)' +docname: 'context_cache' +--- + +## Overview + +Instructor offers a simplified way to work with LLM providers' APIs supporting caching +(currently only Anthropic API), so you can focus on your business logic while still being +able to take advantage of lower latency and costs. + +> **Note 1:** Instructor supports context caching for Anthropic API and OpenAI API. + +> **Note 2:** Context caching is automatic for all OpenAI API calls. Read more +> in the [OpenAI API documentation](https://platform.openai.com/docs/guides/prompt-caching). + + +## Example + +When you need to process multiple requests with the same context, you can use context +caching to improve performance and reduce costs. + +In our example we will be analyzing the README.md file of this Github project and +generating its structured description for multiple audiences. + +Let's start by defining the data model for the project details and the properties +that we want to extract or generate based on README file. + +```php +add('Cognesy\\Instructor\\', __DIR__ . '../../src/'); + +use Cognesy\Instructor\Enums\Mode; +use Cognesy\Instructor\Features\Schema\Attributes\Description; +use Cognesy\Instructor\Instructor; +use Cognesy\Instructor\Utils\Str; + +class Project { + public string $name; + public string $targetAudience; + /** @var string[] */ + #[Description('Technology platform and libraries used in the project')] + public array $technologies; + /** @var string[] */ + #[Description('Features and capabilities of the project')] + public array $features; + /** @var string[] */ + #[Description('Applications and potential use cases of the project')] + public array $applications; + #[Description('Explain the purpose of the project and the domain specific problems it solves')] + public string $description; + #[Description('Example code in Markdown demonstrating domain specific application of the library')] + public string $code; +} +?> +``` + +We read the content of the README.md file and cache the context, so it can be reused for +multiple requests. + +```php +withConnection('openai')->withCachedContext( + system: 'Your goal is to respond questions about the project described in the README.md file' + . "\n\n# README.md\n\n" . $content, + prompt: 'Respond to the user with a description of the project with JSON using schema:\n<|json_schema|>', +); +?> +``` +At this point we can use Instructor structured output processing to extract the project +details from the README.md file into the `Project` data model. + +Let's start by asking the user to describe the project for a specific audience: P&C insurance CIOs. + +```php +respond( + messages: 'Describe the project in a way compelling to my audience: P&C insurance CIOs.', + responseModel: Project::class, + options: ['max_tokens' => 4096], + mode: Mode::Json, +); +dump($project); +assert($project instanceof Project); +assert(Str::contains($project->name, 'Instructor')); +?> +``` +Now we can use the same context to ask the user to describe the project for a different +audience: boutique CMS consulting company owner. + +Anthropic API will use the context cached in the previous request to provide the response, +which results in faster processing and lower costs. + +```php +respond( + messages: "Describe the project in a way compelling to my audience: boutique CMS consulting company owner.", + responseModel: Project::class, + options: ['max_tokens' => 4096], + mode: Mode::Json, +); +dump($project); +assert($project instanceof Project); +assert(Str::contains($project->name, 'Instructor')); +?> +``` diff --git a/examples/A05_Extras/ImageCarDamage/run.php b/examples/A05_Extras/ImageCarDamage/run.php index 5f302506..c376758c 100644 --- a/examples/A05_Extras/ImageCarDamage/run.php +++ b/examples/A05_Extras/ImageCarDamage/run.php @@ -1,83 +1,83 @@ ---- -title: 'Image processing - car damage detection' -docname: 'image_car_damage' ---- - -## Overview - -This is an example of how to extract structured data from an image using -Instructor. The image is loaded from a file and converted to base64 format -before sending it to OpenAI API. - -In this example we will be extracting structured data from an image of a car -with visible damage. The response model will contain information about the -location of the damage and the type of damage. - -## Scanned image - -Here's the image we're going to extract data from. - -![Car Photo](/images/car-damage.jpg) - - -## Example - -```php -add('Cognesy\\Instructor\\', __DIR__ . '../../src/'); - -use Cognesy\Instructor\Extras\Image\Image; -use Cognesy\Instructor\Features\Schema\Attributes\Description; -use Cognesy\Instructor\Utils\Str; - -enum DamageSeverity : string { - case Minor = 'minor'; - case Moderate = 'moderate'; - case Severe = 'severe'; - case Total = 'total'; -} - -enum DamageLocation : string { - case Front = 'front'; - case Rear = 'rear'; - case Left = 'left'; - case Right = 'right'; - case Top = 'top'; - case Bottom = 'bottom'; -} - -class Damage { - #[Description('Identify damaged element')] - public string $element; - /** @var DamageLocation[] */ - public array $locations; - public DamageSeverity $severity; - public string $description; -} - -class DamageAssessment { - public string $make; - public string $model; - public string $bodyColor; - /** @var Damage[] */ - public array $damages = []; - public string $summary; -} - -$assessment = Image::fromFile(__DIR__ . '/car-damage.jpg') - ->toData( - responseModel: DamageAssessment::class, - prompt: 'Identify and assess each car damage location and severity separately.', - connection: 'openai', - model: 'gpt-4o', - options: ['max_tokens' => 4096] - ); - -dump($assessment); -assert(Str::contains($assessment->make, 'Toyota', false)); -assert(Str::contains($assessment->model, 'Prius', false)); -assert(Str::contains($assessment->bodyColor, 'white', false)); -assert(count($assessment->damages) > 0); -?> -``` +--- +title: 'Image processing - car damage detection' +docname: 'image_car_damage' +--- + +## Overview + +This is an example of how to extract structured data from an image using +Instructor. The image is loaded from a file and converted to base64 format +before sending it to OpenAI API. + +In this example we will be extracting structured data from an image of a car +with visible damage. The response model will contain information about the +location of the damage and the type of damage. + +## Scanned image + +Here's the image we're going to extract data from. + +![Car Photo](/images/car-damage.jpg) + + +## Example + +```php +add('Cognesy\\Instructor\\', __DIR__ . '../../src/'); + +use Cognesy\Instructor\Extras\Image\Image; +use Cognesy\Instructor\Features\Schema\Attributes\Description; +use Cognesy\Instructor\Utils\Str; + +enum DamageSeverity : string { + case Minor = 'minor'; + case Moderate = 'moderate'; + case Severe = 'severe'; + case Total = 'total'; +} + +enum DamageLocation : string { + case Front = 'front'; + case Rear = 'rear'; + case Left = 'left'; + case Right = 'right'; + case Top = 'top'; + case Bottom = 'bottom'; +} + +class Damage { + #[Description('Identify damaged element')] + public string $element; + /** @var DamageLocation[] */ + public array $locations; + public DamageSeverity $severity; + public string $description; +} + +class DamageAssessment { + public string $make; + public string $model; + public string $bodyColor; + /** @var Damage[] */ + public array $damages = []; + public string $summary; +} + +$assessment = Image::fromFile(__DIR__ . '/car-damage.jpg') + ->toData( + responseModel: DamageAssessment::class, + prompt: 'Identify and assess each car damage location and severity separately.', + connection: 'openai', + model: 'gpt-4o', + options: ['max_tokens' => 4096] + ); + +dump($assessment); +assert(Str::contains($assessment->make, 'Toyota', false)); +assert(Str::contains($assessment->model, 'Prius', false)); +assert(Str::contains($assessment->bodyColor, 'white', false)); +assert(count($assessment->damages) > 0); +?> +``` diff --git a/examples/A05_Extras/LLM/run.php b/examples/A05_Extras/LLM/run.php index e29ce866..2196a2fe 100644 --- a/examples/A05_Extras/LLM/run.php +++ b/examples/A05_Extras/LLM/run.php @@ -1,69 +1,69 @@ ---- -title: 'Working directly with LLMs' -docname: 'llm' ---- - -## Overview - -`Inference` class offers access to LLM APIs and convenient methods to execute -model inference, incl. chat completions, tool calling or JSON output -generation. - -LLM providers access details can be found and modified via -`/config/llm.php`. - - -## Example - -```php -add('Cognesy\\Instructor\\', __DIR__ . '../../src/'); - -use Cognesy\Instructor\Features\LLM\Inference; -use Cognesy\Instructor\Utils\Str; - - -// EXAMPLE 1: simplified API, default connection for convenient ad-hoc calls -$answer = Inference::text('What is capital of Germany'); - -echo "USER: What is capital of Germany\n"; -echo "ASSISTANT: $answer\n"; -assert(Str::contains($answer, 'Berlin')); - - - - -// EXAMPLE 2: regular API, allows to customize inference options -$answer = (new Inference) - ->withConnection('openai') // optional, default is set in /config/llm.php - ->create( - messages: [['role' => 'user', 'content' => 'What is capital of France']], - options: ['max_tokens' => 64] - ) - ->toText(); - -echo "USER: What is capital of France\n"; -echo "ASSISTANT: $answer\n"; -assert(Str::contains($answer, 'Paris')); - - - - -// EXAMPLE 3: streaming response -$stream = (new Inference) - ->create( - messages: [['role' => 'user', 'content' => 'Describe capital of Brasil']], - options: ['max_tokens' => 128, 'stream' => true] - ) - ->stream() - ->responses(); - -echo "USER: Describe capital of Brasil\n"; -echo "ASSISTANT: "; -foreach ($stream as $partial) { - echo $partial->contentDelta; -} -echo "\n"; -?> -``` +--- +title: 'Working directly with LLMs' +docname: 'llm' +--- + +## Overview + +`Inference` class offers access to LLM APIs and convenient methods to execute +model inference, incl. chat completions, tool calling or JSON output +generation. + +LLM providers access details can be found and modified via +`/config/llm.php`. + + +## Example + +```php +add('Cognesy\\Instructor\\', __DIR__ . '../../src/'); + +use Cognesy\Instructor\Features\LLM\Inference; +use Cognesy\Instructor\Utils\Str; + + +// EXAMPLE 1: simplified API, default connection for convenient ad-hoc calls +$answer = Inference::text('What is capital of Germany'); + +echo "USER: What is capital of Germany\n"; +echo "ASSISTANT: $answer\n"; +assert(Str::contains($answer, 'Berlin')); + + + + +// EXAMPLE 2: regular API, allows to customize inference options +$answer = (new Inference) + ->withConnection('openai') // optional, default is set in /config/llm.php + ->create( + messages: [['role' => 'user', 'content' => 'What is capital of France']], + options: ['max_tokens' => 64] + ) + ->toText(); + +echo "USER: What is capital of France\n"; +echo "ASSISTANT: $answer\n"; +assert(Str::contains($answer, 'Paris')); + + + + +// EXAMPLE 3: streaming response +$stream = (new Inference) + ->create( + messages: [['role' => 'user', 'content' => 'Describe capital of Brasil']], + options: ['max_tokens' => 128, 'stream' => true] + ) + ->stream() + ->responses(); + +echo "USER: Describe capital of Brasil\n"; +echo "ASSISTANT: "; +foreach ($stream as $partial) { + echo $partial->contentDelta; +} +echo "\n"; +?> +``` diff --git a/examples/A05_Extras/PromptText/run.php b/examples/A05_Extras/PromptText/run.php index a8d3db2e..02fdf7a3 100644 --- a/examples/A05_Extras/PromptText/run.php +++ b/examples/A05_Extras/PromptText/run.php @@ -1,50 +1,50 @@ ---- -title: 'Prompts' -docname: 'prompt_text' ---- - -## Overview - -`Prompt` class in Instructor PHP provides a way to define and use -prompt templates using Twig or Blade template syntax. - - -## Example - -```php -add('Cognesy\\Instructor\\', __DIR__ . '../../src/'); - -use Cognesy\Instructor\Extras\Prompt\Prompt; -use Cognesy\Instructor\Features\LLM\Inference; -use Cognesy\Instructor\Utils\Str; - -// EXAMPLE 1: Simplfied API - -// use default template language, prompt files are in /prompts/twig/.twig -$prompt = Prompt::text('capital', ['country' => 'Germany']); -$answer = (new Inference)->create(messages: $prompt)->toText(); - -echo "EXAMPLE 1: prompt = $prompt\n"; -echo "ASSISTANT: $answer\n"; -echo "\n"; -assert(Str::contains($answer, 'Berlin')); - -// EXAMPLE 2: Define prompt template inline - -$prompt = Prompt::using('twig') - ->withTemplateContent('What is capital of {{country}}') - ->withValues(['country' => 'Germany']) - ->toText(); -$answer = (new Inference)->create(messages: $prompt)->toText(); - - - -echo "EXAMPLE 2: prompt = $prompt\n"; -echo "ASSISTANT: $answer\n"; -echo "\n"; -assert(Str::contains($answer, 'Berlin')); - -?> -``` +--- +title: 'Prompts' +docname: 'prompt_text' +--- + +## Overview + +`Prompt` class in Instructor PHP provides a way to define and use +prompt templates using Twig or Blade template syntax. + + +## Example + +```php +add('Cognesy\\Instructor\\', __DIR__ . '../../src/'); + +use Cognesy\Instructor\Extras\Prompt\Prompt; +use Cognesy\Instructor\Features\LLM\Inference; +use Cognesy\Instructor\Utils\Str; + +// EXAMPLE 1: Simplfied API + +// use default template language, prompt files are in /prompts/twig/.twig +$prompt = Prompt::text('capital', ['country' => 'Germany']); +$answer = (new Inference)->create(messages: $prompt)->toText(); + +echo "EXAMPLE 1: prompt = $prompt\n"; +echo "ASSISTANT: $answer\n"; +echo "\n"; +assert(Str::contains($answer, 'Berlin')); + +// EXAMPLE 2: Define prompt template inline + +$prompt = Prompt::using('twig') + ->withTemplateContent('What is capital of {{country}}') + ->withValues(['country' => 'Germany']) + ->toText(); +$answer = (new Inference)->create(messages: $prompt)->toText(); + + + +echo "EXAMPLE 2: prompt = $prompt\n"; +echo "ASSISTANT: $answer\n"; +echo "\n"; +assert(Str::contains($answer, 'Berlin')); + +?> +``` diff --git a/hub.bat b/hub.bat deleted file mode 100644 index da629b25..00000000 --- a/hub.bat +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -set DIR=%~dp0 -php "%DIR%hub.php" %* \ No newline at end of file diff --git a/hub.sh b/hub.sh deleted file mode 100755 index d809b973..00000000 --- a/hub.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash -DIR="$(dirname "$0")" -php $DIR/hub.php "$@" diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 00000000..41d8e778 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,10 @@ +parameters: + level: 1 + paths: + - src + - tests + - src-hub + - config + reportUnmatchedIgnoredErrors: false + checkGenericClassInNonGenericObjectType: false + tmpDir: /tmp diff --git a/prompt.txt b/prompt.txt deleted file mode 100644 index c7112435..00000000 --- a/prompt.txt +++ /dev/null @@ -1,868 +0,0 @@ -Project Path: Prompt - -Source Tree: - -``` -Prompt -├── Drivers -│ ├── BladeDriver.php -│ └── TwigDriver.php -├── Contracts -│ └── CanHandleTemplate.php -├── Enums -│ ├── FrontMatterFormat.php -│ └── TemplateType.php -├── PromptInfo.php -├── Data -│ └── PromptEngineConfig.php -└── Prompt.php - -``` - -`/home/ddebowczyk/projects/instructor-php/src/Extras/Prompt/Drivers/BladeDriver.php`: - -```php -config->resourcePath; - $cache = __DIR__ . $this->config->cachePath; - $extension = $this->config->extension; - $mode = $this->config->metadata['mode'] ?? BladeOne::MODE_AUTO; - $this->blade = new BladeOne($views, $cache, $mode); - $this->blade->setFileExtension($extension); - } - - /** - * Renders a template file with the given parameters. - * - * @param string $name The name of the template file - * @param array $parameters The parameters to pass to the template - * @return string The rendered template - */ - public function renderFile(string $name, array $parameters = []): string { - return $this->blade->run($name, $parameters); - } - - /** - * Renders a template from a string with the given parameters. - * - * @param string $content The template content as a string - * @param array $parameters The parameters to pass to the template - * @return string The rendered template - */ - public function renderString(string $content, array $parameters = []): string { - return $this->blade->runString($content, $parameters); - } - - /** - * Gets the content of a template file. - * - * @param string $name - * @return string - */ - public function getTemplateContent(string $name): string { - $templatePath = $this->blade->getTemplateFile($name); - if (!file_exists($templatePath)) { - throw new Exception("Template '$name' file does not exist: $templatePath"); - } - return file_get_contents($templatePath); - } - - /** - * Gets names of variables from a template content. - * @param string $content - * @return array - */ - public function getVariableNames(string $content): array { - $variables = []; - preg_match_all('/{{\s*([$a-zA-Z0-9_]+)\s*}}/', $content, $matches); - foreach ($matches[1] as $match) { - $name = trim($match); - $name = str_starts_with($name, '$') ? substr($name, 1) : $name; - $variables[] = $name; - } - return array_unique($variables); - } -} - -``` - -`/home/ddebowczyk/projects/instructor-php/src/Extras/Prompt/Drivers/TwigDriver.php`: - -```php -config->resourcePath]; - $extension = $this->config->extension; - - $loader = new class( - paths: $paths, - fileExtension: $extension - ) extends FilesystemLoader { - private string $fileExtension; - - /** - * Constructor for the custom FilesystemLoader. - * - * @param array $paths The paths where templates are stored - * @param string|null $rootPath The root path for templates - * @param string $fileExtension The file extension to use for templates - */ - public function __construct( - $paths = [], - ?string $rootPath = null, - string $fileExtension = '', - ) { - parent::__construct($paths, $rootPath); - $this->fileExtension = $fileExtension; - } - - /** - * Finds a template by its name and appends the file extension if not present. - * - * @param string $name The name of the template - * @param bool $throw Whether to throw an exception if the template is not found - * @return string The path to the template - */ - protected function findTemplate(string $name, bool $throw = true): string { - if (pathinfo($name, PATHINFO_EXTENSION) === '') { - $name .= $this->fileExtension; - } - return parent::findTemplate($name, $throw); - } - }; - - $this->twig = new Environment( - loader: $loader, - options: ['cache' => $this->config->cachePath], - ); - } - - /** - * Renders a template file with the given parameters. - * - * @param string $name The name of the template file - * @param array $parameters The parameters to pass to the template - * @return string The rendered template - */ - public function renderFile(string $name, array $parameters = []): string { - return $this->twig->render($name, $parameters); - } - - /** - * Renders a template from a string with the given parameters. - * - * @param string $content The template content as a string - * @param array $parameters The parameters to pass to the template - * @return string The rendered template - */ - public function renderString(string $content, array $parameters = []): string { - return $this->twig->createTemplate($content)->render($parameters); - } - - /** - * Gets the content of a template file. - * - * @param string $name - * @return string - */ - public function getTemplateContent(string $name): string { - return $this->twig->getLoader()->getSourceContext($name)->getCode(); - } - - /** - * Gets names of variables used in a template content. - * - * @param string $content - * @return array - * @throws \Twig\Error\SyntaxError - */ - public function getVariableNames(string $content): array { - // make Twig Source from content string - $source = new Source($content, 'template'); - // Parse the template to get its AST - $parsedTemplate = $this->twig->parse($this->twig->tokenize($source)); - // Collect variables - $variables = $this->findVariables($parsedTemplate); - // Remove duplicates - return array_unique($variables); - } - - // INTERNAL ///////////////////////////////////////////////// - - private function findVariables(Node $node): array { - $variables = []; - // Check for variable nodes and add them to the list - if ($node instanceof NameExpression) { - $variables[] = $node->getAttribute('name'); - } - // Recursively search in child nodes - foreach ($node as $child) { - $childVariables = $this->findVariables($child); - foreach ($childVariables as $variable) { - $variables[] = $variable; - } - } - return $variables; - } -} - -``` - -`/home/ddebowczyk/projects/instructor-php/src/Extras/Prompt/Contracts/CanHandleTemplate.php`: - -```php -config->frontMatterTags[0] ?? '---'; - $endTag = $this->config->frontMatterTags[1] ?? '---'; - $format = $this->config->frontMatterFormat; - $this->engine = $this->makeEngine($format, $startTag, $endTag); - - $document = $this->engine->parse($content); - $this->templateData = $document->getData(); - $this->templateContent = $document->getContent(); - } - - public function field(string $name) : mixed { - return $this->templateData[$name] ?? null; - } - - public function hasField(string $name) : bool { - return array_key_exists($name, $this->templateData); - } - - public function data() : array { - return $this->templateData; - } - - public function content() : string { - return $this->templateContent; - } - - public function variables() : array { - return $this->field('variables') ?? []; - } - - public function variableNames() : array { - return array_keys($this->variables()); - } - - public function hasVariables() : bool { - return $this->hasField('variables'); - } - - public function schema() : array { - return $this->field('schema') ?? []; - } - - public function hasSchema() : bool { - return $this->hasField('schema'); - } - - // INTERNAL ///////////////////////////////////////////////// - - private function makeEngine(FrontMatterFormat $format, string $startTag, string $endTag) : FrontMatter { - return match($format) { - FrontMatterFormat::Yaml => new FrontMatter(new YamlProcessor(), $startTag, $endTag), - FrontMatterFormat::Json => new FrontMatter(new JsonProcessor(), $startTag, $endTag), - FrontMatterFormat::Toml => new FrontMatter(new TomlProcessor(), $startTag, $endTag), - default => throw new InvalidArgumentException("Unknown front matter format: $format->value"), - }; - } -} - -``` - -`/home/ddebowczyk/projects/instructor-php/src/Extras/Prompt/Data/PromptEngineConfig.php`: - -```php -config = $config ?? PromptEngineConfig::load( - setting: $setting ?: Settings::get('prompt', "defaultSetting") - ); - $this->driver = $driver ?? $this->makeDriver($this->config); - $this->templateContent = $name ? $this->load($name) : ''; - } - - public static function using(string $setting) : Prompt { - return new self(setting: $setting); - } - - public static function get(string $name, string $setting = '') : Prompt { - return new self(name: $name, setting: $setting); - } - - public static function text(string $name, array $variables, string $setting = '') : string { - return (new self(name: $name, setting: $setting))->withValues($variables)->toText(); - } - - public static function messages(string $name, array $variables, string $setting = '') : Messages { - return (new self(name: $name, setting: $setting))->withValues($variables)->toMessages(); - } - - public function withSetting(string $setting) : self { - $this->config = PromptEngineConfig::load($setting); - $this->driver = $this->makeDriver($this->config); - return $this; - } - - public function withConfig(PromptEngineConfig $config) : self { - $this->config = $config; - $this->driver = $this->makeDriver($config); - return $this; - } - - public function withDriver(CanHandleTemplate $driver) : self { - $this->driver = $driver; - return $this; - } - - public function withTemplate(string $name) : self { - $this->templateContent = $this->load($name); - $this->promptInfo = new PromptInfo($this->templateContent, $this->config); - return $this; - } - - public function withTemplateContent(string $content) : self { - $this->templateContent = $content; - $this->promptInfo = new PromptInfo($this->templateContent, $this->config); - return $this; - } - - public function withValues(array $values) : self { - $this->variableValues = $values; - return $this; - } - - public function toText() : string { - return $this->rendered(); - } - - public function toMessages() : Messages { - return $this->makeMessages($this->rendered()); - } - - public function toArray() : array { - return $this->toMessages()->toArray(); - } - - public function config() : PromptEngineConfig { - return $this->config; - } - - public function params() : array { - return $this->variableValues; - } - - public function template() : string { - return $this->templateContent; - } - - public function variables() : array { - return $this->driver->getVariableNames($this->templateContent); - } - - public function info() : PromptInfo { - return $this->promptInfo; - } - - public function validationErrors() : array { - $infoVars = $this->info()->variableNames(); - $templateVars = $this->variables(); - $valueKeys = array_keys($this->variableValues); - - $messages = []; - foreach($infoVars as $var) { - if (!in_array($var, $valueKeys)) { - $messages[] = "$var: variable defined in template info, but value not provided"; - } - if (!in_array($var, $templateVars)) { - $messages[] = "$var: variable defined in template info, but not used"; - } - } - foreach($valueKeys as $var) { - if (!in_array($var, $infoVars)) { - $messages[] = "$var: value provided, but not defined in template info"; - } - if (!in_array($var, $templateVars)) { - $messages[] = "$var: value provided, but not used in template content"; - } - } - foreach($templateVars as $var) { - if (!in_array($var, $infoVars)) { - $messages[] = "$var: variable used in template, but not defined in template info"; - } - if (!in_array($var, $valueKeys)) { - $messages[] = "$var: variable used in template, but value not provided"; - } - } - return $messages; - } - - // INTERNAL /////////////////////////////////////////////////// - - private function rendered() : string { - if (!isset($this->rendered)) { - $rendered = $this->render($this->templateContent, $this->variableValues); - $this->rendered = $rendered; - } - return $this->rendered; - } - - private function makeMessages(string $text) : Messages { - return match(true) { - $this->containsXml($text) && $this->hasRoles() => $this->makeMessagesFromXml($text), - default => Messages::fromString($text), - }; - } - - private function hasRoles() : string { - $roleStrings = [ - '', '', '' - ]; - if (Str::contains($this->rendered(), $roleStrings)) { - return true; - } - return false; - } - - private function containsXml(string $text) : bool { - return preg_match('/<[^>]+>/', $text) === 1; - } - - private function makeMessagesFromXml(string $text) : Messages { - $messages = new Messages(); - $xml = Xml::from($text)->wrapped('chat')->toArray(); - // TODO: validate - foreach ($xml as $key => $message) { - $messages->appendMessage(Message::make($key, $message)); - } - return $messages; - } - - private function makeDriver(PromptEngineConfig $config) : CanHandleTemplate { - return match($config->templateType) { - TemplateType::Twig => new TwigDriver($config), - TemplateType::Blade => new BladeDriver($config), - default => throw new InvalidArgumentException("Unknown driver: $config->templateType"), - }; - } - - private function load(string $path) : string { - return $this->driver->getTemplateContent($path); - } - - private function render(string $template, array $parameters = []) : string { - return $this->driver->renderString($template, $parameters); - } -} -``` - -Project Path: prompts - -Source Tree: - -``` -prompts -├── twig -│ ├── summary_struct.twig -│ ├── summary.twig -│ ├── capital.twig -│ └── hello.twig -└── blade - ├── capital.blade.php - └── hello.blade.php - -``` - -`/home/ddebowczyk/projects/instructor-php/prompts/twig/summary_struct.twig`: - -```twig -[Action: {{input}}] [Noun: Analyze] [Modifier: Thoroughly] [Noun: Input_Text] [Goal: Generate_Essential_Questions] [Parameter: Number=5] - -[Given: Essential_Questions] -[Action: {{input}}] [Noun: Formulate_Questions] [Modifier: To Capture] [Parameter: Themes=Core Meaning, Argument, Supporting_Ideas, Author_Purpose, Implications] -[Action: Address] [Noun: Central_Theme] -[Action: Identify] [Noun: Key_Supporting_Ideas] -[Action: Highlight] [Noun: Important_Facts or Evidence] -[Action: Reveal] [Noun: Author_Purpose or Perspective] -[Action: Explore] [Noun: Significant_Implications or Conclusions] - -[Action: {{input}}] [Noun: Answer_Generated_Questions] [Modifier: Thoroughly] [Parameter: Detail=High] - -``` - -`/home/ddebowczyk/projects/instructor-php/prompts/twig/summary.twig`: - -```twig -{# ---- -description: Summarize input -params: - input: - description: Input text to summarize - type: string - depth: - description: Depth of summarization (number of essential questions) - type: int - default: 5 ---- -#} - -1) Analyze the input and generate {{ depth }} essential questions that, when answered, capture the main points and core meaning of the text. -2) When formulating your questions: - - Address the central theme or argument - - Identify key supporting ideas - - Highlight important facts or evidence - - Reveal the author's purpose or perspective - - Explore any significant implications or conclusions. -3) Answer all of your generated questions one-by-one in detail. - -# INPUT -{{ input }} - -``` - -`/home/ddebowczyk/projects/instructor-php/prompts/twig/capital.twig`: - -```twig -{# ---- -description: Find country capital template for testing templates -variables: - country: - description: country name - type: string - default: France -schema: - name: capital - properties: - name: - description: Capital of the country - type: string - required: [name] ---- -#} - - - You are a helpful assistant, respond to the user's questions in a concise manner. - Respond with JSON object, follow the format: {{ json_schema }} - - - {# examples #} - - - What is the capital of France? - - - - {{ ['name': 'Paris'] | json_encode() }} - - - {# /examples #} - - - What is the capital of {{ country }}? - - -``` - -`/home/ddebowczyk/projects/instructor-php/prompts/twig/hello.twig`: - -```twig -{#--- -description: Hello world template for testing templates -variables: - name: - description: Name of the person to greet - type: string - default: World ----#} - -Hello, {{ name }}! -``` - -`/home/ddebowczyk/projects/instructor-php/prompts/blade/capital.blade.php`: - -```php -{{-- -description: Find country capital template for testing templates -variables: - country: - description: country name - type: string - default: France -schema: - name: capital - properties: - name: - description: Capital of the country - type: string - required: [name] ---}} - - - You are a helpful assistant, respond to the user's questions in a concise manner. - - - {{-- examples --}} - - - What is the capital of France? - - - - {{ json_encode(['name' => 'Paris']) }} - - - {{-- /examples --}} - - - What is the capital of {{ $country }}? - - - -``` - -`/home/ddebowczyk/projects/instructor-php/prompts/blade/hello.blade.php`: - -```php -{{-- -description: Hello world template for testing templates -variables: - name: - description: Name of the person to greet - type: string - default: World ---}} - -Hello, {{ $name }}! -``` diff --git a/prompts/blade/capital.blade.php b/prompts/demo-blade/capital.blade.php similarity index 95% rename from prompts/blade/capital.blade.php rename to prompts/demo-blade/capital.blade.php index 29270dde..807a9768 100644 --- a/prompts/blade/capital.blade.php +++ b/prompts/demo-blade/capital.blade.php @@ -1,11 +1,11 @@ -{{-- ---- -description: Find country capital template for testing templates -variables: - country: - description: country name - type: string - default: France ---- ---}} -What is the capital of {{ $country }}? +{{-- +--- +description: Find country capital template for testing templates +variables: + country: + description: country name + type: string + default: France +--- +--}} +What is the capital of {{ $country }}? diff --git a/prompts/blade/capital_json.blade.php b/prompts/demo-blade/capital_json.blade.php similarity index 95% rename from prompts/blade/capital_json.blade.php rename to prompts/demo-blade/capital_json.blade.php index 00840741..cf2ec206 100644 --- a/prompts/blade/capital_json.blade.php +++ b/prompts/demo-blade/capital_json.blade.php @@ -1,36 +1,36 @@ -{{-- -description: Find country capital template for testing templates -variables: - country: - description: country name - type: string - default: France -schema: - name: capital - properties: - name: - description: Capital of the country - type: string - required: [name] ---}} - - - You are a helpful assistant, respond to the user's questions in a concise manner. - - - {{-- examples --}} - - - What is the capital of France? - - - - {{ json_encode(['name' => 'Paris']) }} - - - {{-- /examples --}} - - - What is the capital of {{ $country }}? - - +{{-- +description: Find country capital template for testing templates +variables: + country: + description: country name + type: string + default: France +schema: + name: capital + properties: + name: + description: Capital of the country + type: string + required: [name] +--}} + + + You are a helpful assistant, respond to the user's questions in a concise manner. + + + {{-- examples --}} + + + What is the capital of France? + + + + {{ json_encode(['name' => 'Paris']) }} + + + {{-- /examples --}} + + + What is the capital of {{ $country }}? + + diff --git a/prompts/blade/hello.blade.php b/prompts/demo-blade/hello.blade.php similarity index 95% rename from prompts/blade/hello.blade.php rename to prompts/demo-blade/hello.blade.php index 1468f3da..936dfc85 100644 --- a/prompts/blade/hello.blade.php +++ b/prompts/demo-blade/hello.blade.php @@ -1,10 +1,9 @@ -{{-- -description: Hello world template for testing templates -variables: - name: - description: Name of the person to greet - type: string - default: World ---}} - +{{-- +description: Hello world template for testing templates +variables: + name: + description: Name of the person to greet + type: string + default: World +--}} Hello, {{ $name }}! \ No newline at end of file diff --git a/prompts/twig/capital.twig b/prompts/demo-twig/capital.twig similarity index 100% rename from prompts/twig/capital.twig rename to prompts/demo-twig/capital.twig diff --git a/prompts/twig/capital_json.twig b/prompts/demo-twig/capital_json.twig similarity index 100% rename from prompts/twig/capital_json.twig rename to prompts/demo-twig/capital_json.twig diff --git a/prompts/twig/hello.twig b/prompts/demo-twig/hello.twig similarity index 95% rename from prompts/twig/hello.twig rename to prompts/demo-twig/hello.twig index 11f6cc81..0b7371ee 100644 --- a/prompts/twig/hello.twig +++ b/prompts/demo-twig/hello.twig @@ -6,5 +6,4 @@ variables: type: string default: World ---#} - Hello, {{ name }}! \ No newline at end of file diff --git a/prompts/examples/jina_metaprompt.twig b/prompts/examples/jina_metaprompt.twig new file mode 100644 index 00000000..51c9b7c0 --- /dev/null +++ b/prompts/examples/jina_metaprompt.twig @@ -0,0 +1,181 @@ +You are an AI engineer designed to help users use Jina AI Search Foundation API's for their specific use case. + +# Core principles + +1. Use the simplest solution possible (use single API's whenever possible, do not overcomplicate things); +2. Answer "can't do" for tasks outside the scope of Jina AI Search Foundation; +3. Choose built-in features over custom implementations whenever possible; +4. Leverage multimodal models when needed; + +# Jina AI Search Foundation API's documentation + +1. Embeddings API +Endpoint: https://api.jina.ai/v1/embeddings +Purpose: Convert text/images to fixed-length vectors +Best for: semantic search, similarity matching, clustering, etc. +Method: POST +Authorization: HTTPBearer +Request body schema: {"application/json":{"model":{"type":"string","required":true,"description":"Identifier of the model to use.","options":[{"name":"jina-clip-v1","size":"223M","dimensions":768},{"name":"jina-embeddings-v2-base-en","size":"137M","dimensions":768},{"name":"jina-embeddings-v2-base-es","size":"161M","dimensions":768},{"name":"jina-embeddings-v2-base-de","size":"161M","dimensions":768},{"name":"jina-embeddings-v2-base-fr","size":"161M","dimensions":768},{"name":"jina-embeddings-v2-base-code","size":"137M","dimensions":768},{"name":"jina-embeddings-v3","size":"570M","dimensions":1024}]},"input":{"type":"array","required":true,"description":"Array of input strings or objects to be embedded."},"embedding_type":{"type":"string or array of strings","required":false,"default":"float","description":"The format of the returned embeddings.","options":["float","base64","binary","ubinary"]},"task":{"type":"string","required":false,"description":"Specifies the intended downstream application to optimize embedding output.","options":["retrieval.query","retrieval.passage","text-matching","classification","separation"]},"dimensions":{"type":"integer","required":false,"description":"Truncates output embeddings to the specified size if set."},"normalized":{"type":"boolean","required":false,"default":false,"description":"If true, embeddings are normalized to unit L2 norm."},"late_chunking":{"type":"boolean","required":false,"default":false,"description":"If true, concatenates all sentences in input and treats as a single input for late chunking."}}} +Example request: {"model":"jina-embeddings-v3","input":["Hello, world!"]} +Example response: {"200":{"data":[{"embedding":"..."}],"usage":{"total_tokens":15}},"422":{"error":{"message":"Invalid input or parameters"}}} + +2. Reranker API +Endpoint: https://api.jina.ai/v1/rerank +Purpose: find the most relevant search results +Best for: refining search results, refining RAG (retrieval augmented generation) contextual chunks, etc. +Method: POST +Authorization: HTTPBearer +Request body schema: {"application/json":{"model":{"type":"string","required":true,"description":"Identifier of the model to use.","options":[{"name":"jina-reranker-v2-base-multilingual","size":"278M"},{"name":"jina-reranker-v1-base-en","size":"137M"},{"name":"jina-reranker-v1-tiny-en","size":"33M"},{"name":"jina-reranker-v1-turbo-en","size":"38M"},{"name":"jina-colbert-v1-en","size":"137M"}]},"query":{"type":"string or TextDoc","required":true,"description":"The search query."},"documents":{"type":"array of strings or objects","required":true,"description":"A list of text documents or strings to rerank. If a document object is provided, all text fields will be preserved in the response."},"top_n":{"type":"integer","required":false,"description":"The number of most relevant documents or indices to return, defaults to the length of documents."},"return_documents":{"type":"boolean","required":false,"default":true,"description":"If false, returns only the index and relevance score without the document text. If true, returns the index, text, and relevance score."}}} +Example request: {"model":"jina-reranker-v2-base-multilingual","query":"Search query","documents":["Document to rank 1","Document to rank 2"]} +Example response: {"results":[{"index":0,"document":{"text":"Document to rank 1"},"relevance_score":0.9},{"index":1,"document":{"text":"Document to rank 2"},"relevance_score":0.8}],"usage":{"total_tokens":15,"prompt_tokens":15}} + +3. Reader API +Endpoint: https://r.jina.ai/ +Purpose: retrieve/parse content from URL in a format optimized for downstream tasks like LLMs and other applications +Best for: extracting structured content from web pages, suitable for generative models and search applications +Method: POST +Authorization: HTTPBearer +Headers: +- **Authorization**: Bearer +- **Content-Type**: application/json +- **Accept**: application/json +- **X-Timeout** (optional): Specifies the maximum time (in seconds) to wait for the webpage to load +- **X-Target-Selector** (optional): CSS selectors to focus on specific elements within the page +- **X-Wait-For-Selector** (optional): CSS selectors to wait for specific elements before returning +- **X-Remove-Selector** (optional): CSS selectors to exclude certain parts of the page (e.g., headers, footers) +- **X-With-Links-Summary** (optional): `true` to gather all links at the end of the response +- **X-With-Images-Summary** (optional): `true` to gather all images at the end of the response +- **X-With-Generated-Alt** (optional): `true` to add alt text to images lacking captions +- **X-No-Cache** (optional): `true` to bypass cache for fresh retrieval +- **X-With-Iframe** (optional): `true` to include iframe content in the response + +Request body schema: {"application/json":{"url":{"type":"string","required":true},"options":{"type":"string","default":"Default","options":["Default","Markdown","HTML","Text","Screenshot","Pageshot"]}}} +Example cURL request: ```curl -X POST 'https://r.jina.ai/' -H "Accept: application/json" -H "Authorization: Bearer ..." -H "Content-Type: application/json" -H "X-No-Cache: true" -H "X-Remove-Selector: header,.class,#id" -H "X-Target-Selector: body,.class,#id" -H "X-Timeout: 10" -H "X-Wait-For-Selector: body,.class,#id" -H "X-With-Generated-Alt: true" -H "X-With-Iframe: true" -H "X-With-Images-Summary: true" -H "X-With-Links-Summary: true" -d '{"url":"https://jina.ai"}'``` +Example response: {"code":200,"status":20000,"data":{"title":"Jina AI - Your Search Foundation, Supercharged.","description":"Best-in-class embeddings, rerankers, LLM-reader, web scraper, classifiers. The best search AI for multilingual and multimodal data.","url":"https://jina.ai/","content":"Jina AI - Your Search Foundation, Supercharged.\n===============\n","images":{"Image 1":"https://jina.ai/Jina%20-%20Dark.svg"},"links":{"Newsroom":"https://jina.ai/#newsroom","Contact sales":"https://jina.ai/contact-sales","Commercial License":"https://jina.ai/COMMERCIAL-LICENSE-TERMS.pdf","Security":"https://jina.ai/legal/#security","Terms & Conditions":"https://jina.ai/legal/#terms-and-conditions","Privacy":"https://jina.ai/legal/#privacy-policy"},"usage":{"tokens +Pay attention to the response format of the reader API, the actual content of the page will be available in `response["data"]["content"]`, and links / images (if using "X-With-Links-Summary: true" or "X-With-Images-Summary: true") will be available in `response["data"]["links"]` and `response["data"]["images"]`. + +4. Search API +Endpoint: https://s.jina.ai/ +Purpose: search the web for information and return results in a format optimized for downstream tasks like LLMs and other applications +Best for: customizable web search with results optimized for enterprise search systems and LLMs, with options for Markdown, HTML, JSON, text, and image outputs +Method: POST +Authorization: HTTPBearer +Headers: +- **Authorization**: Bearer +- **Content-Type**: application/json +- **Accept**: application/json +- **X-Site** (optional): Use "X-Site: " for in-site searches limited to the given domain +- **X-With-Links-Summary** (optional): "true" to gather all page links at the end +- **X-With-Images-Summary** (optional): "true" to gather all images at the end +- **X-No-Cache** (optional): "true" to bypass cache and retrieve real-time data +- **X-With-Generated-Alt** (optional): "true" to generate captions for images without alt tags + +Request body schema: {"application/json":{"q":{"type":"string","required":true},"options":{"type":"string","default":"Default","options":["Default","Markdown","HTML","Text","Screenshot","Pageshot"]}}} +Example request cURL request: ```curl -X POST 'https://s.jina.ai/' -H "Authorization: Bearer ..." -H "Content-Type: application/json" -H "Accept: application/json" -H "X-No-Cache: true" -H "X-Site: https://jina.ai" -d '{"q":"When was Jina AI founded?","options":"Markdown"}'``` +Example response: {"code":200,"status":20000,"data":[{"title":"Jina AI - Your Search Foundation, Supercharged.","description":"Our frontier models form the search foundation for high-quality enterprise search...","url":"https://jina.ai/","content":"Jina AI - Your Search Foundation, Supercharged...","usage":{"tokens":10475}},{"title":"Jina AI CEO, Founder, Key Executive Team, Board of Directors & Employees","description":"An open-source vector search engine that supports structured filtering...","url":"https://www.cbinsights.com/company/jina-ai/people","content":"Jina AI Management Team...","usage":{"tokens":8472}}]} +Similarly to the reader API, you must pay attention to the response format of the search API, and you must ensure to extract the required content correctly. + +5. Grounding API +Endpoint: https://g.jina.ai/ +Purpose: verify the factual accuracy of a given statement by cross-referencing it with sources from the internet +Best for: ideal for validating claims or facts by using verifiable sources, such as company websites or social media profiles +Method: POST +Authorization: HTTPBearer +Headers: +- **Authorization**: Bearer +- **Content-Type**: application/json +- **Accept**: application/json +- **X-Site** (optional): comma-separated list of URLs to serve as grounding references for verifying the statement (if not specified, all sources found on the internet will be used) +- **X-No-Cache** (optional): "true" to bypass cache and retrieve real-time data + +Request body schema: {"application/json":{"statement":{"type":"string","required":true,"description":"The statement to verify for factual accuracy"}}} +Example cURL request: ```curl -X POST 'https://g.jina.ai/' -H "Accept: application/json" -H "Authorization: Bearer ..." -H "Content-Type: application/json" -H "X-Site: https://jina.ai, https://linkedin.com" -d '{"statement":"Jina AI was founded in 2020 in Berlin."}'``` +Example response: {"code":200,"status":20000,"data":{"factuality":1,"result":true,"reason":"The statement that Jina AI was founded in 2020 in Berlin is supported by the references. The first reference confirms the founding year as 2020 and the location as Berlin. The second and third references specify that Jina AI was founded in February 2020, which aligns with the year mentioned in the statement. Therefore, the statement is factually correct based on the provided references.","references":[{"url":"https://es.linkedin.com/company/jinaai?trk=ppro_cprof","keyQuote":"Founded in February 2020, Jina AI has swiftly emerged as a global pioneer in multimodal AI technology.","isSupportive":true},{"url":"https://jina.ai/about-us/","keyQuote":"Founded in 2020 in Berlin, Jina AI is a leading search AI company.","isSupportive":true},{"url":"https://www.linkedin.com/company/jinaai","keyQuote":"Founded in February 2020, Jina AI has swiftly emerged as a global pioneer in multimodal AI technology.","isSupportive":true}],"usage":{"tokens":7620}}} + +6. Segmenter API +Endpoint: https://segment.jina.ai/ +Purpose: tokenizes text, divide text into chunks +Best for: counting number of tokens in text, segmenting text into manageable chunks (ideal for downstream applications like RAG) +Method: POST +Authorization: HTTPBearer +Headers: +- **Authorization**: Bearer +- **Content-Type**: application/json +- **Accept**: application/json + +Request body schema: {"application/json":{"content":{"type":"string","required":true,"description":"The text content to segment."},"tokenizer":{"type":"string","required":false,"default":"cl100k_base","enum":["cl100k_base","o200k_base","p50k_base","r50k_base","p50k_edit","gpt2"],"description":"Specifies the tokenizer to use."},"return_tokens":{"type":"boolean","required":false,"default":false,"description":"If true, includes tokens and their IDs in the response."},"return_chunks":{"type":"boolean","required":false,"default":false,"description":"If true, segments the text into semantic chunks."},"max_chunk_length":{"type":"integer","required":false,"default":1000,"description":"Maximum characters per chunk (only effective if 'return_chunks' is true)."},"head":{"type":"integer","required":false,"description":"Returns the first N tokens (exclusive with 'tail')."},"tail":{"type":"integer","required":false,"description":"Returns the last N tokens (exclusive with 'head')."}}} +Example cURL request: ```curl -X POST 'https://segment.jina.ai/' -H "Content-Type: application/json" -H "Authorization: Bearer ..." -d '{"content":"\n Jina AI: Your Search Foundation, Supercharged! 🚀\n Ihrer Suchgrundlage, aufgeladen! 🚀\n 您的搜索底座,从此不同!🚀\n 検索ベース,もう二度と同じことはありません!🚀\n","tokenizer":"cl100k_base","return_tokens":true,"return_chunks":true,"max_chunk_length":1000,"head":5}'``` +Example response: {"num_tokens":78,"tokenizer":"cl100k_base","usage":{"tokens":0},"num_chunks":4,"chunk_positions":[[3,55],[55,93],[93,110],[110,135]],"tokens":[[["J",[41]],["ina",[2259]],[" AI",[15592]],[":",[25]],[" Your",[4718]],[" Search",[7694]],[" Foundation",[5114]],[",",[11]],[" Super",[7445]],["charged",[38061]],["!",[0]],[" ",[11410]],["🚀",[248,222]],["\n",[198]],[" ",[256]]],[["I",[40]],["hr",[4171]],["er",[261]],[" Such",[15483]],["grund",[60885]],["lage",[56854]],[",",[11]],[" auf",[7367]],["gel",[29952]],["aden",[21825]],["!",[0]],[" ",[11410]],["🚀",[248,222]],["\n",[198]],[" ",[256]]],[["您",[88126]],["的",[9554]],["搜索",[80073]],["底",[11795,243]],["座",[11795,100]],[",",[3922]],["从",[46281]],["此",[33091]],["不",[16937]],["同",[42016]],["!",[6447]],["🚀",[9468,248,222]],["\n",[198]],[" ",[256]]],[["検",[162,97,250]],["索",[52084]],["ベ",[2845,247]],["ース",[61398]],[",",[11]],["も",[32977]],["う",[30297]],["二",[41920]],["度",[27479]],["と",[19732]],["同",[42016]],["じ",[100204]],["こ",[22957]],["と",[19732]],["は",[15682]],["あり",[57903]],["ま",[17129]],["せ",[72342]],["ん",[25827]],["!",[6447]],["🚀",[9468,248,222]],["\n",[198]]]],"chunks":["Jina AI: Your Search Foundation, Supercharged! 🚀\n ","Ihrer Suchgrundlage, aufgeladen! 🚀\n ","您的搜索底座,从此不同!🚀\n ","検索ベース,もう二度と同じことはありません!🚀\n"]} +Note: for the API to return chunks, you must specify `"return_chunks": true` as part of the request body. + +7. Classifier API +Endpoint: https://api.jina.ai/v1/classify +Purpose: zero-shot classification for text or images +Best for: text or image classification without training +Request body schema: {"application/json":{"model":{"type":"string","required":false,"description":"Identifier of the model to use. Required if classifier_id is not provided.","options":[{"name":"jina-clip-v1","size":"223M","dimensions":768},{"name":"jina-embeddings-v2-base-en","size":"137M","dimensions":768},{"name":"jina-embeddings-v2-base-es","size":"161M","dimensions":768},{"name":"jina-embeddings-v2-base-de","size":"161M","dimensions":768},{"name":"jina-embeddings-v2-base-fr","size":"161M","dimensions":768},{"name":"jina-embeddings-v2-base-code","size":"137M","dimensions":768},{"name":"jina-embeddings-v3","size":"570M","dimensions":1024}]},"classifier_id":{"type":"string","required":false,"description":"The identifier of the classifier. If not provided, a new classifier will be created."},"input":{"type":"array","required":true,"description":"Array of inputs for classification. Each entry can either be a text object {\"text\": \"your_text_here\"} or an image object {\"image\": \"base64_image_string\"}. You cannot mix text and image objects in the same request."},"labels":{"type":"array of strings","required":true,"description":"List of labels used for classification."}}} +Example request: {"model":"jina-clip-v1","input":[{"image":"base64_image_string"}],"labels":["category1","category2"]} +Example response: {"200":{"data":[{"index":0,"prediction":"category1","object":"classification","score":0.85}],"usage":{"total_tokens":10}},"422":{"detail":[{"message":"Validation error","field":"input"}]}} +Pay attention to the model used, when classifying images you must use `jina-clip-v1`, but when classifying text it is best to use `jina-embeddings-v3` (newest text embedding model from Jina)!!! + +**Note: all API's require authorization using the bearer token (get it from https://jina.ai/?sui=apikey)!** +Make sure that any code you generate uses a JINA_API_KEY variable, and remind the user to correctly set this variable before running the code! + +# Example solutions + +1. Basic search: +- For simple queries, use the search API with the given queries; +- For better relevancy, first use the search API to retrieve results, then use the reranker API to find the most relevant results; + +2. Classification tasks: +- To classify text snippets (multi-lingual texts), you can use the classification API with jina-embeddings-v3 model; +- To classify images, you can use the classification API with jina-clip-v1 model; + +3. Web content processing: +- To scrap a webpage, use the the reader API directly; +- To embed the contents of a webpage, first use the reader API to scrap the text contents of the webpage and then use the embeddings API; + +# Integration guidelines + +You should always: +- Handle API errors using try/catch blocks; +- Implement retries for network failures; +- Validate inputs before API calls; +- Pay attention to the response of each API and parse it to a usable state; + +You should not: +- Chain API's unnecessarily; +- Use reranker API without query-document pairs (reranker API needs a query as context to estimate relevancy); +- Directly use the response of an API without parsing it; + +# Limitations + +The Jina AI Search Foundation API's cannot perform any actions other than those already been mentioned. +This includes: +- Generating text or images; +- Modifying or editing content; +- Executing code or perform calculations; +- Storing or caching results permanently; + +# Tips for responding to user requests + +1. Start by analyzing the task and identifying which API's should be used; + +2. If multiple API's are required, outline the purpose of each API; + +3. Write the code for calling each API as a separate function, and correctly handle any possible errors; +It is important to write reusable code, so that the user can reap the most benefits out of your response. +```python +def read(url): +... + +def main(): +... +``` +Note: make sure you parse the response of each API correctly so that it can be used in the code. +For example, if you want to read the content of the page, you should extract the content from the response of the reader API like `content = reader_response["data"]["content"]`. +Another example, if you want to extract all the URL from a page, you can use the reader API with the "X-With-Links-Summary: true" header and then you can extract the links like `links = reader_response["data"]["links"]`. + +4. Finally, write the complete code, including input loading, calling the API functions, and saving/printing results; +Remember to use variables for required API keys, and point out to the user that they need to correctly set these variables. + +Approach your task step by step. diff --git a/prompts/twig/summary.twig b/prompts/examples/summary.twig similarity index 100% rename from prompts/twig/summary.twig rename to prompts/examples/summary.twig diff --git a/prompts/twig/summary_struct.twig b/prompts/examples/summary_struct.twig similarity index 100% rename from prompts/twig/summary_struct.twig rename to prompts/examples/summary_struct.twig diff --git a/hub.php b/scripts/hub.php similarity index 100% rename from hub.php rename to scripts/hub.php diff --git a/scripts/setup.php b/scripts/setup.php new file mode 100644 index 00000000..d57d3c8f --- /dev/null +++ b/scripts/setup.php @@ -0,0 +1,10 @@ +add(new PublishCommand()); +$application->run(); diff --git a/scripts/tell.php b/scripts/tell.php new file mode 100644 index 00000000..4bfc2de8 --- /dev/null +++ b/scripts/tell.php @@ -0,0 +1,10 @@ +add(new TellCommand()); +$application->run(); diff --git a/src-hub/Data/Example.php b/src-hub/Data/Example.php index 16427426..ed36fd62 100644 --- a/src-hub/Data/Example.php +++ b/src-hub/Data/Example.php @@ -1,105 +1,73 @@ -loadExample($baseDir, $path, $index); - } - - public function toNavigationItem() : NavigationItem { - return NavigationItem::fromString('cookbook' . $this->toDocPath()); - } - - public function toDocPath() : string { - return '/' . $this->tab . '/' . $this->group . '/' . $this->docName; - } - - // INTERNAL //////////////////////////////////////////////////////////////////// - - private function loadExample(string $baseDir, string $path, int $index = 0) : static { - [$group, $name] = explode('/', $path, 2); - - $document = YamlFrontMatter::parseFile($baseDir . $path . '/run.php'); - $content = $document->body(); - $title = $document->matter('title') ?: $this->getTitle($content); - $docName = $document->matter('docname') ?: Str::snake($name); - $hasTitle = !empty($title); - $mapping = [ - 'A01_Basics' => ['tab' => 'examples', 'name' => 'basics', 'title' => 'Basics'], - 'A02_Advanced' => ['tab' => 'examples', 'name' => 'advanced', 'title' => 'Advanced'], - 'A03_Troubleshooting' => ['tab' => 'examples', 'name' => 'troubleshooting', 'title' => 'Troubleshooting'], - 'A04_APISupport' => ['tab' => 'examples', 'name' => 'api_support', 'title' => 'LLM API Support'], - 'A05_Extras' => ['tab' => 'examples', 'name' => 'extras', 'title' => 'Extras'], - 'B01_ZeroShot' => ['tab' => 'prompting', 'name' => 'zero_shot', 'title' => 'Zero-Shot Prompting'], - 'B02_FewShot' => ['tab' => 'prompting', 'name' => 'few_shot', 'title' => 'Few-Shot Prompting'], - 'B03_ThoughtGen' => ['tab' => 'prompting', 'name' => 'thought_gen', 'title' => 'Thought Generation'], - 'B04_Ensembling' => ['tab' => 'prompting', 'name' => 'ensembling', 'title' => 'Ensembling'], - 'B05_SelfCriticism' => ['tab' => 'prompting', 'name' => 'self_criticism', 'title' => 'Self-Criticism'], - 'B06_Decomposition' => ['tab' => 'prompting', 'name' => 'decomposition', 'title' => 'Decomposition'], - 'B07_Misc' => ['tab' => 'prompting', 'name' => 'misc', 'title' => 'Miscellaneous'], - ]; - - $tab = $mapping[$group]['tab']; - return new Example( - index: $index, - tab: $tab, - group: $mapping[$group]['name'], - groupTitle: $mapping[$group]['title'], - name: $name, - hasTitle: $hasTitle, - title: $title, - docName: $docName, - content: $content, - directory: $baseDir . $path, - relativePath: './' . $tab . '/' . $path . '/run.php', - runPath: $baseDir . $path . '/run.php', - ); - } - - private function getTitle(string $content) : string { - $header = $this->findMdH1Line($content); - return $this->cleanStr($header, 60); - } - - private function cleanStr(string $input, int $limit) : string { - // remove any \n, \r, PHP tags, md hashes - $output = str_replace(array("\n", "\r", '', '#'), array(' ', '', '', '', ''), $input); - // remove leading and trailing spaces - $output = trim($output); - // remove double spaces - $output = preg_replace('/\s+/', ' ', $output); - // remove any ANSI codes - $output = preg_replace('/\e\[[\d;]*m/', '', $output); - return substr(trim($output), 0, $limit); - } - - private function findMdH1Line(string $input) : string { - $lines = explode("\n", $input); - foreach ($lines as $line) { - if (substr($line, 0, 2) === '# ') { - return $line; - } - } - return ''; - } -} +toDocPath()); + } + + public function toDocPath() : string { + return '/' . $this->tab . '/' . $this->group . '/' . $this->docName; + } + + // INTERNAL //////////////////////////////////////////////////////////////////// + + private static function loadExample(string $baseDir, string $path, int $index = 0) : static { + [$group, $name] = explode('/', $path, 2); + + $info = ExampleInfo::fromFile($baseDir . $path . '/run.php', $name); + + $mapping = [ + 'A01_Basics' => ['tab' => 'examples', 'name' => 'basics', 'title' => 'Basics'], + 'A02_Advanced' => ['tab' => 'examples', 'name' => 'advanced', 'title' => 'Advanced'], + 'A03_Troubleshooting' => ['tab' => 'examples', 'name' => 'troubleshooting', 'title' => 'Troubleshooting'], + 'A04_APISupport' => ['tab' => 'examples', 'name' => 'api_support', 'title' => 'LLM API Support'], + 'A05_Extras' => ['tab' => 'examples', 'name' => 'extras', 'title' => 'Extras'], + 'B01_ZeroShot' => ['tab' => 'prompting', 'name' => 'zero_shot', 'title' => 'Zero-Shot Prompting'], + 'B02_FewShot' => ['tab' => 'prompting', 'name' => 'few_shot', 'title' => 'Few-Shot Prompting'], + 'B03_ThoughtGen' => ['tab' => 'prompting', 'name' => 'thought_gen', 'title' => 'Thought Generation'], + 'B04_Ensembling' => ['tab' => 'prompting', 'name' => 'ensembling', 'title' => 'Ensembling'], + 'B05_SelfCriticism' => ['tab' => 'prompting', 'name' => 'self_criticism', 'title' => 'Self-Criticism'], + 'B06_Decomposition' => ['tab' => 'prompting', 'name' => 'decomposition', 'title' => 'Decomposition'], + 'B07_Misc' => ['tab' => 'prompting', 'name' => 'misc', 'title' => 'Miscellaneous'], + ]; + + $tab = $mapping[$group]['tab']; + return new Example( + index: $index, + tab: $tab, + group: $mapping[$group]['name'], + groupTitle: $mapping[$group]['title'], + name: $name, + hasTitle: $info->hasTitle(), + title: $info->title, + docName: $info->docName, + content: $info->content, + directory: $baseDir . $path, + relativePath: './' . $tab . '/' . $path . '/run.php', + runPath: $baseDir . $path . '/run.php', + ); + } +} diff --git a/src-hub/Data/ExampleInfo.php b/src-hub/Data/ExampleInfo.php new file mode 100644 index 00000000..716e9425 --- /dev/null +++ b/src-hub/Data/ExampleInfo.php @@ -0,0 +1,68 @@ +title !== ''; + } + + // INTERNAL //////////////////////////////////////////////////////////////////// + + private static function yamlFrontMatter(string $path) : array { + $content = file_get_contents($path); + $document = FrontMatter::createYaml()->parse($content); + $content = $document->getContent(); + $data = $document->getData(); + return [$content, $data]; + } + + private static function getTitle(string $content) : string { + $header = self::findMdH1Line($content); + return self::cleanStr($header, 60); + } + + private static function cleanStr(string $input, int $limit) : string { + // remove any \n, \r, PHP tags, md hashes + $output = str_replace(["\n", "\r", '', '#'], [' ', '', '', '', ''], $input); + // remove leading and trailing spaces + $output = trim($output); + // remove double spaces + $output = preg_replace('/\s+/', ' ', $output); + // remove any ANSI codes + $output = preg_replace('/\e\[[\d;]*m/', '', $output); + return substr(trim($output), 0, $limit); + } + + private static function findMdH1Line(string $input) : string { + $lines = explode("\n", $input); + foreach ($lines as $line) { + if (substr($line, 0, 2) === '# ') { + return $line; + } + } + return ''; + } +} diff --git a/src-hub/Services/DocGenerator.php b/src-hub/Services/DocGenerator.php index 30c2f4b2..5c617253 100644 --- a/src-hub/Services/DocGenerator.php +++ b/src-hub/Services/DocGenerator.php @@ -1,187 +1,187 @@ -view = new DocGenView; - } - - public function makeDocs(bool $refresh = false) : void { - // check if hub docs directory exists - if (!is_dir($this->hubDocsDir)) { - throw new \Exception("Hub docs directory '$this->hubDocsDir' does not exist"); - } - $this->view->renderHeader(); - $list = $this->examples->forEachExample(function(Example $example) use ($refresh) { - $this->view->renderFile($example); - if ($refresh) { - $success = $this->replaceAll($example); - } else { - $success = $this->replaceNew($example); - } - $this->view->renderResult($success); - if (!$success) { - throw new \Exception("Failed to copy or replace example: {$example->name}"); - } - return true; - }); - $success = $this->updateIndex($list); - $this->view->renderUpdate($success); - if (!$success) { - throw new \Exception('Failed to update hub docs index'); - } - } - - public function clearDocs() : void { - $this->view->renderHeader(); - $list = $this->examples->forEachExample(function(Example $example) { - $this->view->renderFile($example); - $success = $this->remove($example); - $this->view->renderResult($success); - if (!$success) { - throw new \Exception("Failed to remove example: {$example->name}"); - } - return true; - }); - $success = $this->updateIndex($list); - $this->view->renderUpdate($success); - if (!$success) { - throw new \Exception('Failed to update hub docs index'); - } - } - - private function replaceAll(Example $example) : bool { - // make target md filename - replace .php with .md, - $newFileName = Str::snake($example->name).'.md'; - $subdir = Str::snake(substr($example->group, 3)); - $targetPath = $this->hubDocsDir . '/' . $subdir . '/' .$newFileName; - // copy example file to docs - if (file_exists($targetPath)) { - unlink($targetPath); - } - $this->view->renderNew(); - return $this->copy($example->runPath, $targetPath); - } - - private function remove(Example $example) : bool { - // make target md filename - replace .php with .md, - $newFileName = Str::snake($example->name).'.mdx'; - $subdir = Str::snake(substr($example->group, 3)); - $targetPath = $this->hubDocsDir . '/' . $subdir . '/' .$newFileName; - // remove example file from docs - if (!file_exists($targetPath)) { - return false; - } - //unlink($targetPath); - echo "unlink $targetPath\n"; - return true; - } - - private function replaceNew(Example $example) : bool { - // make target md filename - replace .php with .md, - $subdir = Str::snake(substr($example->group, 3)); - $newFileName = Str::snake($example->name).'.mdx'; - $targetPath = $this->hubDocsDir . '/' . $subdir . '/' .$newFileName; - // copy example file to docs - if (file_exists($targetPath)) { - // compare update dates of $targetPath and $example->runPath - $targetDate = filemtime($targetPath); - $exampleDate = filemtime($example->runPath); - if ($exampleDate > $targetDate) { - // if the file already exists, replace it - unlink($targetPath); - } - $this->view->renderExists($exampleDate > $targetDate); - return true; - } - $this->view->renderNew(); - return $this->copy($example->runPath, $targetPath); - } - - private function updateIndex(array $list) : bool { - $yamlLines = []; - $subgroup = ''; - foreach ($list as $example) { - $groupTitle = Str::title(substr($example->group, 3)); - $subdir = Str::snake(substr($example->group, 3)); - if ($subgroup !== $subdir) { - $yamlLines[] = " - $groupTitle:"; - $subgroup = $subdir; - } - $fileName = Str::snake($example->name).'.md'; - $title = $example->hasTitle ? $example->title : Str::title($example->name); - $yamlLines[] = " - $title: 'hub/$subdir/$fileName'"; - } - if (empty($yamlLines)) { - throw new \Exception('No examples found'); - } - $this->modifyHubIndex($yamlLines); - return true; - } - - private function copy(string $source, string $destination) : bool { - // if destination does not exist, create it - $destDir = dirname($destination); - if (!is_dir($destDir) && !mkdir($destDir, 0777, true) && !is_dir($destDir)) { - throw new \RuntimeException(sprintf('Directory "%s" was not created', $destDir)); - } - return copy($source, $destination); - } - - private function modifyHubIndex(array $indexLines) : bool { - // get the content of the hub index - $indexContent = file_get_contents($this->mkDocsFile); - if ($indexContent === false) { - throw new \Exception("Failed to read hub index file"); - } - - if (!Str::contains($indexContent, $this->sectionStartMarker)) { - throw new \Exception("Section start marker not found in hub index"); - } - if (!Str::contains($indexContent, $this->sectionEndMarker)) { - throw new \Exception("Section end marker not found in hub index"); - } - - // find the start and end markers - $lines = explode("\n", $indexContent); - $preHubLines = []; - $postHubLines = []; - $hubSectionFound = false; - $inHubSection = false; - foreach ($lines as $line) { - if (!$hubSectionFound) { - if (Str::contains($line, $this->sectionStartMarker)) { - $hubSectionFound = true; - $inHubSection = true; - $preHubLines[] = $line; - } else { - $preHubLines[] = $line; - } - } elseif ($inHubSection) { - if (Str::contains($line, $this->sectionEndMarker)) { - $postHubLines[] = $line; - $inHubSection = false; - } - } else { - $postHubLines[] = $line; - } - } - $outputLines = array_merge($preHubLines, $indexLines, $postHubLines); - $output = implode("\n", $outputLines); - // write the new content to the hub index - return (bool) file_put_contents($this->mkDocsFile, $output); - } -} +view = new DocGenView; + } + + public function makeDocs(bool $refresh = false) : void { + // check if hub docs directory exists + if (!is_dir($this->hubDocsDir)) { + throw new \Exception("Hub docs directory '$this->hubDocsDir' does not exist"); + } + $this->view->renderHeader(); + $list = $this->examples->forEachExample(function(Example $example) use ($refresh) { + $this->view->renderFile($example); + if ($refresh) { + $success = $this->replaceAll($example); + } else { + $success = $this->replaceNew($example); + } + $this->view->renderResult($success); + if (!$success) { + throw new \Exception("Failed to copy or replace example: {$example->name}"); + } + return true; + }); + $success = $this->updateIndex($list); + $this->view->renderUpdate($success); + if (!$success) { + throw new \Exception('Failed to update hub docs index'); + } + } + + public function clearDocs() : void { + $this->view->renderHeader(); + $list = $this->examples->forEachExample(function(Example $example) { + $this->view->renderFile($example); + $success = $this->remove($example); + $this->view->renderResult($success); + if (!$success) { + throw new \Exception("Failed to remove example: {$example->name}"); + } + return true; + }); + $success = $this->updateIndex($list); + $this->view->renderUpdate($success); + if (!$success) { + throw new \Exception('Failed to update hub docs index'); + } + } + + private function replaceAll(Example $example) : bool { + // make target md filename - replace .php with .md, + $newFileName = Str::snake($example->name).'.md'; + $subdir = Str::snake(substr($example->group, 3)); + $targetPath = $this->hubDocsDir . '/' . $subdir . '/' .$newFileName; + // copy example file to docs + if (file_exists($targetPath)) { + unlink($targetPath); + } + $this->view->renderNew(); + return $this->copy($example->runPath, $targetPath); + } + + private function remove(Example $example) : bool { + // make target md filename - replace .php with .md, + $newFileName = Str::snake($example->name).'.mdx'; + $subdir = Str::snake(substr($example->group, 3)); + $targetPath = $this->hubDocsDir . '/' . $subdir . '/' .$newFileName; + // remove example file from docs + if (!file_exists($targetPath)) { + return false; + } + //unlink($targetPath); + echo "unlink $targetPath\n"; + return true; + } + + private function replaceNew(Example $example) : bool { + // make target md filename - replace .php with .md, + $subdir = Str::snake(substr($example->group, 3)); + $newFileName = Str::snake($example->name).'.mdx'; + $targetPath = $this->hubDocsDir . '/' . $subdir . '/' .$newFileName; + // copy example file to docs + if (file_exists($targetPath)) { + // compare update dates of $targetPath and $example->runPath + $targetDate = filemtime($targetPath); + $exampleDate = filemtime($example->runPath); + if ($exampleDate > $targetDate) { + // if the file already exists, replace it + unlink($targetPath); + } + $this->view->renderExists($exampleDate > $targetDate); + return true; + } + $this->view->renderNew(); + return $this->copy($example->runPath, $targetPath); + } + + private function updateIndex(array $list) : bool { + $yamlLines = []; + $subgroup = ''; + foreach ($list as $example) { + $groupTitle = Str::title(substr($example->group, 3)); + $subdir = Str::snake(substr($example->group, 3)); + if ($subgroup !== $subdir) { + $yamlLines[] = " - $groupTitle:"; + $subgroup = $subdir; + } + $fileName = Str::snake($example->name).'.md'; + $title = $example->hasTitle ? $example->title : Str::title($example->name); + $yamlLines[] = " - $title: 'hub/$subdir/$fileName'"; + } + if (empty($yamlLines)) { + throw new \Exception('No examples found'); + } + $this->modifyHubIndex($yamlLines); + return true; + } + + private function copy(string $source, string $destination) : bool { + // if destination does not exist, create it + $destDir = dirname($destination); + if (!is_dir($destDir) && !mkdir($destDir, 0777, true) && !is_dir($destDir)) { + throw new \RuntimeException(sprintf('Directory "%s" was not created', $destDir)); + } + return copy($source, $destination); + } + + private function modifyHubIndex(array $indexLines) : bool { + // get the content of the hub index + $indexContent = file_get_contents($this->mkDocsFile); + if ($indexContent === false) { + throw new \Exception("Failed to read hub index file"); + } + + if (!Str::contains($indexContent, $this->sectionStartMarker)) { + throw new \Exception("Section start marker not found in hub index"); + } + if (!Str::contains($indexContent, $this->sectionEndMarker)) { + throw new \Exception("Section end marker not found in hub index"); + } + + // find the start and end markers + $lines = explode("\n", $indexContent); + $preHubLines = []; + $postHubLines = []; + $hubSectionFound = false; + $inHubSection = false; + foreach ($lines as $line) { + if (!$hubSectionFound) { + if (Str::contains($line, $this->sectionStartMarker)) { + $hubSectionFound = true; + $inHubSection = true; + $preHubLines[] = $line; + } else { + $preHubLines[] = $line; + } + } elseif ($inHubSection) { + if (Str::contains($line, $this->sectionEndMarker)) { + $postHubLines[] = $line; + $inHubSection = false; + } + } else { + $postHubLines[] = $line; + } + } + $outputLines = array_merge($preHubLines, $indexLines, $postHubLines); + $output = implode("\n", $outputLines); + // write the new content to the hub index + return (bool) file_put_contents($this->mkDocsFile, $output); + } +} diff --git a/src-hub/Services/MintlifyDocGenerator.php b/src-hub/Services/MintlifyDocGenerator.php index 6e93253b..a8a8d054 100644 --- a/src-hub/Services/MintlifyDocGenerator.php +++ b/src-hub/Services/MintlifyDocGenerator.php @@ -1,111 +1,111 @@ -view = new DocGenView; - } - - public function makeDocs() : void { - if (!is_dir($this->mintlifyCookbookDir)) { - throw new \Exception("Hub docs directory '$this->mintlifyCookbookDir' does not exist"); - } - $this->view->renderHeader(); - $this->updateFiles(); - $this->view->renderUpdate(true); - } - - public function clearDocs() : void { - $this->view->renderHeader(); - // get only subdirectories of mintlifyCookbookDir - $subdirs = array_filter(glob($this->mintlifyCookbookDir . '/*'), 'is_dir'); - foreach ($subdirs as $subdir) { - $this->removeDir($subdir); - } - $this->view->renderUpdate(true); - } - - private function updateFiles() : void { - //$this->removeDir($this->mintlifyCookbookDir . '/examples'); - $groups = $this->examples->getExampleGroups(); - foreach ($groups as $group) { - foreach ($group->examples as $example) { - $this->view->renderFile($example); - $targetFilePath = $this->mintlifyCookbookDir . $example->toDocPath() . '.mdx'; - - // get last update date of source file - $sourceFileLastUpdate = filemtime($example->runPath); - // get last update date of target file - $targetFile = $this->mintlifyCookbookDir . $example->toDocPath() . '.mdx'; - $targetFileExists = file_exists($targetFile); - - if ($targetFileExists) { - $targetFileLastUpdate = filemtime($targetFile); - // if source file is older than target file, skip - if ($sourceFileLastUpdate > $targetFileLastUpdate) { - // remove target file - unlink($targetFile); - $this->copy($example->runPath, $targetFilePath); - $this->view->renderExists(true); - } else { - $this->view->renderExists(false); - } - } else { - $this->copy($example->runPath, $targetFilePath); - $this->view->renderNew(); - } - $this->view->renderResult(true); - } - } - // make backup copy of mint.json - //$currentDateTime = date('Y-m-d_H-i-s'); - //$this->copy($this->mintlifyIndexFile, $this->mintlifyIndexFile . '_' . $currentDateTime); - // update mint.json - $this->updateHubIndex($groups); - $this->view->renderResult(true); - } - - private function updateHubIndex(array $exampleGroups) : bool { - // get the content of the hub index - $index = Index::fromFile($this->mintlifyIndexFile); - if ($index === false) { - throw new \Exception("Failed to read hub index file"); - } - $index->navigation->removeGroups($this->dynamicGroups); - foreach ($exampleGroups as $exampleGroup) { - $index->navigation->appendGroup($exampleGroup->toNavigationGroup()); - } - return $index->saveFile($this->mintlifyIndexFile); - } - - // INTERNAL //////////////////////////////////////////////////////////////// - - private function removeDir(string $path) : void { - $files = glob($path . '/*'); - foreach ($files as $file) { - is_dir($file) ? $this->removeDir($file) : unlink($file); - } - rmdir($path); - } - - private function copy(string $source, string $destination) : void { - // if destination does not exist, create it - $destDir = dirname($destination); - if (!is_dir($destDir) && !mkdir($destDir, 0777, true) && !is_dir($destDir)) { - throw new \RuntimeException(sprintf('Directory "%s" was not created', $destDir)); - } - copy($source, $destination); - } -} +view = new DocGenView; + } + + public function makeDocs() : void { + if (!is_dir($this->mintlifyCookbookDir)) { + throw new \Exception("Hub docs directory '$this->mintlifyCookbookDir' does not exist"); + } + $this->view->renderHeader(); + $this->updateFiles(); + $this->view->renderUpdate(true); + } + + public function clearDocs() : void { + $this->view->renderHeader(); + // get only subdirectories of mintlifyCookbookDir + $subdirs = array_filter(glob($this->mintlifyCookbookDir . '/*'), 'is_dir'); + foreach ($subdirs as $subdir) { + $this->removeDir($subdir); + } + $this->view->renderUpdate(true); + } + + private function updateFiles() : void { + //$this->removeDir($this->mintlifyCookbookDir . '/examples'); + $groups = $this->examples->getExampleGroups(); + foreach ($groups as $group) { + foreach ($group->examples as $example) { + $this->view->renderFile($example); + $targetFilePath = $this->mintlifyCookbookDir . $example->toDocPath() . '.mdx'; + + // get last update date of source file + $sourceFileLastUpdate = filemtime($example->runPath); + // get last update date of target file + $targetFile = $this->mintlifyCookbookDir . $example->toDocPath() . '.mdx'; + $targetFileExists = file_exists($targetFile); + + if ($targetFileExists) { + $targetFileLastUpdate = filemtime($targetFile); + // if source file is older than target file, skip + if ($sourceFileLastUpdate > $targetFileLastUpdate) { + // remove target file + unlink($targetFile); + $this->copy($example->runPath, $targetFilePath); + $this->view->renderExists(true); + } else { + $this->view->renderExists(false); + } + } else { + $this->copy($example->runPath, $targetFilePath); + $this->view->renderNew(); + } + $this->view->renderResult(true); + } + } + // make backup copy of mint.json + //$currentDateTime = date('Y-m-d_H-i-s'); + //$this->copy($this->mintlifyIndexFile, $this->mintlifyIndexFile . '_' . $currentDateTime); + // update mint.json + $this->updateHubIndex($groups); + $this->view->renderResult(true); + } + + private function updateHubIndex(array $exampleGroups) : bool { + // get the content of the hub index + $index = MintlifyIndex::fromFile($this->mintlifyIndexFile); + if ($index === false) { + throw new \Exception("Failed to read hub index file"); + } + $index->navigation->removeGroups($this->dynamicGroups); + foreach ($exampleGroups as $exampleGroup) { + $index->navigation->appendGroup($exampleGroup->toNavigationGroup()); + } + return $index->saveFile($this->mintlifyIndexFile); + } + + // INTERNAL //////////////////////////////////////////////////////////////// + + private function removeDir(string $path) : void { + $files = glob($path . '/*'); + foreach ($files as $file) { + is_dir($file) ? $this->removeDir($file) : unlink($file); + } + rmdir($path); + } + + private function copy(string $source, string $destination) : void { + // if destination does not exist, create it + $destDir = dirname($destination); + if (!is_dir($destDir) && !mkdir($destDir, 0777, true) && !is_dir($destDir)) { + throw new \RuntimeException(sprintf('Directory "%s" was not created', $destDir)); + } + copy($source, $destination); + } +} diff --git a/src-hub/Utils/Mintlify/MdxFile.php b/src-hub/Utils/Mintlify/MdxFile.php index 066f0a24..2484b030 100644 --- a/src-hub/Utils/Mintlify/MdxFile.php +++ b/src-hub/Utils/Mintlify/MdxFile.php @@ -1,64 +1,86 @@ -title !== ''; - } - - public function title() : string { - return $this->title; - } - - public function body() : string { - return $this->document->body(); - } - - public function path() : string { - return $this->fullPath; - } - - public function basePath() : string { - return $this->basePath; - } - - public function fileName() : string { - return $this->fileName; - } - - public function extension() : string { - return $this->extension; - } - - public static function fromFile(string $path) : MdxFile { - $mdxFile = new MdxFile(); - $mdxFile->fullPath = $path; - $mdxFile->basePath = dirname($path); - $mdxFile->fileName = basename($path); - $mdxFile->extension = pathinfo($path, PATHINFO_EXTENSION); - $mdxFile->document = YamlFrontMatter::parseFile($path); - $mdxFile->title = $mdxFile->document->matter('title'); - return $mdxFile; - } - - public static function fromString(string $content) : MdxFile { - $mdxFile = new MdxFile(); - $mdxFile->document = YamlFrontMatter::parse($content); - $mdxFile->title = $mdxFile->document->matter('title'); - return $mdxFile; - } -} +title !== ''; + } + + public function title() : string { + return $this->title; + } + + public function content() : string { + return $this->content; + } + + public function path() : string { + return $this->fullPath; + } + + public function basePath() : string { + return $this->basePath; + } + + public function fileName() : string { + return $this->fileName; + } + + public function extension() : string { + return $this->extension; + } + + public static function fromFile(string $path) : MdxFile { + if (!file_exists($path)) { + throw new \Exception("Failed to Mintlify index file"); + } + [$content, $data] = self::yamlFrontMatterFromFile($path); + return new MdxFile( + title: $data['title'] ?? '', + content: $content, + fullPath: $path, + basePath: dirname($path), + fileName: basename($path), + extension: pathinfo($path, PATHINFO_EXTENSION), + ); + } + + public static function fromString(string $content) : MdxFile { + [$content, $data] = self::yamlFrontMatterFromString($content); + return new MdxFile( + title: $data['title'] ?? '', + content: $content, + fullPath: '', + basePath: '', + fileName: '', + extension: '', + ); + } + + private static function yamlFrontMatterFromFile(string $path) : array { + $content = file_get_contents($path); + return self::yamlFrontMatterFromString($content); + } + + private static function yamlFrontMatterFromString(string $fileContent) : array { + $document = FrontMatter::createYaml()->parse($fileContent); + $content = $document->getContent(); + $data = $document->getData(); + return [$content, $data]; + } +} diff --git a/src-hub/Utils/Mintlify/Index.php b/src-hub/Utils/Mintlify/MintlifyIndex.php similarity index 96% rename from src-hub/Utils/Mintlify/Index.php rename to src-hub/Utils/Mintlify/MintlifyIndex.php index 2d03fa2b..7556c7e7 100644 --- a/src-hub/Utils/Mintlify/Index.php +++ b/src-hub/Utils/Mintlify/MintlifyIndex.php @@ -1,69 +1,69 @@ -toArray(), JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); - return file_put_contents($path, $json); - } - - public static function fromJson(array $data) : static { - $index = new static(); - $index->name = $data['name'] ?? ''; - $index->logo = $data['logo'] ?? []; - $index->favicon = $data['favicon'] ?? ''; - $index->colors = $data['colors'] ?? []; - $index->topbarLinks = $data['topbarLinks'] ?? []; - $index->topbarCtaButton = $data['topbarCtaButton'] ?? []; - $index->primaryTab = $data['primaryTab'] ?? []; - $index->tabs = $data['tabs'] ?? []; - $index->anchors = $data['anchors'] ?? []; - $index->navigation = Navigation::fromArray($data['navigation'] ?? []); - $index->footerSocials = $data['footerSocials'] ?? []; - $index->analytics = $data['analytics'] ?? []; - return $index; - } - - public function toArray() : array { - return array_filter([ - 'name' => $this->name, - 'logo' => $this->logo, - 'favicon' => $this->favicon, - 'colors' => $this->colors, - 'topbarLinks' => $this->topbarLinks, - 'topbarCtaButton' => $this->topbarCtaButton, - 'primaryTab' => $this->primaryTab, - 'tabs' => $this->tabs, - 'anchors' => $this->anchors, - 'navigation' => array_values($this->navigation->toArray()), - 'footerSocials' => $this->footerSocials, - 'analytics' => $this->analytics, - ], fn($v) => !empty($v)); - } -} +toArray(), JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + return file_put_contents($path, $json); + } + + public static function fromJson(array $data) : static { + $index = new static(); + $index->name = $data['name'] ?? ''; + $index->logo = $data['logo'] ?? []; + $index->favicon = $data['favicon'] ?? ''; + $index->colors = $data['colors'] ?? []; + $index->topbarLinks = $data['topbarLinks'] ?? []; + $index->topbarCtaButton = $data['topbarCtaButton'] ?? []; + $index->primaryTab = $data['primaryTab'] ?? []; + $index->tabs = $data['tabs'] ?? []; + $index->anchors = $data['anchors'] ?? []; + $index->navigation = Navigation::fromArray($data['navigation'] ?? []); + $index->footerSocials = $data['footerSocials'] ?? []; + $index->analytics = $data['analytics'] ?? []; + return $index; + } + + public function toArray() : array { + return array_filter([ + 'name' => $this->name, + 'logo' => $this->logo, + 'favicon' => $this->favicon, + 'colors' => $this->colors, + 'topbarLinks' => $this->topbarLinks, + 'topbarCtaButton' => $this->topbarCtaButton, + 'primaryTab' => $this->primaryTab, + 'tabs' => $this->tabs, + 'anchors' => $this->anchors, + 'navigation' => array_values($this->navigation->toArray()), + 'footerSocials' => $this->footerSocials, + 'analytics' => $this->analytics, + ], fn($v) => !empty($v)); + } +} diff --git a/src-setup/Assets/ConfigurationsDirAsset.php b/src-setup/Assets/ConfigurationsDirAsset.php new file mode 100644 index 00000000..0316ad7a --- /dev/null +++ b/src-setup/Assets/ConfigurationsDirAsset.php @@ -0,0 +1,71 @@ +name = 'config'; + $this->description = 'Configuration files for the Instructor library'; + $this->sourcePath = Path::resolve($source); + $this->destinationPath = Path::resolve($dest); + + $this->filesystem = $filesystem; + $this->output = $output; + } + + public function publish(): bool { + if ($this->filesystem->exists($this->destinationPath)) { + $this->output->out( + "Skipped publishing configurations: Directory already exists at {$this->destinationPath}. Please merge the configuration files manually.", + 'warning' + ); + return false; + } + + // Attempt to create the directory + $created = $this->filesystem->createDirectory($this->destinationPath); + if ($created === Filesystem::RESULT_ERROR) { + $this->output->out( + "Failed to create configurations directory at {$this->destinationPath}.", + 'error' + ); + return false; + } + + // Proceed to copy the directory + $result = $this->filesystem->copyDir($this->sourcePath, $this->destinationPath); + if ($result === Filesystem::RESULT_NOOP) { + $this->output->out("Would publish configurations:\n from {$this->sourcePath}\n to {$this->destinationPath}"); + return true; + } + + if ($this->filesystem->exists($this->destinationPath)) { + $this->output->out("Published configurations from {$this->sourcePath} to {$this->destinationPath}"); + return true; + } + + $this->output->out( + "Failed to publish configurations from {$this->sourcePath} to {$this->destinationPath}.", + 'error' + ); + return false; + } +} diff --git a/src-setup/Assets/EnvFileAsset.php b/src-setup/Assets/EnvFileAsset.php new file mode 100644 index 00000000..730f81d2 --- /dev/null +++ b/src-setup/Assets/EnvFileAsset.php @@ -0,0 +1,60 @@ +name = 'env'; + $this->description = 'Environment configuration file (.env)'; + $this->sourcePath = Path::resolve($source); + $this->destinationPath = Path::resolve($dest); + $this->configDir = Path::resolve($configDir); + + $this->envFile = $envFile; + $this->filesystem = $filesystem; + $this->output = $output; + } + + public function publish(): bool { + // copy from .env-dist to .env if .env does not exist + if (!$this->filesystem->exists($this->destinationPath)) { + $result = $this->filesystem->copyFile($this->sourcePath, $this->destinationPath); + if ($result === Filesystem::RESULT_NOOP) { + $this->output->out("Would copy & update env file:\n from {$this->sourcePath}\n to {$this->destinationPath}"); + } elseif ($this->filesystem->exists($this->destinationPath)) { + $this->output->out("Published env file from {$this->sourcePath} to {$this->destinationPath}"); + } + } + + // merge variable values into existing env file - needed to add INSTRUCTOR_CONFIG_PATH + $this->envFile->mergeEnvFiles($this->sourcePath, $this->destinationPath, $this->configDir); + if ($this->filesystem->exists($this->destinationPath)) { + //$this->output->out("Merged env files into {$this->destinationPath}"); + return true; + } + return false; + } +} diff --git a/src-setup/Assets/PromptsDirAsset.php b/src-setup/Assets/PromptsDirAsset.php new file mode 100644 index 00000000..20d6f3ce --- /dev/null +++ b/src-setup/Assets/PromptsDirAsset.php @@ -0,0 +1,75 @@ +name = 'prompts'; + $this->description = 'Prompt templates used by the Instructor library'; + $this->sourcePath = Path::resolve($source); + $this->destinationPath = Path::resolve($dest); + + $this->filesystem = $filesystem; + $this->output = $output; + } + + public function publish(): bool { + if ($this->filesystem->exists($this->destinationPath)) { + $this->output->out( + "Skipped publishing prompts: Directory already exists at {$this->destinationPath}. Please merge the prompt files manually.", + 'warning' + ); + return false; + } + + // Attempt to create the directory + $created = $this->filesystem->createDirectory($this->destinationPath); + if ($created === Filesystem::RESULT_ERROR) { + $this->output->out( + "Failed to create prompts directory at {$this->destinationPath}.", + 'error' + ); + return false; + } + + // Proceed to copy the directory + $result = $this->filesystem->copyDir($this->sourcePath, $this->destinationPath); + if ($result === Filesystem::RESULT_NOOP) { + $this->output->out("Would publish prompts:\n from {$this->sourcePath}\n to {$this->destinationPath}"); + return true; + } + + if ($this->filesystem->exists($this->destinationPath)) { + $this->output->out("Published prompts from {$this->sourcePath} to {$this->destinationPath}"); + return true; + } + + $this->output->out( + "Failed to publish prompts from {$this->sourcePath} to {$this->destinationPath}.", + 'error' + ); + return false; + } +} diff --git a/src-setup/Contracts/Publishable.php b/src-setup/Contracts/Publishable.php new file mode 100644 index 00000000..9bb00eac --- /dev/null +++ b/src-setup/Contracts/Publishable.php @@ -0,0 +1,8 @@ +noOp) { + return $this->processNoOpMerge($configDir); + } + + try { + $mergedContent = $this->prepareMergedContent($source, $dest, $configDir); + + if ($mergedContent === null) { + $this->output->out("No new variables to merge"); + return self::RESULT_OK; + } + + $this->filesystem->writeFile($dest, $mergedContent); + $this->output->out("Merged env files into:\n $dest"); + return self::RESULT_OK; + + } catch (\Exception $e) { + $this->output->out("Failed to merge env files: " . $e->getMessage(), 'error'); + return self::RESULT_ERROR; + } + } + + /** + * Prepares merged content for the destination file. + */ + private function prepareMergedContent(string $source, string $dest, string $configDir): ?string + { + $sourceVars = $this->parseEnvFile($source); + $destContent = $this->filesystem->exists($dest) ? $this->filesystem->readFile($dest) : ''; + $destVars = $this->parseEnvFile($dest); + + $newVars = $this->getNewVariables($sourceVars, $destVars, $configDir); + + if (empty($newVars)) { + return null; + } + + return $this->updateEnvContent($destContent, $newVars); + } + + /** + * Updates the environment file content by replacing or adding new variables. + */ + private function updateEnvContent(string $destContent, array $newVars): string + { + $lines = explode("\n", $destContent); + $updated = false; + + foreach ($lines as &$line) { + foreach ($newVars as $key => $value) { + if (str_starts_with(trim($line), "$key=")) { + $line = "$key=$value"; + unset($newVars[$key]); + $updated = true; + } + } + if (empty($newVars)) { + break; + } + } + unset($line); // Break the reference with the last element + + // If there are still new variables to add, append them under the new vars header + if (!empty($newVars)) { + $lines[] = ''; + $lines[] = self::HEADER_NEW_VARS; + foreach ($newVars as $key => $value) { + $lines[] = "$key=$value"; + } + } + + return implode("\n", $lines); + } + + /** + * Parses an environment file into key-value pairs. + */ + private function parseEnvFile(string $file): array + { + if (!$this->filesystem->exists($file)) { + return []; + } + + return array_reduce(explode("\n", $this->filesystem->readFile($file)), function ($vars, $line) { + $line = trim($line); + if ($this->shouldSkipLine($line)) { + return $vars; + } + + [$key, $value] = $this->parseEnvLine($line); + $vars[$key] = $value; + return $vars; + }, []); + } + + /** + * Determines if a line should be skipped during parsing. + */ + private function shouldSkipLine(string $line): bool + { + return $line === '' || str_starts_with($line, '#') || !str_contains($line, '='); + } + + /** + * Parses a single environment file line into a key-value pair. + */ + private function parseEnvLine(string $line): array + { + [$key, $value] = explode('=', $line, 2); + return [trim($key), trim($value)]; + } + + /** + * Determines which variables need to be added or updated in the destination file. + * + * @throws RuntimeException If config directory cannot be resolved. + */ + private function getNewVariables(array $sourceVars, array $destVars, string $configDir): array + { + // Identify new variables present in the source but not in the destination + $newVars = array_diff_key($sourceVars, $destVars); + + // Prepare the CONFIG_PATH_KEY variable + $configDirAbs = $this->resolveConfigDir($configDir); + $settingsDir = $this->getSettingsBaseDir(); + $relativeConfigPath = $this->getRelativePath($settingsDir, $configDirAbs); + + $newVars[self::CONFIG_PATH_KEY] = $relativeConfigPath; + + return $newVars; + } + + /** + * Gets the base directory containing Settings.php. + */ + private function getSettingsBaseDir(): string + { + return dirname((new ReflectionClass(Settings::class))->getFileName()); + } + + /** + * Processes merge operation in no-op mode. + * + * @throws RuntimeException If config directory cannot be resolved. + */ + private function processNoOpMerge(string $configDir): int + { + try { + $configDirAbs = $this->resolveConfigDir($configDir); + $settingsDir = $this->getSettingsBaseDir(); + $relativeConfigPath = $this->getRelativePath($settingsDir, $configDirAbs); + + $this->output->out("Would set " . self::CONFIG_PATH_KEY . " to:\n $relativeConfigPath"); + return self::RESULT_NOOP; + } catch (\Exception $e) { + $this->output->out("Failed to process no-op merge: " . $e->getMessage(), 'error'); + return self::RESULT_ERROR; + } + } + + /** + * Resolves and validates a config directory path. + * + * @throws RuntimeException If path cannot be resolved and not in no-op mode. + */ + private function resolveConfigDir(string $configDir): string + { + $configDirAbs = realpath($configDir); + if (!$configDirAbs) { + if ($this->noOp) { + // In no-op mode, allow unresolved paths by treating them as relative to the current directory + return rtrim($configDir, DIRECTORY_SEPARATOR); + } + throw new RuntimeException("Cannot resolve config directory: $configDir"); + } + return $configDirAbs; + } + + /** + * Calculates the relative path between two filesystem locations. + * + * @throws RuntimeException If paths cannot be resolved or are invalid. + */ + private function getRelativePath(string $fromPath, string $toPath): string + { + // In no-op mode, toPath might not be resolvable. Handle accordingly. + if ($this->noOp && !is_dir($toPath)) { + // Treat toPath as relative to fromPath + return $this->computeRelativePath($fromPath, $toPath); + } + + $fromPath = realpath($fromPath); + $toPath = realpath($toPath); + + if (!$fromPath || !$toPath) { + throw new RuntimeException("Failed to resolve paths for relative path calculation."); + } + + $fromParts = explode(DIRECTORY_SEPARATOR, $fromPath); + $toParts = explode(DIRECTORY_SEPARATOR, $toPath); + + // Find common path + $commonLength = 0; + $maxCommon = min(count($fromParts), count($toParts)); + while ($commonLength < $maxCommon && $fromParts[$commonLength] === $toParts[$commonLength]) { + $commonLength++; + } + + // Calculate how many directories to go up from 'fromPath' + $backtracks = array_fill(0, count($fromParts) - $commonLength, '..'); + // Append the remaining part of 'toPath' + $remaining = array_slice($toParts, $commonLength); + + return implode('/', array_merge($backtracks, $remaining)); + } + + /** + * Computes relative path without relying on realpath. + * This is used in no-op mode when toPath may not exist. + */ + private function computeRelativePath(string $fromPath, string $toPath): string + { + $fromPath = rtrim($fromPath, DIRECTORY_SEPARATOR); + $toPath = rtrim($toPath, DIRECTORY_SEPARATOR); + + $fromParts = explode(DIRECTORY_SEPARATOR, $fromPath); + $toParts = explode(DIRECTORY_SEPARATOR, $toPath); + + // Find common path + $commonLength = 0; + $maxCommon = min(count($fromParts), count($toParts)); + while ($commonLength < $maxCommon && $fromParts[$commonLength] === $toParts[$commonLength]) { + $commonLength++; + } + + // Calculate how many directories to go up from 'fromPath' + $backtracks = array_fill(0, count($fromParts) - $commonLength, '..'); + // Append the remaining part of 'toPath' + $remaining = array_slice($toParts, $commonLength); + + return implode('/', array_merge($backtracks, $remaining)); + } +} diff --git a/src-setup/Filesystem.php b/src-setup/Filesystem.php new file mode 100644 index 00000000..18adea25 --- /dev/null +++ b/src-setup/Filesystem.php @@ -0,0 +1,110 @@ +fs = new SymfonyFilesystem(); + } + + public function createDirectory(string $path): int { + if ($this->noOp) { + $this->output->out("Would create directory:\n $path"); + return self::RESULT_NOOP; + } + + try { + $this->fs->mkdir($path, 0755); + } catch (IOExceptionInterface $e) { + $this->output->out("Failed to create directory: {$e->getPath()}", 'error'); + return self::RESULT_ERROR; + } + return self::RESULT_OK; + } + + public function copyDir(string $source, string $dest): int { + if ($this->noOp) { + $this->output->out("Would copy directory:\n from $source\n to $dest"); + return self::RESULT_NOOP; + } + try { + $this->fs->mirror($source, $dest); + } catch (IOExceptionInterface $e) { + $this->output->out("Failed to copy directory:\n from $source\n to $dest\n - {$e->getMessage()}", 'error'); + return self::RESULT_ERROR; + } + $this->output->out("Copied directory:\n from $source\n to $dest"); + return self::RESULT_OK; + } + + public function copyFile(string $source, string $dest): int { + if (!$this->exists($source)) { + $this->output->out("Source file does not exist:\n $source", 'error'); + return self::RESULT_ERROR; + } + + if ($this->fs->exists($dest)) { + $this->output->out("Skipping - destination file already exists:\n $dest", 'warning'); + return self::RESULT_NOOP; + } + + if ($this->noOp) { + $this->output->out("Would copy file:\n from $source\n to $dest"); + return self::RESULT_NOOP; + } + + try { + $this->fs->copy($source, $dest); + } catch (IOExceptionInterface $e) { + $this->output->out("Failed to copy file:\n from $source\n to $dest\n - {$e->getMessage()}", 'error'); + return self::RESULT_ERROR; + } + $this->output->out("Copied file:\n from $source\n to $dest"); + return self::RESULT_OK; + } + + public function readFile(string $path): string { + if (!$this->fs->exists($path)) { + throw new \RuntimeException("File does not exist: $path"); + } + + $content = file_get_contents($path); + if ($content === false) { + throw new \RuntimeException("Failed to read file: $path"); + } + + return $content; + } + + public function writeFile(string $path, string $content): int { + if ($this->noOp) { + $this->output->out("Would write to file:\n $path"); + return self::RESULT_NOOP; + } + + try { + $this->fs->dumpFile($path, $content); + } catch (IOExceptionInterface $e) { + $this->output->out("Failed to write to file:\n $path\n Error: {$e->getMessage()}", 'error'); + return self::RESULT_ERROR; + } + $this->output->out("Wrote to file: $path"); + return self::RESULT_OK; + } + + public function exists(string $path): bool { + return $this->fs->exists($path); + } +} diff --git a/src-setup/Loggers/FileLogger.php b/src-setup/Loggers/FileLogger.php new file mode 100644 index 00000000..c60cbde8 --- /dev/null +++ b/src-setup/Loggers/FileLogger.php @@ -0,0 +1,32 @@ +logFile = $logFile; + } + + public function log($level, $message, array $context = []): void + { + $timestamp = date('[Y-m-d H:i:s]'); + $logMessage = sprintf('%s [%s] %s', $timestamp, strtoupper($level), $message); + file_put_contents($this->logFile, $logMessage . PHP_EOL, FILE_APPEND); + } + + public function emergency($message, array $context = []): void { $this->log(LogLevel::EMERGENCY, $message, $context); } + public function alert($message, array $context = []): void { $this->log(LogLevel::ALERT, $message, $context); } + public function critical($message, array $context = []): void { $this->log(LogLevel::CRITICAL, $message, $context); } + public function error($message, array $context = []): void { $this->log(LogLevel::ERROR, $message, $context); } + public function warning($message, array $context = []): void { $this->log(LogLevel::WARNING, $message, $context); } + public function notice($message, array $context = []): void { $this->log(LogLevel::NOTICE, $message, $context); } + public function info($message, array $context = []): void { $this->log(LogLevel::INFO, $message, $context); } + public function debug($message, array $context = []): void { $this->log(LogLevel::DEBUG, $message, $context); } +} diff --git a/src-setup/Output.php b/src-setup/Output.php new file mode 100644 index 00000000..d4abfc0c --- /dev/null +++ b/src-setup/Output.php @@ -0,0 +1,47 @@ +io = new SymfonyStyle($input, $output); + $this->setupOutputStyles($output); + $logFile = $input->getOption('log-file'); + $this->logger = $logFile + ? new FileLogger(Path::resolve($logFile)) + : new NullLogger(); + } + + public function out(string $message, string $level = 'info'): void { + $this->io->writeln($message); + $this->logger->log($level, strip_tags($message)); + } + + private function setupOutputStyles(OutputInterface $output): void { + $styles = [ + 'blue' => ['blue', null, ['bold']], + 'gray' => ['gray', null, ['bold']], + 'green' => ['green', null, ['bold']], + 'red' => ['red', null, ['bold']], + 'white' => ['white', null, ['bold']], + 'yellow' => ['yellow', null, ['bold']], + 'dark-gray' => ['black', null, ['bold']], + ]; + + foreach ($styles as $name => [$color, $background, $options]) { + $output->getFormatter()->setStyle($name, new OutputFormatterStyle($color, $background, $options)); + } + } +} diff --git a/src-setup/Path.php b/src-setup/Path.php new file mode 100644 index 00000000..2e9020db --- /dev/null +++ b/src-setup/Path.php @@ -0,0 +1,18 @@ +setName(self::$defaultName) + ->setDescription('Publishes or updates assets for the Instructor library.') + ->addOption('target-config-dir', 'c', InputOption::VALUE_REQUIRED, 'Target directory for configuration files') + ->addOption('target-prompts-dir', 'p', InputOption::VALUE_REQUIRED, 'Target directory for prompt files') + ->addOption('target-env-file', 'e', InputOption::VALUE_REQUIRED, 'Target .env file') + ->addOption('log-file', 'l', InputOption::VALUE_OPTIONAL, 'Log file path') + ->addOption('no-op', 'no', InputOption::VALUE_NONE, 'Do not perform any actions, only log what would be done'); + } + + protected function initialize(InputInterface $input, OutputInterface $output): void { + $this->noOp = (bool)$input->getOption('no-op'); + $this->stopOnError = !$this->noOp; + $this->output = new Output($input, $output); + $this->filesystem = new Filesystem($this->noOp, $this->output); + $this->envFile = new EnvFile($this->noOp, $this->output, $this->filesystem); + + // Ensure the command is run from the project root + $projectRoot = getcwd(); + if (!file_exists($projectRoot . '/composer.json')) { + throw new InvalidArgumentException("This command must be run from your project root directory."); + } + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $this->output->out(""); + $this->output->out("{$this->getApplication()?->getName()} v{$this->getApplication()?->getVersion()}"); + + $this->targetConfigDir = $input->getOption('target-config-dir') ?? throw new InvalidArgumentException('Missing target-config-dir option'); + $this->targetPromptsDir = $input->getOption('target-prompts-dir') ?? throw new InvalidArgumentException('Missing target-prompts-dir option'); + $this->targetEnvFile = $input->getOption('target-env-file') ?? throw new InvalidArgumentException('Missing target-env-file option'); + + $assets = $this->getAssets(); + + $this->output->out(""); + $this->output->out("Publishing Instructor assets..."); + + $totalAssets = count($assets); + $step = 0; + try { + foreach ($assets as $asset) { + $step += 1; + $this->output->out(""); + $this->output->out("(step $step of $totalAssets) Processing asset: {$asset->name} ({$asset->description})"); + $success = $asset->publish(); + if (!$success && $this->stopOnError) { + return Command::FAILURE; + } + } + + $this->output->out(""); + $this->output->out(" ... DONE"); + $this->output->out(""); + return Command::SUCCESS; + } catch (Exception $e) { + $this->handleError('command execution', $e); + return Command::FAILURE; + } + } + + private function getAssets(): array { + return [ + new ConfigurationsDirAsset( + __DIR__ . '/../config', + $this->targetConfigDir, + $this->filesystem, + $this->output, + ), + new PromptsDirAsset( + __DIR__ . '/../prompts', + $this->targetPromptsDir, + $this->filesystem, + $this->output, + ), + new EnvFileAsset( + __DIR__ . '/../.env-dist', + $this->targetEnvFile, $this->targetConfigDir, + $this->filesystem, + $this->output, + $this->envFile, + ), + ]; + } + + private function handleError(string $context, Exception $e): void { + $this->output->out(" ... (!) Error in $context: " . $e->getMessage(), 'error'); + + if ($this->stopOnError) { + $this->output->out(""); + $this->output->out(" STOPPED - Error details:"); + $this->output->out($e->getMessage()); + $this->output->out($e->getTraceAsString()); + $this->output->out(""); + } + } +} diff --git a/src-tell/TellCommand.php b/src-tell/TellCommand.php new file mode 100644 index 00000000..62c6e6f6 --- /dev/null +++ b/src-tell/TellCommand.php @@ -0,0 +1,9 @@ +config->resourcePath; - $cache = __DIR__ . $this->config->cachePath; - $extension = $this->config->extension; - $mode = $this->config->metadata['mode'] ?? BladeOne::MODE_AUTO; - $this->blade = new BladeOne($views, $cache, $mode); - $this->blade->setFileExtension($extension); - } - - /** - * Renders a template file with the given parameters. - * - * @param string $name The name of the template file - * @param array $parameters The parameters to pass to the template - * @return string The rendered template - */ - public function renderFile(string $name, array $parameters = []): string { - return $this->blade->run($name, $parameters); - } - - /** - * Renders a template from a string with the given parameters. - * - * @param string $content The template content as a string - * @param array $parameters The parameters to pass to the template - * @return string The rendered template - */ - public function renderString(string $content, array $parameters = []): string { - return $this->blade->runString($content, $parameters); - } - - /** - * Gets the content of a template file. - * - * @param string $name - * @return string - */ - public function getTemplateContent(string $name): string { - $templatePath = $this->blade->getTemplateFile($name); - if (!file_exists($templatePath)) { - throw new Exception("Template '$name' file does not exist: $templatePath"); - } - return file_get_contents($templatePath); - } - - /** - * Gets names of variables from a template content. - * @param string $content - * @return array - */ - public function getVariableNames(string $content): array { - $variables = []; - preg_match_all('/{{\s*([$a-zA-Z0-9_]+)\s*}}/', $content, $matches); - foreach ($matches[1] as $match) { - $name = trim($match); - $name = str_starts_with($name, '$') ? substr($name, 1) : $name; - $variables[] = $name; - } - return array_unique($variables); - } -} +config->resourcePath; + $cache = __DIR__ . $this->config->cachePath; + $extension = $this->config->extension; + $mode = $this->config->metadata['mode'] ?? BladeOne::MODE_AUTO; + $this->blade = new BladeOne($views, $cache, $mode); + $this->blade->setFileExtension($extension); + } + + /** + * Renders a template file with the given parameters. + * + * @param string $path Library path of the template file + * @param array $parameters The parameters to pass to the template + * @return string The rendered template + */ + public function renderFile(string $path, array $parameters = []): string { + return $this->blade->run($path, $parameters); + } + + /** + * Renders a template from a string with the given parameters. + * + * @param string $content The template content as a string + * @param array $parameters The parameters to pass to the template + * @return string The rendered template + */ + public function renderString(string $content, array $parameters = []): string { + return $this->blade->runString($content, $parameters); + } + + /** + * Gets the content of a template file. + * + * @param string $path Library path of the template file + * @return string Raw content of the template file + */ + public function getTemplateContent(string $path): string { + $templatePath = $this->blade->getTemplateFile($path); + if (!file_exists($templatePath)) { + throw new Exception("Template '$path' file does not exist: $templatePath"); + } + return file_get_contents($templatePath); + } + + /** + * Gets names of variables from a template content. + * @param string $content + * @return array + */ + public function getVariableNames(string $content): array { + $variables = []; + preg_match_all('/{{\s*([$a-zA-Z0-9_]+)\s*}}/', $content, $matches); + foreach ($matches[1] as $match) { + $name = trim($match); + $name = str_starts_with($name, '$') ? substr($name, 1) : $name; + $variables[] = $name; + } + return array_unique($variables); + } +} diff --git a/src/Extras/Prompt/Drivers/TwigDriver.php b/src/Extras/Prompt/Drivers/TwigDriver.php index b7098c13..69e82a14 100644 --- a/src/Extras/Prompt/Drivers/TwigDriver.php +++ b/src/Extras/Prompt/Drivers/TwigDriver.php @@ -1,144 +1,144 @@ -config->resourcePath]; - $extension = $this->config->extension; - - $loader = new class( - paths: $paths, - fileExtension: $extension - ) extends FilesystemLoader { - private string $fileExtension; - - /** - * Constructor for the custom FilesystemLoader. - * - * @param array $paths The paths where templates are stored - * @param string|null $rootPath The root path for templates - * @param string $fileExtension The file extension to use for templates - */ - public function __construct( - $paths = [], - ?string $rootPath = null, - string $fileExtension = '', - ) { - parent::__construct($paths, $rootPath); - $this->fileExtension = $fileExtension; - } - - /** - * Finds a template by its name and appends the file extension if not present. - * - * @param string $name The name of the template - * @param bool $throw Whether to throw an exception if the template is not found - * @return string The path to the template - */ - protected function findTemplate(string $name, bool $throw = true): string { - if (pathinfo($name, PATHINFO_EXTENSION) === '') { - $name .= $this->fileExtension; - } - return parent::findTemplate($name, $throw); - } - }; - - $this->twig = new Environment( - loader: $loader, - options: ['cache' => $this->config->cachePath], - ); - } - - /** - * Renders a template file with the given parameters. - * - * @param string $name The name of the template file - * @param array $parameters The parameters to pass to the template - * @return string The rendered template - */ - public function renderFile(string $name, array $parameters = []): string { - return $this->twig->render($name, $parameters); - } - - /** - * Renders a template from a string with the given parameters. - * - * @param string $content The template content as a string - * @param array $parameters The parameters to pass to the template - * @return string The rendered template - */ - public function renderString(string $content, array $parameters = []): string { - return $this->twig->createTemplate($content)->render($parameters); - } - - /** - * Gets the content of a template file. - * - * @param string $name - * @return string - */ - public function getTemplateContent(string $name): string { - return $this->twig->getLoader()->getSourceContext($name)->getCode(); - } - - /** - * Gets names of variables used in a template content. - * - * @param string $content - * @return array - * @throws \Twig\Error\SyntaxError - */ - public function getVariableNames(string $content): array { - // make Twig Source from content string - $source = new Source($content, 'template'); - // Parse the template to get its AST - $parsedTemplate = $this->twig->parse($this->twig->tokenize($source)); - // Collect variables - $variables = $this->findVariables($parsedTemplate); - // Remove duplicates - return array_unique($variables); - } - - // INTERNAL ///////////////////////////////////////////////// - - private function findVariables(Node $node): array { - $variables = []; - // Check for variable nodes and add them to the list - if ($node instanceof NameExpression) { - $variables[] = $node->getAttribute('name'); - } - // Recursively search in child nodes - foreach ($node as $child) { - $childVariables = $this->findVariables($child); - foreach ($childVariables as $variable) { - $variables[] = $variable; - } - } - return $variables; - } -} +config->resourcePath]; + $extension = $this->config->extension; + + $loader = new class( + paths: $paths, + fileExtension: $extension + ) extends FilesystemLoader { + private string $fileExtension; + + /** + * Constructor for the custom FilesystemLoader. + * + * @param array $paths The paths where templates are stored + * @param string|null $rootPath The root path for templates + * @param string $fileExtension The file extension to use for templates + */ + public function __construct( + $paths = [], + ?string $rootPath = null, + string $fileExtension = '', + ) { + parent::__construct($paths, $rootPath); + $this->fileExtension = $fileExtension; + } + + /** + * Finds a template by its name and appends the file extension if not present. + * + * @param string $name The name of the template + * @param bool $throw Whether to throw an exception if the template is not found + * @return string The path to the template + */ + protected function findTemplate(string $name, bool $throw = true): string { + if (pathinfo($name, PATHINFO_EXTENSION) === '') { + $name .= $this->fileExtension; + } + return parent::findTemplate($name, $throw); + } + }; + + $this->twig = new Environment( + loader: $loader, + options: ['cache' => $this->config->cachePath], + ); + } + + /** + * Renders a template file with the given parameters. + * + * @param string $path The name of the template file + * @param array $parameters The parameters to pass to the template + * @return string The rendered template + */ + public function renderFile(string $path, array $parameters = []): string { + return $this->twig->render($path, $parameters); + } + + /** + * Renders a template from a string with the given parameters. + * + * @param string $content The template content as a string + * @param array $parameters The parameters to pass to the template + * @return string The rendered template + */ + public function renderString(string $content, array $parameters = []): string { + return $this->twig->createTemplate($content)->render($parameters); + } + + /** + * Gets the content of a template file. + * + * @param string $path + * @return string + */ + public function getTemplateContent(string $path): string { + return $this->twig->getLoader()->getSourceContext($path)->getCode(); + } + + /** + * Gets names of variables used in a template content. + * + * @param string $content + * @return array + * @throws \Twig\Error\SyntaxError + */ + public function getVariableNames(string $content): array { + // make Twig Source from content string + $source = new Source($content, 'template'); + // Parse the template to get its AST + $parsedTemplate = $this->twig->parse($this->twig->tokenize($source)); + // Collect variables + $variables = $this->findVariables($parsedTemplate); + // Remove duplicates + return array_unique($variables); + } + + // INTERNAL ///////////////////////////////////////////////// + + private function findVariables(Node $node): array { + $variables = []; + // Check for variable nodes and add them to the list + if ($node instanceof NameExpression) { + $variables[] = $node->getAttribute('name'); + } + // Recursively search in child nodes + foreach ($node as $child) { + $childVariables = $this->findVariables($child); + foreach ($childVariables as $variable) { + $variables[] = $variable; + } + } + return $variables; + } +} diff --git a/src/Extras/Prompt/Enums/TemplateType.php b/src/Extras/Prompt/Enums/TemplateEngine.php similarity index 62% rename from src/Extras/Prompt/Enums/TemplateType.php rename to src/Extras/Prompt/Enums/TemplateEngine.php index 231be93f..d959ed42 100644 --- a/src/Extras/Prompt/Enums/TemplateType.php +++ b/src/Extras/Prompt/Enums/TemplateEngine.php @@ -1,10 +1,9 @@ -config = $config ?? PromptEngineConfig::load( - setting: $setting ?: Settings::get('prompt', "defaultSetting") - ); - $this->driver = $driver ?? $this->makeDriver($this->config); - $this->templateContent = $name ? $this->load($name) : ''; - } - - public static function using(string $setting) : Prompt { - return new self(setting: $setting); - } - - public static function get(string $name, string $setting = '') : Prompt { - return new self(name: $name, setting: $setting); - } - - public static function text(string $name, array $variables, string $setting = '') : string { - return (new self(name: $name, setting: $setting))->withValues($variables)->toText(); - } - - public static function messages(string $name, array $variables, string $setting = '') : Messages { - return (new self(name: $name, setting: $setting))->withValues($variables)->toMessages(); - } - - public function withSetting(string $setting) : self { - $this->config = PromptEngineConfig::load($setting); - $this->driver = $this->makeDriver($this->config); - return $this; - } - - public function withConfig(PromptEngineConfig $config) : self { - $this->config = $config; - $this->driver = $this->makeDriver($config); - return $this; - } - - public function withDriver(CanHandleTemplate $driver) : self { - $this->driver = $driver; - return $this; - } - - public function withTemplate(string $name) : self { - $this->templateContent = $this->load($name); - $this->promptInfo = new PromptInfo($this->templateContent, $this->config); - return $this; - } - - public function withTemplateContent(string $content) : self { - $this->templateContent = $content; - $this->promptInfo = new PromptInfo($this->templateContent, $this->config); - return $this; - } - - public function withValues(array $values) : self { - $this->variableValues = $values; - return $this; - } - - public function toText() : string { - return $this->rendered(); - } - - public function toMessages() : Messages { - return $this->makeMessages($this->rendered()); - } - - public function toArray() : array { - return $this->toMessages()->toArray(); - } - - public function config() : PromptEngineConfig { - return $this->config; - } - - public function params() : array { - return $this->variableValues; - } - - public function template() : string { - return $this->templateContent; - } - - public function variables() : array { - return $this->driver->getVariableNames($this->templateContent); - } - - public function info() : PromptInfo { - return $this->promptInfo; - } - - public function validationErrors() : array { - $infoVars = $this->info()->variableNames(); - $templateVars = $this->variables(); - $valueKeys = array_keys($this->variableValues); - - $messages = []; - foreach($infoVars as $var) { - if (!in_array($var, $valueKeys)) { - $messages[] = "$var: variable defined in template info, but value not provided"; - } - if (!in_array($var, $templateVars)) { - $messages[] = "$var: variable defined in template info, but not used"; - } - } - foreach($valueKeys as $var) { - if (!in_array($var, $infoVars)) { - $messages[] = "$var: value provided, but not defined in template info"; - } - if (!in_array($var, $templateVars)) { - $messages[] = "$var: value provided, but not used in template content"; - } - } - foreach($templateVars as $var) { - if (!in_array($var, $infoVars)) { - $messages[] = "$var: variable used in template, but not defined in template info"; - } - if (!in_array($var, $valueKeys)) { - $messages[] = "$var: variable used in template, but value not provided"; - } - } - return $messages; - } - - // INTERNAL /////////////////////////////////////////////////// - - private function rendered() : string { - if (!isset($this->rendered)) { - $rendered = $this->render($this->templateContent, $this->variableValues); - $this->rendered = $rendered; - } - return $this->rendered; - } - - private function makeMessages(string $text) : Messages { - return match(true) { - $this->containsXml($text) && $this->hasRoles() => $this->makeMessagesFromXml($text), - default => Messages::fromString($text), - }; - } - - private function hasRoles() : string { - $roleStrings = [ - '', '', '' - ]; - if (Str::contains($this->rendered(), $roleStrings)) { - return true; - } - return false; - } - - private function containsXml(string $text) : bool { - return preg_match('/<[^>]+>/', $text) === 1; - } - - private function makeMessagesFromXml(string $text) : Messages { - $messages = new Messages(); - $xml = Xml::from($text)->wrapped('chat')->toArray(); - // TODO: validate - foreach ($xml as $key => $message) { - $messages->appendMessage(Message::make($key, $message)); - } - return $messages; - } - - private function makeDriver(PromptEngineConfig $config) : CanHandleTemplate { - return match($config->templateType) { - TemplateType::Twig => new TwigDriver($config), - TemplateType::Blade => new BladeDriver($config), - default => throw new InvalidArgumentException("Unknown driver: $config->templateType"), - }; - } - - private function load(string $path) : string { - return $this->driver->getTemplateContent($path); - } - - private function render(string $template, array $parameters = []) : string { - return $this->driver->renderString($template, $parameters); - } +library = new PromptLibrary($library, $config, $driver); + $this->templateContent = $path ? $this->library->loadTemplate($path) : ''; + } + + public static function make(string $pathOrDsn) : Prompt { + return match(true) { + Str::contains($pathOrDsn, self::DSN_SEPARATOR) => self::fromDsn($pathOrDsn), + default => new self(path: $pathOrDsn), + }; + } + + public static function using(string $library) : Prompt { + return new self(library: $library); + } + + public static function text(string $pathOrDsn, array $variables) : string { + return self::make($pathOrDsn)->withValues($variables)->toText(); + } + + public static function messages(string $pathOrDsn, array $variables) : Messages { + return self::make($pathOrDsn)->withValues($variables)->toMessages(); + } + + public static function fromDsn(string $dsn) : Prompt { + if (!Str::contains($dsn, self::DSN_SEPARATOR)) { + throw new InvalidArgumentException("Invalid DSN: $dsn - missing separator"); + } + $parts = explode(self::DSN_SEPARATOR, $dsn, 2); + if (count($parts) !== 2) { + throw new InvalidArgumentException("Invalid DSN: `$dsn` - failed to parse"); + } + return new self(path: $parts[1], library: $parts[0]); + } + + public function withLibrary(string $library) : self { + $this->library->get($library); + return $this; + } + + public function withConfig(PromptEngineConfig $config) : self { + $this->library->withConfig($config); + return $this; + } + + public function withDriver(CanHandleTemplate $driver) : self { + $this->library->withDriver($driver); + return $this; + } + + public function get(string $path) : self { + return $this->withTemplate($path); + } + + public function withTemplate(string $path) : self { + $this->templateContent = $this->library->loadTemplate($path); + $this->promptInfo = new PromptInfo($this->templateContent, $this->library->config()); + return $this; + } + + public function withTemplateContent(string $content) : self { + $this->templateContent = $content; + $this->promptInfo = new PromptInfo($this->templateContent, $this->library->config()); + return $this; + } + + public function with(array $values) : self { + return $this->withValues($values); + } + + public function withValues(array $values) : self { + $this->variableValues = $values; + return $this; + } + + public function toText() : string { + return $this->rendered(); + } + + public function toMessages() : Messages { + return $this->makeMessages($this->rendered()); + } + + public function toArray() : array { + return $this->toMessages()->toArray(); + } + + public function config() : PromptEngineConfig { + return $this->library->config(); + } + + public function params() : array { + return $this->variableValues; + } + + public function template() : string { + return $this->templateContent; + } + + public function variables() : array { + return $this->library->getVariableNames($this->templateContent); + } + + public function info() : PromptInfo { + return $this->promptInfo; + } + + public function validationErrors() : array { + $infoVars = $this->info()->variableNames(); + $templateVars = $this->variables(); + $valueKeys = array_keys($this->variableValues); + + $messages = []; + foreach($infoVars as $var) { + if (!in_array($var, $valueKeys)) { + $messages[] = "$var: variable defined in template info, but value not provided"; + } + if (!in_array($var, $templateVars)) { + $messages[] = "$var: variable defined in template info, but not used"; + } + } + foreach($valueKeys as $var) { + if (!in_array($var, $infoVars)) { + $messages[] = "$var: value provided, but not defined in template info"; + } + if (!in_array($var, $templateVars)) { + $messages[] = "$var: value provided, but not used in template content"; + } + } + foreach($templateVars as $var) { + if (!in_array($var, $infoVars)) { + $messages[] = "$var: variable used in template, but not defined in template info"; + } + if (!in_array($var, $valueKeys)) { + $messages[] = "$var: variable used in template, but value not provided"; + } + } + return $messages; + } + + // INTERNAL /////////////////////////////////////////////////// + + private function rendered() : string { + if (!isset($this->rendered)) { + $rendered = $this->library->renderString($this->templateContent, $this->variableValues); + $this->rendered = $rendered; + } + return $this->rendered; + } + + private function makeMessages(string $text) : Messages { + return match(true) { + $this->containsXml($text) && $this->hasChatRoles($text) => $this->makeMessagesFromXml($text), + default => Messages::fromString($text), + }; + } + + private function hasChatRoles(string $text) : bool { + $roleStrings = [ + '', '', '', '' + ]; + if (Str::containsAny($text, $roleStrings)) { + return true; + } + return false; + } + + private function containsXml(string $text) : bool { + return preg_match('/<[^>]+>/', $text) === 1; + } + + private function makeMessagesFromXml(string $text) : Messages { + $messages = new Messages(); + $xml = match(Str::contains($text, '')) { + true => Xml::from($text)->toArray(), + default => Xml::from($text)->wrapped('chat')->toArray(), + }; + // TODO: validate + foreach ($xml as $key => $message) { + $messages->appendMessage(Message::make($key, $message)); + } + return $messages; + } } \ No newline at end of file diff --git a/src/Extras/Prompt/PromptInfo.php b/src/Extras/Prompt/PromptInfo.php index a97ebcab..199d83b8 100644 --- a/src/Extras/Prompt/PromptInfo.php +++ b/src/Extras/Prompt/PromptInfo.php @@ -1,79 +1,79 @@ -config->frontMatterTags[0] ?? '---'; - $endTag = $this->config->frontMatterTags[1] ?? '---'; - $format = $this->config->frontMatterFormat; - $this->engine = $this->makeEngine($format, $startTag, $endTag); - - $document = $this->engine->parse($content); - $this->templateData = $document->getData(); - $this->templateContent = $document->getContent(); - } - - public function field(string $name) : mixed { - return $this->templateData[$name] ?? null; - } - - public function hasField(string $name) : bool { - return array_key_exists($name, $this->templateData); - } - - public function data() : array { - return $this->templateData; - } - - public function content() : string { - return $this->templateContent; - } - - public function variables() : array { - return $this->field('variables') ?? []; - } - - public function variableNames() : array { - return array_keys($this->variables()); - } - - public function hasVariables() : bool { - return $this->hasField('variables'); - } - - public function schema() : array { - return $this->field('schema') ?? []; - } - - public function hasSchema() : bool { - return $this->hasField('schema'); - } - - // INTERNAL ///////////////////////////////////////////////// - - private function makeEngine(FrontMatterFormat $format, string $startTag, string $endTag) : FrontMatter { - return match($format) { - FrontMatterFormat::Yaml => new FrontMatter(new YamlProcessor(), $startTag, $endTag), - FrontMatterFormat::Json => new FrontMatter(new JsonProcessor(), $startTag, $endTag), - FrontMatterFormat::Toml => new FrontMatter(new TomlProcessor(), $startTag, $endTag), - default => throw new InvalidArgumentException("Unknown front matter format: $format->value"), - }; - } -} +config->frontMatterTags[0] ?? '---'; + $endTag = $this->config->frontMatterTags[1] ?? '---'; + $format = $this->config->frontMatterFormat; + $this->engine = $this->makeEngine($format, $startTag, $endTag); + + $document = $this->engine->parse($content); + $this->templateData = $document->getData(); + $this->templateContent = $document->getContent(); + } + + public function field(string $name) : mixed { + return $this->templateData[$name] ?? null; + } + + public function hasField(string $name) : bool { + return array_key_exists($name, $this->templateData); + } + + public function data() : array { + return $this->templateData; + } + + public function content() : string { + return $this->templateContent; + } + + public function variables() : array { + return $this->field('variables') ?? []; + } + + public function variableNames() : array { + return array_keys($this->variables()); + } + + public function hasVariables() : bool { + return $this->hasField('variables'); + } + + public function schema() : array { + return $this->field('schema') ?? []; + } + + public function hasSchema() : bool { + return $this->hasField('schema'); + } + + // INTERNAL ///////////////////////////////////////////////// + + private function makeEngine(FrontMatterFormat $format, string $startTag, string $endTag) : FrontMatter { + return match($format) { + FrontMatterFormat::Yaml => new FrontMatter(new YamlProcessor(), $startTag, $endTag), + FrontMatterFormat::Json => new FrontMatter(new JsonProcessor(), $startTag, $endTag), + FrontMatterFormat::Toml => new FrontMatter(new TomlProcessor(), $startTag, $endTag), + default => throw new InvalidArgumentException("Unknown front matter format: $format->value"), + }; + } +} diff --git a/src/Extras/Prompt/PromptLibrary.php b/src/Extras/Prompt/PromptLibrary.php new file mode 100644 index 00000000..b52a1458 --- /dev/null +++ b/src/Extras/Prompt/PromptLibrary.php @@ -0,0 +1,79 @@ +config = $config ?? PromptEngineConfig::load( + library: $library ?: Settings::get('prompt', "defaultLibrary") + ); + $this->driver = $driver ?? $this->makeDriver($this->config); + } + + public function get(string $library): self { + $this->config = PromptEngineConfig::load($library); + $this->driver = $this->makeDriver($this->config); + return $this; + } + + public function withConfig(PromptEngineConfig $config): self { + $this->config = $config; + $this->driver = $this->makeDriver($config); + return $this; + } + + public function withDriver(CanHandleTemplate $driver): self { + $this->driver = $driver; + return $this; + } + + public function config(): PromptEngineConfig { + return $this->config; + } + + public function driver(): CanHandleTemplate { + return $this->driver; + } + + public function loadTemplate(string $path): string { + return $this->driver->getTemplateContent($path); + } + + public function renderString(string $path, array $variables): string { + return $this->driver->renderString($path, $variables); + } + + public function renderFile(string $path, array $variables): string { + return $this->driver->renderFile($path, $variables); + } + + public function getVariableNames(string $content): array { + return $this->driver->getVariableNames($content); + } + + // INTERNAL /////////////////////////////////////////////////// + + private function makeDriver(PromptEngineConfig $config): CanHandleTemplate { + return match ($config->templateEngine) { + TemplateEngine::Twig => new TwigDriver($config), + TemplateEngine::Blade => new BladeDriver($config), + default => throw new InvalidArgumentException("Unknown driver: $config->templateEngine"), + }; + } +} \ No newline at end of file diff --git a/src/Traits/HandlesEnv.php b/src/Traits/HandlesEnv.php index ae33e124..0b4f09ec 100644 --- a/src/Traits/HandlesEnv.php +++ b/src/Traits/HandlesEnv.php @@ -1,20 +1,22 @@ -get($key, $default); - } - - public static function has(string $group, string $key) : bool { - if (empty($group)) { - throw new Exception("Settings group not provided"); - } - - if (!self::isGroupLoaded($group)) { - self::$settings[$group] = dot(self::loadGroup($group)); - } - - return self::$settings[$group]->has($key); - } - - public static function set(string $group, string $key, mixed $value) : void { - if (!self::isGroupLoaded($group)) { - self::$settings[$group] = dot(self::loadGroup($group)); - } - - self::$settings[$group] = self::$settings[$group]->set($key, $value); - } - - // INTERNAL ////////////////////////////////////////////////////////////////// - - private static function isGroupLoaded(string $group) : bool { - return isset(self::$settings[$group]) && (self::$settings[$group] !== null); - } - - private static function loadGroup(string $group) : array { - $rootPath = $_ENV['INSTRUCTOR_CONFIG_PATH'] ?? self::$path; - $path = $rootPath . $group . '.php'; - if (!file_exists(__DIR__ . $path)) { - throw new Exception("Settings file not found: $path"); - } - return require __DIR__ . $path; - } -} +get($key, $default); + } + + /** + * Checks if a setting exists by group and key. + * + * @param string $group The settings group. + * @param string $key The settings key. + * @return bool True if the setting exists, false otherwise. + * @throws Exception If the group is not provided. + */ + public static function has(string $group, string $key) : bool { + if (empty($group)) { + throw new Exception("Settings group not provided"); + } + + if (!self::isGroupLoaded($group)) { + self::$settings[$group] = dot(self::loadGroup($group)); + } + + return self::$settings[$group]->has($key); + } + + /** + * Sets a setting value by group and key. + * + * @param string $group The settings group. + * @param string $key The settings key. + * @param mixed $value The value to set. + */ + public static function set(string $group, string $key, mixed $value) : void { + if (!self::isGroupLoaded($group)) { + self::$settings[$group] = dot(self::loadGroup($group)); + } + + self::$settings[$group] = self::$settings[$group]->set($key, $value); + } + + // INTERNAL ////////////////////////////////////////////////////////////////// + + /** + * Checks if a settings group is loaded. + * + * @param string $group The settings group. + * @return bool True if the group is loaded, false otherwise. + */ + private static function isGroupLoaded(string $group) : bool { + return isset(self::$settings[$group]) && (self::$settings[$group] !== null); + } + + /** + * Loads a settings group from a file. + * + * @param string $group The settings group. + * @return array The loaded settings group. + * @throws Exception If the settings file is not found. + */ + private static function loadGroup(string $group) : array { + $rootPath = self::getPath(); + + // Ensure the rootPath ends with a directory separator + $rootPath = rtrim($rootPath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + + $path = $rootPath . $group . '.php'; + + if (!file_exists($path)) { + throw new Exception("Settings file not found: $path"); + } + + return require $path; + } + + /** + * Resolves a given path to an absolute path. + * + * @param string $path The path to resolve. + * @return string The resolved absolute path. + */ + private static function resolvePath(string $path) : string { + if (self::isAbsolutePath($path)) { + return rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + } + + // Resolve relative paths based on the base directory + $resolvedPath = realpath(self::$baseDir . DIRECTORY_SEPARATOR . $path); + if ($resolvedPath !== false) { + return rtrim($resolvedPath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + } + + // If realpath fails (path doesn't exist), return the concatenated path + return rtrim(self::$baseDir . DIRECTORY_SEPARATOR . $path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + } + + /** + * Checks if a given path is absolute. + * + * @param string $path The path to check. + * @return bool True if the path is absolute, false otherwise. + */ + private static function isAbsolutePath(string $path) : bool { + return strpos($path, '/') === 0 || preg_match('/^[A-Z]:\\\\/', $path) === 1; + } +} diff --git a/src/Utils/Str.php b/src/Utils/Str.php index 6ca2315d..5428b21a 100644 --- a/src/Utils/Str.php +++ b/src/Utils/Str.php @@ -1,141 +1,156 @@ -through([ - // separate groups of capitalized words - fn ($data) => preg_replace('/([A-Z])([a-z])/', ' $1$2', $data), - // de-camel - //fn ($data) => preg_replace('/([A-Z]{2,})([A-Z])([a-z])/', '$1 $2$3', $data), - //fn ($data) => preg_replace('/([a-z])([A-Z])([a-z])/', '$1 $2$3', $data), - // separate groups of capitalized words of 2+ characters with spaces - fn ($data) => preg_replace('/([A-Z]{2,})/', ' $1 ', $data), - // de-kebab - fn ($data) => str_replace('-', ' ', $data), - // de-snake - fn ($data) => str_replace('_', ' ', $data), - // remove double spaces - fn ($data) => preg_replace('/\s+/', ' ', $data), - // remove leading _ - fn ($data) => ltrim($data, '_'), - // remove leading - - fn ($data) => ltrim($data, '-'), - // trim space - fn ($data) => trim($data), - ])->process($input); - } - - static public function contains(string $haystack, string|array $needle, bool $caseSensitive = true) : bool { - $needle = is_string($needle) ? [$needle] : $needle; - foreach($needle as $item) { - $result = match($caseSensitive) { - true => strpos($haystack, $item) !== false, - false => stripos($haystack, $item) !== false, - }; - if (!$result) { - return false; - } - } - return true; - } - - public static function startsWith(string $url, string $string) : bool { - return substr($url, 0, strlen($string)) === $string; - } - - public static function endsWith(string $url, string $string) : bool { - return substr($url, -strlen($string)) === $string; - } - - public static function between(mixed $url, string $string, string $string1) : string { - $start = strpos($url, $string); - if ($start === false) { - return ''; - } - $start += strlen($string); - $end = strpos($url, $string1, $start); - if ($end === false) { - return ''; - } - return substr($url, $start, $end - $start); - } - - public static function after(mixed $url, string $string) : string { - $start = strpos($url, $string); - if ($start === false) { - return ''; - } - $start += strlen($string); - return substr($url, $start); - } - - public static function when(bool $condition, string $onTrue, string $onFalse) : string { - return match($condition) { - true => $onTrue, - default => $onFalse, - }; - } - - public static function limit( - string $text, - int $limit, - string $cutMarker = '…', - int $align = STR_PAD_RIGHT, - bool $fit = true - ) : string { - $short = ($align === STR_PAD_LEFT) - ? substr($text, -$limit) - : substr($text, 0, $limit); - - if ($text === $short) { - return $text; - } - - $cutLength = strlen($cutMarker); - if ($fit && $cutLength > 0) { - return ($align === STR_PAD_LEFT) - ? $cutMarker . substr($short, $cutLength) - : substr($short, 0, -$cutLength) . $cutMarker; - } - - return ($align === STR_PAD_LEFT) - ? $cutMarker . $short - : $short . $cutMarker; - } -} +through([ + // separate groups of capitalized words + fn ($data) => preg_replace('/([A-Z])([a-z])/', ' $1$2', $data), + // de-camel + //fn ($data) => preg_replace('/([A-Z]{2,})([A-Z])([a-z])/', '$1 $2$3', $data), + //fn ($data) => preg_replace('/([a-z])([A-Z])([a-z])/', '$1 $2$3', $data), + // separate groups of capitalized words of 2+ characters with spaces + fn ($data) => preg_replace('/([A-Z]{2,})/', ' $1 ', $data), + // de-kebab + fn ($data) => str_replace('-', ' ', $data), + // de-snake + fn ($data) => str_replace('_', ' ', $data), + // remove double spaces + fn ($data) => preg_replace('/\s+/', ' ', $data), + // remove leading _ + fn ($data) => ltrim($data, '_'), + // remove leading - + fn ($data) => ltrim($data, '-'), + // trim space + fn ($data) => trim($data), + ])->process($input); + } + + static public function contains(string $haystack, string $needle, bool $caseSensitive = true) : bool { + return match($caseSensitive) { + true => strpos($haystack, $needle) !== false, + false => stripos($haystack, $needle) !== false, + }; + } + + static public function containsAll(string $haystack, string|array $needles, bool $caseSensitive = true) : bool { + $needles = is_string($needles) ? [$needles] : $needles; + foreach($needles as $item) { + $result = Str::contains($haystack, $item, $caseSensitive); + if (!$result) { + return false; + } + } + return true; + } + + static public function containsAny(string $haystack, string|array $needles, bool $caseSensitive = true) : bool { + $needles = is_string($needles) ? [$needles] : $needles; + foreach($needles as $item) { + $result = Str::contains($haystack, $item, $caseSensitive); + if ($result) { + return true; + } + } + return false; + } + + public static function startsWith(string $url, string $string) : bool { + return substr($url, 0, strlen($string)) === $string; + } + + public static function endsWith(string $url, string $string) : bool { + return substr($url, -strlen($string)) === $string; + } + + public static function between(mixed $url, string $string, string $string1) : string { + $start = strpos($url, $string); + if ($start === false) { + return ''; + } + $start += strlen($string); + $end = strpos($url, $string1, $start); + if ($end === false) { + return ''; + } + return substr($url, $start, $end - $start); + } + + public static function after(mixed $url, string $string) : string { + $start = strpos($url, $string); + if ($start === false) { + return ''; + } + $start += strlen($string); + return substr($url, $start); + } + + public static function when(bool $condition, string $onTrue, string $onFalse) : string { + return match($condition) { + true => $onTrue, + default => $onFalse, + }; + } + + public static function limit( + string $text, + int $limit, + string $cutMarker = '…', + int $align = STR_PAD_RIGHT, + bool $fit = true + ) : string { + $short = ($align === STR_PAD_LEFT) + ? substr($text, -$limit) + : substr($text, 0, $limit); + + if ($text === $short) { + return $text; + } + + $cutLength = strlen($cutMarker); + if ($fit && $cutLength > 0) { + return ($align === STR_PAD_LEFT) + ? $cutMarker . substr($short, $cutLength) + : substr($short, 0, -$cutLength) . $cutMarker; + } + + return ($align === STR_PAD_LEFT) + ? $cutMarker . $short + : $short . $cutMarker; + } +} diff --git a/src/Utils/Web/Filters/AnyKeywordFilter.php b/src/Utils/Web/Filters/AnyKeywordFilter.php index 015f8b7e..b511a1cb 100644 --- a/src/Utils/Web/Filters/AnyKeywordFilter.php +++ b/src/Utils/Web/Filters/AnyKeywordFilter.php @@ -1,18 +1,18 @@ -keywords, $this->isCaseSensitive); - } +keywords, $this->isCaseSensitive); + } } \ No newline at end of file diff --git a/tests/Feature/Extras/PromptTest.php b/tests/Feature/Extras/PromptTest.php index 0375eed9..e24fec56 100644 --- a/tests/Feature/Extras/PromptTest.php +++ b/tests/Feature/Extras/PromptTest.php @@ -1,84 +1,128 @@ -toBeInstanceOf(Prompt::class); -}); - -it('can set a custom config', function () { - $config = new PromptEngineConfig(); - $prompt = new Prompt(); - $prompt->withConfig($config); - expect($prompt->config())->toBe($config); -}); - -it('can set template content directly', function () { - $content = 'template content'; - $prompt = new Prompt(); - $prompt->withTemplateContent($content); - expect($prompt->template())->toBe($content); -}); - -it('can set parameters for rendering', function () { - $params = ['key' => 'value']; - $prompt = new Prompt(); - $prompt->withValues($params); - expect($prompt->params())->toBe($params); -}); - -it('can load a template by name - Blade', function () { - $prompt = Prompt::using('blade')->withTemplate('hello'); - expect($prompt->template())->toContain('Hello'); -}); - -it('can render the template - Blade', function () { - $prompt = Prompt::using('blade') - ->withTemplateContent('Hello, {{ $name }}!') - ->withValues(['name' => 'World']); - expect($prompt->toText())->toBe('Hello, World!'); -}); - -it('can find template variables - Blade', function () { - $prompt = Prompt::using('blade') - ->withTemplateContent('Hello, {{ $name }}!') - ->withValues(['name' => 'World']); - $variables = $prompt->variables(); - expect($variables)->toContain('name'); -}); - -it('can convert rendered text to messages', function () { - $prompt = Prompt::using('blade') - ->withTemplateContent('Hello, {{ $name }}!') - ->withValues(['name' => 'World']); - $messages = $prompt->toMessages(); - expect($messages)->toBeInstanceOf(Messages::class); -}); - -it('can load a template by name - Twig', function () { - $prompt = Prompt::using('twig')->withTemplate('hello'); - expect($prompt->template())->toContain('Hello'); -}); - -it('can render the template - Twig', function () { - $prompt = Prompt::using('twig') - ->withTemplateContent('Hello, {{ name }}!') - ->withValues(['name' => 'World']); - expect($prompt->toText())->toBe('Hello, World!'); -}); - -it('can find template variables - Twig', function () { - $prompt = Prompt::using('twig') - ->withTemplateContent('Hello, {{ name }}!') - ->withValues(['name' => 'World']); - $variables = $prompt->variables(); - expect($variables)->toContain('name'); -}); - -it('can render the template using short syntax', function () { - $prompt = Prompt::text(name: 'hello', variables: ['name' => 'World']); - expect($prompt)->toBe('Hello, World!'); -}); +get->with" syntax', function () { + $prompt = Prompt::using('demo-twig')->get('hello')->with(['name' => 'World']); + expect($prompt->toText())->toBe('Hello, World!'); + expect($prompt->toMessages()->toArray())->toBe([['role' => 'user', 'content' => 'Hello, World!']]); +}); + +it('can use short "make->with" syntax', function () { + $prompt = Prompt::make('demo-twig:hello')->with(['name' => 'World']); + expect($prompt->toText())->toBe('Hello, World!'); + expect($prompt->toMessages()->toArray())->toBe([['role' => 'user', 'content' => 'Hello, World!']]); +}); + +it('can render the template using short syntax', function () { + $prompt = Prompt::text('demo-twig:hello', ['name' => 'World']); + expect($prompt)->toBe('Hello, World!'); +}); + +it('can render the template to messages using short syntax', function () { + $messages = Prompt::messages('demo-twig:hello', ['name' => 'World']); + expect($messages->toArray())->toBe([['role' => 'user', 'content' => 'Hello, World!']]); +}); + +// OTHER METHOD CHECKS + +it('can be instantiated with default settings', function () { + $prompt = new Prompt(); + expect($prompt)->toBeInstanceOf(Prompt::class); +}); + +it('can set a custom config', function () { + $config = new PromptEngineConfig(); + $prompt = new Prompt(); + $prompt->withConfig($config); + expect($prompt->config())->toBe($config); +}); + +it('can set template content directly', function () { + $content = 'template content'; + $prompt = new Prompt(); + $prompt->withTemplateContent($content); + expect($prompt->template())->toBe($content); +}); + +it('can set parameters for rendering', function () { + $params = ['key' => 'value']; + $prompt = new Prompt(); + $prompt->withValues($params); + expect($prompt->params())->toBe($params); +}); + +it('can load a template by name - Blade', function () { + $prompt = Prompt::using('demo-blade')->withTemplate('hello'); + expect($prompt->template())->toContain('Hello'); +}); + +it('can render string template - Blade', function () { + $prompt = Prompt::using('demo-blade') + ->withTemplateContent('Hello, {{ $name }}!') + ->withValues(['name' => 'World']); + expect($prompt->toText())->toBe('Hello, World!'); +}); + +it('can find template variables - Blade', function () { + $prompt = Prompt::using('demo-blade') + ->withTemplateContent('Hello, {{ $name }}!') + ->withValues(['name' => 'World']); + $variables = $prompt->variables(); + expect($variables)->toContain('name'); +}); + +it('can convert template with chat markup to messages', function () { + $prompt = Prompt::using('demo-blade') + ->withTemplateContent('You are helpful assistant.Hello, {{ $name }}') + ->withValues(['name' => 'assistant']); + $messages = $prompt->toMessages(); + expect($messages)->toBeInstanceOf(Messages::class); + expect($messages->toString())->toContain('Hello, assistant'); + expect($messages->toArray())->toHaveCount(2); +}); + +it('can load a template by name - Twig', function () { + $prompt = Prompt::using('demo-twig')->withTemplate('hello'); + expect($prompt->template())->toContain('Hello'); +}); + +it('can render string template - Twig', function () { + $prompt = (new Prompt(library: 'demo-twig')) + ->withTemplateContent('Hello, {{ name }}!') + ->withValues(['name' => 'World']); + expect($prompt->toText())->toBe('Hello, World!'); +}); + +it('can find template variables - Twig', function () { + $prompt = Prompt::using('demo-twig') + ->withTemplateContent('Hello, {{ name }}!') + ->withValues(['name' => 'World']); + $variables = $prompt->variables(); + expect($variables)->toContain('name'); +}); + +it('can create prompt from "in memory" config', function () { + $prompt = (new Prompt) + ->withConfig(new PromptEngineConfig( + templateEngine: TemplateEngine::Blade, + resourcePath: '', + cachePath: '/tmp/any', + extension: 'blade.php', + metadata: [], + )) + ->withTemplateContent('Hello, {{ $name }}!') + ->withValues(['name' => 'World']); + $messages = $prompt->toMessages(); + expect($messages)->toBeInstanceOf(Messages::class); +}); + +it('can use DSN to load a template', function () { + $prompt = Prompt::fromDsn('demo-blade:hello')->with(['name' => 'World']); + expect($prompt->toText())->toBe('Hello, World!'); +});