Files
hoshpoint-backend/app/Services/Ai/OpenAiCompatibleTextGenerator.php
soheil khaledabadi 46ae711a20 feat(api): add AI text-to-deck generation, wallet deposit structure, and profile/notification endpoints
Fills the gaps between the admin panel screens and the backend: a full
free-text + slide-count + language -> AI-generated presentation flow with
new slide-cloning logic in TemplateFillService, a "my presentations" list
endpoint, a swappable payment-gateway structure for wallet top-ups (fake
driver for now), and avatar upload / public-profile toggle / account
deletion / password change / notification preference endpoints.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 01:36:17 +03:30

204 lines
8.0 KiB
PHP

<?php
namespace App\Services\Ai;
use App\Enums\TemplateKeyType;
use App\Models\AiProvider;
use App\Models\Template;
use App\Models\TemplateKey;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class OpenAiCompatibleTextGenerator
{
/**
* @return array<string, string>
*/
public function generateForTemplate(Template $template, string $topic, AiProvider $provider): array
{
$textKeys = $template->keys->where('type', TemplateKeyType::Text);
if ($textKeys->isEmpty()) {
return [];
}
$keysDescription = $textKeys
->map(fn (TemplateKey $key): string => sprintf('- %s (%s)%s', $key->key, $key->label, $key->description ? ': '.$key->description : ''))
->implode("\n");
$systemPrompt = <<<PROMPT
شما دستیار تولید محتوای پاورپوینت هستید. بر اساس موضوعی که کاربر می‌دهد، برای هرکدام از کلیدهای زیر یک متن مناسب فارسی تولید کن.
فقط یک JSON معتبر برگردان که کلیدهای آن دقیقاً و بدون هیچ تغییری (بدون تغییر حروف بزرگ/کوچک، بدون ترجمه، بدون حذف فاصله) برابر با نام کلیدهای زیر باشد. هیچ توضیح اضافه‌ای برنگردان.
کلیدها:
{$keysDescription}
PROMPT;
$response = Http::withToken($provider->api_key)
->timeout(60)
->post(rtrim($provider->resolvedBaseUrl(), '/').'/chat/completions', [
'model' => $provider->model,
'response_format' => ['type' => 'json_object'],
'messages' => [
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $topic],
],
]);
if ($response->failed()) {
throw new RuntimeException('خطا در ارتباط با سرویس هوش مصنوعی: '.$response->body());
}
$decoded = json_decode((string) $response->json('choices.0.message.content'), true);
if (! is_array($decoded)) {
throw new RuntimeException('پاسخ هوش مصنوعی قابل تفسیر نبود.');
}
$normalizedDecoded = [];
foreach ($decoded as $decodedKey => $decodedValue) {
$normalizedDecoded[$this->normalizeKey((string) $decodedKey)] = $decodedValue;
}
$values = [];
foreach ($textKeys as $key) {
$normalizedKey = $this->normalizeKey($key->key);
if (array_key_exists($normalizedKey, $normalizedDecoded)) {
$values[$key->key] = (string) $normalizedDecoded[$normalizedKey];
}
}
return $values;
}
/**
* Generates a full slide outline from free-form text: a set of fixed "title_values"
* (filled once for the whole deck) plus exactly $slideCount "slides", each carrying
* its own values for the template's repeatable keys.
*
* @return array{title_values: array<string, string>, slides: array<int, array<string, string>>}
*/
public function generateOutline(Template $template, string $title, string $sourceText, int $slideCount, string $language, AiProvider $provider): array
{
$textKeys = $template->keys->where('type', TemplateKeyType::Text);
$fixedKeys = $textKeys->where('is_repeatable', false);
$repeatableKeys = $textKeys->where('is_repeatable', true);
if ($repeatableKeys->isEmpty()) {
throw new RuntimeException('کلید تکرارشونده‌ای برای این قالب تعریف نشده است.');
}
$describeKeys = fn ($keys): string => $keys
->map(fn (TemplateKey $key): string => sprintf('- %s (%s)%s', $key->key, $key->label, $key->description ? ': '.$key->description : ''))
->implode("\n");
$fixedKeysDescription = $fixedKeys->isEmpty() ? 'هیچ' : $describeKeys($fixedKeys);
$repeatableKeysDescription = $describeKeys($repeatableKeys);
$systemPrompt = <<<PROMPT
شما دستیار تولید محتوای پاورپوینت هستید. بر اساس عنوان و متن خامی که کاربر می‌دهد، یک ارائه به زبان "{$language}" شامل دقیقاً {$slideCount} اسلاید محتوایی طراحی کن.
فقط یک JSON معتبر با دقیقاً این ساختار برگردان، بدون هیچ توضیح اضافه:
{"title_values": {...}, "slides": [{...}, ...]}
"slides" باید دقیقاً {$slideCount} عضو داشته باشد.
کلیدهای ثابت (یک بار برای کل ارائه، در title_values):
{$fixedKeysDescription}
کلیدهای تکرارشونده (برای هر اسلاید، در هر عضو آرایه slides):
{$repeatableKeysDescription}
نام کلیدها باید دقیقاً و بدون هیچ تغییری (بدون تغییر حروف بزرگ/کوچک، بدون ترجمه، بدون حذف فاصله) برابر با نام‌های بالا باشند.
PROMPT;
$userPrompt = "عنوان: {$title}\n\nمتن خام:\n{$sourceText}";
$response = Http::withToken($provider->api_key)
->timeout(120)
->post(rtrim($provider->resolvedBaseUrl(), '/').'/chat/completions', [
'model' => $provider->model,
'response_format' => ['type' => 'json_object'],
'messages' => [
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userPrompt],
],
]);
if ($response->failed()) {
throw new RuntimeException('خطا در ارتباط با سرویس هوش مصنوعی: '.$response->body());
}
$decoded = json_decode((string) $response->json('choices.0.message.content'), true);
if (! is_array($decoded) || ! is_array($decoded['slides'] ?? null)) {
throw new RuntimeException('پاسخ هوش مصنوعی قابل تفسیر نبود.');
}
$normalizedTitleValues = $this->normalizeValueMap(is_array($decoded['title_values'] ?? null) ? $decoded['title_values'] : []);
$titleValues = [];
foreach ($fixedKeys as $key) {
$normalizedKey = $this->normalizeKey($key->key);
if (array_key_exists($normalizedKey, $normalizedTitleValues)) {
$titleValues[$key->key] = (string) $normalizedTitleValues[$normalizedKey];
}
}
$slides = [];
foreach ($decoded['slides'] as $slideData) {
if (! is_array($slideData)) {
continue;
}
$normalizedSlideData = $this->normalizeValueMap($slideData);
$slideValues = [];
foreach ($repeatableKeys as $key) {
$normalizedKey = $this->normalizeKey($key->key);
if (array_key_exists($normalizedKey, $normalizedSlideData)) {
$slideValues[$key->key] = (string) $normalizedSlideData[$normalizedKey];
}
}
$slides[] = $slideValues;
}
if ($slides === []) {
throw new RuntimeException('هوش مصنوعی محتوایی برای اسلایدها تولید نکرد.');
}
return [
'title_values' => $titleValues,
'slides' => array_slice($slides, 0, $slideCount),
];
}
/**
* @param array<mixed, mixed> $values
* @return array<string, mixed>
*/
private function normalizeValueMap(array $values): array
{
$normalized = [];
foreach ($values as $key => $value) {
$normalized[$this->normalizeKey((string) $key)] = $value;
}
return $normalized;
}
private function normalizeKey(string $key): string
{
return mb_strtolower(trim($key, "{} \t\n\r\0\x0B"));
}
}