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

491 lines
19 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Services\AiBiography;
use 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' => [],
];
}
}