162 خطوط
7.0 KiB
PHP
162 خطوط
7.0 KiB
PHP
<?php
|
|
|
|
namespace App\Modules\AI\Infrastructure;
|
|
|
|
use App\Modules\AI\Domain\AiProvider;
|
|
use Illuminate\Http\Client\PendingRequest;
|
|
use Illuminate\Support\Facades\Http;
|
|
use RuntimeException;
|
|
|
|
final class OpenAiCompatibleProvider implements AiProvider
|
|
{
|
|
public function __construct(
|
|
private readonly AiEndpointPolicy $endpointPolicy,
|
|
private readonly ?array $connection = null,
|
|
) {}
|
|
|
|
public function id(): string
|
|
{
|
|
return isset($this->connection['id']) ? 'connection:'.$this->connection['id'] : 'openai';
|
|
}
|
|
|
|
public function external(): bool
|
|
{
|
|
return ($this->connection['mode'] ?? 'online') === 'online';
|
|
}
|
|
|
|
public function configured(): bool
|
|
{
|
|
if ($this->connection) {
|
|
return filled($this->connection['default_model'] ?? null) && (! $this->external() || filled($this->apiKey()));
|
|
}
|
|
|
|
return filled($this->baseUrl()) && filled($this->apiKey()) && filled(config('ai.openai.models.balanced'));
|
|
}
|
|
|
|
public function chat(string $prompt, ?string $systemPrompt = null): string
|
|
{
|
|
if (! $this->configured()) {
|
|
throw new RuntimeException('ارائهدهنده انتخابشده هنوز تنظیمات معتبر ندارد.');
|
|
}
|
|
|
|
$messages = [];
|
|
if (filled($systemPrompt)) {
|
|
$messages[] = ['role' => 'system', 'content' => $systemPrompt];
|
|
}
|
|
$messages[] = ['role' => 'user', 'content' => $prompt];
|
|
$response = $this->client($this->timeout())->asJson()->retry(2, 500, throw: false)->post($this->baseUrl().'/chat/completions', [
|
|
'model' => $this->model('balanced'),
|
|
'messages' => $messages,
|
|
]);
|
|
if (! $response->successful()) {
|
|
throw new RuntimeException('AI provider request failed with status '.$response->status().'.');
|
|
}
|
|
|
|
$answer = trim((string) $response->json('choices.0.message.content'));
|
|
if ($answer === '') {
|
|
throw new RuntimeException('AI provider returned an empty response.');
|
|
}
|
|
|
|
return $answer;
|
|
}
|
|
|
|
public function health(): array
|
|
{
|
|
if (! $this->configured()) {
|
|
return ['connected' => false, 'message' => 'کلید دسترسی و مدل پیشفرض اتصال را تکمیل کنید.'];
|
|
}
|
|
try {
|
|
$started = microtime(true);
|
|
$response = $this->client(15)->get($this->baseUrl().'/models');
|
|
|
|
return ['connected' => $response->successful(), 'latencyMs' => (int) round((microtime(true) - $started) * 1000), 'message' => $response->successful() ? 'اتصال برقرار است.' : 'ارائهدهنده پاسخ معتبر نداد.'];
|
|
} catch (\Throwable) {
|
|
return ['connected' => false, 'message' => 'اتصال با ارائهدهنده برقرار نشد.'];
|
|
}
|
|
}
|
|
|
|
public function structure(array $fragments, array $options): array
|
|
{
|
|
$source = collect($fragments)->map(fn (array $item) => ['id' => $item['id'], 'heading' => $item['heading'], 'content' => mb_substr($item['content'], 0, 12000)])->all();
|
|
$instruction = 'Create a source-grounded microlearning course draft. Return JSON only with: schemaVersion, providerDisclosure, title, description, settings, modules[].title, modules[].lessons[].title, summary, sourceFragmentIds, blocks[].type, schemaVersion, data. Use only text blocks with data.html. Preserve supplied fragment IDs. Never publish automatically.';
|
|
$payload = $this->complete($instruction, ['source' => $source, 'options' => $options], (string) ($options['modelPolicy'] ?? 'balanced'));
|
|
abort_unless(isset($payload['title'], $payload['modules']) && is_array($payload['modules']), 502, 'پاسخ هوش مصنوعی ساختار معتبر دوره را نداشت.');
|
|
$payload['schemaVersion'] = 1;
|
|
$payload['providerDisclosure'] = 'Generated by the configured OpenAI-compatible provider; human review is required.';
|
|
|
|
return $payload;
|
|
}
|
|
|
|
public function assist(string $operation, string $content, array $context): array
|
|
{
|
|
$modelPolicy = (string) ($context['modelPolicy'] ?? 'fast');
|
|
unset($context['modelPolicy']);
|
|
|
|
return ['schemaVersion' => 1, 'operation' => $operation, 'proposal' => $this->complete('Perform the requested course-authoring operation. Return JSON only. Do not invent facts not present in the content.', ['operation' => $operation, 'content' => $content, 'context' => $context], $modelPolicy), 'providerDisclosure' => 'OpenAI-compatible provider; review before accepting.'];
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
private function complete(string $system, array $input, string $modelPolicy): array
|
|
{
|
|
if (! $this->configured()) {
|
|
throw new RuntimeException('ارائهدهنده انتخابشده هنوز کلید دسترسی معتبر ندارد.');
|
|
}
|
|
$response = $this->client($this->timeout())->asJson()->retry(2, 500, throw: false)->post($this->baseUrl().'/chat/completions', [
|
|
'model' => $this->model($modelPolicy), 'temperature' => 0.2, 'response_format' => ['type' => 'json_object'],
|
|
'messages' => [['role' => 'system', 'content' => $system], ['role' => 'user', 'content' => json_encode($input, JSON_UNESCAPED_UNICODE)]],
|
|
]);
|
|
if (! $response->successful()) {
|
|
throw new RuntimeException('AI provider request failed with status '.$response->status().'.');
|
|
}
|
|
$content = (string) $response->json('choices.0.message.content');
|
|
$decoded = json_decode($content, true);
|
|
if (! is_array($decoded)) {
|
|
throw new RuntimeException('AI provider returned invalid JSON.');
|
|
}
|
|
|
|
return $decoded;
|
|
}
|
|
|
|
private function client(int $timeout): PendingRequest
|
|
{
|
|
$client = Http::acceptJson()
|
|
->timeout($timeout)
|
|
->withOptions($this->endpointPolicy->requestOptions(
|
|
(string) ($this->connection['provider'] ?? 'openai'),
|
|
(string) ($this->connection['mode'] ?? 'online'),
|
|
$this->baseUrl(),
|
|
));
|
|
if (filled($this->apiKey())) {
|
|
$client = $client->withToken($this->apiKey());
|
|
}
|
|
|
|
return $client;
|
|
}
|
|
|
|
private function baseUrl(): string
|
|
{
|
|
return rtrim((string) ($this->connection['base_url'] ?? config('ai.openai.base_url')), '/');
|
|
}
|
|
|
|
private function apiKey(): string
|
|
{
|
|
return (string) ($this->connection['api_key'] ?? config('ai.openai.api_key'));
|
|
}
|
|
|
|
private function model(string $policy): string
|
|
{
|
|
if ($this->connection) {
|
|
return in_array($policy, ['fast', 'balanced', 'advanced'], true)
|
|
? (string) ($this->connection['default_model'] ?? '')
|
|
: $policy;
|
|
}
|
|
|
|
return (string) config("ai.openai.models.{$policy}", config('ai.openai.models.balanced'));
|
|
}
|
|
|
|
private function timeout(): int
|
|
{
|
|
return (int) ($this->connection['timeout_seconds'] ?? config('ai.openai.timeout', 90));
|
|
}
|
|
}
|