From b8d31e3dc4d5de664d67db1f0898fc8739c96650 Mon Sep 17 00:00:00 2001 From: Nyan Lin Paing <117423022+LinPaing21@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:52:21 +0700 Subject: [PATCH] Add bnfexpress signed admin client and AI Agent Filament UI - Modules\Shared\Bnfexpress\BnfexpressAdminClient: HMAC-signed HTTP client for bnfexpress's admin API (EV FAQs, agent instructions, chat history), with a bnfexpress:smoke-test command and full unit coverage. - New ai-agent module: Filament pages to manage EV FAQs, publish/roll back agent instruction versions, and browse EV chat history + transcripts. - New manage_ai_agent permission (super_admin/admin). - Recorded .ai/rules for the client's auth scheme and non-Resource Filament page/table testing gotchas. --- .ai/rules/bnfexpress.md | 15 ++ .ai/rules/index.md | 8 + .ai/rules/pages.md | 13 ++ .env.example | 5 + app-modules/ai-agent/composer.json | 24 +++ .../pages/manage-agent-instructions.blade.php | 13 ++ .../filament/pages/manage-faqs.blade.php | 3 + .../pages/partials/transcript-error.blade.php | 1 + .../pages/partials/transcript.blade.php | 34 +++ .../pages/view-ev-chat-history.blade.php | 3 + app-modules/ai-agent/src/AiAgentPlugin.php | 29 +++ .../Concerns/HandlesBnfexpressErrors.php | 28 +++ .../Pages/ManageAgentInstructions.php | 145 +++++++++++++ .../src/Filament/Pages/ManageFaqs.php | 177 +++++++++++++++ .../src/Filament/Pages/ViewEvChatHistory.php | 109 ++++++++++ .../src/Providers/AiAgentServiceProvider.php | 12 ++ .../Feature/ManageAgentInstructionsTest.php | 97 +++++++++ .../ai-agent/tests/Feature/ManageFaqsTest.php | 83 +++++++ .../tests/Feature/ViewEvChatHistoryTest.php | 88 ++++++++ .../database/seeders/RolePermissionSeeder.php | 3 + .../src/Bnfexpress/BnfexpressAdminClient.php | 204 ++++++++++++++++++ .../Exceptions/BnfexpressApiException.php | 18 ++ .../Support/BnfexpressSignature.php | 32 +++ .../Commands/BnfexpressSmokeTestCommand.php | 42 ++++ .../tests/Unit/BnfexpressAdminClientTest.php | 87 ++++++++ .../tests/Unit/BnfexpressSignatureTest.php | 46 ++++ app/Providers/Filament/AdminPanelProvider.php | 3 + composer.json | 1 + composer.lock | 34 ++- config/services.php | 6 + 30 files changed, 1362 insertions(+), 1 deletion(-) create mode 100644 .ai/rules/bnfexpress.md create mode 100644 .ai/rules/index.md create mode 100644 .ai/rules/pages.md create mode 100644 app-modules/ai-agent/composer.json create mode 100644 app-modules/ai-agent/resources/views/filament/pages/manage-agent-instructions.blade.php create mode 100644 app-modules/ai-agent/resources/views/filament/pages/manage-faqs.blade.php create mode 100644 app-modules/ai-agent/resources/views/filament/pages/partials/transcript-error.blade.php create mode 100644 app-modules/ai-agent/resources/views/filament/pages/partials/transcript.blade.php create mode 100644 app-modules/ai-agent/resources/views/filament/pages/view-ev-chat-history.blade.php create mode 100644 app-modules/ai-agent/src/AiAgentPlugin.php create mode 100644 app-modules/ai-agent/src/Filament/Concerns/HandlesBnfexpressErrors.php create mode 100644 app-modules/ai-agent/src/Filament/Pages/ManageAgentInstructions.php create mode 100644 app-modules/ai-agent/src/Filament/Pages/ManageFaqs.php create mode 100644 app-modules/ai-agent/src/Filament/Pages/ViewEvChatHistory.php create mode 100644 app-modules/ai-agent/src/Providers/AiAgentServiceProvider.php create mode 100644 app-modules/ai-agent/tests/Feature/ManageAgentInstructionsTest.php create mode 100644 app-modules/ai-agent/tests/Feature/ManageFaqsTest.php create mode 100644 app-modules/ai-agent/tests/Feature/ViewEvChatHistoryTest.php create mode 100644 app-modules/shared/src/Bnfexpress/BnfexpressAdminClient.php create mode 100644 app-modules/shared/src/Bnfexpress/Exceptions/BnfexpressApiException.php create mode 100644 app-modules/shared/src/Bnfexpress/Support/BnfexpressSignature.php create mode 100644 app-modules/shared/src/Console/Commands/BnfexpressSmokeTestCommand.php create mode 100644 app-modules/shared/tests/Unit/BnfexpressAdminClientTest.php create mode 100644 app-modules/shared/tests/Unit/BnfexpressSignatureTest.php diff --git a/.ai/rules/bnfexpress.md b/.ai/rules/bnfexpress.md new file mode 100644 index 0000000..294eb0a --- /dev/null +++ b/.ai/rules/bnfexpress.md @@ -0,0 +1,15 @@ +--- +paths: + - 'app-modules/shared/src/Bnfexpress/**' +--- + +# Bnfexpress + +## bnfexpress admin API calls go through BnfexpressAdminClient +Signed backend-to-backend calls to bnfexpress's admin API (EV FAQs, agent instructions, chat history) go through `Modules\Shared\Bnfexpress\BnfexpressAdminClient` — do not call `Http::` directly against BNFEXPRESS_AI_API_URL elsewhere. + +Auth is HMAC, not JWT/session: X-Client-Id/X-Timestamp/X-Signature per `BnfexpressSignature::headers()`, signed over `METHOD\nPATH\nTIMESTAMP\nRAW_BODY` (path only, no query string; empty string body for GET/DELETE). Timestamps must be generated fresh per request (server rejects >300s skew) — never cache/reuse a signed header set. + +Config lives in `config('services.bnfexpress')` (BNFEXPRESS_AI_API_URL/CLIENT_ID/CLIENT_SECRET in .env). The client_secret must match bnfexpress's own ADMIN_SERVICE_CLIENTS entry for ev_admin — get it from whoever manages that deploy. + +Non-2xx responses throw `BnfexpressApiException` carrying the gateway's `{"detail": "..."}` message. Verify signing end-to-end with `php artisan bnfexpress:smoke-test` before wiring up any UI. diff --git a/.ai/rules/index.md b/.ai/rules/index.md new file mode 100644 index 0000000..5067855 --- /dev/null +++ b/.ai/rules/index.md @@ -0,0 +1,8 @@ +# Project Rules Index + +Before planning or editing, find the row whose globs match the file's path and read that rule file. + +| Applies to | Rule file | +| --- | --- | +| app-modules/shared/src/Bnfexpress/** | .ai/rules/bnfexpress.md | +| app-modules/*/src/Filament/Pages/** | .ai/rules/pages.md | diff --git a/.ai/rules/pages.md b/.ai/rules/pages.md new file mode 100644 index 0000000..c6d36aa --- /dev/null +++ b/.ai/rules/pages.md @@ -0,0 +1,13 @@ +--- +paths: + - 'app-modules/*/src/Filament/Pages/**' +--- + +# Pages + +## Non-Resource Filament pages need an explicit table-rendering view + deferLoading-aware tests +A `Filament\Pages\Page implements HasTable` (not a Resource) does NOT render its table automatically — it must set `protected string $view = '::filament.pages.';` pointing at a Blade file containing `{{ $this->table }}` (see `ManageFaqs`/`ViewEvChatHistory`/`BookingsRevenueReport`). Omitting this silently renders an empty page — no error, just a blank `fi-page-content`. + +Filament v4 tables default to deferred loading. In Pest/Livewire tests, call `->loadTable()` before any `assertSee()`/`assertCanSeeTableRecords()` on a freshly-mounted component, or the table body won't be in the rendered HTML yet. + +For custom-data (`->records()`-backed, non-Eloquent) tables: use `->callTableAction($name, $record, data: [...])` / `->mountTableAction(...)` (not the generic `->callAction()`, which targets page-level actions and misses table header/record actions), and use `->assertMountedActionModalSee(...)` to check `->modalContent()` output — modal content is lazily rendered and won't appear in a plain `->html()`/`->assertSee()` snapshot even after mounting the action. See `[[project_internachi_modular]]`-style module layout in `app-modules/ai-agent`. diff --git a/.env.example b/.env.example index 7fc683c..00e5c87 100644 --- a/.env.example +++ b/.env.example @@ -99,3 +99,8 @@ VITE_APP_NAME="${APP_NAME}" FASTAPI_AGENT_JWT_SECRET= FASTAPI_AGENT_JWT_ALGORITHM=HS256 + +# Must match the ev_admin entry in bnfexpress's own ADMIN_SERVICE_CLIENTS. +BNFEXPRESS_AI_API_URL=http://bnfexpress-app:8000 +BNFEXPRESS_AI_CLIENT_ID=ev_admin +BNFEXPRESS_AI_CLIENT_SECRET= diff --git a/app-modules/ai-agent/composer.json b/app-modules/ai-agent/composer.json new file mode 100644 index 0000000..bac8466 --- /dev/null +++ b/app-modules/ai-agent/composer.json @@ -0,0 +1,24 @@ +{ + "name": "modules/ai-agent", + "description": "", + "type": "library", + "version": "1.0", + "license": "proprietary", + "require": {}, + "autoload": { + "psr-4": { + "Modules\\AiAgent\\": "src/", + "Modules\\AiAgent\\Tests\\": "tests/", + "Modules\\AiAgent\\Database\\Factories\\": "database/factories/", + "Modules\\AiAgent\\Database\\Seeders\\": "database/seeders/" + } + }, + "minimum-stability": "stable", + "extra": { + "laravel": { + "providers": [ + "Modules\\AiAgent\\Providers\\AiAgentServiceProvider" + ] + } + } +} diff --git a/app-modules/ai-agent/resources/views/filament/pages/manage-agent-instructions.blade.php b/app-modules/ai-agent/resources/views/filament/pages/manage-agent-instructions.blade.php new file mode 100644 index 0000000..ddbb0c0 --- /dev/null +++ b/app-modules/ai-agent/resources/views/filament/pages/manage-agent-instructions.blade.php @@ -0,0 +1,13 @@ + + + @if ($active) +
{{ $active['content'] ?? '' }}
+ @elseif ($activeError) +

