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-слой делает миграцию безопасной: даже если серверная экстракция временно недоступна, пользователь увидит доказательства. Просто немного медленнее.