Перейти к содержанию

AI Chat — модуль чата с AI-ассистентом

Архитектура

Бэкенд (cryptodiary)

Точки входа

  • AiChatController — CRUD чатов + отправка сообщений (SSE stream)
  • POST /api/ai-chat — создать чат (context_type, context_id)
  • POST /api/ai-chat/{id}/messages — отправить сообщение (message, context_data)

Сервисы

  • AiChatService — стриминг ответа через OpenAI API. Собирает системный промпт (через ChatContextProvider), историю (до 50 сообщений) и отправляет в OpenAI.
  • ChatContextRegistry — реестр провайдеров контекста. Резолвит ChatContextProvider по context_type.
  • AiChatLimitService — лимиты бесплатных сообщений.

Контексты (ChatContextProvider)

Интерфейс: App\Services\AI\Chat\Contracts\ChatContextProvider

  • contextType() — строковый идентификатор (совпадает с context_type чата)
  • getSystemPrompt(AiChat $chat, array $contextData) — формирует системный промпт
  • getModel() — модель OpenAI
  • getDailyFreeLimit() / getLimitPeriod() — настройки лимитов

Реализации:

  • PortfolioHealthChatContext (portfolio_health) — анализ портфеля. Загружает данные портфеля с бэкенда через PortfolioStatsCacheService (state-кеш 7 дней → hydrated-кеш 50 с → расчёт с нуля). context_id = ID портфеля. Фильтрует тикеры, убирая thumb_url, currency, assetable, xirr flows. Фронтенд может дополнительно передавать метрики волатильности/просадки (рассчитываются на клиенте по графику).
  • SupportChatContext (support) — чат поддержки.

Модели

  • AiChatuser_id, context_type, context_id, title
  • AiChatMessageai_chat_id, role, content, rating, rating_comment

Фронтенд (hadl-front)

Composable & Store

  • composables/aiChat.js (useAiChat) — работа с API: CRUD чатов, отправка сообщений (SSE), стриминг ответа, лимиты.
  • stores/aiChat.js (useAiChatStore) — состояние виджета: isWidgetOpen, currentContextType, currentContextId, contextData. Метод openWidget(contextType, contextId, contextData) открывает виджет с нужным контекстом.

Компоненты

  • AiChatWidget.vue — основной виджет чата (fullscreen/mini, sidebar, messages, input).
  • AiChatToggleButton.vue — кнопка открытия чата (принимает contextType, contextId, contextData пропсами).
  • AiChatMessages.vue / AiChatInput.vue / AiChatSidebar.vue — внутренние компоненты.

Вызов из PortfolioHealth

В PortfolioHealthOverview.vue и PortfolioHealthSwipeCard.vue:

js
aiChatStore.openWidget('portfolio_health', portfolioId, insightsPayload)

insightsPayload формируется в composables/portfolioHealth.js — health-скоры, волатильность, просадка, XIRR. Эти данные отправляются как context_data при каждом сообщении.

Поток данных для portfolio_health

  1. Фронт вызывает openWidget('portfolio_health', portfolioId, insightsPayload)
  2. При sendMessage(text)POST /api/ai-chat/{id}/messages с { message, context_data }
  3. Бэкенд в PortfolioHealthChatContext::getSystemPrompt:
    • Загружает статистику портфеля из кеша PortfolioStatsCacheService (приоритет: hydrated → state → расчёт с нуля)
    • Фильтрует тикеры: оставляет ticker, title, type, symbol, totalQuantity, currentPrice, currentNetworth, currentIncome, spent, fixed, accruals, expenses, commissions, taxes, averageWeightedPrice, price_change_percentage_24h
    • Добавляет итоговые показатели: totalSpent, totalIncome, totalCurrentNetworth, xirr и т.д.
    • Добавляет health-метрики: diversificationScore, concentrationScore, assetClasses, topPositions
    • Мержит метрики с фронта: volatility, max_drawdown (рассчитываются на клиенте по графику)
  4. Системный промпт содержит полный JSON портфеля + рыночный контекст (определяется по portfolio.currency.code)
  5. OpenAI стримит ответ через SSE

Локализация рыночного контекста

Определяется в buildMarketContext(?Portfolio $portfolio) по коду валюты портфеля:

  • RUBrussianMarketContext(): санкции 2022 (заморозка НРД, недоступность иностранных ETF/акций через российских брокеров), доступные инструменты (MOEX, ОФЗ, флоатеры, БПИФы, замещающие облигации, золото, крипта), высокая ставка ЦБ, налоговая специфика (НДФЛ 13%/15%, ЛДВ, ИИС).
  • Другие валюты → пустая строка (общие советы без ограничений).

Для добавления нового рынка: добавить case 'XXX': в match внутри buildMarketContext и реализовать метод xxxMarketContext().