TECH 6 мин

Server-side evidence extraction: как мы вынесли анализ доказательств на бэкенд

Фронтенд парсил доказательства из текста ответа regex-ами — мобильный Safari зависал на секунду. Мы перенесли извлечение доказательств на бэкенд, добавили SSE-событие evidence, и теперь клиент просто рендерит готовые объекты. Время до первого доказательства: с 2.1с до 0.8с.

Server-side evidence extraction: как мы вынесли анализ доказательств на бэкенд

Когда парсинг на клиенте перестал справляться — мы перенесли разбор доказательств туда, где ему место.


Проблема

LEX AI возвращает пользователю не просто текст. Каждый ответ содержит доказательства: фрагменты судебных решений, статьи законодательства, выдержки из документов. Раньше весь этот поток приходил как единый текстовый блок, и фронтенд должен был самостоятельно разбирать его на структурированные карточки.

На десктопе это работало приемлемо. На мобильных устройствах — нет.

Симптомы, которые мы наблюдали:

Проблема Причина
UI freezes на 300-800 мс Парсинг больших ответов блокировал main thread
Неправильное выделение доказательств Regex-эвристики не покрывали все форматы
Дублирование логики Каждый клиент (веб, мобайл, MCP) писал свой парсер
Ухудшение при масштабировании Чем больше доказательств — тем медленнее рендер

Когда ответ содержал 15-20 доказательств (типичная ситуация для анализа судебной практики), мобильный Safari просто зависал на секунду. Пользователи это замечали.

Архитектурное решение

Вместо того, чтобы оптимизировать клиентский парсер, мы поставили вопрос иначе: зачем вообще парсить на клиенте то, что бэкенд уже знает?

Когда ChatService вызывает инструменты (search_court_decisions, get_legislation_section, vault_search), он получает структурированные данные. Потом LLM генерирует текстовый ответ, а клиент пытается из текста извлечь обратно ту же структуру. Это лишний цикл.

Решение: бэкенд извлекает доказательства во время генерации ответа и отправляет их отдельными SSE-событиями.

Поток данных: до и после

Раньше:

Backend: LLM генерирует текст с доказательствами вперемешку
   -> SSE: answer (один большой блок)
   -> Frontend: regex-парсинг, построение карточек
   -> Рендер

Теперь:

Backend: LLM генерирует текст
   -> EvidenceExtractor классифицирует tool_result
   -> SSE: evidence { type, title, source, content, relevance_score }
   -> SSE: answer (чистый текст без встроенных доказательств)
   -> Frontend: рендер готовых объектов

SSE-протокол

Мы расширили существующий SSE-поток новым событием evidence. Полный набор событий теперь выглядит так:

Событие Назначение Payload
thinking Индикатор обработки { stage: string }
tool_result Результат вызова инструмента { tool, result, cost }
evidence Структурированное доказательство { type, title, source, content, relevance_score }
answer Текстовый фрагмент ответа { delta: string }
complete Завершение потока { total_cost, evidence_count }

Объект evidence имеет чёткую типизацию:

interface EvidenceBlock {
  type: 'court_decision' | 'legislation' | 'document' | 'legal_position';
  title: string;
  source: string;
  content: string;
  relevance_score: number;
}

Поле relevance_score (0-1) позволяет фронтенду сортировать доказательства по релевантности и сворачивать менее важные по умолчанию.

Извлечение доказательств на бэкенде

EvidenceExtractor работает на этапе обработки tool_result. Когда ChatService получает результат от инструмента, он передаёт его в экстрактор до того, как LLM начнёт генерировать финальный ответ.

Для классификации (court_decision vs legislation vs document) мы используем LLM на уровне quick-модели (gpt-4o-mini). Это добавляет 50-100 мс на доказательство, но экономит значительно больше на клиенте и гарантирует корректную классификацию.

Критический момент: экстракция происходит параллельно с генерацией ответа. Пока LLM пишет текст, доказательства уже летят к клиенту. Пользователь видит карточки в EvidencePanel ещё до завершения текстового ответа.

Fallback-механизм

Мы не удалили клиентский парсер. Он остался как fallback:

if (receivedEvidenceEvents.length > 0) {
  // Используем серверные доказательства
  renderStructuredEvidence(receivedEvidenceEvents);
} else {
  // Fallback: парсим из текста ответа
  const extracted = parseEvidenceFromText(fullAnswer);
  renderStructuredEvidence(extracted);
}

Это защищает от трёх сценариев: бэкенд ещё не обновлён (постепенный деплой), экстрактор упал с ошибкой, соединение разорвалось посреди потока и evidence-события потерялись.

Результаты

Метрика До После
Время до первого доказательства в UI 2.1 сек 0.8 сек
Main thread blocking (мобайл) 300-800 мс < 50 мс
Корректность классификации ~82% ~96%
Размер клиентского бандла baseline -4 KB (удалённые regex-паттерны)

Наибольший выигрыш — на мобильных. UI jank практически исчез, потому что фронтенд больше не занимается тяжёлым парсингом. EvidencePanel просто рендерит готовые объекты.

Выводы

Эта миграция подтвердила принцип, которого мы придерживаемся в LEX AI: данные должны структурироваться как можно ближе к источнику. Бэкенд знает, что он вернул из инструмента. Заставлять клиент догадываться об этом из текста — это архитектурный долг, который мы наконец закрыли.

Fallback-слой делает миграцию безопасной: даже если серверная экстракция временно недоступна, пользователь увидит доказательства. Просто немного медленнее.