diff --git a/ai/LocalAIApi.php b/ai/LocalAIApi.php index e3c3d05..5f429a6 100644 --- a/ai/LocalAIApi.php +++ b/ai/LocalAIApi.php @@ -1,617 +1,589 @@ questionnaireData = $questionnaireData; - $this->language = $language; - } + /** @var array|null */ + private static ?array $configCache = null; + /** @var array Default options */ + private static array $defaultOptions = [ + 'poll_interval' => 5, + 'poll_timeout' => 300, + 'timeout' => 30, + 'verify_tls' => true, + 'max_retries' => 3, + 'retry_delay' => 2, + ]; + /** - * Process the complete questionnaire and generate AI analysis + * Create an AI response (async: auto-polls status until ready). + * Signature compatible with the OpenAI Responses API. + * + * Usage: + * require_once __DIR__ . '/ai/LocalAIApi.php'; + * $response = LocalAIApi::createResponse([ + * 'input' => [ + * ['role' => 'system', 'content' => 'You are a helpful assistant.'], + * ['role' => 'user', 'content' => 'Tell me a bedtime story.'], + * ], + * ]); + * if (!empty($response['success'])) { + * // response['data'] contains full payload + * $decoded = LocalAIApi::decodeJsonFromResponse($response); + * } + * + * @param array $params Request body (model, input, text, reasoning, metadata, etc.) + * @param array $options Extra options (timeout, verify_tls, headers, path, project_uuid) + * @return array{ + * success: bool, + * status?: int, + * data?: mixed, + * error?: string, + * response?: mixed, + * message?: string + * } */ - public function process(): array + public static function createResponse(array $params, array $options = []): array { - try { - // Step 1: Generate AI prompt - $prompt = $this->generateAIPrompt(); - - // Step 2: Call AI service - $aiResponse = $this->callAIService($prompt); - - // Step 3: Parse and structure the response - $analysis = $this->parseAIResponse($aiResponse); - - // Step 4: Calculate scores and metrics - $analysis['scores'] = $this->calculateScores(); - $analysis['metrics'] = $this->calculateMetrics(); - $analysis['tools'] = $this->recommendTools(); - $analysis['timeline'] = $this->generateTimeline(); - - return [ - 'success' => true, - 'analysis' => $analysis, - 'report_id' => $this->generateReportId(), - 'timestamp' => date('Y-m-d H:i:s') - ]; - - } catch (Exception $e) { + $cfg = self::config(); + $payload = $params; + + // Validate input + if (empty($payload['input']) || !is_array($payload['input'])) { return [ 'success' => false, - 'error' => $e->getMessage(), - 'fallback' => $this->generateFallbackAnalysis() + 'error' => 'input_missing', + 'message' => 'Parameter "input" is required and must be an array.', ]; } + + // Set default model if not provided + if (!isset($payload['model']) || $payload['model'] === '') { + $payload['model'] = $cfg['default_model']; + } + + // Merge with default options + $options = array_merge(self::$defaultOptions, $options); + + // Make initial request + $initial = self::request($options['path'] ?? null, $payload, $options); + if (empty($initial['success'])) { + return $initial; + } + + // Async flow: if backend returns ai_request_id, poll status until ready + $data = $initial['data'] ?? null; + if (is_array($data) && isset($data['ai_request_id'])) { + $aiRequestId = $data['ai_request_id']; + return self::awaitResponse($aiRequestId, $options); + } + + return $initial; } - + /** - * Generate comprehensive AI prompt based on questionnaire answers + * Snake_case alias for createResponse (matches the provided example). + * + * @param array $params + * @param array $options + * @return array */ - private function generateAIPrompt(): string + public static function create_response(array $params, array $options = []): array { - $answers = $this->questionnaireData; - - // System prompt based on language - $systemPrompts = [ - 'de' => "Du bist ein KI-Beratungsexperte für kleine und mittlere Unternehmen in Deutschland. Analysiere diese KI-Readiness-Bewertung und erstelle eine professionelle, maßgeschneiderte Analyse.", - 'en' => "You are an AI consulting expert for small and medium-sized businesses. Analyze this AI readiness assessment and create a professional, tailored analysis.", - 'tr' => "Küçük ve orta ölçekli işletmeler için bir AI danışmanlık uzmanısınız. Bu AI hazırlık değerlendirmesini analiz edin ve profesyonel, özelleştirilmiş bir analiz oluşturun." + return self::createResponse($params, $options); + } + + /** + * Perform a raw request to the AI proxy. + * + * @param string|null $path Endpoint (may be an absolute URL) + * @param array $payload JSON payload + * @param array $options Additional request options + * @return array + */ + public static function request(?string $path = null, array $payload = [], array $options = []): array + { + $cfg = self::config(); + + $projectUuid = $cfg['project_uuid']; + if (empty($projectUuid)) { + return [ + 'success' => false, + 'error' => 'project_uuid_missing', + 'message' => 'PROJECT_UUID is not defined; aborting AI request.', + ]; + } + + $defaultPath = $cfg['responses_path'] ?? null; + $resolvedPath = $path ?? ($options['path'] ?? $defaultPath); + if (empty($resolvedPath)) { + return [ + 'success' => false, + 'error' => 'project_id_missing', + 'message' => 'PROJECT_ID is not defined; cannot resolve AI proxy endpoint.', + ]; + } + + $url = self::buildUrl($resolvedPath, $cfg['base_url']); + $timeout = $options['timeout'] ?? $cfg['timeout'] ?? 30; + $verifyTls = $options['verify_tls'] ?? $cfg['verify_tls'] ?? true; + $maxRetries = $options['max_retries'] ?? $cfg['max_retries'] ?? 3; + $retryDelay = $options['retry_delay'] ?? $cfg['retry_delay'] ?? 2; + + $projectHeader = $cfg['project_header']; + + $headers = [ + 'Content-Type: application/json', + 'Accept: application/json', ]; + $headers[] = $projectHeader . ': ' . $projectUuid; - $systemPrompt = $systemPrompts[$this->language] ?? $systemPrompts['de']; - - // Build detailed user prompt - $prompt = "Analysiere diese KI-Readiness-Bewertung:\n\n"; - - // Company Information - $prompt .= "=== UNTERNEHMENSINFORMATIONEN ===\n"; - if (!empty($answers['q2'])) { - $prompt .= "Branche: " . $answers['q2'] . "\n"; - } - if (!empty($answers['q1'])) { - $sizeMap = [ - '1-person' => 'Einzelunternehmer (1 Person)', - '2-5' => 'Kleinstunternehmen (2-5 Mitarbeiter)', - '6-20' => 'Kleinunternehmen (6-20 Mitarbeiter)', - '21-50' => 'Mittelstand (21-50 Mitarbeiter)', - '50plus' => 'Großunternehmen (50+ Mitarbeiter)' - ]; - $prompt .= "Unternehmensgröße: " . ($sizeMap[$answers['q1']] ?? $answers['q1']) . "\n"; - } - - // Business Model - if (!empty($answers['q3']) && is_array($answers['q3'])) { - $modelMap = [ - 'dienstleistung' => 'Dienstleistung', - 'handel' => 'Handel', - 'coaching' => 'Coaching/Beratung', - 'agentur' => 'Agentur', - 'ecommerce' => 'E-Commerce', - 'produktion' => 'Produktion' - ]; - $models = array_map(fn($m) => $modelMap[$m] ?? $m, $answers['q3']); - $prompt .= "Geschäftsmodelle: " . implode(', ', $models) . "\n"; - } - - // Primary Goals - if (!empty($answers['q4'])) { - $goalMap = [ - 'zeit-sparen' => 'Zeit sparen bei repetitiven Aufgaben', - 'kosten-reduzieren' => 'Kosten reduzieren durch Automatisierung', - 'wachstum' => 'Wachstum beschleunigen', - 'qualität' => 'Qualität verbessern', - 'innovation' => 'Innovation vorantreiben' - ]; - $prompt .= "Primäres KI-Ziel: " . ($goalMap[$answers['q4']] ?? $answers['q4']) . "\n"; - } - - // AI Importance - if (!empty($answers['q5'])) { - $importance = [ - '1' => 'nicht wichtig', - '2' => 'eher unwichtig', - '3' => 'neutral', - '4' => 'wichtig', - '5' => 'sehr wichtig' - ]; - $prompt .= "KI-Bedeutung: " . ($importance[$answers['q5']] ?? $answers['q5']) . "/5\n"; - } - - // Time-consuming Tasks - if (!empty($answers['q6']) && is_array($answers['q6'])) { - $taskMap = [ - 'kundenkommunikation' => 'Kundenkommunikation', - 'administrative' => 'Administrative Aufgaben', - 'marketing' => 'Marketing/Content', - 'buchhaltung' => 'Buchhaltung/Finanzen', - 'planung' => 'Planung/Organisation', - 'daten' => 'Datenanalyse' - ]; - $tasks = array_map(fn($t) => $taskMap[$t] ?? $t, $answers['q6']); - $prompt .= "\nZeitintensive Aufgaben:\n- " . implode("\n- ", $tasks) . "\n"; - } - - // Current Automation - if (!empty($answers['q7'])) { - $prompt .= "\nAktuelle Automatisierung:\n" . $answers['q7'] . "\n"; - } - - // Weekly Hours on Repetitive Tasks - if (!empty($answers['q8'])) { - $hoursMap = [ - 'unter-5h' => 'unter 5 Stunden/Woche', - '5-10h' => '5-10 Stunden/Woche', - '11-15h' => '11-15 Stunden/Woche', - '16-20h' => '16-20 Stunden/Woche', - '20plus' => 'über 20 Stunden/Woche' - ]; - $prompt .= "Repetitive Stunden/Woche: " . ($hoursMap[$answers['q8']] ?? $answers['q8']) . "\n"; - } - - // Budget - if (!empty($answers['q9'])) { - $budgetMap = [ - 'unter-50' => 'unter 50€/Monat', - '50-100' => '50-100€/Monat', - '100-300' => '100-300€/Monat', - '300-500' => '300-500€/Monat', - '500plus' => '500€+/Monat' - ]; - $prompt .= "KI-Budget: " . ($budgetMap[$answers['q9']] ?? $answers['q9']) . "\n"; - } - - // Technical Affinity - if (!empty($answers['q10'])) { - $affinity = [ - '1' => 'sehr schwer', - '2' => 'eher schwer', - '3' => 'neutral', - '4' => 'einfach', - '5' => 'sehr einfach' - ]; - $prompt .= "Technische Affinität: " . ($affinity[$answers['q10']] ?? $answers['q10']) . "/5\n"; - } - - // Current AI Tools - if (!empty($answers['q11']) && is_array($answers['q11'])) { - $toolMap = [ - 'chatgpt' => 'ChatGPT/Sprach-KIs', - 'midjourney' => 'Bildgenerierung', - 'crm' => 'KI-CRM Tools', - 'marketing' => 'KI-Marketing Tools', - 'automatisierung' => 'Automatisierungs-Tools', - 'keine' => 'Keine KI-Tools' - ]; - $tools = array_map(fn($t) => $toolMap[$t] ?? $t, $answers['q11']); - $prompt .= "Aktuelle KI-Tools: " . implode(', ', $tools) . "\n"; - } - - // Biggest Challenge - if (!empty($answers['q12'])) { - $challengeMap = [ - 'zeit' => 'Zeitmangel', - 'wissen' => 'Fehlendes Wissen', - 'kosten' => 'Kosten', - 'integration' => 'Integration', - 'nutzen' => 'Unklarheit über Nutzen' - ]; - $prompt .= "Größte Herausforderung: " . ($challengeMap[$answers['q12']] ?? $answers['q12']) . "\n"; - } - - // Implementation Timeline - if (!empty($answers['q13'])) { - $timelineMap = [ - 'sofort' => 'sofort (innerhalb 1 Monat)', - 'kurz' => 'kurzfristig (1-3 Monate)', - 'mittel' => 'mittelfristig (3-6 Monate)', - 'lang' => 'langfristig (6+ Monate)', - 'keine' => 'keine konkreten Pläne' - ]; - $prompt .= "Umsetzungszeitraum: " . ($timelineMap[$answers['q13']] ?? $answers['q13']) . "\n"; - } - - $prompt .= "\n=== ANFORDERUNGEN FÜR DIE ANALYSE ===\n"; - $prompt .= "Bitte erstelle eine strukturierte Analyse mit folgenden Abschnitten:\n"; - $prompt .= "1. KI-Readiness Score (0-100) mit Begründung\n"; - $prompt .= "2. Stärken des Unternehmens für KI-Implementierung\n"; - $prompt .= "3. Verbesserungsbereiche\n"; - $prompt .= "4. Konkrete KI-Anwendungsfälle für dieses Unternehmen\n"; - $prompt .= "5. Quick Wins (schnell umsetzbare Maßnahmen)\n"; - $prompt .= "6. Strategische Empfehlungen für die nächsten 12 Monate\n"; - $prompt .= "7. Geschätzte Zeit- und Kosteneinsparungspotenziale\n\n"; - $prompt .= "Antworte in einem professionellen, aber verständlichen Ton. Nutze Überschriften und Aufzählungen für bessere Lesbarkeit."; - - return json_encode([ - 'model' => 'gpt-4', - 'input' => [ - ['role' => 'system', 'content' => $systemPrompt], - ['role' => 'user', 'content' => $prompt] - ], - 'temperature' => 0.7, - 'max_tokens' => 3000 - ]); - } - - /** - * Call the AI service using LocalAIApi - */ - private function callAIService(string $prompt): array - { - $payload = json_decode($prompt, true); - - // Use LocalAIApi to call the AI service - $response = LocalAIApi::createResponse($payload, [ - 'poll_interval' => 3, - 'poll_timeout' => 60 - ]); - - if (empty($response['success'])) { - throw new Exception('AI service error: ' . ($response['error'] ?? 'Unknown error')); - } - - return $response; - } - - /** - * Parse AI response into structured format - */ - private function parseAIResponse(array $aiResponse): array - { - $text = LocalAIApi::extractText($aiResponse); - - // Try to parse as JSON first - $json = LocalAIApi::decodeJsonFromResponse($aiResponse); - if ($json) { - return $this->parseStructuredJSON($json); - } - - // Otherwise, parse the text - return $this->parseTextResponse($text); - } - - /** - * Parse structured JSON response - */ - private function parseStructuredJSON(array $json): array - { - return [ - 'score' => $json['score'] ?? $this->calculateScore(), - 'score_explanation' => $json['score_explanation'] ?? '', - 'strengths' => $json['strengths'] ?? [], - 'improvements' => $json['improvements'] ?? [], - 'use_cases' => $json['use_cases'] ?? [], - 'quick_wins' => $json['quick_wins'] ?? [], - 'recommendations' => $json['recommendations'] ?? [], - 'estimated_savings' => $json['estimated_savings'] ?? [], - 'full_analysis' => $json - ]; - } - - /** - * Parse text response - */ - private function parseTextResponse(string $text): array - { - // Extract score from text - preg_match('/Score[\s:]*(\d+)/i', $text, $scoreMatches); - $score = isset($scoreMatches[1]) ? (int)$scoreMatches[1] : $this->calculateScore(); - - // Extract sections (simplified parsing) - $sections = [ - 'strengths' => $this->extractSection($text, ['Stärken', 'Stärke', 'Vorteile']), - 'improvements' => $this->extractSection($text, ['Verbesserung', 'Schwächen', 'Herausforderung']), - 'quick_wins' => $this->extractSection($text, ['Quick Wins', 'schnelle Maßnahmen', 'sofort umsetzbar']), - 'recommendations' => $this->extractSection($text, ['Empfehlung', 'Maßnahmen', 'Vorschläge']) - ]; - - return [ - 'score' => $score, - 'score_explanation' => $this->extractScoreExplanation($text), - 'strengths' => $sections['strengths'], - 'improvements' => $sections['improvements'], - 'use_cases' => $this->extractUseCases($text), - 'quick_wins' => $sections['quick_wins'], - 'recommendations' => $sections['recommendations'], - 'estimated_savings' => $this->extractSavings($text), - 'full_analysis' => $text - ]; - } - - /** - * Calculate readiness score based on answers - */ - private function calculateScore(): int - { - $score = 50; // Base score - - // Add points based on answers - if (!empty($this->questionnaireData['q5'])) { - $score += ((int)$this->questionnaireData['q5'] - 3) * 10; - } - - if (!empty($this->questionnaireData['q10'])) { - $score += ((int)$this->questionnaireData['q10'] - 3) * 8; - } - - if (!empty($this->questionnaireData['q8'])) { - $hoursMap = [ - 'unter-5h' => -10, - '5-10h' => 0, - '11-15h' => 15, - '16-20h' => 25, - '20plus' => 30 - ]; - $score += $hoursMap[$this->questionnaireData['q8']] ?? 0; - } - - if (!empty($this->questionnaireData['q13'])) { - $timelineMap = [ - 'sofort' => 20, - 'kurz' => 15, - 'mittel' => 10, - 'lang' => 5, - 'keine' => 0 - ]; - $score += $timelineMap[$this->questionnaireData['q13']] ?? 0; - } - - return max(0, min(100, $score)); - } - - /** - * Calculate business metrics - */ - private function calculateMetrics(): array - { - $answers = $this->questionnaireData; - - // Time saving potential - $hoursMap = [ - 'unter-5h' => 10, - '5-10h' => 20, - '11-15h' => 30, - '16-20h' => 40, - '20plus' => 50 - ]; - $weeklyHours = $hoursMap[$answers['q8']] ?? 20; - - // Cost saving (assuming €50/hour) - $monthlyCostSaving = $weeklyHours * 50 * 4.33; - - // Quick wins based on number of time-consuming tasks - $quickWins = !empty($answers['q6']) && is_array($answers['q6']) - ? min(count($answers['q6']), 8) - : 3; - - return [ - 'time_saving_weekly' => $weeklyHours . 'h', - 'time_saving_monthly' => ($weeklyHours * 4.33) . 'h', - 'cost_saving_monthly' => '€' . number_format($monthlyCostSaving, 0, ',', '.'), - 'cost_saving_yearly' => '€' . number_format($monthlyCostSaving * 12, 0, ',', '.'), - 'quick_wins_count' => $quickWins, - 'roi_months' => 6 // Estimated ROI in months - ]; - } - - /** - * Recommend tools based on answers - */ - private function recommendTools(): array - { - $answers = $this->questionnaireData; - $tools = []; - - // Always recommend these basics - $tools[] = [ - 'name' => 'ChatGPT Plus', - 'category' => 'Content & Kreativität', - 'priority' => 'high', - 'description' => 'Für Texterstellung, Ideen & Content', - 'price' => '20€/Monat' - ]; - - // Based on business model - if (!empty($answers['q3']) && is_array($answers['q3'])) { - if (in_array('dienstleistung', $answers['q3']) || in_array('coaching', $answers['q3'])) { - $tools[] = [ - 'name' => 'Calendly', - 'category' => 'Terminplanung', - 'priority' => 'medium', - 'description' => 'Automatisierte Terminbuchung', - 'price' => '10€/Monat' - ]; - } - - if (in_array('ecommerce', $answers['q3'])) { - $tools[] = [ - 'name' => 'Zapier', - 'category' => 'Automatisierung', - 'priority' => 'high', - 'description' => 'App-Integration ohne Code', - 'price' => '20€/Monat' - ]; - } - - if (in_array('agentur', $answers['q3']) || in_array('marketing', $answers['q3'])) { - $tools[] = [ - 'name' => 'Jasper AI', - 'category' => 'Content Marketing', - 'priority' => 'high', - 'description' => 'Professionelle Marketing-Texte', - 'price' => '39€/Monat' - ]; - } - } - - // Based on time-consuming tasks - if (!empty($answers['q6']) && is_array($answers['q6'])) { - if (in_array('kundenkommunikation', $answers['q6'])) { - $tools[] = [ - 'name' => 'Pipedrive', - 'category' => 'CRM & Vertrieb', - 'priority' => 'high', - 'description' => 'Visuelles CRM für den Vertrieb', - 'price' => '15€/Monat' - ]; - } - - if (in_array('buchhaltung', $answers['q6'])) { - $tools[] = [ - 'name' => 'QuickBooks', - 'category' => 'Buchhaltung', - 'priority' => 'medium', - 'description' => 'KI-gestützte Buchhaltung', - 'price' => '25€/Monat' - ]; - } - } - - // Based on budget - if (!empty($answers['q9'])) { - if ($answers['q9'] === 'unter-50') { - $tools = array_slice($tools, 0, 2); // Limit to 2 tools for small budget - } - } - - return array_slice($tools, 0, 5); // Max 5 tools - } - - /** - * Generate implementation timeline - */ - private function generateTimeline(): array - { - $answers = $this->questionnaireData; - - if (empty($answers['q13'])) { - return $this->getDefaultTimeline(); - } - - $timelines = [ - 'sofort' => [ - ['month' => 1, 'action' => 'Quick Wins implementieren', 'tools' => ['ChatGPT', 'Calendly']], - ['month' => 2, 'action' => 'Kundenkommunikation automatisieren', 'tools' => ['Pipedrive', 'Zapier']], - ['month' => 3, 'action' => 'Reporting & Analyse aufbauen', 'tools' => ['Power BI', 'Google Analytics']] - ], - 'kurz' => [ - ['month' => 1, 'action' => 'Machbarkeitsstudie', 'tools' => []], - ['month' => 2, 'action' => 'Pilotprojekt starten', 'tools' => ['ChatGPT']], - ['month' => 3, 'action' => 'Team schulen', 'tools' => []], - ['month' => 4, 'action' => 'Erste Prozesse automatisieren', 'tools' => ['Zapier']] - ], - 'mittel' => [ - ['month' => 1, 'action' => 'Strategie entwickeln', 'tools' => []], - ['month' => 2, 'action' => 'Tools evaluieren', 'tools' => []], - ['month' => 3, 'action' => 'Budget planen', 'tools' => []], - ['month' => 4, 'action' => 'Pilot starten', 'tools' => []], - ['month' => 5, 'action' => 'Erste Ergebnisse analysieren', 'tools' => []], - ['month' => 6, 'action' => 'Skalierung planen', 'tools' => []] - ], - 'lang' => [ - ['month' => 1, 'action' => 'Informationsphase', 'tools' => []], - ['month' => 3, 'action' => 'Anforderungsanalyse', 'tools' => []], - ['month' => 6, 'action' => 'Strategieentwicklung', 'tools' => []], - ['month' => 9, 'action' => 'Pilotprojekt starten', 'tools' => []], - ['month' => 12, 'action' => 'Evaluierung', 'tools' => []] - ] - ]; - - return $timelines[$answers['q13']] ?? $this->getDefaultTimeline(); - } - - private function getDefaultTimeline(): array - { - return [ - ['month' => 1, 'action' => 'Quick Wins umsetzen', 'tools' => ['ChatGPT']], - ['month' => 2, 'action' => 'Prozessanalyse durchführen', 'tools' => []], - ['month' => 3, 'action' => 'Erste Automatisierungen', 'tools' => ['Zapier']], - ['month' => 6, 'action' => 'Ergebnisse evaluieren', 'tools' => []], - ['month' => 12, 'action' => 'KI-Strategie skalieren', 'tools' => []] - ]; - } - - /** - * Helper methods for text parsing - */ - private function extractSection(string $text, array $keywords): array - { - $lines = explode("\n", $text); - $items = []; - $inSection = false; - - foreach ($lines as $line) { - $line = trim($line); - - // Check if we've entered a section - foreach ($keywords as $keyword) { - if (stripos($line, $keyword) !== false && strlen($line) < 100) { - $inSection = true; - continue 2; + if (!empty($options['headers']) && is_array($options['headers'])) { + foreach ($options['headers'] as $header) { + if (is_string($header) && $header !== '') { + $headers[] = $header; } } + } + + // Add project_uuid to payload if not present + if (!empty($projectUuid) && !array_key_exists('project_uuid', $payload)) { + $payload['project_uuid'] = $projectUuid; + } + + // Add debug info if enabled + if ($cfg['debug'] ?? false) { + $payload['debug'] = true; + } + + $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($body === false) { + return [ + 'success' => false, + 'error' => 'json_encode_failed', + 'message' => 'Failed to encode request body to JSON.', + ]; + } + + // Retry logic + $attempt = 0; + $lastError = null; + + while ($attempt < $maxRetries) { + $attempt++; - // Collect items in section - if ($inSection && ($line === '' || stripos($line, '###') !== false)) { + $result = self::sendCurl($url, 'POST', $body, $headers, $timeout, $verifyTls); + + if ($result['success']) { + return $result; + } + + $lastError = $result; + + // Don't retry on client errors (4xx) + $status = $result['status'] ?? 0; + if ($status >= 400 && $status < 500) { break; } - if ($inSection && $line !== '' && preg_match('/^[-•\d.]+\s+(.+)$/', $line, $matches)) { - $items[] = $matches[1]; + // Wait before retrying + if ($attempt < $maxRetries) { + sleep($retryDelay); } } + + return $lastError ?? [ + 'success' => false, + 'error' => 'max_retries_exceeded', + 'message' => 'Maximum retry attempts exceeded.', + ]; + } + + /** + * Poll AI request status until ready or timeout. + * + * @param int|string $aiRequestId + * @param array $options + * @return array + */ + public static function awaitResponse($aiRequestId, array $options = []): array + { + $cfg = self::config(); + + $timeout = $options['poll_timeout'] ?? 300; // seconds + $interval = $options['poll_interval'] ?? 5; // seconds + if ($interval <= 0) { + $interval = 5; + } + $perCallTimeout = $options['timeout'] ?? null; + + $deadline = time() + max($timeout, $interval); + $headers = $options['headers'] ?? []; + + $attempt = 0; - return array_slice($items, 0, 5); + while (true) { + $attempt++; + $statusResp = self::fetchStatus($aiRequestId, [ + 'headers' => $headers, + 'timeout' => $perCallTimeout, + ]); + + if (!empty($statusResp['success'])) { + $data = $statusResp['data'] ?? []; + if (is_array($data)) { + $statusValue = $data['status'] ?? null; + if ($statusValue === 'success' || $statusValue === 'completed') { + return [ + 'success' => true, + 'status' => 200, + 'data' => $data['response'] ?? $data, + 'attempts' => $attempt, + ]; + } + if ($statusValue === 'failed' || $statusValue === 'error') { + return [ + 'success' => false, + 'status' => 500, + 'error' => isset($data['error']) ? (string)$data['error'] : 'AI request failed', + 'data' => $data, + 'attempts' => $attempt, + ]; + } + } + } else { + return $statusResp; + } + + if (time() >= $deadline) { + return [ + 'success' => false, + 'error' => 'timeout', + 'message' => 'Timed out waiting for AI response after ' . $attempt . ' attempts.', + 'attempts' => $attempt, + ]; + } + + // Exponential backoff + $sleepTime = $interval * (1 + ($attempt * 0.1)); + sleep(min($sleepTime, 30)); // Max 30 seconds between polls + } } - - private function extractUseCases(string $text): array + + /** + * Fetch status for queued AI request. + * + * @param int|string $aiRequestId + * @param array $options + * @return array + */ + public static function fetchStatus($aiRequestId, array $options = []): array { - // Simple extraction of numbered items - preg_match_all('/\d+\.\s+(.+?)(?=\n\d+\.|\n###|$)/s', $text, $matches); - return $matches[1] ?? ['Kundenkommunikation automatisieren', 'Content-Erstellung optimieren', 'Datenanalyse verbessern']; - } - - private function extractSavings(string $text): array - { - preg_match_all('/(\d+[\d.,]*)\s*(?:Stunden|h|€|EUR)/i', $text, $matches); - return $matches[0] ?? ['20-30 Stunden/Woche', '1.000-2.000€/Monat']; - } - - private function extractScoreExplanation(string $text): string - { - $lines = explode("\n", $text); - foreach ($lines as $line) { - if (stripos($line, 'Score') !== false || stripos($line, 'Bewertung') !== false) { - return $line; + $cfg = self::config(); + $projectUuid = $cfg['project_uuid']; + if (empty($projectUuid)) { + return [ + 'success' => false, + 'error' => 'project_uuid_missing', + 'message' => 'PROJECT_UUID is not defined; aborting status check.', + ]; + } + + $statusPath = self::resolveStatusPath($aiRequestId, $cfg); + $url = self::buildUrl($statusPath, $cfg['base_url']); + + $timeout = $options['timeout'] ?? $cfg['timeout'] ?? 30; + $verifyTls = $options['verify_tls'] ?? $cfg['verify_tls'] ?? true; + + $projectHeader = $cfg['project_header']; + $headers = [ + 'Accept: application/json', + $projectHeader . ': ' . $projectUuid, + ]; + if (!empty($options['headers']) && is_array($options['headers'])) { + foreach ($options['headers'] as $header) { + if (is_string($header) && $header !== '') { + $headers[] = $header; + } } } + + return self::sendCurl($url, 'GET', null, $headers, $timeout, $verifyTls); + } + + /** + * Extract plain text from a Responses API payload. + * + * @param array $response Result of LocalAIApi::createResponse|request + * @return string + */ + public static function extractText(array $response): string + { + $payload = $response['data'] ?? $response; + if (!is_array($payload)) { + return ''; + } + + // Try to extract from OpenAI Responses API format + if (!empty($payload['output']) && is_array($payload['output'])) { + $combined = ''; + foreach ($payload['output'] as $item) { + if (!isset($item['content']) || !is_array($item['content'])) { + continue; + } + foreach ($item['content'] as $block) { + if (is_array($block) && ($block['type'] ?? '') === 'output_text' && !empty($block['text'])) { + $combined .= $block['text']; + } + } + } + if ($combined !== '') { + return $combined; + } + } + + // Try to extract from OpenAI Chat Completion format + if (!empty($payload['choices'][0]['message']['content'])) { + return (string) $payload['choices'][0]['message']['content']; + } + + // Try to extract from generic response + if (!empty($payload['text'])) { + return (string) $payload['text']; + } + return ''; } - - private function generateReportId(): string + + /** + * Attempt to decode JSON emitted by the model (handles markdown fences). + * + * @param array $response + * @return array|null + */ + public static function decodeJsonFromResponse(array $response): ?array { - return 'KI-' . date('Ymd-His') . '-' . strtoupper(substr(md5(uniqid()), 0, 6)); + $text = self::extractText($response); + if ($text === '') { + return null; + } + + // First try to decode directly + $decoded = json_decode($text, true); + if (is_array($decoded)) { + return $decoded; + } + + // Try stripping markdown code fences + $stripped = preg_replace('/^```(?:json)?\s*\n|\n```$/m', '', trim($text)); + if ($stripped !== null && $stripped !== $text) { + $decoded = json_decode($stripped, true); + if (is_array($decoded)) { + return $decoded; + } + } + + // Try to extract JSON from text + if (preg_match('/\{.*\}/s', $text, $matches)) { + $decoded = json_decode($matches[0], true); + if (is_array($decoded)) { + return $decoded; + } + } + + return null; } - - private function generateFallbackAnalysis(): array + + /** + * Load configuration from ai/config.php. + * + * @return array + * @throws RuntimeException + */ + private static function config(): array { + if (self::$configCache === null) { + $configPath = __DIR__ . '/config.php'; + if (!file_exists($configPath)) { + throw new RuntimeException('AI config file not found: ai/config.php'); + } + $cfg = require $configPath; + if (!is_array($cfg)) { + throw new RuntimeException('Invalid AI config format: expected array'); + } + + // Merge with defaults + $defaults = [ + 'debug' => false, + 'max_retries' => 3, + 'retry_delay' => 2, + 'features' => [ + 'async_polling' => true, + 'json_response' => true, + 'streaming' => false, + ], + ]; + + self::$configCache = array_merge($defaults, $cfg); + } + + return self::$configCache; + } + + /** + * Build an absolute URL from base_url and a path. + */ + private static function buildUrl(string $path, string $baseUrl): string + { + $trimmed = trim($path); + if ($trimmed === '') { + return $baseUrl; + } + if (str_starts_with($trimmed, 'http://') || str_starts_with($trimmed, 'https://')) { + return $trimmed; + } + if ($trimmed[0] === '/') { + return $baseUrl . $trimmed; + } + return $baseUrl . '/' . $trimmed; + } + + /** + * Resolve status path based on configured responses_path and ai_request_id. + * + * @param int|string $aiRequestId + * @param array $cfg + * @return string + */ + private static function resolveStatusPath($aiRequestId, array $cfg): string + { + $basePath = $cfg['responses_path'] ?? ''; + $trimmed = rtrim($basePath, '/'); + if ($trimmed === '') { + return '/ai-request/' . rawurlencode((string)$aiRequestId) . '/status'; + } + if (substr($trimmed, -11) !== '/ai-request') { + $trimmed .= '/ai-request'; + } + return $trimmed . '/' . rawurlencode((string)$aiRequestId) . '/status'; + } + + /** + * Shared CURL sender for GET/POST requests. + * + * @param string $url + * @param string $method + * @param string|null $body + * @param array $headers + * @param int $timeout + * @param bool $verifyTls + * @return array + */ + private static function sendCurl(string $url, string $method, ?string $body, array $headers, int $timeout, bool $verifyTls): array + { + if (!function_exists('curl_init')) { + return [ + 'success' => false, + 'error' => 'curl_missing', + 'message' => 'PHP cURL extension is missing. Install or enable it on the server.', + ]; + } + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $verifyTls); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, $verifyTls ? 2 : 0); + curl_setopt($ch, CURLOPT_FAILONERROR, false); + + // Add debug info if enabled + $cfg = self::config(); + if ($cfg['debug'] ?? false) { + curl_setopt($ch, CURLOPT_VERBOSE, true); + $debugFile = fopen(__DIR__ . '/../logs/curl_debug.log', 'a'); + if ($debugFile) { + curl_setopt($ch, CURLOPT_STDERR, $debugFile); + } + } + + $upper = strtoupper($method); + if ($upper === 'POST') { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body ?? ''); + } else { + curl_setopt($ch, CURLOPT_HTTPGET, true); + } + + $responseBody = curl_exec($ch); + $error = curl_error($ch); + $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + // Close debug file if opened + if (isset($debugFile) && is_resource($debugFile)) { + fclose($debugFile); + } + + if ($responseBody === false) { + return [ + 'success' => false, + 'error' => 'curl_error', + 'message' => $error ?: 'Unknown cURL error', + 'status' => $status, + ]; + } + + $decoded = null; + if ($responseBody !== '' && $responseBody !== null) { + $decoded = json_decode($responseBody, true); + if (json_last_error() !== JSON_ERROR_NONE) { + $decoded = null; + } + } + + if ($status >= 200 && $status < 300) { + return [ + 'success' => true, + 'status' => $status, + 'data' => $decoded ?? $responseBody, + ]; + } + + $errorMessage = 'AI proxy request failed'; + if (is_array($decoded)) { + $errorMessage = $decoded['error'] ?? $decoded['message'] ?? $errorMessage; + } elseif (is_string($responseBody) && $responseBody !== '') { + $errorMessage = $responseBody; + } + return [ - 'score' => $this->calculateScore(), - 'score_explanation' => 'Basierend auf Ihren Antworten berechnet', - 'strengths' => ['Bereitschaft zur Veränderung', 'Klares Ziel definiert'], - 'improvements' => ['Prozessanalyse benötigt', 'Budgetplanung optimieren'], - 'use_cases' => ['Kundenkommunikation automatisieren', 'Content-Erstellung optimieren'], - 'quick_wins' => ['ChatGPT für E-Mail-Vorlagen nutzen', 'Automatische Terminbuchung einrichten'], - 'recommendations' => ['Mit kleinen Projekten starten', 'Team schulen und einbinden'], - 'estimated_savings' => ['20-30 Stunden/Woche', '1.000-2.000€/Monat'] + 'success' => false, + 'status' => $status, + 'error' => $errorMessage, + 'response' => $decoded ?? $responseBody, ]; } } -?> \ No newline at end of file + +// Legacy alias for backward compatibility with the previous class name. +if (!class_exists('OpenAIService')) { + class_alias(LocalAIApi::class, 'OpenAIService'); +} + +// Helper function for quick usage +if (!function_exists('ai_request')) { + /** + * Helper function for quick AI requests + * + * @param array $params + * @param array $options + * @return array + */ + function ai_request(array $params, array $options = []): array { + return LocalAIApi::createResponse($params, $options); + } +} \ No newline at end of file