Files
City10/app/Http/Controllers/Web/ChatController.php
T
marcocosta10000 e7498e1566 feat(city): add airports, ATC agent, luminous signs, and AI model fallback chain
- 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.
2026-10-31 22:06:18 -03:00

234 lines
8.1 KiB
PHP
Executable File

<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\AiAgent;
use App\Models\Store;
use Illuminate\Http\Request;
use Illuminate\Http\StreamedResponse;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
/**
* NPC chat endpoints consumed by the 3D city viewer.
*
* The POST endpoint accepts a user message, enqueues the OpenRouter call
* asynchronously and immediately returns 202 so the UI can show a
* "thinking" state. The GET endpoint streams agent replies as SSE events.
* Only simulated city context (never personal data) is sent upstream,
* as required by RULES.md.
*/
class ChatController extends Controller
{
/**
* Send a message to an agent and trigger an asynchronous reply.
*/
public function send(Request $request, AiAgent $agent): Response
{
$data = $request->validate([
'message' => ['required', 'string', 'max:2000'],
]);
// Persist the conversation in the agent's last_decision for audit
// and stream the pending state to any connected SSE listener.
$history = $agent->last_decision ?? [];
$history[] = ['role' => 'user', 'content' => $data['message'], 'at' => now()->toIso8601String()];
$agent->update([
'last_decision' => array_slice($history, -20),
'status' => 'thinking',
]);
self::publish($agent, ['type' => 'thinking', 'agent_id' => $agent->id, 'status' => 'thinking']);
// Fire the OpenRouter call without blocking the HTTP response.
dispatch(function () use ($agent, $data): void {
self::think($agent, $data['message']);
});
return response()->json([
'status' => 'accepted',
'agent_id' => $agent->id,
'message' => __('city.chat_accepted'),
], 202);
}
/**
* Stream agent status and replies as Server-Sent Events.
*/
public function stream(Request $request, AiAgent $agent): StreamedResponse
{
$request->session()->save();
return response()->stream(function () use ($agent): void {
echo "retry: 3000\n\n";
flush();
$last = null;
while (true) {
if (connection_aborted()) {
break;
}
// Poll the cache for a message published by think().
$payload = cache()->pull("chat.{$agent->id}");
if ($payload !== null) {
$json = json_encode($payload, JSON_UNESCAPED_UNICODE);
echo "event: message\n";
echo "data: {$json}\n\n";
flush();
$last = $payload;
}
// Heartbeat keeps proxies from closing the stream.
echo ": ping\n\n";
flush();
sleep(2);
}
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'X-Accel-Buffering' => 'no',
]);
}
/**
* Call OpenRouter with the agent persona and simulated city context.
*
* Never includes personal data — only city state (RULES.md 2.2).
*/
private static function think(AiAgent $agent, string $userMessage): void
{
try {
$apiKey = config('ai.openrouter.api_key');
if (! filled($apiKey)) {
self::fail($agent, 'OPENROUTER_API_KEY not configured');
return;
}
// Simulated city context only — no PII (RULES.md 2.2).
$cityContext = [
'stores' => Store::query()
->get(['name', 'type', 'is_open'])
->map(fn ($s) => ['name' => $s->name, 'type' => $s->type, 'open' => $s->is_open])
->all(),
'agent_name' => $agent->name,
'agent_type' => $agent->type,
'current_goal' => $agent->current_goal,
];
$system = ($agent->system_prompt ?: "Você é {$agent->name}, um habitante da cidade virtual City10.")
."\n\nContexto simulado da cidade: ".json_encode($cityContext, JSON_UNESCAPED_UNICODE)
."\nResponda de forma curta e natural, em português. Não invente dados pessoais de usuários.";
// Preferred agent model first, then the configured fallback
// chain (AI_FALLBACK_MODEL) before failing the turn.
$models = [$agent->model];
$fallback = config('ai.fallback_model');
if (is_string($fallback) && $fallback !== ''
&& $fallback !== $agent->model
&& in_array($fallback, config('ai.allowed_models', []), true)) {
$models[] = $fallback;
}
$response = null;
foreach ($models as $candidate) {
try {
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$apiKey,
'HTTP-Referer' => config('ai.openrouter.app_url'),
'X-Title' => config('ai.openrouter.app_name'),
])->timeout((int) config('ai.openrouter.timeout', 30))
->post(config('ai.openrouter.base_url').'/chat/completions', [
'model' => $candidate,
'messages' => [
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $userMessage],
],
'max_tokens' => 300,
]);
} catch (\Throwable $e) {
Log::warning('NPC think model exception', ['model' => $candidate, 'error' => $e->getMessage()]);
$response = null;
continue;
}
if ($response->failed()) {
Log::warning('NPC think model failed', ['model' => $candidate, 'status' => $response->status()]);
$response = null;
continue;
}
break;
}
if ($response === null) {
self::fail($agent, 'all models failed');
return;
}
$reply = $response->json('choices.0.message.content') ?? __('city.chat_error');
$history = $agent->last_decision ?? [];
$history[] = ['role' => 'assistant', 'content' => $reply, 'at' => now()->toIso8601String()];
$agent->update([
'last_decision' => array_slice($history, -20),
'status' => 'active',
'error_count' => 0,
'last_think_at' => now(),
]);
self::publish($agent, [
'type' => 'reply',
'agent_id' => $agent->id,
'name' => $agent->name,
'reply' => $reply,
'status' => 'active',
]);
} catch (\Throwable $e) {
Log::error('NPC think failed', ['agent' => $agent->id, 'error' => $e->getMessage()]);
self::fail($agent, $e->getMessage());
}
}
/**
* Record a failure with exponential backoff (RULES.md 2.4).
*/
private static function fail(AiAgent $agent, string $reason): void
{
$errors = (int) $agent->error_count + 1;
$backoff = min(300, 2 ** min($errors, 8));
$agent->update([
'status' => $errors >= 5 ? 'error' : 'active',
'error_count' => $errors,
]);
self::publish($agent, [
'type' => 'error',
'agent_id' => $agent->id,
'reply' => __('city.chat_error'),
'status' => $agent->status,
'retry_after' => $backoff,
]);
}
/**
* Publish a message for the SSE stream to pick up.
*/
private static function publish(AiAgent $agent, array $payload): void
{
cache()->put("chat.{$agent->id}", $payload, now()->addMinutes(5));
}
}