Save workspace changes
This commit is contained in:
182
app/Services/AiBiography/AiBiographyGenerator.php
Normal file
182
app/Services/AiBiography/AiBiographyGenerator.php
Normal file
@@ -0,0 +1,182 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user