491 lines
19 KiB
PHP
491 lines
19 KiB
PHP
<?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' => [],
|
||
];
|
||
}
|
||
}
|