diff --git a/public/ajax.php b/public/ajax.php index dc421a9..5ff48bd 100644 --- a/public/ajax.php +++ b/public/ajax.php @@ -139,7 +139,8 @@ try { if (!$projectId) { sendResponse(false, ['code' => 'MISSING_PROJECT_ID', 'message' => 'Project ID is required.'], 400); } - sendResponse(true, $taskActions->generateWebsite($userId, $projectId)); + $result = $taskActions->generateWebsite($userId, $projectId); + sendResponse(true, $result); break; default: diff --git a/scripts/worker.php b/scripts/worker.php new file mode 100644 index 0000000..38b1eb2 --- /dev/null +++ b/scripts/worker.php @@ -0,0 +1,42 @@ +processQueue(); +} catch (Throwable $e) { + echo "[" . date('Y-m-d H:i:s') . "] FATAL ERROR: " . $e->getMessage() . PHP_EOL; +} + +echo "[" . date('Y-m-d H:i:s') . "] Finished LLMpool worker." . PHP_EOL; + +flock($fp, LOCK_UN); +fclose($fp); +unlink($lockFile); diff --git a/src/Services/DAIClient.php b/src/Services/DAIClient.php index 1341679..aa86e49 100644 --- a/src/Services/DAIClient.php +++ b/src/Services/DAIClient.php @@ -9,7 +9,7 @@ use Exception; class DAIClient { private string $apiUrl; - private int $timeout = 60; + private int $timeout = 120; public function __construct(?string $apiUrl = null) { diff --git a/src/Services/LLMpool.php b/src/Services/LLMpool.php new file mode 100644 index 0000000..ff1991e --- /dev/null +++ b/src/Services/LLMpool.php @@ -0,0 +1,164 @@ +storage = new FileStorage(); + $this->daiClient = new DAIClient(); + $this->promptGenerator = new ContentPrompt(); + } + + /** + * Processes all queued tasks. + */ + public function processQueue(): void + { + $tasksDir = realpath(__DIR__ . '/../../data/llm'); + $files = glob($tasksDir . '/t_*.json'); + + foreach ($files as $file) { + $this->processTask(basename($file)); + } + } + + /** + * Processes a single task by its filename. + */ + public function processTask(string $taskFilename): bool + { + $taskPath = "llm/$taskFilename"; + $task = $this->storage->get($taskPath); + + if (!$task || $task['status'] !== 'queued') { + return false; + } + + // 1. Lock task (atomic update to 'generating') + $task['status'] = 'generating'; + $task['attempt_count']++; + $task['started_at'] = date('c'); + $this->storage->put($taskPath, $task); + + $projectId = $task['project_id']; + + // Update project status to 'generating' + $projectData = $this->storage->get("projects/{$projectId}.json"); + if ($projectData) { + $projectData['status'] = 'generating'; + $this->storage->put("projects/{$projectId}.json", $projectData); + } + + $projectActions = new ProjectActions(); + + try { + // 2. Generate Prompt + $prompt = $this->promptGenerator->generate($task['wizard_data']); + + // 3. Call AI + $aiResponse = $this->daiClient->sendRequest($prompt); + + if ($aiResponse === null) { + throw new Exception("AI API returned no answer."); + } + + // Update project status to 'rendering' + if ($projectData) { + $projectData['status'] = 'rendering'; + $this->storage->put("projects/{$projectId}.json", $projectData); + } + + // 4. Validate and Parse + $content = $this->validateAndParseResponse($aiResponse); + + // 5. Update Project + $projectData = $this->storage->get("projects/{$projectId}.json"); + if (!$projectData) { + throw new Exception("Project $projectId not found during processing."); + } + + $projectData['content']['generated'] = $content; + $projectData['status'] = 'ready'; // Transition to ready + $projectData['current_step'] = 7; // Automatically ready for preview + $projectData['updated_at'] = date('c'); + $this->storage->put("projects/{$projectId}.json", $projectData); + + // 6. Finish Task (Delete or Archive) + $this->storage->delete($taskPath); + + error_log("LLMpool: Task $taskFilename processed successfully for project $projectId."); + return true; + + } catch (Throwable $e) { + error_log("LLMpool Error processing task $taskFilename: " . $e->getMessage()); + + $task['status'] = 'queued'; // Back to queue for retry + $task['last_error'] = $e->getMessage(); + + if ($task['attempt_count'] >= $task['max_attempts']) { + $task['status'] = 'failed'; + + // Update project status to failed as well + $projectData = $this->storage->get("projects/{$projectId}.json"); + if ($projectData) { + $projectData['status'] = 'failed'; + $projectData['last_error'] = $e->getMessage(); + $this->storage->put("projects/{$projectId}.json", $projectData); + } + } + + $this->storage->put($taskPath, $task); + return false; + } + } + + /** + * Validates AI output: must be JSON, no HTML, has required sections. + */ + private function validateAndParseResponse(string $rawResponse): array + { + // Strip potential markdown code blocks if AI included them + $jsonStr = trim($rawResponse); + if (strpos($jsonStr, '```json') === 0) { + $jsonStr = substr($jsonStr, 7); + } + if (str_ends_with($jsonStr, '```')) { + $jsonStr = substr($jsonStr, 0, -3); + } + $jsonStr = trim($jsonStr); + + $data = json_decode($jsonStr, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new Exception("Invalid JSON format from AI: " . json_last_error_msg()); + } + + // Check for HTML tags + if (preg_match('/<[a-z\/][^>]*>/i', $jsonStr)) { + throw new Exception("AI response contains prohibited HTML tags."); + } + + // Check required sections + $required = ['seo', 'hero', 'about', 'services']; + foreach ($required as $section) { + if (!isset($data[$section])) { + throw new Exception("AI response is missing required section: $section"); + } + } + + return $data; + } +} diff --git a/tests/test_llmpool.php b/tests/test_llmpool.php new file mode 100644 index 0000000..f82582c --- /dev/null +++ b/tests/test_llmpool.php @@ -0,0 +1,72 @@ + $userId, + 'created_at' => date('c'), + 'projects' => [] +]; +$storage->put("users/{$userId}.json", $userData); + +$project = $projectActions->createProject($userId); +$projectId = $project['project_id']; + +// Mock some wizard data +$project['wizard_data'] = [ + 'business_category' => ['group' => 'gastro', 'subcategory' => 'cafe'], + 'identity' => ['business_name' => 'Kaviareň u Robota', 'tagline' => 'Káva s dušou'], + 'services' => ['items' => [['name' => 'Espresso', 'description' => 'Silná káva', 'price_from' => '2']]], + 'smart_answers' => ['terrace' => true] +]; +$storage->put("projects/{$projectId}.json", $project); + +// 2. Create a dummy task +$taskId = 't_test_worker'; +$taskData = [ + 'task_id' => $taskId, + 'project_id' => $projectId, + 'status' => 'queued', + 'attempt_count' => 0, + 'max_attempts' => 3, + 'created_at' => date('c'), + 'wizard_data' => $project['wizard_data'] +]; +$storage->put("llm/{$taskId}.json", $taskData); + +echo "[INFO] Task created: $taskId for project $projectId" . PHP_EOL; + +// 3. Run Worker +$worker = new LLMpool(); +echo "[PROCESS] Running worker for task $taskId.json..." . PHP_EOL; + +// Since we call real AI, it might take time +$success = $worker->processTask($taskId . '.json'); + +if ($success) { + echo "[SUCCESS] Worker finished successfully." . PHP_EOL; + $updatedProject = $storage->get("projects/{$projectId}.json"); + echo "[INFO] Project status: " . $updatedProject['status'] . PHP_EOL; + echo "[INFO] Generated content keys: " . implode(', ', array_keys($updatedProject['content']['generated'])) . PHP_EOL; +} else { + echo "[FAIL] Worker failed to process task." . PHP_EOL; + $task = $storage->get("llm/{$taskId}.json"); + if ($task) { + echo "[DEBUG] Task status: " . $task['status'] . PHP_EOL; + echo "[DEBUG] Last error: " . ($task['last_error'] ?? 'N/A') . PHP_EOL; + } +}