user()); [$kind, $peer] = $this->resolvePeer($request); $rows = PersonTalk::conversation($me->id, $kind, (int) $peer->id) ->orderBy('id') ->limit(self::HISTORY_LIMIT) ->get(); $messages = $rows->map(fn (PersonTalk $talk): array => [ 'id' => (int) $talk->id, 'from' => (int) $talk->from_character_id === (int) $me->id ? 'me' : 'them', 'body' => $talk->body, 'at' => $talk->created_at?->toIso8601String(), 'talked_on' => $talk->talked_on?->toDateString(), 'fallback' => (bool) $talk->is_fallback, ])->values(); $last = $messages->last(); $awaitingMe = $last !== null && $last['from'] === 'me'; $thinking = $kind === PersonTalk::KIND_AGENT ? $awaitingMe : ($awaitingMe && cache()->has(self::pendingKey((int) $peer->id))); return response()->json([ 'me' => ['character_id' => (int) $me->id, 'name' => $me->name], 'peer' => [ 'kind' => $kind, 'id' => (int) $peer->id, 'name' => $peer->name, 'status' => $kind === PersonTalk::KIND_AGENT ? $peer->status : 'active', ], 'messages' => $messages, 'thinking' => $thinking, ])->header('Cache-Control', 'no-store'); } /** * Register one message of the day and reply when nobody human is * manning the other side. */ public function send(Request $request): JsonResponse { $me = Character::ensureForUser($request->user()); $data = $request->validate([ 'message' => ['required', 'string', 'max:1000'], ]); [$kind, $peer] = $this->resolvePeer($request); if ($kind === PersonTalk::KIND_CHARACTER && (int) $peer->id === (int) $me->id) { return response()->json(['message' => __('city.social_self')], 422); } $talk = PersonTalk::create([ 'from_character_id' => $me->id, 'to_character_id' => $kind === PersonTalk::KIND_CHARACTER ? $peer->id : null, 'to_agent_id' => $kind === PersonTalk::KIND_AGENT ? $peer->id : null, 'body' => $data['message'], 'talked_on' => now()->toDateString(), ]); $talkId = (int) $talk->id; if ($kind === PersonTalk::KIND_AGENT) { /** @var AiAgent $peer */ $peer->update(['status' => 'thinking']); // Answer after the HTTP response so the chat stays snappy; // runs in-process (no queue worker required). dispatch(function () use ($talkId): void { self::think($talkId); })->afterResponse(); } elseif (! self::characterPeerIsLive($peer)) { // No live human on the other side (seeded citizen, NPC child, // demo account): answer through the generic persona so the // conversation keeps flowing. /** @var Character $peer */ cache()->put(self::pendingKey((int) $peer->id), 1, now()->addSeconds(60)); dispatch(function () use ($talkId): void { self::thinkAsCitizen($talkId); })->afterResponse(); } return response()->json([ 'id' => (int) $talk->id, 'from' => 'me', 'body' => $talk->body, 'at' => $talk->created_at?->toIso8601String(), 'talked_on' => $talk->talked_on->toDateString(), ], 201); } /** * Generate the agent reply for a pending talk message. * * Public static so it can be exercised directly in tests with a * faked HTTP client. Reads the whole daily conversation from the * database, which keeps the thread of previous days intact. */ public static function think(int $talkId): void { try { $talk = PersonTalk::query()->find($talkId); if ($talk === null || $talk->to_agent_id === null) { return; } $agent = AiAgent::query()->find($talk->to_agent_id); $me = Character::query()->find($talk->from_character_id); if ($agent === null || $me === null) { return; } if (! self::acquireThinkLock('agent.'.$agent->id)) { return; } // Another reply already landed after this message (serialised // bursts) — nothing left to answer. $answered = PersonTalk::conversation($me->id, PersonTalk::KIND_AGENT, (int) $agent->id) ->where('from_agent_id', $agent->id) ->where('id', '>', $talk->id) ->exists(); if ($answered) { return; } if (! filled(config('ai.openrouter.api_key'))) { self::recordAgentFailure($agent); self::storeReply( ['from_agent_id' => $agent->id, 'to_character_id' => $me->id], __('city.chat_error'), true ); return; } $system = ($agent->system_prompt ?: "Você é {$agent->name}, um habitante da cidade virtual City10.") ."\n\nContexto simulado da cidade: ".json_encode(self::simulatedCityContext(), JSON_UNESCAPED_UNICODE) ."\nAs mensagens abaixo são o histórico da conversa de rua com este cidadão, incluindo dias anteriores." .' 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."; $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( ['from_agent_id' => $agent->id, 'to_character_id' => $me->id], __('city.chat_error'), true ); return; } self::storeReply( ['from_agent_id' => $agent->id, 'to_character_id' => $me->id], $reply ); $historyLog = $agent->last_decision ?? []; $historyLog[] = ['role' => 'user', 'content' => $talk->body, 'at' => $talk->created_at?->toIso8601String()]; $historyLog[] = ['role' => 'assistant', 'content' => $reply, 'at' => now()->toIso8601String()]; $agent->update([ 'last_decision' => array_slice($historyLog, -20), 'status' => 'active', 'error_count' => 0, 'last_think_at' => now(), ]); } catch (\Throwable $e) { Log::error('Person talk think failed', ['talk' => $talkId, 'error' => $e->getMessage()]); } } /** * Generate a reply on behalf of a character peer that has no live * human behind it, using an anonymous city-inhabitant persona. * * The payload never includes registration data — no names, e-mails * or any other PII (RULES.md 2.2); only simulated city context and * the dialogue itself travel upstream. */ public static function thinkAsCitizen(int $talkId): void { $pendingKey = null; try { $talk = PersonTalk::query()->find($talkId); if ($talk === null || $talk->to_character_id === null) { return; } $citizen = Character::query()->find($talk->to_character_id); $me = Character::query()->find($talk->from_character_id); if ($citizen === null || $me === null) { return; } $pendingKey = self::pendingKey((int) $citizen->id); $replySides = [ 'from_character_id' => $citizen->id, 'to_character_id' => $me->id, ]; if (! self::acquireThinkLock('character.'.$citizen->id)) { return; } // A human (or a serialised sibling job) already answered. $answered = PersonTalk::conversation($me->id, PersonTalk::KIND_CHARACTER, (int) $citizen->id) ->where('from_character_id', $citizen->id) ->where('id', '>', $talk->id) ->exists(); if ($answered) { cache()->forget($pendingKey); return; } if (! filled(config('ai.openrouter.api_key'))) { self::storeReply($replySides, __('city.chat_error'), true); cache()->forget($pendingKey); return; } // Persona plays the citizen in-world: no registration data // 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 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."; $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); return; } self::storeReply($replySides, $reply); cache()->forget($pendingKey); } catch (\Throwable $e) { Log::error('Citizen talk think failed', ['talk' => $talkId, 'error' => $e->getMessage()]); if ($pendingKey !== null) { cache()->forget($pendingKey); } } } /** * Validate and resolve the peer query parameters (kind + id). * * @return array{0: string, 1: AiAgent|Character} */ private function resolvePeer(Request $request): array { $data = $request->validate([ 'kind' => ['required', 'in:agent,character'], 'peer' => ['required', 'integer', 'min:1'], ]); $peer = $data['kind'] === PersonTalk::KIND_AGENT ? AiAgent::query()->findOrFail($data['peer']) : Character::query()->findOrFail($data['peer']); return [$data['kind'], $peer]; } /** * Whether a live human session currently drives this character. * * Based on the sessions table: a peer with a session touched within * LIVE_PEER_SECONDS answers for itself and is never impersonated. * Everything else (seeded citizens, NPC children, demo accounts) is * answered by the generic persona. */ private static function characterPeerIsLive(Character $peer): bool { if ($peer->user_id === null) { return false; } try { $last = Db::table('sessions') ->where('user_id', $peer->user_id) ->max('last_activity'); } catch (\Throwable) { return false; } return $last !== null && (int) $last >= now()->getTimestamp() - self::LIVE_PEER_SECONDS; } /** * Cache key of a pending auto-reply for a character peer. */ private static function pendingKey(int $characterId): string { return 'talk.pending.c.'.$characterId; } /** * Space out reasoning calls per peer (RULES.md 2.3 in spirit: never * repeat API calls at high frequency for the same counterpart). */ private static function acquireThinkLock(string $scope): bool { $lockKey = 'talk.think.'.$scope; $deadline = microtime(true) + (self::THINK_SPACING_SECONDS * 2); while (! cache()->add($lockKey, 1, self::THINK_SPACING_SECONDS) && microtime(true) < $deadline) { usleep(400000); } return true; } /** * Simulated city snapshot sent upstream — no PII (RULES.md 2.2). * * @return array */ private static function simulatedCityContext(): array { return [ 'stores' => Store::query() ->get(['name', 'type', 'is_open']) ->map(fn ($s): array => ['name' => $s->name, 'type' => $s->type, 'open' => $s->is_open]) ->all(), 'clock' => now()->toTimeString(), ]; } /** * 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(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); $history = PersonTalk::conversation($meCharacterId, $peerKind, $peerId) // Error placeholders are UI notes, never part of the dialogue. // Legacy rows (stored before the flag existed) are matched by // their localized body so old apologies stay excluded too. ->where('is_fallback', false) ->whereNotIn('body', self::fallbackBodies()) ->orderBy('id') ->limit(80) ->get(); $messages = [['role' => 'system', 'content' => $system]]; foreach ($history as $row) { $role = (int) $row->from_character_id === $meCharacterId ? 'user' : 'assistant'; $lastIdx = count($messages) - 1; if ($lastIdx > 0 && $messages[$lastIdx]['role'] === $role) { $messages[$lastIdx]['content'] .= "\n".$row->body; } else { $messages[] = ['role' => $role, 'content' => $row->body]; } } // 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); try { $response = Http::withHeaders([ 'Authorization' => 'Bearer '.config('ai.openrouter.api_key'), 'HTTP-Referer' => config('ai.openrouter.app_url'), 'X-Title' => config('ai.openrouter.app_name'), ])->timeout($timeout) ->post(config('ai.openrouter.base_url').'/chat/completions', [ 'model' => $model, 'messages' => $messages, // Room for models that reason before answering — too low // a budget starves the visible content (e.g. "Ah"). 'max_tokens' => 700, ]); } catch (\Throwable $e) { Log::warning('Person talk upstream exception', ['error' => $e->getMessage()]); return null; } if ($response->failed()) { Log::warning('Person talk upstream failed', ['status' => $response->status()]); return null; } $reply = $response->json('choices.0.message.content'); // Some models return content as an array of text parts. if (is_array($reply)) { $reply = implode('', array_map( static fn ($part): string => is_array($part) ? (string) ($part['text'] ?? '') : (string) $part, $reply )); } if (is_string($reply) && mb_strlen(trim($reply)) < 10) { Log::warning('Person talk suspiciously short reply', [ 'finish_reason' => $response->json('choices.0.finish_reason'), 'raw' => substr((string) json_encode($response->json('choices.0.message'), JSON_UNESCAPED_UNICODE), 0, 800), ]); } return is_string($reply) && trim($reply) !== '' ? $reply : null; } /** * Localized error-fallback bodies (current and past locales). * * @return list */ private static function fallbackBodies(): array { return array_values(array_unique(array_filter([ __('city.chat_error'), __('city.chat_error', [], 'en'), __('city.chat_error', [], 'es'), ]))); } /** * Persist one side of the conversation. * * @param array $sides from/to identifiers * @param bool $isFallback stored error placeholder (kept out of model context) */ private static function storeReply(array $sides, string $body, bool $isFallback = false): PersonTalk { return PersonTalk::create($sides + [ 'body' => $body, 'talked_on' => now()->toDateString(), 'is_fallback' => $isFallback, ]); } /** * Count a failed attempt against the agent (RULES.md 2.4). */ private static function recordAgentFailure(AiAgent $agent): void { $errors = (int) $agent->error_count + 1; $agent->update([ 'status' => $errors >= 5 ? 'error' : 'active', 'error_count' => $errors, ]); } }