Save workspace changes
This commit is contained in:
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' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user