New_Micro_Learning/backend/app/Modules/AI/Infrastructure/OpenAiCompatibleProvider.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));
}
}