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:
2026-10-31 18:35:57 -03:00
parent 59963de995
commit 81be63b4c3
7 changed files with 354 additions and 13 deletions
+7
View File
@@ -60,6 +60,13 @@ MAIL_FROM_NAME="${APP_NAME}"
OPENROUTER_API_KEY=
AI_DEFAULT_MODEL=deepseek/deepseek-v4-flash-0731
# AI_MODELS: pool of allow-listed free OpenRouter models distributed
# deterministically across characters (each character keeps its own model).
AI_MODELS=deepseek/deepseek-v4-flash-0731,nvidia/nemotron-3.5-lightning:free,liquid/lfm-2.5-2.6b:free,openrouter/free,nvidia/nemotron-3-ultra-550b-a55b:free
# AI_CHARACTER_MODELS: per-character override "name or id=model" (comma
# separated), e.g. Maria=liquid/lfm-2.5-2.6b:free,7=openrouter/free
AI_CHARACTER_MODELS=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

@@ -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);
+19 -1
View File
@@ -17,7 +17,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* ticker mutates needs and job progress continuously while nobody is online.
*/
#[Fillable([
'user_id', 'city_id', 'name', 'skin', 'hair', 'shirt',
'user_id', 'city_id', 'name', 'ai_model', 'ai_age', 'ai_gender',
'ai_personality', 'ai_bio', 'skin', 'hair', 'shirt',
'pos_x', 'pos_y', 'pos_z', 'money', 'energy', 'hunger', 'thirst', 'health',
'state', 'job_id', 'house_id', 'education', 'exercise_count', 'is_online', 'last_tick_at',
'hospitalized_until', 'face', 'beard', 'accessory', 'ride_kind', 'ride_until',
@@ -27,6 +28,20 @@ class Character extends Model
/** @use HasFactory<CharacterFactory, $this> */
use HasFactory;
/**
* Default personality pool for characters created without one.
*
* @var list<string>
*/
public const AI_PERSONALITIES = [
'Extrovertido e curioso, adora puxar conversa com quem passa.',
'Tranquilo e observador, fala pouco mas escuta tudo com atenção.',
'Bem-humorado e espirituoso, brinca com todo mundo por perto.',
'Sério e direto, vai ao ponto sem rodeios.',
'Gentil e prestativo, sempre oferece ajuda antes de perguntarem.',
'Aventureiro e barulhento, vive contando histórias da cidade.',
];
public const STATE_AWAKE = 'awake';
public const STATE_SLEEPING = 'sleeping';
@@ -176,6 +191,8 @@ class Character extends Model
'user_id' => $user->id,
'city_id' => $city->id,
'name' => $firstName,
'ai_age' => random_int(18, 68),
'ai_personality' => self::AI_PERSONALITIES[array_rand(self::AI_PERSONALITIES)],
'skin' => random_int(1, 4),
'hair' => random_int(1, 4),
'shirt' => random_int(1, 5),
@@ -220,6 +237,7 @@ class Character extends Model
protected function casts(): array
{
return [
'ai_age' => 'integer',
'pos_x' => 'float',
'pos_y' => 'float',
'pos_z' => 'float',
+63
View File
@@ -1,5 +1,33 @@
<?php
/*
|--------------------------------------------------------------------------
| Character model map parser
|--------------------------------------------------------------------------
|
| Turns AI_CHARACTER_MODELS entries such as "Maria=qwen/qwen3.7-flash:free"
| or "3=deepseek/deepseek-v4-flash-0731" into a lookup array keyed by
| lowercased character id or name. Invalid pairs are ignored.
|
*/
$parseCharacterModels = static function (string $raw): array {
$map = [];
foreach (explode(',', $raw) as $pair) {
$pair = trim($pair);
if ($pair === '' || ! str_contains($pair, '=')) {
continue;
}
[$key, $model] = array_map('trim', explode('=', $pair, 2));
if ($key !== '' && $model !== '') {
$map[strtolower($key)] = $model;
}
}
return $map;
};
return [
/*
@@ -50,8 +78,43 @@ return [
'poolside/laguna-xs-2.1:free',
'inclusionai/ling-3.0-flash-vl:free',
'qwen/qwen3.7-flash:free',
// Verified working free chat models (live probe of the OpenRouter
// catalogue). Safe defaults for per-character assignment.
'deepseek/deepseek-v4-flash-0731',
'nvidia/nemotron-3.5-lightning:free',
'liquid/lfm-2.5-2.6b:free',
'openrouter/free',
],
/*
|--------------------------------------------------------------------------
| Per-Character Model Pool (AI_MODELS)
|--------------------------------------------------------------------------
|
| Comma-separated allow-listed models distributed deterministically
| across characters (each character keeps a stable own model). Falls
| back to AI_DEFAULT_MODEL when empty.
|
*/
'models' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('AI_MODELS', ''))
))),
/*
|--------------------------------------------------------------------------
| Per-Character Model Map (AI_CHARACTER_MODELS)
|--------------------------------------------------------------------------
|
| Explicit "id or name = model" overrides from the environment, e.g.
| "Maria=liquid/lfm-2.5-2.6b:free,7=openrouter/free". Takes priority
| over the pool; entries outside the allow-list are ignored.
|
*/
'character_models' => $parseCharacterModels((string) env('AI_CHARACTER_MODELS', '')),
/*
|--------------------------------------------------------------------------
| Default Model
@@ -0,0 +1,67 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Adds the per-character AI profile to "characters".
*
* Every citizen can now carry its own model override plus the identity
* used by the conversational persona: age, gender, short biography and
* personality. Existing rows are backfilled with a deterministic default
* persona so no character speaks without one.
*/
return new class extends Migration
{
/**
* Personality pool used for the backfill (kept inline so this
* migration stays a self-contained snapshot).
*
* @var list<string>
*/
private const PERSONALITIES = [
'Extrovertido e curioso, adora puxar conversa com quem passa.',
'Tranquilo e observador, fala pouco mas escuta tudo com atenção.',
'Bem-humorado e espirituoso, brinca com todo mundo por perto.',
'Sério e direto, vai ao ponto sem rodeios.',
'Gentil e prestativo, sempre oferece ajuda antes de perguntarem.',
'Aventureiro e barulhento, vive contando histórias da cidade.',
];
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('characters', function (Blueprint $table): void {
$table->string('ai_model', 120)->nullable()->after('name');
$table->unsignedSmallInteger('ai_age')->nullable();
$table->string('ai_gender', 24)->nullable();
$table->string('ai_personality', 255)->nullable();
$table->string('ai_bio', 500)->nullable();
});
DB::table('characters')
->whereNull('ai_personality')
->orderBy('id')
->get()
->each(function (object $row): void {
DB::table('characters')->where('id', $row->id)->update([
'ai_age' => 18 + (7 * (int) $row->id) % 50,
'ai_personality' => self::PERSONALITIES[(int) $row->id % count(self::PERSONALITIES)],
]);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('characters', function (Blueprint $table): void {
$table->dropColumn(['ai_model', 'ai_age', 'ai_gender', 'ai_personality', 'ai_bio']);
});
}
};
+118
View File
@@ -2,6 +2,7 @@
namespace Tests\Feature;
use App\Http\Controllers\Web\PersonTalkController;
use App\Models\AiAgent;
use App\Models\Character;
use App\Models\PersonTalk;
@@ -521,4 +522,121 @@ class PersonTalkTest extends TestCase
$this->assertNotNull($reply);
$this->assertSame('Praça fechada para obra, já passa.', $reply->body);
}
/**
* Model resolution priority: column → env map (id/name) → pool slot,
* always inside the allow-list (RULES.md 2.1) and stable per id.
*/
public function test_model_resolution_priority_and_allow_list(): void
{
$mapped = 'openrouter/free';
$pooled = 'nvidia/nemotron-3.5-lightning:free';
config([
'ai.models' => [$pooled, $mapped],
'ai.character_models' => [
'maria' => $mapped,
'7' => $pooled,
'bad' => 'not/allowed-model',
],
]);
// 1) Character column wins over everything.
$this->assertSame(
$mapped,
PersonTalkController::resolveModel($mapped, 'Qualquer', 99)
);
// 2) Env map by name (case-insensitive) and by id.
$this->assertSame($mapped, PersonTalkController::resolveModel(null, 'Maria', 3));
$this->assertSame($pooled, PersonTalkController::resolveModel(null, 'Outro', 7));
// 3) Non allow-listed entries are ignored → deterministic pool slot.
$fromPool = PersonTalkController::resolveModel('not/allowed-model', 'Bad', 4);
$this->assertContains($fromPool, [$pooled, $mapped]);
$this->assertNotSame('not/allowed-model', $fromPool);
// 4) Empty map/pool falls back to the allow-listed default.
config(['ai.character_models' => [], 'ai.models' => []]);
$default = config('ai.default_model');
$this->assertContains($default, config('ai.allowed_models'));
$this->assertSame($default, PersonTalkController::resolveModel(null, 'X', 1));
// 5) Same character always resolves to the same model.
config(['ai.models' => [$pooled, $mapped]]);
$stable = PersonTalkController::resolveModel(null, 'Estável', 5);
$this->assertSame($stable, PersonTalkController::resolveModel(null, 'Estável', 5));
}
/**
* The identity/personality profile and the resolved model travel in
* the upstream payload while registration data stays out (RULES.md 2.2).
*/
public function test_identity_and_model_flow_into_upstream_payload(): void
{
config([
'ai.openrouter.api_key' => 'test-key',
'ai.models' => ['liquid/lfm-2.5-2.6b:free'],
'ai.character_models' => [],
]);
Http::fake([
'openrouter.ai/*' => Http::response([
'choices' => [['message' => ['content' => 'Oi! Tudo tranquilo na cidade.']]],
]),
]);
[$userA, $me] = $this->makeCitizen();
[$userB, $them] = $this->makeCitizen();
$them->update([
'ai_age' => 34,
'ai_gender' => 'feminino',
'ai_personality' => 'Extrovertido e curiosa, adora puxar conversa.',
'ai_bio' => 'Moradora do centro, vive nas novidades do bairro.',
]);
$this->actingAs($userA)
->postJson('/talk/person', [
'kind' => 'character',
'peer' => $them->id,
'message' => 'Oi, tudo bem?',
])
->assertCreated();
$peerEmail = $userB->email;
$peerName = $them->name;
Http::assertSent(function ($request) use ($peerEmail, $peerName): bool {
$payload = $request->data();
$encoded = json_encode($payload, JSON_UNESCAPED_UNICODE);
$system = (string) ($payload['messages'][0]['content'] ?? '');
return $payload['model'] === 'liquid/lfm-2.5-2.6b:free'
&& str_contains($system, 'Idade: 34')
&& str_contains($system, 'Sexo: feminino')
&& str_contains($system, 'Extrovertido e curiosa')
&& str_contains($system, 'Moradora do centro')
&& str_contains($system, 'Nome: '.$peerName)
&& ! str_contains($encoded, $peerEmail);
});
// The reply from the fake model is stored normally.
$this->assertNotNull(PersonTalk::query()
->where('from_character_id', $them->id)
->where('body', 'Oi! Tudo tranquilo na cidade.')
->first());
}
/**
* Fresh citizens are created with a ready-to-use default persona.
*/
public function test_new_characters_receive_default_persona(): void
{
[, $character] = $this->makeCitizen();
$this->assertNotNull($character->ai_age);
$this->assertGreaterThanOrEqual(18, $character->ai_age);
$this->assertLessThanOrEqual(68, $character->ai_age);
$this->assertContains($character->ai_personality, Character::AI_PERSONALITIES);
}
}