diff --git a/.env.example b/.env.example index d5e0567..3b54ae0 100755 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.playwright-mcp/persona-identidade-maria.png b/.playwright-mcp/persona-identidade-maria.png new file mode 100644 index 0000000..5cb0abf Binary files /dev/null and b/.playwright-mcp/persona-identidade-maria.png differ diff --git a/app/Http/Controllers/Web/PersonTalkController.php b/app/Http/Controllers/Web/PersonTalkController.php index 703b363..e713bde 100644 --- a/app/Http/Controllers/Web/PersonTalkController.php +++ b/app/Http/Controllers/Web/PersonTalkController.php @@ -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); diff --git a/app/Models/Character.php b/app/Models/Character.php index 2feb2a0..b4a9255 100755 --- a/app/Models/Character.php +++ b/app/Models/Character.php @@ -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 */ use HasFactory; + /** + * Default personality pool for characters created without one. + * + * @var list + */ + 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', diff --git a/config/ai.php b/config/ai.php index 39f3980..0d12a84 100755 --- a/config/ai.php +++ b/config/ai.php @@ -1,5 +1,33 @@ 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 diff --git a/database/migrations/2026_10_31_210846_add_ai_profile_to_characters_table.php b/database/migrations/2026_10_31_210846_add_ai_profile_to_characters_table.php new file mode 100644 index 0000000..460f71e --- /dev/null +++ b/database/migrations/2026_10_31_210846_add_ai_profile_to_characters_table.php @@ -0,0 +1,67 @@ + + */ + 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']); + }); + } +}; diff --git a/tests/Feature/PersonTalkTest.php b/tests/Feature/PersonTalkTest.php index 60859c7..c30cb18 100644 --- a/tests/Feature/PersonTalkTest.php +++ b/tests/Feature/PersonTalkTest.php @@ -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); + } }