From d83a9980d9b6ea6a77cf75293bf6d12aa875c7c1 Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Wed, 23 Jul 2025 00:10:11 +0100 Subject: [PATCH 1/9] feat: Prompt factories --- src/Prompts/Builders/ChatPromptBuilder.php | 4 +- .../Builders/Concerns/BuildsPrompts.php | 12 +- src/Prompts/Contracts/PromptFactory.php | 10 + .../Factories/LangfusePromptFactory.php | 190 ++++++++ src/Prompts/Factories/McpPromptFactory.php | 21 + src/Prompts/Templates/ChatPromptTemplate.php | 2 +- src/Support/Utils.php | 2 +- .../Factories/LangfusePromptFactoryTest.php | 439 ++++++++++++++++++ 8 files changed, 674 insertions(+), 6 deletions(-) create mode 100644 src/Prompts/Contracts/PromptFactory.php create mode 100644 src/Prompts/Factories/LangfusePromptFactory.php create mode 100644 src/Prompts/Factories/McpPromptFactory.php create mode 100644 tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php diff --git a/src/Prompts/Builders/ChatPromptBuilder.php b/src/Prompts/Builders/ChatPromptBuilder.php index 9c16d27..8cbfcd5 100644 --- a/src/Prompts/Builders/ChatPromptBuilder.php +++ b/src/Prompts/Builders/ChatPromptBuilder.php @@ -16,7 +16,7 @@ class ChatPromptBuilder implements PromptBuilder use BuildsPrompts; /** - * @var \Cortex\LLM\Data\Messages\MessageCollection|array|string + * @var \Cortex\LLM\Data\Messages\MessageCollection|array|string */ protected MessageCollection|array|string $messages = []; @@ -32,7 +32,7 @@ public function build(): PromptTemplate&Pipeable } /** - * @param \Cortex\LLM\Data\Messages\MessageCollection|array|string $messages + * @param \Cortex\LLM\Data\Messages\MessageCollection|non-empty-array|string $messages */ public function messages(MessageCollection|array|string $messages): self { diff --git a/src/Prompts/Builders/Concerns/BuildsPrompts.php b/src/Prompts/Builders/Concerns/BuildsPrompts.php index c7a49a6..d7e2498 100644 --- a/src/Prompts/Builders/Concerns/BuildsPrompts.php +++ b/src/Prompts/Builders/Concerns/BuildsPrompts.php @@ -51,14 +51,22 @@ public function metadata( StructuredOutputConfig|ObjectSchema|string|null $structuredOutput = null, array $additional = [], ): self { - $this->metadata = new PromptMetadata( + return $this->setMetadata(new PromptMetadata( $provider, $model, $parameters, $tools, $structuredOutput, $additional, - ); + )); + } + + /** + * Set the metadata for the prompt. + */ + public function setMetadata(PromptMetadata $metadata): self + { + $this->metadata = $metadata; return $this; } diff --git a/src/Prompts/Contracts/PromptFactory.php b/src/Prompts/Contracts/PromptFactory.php new file mode 100644 index 0000000..1a03c13 --- /dev/null +++ b/src/Prompts/Contracts/PromptFactory.php @@ -0,0 +1,10 @@ +, + * name: string, + * version: int, + * config: mixed, + * labels: array, + * tags: array, + * commitMessage?: string|null, + * resolutionGraph?: object|null + * } + * @phpstan-type LangfuseTextPrompt array{ + * type: 'text', + * prompt: string, + * name: string, + * version: int, + * config: mixed, + * labels: array, + * tags: array, + * commitMessage?: string|null, + * resolutionGraph?: object|null + * } + * @phpstan-type LangfusePromptResponse LangfuseChatPrompt|LangfuseTextPrompt + */ +class LangfusePromptFactory implements PromptFactory +{ + use DiscoversPsrImplementations; + + public function __construct( + protected string $name, + #[SensitiveParameter] + protected string $username, + #[SensitiveParameter] + protected string $password, + protected ?int $version = null, + protected ?string $label = null, + protected string $baseUrl = 'https://cloud.langfuse.com', + protected ?ClientInterface $httpClient = null, + protected ?Closure $metadataResolver = null, + ) {} + + public function create(): PromptTemplate + { + $data = $this->getResponseContent(); + + $builder = match ($data['type']) { + 'chat' => $this->buildChatPrompt($data), + 'text' => $this->buildTextPrompt($data), // @phpstan-ignore match.alwaysTrue + default => throw new PromptException('Unsupported prompt type: ' . $data['type']), + }; + + $metadataResolver = $this->metadataResolver ?? static::defaultMetadataResolver(); + + $builder->setMetadata($metadataResolver($data['config'])); + + return $builder->build(); + } + + /** + * @param \Closure(array): \Cortex\Prompts\Data\PromptMetadata $resolver + */ + public function resolveMetadataUsing(Closure $resolver): self + { + $this->metadataResolver = $resolver; + + return $this; + } + + /** + * @param LangfuseChatPrompt $data + */ + protected function buildChatPrompt(array $data): ChatPromptBuilder + { + $builder = new ChatPromptBuilder(); + $messages = array_map(function (array $message): Message|MessagePlaceholder { + return match ($message['type']) { + 'chatmessage' => match ($message['role']) { + 'user' => new UserMessage($message['content']), + 'assistant' => new AssistantMessage($message['content']), + 'system' => new SystemMessage($message['content']), + default => throw new PromptException('Unsupported message role: ' . $message['role']), + }, + 'placeholder' => new MessagePlaceholder($message['name']), // @phpstan-ignore match.alwaysTrue + default => throw new PromptException('Unsupported message type: ' . $message['type']), + }; + }, $data['prompt']); + + return $builder->messages($messages); + } + + /** + * @param LangfuseTextPrompt $data + */ + protected function buildTextPrompt(array $data): TextPromptBuilder + { + $builder = new TextPromptBuilder(); + + return $builder->text($data['prompt']); + } + + /** + * @return LangfusePromptResponse + */ + protected function getResponseContent(): array + { + $params = []; + + if ($this->version !== null && $this->version !== 0) { + $params['version'] = $this->version; + } + + if ($this->label !== null && $this->label !== '' && $this->label !== '0') { + $params['label'] = $this->label; + } + + $uri = sprintf('%s/api/public/v2/prompts/%s', $this->baseUrl, $this->name); + + if ($params !== []) { + $uri .= '?' . http_build_query($params); + } + + $client = $this->httpClient ?? $this->discoverHttpClient(); + + $response = $client->sendRequest( + $this->discoverHttpRequestFactory() + ->createRequest('GET', $uri) + ->withHeader( + 'Authorization', + sprintf('Basic %s', base64_encode($this->username . ':' . $this->password)), + ), + ); + + try { + return json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new PromptException('Invalid JSON response from Langfuse: ' . $e->getMessage()); + } + } + + /** + * @return \Closure(array): \Cortex\Prompts\Data\PromptMetadata + */ + protected static function defaultMetadataResolver(): Closure + { + return fn(array $config): PromptMetadata => new PromptMetadata( + $config['provider'] ?? null, + $config['model'] ?? null, + $config['parameters'] ?? [], + $config['tools'] ?? [], + $config['structuredOutput'] ?? null, + $config['additional'] ?? [], + ); + } +} diff --git a/src/Prompts/Factories/McpPromptFactory.php b/src/Prompts/Factories/McpPromptFactory.php new file mode 100644 index 0000000..b304dd5 --- /dev/null +++ b/src/Prompts/Factories/McpPromptFactory.php @@ -0,0 +1,21 @@ +|string $messages + * @param MessageCollection|array|string $messages * @param array $initialVariables */ public function __construct( diff --git a/src/Support/Utils.php b/src/Support/Utils.php index d8d5c28..24b91ad 100644 --- a/src/Support/Utils.php +++ b/src/Support/Utils.php @@ -69,7 +69,7 @@ public static function toToolCollection(array $tools): Collection /** * Ensure that the given messages are a MessageCollection. * - * @param \Cortex\LLM\Data\Messages\MessageCollection|\Cortex\LLM\Contracts\Message|array|string $messages + * @param \Cortex\LLM\Data\Messages\MessageCollection|\Cortex\LLM\Contracts\Message|non-empty-array|string $messages * * @return \Cortex\LLM\Data\Messages\MessageCollection */ diff --git a/tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php b/tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php new file mode 100644 index 0000000..3492a23 --- /dev/null +++ b/tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php @@ -0,0 +1,439 @@ + HandlerStack::create(new MockHandler($responses)), + ]), + ); +} + +test('it can create a chat prompt template from langfuse', function (): void { + $responseData = [ + 'type' => 'chat', + 'name' => 'test-chat-prompt', + 'version' => 1, + 'config' => [], + 'labels' => [], + 'tags' => [], + 'prompt' => [ + [ + 'type' => 'chatmessage', + 'role' => 'system', + 'content' => 'You are a helpful assistant.', + ], + [ + 'type' => 'chatmessage', + 'role' => 'user', + 'content' => 'What is the capital of {{country}}?', + ], + [ + 'type' => 'placeholder', + 'name' => 'history', + ], + ], + ]; + + $factory = createLangfuseFactory('test-chat-prompt', [ + new Response(200, body: json_encode($responseData)), + ]); + + $prompt = $factory->create(); + + expect($prompt)->toBeInstanceOf(ChatPromptTemplate::class); + expect($prompt->messages)->toHaveCount(3); + expect($prompt->messages[0])->toBeInstanceOf(SystemMessage::class); + expect($prompt->messages[0]->content)->toBe('You are a helpful assistant.'); + expect($prompt->messages[1])->toBeInstanceOf(UserMessage::class); + expect($prompt->messages[1]->content)->toBe('What is the capital of {{country}}?'); + expect($prompt->messages[2])->toBeInstanceOf(MessagePlaceholder::class); + expect($prompt->messages[2]->name)->toBe('history'); +}); + +test('it can create a text prompt template from langfuse', function (): void { + $responseData = [ + 'type' => 'text', + 'name' => 'test-text-prompt', + 'version' => 2, + 'config' => [], + 'labels' => [], + 'tags' => [], + 'prompt' => 'Write a story about {{topic}}. Make it {{length}} words long.', + ]; + + $factory = createLangfuseFactory('test-text-prompt', [ + new Response(200, body: json_encode($responseData)), + ]); + + $prompt = $factory->create(); + + expect($prompt)->toBeInstanceOf(TextPromptTemplate::class); + expect($prompt->text)->toBe('Write a story about {{topic}}. Make it {{length}} words long.'); +}); + +test('it handles assistant messages in chat prompts', function (): void { + $responseData = [ + 'type' => 'chat', + 'name' => 'test-assistant-prompt', + 'version' => 1, + 'config' => [], + 'labels' => [], + 'tags' => [], + 'prompt' => [ + [ + 'type' => 'chatmessage', + 'role' => 'user', + 'content' => 'Hello!', + ], + [ + 'type' => 'chatmessage', + 'role' => 'assistant', + 'content' => 'Hi there! How can I help you?', + ], + ], + ]; + + $factory = createLangfuseFactory('test-assistant-prompt', [ + new Response(200, body: json_encode($responseData)), + ]); + + $prompt = $factory->create(); + + expect($prompt->messages[0])->toBeInstanceOf(UserMessage::class); + expect($prompt->messages[1])->toBeInstanceOf(AssistantMessage::class); + expect($prompt->messages[1]->content)->toBe('Hi there! How can I help you?'); +}); + +test('it throws exception for unsupported prompt type', function (): void { + $responseData = [ + 'type' => 'unsupported', + 'name' => 'test-prompt', + 'version' => 1, + 'config' => [], + 'labels' => [], + 'tags' => [], + 'prompt' => 'Some content', + ]; + + $factory = createLangfuseFactory('test-prompt', [ + new Response(200, body: json_encode($responseData)), + ]); + + expect(fn(): PromptTemplate => $factory->create()) + ->toThrow(PromptException::class, 'Unsupported prompt type: unsupported'); +}); + +test('it throws exception for unsupported message role', function (): void { + $responseData = [ + 'type' => 'chat', + 'name' => 'test-prompt', + 'version' => 1, + 'config' => [], + 'labels' => [], + 'tags' => [], + 'prompt' => [ + [ + 'type' => 'chatmessage', + 'role' => 'unknown', + 'content' => 'Hello!', + ], + ], + ]; + + $factory = createLangfuseFactory('test-prompt', [ + new Response(200, body: json_encode($responseData)), + ]); + + expect(fn(): PromptTemplate => $factory->create()) + ->toThrow(PromptException::class, 'Unsupported message role: unknown'); +}); + +test('it throws exception for unsupported message type', function (): void { + $responseData = [ + 'type' => 'chat', + 'name' => 'test-prompt', + 'version' => 1, + 'config' => [], + 'labels' => [], + 'tags' => [], + 'prompt' => [ + [ + 'type' => 'unknown', + 'content' => 'Hello!', + ], + ], + ]; + + $factory = createLangfuseFactory('test-prompt', [ + new Response(200, body: json_encode($responseData)), + ]); + + expect(fn(): PromptTemplate => $factory->create()) + ->toThrow(PromptException::class, 'Unsupported message type: unknown'); +}); + +test('it builds correct url with version and label parameters', function (): void { + $responseData = [ + 'type' => 'text', + 'name' => 'test-prompt', + 'version' => 1, + 'config' => [], + 'labels' => [], + 'tags' => [], + 'prompt' => 'Test prompt', + ]; + + $factory = createLangfuseFactory('test-prompt', [ + new Response(200, body: json_encode($responseData)), + ]); + + $prompt = $factory->create(); + + expect($prompt)->toBeInstanceOf(TextPromptTemplate::class); +}); + +test('it handles http errors that return invalid json', function (): void { + $factory = createLangfuseFactory('nonexistent-prompt', [ + new Response(404, body: 'Prompt not found'), + ]); + + expect(fn(): PromptTemplate => $factory->create()) + ->toThrow(PromptException::class, 'Invalid JSON response from Langfuse: Syntax error'); +}); + +test('it handles malformed json response', function (): void { + $factory = createLangfuseFactory('test-prompt', [ + new Response(200, body: 'invalid json {'), + ]); + + expect(fn(): PromptTemplate => $factory->create()) + ->toThrow(PromptException::class, 'Invalid JSON response from Langfuse: Syntax error'); +}); + +test('it sets metadata from config using default resolver', function (): void { + $responseData = [ + 'type' => 'text', + 'name' => 'test-prompt', + 'version' => 1, + 'config' => [ + 'provider' => 'openai', + 'model' => 'gpt-4', + 'parameters' => [ + 'temperature' => 0.7, + 'max_tokens' => 100, + ], + 'tools' => ['calculator', 'search'], + 'structuredOutput' => 'json', + 'additional' => [ + 'custom_field' => 'custom_value', + ], + ], + 'labels' => [], + 'tags' => [], + 'prompt' => 'Test prompt with metadata', + ]; + + $factory = createLangfuseFactory('test-prompt', [ + new Response(200, body: json_encode($responseData)), + ]); + + /** @var \Cortex\Prompts\Templates\TextPromptTemplate $prompt */ + $prompt = $factory->create(); + + expect($prompt)->toBeInstanceOf(TextPromptTemplate::class); + expect($prompt->metadata)->not()->toBeNull(); + expect($prompt->metadata->provider)->toBe('openai'); + expect($prompt->metadata->model)->toBe('gpt-4'); + expect($prompt->metadata->parameters)->toBe([ + 'temperature' => 0.7, + 'max_tokens' => 100, + ]); + expect($prompt->metadata->tools)->toBe(['calculator', 'search']); + expect($prompt->metadata->structuredOutput)->toBe('json'); + expect($prompt->metadata->additional)->toBe([ + 'custom_field' => 'custom_value', + ]); +}); + +test('it sets metadata from empty config using default resolver', function (): void { + $responseData = [ + 'type' => 'chat', + 'name' => 'test-prompt', + 'version' => 1, + 'config' => [], + 'labels' => [], + 'tags' => [], + 'prompt' => [ + [ + 'type' => 'chatmessage', + 'role' => 'user', + 'content' => 'Hello!', + ], + ], + ]; + + $factory = createLangfuseFactory('test-prompt', [ + new Response(200, body: json_encode($responseData)), + ]); + + $prompt = $factory->create(); + + expect($prompt)->toBeInstanceOf(ChatPromptTemplate::class); + expect($prompt->metadata)->not()->toBeNull(); + expect($prompt->metadata->provider)->toBeNull(); + expect($prompt->metadata->model)->toBeNull(); + expect($prompt->metadata->parameters)->toBe([]); + expect($prompt->metadata->tools)->toBe([]); + expect($prompt->metadata->structuredOutput)->toBeNull(); + expect($prompt->metadata->additional)->toBe([]); +}); + +test('it sets metadata from partial config using default resolver', function (): void { + $responseData = [ + 'type' => 'text', + 'name' => 'test-prompt', + 'version' => 1, + 'config' => [ + 'provider' => 'anthropic', + 'parameters' => [ + 'temperature' => 0.5, + ], + // missing model, tools, structuredOutput, additional + ], + 'labels' => [], + 'tags' => [], + 'prompt' => 'Test prompt', + ]; + + $factory = createLangfuseFactory('test-prompt', [ + new Response(200, body: json_encode($responseData)), + ]); + + $prompt = $factory->create(); + + expect($prompt->metadata)->not()->toBeNull(); + expect($prompt->metadata->provider)->toBe('anthropic'); + expect($prompt->metadata->model)->toBeNull(); + expect($prompt->metadata->parameters)->toBe([ + 'temperature' => 0.5, + ]); + expect($prompt->metadata->tools)->toBe([]); + expect($prompt->metadata->structuredOutput)->toBeNull(); + expect($prompt->metadata->additional)->toBe([]); +}); + +test('it uses custom metadata resolver when provided', function (): void { + $responseData = [ + 'type' => 'text', + 'name' => 'test-prompt', + 'version' => 1, + 'config' => [ + 'llm_provider' => 'custom-provider', + 'llm_model' => 'custom-model', + 'extra_data' => 'some value', + ], + 'labels' => [], + 'tags' => [], + 'prompt' => 'Test prompt', + ]; + + $factory = createLangfuseFactory('test-prompt', [ + new Response(200, body: json_encode($responseData)), + ]); + + // Set a custom metadata resolver + $factory->resolveMetadataUsing(function (array $config): PromptMetadata { + return new PromptMetadata( + provider: $config['llm_provider'] ?? null, + model: $config['llm_model'] ?? null, + parameters: [ + 'custom' => true, + ], + tools: [], + structuredOutput: null, + additional: [ + 'extra' => $config['extra_data'] ?? null, + ], + ); + }); + + $prompt = $factory->create(); + + expect($prompt->metadata)->not()->toBeNull(); + expect($prompt->metadata->provider)->toBe('custom-provider'); + expect($prompt->metadata->model)->toBe('custom-model'); + expect($prompt->metadata->parameters)->toBe([ + 'custom' => true, + ]); + expect($prompt->metadata->tools)->toBe([]); + expect($prompt->metadata->structuredOutput)->toBeNull(); + expect($prompt->metadata->additional)->toBe([ + 'extra' => 'some value', + ]); +}); + +test('it returns same factory instance when setting custom metadata resolver', function (): void { + $factory = createLangfuseFactory('test-prompt', []); + + $resolver = fn(array $config): PromptMetadata => new PromptMetadata(); + $result = $factory->resolveMetadataUsing($resolver); + + expect($result)->toBe($factory); +}); + +test('it sets metadata on chat prompt template', function (): void { + $responseData = [ + 'type' => 'chat', + 'name' => 'test-prompt', + 'version' => 1, + 'config' => [ + 'provider' => 'openai', + 'model' => 'gpt-3.5-turbo', + ], + 'labels' => [], + 'tags' => [], + 'prompt' => [ + [ + 'type' => 'chatmessage', + 'role' => 'system', + 'content' => 'You are helpful.', + ], + ], + ]; + + $factory = createLangfuseFactory('test-prompt', [ + new Response(200, body: json_encode($responseData)), + ]); + + $prompt = $factory->create(); + + expect($prompt)->toBeInstanceOf(ChatPromptTemplate::class); + expect($prompt->metadata)->not()->toBeNull(); + expect($prompt->metadata->provider)->toBe('openai'); + expect($prompt->metadata->model)->toBe('gpt-3.5-turbo'); +}); From 074689615340284fed270bfd4f299518bd5600b4 Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Wed, 23 Jul 2025 08:40:16 +0100 Subject: [PATCH 2/9] tweaks --- src/Prompts/Contracts/PromptFactory.php | 2 +- .../Factories/LangfusePromptFactory.php | 42 ++++++----- src/Prompts/Factories/McpPromptFactory.php | 2 +- .../Factories/LangfusePromptFactoryTest.php | 74 ++++++++++--------- 4 files changed, 68 insertions(+), 52 deletions(-) diff --git a/src/Prompts/Contracts/PromptFactory.php b/src/Prompts/Contracts/PromptFactory.php index 1a03c13..65b0571 100644 --- a/src/Prompts/Contracts/PromptFactory.php +++ b/src/Prompts/Contracts/PromptFactory.php @@ -6,5 +6,5 @@ interface PromptFactory { - public function create(): PromptTemplate; + public function create(string $id): PromptTemplate; } diff --git a/src/Prompts/Factories/LangfusePromptFactory.php b/src/Prompts/Factories/LangfusePromptFactory.php index 3fd6813..832558f 100644 --- a/src/Prompts/Factories/LangfusePromptFactory.php +++ b/src/Prompts/Factories/LangfusePromptFactory.php @@ -8,6 +8,7 @@ use JsonException; use SensitiveParameter; use Cortex\LLM\Contracts\Message; +use Cortex\JsonSchema\SchemaFactory; use Psr\Http\Client\ClientInterface; use Cortex\Exceptions\PromptException; use Cortex\Prompts\Data\PromptMetadata; @@ -39,7 +40,7 @@ * prompt: array, * name: string, * version: int, - * config: mixed, + * config: array, * labels: array, * tags: array, * commitMessage?: string|null, @@ -50,7 +51,7 @@ * prompt: string, * name: string, * version: int, - * config: mixed, + * config: array, * labels: array, * tags: array, * commitMessage?: string|null, @@ -63,7 +64,6 @@ class LangfusePromptFactory implements PromptFactory use DiscoversPsrImplementations; public function __construct( - protected string $name, #[SensitiveParameter] protected string $username, #[SensitiveParameter] @@ -75,9 +75,9 @@ public function __construct( protected ?Closure $metadataResolver = null, ) {} - public function create(): PromptTemplate + public function create(string $id): PromptTemplate { - $data = $this->getResponseContent(); + $data = $this->getResponseContent($id); $builder = match ($data['type']) { 'chat' => $this->buildChatPrompt($data), @@ -137,19 +137,19 @@ protected function buildTextPrompt(array $data): TextPromptBuilder /** * @return LangfusePromptResponse */ - protected function getResponseContent(): array + protected function getResponseContent(string $id): array { $params = []; - if ($this->version !== null && $this->version !== 0) { + if ($this->version !== null) { $params['version'] = $this->version; } - if ($this->label !== null && $this->label !== '' && $this->label !== '0') { + if ($this->label !== null) { $params['label'] = $this->label; } - $uri = sprintf('%s/api/public/v2/prompts/%s', $this->baseUrl, $this->name); + $uri = sprintf('%s/api/public/v2/prompts/%s', $this->baseUrl, $id); if ($params !== []) { $uri .= '?' . http_build_query($params); @@ -178,13 +178,21 @@ protected function getResponseContent(): array */ protected static function defaultMetadataResolver(): Closure { - return fn(array $config): PromptMetadata => new PromptMetadata( - $config['provider'] ?? null, - $config['model'] ?? null, - $config['parameters'] ?? [], - $config['tools'] ?? [], - $config['structuredOutput'] ?? null, - $config['additional'] ?? [], - ); + return function (array $config): PromptMetadata { + $structuredOutput = $config['structuredOutput'] ?? null; + + if (is_string($structuredOutput) || is_array($structuredOutput)) { + $structuredOutput = SchemaFactory::fromJson($structuredOutput); + } + + return new PromptMetadata( + $config['provider'] ?? null, + $config['model'] ?? null, + $config['parameters'] ?? [], + $config['tools'] ?? [], + $structuredOutput, + $config['additional'] ?? [], + ); + }; } } diff --git a/src/Prompts/Factories/McpPromptFactory.php b/src/Prompts/Factories/McpPromptFactory.php index b304dd5..7694fbf 100644 --- a/src/Prompts/Factories/McpPromptFactory.php +++ b/src/Prompts/Factories/McpPromptFactory.php @@ -13,7 +13,7 @@ */ class McpPromptFactory implements PromptFactory { - public function create(): PromptTemplate + public function create(string $id): PromptTemplate { // TODO: Implement return new ChatPromptTemplate([]); diff --git a/tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php b/tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php index 3492a23..6ab375a 100644 --- a/tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php +++ b/tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php @@ -10,6 +10,7 @@ use GuzzleHttp\Handler\MockHandler; use Cortex\Exceptions\PromptException; use Cortex\Prompts\Data\PromptMetadata; +use Cortex\JsonSchema\Types\ObjectSchema; use Cortex\LLM\Data\Messages\UserMessage; use Cortex\LLM\Data\Messages\SystemMessage; use Cortex\Prompts\Contracts\PromptTemplate; @@ -20,11 +21,9 @@ use Cortex\Prompts\Factories\LangfusePromptFactory; function createLangfuseFactory( - string $name, array $responses, ): LangfusePromptFactory { return new LangfusePromptFactory( - name: $name, username: 'username', password: 'password', httpClient: new Client([ @@ -59,11 +58,11 @@ function createLangfuseFactory( ], ]; - $factory = createLangfuseFactory('test-chat-prompt', [ + $factory = createLangfuseFactory([ new Response(200, body: json_encode($responseData)), ]); - $prompt = $factory->create(); + $prompt = $factory->create('test-chat-prompt'); expect($prompt)->toBeInstanceOf(ChatPromptTemplate::class); expect($prompt->messages)->toHaveCount(3); @@ -86,11 +85,11 @@ function createLangfuseFactory( 'prompt' => 'Write a story about {{topic}}. Make it {{length}} words long.', ]; - $factory = createLangfuseFactory('test-text-prompt', [ + $factory = createLangfuseFactory([ new Response(200, body: json_encode($responseData)), ]); - $prompt = $factory->create(); + $prompt = $factory->create('test-text-prompt'); expect($prompt)->toBeInstanceOf(TextPromptTemplate::class); expect($prompt->text)->toBe('Write a story about {{topic}}. Make it {{length}} words long.'); @@ -118,11 +117,11 @@ function createLangfuseFactory( ], ]; - $factory = createLangfuseFactory('test-assistant-prompt', [ + $factory = createLangfuseFactory([ new Response(200, body: json_encode($responseData)), ]); - $prompt = $factory->create(); + $prompt = $factory->create('test-assistant-prompt'); expect($prompt->messages[0])->toBeInstanceOf(UserMessage::class); expect($prompt->messages[1])->toBeInstanceOf(AssistantMessage::class); @@ -140,11 +139,11 @@ function createLangfuseFactory( 'prompt' => 'Some content', ]; - $factory = createLangfuseFactory('test-prompt', [ + $factory = createLangfuseFactory([ new Response(200, body: json_encode($responseData)), ]); - expect(fn(): PromptTemplate => $factory->create()) + expect(fn(): PromptTemplate => $factory->create('test-prompt')) ->toThrow(PromptException::class, 'Unsupported prompt type: unsupported'); }); @@ -165,11 +164,11 @@ function createLangfuseFactory( ], ]; - $factory = createLangfuseFactory('test-prompt', [ + $factory = createLangfuseFactory([ new Response(200, body: json_encode($responseData)), ]); - expect(fn(): PromptTemplate => $factory->create()) + expect(fn(): PromptTemplate => $factory->create('test-prompt')) ->toThrow(PromptException::class, 'Unsupported message role: unknown'); }); @@ -189,11 +188,11 @@ function createLangfuseFactory( ], ]; - $factory = createLangfuseFactory('test-prompt', [ + $factory = createLangfuseFactory([ new Response(200, body: json_encode($responseData)), ]); - expect(fn(): PromptTemplate => $factory->create()) + expect(fn(): PromptTemplate => $factory->create('test-prompt')) ->toThrow(PromptException::class, 'Unsupported message type: unknown'); }); @@ -208,30 +207,30 @@ function createLangfuseFactory( 'prompt' => 'Test prompt', ]; - $factory = createLangfuseFactory('test-prompt', [ + $factory = createLangfuseFactory([ new Response(200, body: json_encode($responseData)), ]); - $prompt = $factory->create(); + $prompt = $factory->create('test-prompt'); expect($prompt)->toBeInstanceOf(TextPromptTemplate::class); }); test('it handles http errors that return invalid json', function (): void { - $factory = createLangfuseFactory('nonexistent-prompt', [ + $factory = createLangfuseFactory([ new Response(404, body: 'Prompt not found'), ]); - expect(fn(): PromptTemplate => $factory->create()) + expect(fn(): PromptTemplate => $factory->create('nonexistent-prompt')) ->toThrow(PromptException::class, 'Invalid JSON response from Langfuse: Syntax error'); }); test('it handles malformed json response', function (): void { - $factory = createLangfuseFactory('test-prompt', [ + $factory = createLangfuseFactory([ new Response(200, body: 'invalid json {'), ]); - expect(fn(): PromptTemplate => $factory->create()) + expect(fn(): PromptTemplate => $factory->create('test-prompt')) ->toThrow(PromptException::class, 'Invalid JSON response from Langfuse: Syntax error'); }); @@ -248,7 +247,15 @@ function createLangfuseFactory( 'max_tokens' => 100, ], 'tools' => ['calculator', 'search'], - 'structuredOutput' => 'json', + 'structuredOutput' => [ + 'type' => 'object', + 'properties' => [ + 'name' => [ + 'type' => 'string', + ], + ], + 'required' => ['name'], + ], 'additional' => [ 'custom_field' => 'custom_value', ], @@ -258,12 +265,12 @@ function createLangfuseFactory( 'prompt' => 'Test prompt with metadata', ]; - $factory = createLangfuseFactory('test-prompt', [ + $factory = createLangfuseFactory([ new Response(200, body: json_encode($responseData)), ]); /** @var \Cortex\Prompts\Templates\TextPromptTemplate $prompt */ - $prompt = $factory->create(); + $prompt = $factory->create('test-prompt'); expect($prompt)->toBeInstanceOf(TextPromptTemplate::class); expect($prompt->metadata)->not()->toBeNull(); @@ -274,7 +281,8 @@ function createLangfuseFactory( 'max_tokens' => 100, ]); expect($prompt->metadata->tools)->toBe(['calculator', 'search']); - expect($prompt->metadata->structuredOutput)->toBe('json'); + expect($prompt->metadata->structuredOutput)->toBeInstanceOf(ObjectSchema::class); + expect($prompt->metadata->structuredOutput->getPropertyKeys())->toBe(['name']); expect($prompt->metadata->additional)->toBe([ 'custom_field' => 'custom_value', ]); @@ -297,11 +305,11 @@ function createLangfuseFactory( ], ]; - $factory = createLangfuseFactory('test-prompt', [ + $factory = createLangfuseFactory([ new Response(200, body: json_encode($responseData)), ]); - $prompt = $factory->create(); + $prompt = $factory->create('test-prompt'); expect($prompt)->toBeInstanceOf(ChatPromptTemplate::class); expect($prompt->metadata)->not()->toBeNull(); @@ -330,11 +338,11 @@ function createLangfuseFactory( 'prompt' => 'Test prompt', ]; - $factory = createLangfuseFactory('test-prompt', [ + $factory = createLangfuseFactory([ new Response(200, body: json_encode($responseData)), ]); - $prompt = $factory->create(); + $prompt = $factory->create('test-prompt'); expect($prompt->metadata)->not()->toBeNull(); expect($prompt->metadata->provider)->toBe('anthropic'); @@ -362,7 +370,7 @@ function createLangfuseFactory( 'prompt' => 'Test prompt', ]; - $factory = createLangfuseFactory('test-prompt', [ + $factory = createLangfuseFactory([ new Response(200, body: json_encode($responseData)), ]); @@ -382,7 +390,7 @@ function createLangfuseFactory( ); }); - $prompt = $factory->create(); + $prompt = $factory->create('test-prompt'); expect($prompt->metadata)->not()->toBeNull(); expect($prompt->metadata->provider)->toBe('custom-provider'); @@ -398,7 +406,7 @@ function createLangfuseFactory( }); test('it returns same factory instance when setting custom metadata resolver', function (): void { - $factory = createLangfuseFactory('test-prompt', []); + $factory = createLangfuseFactory([]); $resolver = fn(array $config): PromptMetadata => new PromptMetadata(); $result = $factory->resolveMetadataUsing($resolver); @@ -426,11 +434,11 @@ function createLangfuseFactory( ], ]; - $factory = createLangfuseFactory('test-prompt', [ + $factory = createLangfuseFactory([ new Response(200, body: json_encode($responseData)), ]); - $prompt = $factory->create(); + $prompt = $factory->create('test-prompt'); expect($prompt)->toBeInstanceOf(ChatPromptTemplate::class); expect($prompt->metadata)->not()->toBeNull(); From 93a0e14eaad5ce3251d501aa8e11068a8c792aaf Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Wed, 23 Jul 2025 08:50:07 +0100 Subject: [PATCH 3/9] rename to make --- src/Prompts/Contracts/PromptFactory.php | 2 +- .../Factories/LangfusePromptFactory.php | 8 +++--- src/Prompts/Factories/McpPromptFactory.php | 2 +- .../Factories/LangfusePromptFactoryTest.php | 28 +++++++++---------- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/Prompts/Contracts/PromptFactory.php b/src/Prompts/Contracts/PromptFactory.php index 65b0571..81dd7a3 100644 --- a/src/Prompts/Contracts/PromptFactory.php +++ b/src/Prompts/Contracts/PromptFactory.php @@ -6,5 +6,5 @@ interface PromptFactory { - public function create(string $id): PromptTemplate; + public function make(string $name): PromptTemplate; } diff --git a/src/Prompts/Factories/LangfusePromptFactory.php b/src/Prompts/Factories/LangfusePromptFactory.php index 832558f..f0bec5b 100644 --- a/src/Prompts/Factories/LangfusePromptFactory.php +++ b/src/Prompts/Factories/LangfusePromptFactory.php @@ -75,9 +75,9 @@ public function __construct( protected ?Closure $metadataResolver = null, ) {} - public function create(string $id): PromptTemplate + public function make(string $name): PromptTemplate { - $data = $this->getResponseContent($id); + $data = $this->getResponseContent($name); $builder = match ($data['type']) { 'chat' => $this->buildChatPrompt($data), @@ -137,7 +137,7 @@ protected function buildTextPrompt(array $data): TextPromptBuilder /** * @return LangfusePromptResponse */ - protected function getResponseContent(string $id): array + protected function getResponseContent(string $name): array { $params = []; @@ -149,7 +149,7 @@ protected function getResponseContent(string $id): array $params['label'] = $this->label; } - $uri = sprintf('%s/api/public/v2/prompts/%s', $this->baseUrl, $id); + $uri = sprintf('%s/api/public/v2/prompts/%s', $this->baseUrl, $name); if ($params !== []) { $uri .= '?' . http_build_query($params); diff --git a/src/Prompts/Factories/McpPromptFactory.php b/src/Prompts/Factories/McpPromptFactory.php index 7694fbf..02ebb2e 100644 --- a/src/Prompts/Factories/McpPromptFactory.php +++ b/src/Prompts/Factories/McpPromptFactory.php @@ -13,7 +13,7 @@ */ class McpPromptFactory implements PromptFactory { - public function create(string $id): PromptTemplate + public function make(string $name): PromptTemplate { // TODO: Implement return new ChatPromptTemplate([]); diff --git a/tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php b/tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php index 6ab375a..28d20cd 100644 --- a/tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php +++ b/tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php @@ -62,7 +62,7 @@ function createLangfuseFactory( new Response(200, body: json_encode($responseData)), ]); - $prompt = $factory->create('test-chat-prompt'); + $prompt = $factory->make('test-chat-prompt'); expect($prompt)->toBeInstanceOf(ChatPromptTemplate::class); expect($prompt->messages)->toHaveCount(3); @@ -89,7 +89,7 @@ function createLangfuseFactory( new Response(200, body: json_encode($responseData)), ]); - $prompt = $factory->create('test-text-prompt'); + $prompt = $factory->make('test-text-prompt'); expect($prompt)->toBeInstanceOf(TextPromptTemplate::class); expect($prompt->text)->toBe('Write a story about {{topic}}. Make it {{length}} words long.'); @@ -121,7 +121,7 @@ function createLangfuseFactory( new Response(200, body: json_encode($responseData)), ]); - $prompt = $factory->create('test-assistant-prompt'); + $prompt = $factory->make('test-assistant-prompt'); expect($prompt->messages[0])->toBeInstanceOf(UserMessage::class); expect($prompt->messages[1])->toBeInstanceOf(AssistantMessage::class); @@ -143,7 +143,7 @@ function createLangfuseFactory( new Response(200, body: json_encode($responseData)), ]); - expect(fn(): PromptTemplate => $factory->create('test-prompt')) + expect(fn(): PromptTemplate => $factory->make('test-prompt')) ->toThrow(PromptException::class, 'Unsupported prompt type: unsupported'); }); @@ -168,7 +168,7 @@ function createLangfuseFactory( new Response(200, body: json_encode($responseData)), ]); - expect(fn(): PromptTemplate => $factory->create('test-prompt')) + expect(fn(): PromptTemplate => $factory->make('test-prompt')) ->toThrow(PromptException::class, 'Unsupported message role: unknown'); }); @@ -192,7 +192,7 @@ function createLangfuseFactory( new Response(200, body: json_encode($responseData)), ]); - expect(fn(): PromptTemplate => $factory->create('test-prompt')) + expect(fn(): PromptTemplate => $factory->make('test-prompt')) ->toThrow(PromptException::class, 'Unsupported message type: unknown'); }); @@ -211,7 +211,7 @@ function createLangfuseFactory( new Response(200, body: json_encode($responseData)), ]); - $prompt = $factory->create('test-prompt'); + $prompt = $factory->make('test-prompt'); expect($prompt)->toBeInstanceOf(TextPromptTemplate::class); }); @@ -221,7 +221,7 @@ function createLangfuseFactory( new Response(404, body: 'Prompt not found'), ]); - expect(fn(): PromptTemplate => $factory->create('nonexistent-prompt')) + expect(fn(): PromptTemplate => $factory->make('nonexistent-prompt')) ->toThrow(PromptException::class, 'Invalid JSON response from Langfuse: Syntax error'); }); @@ -230,7 +230,7 @@ function createLangfuseFactory( new Response(200, body: 'invalid json {'), ]); - expect(fn(): PromptTemplate => $factory->create('test-prompt')) + expect(fn(): PromptTemplate => $factory->make('test-prompt')) ->toThrow(PromptException::class, 'Invalid JSON response from Langfuse: Syntax error'); }); @@ -270,7 +270,7 @@ function createLangfuseFactory( ]); /** @var \Cortex\Prompts\Templates\TextPromptTemplate $prompt */ - $prompt = $factory->create('test-prompt'); + $prompt = $factory->make('test-prompt'); expect($prompt)->toBeInstanceOf(TextPromptTemplate::class); expect($prompt->metadata)->not()->toBeNull(); @@ -309,7 +309,7 @@ function createLangfuseFactory( new Response(200, body: json_encode($responseData)), ]); - $prompt = $factory->create('test-prompt'); + $prompt = $factory->make('test-prompt'); expect($prompt)->toBeInstanceOf(ChatPromptTemplate::class); expect($prompt->metadata)->not()->toBeNull(); @@ -342,7 +342,7 @@ function createLangfuseFactory( new Response(200, body: json_encode($responseData)), ]); - $prompt = $factory->create('test-prompt'); + $prompt = $factory->make('test-prompt'); expect($prompt->metadata)->not()->toBeNull(); expect($prompt->metadata->provider)->toBe('anthropic'); @@ -390,7 +390,7 @@ function createLangfuseFactory( ); }); - $prompt = $factory->create('test-prompt'); + $prompt = $factory->make('test-prompt'); expect($prompt->metadata)->not()->toBeNull(); expect($prompt->metadata->provider)->toBe('custom-provider'); @@ -438,7 +438,7 @@ function createLangfuseFactory( new Response(200, body: json_encode($responseData)), ]); - $prompt = $factory->create('test-prompt'); + $prompt = $factory->make('test-prompt'); expect($prompt)->toBeInstanceOf(ChatPromptTemplate::class); expect($prompt->metadata)->not()->toBeNull(); From a492007b02a90f93a1042f187c1cd277e307eeff Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Wed, 23 Jul 2025 09:00:41 +0100 Subject: [PATCH 4/9] move to config --- src/Prompts/Contracts/PromptFactory.php | 2 +- .../Factories/LangfusePromptFactory.php | 19 ++++++++++--------- src/Prompts/Factories/McpPromptFactory.php | 2 +- .../Factories/LangfusePromptFactoryTest.php | 9 +++++++-- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/Prompts/Contracts/PromptFactory.php b/src/Prompts/Contracts/PromptFactory.php index 81dd7a3..10cf006 100644 --- a/src/Prompts/Contracts/PromptFactory.php +++ b/src/Prompts/Contracts/PromptFactory.php @@ -6,5 +6,5 @@ interface PromptFactory { - public function make(string $name): PromptTemplate; + public function make(string $name, array $config = []): PromptTemplate; } diff --git a/src/Prompts/Factories/LangfusePromptFactory.php b/src/Prompts/Factories/LangfusePromptFactory.php index f0bec5b..0795ec1 100644 --- a/src/Prompts/Factories/LangfusePromptFactory.php +++ b/src/Prompts/Factories/LangfusePromptFactory.php @@ -68,16 +68,17 @@ public function __construct( protected string $username, #[SensitiveParameter] protected string $password, - protected ?int $version = null, - protected ?string $label = null, protected string $baseUrl = 'https://cloud.langfuse.com', protected ?ClientInterface $httpClient = null, protected ?Closure $metadataResolver = null, ) {} - public function make(string $name): PromptTemplate + /** + * @param array{version?: int, label?: string} $config + */ + public function make(string $name, array $config = []): PromptTemplate { - $data = $this->getResponseContent($name); + $data = $this->getResponseContent($name, $config); $builder = match ($data['type']) { 'chat' => $this->buildChatPrompt($data), @@ -137,16 +138,16 @@ protected function buildTextPrompt(array $data): TextPromptBuilder /** * @return LangfusePromptResponse */ - protected function getResponseContent(string $name): array + protected function getResponseContent(string $name, array $config = []): array { $params = []; - if ($this->version !== null) { - $params['version'] = $this->version; + if (isset($config['version'])) { + $params['version'] = $config['version']; } - if ($this->label !== null) { - $params['label'] = $this->label; + if (isset($config['label'])) { + $params['label'] = $config['label']; } $uri = sprintf('%s/api/public/v2/prompts/%s', $this->baseUrl, $name); diff --git a/src/Prompts/Factories/McpPromptFactory.php b/src/Prompts/Factories/McpPromptFactory.php index 02ebb2e..499f478 100644 --- a/src/Prompts/Factories/McpPromptFactory.php +++ b/src/Prompts/Factories/McpPromptFactory.php @@ -13,7 +13,7 @@ */ class McpPromptFactory implements PromptFactory { - public function make(string $name): PromptTemplate + public function make(string $name, array $config = []): PromptTemplate { // TODO: Implement return new ChatPromptTemplate([]); diff --git a/tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php b/tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php index 28d20cd..7a2c804 100644 --- a/tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php +++ b/tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php @@ -80,7 +80,9 @@ function createLangfuseFactory( 'name' => 'test-text-prompt', 'version' => 2, 'config' => [], - 'labels' => [], + 'labels' => [ + 'production', + ], 'tags' => [], 'prompt' => 'Write a story about {{topic}}. Make it {{length}} words long.', ]; @@ -89,7 +91,10 @@ function createLangfuseFactory( new Response(200, body: json_encode($responseData)), ]); - $prompt = $factory->make('test-text-prompt'); + $prompt = $factory->make('test-text-prompt', [ + 'version' => 2, + 'label' => 'production', + ]); expect($prompt)->toBeInstanceOf(TextPromptTemplate::class); expect($prompt->text)->toBe('Write a story about {{topic}}. Make it {{length}} words long.'); From 67aef26487352ec45bd5d437b3aad46ab699f2e0 Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Thu, 24 Jul 2025 00:22:09 +0100 Subject: [PATCH 5/9] updates --- config/cortex.php | 24 ++++++++++++ scratchpad.php | 38 ++++++++++++++++++- src/Cortex.php | 34 +++++++++++------ src/CortexServiceProvider.php | 6 +++ src/Facades/PromptFactory.php | 22 +++++++++++ src/Prompts/Contracts/PromptBuilder.php | 25 ++++++++++++ src/Prompts/Contracts/PromptFactory.php | 7 +++- src/Prompts/Enums/PromptType.php | 22 +++++++++++ .../Factories/LangfusePromptFactory.php | 18 +++++---- src/Prompts/Prompt.php | 32 ++++++++++++++++ src/Prompts/PromptFactoryManager.php | 28 ++++++++++++++ 11 files changed, 234 insertions(+), 22 deletions(-) create mode 100644 src/Facades/PromptFactory.php create mode 100644 src/Prompts/Enums/PromptType.php create mode 100644 src/Prompts/Prompt.php create mode 100644 src/Prompts/PromptFactoryManager.php diff --git a/config/cortex.php b/config/cortex.php index c5d31b8..4045ec0 100644 --- a/config/cortex.php +++ b/config/cortex.php @@ -193,6 +193,30 @@ ], ], + /* + |-------------------------------------------------------------------------- + | Prompt Factories + |-------------------------------------------------------------------------- + | + | Here you may define the prompt factories. + | + | Supported drivers: "langfuse", "mcp" + | + */ + 'prompt_factory' => [ + 'default' => env('CORTEX_DEFAULT_PROMPT_FACTORY', 'langfuse'), + + 'langfuse' => [ + 'username' => env('LANGFUSE_USERNAME', ''), + 'password' => env('LANGFUSE_PASSWORD', ''), + 'base_uri' => env('LANGFUSE_BASE_URI', 'https://cloud.langfuse.com'), + ], + + // 'mcp' => [ + // 'base_uri' => env('MCP_BASE_URI', 'http://localhost:3000'), + // ], + ], + /* |-------------------------------------------------------------------------- | Embedding Providers diff --git a/scratchpad.php b/scratchpad.php index efb40bd..48592a4 100644 --- a/scratchpad.php +++ b/scratchpad.php @@ -3,7 +3,9 @@ declare(strict_types=1); use Cortex\Cortex; +use Cortex\Prompts\Prompt; use Cortex\JsonSchema\SchemaFactory; +use Cortex\Prompts\Enums\PromptType; use Cortex\LLM\Data\Messages\UserMessage; use Cortex\LLM\Data\Messages\SystemMessage; @@ -18,11 +20,45 @@ // Or more simply $result = Cortex::prompt('What is the capital of {country}?') - ->llm('anthropic', 'claude-3-5-sonnet-20240620') + ->metadata( + provider: 'anthropic', + model: 'claude-3-5-sonnet-20240620', + structuredOutput: SchemaFactory::object()->properties( + SchemaFactory::string('capital'), + ), + ) + ->llm() ->invoke([ 'country' => 'France', ]); +// Get a text prompt builder +$prompt = Cortex::prompt('What is the capital of {country}?'); + +// Get a chat prompt builder +$prompt = Cortex::prompt([ + new SystemMessage('You are an expert at geography.'), + new UserMessage('What is the capital of {country}?'), +]); + +// Get a prompt from the given factory +$prompt = Cortex::prompt()->factory('langfuse')->make('test-prompt'); + +// Get a chat prompt builder +$prompt = Cortex::prompt()->builder(PromptType::Chat)->messages([ + new SystemMessage('You are an expert at geography.'), + new UserMessage('What is the capital of {country}?'), +]); + +// Get a text prompt builder +$prompt = Cortex::prompt()->builder(PromptType::Text); + +$prompt = Prompt::factory()->make('test-prompt'); +$prompt = Prompt::builder()->messages([ + new SystemMessage('You are an expert at geography.'), + new UserMessage('What is the capital of {country}?'), +]); + // Uses default llm driver $result = Cortex::llm()->invoke([ new SystemMessage('You are a helpful assistant'), diff --git a/src/Cortex.php b/src/Cortex.php index aa20a01..e9e00ac 100644 --- a/src/Cortex.php +++ b/src/Cortex.php @@ -5,11 +5,13 @@ namespace Cortex; use Cortex\Facades\LLM; +use Cortex\Prompts\Prompt; use Cortex\Facades\Embeddings; use Cortex\Tasks\Enums\TaskType; +use Cortex\Prompts\Enums\PromptType; use Cortex\Tasks\Builders\TextTaskBuilder; +use Cortex\Prompts\Contracts\PromptBuilder; use Cortex\LLM\Contracts\LLM as LLMContract; -use Cortex\Prompts\Builders\ChatPromptBuilder; use Cortex\LLM\Data\Messages\MessageCollection; use Cortex\Tasks\Builders\StructuredTaskBuilder; use Cortex\Embeddings\Contracts\Embeddings as EmbeddingsContract; @@ -17,20 +19,22 @@ class Cortex { /** - * Create a chat prompt builder. + * Create a prompt builder or factory. * - * @param \Cortex\LLM\Data\Messages\MessageCollection|array|string $messages + * @param \Cortex\LLM\Data\Messages\MessageCollection|array|string|null $messages + * + * @return ($messages is null ? \Cortex\Prompts\Prompt : ($messages is string ? \Cortex\Prompts\Builders\TextPromptBuilder : \Cortex\Prompts\Builders\ChatPromptBuilder)) */ public static function prompt( - MessageCollection|array|string|null $messages, - ): ChatPromptBuilder { - $builder = new ChatPromptBuilder(); - - if ($messages !== null) { - $builder->messages($messages); + MessageCollection|array|string|null $messages = null, + ): Prompt|PromptBuilder { + if (func_num_args() === 0) { + return new Prompt(); } - return $builder; + return is_string($messages) + ? Prompt::builder(PromptType::Text)->text($messages) + : Prompt::builder(PromptType::Chat)->messages($messages); } /** @@ -47,9 +51,15 @@ public static function llm(?string $provider = null, ?string $model = null): LLM return $llm; } - public static function embeddings(?string $driver = null): EmbeddingsContract + public static function embeddings(?string $driver = null, ?string $model = null): EmbeddingsContract { - return Embeddings::driver($driver); + $embeddings = Embeddings::driver($driver); + + if ($model !== null) { + $embeddings->withModel($model); + } + + return $embeddings; } /** diff --git a/src/CortexServiceProvider.php b/src/CortexServiceProvider.php index aec67f0..b0aa236 100644 --- a/src/CortexServiceProvider.php +++ b/src/CortexServiceProvider.php @@ -9,7 +9,9 @@ use Cortex\ModelInfo\ModelInfoFactory; use Spatie\LaravelPackageTools\Package; use Cortex\Embeddings\EmbeddingsManager; +use Cortex\Prompts\PromptFactoryManager; use Cortex\Embeddings\Contracts\Embeddings; +use Cortex\Prompts\Contracts\PromptFactory; use Illuminate\Contracts\Container\Container; use Spatie\LaravelPackageTools\PackageServiceProvider; @@ -30,6 +32,10 @@ public function packageRegistered(): void $this->app->alias('cortex.embeddings', EmbeddingsManager::class); $this->app->bind(Embeddings::class, fn(Container $app) => $app->make('cortex.embeddings')->driver()); + $this->app->singleton('cortex.prompt_factory', fn(Container $app): PromptFactoryManager => new PromptFactoryManager($app)); + $this->app->alias('cortex.prompt_factory', PromptFactoryManager::class); + $this->app->bind(PromptFactory::class, fn(Container $app) => $app->make('cortex.prompt_factory')->driver()); + $this->app->singleton('cortex.model_info_factory', function (Container $app): ModelInfoFactory { $providers = []; foreach ($app->make('config')->get('cortex.model_info_providers', []) as $provider => $config) { diff --git a/src/Facades/PromptFactory.php b/src/Facades/PromptFactory.php new file mode 100644 index 0000000..089d845 --- /dev/null +++ b/src/Facades/PromptFactory.php @@ -0,0 +1,22 @@ + $options = []) + * @method static string getDefaultDriver() + * + * @see \Cortex\Prompts\PromptFactoryManager + */ +class PromptFactory extends Facade +{ + protected static function getFacadeAccessor(): string + { + return 'cortex.prompt_factory'; + } +} diff --git a/src/Prompts/Contracts/PromptBuilder.php b/src/Prompts/Contracts/PromptBuilder.php index b95cba4..2698dc2 100644 --- a/src/Prompts/Contracts/PromptBuilder.php +++ b/src/Prompts/Contracts/PromptBuilder.php @@ -4,10 +4,35 @@ namespace Cortex\Prompts\Contracts; +use Closure; +use Cortex\Pipeline; +use Cortex\LLM\Contracts\LLM; +use Cortex\JsonSchema\Types\ObjectSchema; +use Cortex\LLM\Data\StructuredOutputConfig; + interface PromptBuilder { /** * Build the prompt template. */ public function build(): PromptTemplate; + + /** + * Convenience method to build and pipe the prompt template to an LLM. + */ + public function llm(LLM|string|null $provider = null, Closure|string|null $model = null): Pipeline; + + /** + * @param array $parameters + * @param array $tools + * @param array $additional + */ + public function metadata( + ?string $provider = null, + ?string $model = null, + array $parameters = [], + array $tools = [], + StructuredOutputConfig|ObjectSchema|string|null $structuredOutput = null, + array $additional = [], + ): self; } diff --git a/src/Prompts/Contracts/PromptFactory.php b/src/Prompts/Contracts/PromptFactory.php index 10cf006..207ee79 100644 --- a/src/Prompts/Contracts/PromptFactory.php +++ b/src/Prompts/Contracts/PromptFactory.php @@ -6,5 +6,10 @@ interface PromptFactory { - public function make(string $name, array $config = []): PromptTemplate; + /** + * Make a prompt template from the given name and options. + * + * @param array $options + */ + public function make(string $name, array $options = []): PromptTemplate; } diff --git a/src/Prompts/Enums/PromptType.php b/src/Prompts/Enums/PromptType.php new file mode 100644 index 0000000..ccfbdaf --- /dev/null +++ b/src/Prompts/Enums/PromptType.php @@ -0,0 +1,22 @@ + new ChatPromptBuilder(), + self::Text => new TextPromptBuilder(), + }; + } +} diff --git a/src/Prompts/Factories/LangfusePromptFactory.php b/src/Prompts/Factories/LangfusePromptFactory.php index 0795ec1..4a94963 100644 --- a/src/Prompts/Factories/LangfusePromptFactory.php +++ b/src/Prompts/Factories/LangfusePromptFactory.php @@ -74,11 +74,11 @@ public function __construct( ) {} /** - * @param array{version?: int, label?: string} $config + * @param array{version?: int, label?: string} $options */ - public function make(string $name, array $config = []): PromptTemplate + public function make(string $name, array $options = []): PromptTemplate { - $data = $this->getResponseContent($name, $config); + $data = $this->getResponseContent($name, $options); $builder = match ($data['type']) { 'chat' => $this->buildChatPrompt($data), @@ -136,18 +136,20 @@ protected function buildTextPrompt(array $data): TextPromptBuilder } /** + * @param array{version?: int, label?: string} $options + * * @return LangfusePromptResponse */ - protected function getResponseContent(string $name, array $config = []): array + protected function getResponseContent(string $name, array $options = []): array { $params = []; - if (isset($config['version'])) { - $params['version'] = $config['version']; + if (isset($options['version'])) { + $params['version'] = $options['version']; } - if (isset($config['label'])) { - $params['label'] = $config['label']; + if (isset($options['label'])) { + $params['label'] = $options['label']; } $uri = sprintf('%s/api/public/v2/prompts/%s', $this->baseUrl, $name); diff --git a/src/Prompts/Prompt.php b/src/Prompts/Prompt.php new file mode 100644 index 0000000..e9da828 --- /dev/null +++ b/src/Prompts/Prompt.php @@ -0,0 +1,32 @@ +builder(); + } + + /** + * Get the prompt factory for the given driver. + */ + public static function factory(?string $driver = null): PromptFactoryContract + { + return PromptFactory::driver($driver); + } +} diff --git a/src/Prompts/PromptFactoryManager.php b/src/Prompts/PromptFactoryManager.php new file mode 100644 index 0000000..ddc0bbc --- /dev/null +++ b/src/Prompts/PromptFactoryManager.php @@ -0,0 +1,28 @@ +config->get('cortex.prompt_factory.default'); + } + + public function createLangfuseDriver(): LangfusePromptFactory + { + /** @var array $config */ + $config = $this->config->get('cortex.prompt_factory.langfuse'); + + return new LangfusePromptFactory( + $config['username'] ?? '', + $config['password'] ?? '', + $config['base_uri'] ?? 'https://cloud.langfuse.com', + ); + } +} From 819b0277258a6d84ec4349186eea1532475120e6 Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Wed, 30 Jul 2025 08:22:05 +0100 Subject: [PATCH 6/9] updates --- scratchpad.php | 5 ++--- src/Cortex.php | 7 +++---- src/LLM/LLMManager.php | 4 ++-- src/Prompts/Enums/PromptType.php | 8 +++++++- src/Prompts/Factories/LangfusePromptFactory.php | 3 +++ src/Prompts/Factories/McpPromptFactory.php | 4 ++++ src/Prompts/Prompt.php | 11 ++++++----- src/Prompts/PromptFactoryManager.php | 13 ++++++++++++- 8 files changed, 39 insertions(+), 16 deletions(-) diff --git a/scratchpad.php b/scratchpad.php index 48592a4..b61d040 100644 --- a/scratchpad.php +++ b/scratchpad.php @@ -5,7 +5,6 @@ use Cortex\Cortex; use Cortex\Prompts\Prompt; use Cortex\JsonSchema\SchemaFactory; -use Cortex\Prompts\Enums\PromptType; use Cortex\LLM\Data\Messages\UserMessage; use Cortex\LLM\Data\Messages\SystemMessage; @@ -45,13 +44,13 @@ $prompt = Cortex::prompt()->factory('langfuse')->make('test-prompt'); // Get a chat prompt builder -$prompt = Cortex::prompt()->builder(PromptType::Chat)->messages([ +$prompt = Cortex::prompt()->builder('chat')->messages([ new SystemMessage('You are an expert at geography.'), new UserMessage('What is the capital of {country}?'), ]); // Get a text prompt builder -$prompt = Cortex::prompt()->builder(PromptType::Text); +$prompt = Cortex::prompt()->builder('text'); $prompt = Prompt::factory()->make('test-prompt'); $prompt = Prompt::builder()->messages([ diff --git a/src/Cortex.php b/src/Cortex.php index e9e00ac..e212c66 100644 --- a/src/Cortex.php +++ b/src/Cortex.php @@ -8,7 +8,6 @@ use Cortex\Prompts\Prompt; use Cortex\Facades\Embeddings; use Cortex\Tasks\Enums\TaskType; -use Cortex\Prompts\Enums\PromptType; use Cortex\Tasks\Builders\TextTaskBuilder; use Cortex\Prompts\Contracts\PromptBuilder; use Cortex\LLM\Contracts\LLM as LLMContract; @@ -21,7 +20,7 @@ class Cortex /** * Create a prompt builder or factory. * - * @param \Cortex\LLM\Data\Messages\MessageCollection|array|string|null $messages + * @param \Cortex\LLM\Data\Messages\MessageCollection|array|string|null $messages * * @return ($messages is null ? \Cortex\Prompts\Prompt : ($messages is string ? \Cortex\Prompts\Builders\TextPromptBuilder : \Cortex\Prompts\Builders\ChatPromptBuilder)) */ @@ -33,8 +32,8 @@ public static function prompt( } return is_string($messages) - ? Prompt::builder(PromptType::Text)->text($messages) - : Prompt::builder(PromptType::Chat)->messages($messages); + ? Prompt::builder('text')->text($messages) + : Prompt::builder('chat')->messages($messages); } /** diff --git a/src/LLM/LLMManager.php b/src/LLM/LLMManager.php index 2366dad..eda613f 100644 --- a/src/LLM/LLMManager.php +++ b/src/LLM/LLMManager.php @@ -62,7 +62,7 @@ protected function createDriver($driver): LLM // @pest-ignore-type /** * Create an OpenAI LLM driver instance. * - * @param array $config + * @param array{default_model?: string, model_provider?: string, default_parameters: array, options: array{api_key?: string, organization?: string, base_uri?: string, headers?: array, query_params?: array}} $config */ public function createOpenAIDriver(array $config, string $name): OpenAIChat|CacheDecorator { @@ -142,7 +142,7 @@ public function createAnthropicDriver(array $config, string $name): AnthropicCha } /** - * @param array $config + * @param array{model_provider?: string} $config * * @throws \InvalidArgumentException */ diff --git a/src/Prompts/Enums/PromptType.php b/src/Prompts/Enums/PromptType.php index ccfbdaf..0dd8d8a 100644 --- a/src/Prompts/Enums/PromptType.php +++ b/src/Prompts/Enums/PromptType.php @@ -4,6 +4,7 @@ namespace Cortex\Prompts\Enums; +use Cortex\Prompts\Contracts\PromptBuilder; use Cortex\Prompts\Builders\ChatPromptBuilder; use Cortex\Prompts\Builders\TextPromptBuilder; @@ -12,7 +13,12 @@ enum PromptType: string case Chat = 'chat'; case Text = 'text'; - public function builder(): ChatPromptBuilder|TextPromptBuilder + /** + * Get the prompt builder for the given type. + * + * @return ($this is \Cortex\Prompts\Enums\PromptType::Chat ? \Cortex\Prompts\Builders\ChatPromptBuilder : \Cortex\Prompts\Builders\TextPromptBuilder) + */ + public function builder(): PromptBuilder { return match ($this) { self::Chat => new ChatPromptBuilder(), diff --git a/src/Prompts/Factories/LangfusePromptFactory.php b/src/Prompts/Factories/LangfusePromptFactory.php index 4a94963..dd4cbc3 100644 --- a/src/Prompts/Factories/LangfusePromptFactory.php +++ b/src/Prompts/Factories/LangfusePromptFactory.php @@ -63,6 +63,9 @@ class LangfusePromptFactory implements PromptFactory { use DiscoversPsrImplementations; + /** + * @param \Closure(array): \Cortex\Prompts\Data\PromptMetadata $metadataResolver + */ public function __construct( #[SensitiveParameter] protected string $username, diff --git a/src/Prompts/Factories/McpPromptFactory.php b/src/Prompts/Factories/McpPromptFactory.php index 499f478..ddc953c 100644 --- a/src/Prompts/Factories/McpPromptFactory.php +++ b/src/Prompts/Factories/McpPromptFactory.php @@ -13,6 +13,10 @@ */ class McpPromptFactory implements PromptFactory { + public function __construct( + protected string $baseUri, + ) {} + public function make(string $name, array $config = []): PromptTemplate { // TODO: Implement diff --git a/src/Prompts/Prompt.php b/src/Prompts/Prompt.php index e9da828..ae03bc3 100644 --- a/src/Prompts/Prompt.php +++ b/src/Prompts/Prompt.php @@ -6,8 +6,7 @@ use Cortex\Facades\PromptFactory; use Cortex\Prompts\Enums\PromptType; -use Cortex\Prompts\Builders\ChatPromptBuilder; -use Cortex\Prompts\Builders\TextPromptBuilder; +use Cortex\Prompts\Contracts\PromptBuilder; use Cortex\Prompts\Contracts\PromptFactory as PromptFactoryContract; class Prompt @@ -15,11 +14,13 @@ class Prompt /** * Get the prompt builder for the given type. * - * @return ($type is \Cortex\Prompts\Enums\PromptType::Chat ? \Cortex\Prompts\Builders\ChatPromptBuilder : \Cortex\Prompts\Builders\TextPromptBuilder) + * @param value-of<\Cortex\Prompts\Enums\PromptType> $type + * + * @return ($type is 'chat' ? \Cortex\Prompts\Builders\ChatPromptBuilder : \Cortex\Prompts\Builders\TextPromptBuilder) */ - public static function builder(PromptType $type = PromptType::Chat): ChatPromptBuilder|TextPromptBuilder + public static function builder(string $type = 'chat'): PromptBuilder { - return $type->builder(); + return PromptType::from($type)->builder(); } /** diff --git a/src/Prompts/PromptFactoryManager.php b/src/Prompts/PromptFactoryManager.php index ddc0bbc..2cc7a08 100644 --- a/src/Prompts/PromptFactoryManager.php +++ b/src/Prompts/PromptFactoryManager.php @@ -5,6 +5,7 @@ namespace Cortex\Prompts; use Illuminate\Support\Manager; +use Cortex\Prompts\Factories\McpPromptFactory; use Cortex\Prompts\Factories\LangfusePromptFactory; class PromptFactoryManager extends Manager @@ -16,7 +17,7 @@ public function getDefaultDriver(): string public function createLangfuseDriver(): LangfusePromptFactory { - /** @var array $config */ + /** @var array{username?: string, password?: string, base_uri?: string} $config */ $config = $this->config->get('cortex.prompt_factory.langfuse'); return new LangfusePromptFactory( @@ -25,4 +26,14 @@ public function createLangfuseDriver(): LangfusePromptFactory $config['base_uri'] ?? 'https://cloud.langfuse.com', ); } + + public function createMcpDriver(): McpPromptFactory + { + /** @var array{base_uri?: string} $config */ + $config = $this->config->get('cortex.prompt_factory.mcp'); + + return new McpPromptFactory( + $config['base_uri'] ?? 'http://localhost:3000', + ); + } } From f340de8a412435fc2636933de48134caea34d18a Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Thu, 31 Jul 2025 00:12:12 +0100 Subject: [PATCH 7/9] add caching --- config/cortex.php | 6 + .../Factories/LangfusePromptFactory.php | 29 +++- src/Prompts/PromptFactoryManager.php | 4 +- .../Traits/DiscoversPsrImplementations.php | 55 +++++-- .../Factories/LangfusePromptFactoryTest.php | 147 ++++++++++++++++++ 5 files changed, 224 insertions(+), 17 deletions(-) diff --git a/config/cortex.php b/config/cortex.php index 4045ec0..ec662ef 100644 --- a/config/cortex.php +++ b/config/cortex.php @@ -210,6 +210,12 @@ 'username' => env('LANGFUSE_USERNAME', ''), 'password' => env('LANGFUSE_PASSWORD', ''), 'base_uri' => env('LANGFUSE_BASE_URI', 'https://cloud.langfuse.com'), + + 'cache' => [ + 'enabled' => env('CORTEX_PROMPT_CACHE_ENABLED', false), + 'store' => env('CORTEX_PROMPT_CACHE_STORE', null), + 'ttl' => env('CORTEX_PROMPT_CACHE_TTL', 3600), + ], ], // 'mcp' => [ diff --git a/src/Prompts/Factories/LangfusePromptFactory.php b/src/Prompts/Factories/LangfusePromptFactory.php index dd4cbc3..d9b0173 100644 --- a/src/Prompts/Factories/LangfusePromptFactory.php +++ b/src/Prompts/Factories/LangfusePromptFactory.php @@ -8,6 +8,7 @@ use JsonException; use SensitiveParameter; use Cortex\LLM\Contracts\Message; +use Psr\SimpleCache\CacheInterface; use Cortex\JsonSchema\SchemaFactory; use Psr\Http\Client\ClientInterface; use Cortex\Exceptions\PromptException; @@ -72,8 +73,10 @@ public function __construct( #[SensitiveParameter] protected string $password, protected string $baseUrl = 'https://cloud.langfuse.com', - protected ?ClientInterface $httpClient = null, protected ?Closure $metadataResolver = null, + protected ?ClientInterface $httpClient = null, + protected ?CacheInterface $cache = null, + protected int $cacheTtl = 3600, ) {} /** @@ -161,10 +164,22 @@ protected function getResponseContent(string $name, array $options = []): array $uri .= '?' . http_build_query($params); } - $client = $this->httpClient ?? $this->discoverHttpClient(); + $cache = $this->cache ?? $this->discoverCache(); + + if ($cache !== null) { + $cacheKey = 'cortex.prompts.langfuse.' . hash('sha256', $uri); + + $result = $cache->get($cacheKey); + + if ($result !== null) { + return $result; + } + } + + $client = $this->httpClient ?? $this->discoverHttpClientOrFail(); $response = $client->sendRequest( - $this->discoverHttpRequestFactory() + $this->discoverHttpRequestFactoryOrFail() ->createRequest('GET', $uri) ->withHeader( 'Authorization', @@ -173,10 +188,16 @@ protected function getResponseContent(string $name, array $options = []): array ); try { - return json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR); + $result = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR); } catch (JsonException $e) { throw new PromptException('Invalid JSON response from Langfuse: ' . $e->getMessage()); } + + if ($cache !== null) { + $cache->set($cacheKey, $result, $this->cacheTtl); + } + + return $result; } /** diff --git a/src/Prompts/PromptFactoryManager.php b/src/Prompts/PromptFactoryManager.php index 2cc7a08..812ff70 100644 --- a/src/Prompts/PromptFactoryManager.php +++ b/src/Prompts/PromptFactoryManager.php @@ -17,13 +17,15 @@ public function getDefaultDriver(): string public function createLangfuseDriver(): LangfusePromptFactory { - /** @var array{username?: string, password?: string, base_uri?: string} $config */ + /** @var array{username?: string, password?: string, base_uri?: string, cache_store?: ?string, cache_ttl?: int} $config */ $config = $this->config->get('cortex.prompt_factory.langfuse'); return new LangfusePromptFactory( $config['username'] ?? '', $config['password'] ?? '', $config['base_uri'] ?? 'https://cloud.langfuse.com', + cache: $this->container->make('cache')->store($config['cache_store'] ?? null), + cacheTtl: $config['cache_ttl'] ?? 3600, ); } diff --git a/src/Support/Traits/DiscoversPsrImplementations.php b/src/Support/Traits/DiscoversPsrImplementations.php index ff08707..e6592f4 100644 --- a/src/Support/Traits/DiscoversPsrImplementations.php +++ b/src/Support/Traits/DiscoversPsrImplementations.php @@ -15,9 +15,14 @@ trait DiscoversPsrImplementations { - protected function discoverHttpClient(bool $singleton = true): ClientInterface + protected function discoverHttpClient(bool $singleton = true): ?ClientInterface { - $client = Discover::httpClient($singleton); + return Discover::httpClient($singleton); + } + + protected function discoverHttpClientOrFail(bool $singleton = true): ClientInterface + { + $client = $this->discoverHttpClient($singleton); if (! $client instanceof ClientInterface) { throw new RuntimeException('HTTP client not found'); @@ -26,9 +31,14 @@ protected function discoverHttpClient(bool $singleton = true): ClientInterface return $client; } - protected function discoverHttpRequestFactory(bool $singleton = true): RequestFactoryInterface + protected function discoverHttpRequestFactory(bool $singleton = true): ?RequestFactoryInterface { - $requestFactory = Discover::httpRequestFactory($singleton); + return Discover::httpRequestFactory($singleton); + } + + protected function discoverHttpRequestFactoryOrFail(bool $singleton = true): RequestFactoryInterface + { + $requestFactory = $this->discoverHttpRequestFactory($singleton); if (! $requestFactory instanceof RequestFactoryInterface) { throw new RuntimeException('HTTP request factory not found'); @@ -37,9 +47,14 @@ protected function discoverHttpRequestFactory(bool $singleton = true): RequestFa return $requestFactory; } - protected function discoverHttpUriFactory(bool $singleton = true): UriFactoryInterface + protected function discoverHttpUriFactory(bool $singleton = true): ?UriFactoryInterface + { + return Discover::httpUriFactory($singleton); + } + + protected function discoverHttpUriFactoryOrFail(bool $singleton = true): UriFactoryInterface { - $uriFactory = Discover::httpUriFactory($singleton); + $uriFactory = $this->discoverHttpUriFactory($singleton); if (! $uriFactory instanceof UriFactoryInterface) { throw new RuntimeException('HTTP URI factory not found'); @@ -48,9 +63,14 @@ protected function discoverHttpUriFactory(bool $singleton = true): UriFactoryInt return $uriFactory; } - protected function discoverHttpStreamFactory(bool $singleton = true): StreamFactoryInterface + protected function discoverHttpStreamFactory(bool $singleton = true): ?StreamFactoryInterface { - $streamFactory = Discover::httpStreamFactory($singleton); + return Discover::httpStreamFactory($singleton); + } + + protected function discoverHttpStreamFactoryOrFail(bool $singleton = true): StreamFactoryInterface + { + $streamFactory = $this->discoverHttpStreamFactory($singleton); if (! $streamFactory instanceof StreamFactoryInterface) { throw new RuntimeException('HTTP stream factory not found'); @@ -59,9 +79,14 @@ protected function discoverHttpStreamFactory(bool $singleton = true): StreamFact return $streamFactory; } - protected function discoverEventDispatcher(bool $singleton = true): EventDispatcherInterface + protected function discoverEventDispatcher(bool $singleton = true): ?EventDispatcherInterface { - $eventDispatcher = Discover::eventDispatcher($singleton); + return Discover::eventDispatcher($singleton); + } + + protected function discoverEventDispatcherOrFail(bool $singleton = true): EventDispatcherInterface + { + $eventDispatcher = $this->discoverEventDispatcher($singleton); if (! $eventDispatcher instanceof EventDispatcherInterface) { throw new RuntimeException('Event dispatcher not found'); @@ -70,9 +95,15 @@ protected function discoverEventDispatcher(bool $singleton = true): EventDispatc return $eventDispatcher; } - protected function discoverCache(bool $singleton = true): CacheInterface + protected function discoverCache(bool $singleton = true): ?CacheInterface + { + // @phpstan-ignore return.type + return Discover::cache($singleton); + } + + protected function discoverCacheOrFail(bool $singleton = true): CacheInterface { - $cache = Discover::cache($singleton); + $cache = $this->discoverCache($singleton); if (! $cache instanceof CacheInterface) { throw new RuntimeException('Cache not found'); diff --git a/tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php b/tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php index 7a2c804..f6ddb36 100644 --- a/tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php +++ b/tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php @@ -4,10 +4,12 @@ namespace Cortex\Tests\Unit\Prompts\Factories; +use Mockery; use GuzzleHttp\Client; use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Response; use GuzzleHttp\Handler\MockHandler; +use Psr\SimpleCache\CacheInterface; use Cortex\Exceptions\PromptException; use Cortex\Prompts\Data\PromptMetadata; use Cortex\JsonSchema\Types\ObjectSchema; @@ -22,6 +24,8 @@ function createLangfuseFactory( array $responses, + ?CacheInterface $cache = null, + int $cacheTtl = 3600, ): LangfusePromptFactory { return new LangfusePromptFactory( username: 'username', @@ -29,6 +33,8 @@ function createLangfuseFactory( httpClient: new Client([ 'handler' => HandlerStack::create(new MockHandler($responses)), ]), + cache: $cache, + cacheTtl: $cacheTtl, ); } @@ -450,3 +456,144 @@ function createLangfuseFactory( expect($prompt->metadata->provider)->toBe('openai'); expect($prompt->metadata->model)->toBe('gpt-3.5-turbo'); }); + +test('it caches prompt responses and reuses them on subsequent calls', function (): void { + $responseData = [ + 'type' => 'text', + 'name' => 'cached-prompt', + 'version' => 1, + 'config' => [ + 'provider' => 'openai', + 'model' => 'gpt-4', + ], + 'labels' => [], + 'tags' => [], + 'prompt' => 'This is a cached prompt', + ]; + + /** @var CacheInterface&\Mockery\MockInterface $cache */ + $cache = mock(CacheInterface::class); + + // First call: cache miss, then cache set + $cache->shouldReceive('get') + ->once() + ->with(Mockery::type('string')) + ->andReturn(null); + + $cache->shouldReceive('set') + ->once() + ->with(Mockery::type('string'), $responseData, 3600) + ->andReturnTrue(); + + // Second call: cache hit + $cache->shouldReceive('get') + ->once() + ->with(Mockery::type('string')) + ->andReturn($responseData); + + $factory = createLangfuseFactory( + [new Response(200, body: json_encode($responseData))], + $cache, + 3600, + ); + + // First call - should hit HTTP client and cache the result + $prompt1 = $factory->make('cached-prompt'); + + expect($prompt1)->toBeInstanceOf(TextPromptTemplate::class); + expect($prompt1->text)->toBe('This is a cached prompt'); + expect($prompt1->metadata->provider)->toBe('openai'); + expect($prompt1->metadata->model)->toBe('gpt-4'); + + // Second call with same parameters - should use cache, not HTTP + $prompt2 = $factory->make('cached-prompt'); + + expect($prompt2)->toBeInstanceOf(TextPromptTemplate::class); + expect($prompt2->text)->toBe('This is a cached prompt'); + expect($prompt2->metadata->provider)->toBe('openai'); + expect($prompt2->metadata->model)->toBe('gpt-4'); + + // Verify both prompts are equivalent + expect($prompt1->text)->toBe($prompt2->text); + expect($prompt1->metadata->provider)->toBe($prompt2->metadata->provider); + expect($prompt1->metadata->model)->toBe($prompt2->metadata->model); +}); + +test('it generates correct cache keys for different prompt parameters', function (): void { + $responseData = [ + 'type' => 'text', + 'name' => 'test-prompt', + 'version' => 1, + 'config' => [], + 'labels' => [], + 'tags' => [], + 'prompt' => 'Test prompt', + ]; + + $cacheKeys = []; + /** @var CacheInterface&\Mockery\MockInterface $cache */ + $cache = mock(CacheInterface::class); + + // Mock cache to capture the keys used and always return null (cache miss) + $cache->shouldReceive('get') + ->times(3) + ->with(Mockery::on(function ($key) use (&$cacheKeys): bool { + $cacheKeys[] = $key; + + return is_string($key); + })) + ->andReturn(null); + + $cache->shouldReceive('set') + ->times(3) + ->with(Mockery::type('string'), $responseData, Mockery::any()) + ->andReturnTrue(); + + $factory = createLangfuseFactory([ + new Response(200, body: json_encode($responseData)), + new Response(200, body: json_encode($responseData)), + new Response(200, body: json_encode($responseData)), + ], $cache); + + // Call with different parameters to generate different cache keys + $factory->make('test-prompt'); + $factory->make('test-prompt', [ + 'version' => 2, + ]); + $factory->make('test-prompt', [ + 'label' => 'production', + ]); + + expect($cacheKeys)->toHaveCount(3); + + // Verify all cache keys are different (different URLs generate different hashes) + expect($cacheKeys[0])->not()->toBe($cacheKeys[1]); + expect($cacheKeys[1])->not()->toBe($cacheKeys[2]); + expect($cacheKeys[0])->not()->toBe($cacheKeys[2]); + + // Verify all keys start with the expected prefix + foreach ($cacheKeys as $key) { + expect($key)->toStartWith('cortex.prompts.langfuse.'); + } +}); + +test('it works without cache when none is provided', function (): void { + $responseData = [ + 'type' => 'text', + 'name' => 'test-prompt', + 'version' => 1, + 'config' => [], + 'labels' => [], + 'tags' => [], + 'prompt' => 'Test prompt without cache', + ]; + + $factory = createLangfuseFactory([ + new Response(200, body: json_encode($responseData)), + ]); // No cache provided - should use PSR discovery + + $prompt = $factory->make('test-prompt'); + + expect($prompt)->toBeInstanceOf(TextPromptTemplate::class); + expect($prompt->text)->toBe('Test prompt without cache'); +}); From a0d311910e87cbe65171c7072b2c60f2976819b1 Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Fri, 1 Aug 2025 00:41:06 +0100 Subject: [PATCH 8/9] fix --- src/Prompts/PromptFactoryManager.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Prompts/PromptFactoryManager.php b/src/Prompts/PromptFactoryManager.php index 812ff70..8ba1667 100644 --- a/src/Prompts/PromptFactoryManager.php +++ b/src/Prompts/PromptFactoryManager.php @@ -17,15 +17,15 @@ public function getDefaultDriver(): string public function createLangfuseDriver(): LangfusePromptFactory { - /** @var array{username?: string, password?: string, base_uri?: string, cache_store?: ?string, cache_ttl?: int} $config */ + /** @var array{username?: string, password?: string, base_uri?: string, cache?: array{store?: ?string, ttl?: int}} $config */ $config = $this->config->get('cortex.prompt_factory.langfuse'); return new LangfusePromptFactory( $config['username'] ?? '', $config['password'] ?? '', $config['base_uri'] ?? 'https://cloud.langfuse.com', - cache: $this->container->make('cache')->store($config['cache_store'] ?? null), - cacheTtl: $config['cache_ttl'] ?? 3600, + cache: $this->container->make('cache')->store($config['cache']['store'] ?? null), + cacheTtl: $config['cache']['ttl'] ?? 3600, ); } From 5da89907b6b4e00279532dab06a77b819ed0f823 Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Mon, 11 Aug 2025 23:41:54 +0100 Subject: [PATCH 9/9] tweaks --- src/LLM/AbstractLLM.php | 2 + src/LLM/Data/ChatStreamResult.php | 4 +- src/LLM/Drivers/AnthropicChat.php | 7 +++- src/LLM/Drivers/OpenAIChat.php | 20 +++++++--- src/OutputParsers/AbstractOutputParser.php | 4 +- src/OutputParsers/EnumOutputParser.php | 6 +-- src/OutputParsers/StructuredOutputParser.php | 2 +- src/Tasks/Builders/StructuredTaskBuilder.php | 6 +-- src/Tasks/Builders/TextTaskBuilder.php | 6 +-- tests/Unit/Experimental/PlaygroundTest.php | 39 ++++++++++++------- .../OutputParsers/EnumOutputParserTest.php | 2 +- 11 files changed, 62 insertions(+), 36 deletions(-) diff --git a/src/LLM/AbstractLLM.php b/src/LLM/AbstractLLM.php index 5e1a36e..8ae74e6 100644 --- a/src/LLM/AbstractLLM.php +++ b/src/LLM/AbstractLLM.php @@ -364,6 +364,8 @@ public function shouldApplyFormatInstructions(bool $applyFormatInstructions = tr /** * Apply the given format instructions to the messages. + * + * @return \Cortex\LLM\Data\Messages\MessageCollection */ protected static function applyFormatInstructions( MessageCollection $messages, diff --git a/src/LLM/Data/ChatStreamResult.php b/src/LLM/Data/ChatStreamResult.php index 2f43e3a..f784787 100644 --- a/src/LLM/Data/ChatStreamResult.php +++ b/src/LLM/Data/ChatStreamResult.php @@ -40,13 +40,13 @@ public static function fake(?string $string = null, ?ToolCallCollection $toolCal index: $index, createdAt: new DateTimeImmutable(), finishReason: $isFinal ? FinishReason::Stop : null, - contentSoFar: $contentSoFar, - isFinal: $isFinal, usage: new Usage( promptTokens: 0, completionTokens: $index, totalTokens: $index, ), + contentSoFar: $contentSoFar, + isFinal: $isFinal, ); // $this->dispatchEvent( diff --git a/src/LLM/Drivers/AnthropicChat.php b/src/LLM/Drivers/AnthropicChat.php index 9555131..01d1edb 100644 --- a/src/LLM/Drivers/AnthropicChat.php +++ b/src/LLM/Drivers/AnthropicChat.php @@ -63,6 +63,11 @@ public function invoke( throw new LLMException('You must provide at least one message to the LLM.'); } + // Apply format instructions if applicable. + if ($this->shouldApplyFormatInstructions && $formatInstructions = $this->outputParser?->formatInstructions()) { + $messages = static::applyFormatInstructions($messages, $formatInstructions); + } + [$systemMessages, $messages] = $messages->partition( fn(Message $message): bool => $message instanceof SystemMessage, ); @@ -83,8 +88,6 @@ public function invoke( $params['system'] = $systemMessage->text(); } - // TODO: Apply format instructions if applicable. - $this->dispatchEvent(new ChatModelStart($messages, $params)); try { diff --git a/src/LLM/Drivers/OpenAIChat.php b/src/LLM/Drivers/OpenAIChat.php index f2864d6..b9025eb 100644 --- a/src/LLM/Drivers/OpenAIChat.php +++ b/src/LLM/Drivers/OpenAIChat.php @@ -443,11 +443,21 @@ protected function buildParams(array $additionalParameters): array ...$additionalParameters, ]; - if ($this->modelProvider === ModelProvider::OpenAI && isset($allParams['max_tokens'])) { - // `max_tokens` is deprecated in favour of `max_completion_tokens` - // https://platform.openai.com/docs/api-reference/chat/create#chat-create-max_tokens - $allParams['max_completion_tokens'] = $allParams['max_tokens']; - unset($allParams['max_tokens']); + if ($this->modelProvider === ModelProvider::OpenAI) { + if (array_key_exists('max_tokens', $allParams)) { + // `max_tokens` is deprecated in favour of `max_completion_tokens` + // https://platform.openai.com/docs/api-reference/chat/create#chat-create-max_tokens + $allParams['max_completion_tokens'] = $allParams['max_tokens']; + unset($allParams['max_tokens']); + } + + if (array_key_exists('temperature', $allParams) && $allParams['temperature'] === null) { + unset($allParams['temperature']); + } + + if (array_key_exists('top_p', $allParams) && $allParams['top_p'] === null) { + unset($allParams['top_p']); + } } return $allParams; diff --git a/src/OutputParsers/AbstractOutputParser.php b/src/OutputParsers/AbstractOutputParser.php index 96a40aa..1764543 100644 --- a/src/OutputParsers/AbstractOutputParser.php +++ b/src/OutputParsers/AbstractOutputParser.php @@ -40,9 +40,11 @@ public function formatInstructions(): ?string /** * Set the format instructions to use in the prompt. */ - public function setFormatInstructions(string $formatInstructions): void + public function withFormatInstructions(string $formatInstructions): self { $this->formatInstructions = $formatInstructions; + + return $this; } public function handlePipeable(mixed $payload, Closure $next): mixed diff --git a/src/OutputParsers/EnumOutputParser.php b/src/OutputParsers/EnumOutputParser.php index 765e107..6865de0 100644 --- a/src/OutputParsers/EnumOutputParser.php +++ b/src/OutputParsers/EnumOutputParser.php @@ -12,7 +12,7 @@ class EnumOutputParser extends AbstractOutputParser { /** - * @param class-string $enum + * @param class-string<\BackedEnum> $enum */ public function __construct( protected string $enum, @@ -20,8 +20,8 @@ public function __construct( public function parse(ChatGeneration|ChatGenerationChunk|string $output): BackedEnum { - if (! enum_exists($this->enum)) { - throw OutputParserException::failed(sprintf('Enum %s does not exist', $this->enum)); + if (! enum_exists($this->enum) || ! is_subclass_of($this->enum, BackedEnum::class)) { + throw OutputParserException::failed(sprintf('Invalid enum: %s', $this->enum)); } $enumName = class_basename($this->enum); diff --git a/src/OutputParsers/StructuredOutputParser.php b/src/OutputParsers/StructuredOutputParser.php index 0e35317..81dd025 100644 --- a/src/OutputParsers/StructuredOutputParser.php +++ b/src/OutputParsers/StructuredOutputParser.php @@ -25,7 +25,7 @@ public function parse(ChatGeneration|ChatGenerationChunk|string $output): array $parser = match (true) { is_string($output) => new JsonOutputParser(), // If the message has tool calls and no text, assume we are using the schema tool - $output->message->hasToolCalls() && in_array($output->message->text(), [null, '', '0'], true) => new JsonOutputToolsParser(singleToolCall: true), + $output->message->hasToolCalls() && in_array($output->message->text(), [null, ''], true) => new JsonOutputToolsParser(singleToolCall: true), default => new JsonOutputParser(), }; diff --git a/src/Tasks/Builders/StructuredTaskBuilder.php b/src/Tasks/Builders/StructuredTaskBuilder.php index f7c576c..ffa6c07 100644 --- a/src/Tasks/Builders/StructuredTaskBuilder.php +++ b/src/Tasks/Builders/StructuredTaskBuilder.php @@ -172,12 +172,12 @@ public function build(): Task return new StructuredOutputTask( name: $this->name, - description: $this->description, - messages: $this->messages, schema: $this->schema, llm: $this->llm ?? LLM::provider(), - initialPromptVariables: $this->initialPromptVariables, + messages: $this->messages, + description: $this->description, outputParser: $this->getOutputParser(), + initialPromptVariables: $this->initialPromptVariables, outputMode: $this->outputMode, tools: $this->tools, toolChoice: $this->toolChoice, diff --git a/src/Tasks/Builders/TextTaskBuilder.php b/src/Tasks/Builders/TextTaskBuilder.php index 9b50a9d..56056e5 100644 --- a/src/Tasks/Builders/TextTaskBuilder.php +++ b/src/Tasks/Builders/TextTaskBuilder.php @@ -40,13 +40,13 @@ public function build(): Task return new TextOutputTask( name: $this->name, - description: $this->description, - messages: $this->messages, llm: $this->llm ?? LLM::provider(), + messages: $this->messages, + description: $this->description, + outputParser: $this->getOutputParser(), initialPromptVariables: $this->initialPromptVariables, tools: $this->tools, toolChoice: $this->toolChoice, - outputParser: $this->getOutputParser(), maxIterations: $this->maxIterations, ); } diff --git a/tests/Unit/Experimental/PlaygroundTest.php b/tests/Unit/Experimental/PlaygroundTest.php index 6914ee5..5557f7d 100644 --- a/tests/Unit/Experimental/PlaygroundTest.php +++ b/tests/Unit/Experimental/PlaygroundTest.php @@ -11,13 +11,13 @@ use Cortex\Facades\ModelInfo; use Cortex\Memory\ChatMemory; use Cortex\Facades\Embeddings; +use Cortex\Events\ChatModelEnd; use Cortex\Facades\VectorStore; use Cortex\Tasks\Enums\TaskType; use Cortex\Events\ChatModelStart; use Cortex\JsonSchema\SchemaFactory; use Cortex\Memory\ChatSummaryMemory; use Illuminate\Support\Facades\Event; -use Cortex\ModelInfo\Enums\ModelFeature; use Cortex\JsonSchema\Types\StringSchema; use Cortex\LLM\Data\Messages\UserMessage; use Cortex\ModelInfo\Enums\ModelProvider; @@ -113,9 +113,9 @@ dump($event->parameters); }); - // Event::listen(ChatModelEnd::class, function (ChatModelEnd $event): void { - // dump($event->result->generation->message->toArray()); - // }); + Event::listen(ChatModelEnd::class, function (ChatModelEnd $event): void { + dump($event->result); + }); // $generateStoryIdea = task('generate_story_idea', TaskType::Structured) // // ->llm('ollama', 'qwen2.5:14b') @@ -124,19 +124,28 @@ // ->user('Generate a story idea about {topic}. Only answer in a single sentence.') // ->properties(new StringSchema('story_idea')); - $generateStoryIdea = Cortex::prompt([ + $prompt = Cortex::prompt([ new SystemMessage('You are an expert story ideator.'), new UserMessage('Generate a story idea about {topic}. Only answer in a single sentence.'), - ]) - ->llm('github', function (LLMContract $llm): LLMContract { - return $llm->withFeatures(ModelFeature::ToolCalling, ModelFeature::StructuredOutput, ModelFeature::JsonOutput) - // ->withModel('xai/grok-3-mini') - // ->withModel('mistral-small3.1') - ->withStructuredOutput( - output: SchemaFactory::object()->properties(SchemaFactory::string('story_idea')->required()), - outputMode: StructuredOutputMode::Auto, - ); - }); + ]); + + $generateStoryIdea = $prompt->llm('openai', function (LLMContract $llm): LLMContract { + return $llm->withModel('gpt-5-mini') + ->withStructuredOutput( + output: SchemaFactory::object()->properties(SchemaFactory::string('story_idea')->required()), + outputMode: StructuredOutputMode::Auto, + ); + }); + + // $generateStoryIdea = $prompt->llm('github', function (LLMContract $llm): LLMContract { + // return $llm->withFeatures(ModelFeature::ToolCalling, ModelFeature::StructuredOutput, ModelFeature::JsonOutput) + // // ->withModel('xai/grok-3-mini') + // // ->withModel('mistral-small3.1') + // ->withStructuredOutput( + // output: SchemaFactory::object()->properties(SchemaFactory::string('story_idea')->required()), + // outputMode: StructuredOutputMode::Auto, + // ); + // }); // dd($generateStoryIdea->invoke([ // 'topic' => 'a dragon', diff --git a/tests/Unit/OutputParsers/EnumOutputParserTest.php b/tests/Unit/OutputParsers/EnumOutputParserTest.php index 81f511a..8315a84 100644 --- a/tests/Unit/OutputParsers/EnumOutputParserTest.php +++ b/tests/Unit/OutputParsers/EnumOutputParserTest.php @@ -16,7 +16,7 @@ $parser = new EnumOutputParser('NonExistentEnum'); expect(fn(): BackedEnum => $parser->parse('one')) - ->toThrow(OutputParserException::class, 'Enum NonExistentEnum does not exist'); + ->toThrow(OutputParserException::class, 'Invalid enum: NonExistentEnum'); }); test('parses string input directly to enum', function (): void {