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;
|
||||
}
|
||||
}
|
||||
430
app/Services/AiBiography/AiBiographyInputBuilder.php
Normal file
430
app/Services/AiBiography/AiBiographyInputBuilder.php
Normal file
@@ -0,0 +1,430 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\AiBiography;
|
||||
|
||||
use App\Models\ArtworkRelation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Builds a normalized, public-safe input payload from creator data.
|
||||
*
|
||||
* Data sources: user record, user_profiles, creator_milestones, creator_eras,
|
||||
* artworks (public only), artwork_features, artwork_relations.
|
||||
*
|
||||
* Privacy rules:
|
||||
* – Only public, approved, non-deleted artworks are used.
|
||||
* – No private milestones (is_public = false).
|
||||
* – No moderation, staff, or hidden data.
|
||||
* – No personal attributes (age, gender, location, religion, etc.).
|
||||
*/
|
||||
final class AiBiographyInputBuilder
|
||||
{
|
||||
/**
|
||||
* Build and return the normalized input array for a creator.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function build(User $user): array
|
||||
{
|
||||
$userId = (int) $user->id;
|
||||
|
||||
$memberSinceYear = (int) $user->created_at->format('Y');
|
||||
$yearsOnSkinbase = (int) now()->format('Y') - $memberSinceYear;
|
||||
|
||||
$uploadsCount = $this->publicUploadsCount($userId);
|
||||
$featuredCount = $this->featuredCount($userId);
|
||||
$downloadsCount = $this->totalDownloads($userId);
|
||||
$topCategories = $this->topCategories($userId);
|
||||
$topTags = $this->topTags($userId);
|
||||
$bestWork = $this->bestPerformingWork($userId);
|
||||
$mostProductiveYear = $this->mostProductiveYear($userId);
|
||||
$evolutionCount = $this->evolutionCount($userId);
|
||||
$activityStatus = $this->activityStatus($userId);
|
||||
$milestones = $this->publicMilestoneSignals($userId);
|
||||
$eras = $this->publicEras($userId);
|
||||
|
||||
return [
|
||||
'user_id' => $userId,
|
||||
'username' => (string) $user->username,
|
||||
'member_since_year' => $memberSinceYear,
|
||||
'years_on_skinbase' => max(0, $yearsOnSkinbase),
|
||||
'uploads_count' => $uploadsCount,
|
||||
'featured_count' => $featuredCount,
|
||||
'downloads_count' => $downloadsCount,
|
||||
'top_categories' => $topCategories,
|
||||
'top_tags' => $topTags,
|
||||
'best_performing_work' => $bestWork,
|
||||
'most_productive_year' => $mostProductiveYear,
|
||||
'evolution_count' => $evolutionCount,
|
||||
'current_activity_status' => $activityStatus,
|
||||
'milestones' => $milestones,
|
||||
'eras' => $eras,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a deterministic SHA-256 hash from the normalized input.
|
||||
* Changing any meaningful field changes the hash, enabling stale detection.
|
||||
*
|
||||
* @param array<string, mixed> $input
|
||||
*/
|
||||
public function sourceHash(array $input): string
|
||||
{
|
||||
// Exclude fields that should not affect staleness:
|
||||
// – user_id / username: identity, not profile signal
|
||||
// – downloads_count: noisy micro-increments that change frequently without
|
||||
// meaningfully altering what the biography should say
|
||||
$excluded = ['user_id', 'username', 'downloads_count'];
|
||||
$significant = array_diff_key($input, array_flip($excluded));
|
||||
|
||||
return hash('sha256', json_encode($significant, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify the creator's data richness for prompt and threshold decisions.
|
||||
*
|
||||
* rich – long history, featured work, milestones/eras/evolution
|
||||
* medium – some uploads, limited signal depth
|
||||
* sparse – very little data; may not warrant generation at all
|
||||
*
|
||||
* @param array<string, mixed> $input from build()
|
||||
*/
|
||||
public function qualityTier(array $input): string
|
||||
{
|
||||
$uploads = (int) ($input['uploads_count'] ?? 0);
|
||||
$featured = (int) ($input['featured_count'] ?? 0);
|
||||
$years = (int) ($input['years_on_skinbase'] ?? 0);
|
||||
$milestones = (array) ($input['milestones'] ?? []);
|
||||
$eras = (array) ($input['eras'] ?? []);
|
||||
$evolution = (int) ($input['evolution_count'] ?? 0);
|
||||
$hasComeBack = ! empty($milestones['has_comeback']);
|
||||
$hasStreak = (int) ($milestones['best_upload_streak_months'] ?? 0) >= 3;
|
||||
|
||||
$richSignals = ($featured >= 1 ? 1 : 0)
|
||||
+ ($uploads >= 30 ? 1 : 0)
|
||||
+ ($hasComeBack || $hasStreak ? 1 : 0)
|
||||
+ (count($eras) >= 2 ? 1 : 0)
|
||||
+ ($evolution >= 2 ? 1 : 0);
|
||||
|
||||
if ($uploads >= 20 && $years >= 2 && $richSignals >= 2) {
|
||||
return 'rich';
|
||||
}
|
||||
|
||||
if ($uploads >= 5 || $featured >= 1 || ($years >= 1 && $richSignals >= 1)) {
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
return 'sparse';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the creator has enough public data to warrant biography generation.
|
||||
*
|
||||
* Returns false for brand-new or essentially empty profiles where any
|
||||
* generated output would be generic or misleading.
|
||||
*
|
||||
* @param array<string, mixed> $input from build()
|
||||
*/
|
||||
public function meetsMinimumThreshold(array $input): bool
|
||||
{
|
||||
$uploads = (int) ($input['uploads_count'] ?? 0);
|
||||
$featured = (int) ($input['featured_count'] ?? 0);
|
||||
$categories = (array) ($input['top_categories'] ?? []);
|
||||
$milestones = (array) ($input['milestones'] ?? []);
|
||||
$years = (int) ($input['years_on_skinbase'] ?? 0);
|
||||
|
||||
return $uploads >= 3
|
||||
|| $featured >= 1
|
||||
|| ! empty($milestones['has_comeback'])
|
||||
|| (int) ($milestones['best_upload_streak_months'] ?? 0) >= 3
|
||||
|| (count($categories) >= 1 && $uploads >= 1 && $years >= 1);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private helpers – public data only
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function publicUploadsCount(int $userId): int
|
||||
{
|
||||
return (int) DB::table('artworks')
|
||||
->where('user_id', $userId)
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNotNull('published_at')
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
}
|
||||
|
||||
private function featuredCount(int $userId): int
|
||||
{
|
||||
if (! Schema::hasTable('artwork_features')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) DB::table('artwork_features')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_features.artwork_id')
|
||||
->where('artworks.user_id', $userId)
|
||||
->whereNull('artworks.deleted_at')
|
||||
->count();
|
||||
}
|
||||
|
||||
private function totalDownloads(int $userId): int
|
||||
{
|
||||
if (! Schema::hasTable('artwork_stats')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) DB::table('artworks')
|
||||
->join('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->where('artworks.user_id', $userId)
|
||||
->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
->whereNotNull('artworks.published_at')
|
||||
->whereNull('artworks.deleted_at')
|
||||
->sum('artwork_stats.downloads');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function topCategories(int $userId): array
|
||||
{
|
||||
if (! Schema::hasTable('artwork_category') || ! Schema::hasTable('categories')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return DB::table('artwork_category')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_category.artwork_id')
|
||||
->join('categories', 'categories.id', '=', 'artwork_category.category_id')
|
||||
->where('artworks.user_id', $userId)
|
||||
->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
->whereNotNull('artworks.published_at')
|
||||
->whereNull('artworks.deleted_at')
|
||||
->groupBy('categories.id', 'categories.name')
|
||||
->orderByRaw('COUNT(*) DESC')
|
||||
->orderBy('categories.name')
|
||||
->limit(3)
|
||||
->pluck('categories.name')
|
||||
->map(fn ($n) => (string) $n)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function topTags(int $userId): array
|
||||
{
|
||||
if (! Schema::hasTable('artwork_tag') || ! Schema::hasTable('tags')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return DB::table('artwork_tag')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_tag.artwork_id')
|
||||
->join('tags', 'tags.id', '=', 'artwork_tag.tag_id')
|
||||
->where('artworks.user_id', $userId)
|
||||
->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
->whereNotNull('artworks.published_at')
|
||||
->whereNull('artworks.deleted_at')
|
||||
->groupBy('tags.id', 'tags.name')
|
||||
->orderByRaw('COUNT(*) DESC')
|
||||
->orderBy('tags.name')
|
||||
->limit(5)
|
||||
->pluck('tags.name')
|
||||
->map(fn ($n) => (string) $n)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{title: string, year: int}|null
|
||||
*/
|
||||
private function bestPerformingWork(int $userId): ?array
|
||||
{
|
||||
$query = DB::table('artworks')
|
||||
->where('artworks.user_id', $userId)
|
||||
->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
->whereNotNull('artworks.published_at')
|
||||
->whereNull('artworks.deleted_at')
|
||||
->limit(1)
|
||||
->select('artworks.title', 'artworks.published_at');
|
||||
|
||||
if (Schema::hasTable('artwork_stats')) {
|
||||
$query
|
||||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->orderByRaw('(COALESCE(artwork_stats.downloads, 0) + COALESCE(artwork_stats.views, 0) + COALESCE(artwork_stats.favorites, 0)) DESC');
|
||||
} else {
|
||||
$query->orderByDesc('artworks.published_at');
|
||||
}
|
||||
|
||||
$row = $query->first();
|
||||
|
||||
if ($row === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => (string) $row->title,
|
||||
'year' => (int) date('Y', strtotime((string) $row->published_at)),
|
||||
];
|
||||
}
|
||||
|
||||
private function mostProductiveYear(int $userId): ?int
|
||||
{
|
||||
// Use strftime for SQLite compatibility; MySQL also supports strftime via
|
||||
// a compatibility shim, but we use a driver-agnostic expression here.
|
||||
$driver = DB::getDriverName();
|
||||
$yearExpr = $driver === 'sqlite'
|
||||
? "strftime('%Y', published_at)"
|
||||
: 'YEAR(published_at)';
|
||||
|
||||
$row = DB::table('artworks')
|
||||
->where('user_id', $userId)
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNotNull('published_at')
|
||||
->whereNull('deleted_at')
|
||||
->selectRaw("{$yearExpr} as yr, COUNT(*) as cnt")
|
||||
->groupByRaw($yearExpr)
|
||||
->orderByRaw('COUNT(*) DESC')
|
||||
->limit(1)
|
||||
->first();
|
||||
|
||||
return $row !== null ? (int) $row->yr : null;
|
||||
}
|
||||
|
||||
private function evolutionCount(int $userId): int
|
||||
{
|
||||
if (! Schema::hasTable('artwork_relations')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$evolutionTypes = [
|
||||
ArtworkRelation::TYPE_REMASTER_OF,
|
||||
ArtworkRelation::TYPE_REMAKE_OF,
|
||||
ArtworkRelation::TYPE_REVISION_OF,
|
||||
];
|
||||
|
||||
return (int) DB::table('artwork_relations')
|
||||
->join('artworks as src', 'src.id', '=', 'artwork_relations.source_artwork_id')
|
||||
->where('src.user_id', $userId)
|
||||
->whereIn('artwork_relations.relation_type', $evolutionTypes)
|
||||
->whereNull('src.deleted_at')
|
||||
->count();
|
||||
}
|
||||
|
||||
private function activityStatus(int $userId): string
|
||||
{
|
||||
$latestPublished = DB::table('artworks')
|
||||
->where('user_id', $userId)
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNotNull('published_at')
|
||||
->whereNull('deleted_at')
|
||||
->max('published_at');
|
||||
|
||||
if ($latestPublished === null) {
|
||||
return 'inactive';
|
||||
}
|
||||
|
||||
$daysSinceLast = now()->diffInDays(date('Y-m-d', strtotime((string) $latestPublished)));
|
||||
|
||||
if ($daysSinceLast <= 60) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
if ($daysSinceLast <= 365) {
|
||||
return 'recently_active';
|
||||
}
|
||||
|
||||
// Check for comeback: a gap > 180 days before the latest upload.
|
||||
$previousPublished = DB::table('artworks')
|
||||
->where('user_id', $userId)
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNotNull('published_at')
|
||||
->whereNull('deleted_at')
|
||||
->where('published_at', '<', $latestPublished)
|
||||
->max('published_at');
|
||||
|
||||
if ($previousPublished !== null) {
|
||||
$gapDays = (int) (strtotime((string) $latestPublished) - strtotime((string) $previousPublished)) / 86400;
|
||||
if ($gapDays >= 180) {
|
||||
return 'returning';
|
||||
}
|
||||
}
|
||||
|
||||
return 'legacy';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{has_comeback: bool, best_upload_streak_months: int}
|
||||
*/
|
||||
private function publicMilestoneSignals(int $userId): array
|
||||
{
|
||||
if (! Schema::hasTable('creator_milestones')) {
|
||||
return ['has_comeback' => false, 'best_upload_streak_months' => 0];
|
||||
}
|
||||
|
||||
$types = DB::table('creator_milestones')
|
||||
->where('user_id', $userId)
|
||||
->where('is_public', true)
|
||||
->pluck('type')
|
||||
->all();
|
||||
|
||||
$hasComeback = in_array('comeback_detected', $types, true);
|
||||
|
||||
$streakRow = DB::table('creator_milestones')
|
||||
->where('user_id', $userId)
|
||||
->where('is_public', true)
|
||||
->whereIn('type', ['upload_streak_3', 'upload_streak_6', 'upload_streak_9', 'upload_streak_12'])
|
||||
->orderByRaw('priority DESC')
|
||||
->limit(1)
|
||||
->first();
|
||||
|
||||
$bestStreakMonths = 0;
|
||||
if ($streakRow !== null) {
|
||||
$streakMap = [
|
||||
'upload_streak_3' => 3,
|
||||
'upload_streak_6' => 6,
|
||||
'upload_streak_9' => 9,
|
||||
'upload_streak_12' => 12,
|
||||
];
|
||||
$bestStreakMonths = $streakMap[$streakRow->type] ?? 0;
|
||||
}
|
||||
|
||||
return [
|
||||
'has_comeback' => $hasComeback,
|
||||
'best_upload_streak_months' => $bestStreakMonths,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{title: string, starts_at: string, ends_at: string|null}>
|
||||
*/
|
||||
private function publicEras(int $userId): array
|
||||
{
|
||||
if (! Schema::hasTable('creator_eras')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return DB::table('creator_eras')
|
||||
->where('user_id', $userId)
|
||||
->orderBy('starts_at')
|
||||
->get(['title', 'starts_at', 'ends_at'])
|
||||
->map(fn ($row): array => [
|
||||
'title' => (string) $row->title,
|
||||
'starts_at' => (string) $row->starts_at,
|
||||
'ends_at' => $row->ends_at !== null ? (string) $row->ends_at : null,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
276
app/Services/AiBiography/AiBiographyPromptBuilder.php
Normal file
276
app/Services/AiBiography/AiBiographyPromptBuilder.php
Normal file
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\AiBiography;
|
||||
|
||||
/**
|
||||
* Builds the LLM prompt payload from a normalized creator input.
|
||||
*
|
||||
* v1.1 changes:
|
||||
* – PROMPT_VERSION constant tracks the active template family.
|
||||
* – Improved system prompt discourages formulaic openings and stat-dumps.
|
||||
* – Sparse profile branch uses a lighter, safer template.
|
||||
* – Strict mode is used on retry; produces a more conservative output.
|
||||
*
|
||||
* Prompt rules:
|
||||
* – Only include facts that are actually present in the input.
|
||||
* – Never instruct the model to invent details or speculate.
|
||||
* – Always require a single paragraph output with no markdown.
|
||||
* – Keep max_tokens tight to enforce the word cap.
|
||||
*/
|
||||
final class AiBiographyPromptBuilder
|
||||
{
|
||||
public const PROMPT_VERSION = 'v1.1';
|
||||
private const MIN_WORDS = 30;
|
||||
|
||||
private const SYSTEM_PROMPT = <<<'PROMPT'
|
||||
You are a concise writing assistant for Skinbase Nova, a digital art platform.
|
||||
|
||||
Write short creator biographies using only the facts provided. Use a polished, factual, and slightly editorial tone.
|
||||
|
||||
Rules:
|
||||
- Use only the provided data. Do not invent achievements, personal details, visual style claims, or platform fame.
|
||||
- Do not write bullet points, headings, or markdown.
|
||||
- Output exactly one paragraph.
|
||||
- Do not exceed 140 words.
|
||||
- Avoid hype language: do not use "world-class", "iconic", "legendary", "renowned", "celebrated", "masterpiece", or "beloved".
|
||||
- Do not speculate about personality, age, gender, politics, religion, or private life.
|
||||
- Do not mention data points that are not provided or are zero/empty.
|
||||
- Do not open with "has been part of Skinbase since" or similar formulaic phrases. Vary the opening.
|
||||
- Mention only the 2 to 3 most meaningful signals. Do not list every available stat.
|
||||
- Do not write "creator journey shows..." — describe what the data reflects directly.
|
||||
- Prefer natural narrative flow over data listing.
|
||||
PROMPT;
|
||||
|
||||
private const SYSTEM_PROMPT_STRICT = <<<'PROMPT'
|
||||
You are a cautious writing assistant for Skinbase Nova, a digital art platform.
|
||||
|
||||
Write a short, safe creator biography using only the facts provided. Be conservative.
|
||||
|
||||
Rules:
|
||||
- Use only the provided facts. Do not invent or speculate.
|
||||
- Output exactly one paragraph, no markdown, no headings, no bullets.
|
||||
- Maximum 100 words.
|
||||
- Mention only 1 or 2 standout facts. Do not list all available data.
|
||||
- Avoid any superlatives, praise, or style claims.
|
||||
- Do not mention missing or zero-value fields.
|
||||
- Keep the tone neutral, simple, and factual.
|
||||
PROMPT;
|
||||
|
||||
private const SYSTEM_PROMPT_SPARSE = <<<'PROMPT'
|
||||
You are a cautious writing assistant for Skinbase Nova, a digital art platform.
|
||||
|
||||
Write a short, modest creator introduction using only the facts provided.
|
||||
|
||||
Rules:
|
||||
- Use only the facts provided.
|
||||
- Output exactly one paragraph, no markdown, no bullets.
|
||||
- Write between 35 and 60 words.
|
||||
- Minimum 30 words.
|
||||
- Keep it simple. Mention member-since year and upload count if available.
|
||||
- Add one category or another factual signal when available so the paragraph has enough substance.
|
||||
- Do not invent anything. Do not praise. Do not speculate.
|
||||
- If data is very limited, use two short factual sentences rather than a fragment.
|
||||
PROMPT;
|
||||
|
||||
private const SYSTEM_PROMPT_SPARSE_STRICT = <<<'PROMPT'
|
||||
You are a cautious writing assistant for Skinbase Nova, a digital art platform.
|
||||
|
||||
Write a short, modest creator introduction using only the facts provided. Be conservative and precise.
|
||||
|
||||
Rules:
|
||||
- Use only the facts provided.
|
||||
- Output exactly one paragraph, no markdown, no bullets.
|
||||
- Write between 35 and 50 words.
|
||||
- Minimum 30 words.
|
||||
- Prefer two short factual sentences.
|
||||
- Mention member-since year, upload count, and one category when available.
|
||||
- Do not invent anything. Do not praise. Do not speculate.
|
||||
PROMPT;
|
||||
|
||||
/**
|
||||
* Build the full messages payload for the LLM.
|
||||
*
|
||||
* @param array<string, mixed> $input normalized creator input from AiBiographyInputBuilder
|
||||
* @param bool $strict true on retry — forces more conservative output
|
||||
* @param bool $sparse true for sparse-profile creators
|
||||
* @return array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool, prompt_version: string}
|
||||
*/
|
||||
public function build(array $input, bool $strict = false, bool $sparse = false): array
|
||||
{
|
||||
if ($sparse && $strict) {
|
||||
$systemPrompt = self::SYSTEM_PROMPT_SPARSE_STRICT;
|
||||
$userPrompt = $this->buildSparseUserPrompt($input, strict: true);
|
||||
$maxTokens = 240;
|
||||
$temperature = 0.2;
|
||||
} elseif ($sparse) {
|
||||
$systemPrompt = self::SYSTEM_PROMPT_SPARSE;
|
||||
$userPrompt = $this->buildSparseUserPrompt($input, strict: false);
|
||||
$maxTokens = 320;
|
||||
$temperature = 0.3;
|
||||
} elseif ($strict) {
|
||||
$systemPrompt = self::SYSTEM_PROMPT_STRICT;
|
||||
$userPrompt = $this->buildUserPrompt($input, strict: true);
|
||||
$maxTokens = 450;
|
||||
$temperature = 0.25;
|
||||
} else {
|
||||
$systemPrompt = self::SYSTEM_PROMPT;
|
||||
$userPrompt = $this->buildUserPrompt($input, strict: false);
|
||||
$maxTokens = 600;
|
||||
$temperature = 0.45;
|
||||
}
|
||||
|
||||
return [
|
||||
'messages' => [
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $userPrompt],
|
||||
],
|
||||
'max_tokens' => $maxTokens,
|
||||
'temperature' => $temperature,
|
||||
'stream' => false,
|
||||
'prompt_version' => self::PROMPT_VERSION,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildUserPrompt(array $input, bool $strict): string
|
||||
{
|
||||
$wordTarget = $strict ? '60 to 100' : '70 to 130';
|
||||
|
||||
$lines = [
|
||||
"Write a creator biography in {$wordTarget} words using only the facts below. Output one paragraph only.",
|
||||
'',
|
||||
];
|
||||
|
||||
$username = (string) ($input['username'] ?? '');
|
||||
if ($username !== '') {
|
||||
$lines[] = "- Creator: {$username}";
|
||||
}
|
||||
|
||||
$memberYear = $input['member_since_year'] ?? null;
|
||||
$years = $input['years_on_skinbase'] ?? null;
|
||||
if ($memberYear !== null && (int) $memberYear > 0) {
|
||||
$label = ((int) ($years ?? 0) > 1) ? ", {$years} years on the platform" : '';
|
||||
$lines[] = "- Member since: {$memberYear}{$label}";
|
||||
}
|
||||
|
||||
$uploads = $input['uploads_count'] ?? 0;
|
||||
if ((int) $uploads > 0) {
|
||||
$lines[] = "- Total public uploads: {$uploads}";
|
||||
}
|
||||
|
||||
$featured = $input['featured_count'] ?? 0;
|
||||
if ((int) $featured > 0) {
|
||||
$lines[] = "- Featured artworks: {$featured}";
|
||||
}
|
||||
|
||||
$downloads = $input['downloads_count'] ?? 0;
|
||||
if ((int) $downloads > 5000) {
|
||||
$lines[] = sprintf('- Total downloads: %s', number_format((int) $downloads));
|
||||
}
|
||||
|
||||
$categories = $input['top_categories'] ?? [];
|
||||
if ($categories !== []) {
|
||||
$lines[] = '- Top categories: ' . implode(', ', array_slice((array) $categories, 0, 2));
|
||||
}
|
||||
|
||||
// On strict retry, trim tags to keep prompt tight and reduce hallucination surface.
|
||||
if (! $strict) {
|
||||
$tags = $input['top_tags'] ?? [];
|
||||
if ($tags !== []) {
|
||||
$lines[] = '- Common themes: ' . implode(', ', array_slice((array) $tags, 0, 3));
|
||||
}
|
||||
}
|
||||
|
||||
$bestWork = $input['best_performing_work'] ?? null;
|
||||
if (is_array($bestWork) && isset($bestWork['title'], $bestWork['year'])) {
|
||||
$lines[] = "- Best-performing work: {$bestWork['title']} ({$bestWork['year']})";
|
||||
}
|
||||
|
||||
$productiveYear = $input['most_productive_year'] ?? null;
|
||||
if ($productiveYear !== null && (int) $productiveYear > 0) {
|
||||
$lines[] = "- Most productive year: {$productiveYear}";
|
||||
}
|
||||
|
||||
$status = $input['current_activity_status'] ?? null;
|
||||
if ($status !== null && $status !== '') {
|
||||
$statusLabels = [
|
||||
'active' => 'currently active',
|
||||
'recently_active' => 'recently active',
|
||||
'returning' => 'returning creator',
|
||||
'legacy' => 'long-standing creator',
|
||||
'inactive' => null,
|
||||
];
|
||||
$statusLabel = $statusLabels[$status] ?? null;
|
||||
if ($statusLabel !== null) {
|
||||
$lines[] = "- Activity: {$statusLabel}";
|
||||
}
|
||||
}
|
||||
|
||||
$milestones = $input['milestones'] ?? [];
|
||||
if (is_array($milestones)) {
|
||||
if (! empty($milestones['has_comeback'])) {
|
||||
$lines[] = '- Notable milestone: returned after a significant break';
|
||||
}
|
||||
$streak = (int) ($milestones['best_upload_streak_months'] ?? 0);
|
||||
if ($streak >= 3 && ! $strict) {
|
||||
$lines[] = "- Upload streak: {$streak} consecutive months";
|
||||
}
|
||||
}
|
||||
|
||||
// Include eras and evolution only when not on strict retry.
|
||||
if (! $strict) {
|
||||
$eras = $input['eras'] ?? [];
|
||||
if (is_array($eras) && count($eras) >= 2) {
|
||||
$eraTitles = array_column($eras, 'title');
|
||||
$lines[] = '- Creator eras: ' . implode(' → ', $eraTitles);
|
||||
}
|
||||
|
||||
$evolutionCount = $input['evolution_count'] ?? 0;
|
||||
if ((int) $evolutionCount > 0) {
|
||||
$lines[] = "- Remastered/evolved works: {$evolutionCount}";
|
||||
}
|
||||
}
|
||||
|
||||
$lines[] = '';
|
||||
$lines[] = 'Avoid hype. Do not open with a formulaic phrase. Do not list every stat. Output one paragraph only. No markdown.';
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
private function buildSparseUserPrompt(array $input, bool $strict = false): string
|
||||
{
|
||||
$wordTarget = $strict ? '35 to 50' : '35 to 60';
|
||||
$lines = [
|
||||
"Write a brief, modest creator introduction in {$wordTarget} words using only these facts. Output one paragraph only.",
|
||||
'',
|
||||
];
|
||||
|
||||
$username = (string) ($input['username'] ?? '');
|
||||
if ($username !== '') {
|
||||
$lines[] = "- Creator: {$username}";
|
||||
}
|
||||
|
||||
$memberYear = $input['member_since_year'] ?? null;
|
||||
$years = $input['years_on_skinbase'] ?? null;
|
||||
if ($memberYear !== null && (int) $memberYear > 0) {
|
||||
$yearsLabel = ((int) ($years ?? 0) > 1) ? ", {$years} years on the platform" : '';
|
||||
$lines[] = "- Member since: {$memberYear}{$yearsLabel}";
|
||||
}
|
||||
|
||||
$uploads = $input['uploads_count'] ?? 0;
|
||||
if ((int) $uploads > 0) {
|
||||
$lines[] = "- Public uploads: {$uploads}";
|
||||
}
|
||||
|
||||
$categories = $input['top_categories'] ?? [];
|
||||
if ($categories !== []) {
|
||||
$lines[] = '- Categories: ' . implode(', ', array_slice((array) $categories, 0, $strict ? 1 : 2));
|
||||
}
|
||||
|
||||
$lines[] = '';
|
||||
$lines[] = 'Keep it simple and factual. Write at least ' . self::MIN_WORDS . ' words. Prefer two short sentences if needed. No praise. No markdown.';
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
}
|
||||
490
app/Services/AiBiography/AiBiographyService.php
Normal file
490
app/Services/AiBiography/AiBiographyService.php
Normal file
@@ -0,0 +1,490 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\AiBiography;
|
||||
|
||||
use App\Models\CreatorAiBiography;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Orchestrates AI biography generation, storage, retrieval, and creator controls.
|
||||
*
|
||||
* v1.1 additions:
|
||||
* – Quality tier classification and minimum-threshold gating before generation.
|
||||
* – Sparse profiles below threshold are suppressed (or produce a safe fallback).
|
||||
* – All new metadata columns (prompt_version, input_quality_tier, generation_reason,
|
||||
* needs_review, last_attempted_at, last_error_code, last_error_reason) are written.
|
||||
* – Stale detection for user-edited biographies: sets needs_review=true instead of
|
||||
* silently overwriting, and stores a draft.
|
||||
* – Hidden biographies remain hidden unless explicitly shown again.
|
||||
* – adminInspect() returns full metadata for artisan/admin tooling.
|
||||
*
|
||||
* Public API:
|
||||
* generate(User, reason): array – generate and store a new biography
|
||||
* regenerate(User, force, reason): array – force-regenerate, respects user-edit lock
|
||||
* updateText(User, string): void – creator edits their biography
|
||||
* hide(User): void – creator hides their AI bio
|
||||
* show(User): void – creator re-enables their AI bio
|
||||
* publicPayload(User): array|null – public profile rendering payload
|
||||
* creatorStatusPayload(User): array – authenticated creator status (more fields)
|
||||
* adminInspect(User): array – full metadata for admin/artisan tooling
|
||||
* isStale(User): bool – source-hash staleness check
|
||||
*/
|
||||
final class AiBiographyService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AiBiographyInputBuilder $inputBuilder,
|
||||
private readonly AiBiographyGenerator $generator,
|
||||
) {
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Generation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate a biography for the user.
|
||||
*
|
||||
* 1. Classify quality tier.
|
||||
* 2. Check minimum-data threshold; suppress if below.
|
||||
* 3. If existing active bio is user-edited, store draft + flag needs_review.
|
||||
* 4. Otherwise generate and activate.
|
||||
*
|
||||
* @param string $reason why generation was triggered (CreatorAiBiography::REASON_*)
|
||||
* @return array{success: bool, action: string, errors: list<string>}
|
||||
*/
|
||||
public function generate(User $user, string $reason = CreatorAiBiography::REASON_INITIAL_GENERATE): array
|
||||
{
|
||||
$input = $this->inputBuilder->build($user);
|
||||
$sourceHash = $this->inputBuilder->sourceHash($input);
|
||||
$qualityTier = $this->inputBuilder->qualityTier($input);
|
||||
$existing = $this->activeRecord($user);
|
||||
|
||||
Log::info('AiBiographyService: generate requested', [
|
||||
'user_id' => (int) $user->id,
|
||||
'quality_tier' => $qualityTier,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
|
||||
// ── Minimum threshold check ──────────────────────────────────────────
|
||||
if (! $this->inputBuilder->meetsMinimumThreshold($input)) {
|
||||
Log::info('AiBiographyService: suppressed — below minimum data threshold', [
|
||||
'user_id' => (int) $user->id,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'action' => 'suppressed_low_signal',
|
||||
'errors' => ['Creator profile does not have enough public data for biography generation.'],
|
||||
];
|
||||
}
|
||||
|
||||
// ── User-edited protection ────────────────────────────────────────────
|
||||
if ($existing !== null && $existing->is_user_edited) {
|
||||
return $this->storeDraftForUserEdited($user, $input, $sourceHash, $qualityTier, $reason, $existing);
|
||||
}
|
||||
|
||||
return $this->generateAndActivate($user, $input, $sourceHash, $qualityTier, $reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-regenerate, respecting user-edit lock unless $force=true.
|
||||
*
|
||||
* @param string $reason
|
||||
* @return array{success: bool, action: string, errors: list<string>}
|
||||
*/
|
||||
public function regenerate(
|
||||
User $user,
|
||||
bool $force = false,
|
||||
string $reason = CreatorAiBiography::REASON_MANUAL_REGENERATE,
|
||||
): array {
|
||||
$input = $this->inputBuilder->build($user);
|
||||
$sourceHash = $this->inputBuilder->sourceHash($input);
|
||||
$qualityTier = $this->inputBuilder->qualityTier($input);
|
||||
$existing = $this->activeRecord($user);
|
||||
|
||||
// ── Minimum threshold check ──────────────────────────────────────────
|
||||
if (! $this->inputBuilder->meetsMinimumThreshold($input)) {
|
||||
Log::info('AiBiographyService: regenerate suppressed — below minimum data threshold', [
|
||||
'user_id' => (int) $user->id,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'action' => 'suppressed_low_signal',
|
||||
'errors' => ['Creator profile does not have enough public data for biography generation.'],
|
||||
];
|
||||
}
|
||||
|
||||
if ($existing !== null && $existing->is_user_edited && ! $force) {
|
||||
return [
|
||||
'success' => false,
|
||||
'action' => 'user_edited_locked',
|
||||
'errors' => ['Existing biography is user-edited. Pass force=true to overwrite.'],
|
||||
];
|
||||
}
|
||||
|
||||
return $this->generateAndActivate($user, $input, $sourceHash, $qualityTier, $reason);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Creator controls
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function updateText(User $user, string $text): void
|
||||
{
|
||||
$existing = $this->activeRecord($user);
|
||||
|
||||
if ($existing !== null) {
|
||||
$existing->update([
|
||||
'text' => $text,
|
||||
'is_user_edited' => true,
|
||||
'needs_review' => false,
|
||||
'status' => CreatorAiBiography::STATUS_EDITED,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
CreatorAiBiography::create([
|
||||
'user_id' => (int) $user->id,
|
||||
'text' => $text,
|
||||
'source_hash' => null,
|
||||
'model' => null,
|
||||
'prompt_version' => null,
|
||||
'input_quality_tier' => null,
|
||||
'generation_reason' => null,
|
||||
'status' => CreatorAiBiography::STATUS_EDITED,
|
||||
'is_active' => true,
|
||||
'is_hidden' => false,
|
||||
'is_user_edited' => true,
|
||||
'needs_review' => false,
|
||||
'generated_at' => now(),
|
||||
'approved_at' => now(),
|
||||
'last_attempted_at' => null,
|
||||
'last_error_code' => null,
|
||||
'last_error_reason' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the biography. Hidden state persists until explicitly shown.
|
||||
*/
|
||||
public function hide(User $user): void
|
||||
{
|
||||
$this->activeRecord($user)?->update(['is_hidden' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show (un-hide) the biography. Requires explicit creator action.
|
||||
*/
|
||||
public function show(User $user): void
|
||||
{
|
||||
$this->activeRecord($user)?->update(['is_hidden' => false]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public rendering
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return the public-facing payload for the profile API.
|
||||
* Returns null if no visible biography exists.
|
||||
*
|
||||
* @return array{text: string, is_visible: bool, is_user_edited: bool, generated_at: string|null, status: string}|null
|
||||
*/
|
||||
public function publicPayload(User $user): ?array
|
||||
{
|
||||
$record = $this->activeRecord($user);
|
||||
|
||||
if ($record === null || ! $record->isVisible()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'text' => (string) $record->text,
|
||||
'is_visible' => true,
|
||||
'is_user_edited' => (bool) $record->is_user_edited,
|
||||
'generated_at' => $record->generated_at?->toIso8601String(),
|
||||
'status' => (string) $record->status,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the authenticated creator's full status payload.
|
||||
* Includes generation metadata not shown publicly.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function creatorStatusPayload(User $user): array
|
||||
{
|
||||
$record = $this->activeRecord($user);
|
||||
|
||||
if ($record === null) {
|
||||
return [
|
||||
'has_biography' => false,
|
||||
'is_hidden' => false,
|
||||
'is_user_edited' => false,
|
||||
'needs_review' => false,
|
||||
'status' => null,
|
||||
'prompt_version' => null,
|
||||
'input_quality_tier' => null,
|
||||
'generation_reason' => null,
|
||||
'generated_at' => null,
|
||||
'last_attempted_at' => null,
|
||||
'last_error_code' => null,
|
||||
'last_error_reason' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'has_biography' => true,
|
||||
'is_visible' => $record->isVisible(),
|
||||
'is_hidden' => (bool) $record->is_hidden,
|
||||
'is_user_edited' => (bool) $record->is_user_edited,
|
||||
'needs_review' => (bool) $record->needs_review,
|
||||
'status' => (string) $record->status,
|
||||
'prompt_version' => $record->prompt_version,
|
||||
'input_quality_tier' => $record->input_quality_tier,
|
||||
'generation_reason' => $record->generation_reason,
|
||||
'generated_at' => $record->generated_at?->toIso8601String(),
|
||||
'last_attempted_at' => $record->last_attempted_at?->toIso8601String(),
|
||||
'last_error_code' => $record->last_error_code,
|
||||
'last_error_reason' => $record->last_error_reason,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Full metadata record for admin/artisan inspection.
|
||||
* Includes normalized input payload.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function adminInspect(User $user): array
|
||||
{
|
||||
$record = $this->activeRecord($user);
|
||||
$input = $this->inputBuilder->build($user);
|
||||
|
||||
return [
|
||||
'record' => $record?->toArray(),
|
||||
'input_payload' => $input,
|
||||
'quality_tier' => $this->inputBuilder->qualityTier($input),
|
||||
'meets_threshold' => $this->inputBuilder->meetsMinimumThreshold($input),
|
||||
'source_hash_live' => $this->inputBuilder->sourceHash($input),
|
||||
'is_stale' => $this->isStale($user),
|
||||
];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Stale check
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function isStale(User $user): bool
|
||||
{
|
||||
$record = $this->activeRecord($user);
|
||||
|
||||
if ($record === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$input = $this->inputBuilder->build($user);
|
||||
$sourceHash = $this->inputBuilder->sourceHash($input);
|
||||
|
||||
return $record->source_hash !== $sourceHash;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function activeRecord(User $user): ?CreatorAiBiography
|
||||
{
|
||||
return CreatorAiBiography::query()
|
||||
->where('user_id', (int) $user->id)
|
||||
->where('is_active', true)
|
||||
->latest()
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{success: bool, action: string, errors: list<string>}
|
||||
*/
|
||||
private function generateAndActivate(
|
||||
User $user,
|
||||
array $input,
|
||||
string $sourceHash,
|
||||
string $qualityTier,
|
||||
string $reason,
|
||||
): array {
|
||||
$now = now();
|
||||
$result = $this->generator->generate($input, $qualityTier);
|
||||
|
||||
// ── Record attempt regardless of outcome ─────────────────────────────
|
||||
if (! $result['success']) {
|
||||
// Update last-attempt metadata on the existing active record if present,
|
||||
// or create a failed record for observability.
|
||||
$existing = $this->activeRecord($user);
|
||||
|
||||
$failedAttrs = [
|
||||
'last_attempted_at' => $now,
|
||||
'last_error_code' => 'generation_failed',
|
||||
'last_error_reason' => implode('; ', $result['errors']),
|
||||
];
|
||||
|
||||
if ($existing !== null) {
|
||||
$existing->update($failedAttrs);
|
||||
} else {
|
||||
CreatorAiBiography::create(array_merge([
|
||||
'user_id' => (int) $user->id,
|
||||
'text' => null,
|
||||
'source_hash' => $sourceHash,
|
||||
'model' => null,
|
||||
'prompt_version' => $result['prompt_version'] ?? null,
|
||||
'input_quality_tier' => $qualityTier,
|
||||
'generation_reason' => $reason,
|
||||
'status' => CreatorAiBiography::STATUS_FAILED,
|
||||
'is_active' => false,
|
||||
'is_hidden' => false,
|
||||
'is_user_edited' => false,
|
||||
'needs_review' => false,
|
||||
'generated_at' => null,
|
||||
'approved_at' => null,
|
||||
], $failedAttrs));
|
||||
}
|
||||
|
||||
Log::warning('AiBiographyService: generation failed', [
|
||||
'user_id' => (int) $user->id,
|
||||
'errors' => $result['errors'],
|
||||
'retried' => $result['was_retried'] ?? false,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'action' => 'generation_failed',
|
||||
'errors' => $result['errors'],
|
||||
];
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($user, $result, $sourceHash, $qualityTier, $reason, $now): void {
|
||||
// Deactivate any previous active records.
|
||||
CreatorAiBiography::query()
|
||||
->where('user_id', (int) $user->id)
|
||||
->where('is_active', true)
|
||||
->update(['is_active' => false]);
|
||||
|
||||
CreatorAiBiography::create([
|
||||
'user_id' => (int) $user->id,
|
||||
'text' => $result['text'],
|
||||
'source_hash' => $sourceHash,
|
||||
'model' => $result['model'],
|
||||
'prompt_version' => $result['prompt_version'],
|
||||
'input_quality_tier' => $qualityTier,
|
||||
'generation_reason' => $reason,
|
||||
'status' => CreatorAiBiography::STATUS_GENERATED,
|
||||
'is_active' => true,
|
||||
'is_hidden' => false,
|
||||
'is_user_edited' => false,
|
||||
'needs_review' => false,
|
||||
'generated_at' => $now,
|
||||
'approved_at' => $now,
|
||||
'last_attempted_at' => $now,
|
||||
'last_error_code' => null,
|
||||
'last_error_reason' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
Log::info('AiBiographyService: biography generated and stored', [
|
||||
'user_id' => (int) $user->id,
|
||||
'model' => $result['model'],
|
||||
'prompt_version' => $result['prompt_version'],
|
||||
'quality_tier' => $qualityTier,
|
||||
'was_retried' => $result['was_retried'] ?? false,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'action' => 'generated',
|
||||
'errors' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a draft (non-active) without replacing the current user-edited biography.
|
||||
* Marks the existing user-edited record as needs_review so the creator is notified.
|
||||
*
|
||||
* @return array{success: bool, action: string, errors: list<string>}
|
||||
*/
|
||||
private function storeDraftForUserEdited(
|
||||
User $user,
|
||||
array $input,
|
||||
string $sourceHash,
|
||||
string $qualityTier,
|
||||
string $reason,
|
||||
CreatorAiBiography $existingEdited,
|
||||
): array {
|
||||
$now = now();
|
||||
$result = $this->generator->generate($input, $qualityTier);
|
||||
|
||||
if (! $result['success']) {
|
||||
$existingEdited->update([
|
||||
'last_attempted_at' => $now,
|
||||
'last_error_code' => 'generation_failed',
|
||||
'last_error_reason' => implode('; ', $result['errors']),
|
||||
]);
|
||||
|
||||
Log::warning('AiBiographyService: draft generation failed for user-edited bio', [
|
||||
'user_id' => (int) $user->id,
|
||||
'errors' => $result['errors'],
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'action' => 'generation_failed',
|
||||
'errors' => $result['errors'],
|
||||
];
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($user, $result, $sourceHash, $qualityTier, $reason, $now, $existingEdited): void {
|
||||
// Store the new generation as a non-active draft.
|
||||
CreatorAiBiography::create([
|
||||
'user_id' => (int) $user->id,
|
||||
'text' => $result['text'],
|
||||
'source_hash' => $sourceHash,
|
||||
'model' => $result['model'],
|
||||
'prompt_version' => $result['prompt_version'],
|
||||
'input_quality_tier' => $qualityTier,
|
||||
'generation_reason' => $reason,
|
||||
'status' => CreatorAiBiography::STATUS_GENERATED,
|
||||
'is_active' => false, // kept as draft; user-edited version remains active
|
||||
'is_hidden' => false,
|
||||
'is_user_edited' => false,
|
||||
'needs_review' => false,
|
||||
'generated_at' => $now,
|
||||
'approved_at' => null,
|
||||
'last_attempted_at' => $now,
|
||||
'last_error_code' => null,
|
||||
'last_error_reason' => null,
|
||||
]);
|
||||
|
||||
// Flag the active user-edited record: a newer AI draft is available.
|
||||
$existingEdited->update([
|
||||
'needs_review' => true,
|
||||
'last_attempted_at' => $now,
|
||||
]);
|
||||
});
|
||||
|
||||
Log::info('AiBiographyService: draft stored for user-edited biography', [
|
||||
'user_id' => (int) $user->id,
|
||||
'prompt_version' => $result['prompt_version'],
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'action' => 'draft_stored',
|
||||
'errors' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
241
app/Services/AiBiography/AiBiographyValidator.php
Normal file
241
app/Services/AiBiography/AiBiographyValidator.php
Normal file
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\AiBiography;
|
||||
|
||||
/**
|
||||
* Validates generated biography text before it is stored.
|
||||
*
|
||||
* v1.1 additions:
|
||||
* – Extended forbidden phrases (renowned, celebrated, iconic, etc.)
|
||||
* – Generic filler detection ("creator journey shows", "over the years" spam)
|
||||
* – Stat-dump detection (too many bare numbers in a short text)
|
||||
* – Repetitive phrase detection
|
||||
* – Sparse-profile mismatch check (rich-sounding bio for sparse creator)
|
||||
*
|
||||
* Rejects output that is:
|
||||
* – empty or too short to be useful
|
||||
* – too long (hard cap)
|
||||
* – not a single paragraph (multiple newlines separating blocks)
|
||||
* – contains markdown (headings, bullets, bold, italic, code)
|
||||
* – contains forbidden hype terms
|
||||
* – contains placeholder or apology patterns
|
||||
* – sounds too rich/boastful for a sparse creator profile
|
||||
*/
|
||||
final class AiBiographyValidator
|
||||
{
|
||||
private const MIN_WORDS = 20;
|
||||
private const MAX_WORDS = 180;
|
||||
|
||||
/**
|
||||
* Phrases that are always forbidden, regardless of tier.
|
||||
* These indicate hallucinated praise, AI-apology patterns, or unsupported claims.
|
||||
*/
|
||||
private const FORBIDDEN_PHRASES = [
|
||||
// Unsupported significance claims
|
||||
'world-class',
|
||||
'world class',
|
||||
'iconic visionary',
|
||||
'unmatched style',
|
||||
'legendary',
|
||||
'changed the platform',
|
||||
'beloved by everyone',
|
||||
'renowned for',
|
||||
'masterpiece creator',
|
||||
'masterclass',
|
||||
'celebrated artist',
|
||||
'celebrated creator',
|
||||
'celebrated by',
|
||||
'iconic creator',
|
||||
'iconic artist',
|
||||
'iconic work',
|
||||
'platform legend',
|
||||
'community favorite',
|
||||
'widely recognized',
|
||||
'highly regarded',
|
||||
'critically acclaimed',
|
||||
// AI apology / refusal patterns
|
||||
'i cannot',
|
||||
"i can't",
|
||||
'i apologize',
|
||||
'as an ai',
|
||||
'as a language model',
|
||||
'i do not have',
|
||||
"i don't have",
|
||||
'based on the information provided',
|
||||
'unfortunately',
|
||||
"i'm unable to",
|
||||
'i am unable to',
|
||||
// Vague over-praising filler
|
||||
'truly remarkable',
|
||||
'absolutely exceptional',
|
||||
'without a doubt',
|
||||
'undeniably talented',
|
||||
];
|
||||
|
||||
/**
|
||||
* Phrases that signal generic, formulaic filler when used more than once,
|
||||
* or which are always a warning sign of lazy output.
|
||||
* A single occurrence is allowed; repeated use is rejected.
|
||||
*/
|
||||
private const REPETITION_PHRASES = [
|
||||
'creator journey',
|
||||
'over the years',
|
||||
'has been part of skinbase',
|
||||
'has been a member',
|
||||
'throughout the years',
|
||||
'through the years',
|
||||
'journey on skinbase',
|
||||
];
|
||||
|
||||
/**
|
||||
* Validate the generated biography.
|
||||
*
|
||||
* @param string $text the generated biography text
|
||||
* @param string $qualityTier 'rich'|'medium'|'sparse' — used for sparse mismatch check
|
||||
* @return list<string> validation errors; empty list means valid
|
||||
*/
|
||||
public function validate(string $text, string $qualityTier = 'rich'): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
$trimmed = trim($text);
|
||||
|
||||
if ($trimmed === '') {
|
||||
$errors[] = 'Biography is empty.';
|
||||
return $errors;
|
||||
}
|
||||
|
||||
$wordCount = str_word_count($trimmed);
|
||||
|
||||
if ($wordCount < self::MIN_WORDS) {
|
||||
$errors[] = "Biography is too short ({$wordCount} words, minimum " . self::MIN_WORDS . ').';
|
||||
}
|
||||
|
||||
if ($wordCount > self::MAX_WORDS) {
|
||||
$errors[] = "Biography is too long ({$wordCount} words, maximum " . self::MAX_WORDS . ').';
|
||||
}
|
||||
|
||||
if ($this->containsMarkdown($trimmed)) {
|
||||
$errors[] = 'Biography contains markdown or structural formatting.';
|
||||
}
|
||||
|
||||
if ($this->hasMultipleParagraphs($trimmed)) {
|
||||
$errors[] = 'Biography contains multiple paragraphs; must be a single paragraph.';
|
||||
}
|
||||
|
||||
foreach (self::FORBIDDEN_PHRASES as $phrase) {
|
||||
if (str_contains(mb_strtolower($trimmed), $phrase)) {
|
||||
$errors[] = "Biography contains forbidden phrase: \"{$phrase}\".";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$repetitionError = $this->checkRepetition($trimmed);
|
||||
if ($repetitionError !== null) {
|
||||
$errors[] = $repetitionError;
|
||||
}
|
||||
|
||||
if ($qualityTier === 'sparse' && $this->soundsTooRichForSparseProfile($trimmed)) {
|
||||
$errors[] = 'Biography sounds too claim-heavy for a sparse creator profile.';
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
public function isValid(string $text, string $qualityTier = 'rich'): bool
|
||||
{
|
||||
return $this->validate($text, $qualityTier) === [];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function containsMarkdown(string $text): bool
|
||||
{
|
||||
// Headings: #, ##, ###
|
||||
if (preg_match('/^\s*#{1,6}\s/m', $text)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Bullets: lines starting with -, *, or numbered list
|
||||
if (preg_match('/^\s*[-*]\s/m', $text)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preg_match('/^\s*\d+\.\s/m', $text)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Bold / italic markers
|
||||
if (preg_match('/\*\*|__|\*[^*]|_[^_]/', $text)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Code blocks or inline code
|
||||
if (str_contains($text, '`') || str_contains($text, '```')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function hasMultipleParagraphs(string $text): bool
|
||||
{
|
||||
// Two or more consecutive newlines indicate paragraph break.
|
||||
return (bool) preg_match('/\n\s*\n/', $text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether any formulaic phrase appears more than once,
|
||||
* which usually indicates a recycled or low-quality output.
|
||||
*/
|
||||
private function checkRepetition(string $text): ?string
|
||||
{
|
||||
$lower = mb_strtolower($text);
|
||||
|
||||
foreach (self::REPETITION_PHRASES as $phrase) {
|
||||
// Count non-overlapping occurrences.
|
||||
$count = substr_count($lower, $phrase);
|
||||
if ($count >= 2) {
|
||||
return "Biography repeats the phrase \"{$phrase}\" too many times.";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* For sparse-profile biographies, reject text that sounds too achievement-heavy.
|
||||
* These signals typically appear only in rich profiles and would be hallucinated
|
||||
* or misleading when the creator has very little public history.
|
||||
*/
|
||||
private function soundsTooRichForSparseProfile(string $text): bool
|
||||
{
|
||||
$lower = mb_strtolower($text);
|
||||
|
||||
$richIndicators = [
|
||||
'featured',
|
||||
'best-performing',
|
||||
'standout',
|
||||
'milestone',
|
||||
'comeback',
|
||||
'evolution',
|
||||
'remaster',
|
||||
'era',
|
||||
'streak',
|
||||
'downloads',
|
||||
'most productive',
|
||||
];
|
||||
|
||||
$hitCount = 0;
|
||||
foreach ($richIndicators as $indicator) {
|
||||
if (str_contains($lower, $indicator)) {
|
||||
$hitCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// If a sparse profile biography references 3+ rich signals, it likely hallucinated them.
|
||||
return $hitCount >= 3;
|
||||
}
|
||||
}
|
||||
507
app/Services/AiBiography/VisionLlmClient.php
Normal file
507
app/Services/AiBiography/VisionLlmClient.php
Normal file
@@ -0,0 +1,507 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\AiBiography;
|
||||
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Thin client for the Skinbase Vision LLM gateway.
|
||||
*
|
||||
* Uses the existing Vision gateway infrastructure (VISION_GATEWAY_URL / VISION_GATEWAY_API_KEY).
|
||||
* Prefers /ai/chat; falls back to /v1/chat/completions if configured.
|
||||
*
|
||||
* Error codes handled:
|
||||
* 401 – invalid API key
|
||||
* 413 – oversized request
|
||||
* 422 – invalid payload
|
||||
* 503 – LLM unavailable
|
||||
* 504 – timeout / upstream
|
||||
*/
|
||||
final class VisionLlmClient
|
||||
{
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return match ($this->provider()) {
|
||||
'together' => $this->togetherApiKey() !== '' && $this->togetherModel() !== '',
|
||||
'gemini' => $this->geminiBaseUrl() !== '' && $this->geminiApiKey() !== '' && $this->geminiModel() !== '',
|
||||
'home' => $this->homeBaseUrl() !== '' && $this->homeModel() !== '',
|
||||
default => $this->baseUrl() !== '' && $this->apiKey() !== '',
|
||||
};
|
||||
}
|
||||
|
||||
public function configuredModel(): string
|
||||
{
|
||||
return match ($this->provider()) {
|
||||
'together' => $this->togetherModel(),
|
||||
'gemini' => $this->geminiModel(),
|
||||
'home' => $this->homeModel(),
|
||||
default => trim((string) config('ai_biography.llm_model', 'vision-gateway')),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a chat completion payload to the Vision gateway.
|
||||
*
|
||||
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
|
||||
* @return string The generated text content.
|
||||
*
|
||||
* @throws VisionLlmException On structured gateway failure.
|
||||
*/
|
||||
public function chat(array $payload): string
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
throw new VisionLlmException(
|
||||
match ($this->provider()) { 'together' => 'Together.ai is not configured. Set TOGETHER_API_KEY and optionally AI_BIOGRAPHY_TOGETHER_MODEL.', 'gemini' => 'Gemini API is not configured. Set GEMINI_API_KEY and optionally AI_BIOGRAPHY_GEMINI_MODEL.',
|
||||
'home' => 'Home LM Studio is not configured. Set AI_BIOGRAPHY_HOME_BASE_URL and AI_BIOGRAPHY_HOME_MODEL.',
|
||||
default => 'Vision LLM gateway is not configured. Set VISION_GATEWAY_URL and VISION_GATEWAY_API_KEY.',
|
||||
},
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
return match ($this->provider()) {
|
||||
'together' => $this->chatWithTogether($payload),
|
||||
'gemini' => $this->chatWithGemini($payload),
|
||||
'home' => $this->chatWithHome($payload),
|
||||
default => $this->chatWithVisionGateway($payload),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
|
||||
*/
|
||||
private function chatWithTogether(array $payload): string
|
||||
{
|
||||
$response = $this->sendTogetherRequest($this->togetherEndpoint(), $this->toTogetherPayload($payload));
|
||||
|
||||
$this->assertSuccessful($response, 'together');
|
||||
|
||||
return $this->extractContent($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
|
||||
*/
|
||||
private function chatWithVisionGateway(array $payload): string
|
||||
{
|
||||
$endpoint = $this->primaryEndpoint();
|
||||
$response = $this->sendRequest($endpoint, $payload);
|
||||
|
||||
// If primary endpoint returned a 404, fall back to the OpenAI-compatible path.
|
||||
if ($response->status() === 404) {
|
||||
$fallbackEndpoint = $this->fallbackEndpoint();
|
||||
if ($fallbackEndpoint !== $endpoint) {
|
||||
Log::debug('VisionLlmClient: primary endpoint returned 404, trying fallback', [
|
||||
'primary' => $endpoint,
|
||||
'fallback' => $fallbackEndpoint,
|
||||
]);
|
||||
$response = $this->sendRequest($fallbackEndpoint, $payload);
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSuccessful($response, 'vision_gateway');
|
||||
|
||||
return $this->extractContent($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
|
||||
*/
|
||||
private function chatWithGemini(array $payload): string
|
||||
{
|
||||
$response = $this->sendGeminiRequest($this->geminiEndpoint(), $this->toGeminiPayload($payload));
|
||||
|
||||
$this->assertSuccessful($response, 'gemini');
|
||||
|
||||
return $this->extractGeminiContent($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
|
||||
*/
|
||||
private function chatWithHome(array $payload): string
|
||||
{
|
||||
$response = $this->sendHomeRequest($this->homeEndpoint(), $this->toHomePayload($payload));
|
||||
|
||||
$this->assertSuccessful($response, 'home');
|
||||
|
||||
return $this->extractContent($response);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function sendRequest(string $url, array $payload): Response
|
||||
{
|
||||
try {
|
||||
return $this->buildRequest()->post($url, $payload);
|
||||
} catch (\Illuminate\Http\Client\ConnectionException $e) {
|
||||
throw new VisionLlmException(
|
||||
'Vision LLM gateway connection failed: ' . $e->getMessage(),
|
||||
504,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function sendTogetherRequest(string $url, array $payload): Response
|
||||
{
|
||||
try {
|
||||
return $this->buildTogetherRequest()->post($url, $payload);
|
||||
} catch (\Illuminate\Http\Client\ConnectionException $e) {
|
||||
throw new VisionLlmException(
|
||||
'Together.ai connection failed: ' . $e->getMessage(),
|
||||
504,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function sendGeminiRequest(string $url, array $payload): Response
|
||||
{
|
||||
try {
|
||||
return $this->buildGeminiRequest()->post($url, $payload);
|
||||
} catch (\Illuminate\Http\Client\ConnectionException $e) {
|
||||
throw new VisionLlmException(
|
||||
'Gemini API connection failed: ' . $e->getMessage(),
|
||||
504,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function sendHomeRequest(string $url, array $payload): Response
|
||||
{
|
||||
try {
|
||||
return $this->buildHomeRequest()->post($url, $payload);
|
||||
} catch (\Illuminate\Http\Client\ConnectionException $e) {
|
||||
throw new VisionLlmException(
|
||||
'Home LM Studio connection failed: ' . $e->getMessage(),
|
||||
504,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertSuccessful(Response $response, string $provider): void
|
||||
{
|
||||
if ($response->successful()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$status = $response->status();
|
||||
$body = mb_substr(trim($response->body()), 0, 300);
|
||||
|
||||
$label = match ($provider) {
|
||||
'together' => 'Together.ai',
|
||||
'gemini' => 'Gemini API',
|
||||
'home' => 'Home LM Studio',
|
||||
default => 'Vision LLM gateway',
|
||||
};
|
||||
|
||||
$message = match ($status) {
|
||||
401, 403 => "{$label}: invalid or unauthorized API key ({$status}).",
|
||||
413 => "{$label}: request payload too large (413).",
|
||||
422 => "{$label}: invalid payload (422). {$body}",
|
||||
429 => "{$label}: rate limit or quota exceeded (429).",
|
||||
503 => "{$label}: LLM service unavailable (503).",
|
||||
504 => "{$label}: upstream timeout (504).",
|
||||
default => "{$label}: unexpected HTTP {$status}. {$body}",
|
||||
};
|
||||
|
||||
Log::warning('VisionLlmClient: gateway error', [
|
||||
'provider' => $provider,
|
||||
'status' => $status,
|
||||
'excerpt' => $body,
|
||||
]);
|
||||
|
||||
throw new VisionLlmException($message, $status);
|
||||
}
|
||||
|
||||
private function extractContent(Response $response): string
|
||||
{
|
||||
$json = $response->json();
|
||||
|
||||
// Standard OpenAI-compatible shape: choices[0].message.content
|
||||
if (isset($json['choices'][0]['message']['content'])) {
|
||||
return trim((string) $json['choices'][0]['message']['content']);
|
||||
}
|
||||
|
||||
// Simple shape: { "content": "..." }
|
||||
if (isset($json['content']) && is_string($json['content'])) {
|
||||
return trim($json['content']);
|
||||
}
|
||||
|
||||
// Shape: { "text": "..." }
|
||||
if (isset($json['text']) && is_string($json['text'])) {
|
||||
return trim($json['text']);
|
||||
}
|
||||
|
||||
throw new VisionLlmException(
|
||||
'Vision LLM gateway: unrecognized response shape. Could not extract generated text.',
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
private function extractGeminiContent(Response $response): string
|
||||
{
|
||||
$json = $response->json();
|
||||
$parts = $json['candidates'][0]['content']['parts'] ?? null;
|
||||
|
||||
if (! is_array($parts) || $parts === []) {
|
||||
throw new VisionLlmException(
|
||||
'Gemini API: unrecognized response shape. Could not extract generated text.',
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
$text = collect($parts)
|
||||
->map(fn ($part) => is_array($part) ? trim((string) ($part['text'] ?? '')) : '')
|
||||
->filter(fn (string $value): bool => $value !== '')
|
||||
->implode("\n");
|
||||
|
||||
if ($text === '') {
|
||||
throw new VisionLlmException(
|
||||
'Gemini API: response did not contain text content.',
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
private function buildRequest(): PendingRequest
|
||||
{
|
||||
return Http::acceptJson()
|
||||
->contentType('application/json')
|
||||
->withHeaders(['X-API-Key' => $this->apiKey()])
|
||||
->connectTimeout(max(1, (int) config('vision.gateway.connect_timeout_seconds', 3)))
|
||||
->timeout(max(5, (int) config('ai_biography.llm_timeout_seconds', 30)));
|
||||
}
|
||||
|
||||
private function buildTogetherRequest(): PendingRequest
|
||||
{
|
||||
return Http::acceptJson()
|
||||
->contentType('application/json')
|
||||
->withToken($this->togetherApiKey())
|
||||
->connectTimeout(max(1, (int) config('ai_biography.together.connect_timeout_seconds', 5)))
|
||||
->timeout(max(5, (int) config('ai_biography.together.timeout_seconds', config('ai_biography.llm_timeout_seconds', 90))));
|
||||
}
|
||||
|
||||
private function buildGeminiRequest(): PendingRequest
|
||||
{
|
||||
return Http::acceptJson()
|
||||
->contentType('application/json')
|
||||
->withHeaders(['X-goog-api-key' => $this->geminiApiKey()])
|
||||
->connectTimeout(max(1, (int) config('vision.gateway.connect_timeout_seconds', 3)))
|
||||
->timeout(max(5, (int) config('ai_biography.llm_timeout_seconds', 30)));
|
||||
}
|
||||
|
||||
private function buildHomeRequest(): PendingRequest
|
||||
{
|
||||
$request = Http::acceptJson()
|
||||
->contentType('application/json')
|
||||
->connectTimeout(max(1, (int) config('ai_biography.home.connect_timeout_seconds', 3)))
|
||||
->timeout(max(5, (int) config('ai_biography.home.timeout_seconds', config('ai_biography.llm_timeout_seconds', 30))));
|
||||
|
||||
if (! (bool) config('ai_biography.home.verify_ssl', true)) {
|
||||
$request = $request->withoutVerifying();
|
||||
}
|
||||
|
||||
if ($this->homeApiKey() !== '') {
|
||||
$request = $request->withToken($this->homeApiKey());
|
||||
}
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
private function baseUrl(): string
|
||||
{
|
||||
return rtrim((string) config('vision.gateway.base_url', ''), '/');
|
||||
}
|
||||
|
||||
private function apiKey(): string
|
||||
{
|
||||
return trim((string) config('vision.gateway.api_key', ''));
|
||||
}
|
||||
|
||||
private function primaryEndpoint(): string
|
||||
{
|
||||
$path = ltrim((string) config('ai_biography.llm_endpoint', '/ai/chat'), '/');
|
||||
|
||||
return $this->baseUrl() . '/' . $path;
|
||||
}
|
||||
|
||||
private function fallbackEndpoint(): string
|
||||
{
|
||||
$path = ltrim((string) config('ai_biography.llm_fallback_endpoint', '/v1/chat/completions'), '/');
|
||||
|
||||
return $this->baseUrl() . '/' . $path;
|
||||
}
|
||||
|
||||
private function provider(): string
|
||||
{
|
||||
$override = trim(strtolower((string) config('ai_biography.provider_override', '')));
|
||||
|
||||
if (in_array($override, ['together', 'vision_gateway', 'gemini', 'home'], true)) {
|
||||
return $override;
|
||||
}
|
||||
|
||||
if ($this->togetherApiKey() !== '' && $this->togetherModel() !== '') {
|
||||
return 'together';
|
||||
}
|
||||
|
||||
$provider = trim(strtolower((string) config('ai_biography.provider', 'together')));
|
||||
|
||||
return in_array($provider, ['together', 'vision_gateway', 'gemini', 'home'], true) ? $provider : 'together';
|
||||
}
|
||||
|
||||
private function geminiBaseUrl(): string
|
||||
{
|
||||
return rtrim((string) config('ai_biography.gemini.base_url', 'https://generativelanguage.googleapis.com'), '/');
|
||||
}
|
||||
|
||||
private function geminiApiKey(): string
|
||||
{
|
||||
return trim((string) config('ai_biography.gemini.api_key', ''));
|
||||
}
|
||||
|
||||
private function geminiModel(): string
|
||||
{
|
||||
return trim((string) config('ai_biography.gemini.model', 'gemini-flash-latest'));
|
||||
}
|
||||
|
||||
private function geminiEndpoint(): string
|
||||
{
|
||||
return $this->geminiBaseUrl() . '/v1beta/models/' . rawurlencode($this->geminiModel()) . ':generateContent';
|
||||
}
|
||||
|
||||
private function togetherApiKey(): string
|
||||
{
|
||||
return trim((string) config('ai_biography.together.api_key', ''));
|
||||
}
|
||||
|
||||
private function togetherModel(): string
|
||||
{
|
||||
return trim((string) config('ai_biography.together.model', 'google/gemma-3n-E4B-it'));
|
||||
}
|
||||
|
||||
private function togetherEndpoint(): string
|
||||
{
|
||||
$base = rtrim((string) config('ai_biography.together.base_url', 'https://api.together.xyz'), '/');
|
||||
$path = ltrim((string) config('ai_biography.together.endpoint', '/v1/chat/completions'), '/');
|
||||
|
||||
return $base . '/' . $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function toTogetherPayload(array $payload): array
|
||||
{
|
||||
return [
|
||||
'model' => $this->togetherModel(),
|
||||
'messages' => array_values((array) ($payload['messages'] ?? [])),
|
||||
'max_tokens' => max(1, (int) ($payload['max_tokens'] ?? 256)),
|
||||
'temperature' => (float) ($payload['temperature'] ?? 0.3),
|
||||
'stream' => false,
|
||||
];
|
||||
}
|
||||
|
||||
private function homeBaseUrl(): string
|
||||
{
|
||||
return rtrim((string) config('ai_biography.home.base_url', 'http://home.klevze.si:8200'), '/');
|
||||
}
|
||||
|
||||
private function homeApiKey(): string
|
||||
{
|
||||
return trim((string) config('ai_biography.home.api_key', ''));
|
||||
}
|
||||
|
||||
private function homeModel(): string
|
||||
{
|
||||
return trim((string) config('ai_biography.home.model', 'qwen/qwen3.5-9b'));
|
||||
}
|
||||
|
||||
private function homeEndpoint(): string
|
||||
{
|
||||
$path = ltrim((string) config('ai_biography.home.endpoint', '/v1/chat/completions'), '/');
|
||||
|
||||
return $this->homeBaseUrl() . '/' . $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function toGeminiPayload(array $payload): array
|
||||
{
|
||||
$systemParts = [];
|
||||
$contents = [];
|
||||
|
||||
foreach ((array) ($payload['messages'] ?? []) as $message) {
|
||||
$role = strtolower((string) ($message['role'] ?? 'user'));
|
||||
$content = trim((string) ($message['content'] ?? ''));
|
||||
|
||||
if ($content === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($role === 'system') {
|
||||
$systemParts[] = ['text' => $content];
|
||||
continue;
|
||||
}
|
||||
|
||||
$contents[] = [
|
||||
'role' => $role === 'assistant' ? 'model' : 'user',
|
||||
'parts' => [
|
||||
['text' => $content],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if ($contents === [] && $systemParts !== []) {
|
||||
$contents[] = [
|
||||
'role' => 'user',
|
||||
'parts' => $systemParts,
|
||||
];
|
||||
$systemParts = [];
|
||||
}
|
||||
|
||||
$geminiPayload = [
|
||||
'contents' => $contents,
|
||||
'generationConfig' => [
|
||||
'temperature' => (float) ($payload['temperature'] ?? 0.3),
|
||||
'maxOutputTokens' => max(1, (int) ($payload['max_tokens'] ?? 256)),
|
||||
],
|
||||
];
|
||||
|
||||
if ($systemParts !== []) {
|
||||
$geminiPayload['systemInstruction'] = [
|
||||
'parts' => $systemParts,
|
||||
];
|
||||
}
|
||||
|
||||
return $geminiPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function toHomePayload(array $payload): array
|
||||
{
|
||||
return [
|
||||
'model' => $this->homeModel(),
|
||||
'messages' => array_values((array) ($payload['messages'] ?? [])),
|
||||
'max_tokens' => max(1, (int) ($payload['max_tokens'] ?? 256)),
|
||||
'temperature' => (float) ($payload['temperature'] ?? 0.3),
|
||||
'stream' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
14
app/Services/AiBiography/VisionLlmException.php
Normal file
14
app/Services/AiBiography/VisionLlmException.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\AiBiography;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Thrown when the Vision LLM gateway returns a structured failure.
|
||||
*/
|
||||
final class VisionLlmException extends RuntimeException
|
||||
{
|
||||
}
|
||||
Reference in New Issue
Block a user