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
+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);
}
}