Files
SkinbaseNova/app/Services/AiBiography/AiBiographyPromptBuilder.php

277 lines
11 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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, 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, 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, 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, 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);
}
}