Добавлен сервис AI

Добавлен ai агент для формирования и проверки статистики
Добавлен ai агент для поддержки
This commit is contained in:
brusnitsyn
2026-07-20 16:58:21 +09:00
parent 6350159943
commit a9e032f658
21 changed files with 1546 additions and 4 deletions

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Ai\Agents;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Messages\Message;
use Laravel\Ai\Promptable;
use Stringable;
class RouterAgent implements Agent, Conversational, HasTools
{
use Promptable;
/**
* Get the instructions that the agent should follow.
*/
public function instructions(): Stringable|string
{
return 'Ты маршрутизатор запросов в системе медицинской статистики.
Твоя задача определить, к какой категории относится вопрос пользователя:
1. "stats" если вопрос касается показателей работы отделений: загрузка, койко-дни, операции, поступления, выбытия, предоперационные дни, сравнения, тренды.
2. "support" если вопрос касается работы с системой, инструкций, регламентов, ошибок, как что-то сделать.
3. "other" если вопрос не относится ни к статистике, ни к поддержке (например, общие вопросы, приветствия).
Ответ должен быть строго в формате JSON:
{"route": "stats", "confidence": 0.95, "reason": "вопрос про загрузку отделения"}
Оценивай уверенность (confidence) от 0 до 1. Если сомневаешься, выбери наиболее вероятный маршрут.
Отвечай только JSON.';
}
/**
* 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 [];
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Ai\Agents;
use App\Services\DutyReportService;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Messages\Message;
use Laravel\Ai\Promptable;
use App\Ai\Tools\GetDepartmentStatsTool;
use Stringable;
class StatisticsAnalyst implements Agent, Conversational, HasTools
{
use Promptable;
public function __construct(
protected DutyReportService $reportService,
protected string $role = 'guest'
) {}
/**
* Get the instructions that the agent should follow.
*/
public function instructions(): Stringable|string
{
return <<<'PROMPT'
Ты эксперт по медицинской статистике стационара. Отвечаешь на вопросы пользователей о показателях работы отделений.
**Правила работы:**
1. Период отчёта это смена: с 09:00 текущего дня по 09:00 следующего дня (сутки). Если пользователь не указал период, используй переданный в вопросе диапазон.
2. Для получения данных используй инструмент GetDepartmentStatsTool. Он возвращает статистику за указанный период.
3. Анализируй данные и давай развёрнутые, понятные ответы на русском языке.
4. Если данных недостаточно, предложи уточнить вопрос или указать отделение/период.
5. Если роль пользователя ({$this->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),
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Ai\Agents;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Messages\Message;
use Laravel\Ai\Promptable;
use Stringable;
class SupportAgent implements Agent, Conversational, HasTools
{
use Promptable;
/**
* Get the instructions that the agent should follow.
*/
public function instructions(): Stringable|string
{
return 'Ты ассистент поддержки пользователей медицинской информационной системы.
Отвечаешь на вопросы о работе с системой: как создать отчёт, как посмотреть статистику, как исправить ошибку, какие регламенты действуют.
Используй информацию из внутренней базы знаний (если она есть). Если не знаешь предложи обратиться к администратору.
Отвечай на русском языке, чётко и по делу.';
}
/**
* 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 [];
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Ai\Tools;
use App\Models\ReportDuty;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use App\Services\DutyReportService;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;
class GetDepartmentStatsTool implements Tool
{
public function __construct(
private readonly DutyReportService $reportService
) {}
/**
* Get the description of the tool's purpose.
*/
public function description(): Stringable|string
{
return 'Получить статистику по конкретному отделению за указанный период. ' .
'Возвращает: название отделения, количество коек, поступивших, выбывших, ' .
'средний койко-день, процент загруженности и т.д.' .
'Средний койко-день считается по формуле: total_bed_days / (patient_outcome + patients_in_department)';
}
/**
* Execute the tool.
*/
public function handle(Request $request): Stringable|string
{
// 1. Получаем параметры из запроса агента
$departmentName = $request['department_name'];
$startDate = $request['start_date'];
$endDate = $request['end_date'];
// 2. Здесь вы используете вашу существующую логику для получения отчета.
// Например, вы можете найти ID отделения по имени и вызвать ваш DutyReportService.
// Это упрощенный пример, вам нужно адаптировать его под вашу архитектуру.
// Пример: получаем последний отчет для этого отделения за период.
$report = ReportDuty::query()
->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'),
];
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Http\Controllers\Api\Ai;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\Ai\AiDispatcherService;
use App\Services\DateRangeService;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class AiController extends Controller
{
public function __construct(
protected AiDispatcherService $dispatcher,
protected DateRangeService $dateRangeService
) {}
public function ask(Request $request)
{
$request->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);
}
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Services\Ai;
use App\Ai\Agents\RouterAgent;
use App\Ai\Agents\StatisticsAnalyst;
use App\Ai\Agents\SupportAgent;
use App\Services\DutyReportService;
class AiDispatcherService
{
public function __construct(
private readonly DutyReportService $reportService
) {}
public function dispatch(string $question, string $role = 'guest'): array
{
// 1. Маршрутизация
$router = new RouterAgent();
$routerResponse = $router->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,
];
}
}