464 lines
19 KiB
PHP
Executable File
464 lines
19 KiB
PHP
Executable File
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Enums\MessageType;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* Multi-provider AI service supporting OpenAI-compatible APIs.
|
|
* Providers: OpenRouter, OpenAI, StepFun, llama.cpp, QwenCloud, etc.
|
|
*/
|
|
class AIService
|
|
{
|
|
/** @var string The API base URL */
|
|
protected string $baseUrl;
|
|
|
|
/** @var string The API key */
|
|
protected string $apiKey;
|
|
|
|
/** @var string The model name */
|
|
protected string $model;
|
|
|
|
/** @var string The provider name for logging */
|
|
protected string $provider;
|
|
|
|
/** @var array Default headers */
|
|
protected array $headers = [];
|
|
|
|
/**
|
|
* Get the configured default AI provider/model for translations.
|
|
*
|
|
* @return array{provider: string, model: string}
|
|
*/
|
|
public static function getTranslationDefaults(): array
|
|
{
|
|
$provider = \App\Models\Setting::where('key', 'ai_default_provider')->value('value');
|
|
$model = \App\Models\Setting::where('key', 'ai_default_model')->value('value');
|
|
|
|
$provider = $provider ?: config('services.ai.default_provider', 'openrouter');
|
|
$model = $model ?: config('services.ai.providers.'.$provider.'.model', 'inclusionai/ling-3.0-tiny:free');
|
|
|
|
return [
|
|
'provider' => $provider,
|
|
'model' => $model,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Create a new AIService instance.
|
|
*
|
|
* @param string|null $provider Optional provider name. If null, uses default from config.
|
|
* @param string|null $model Optional model name. If null, uses provider default.
|
|
*/
|
|
public function __construct(?string $provider = null, ?string $model = null)
|
|
{
|
|
$defaultProvider = config('services.ai.default_provider', 'openrouter');
|
|
$defaults = self::getTranslationDefaults();
|
|
|
|
$this->loadProvider($provider ?? $defaults['provider'] ?? $defaultProvider);
|
|
|
|
if ($model) {
|
|
$this->model = $model;
|
|
} elseif (!empty($defaults['model'])) {
|
|
$this->model = $defaults['model'];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load provider configuration.
|
|
*/
|
|
protected function loadProvider(string $provider): void
|
|
{
|
|
$this->provider = $provider;
|
|
$config = config("services.ai.providers.{$provider}", []);
|
|
|
|
$this->baseUrl = rtrim($config['base_url'] ?? '', '/') . '/';
|
|
$this->apiKey = $config['api_key'] ?? '';
|
|
$this->model = $config['model'] ?? 'gpt-3.5-turbo';
|
|
|
|
// Set default headers
|
|
$this->headers = [
|
|
'Content-Type' => 'application/json',
|
|
];
|
|
|
|
if (!empty($this->apiKey)) {
|
|
// Some providers use 'Authorization: Bearer', others use 'x-api-key'
|
|
$this->headers['Authorization'] = 'Bearer ' . $this->apiKey;
|
|
}
|
|
|
|
// Provider-specific headers
|
|
if ($provider === 'openrouter') {
|
|
$this->headers['HTTP-Referer'] = config('app.url');
|
|
$this->headers['X-Title'] = config('app.name');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a message based on the configured message type.
|
|
*
|
|
* @param string $subject The subject/topic for the message
|
|
* @param string $userMessage Additional user instructions
|
|
* @param array $context Optional context (language, introduction, etc.)
|
|
* @param \App\Enums\MessageType|null $messageType The type of message to generate
|
|
*/
|
|
public function generateInsight(string $subject, string $userMessage, array $context = [], ?MessageType $messageType = null): array
|
|
{
|
|
if (empty($this->apiKey)) {
|
|
return [
|
|
'success' => false,
|
|
'error' => __('API key not configured for provider: :provider', ['provider' => $this->provider]),
|
|
];
|
|
}
|
|
|
|
// Get language preference from context
|
|
$language = $context['language'] ?? 'en';
|
|
unset($context['language']);
|
|
|
|
$languageNames = [
|
|
'pt_BR' => 'Brazilian Portuguese',
|
|
'en' => 'English',
|
|
'es' => 'Spanish',
|
|
];
|
|
$languageName = $languageNames[$language] ?? 'English';
|
|
|
|
$messageType = $messageType ?? MessageType::Insight;
|
|
|
|
$baseSystemPrompt = "You are an expert learning assistant for the subject: {$subject}. ";
|
|
$languageInstruction = "CRITICAL LANGUAGE INSTRUCTION - You MUST follow this exactly:\n"
|
|
. "Your entire response MUST be written ONLY in {$languageName} ({$language}).\n"
|
|
. "Do NOT use any other language. Do NOT include English translations or explanations.\n"
|
|
. "If the subject is a language course (like English), you may use that language as examples,\n"
|
|
. "but all instructions, explanations, and tips MUST be in {$languageName}.\n"
|
|
. "This is mandatory. Start your response directly with the content in {$languageName}.";
|
|
|
|
$systemPrompt = match ($messageType) {
|
|
MessageType::LanguageLesson => $baseSystemPrompt
|
|
. "You are acting as a specialized Language Teacher. "
|
|
. "CRITICAL LANGUAGE RULE FOR LESSONS:\n"
|
|
. "- Explanations, instructions, and tips MUST be written in {$languageName} ({$language}).\n"
|
|
. "- BUT all target-language content (examples, vocabulary, practice sentences) MUST be in the language being taught: English.\n"
|
|
. "- Do NOT translate English examples into {$languageName}. Keep English content pure.\n"
|
|
. "- For Practical Examples and Practice Sentences: the ENGLISH sentence comes FIRST, then its Portuguese translation AFTER a hyphen.\n"
|
|
. "- Example: 'The experiment shows that light travels faster than sound. - O experimento mostra que a luz viaja mais rápido que o som.'\n\n"
|
|
. "ABSOLUTE RULES FOR PRACTICAL EXAMPLES AND PRACTICE SENTENCES:\n"
|
|
. "1. The English sentence MUST come FIRST.\n"
|
|
. "2. After the English sentence, add a hyphen and the Portuguese translation.\n"
|
|
. "3. Do NOT write the Portuguese sentence first.\n"
|
|
. "4. Do NOT repeat the same sentence twice.\n"
|
|
. "5. Do NOT mix languages in the same sentence.\n\n"
|
|
. "Create a complete language lesson structured exactly as follows:\n\n"
|
|
. "## Insight\n"
|
|
. "A brief, motivating introduction/insight about the language concept being taught. Write this in {$languageName}.\n\n"
|
|
. "## Grammar Explanation\n"
|
|
. "Explain ONE specific grammar point clearly and concisely. "
|
|
. "Include rules, usage, and common mistakes. Write this in {$languageName}.\n\n"
|
|
. "## Vocabulary Building\n"
|
|
. "List 5-8 essential words/phrases IN ENGLISH. For each, provide:\n"
|
|
. "- The word/phrase IN ENGLISH wrapped in a <span class=\"tts\" data-text=\"[English word/phrase]\">[English word/phrase]</span> tag\n"
|
|
. "- Its meaning in {$languageName}\n"
|
|
. "- An example sentence IN ENGLISH wrapped in a <span class=\"tts\" data-text=\"[English example sentence]\">[English example sentence]</span> tag\n\n"
|
|
. "## Practical Examples\n"
|
|
. "Provide 3-5 practical example sentences IN ENGLISH showing real-world usage. "
|
|
. "Format each line EXACTLY as:\n"
|
|
. "<span class=\"tts\" data-text=\"[English sentence]\">[English sentence]</span> - [Portuguese translation]\n"
|
|
. "Do NOT put Portuguese first. English MUST be first.\n\n"
|
|
. "## Practice Sentences\n"
|
|
. "Provide 5-7 complete sentences IN ENGLISH for the user to practice speaking. "
|
|
. "Format each line EXACTLY as:\n"
|
|
. "/[Complete IPA for the full English sentence]/ <span class=\"tts\" data-text=\"[English sentence]\">[English sentence]</span> - [Portuguese translation]\n"
|
|
. "The IPA must be COMPLETE for the FULL English sentence. Do NOT truncate IPA.\n\n"
|
|
. "WRONG FORMAT (DO NOT USE):\n"
|
|
. "- [Portuguese sentence] - [English sentence]\n"
|
|
. "CORRECT FORMAT (MANDATORY):\n"
|
|
. "- [English sentence] - [Portuguese sentence]\n\n"
|
|
. "Make it engaging, structured, and suitable for self-study.\n\n"
|
|
. $languageInstruction,
|
|
|
|
MessageType::GameSimulator => $baseSystemPrompt
|
|
. "You are acting as a Game and Simulator Strategy Expert. "
|
|
. "Create content that includes:\n"
|
|
. "- An insight or strategic tip related to the subject\n"
|
|
. "- Practical strategies and tactics\n"
|
|
. "- Commands, shortcuts, or step-by-step instructions where applicable\n"
|
|
. "- Scenarios or simulation tips\n"
|
|
. "Make it actionable and engaging.\n\n"
|
|
. $languageInstruction,
|
|
|
|
MessageType::TechnicalTeaching => $baseSystemPrompt
|
|
. "You are acting as a Specialized Technical Teacher. "
|
|
. "Create content that includes:\n"
|
|
. "- An insight about the technical subject\n"
|
|
. "- Description and practical use of components/tools\n"
|
|
. "- Types of components/tools and their differences\n"
|
|
. "- Service techniques and best practices\n"
|
|
. "- Repair techniques and troubleshooting\n"
|
|
. "- How to read technical diagrams/schematics\n"
|
|
. "- Practical examples and applied exercises when relevant\n"
|
|
. "Be direct, technical, and educational. Use clear step-by-step explanations.\n\n"
|
|
. $languageInstruction,
|
|
|
|
default => $baseSystemPrompt
|
|
. "Provide insightful, educational content. Be concise and practical.\n\n"
|
|
. $languageInstruction,
|
|
};
|
|
|
|
$userMessageWithLanguage = "[IMPORTANTE: Responda EXCLUSIVAMENTE em {$languageName}. "
|
|
. "NÃO use inglês. NÃO traduza. NÃO misture idiomas. "
|
|
. "Toda a resposta deve ser APENAS em {$languageName}.]\n\n"
|
|
. $userMessage;
|
|
|
|
if ($messageType === MessageType::TechnicalTeaching && str_contains(strtolower($subject), 'eletrônica')) {
|
|
$userMessageWithLanguage .= "\n\nSPECIAL INSTRUCTION: For each lesson, include explanation, technical description, and practical applications of specific electronic components. Examples: LM555, AT89C2051, BC557.\n";
|
|
$userMessageWithLanguage .= "CRITICAL: Write ALL content in Brazilian Portuguese. Even technical terms must be explained in Portuguese. Do NOT write in English.";
|
|
}
|
|
|
|
$messages = [
|
|
['role' => 'system', 'content' => $systemPrompt],
|
|
['role' => 'user', 'content' => $userMessageWithLanguage],
|
|
];
|
|
|
|
// Add context if provided
|
|
if (!empty($context)) {
|
|
$contextStr = "Context information:\n";
|
|
foreach ($context as $key => $value) {
|
|
$contextStr .= "- {$key}: {$value}\n";
|
|
}
|
|
array_unshift($messages, ['role' => 'system', 'content' => $contextStr]);
|
|
}
|
|
|
|
try {
|
|
$response = Http::withHeaders($this->headers)
|
|
->timeout(120)
|
|
->post($this->baseUrl . 'chat/completions', [
|
|
'model' => $this->model,
|
|
'messages' => $messages,
|
|
]);
|
|
|
|
$data = $response->json();
|
|
|
|
if (!$data || !isset($data['choices'][0]['message'])) {
|
|
$errorMsg = $data['error']['message'] ?? ($data['error'] ?? __('Unknown API error'));
|
|
Log::error("AI Service ({$this->provider}) error response", [
|
|
'response' => $data,
|
|
'subject' => $subject,
|
|
'message_type' => $messageType->value,
|
|
]);
|
|
return [
|
|
'success' => false,
|
|
'error' => $errorMsg,
|
|
];
|
|
}
|
|
|
|
$message = $data['choices'][0]['message'];
|
|
$content = $message['content'] ?? $message['reasoning'] ?? null;
|
|
|
|
if ($content) {
|
|
return [
|
|
'success' => true,
|
|
'content' => $content,
|
|
'input_tokens' => $data['usage']['prompt_tokens'] ?? 0,
|
|
'output_tokens' => $data['usage']['completion_tokens'] ?? 0,
|
|
];
|
|
}
|
|
|
|
Log::error("AI Service ({$this->provider}): Empty response content", [
|
|
'response' => $data,
|
|
'subject' => $subject,
|
|
'message_type' => $messageType->value,
|
|
]);
|
|
|
|
return [
|
|
'success' => false,
|
|
'error' => __('The AI model returned an empty response. Please try again.'),
|
|
];
|
|
} catch (\Exception $e) {
|
|
Log::error("AI Service ({$this->provider}) error: " . $e->getMessage(), [
|
|
'subject' => $subject,
|
|
'message_type' => $messageType->value,
|
|
]);
|
|
return [
|
|
'success' => false,
|
|
'error' => $e->getMessage(),
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the current provider name.
|
|
*/
|
|
public function getProvider(): string
|
|
{
|
|
return $this->provider;
|
|
}
|
|
|
|
/**
|
|
* Get the current model name.
|
|
*/
|
|
public function getModel(): string
|
|
{
|
|
return $this->model;
|
|
}
|
|
|
|
/**
|
|
* Translate text to the specified language using AI.
|
|
*
|
|
* @param string $text The text to translate
|
|
* @param string $targetLanguage The target language code (pt_BR, en, es)
|
|
* @param string|null $subject Optional subject context for better translation
|
|
*/
|
|
public function translate(string $text, string $targetLanguage, ?string $subject = null): array
|
|
{
|
|
if (empty($this->apiKey)) {
|
|
return [
|
|
'success' => false,
|
|
'error' => __('API key not configured for provider: :provider', ['provider' => $this->provider]),
|
|
];
|
|
}
|
|
|
|
$languageNames = [
|
|
'pt_BR' => 'Brazilian Portuguese',
|
|
'en' => 'English',
|
|
'es' => 'Spanish',
|
|
];
|
|
$languageName = $languageNames[$targetLanguage] ?? 'English';
|
|
|
|
$subjectContext = $subject ? "This is about: {$subject}.\n" : '';
|
|
|
|
$systemPrompt = "You are a professional translator. Translate the following text to {$languageName} ({$targetLanguage}).\n\n"
|
|
. "RULES:\n"
|
|
. "1. Translate ONLY the content, preserving the original meaning, tone, and structure.\n"
|
|
. "2. Keep all markdown formatting, HTML tags, and special characters exactly as they are.\n"
|
|
. "3. Do NOT add any explanations, notes, or translations of your own.\n"
|
|
. "4. Do NOT translate code blocks, URLs, or technical terms that are commonly kept in English.\n"
|
|
. "5. Return ONLY the translated text, nothing else.\n"
|
|
. "6. The entire response must be in {$languageName}.";
|
|
|
|
$userMessage = "{$subjectContext}Translate this text to {$languageName}:\n\n{$text}";
|
|
|
|
$messages = [
|
|
['role' => 'system', 'content' => $systemPrompt],
|
|
['role' => 'user', 'content' => $userMessage],
|
|
];
|
|
|
|
try {
|
|
$response = Http::withHeaders($this->headers)
|
|
->timeout(120)
|
|
->post($this->baseUrl . 'chat/completions', [
|
|
'model' => $this->model,
|
|
'messages' => $messages,
|
|
]);
|
|
|
|
$data = $response->json();
|
|
|
|
if (!$data || !isset($data['choices'][0]['message'])) {
|
|
$errorMsg = $data['error']['message'] ?? ($data['error'] ?? __('Unknown API error'));
|
|
Log::error("AI Service ({$this->provider}) translation error", [
|
|
'response' => $data,
|
|
'target_language' => $targetLanguage,
|
|
]);
|
|
return [
|
|
'success' => false,
|
|
'error' => $errorMsg,
|
|
];
|
|
}
|
|
|
|
$message = $data['choices'][0]['message'];
|
|
$content = $message['content'] ?? $message['reasoning'] ?? null;
|
|
|
|
if ($content) {
|
|
return [
|
|
'success' => true,
|
|
'content' => $content,
|
|
'input_tokens' => $data['usage']['prompt_tokens'] ?? 0,
|
|
'output_tokens' => $data['usage']['completion_tokens'] ?? 0,
|
|
];
|
|
}
|
|
|
|
Log::error("AI Service ({$this->provider}): Empty translation response", [
|
|
'response' => $data,
|
|
'target_language' => $targetLanguage,
|
|
]);
|
|
|
|
return [
|
|
'success' => false,
|
|
'error' => __('The AI model returned an empty response. Please try again.'),
|
|
];
|
|
} catch (\Exception $e) {
|
|
Log::error("AI Service ({$this->provider}) translation error: " . $e->getMessage(), [
|
|
'target_language' => $targetLanguage,
|
|
]);
|
|
return [
|
|
'success' => false,
|
|
'error' => $e->getMessage(),
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send a chat message to the AI and get a response.
|
|
*
|
|
* @param array<int, array{role: string, content: string}> $messages
|
|
*/
|
|
public function chat(array $messages): array
|
|
{
|
|
if (empty($this->apiKey)) {
|
|
return [
|
|
'success' => false,
|
|
'error' => __('API key not configured for provider: :provider', ['provider' => $this->provider]),
|
|
];
|
|
}
|
|
|
|
try {
|
|
$response = Http::withHeaders($this->headers)
|
|
->timeout(120)
|
|
->post($this->baseUrl . 'chat/completions', [
|
|
'model' => $this->model,
|
|
'messages' => $messages,
|
|
]);
|
|
|
|
$data = $response->json();
|
|
|
|
if (!$data || !isset($data['choices'][0]['message'])) {
|
|
$errorMsg = $data['error']['message'] ?? ($data['error'] ?? __('Unknown API error'));
|
|
Log::error("AI Service ({$this->provider}) chat error", [
|
|
'response' => $data,
|
|
]);
|
|
return [
|
|
'success' => false,
|
|
'error' => $errorMsg,
|
|
];
|
|
}
|
|
|
|
$message = $data['choices'][0]['message'];
|
|
$content = $message['content'] ?? $message['reasoning'] ?? null;
|
|
|
|
if ($content) {
|
|
return [
|
|
'success' => true,
|
|
'content' => $content,
|
|
'input_tokens' => $data['usage']['prompt_tokens'] ?? 0,
|
|
'output_tokens' => $data['usage']['completion_tokens'] ?? 0,
|
|
];
|
|
}
|
|
|
|
Log::error("AI Service ({$this->provider}): Empty chat response", [
|
|
'response' => $data,
|
|
]);
|
|
|
|
return [
|
|
'success' => false,
|
|
'error' => __('The AI model returned an empty response. Please try again.'),
|
|
];
|
|
} catch (\Exception $e) {
|
|
Log::error("AI Service ({$this->provider}) chat error: " . $e->getMessage(), []);
|
|
return [
|
|
'success' => false,
|
|
'error' => $e->getMessage(),
|
|
];
|
|
}
|
|
}
|
|
} |