183 lines
7.0 KiB
PHP
183 lines
7.0 KiB
PHP
<?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;
|
||
}
|
||
}
|