Could not load the active instruction: {{ $activeError }}

+ @else +

No active instruction.

+ @endif +
+ + {{ $this->table }} +
diff --git a/app-modules/ai-agent/resources/views/filament/pages/manage-faqs.blade.php b/app-modules/ai-agent/resources/views/filament/pages/manage-faqs.blade.php new file mode 100644 index 0000000..ce096a2 --- /dev/null +++ b/app-modules/ai-agent/resources/views/filament/pages/manage-faqs.blade.php @@ -0,0 +1,3 @@ + + {{ $this->table }} + diff --git a/app-modules/ai-agent/resources/views/filament/pages/partials/transcript-error.blade.php b/app-modules/ai-agent/resources/views/filament/pages/partials/transcript-error.blade.php new file mode 100644 index 0000000..fd91cbf --- /dev/null +++ b/app-modules/ai-agent/resources/views/filament/pages/partials/transcript-error.blade.php @@ -0,0 +1 @@ +

Could not load this transcript: {{ $message }}

diff --git a/app-modules/ai-agent/resources/views/filament/pages/partials/transcript.blade.php b/app-modules/ai-agent/resources/views/filament/pages/partials/transcript.blade.php new file mode 100644 index 0000000..8d71127 --- /dev/null +++ b/app-modules/ai-agent/resources/views/filament/pages/partials/transcript.blade.php @@ -0,0 +1,34 @@ +@php + $labels = [ + 'user' => 'User', + 'bnfexpress_ev_agent' => 'Assistant', + ]; +@endphp + +
+ @forelse (($transcript['messages'] ?? []) as $message) + @php + $author = $message['author'] ?? 'unknown'; + $isUser = $author === 'user'; + @endphp +
$isUser, + 'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800' => ! $isUser, + ])> +
+ + {{ $labels[$author] ?? $author }} + + @if (isset($message['timestamp'])) + + {{ \Illuminate\Support\Carbon::createFromTimestamp($message['timestamp'])->format('M j, Y g:i A') }} + + @endif +
+

{{ $message['text'] ?? '' }}

+
+ @empty +

No messages in this session.

