frontend(React + TypeScript + MUI):- загрузка Excel,
- выбор колонок для анализа, колонок для passthrough и опциональной группировки,
- шаблон (промпт + поля ответа) с готовыми пресетами,
- запуск/пауза/resume/cancel/перезапуск,
- live-превью обработанных строк и просмотр отчётов.
backend(FastAPI):- API, auth, валидация,
- прием upload и постановка inspect-задач в очередь,
- выдача отчетов и файлов.
worker(Python process):- фоновая обработка строк,
- вызовы LLM,
- запись прогресса и результатов.
inspect-worker(Python process):- отдельная очередь на чтение/инспекцию Excel,
- подготовка листов/колонок без нагрузки на API и analysis-worker.
postgres:- пользователи/сессии,
- отчеты/строки отчета,
- долговременный кэш.
redis:- очередь задач анализа,
- очередь задач inspect,
- служебные lease/дедуп-маркеры.
- Пользователь загружает
.xlsxчерез/api/file/inspect. - Backend сохраняет файл, ставит
file_inspect-задачу в Redis-очередь и возвращаетinspect_status=parsing. inspect-workerвыполняет инспекцию и обновляет статус файла (queued -> parsing -> ready|error), UI опрашиваетGET /api/file/{file_id}/inspect. Если всеinspect-workerзаняты, новые upload-задачи ждут в inspect-очереди (статусparsingдо освобождения воркера).- Пользователь запускает задачу
/api/jobsтолько послеinspect_status=ready. В UI он выбирает:Колонки для анализа— попадают в{row_json}и отправляются в LLM. В итоговом xlsx отдельными столбцами не пишутся — их содержимое уже учтено в запросе к LLM.Колонки из исходника в итоговый отчёт(в API —non_analysis_columns) — не отправляются в LLM и сохраняются в итоговом файле. Можно выбирать те же колонки, что и для анализа — в итоге появятся один раз. Приgroup_by_columnсписок подрезается до одной колонки группировки, т.к. в групповой выгрузке одна строка = одна группа.group_by_column(опционально) — имя колонки, по которой строки группируются. LLM получает всю группу целиком, возвращает один ответ на группу; все строки группы получают одинаковыйcustom_json. Размер группы ограничен переменнойGROUP_MAX_ROWS(дефолт 100000).
- Backend в одном переходе создаёт запись
reports, стримит placeholder-строкиreport_rowsиз исходного.xlsxи кладёт payload анализа в Redis-очередь.- Вставка placeholder-строк использует psycopg3
COPY FROM STDINна чистой таблице и фоллбэкINSERT ... ON CONFLICT DO NOTHINGпри повторной вставке.
- Вставка placeholder-строк использует psycopg3
workerзабирает задачу анализа, читает строки и обрабатывает их через LLM.- В групповом режиме все строки группы обновляются одним запросом через
bulk_update_report_rows_same_result(row_numbers)(батчи по 5000, фильтрrow_number = ANY(?)).
- В групповом режиме все строки группы обновляются одним запросом через
- Прогресс пишется в БД, UI получает обновления через SSE
/api/jobs/{job_id}/events. - По завершении формируются
results/{job_id}_results.xlsxи опциональноresults/{job_id}_raw.json.- В групповом режиме в xlsx попадает одна строка на
group_key; первая колонка называетсяgroup_numberи содержит порядковый номер группы (1..N). Из passthrough-колонок сохраняется только сама колонка группировки.
- В групповом режиме в xlsx попадает одна строка на
Основной режим — analysis_mode=custom.
- В шаблоне используются:
{row_json}— JSON из выбранных входных колонок (подставляется сервисом автоматически, в явном виде плейсхолдер в промпте указывать не обязательно),{row_number}— номер строки; в групповом режиме — порядковый номер группы (1..N).
EXPECTED_JSONописывает только структуру ответа: имена полей, типы, допустимые значения и ограничения. Обязательный набор полей не фиксирован — пользователь сам выбирает, что модель должна вернуть под свою задачу. Поля могут называться по-русски.- Backend строит схему валидации из
EXPECTED_JSONи проверяет ответ модели. Если ответ не прошёл валидацию, строка отправляется на повторную попытку с текстом ошибки, чтобы модель скорректировала формат.
Поддерживаемые типы в EXPECTED_JSON:
stringnumberintegerbooleanenumarrayobjectdatedatetime
Поддерживаемые ограничения:
string:min_length,max_lengthnumber/integer:min,maxenum:valuesarray:items,min_items,max_itemsobject:properties,requireddate: форматYYYY-MM-DDdatetime: ISO-формат даты-времени
Пример schema-формата (из пресета «Анализ мошенничества по одному отзыву»):
{
"категория": { "type": "enum", "values": ["мошенничество", "плохой сервис", "брак товара", "позитивный", "нейтральный"] },
"тип_нарушения": { "type": "enum", "values": ["нет мошенничества", "оплата мимо кассы", "вымогательство", "личные скидки", "подделка возвратов", "подмена товара", "иное мошенничество"] },
"уверенность": { "type": "number", "min": 0, "max": 1 },
"описание": { "type": "string", "max_length": 240 }
}Имена полей произвольны — главное, чтобы они соответствовали тому, что просит вернуть промпт. Если в ответе окажутся стандартные поля summary, category, sentiment_label, negativity_score — UI покажет их на плашках и диаграммах; остальные поля доступны в итоговом xlsx.
iter_report_rowsиспользует keyset-пагинацию (WHERE row_number > :last_seen ORDER BY row_number ASC LIMIT :batch). Это удерживает чтение в O(n) — на 100k строкOFFSET/LIMITдеградировал бы до O(n²) сканов.- Экспорт xlsx и построение превью (
build_report_analysis) идут поверх того же итератора. Для группового режима в превью и в xlsx пропускаются дубли поgroup_key(одна запись на группу). - Массовое обновление строк группы — через
bulk_update_report_rows_same_result(row_numbers, ...): один UPDATE сrow_number = ANY(?)на батч 5000, вместо N отдельных апдейтов при одинаковом LLM-ответе для всей группы. reset_failed_and_skipped_rows(report_id)при retry сбрасывает вpendingвсе строки соstatus='error'и группы, пропущенные из-за старого лимита (skipped_large_group), и очищает ихcustom_json/warnings_json/error_text. После этогоprocessed_rowsпересчитывается, а воркер подбирает эти строки заново.
- Точный кэш:
llm_cache. - Семантический кэш:
llm_semantic_cache(embedding similarity). - Инвалидация на чтении: при cache-hit ответ ещё раз прогоняется через валидатор
expected_json. Если формат не сошёлся (например, пользователь поменял схему), запись вllm_cacheудаляется черезdelete_cached_analysis(cache_key)и строка уходит в LLM заново. - Retry с feedback: при повторной обработке строки с
retry_feedbackкэш игнорируется — иначе модель получила бы тот же сломанный ответ. - Лимиты/обслуживание задаются через ENV:
MAX_LLM_CACHE_ROWS,MAX_SEMANTIC_CACHE_ROWS,CACHE_MAINTENANCE_INTERVAL_SEC,SEMANTIC_CACHE_*.
- Реплики backend/worker задаются через compose/scale.
- Для хоста
8 CPU / 16 GB RAMрекомендуемый старт:backend=2,worker=4,frontend=1.
- Логи:
docker compose logs -f <service>. - Проверка состояния:
docker compose ps,docker stats. - Health endpoint:
GET /api/health(через frontend nginx:http://localhost:8080/api/health).
Используется Semantic Versioning — MAJOR.MINOR.PATCH:
- MAJOR — несовместимые изменения. Поднимаем, если релиз ломает одно из:
- контракт публичного API (
/api/*: endpoints, форматы запросов/ответов) - формат итоговой выгрузки (xlsx/raw JSON), на который завязаны скрипты пользователей
- обязательные поля конфигурации (переименование, удаление или смена семантики переменной окружения, которую пользователь уже мог задать)
- язык контрактов
expected_json_template(семантика типов/ограничений)
- контракт публичного API (
- MINOR — новые возможности с обратной совместимостью: новые endpoint'ы, новые опциональные ENV с разумными дефолтами, новые возможности UI.
- PATCH — исправление ошибок без изменений в функциональности и конфигурации.
Единый источник версии: backend/app/__init__.py (__version__). Используется в:
FastAPI(version=...)— для OpenAPI (/docs,/openapi.json)export_raw_json(app_version=...)— в метаданных скачанного raw JSON
frontend/package.json остаётся отдельной npm-экосистемой — синхронизируется вручную при сборке.
Процесс релиза:
- Обновить
backend/app/__init__.py::__version__ - При необходимости —
frontend/package.json::version - Добавить раздел в
docs/RELEASE_NOTES.mdс датой и изменениями по трём блокам: «Для пользователей», «Юнит-тестовое покрытие» (если поменялось), «Технические детали» - Актуализировать
README.md(раздел «Последние изменения», структура проекта, ENV-переменные) - Тег коммита:
vX.Y.Z