Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Feat: Add AI analyzing of comments (#10)
- Loading branch information
1 parent
d614dad
commit c762c0f
Showing
17 changed files
with
477 additions
and
17 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
<?php | ||
|
||
namespace App\Enum; | ||
|
||
enum AiActor: string | ||
{ | ||
case System = 'system'; | ||
case User = 'user'; | ||
case Assistant = 'assistant'; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
<?php | ||
|
||
namespace App\Enum; | ||
|
||
enum AiModel: string | ||
{ | ||
case Mistral7BOpenHermes = 'OpenHermes-2.5-Mistral-7B'; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
<?php | ||
|
||
namespace App\MessageHandler; | ||
|
||
use App\Message\RunExpressionAsyncMessage; | ||
use App\Service\Expression\ExpressionLanguage; | ||
use Symfony\Component\Messenger\Attribute\AsMessageHandler; | ||
|
||
#[AsMessageHandler] | ||
final readonly class RunExpressionAsyncHandler | ||
{ | ||
public function __construct( | ||
private ExpressionLanguage $expressionLanguage, | ||
) { | ||
} | ||
|
||
public function __invoke(RunExpressionAsyncMessage $message): void | ||
{ | ||
$this->expressionLanguage->evaluate($message->expression, $message->context); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,134 @@ | ||
<?php | ||
|
||
namespace App\Service\AiHorde; | ||
|
||
use App\Enum\AiActor; | ||
use App\Enum\AiModel; | ||
use App\Service\AiHorde\Message\Message; | ||
use App\Service\AiHorde\Message\MessageHistory; | ||
use App\Service\AiHorde\MessageFormatter\MessageFormatter; | ||
use LogicException; | ||
use Symfony\Component\DependencyInjection\Attribute\Autowire; | ||
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; | ||
use Symfony\Component\HttpFoundation\Request; | ||
use Symfony\Contracts\HttpClient\HttpClientInterface; | ||
|
||
final readonly class AiHorde | ||
{ | ||
public function __construct( | ||
private HttpClientInterface $httpClient, | ||
#[TaggedIterator('app.message_formatter')] | ||
private iterable $formatters, | ||
#[Autowire('%app.ai_horde.api_key%')] | ||
private string $apiKey, | ||
) { | ||
} | ||
|
||
public function getResponse( | ||
string $message, | ||
AiModel $model, | ||
MessageHistory $history = new MessageHistory(), | ||
): string { | ||
if (!$this->apiKey) { | ||
throw new LogicException('There is no api key set, cannot use AI actions'); | ||
} | ||
|
||
$models = $this->findModels($model); | ||
if (!count($models)) { | ||
throw new LogicException('There was an error while looking for available models - no model able to handle your message seems to be online. Please try again later.'); | ||
} | ||
$formatter = $this->findFormatter($model) ?? throw new LogicException("Could not find formatter for {$model->value}"); | ||
[$maxLength, $maxContextLength] = $this->getMaxLength($model); | ||
|
||
$response = $this->httpClient->request(Request::METHOD_POST, 'https://aihorde.net/api/v2/generate/text/async', [ | ||
'json' => [ | ||
'prompt' => $formatter->getPrompt(new MessageHistory( | ||
...[...$history, new Message(role: AiActor::User, content: $message)], | ||
)), | ||
'params' => [ | ||
'max_length' => $maxLength, | ||
'max_context_length' => $maxContextLength, | ||
], | ||
'models' => $models, | ||
], | ||
'headers' => [ | ||
'apikey' => $this->apiKey, | ||
], | ||
]); | ||
$json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); | ||
$jobId = $json['id']; | ||
|
||
do { | ||
$response = $this->httpClient->request(Request::METHOD_GET, "https://aihorde.net/api/v2/generate/text/status/{$jobId}", [ | ||
'headers' => [ | ||
'apikey' => $this->apiKey, | ||
], | ||
]); | ||
$json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); | ||
if (!$json['done']) { | ||
sleep(1); | ||
} | ||
} while (!$json['done']); | ||
|
||
if (!isset($json['generations'][0])) { | ||
throw new LogicException('Missing generations output'); | ||
} | ||
|
||
$output = $formatter->formatOutput($json['generations'][0]['text']); | ||
|
||
return $output->content; | ||
} | ||
|
||
/** | ||
* @return array<string> | ||
*/ | ||
public function findModels(AiModel $model): array | ||
{ | ||
$response = $this->httpClient->request(Request::METHOD_GET, 'https://aihorde.net/api/v2/status/models?type=text'); | ||
$json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); | ||
|
||
return array_values(array_map( | ||
fn (array $modelData) => $modelData['name'], | ||
array_filter($json, fn (array $modelData) => fnmatch("*/{$model->value}", $modelData['name'])), | ||
)); | ||
} | ||
|
||
private function findFormatter(AiModel $model): ?MessageFormatter | ||
{ | ||
foreach ($this->formatters as $formatter) { | ||
if ($formatter->supports($model)) { | ||
return $formatter; | ||
} | ||
} | ||
|
||
return null; | ||
} | ||
|
||
private function getMaxLength(AiModel $model): array | ||
{ | ||
$response = $this->httpClient->request(Request::METHOD_GET, 'https://aihorde.net/api/v2/workers?type=text'); | ||
$json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); | ||
$workers = array_filter( | ||
$json, | ||
fn (array $worker) => count(array_filter( | ||
$worker['models'], | ||
fn (string $modelName) => fnmatch("*/{$model->value}", $modelName), | ||
)) > 0, | ||
); | ||
$targetLength = 1024; | ||
$targetContext = 2048; | ||
|
||
if (!count(array_filter($workers, fn(array $worker) => $worker['max_length'] >= $targetLength))) { | ||
$targetLength = max(array_map(fn (array $worker) => $worker['max_length'], $workers)); | ||
} | ||
if (!count(array_filter($workers, fn(array $worker) => $worker['max_context_length'] >= $targetContext))) { | ||
$targetContext = max(array_map(fn (array $worker) => $worker['max_context_length'], $workers)); | ||
} | ||
|
||
if ($targetLength > $targetContext / 2) { | ||
$targetLength = $targetContext / 2; | ||
} | ||
|
||
return [$targetLength, $targetContext]; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
<?php | ||
|
||
namespace App\Service\AiHorde\Message; | ||
|
||
use App\Enum\AiActor; | ||
|
||
final class Message implements \JsonSerializable | ||
{ | ||
public function __construct( | ||
public AiActor $role, | ||
public string $content, | ||
) { | ||
} | ||
|
||
/** | ||
* @return array{role: string, content: string} | ||
*/ | ||
public function jsonSerialize(): array | ||
{ | ||
return [ | ||
'role' => $this->role->value, | ||
'content' => $this->content, | ||
]; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,73 @@ | ||
<?php | ||
|
||
namespace App\Service\AiHorde\Message; | ||
|
||
use ArrayAccess; | ||
use ArrayIterator; | ||
use Countable; | ||
use InvalidArgumentException; | ||
use IteratorAggregate; | ||
use JsonSerializable; | ||
use Traversable; | ||
|
||
/** | ||
* @implements IteratorAggregate<int, Message> | ||
* @implements ArrayAccess<int, Message> | ||
*/ | ||
final class MessageHistory implements IteratorAggregate, ArrayAccess, Countable, JsonSerializable | ||
{ | ||
/** | ||
* @var array<Message> | ||
*/ | ||
private array $messages; | ||
|
||
public function __construct(Message ...$messages) | ||
{ | ||
$this->messages = $messages; | ||
} | ||
|
||
public function getIterator(): Traversable | ||
{ | ||
return new ArrayIterator($this->messages); | ||
} | ||
|
||
public function offsetExists(mixed $offset): bool | ||
{ | ||
return isset($this->messages[$offset]); | ||
} | ||
|
||
public function offsetGet(mixed $offset): Message | ||
{ | ||
return $this->messages[$offset]; | ||
} | ||
|
||
public function offsetSet(mixed $offset, mixed $value): void | ||
{ | ||
if (!$value instanceof Message) { | ||
throw new InvalidArgumentException('Only instances of ' . Message::class . ' are supported'); | ||
} | ||
if ($offset !== null) { | ||
$this->messages[$offset] = $value; | ||
} else { | ||
$this->messages[] = $value; | ||
} | ||
} | ||
|
||
public function offsetUnset(mixed $offset): void | ||
{ | ||
unset($this->messages[$offset]); | ||
} | ||
|
||
public function count(): int | ||
{ | ||
return count($this->messages); | ||
} | ||
|
||
/** | ||
* @return array<array{role: string, content: string}> | ||
*/ | ||
public function jsonSerialize(): array | ||
{ | ||
return array_map(fn (Message $message) => $message->jsonSerialize(), $this->messages); | ||
} | ||
} |
43 changes: 43 additions & 0 deletions
43
src/Service/AiHorde/MessageFormatter/ChatMLPromptFormat.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
<?php | ||
|
||
namespace App\Service\AiHorde\MessageFormatter; | ||
|
||
use App\Enum\AiActor; | ||
use App\Enum\AiModel; | ||
use App\Service\AiHorde\Message\Message; | ||
use App\Service\AiHorde\Message\MessageHistory; | ||
|
||
final readonly class ChatMLPromptFormat implements MessageFormatter | ||
{ | ||
public function getPrompt(MessageHistory $messages): string | ||
{ | ||
return trim(implode("\n", array_map(function (Message $message) { | ||
return "<|im_start|>{$message->role->value}\n{$message->content}<|im_end|>"; | ||
}, [...$messages]))); | ||
} | ||
|
||
public function formatOutput(string $message): Message | ||
{ | ||
$role = 'assistant'; | ||
$message = trim($message); | ||
|
||
if (str_starts_with($message, '<|im_start|>')) { | ||
$message = substr($message, strlen('<|im_start|>')); | ||
$parts = explode("\n", $message, 2); | ||
$message = $parts[1]; | ||
$role = $parts[0]; | ||
} | ||
if (str_ends_with($message, '<|im_end|>')) { | ||
$message = substr($message, 0, -strlen('<|im_end|>')); | ||
} | ||
|
||
$role = AiActor::tryFrom($role) ?? AiActor::Assistant; | ||
|
||
return new Message(role: $role, content: $message); | ||
} | ||
|
||
public function supports(AiModel $model): bool | ||
{ | ||
return in_array($model, [AiModel::Mistral7BOpenHermes], true); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
<?php | ||
|
||
namespace App\Service\AiHorde\MessageFormatter; | ||
|
||
use App\Enum\AiModel; | ||
use App\Service\AiHorde\Message\Message; | ||
use App\Service\AiHorde\Message\MessageHistory; | ||
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; | ||
|
||
#[AutoconfigureTag('app.message_formatter')] | ||
interface MessageFormatter | ||
{ | ||
public function getPrompt(MessageHistory $messages): string; | ||
|
||
public function formatOutput(string $message): Message; | ||
public function supports(AiModel $model): bool; | ||
} |
15 changes: 15 additions & 0 deletions
15
src/Service/Expression/AbstractExpressionLanguageFunctionProvider.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
<?php | ||
|
||
namespace App\Service\Expression; | ||
|
||
use Closure; | ||
use LogicException; | ||
use Symfony\Component\ExpressionLanguage\ExpressionFunctionProviderInterface; | ||
|
||
abstract readonly class AbstractExpressionLanguageFunctionProvider implements ExpressionFunctionProviderInterface | ||
{ | ||
protected function uncompilableFunction(): Closure | ||
{ | ||
return fn () => throw new LogicException('This function cannot be compiled'); | ||
} | ||
} |
Oops, something went wrong.