- Add two airports (north/south) with full geometry: runways, taxiways,
aprons, terminals, hangars, stands, and helipads; walkable airport
grounds extend beyond city bounds.
- Introduce ATC AI agent ("Torre de Controle City10") using AI_ATC_MODEL
to generate takeoff clearances; includes dispatch endpoint with
30-second cooldown and deterministic fallback on failure.
- Implement AI model fallback chain: preferred model → AI_FALLBACK_MODEL
before failing, applied in ChatController and PersonTalkController.
- Add luminous sign labels (letreiros) for plazas, beach, food, hospital,
school, city hall, and airports with day/night glow.
- Fix city presence cache: compute is_me per viewer instead of baking
into shared per-second cache to prevent cross-user flag leakage.
- Add tests: AirportTest, CityPresenceTest, SignLabelsTest, and
PersonTalkTest fallback model retry scenario.
621 lines
23 KiB
PHP
621 lines
23 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);
|
|
|
|
// Preferred model first, then the configured fallback chain
|
|
// (AI_FALLBACK_MODEL) before giving up to the caller.
|
|
$models = [$model];
|
|
$fallback = config('ai.fallback_model');
|
|
if (is_string($fallback) && $fallback !== ''
|
|
&& $fallback !== $model
|
|
&& in_array($fallback, config('ai.allowed_models', []), true)) {
|
|
$models[] = $fallback;
|
|
}
|
|
|
|
foreach ($models as $candidate) {
|
|
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' => $candidate,
|
|
'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', ['model' => $candidate, 'error' => $e->getMessage()]);
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($response->failed()) {
|
|
Log::warning('Person talk upstream failed', ['model' => $candidate, 'status' => $response->status()]);
|
|
|
|
continue;
|
|
}
|
|
|
|
$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', [
|
|
'model' => $candidate,
|
|
'finish_reason' => $response->json('choices.0.finish_reason'),
|
|
'raw' => substr((string) json_encode($response->json('choices.0.message'), JSON_UNESCAPED_UNICODE), 0, 800),
|
|
]);
|
|
}
|
|
|
|
if (is_string($reply) && trim($reply) !== '') {
|
|
return $reply;
|
|
}
|
|
}
|
|
|
|
return 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,
|
|
]);
|
|
}
|
|
}
|