+ @endforelse +
diff --git a/app-modules/ai-agent/resources/views/filament/pages/view-ev-chat-history.blade.php b/app-modules/ai-agent/resources/views/filament/pages/view-ev-chat-history.blade.php new file mode 100644 index 0000000..ce096a2 --- /dev/null +++ b/app-modules/ai-agent/resources/views/filament/pages/view-ev-chat-history.blade.php @@ -0,0 +1,3 @@ + + {{ $this->table }} + diff --git a/app-modules/ai-agent/src/AiAgentPlugin.php b/app-modules/ai-agent/src/AiAgentPlugin.php new file mode 100644 index 0000000..05dd231 --- /dev/null +++ b/app-modules/ai-agent/src/AiAgentPlugin.php @@ -0,0 +1,29 @@ +discoverPages( + in: __DIR__.'/Filament/Pages', + for: 'Modules\AiAgent\Filament\Pages', + ); + } + + public function boot(Panel $panel): void {} + + public static function make(): static + { + return app(static::class); + } +} diff --git a/app-modules/ai-agent/src/Filament/Concerns/HandlesBnfexpressErrors.php b/app-modules/ai-agent/src/Filament/Concerns/HandlesBnfexpressErrors.php new file mode 100644 index 0000000..b293eba --- /dev/null +++ b/app-modules/ai-agent/src/Filament/Concerns/HandlesBnfexpressErrors.php @@ -0,0 +1,28 @@ +title($successTitle)->success()->send(); + } catch (BnfexpressApiException $exception) { + Notification::make()->title($failureTitle)->body($exception->getMessage())->danger()->send(); + } + } +} diff --git a/app-modules/ai-agent/src/Filament/Pages/ManageAgentInstructions.php b/app-modules/ai-agent/src/Filament/Pages/ManageAgentInstructions.php new file mode 100644 index 0000000..f67a23b --- /dev/null +++ b/app-modules/ai-agent/src/Filament/Pages/ManageAgentInstructions.php @@ -0,0 +1,145 @@ +|null + */ + public ?array $active = null; + + public ?string $activeError = null; + + public static function canAccess(): bool + { + return auth()->user()?->can('manage_ai_agent') ?? false; + } + + public function mount(): void + { + $this->refreshActive(); + } + + public function table(Table $table): Table + { + return $table + ->records(function (int $page, int $recordsPerPage): LengthAwarePaginator { + $result = app(BnfexpressAdminClient::class)->listInstructions( + limit: $recordsPerPage, + offset: ($page - 1) * $recordsPerPage, + ); + + $items = $result['instructions'] ?? (array_is_list($result) ? $result : []); + $total = $result['total'] ?? (($page - 1) * $recordsPerPage) + count($items) + (count($items) === $recordsPerPage ? 1 : 0); + + return new LengthAwarePaginator( + items: collect($items)->mapWithKeys(fn (array $item): array => [$item['id'] => $item]), + total: $total, + perPage: $recordsPerPage, + currentPage: $page, + ); + }) + ->columns([ + TextColumn::make('id'), + IconColumn::make('is_active')->boolean(), + TextColumn::make('content')->limit(80)->wrap(), + TextColumn::make('created_at')->dateTime(), + ]) + ->recordActions([ + Action::make('activate') + ->label('Activate') + ->icon(Heroicon::OutlinedArrowUturnLeft) + ->visible(fn (array $record): bool => ! ($record['is_active'] ?? false)) + ->requiresConfirmation() + ->action(function (array $record): void { + $this->callBnfexpress( + fn () => app(BnfexpressAdminClient::class)->activateInstruction($record['id']), + successTitle: 'Instruction activated', + failureTitle: 'Failed to activate instruction', + ); + + $this->resetTable(); + $this->refreshActive(); + }), + ]) + ->headerActions([ + Action::make('publish') + ->label('Publish New Version') + ->icon(Heroicon::OutlinedPlusCircle) + ->schema([ + Textarea::make('content') + ->required() + ->rows(10), + Toggle::make('activate') + ->label('Activate immediately') + ->default(true) + ->helperText('Deactivates the current active version automatically.'), + ]) + ->action(function (array $data): void { + $this->callBnfexpress( + fn () => app(BnfexpressAdminClient::class)->publishInstruction($data['content'], $data['activate']), + successTitle: 'New instruction version published', + failureTitle: 'Failed to publish instruction', + ); + + $this->resetTable(); + $this->refreshActive(); + }), + ]); + } + + private function refreshActive(): void + { + try { + $this->active = app(BnfexpressAdminClient::class)->getActiveInstruction(); + $this->activeError = null; + } catch (BnfexpressApiException $exception) { + $this->active = null; + $this->activeError = $exception->getMessage(); + + Notification::make() + ->title('Could not load the active instruction') + ->body($exception->getMessage()) + ->danger() + ->send(); + } + } +} diff --git a/app-modules/ai-agent/src/Filament/Pages/ManageFaqs.php b/app-modules/ai-agent/src/Filament/Pages/ManageFaqs.php new file mode 100644 index 0000000..aeb63df --- /dev/null +++ b/app-modules/ai-agent/src/Filament/Pages/ManageFaqs.php @@ -0,0 +1,177 @@ +user()?->can('manage_ai_agent') ?? false; + } + + public function table(Table $table): Table + { + return $table + ->records(function (?string $search, array $filters, int $page, int $recordsPerPage): LengthAwarePaginator { + $result = app(BnfexpressAdminClient::class)->listFaqs( + q: $search, + search: $filters['search_mode']['value'] ?? 'normal', + limit: $recordsPerPage, + offset: ($page - 1) * $recordsPerPage, + ); + + return $this->paginate($result, 'faqs', $page, $recordsPerPage); + }) + ->columns([ + TextColumn::make('id'), + TextColumn::make('content') + ->limit(80) + ->wrap(), + TextColumn::make('metadata') + ->formatStateUsing(fn (mixed $state): string => json_encode($state ?? [], JSON_THROW_ON_ERROR)) + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->searchable() + ->filters([ + SelectFilter::make('search_mode') + ->label('Search mode') + ->options([ + 'normal' => 'Normal (substring)', + 'semantic' => 'Semantic (meaning-based)', + ]) + ->default('normal'), + ]) + ->recordActions([ + $this->editAction(), + $this->deleteAction(), + ]) + ->headerActions([ + $this->createAction(), + ]); + } + + protected function createAction(): Action + { + return Action::make('create') + ->label('New FAQ') + ->icon(Heroicon::OutlinedPlus) + ->schema($this->formSchema()) + ->action(function (array $data): void { + $this->callBnfexpress( + fn () => app(BnfexpressAdminClient::class)->createFaq($data['content'], $data['metadata'] ?? []), + successTitle: 'FAQ created', + failureTitle: 'Failed to create FAQ', + ); + + $this->resetTable(); + }); + } + + protected function editAction(): Action + { + return Action::make('edit') + ->icon(Heroicon::OutlinedPencilSquare) + ->fillForm(fn (array $record): array => $record) + ->schema($this->formSchema()) + ->action(function (array $data, array $record): void { + $this->callBnfexpress( + fn () => app(BnfexpressAdminClient::class)->updateFaq($record['id'], $data['content'], $data['metadata'] ?? []), + successTitle: 'FAQ updated', + failureTitle: 'Failed to update FAQ', + ); + + $this->resetTable(); + }); + } + + protected function deleteAction(): Action + { + return Action::make('delete') + ->color('danger') + ->icon(Heroicon::OutlinedTrash) + ->requiresConfirmation() + ->action(function (array $record): void { + $this->callBnfexpress( + fn () => app(BnfexpressAdminClient::class)->deleteFaq($record['id']), + successTitle: 'FAQ deleted', + failureTitle: 'Failed to delete FAQ', + ); + + $this->resetTable(); + }); + } + + /** + * @return array + */ + protected function formSchema(): array + { + return [ + Textarea::make('content') + ->required() + ->rows(4), + KeyValue::make('metadata'), + ]; + } + + /** + * bnfexpress's list/faqs and list/agent-instructions endpoints return a + * bare JSON array (confirmed live via `php artisan bnfexpress:smoke-test`), + * with no total/limit/offset envelope — so there's no real total to + * report, and this falls back to a "there might be one more page" + * heuristic (also tolerates a {total, <$itemsKey>} envelope, in case + * that ever changes). + * + * @param array $result + */ + private function paginate(array $result, string $itemsKey, int $page, int $recordsPerPage): LengthAwarePaginator + { + $items = $result[$itemsKey] ?? (array_is_list($result) ? $result : []); + + $total = $result['total'] ?? (($page - 1) * $recordsPerPage) + count($items) + (count($items) === $recordsPerPage ? 1 : 0); + + return new LengthAwarePaginator( + items: collect($items)->mapWithKeys(fn (array $item): array => [$item['id'] => $item]), + total: $total, + perPage: $recordsPerPage, + currentPage: $page, + ); + } +} diff --git a/app-modules/ai-agent/src/Filament/Pages/ViewEvChatHistory.php b/app-modules/ai-agent/src/Filament/Pages/ViewEvChatHistory.php new file mode 100644 index 0000000..ec17714 --- /dev/null +++ b/app-modules/ai-agent/src/Filament/Pages/ViewEvChatHistory.php @@ -0,0 +1,109 @@ +user()?->can('manage_ai_agent') ?? false; + } + + public function table(Table $table): Table + { + return $table + ->records(function (int $page, int $recordsPerPage): LengthAwarePaginator { + $result = app(BnfexpressAdminClient::class)->listSessions( + limit: $recordsPerPage, + offset: ($page - 1) * $recordsPerPage, + ); + + $sessions = $result['sessions'] ?? []; + + return new LengthAwarePaginator( + items: collect($sessions)->mapWithKeys( + fn (array $session): array => ["{$session['user_id']}:{$session['session_id']}" => $session] + ), + total: $result['total'] ?? count($sessions), + perPage: $result['limit'] ?? $recordsPerPage, + currentPage: $page, + ); + }) + ->columns([ + TextColumn::make('session_id') + ->limit(20) + ->tooltip(fn (TextColumn $column): ?string => $this->tooltipIfTruncated($column)), + TextColumn::make('user_id') + ->limit(20) + ->tooltip(fn (TextColumn $column): ?string => $this->tooltipIfTruncated($column)), + TextColumn::make('title')->limit(50), + TextColumn::make('last_message')->limit(80)->wrap(), + TextColumn::make('updated_at')->dateTime(), + ]) + ->recordActions([ + Action::make('view') + ->label('View Transcript') + ->icon(Heroicon::OutlinedEye) + ->modalHeading(fn (array $record): string => "Transcript — {$record['session_id']}") + ->modalContent(fn (array $record): View => $this->transcriptView($record)) + ->modalWidth('2xl') + ->modalSubmitAction(false) + ->modalCancelActionLabel('Close'), + ]); + } + + private function tooltipIfTruncated(TextColumn $column): ?string + { + $state = (string) $column->getState(); + + return strlen($state) > $column->getCharacterLimit() ? $state : null; + } + + private function transcriptView(array $record): View + { + try { + $transcript = app(BnfexpressAdminClient::class)->getSessionTranscript($record['user_id'], $record['session_id']); + + return view('ai-agent::filament.pages.partials.transcript', ['transcript' => $transcript]); + } catch (BnfexpressApiException $exception) { + Notification::make() + ->title('Could not load transcript') + ->body($exception->getMessage()) + ->danger() + ->send(); + + return view('ai-agent::filament.pages.partials.transcript-error', ['message' => $exception->getMessage()]); + } + } +} diff --git a/app-modules/ai-agent/src/Providers/AiAgentServiceProvider.php b/app-modules/ai-agent/src/Providers/AiAgentServiceProvider.php new file mode 100644 index 0000000..5f88b24 --- /dev/null +++ b/app-modules/ai-agent/src/Providers/AiAgentServiceProvider.php @@ -0,0 +1,12 @@ + 'https://bnfexpress.test', + 'services.bnfexpress.client_id' => 'ev_admin', + 'services.bnfexpress.client_secret' => 'test-secret', + ]); + + Permission::findOrCreate('manage_ai_agent', 'web'); + + $this->admin = User::factory()->create()->givePermissionTo(['manage_ai_agent']); + $this->actingAs($this->admin); +}); + +test('a user without manage_ai_agent cannot access it', function () { + $this->actingAs(User::factory()->create()); + + expect(ManageAgentInstructions::canAccess())->toBeFalse(); +}); + +test('it renders the active instruction and version history', function () { + Http::fake([ + 'bnfexpress.test/admin/agent-instructions/active*' => Http::response(['id' => 2, 'content' => 'You are the EV assistant.']), + 'bnfexpress.test/admin/agent-instructions*' => Http::response([ + 'total' => 1, + 'instructions' => [['id' => 2, 'is_active' => true, 'content' => 'You are the EV assistant.', 'created_at' => now()->toIso8601String()]], + ]), + ]); + + Livewire::test(ManageAgentInstructions::class) + ->assertOk() + ->assertSee('You are the EV assistant.') + ->loadTable() + ->assertSee('You are the EV assistant.'); +}); + +test('publishing a new version calls publishInstruction and shows a success notification', function () { + Http::fake([ + 'bnfexpress.test/admin/agent-instructions/active*' => Http::response(['id' => 3, 'content' => 'New instructions.']), + 'bnfexpress.test/admin/agent-instructions*' => fn (Request $request) => match ($request->method()) { + 'POST' => Http::response(['id' => 3, 'content' => 'New instructions.', 'is_active' => true], 201), + default => Http::response(['total' => 0, 'instructions' => []]), + }, + ]); + + Livewire::test(ManageAgentInstructions::class) + ->loadTable() + ->callTableAction('publish', data: ['content' => 'New instructions.', 'activate' => true]) + ->assertNotified('New instruction version published'); + + Http::assertSent(fn (Request $request) => $request->method() === 'POST' + && str_contains($request->url(), '/admin/agent-instructions') + && ! str_contains($request->url(), '/active') + && $request['content'] === 'New instructions.' + && $request['activate'] === true); +}); + +test('a failed publish surfaces the gateway detail message', function () { + Http::fake([ + 'bnfexpress.test/admin/agent-instructions/active*' => Http::response(['id' => 1, 'content' => 'Old instructions.']), + 'bnfexpress.test/admin/agent-instructions*' => fn (Request $request) => match ($request->method()) { + 'POST' => Http::response(['detail' => 'Content is required.'], 422), + default => Http::response(['total' => 0, 'instructions' => []]), + }, + ]); + + Livewire::test(ManageAgentInstructions::class) + ->loadTable() + ->callTableAction('publish', data: ['content' => 'New instructions.', 'activate' => true]) + ->assertNotified('Failed to publish instruction'); +}); + +test('activate is hidden on the already-active row and visible on others', function () { + Http::fake([ + 'bnfexpress.test/admin/agent-instructions/active*' => Http::response(['id' => 2, 'content' => 'Current.']), + 'bnfexpress.test/admin/agent-instructions*' => Http::response([ + 'total' => 2, + 'instructions' => [ + ['id' => 2, 'is_active' => true, 'content' => 'Current.', 'created_at' => now()->toIso8601String()], + ['id' => 1, 'is_active' => false, 'content' => 'Older.', 'created_at' => now()->toIso8601String()], + ], + ]), + ]); + + Livewire::test(ManageAgentInstructions::class) + ->loadTable() + ->assertTableActionHidden('activate', 2) + ->assertTableActionVisible('activate', 1); +}); diff --git a/app-modules/ai-agent/tests/Feature/ManageFaqsTest.php b/app-modules/ai-agent/tests/Feature/ManageFaqsTest.php new file mode 100644 index 0000000..d076b7d --- /dev/null +++ b/app-modules/ai-agent/tests/Feature/ManageFaqsTest.php @@ -0,0 +1,83 @@ + 'https://bnfexpress.test', + 'services.bnfexpress.client_id' => 'ev_admin', + 'services.bnfexpress.client_secret' => 'test-secret', + ]); + + Permission::findOrCreate('manage_ai_agent', 'web'); + + $this->admin = User::factory()->create()->givePermissionTo(['manage_ai_agent']); + $this->actingAs($this->admin); +}); + +test('a user without manage_ai_agent cannot access it', function () { + $this->actingAs(User::factory()->create()); + + expect(ManageFaqs::canAccess())->toBeFalse(); +}); + +test('it renders and lists faqs from the client', function () { + Http::fake(['bnfexpress.test/*' => Http::response([ + 'total' => 1, + 'faqs' => [ + ['id' => 1, 'content' => 'How do I charge my EV?', 'metadata' => []], + ], + ])]); + + Livewire::test(ManageFaqs::class) + ->assertOk() + ->loadTable() + ->assertSee('How do I charge my EV?'); + + Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/faqs') && $request->method() === 'GET'); +}); + +test('creating a faq calls createFaq and shows a success notification', function () { + Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) { + 'POST' => Http::response(['id' => 1, 'content' => 'New FAQ', 'metadata' => []], 201), + default => Http::response(['total' => 0, 'faqs' => []]), + }]); + + Livewire::test(ManageFaqs::class) + ->loadTable() + ->callTableAction('create', data: ['content' => 'New FAQ', 'metadata' => []]) + ->assertNotified('FAQ created'); + + Http::assertSent(fn (Request $request) => $request->method() === 'POST' && $request['content'] === 'New FAQ'); +}); + +test('a failed create surfaces the gateway detail message', function () { + Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) { + 'POST' => Http::response(['detail' => 'Content is required.'], 422), + default => Http::response(['total' => 0, 'faqs' => []]), + }]); + + Livewire::test(ManageFaqs::class) + ->loadTable() + ->callTableAction('create', data: ['content' => 'New FAQ', 'metadata' => []]) + ->assertNotified('Failed to create FAQ'); +}); + +test('deleting a faq calls deleteFaq and shows a success notification', function () { + Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) { + 'DELETE' => Http::response(['deleted' => true]), + default => Http::response(['total' => 1, 'faqs' => [['id' => 1, 'content' => 'To delete', 'metadata' => []]]]), + }]); + + Livewire::test(ManageFaqs::class) + ->loadTable() + ->callTableAction('delete', 1) + ->assertNotified('FAQ deleted'); + + Http::assertSent(fn (Request $request) => $request->method() === 'DELETE' && str_contains($request->url(), '/admin/faqs/1')); +}); diff --git a/app-modules/ai-agent/tests/Feature/ViewEvChatHistoryTest.php b/app-modules/ai-agent/tests/Feature/ViewEvChatHistoryTest.php new file mode 100644 index 0000000..91dda7d --- /dev/null +++ b/app-modules/ai-agent/tests/Feature/ViewEvChatHistoryTest.php @@ -0,0 +1,88 @@ + 'https://bnfexpress.test', + 'services.bnfexpress.client_id' => 'ev_admin', + 'services.bnfexpress.client_secret' => 'test-secret', + ]); + + Permission::findOrCreate('manage_ai_agent', 'web'); + + $this->admin = User::factory()->create()->givePermissionTo(['manage_ai_agent']); + $this->actingAs($this->admin); +}); + +test('a user without manage_ai_agent cannot access it', function () { + $this->actingAs(User::factory()->create()); + + expect(ViewEvChatHistory::canAccess())->toBeFalse(); +}); + +test('it lists sessions from the client', function () { + Http::fake(['bnfexpress.test/admin/ev/history*' => Http::response([ + 'total' => 1, + 'limit' => 25, + 'offset' => 0, + 'sessions' => [ + ['session_id' => 'sess-1', 'user_id' => 'user-1', 'title' => 'Charging question', 'last_message' => 'Thanks!', 'updated_at' => now()->toIso8601String()], + ], + ])]); + + Livewire::test(ViewEvChatHistory::class) + ->assertOk() + ->loadTable() + ->assertSee('Charging question'); +}); + +test('viewing a transcript shows its messages', function () { + Http::fake([ + 'bnfexpress.test/admin/ev/history/user-1/sess-1' => Http::response([ + 'session_id' => 'sess-1', + 'user_id' => 'user-1', + 'messages' => [ + ['author' => 'user', 'text' => 'How do I charge my EV?', 'timestamp' => now()->timestamp], + ['author' => 'bnfexpress_ev_agent', 'text' => 'Plug it in at a station.', 'timestamp' => now()->timestamp], + ], + ]), + 'bnfexpress.test/admin/ev/history*' => Http::response([ + 'total' => 1, + 'limit' => 25, + 'offset' => 0, + 'sessions' => [ + ['session_id' => 'sess-1', 'user_id' => 'user-1', 'title' => 'Charging question', 'last_message' => 'Thanks!', 'updated_at' => now()->toIso8601String()], + ], + ]), + ]); + + Livewire::test(ViewEvChatHistory::class) + ->loadTable() + ->mountTableAction('view', 'user-1:sess-1') + ->assertMountedActionModalSee('Plug it in at a station.'); +}); + +test('a failed transcript fetch surfaces the gateway detail message', function () { + Http::fake([ + 'bnfexpress.test/admin/ev/history/user-1/sess-1' => Http::response(['detail' => 'Session not found.'], 404), + 'bnfexpress.test/admin/ev/history*' => Http::response([ + 'total' => 1, + 'limit' => 25, + 'offset' => 0, + 'sessions' => [ + ['session_id' => 'sess-1', 'user_id' => 'user-1', 'title' => 'Charging question', 'last_message' => 'Thanks!', 'updated_at' => now()->toIso8601String()], + ], + ]), + ]); + + Livewire::test(ViewEvChatHistory::class) + ->loadTable() + ->mountTableAction('view', 'user-1:sess-1') + ->assertMountedActionModalSee('Session not found.') + ->assertNotified('Could not load transcript'); +}); diff --git a/app-modules/identity/database/seeders/RolePermissionSeeder.php b/app-modules/identity/database/seeders/RolePermissionSeeder.php index 71591e2..cbf426d 100644 --- a/app-modules/identity/database/seeders/RolePermissionSeeder.php +++ b/app-modules/identity/database/seeders/RolePermissionSeeder.php @@ -26,6 +26,7 @@ class RolePermissionSeeder extends Seeder 'view_customers', 'manage_settings', 'view_reports', + 'manage_ai_agent', ]; /** @@ -46,6 +47,7 @@ class RolePermissionSeeder extends Seeder 'view_customers', 'manage_settings', 'view_reports', + 'manage_ai_agent', ], 'admin' => [ 'manage_catalog', @@ -59,6 +61,7 @@ class RolePermissionSeeder extends Seeder 'view_customers', 'manage_settings', 'view_reports', + 'manage_ai_agent', ], 'support' => [ 'view_bookings', diff --git a/app-modules/shared/src/Bnfexpress/BnfexpressAdminClient.php b/app-modules/shared/src/Bnfexpress/BnfexpressAdminClient.php new file mode 100644 index 0000000..0f7e24b --- /dev/null +++ b/app-modules/shared/src/Bnfexpress/BnfexpressAdminClient.php @@ -0,0 +1,204 @@ +|null $config + */ + public function __construct(?array $config = null) + { + $config ??= (array) config('services.bnfexpress'); + + $this->baseUrl = rtrim((string) ($config['ai_api_url'] ?? ''), '/'); + $this->clientId = (string) ($config['client_id'] ?? ''); + $this->secret = (string) ($config['client_secret'] ?? ''); + } + + // --- FAQs --------------------------------------------------------- + + /** + * @return array + */ + public function listFaqs(?string $q = null, string $search = 'normal', ?int $limit = null, ?int $offset = null): array + { + return $this->request('GET', '/admin/faqs', query: array_filter([ + 'agent' => self::AGENT, + 'q' => $q, + // Ignored by bnfexpress when q is empty, but only sent when q is set. + 'search' => ($q !== null && $q !== '') ? $search : null, + 'limit' => $limit, + 'offset' => $offset, + ], fn (mixed $value): bool => $value !== null)); + } + + /** + * @return array + */ + public function getFaq(int|string $id): array + { + return $this->request('GET', "/admin/faqs/{$id}"); + } + + /** + * @param array $metadata + * @return array + */ + public function createFaq(string $content, array $metadata = []): array + { + return $this->request('POST', '/admin/faqs', body: [ + 'content' => $content, + 'agent' => self::AGENT, + 'metadata' => $metadata, + ]); + } + + /** + * @param array|null $metadata + * @return array + */ + public function updateFaq(int|string $id, ?string $content = null, ?array $metadata = null): array + { + return $this->request('PATCH', "/admin/faqs/{$id}", body: array_filter([ + 'content' => $content, + 'metadata' => $metadata, + ], fn (mixed $value): bool => $value !== null)); + } + + /** + * @return array + */ + public function deleteFaq(int|string $id): array + { + return $this->request('DELETE', "/admin/faqs/{$id}"); + } + + // --- Agent instructions -------------------------------------------- + + /** + * @return array + */ + public function listInstructions(?int $limit = null, ?int $offset = null): array + { + return $this->request('GET', '/admin/agent-instructions', query: array_filter([ + 'agent' => self::AGENT, + 'limit' => $limit, + 'offset' => $offset, + ], fn (mixed $value): bool => $value !== null)); + } + + /** + * @return array + */ + public function getActiveInstruction(): array + { + return $this->request('GET', '/admin/agent-instructions/active', query: [ + 'agent' => self::AGENT, + ]); + } + + /** + * Publishes a new instruction version. Setting $activate (default true) + * deactivates the previously active version automatically, server-side. + * + * @return array + */ + public function publishInstruction(string $content, bool $activate = true): array + { + return $this->request('POST', '/admin/agent-instructions', body: [ + 'agent' => self::AGENT, + 'content' => $content, + 'activate' => $activate, + ]); + } + + /** + * Rolls back to an older instruction version. + * + * @return array + */ + public function activateInstruction(int|string $id): array + { + return $this->request('POST', "/admin/agent-instructions/{$id}/activate"); + } + + // --- EV chat history (read-only) ------------------------------------ + + /** + * @return array{total: int, limit: int, offset: int, sessions: list>} + */ + public function listSessions(?int $limit = null, ?int $offset = null): array + { + return $this->request('GET', '/admin/ev/history', query: array_filter([ + 'limit' => $limit, + 'offset' => $offset, + ], fn (mixed $value): bool => $value !== null)); + } + + /** + * @return array + */ + public function getSessionTranscript(string $userId, string $sessionId): array + { + return $this->request('GET', "/admin/ev/history/{$userId}/{$sessionId}"); + } + + // --- Request plumbing ------------------------------------------------ + + /** + * @param array $query + * @param array|null $body + * @return array + */ + private function request(string $method, string $path, array $query = [], ?array $body = null): array + { + // Signed over exactly these bytes — must match what's actually sent, + // so it's built once and reused for both the signature and the body. + $rawBody = $body !== null ? json_encode($body, JSON_THROW_ON_ERROR) : ''; + + $headers = BnfexpressSignature::headers($method, $path, $rawBody, $this->clientId, $this->secret); + + $pending = Http::baseUrl($this->baseUrl)->withHeaders($headers); + + try { + $response = match ($method) { + 'GET' => $pending->get($path, $query), + 'DELETE' => $pending->delete($path, $query), + 'POST' => $pending->withBody($rawBody, 'application/json')->post($path), + 'PATCH' => $pending->withBody($rawBody, 'application/json')->patch($path), + default => throw new \InvalidArgumentException("Unsupported HTTP method [{$method}]."), + }; + } catch (ConnectionException $exception) { + throw new BnfexpressApiException($exception->getMessage()); + } + + if (! $response->successful()) { + throw new BnfexpressApiException( + (string) ($response->json('detail') ?? "bnfexpress request failed with status {$response->status()}."), + $response->status(), + ); + } + + return (array) $response->json(); + } +} diff --git a/app-modules/shared/src/Bnfexpress/Exceptions/BnfexpressApiException.php b/app-modules/shared/src/Bnfexpress/Exceptions/BnfexpressApiException.php new file mode 100644 index 0000000..6de03c0 --- /dev/null +++ b/app-modules/shared/src/Bnfexpress/Exceptions/BnfexpressApiException.php @@ -0,0 +1,18 @@ + $clientId, + 'X-Timestamp' => $timestamp, + 'X-Signature' => hash_hmac('sha256', $payload, $secret), + ]; + } +} diff --git a/app-modules/shared/src/Console/Commands/BnfexpressSmokeTestCommand.php b/app-modules/shared/src/Console/Commands/BnfexpressSmokeTestCommand.php new file mode 100644 index 0000000..4f2c6c9 --- /dev/null +++ b/app-modules/shared/src/Console/Commands/BnfexpressSmokeTestCommand.php @@ -0,0 +1,42 @@ +components->task('GET /admin/faqs?agent=ev', function () use ($client) { + $faqs = $client->listFaqs(); + $this->line(' '.json_encode($faqs)); + }); + + $this->components->task('GET /admin/agent-instructions/active?agent=ev', function () use ($client) { + $active = $client->getActiveInstruction(); + $this->line(' '.json_encode($active)); + }); + } catch (BnfexpressApiException $exception) { + $this->components->error('bnfexpress request failed: '.$exception->getMessage()); + + return self::FAILURE; + } + + $this->components->info('bnfexpress signing verified.'); + + return self::SUCCESS; + } +} diff --git a/app-modules/shared/tests/Unit/BnfexpressAdminClientTest.php b/app-modules/shared/tests/Unit/BnfexpressAdminClientTest.php new file mode 100644 index 0000000..0318f0a --- /dev/null +++ b/app-modules/shared/tests/Unit/BnfexpressAdminClientTest.php @@ -0,0 +1,87 @@ + 'https://bnfexpress.test', + 'client_id' => 'ev_admin', + 'client_secret' => 'shared-secret', +]; + +function assertSignedCorrectly($request, string $method, string $path, string $rawBody, string $secret): bool +{ + $timestamp = $request->header('X-Timestamp')[0] ?? null; + $expected = hash_hmac('sha256', strtoupper($method)."\n".$path."\n".$timestamp."\n".$rawBody, $secret); + + return $request->hasHeader('X-Client-Id', 'ev_admin') + && $timestamp !== null + && abs(time() - (int) $timestamp) < 5 + && $request->header('X-Signature')[0] === $expected; +} + +test('listFaqs signs a GET request and excludes the query string from the signed path', function () use ($config) { + Http::fake(['bnfexpress.test/*' => Http::response(['faqs' => []])]); + + (new BnfexpressAdminClient($config))->listFaqs(q: 'range', search: 'semantic', limit: 10, offset: 0); + + Http::assertSent(function ($request) use ($config) { + return $request->url() === 'https://bnfexpress.test/admin/faqs?agent=ev&q=range&search=semantic&limit=10&offset=0' + && assertSignedCorrectly($request, 'GET', '/admin/faqs', '', $config['client_secret']); + }); +}); + +test('listFaqs omits search when q is empty', function () use ($config) { + Http::fake(['bnfexpress.test/*' => Http::response(['faqs' => []])]); + + (new BnfexpressAdminClient($config))->listFaqs(); + + Http::assertSent(fn ($request) => $request->url() === 'https://bnfexpress.test/admin/faqs?agent=ev'); +}); + +test('createFaq signs the exact raw JSON body being sent', function () use ($config) { + Http::fake(['bnfexpress.test/*' => Http::response(['id' => 1], 201)]); + + (new BnfexpressAdminClient($config))->createFaq('How do I charge?', ['source' => 'admin']); + + $rawBody = json_encode([ + 'content' => 'How do I charge?', + 'agent' => 'ev', + 'metadata' => ['source' => 'admin'], + ]); + + Http::assertSent(function ($request) use ($config, $rawBody) { + return $request->url() === 'https://bnfexpress.test/admin/faqs' + && $request->method() === 'POST' + && $request->body() === $rawBody + && assertSignedCorrectly($request, 'POST', '/admin/faqs', $rawBody, $config['client_secret']); + }); +}); + +test('getActiveInstruction requests the active instruction for the ev agent', function () use ($config) { + Http::fake(['bnfexpress.test/*' => Http::response(['content' => 'You are the EV assistant.'])]); + + $result = (new BnfexpressAdminClient($config))->getActiveInstruction(); + + expect($result)->toBe(['content' => 'You are the EV assistant.']); + Http::assertSent(fn ($request) => $request->url() === 'https://bnfexpress.test/admin/agent-instructions/active?agent=ev'); +}); + +test('a non-2xx response surfaces the gateway detail message', function () use ($config) { + Http::fake(['bnfexpress.test/*' => Http::response(['detail' => 'FAQ not found.'], 404)]); + + (new BnfexpressAdminClient($config))->getFaq(999); +})->throws(BnfexpressApiException::class, 'FAQ not found.'); + +test('deleteFaq signs a bodyless DELETE request', function () use ($config) { + Http::fake(['bnfexpress.test/*' => Http::response(['deleted' => true])]); + + (new BnfexpressAdminClient($config))->deleteFaq(5); + + Http::assertSent(function ($request) use ($config) { + return $request->url() === 'https://bnfexpress.test/admin/faqs/5' + && $request->method() === 'DELETE' + && assertSignedCorrectly($request, 'DELETE', '/admin/faqs/5', '', $config['client_secret']); + }); +}); diff --git a/app-modules/shared/tests/Unit/BnfexpressSignatureTest.php b/app-modules/shared/tests/Unit/BnfexpressSignatureTest.php new file mode 100644 index 0000000..3826332 --- /dev/null +++ b/app-modules/shared/tests/Unit/BnfexpressSignatureTest.php @@ -0,0 +1,46 @@ +toBe([ + 'X-Client-Id' => 'ev_admin', + 'X-Timestamp' => '1700000000', + 'X-Signature' => $expected, + ]); +}); + +test('headers signs an empty raw body for a bodyless request', function () { + $headers = BnfexpressSignature::headers( + method: 'GET', + path: '/admin/faqs/1', + rawBody: '', + clientId: 'ev_admin', + secret: 'shared-secret', + timestamp: 1_700_000_000, + ); + + $expected = hash_hmac('sha256', "GET\n/admin/faqs/1\n1700000000\n", 'shared-secret'); + + expect($headers['X-Signature'])->toBe($expected); +}); + +test('headers generates a fresh timestamp per call when none is given', function () { + $before = time(); + + $headers = BnfexpressSignature::headers('GET', '/admin/faqs', '', 'ev_admin', 'secret'); + + expect((int) $headers['X-Timestamp'])->toBeGreaterThanOrEqual($before) + ->and((int) $headers['X-Timestamp'])->toBeLessThanOrEqual(time()); +}); diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index a7d2554..d1b6a06 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -21,6 +21,7 @@ use Illuminate\Foundation\Http\Middleware\PreventRequestForgery; use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\Session\Middleware\StartSession; use Illuminate\View\Middleware\ShareErrorsFromSession; +use Modules\AiAgent\AiAgentPlugin; use Modules\Booking\BookingPlugin; use Modules\Catalog\CatalogPlugin; use Modules\Cms\CmsPlugin; @@ -51,6 +52,7 @@ class AdminPanelProvider extends PanelProvider NavigationGroup::make()->label('Operations'), NavigationGroup::make()->label('Reports'), NavigationGroup::make()->label('CMS'), + NavigationGroup::make()->label('AI Agent'), ]) ->plugins([ CatalogPlugin::make(), @@ -60,6 +62,7 @@ class AdminPanelProvider extends PanelProvider IdentityPlugin::make(), ReportingPlugin::make(), CmsPlugin::make(), + AiAgentPlugin::make(), // T6.5 — ops convenience for browsing storage/logs/*.log // in-browser; distinct from the structured, per-model audit // trail (AuditLogResource, T6.2). No extra permission gate: diff --git a/composer.json b/composer.json index 76c5e30..ce4a0ef 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "laravel/sanctum": "^4.0", "laravel/tinker": "^3.0", "internachi/modular": "^3.0", + "modules/ai-agent": "*", "modules/booking": "*", "modules/catalog": "*", "modules/cms": "*", diff --git a/composer.lock b/composer.lock index c29cf09..801d1f8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "00ca2f59b7cce7ed3ade4856855a5d51", + "content-hash": "947c21682eb62d7b6f63b0f1e11dbe38", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -4663,6 +4663,38 @@ }, "time": "2022-12-02T22:17:43+00:00" }, + { + "name": "modules/ai-agent", + "version": "1.0", + "dist": { + "type": "path", + "url": "app-modules/ai-agent", + "reference": "3a8b4cb091495eaa57bbb80f6879f85a014318bd" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Modules\\AiAgent\\Providers\\AiAgentServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Modules\\AiAgent\\": "src/", + "Modules\\AiAgent\\Tests\\": "tests/", + "Modules\\AiAgent\\Database\\Factories\\": "database/factories/", + "Modules\\AiAgent\\Database\\Seeders\\": "database/seeders/" + } + }, + "license": [ + "proprietary" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, { "name": "modules/booking", "version": "1.0", diff --git a/config/services.php b/config/services.php index 8e52b78..f189fb2 100644 --- a/config/services.php +++ b/config/services.php @@ -40,6 +40,12 @@ return [ 'jwt_algorithm' => env('FASTAPI_AGENT_JWT_ALGORITHM', 'HS256'), ], + 'bnfexpress' => [ + 'ai_api_url' => env('BNFEXPRESS_AI_API_URL', 'http://bnfexpress-app:8000'), + 'client_id' => env('BNFEXPRESS_AI_CLIENT_ID', 'ev_admin'), + 'client_secret' => env('BNFEXPRESS_AI_CLIENT_SECRET'), + ], + 'sms' => [ 'enabled' => env('SMS_ENABLED', false), 'sms_poh' => [