diff --git a/composer.json b/composer.json
index 2b06f634..ff6e2d2d 100644
--- a/composer.json
+++ b/composer.json
@@ -34,6 +34,7 @@
"ext-dom": "*",
"cebe/markdown": "^1.2",
"duzun/hquery": "^3.1",
+ "eftec/bladeone": "^4.16",
"gioni06/gpt3-tokenizer": "^1.2",
"guzzlehttp/psr7": "^2.7",
"illuminate/database": "^11.10",
@@ -49,7 +50,6 @@
"spatie/array-to-xml": "^3.3",
"spatie/browsershot": "^4.1",
"spatie/php-structure-discoverer": "^2.1",
- "spatie/yaml-front-matter": "^2.0",
"symfony/browser-kit": "^7.1",
"symfony/css-selector": "^7.1",
"symfony/dom-crawler": "^7.1",
@@ -58,7 +58,8 @@
"symfony/var-dumper": "^6.4 || ^7.0",
"toolkit/cli-utils": "^2.0",
"twig/twig": "^3.0",
- "vimeo/psalm": "dev-master"
+ "vimeo/psalm": "dev-master",
+ "webuni/front-matter": "^2.0"
},
"config": {
"allow-plugins": {
@@ -70,6 +71,7 @@
"require": {
"php": "^8.2",
"ext-fileinfo": "*",
+ "ext-simplexml": "*",
"adbario/php-dot-notation": "^3.3",
"aimeos/map": "^3.8",
"guzzlehttp/guzzle": "^7.8",
@@ -77,6 +79,7 @@
"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",
diff --git a/config/prompt.php b/config/prompt.php
new file mode 100644
index 00000000..757831f0
--- /dev/null
+++ b/config/prompt.php
@@ -0,0 +1,26 @@
+ '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',
+ ],
+ ]
+];
diff --git a/docs/advanced/prompts.mdx b/docs/advanced/prompts.mdx
new file mode 100644
index 00000000..28d3b3da
--- /dev/null
+++ b/docs/advanced/prompts.mdx
@@ -0,0 +1,573 @@
+## 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.
diff --git a/docs/essentials/prompts.mdx b/docs/essentials/customize_prompts.mdx
similarity index 100%
rename from docs/essentials/prompts.mdx
rename to docs/essentials/customize_prompts.mdx
diff --git a/docs/mint.json b/docs/mint.json
index db8d0e85..075bfd52 100644
--- a/docs/mint.json
+++ b/docs/mint.json
@@ -74,7 +74,7 @@
"essentials/data_model",
"essentials/scalars",
"essentials/validation",
- "essentials/prompts",
+ "essentials/customize_prompts",
"essentials/demonstrations"
]
},
diff --git a/evals/ComplexExtraction/run.php b/evals/ComplexExtraction/run.php
index bb955bd0..006bc1c3 100644
--- a/evals/ComplexExtraction/run.php
+++ b/evals/ComplexExtraction/run.php
@@ -1,14 +1,14 @@
add('Cognesy\\Instructor\\', __DIR__ . '../../src/');
@@ -35,13 +35,13 @@
),
],
postprocessors: [
- new AggregateExperimentObservation(
+ new AggregateExperimentObserver(
name: 'experiment.mean_completeness',
observationKey: 'execution.fractionFound',
params: ['unit' => 'fraction', 'format' => '%.2f'],
method: NumberAggregationMethod::Mean,
),
- new AggregateExperimentObservation(
+ new AggregateExperimentObserver(
name: 'experiment.latency_p95',
observationKey: 'execution.timeElapsed',
params: ['percentile' => 95, 'unit' => 'seconds'],
diff --git a/evals/LLMModes/run.php b/evals/LLMModes/run.php
index 084f97f1..10152ba8 100644
--- a/evals/LLMModes/run.php
+++ b/evals/LLMModes/run.php
@@ -5,15 +5,13 @@
use Cognesy\Evals\LLMModes\CompanyEval;
use Cognesy\Instructor\Enums\Mode;
-use Cognesy\Instructor\Extras\Evals\Aggregators\AggregateExperimentObservation;
use Cognesy\Instructor\Extras\Evals\Enums\NumberAggregationMethod;
-use Cognesy\Instructor\Extras\Evals\Evaluators\ArrayMatchEval;
-use Cognesy\Instructor\Extras\Evals\Experiment;
use Cognesy\Instructor\Extras\Evals\Executors\Data\InferenceCases;
use Cognesy\Instructor\Extras\Evals\Executors\Data\InferenceData;
use Cognesy\Instructor\Extras\Evals\Executors\Data\InferenceSchema;
use Cognesy\Instructor\Extras\Evals\Executors\RunInference;
-use Cognesy\Instructor\Utils\Debug\Debug;
+use Cognesy\Instructor\Extras\Evals\Experiment;
+use Cognesy\Instructor\Extras\Evals\Observers\Aggregate\AggregateExperimentObserver;
$data = new InferenceData(
messages: [
@@ -61,7 +59,7 @@
]),
],
postprocessors: [
- new AggregateExperimentObservation(
+ new AggregateExperimentObserver(
name: 'experiment.reliability',
observationKey: 'execution.is_correct',
params: ['unit' => 'fraction', 'format' => '%.2f'],
diff --git a/evals/SimpleExtraction/run.php b/evals/SimpleExtraction/run.php
index b1d343ab..2baefe3c 100644
--- a/evals/SimpleExtraction/run.php
+++ b/evals/SimpleExtraction/run.php
@@ -3,14 +3,13 @@
use Cognesy\Evals\SimpleExtraction\Company;
use Cognesy\Evals\SimpleExtraction\CompanyEval;
use Cognesy\Instructor\Enums\Mode;
-use Cognesy\Instructor\Extras\Evals\Aggregators\AggregateExperimentObservation;
use Cognesy\Instructor\Extras\Evals\Enums\NumberAggregationMethod;
-use Cognesy\Instructor\Extras\Evals\Evaluators\ArrayMatchEval;
-use Cognesy\Instructor\Extras\Evals\Experiment;
use Cognesy\Instructor\Extras\Evals\Executors\Data\InferenceCases;
use Cognesy\Instructor\Extras\Evals\Executors\Data\InstructorData;
use Cognesy\Instructor\Extras\Evals\Executors\RunInstructor;
-use Cognesy\Instructor\Utils\Debug\Debug;
+use Cognesy\Instructor\Extras\Evals\Experiment;
+use Cognesy\Instructor\Extras\Evals\Observers\Aggregate\AggregateExperimentObserver;
+use Cognesy\Instructor\Extras\Evals\Observers\Evaluate\ArrayMatchEval;
$loader = require 'vendor/autoload.php';
$loader->add('Cognesy\\Instructor\\', __DIR__ . '../../src/');
@@ -39,32 +38,40 @@
expectations: [
'name' => 'ACME',
'year' => 2020
- ]),
- new ArrayMatchEval(expected: [
- 'name' => 'ACME',
- 'year' => 2020,
- ]),
+ ]
+ ),
+ new ArrayMatchEval(
+ expected: [
+ 'name' => 'ACME',
+ 'year' => 2020,
+ ],
+ metricNames: [
+ 'precision' => 'execution.precision',
+ 'recall' => 'execution.recall',
+ 'field_feedback' => 'execution.field_feedback',
+ ]
+ ),
],
postprocessors: [
- new AggregateExperimentObservation(
+ new AggregateExperimentObserver(
name: 'experiment.reliability',
observationKey: 'execution.is_correct',
params: ['unit' => 'fraction', 'format' => '%.2f'],
method: NumberAggregationMethod::Mean,
),
- new AggregateExperimentObservation(
+ new AggregateExperimentObserver(
name: 'experiment.mean_precision',
observationKey: 'execution.precision',
params: ['unit' => 'fraction', 'format' => '%.2f'],
method: NumberAggregationMethod::Mean,
),
- new AggregateExperimentObservation(
+ new AggregateExperimentObserver(
name: 'experiment.mean_recall',
observationKey: 'execution.recall',
params: ['unit' => 'fraction', 'format' => '%.2f'],
method: NumberAggregationMethod::Mean,
),
- new AggregateExperimentObservation(
+ new AggregateExperimentObserver(
name: 'experiment.latency_p95',
observationKey: 'execution.timeElapsed',
params: ['percentile' => 95, 'unit' => 'seconds'],
diff --git a/evals/UseExamples/Company.php b/evals/UseExamples/Company.php
new file mode 100644
index 00000000..b2b644a6
--- /dev/null
+++ b/evals/UseExamples/Company.php
@@ -0,0 +1,12 @@
+key = $key;
+ $this->expectations = $expectations;
+ }
+
+ public function observe(Execution $execution): Observation {
+ $company = $execution->data()->get('response')?->value();
+ $isCorrect = ($this->expectations['name'] === ($company->name ?? null))
+ && ($this->expectations['year'] === ($company->year ?? null));
+
+ return Observation::make(
+ type: 'metric',
+ key: $this->key,
+ value: $isCorrect ? 1 : 0,
+ metadata: [
+ 'executionId' => $execution->id(),
+ 'data' => json_encode($company),
+ ],
+ );
+ }
+}
diff --git a/evals/UseExamples/run.php b/evals/UseExamples/run.php
new file mode 100644
index 00000000..9c07c6a5
--- /dev/null
+++ b/evals/UseExamples/run.php
@@ -0,0 +1,83 @@
+add('Cognesy\\Instructor\\', __DIR__ . '../../src/');
+
+$data = new InstructorData(
+ messages: [
+ ['role' => 'user', 'content' => 'YOUR GOAL: Use tools to store the information from context based on user questions.'],
+ ['role' => 'user', 'content' => 'CONTEXT: Our company ACME was founded in 2020.'],
+ ['role' => 'user', 'content' => 'What is the name and founding year of our company?'],
+ ],
+ responseModel: Company::class,
+);
+
+//Debug::enable();
+
+$experiment = new Experiment(
+ cases: InferenceCases::except(
+ connections: ['ollama'],
+ modes: [Mode::JsonSchema, Mode::Text],
+ stream: [true]
+ ),
+ executor: new RunInstructor($data),
+ processors: [
+ new CompanyEval(
+ key: 'execution.is_correct',
+ expectations: [
+ 'name' => 'ACME',
+ 'year' => 2020
+ ]
+ ),
+ new ArrayMatchEval(
+ expected: [
+ 'name' => 'ACME',
+ 'year' => 2020,
+ ],
+ metricNames: [
+ 'precision' => 'execution.precision',
+ 'recall' => 'execution.recall',
+ 'field_feedback' => 'execution.field_feedback',
+ ]
+ ),
+ ],
+ postprocessors: [
+ new AggregateExperimentObserver(
+ name: 'experiment.reliability',
+ observationKey: 'execution.is_correct',
+ params: ['unit' => 'fraction', 'format' => '%.2f'],
+ method: NumberAggregationMethod::Mean,
+ ),
+ new AggregateExperimentObserver(
+ name: 'experiment.mean_precision',
+ observationKey: 'execution.precision',
+ params: ['unit' => 'fraction', 'format' => '%.2f'],
+ method: NumberAggregationMethod::Mean,
+ ),
+ new AggregateExperimentObserver(
+ name: 'experiment.mean_recall',
+ observationKey: 'execution.recall',
+ params: ['unit' => 'fraction', 'format' => '%.2f'],
+ method: NumberAggregationMethod::Mean,
+ ),
+ new AggregateExperimentObserver(
+ name: 'experiment.latency_p95',
+ observationKey: 'execution.timeElapsed',
+ params: ['percentile' => 95, 'unit' => 'seconds'],
+ method: NumberAggregationMethod::Percentile,
+ ),
+ ],
+);
+
+$outputs = $experiment->execute();
diff --git a/examples/A05_Extras/PromptText/run.php b/examples/A05_Extras/PromptText/run.php
new file mode 100644
index 00000000..2f6d475c
--- /dev/null
+++ b/examples/A05_Extras/PromptText/run.php
@@ -0,0 +1,48 @@
+---
+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
+$text = Prompt::text('capital', ['country' => 'Germany']);
+$answer = (new Inference)->create(messages: $text)->toText();
+
+echo "EXAMPLE 1: prompt = $text\n";
+echo "ASSISTANT: $answer\n";
+echo "\n";
+assert(Str::contains($answer, 'Berlin'));
+
+// EXAMPLE 2: Define prompt template inline
+
+$text = Prompt::using('twig')
+ ->withTemplateContent('What is capital of {{country}}')
+ ->withValues(['country' => 'Germany'])
+ ->toText();
+$answer = (new Inference)->create(messages: $text)->toText();
+
+echo "EXAMPLE 2: prompt = $text\n";
+echo "ASSISTANT: $answer\n";
+echo "\n";
+assert(Str::contains($answer, 'Berlin'));
+
+?>
+```
diff --git a/notes/NOTES.md b/notes/NOTES.md
index 43df5271..7039eec7 100644
--- a/notes/NOTES.md
+++ b/notes/NOTES.md
@@ -4,8 +4,6 @@
- Evals / eval framework
* execution level correctness metric
- * add input, output, etc. tokens default metrics
- * simplify contracts - currently 5 (!) contracts for observations
- Add 'Output' section to each example, generate it and include in docs, so reader can see what they can expect
- Logging via PSR-3
- Schema abstraction layer - decouple names and descriptions from the model
@@ -78,16 +76,6 @@
-# Done
-
-> NOTE: Move notes here
-
-## Configuration
-
-- Examples how to override default configuration
-
-
-
# Brain dump
@@ -148,3 +136,19 @@ Examples to demonstrate use cases.
## Test coverage
Catch up with the latest additions.
+
+
+
+# Done
+
+> NOTE: Move notes here
+
+## Configuration
+
+- Examples how to override default configuration
+
+## Evals
+
+- Simplify contracts - currently 5 (!) contracts for observations
+- Add input, output, etc. tokens default metrics
+
diff --git a/prompt.txt b/prompt.txt
new file mode 100644
index 00000000..c7112435
--- /dev/null
+++ b/prompt.txt
@@ -0,0 +1,868 @@
+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/blade/capital.blade.php
new file mode 100644
index 00000000..29270dde
--- /dev/null
+++ b/prompts/blade/capital.blade.php
@@ -0,0 +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 }}?
diff --git a/prompts/blade/capital_json.blade.php b/prompts/blade/capital_json.blade.php
new file mode 100644
index 00000000..00840741
--- /dev/null
+++ b/prompts/blade/capital_json.blade.php
@@ -0,0 +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 }}?
+
+
diff --git a/prompts/blade/hello.blade.php b/prompts/blade/hello.blade.php
new file mode 100644
index 00000000..1468f3da
--- /dev/null
+++ b/prompts/blade/hello.blade.php
@@ -0,0 +1,10 @@
+{{--
+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/twig/capital.twig
index 2271d80b..d771c94b 100644
--- a/prompts/twig/capital.twig
+++ b/prompts/twig/capital.twig
@@ -1,24 +1,11 @@
{#
---
description: Find country capital template for testing templates
-params:
+variables:
country:
- description: Country name
+ description: country name
type: string
default: France
---
#}
-
-{# [assistant] #}
-You are a helpful assistant, respond to the user's questions in a concise manner.
-
-{# [user] #}
-What is the capital of France?
-
-{# [assistant] #}
-{{ ['name': 'Paris'] | json_encode() }}
-
-{# [user] #}
-What is the capital of {{ country }}?
-
-{# [assistant] #}
+What is the capital of {{ country }}?
\ No newline at end of file
diff --git a/prompts/twig/capital_json.twig b/prompts/twig/capital_json.twig
new file mode 100644
index 00000000..04e27fea
--- /dev/null
+++ b/prompts/twig/capital_json.twig
@@ -0,0 +1,39 @@
+{#
+---
+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 }}?
+
+
\ No newline at end of file
diff --git a/prompts/twig/hello.twig b/prompts/twig/hello.twig
index 4b6e78eb..11f6cc81 100644
--- a/prompts/twig/hello.twig
+++ b/prompts/twig/hello.twig
@@ -1,10 +1,10 @@
{#---
description: Hello world template for testing templates
-params:
+variables:
name:
description: Name of the person to greet
type: string
default: World
---#}
-Hello, {{ name }}!
+Hello, {{ name }}!
\ No newline at end of file
diff --git a/prompts/twig/summary.twig b/prompts/twig/summary.twig
new file mode 100644
index 00000000..0f4d524c
--- /dev/null
+++ b/prompts/twig/summary.twig
@@ -0,0 +1,25 @@
+{#
+---
+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 }}
diff --git a/prompts/twig/summary_struct.twig b/prompts/twig/summary_struct.twig
new file mode 100644
index 00000000..cf61deaf
--- /dev/null
+++ b/prompts/twig/summary_struct.twig
@@ -0,0 +1,11 @@
+[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]
diff --git a/src/Extras/Evals/Console/Display.php b/src/Extras/Evals/Console/Display.php
index 753a5aaf..0b1a3cc8 100644
--- a/src/Extras/Evals/Console/Display.php
+++ b/src/Extras/Evals/Console/Display.php
@@ -4,7 +4,6 @@
use Cognesy\Instructor\Extras\Evals\Execution;
use Cognesy\Instructor\Extras\Evals\Experiment;
-use Cognesy\Instructor\Extras\Evals\Observation\SelectObservations;
use Cognesy\Instructor\Utils\Cli\Color;
use Cognesy\Instructor\Utils\Cli\Console;
use Cognesy\Instructor\Utils\Debug\Debug;
@@ -20,45 +19,56 @@ public function __construct(array $options = []) {
}
public function header(Experiment $experiment) : void {
+ $id = $experiment->id();
+ $title = ' EXPERIMENT (' . Str::limit(
+ text: $id,
+ limit: 4,
+ cutMarker: '',
+ align: STR_PAD_LEFT,
+ fit: false
+ ) . ") ";
+ $startedAt = ' ' . $experiment->startedAt()->format('Y-m-d H:i:s') . ' ';
+
Console::println('');
Console::printColumns([
- [22, ' EXPERIMENT (' . Str::limit(text: $experiment->id(), limit: 4, align: STR_PAD_LEFT, fit: false) . ") ", STR_PAD_RIGHT, [Color::BG_BLUE, Color::WHITE, Color::BOLD]],
+ [22, $title, STR_PAD_RIGHT, [Color::BG_BLUE, Color::WHITE, Color::BOLD]],
[$this->flex(22, 30, -2), ' ', STR_PAD_LEFT, [Color::BG_GRAY, Color::DARK_GRAY]],
- [30, ' ' . $experiment->startedAt()->format('Y-m-d H:i:s') . ' ', STR_PAD_LEFT, [Color::BG_GRAY, Color::DARK_GRAY]],
+ [30, $startedAt, STR_PAD_LEFT, [Color::BG_GRAY, Color::DARK_GRAY]],
], $this->terminalWidth, '');
Console::println('');
Console::println('');
}
public function footer(Experiment $experiment) {
+ $title = ' SUMMARY ';
+ $info = ' Time: ' . number_format($experiment->timeElapsed(), 2) . ' sec '
+ . ' ' . $experiment->usage()->toString() . ' ';
+
Console::println('');
Console::printColumns([
- [20, number_format($experiment->timeElapsed(), 2) . ' sec ', STR_PAD_LEFT, [Color::BG_BLUE, Color::WHITE, Color::BOLD]],
- [$this->flex(20, 50), ' ', STR_PAD_LEFT, [Color::BG_GRAY, Color::DARK_GRAY]],
- [50, ' ' . $experiment->usage()->toString() . ' ', STR_PAD_LEFT, [Color::BG_GRAY, Color::DARK_GRAY]],
+ [20, $title, STR_PAD_RIGHT, [Color::BG_BLUE, Color::WHITE, Color::BOLD]],
+ [$this->flex(20, 60), ' ', STR_PAD_LEFT, [Color::BG_GRAY, Color::DARK_GRAY]],
+ [60, $info, STR_PAD_LEFT, [Color::BG_GRAY, Color::DARK_GRAY]],
], $this->terminalWidth, '');
Console::println('');
- Console::println('');
$this->displayObservations($experiment);
Console::println('');
}
- public function before(Execution $execution) : void {
- $id = Str::limit($execution->id(), 4, STR_PAD_LEFT);
+ public function displayExecution(Execution $execution) : void {
+ $id = Str::limit(text: $execution->id(), limit: 4, cutMarker: '', align: STR_PAD_LEFT);
$connection = $execution->get('case.connection');
$mode = $execution->get('case.mode')->value;
$streamed = $execution->get('case.isStreamed');
+ $streamLabel = $streamed ? 'stream' : 'sync';
Console::printColumns([
- [4, $id, STR_PAD_LEFT, Color::DARK_GRAY],
+ [5, $id, STR_PAD_LEFT, Color::DARK_GRAY],
[10, $connection, STR_PAD_RIGHT, Color::WHITE],
[11, $mode, STR_PAD_RIGHT, Color::YELLOW],
- [8, $streamed ? 'stream' : 'sync', STR_PAD_LEFT, $streamed ? Color::BLUE : Color::DARK_BLUE],
+ [8, $streamLabel, STR_PAD_LEFT, $streamed ? Color::BLUE : Color::DARK_BLUE],
], $this->terminalWidth);
Console::print('', [Color::GRAY, Color::BG_BLACK]);
- }
-
- public function after(Execution $execution) : void {
if ($execution->hasException()) {
$this->displayException($execution->exception());
} else {
@@ -143,7 +153,6 @@ private function exceptionToText(Exception $e, int $maxLen) : string {
private function displayObservations(Experiment $experiment)
{
- Console::println('SUMMARY:', [Color::WHITE, Color::BOLD]);
Console::printColumns([
[5, 'ID', STR_PAD_LEFT, [Color::DARK_YELLOW]],
[25, 'KEY', STR_PAD_LEFT, [Color::DARK_YELLOW]],
@@ -154,16 +163,19 @@ private function displayObservations(Experiment $experiment)
], $this->terminalWidth);
foreach ($experiment->observations() as $observation) {
- $id = Str::limit($observation->id(), 4, STR_PAD_LEFT);
+ $id = Str::limit(text: $observation->id(), limit: 4, cutMarker: '', align: STR_PAD_LEFT);
$value = $observation->value();
$unit = $observation->metadata()->get('unit', '-');
$format = $observation->metadata()->get('format', '%s');
$method = $observation->metadata()->get('aggregationMethod', '-');
- $meta = Str::limit($observation->metadata()->except('experimentId', 'unit', 'format', 'aggregationMethod')->toJson(), 60);
+ $meta = Str::limit(
+ text: $observation->metadata()->except('experimentId', 'unit', 'format', 'aggregationMethod')->toJson(),
+ limit: 60
+ );
Console::printColumns([
[5, $id, STR_PAD_LEFT, [Color::DARK_GRAY]],
- [25, $observation->key(), STR_PAD_LEFT, [Color::GRAY]],
+ [25, $observation->key(), STR_PAD_RIGHT, [Color::GRAY]],
[10, sprintf($format, $value), STR_PAD_LEFT, [Color::WHITE]],
[10, $unit, STR_PAD_RIGHT, [Color::DARK_GRAY]],
[10, $method, STR_PAD_RIGHT, [Color::DARK_GRAY]],
diff --git a/src/Extras/Evals/Dataset/Dataset.php b/src/Extras/Evals/Dataset/Dataset.php
new file mode 100644
index 00000000..694756b8
--- /dev/null
+++ b/src/Extras/Evals/Dataset/Dataset.php
@@ -0,0 +1,8 @@
+events = $events ?? new EventDispatcher();
$this->id = Uuid::uuid4();
$this->data = new DataMap();
$this->data->set('case', $case);
$this->usage = Usage::none();
}
- public function id() : string {
- return $this->id;
- }
-
- public function get(string $key) : mixed {
- return $this->data->get($key);
- }
-
- public function set(string $key, mixed $value) : self {
- $this->data->set($key, $value);
- return $this;
- }
-
- public function data() : DataMap {
- return $this->data;
- }
-
- public function withData(DataMap $data) : self {
- $this->data = $data;
- return $this;
- }
-
- public function withExecutor(CanRunExecution $executor) : self {
- $this->action = $executor;
- return $this;
- }
-
- public function withProcessors(array|object $processors) : self {
- $this->processors = match(true) {
- is_array($processors) => $processors,
- default => [$processors],
- };
- return $this;
- }
-
- public function withPostprocessors(array $processors) : self {
- $this->postprocessors = match(true) {
- is_array($processors) => $processors,
- default => [$processors],
- };
- return $this;
- }
-
- public function execute() : void {
- $this->startedAt = new DateTime();
- $time = microtime(true);
- try {
- $this->action->run($this);
- } catch(Exception $e) {
- $this->timeElapsed = microtime(true) - $time;
- $this->data()->set('output.notes', $e->getMessage());
- $this->exception = $e;
- throw $e;
- }
- $this->timeElapsed = microtime(true) - $time;
- $this->data()->set('output.notes', $this->get('response')?->content());
- $this->usage = $this->get('response')?->usage() ?? Usage::none();
- $this->observations = $this->makeObservations();
- }
-
- // HELPERS //////////////////////////////////////////////////
-
- /**
- * @return Observation[]
- */
- public function observations() : array {
- return $this->observations;
- }
-
- public function hasObservations() : bool {
- return count($this->observations) > 0;
- }
-
- // HELPERS //////////////////////////////////////////////////
-
- public function exception() : ?Exception {
- return $this->exception;
- }
-
- public function hasException() : bool {
- return $this->exception !== null;
- }
-
- public function status() : string {
- return $this->exception ? 'failed' : 'success';
- }
-
- public function startedAt() : DateTime {
- return $this->startedAt;
- }
-
- public function timeElapsed() : float {
- return $this->timeElapsed;
- }
-
- public function usage() : Usage {
- return $this->usage;
- }
-
- public function totalTps() : float {
- if ($this->timeElapsed() === 0) {
- return 0;
- }
- return $this->usage->total() / $this->timeElapsed();
- }
-
- public function outputTps() : float {
- if ($this->timeElapsed === 0) {
- return 0;
- }
- return $this->usage->output() / $this->timeElapsed();
- }
-
- /**
- * @return Observation[]
- */
- public function metrics() : array {
- return SelectObservations::from($this->observations)
- ->withTypes(['metric'])
- ->all();
- }
-
- public function hasMetrics() : bool {
- return count($this->metrics()) > 0;
- }
-
- /**
- * @return Observation[]
- */
- public function feedback() : array {
- return SelectObservations::from($this->observations)
- ->withTypes(['feedback'])
- ->all();
- }
-
- public function hasFeedback() : bool {
- return count($this->feedback()) > 0;
- }
-
- /**
- * @return Observation[]
- */
- public function summaries() : array {
- return SelectObservations::from([$this->observations])
- ->withTypes(['summary'])
- ->all();
- }
-
- public function hasSummaries() : bool {
- return count($this->summaries()) > 0;
- }
-
- private function makeObservations() : array {
- $observations = MakeObservations::for($this)
- ->withObservers([
- $this->processors,
- $this->defaultObservers,
- ])
- ->only([
- CanObserveExecution::class,
- CanGenerateObservations::class,
- ]);
-
- $summaries = MakeObservations::for($this)
- ->withObservers([
- $this->postprocessors
- ])
- ->only([
- CanObserveExecution::class,
- CanGenerateObservations::class,
- ]);
+ // PUBLIC /////////////////////////////////////////////////////////
- return array_filter(array_merge($observations, $summaries));
+ public function toArray() : array {
+ return [
+ 'id' => $this->id(),
+ 'startedAt' => $this->startedAt(),
+ 'status' => $this->status(),
+ 'data' => $this->data(),
+ 'timeElapsed' => $this->timeElapsed(),
+ 'usage' => $this->usage(),
+ 'exception' => $this->exception(),
+ ];
}
}
diff --git a/src/Extras/Evals/Experiment.php b/src/Extras/Evals/Experiment.php
index 0e4ee3f7..d30dcd95 100644
--- a/src/Extras/Evals/Experiment.php
+++ b/src/Extras/Evals/Experiment.php
@@ -1,16 +1,13 @@
events = $events ?? new EventDispatcher();
$this->id = Uuid::uuid4();
$this->display = new Display();
$this->data = new DataMap();
@@ -70,158 +73,11 @@ public function __construct(
// PUBLIC //////////////////////////////////////////////////
- public function id() : string {
- return $this->id;
- }
-
- public function startedAt() : DateTime {
- return $this->startedAt;
- }
-
- public function timeElapsed() : float {
- return $this->timeElapsed;
- }
-
- public function usage() : Usage {
- return $this->usage;
- }
-
- public function data() : DataMap {
- return $this->data;
- }
-
- /**
- * @return Observation[]
- */
- public function execute() : array {
- $this->startedAt = new DateTime();
- $this->display->header($this);
-
- // execute cases
- foreach ($this->cases as $case) {
- $this->executeCase($case);
- }
- $this->usage = $this->accumulateUsage();
- $this->timeElapsed = microtime(true) - $this->startedAt->getTimestamp();
-
- $this->observations = $this->makeObservations();
-
- $this->display->footer($this);
- if (!empty($this->exceptions)) {
- $this->display->displayExceptions($this->exceptions);
- }
-
- return $this->summaries();
- }
-
- /**
- * @return Execution[]
- */
- public function executions() : array {
- return $this->executions;
- }
-
- /**
- * @return Observation[]
- */
- public function metrics(string $name) : array {
- return SelectObservations::from($this->observations)->withTypes(['metric'])->get($name);
- }
-
- /**
- * @return Observation[]
- */
- public function summaries() : array {
- return SelectObservations::from($this->observations)->withTypes(['summary'])->all();
- }
-
- /**
- * @return Observation[]
- */
- public function feedback() : array {
- return SelectObservations::from($this->observations)->withTypes(['feedback'])->all();
- }
-
- /**
- * @return Observation[]
- */
- public function observations() : array {
- return $this->observations;
- }
-
- public function hasObservations() : bool {
- return count($this->observations) > 0;
- }
-
- /**
- * @return Observation[]
- */
- public function executionObservations() : array {
- $observations = [];
- foreach($this->executions as $execution) {
- foreach($execution->observations() as $observation) {
- $observations[] = $observation;
- }
- }
- return $observations;
- }
-
- // INTERNAL /////////////////////////////////////////////////
-
- private function executeCase(mixed $case) : void {
- $execution = $this->makeExecution($case);
- $this->display->before($execution);
- try {
- $execution->execute();
- } catch(Exception $e) {
- $this->exceptions[$execution->id()] = $execution->exception();
- }
- $this->executions[] = $execution;
- $this->display->after($execution);
- }
-
- private function makeExecution(mixed $case) : Execution {
- $caseData = match(true) {
- is_array($case) => $case,
- method_exists($case, 'toArray') => $case->toArray(),
- default => (array) $case,
- };
- return (new Execution(case: $caseData))
- ->withExecutor($this->executor)
- ->withProcessors($this->processors)
- ->withPostprocessors($this->postprocessors);
- }
-
- private function accumulateUsage() : Usage {
- $usage = new Usage();
- foreach ($this->executions as $execution) {
- $usage->accumulate($execution->usage());
- }
- return $usage;
- }
-
- private function makeObservations() : array {
- // execute observers
- $observations = MakeObservations::for($this)
- ->withObservers([
- $this->processors,
- $this->defaultProcessors,
- ])
- ->only([
- CanObserveExperiment::class,
- CanGenerateObservations::class,
- ]);
-
- // execute summarizers
- $summaries = MakeObservations::for($this)
- ->withObservers([
- $this->postprocessors,
- ])
- ->only([
- CanObserveExperiment::class,
- CanGenerateObservations::class,
- ]);
-
- return array_filter(array_merge($observations, $summaries));
+ public function toArray() : array {
+ return [
+ 'id' => $this->id,
+ 'data' => $this->data->toArray(),
+ 'executions' => array_map(fn($e) => $e->id(), $this->executions),
+ ];
}
}
diff --git a/src/Extras/Evals/Observation.php b/src/Extras/Evals/Observation.php
index 699cccf3..f229bc7a 100644
--- a/src/Extras/Evals/Observation.php
+++ b/src/Extras/Evals/Observation.php
@@ -8,6 +8,8 @@
class Observation
{
+ use Traits\Observation\HandlesAccess;
+
private readonly string $id;
private readonly DateTimeImmutable $timestamp;
private string $type;
@@ -58,16 +60,6 @@ public static function fromArray(array $data) : self {
);
}
- public function withValue(mixed $value) : self {
- $this->value = $value;
- return $this;
- }
-
- public function withMetadata(array $metadata) : self {
- $this->metadata->merge($metadata);
- return $this;
- }
-
public function toArray() : array {
return [
'id' => $this->id,
@@ -78,40 +70,4 @@ public function toArray() : array {
'metadata' => $this->metadata->toArray(),
];
}
-
- public function has(string $key) : bool {
- return $this->metadata->has($key);
- }
-
- public function get(string $key, mixed $default = null) : mixed {
- return $this->metadata->get($key, $default);
- }
-
- public function id() : string {
- return $this->id;
- }
-
- public function timestamp() : DateTimeImmutable {
- return $this->timestamp;
- }
-
- public function type() : string {
- return $this->type;
- }
-
- public function key() : string {
- return $this->key;
- }
-
- public function value() : mixed {
- return $this->value;
- }
-
- public function metadata() : DataMap {
- return $this->metadata;
- }
-
- public function toFloat() : float {
- return (float) $this->value;
- }
}
diff --git a/src/Extras/Evals/Aggregators/AggregateExperimentObservation.php b/src/Extras/Evals/Observers/Aggregate/AggregateExperimentObserver.php
similarity index 91%
rename from src/Extras/Evals/Aggregators/AggregateExperimentObservation.php
rename to src/Extras/Evals/Observers/Aggregate/AggregateExperimentObserver.php
index 006b1ed5..4b18f14e 100644
--- a/src/Extras/Evals/Aggregators/AggregateExperimentObservation.php
+++ b/src/Extras/Evals/Observers/Aggregate/AggregateExperimentObserver.php
@@ -1,6 +1,6 @@
metrics($experiment)->failureRate,
metadata: [
diff --git a/src/Extras/Evals/Observers/ExperimentLatency.php b/src/Extras/Evals/Observers/Aggregate/ExperimentLatency.php
similarity index 77%
rename from src/Extras/Evals/Observers/ExperimentLatency.php
rename to src/Extras/Evals/Observers/Aggregate/ExperimentLatency.php
index a6be89f6..6caed1d5 100644
--- a/src/Extras/Evals/Observers/ExperimentLatency.php
+++ b/src/Extras/Evals/Observers/Aggregate/ExperimentLatency.php
@@ -1,8 +1,7 @@
metricNames['precision'] ?? 'execution.precision',
value: $precision,
metadata: [
'executionId' => $execution->id(),
@@ -83,7 +84,7 @@ private function recall(Execution $execution, array $analysis) : Observation {
$recall = $analysis['true_positives'] / ($analysis['true_positives'] + $analysis['false_negatives']);
return Observation::make(
type: 'metric',
- key: 'execution.recall',
+ key: $this->metricNames['recall'] ?? 'execution.recall',
value: $recall,
metadata: [
'executionId' => $execution->id(),
@@ -102,7 +103,7 @@ private function critique(Execution $execution): array {
callback: fn(Observation $observation) => $observation->withMetadata(['executionId' => $execution->id()]),
array: $feedback->toObservations([
'executionId' => $execution->id(),
- 'key' => 'execution.field_feedback',
+ 'key' => $this->metricNames['field_feedback'] ?? 'execution.field_feedback',
])
);
}
diff --git a/src/Extras/Evals/Evaluators/Data/BooleanCorrectnessAnalysis.php b/src/Extras/Evals/Observers/Evaluate/Data/BooleanCorrectnessAnalysis.php
similarity index 88%
rename from src/Extras/Evals/Evaluators/Data/BooleanCorrectnessAnalysis.php
rename to src/Extras/Evals/Observers/Evaluate/Data/BooleanCorrectnessAnalysis.php
index 5c94c0f6..9192d233 100644
--- a/src/Extras/Evals/Evaluators/Data/BooleanCorrectnessAnalysis.php
+++ b/src/Extras/Evals/Observers/Evaluate/Data/BooleanCorrectnessAnalysis.php
@@ -1,6 +1,6 @@
id;
+ }
+
+ public function get(string $key) : mixed {
+ return $this->data->get($key);
+ }
+
+ public function set(string $key, mixed $value) : self {
+ $this->data->set($key, $value);
+ return $this;
+ }
+
+ public function data() : DataMap {
+ return $this->data;
+ }
+
+ public function withData(DataMap $data) : self {
+ $this->data = $data;
+ return $this;
+ }
+
+ public function withExecutor(CanRunExecution $executor) : self {
+ $this->action = $executor;
+ return $this;
+ }
+
+ public function withProcessors(array|object $processors) : self {
+ $this->processors = match(true) {
+ is_array($processors) => $processors,
+ default => [$processors],
+ };
+ return $this;
+ }
+
+ public function withPostprocessors(array $processors) : self {
+ $this->postprocessors = match(true) {
+ is_array($processors) => $processors,
+ default => [$processors],
+ };
+ return $this;
+ }
+
+ /**
+ * @return Observation[]
+ */
+ public function observations() : array {
+ return $this->observations;
+ }
+
+ public function hasObservations() : bool {
+ return count($this->observations) > 0;
+ }
+
+ public function exception() : ?Exception {
+ return $this->exception;
+ }
+
+ public function hasException() : bool {
+ return $this->exception !== null;
+ }
+
+ public function status() : string {
+ return $this->exception ? 'failed' : 'success';
+ }
+
+ public function startedAt() : DateTime {
+ return $this->startedAt;
+ }
+
+ public function timeElapsed() : float {
+ return $this->timeElapsed;
+ }
+
+ public function usage() : Usage {
+ return $this->usage;
+ }
+
+ public function totalTps() : float {
+ if ($this->timeElapsed() === 0) {
+ return 0;
+ }
+ return $this->usage->total() / $this->timeElapsed();
+ }
+
+ public function outputTps() : float {
+ if ($this->timeElapsed === 0) {
+ return 0;
+ }
+ return $this->usage->output() / $this->timeElapsed();
+ }
+
+ public function hasMetrics() : bool {
+ return count($this->metrics()) > 0;
+ }
+
+ /**
+ * @return Observation[]
+ */
+ public function metrics() : array {
+ return SelectObservations::from($this->observations)
+ ->withTypes(['metric'])
+ ->all();
+ }
+
+ public function hasFeedback() : bool {
+ return count($this->feedback()) > 0;
+ }
+
+ /**
+ * @return Observation[]
+ */
+ public function feedback() : array {
+ return SelectObservations::from($this->observations)
+ ->withTypes(['feedback'])
+ ->all();
+ }
+
+ public function hasSummaries() : bool {
+ return count($this->summaries()) > 0;
+ }
+
+ /**
+ * @return Observation[]
+ */
+ public function summaries() : array {
+ return SelectObservations::from([$this->observations])
+ ->withTypes(['summary'])
+ ->all();
+ }
+}
\ No newline at end of file
diff --git a/src/Extras/Evals/Traits/Execution/HandlesExecution.php b/src/Extras/Evals/Traits/Execution/HandlesExecution.php
new file mode 100644
index 00000000..66d6a517
--- /dev/null
+++ b/src/Extras/Evals/Traits/Execution/HandlesExecution.php
@@ -0,0 +1,61 @@
+startedAt = new DateTime();
+ $time = microtime(true);
+ try {
+ $this->action->run($this);
+ $this->events->dispatch(new ExecutionDone($this->toArray()));
+ } catch(Exception $e) {
+ $this->timeElapsed = microtime(true) - $time;
+ $this->data()->set('output.notes', $e->getMessage());
+ $this->exception = $e;
+ $this->events->dispatch(new ExecutionFailed($this->toArray()));
+ throw $e;
+ }
+ $this->timeElapsed = microtime(true) - $time;
+ $this->data()->set('output.notes', $this->get('response')?->content());
+ $this->usage = $this->get('response')?->usage() ?? Usage::none();
+ $this->observations = $this->makeObservations();
+ $this->events->dispatch(new ExecutionProcessed($this->toArray()));
+ }
+
+ // INTERNAL //////////////////////////////////////////////////
+
+ private function makeObservations() : array {
+ $observations = MakeObservations::for($this)
+ ->withObservers([
+ $this->processors,
+ $this->defaultObservers,
+ ])
+ ->only([
+ CanObserveExecution::class,
+ CanGenerateObservations::class,
+ ]);
+
+ $summaries = MakeObservations::for($this)
+ ->withObservers([
+ $this->postprocessors
+ ])
+ ->only([
+ CanObserveExecution::class,
+ CanGenerateObservations::class,
+ ]);
+
+ return array_filter(array_merge($observations, $summaries));
+ }
+}
\ No newline at end of file
diff --git a/src/Extras/Evals/Traits/Experiment/HandlesAccess.php b/src/Extras/Evals/Traits/Experiment/HandlesAccess.php
new file mode 100644
index 00000000..0261a841
--- /dev/null
+++ b/src/Extras/Evals/Traits/Experiment/HandlesAccess.php
@@ -0,0 +1,86 @@
+id;
+ }
+
+ public function startedAt() : DateTime {
+ return $this->startedAt;
+ }
+
+ public function timeElapsed() : float {
+ return $this->timeElapsed;
+ }
+
+ public function usage() : Usage {
+ return $this->usage;
+ }
+
+ public function data() : DataMap {
+ return $this->data;
+ }
+
+ /**
+ * @return Execution[]
+ */
+ public function executions() : array {
+ return $this->executions;
+ }
+
+ /**
+ * @return Observation[]
+ */
+ public function metrics(string $name) : array {
+ return SelectObservations::from($this->observations)->withTypes(['metric'])->get($name);
+ }
+
+ /**
+ * @return Observation[]
+ */
+ public function summaries() : array {
+ return SelectObservations::from($this->observations)->withTypes(['summary'])->all();
+ }
+
+ /**
+ * @return Observation[]
+ */
+ public function feedback() : array {
+ return SelectObservations::from($this->observations)->withTypes(['feedback'])->all();
+ }
+
+ public function hasObservations() : bool {
+ return count($this->observations) > 0;
+ }
+
+ /**
+ * @return Observation[]
+ */
+ public function observations() : array {
+ return $this->observations;
+ }
+
+ /**
+ * @return Observation[]
+ */
+ public function executionObservations() : array {
+ $observations = [];
+ foreach($this->executions as $execution) {
+ foreach($execution->observations() as $observation) {
+ $observations[] = $observation;
+ }
+ }
+ return $observations;
+ }
+
+}
\ No newline at end of file
diff --git a/src/Extras/Evals/Traits/Experiment/HandlesExecution.php b/src/Extras/Evals/Traits/Experiment/HandlesExecution.php
new file mode 100644
index 00000000..99486dff
--- /dev/null
+++ b/src/Extras/Evals/Traits/Experiment/HandlesExecution.php
@@ -0,0 +1,91 @@
+startedAt = new DateTime();
+ $this->events->dispatch(new ExperimentStarted($this->toArray()));
+ $this->display->header($this);
+
+ // execute cases
+ foreach ($this->cases as $case) {
+ $execution = $this->executeCase($case);
+ $this->display->displayExecution($execution);
+ }
+ $this->usage = $this->accumulateUsage();
+ $this->timeElapsed = microtime(true) - $this->startedAt->getTimestamp();
+
+ $this->observations = $this->makeObservations();
+
+ $this->display->footer($this);
+ if (!empty($this->exceptions)) {
+ $this->display->displayExceptions($this->exceptions);
+ }
+
+ $this->events->dispatch(new ExperimentDone($this->toArray()));
+ return $this->summaries();
+ }
+
+ // INTERNAL /////////////////////////////////////////////////
+
+ private function executeCase(mixed $case) : Execution {
+ $execution = $this->makeExecution($case);
+ try {
+ $execution->execute();
+ } catch(Exception $e) {
+ $this->exceptions[$execution->id()] = $execution->exception();
+ }
+ $this->executions[] = $execution;
+ return $execution;
+ }
+
+ private function makeExecution(mixed $case) : Execution {
+ $caseData = match(true) {
+ is_array($case) => $case,
+ method_exists($case, 'toArray') => $case->toArray(),
+ default => (array) $case,
+ };
+ return (new Execution(case: $caseData))
+ ->withExecutor($this->executor)
+ ->withProcessors($this->processors)
+ ->withPostprocessors($this->postprocessors);
+ }
+
+ private function accumulateUsage() : Usage {
+ $usage = new Usage();
+ foreach ($this->executions as $execution) {
+ $usage->accumulate($execution->usage());
+ }
+ return $usage;
+ }
+
+ private function makeObservations() : array {
+ // execute observers
+ $observations = MakeObservations::for($this)
+ ->withObservers([$this->processors, $this->defaultProcessors])
+ ->only([CanObserveExperiment::class, CanGenerateObservations::class]);
+
+ // execute summarizers
+ $summaries = MakeObservations::for($this)
+ ->withObservers([$this->postprocessors])
+ ->only([CanObserveExperiment::class, CanGenerateObservations::class]);
+
+ return array_filter(array_merge($observations, $summaries));
+ }
+}
\ No newline at end of file
diff --git a/src/Extras/Evals/Traits/Observation/HandlesAccess.php b/src/Extras/Evals/Traits/Observation/HandlesAccess.php
new file mode 100644
index 00000000..b65e1005
--- /dev/null
+++ b/src/Extras/Evals/Traits/Observation/HandlesAccess.php
@@ -0,0 +1,55 @@
+value = $value;
+ return $this;
+ }
+
+ public function withMetadata(array $metadata) : self {
+ $this->metadata->merge($metadata);
+ return $this;
+ }
+
+ public function has(string $key) : bool {
+ return $this->metadata->has($key);
+ }
+
+ public function get(string $key, mixed $default = null) : mixed {
+ return $this->metadata->get($key, $default);
+ }
+
+ public function id() : string {
+ return $this->id;
+ }
+
+ public function timestamp() : DateTimeImmutable {
+ return $this->timestamp;
+ }
+
+ public function type() : string {
+ return $this->type;
+ }
+
+ public function key() : string {
+ return $this->key;
+ }
+
+ public function value() : mixed {
+ return $this->value;
+ }
+
+ public function metadata() : DataMap {
+ return $this->metadata;
+ }
+
+ public function toFloat() : float {
+ return (float) $this->value;
+ }
+}
\ No newline at end of file
diff --git a/src/Extras/Prompt/Contracts/CanHandleTemplate.php b/src/Extras/Prompt/Contracts/CanHandleTemplate.php
new file mode 100644
index 00000000..269541fe
--- /dev/null
+++ b/src/Extras/Prompt/Contracts/CanHandleTemplate.php
@@ -0,0 +1,45 @@
+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);
+ }
+}
diff --git a/src/Extras/Prompt/Drivers/TwigDriver.php b/src/Extras/Prompt/Drivers/TwigDriver.php
index 697bf3c7..b7098c13 100644
--- a/src/Extras/Prompt/Drivers/TwigDriver.php
+++ b/src/Extras/Prompt/Drivers/TwigDriver.php
@@ -2,7 +2,143 @@
namespace Cognesy\Instructor\Extras\Prompt\Drivers;
-class TwigDriver
+use Cognesy\Instructor\Extras\Prompt\Contracts\CanHandleTemplate;
+use Cognesy\Instructor\Extras\Prompt\Data\PromptEngineConfig;
+use Cognesy\Instructor\Utils\Arrays;
+use Twig\Environment;
+use Twig\Loader\FilesystemLoader;
+use Twig\Node\Expression\NameExpression;
+use Twig\Node\Node;
+use Twig\Source;
+
+/**
+ * Class TwigDriver
+ *
+ * Handles the rendering of Twig templates with custom file extensions and front matter support.
+ */
+class TwigDriver implements CanHandleTemplate
{
+ private Environment $twig;
+
+ /**
+ * TwigDriver constructor.
+ *
+ * @param PromptEngineConfig $config The configuration for the prompt engine
+ */
+ public function __construct(
+ private PromptEngineConfig $config,
+ ) {
+ $paths = [__DIR__ . $this->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 /////////////////////////////////////////////////
-}
\ No newline at end of file
+ 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/FrontMatterFormat.php b/src/Extras/Prompt/Enums/FrontMatterFormat.php
new file mode 100644
index 00000000..f1b92e96
--- /dev/null
+++ b/src/Extras/Prompt/Enums/FrontMatterFormat.php
@@ -0,0 +1,11 @@
+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);
+ }
}
\ No newline at end of file
diff --git a/src/Extras/Prompt/PromptInfo.php b/src/Extras/Prompt/PromptInfo.php
new file mode 100644
index 00000000..a97ebcab
--- /dev/null
+++ b/src/Extras/Prompt/PromptInfo.php
@@ -0,0 +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"),
+ };
+ }
+}
diff --git a/src/Features/LLM/Inference.php b/src/Features/LLM/Inference.php
index 7c0757b2..290b6cd9 100644
--- a/src/Features/LLM/Inference.php
+++ b/src/Features/LLM/Inference.php
@@ -55,8 +55,8 @@ public function __construct(
EventDispatcher $events = null,
) {
$this->events = $events ?? new EventDispatcher();
- $this->config = $config ?? LLMConfig::load(connection: $connection
- ?: Settings::get('llm', "defaultConnection")
+ $this->config = $config ?? LLMConfig::load(
+ connection: $connection ?: Settings::get('llm', "defaultConnection")
);
$this->httpClient = $httpClient ?? HttpClient::make($this->config->httpClient);
$this->driver = $driver ?? $this->makeDriver($this->config, $this->httpClient);
diff --git a/src/Utils/Cli/Console.php b/src/Utils/Cli/Console.php
index 1060da7b..499a78ab 100644
--- a/src/Utils/Cli/Console.php
+++ b/src/Utils/Cli/Console.php
@@ -44,7 +44,7 @@ public static function columns(array $columns, int $maxWidth, string $divider =
$message .= self::color($color);
}
$message .= self::toColumn(
- chars: $row[0],
+ width: $row[0],
text: $row[1],
align: $row[2]??STR_PAD_RIGHT
);
@@ -55,17 +55,14 @@ public static function columns(array $columns, int $maxWidth, string $divider =
return $message;
}
- static private function toColumn(int $chars, mixed $text, int $align, string|array $color = ''): string {
-// $short = ($align === STR_PAD_LEFT)
-// ? substr($text, -$chars)
-// : substr($text, 0, $chars);
-// if ($text !== $short) {
-// $short = ($align === STR_PAD_LEFT)
-// ? '…'.substr($short,1)
-// : substr($short, 0, -1).'…';
-// }
- $short = Str::limit($text, $chars, '…', $align);
- return self::color($color, str_pad($short, $chars, ' ', $align));
+ static private function toColumn(
+ int $width,
+ mixed $text,
+ int $align,
+ string|array $color = ''
+ ): string {
+ $short = Str::limit(text: $text, limit: $width, align: $align);
+ return self::color($color, str_pad($short, $width, ' ', $align));
}
static private function color(string|array $color, string $output = '') : string {
diff --git a/src/Utils/Git/Branch.php b/src/Utils/Git/Branch.php
new file mode 100644
index 00000000..fcb8f5e4
--- /dev/null
+++ b/src/Utils/Git/Branch.php
@@ -0,0 +1,45 @@
+branchName = $branchName;
+ $this->gitService = $gitService;
+ }
+
+ public function create(): self {
+ $this->gitService->runCommand(sprintf('checkout -b %s', escapeshellarg($this->branchName)));
+ return $this;
+ }
+
+ public function delete(bool $force = false): self {
+ $command = $force ? 'branch -D' : 'branch -d';
+ $this->gitService->runCommand(sprintf('%s %s', $command, escapeshellarg($this->branchName)));
+ return $this;
+ }
+
+ public function rebase(): self {
+ $this->gitService->runCommand(sprintf('rebase %s', escapeshellarg($this->branchName)));
+ return $this;
+ }
+
+ public function merge(): self {
+ $this->gitService->runCommand(sprintf('merge %s', escapeshellarg($this->branchName)));
+ return $this;
+ }
+
+ public function push(string $remote = 'origin'): self {
+ $this->gitService->runCommand(sprintf('push %s %s', escapeshellarg($remote), escapeshellarg($this->branchName)));
+ return $this;
+ }
+
+ public function pull(string $remote = 'origin'): self {
+ $this->gitService->runCommand(sprintf('pull %s %s', escapeshellarg($remote), escapeshellarg($this->branchName)));
+ return $this;
+ }
+}
\ No newline at end of file
diff --git a/src/Utils/Git/Branches.php b/src/Utils/Git/Branches.php
index aba6aa8d..7038b207 100644
--- a/src/Utils/Git/Branches.php
+++ b/src/Utils/Git/Branches.php
@@ -6,32 +6,16 @@ class Branches
{
protected GitService $gitService;
- public function __construct(GitService $gitService)
- {
+ public function __construct(GitService $gitService) {
$this->gitService = $gitService;
}
- public function current(): string
- {
- return $this->gitService->runCommand('rev-parse --abbrev-ref HEAD');
- }
-
- public function all(): array
- {
+ public function all(): array {
$branches = $this->gitService->runCommand('branch');
return array_map('trim', explode("\n", $branches));
}
- public function create(string $branchName): self
- {
- $this->gitService->runCommand(sprintf('checkout -b %s', escapeshellarg($branchName)));
- return $this;
- }
-
- public function delete(string $branchName, bool $force = false): self
- {
- $command = $force ? 'branch -D' : 'branch -d';
- $this->gitService->runCommand(sprintf('%s %s', $command, escapeshellarg($branchName)));
- return $this;
+ public function current(): string {
+ return $this->gitService->runCommand('rev-parse --abbrev-ref HEAD');
}
}
diff --git a/src/Utils/Git/Changes.php b/src/Utils/Git/Changes.php
new file mode 100644
index 00000000..6ef4c3a4
--- /dev/null
+++ b/src/Utils/Git/Changes.php
@@ -0,0 +1,62 @@
+gitService = $gitService;
+ }
+
+ public function last(int $days): self
+ {
+ $since = (new DateTime())->modify(sprintf('-%d days', $days))->format('Y-m-d');
+ $command = sprintf("log --since=%s --pretty=format:%%H", escapeshellarg($since));
+ $this->commits = array_filter(array_map('trim', explode("\n", $this->gitService->runCommand($command))));
+ return $this;
+ }
+
+ public function files(): array
+ {
+ $files = [];
+ foreach ($this->commits as $commit) {
+ $command = sprintf('diff-tree --no-commit-id --name-only -r %s', escapeshellarg($commit));
+ $changedFiles = array_filter(array_map('trim', explode("\n", $this->gitService->runCommand($command))));
+ foreach($changedFiles as $file) {
+ $files[] = $file;
+ }
+ }
+ return array_unique($files);
+ }
+
+ public function content(): array
+ {
+ $contentChanges = [];
+ foreach ($this->files() as $file) {
+ $command = sprintf('show %s:%s', escapeshellarg($this->commits[0]), escapeshellarg($file));
+ $content = $this->gitService->runCommand($command);
+ $contentChanges[] = ['file' => $file, 'content' => $content];
+ }
+ return $contentChanges;
+ }
+
+ public function diffs(): array
+ {
+ $diffs = [];
+ foreach ($this->commits as $commit) {
+ foreach ($this->files() as $file) {
+ $command = sprintf('diff %s %s -- %s', escapeshellarg($commit . "^"), escapeshellarg($commit), escapeshellarg($file));
+ $diff = $this->gitService->runCommand($command);
+ $diffs[] = ['file' => $file, 'diff' => $diff];
+ }
+ }
+ return $diffs;
+ }
+}
diff --git a/src/Utils/Git/Commit.php b/src/Utils/Git/Commit.php
index 5c81c25e..fcbd85d0 100644
--- a/src/Utils/Git/Commit.php
+++ b/src/Utils/Git/Commit.php
@@ -2,34 +2,50 @@
namespace Cognesy\Instructor\Utils\Git;
+use DateTime;
+
class Commit
{
- protected string $hash;
protected GitService $gitService;
+ protected string $hash;
+ protected string $message;
+ protected string $author;
+ protected DateTime $date;
+ protected string $diff;
- public function __construct(string $hash, GitService $gitService)
- {
- $this->hash = $hash;
+ public function __construct(
+ string $hash,
+ GitService $gitService
+ ) {
$this->gitService = $gitService;
+ $this->hash = $hash;
}
- public function hash(): string
- {
+ public function hash(): string {
return $this->hash;
}
- public function author(): string
- {
- return $this->gitService->runCommand(sprintf('log -1 --pretty=format:%%an %%ae %s', escapeshellarg($this->hash)));
+ public function author(): string {
+ if (!isset($this->author)) {
+ $author = $this->gitService->runCommand(sprintf('log -1 --pretty=format:%%an %%ae %s', escapeshellarg($this->hash)));
+ $this->author = $author;
+ }
+ return $this->author;
}
- public function message(): string
- {
- return $this->gitService->runCommand(sprintf('log -1 --pretty=format:%%B %s', escapeshellarg($this->hash)));
+ public function message(): string {
+ if (!isset($this->message)) {
+ $message = $this->gitService->runCommand(sprintf('log -1 --pretty=format:%%B %s', escapeshellarg($this->hash)));
+ $this->message = $message;
+ }
+ return $this->message;
}
- public function date(): string
- {
- return $this->gitService->runCommand(sprintf('log -1 --pretty=format:%%ad %s', escapeshellarg($this->hash)));
+ public function date(): DateTime {
+ if (!isset($this->date)) {
+ $date = $this->gitService->runCommand(sprintf('log -1 --pretty=format:%%ad %s', escapeshellarg($this->hash)));
+ $this->date = new DateTime($date);
+ }
+ return $this->date;
}
}
diff --git a/src/Utils/Git/Diff.php b/src/Utils/Git/Diff.php
index d9cadae1..85453c9c 100644
--- a/src/Utils/Git/Diff.php
+++ b/src/Utils/Git/Diff.php
@@ -6,18 +6,15 @@ class Diff
{
protected GitService $gitService;
- public function __construct(GitService $gitService)
- {
+ public function __construct(GitService $gitService) {
$this->gitService = $gitService;
}
- public function against(string $target = 'HEAD'): string
- {
+ public function against(string $target = 'HEAD'): string {
return $this->gitService->runCommand(sprintf('diff %s', escapeshellarg($target)));
}
- public function between(string $commitA, string $commitB): string
- {
+ public function between(string $commitA, string $commitB): string {
return $this->gitService->runCommand(sprintf('diff %s %s', escapeshellarg($commitA), escapeshellarg($commitB)));
}
}
diff --git a/src/Utils/Git/File.php b/src/Utils/Git/File.php
index 372de3d7..5b74bae1 100644
--- a/src/Utils/Git/File.php
+++ b/src/Utils/Git/File.php
@@ -5,32 +5,43 @@
class File
{
protected GitService $gitService;
+ protected string $path;
- public function __construct(GitService $gitService)
- {
+ public function __construct(
+ string $path,
+ GitService $gitService,
+ ) {
+ $this->path = $path;
$this->gitService = $gitService;
}
- public function log(string $path, int $number = 10): array
- {
- $log = $this->gitService->runCommand(sprintf('log -n %d --pretty=format:%%H -- %s', $number, escapeshellarg($path)));
+ /**
+ * Retrieves a list of commit hashes for a specified path in the git repository.
+ *
+ * @param string $path The file path to get the log for.
+ * @param int $number (optional) The number of commits to retrieve. Default is 10.
+ * @return array An array of Commit objects corresponding to the retrieved hashes.
+ */
+ public function log(int $number = 10): array {
+ $log = $this->gitService->runCommand(sprintf('log -n %d --pretty=format:%%H -- %s', $number, escapeshellarg($this->path)));
$hashes = array_filter(array_map('trim', explode("\n", $log)));
return array_map(fn($hash) => new Commit($hash, $this->gitService), $hashes);
}
- public function diff(string $path): string
- {
- return $this->gitService->runCommand(sprintf('diff HEAD -- %s', escapeshellarg($path)));
- }
-
- public function versions(string $path, int $number = 10): array
- {
- $log = $this->gitService->runCommand(sprintf('log -n %d --pretty=format:%%H -- %s', $number, escapeshellarg($path)));
+ /**
+ * Retrieves a list of file versions from a git repository.
+ *
+ * @param string $path The file path for which to retrieve versions.
+ * @param int $number The number of versions to retrieve. Defaults to 10.
+ * @return array An array of file versions, where each version is an associative array containing the hash and content.
+ */
+ public function versions(int $number = 10): array {
+ $log = $this->gitService->runCommand(sprintf('log -n %d --pretty=format:%%H -- %s', $number, escapeshellarg($this->path)));
$hashes = array_filter(array_map('trim', explode("\n", $log)));
$versions = [];
foreach ($hashes as $hash) {
- $content = $this->gitService->runCommand(sprintf('show %s:%s', escapeshellarg($hash), escapeshellarg($path)));
+ $content = $this->gitService->runCommand(sprintf('show %s:%s', escapeshellarg($hash), escapeshellarg($this->path)));
$versions[] = [
'hash' => $hash,
'content' => $content,
@@ -39,4 +50,14 @@ public function versions(string $path, int $number = 10): array
return $versions;
}
+
+ /**
+ * Generates a git diff for the provided file path.
+ *
+ * @param string $path The file path for which to generate the git diff.
+ * @return string The result of the git diff command.
+ */
+ public function diff(): string {
+ return $this->gitService->runCommand(sprintf('diff HEAD -- %s', escapeshellarg($this->path)));
+ }
}
\ No newline at end of file
diff --git a/src/Utils/Git/Git.php b/src/Utils/Git/Git.php
index 8296ace5..84ccbeab 100644
--- a/src/Utils/Git/Git.php
+++ b/src/Utils/Git/Git.php
@@ -7,93 +7,71 @@ class Git
protected string $repoPath;
protected GitService $gitService;
- public function __construct(string $repoPath)
- {
+ public function __construct(string $repoPath) {
$this->repoPath = $repoPath;
$this->gitService = new GitService($repoPath);
}
- public static function dir(string $repoPath): self
- {
+ public static function dir(string $repoPath): self {
return new self($repoPath);
}
- public function add(string $path): self
- {
+ public function add(string $path): self {
$this->gitService->runCommand(sprintf('add %s', escapeshellarg($path)));
return $this;
}
- public function commit(string $message): self
- {
+ public function commit(string $message): self {
$this->gitService->runCommand(sprintf('commit -m %s', escapeshellarg($message)));
return $this;
}
- public function push(string $remote = 'origin', string $branch = 'HEAD'): self
- {
+ public function push(string $remote = 'origin', string $branch = 'HEAD'): self {
$this->gitService->runCommand(sprintf('push %s %s', escapeshellarg($remote), escapeshellarg($branch)));
return $this;
}
- public function pull(string $remote = 'origin', string $branch = 'HEAD'): self
- {
+ public function pull(string $remote = 'origin', string $branch = 'HEAD'): self {
$this->gitService->runCommand(sprintf('pull %s %s', escapeshellarg($remote), escapeshellarg($branch)));
return $this;
}
- public function branches(): Branches
- {
- return new Branches($this->gitService);
- }
-
- public function commits(): Commits
- {
- return new Commits($this->gitService);
- }
-
- public function reset(string $commit = 'HEAD', bool $hard = false): self
- {
+ public function reset(string $commit = 'HEAD', bool $hard = false): self {
$command = $hard ? 'reset --hard' : 'reset';
$this->gitService->runCommand(sprintf('%s %s', $command, escapeshellarg($commit)));
return $this;
}
- public function merge(string $branch): self
- {
- $this->gitService->runCommand(sprintf('merge %s', escapeshellarg($branch)));
- return $this;
+ public function status(): array {
+ $status = $this->gitService->runCommand('status --short');
+ return array_filter(array_map('trim', explode("\n", $status)));
}
- public function rebase(string $branch): self
- {
- $this->gitService->runCommand(sprintf('rebase %s', escapeshellarg($branch)));
- return $this;
+ public function branch(string $branchName): Branch {
+ return new Branch($branchName, $this->gitService);
}
- public function status(): array
- {
- $status = $this->gitService->runCommand('status --short');
- return array_filter(array_map('trim', explode("\n", $status)));
+ public function branches(): Branches {
+ return new Branches($this->gitService);
+ }
+
+ public function commits(): Commits {
+ return new Commits($this->gitService);
}
- public function remote(): Remote
- {
- return new Remote($this->gitService);
+ public function remote(string $remote = 'origin'): Remote {
+ return new Remote($remote, $this->gitService);
}
- public function stash(): Stash
- {
+ public function stash(): Stash {
return new Stash($this->gitService);
}
- public function diff(): Diff
- {
+ public function diff(): Diff {
return new Diff($this->gitService);
}
- public function file(): File
- {
- return new File($this->gitService);
+ public function file(string $path): File {
+ return new File($path, $this->gitService);
}
}
diff --git a/src/Utils/Git/GitService.php b/src/Utils/Git/GitService.php
index 552974ce..1ab5793d 100644
--- a/src/Utils/Git/GitService.php
+++ b/src/Utils/Git/GitService.php
@@ -8,13 +8,11 @@ class GitService
{
protected string $repoPath;
- public function __construct(string $repoPath)
- {
+ public function __construct(string $repoPath) {
$this->repoPath = $repoPath;
}
- public function runCommand(string $command): string
- {
+ public function runCommand(string $command): string {
$fullCommand = sprintf('cd %s && git %s', escapeshellarg($this->repoPath), $command);
$output = shell_exec($fullCommand);
if ($output === null) {
diff --git a/src/Utils/Git/Remote.php b/src/Utils/Git/Remote.php
index 88380340..2caef5d2 100644
--- a/src/Utils/Git/Remote.php
+++ b/src/Utils/Git/Remote.php
@@ -5,19 +5,31 @@
class Remote
{
protected GitService $gitService;
+ private string $remote;
- public function __construct(GitService $gitService)
- {
+ public function __construct(
+ string $remote,
+ GitService $gitService,
+ ) {
+ $this->remote = $remote;
$this->gitService = $gitService;
}
- public function url(): string
- {
+ public function url(): string {
return $this->gitService->runCommand('config --get remote.origin.url');
}
- public function diff(string $branch): string
- {
- return $this->gitService->runCommand(sprintf('diff origin/%s', escapeshellarg($branch)));
+ public function diff(string $branch): string {
+ return $this->gitService->runCommand(sprintf('diff %s/%s', escapeshellarg($this->remote), escapeshellarg($branch)));
+ }
+
+ public function push(string $branch = 'HEAD'): self {
+ $this->gitService->runCommand(sprintf('push %s %s', escapeshellarg($this->remote), escapeshellarg($branch)));
+ return $this;
+ }
+
+ public function pull(string $branch = 'HEAD'): self {
+ $this->gitService->runCommand(sprintf('pull %s %s', escapeshellarg($this->remote), escapeshellarg($branch)));
+ return $this;
}
}
diff --git a/src/Utils/Git/Stash.php b/src/Utils/Git/Stash.php
index da2b7323..4cd7f814 100644
--- a/src/Utils/Git/Stash.php
+++ b/src/Utils/Git/Stash.php
@@ -2,23 +2,34 @@
namespace Cognesy\Instructor\Utils\Git;
+/**
+ * Stash class provides methods to interact with Git stash functionality.
+ * It allows for saving and applying stashed changes using a GitService.
+ */
class Stash
{
protected GitService $gitService;
- public function __construct(GitService $gitService)
- {
+ public function __construct(GitService $gitService) {
$this->gitService = $gitService;
}
- public function save(): self
- {
+ /**
+ * Saves stash
+ *
+ * @return self
+ */
+ public function save(): self {
$this->gitService->runCommand('stash');
return $this;
}
- public function apply(): self
- {
+ /**
+ * Applies the latest stashed changes to the working directory.
+ *
+ * @return self Returns the current instance for method chaining.
+ */
+ public function apply(): self {
$this->gitService->runCommand('stash apply');
return $this;
}
diff --git a/src/Utils/Messages/Message.php b/src/Utils/Messages/Message.php
index f8f2cbee..1d94ecbc 100644
--- a/src/Utils/Messages/Message.php
+++ b/src/Utils/Messages/Message.php
@@ -7,6 +7,8 @@ class Message {
use Traits\Message\HandlesAccess;
use Traits\Message\HandlesTransformation;
+ public const DEFAULT_ROLE = 'user';
+
/**
* @param string $role
* @param string|array $content
diff --git a/src/Utils/Messages/Traits/Message/HandlesCreation.php b/src/Utils/Messages/Traits/Message/HandlesCreation.php
index 7f7a6e64..ad3522ed 100644
--- a/src/Utils/Messages/Traits/Message/HandlesCreation.php
+++ b/src/Utils/Messages/Traits/Message/HandlesCreation.php
@@ -10,8 +10,6 @@
trait HandlesCreation
{
- public const DEFAULT_ROLE = 'user';
-
public static function make(string $role, string|array $content) : static {
return new static($role, $content);
}
diff --git a/src/Utils/Messages/Traits/Message/HandlesTransformation.php b/src/Utils/Messages/Traits/Message/HandlesTransformation.php
index 47e9af0b..a053765e 100644
--- a/src/Utils/Messages/Traits/Message/HandlesTransformation.php
+++ b/src/Utils/Messages/Traits/Message/HandlesTransformation.php
@@ -25,23 +25,27 @@ public function toString() : string {
return $text;
}
+ public function toRoleString() : string {
+ return $this->role . ': ' . $this->toString();
+ }
+
public function toCompositeMessage() : Message {
return Message::fromArray($this->toCompositeArray());
}
public function toCompositeArray() : array {
- if ($this->isComposite()) {
- return [
+ return match($this->isComposite()) {
+ true => [
'role' => $this->role,
'content' => $this->content,
- ];
- }
- return [
- 'role' => $this->role,
- 'content' => [[
- 'type' => 'text',
- 'text' => $this->content,
- ]]
- ];
+ ],
+ default => [
+ 'role' => $this->role,
+ 'content' => [[
+ 'type' => 'text',
+ 'text' => $this->content,
+ ]]
+ ]
+ };
}
}
diff --git a/src/Utils/Messages/Traits/Messages/HandlesConversion.php b/src/Utils/Messages/Traits/Messages/HandlesConversion.php
index 6772e08b..fa64f8ad 100644
--- a/src/Utils/Messages/Traits/Messages/HandlesConversion.php
+++ b/src/Utils/Messages/Traits/Messages/HandlesConversion.php
@@ -29,7 +29,7 @@ public static function asPerRoleArray(array $messages) : array {
$role = $message['role'];
$content = [];
- if (\Cognesy\Instructor\Utils\Messages\Message::becomesComposite($message)) {
+ if (Message::becomesComposite($message)) {
$merged->appendMessage($message);
continue;
}
diff --git a/src/Utils/Messages/Traits/Messages/HandlesMutation.php b/src/Utils/Messages/Traits/Messages/HandlesMutation.php
index a1bc4dbd..80d6ce0e 100644
--- a/src/Utils/Messages/Traits/Messages/HandlesMutation.php
+++ b/src/Utils/Messages/Traits/Messages/HandlesMutation.php
@@ -8,14 +8,14 @@ trait HandlesMutation
{
public function setMessage(string|array|Message $message) : static {
$this->messages = match (true) {
- is_string($message) => [\Cognesy\Instructor\Utils\Messages\Message::fromString($message)],
- is_array($message) => [\Cognesy\Instructor\Utils\Messages\Message::fromArray($message)],
+ is_string($message) => [Message::fromString($message)],
+ is_array($message) => [Message::fromArray($message)],
default => [$message],
};
return $this;
}
- public function appendMessage(array|\Cognesy\Instructor\Utils\Messages\Message $message) : static {
+ public function appendMessage(array|Message $message) : static {
$this->messages[] = match (true) {
is_array($message) => Message::fromArray($message),
default => $message,
diff --git a/src/Utils/Messages/Traits/Messages/HandlesTransformation.php b/src/Utils/Messages/Traits/Messages/HandlesTransformation.php
index d980fbdb..ca8be9d4 100644
--- a/src/Utils/Messages/Traits/Messages/HandlesTransformation.php
+++ b/src/Utils/Messages/Traits/Messages/HandlesTransformation.php
@@ -38,6 +38,14 @@ public function exceptRoles(array $roles) : Messages {
return $messages;
}
+ public function toRoleString() : string {
+ $text = '';
+ foreach ($this->messages as $message) {
+ $text .= $message->toRoleString() . "\n";
+ }
+ return $text;
+ }
+
// INTERNAL /////////////////////////////////////////////////////////////////////////
private function mergeRolesFlat() : Messages {
diff --git a/src/Utils/Messages/Traits/RendersContent.php b/src/Utils/Messages/Traits/RendersContent.php
index 55808832..e9d94447 100644
--- a/src/Utils/Messages/Traits/RendersContent.php
+++ b/src/Utils/Messages/Traits/RendersContent.php
@@ -21,7 +21,7 @@ private function renderString(string $template, ?array $parameters) : string {
}
/**
- * @param array|\Cognesy\Instructor\Utils\Messages\Message $messages
+ * @param array|Message $messages
* @param array|null $parameters
* @return string
*/
@@ -33,7 +33,7 @@ protected function renderMessage(array|Message $message, ?array $parameters) : a
}
/**
- * @param array|\Cognesy\Instructor\Utils\Messages\Messages $messages
+ * @param array|Messages $messages
* @param array|null $parameters
* @return string
*/
diff --git a/src/Utils/Str.php b/src/Utils/Str.php
index 09f97160..6ca2315d 100644
--- a/src/Utils/Str.php
+++ b/src/Utils/Str.php
@@ -112,7 +112,13 @@ public static function when(bool $condition, string $onTrue, string $onFalse) :
};
}
- public static function limit(string $text, int $limit, string $end = '…', int $align = STR_PAD_RIGHT, bool $fit = true) : string {
+ 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);
@@ -121,14 +127,15 @@ public static function limit(string $text, int $limit, string $end = '…', int
return $text;
}
- if ($fit) {
+ $cutLength = strlen($cutMarker);
+ if ($fit && $cutLength > 0) {
return ($align === STR_PAD_LEFT)
- ? $end . substr($short,1)
- : substr($short, 0, -1) . $end;
+ ? $cutMarker . substr($short, $cutLength)
+ : substr($short, 0, -$cutLength) . $cutMarker;
}
return ($align === STR_PAD_LEFT)
- ? $end . $short
- : $short . $end;
+ ? $cutMarker . $short
+ : $short . $cutMarker;
}
}
diff --git a/src/Utils/Xml.php b/src/Utils/Xml.php
new file mode 100644
index 00000000..e121add4
--- /dev/null
+++ b/src/Utils/Xml.php
@@ -0,0 +1,150 @@
+xmlString = $xmlString;
+ }
+
+ /**
+ * Create a new instance from XML string
+ * @param string $xmlString
+ * @return self
+ */
+ public static function from(string $xmlString): self {
+ return new self($xmlString);
+ }
+
+ /**
+ * Include attributes in the resulting array
+ * @return self
+ */
+ public function withAttributes(): self {
+ $this->includeAttributes = true;
+ return $this;
+ }
+
+ /**
+ * Include root element in the resulting array
+ * @return self
+ */
+ public function withRoot(): self {
+ $this->includeRoot = true;
+ return $this;
+ }
+
+ public function wrapped(string $root = 'root'): self {
+ $this->xmlString = "<$root>{$this->xmlString}$root>";
+ return $this;
+ }
+
+ /**
+ * Set the naming convention for the resulting array
+ * @param string $convention
+ * @return self
+ */
+ public function withNaming(string $convention): self {
+ $this->namingConvention = $convention;
+ return $this;
+ }
+
+ /**
+ * Return the array representation of the XML
+ * @return array
+ */
+ public function toArray(): array {
+ if ($this->parsedArray === null) {
+ $this->parsedArray = $this->convertXmlToArray();
+ }
+ return $this->parsedArray;
+ }
+
+ // INTERNAL ///////////////////////////////////////////////////
+
+ private function convertXmlToArray(): array {
+ if ($this->xmlString === '') {
+ return [];
+ }
+
+ $xmlElement = new SimpleXMLElement($this->xmlString, LIBXML_NOCDATA);
+ $array = $this->xmlToArray($xmlElement);
+
+ if ($this->includeRoot) {
+ return [$xmlElement->getName() => $array];
+ }
+
+ return $array;
+ }
+
+ private function xmlToArray(SimpleXMLElement $element): array|string {
+ $result = [];
+
+ // Handle attributes if required
+ if ($this->includeAttributes) {
+ foreach ($element->attributes() as $attrKey => $attrValue) {
+ $result['_attributes'][$attrKey] = (string) $attrValue;
+ }
+ }
+
+ // Handle child elements
+ foreach ($element->children() as $child) {
+ $childName = $this->sanitizeName($child->getName());
+ $childValue = $this->xmlToArray($child);
+
+ if (isset($result[$childName])) {
+ if (!is_array($result[$childName]) || !isset($result[$childName][0])) {
+ $result[$childName] = [$result[$childName]];
+ }
+ $result[$childName][] = $childValue;
+ } else {
+ $result[$childName] = $childValue;
+ }
+ }
+
+ // Handle text content or CDATA
+ $textContent = trim((string) $element);
+ if (strlen($textContent) > 0) {
+ if ($this->includeAttributes && count($result) > 0) {
+ $result['_value'] = $textContent;
+ } else {
+ return $textContent;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * TODO: allow conversion of names to snake_case or camelCase
+ * @param string $name
+ * @return string
+ */
+ private function sanitizeName(string $name): string {
+ return match($this->namingConvention) {
+ 'camel' => Str::camel($name),
+ 'snake' => Str::snake($name),
+ default => $name,
+ };
+ }
+}
diff --git a/tests/Feature/Extras/PromptTest.php b/tests/Feature/Extras/PromptTest.php
new file mode 100644
index 00000000..0375eed9
--- /dev/null
+++ b/tests/Feature/Extras/PromptTest.php
@@ -0,0 +1,84 @@
+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!');
+});
diff --git a/tests/Feature/Utils/ConsoleTest.php b/tests/Feature/Utils/ConsoleTest.php
index c35dff7b..7c8cb5a2 100644
--- a/tests/Feature/Utils/ConsoleTest.php
+++ b/tests/Feature/Utils/ConsoleTest.php
@@ -28,7 +28,7 @@
it('truncates and appends ellipsis to long text based on maxWidth', function () {
$longText = str_repeat('A', 100);
$output = Console::columns([[-1, $longText]], 80);
- $expected = str_pad(substr($longText, 0, 79) . '…' . Color::RESET . ' ', 80);
+ $expected = str_pad(substr($longText, 0, 77) . '…' . Color::RESET . ' ', 80);
expect($output)->toBe($expected);
});
diff --git a/tests/Feature/Utils/XmlTest.php b/tests/Feature/Utils/XmlTest.php
new file mode 100644
index 00000000..413298bb
--- /dev/null
+++ b/tests/Feature/Utils/XmlTest.php
@@ -0,0 +1,119 @@
+content';
+ $xml = Xml::from($xmlString)->withAttributes();
+ $expected = [
+ '_attributes' => ['attr' => 'value'],
+ 'child' => 'content'
+ ];
+ expect($xml->toArray())->toEqual($expected);
+});
+
+it('converts XML to array without attributes', function () {
+ $xmlString = 'content';
+ $xml = Xml::from($xmlString);
+ $expected = ['child' => 'content'];
+ expect($xml->toArray())->toEqual($expected);
+});
+
+it('includes root element in array', function () {
+ $xmlString = 'content';
+ $xml = Xml::from($xmlString)->withRoot();
+ $expected = ['root' => ['child' => 'content']];
+ expect($xml->toArray())->toEqual($expected);
+});
+
+it('converts names to snake_case', function () {
+ $xmlString = 'content';
+ $xml = Xml::from($xmlString)->withNaming('snake');
+ $expected = ['child_element' => 'content'];
+ expect($xml->toArray())->toEqual($expected);
+});
+
+it('converts names to camelCase', function () {
+ $xmlString = 'content';
+ $xml = Xml::from($xmlString)->withNaming('camel');
+ $expected = ['childElement' => 'content'];
+ expect($xml->toArray())->toEqual($expected);
+});
+
+it('handles empty XML string', function () {
+ $xmlString = '';
+ $xml = Xml::from($xmlString);
+ $expected = [];
+ expect($xml->toArray())->toEqual($expected);
+});
+
+it('handles XML with multiple children', function () {
+ $xmlString = 'content1content2';
+ $xml = Xml::from($xmlString);
+ $expected = ['child' => ['content1', 'content2']];
+ expect($xml->toArray())->toEqual($expected);
+});
+
+it('handles XML with multiple children with the same name', function () {
+ $xmlString = 'content1content2';
+ $xml = Xml::from($xmlString)->withNaming('snake');
+ $expected = ['child' => ['content1', 'content2']];
+ expect($xml->toArray())->toEqual($expected);
+});
+
+it('handles CDATA in XML', function () {
+ $xmlString = 'content]]>';
+ $xml = Xml::from($xmlString)->withRoot();
+ $expected = ['root' => ['child' => 'content']];
+ expect($xml->toArray())->toEqual($expected);
+});
+
+it('throws exception for invalid XML', function () {
+ $xmlString = 'content';
+ expect(fn() => Xml::from($xmlString)->toArray())->toThrow(Exception::class);
+});
+
+it('handles attributes with special characters in XML', function () {
+ $xmlString = 'content';
+ $xml = Xml::from($xmlString)->withAttributes();
+ $expected = [
+ '_attributes' => ['attr' => 'value & more'],
+ 'child' => 'content'
+ ];
+ expect($xml->toArray())->toEqual($expected);
+});
+
+it('wraps XML string with specified root element', function () {
+ $xmlString = 'content';
+ $xml = Xml::from($xmlString)->withRoot()->wrapped('wrapper');
+ $expected = ['wrapper' => ['child' => 'content']];
+ expect($xml->toArray())->toEqual($expected);
+});
+
+it('handles special characters in XML', function () {
+ $xmlString = 'content & more content';
+ $xml = Xml::from($xmlString);
+ $expected = ['child' => 'content & more content'];
+ expect($xml->toArray())->toEqual($expected);
+});
+
+it('handles empty elements in XML', function () {
+ $xmlString = '';
+ $xml = Xml::from($xmlString);
+ $expected = ['child' => []];
+ expect($xml->toArray())->toEqual($expected);
+});
+
+it('handles nested elements in XML', function () {
+ $xmlString = 'content';
+ $xml = Xml::from($xmlString);
+ $expected = ['parent' => ['child' => 'content']];
+ expect($xml->toArray())->toEqual($expected);
+});
+
+it('handles mixed content in XML', function () {
+ $xmlString = 'textcontentmore text';
+ $xml = Xml::from($xmlString)->withRoot()->toArray();
+ $expected = ['root' => ['textmore text', ['child' => 'content']]];
+ expect($xml)->toEqual($expected);
+})->skip();
\ No newline at end of file