Server-side evidence extraction: як ми винесли аналіз доказів на бекенд
Фронтенд парсив докази з тексту відповіді regex-ами — мобільний Safari зависав на секунду. Ми перенесли витяг доказів на бекенд, додали SSE-подію evidence, і тепер клієнт просто рендерить готові об\
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-шар робить міграцію безпечною: навіть якщо серверна екстракція тимчасово недоступна, користувач побачить докази. Просто трохи повільніше.