} */ 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} */ 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 */ 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 */ 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} */ 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} */ 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' => [], ]; } }