Added openai integration
This commit is contained in:
parent
4448740277
commit
86aa6f62f8
@ -19,6 +19,7 @@
|
||||
"symfony/dotenv": "6.4.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "6.4.*",
|
||||
"symfony/http-client": "6.4.*",
|
||||
"symfony/property-access": "6.4.*",
|
||||
"symfony/property-info": "6.4.*",
|
||||
"symfony/runtime": "6.4.*",
|
||||
|
||||
173
backend/composer.lock
generated
173
backend/composer.lock
generated
@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "58ca9f6d53632372fae9dee2c6c72aa7",
|
||||
"content-hash": "f41287711c3c1d476ebbca47f5b529b5",
|
||||
"packages": [
|
||||
{
|
||||
"name": "doctrine/annotations",
|
||||
@ -3123,6 +3123,177 @@
|
||||
],
|
||||
"time": "2025-03-23T16:46:24+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client",
|
||||
"version": "v6.4.19",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client.git",
|
||||
"reference": "3294a433fc9d12ae58128174896b5b1822c28dad"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/3294a433fc9d12ae58128174896b5b1822c28dad",
|
||||
"reference": "3294a433fc9d12ae58128174896b5b1822c28dad",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"psr/log": "^1|^2|^3",
|
||||
"symfony/deprecation-contracts": "^2.5|^3",
|
||||
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
|
||||
"symfony/service-contracts": "^2.5|^3"
|
||||
},
|
||||
"conflict": {
|
||||
"php-http/discovery": "<1.15",
|
||||
"symfony/http-foundation": "<6.3"
|
||||
},
|
||||
"provide": {
|
||||
"php-http/async-client-implementation": "*",
|
||||
"php-http/client-implementation": "*",
|
||||
"psr/http-client-implementation": "1.0",
|
||||
"symfony/http-client-implementation": "3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"amphp/amp": "^2.5",
|
||||
"amphp/http-client": "^4.2.1",
|
||||
"amphp/http-tunnel": "^1.0",
|
||||
"amphp/socket": "^1.1",
|
||||
"guzzlehttp/promises": "^1.4|^2.0",
|
||||
"nyholm/psr7": "^1.0",
|
||||
"php-http/httplug": "^1.0|^2.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"symfony/dependency-injection": "^5.4|^6.0|^7.0",
|
||||
"symfony/http-kernel": "^5.4|^6.0|^7.0",
|
||||
"symfony/messenger": "^5.4|^6.0|^7.0",
|
||||
"symfony/process": "^5.4|^6.0|^7.0",
|
||||
"symfony/stopwatch": "^5.4|^6.0|^7.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\HttpClient\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"http"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client/tree/v6.4.19"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-02-13T09:55:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client-contracts",
|
||||
"version": "v3.5.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client-contracts.git",
|
||||
"reference": "ee8d807ab20fcb51267fdace50fbe3494c31e645"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/ee8d807ab20fcb51267fdace50fbe3494c31e645",
|
||||
"reference": "ee8d807ab20fcb51267fdace50fbe3494c31e645",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/contracts",
|
||||
"name": "symfony/contracts"
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.5-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Contracts\\HttpClient\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Test/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Generic abstractions related to HTTP clients",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"abstractions",
|
||||
"contracts",
|
||||
"decoupling",
|
||||
"interfaces",
|
||||
"interoperability",
|
||||
"standards"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client-contracts/tree/v3.5.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-12-07T08:49:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-foundation",
|
||||
"version": "v6.4.18",
|
||||
|
||||
104
backend/src/Application/Command/TestChatSessionCommand.php
Normal file
104
backend/src/Application/Command/TestChatSessionCommand.php
Normal file
@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Command;
|
||||
|
||||
use App\Domain\Chat\ChatProviderInterface;
|
||||
use App\Domain\Chat\ChatSession;
|
||||
use App\Domain\Chat\MessageCollection;
|
||||
use App\Domain\Chat\ToolCollection;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:test-chat-session',
|
||||
description: 'Test the chat session interface with sample interactions'
|
||||
)]
|
||||
final class TestChatSessionCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ChatProviderInterface $chatProvider,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('message', 'm', InputOption::VALUE_REQUIRED, 'Initial message to send to the chat session', 'Hello, this is a test message.')
|
||||
->addOption('max-steps', null, InputOption::VALUE_REQUIRED, 'Maximum number of steps to run', 3)
|
||||
->addOption('system-prompt', 's', InputOption::VALUE_REQUIRED, 'System prompt to use', 'You are a helpful assistant. Keep your answers brief.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$message = $input->getOption('message');
|
||||
$maxSteps = (int) $input->getOption('max-steps');
|
||||
$systemPrompt = $input->getOption('system-prompt');
|
||||
|
||||
$io->title('Testing Chat Session Interface');
|
||||
|
||||
try {
|
||||
$chatSession = new ChatSession(
|
||||
$this->chatProvider,
|
||||
new ToolCollection([])
|
||||
);
|
||||
|
||||
// Add a chat listener for real-time messaging
|
||||
$chatSession->addChatListener(function (MessageCollection $messages) use ($io) {
|
||||
$lastMessage = $messages->getLastMessage();
|
||||
$role = $lastMessage->getRole();
|
||||
$content = $lastMessage->getContent();
|
||||
|
||||
if ($content !== null) {
|
||||
$io->section(ucfirst($role) . ' Message');
|
||||
$io->writeln($content);
|
||||
}
|
||||
|
||||
$toolCalls = $lastMessage->getToolCalls();
|
||||
if (count($toolCalls) > 0) {
|
||||
$io->section('Tool Calls');
|
||||
foreach ($toolCalls as $toolCall) {
|
||||
$io->writeln('- ' . $toolCall->getName() . ': ' . json_encode($toolCall->getArguments()));
|
||||
}
|
||||
}
|
||||
|
||||
$toolResult = $lastMessage->getToolResult();
|
||||
if ($toolResult !== null) {
|
||||
$io->section('Tool Result');
|
||||
$io->writeln('Tool: ' . $toolResult->getToolName());
|
||||
$io->writeln('ID: ' . $toolResult->getToolCallId());
|
||||
}
|
||||
});
|
||||
|
||||
// Set system prompt
|
||||
$io->section('Setting System Prompt');
|
||||
$io->writeln($systemPrompt);
|
||||
$chatSession->system($systemPrompt);
|
||||
|
||||
// Send user message
|
||||
$io->section('Sending User Message');
|
||||
$io->writeln($message);
|
||||
$chatSession->user($message);
|
||||
|
||||
// Commit the conversation for the specified number of steps
|
||||
$io->section('Committing Conversation');
|
||||
$chatSession->commit($maxSteps);
|
||||
|
||||
// Summary
|
||||
$io->success('Chat session test completed successfully.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
} catch (\Exception $e) {
|
||||
$io->error('Error during chat session test: ' . $e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
10
backend/src/Domain/Chat/ChatProviderInterface.php
Normal file
10
backend/src/Domain/Chat/ChatProviderInterface.php
Normal file
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Chat;
|
||||
|
||||
interface ChatProviderInterface
|
||||
{
|
||||
public function chat(MessageCollection $messages, ToolCollection $tools, bool $forceToolCalls = false, bool $reasoning = true): ChatResult;
|
||||
}
|
||||
70
backend/src/Domain/Chat/ChatResult.php
Normal file
70
backend/src/Domain/Chat/ChatResult.php
Normal file
@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Chat;
|
||||
|
||||
final class ChatResult
|
||||
{
|
||||
/**
|
||||
* @param list<Choice> $choices
|
||||
* @param list<Message> $messageHistory
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $choices = [],
|
||||
private readonly array $messageHistory = [],
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Choice>
|
||||
*/
|
||||
public function getChoices(): array
|
||||
{
|
||||
return $this->choices;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Message>
|
||||
*/
|
||||
public function getMessageHistory(): array
|
||||
{
|
||||
return $this->messageHistory;
|
||||
}
|
||||
|
||||
public function getFirstChoice(): ?Choice
|
||||
{
|
||||
if (empty($this->choices)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->choices[0];
|
||||
}
|
||||
|
||||
public function getContent(): ?string
|
||||
{
|
||||
$firstChoice = $this->getFirstChoice();
|
||||
if ($firstChoice === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $firstChoice->getContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ToolCall>
|
||||
*/
|
||||
public function getToolCalls(): array
|
||||
{
|
||||
$toolCalls = [];
|
||||
foreach ($this->choices as $choice) {
|
||||
if (!empty($choice->getToolCalls())) {
|
||||
foreach ($choice->getToolCalls() as $toolCall) {
|
||||
$toolCalls[] = $toolCall;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $toolCalls;
|
||||
}
|
||||
}
|
||||
94
backend/src/Domain/Chat/ChatSession.php
Normal file
94
backend/src/Domain/Chat/ChatSession.php
Normal file
@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Chat;
|
||||
|
||||
use Exception;
|
||||
|
||||
final class ChatSession
|
||||
{
|
||||
private MessageCollection $messages;
|
||||
|
||||
/**
|
||||
* @var array<callable(MessageCollection): void>
|
||||
*/
|
||||
private array $chatListeners = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly ChatProviderInterface $chatProvider,
|
||||
private readonly ToolCollection $toolCollection = new ToolCollection([]),
|
||||
) {
|
||||
$this->messages = new MessageCollection();
|
||||
}
|
||||
|
||||
public function addChatListener(callable $listener): void
|
||||
{
|
||||
$this->chatListeners[] = $listener;
|
||||
}
|
||||
|
||||
public function system(string $message): void
|
||||
{
|
||||
$this->addMessage(Message::fromSystem($message));
|
||||
}
|
||||
|
||||
public function user(string $message): void
|
||||
{
|
||||
$this->addMessage(Message::fromUser($message));
|
||||
}
|
||||
|
||||
public function getMessages(): MessageCollection
|
||||
{
|
||||
return $this->messages;
|
||||
}
|
||||
|
||||
public function commit(int $maxSteps = 10, int $forcedToolCalls = 0, bool $reasoning = true): void
|
||||
{
|
||||
if ($maxSteps <= 0) {
|
||||
throw new Exception('Max steps reached');
|
||||
}
|
||||
|
||||
$result = $this->chatProvider->chat($this->messages, $this->toolCollection, $forcedToolCalls > 0, $reasoning);
|
||||
|
||||
$choices = $result->getChoices();
|
||||
if (count($choices) === 0) {
|
||||
throw new Exception('No choices found');
|
||||
}
|
||||
|
||||
$this->addMessage(Message::fromAssistant($result->getContent(), $result->getToolCalls()));
|
||||
|
||||
if (count($result->getToolCalls()) === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($result->getToolCalls() as $toolCall) {
|
||||
$tool = $this->toolCollection->findTool($toolCall->getName());
|
||||
if ($tool === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$toolResult = $tool->execute($toolCall->getArguments(), []);
|
||||
|
||||
$this->addMessage(Message::fromToolResult(
|
||||
$toolResult,
|
||||
$toolCall->getId(),
|
||||
$toolCall->getName(),
|
||||
));
|
||||
}
|
||||
|
||||
$this->commit($maxSteps - 1, $forcedToolCalls - 1);
|
||||
}
|
||||
|
||||
private function notifyChatListeners(MessageCollection $messages): void
|
||||
{
|
||||
foreach ($this->chatListeners as $listener) {
|
||||
$listener($messages);
|
||||
}
|
||||
}
|
||||
|
||||
private function addMessage(Message $message): void
|
||||
{
|
||||
$this->messages->addMessage($message);
|
||||
$this->notifyChatListeners($this->messages);
|
||||
}
|
||||
}
|
||||
124
backend/src/Domain/Chat/Choice.php
Normal file
124
backend/src/Domain/Chat/Choice.php
Normal file
@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Chat;
|
||||
|
||||
final class Choice
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $contentFilterResults
|
||||
* @param array<ToolCall> $toolCalls
|
||||
* @param array<string, mixed>|null $logprobs
|
||||
* @param array<string, mixed>|null $toolResult
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $contentFilterResults,
|
||||
private readonly string $finishReason,
|
||||
private readonly int $index,
|
||||
private readonly ?array $logprobs,
|
||||
private readonly ?string $content,
|
||||
private readonly ?string $refusal,
|
||||
private readonly string $role,
|
||||
private readonly array $toolCalls,
|
||||
private readonly ?array $toolResult = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getContentFilterResults(): array
|
||||
{
|
||||
return $this->contentFilterResults;
|
||||
}
|
||||
|
||||
public function getFinishReason(): string
|
||||
{
|
||||
return $this->finishReason;
|
||||
}
|
||||
|
||||
public function getIndex(): int
|
||||
{
|
||||
return $this->index;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function getLogprobs(): ?array
|
||||
{
|
||||
return $this->logprobs;
|
||||
}
|
||||
|
||||
public function getContent(): ?string
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
public function getRefusal(): ?string
|
||||
{
|
||||
return $this->refusal;
|
||||
}
|
||||
|
||||
public function getRole(): string
|
||||
{
|
||||
return $this->role;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<ToolCall>
|
||||
*/
|
||||
public function getToolCalls(): array
|
||||
{
|
||||
return $this->toolCalls;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function getToolResult(): ?array
|
||||
{
|
||||
return $this->toolResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* content_filter_results?: array<string, mixed>,
|
||||
* finish_reason?: string,
|
||||
* index?: int,
|
||||
* logprobs?: ?array<string, mixed>,
|
||||
* message: array{
|
||||
* content: ?string,
|
||||
* refusal: ?string,
|
||||
* role: string,
|
||||
* tool_calls: array<array{
|
||||
* function: array{arguments: string, name: string},
|
||||
* id: string,
|
||||
* type: string
|
||||
* }>
|
||||
* }
|
||||
* } $data
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$toolCalls = array_map(
|
||||
static fn (array $toolCall): ToolCall => ToolCall::fromArray($toolCall),
|
||||
$data['message']['tool_calls'] ?? []
|
||||
);
|
||||
|
||||
$toolResult = $data['message']['tool_result'] ?? null;
|
||||
|
||||
return new self(
|
||||
contentFilterResults: $data['content_filter_results'] ?? [],
|
||||
finishReason: $data['finish_reason'] ?? '',
|
||||
index: $data['index'] ?? 0,
|
||||
logprobs: $data['logprobs'] ?? null,
|
||||
content: $data['message']['content'] ?? null,
|
||||
refusal: $data['message']['refusal'] ?? null,
|
||||
role: $data['message']['role'] ?? '',
|
||||
toolCalls: $toolCalls,
|
||||
toolResult: $toolResult,
|
||||
);
|
||||
}
|
||||
}
|
||||
138
backend/src/Domain/Chat/Message.php
Normal file
138
backend/src/Domain/Chat/Message.php
Normal file
@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Chat;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
|
||||
final class Message
|
||||
{
|
||||
/**
|
||||
* @param array<ToolCall>|null $toolCalls
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $role,
|
||||
private readonly ?string $content,
|
||||
private readonly ?array $toolCalls = null,
|
||||
private readonly ?ToolResult $toolResult = null,
|
||||
private readonly DateTimeInterface $createdAt = new DateTimeImmutable(),
|
||||
) {
|
||||
}
|
||||
|
||||
public function getRole(): string
|
||||
{
|
||||
return $this->role;
|
||||
}
|
||||
|
||||
public function getContent(): ?string
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
public function getTwig(): ?string
|
||||
{
|
||||
$content = $this->getContent() ?? '```twig\n{}\n```';
|
||||
$matches = [];
|
||||
if (preg_match('/```twig\s*([\s\S]*?)\s*```/m', $content, $matches) > 0) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function getJson(): ?array
|
||||
{
|
||||
$content = $this->getContent() ?? '```json\n{}\n```';
|
||||
$matches = [];
|
||||
if (preg_match('/```json\s*([\s\S]*?)\s*```/m', $content, $matches) > 0) {
|
||||
$decoded = json_decode($matches[1], true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeInterface
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<ToolCall>
|
||||
*/
|
||||
public function getToolCalls(): array
|
||||
{
|
||||
return $this->toolCalls ?? [];
|
||||
}
|
||||
|
||||
public function getToolResult(): ?ToolResult
|
||||
{
|
||||
if (isset($this->toolResult)) {
|
||||
return $this->toolResult;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$result = [
|
||||
'role' => $this->role,
|
||||
];
|
||||
|
||||
if ($this->content !== null) {
|
||||
$result['content'] = $this->content;
|
||||
}
|
||||
|
||||
if ($this->toolResult !== null) {
|
||||
$result['tool_call_id'] = $this->toolResult->getToolCallId();
|
||||
}
|
||||
|
||||
if ($this->toolCalls !== null && $this->toolCalls !== []) {
|
||||
$result['tool_calls'] = array_map(
|
||||
fn (ToolCall $toolCall) => [
|
||||
'id' => $toolCall->getId(),
|
||||
'type' => $toolCall->getType(),
|
||||
'function' => [
|
||||
'name' => $toolCall->getName(),
|
||||
'arguments' => json_encode($toolCall->getArguments()),
|
||||
],
|
||||
],
|
||||
$this->toolCalls
|
||||
);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public static function fromUser(string $content): self
|
||||
{
|
||||
return new self(role: 'user', content: $content);
|
||||
}
|
||||
|
||||
public static function fromSystem(string $content): self
|
||||
{
|
||||
return new self(role: 'system', content: $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<ToolCall>|null $toolCalls
|
||||
*/
|
||||
public static function fromAssistant(?string $content = null, ?array $toolCalls = null): self
|
||||
{
|
||||
return new self(role: 'assistant', content: $content, toolCalls: $toolCalls);
|
||||
}
|
||||
|
||||
public static function fromToolResult(string $content, string $toolCallId, string $toolName): self
|
||||
{
|
||||
return new self(role: 'tool', content: $content, toolResult: new ToolResult($toolCallId, $toolName));
|
||||
}
|
||||
}
|
||||
212
backend/src/Domain/Chat/MessageCollection.php
Normal file
212
backend/src/Domain/Chat/MessageCollection.php
Normal file
@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Chat;
|
||||
|
||||
use ArrayIterator;
|
||||
use Countable;
|
||||
use IteratorAggregate;
|
||||
|
||||
use function array_filter;
|
||||
|
||||
/**
|
||||
* @implements IteratorAggregate<int, Message>
|
||||
*/
|
||||
final class MessageCollection implements IteratorAggregate, Countable
|
||||
{
|
||||
/**
|
||||
* @var list<Message>
|
||||
*/
|
||||
private array $messages = [];
|
||||
|
||||
/**
|
||||
* @param list<Message> $messages
|
||||
*/
|
||||
public function __construct(array $messages = [])
|
||||
{
|
||||
$this->messages = $messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Message>
|
||||
*/
|
||||
public function getMessagesSortedByCreatedAtDesc(): array
|
||||
{
|
||||
$messages = $this->messages;
|
||||
usort($messages, fn (Message $a, Message $b) => $b->getCreatedAt()->getTimestamp() <=> $a->getCreatedAt()->getTimestamp());
|
||||
|
||||
return $messages;
|
||||
}
|
||||
|
||||
public function getLastMessage(): Message
|
||||
{
|
||||
$messages = $this->getMessagesSortedByCreatedAtDesc();
|
||||
|
||||
return $messages[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Message>
|
||||
*/
|
||||
public function getLastMessages(int $count): array
|
||||
{
|
||||
$messages = $this->getMessagesSortedByCreatedAtDesc();
|
||||
|
||||
return array_slice($messages, 0, $count);
|
||||
}
|
||||
|
||||
public function addMessage(Message $message): void
|
||||
{
|
||||
$this->messages[] = $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ArrayIterator<int, Message>
|
||||
*/
|
||||
public function getIterator(): ArrayIterator
|
||||
{
|
||||
return new ArrayIterator($this->messages);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Message>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ToolCall>
|
||||
*/
|
||||
public function getToolCalls(): array
|
||||
{
|
||||
$toolCalls = [];
|
||||
foreach ($this->messages as $message) {
|
||||
foreach ($message->getToolCalls() as $toolCall) {
|
||||
$toolCalls[] = $toolCall;
|
||||
}
|
||||
}
|
||||
|
||||
return $toolCalls;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ToolCall>
|
||||
*/
|
||||
public function getToolCallsByToolName(string $toolName): array
|
||||
{
|
||||
return array_values(array_filter($this->getToolCalls(), fn (ToolCall $toolCall) => $toolCall->getName() === $toolName));
|
||||
}
|
||||
|
||||
public function getToolCallsById(string $toolCallId): ?ToolCall
|
||||
{
|
||||
foreach ($this->getToolCalls() as $toolCall) {
|
||||
if ($toolCall->getId() === $toolCallId) {
|
||||
return $toolCall;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function getToolResultById(string $toolCallId): ?array
|
||||
{
|
||||
foreach ($this->getToolCalls() as $toolCall) {
|
||||
if ($toolCall->getId() === $toolCallId) {
|
||||
return $toolCall->getArguments();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Message>
|
||||
*/
|
||||
public function getMessagesWithToolCallByToolName(string $toolName): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($this->getMessagesSortedByCreatedAtDesc() as $message) {
|
||||
$toolCalls = $message->getToolCalls();
|
||||
foreach ($toolCalls as $toolCall) {
|
||||
if ($toolCall->getName() === $toolName) {
|
||||
$result[] = $message;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Message>
|
||||
*/
|
||||
public function getMessagesWithToolResults(): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($this->getMessagesSortedByCreatedAtDesc() as $message) {
|
||||
if ($message->getToolResult() !== null) {
|
||||
$result[] = $message;
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Message>
|
||||
*/
|
||||
public function getMessagesWithToolResultByToolName(string $toolName): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($this->getMessagesWithToolResults() as $message) {
|
||||
if ($message->getToolResult()?->getToolName() === $toolName) {
|
||||
$result[] = $message;
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getLatestMessageWithToolResultByToolName(string $toolName): ?Message
|
||||
{
|
||||
$toolResults = $this->getMessagesWithToolResultByToolName($toolName);
|
||||
|
||||
if (count($toolResults) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $toolResults[0];
|
||||
}
|
||||
|
||||
public function getLatestMessageWithToolCallByToolName(string $toolName): ?Message
|
||||
{
|
||||
$messagesWithToolCall = $this->getMessagesWithToolCallByToolName($toolName);
|
||||
|
||||
if (count($messagesWithToolCall) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $messagesWithToolCall[0];
|
||||
}
|
||||
|
||||
public function getLatestToolCallByToolName(string $toolName): ?ToolCall
|
||||
{
|
||||
$messageWithToolCall = $this->getLatestMessageWithToolCallByToolName($toolName);
|
||||
|
||||
if ($messageWithToolCall === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$toolCalls = array_filter($messageWithToolCall->getToolCalls(), fn (ToolCall $toolCall) => $toolCall->getName() === $toolName);
|
||||
return !empty($toolCalls) ? array_values($toolCalls)[0] : null;
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->messages);
|
||||
}
|
||||
}
|
||||
24
backend/src/Domain/Chat/ShouldStopResult.php
Normal file
24
backend/src/Domain/Chat/ShouldStopResult.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Chat;
|
||||
|
||||
final class ShouldStopResult
|
||||
{
|
||||
public function __construct(
|
||||
private readonly bool $shouldStop,
|
||||
private readonly ?string $errorPrompt = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function shouldStop(): bool
|
||||
{
|
||||
return $this->shouldStop;
|
||||
}
|
||||
|
||||
public function getErrorPrompt(): ?string
|
||||
{
|
||||
return $this->errorPrompt;
|
||||
}
|
||||
}
|
||||
67
backend/src/Domain/Chat/ToolCall.php
Normal file
67
backend/src/Domain/Chat/ToolCall.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Chat;
|
||||
|
||||
final class ToolCall
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $id,
|
||||
private readonly string $type,
|
||||
private readonly string $name,
|
||||
private readonly array $arguments,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getArguments(): array
|
||||
{
|
||||
return $this->arguments;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* id: string,
|
||||
* type: string,
|
||||
* function: array{
|
||||
* name: string,
|
||||
* arguments: string
|
||||
* }
|
||||
* } $data
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$decodedArgs = json_decode($data['function']['arguments'], true);
|
||||
|
||||
/** @var array<string, mixed> $arguments */
|
||||
$arguments = is_array($decodedArgs) ? $decodedArgs : [];
|
||||
|
||||
return new self(
|
||||
id: $data['id'],
|
||||
type: $data['type'],
|
||||
name: $data['function']['name'],
|
||||
arguments: $arguments,
|
||||
);
|
||||
}
|
||||
}
|
||||
43
backend/src/Domain/Chat/ToolCollection.php
Normal file
43
backend/src/Domain/Chat/ToolCollection.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Chat;
|
||||
|
||||
/**
|
||||
* @extends \ArrayObject<int, ToolInterface>
|
||||
*/
|
||||
final class ToolCollection extends \ArrayObject
|
||||
{
|
||||
/**
|
||||
* @param list<ToolInterface> $tools
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly iterable $tools,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<ToolInterface>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->tools;
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->tools);
|
||||
}
|
||||
|
||||
public function findTool(string $name): ?ToolInterface
|
||||
{
|
||||
foreach ($this->tools as $tool) {
|
||||
if ($tool->getName() === $name) {
|
||||
return $tool;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
40
backend/src/Domain/Chat/ToolInterface.php
Normal file
40
backend/src/Domain/Chat/ToolInterface.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Chat;
|
||||
|
||||
interface ToolInterface
|
||||
{
|
||||
/**
|
||||
* Get the name of the tool.
|
||||
*/
|
||||
public function getName(): string;
|
||||
|
||||
/**
|
||||
* Get the description of the tool.
|
||||
*/
|
||||
public function getDescription(): string;
|
||||
|
||||
/**
|
||||
* Get the arguments of the tool.
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
public function getArguments(): array;
|
||||
|
||||
/**
|
||||
* Get the required arguments of the tool.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getRequiredArguments(): array;
|
||||
|
||||
/**
|
||||
* Execute the tool with the given arguments.
|
||||
*
|
||||
* @param array<string, mixed> $arguments
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function execute(array $arguments, array $context = []): string;
|
||||
}
|
||||
38
backend/src/Domain/Chat/ToolProvider.php
Normal file
38
backend/src/Domain/Chat/ToolProvider.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Chat;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
|
||||
|
||||
final class ToolProvider
|
||||
{
|
||||
/**
|
||||
* @param list<ToolInterface> $tools
|
||||
*/
|
||||
public function __construct(
|
||||
#[AutowireIterator(tag: 'app.ai_proxy.tool')]
|
||||
private readonly iterable $tools,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getTools(): ToolCollection
|
||||
{
|
||||
$toolsArray = iterator_to_array($this->tools);
|
||||
// Ensure it's a list (consecutive integer keys starting from 0)
|
||||
$toolsList = array_values($toolsArray);
|
||||
return new ToolCollection($toolsList);
|
||||
}
|
||||
|
||||
public function getToolByClass(string $class): ?ToolInterface
|
||||
{
|
||||
foreach ($this->tools as $tool) {
|
||||
if ($tool instanceof $class) {
|
||||
return $tool;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
24
backend/src/Domain/Chat/ToolResult.php
Normal file
24
backend/src/Domain/Chat/ToolResult.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Chat;
|
||||
|
||||
final class ToolResult
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $toolCallId,
|
||||
private readonly string $toolName,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getToolCallId(): string
|
||||
{
|
||||
return $this->toolCallId;
|
||||
}
|
||||
|
||||
public function getToolName(): string
|
||||
{
|
||||
return $this->toolName;
|
||||
}
|
||||
}
|
||||
98
backend/src/Infrastructure/Chat/OpenAIChatProvider.php
Normal file
98
backend/src/Infrastructure/Chat/OpenAIChatProvider.php
Normal file
@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Chat;
|
||||
|
||||
use App\Domain\Chat\ChatProviderInterface;
|
||||
use App\Domain\Chat\ChatResult;
|
||||
use App\Domain\Chat\Choice;
|
||||
use App\Domain\Chat\Message;
|
||||
use App\Domain\Chat\MessageCollection;
|
||||
use App\Domain\Chat\ToolCollection;
|
||||
use App\Domain\Chat\ToolInterface;
|
||||
use Exception;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
|
||||
final readonly class OpenAIChatProvider implements ChatProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private HttpClientInterface $httpClient,
|
||||
|
||||
#[Autowire('%env(OPENAI_API_KEY)%')]
|
||||
private string $openAiApiKey,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception When the OpenAI API returns an error
|
||||
*/
|
||||
public function chat(MessageCollection $messages, ToolCollection $tools, bool $forceToolCalls = false, bool $reasoning = true): ChatResult
|
||||
{
|
||||
$payload = [
|
||||
'model' => $reasoning ? 'o4-mini' : 'gpt-4o-mini',
|
||||
'messages' => array_map(function (Message $message) {
|
||||
return $message->toArray();
|
||||
}, $messages->toArray()),
|
||||
];
|
||||
|
||||
if ($tools->count() > 0) {
|
||||
$payload['tool_choice'] = $forceToolCalls ? 'required' : 'auto';
|
||||
$payload['tools'] = [];
|
||||
/** @var ToolInterface $tool */
|
||||
foreach ($tools->toArray() as $tool) {
|
||||
$payload['tools'][] = [
|
||||
'type' => 'function',
|
||||
'function' => [
|
||||
'name' => $tool->getName(),
|
||||
'description' => $tool->getDescription(),
|
||||
'parameters' => [
|
||||
'type' => 'object',
|
||||
'properties' => $tool->getArguments(),
|
||||
'required' => $tool->getRequiredArguments(),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
/** @var ResponseInterface $response */
|
||||
$response = $this->httpClient->request('POST', "https://api.openai.com/v1/chat/completions", [
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
'Authorization' => 'Bearer ' . $this->openAiApiKey,
|
||||
],
|
||||
'json' => $payload,
|
||||
'timeout' => 60 * 10,
|
||||
'max_duration' => 60 * 10,
|
||||
]);
|
||||
|
||||
$responseArray = $response->toArray(false);
|
||||
|
||||
if (isset($responseArray['choices']) && is_array($responseArray['choices']) && count($responseArray['choices']) > 0) {
|
||||
$choices = [];
|
||||
foreach ($responseArray['choices'] as $choice) {
|
||||
$choices[] = Choice::fromArray($choice);
|
||||
}
|
||||
|
||||
return new ChatResult(
|
||||
choices: $choices,
|
||||
messageHistory: $messages->toArray(),
|
||||
);
|
||||
}
|
||||
|
||||
throw new Exception('Error: ' . $response->getContent(false));
|
||||
} catch (ClientExceptionInterface | DecodingExceptionInterface | RedirectionExceptionInterface |
|
||||
ServerExceptionInterface | TransportExceptionInterface $e) {
|
||||
throw new Exception('API Error: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user