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,276 @@
<?php
declare(strict_types=1);
namespace App\Services\AiBiography;
/**
* Builds the LLM prompt payload from a normalized creator input.
*
* v1.1 changes:
* PROMPT_VERSION constant tracks the active template family.
* Improved system prompt discourages formulaic openings and stat-dumps.
* Sparse profile branch uses a lighter, safer template.
* Strict mode is used on retry; produces a more conservative output.
*
* Prompt rules:
* Only include facts that are actually present in the input.
* Never instruct the model to invent details or speculate.
* Always require a single paragraph output with no markdown.
* Keep max_tokens tight to enforce the word cap.
*/
final class AiBiographyPromptBuilder
{
public const PROMPT_VERSION = 'v1.1';
private const MIN_WORDS = 30;
private const SYSTEM_PROMPT = <<<'PROMPT'
You are a concise writing assistant for Skinbase Nova, a digital art platform.
Write short creator biographies using only the facts provided. Use a polished, factual, and slightly editorial tone.
Rules:
- Use only the provided data. Do not invent achievements, personal details, visual style claims, or platform fame.
- Do not write bullet points, headings, or markdown.
- Output exactly one paragraph.
- Do not exceed 140 words.
- Avoid hype language: do not use "world-class", "iconic", "legendary", "renowned", "celebrated", "masterpiece", or "beloved".
- Do not speculate about personality, age, gender, politics, religion, or private life.
- Do not mention data points that are not provided or are zero/empty.
- Do not open with "has been part of Skinbase since" or similar formulaic phrases. Vary the opening.
- Mention only the 2 to 3 most meaningful signals. Do not list every available stat.
- Do not write "creator journey shows..." describe what the data reflects directly.
- Prefer natural narrative flow over data listing.
PROMPT;
private const SYSTEM_PROMPT_STRICT = <<<'PROMPT'
You are a cautious writing assistant for Skinbase Nova, a digital art platform.
Write a short, safe creator biography using only the facts provided. Be conservative.
Rules:
- Use only the provided facts. Do not invent or speculate.
- Output exactly one paragraph, no markdown, no headings, no bullets.
- Maximum 100 words.
- Mention only 1 or 2 standout facts. Do not list all available data.
- Avoid any superlatives, praise, or style claims.
- Do not mention missing or zero-value fields.
- Keep the tone neutral, simple, and factual.
PROMPT;
private const SYSTEM_PROMPT_SPARSE = <<<'PROMPT'
You are a cautious writing assistant for Skinbase Nova, a digital art platform.
Write a short, modest creator introduction using only the facts provided.
Rules:
- Use only the facts provided.
- Output exactly one paragraph, no markdown, no bullets.
- Write between 35 and 60 words.
- Minimum 30 words.
- Keep it simple. Mention member-since year and upload count if available.
- Add one category or another factual signal when available so the paragraph has enough substance.
- Do not invent anything. Do not praise. Do not speculate.
- If data is very limited, use two short factual sentences rather than a fragment.
PROMPT;
private const SYSTEM_PROMPT_SPARSE_STRICT = <<<'PROMPT'
You are a cautious writing assistant for Skinbase Nova, a digital art platform.
Write a short, modest creator introduction using only the facts provided. Be conservative and precise.
Rules:
- Use only the facts provided.
- Output exactly one paragraph, no markdown, no bullets.
- Write between 35 and 50 words.
- Minimum 30 words.
- Prefer two short factual sentences.
- Mention member-since year, upload count, and one category when available.
- Do not invent anything. Do not praise. Do not speculate.
PROMPT;
/**
* Build the full messages payload for the LLM.
*
* @param array<string, mixed> $input normalized creator input from AiBiographyInputBuilder
* @param bool $strict true on retry forces more conservative output
* @param bool $sparse true for sparse-profile creators
* @return array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool, prompt_version: string}
*/
public function build(array $input, bool $strict = false, bool $sparse = false): array
{
if ($sparse && $strict) {
$systemPrompt = self::SYSTEM_PROMPT_SPARSE_STRICT;
$userPrompt = $this->buildSparseUserPrompt($input, strict: true);
$maxTokens = 240;
$temperature = 0.2;
} elseif ($sparse) {
$systemPrompt = self::SYSTEM_PROMPT_SPARSE;
$userPrompt = $this->buildSparseUserPrompt($input, strict: false);
$maxTokens = 320;
$temperature = 0.3;
} elseif ($strict) {
$systemPrompt = self::SYSTEM_PROMPT_STRICT;
$userPrompt = $this->buildUserPrompt($input, strict: true);
$maxTokens = 450;
$temperature = 0.25;
} else {
$systemPrompt = self::SYSTEM_PROMPT;
$userPrompt = $this->buildUserPrompt($input, strict: false);
$maxTokens = 600;
$temperature = 0.45;
}
return [
'messages' => [
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userPrompt],
],
'max_tokens' => $maxTokens,
'temperature' => $temperature,
'stream' => false,
'prompt_version' => self::PROMPT_VERSION,
];
}
private function buildUserPrompt(array $input, bool $strict): string
{
$wordTarget = $strict ? '60 to 100' : '70 to 130';
$lines = [
"Write a creator biography in {$wordTarget} words using only the facts below. Output one paragraph only.",
'',
];
$username = (string) ($input['username'] ?? '');
if ($username !== '') {
$lines[] = "- Creator: {$username}";
}
$memberYear = $input['member_since_year'] ?? null;
$years = $input['years_on_skinbase'] ?? null;
if ($memberYear !== null && (int) $memberYear > 0) {
$label = ((int) ($years ?? 0) > 1) ? ", {$years} years on the platform" : '';
$lines[] = "- Member since: {$memberYear}{$label}";
}
$uploads = $input['uploads_count'] ?? 0;
if ((int) $uploads > 0) {
$lines[] = "- Total public uploads: {$uploads}";
}
$featured = $input['featured_count'] ?? 0;
if ((int) $featured > 0) {
$lines[] = "- Featured artworks: {$featured}";
}
$downloads = $input['downloads_count'] ?? 0;
if ((int) $downloads > 5000) {
$lines[] = sprintf('- Total downloads: %s', number_format((int) $downloads));
}
$categories = $input['top_categories'] ?? [];
if ($categories !== []) {
$lines[] = '- Top categories: ' . implode(', ', array_slice((array) $categories, 0, 2));
}
// On strict retry, trim tags to keep prompt tight and reduce hallucination surface.
if (! $strict) {
$tags = $input['top_tags'] ?? [];
if ($tags !== []) {
$lines[] = '- Common themes: ' . implode(', ', array_slice((array) $tags, 0, 3));
}
}
$bestWork = $input['best_performing_work'] ?? null;
if (is_array($bestWork) && isset($bestWork['title'], $bestWork['year'])) {
$lines[] = "- Best-performing work: {$bestWork['title']} ({$bestWork['year']})";
}
$productiveYear = $input['most_productive_year'] ?? null;
if ($productiveYear !== null && (int) $productiveYear > 0) {
$lines[] = "- Most productive year: {$productiveYear}";
}
$status = $input['current_activity_status'] ?? null;
if ($status !== null && $status !== '') {
$statusLabels = [
'active' => 'currently active',
'recently_active' => 'recently active',
'returning' => 'returning creator',
'legacy' => 'long-standing creator',
'inactive' => null,
];
$statusLabel = $statusLabels[$status] ?? null;
if ($statusLabel !== null) {
$lines[] = "- Activity: {$statusLabel}";
}
}
$milestones = $input['milestones'] ?? [];
if (is_array($milestones)) {
if (! empty($milestones['has_comeback'])) {
$lines[] = '- Notable milestone: returned after a significant break';
}
$streak = (int) ($milestones['best_upload_streak_months'] ?? 0);
if ($streak >= 3 && ! $strict) {
$lines[] = "- Upload streak: {$streak} consecutive months";
}
}
// Include eras and evolution only when not on strict retry.
if (! $strict) {
$eras = $input['eras'] ?? [];
if (is_array($eras) && count($eras) >= 2) {
$eraTitles = array_column($eras, 'title');
$lines[] = '- Creator eras: ' . implode(' → ', $eraTitles);
}
$evolutionCount = $input['evolution_count'] ?? 0;
if ((int) $evolutionCount > 0) {
$lines[] = "- Remastered/evolved works: {$evolutionCount}";
}
}
$lines[] = '';
$lines[] = 'Avoid hype. Do not open with a formulaic phrase. Do not list every stat. Output one paragraph only. No markdown.';
return implode("\n", $lines);
}
private function buildSparseUserPrompt(array $input, bool $strict = false): string
{
$wordTarget = $strict ? '35 to 50' : '35 to 60';
$lines = [
"Write a brief, modest creator introduction in {$wordTarget} words using only these facts. Output one paragraph only.",
'',
];
$username = (string) ($input['username'] ?? '');
if ($username !== '') {
$lines[] = "- Creator: {$username}";
}
$memberYear = $input['member_since_year'] ?? null;
$years = $input['years_on_skinbase'] ?? null;
if ($memberYear !== null && (int) $memberYear > 0) {
$yearsLabel = ((int) ($years ?? 0) > 1) ? ", {$years} years on the platform" : '';
$lines[] = "- Member since: {$memberYear}{$yearsLabel}";
}
$uploads = $input['uploads_count'] ?? 0;
if ((int) $uploads > 0) {
$lines[] = "- Public uploads: {$uploads}";
}
$categories = $input['top_categories'] ?? [];
if ($categories !== []) {
$lines[] = '- Categories: ' . implode(', ', array_slice((array) $categories, 0, $strict ? 1 : 2));
}
$lines[] = '';
$lines[] = 'Keep it simple and factual. Write at least ' . self::MIN_WORDS . ' words. Prefer two short sentences if needed. No praise. No markdown.';
return implode("\n", $lines);
}
}