First Commit
This commit is contained in:
Executable
+464
@@ -0,0 +1,464 @@
|
||||
<?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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
/**
|
||||
* Simple Markdown parser service for rendering AI-generated insights.
|
||||
* Converts basic Markdown syntax to HTML without external dependencies.
|
||||
*/
|
||||
class MarkdownService
|
||||
{
|
||||
/**
|
||||
* Convert Markdown text to HTML.
|
||||
*
|
||||
* @param string|null $text The markdown text to convert.
|
||||
* @return string The converted HTML.
|
||||
*/
|
||||
public static function parse(?string $text): string
|
||||
{
|
||||
if (empty($text)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$html = $text;
|
||||
|
||||
// Preserve safe HTML tags before escaping (e.g., <span class="tts" data-text="...">)
|
||||
$placeholders = [];
|
||||
$html = preg_replace_callback(
|
||||
'/<span\s+class="tts"\s+data-text="([^"]+)"\s*>(.*?)<\/span>/',
|
||||
function ($matches) use (&$placeholders) {
|
||||
$key = '___TTS_' . count($placeholders) . '___';
|
||||
$placeholders[$key] = $matches[0];
|
||||
return $key;
|
||||
},
|
||||
$html
|
||||
);
|
||||
|
||||
$html = htmlspecialchars($html, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
|
||||
// Restore preserved tags
|
||||
foreach ($placeholders as $key => $original) {
|
||||
$html = str_replace($key, $original, $html);
|
||||
}
|
||||
|
||||
// Headers (h1-h4)
|
||||
$html = preg_replace('/^### (.+)$/m', '<h4>$1</h4>', $html);
|
||||
$html = preg_replace('/^## (.+)$/m', '<h3>$1</h3>', $html);
|
||||
$html = preg_replace('/^# (.+)$/m', '<h2>$1</h2>', $html);
|
||||
|
||||
// Bold and italic
|
||||
$html = preg_replace('/\*\*\*(.+?)\*\*\*/', '<strong><em>$1</em></strong>', $html);
|
||||
$html = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $html);
|
||||
$html = preg_replace('/\*(.+?)\*/', '<em>$1</em>', $html);
|
||||
|
||||
// Inline code
|
||||
$html = preg_replace('/`(.+?)`/', '<code>$1</code>', $html);
|
||||
|
||||
// Lines starting with dashes or asterisks (unordered list)
|
||||
$html = preg_replace('/^[-*] (.+)$/m', '<li>$1</li>', $html);
|
||||
|
||||
// Wrap consecutive <li> in <ul>
|
||||
$html = preg_replace('/((?:<li>.*?<\/li>\n?)+)/', '<ul>$1</ul>', $html);
|
||||
|
||||
// Lines starting with numbers (ordered list)
|
||||
$html = preg_replace('/^\d+\.\s(.+)$/m', '<li>$1</li>', $html);
|
||||
|
||||
// Horizontal rules
|
||||
$html = preg_replace('/^---$/m', '<hr>', $html);
|
||||
|
||||
// Blockquotes
|
||||
$html = preg_replace('/^> (.+)$/m', '<blockquote>$1</blockquote>', $html);
|
||||
|
||||
// Line breaks (double newline = paragraph)
|
||||
$paragraphs = preg_split('/\n\n+/', $html);
|
||||
$result = [];
|
||||
|
||||
foreach ($paragraphs as $paragraph) {
|
||||
$paragraph = trim($paragraph);
|
||||
|
||||
if (empty($paragraph)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if already wrapped in block-level tags
|
||||
if (preg_match('/^<(?:ul|ol|li|h[2-4]|hr|blockquote|div)/', $paragraph)) {
|
||||
$result[] = $paragraph;
|
||||
} else {
|
||||
$result[] = '<p>' . $paragraph . '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n", $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip markdown formatting, returning plain text.
|
||||
*
|
||||
* @param string|null $text The markdown text to strip.
|
||||
* @return string Plain text without markdown formatting.
|
||||
*/
|
||||
public static function strip(?string $text): string
|
||||
{
|
||||
if (empty($text)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$text = preg_replace('/[*_~`#>`\[\]()!-]/', '', $text);
|
||||
$text = preg_replace('/\n{3,}/', "\n\n", $text);
|
||||
|
||||
return trim($text);
|
||||
}
|
||||
}
|
||||
Executable
+117
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class OpenRouterService
|
||||
{
|
||||
public function generateInsight(string $subject, string $userMessage, array $context = []): array
|
||||
{
|
||||
$apiKey = config("services.openrouter.api_key");
|
||||
$model = config("services.openrouter.model", "google/gemini-2.0-flash-exp:free");
|
||||
|
||||
// Get language preference from context or default to English
|
||||
$language = $context['language'] ?? 'en';
|
||||
unset($context['language']);
|
||||
|
||||
// Map language codes to full names
|
||||
$languageNames = [
|
||||
'pt_BR' => 'Brazilian Portuguese',
|
||||
'en' => 'English',
|
||||
'es' => 'Spanish',
|
||||
];
|
||||
$languageName = $languageNames[$language] ?? 'English';
|
||||
|
||||
if (!$apiKey) {
|
||||
return [
|
||||
"success" => false,
|
||||
"error" => "OpenRouter API key not configured",
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$systemPrompt = "You are an expert learning assistant for the subject: {$subject}. "
|
||||
. "Provide insightful, educational content. Be concise and practical.\n\n"
|
||||
. "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 tip in {$languageName}.";
|
||||
|
||||
// Append language instruction to user message as well
|
||||
$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;
|
||||
|
||||
$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]);
|
||||
}
|
||||
|
||||
$response = Http::withHeaders([
|
||||
"Authorization" => "Bearer " . $apiKey,
|
||||
"Content-Type" => "application/json",
|
||||
"HTTP-Referer" => config("app.url"),
|
||||
"X-Title" => config("app.name"),
|
||||
])->timeout(120)->post("https://openrouter.ai/api/v1/chat/completions", [
|
||||
"model" => $model,
|
||||
"messages" => $messages,
|
||||
]);
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
if (!$data || !isset($data["choices"][0]["message"])) {
|
||||
$errorMsg = $data["error"]["message"] ?? "Unknown API error";
|
||||
Log::error("OpenRouter API error response", [
|
||||
"response" => $data,
|
||||
"subject" => $subject,
|
||||
]);
|
||||
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("OpenRouter API: Empty response content", [
|
||||
"response" => $data,
|
||||
"subject" => $subject,
|
||||
]);
|
||||
|
||||
return [
|
||||
"success" => false,
|
||||
"error" => "The AI model returned an empty response. Please try again.",
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
Log::error("OpenRouter API error: " . $e->getMessage());
|
||||
return [
|
||||
"success" => false,
|
||||
"error" => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Smalot\PdfParser\Parser;
|
||||
|
||||
/**
|
||||
* Service for extracting text content from PDF files.
|
||||
*/
|
||||
class PdfExtractionService
|
||||
{
|
||||
/**
|
||||
* Extract text from a PDF file.
|
||||
*
|
||||
* @param string $filePath The path to the PDF file
|
||||
* @return array{success: bool, content: string|null, error: string|null}
|
||||
*/
|
||||
public function extract(string $filePath): array
|
||||
{
|
||||
if (!file_exists($filePath)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'content' => null,
|
||||
'error' => 'PDF file not found.',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$parser = new Parser();
|
||||
$pdf = $parser->parseFile($filePath);
|
||||
$text = $pdf->getText();
|
||||
|
||||
$text = trim($text);
|
||||
|
||||
if (empty($text)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'content' => null,
|
||||
'error' => 'The PDF appears to be empty or contains no extractable text.',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'content' => $text,
|
||||
'error' => null,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('PDF extraction error: ' . $e->getMessage(), [
|
||||
'file' => $filePath,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'content' => null,
|
||||
'error' => 'Failed to extract text from PDF: ' . $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+149
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Service for sending messages via Telegram bot API.
|
||||
* Supports user-specific bot tokens for individual bot configuration.
|
||||
*/
|
||||
class TelegramService
|
||||
{
|
||||
/** @var string|null The Telegram bot API token */
|
||||
protected ?string $botToken = null;
|
||||
|
||||
/** @var string The Telegram API base URL */
|
||||
protected string $apiBase = 'https://api.telegram.org/bot';
|
||||
|
||||
/**
|
||||
* Create a new TelegramService instance.
|
||||
*
|
||||
* @param string|null $botToken Optional bot token. If null, uses default from config.
|
||||
*/
|
||||
public function __construct(?string $botToken = null)
|
||||
{
|
||||
$this->botToken = $botToken ?? config('services.telegram.bot_token');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a Telegram chat.
|
||||
*
|
||||
* @param string $chatId The chat ID to send the message to.
|
||||
* @param string $message The message text (supports Markdown).
|
||||
* @return bool True if the message was sent successfully.
|
||||
*/
|
||||
public function sendMessage(string $chatId, string $message): bool
|
||||
{
|
||||
if (empty($this->botToken)) {
|
||||
Log::error('TelegramService: No bot token configured.');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$url = $this->apiBase . $this->botToken . '/sendMessage';
|
||||
|
||||
$client = new \GuzzleHttp\Client();
|
||||
$response = $client->post($url, [
|
||||
'json' => [
|
||||
'chat_id' => $chatId,
|
||||
'text' => $message,
|
||||
'parse_mode' => 'Markdown',
|
||||
],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
$data = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
if ($data && ($data['ok'] ?? false)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Log::error('TelegramService: API error', [
|
||||
'response' => $data,
|
||||
'chat_id' => $chatId,
|
||||
]);
|
||||
|
||||
return false;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('TelegramService: Failed to send message', [
|
||||
'error' => $e->getMessage(),
|
||||
'chat_id' => $chatId,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a webhook for the bot.
|
||||
*
|
||||
* @param string $url The webhook URL.
|
||||
* @return bool True if the webhook was set successfully.
|
||||
*/
|
||||
public function setWebhook(string $url): bool
|
||||
{
|
||||
if (empty($this->botToken)) {
|
||||
Log::error('TelegramService: No bot token configured for webhook.');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$webhookUrl = $this->apiBase . $this->botToken . '/setWebhook';
|
||||
|
||||
$client = new \GuzzleHttp\Client();
|
||||
$response = $client->post($webhookUrl, [
|
||||
'json' => [
|
||||
'url' => $url,
|
||||
'allowed_updates' => ['message', 'callback_query'],
|
||||
],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
$data = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
return $data && ($data['ok'] ?? false);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('TelegramService: Failed to set webhook', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bot information.
|
||||
*
|
||||
* @return array|null Bot info or null on failure.
|
||||
*/
|
||||
public function getMe(): ?array
|
||||
{
|
||||
if (empty($this->botToken)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$url = $this->apiBase . $this->botToken . '/getMe';
|
||||
|
||||
$client = new \GuzzleHttp\Client();
|
||||
$response = $client->get($url, ['timeout' => 10]);
|
||||
|
||||
$data = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
if ($data && ($data['ok'] ?? false)) {
|
||||
return $data['result'];
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('TelegramService: Failed to get bot info', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user