Commit 354d0675 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(captain): الكابتن — a read-only AI assistant for managers, on every screen

A floating assistant for the super admin and senior managers (captain.use:
super_admin, board_member, general_manager, membership_director,
sports_director). It answers in Egyptian Arabic: step-by-step guides with the
exact on-screen labels and a link to the page, numbers from the live data with
tables and mermaid charts, and explanations of the page the user has open. It
never talks about code, and it cannot change anything.

The model runs on the DevPilot box under a restricted client token — Haiku at
low effort, only Read/Grep/Glob, confined to its own checkout (clubphp-captain),
enforced by DevPilot from the token. It never touches the database: it asks for
SELECTs in a [[QUERY]] block and QueryGuard runs them on a separate read-only
connection (READ ONLY transaction, one statement, 5 s cap), refusing other
schemas, credential tables and columns, and HR/accounting/treasury data the
user has no permission for; contact data is masked and every query is logged.
Verified against the live database before shipping, including a write with the
text filter bypassed, which MySQL itself refused (1792).

KnowledgeSync keeps .captain/schema.txt (live schema with the values actually
stored) and .captain/menu.txt (every sidebar path) current in that checkout.
Config sits in captain_settings, not system_config, so the token never appears
on the Settings screen; the widget only shows once a token is configured.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent a86c9429
...@@ -7,3 +7,6 @@ ...@@ -7,3 +7,6 @@
/storage/cache/* /storage/cache/*
.DS_Store .DS_Store
/storage/sessions/* /storage/sessions/*
# The Captain's reference files, written on the DevPilot box by KnowledgeSync
.captain/
{
"detector": {
"ignoreRules": [],
"ignoreFiles": [],
"ignoreValues": [
{
"rule": "gradient-text",
"value": "*",
"files": [
"public/assets/css/captain.css"
],
"createdAt": "2026-09-12T11:55:08.758Z",
"reason": "Claude: .cpt-phrase is the moving shimmer on the transient 'thinking' status line of the loading state Mahmoud asked for (animated, pleasing loading, 2026-09-12); not static decorative text; solid ink under prefers-reduced-motion"
}
]
}
}
...@@ -108,6 +108,28 @@ Connection params from `config/database.php` which reads from `.env`: `DB_HOST`, ...@@ -108,6 +108,28 @@ Connection params from `config/database.php` which reads from `.env`: `DB_HOST`,
--- ---
## الكابتن — the in-app AI assistant
`app/Modules/Captain` is the floating chat assistant for managers (permission
`captain.use`). It runs on the DevPilot box under a **restricted client token**:
Haiku at low effort, read-only tools only, confined to its own checkout
`clubphp-captain`. DevPilot enforces that from the token — nothing in the prompt
or the ERP can widen it. It learns screens from `app/Modules/Tutorials` and each
module's Views, so keeping the Arabic labels there accurate keeps it accurate.
- The model never touches the database. It proposes SELECTs in a `[[QUERY]]`
block; `QueryGuard` runs them on a separate read-only connection (READ ONLY
transaction, single statement, 5 s cap), refuses other schemas, credential
tables/columns and HR/accounting/treasury data the user has no permission for,
masks contact data, and logs every query in `captain_query_log`.
- `KnowledgeSync` writes `.captain/schema.txt` (live schema + stored status
values) and `.captain/menu.txt` (every sidebar path) into that checkout.
- Config is in `captain_settings`, not `system_config` — the Settings screen lists
every `system_config` row and the token must never show. `CAPTAIN_DEVPILOT_TOKEN`
/ `_URL` / `_PROJECT` env vars override the table.
- The persona and protocol are in `Services/Persona.php`; `AnswerParser` depends on
the `[[ANSWER]]` / `[[QUERY]]` / `[[RESULTS]]` markers.
## Architecture maps ## Architecture maps
`docs/architecture-maps/` holds notes on the modules that have been mapped so far `docs/architecture-maps/` holds notes on the modules that have been mapped so far
......
<?php
declare(strict_types=1);
namespace App\Modules\Captain\Controllers;
use App\Core\Controller;
use App\Core\Logger;
use App\Core\Request;
use App\Core\Response;
use App\Modules\Captain\Services\CaptainException;
use App\Modules\Captain\Services\CaptainService;
/**
* الكابتن — every endpoint answers JSON, errors included: the chat widget is the
* only caller, and an HTML error page would reach it as a broken reply.
*/
final class CaptainController extends Controller
{
public function conversations(Request $request): Response
{
return $this->respond(fn (): array => ['conversations' => CaptainService::conversations($this->employeeId())]);
}
public function conversation(Request $request, int $id): Response
{
return $this->respond(fn (): array => CaptainService::conversationView($id, $this->employeeId()));
}
public function ask(Request $request): Response
{
return $this->respond(function () use ($request): array {
$conversation = $request->post('conversation_id');
return (new CaptainService())->ask(
$this->currentEmployee(),
(string) $request->post('message', ''),
is_numeric($conversation) ? (int) $conversation : null,
(string) $request->post('page_path', ''),
(string) $request->post('page_title', '')
);
});
}
public function poll(Request $request, int $id): Response
{
return $this->respond(fn (): array => (new CaptainService())->poll($id, $this->currentEmployee()));
}
public function stop(Request $request, int $id): Response
{
return $this->respond(fn (): array => (new CaptainService())->stop($id, $this->currentEmployee()));
}
public function deleteConversation(Request $request, int $id): Response
{
return $this->respond(fn (): array => (new CaptainService())->archive($id, $this->currentEmployee()));
}
private function employeeId(): int
{
return (int) ($this->currentEmployee()->id ?? 0);
}
private function respond(callable $action): Response
{
try {
return $this->json($action());
} catch (CaptainException $e) {
$status = $e->getCode();
return $this->json(['error' => true, 'message' => $e->getMessage()], $status >= 400 && $status < 600 ? $status : 400);
} catch (\Throwable $e) {
Logger::error('Captain: ' . $e->getMessage(), ['file' => $e->getFile(), 'line' => $e->getLine()]);
return $this->json(['error' => true, 'message' => 'معلش، حصلت مشكلة. جرّب تاني بعد شوية.'], 500);
}
}
}
<?php
declare(strict_types=1);
return [
['GET', '/captain/conversations', 'Captain\Controllers\CaptainController@conversations', ['auth'], 'captain.use'],
['GET', '/captain/conversations/{id:\d+}', 'Captain\Controllers\CaptainController@conversation', ['auth'], 'captain.use'],
['POST', '/captain/conversations/{id:\d+}/delete', 'Captain\Controllers\CaptainController@deleteConversation', ['auth', 'csrf'], 'captain.use'],
['POST', '/captain/ask', 'Captain\Controllers\CaptainController@ask', ['auth', 'csrf'], 'captain.use'],
['GET', '/captain/messages/{id:\d+}', 'Captain\Controllers\CaptainController@poll', ['auth'], 'captain.use'],
['POST', '/captain/messages/{id:\d+}/stop', 'Captain\Controllers\CaptainController@stop', ['auth', 'csrf'], 'captain.use'],
];
<?php
declare(strict_types=1);
namespace App\Modules\Captain\Services;
/**
* Reads a run's rendered output and pulls out what the user may see.
*
* The output interleaves the model's text with DevPilot's progress lines (the
* files it read, the searches it ran). None of that may reach the user — the
* Captain must never look like it is reading code — so the model is told to
* wrap its reply in [[ANSWER]] … [[/ANSWER]] or ask for data with
* [[QUERY]] … [[/QUERY]], and only the inside of the last block is used.
* sanitize() then strips anything technical that slipped into the answer.
*/
final class AnswerParser
{
private const ANSWER_OPEN = '[[ANSWER]]';
private const ANSWER_CLOSE = '[[/ANSWER]]';
private const QUERY_OPEN = '[[QUERY]]';
private const QUERY_CLOSE = '[[/QUERY]]';
/** DevPilot's own lines: session banner, tool calls, tool results, thinking, the finish line. */
private const NOISE = '/^(?:▶ |⚙ | → | ✗ |✗ |■ |· |\[output truncated)/u';
/**
* @return array{kind:string, answer:?string, queries:?array, activity:string}
* kind: answer (closed block) | partial (still streaming) | query | pending (query still being written) | none
* activity: what the run is doing, for the waiting animation — guide | data | thinking | answering
*/
public static function parse(string $output): array
{
$a = strrpos($output, self::ANSWER_OPEN);
$q = strrpos($output, self::QUERY_OPEN);
if ($q !== false && ($a === false || $q > $a)) {
$body = substr($output, $q + strlen(self::QUERY_OPEN));
$end = strpos($body, self::QUERY_CLOSE);
$queries = $end === false ? null : self::queries(substr($body, 0, $end));
return ['kind' => $queries ? 'query' : 'pending', 'answer' => null, 'queries' => $queries, 'activity' => 'data'];
}
if ($a !== false) {
$body = substr($output, $a + strlen(self::ANSWER_OPEN));
$end = strpos($body, self::ANSWER_CLOSE);
return [
'kind' => $end === false ? 'partial' : 'answer',
'answer' => self::withoutNoise($end === false ? $body : substr($body, 0, $end)),
'queries' => null,
'activity' => 'answering',
];
}
return ['kind' => 'none', 'answer' => null, 'queries' => null, 'activity' => self::activity($output)];
}
/** When a run ended without the markers: the prose after its last tool call. */
public static function fallback(string $output): string
{
$collected = [];
foreach (preg_split('/\R/u', $output) ?: [] as $line) {
if (str_starts_with($line, '■ ')) {
break;
}
if (str_starts_with($line, '⚙ ') || str_starts_with($line, ' → ') || str_starts_with($line, ' ✗ ')) {
$collected = [];
continue;
}
if (!preg_match(self::NOISE, $line)) {
$collected[] = $line;
}
}
return trim(implode("\n", $collected));
}
/**
* Last line of defence: whatever the model was told, nothing technical
* reaches the screen — no code, file paths, secrets or table names.
*/
public static function sanitize(string $md): string
{
$md = str_replace([self::ANSWER_OPEN, self::ANSWER_CLOSE, self::QUERY_OPEN, self::QUERY_CLOSE], '', $md);
// Fenced blocks: charts stay, anything else (code, SQL, logs) goes.
$md = (string) preg_replace_callback('/```([\w-]*)[^\n]*\n(.*?)```/su', function (array $m): string {
return strtolower($m[1]) === 'mermaid' ? $m[0] : '';
}, $md);
// An unclosed fence that is not a chart (a reply cut mid-block).
$md = (string) preg_replace('/```(?!mermaid)[\w-]*\n[^`]*$/su', '', $md);
// Server paths, source paths and file names.
$md = (string) preg_replace('~(?:/config|/var/www|/opt|/etc|/proc|/home|/root|/usr|/tmp)(?:/[\w.\-]+)+~u', '', $md);
$md = (string) preg_replace('~\b(?:app|database|public|config|docker|storage|vendor|cron|tools|resources|\.captain)/[\w./\-]+~u', '', $md);
$md = (string) preg_replace('~\b[\w\-]+\.(?:php|js|json|sql|env|sh|ya?ml|lock|ini|conf|md|txt|log)\b~iu', '', $md);
// Anything shaped like a credential.
$md = (string) preg_replace('~\b(?:sk-ant-|dpc_|ghp_|glpat-|Bearer\s+)[\w\-.]+~u', '', $md);
$md = (string) preg_replace('~\b[A-Za-z0-9_\-]{40,}\b~', '', $md);
$md = (string) preg_replace('~^.*\b[A-Z][A-Z0-9_]{2,}=\S+.*$~mu', '', $md);
// Inline code that is really an identifier or SQL: `members.status`, `payment_date`, `SELECT …`.
$md = (string) preg_replace_callback('/`([^`\n]+)`/u', function (array $m): string {
$code = $m[1];
if (preg_match('/^[A-Za-z0-9]+(?:[_.][A-Za-z0-9]+)+$/', $code)) {
return '';
}
if (preg_match('/\b(?:select|from|where|join|group by|insert|update|delete|function|class)\b/i', $code)) {
return '';
}
return $m[0];
}, $md);
return trim((string) preg_replace("/\n{3,}/", "\n\n", $md));
}
/** @return array<int,array{title:string,sql:string}>|null */
private static function queries(string $json): ?array
{
$json = trim((string) preg_replace('/^```(?:json)?|```$/m', '', trim($json)));
$data = json_decode($json, true);
if (!is_array($data)) {
return null;
}
$items = isset($data['queries']) && is_array($data['queries']) ? $data['queries'] : (array_is_list($data) ? $data : [$data]);
$out = [];
foreach ($items as $item) {
if (is_array($item) && is_string($item['sql'] ?? null) && trim($item['sql']) !== '') {
$out[] = ['title' => (string) ($item['title'] ?? ''), 'sql' => trim($item['sql'])];
}
}
return $out ? array_slice($out, 0, QueryGuard::MAX_QUERIES) : null;
}
private static function withoutNoise(string $text): string
{
$lines = array_filter(preg_split('/\R/u', $text) ?: [], fn (string $l): bool => !preg_match(self::NOISE, $l));
return trim(implode("\n", $lines));
}
private static function activity(string $output): string
{
$tail = substr($output, -4000);
$last = strrpos($tail, '⚙ ');
if ($last === false) {
return 'thinking';
}
$line = strtok(substr($tail, $last), "\n") ?: '';
if (str_contains($line, 'schema.txt')) {
return 'data';
}
if (str_contains($line, 'Tutorials') || str_contains($line, 'menu.txt') || str_contains($line, '/Views')) {
return 'guide';
}
return 'thinking';
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Captain\Services;
/**
* A failure the user should read. The message is shown as-is in the chat
* window (Egyptian Arabic, no technical detail); the code is the HTTP status.
*/
final class CaptainException extends \RuntimeException
{
}
This diff is collapsed.
<?php
declare(strict_types=1);
namespace App\Modules\Captain\Services;
/**
* The Captain's configuration.
*
* Kept in captain_settings rather than system_config: the Settings screen lists
* every system_config row, and the DevPilot token must never appear on a
* screen. A CapRover environment variable, when set, wins over the table.
*/
final class CaptainSettings
{
private static ?array $cache = null;
public static function get(string $key, ?string $default = null): ?string
{
if (self::$cache === null) {
self::$cache = [];
try {
foreach (db()->select('SELECT setting_key, setting_value FROM captain_settings') as $row) {
self::$cache[$row['setting_key']] = $row['setting_value'];
}
} catch (\Throwable $e) {
// Not migrated yet: every key falls back to its default.
}
}
$value = self::$cache[$key] ?? null;
return ($value === null || $value === '') ? $default : (string) $value;
}
public static function set(string $key, ?string $value): void
{
db()->query(
'INSERT INTO captain_settings (setting_key, setting_value) VALUES (?, ?)
ON DUPLICATE KEY UPDATE setting_value = ?',
[$key, $value, $value]
);
if (self::$cache !== null) {
self::$cache[$key] = $value;
}
}
/** DevPilot is reached inside the CapRover network, not through its public URL. */
public static function devpilotUrl(): string
{
return rtrim(self::fromEnv('CAPTAIN_DEVPILOT_URL') ?? (string) self::get('devpilot_url', 'http://srv-captain--vscode:3001'), '/');
}
/** A DevPilot *client* token (read-only, Haiku-only) — never the operator's. */
public static function devpilotToken(): ?string
{
return self::fromEnv('CAPTAIN_DEVPILOT_TOKEN') ?? self::get('devpilot_token');
}
/** The Captain's own checkout on the DevPilot box, separate from the dev one. */
public static function devpilotProject(): string
{
return self::fromEnv('CAPTAIN_DEVPILOT_PROJECT') ?? (string) self::get('devpilot_project', 'clubphp-captain');
}
private static function fromEnv(string $key): ?string
{
$value = getenv($key);
return is_string($value) && trim($value) !== '' ? trim($value) : null;
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Captain\Services;
use App\Core\Logger;
/**
* The Captain's line to DevPilot, which runs the model on the box.
*
* It holds a DevPilot client token, and DevPilot decides what that token may do
* — not this class and not the prompt: its runs are Haiku at low effort with
* only the read-only tools, confined to the Captain's checkout, and the token
* can reach nothing but its own chats. Nothing sent from here can widen that.
*/
final class DevPilotClient
{
public function __construct(
private string $base,
private string $token,
private string $project,
) {
}
public static function fromSettings(): self
{
$token = CaptainSettings::devpilotToken();
if ($token === null) {
throw new CaptainException('الكابتن لسه ما اتفعّلش. كلّم إدارة النظام.', 503);
}
return new self(CaptainSettings::devpilotUrl(), $token, CaptainSettings::devpilotProject());
}
public function createChat(string $title, string $systemPrompt): string
{
$r = $this->request('POST', '/api/chats', [
'project' => $this->project,
'title' => $title,
'systemPrompt' => $systemPrompt,
]);
if (empty($r['id'])) {
throw new CaptainException('الكابتن مش متاح دلوقتي. جرّب كمان شوية.', 502);
}
return (string) $r['id'];
}
/** Sends one turn and returns the id of the run that answers it. */
public function send(string $chatId, string $text): string
{
$r = $this->request('POST', '/api/chats/' . rawurlencode($chatId) . '/messages', ['text' => $text]);
if (empty($r['taskId'])) {
throw new CaptainException('الكابتن مش متاح دلوقتي. جرّب كمان شوية.', 502);
}
return (string) $r['taskId'];
}
/** A run's status and its rendered output so far. */
public function task(string $taskId): array
{
return $this->request('GET', '/api/tasks/' . rawurlencode($taskId));
}
public function stop(string $chatId): void
{
$this->request('POST', '/api/chats/' . rawurlencode($chatId) . '/stop', []);
}
public function deleteChat(string $chatId): void
{
$this->request('DELETE', '/api/chats/' . rawurlencode($chatId));
}
/** Reference files the runs read. DevPilot only lets this token write under .captain/. */
public function writeFile(string $path, string $content): void
{
$this->request('POST', '/api/files/write', [
'project' => $this->project,
'path' => $path,
'content' => $content,
], 60);
}
/** Fast-forwards the Captain's checkout to the latest pushed code. */
public function pull(): void
{
$this->request('POST', '/api/git/pull', ['repo' => $this->project], 60);
}
private function request(string $method, string $path, ?array $body = null, int $timeout = 20): array
{
$headers = ['Authorization: Bearer ' . $this->token, 'Accept: application/json'];
$options = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CUSTOMREQUEST => $method,
];
if ($body !== null) {
$options[CURLOPT_POSTFIELDS] = json_encode($body, JSON_UNESCAPED_UNICODE);
$headers[] = 'Content-Type: application/json';
}
$options[CURLOPT_HTTPHEADER] = $headers;
$ch = curl_init($this->base . $path);
curl_setopt_array($ch, $options);
$raw = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$error = curl_error($ch);
unset($ch);
if ($raw === false) {
Logger::warning('Captain: DevPilot unreachable', ['path' => $path, 'error' => $error]);
throw new CaptainException('الكابتن مش متاح دلوقتي. جرّب كمان شوية.', 503);
}
if ($status === 429) {
throw new CaptainException('الكابتن اتسأل كتير الساعة دي. استنى شوية وجرّب تاني.', 429);
}
$data = json_decode((string) $raw, true);
if ($status >= 400 || !is_array($data)) {
Logger::warning('Captain: DevPilot error', ['path' => $path, 'status' => $status, 'body' => mb_substr((string) $raw, 0, 300)]);
throw new CaptainException('الكابتن مش متاح دلوقتي. جرّب كمان شوية.', $status === 404 ? 404 : 502);
}
return $data;
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Captain\Services;
use App\Core\Logger;
use App\Core\Registries\MenuRegistry;
/**
* Keeps the Captain's checkout on the DevPilot box current.
*
* Its runs read the code (to learn screens and rules) and two reference files
* this class writes under .captain/:
* schema.txt — the LIVE schema, one line per table, with the values actually
* stored in status/type columns. Migrations are not the truth
* here; the database is.
* menu.txt — every sidebar path and its link, straight from MenuRegistry.
* Credential tables and columns are left out, so the model never even sees
* their names.
*/
final class KnowledgeSync
{
private const SCHEMA_EVERY = 6 * 3600;
private const PULL_EVERY = 30 * 60;
/** Text columns worth listing the stored values of: the ones a WHERE filters on. */
private const CATEGORY_COLUMN = '/(?:^|_)(?:status|type|method|category|kind|state|gender|level|source|channel|mode|stage|result|reason|role|class|grade|shift|frequency|period|unit|tier)$/i';
/** Called when a conversation starts. Never throws: a slightly stale file beats no answer. */
public static function ensureFresh(DevPilotClient $dp): void
{
$now = time();
try {
if ($now - (int) CaptainSettings::get('checkout_pulled_at', '0') > self::PULL_EVERY) {
$dp->pull();
CaptainSettings::set('checkout_pulled_at', (string) $now);
}
} catch (\Throwable $e) {
Logger::warning('Captain: checkout pull failed', ['error' => $e->getMessage()]);
}
try {
$menu = self::menu();
$schemaDue = $now - (int) CaptainSettings::get('schema_synced_at', '0') > self::SCHEMA_EVERY;
// The menu is rewritten with every schema refresh too, so a checkout
// that was re-cloned (and lost .captain/) heals within hours.
if ($schemaDue || md5($menu) !== CaptainSettings::get('menu_hash')) {
$dp->writeFile('.captain/menu.txt', $menu);
CaptainSettings::set('menu_hash', md5($menu));
}
if ($schemaDue) {
$dp->writeFile('.captain/schema.txt', self::schema());
CaptainSettings::set('schema_synced_at', (string) $now);
}
} catch (\Throwable $e) {
Logger::warning('Captain: reference files not refreshed', ['error' => $e->getMessage()]);
}
}
public static function schema(): string
{
$db = db();
$tables = $db->select(
"SELECT TABLE_NAME AS t, TABLE_ROWS AS r, TABLE_COMMENT AS c FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME"
);
$columns = [];
foreach ($db->select(
'SELECT TABLE_NAME AS t, COLUMN_NAME AS n, COLUMN_TYPE AS ty, COLUMN_COMMENT AS c FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME, ORDINAL_POSITION'
) as $row) {
$columns[$row['t']][] = $row;
}
$links = [];
foreach ($db->select(
'SELECT TABLE_NAME AS t, COLUMN_NAME AS n, REFERENCED_TABLE_NAME AS rt FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE() AND REFERENCED_TABLE_NAME IS NOT NULL'
) as $row) {
$links[$row['t']][$row['n']] = $row['rt'];
}
$lines = [];
foreach ($tables as $table) {
$name = (string) $table['t'];
if (QueryGuard::isBlockedTable($name)) {
continue;
}
$rows = (int) $table['r'];
$parts = [];
foreach ($columns[$name] ?? [] as $col) {
$column = (string) $col['n'];
if (preg_match(QueryGuard::SECRET_COLUMN, $column)) {
continue;
}
$rawType = strtolower((string) $col['ty']);
$part = $column . ' ' . self::shortType($rawType);
if (isset($links[$name][$column])) {
$part .= '→' . $links[$name][$column];
}
if (preg_match('/^(?:var)?char/', $rawType) && $rows <= 200000 && preg_match(self::CATEGORY_COLUMN, $column)) {
$values = self::storedValues($name, $column);
if ($values) {
$part .= ' {' . implode('|', $values) . '}';
}
}
$comment = trim((string) preg_replace('/\s+/u', ' ', (string) $col['c']));
if ($comment !== '') {
$part .= ' [' . mb_substr($comment, 0, 120) . ']';
}
$parts[] = $part;
}
$comment = trim((string) $table['c']);
$lines[] = $name . ' (~' . $rows . ' rows' . ($comment !== '' ? ', ' . mb_substr($comment, 0, 120) : '') . '): ' . implode(', ', $parts);
}
return "# Live database schema, one line per table: name (~rows): column type→linked_table {stored values} [comment]\n"
. '# Generated ' . date('Y-m-d H:i') . ' Cairo time — ' . count($lines) . " tables\n"
. implode("\n", $lines) . "\n";
}
public static function menu(): string
{
$items = MenuRegistry::getAll();
$top = array_filter($items, fn (array $i): bool => empty($i['parent']));
foreach ($items as $item) {
$parent = (string) ($item['parent'] ?? '');
if ($parent !== '' && isset($top[$parent])) {
$top[$parent]['children'][] = $item;
}
}
$lines = [];
$walk = function (array $nodes, array $trail) use (&$walk, &$lines): void {
usort($nodes, fn (array $a, array $b): int => ($a['order'] ?? 999) <=> ($b['order'] ?? 999));
foreach ($nodes as $node) {
$label = trim((string) ($node['label_ar'] ?? ''));
if ($label === '') {
continue;
}
$path = [...$trail, $label];
$route = trim((string) ($node['route'] ?? ''));
if ($route !== '' && str_starts_with($route, '/')) {
$lines[] = implode(' › ', $path) . ' → ' . $route;
}
if (!empty($node['children']) && is_array($node['children'])) {
$walk($node['children'], $path);
}
}
};
$walk(array_values($top), []);
return "# Every screen in the sidebar: section › page → link\n" . implode("\n", array_values(array_unique($lines))) . "\n";
}
/** @return array<int,string> the values stored in a status-like column, when there are few enough to list */
private static function storedValues(string $table, string $column): array
{
try {
$t = str_replace('`', '``', $table);
$c = str_replace('`', '``', $column);
$rows = db()->select("SELECT DISTINCT `{$c}` AS v FROM `{$t}` WHERE `{$c}` IS NOT NULL AND `{$c}` <> '' LIMIT 16");
} catch (\Throwable $e) {
return [];
}
if (count($rows) > 15) {
return [];
}
return array_map(fn (array $r): string => mb_substr(str_replace(['|', '{', '}'], ' ', (string) $r['v']), 0, 40), $rows);
}
private static function shortType(string $type): string
{
if (str_starts_with($type, 'enum(') || str_starts_with($type, 'set(')) {
return $type;
}
if ($type === 'tinyint(1)') {
return 'bool';
}
if (str_contains($type, 'int')) {
return 'int';
}
if (preg_match('/decimal|float|double/', $type)) {
return 'num';
}
if (preg_match('/char|text/', $type)) {
return 'text';
}
if (str_starts_with($type, 'datetime') || str_starts_with($type, 'timestamp')) {
return 'datetime';
}
return (string) preg_replace('/\(.*/', '', $type);
}
}
This diff is collapsed.
This diff is collapsed.
<?php
/**
* الكابتن — الأيقونة العائمة ونافذة المحادثة.
* Layout.main بيضيفها لمن معاه captain.use بس. الشكل في captain.css والمنطق في captain.js.
*/
$__cptEmployee = \App\Core\App::getInstance()->currentEmployee();
$__cptFirst = trim(explode(' ', trim((string) ($__cptEmployee->full_name_ar ?? '')))[0] ?? '');
$__cptAssets = dirname(__DIR__, 4) . '/public/assets';
$__cptOrb = '<span class="cpt-orb__core"></span><span class="cpt-orb__ring"></span>'
. '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
. '<path d="M9.94 15.5A2 2 0 0 0 8.5 14.06l-6.14-1.58a.5.5 0 0 1 0-.96L8.5 9.94A2 2 0 0 0 9.94 8.5l1.58-6.14a.5.5 0 0 1 .96 0l1.58 6.14a2 2 0 0 0 1.44 1.44l6.14 1.58a.5.5 0 0 1 0 .96L15.5 14.06a2 2 0 0 0-1.44 1.44l-1.58 6.14a.5.5 0 0 1-.96 0z"/>'
. '<path d="M20 3v4M22 5h-4M4 17v2M5 18H3"/></svg>';
?>
<link rel="stylesheet" href="<?= url('assets/css/captain.css') ?>?v=<?= @filemtime($__cptAssets . '/css/captain.css') ?: time() ?>">
<div id="captain" class="cpt" data-first-name="<?= e($__cptFirst) ?>">
<button type="button" class="cpt-fab" id="cpt-fab" aria-expanded="false" aria-controls="cpt-panel">
<span class="cpt-orb cpt-orb--fab" aria-hidden="true"><?= $__cptOrb ?></span>
<span class="cpt-fab__label">اسأل الكابتن</span>
<span class="cpt-fab__news" aria-hidden="true"></span>
</button>
<section class="cpt-panel" id="cpt-panel" role="dialog" aria-modal="false" aria-labelledby="cpt-title" hidden>
<header class="cpt-head">
<span class="cpt-orb cpt-orb--head" aria-hidden="true"><?= $__cptOrb ?></span>
<div class="cpt-head__text">
<h2 id="cpt-title">الكابتن</h2>
<p class="cpt-head__sub" id="cpt-sub">مساعدك الذكي في النظام</p>
</div>
<div class="cpt-head__actions">
<button type="button" class="cpt-icon" data-cpt="history" title="المحادثات السابقة" aria-label="المحادثات السابقة">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
</button>
<button type="button" class="cpt-icon" data-cpt="new" title="محادثة جديدة" aria-label="محادثة جديدة">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
</button>
<button type="button" class="cpt-icon cpt-icon--wide" data-cpt="expand" title="تكبير النافذة" aria-label="تكبير النافذة" aria-pressed="false">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg>
</button>
<button type="button" class="cpt-icon" data-cpt="close" title="إغلاق" aria-label="إغلاق">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
</header>
<div class="cpt-history" id="cpt-history" hidden>
<div class="cpt-history__head">
<strong>المحادثات السابقة</strong>
<button type="button" class="cpt-icon" data-cpt="history-close" aria-label="رجوع للمحادثة">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<ul class="cpt-history__list" id="cpt-history-list"></ul>
</div>
<div class="cpt-body" id="cpt-body">
<div class="cpt-welcome" id="cpt-welcome">
<span class="cpt-orb cpt-orb--hero" aria-hidden="true"><?= $__cptOrb ?></span>
<h3><?= $__cptFirst !== '' ? 'أهلاً يا ' . e($__cptFirst) : 'أهلاً بيك' ?> 👋 أنا الكابتن</h3>
<p>اسألني عن أي حاجة في النظام: إزاي تعمل حاجة خطوة بخطوة، أو أرقام وإحصائيات — وأرسمهالك كمان.</p>
<div class="cpt-chips">
<button type="button" class="cpt-chip" data-q="إزاي أضيف عضو جديد؟">إزاي أضيف عضو جديد؟</button>
<button type="button" class="cpt-chip" data-q="كام عضو جديد اتسجل الشهر ده؟">كام عضو جديد اتسجل الشهر ده؟</button>
<button type="button" class="cpt-chip" data-q="إيه إجمالي التحصيل النهارده حسب طريقة الدفع؟ وارسمهولي">تحصيل النهارده حسب طريقة الدفع</button>
<button type="button" class="cpt-chip" data-q="اشرحلي الصفحة اللي أنا فاتحها دلوقتي">اشرحلي الصفحة دي</button>
</div>
</div>
<ol class="cpt-thread" id="cpt-thread" aria-live="polite"></ol>
</div>
<form class="cpt-compose" id="cpt-compose" autocomplete="off">
<label for="cpt-input" class="cpt-sr">اكتب سؤالك للكابتن</label>
<textarea id="cpt-input" rows="1" maxlength="2000" placeholder="اسأل الكابتن عن أي حاجة…"></textarea>
<button type="submit" class="cpt-send" id="cpt-send" aria-label="إرسال">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3.7 3.3 21 12 3.7 20.7 6.4 12z"/><path d="M6.4 12H13"/></svg>
</button>
<button type="button" class="cpt-stop" id="cpt-stop" aria-label="إيقاف الإجابة" hidden>
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2"/></svg>
</button>
</form>
<p class="cpt-foot">الكابتن بيتفرّج ويشرح بس — مش بيعدّل أي حاجة في النظام.</p>
</section>
</div>
<script src="<?= url('assets/js/captain.js') ?>?v=<?= @filemtime($__cptAssets . '/js/captain.js') ?: time() ?>" defer></script>
<?php
declare(strict_types=1);
use App\Core\Registries\PermissionRegistry;
// الكابتن — المساعد الذكي جوه النظام.
// مالوش بند في القائمة الجانبية: بيظهر كأيقونة عائمة في كل الشاشات، لمن معاه
// الصلاحية دي بس (السوبر أدمن والمديرين الكبار — Phase_122_001).
PermissionRegistry::register('captain', [
'captain.use' => ['ar' => 'استخدام الكابتن (المساعد الذكي)', 'en' => 'Use the Captain AI assistant'],
]);
...@@ -201,6 +201,10 @@ window.addEventListener('load', function() { ...@@ -201,6 +201,10 @@ window.addEventListener('load', function() {
}); });
</script> </script>
<?= $__template->yield('scripts', '') ?> <?= $__template->yield('scripts', '') ?>
<?php /* الكابتن: للي معاه الصلاحية، ولما يكون متوصّل بس — مسح التوكن بيخفيه من كل الشاشات */ ?>
<?php if (can('captain.use') && \App\Modules\Captain\Services\CaptainSettings::devpilotToken() !== null): ?>
<?php $__template->include('Captain.Views.widget'); ?>
<?php endif; ?>
<?php if (str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/tutorials/')): ?> <?php if (str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/tutorials/')): ?>
<script src="/assets/js/tutorial-screenshots.js"></script> <script src="/assets/js/tutorial-screenshots.js"></script>
<?php endif; ?> <?php endif; ?>
......
<?php
declare(strict_types=1);
// الكابتن — المساعد الذكي: المحادثات، الرسايل، سجل كل استعلام اتنفّذ، والإعدادات.
//
// captain_settings جدول لوحده ومش جوه system_config: شاشة الإعدادات بتعرض
// system_config كله، وتوكن DevPilot لازم ما يظهرش على أي شاشة. والكابتن نفسه
// ممنوع يقرا أي جدول بيبدأ بـ captain_ (QueryGuard).
//
// الصلاحية captain.use بتتدّى للسوبر أدمن والمديرين الكبار بس، وأي حد تاني
// يتدّاها من شاشة الأدوار لو الإدارة عايزة.
return [
'up' => "
CREATE TABLE IF NOT EXISTS captain_conversations (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
employee_id BIGINT UNSIGNED NOT NULL,
title VARCHAR(200) NOT NULL DEFAULT 'محادثة جديدة',
devpilot_chat_id VARCHAR(40) NULL,
is_archived TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_captain_conv_employee (employee_id, is_archived, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS captain_messages (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
conversation_id BIGINT UNSIGNED NOT NULL,
role ENUM('user','assistant') NOT NULL,
content MEDIUMTEXT NULL,
state ENUM('thinking','querying','done','failed','stopped') NOT NULL DEFAULT 'done',
devpilot_task_id VARCHAR(40) NULL,
query_rounds TINYINT UNSIGNED NOT NULL DEFAULT 0,
page_path VARCHAR(255) NULL,
started_at DATETIME NULL,
finished_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_captain_msg_conv (conversation_id, id),
CONSTRAINT fk_captain_msg_conv FOREIGN KEY (conversation_id) REFERENCES captain_conversations(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS captain_query_log (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
message_id BIGINT UNSIGNED NULL,
employee_id BIGINT UNSIGNED NOT NULL,
title VARCHAR(255) NULL,
sql_text TEXT NOT NULL,
status ENUM('ok','rejected','error') NOT NULL,
row_count INT UNSIGNED NULL,
duration_ms INT UNSIGNED NULL,
error_text VARCHAR(1000) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_captain_ql_message (message_id),
KEY idx_captain_ql_employee (employee_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS captain_settings (
setting_key VARCHAR(100) NOT NULL PRIMARY KEY,
setting_value TEXT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO captain_settings (setting_key, setting_value) VALUES
('devpilot_url', 'http://srv-captain--vscode:3001'),
('devpilot_project', 'clubphp-captain'),
('devpilot_token', NULL);
INSERT INTO role_permissions (role_id, permission_key, granted_at)
SELECT r.id, 'captain.use', NOW()
FROM roles r
WHERE r.role_code IN ('super_admin', 'board_member', 'general_manager', 'membership_director', 'sports_director')
AND NOT EXISTS (
SELECT 1 FROM role_permissions rp WHERE rp.role_id = r.id AND rp.permission_key = 'captain.use'
)
",
'down' => "
DELETE FROM role_permissions WHERE permission_key = 'captain.use';
DROP TABLE IF EXISTS captain_query_log;
DROP TABLE IF EXISTS captain_messages;
DROP TABLE IF EXISTS captain_conversations;
DROP TABLE IF EXISTS captain_settings
",
];
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment