feat(port): add sea port with quay, terminal, berths, and AI port master
Adds a new port district on the north-east coast with walkable quay, container yard, terminal, gantry cranes, and berths. Includes AI-driven port master (Capitão do Porto) and dock workers for ship dispatch, plus full frontend rendering of ships, boats, and trucks.
This commit is contained in:
@@ -12,6 +12,7 @@ use App\Models\Store;
|
||||
use App\Support\Airports;
|
||||
use App\Support\Beach;
|
||||
use App\Support\CityPlazas;
|
||||
use App\Support\Port;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -147,6 +148,7 @@ class CityViewerController extends Controller
|
||||
'plazas' => CityPlazas::forCity($city),
|
||||
'beach' => Beach::geometry($city),
|
||||
'airports' => Airports::geometry($city),
|
||||
'port' => Port::geometry($city),
|
||||
'agents' => AiAgent::orderBy('id')->get()->map(fn ($a): array => [
|
||||
'id' => $a->id,
|
||||
'name' => $a->name,
|
||||
@@ -300,36 +302,50 @@ class CityViewerController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the tower for one short clearance phrase; null on failure.
|
||||
* Prompt-echo markers shared by every clearance guard: replies that
|
||||
* restate the instruction instead of speaking are rejected.
|
||||
*
|
||||
* Tries the configured ATC model first (AI_ATC_MODEL), then the
|
||||
* agent's own model and finally the platform default — all
|
||||
* allow-listed — before the caller stores the deterministic
|
||||
* fallback. Clearances stay AI-generated even while a preferred id
|
||||
* is missing from the live OpenRouter catalogue.
|
||||
*
|
||||
* @param array{number: string, airline: string, runway: string} $flight
|
||||
* @var list<string>
|
||||
*/
|
||||
private static function askTower(AiAgent $atc, array $flight): ?string
|
||||
private const PROMPT_META = [
|
||||
'the user wants',
|
||||
'respond only',
|
||||
'no quotes',
|
||||
'atc style',
|
||||
'short sentence',
|
||||
'for example',
|
||||
'instructions',
|
||||
];
|
||||
|
||||
/**
|
||||
* Run a clearance prompt across a model chain (extra models first —
|
||||
* e.g. AI_ATC_MODEL — then the agent model, the platform default and
|
||||
* AI_FALLBACK_MODEL), returning the first reply accepted by $accept.
|
||||
*
|
||||
* Without an API key nothing is attempted (no try consumed).
|
||||
*
|
||||
* @param callable(string): bool|null $accept per-model shape guard
|
||||
* @param list<string|null>|null $extraModels models tried first
|
||||
*/
|
||||
private static function askWithChain(AiAgent $agent, string $prompt, ?callable $accept = null, ?array $extraModels = null): ?string
|
||||
{
|
||||
// Without an API key there is nothing to ask (no try consumed).
|
||||
if (! filled(config('ai.openrouter.api_key'))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$allowed = config('ai.allowed_models', []);
|
||||
$candidates = [];
|
||||
foreach ([config('ai.atc_model'), $atc->model, config('ai.default_model'), config('ai.fallback_model')] as $model) {
|
||||
$chain = array_merge($extraModels ?? [], [
|
||||
$agent->model,
|
||||
config('ai.default_model'),
|
||||
config('ai.fallback_model'),
|
||||
]);
|
||||
foreach ($chain as $model) {
|
||||
if (is_string($model) && $model !== '' && in_array($model, $allowed, true)) {
|
||||
$candidates[$model] = $model;
|
||||
}
|
||||
}
|
||||
|
||||
$prompt = 'Você é a torre de controle aéreo do Aeroporto City10. Autorize em UMA frase '
|
||||
.'curta, em português, estilo controle de tráfego aéreo, a decolagem do voo '
|
||||
.$flight['number'].' ('.$flight['airline'].') na pista '.$flight['runway']
|
||||
.'. Responda apenas com a frase de autorização, sem aspas nem explicações.';
|
||||
|
||||
$timeout = min((int) config('ai.openrouter.timeout', 30), 15);
|
||||
|
||||
foreach ($candidates as $model) {
|
||||
@@ -345,13 +361,13 @@ class CityViewerController extends Controller
|
||||
'max_tokens' => 120,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('ATC tower model exception', ['model' => $model, 'error' => $e->getMessage()]);
|
||||
Log::warning('Clearance model exception', ['model' => $model, 'error' => $e->getMessage()]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::warning('ATC tower model failed', ['model' => $model, 'status' => $response->status()]);
|
||||
Log::warning('Clearance model failed', ['model' => $model, 'status' => $response->status()]);
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -366,19 +382,64 @@ class CityViewerController extends Controller
|
||||
|
||||
if (is_string($text) && trim($text) !== '') {
|
||||
$text = mb_substr(trim($text), 0, 240);
|
||||
if (self::looksLikeClearance($text, $flight)) {
|
||||
if ($accept === null || $accept($text)) {
|
||||
return $text;
|
||||
}
|
||||
// Model echoed the instruction instead of the clearance:
|
||||
// reject and continue down the chain (next model or the
|
||||
// deterministic fallback in the caller).
|
||||
Log::warning('ATC clearance rejected (not a clearance)', ['model' => $model]);
|
||||
// Echoed the instruction instead of the phrase: reject and
|
||||
// continue down the chain (next model, then the caller's
|
||||
// deterministic fallback).
|
||||
Log::warning('Clearance rejected (not a clearance)', ['model' => $model]);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One ATC takeoff clearance (chain tries AI_ATC_MODEL first).
|
||||
*
|
||||
* @param array{number: string, airline: string, runway: string} $flight
|
||||
*/
|
||||
private static function askTower(AiAgent $atc, array $flight): ?string
|
||||
{
|
||||
$prompt = 'Você é a torre de controle aéreo do Aeroporto City10. Autorize em UMA frase '
|
||||
.'curta, em português, estilo controle de tráfego aéreo, a decolagem do voo '
|
||||
.$flight['number'].' ('.$flight['airline'].') na pista '.$flight['runway']
|
||||
.'. Responda apenas com a frase de autorização, sem aspas nem explicações.';
|
||||
|
||||
return self::askWithChain(
|
||||
$atc,
|
||||
$prompt,
|
||||
static fn (string $text): bool => self::looksLikeClearance($text, $flight),
|
||||
[config('ai.atc_model')]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One port-movement clearance from the Capitania (maritime phrasing).
|
||||
*
|
||||
* @param array{ship: string, company: string, action: string, action_text: string, berth: int} $maneuver
|
||||
*/
|
||||
private static function askPort(AiAgent $captain, array $maneuver): ?string
|
||||
{
|
||||
$prompt = 'Você é a Capitania do Porto City10. Autorize em UMA frase curta, em português, '
|
||||
.'estilo marítimo de controle portuário, a '.$maneuver['action_text'].' do navio '
|
||||
.$maneuver['ship'].' ('.$maneuver['company'].') no berço '.$maneuver['berth']
|
||||
.'. Responda apenas com a frase de autorização, sem aspas nem explicações.';
|
||||
|
||||
return self::askWithChain($captain, $prompt, static function (string $text) use ($maneuver): bool {
|
||||
$lower = mb_strtolower($text);
|
||||
foreach (self::PROMPT_META as $needle) {
|
||||
if (str_contains($lower, $needle)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return str_contains($text, $maneuver['ship'])
|
||||
|| preg_match('/autoriz|atrac|cais|berço|berco|manobr|liberad|deixar|partida/iu', $text) === 1;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic guard: a clearance is short, speech-like and about the
|
||||
* requested flight — never a restatement of the prompt itself.
|
||||
@@ -389,16 +450,7 @@ class CityViewerController extends Controller
|
||||
{
|
||||
$lower = mb_strtolower($text);
|
||||
|
||||
$promptMeta = [
|
||||
'the user wants',
|
||||
'respond only',
|
||||
'no quotes',
|
||||
'atc style',
|
||||
'short sentence',
|
||||
'for example',
|
||||
'instructions',
|
||||
];
|
||||
foreach ($promptMeta as $needle) {
|
||||
foreach (self::PROMPT_META as $needle) {
|
||||
if (str_contains($lower, $needle)) {
|
||||
return false;
|
||||
}
|
||||
@@ -408,6 +460,150 @@ class CityViewerController extends Controller
|
||||
|| preg_match('/autoriz|pista|runway|cleared|descolagem|decolag/iu', $text) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Port summary: geometry, Capitania agent and latest maneuver
|
||||
* clearance (generated by the port AI agent on dispatch).
|
||||
*/
|
||||
public function port(Request $request): JsonResponse
|
||||
{
|
||||
$city = $this->resolveCity($request);
|
||||
$captain = AiAgent::query()->where('type', 'port_master')->first();
|
||||
|
||||
return response()->json([
|
||||
'geometry' => Port::geometry($city),
|
||||
'captain' => $captain !== null ? [
|
||||
'id' => (int) $captain->id,
|
||||
'name' => $captain->name,
|
||||
'model' => $captain->model,
|
||||
'status' => $captain->status,
|
||||
'error_count' => (int) $captain->error_count,
|
||||
] : null,
|
||||
'clearance' => cache()->get('port.clearance'),
|
||||
])->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the Capitania for the next port-movement clearance (fire and
|
||||
* forget; one generation per 30 seconds internally).
|
||||
*/
|
||||
public function dispatchPort(): JsonResponse
|
||||
{
|
||||
if (! cache()->add('port.dispatch.lock', 1, 30)) {
|
||||
return response()->json(['accepted' => false, 'reason' => 'cooldown'], 202);
|
||||
}
|
||||
|
||||
$captain = AiAgent::query()->where('type', 'port_master')->first();
|
||||
if ($captain === null) {
|
||||
return response()->json(['message' => __('city.port_missing')], 422);
|
||||
}
|
||||
|
||||
dispatch(function (): void {
|
||||
self::requestPortClearance();
|
||||
})->afterResponse();
|
||||
|
||||
return response()->json(['accepted' => true], 202);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate one port-movement clearance with the Capitania model.
|
||||
*
|
||||
* Runs after the response (no queue worker). The structured fields
|
||||
* (action/ship/berth) are stored with or without AI, so the client
|
||||
* can couple real ship maneuvers to the authorisation; text uses
|
||||
* simulated vessel data only — no user PII (RULES.md 2.2). Failures
|
||||
* are counted for backoff (RULES.md 2.4) and logged to the agent's
|
||||
* decision history (RULES.md 2.5).
|
||||
*/
|
||||
public static function requestPortClearance(): void
|
||||
{
|
||||
try {
|
||||
$captain = AiAgent::query()->where('type', 'port_master')->first();
|
||||
if ($captain === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$maneuver = self::portManeuver();
|
||||
|
||||
$mayTry = false;
|
||||
if (filled(config('ai.openrouter.api_key'))) {
|
||||
$lastTry = (int) cache()->get('port.last_try', 0);
|
||||
$mayTry = (int) $captain->error_count < 5 || $lastTry < time() - 300;
|
||||
if ($mayTry) {
|
||||
cache()->put('port.last_try', time(), now()->addHour());
|
||||
}
|
||||
}
|
||||
|
||||
$text = $mayTry ? self::askPort($captain, $maneuver) : null;
|
||||
|
||||
if ($text === null) {
|
||||
$attempted = $mayTry || ! filled(config('ai.openrouter.api_key'));
|
||||
$errors = (int) $captain->error_count + ($attempted ? 1 : 0);
|
||||
$captain->update([
|
||||
'status' => $errors >= 5 ? 'error' : 'active',
|
||||
'error_count' => $errors,
|
||||
]);
|
||||
$text = sprintf(
|
||||
'Capitania: %s (%s), %s no berço %s, autorizado.',
|
||||
$maneuver['ship'],
|
||||
$maneuver['company'],
|
||||
$maneuver['action_text'],
|
||||
$maneuver['berth']
|
||||
);
|
||||
} else {
|
||||
$captain->update([
|
||||
'status' => 'active',
|
||||
'error_count' => 0,
|
||||
'last_think_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
cache()->put('port.clearance', [
|
||||
'text' => $text,
|
||||
'action' => $maneuver['action'],
|
||||
'action_text' => $maneuver['action_text'],
|
||||
'ship' => $maneuver['ship'],
|
||||
'company' => $maneuver['company'],
|
||||
'berth' => $maneuver['berth'],
|
||||
'source' => $captain->error_count > 0 ? 'fallback' : 'model',
|
||||
'at' => now()->toIso8601String(),
|
||||
], now()->addHours(6));
|
||||
|
||||
$log = $captain->last_decision ?? [];
|
||||
$log[] = ['role' => 'port', 'content' => $text, 'at' => now()->toIso8601String()];
|
||||
$captain->update(['last_decision' => array_slice($log, -20)]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Port clearance generation failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Random simulated maneuver for the next clearance (no real traffic).
|
||||
*
|
||||
* @return array{ship: string, company: string, action: string, action_text: string, berth: int}
|
||||
*/
|
||||
private static function portManeuver(): array
|
||||
{
|
||||
$ships = [
|
||||
['CITY VOYAGER', 'City10 Cargo'],
|
||||
['AZUL MAR', 'AzulCéu Cargo'],
|
||||
['ATLÂNTICO CELESTE', 'NorteLine'],
|
||||
];
|
||||
[$ship, $company] = $ships[array_rand($ships)];
|
||||
$actions = [
|
||||
['berth', 'atracação'],
|
||||
['depart', 'partida'],
|
||||
];
|
||||
[$action, $actionText] = $actions[array_rand($actions)];
|
||||
|
||||
return [
|
||||
'ship' => $ship,
|
||||
'company' => $company,
|
||||
'action' => $action,
|
||||
'action_text' => $actionText,
|
||||
'berth' => random_int(1, 2),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the city from the query string or the active default.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user