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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user