Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View 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;
}
}