feat(ai): add per-character AI model assignment and identity profiles
Implements deterministic model resolution (column → env map → pool → default) and adds AI identity fields to characters. Migration backfills existing rows with deterministic defaults. Controller uses resolved model and identity block in system prompts for contextual conversations.
This commit is contained in:
@@ -209,7 +209,8 @@ class PersonTalkController extends Controller
|
||||
.' Mantenha o fio da meada: referencie o que já foi dito quando fizer sentido.'
|
||||
."\nResponda de forma curta e natural, em português. Não invente dados pessoais de usuários.";
|
||||
|
||||
$reply = self::askModel($agent, $system, $me->id, $talk);
|
||||
$model = self::resolveModel($agent->model, $agent->name, (int) $agent->id);
|
||||
$reply = self::askModel($model, $system, $me->id, $talk);
|
||||
if ($reply === null) {
|
||||
self::recordAgentFailure($agent);
|
||||
self::storeReply(
|
||||
@@ -294,19 +295,21 @@ class PersonTalkController extends Controller
|
||||
}
|
||||
|
||||
// Persona plays the citizen in-world: no registration data
|
||||
// travels upstream (RULES.md 2.2) — only simulated city state
|
||||
// and the dialogue itself, which already carries whatever the
|
||||
// peers chose to type.
|
||||
// travels upstream (RULES.md 2.2) — only simulated city state,
|
||||
// the in-world identity broadcast to every client, and the
|
||||
// dialogue itself.
|
||||
$system = 'Você é um morador da cidade virtual City10, conversando na rua com alguém que chegou perto de você.'
|
||||
."\n\n".self::identityBlock($citizen)
|
||||
."\n\nContexto simulado da cidade: ".json_encode(self::simulatedCityContext(), JSON_UNESCAPED_UNICODE)
|
||||
."\nAs mensagens abaixo são o histórico real da conversa, inclusive de dias anteriores:"
|
||||
.' mantenha o fio da meada e referencie o que já foi dito quando couber.'
|
||||
."\nSe te chamarem por um nome na conversa, aquele é o seu nome — aceite-o e não se diga outra pessoa nem se recuse a responder."
|
||||
."\nSe te chamarem pelo nome na conversa, responda naturalmente — ele é seu; não se negue a responder."
|
||||
."\nFrases curtas e naturais, tom de vizinho conversando na rua; nada de linguagem de atendimento, como 'como posso ajudar'."
|
||||
."\nNunca forneça nem invente e-mail, senha, endereço, telefone ou dados de conta de ninguém."
|
||||
."\nResponda em português.";
|
||||
|
||||
$reply = self::askModel(null, $system, $me->id, $talk);
|
||||
$model = self::resolveModel($citizen->ai_model, $citizen->name, (int) $citizen->id);
|
||||
$reply = self::askModel($model, $system, $me->id, $talk);
|
||||
if ($reply === null) {
|
||||
self::storeReply($replySides, __('city.chat_error'), true);
|
||||
cache()->forget($pendingKey);
|
||||
@@ -408,11 +411,81 @@ class PersonTalkController extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the OpenRouter model for a character or agent.
|
||||
*
|
||||
* Priority: entity column (characters.ai_model / agents.model) →
|
||||
* environment map (AI_CHARACTER_MODELS by id then by name) →
|
||||
* deterministic pool slot (AI_MODELS, stable per id) → AI_DEFAULT_MODEL
|
||||
* → first allow-listed model. Everything is validated against
|
||||
* config('ai.allowed_models') (RULES.md 2.1).
|
||||
*/
|
||||
public static function resolveModel(?string $columnModel, string $name, int $id): string
|
||||
{
|
||||
$allowed = config('ai.allowed_models', []);
|
||||
|
||||
$map = config('ai.character_models', []);
|
||||
$candidates = [
|
||||
$columnModel,
|
||||
$map[(string) $id] ?? null,
|
||||
$map[strtolower($name)] ?? null,
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (is_string($candidate) && in_array($candidate, $allowed, true)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
$pool = array_values(array_filter(
|
||||
(array) config('ai.models', []),
|
||||
static fn ($model): bool => is_string($model) && in_array($model, $allowed, true)
|
||||
));
|
||||
if ($pool !== []) {
|
||||
return $pool[$id % count($pool)];
|
||||
}
|
||||
|
||||
$default = config('ai.default_model');
|
||||
if (is_string($default) && in_array($default, $allowed, true)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return (string) ($allowed[0] ?? $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity/personality block injected into the persona prompt.
|
||||
*
|
||||
* Only in-world data goes upstream: the display name already shown
|
||||
* above heads and broadcast by the presence API, plus the profile
|
||||
* fields below. Registration data (e-mail, password, account data)
|
||||
* is never included (RULES.md 2.2).
|
||||
*/
|
||||
private static function identityBlock(Character $character): string
|
||||
{
|
||||
$lines = [
|
||||
'Identidade do seu personagem — aja sempre de acordo com ela:',
|
||||
'- Nome: '.$character->name,
|
||||
'- Idade: '.($character->ai_age ?? 'não informada'),
|
||||
'- Sexo: '.($character->ai_gender ?? 'não informado'),
|
||||
];
|
||||
|
||||
if (filled($character->ai_bio)) {
|
||||
$lines[] = '- Bio: '.$character->ai_bio;
|
||||
}
|
||||
|
||||
if (filled($character->ai_personality)) {
|
||||
$lines[] = '- Personalidade: '.$character->ai_personality;
|
||||
}
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call OpenRouter with the given system prompt and the full daily
|
||||
* conversation of the pair. Returns null on any failure.
|
||||
*/
|
||||
private static function askModel(?AiAgent $agent, string $system, int $meCharacterId, PersonTalk $talk): ?string
|
||||
private static function askModel(string $model, string $system, int $meCharacterId, PersonTalk $talk): ?string
|
||||
{
|
||||
$peerKind = $talk->to_agent_id !== null ? PersonTalk::KIND_AGENT : PersonTalk::KIND_CHARACTER;
|
||||
$peerId = (int) ($talk->to_agent_id ?? $talk->to_character_id);
|
||||
@@ -438,11 +511,6 @@ class PersonTalkController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$model = ($agent === null ? null : $agent->model);
|
||||
if (! in_array($model, config('ai.allowed_models', []), true)) {
|
||||
$model = config('ai.default_model');
|
||||
}
|
||||
|
||||
// Cap the upstream wait below PHP's default execution limit so
|
||||
// the after-response job is never killed mid-flight.
|
||||
$timeout = min((int) config('ai.openrouter.timeout', 30), 20);
|
||||
|
||||
Reference in New Issue
Block a user