diff --git a/.env.example b/.env.example index 47b4cbc..9eb9593 100644 --- a/.env.example +++ b/.env.example @@ -77,4 +77,6 @@ VITE_SENTRY_DNS= SYNCIO_BASE_URL= SYNCIO_SECRET= SYNCIO_TOKEN= -FIAS_URL= \ No newline at end of file +FIAS_URL= + +DEEPSEEK_API_KEY= \ No newline at end of file diff --git a/app/Ai/Agents/RouterAgent.php b/app/Ai/Agents/RouterAgent.php new file mode 100644 index 0000000..d20b09d --- /dev/null +++ b/app/Ai/Agents/RouterAgent.php @@ -0,0 +1,55 @@ +role}) не позволяет видеть некоторые данные, сообщи об этом вежливо. + + **Формат ответа (строго JSON, без Markdown, без пояснений вне JSON):** + { + "text": "Твой развернутый ответ на русском языке. Используй простые предложения, без маркдауна.", + "data": { + // объект с числовыми показателями, которые ты упомянул в ответе, + // или null, если их нет. + // Ключи — короткие названия на английском (например, occupancy_percent, avg_bed_day, beds, patients). + } + } + + **Важно:** + - Никогда не используй Markdown (**, *, -, # и т.д.) внутри поля "text". + - Все числовые значения, которые ты приводишь в ответе, дублируй в поле "data" (если они относятся к показателям). + - Если пользователь спрашивает про средний койко-день — уточни, за какой период он рассчитан (за смену или за другой период), и приведи соответствующее значение. + - Не добавляй никаких пояснений вне JSON. + + Пример ответа: + { + "text": "В урологическом отделении за смену 19 июля загрузка составляет 89.58% при 48 койках. Средняя длительность пребывания пациентов, находящихся в отделении в эту смену, равна 0.96 дня.", + "data": { + "department": "Урологическое", + "beds": 48, + "patients_in_department": 43, + "avg_bed_day": 0.96, + "occupancy_percent": 89.58 + } + } + PROMPT; + } + + /** + * Get the list of messages comprising the conversation so far. + * + * @return Message[] + */ + public function messages(): iterable + { + return []; + } + + /** + * Get the tools available to the agent. + * + * @return Tool[] + */ + public function tools(): iterable + { + return [ + new GetDepartmentStatsTool($this->reportService), + ]; + } +} diff --git a/app/Ai/Agents/SupportAgent.php b/app/Ai/Agents/SupportAgent.php new file mode 100644 index 0000000..592ace3 --- /dev/null +++ b/app/Ai/Agents/SupportAgent.php @@ -0,0 +1,49 @@ +whereHas('department', function ($query) use ($departmentName) { + $query->where('name_full', 'ilike', "$departmentName%"); + }) + ->where('period_start', '>=', $startDate) + ->where('period_end', '<=', $endDate) + ->latest() + ->first(); + + if (!$report) { + return "Данные по отделению '{$departmentName}' за указанный период не найдены."; + } + +// dd($report, $report->metrikaResult); + + // 3. Возвращаем агенту структурированные данные (например, в JSON). + // Агент проанализирует эти цифры и даст ответ. + return json_encode([ + 'department' => $departmentName, + 'beds' => $report->metrikaResult->where('rf_metrika_item_id', 1)->first()?->value ?? 0, + 'patients_in_department' => $report->metrikaResult->where('rf_metrika_item_id', 8)->first()?->value ?? 0, + 'patient_outcome' => $report->metrikaResult->where('rf_metrika_item_id', 7)->first()?->value ?? 0, + 'avg_bed_day' => $report->metrikaResult->where('rf_metrika_item_id', 18)->first()?->value ?? 0, + 'occupancy_percent' => $report->metrikaResult->where('rf_metrika_item_id', 22)->first()?->value ?? 0, + 'patients_is_recipient' => $report->metrikaResult->where('rf_metrika_item_id', 3)->first()?->value ?? 0, + 'patients_is_discharged' => $report->metrikaResult->where('rf_metrika_item_id', 15)->first()?->value ?? 0, + 'patients_is_transferred' => $report->metrikaResult->where('rf_metrika_item_id', 13)->first()?->value ?? 0, + 'patients_is_deceased' => $report->metrikaResult->where('rf_metrika_item_id', 9)->first()?->value ?? 0, + 'planned_patients' => $report->metrikaResult->where('rf_metrika_item_id', 4)->first()?->value ?? 0, + 'urgent_patients' => $report->metrikaResult->where('rf_metrika_item_id', 12)->first()?->value ?? 0, + 'total_bed_days' => $report->metrikaResult->where('rf_metrika_item_id', 25)->first()?->value ?? 0, + 'total_pre_op_days' => $report->metrikaResult->where('rf_metrika_item_id', 26)->first()?->value ?? 0, + 'patients_with_ops' => $report->metrikaResult->where('rf_metrika_item_id', 27)->first()?->value ?? 0, + 'avg_pre_op_bed_day' => $report->metrikaResult->where('rf_metrika_item_id', 21)->first()?->value ?? 0, + 'mortality_percent' => $report->metrikaResult->where('rf_metrika_item_id', 19)->first()?->value ?? 0, + 'planned_operations' => $report->metrikaResult->where('rf_metrika_item_id', 11)->first()?->value ?? 0, + 'urgent_operations' => $report->metrikaResult->where('rf_metrika_item_id', 10)->first()?->value ?? 0, + 'staff' => $report->metrikaResult->where('rf_metrika_item_id', 17)->first()?->value ?? 0, + +// $this->saveMetric($reportDuty->id, 18, $avgBedDay); // Ср. койко-день + ]); + } + + /** + * Get the tool's schema definition. + */ + public function schema(JsonSchema $schema): array + { + return [ + 'department_name' => $schema->string()->required()->description('Название отделения, например: "Отоларингологическое"'), + 'start_date' => $schema->string()->required()->description('Дата начала периода в формате YYYY-MM-DD HH:mm'), + 'end_date' => $schema->string()->required()->description('Дата окончания периода в формате YYYY-MM-DD HH:mm'), + ]; + } +} diff --git a/app/Http/Controllers/Api/Ai/AiController.php b/app/Http/Controllers/Api/Ai/AiController.php new file mode 100644 index 0000000..99bcc53 --- /dev/null +++ b/app/Http/Controllers/Api/Ai/AiController.php @@ -0,0 +1,79 @@ +validate([ + 'question' => 'required|string|max:500', + 'period_start' => 'nullable', + 'period_end' => 'nullable', + ]); + + $user = $request->user(); + $role = $user?->role ?? 'guest'; + $question = $request->question; + + $periodStart = $request->period_start; + $periodEnd = $request->period_end; + $dates = $this->dateRangeService->getDateRangeForUser($user, $periodStart, $periodEnd, false); + + $cacheKey = 'ai_stats_' . md5("{$role}|{$periodStart}|{$periodEnd}|{$question}"); + + $cached = Cache::get($cacheKey); + if ($cached) { + return response()->json([ + 'success' => true, + 'from_cache' => true, + 'answer' => $cached['answer'], + 'structured' => $cached['structured'] ?? null, + ]); + } + + try { + $result = $this->dispatcher->dispatch( + question: "Период с {$dates[0]} по {$dates[1]}. Вопрос: {$question}", + role: $role + ); + +// Cache::put($cacheKey, [ +// 'answer' => $result['answer'], +// 'structured' => $result['structured'] ?? null, +// ], now()->addMinutes(10)); + + return response()->json([ + 'success' => true, + 'from_cache' => false, + 'answer' => $result['answer'], + 'structured' => $result['structured'] ?? null, + ]); + + } catch (\Exception $e) { + Log::error('AI Dispatch Error: ' . $e->getMessage(), [ + 'user' => $user?->id, + 'question' => $question, + 'period' => "$periodStart - $periodEnd", + ]); + + return response()->json([ + 'success' => false, + 'message' => 'Не удалось обработать запрос. Попробуйте позже.', + ], 500); + } + } +} diff --git a/app/Services/Ai/AiDispatcherService.php b/app/Services/Ai/AiDispatcherService.php new file mode 100644 index 0000000..972f9e7 --- /dev/null +++ b/app/Services/Ai/AiDispatcherService.php @@ -0,0 +1,58 @@ +prompt($question)->text; // получаем текстовый ответ + + // Парсим JSON из ответа роутера + $routeData = json_decode($routerResponse, true); + $route = $routeData['route'] ?? 'other'; + + // 2. Выбор агента + $agent = match ($route) { + 'stats' => new StatisticsAnalyst($this->reportService, $role), + 'support' => new SupportAgent(), + default => null, + }; + + if (!$agent) { + return [ + 'answer' => 'Извините, я не могу ответить на этот вопрос. Пожалуйста, уточните, что вы имеете в виду.', + 'structured' => null, + ]; + } + + // 3. Запрос к специалисту + $responseText = $agent->prompt($question)->text; + + // Пытаемся распарсить JSON (если агент вернул структурированный ответ) + $parsed = json_decode($responseText, true); + if (json_last_error() === JSON_ERROR_NONE && isset($parsed['text'])) { + return [ + 'answer' => $parsed['text'], + 'structured' => $parsed['data'] ?? null, + ]; + } + + // Если не JSON – возвращаем как есть + return [ + 'answer' => $responseText, + 'structured' => null, + ]; + } +} diff --git a/composer.json b/composer.json index d897dbe..75e3938 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ "php": "^8.2", "barryvdh/laravel-dompdf": "^3.1", "inertiajs/inertia-laravel": "^2.0", + "laravel/ai": "^0.7.2", "laravel/framework": "^12.0", "laravel/reverb": "^1.10", "laravel/sanctum": "^4.0", diff --git a/composer.lock b/composer.lock index ca42279..09a04aa 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,159 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e8468d46ea66c9a39170e5847ddf2bac", + "content-hash": "a54d753a04f7ed06ddc6f68ee6efdd66", "packages": [ + { + "name": "aws/aws-crt-php", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/awslabs/aws-crt-php.git", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35||^5.6.3||^9.5", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality." + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "AWS SDK Common Runtime Team", + "email": "aws-sdk-common-runtime@amazon.com" + } + ], + "description": "AWS Common Runtime for PHP", + "homepage": "https://github.com/awslabs/aws-crt-php", + "keywords": [ + "amazon", + "aws", + "crt", + "sdk" + ], + "support": { + "issues": "https://github.com/awslabs/aws-crt-php/issues", + "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7" + }, + "time": "2024-10-18T22:15:13+00:00" + }, + { + "name": "aws/aws-sdk-php", + "version": "3.388.9", + "source": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-php.git", + "reference": "d537c1143942ba8b7a0203a20c223127b9ba7108" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/d537c1143942ba8b7a0203a20c223127b9ba7108", + "reference": "d537c1143942ba8b7a0203a20c223127b9ba7108", + "shasum": "" + }, + "require": { + "aws/aws-crt-php": "^1.2.3", + "ext-json": "*", + "ext-pcre": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/promises": "^2.0", + "guzzlehttp/psr7": "^2.4.5", + "mtdowling/jmespath.php": "^2.9.1", + "php": ">=8.1", + "psr/http-message": "^1.0 || ^2.0", + "symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0" + }, + "require-dev": { + "andrewsville/php-token-reflection": "^1.4", + "aws/aws-php-sns-message-validator": "~1.0", + "behat/behat": "~3.0", + "composer/composer": "^2.7.8", + "dms/phpunit-arraysubset-asserts": "^v0.5.0", + "doctrine/cache": "~1.4", + "ext-dom": "*", + "ext-openssl": "*", + "ext-sockets": "*", + "phpunit/phpunit": "^10.0", + "psr/cache": "^2.0 || ^3.0", + "psr/simple-cache": "^2.0 || ^3.0", + "sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0", + "yoast/phpunit-polyfills": "^2.0" + }, + "suggest": { + "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications", + "doctrine/cache": "To use the DoctrineCacheAdapter", + "ext-curl": "To send requests using cURL", + "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages", + "ext-pcntl": "To use client-side monitoring", + "ext-sockets": "To use client-side monitoring" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Aws\\": "src/" + }, + "exclude-from-classmap": [ + "src/data/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Amazon Web Services", + "homepage": "https://aws.amazon.com" + } + ], + "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", + "homepage": "https://aws.amazon.com/sdk-for-php", + "keywords": [ + "amazon", + "aws", + "cloud", + "dynamodb", + "ec2", + "glacier", + "s3", + "sdk" + ], + "support": { + "forum": "https://github.com/aws/aws-sdk-php/discussions", + "issues": "https://github.com/aws/aws-sdk-php/issues", + "source": "https://github.com/aws/aws-sdk-php/tree/3.388.9" + }, + "time": "2026-07-17T18:11:29+00:00" + }, { "name": "barryvdh/laravel-dompdf", "version": "v3.1.2", @@ -1750,6 +1901,76 @@ }, "time": "2025-12-17T21:57:59+00:00" }, + { + "name": "laravel/ai", + "version": "v0.7.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/ai.git", + "reference": "9154118af9328132f5a17e41c70fdcd0a4f21eec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/ai/zipball/9154118af9328132f5a17e41c70fdcd0a4f21eec", + "reference": "9154118af9328132f5a17e41c70fdcd0a4f21eec", + "shasum": "" + }, + "require": { + "aws/aws-sdk-php": "^3.339", + "illuminate/console": "^12.0|^13.0", + "illuminate/container": "^12.0|^13.0", + "illuminate/contracts": "^12.0|^13.0", + "illuminate/database": "^12.0|^13.0", + "illuminate/filesystem": "^12.0|^13.0", + "illuminate/json-schema": "^12.0|^13.0", + "illuminate/support": "^12.0|^13.0", + "laravel/prompts": "^0.3.6", + "laravel/serializable-closure": "^2.0", + "php": "^8.3" + }, + "require-dev": { + "laravel/pint": "^1.26", + "mockery/mockery": "^1.6.12", + "orchestra/testbench": "^10.6|^11.0", + "pestphp/pest": "^3.0|^4.0", + "pestphp/pest-plugin-laravel": "^3.0|^4.0", + "phpstan/phpstan": "^2.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Ai\\AiServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "files": [ + "functions.php" + ], + "psr-4": { + "Laravel\\Ai\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The official AI SDK for Laravel.", + "homepage": "https://github.com/laravel/ai", + "keywords": [ + "ai", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/ai/issues", + "source": "https://github.com/laravel/ai" + }, + "time": "2026-05-28T19:11:59+00:00" + }, { "name": "laravel/framework", "version": "v12.61.0", @@ -3295,6 +3516,72 @@ ], "time": "2026-01-02T08:56:05+00:00" }, + { + "name": "mtdowling/jmespath.php", + "version": "2.9.2", + "source": { + "type": "git", + "url": "https://github.com/jmespath/jmespath.php.git", + "reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/2157c5e50e813ec6a96c1eed3be7f64a20fb32a8", + "reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-mbstring": "^1.17" + }, + "require-dev": { + "composer/xdebug-handler": "^3.0.3", + "phpunit/phpunit": "^8.5.52" + }, + "bin": [ + "bin/jp.php" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.9-dev" + } + }, + "autoload": { + "files": [ + "src/JmesPath.php" + ], + "psr-4": { + "JmesPath\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Declaratively specify how to extract elements from a JSON document", + "keywords": [ + "json", + "jsonpath" + ], + "support": { + "issues": "https://github.com/jmespath/jmespath.php/issues", + "source": "https://github.com/jmespath/jmespath.php/tree/2.9.2" + }, + "time": "2026-07-06T18:56:19+00:00" + }, { "name": "nesbot/carbon", "version": "3.11.4", @@ -6092,6 +6379,76 @@ ], "time": "2026-01-05T13:30:16+00:00" }, + { + "name": "symfony/filesystem", + "version": "v7.4.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d721ea61b4a5fba8c5b6e7c1feda19efea144b50", + "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.4.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-11T16:38:44+00:00" + }, { "name": "symfony/finder", "version": "v7.4.8", diff --git a/config/ai.php b/config/ai.php new file mode 100644 index 0000000..36a9660 --- /dev/null +++ b/config/ai.php @@ -0,0 +1,143 @@ + 'deepseek', + 'default_for_images' => 'gemini', + 'default_for_audio' => 'openai', + 'default_for_transcription' => 'openai', + 'default_for_embeddings' => 'openai', + 'default_for_reranking' => 'cohere', + + /* + |-------------------------------------------------------------------------- + | Caching + |-------------------------------------------------------------------------- + | + | Below you may configure caching strategies for AI related operations + | such as embedding generation. You are free to adjust these values + | based on your application's available caching stores and needs. + | + */ + + 'caching' => [ + 'embeddings' => [ + 'cache' => false, + 'store' => env('CACHE_STORE', 'database'), + ], + ], + + /* + |-------------------------------------------------------------------------- + | AI Providers + |-------------------------------------------------------------------------- + | + | Below are each of your AI providers defined for this application. Each + | represents an AI provider and API key combination which can be used + | to perform tasks like text, image, and audio creation via agents. + | + */ + + 'providers' => [ + 'anthropic' => [ + 'driver' => 'anthropic', + 'key' => env('ANTHROPIC_API_KEY'), + 'url' => env('ANTHROPIC_URL', 'https://api.anthropic.com/v1'), + ], + + 'azure' => [ + 'driver' => 'azure', + 'key' => env('AZURE_OPENAI_API_KEY'), + 'url' => env('AZURE_OPENAI_URL'), + 'api_version' => env('AZURE_OPENAI_API_VERSION', '2025-04-01-preview'), + 'deployment' => env('AZURE_OPENAI_DEPLOYMENT', 'gpt-4o'), + 'embedding_deployment' => env('AZURE_OPENAI_EMBEDDING_DEPLOYMENT', 'text-embedding-3-small'), + 'image_deployment' => env('AZURE_OPENAI_IMAGE_DEPLOYMENT', 'gpt-image-1'), + ], + + 'bedrock' => [ + 'driver' => 'bedrock', + 'region' => env('AWS_BEDROCK_REGION', 'us-east-1'), + 'key' => env('AWS_BEARER_TOKEN_BEDROCK'), + 'access_key_id' => env('AWS_ACCESS_KEY_ID'), + 'secret_access_key' => env('AWS_SECRET_ACCESS_KEY'), + 'session_token' => env('AWS_SESSION_TOKEN'), + 'use_default_credential_provider' => env('AWS_USE_DEFAULT_CREDENTIALS', true), + ], + + 'cohere' => [ + 'driver' => 'cohere', + 'key' => env('COHERE_API_KEY'), + ], + + 'deepseek' => [ + 'driver' => 'deepseek', + 'key' => env('DEEPSEEK_API_KEY'), + ], + + 'eleven' => [ + 'driver' => 'eleven', + 'key' => env('ELEVENLABS_API_KEY'), + ], + + 'gemini' => [ + 'driver' => 'gemini', + 'key' => env('GEMINI_API_KEY'), + 'url' => env('GEMINI_URL', 'https://generativelanguage.googleapis.com/v1beta/'), + ], + + 'groq' => [ + 'driver' => 'groq', + 'key' => env('GROQ_API_KEY'), + ], + + 'jina' => [ + 'driver' => 'jina', + 'key' => env('JINA_API_KEY'), + ], + + 'mistral' => [ + 'driver' => 'mistral', + 'key' => env('MISTRAL_API_KEY'), + ], + + 'ollama' => [ + 'driver' => 'ollama', + 'key' => env('OLLAMA_API_KEY', ''), + 'url' => env('OLLAMA_URL', 'http://localhost:11434'), + ], + + 'openai' => [ + 'driver' => 'openai', + 'key' => env('OPENAI_API_KEY'), + 'url' => env('OPENAI_URL', 'https://api.openai.com/v1'), + ], + + 'openrouter' => [ + 'driver' => 'openrouter', + 'key' => env('OPENROUTER_API_KEY'), + ], + + 'voyageai' => [ + 'driver' => 'voyageai', + 'key' => env('VOYAGEAI_API_KEY'), + ], + + 'xai' => [ + 'driver' => 'xai', + 'key' => env('XAI_API_KEY'), + ], + ], + +]; diff --git a/database/migrations/2026_07_20_122850_create_agent_conversations_table.php b/database/migrations/2026_07_20_122850_create_agent_conversations_table.php new file mode 100644 index 0000000..33242c9 --- /dev/null +++ b/database/migrations/2026_07_20_122850_create_agent_conversations_table.php @@ -0,0 +1,53 @@ +string('id', 36)->primary(); + $table->foreignId('user_id')->nullable(); + $table->string('title'); + $table->timestamps(); + + $table->index(['user_id', 'updated_at']); + }); + + Schema::create($messagesTable, function (Blueprint $table) { + $table->string('id', 36)->primary(); + $table->string('conversation_id', 36)->index(); + $table->foreignId('user_id')->nullable(); + $table->string('agent'); + $table->string('role', 25); + $table->text('content'); + $table->text('attachments'); + $table->text('tool_calls'); + $table->text('tool_results'); + $table->text('usage'); + $table->text('meta'); + $table->timestamps(); + + $table->index(['conversation_id', 'user_id', 'updated_at'], 'conversation_index'); + $table->index(['user_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists(config('ai.conversations.tables.messages', 'agent_conversation_messages')); + Schema::dropIfExists(config('ai.conversations.tables.conversations', 'agent_conversations')); + } +}; diff --git a/package-lock.json b/package-lock.json index 9465d65..a0e1e4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "apexcharts": "^5.13.0", "date-fns": "^4.1.0", "laravel-echo": "^2.3.4", + "marked": "^18.0.6", "pinia": "^3.0.4", "pusher-js": "^8.5.0", "ufo": "^1.6.1", @@ -3143,6 +3144,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "18.0.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.6.tgz", + "integrity": "sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/package.json b/package.json index 3a87a88..f27de86 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "apexcharts": "^5.13.0", "date-fns": "^4.1.0", "laravel-echo": "^2.3.4", + "marked": "^18.0.6", "pinia": "^3.0.4", "pusher-js": "^8.5.0", "ufo": "^1.6.1", diff --git a/resources/js/Components/Ai/AiAskWidget.vue b/resources/js/Components/Ai/AiAskWidget.vue new file mode 100644 index 0000000..96d7630 --- /dev/null +++ b/resources/js/Components/Ai/AiAskWidget.vue @@ -0,0 +1,376 @@ + + + + + + + + + + + + + + + + + Ассистент + + + Очистить историю + + + + + + + + + + + + + + + Задайте вопрос, и я постараюсь помочь вам. + + + + + + + + + {{ item.time }} + + {{ item.content }} + + + + + + + + Ассистент + {{ item.time }} + + + + + Думаю... + + + + + + + + + + + {{ error }} + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/js/Layouts/AppLayout.vue b/resources/js/Layouts/AppLayout.vue index 7555f4e..1e0ac04 100644 --- a/resources/js/Layouts/AppLayout.vue +++ b/resources/js/Layouts/AppLayout.vue @@ -7,10 +7,14 @@ import Noise from "../Components/Noise.vue"; import { useThemeVars } from "naive-ui"; import { computed } from "vue"; import Tour from "../Components/Tour/Tour.vue"; +import AiAskWidget from "../Components/Ai/AiAskWidget.vue"; +import { usePage } from "@inertiajs/vue3"; const {isGlobalLoading} = useGlobalLoading() const themeVars = useThemeVars() +const pageProps = usePage().props + const blobBg = computed(() => { const p = themeVars.value.primaryColor const w = themeVars.value.warningColor @@ -92,6 +96,7 @@ const blobBg = computed(() => { + diff --git a/resources/js/Pages/Nurse/Report/Index.vue b/resources/js/Pages/Nurse/Report/Index.vue index b36b1b0..3f996f6 100644 --- a/resources/js/Pages/Nurse/Report/Index.vue +++ b/resources/js/Pages/Nurse/Report/Index.vue @@ -212,12 +212,12 @@ const submit = () => { const reportCreator = computed(() => { if (props.latestReport === null) return '' - if (props.latestReport.doctor?.LPUDoctorID === 0) return 'Отчет создан системой' + if (props.latestReport.doctor.LPUDoctorID === 0) return 'Отчет создан системой' else return `Отчет создан: ${props.latestReport.doctor.FAM_V} ${props.latestReport.doctor.IM_V} ${props.latestReport.doctor.OT_V}` }) const reportCreatorType = computed(() => { if (props.latestReport === null) return 'warning' - if (props.latestReport.doctor?.LPUDoctorID === 0) return 'error' + if (props.latestReport.doctor.LPUDoctorID === 0) return 'error' else return 'warning' }) diff --git a/routes/api.php b/routes/api.php index c2ed83c..2ecae44 100644 --- a/routes/api.php +++ b/routes/api.php @@ -122,3 +122,8 @@ Route::prefix('syncio')->group(function () { Route::prefix('fias')->group(function () { Route::post('/', [\App\Http\Controllers\Api\FiasController::class, 'index']); }); + +Route::prefix('ai')->group(function () { + Route::post('/ask', [\App\Http\Controllers\Api\Ai\AiController::class, 'ask']); +}); + diff --git a/stubs/agent-middleware.stub b/stubs/agent-middleware.stub new file mode 100644 index 0000000..c1a50f4 --- /dev/null +++ b/stubs/agent-middleware.stub @@ -0,0 +1,20 @@ +then(function (AgentResponse $response) { + // ... + }); + } +} diff --git a/stubs/agent.stub b/stubs/agent.stub new file mode 100644 index 0000000..06471d5 --- /dev/null +++ b/stubs/agent.stub @@ -0,0 +1,44 @@ + $schema->string()->required(), + ]; + } +} diff --git a/stubs/tool.stub b/stubs/tool.stub new file mode 100644 index 0000000..e096021 --- /dev/null +++ b/stubs/tool.stub @@ -0,0 +1,37 @@ + $schema->string()->required(), + ]; + } +}
Задайте вопрос, и я постараюсь помочь вам.