Files
SkinbaseNova/app/Services/AiBiography/AiBiographyGenerator.php
2026-04-18 17:02:56 +02:00

183 lines
7.0 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Services\AiBiography;
use Illuminate\Support\Facades\Log;
/**
* Coordinates prompt building, Vision gateway call, and result validation.
*
* v1.1 changes:
* Accepts quality tier so the prompt builder can choose the right template.
* One controlled retry on validation failure, using strict/conservative mode.
* Returns prompt_version and was_retried in the result.
* Logging improved to include retry reason and quality tier.
*
* Does NOT read or write to the database.
* Does NOT know about user-edit flags or storage decisions.
* Those responsibilities belong to AiBiographyService.
*/
class AiBiographyGenerator
{
public function __construct(
private readonly AiBiographyPromptBuilder $promptBuilder,
private readonly VisionLlmClient $llmClient,
private readonly AiBiographyValidator $validator,
) {
}
/**
* Generate a biography from a normalized input payload.
*
* On validation failure a single controlled retry is performed with a
* stricter/more conservative prompt. If the retry also fails, the result
* reports the final combined errors alongside was_retried=true.
*
* Returns:
* success bool
* text string|null
* errors list<string>
* model string|null
* prompt_version string
* was_retried bool
*
* @param array<string, mixed> $input from AiBiographyInputBuilder::build()
* @param string $qualityTier 'rich'|'medium'|'sparse'
* @return array{success: bool, text: string|null, errors: list<string>, 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<string>, 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. <think>...</think>).
$rawText = (string) preg_replace('/<think>.*?<\/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;
}
}