* model string|null * prompt_version string * was_retried bool * * @param array $input from AiBiographyInputBuilder::build() * @param string $qualityTier 'rich'|'medium'|'sparse' * @return array{success: bool, text: string|null, errors: list, model: string|null, prompt_version: string, was_retried: bool} */ public function generate(array $input, string $qualityTier = 'rich'): array { Log::info('AiBiographyGenerator: generation started', [ 'user_id' => $input['user_id'] ?? null, 'quality_tier' => $qualityTier, ]); $result = $this->attempt($input, $qualityTier, strict: false); if ($result['success']) { Log::info('AiBiographyGenerator: generation succeeded', [ 'user_id' => $input['user_id'] ?? null, 'prompt_version' => $result['prompt_version'], 'quality_tier' => $qualityTier, ]); return array_merge($result, ['was_retried' => false]); } // ── One retry with stricter prompt ─────────────────────────────────── Log::info('AiBiographyGenerator: first attempt failed; retrying with strict prompt', [ 'user_id' => $input['user_id'] ?? null, 'quality_tier' => $qualityTier, 'first_errors' => $result['errors'], ]); $retryResult = $this->attempt($input, $qualityTier, strict: true); if ($retryResult['success']) { Log::info('AiBiographyGenerator: retry succeeded', [ 'user_id' => $input['user_id'] ?? null, 'prompt_version' => $retryResult['prompt_version'], ]); return array_merge($retryResult, ['was_retried' => true]); } Log::warning('AiBiographyGenerator: retry also failed', [ 'user_id' => $input['user_id'] ?? null, 'first_errors' => $result['errors'], 'retry_errors' => $retryResult['errors'], ]); return array_merge($retryResult, ['was_retried' => true]); } // ------------------------------------------------------------------------- /** * Single generation attempt. * * @return array{success: bool, text: string|null, errors: list, model: string|null, prompt_version: string} */ private function attempt(array $input, string $qualityTier, bool $strict): array { $isSparse = $qualityTier === 'sparse'; $payload = $this->promptBuilder->build($input, strict: $strict, sparse: $isSparse); $promptVersion = (string) ($payload['prompt_version'] ?? AiBiographyPromptBuilder::PROMPT_VERSION); // Strip prompt_version before sending to the gateway (not a standard LLM field). $gatewayPayload = array_diff_key($payload, ['prompt_version' => true]); try { $rawText = $this->llmClient->chat($gatewayPayload); } catch (VisionLlmException $e) { Log::warning('AiBiographyGenerator: gateway failure', [ 'user_id' => $input['user_id'] ?? null, 'error' => $e->getMessage(), 'code' => $e->getCode(), ]); return [ 'success' => false, 'text' => null, 'errors' => [$e->getMessage()], 'model' => null, 'prompt_version' => $promptVersion, ]; } $text = $this->normalizeOutput($rawText); $errors = $this->validator->validate($text, $qualityTier); if ($errors !== []) { Log::info('AiBiographyGenerator: validation rejected generated text', [ 'user_id' => $input['user_id'] ?? null, 'quality_tier' => $qualityTier, 'strict' => $strict, 'errors' => $errors, 'excerpt' => mb_substr($text, 0, 120), 'prompt_version' => $promptVersion, ]); return [ 'success' => false, 'text' => null, 'errors' => $errors, 'model' => null, 'prompt_version' => $promptVersion, ]; } return [ 'success' => true, 'text' => $text, 'errors' => [], 'model' => $this->llmClient->configuredModel(), 'prompt_version' => $promptVersion, ]; } /** * Strip model artifacts and normalize whitespace. */ private function normalizeOutput(string $rawText): string { // Strip chain-of-thought reasoning blocks emitted by some models (e.g. ...). $rawText = (string) preg_replace('/.*?<\/think>/si', '', $rawText); // Strip common markdown formatting the model may add despite instructions. $rawText = (string) preg_replace('/\*\*([^*]+)\*\*/', '$1', $rawText); // **bold** $rawText = (string) preg_replace('/\*([^*\n]+)\*/', '$1', $rawText); // *italic* $rawText = (string) preg_replace('/^#{1,6}\s+/m', '', $rawText); // ## headings $rawText = (string) preg_replace('/`([^`]*)`/', '$1', $rawText); // `code` // Normalize: collapse multiple consecutive newlines into a single space. $text = trim((string) preg_replace('/\n{2,}/', ' ', $rawText)); $text = trim((string) preg_replace('/\s{2,}/', ' ', $text)); return $text; } }