Save workspace changes
This commit is contained in:
276
app/Services/AiBiography/AiBiographyPromptBuilder.php
Normal file
276
app/Services/AiBiography/AiBiographyPromptBuilder.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user