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.
604 lines
22 KiB
PHP
604 lines
22 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Web;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\AiAgent;
|
|
use App\Models\Character;
|
|
use App\Models\PersonTalk;
|
|
use App\Models\Store;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Db;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* Daily street conversations between the local character and another
|
|
* character (human peer) or an agentic NPC.
|
|
*
|
|
* GET /talk/person — conversation history (all days) used both by the
|
|
* chat UI and as the persistent context that keeps the thread of previous
|
|
* conversations. POST /talk/person — registers one message per day; for
|
|
* agent peers it also triggers the OpenRouter reply asynchronously.
|
|
*
|
|
* Peers without a live human session (seeded citizens, NPCs, idle demo
|
|
* accounts) answer through a generic city-inhabitant persona so chats
|
|
* never dead-end; a peer with a recent session is relayed as-is and is
|
|
* never impersonated.
|
|
*
|
|
* Only simulated city context and the dialogue itself are sent upstream,
|
|
* never registration data (RULES.md 2.2). Failures are retried with
|
|
* backoff bookkeeping (RULES.md 2.4) and decisions are persisted for
|
|
* audit (RULES.md 2.5).
|
|
*/
|
|
class PersonTalkController extends Controller
|
|
{
|
|
/**
|
|
* Maximum messages returned for the UI and model context.
|
|
*
|
|
* @var int
|
|
*/
|
|
private const HISTORY_LIMIT = 300;
|
|
|
|
/**
|
|
* Per-peer spacing between two reasoning calls, in seconds.
|
|
*
|
|
* Conversational replies are user-driven (one call per message), and
|
|
* this damper keeps rapid bursts from hammering the upstream API.
|
|
*
|
|
* @var int
|
|
*/
|
|
private const THINK_SPACING_SECONDS = 3;
|
|
|
|
/**
|
|
* How recent a peer session must be to count as a live human.
|
|
*
|
|
* @var int
|
|
*/
|
|
private const LIVE_PEER_SECONDS = 300;
|
|
|
|
/**
|
|
* Conversation history (newest last) plus the peer summary.
|
|
*/
|
|
public function index(Request $request): JsonResponse
|
|
{
|
|
$me = Character::ensureForUser($request->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<string, mixed>
|
|
*/
|
|
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<string>
|
|
*/
|
|
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<string, int|null> $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,
|
|
]);
|
|
}
|
|
}
|