Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

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