feat: scaffold SheetDoc AI MVP (backend + frontend + docker)
FastAPI backend (4 endpoints, Anthropic SDK, in-memory TTL cache, rate limit), Vite/React/Tailwind frontend (5-step wizard, Zustand store), multi-stage Dockerfile, Traefik-labelled compose for sheetdoc.telecom.quest. Rename PRD.md to PRD_SheetDoc_AI.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
efd2323d05
commit
0f31b4517b
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/__pycache__
|
||||||
|
**/.pytest_cache
|
||||||
|
**/.venv
|
||||||
|
**/venv
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
!backend/prompts/*.md
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
3
.env.example
Normal file
3
.env.example
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
ANTHROPIC_MODEL=claude-sonnet-4-6
|
||||||
|
ALLOWED_ORIGINS=https://sheetdoc.telecom.quest
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -15,11 +15,15 @@ node_modules/
|
|||||||
venv/
|
venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
.next/
|
.next/
|
||||||
|
.vite/
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
out/
|
out/
|
||||||
target/
|
target/
|
||||||
|
backend/static/
|
||||||
|
|
||||||
# data
|
# data
|
||||||
*.sqlite*
|
*.sqlite*
|
||||||
|
|||||||
28
Dockerfile
Normal file
28
Dockerfile
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# --- Frontend build ---
|
||||||
|
FROM node:20-alpine AS frontend
|
||||||
|
WORKDIR /app
|
||||||
|
COPY frontend/package.json frontend/package-lock.json* ./
|
||||||
|
RUN npm install --no-audit --no-fund
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# --- Backend runtime ---
|
||||||
|
FROM python:3.11-slim AS backend
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY backend/requirements.txt ./requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY backend/ ./
|
||||||
|
COPY --from=frontend /app/dist ./static
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD curl -fsS http://localhost:8000/api/health || exit 1
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
661
PRD_SheetDoc_AI.md
Normal file
661
PRD_SheetDoc_AI.md
Normal file
@ -0,0 +1,661 @@
|
|||||||
|
# PRD: AI-сервис генерации документов из Google Таблиц
|
||||||
|
|
||||||
|
## 1. Название продукта
|
||||||
|
|
||||||
|
**Рабочее название:** SheetDoc AI
|
||||||
|
|
||||||
|
**Описание в одну строку:**
|
||||||
|
Сайт, который анализирует данные из Google Таблицы, задаёт пользователю уточняющие вопросы и генерирует справку, презентацию или другой структурированный документ.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Проблема
|
||||||
|
|
||||||
|
Офисные сотрудники, менеджеры, аналитики и специалисты телеком-направлений часто работают с данными в Google Таблицах: заявками, клиентами, статусами подключений, продажами, техническими обращениями, финансовыми показателями или операционными отчётами.
|
||||||
|
|
||||||
|
Чтобы подготовить справку, презентацию или краткий отчёт, им приходится вручную:
|
||||||
|
|
||||||
|
- открывать таблицу;
|
||||||
|
- понимать структуру данных;
|
||||||
|
- выбирать важные столбцы;
|
||||||
|
- считать показатели;
|
||||||
|
- формулировать выводы;
|
||||||
|
- переносить информацию в документ или презентацию;
|
||||||
|
- оформлять результат в понятном виде.
|
||||||
|
|
||||||
|
Это занимает время, создаёт риск ошибок и требует навыков структурирования информации.
|
||||||
|
|
||||||
|
**Проблема:** данные уже есть в таблице, но превратить их в понятный документ быстро и качественно сложно.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Целевая аудитория
|
||||||
|
|
||||||
|
### Основная аудитория
|
||||||
|
|
||||||
|
1. **Офисные сотрудники**
|
||||||
|
Готовят справки, отчёты, служебные записки и сводки на основе таблиц.
|
||||||
|
|
||||||
|
2. **Менеджеры телеком-направлений**
|
||||||
|
Работают с заявками, клиентами, подключениями, тарифами, статусами услуг и внутренними отчётами.
|
||||||
|
|
||||||
|
3. **Руководители отделов**
|
||||||
|
Хотят быстро получать понятную выжимку из таблиц без ручной обработки.
|
||||||
|
|
||||||
|
4. **Аналитики начального и среднего уровня**
|
||||||
|
Готовят отчёты, но хотят ускорить рутинную часть работы.
|
||||||
|
|
||||||
|
### Характеристики пользователя
|
||||||
|
|
||||||
|
- регулярно работает с Google Таблицами;
|
||||||
|
- не всегда имеет техническую подготовку;
|
||||||
|
- хочет получить результат быстро;
|
||||||
|
- ценит понятный интерфейс;
|
||||||
|
- может не знать, как правильно сформулировать структуру документа;
|
||||||
|
- ожидает, что система сама подскажет, какие вопросы нужно уточнить.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Цель продукта
|
||||||
|
|
||||||
|
Создать веб-сервис, который помогает пользователю быстро превратить данные из Google Таблицы в структурированный документ.
|
||||||
|
|
||||||
|
### Бизнес-ценность продукта
|
||||||
|
|
||||||
|
Продукт должен:
|
||||||
|
|
||||||
|
- сокращать время подготовки справок и презентаций;
|
||||||
|
- снижать количество ручных ошибок;
|
||||||
|
- помогать пользователю лучше понимать данные;
|
||||||
|
- автоматизировать рутинную офисную работу;
|
||||||
|
- давать качественный черновик документа, который можно использовать или доработать.
|
||||||
|
|
||||||
|
### Главная цель MVP
|
||||||
|
|
||||||
|
Пользователь должен пройти сценарий:
|
||||||
|
|
||||||
|
**вставил ссылку на Google Таблицу → выбрал лист → система проанализировала данные → задала уточняющие вопросы → сгенерировала текстовую справку → пользователь скопировал результат.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Описание продукта
|
||||||
|
|
||||||
|
SheetDoc AI — это сайт, где пользователь вставляет ссылку на Google Таблицу, выбирает нужный лист, а система анализирует активные строки и столбцы с данными.
|
||||||
|
|
||||||
|
После анализа система определяет структуру таблицы, примерное назначение данных и задаёт пользователю уточняющие вопросы. На основе данных таблицы и ответов пользователя сервис генерирует один из результатов:
|
||||||
|
|
||||||
|
- справку;
|
||||||
|
- аналитический отчёт;
|
||||||
|
- структуру презентации;
|
||||||
|
- другой структурированный документ.
|
||||||
|
|
||||||
|
На первом этапе продукт должен быть простым и сфокусированным на одном главном сценарии: генерация текстовой справки из одного листа Google Таблицы.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Основной пользовательский сценарий
|
||||||
|
|
||||||
|
### Сценарий: генерация справки из Google Таблицы
|
||||||
|
|
||||||
|
1. Пользователь открывает сайт.
|
||||||
|
2. Видит поле для вставки ссылки на Google Таблицу.
|
||||||
|
3. Вставляет ссылку.
|
||||||
|
4. Система проверяет корректность ссылки.
|
||||||
|
5. Система проверяет доступность таблицы.
|
||||||
|
6. Система получает список листов.
|
||||||
|
7. Пользователь выбирает конкретный лист.
|
||||||
|
8. Система анализирует активные строки и столбцы.
|
||||||
|
9. Система показывает краткое описание найденных данных:
|
||||||
|
- количество строк;
|
||||||
|
- количество столбцов;
|
||||||
|
- предполагаемые заголовки;
|
||||||
|
- возможные типы данных;
|
||||||
|
- найденные ключевые категории.
|
||||||
|
10. Система задаёт пользователю уточняющие вопросы.
|
||||||
|
11. Пользователь отвечает на вопросы.
|
||||||
|
12. Система генерирует справку.
|
||||||
|
13. Пользователь видит результат на странице.
|
||||||
|
14. Пользователь копирует текст результата.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Дополнительные пользовательские сценарии
|
||||||
|
|
||||||
|
### 7.1. Неверная ссылка
|
||||||
|
|
||||||
|
Если пользователь вставил некорректную ссылку, система должна показать понятную ошибку.
|
||||||
|
|
||||||
|
**Пример сообщения:**
|
||||||
|
> Не удалось распознать ссылку на Google Таблицу. Проверьте, что ссылка ведёт именно на Google Sheets.
|
||||||
|
|
||||||
|
### 7.2. Таблица закрыта
|
||||||
|
|
||||||
|
Если таблица недоступна, система должна объяснить, что нужно сделать.
|
||||||
|
|
||||||
|
**Пример сообщения:**
|
||||||
|
> У системы нет доступа к этой таблице. Откройте доступ по ссылке или войдите через Google-аккаунт, если эта функция доступна.
|
||||||
|
|
||||||
|
### 7.3. В таблице несколько листов
|
||||||
|
|
||||||
|
Если в таблице несколько листов, система должна показать их список и попросить выбрать один лист.
|
||||||
|
|
||||||
|
### 7.4. Лист пустой
|
||||||
|
|
||||||
|
Если выбранный лист не содержит данных, система должна сообщить об этом и предложить выбрать другой лист.
|
||||||
|
|
||||||
|
### 7.5. Таблица плохо структурирована
|
||||||
|
|
||||||
|
Если система не может уверенно определить заголовки или структуру таблицы, она должна задать дополнительные вопросы:
|
||||||
|
|
||||||
|
- где находятся заголовки;
|
||||||
|
- какие колонки важны;
|
||||||
|
- какие строки нужно учитывать;
|
||||||
|
- какой результат нужен пользователю.
|
||||||
|
|
||||||
|
### 7.6. Пользователь хочет презентацию
|
||||||
|
|
||||||
|
В MVP система может генерировать не полноценный `.pptx`, а структуру презентации:
|
||||||
|
|
||||||
|
- название презентации;
|
||||||
|
- список слайдов;
|
||||||
|
- заголовок каждого слайда;
|
||||||
|
- ключевые тезисы;
|
||||||
|
- рекомендации по визуализации.
|
||||||
|
|
||||||
|
Полноценный экспорт в `.pptx` относится к будущим улучшениям.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Функциональные требования
|
||||||
|
|
||||||
|
## 8.1. Импорт данных из Google Sheets
|
||||||
|
|
||||||
|
Система должна позволять пользователю вставить ссылку на Google Таблицу.
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
|
||||||
|
- Пользователь может вставить ссылку в специальное поле.
|
||||||
|
- Система проверяет, является ли ссылка ссылкой на Google Sheets.
|
||||||
|
- Система извлекает идентификатор таблицы из ссылки.
|
||||||
|
- Система проверяет доступность таблицы.
|
||||||
|
- Система получает список листов таблицы.
|
||||||
|
- Система загружает данные выбранного листа.
|
||||||
|
|
||||||
|
### Для MVP
|
||||||
|
|
||||||
|
В первой версии поддерживаются только таблицы, доступные по публичной ссылке или ссылке с открытым доступом.
|
||||||
|
|
||||||
|
### Не входит в MVP
|
||||||
|
|
||||||
|
- полноценная авторизация через Google;
|
||||||
|
- работа с приватными таблицами через OAuth;
|
||||||
|
- загрузка файлов Excel;
|
||||||
|
- подключение к корпоративным хранилищам.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8.2. Выбор листа таблицы
|
||||||
|
|
||||||
|
Система должна позволять пользователю выбрать конкретный лист.
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
|
||||||
|
- Если в таблице один лист, система может выбрать его автоматически.
|
||||||
|
- Если листов несколько, пользователь должен выбрать нужный.
|
||||||
|
- Система должна показывать название каждого листа.
|
||||||
|
- Система должна определять пустые листы.
|
||||||
|
- Пользователь должен иметь возможность вернуться и выбрать другой лист.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8.3. Определение активного диапазона данных
|
||||||
|
|
||||||
|
Система должна определить, какие строки и столбцы реально содержат данные.
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
|
||||||
|
- Игнорировать полностью пустые строки.
|
||||||
|
- Игнорировать полностью пустые столбцы.
|
||||||
|
- Определять первую строку с заголовками.
|
||||||
|
- Определять последнюю строку с данными.
|
||||||
|
- Учитывать таблицы, где есть пропущенные значения.
|
||||||
|
- Учитывать таблицы, где внутри данных есть пустые ячейки.
|
||||||
|
|
||||||
|
### Пример
|
||||||
|
|
||||||
|
Если лист содержит данные в диапазоне `A1:H250`, а после 250-й строки пусто, система должна анализировать только активный диапазон, а не весь лист.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8.4. Анализ колонок и строк
|
||||||
|
|
||||||
|
Система должна анализировать структуру данных.
|
||||||
|
|
||||||
|
### Система должна определить
|
||||||
|
|
||||||
|
- названия колонок;
|
||||||
|
- количество строк;
|
||||||
|
- количество столбцов;
|
||||||
|
- типы данных в колонках;
|
||||||
|
- текстовые поля;
|
||||||
|
- числовые поля;
|
||||||
|
- даты;
|
||||||
|
- статусы;
|
||||||
|
- категории;
|
||||||
|
- повторяющиеся значения;
|
||||||
|
- возможные ключевые показатели;
|
||||||
|
- возможные проблемы в данных.
|
||||||
|
|
||||||
|
### Примеры типов колонок
|
||||||
|
|
||||||
|
- ФИО или название клиента;
|
||||||
|
- номер заявки;
|
||||||
|
- дата заявки;
|
||||||
|
- статус;
|
||||||
|
- сумма;
|
||||||
|
- услуга;
|
||||||
|
- тариф;
|
||||||
|
- регион;
|
||||||
|
- комментарий;
|
||||||
|
- ответственный сотрудник.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8.5. Уточняющие вопросы пользователю
|
||||||
|
|
||||||
|
После анализа таблицы система должна задавать пользователю вопросы, чтобы правильно сформировать итоговый документ.
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
|
||||||
|
- Система задаёт вопросы на основе структуры таблицы.
|
||||||
|
- Количество вопросов должно быть ограничено.
|
||||||
|
- В MVP рекомендуется задавать от 3 до 7 вопросов.
|
||||||
|
- Пользователь должен понимать, зачем задаётся каждый вопрос.
|
||||||
|
- Пользователь может пропустить необязательные вопросы.
|
||||||
|
- Ответы пользователя используются при генерации итогового документа.
|
||||||
|
|
||||||
|
### Примеры вопросов
|
||||||
|
|
||||||
|
1. Для кого готовится документ?
|
||||||
|
2. Какой тип документа нужен: справка, отчёт или структура презентации?
|
||||||
|
3. Какой стиль нужен: официальный, краткий, аналитический или управленческий?
|
||||||
|
4. Какие колонки являются ключевыми?
|
||||||
|
5. Нужно ли добавить выводы и рекомендации?
|
||||||
|
6. Нужно ли учитывать только определённые статусы?
|
||||||
|
7. Какой период данных нужно анализировать?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8.6. Генерация результата
|
||||||
|
|
||||||
|
Система должна генерировать структурированный документ на основе данных таблицы и ответов пользователя.
|
||||||
|
|
||||||
|
### Для MVP
|
||||||
|
|
||||||
|
Поддерживается генерация:
|
||||||
|
|
||||||
|
- текстовой справки;
|
||||||
|
- краткого аналитического отчёта;
|
||||||
|
- структуры презентации без экспорта в `.pptx`.
|
||||||
|
|
||||||
|
### Справка должна включать
|
||||||
|
|
||||||
|
- заголовок;
|
||||||
|
- краткое описание источника данных;
|
||||||
|
- основные показатели;
|
||||||
|
- ключевые наблюдения;
|
||||||
|
- выводы;
|
||||||
|
- при необходимости рекомендации;
|
||||||
|
- дату генерации.
|
||||||
|
|
||||||
|
### Структура презентации должна включать
|
||||||
|
|
||||||
|
- название презентации;
|
||||||
|
- цель презентации;
|
||||||
|
- список слайдов;
|
||||||
|
- заголовки слайдов;
|
||||||
|
- ключевые тезисы;
|
||||||
|
- предложения по графикам или таблицам.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8.7. Просмотр результата
|
||||||
|
|
||||||
|
После генерации пользователь должен увидеть результат на странице.
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
|
||||||
|
- Результат отображается в читаемом виде.
|
||||||
|
- Пользователь может скопировать текст.
|
||||||
|
- Пользователь может вернуться к вопросам и изменить ответы.
|
||||||
|
- Пользователь может повторно сгенерировать результат.
|
||||||
|
- Система должна показывать статус генерации.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8.8. Экспорт или сохранение результата
|
||||||
|
|
||||||
|
### Для MVP
|
||||||
|
|
||||||
|
Обязательная функция:
|
||||||
|
|
||||||
|
- копирование текста результата.
|
||||||
|
|
||||||
|
### После MVP
|
||||||
|
|
||||||
|
Возможные форматы экспорта:
|
||||||
|
|
||||||
|
- `.docx`;
|
||||||
|
- `.pdf`;
|
||||||
|
- `.pptx`;
|
||||||
|
- сохранение в личном кабинете;
|
||||||
|
- отправка результата по ссылке;
|
||||||
|
- экспорт обратно в Google Docs или Google Slides.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Нефункциональные требования
|
||||||
|
|
||||||
|
## 9.1. Безопасность
|
||||||
|
|
||||||
|
Система должна безопасно работать с пользовательскими таблицами.
|
||||||
|
|
||||||
|
### Нужно продумать
|
||||||
|
|
||||||
|
- как пользователь даёт доступ к таблице;
|
||||||
|
- какие данные система получает;
|
||||||
|
- как ограничить доступ только нужной таблицей;
|
||||||
|
- как не запрашивать лишние права;
|
||||||
|
- как обрабатывать закрытые таблицы;
|
||||||
|
- как защитить данные от несанкционированного доступа.
|
||||||
|
|
||||||
|
### Для MVP
|
||||||
|
|
||||||
|
Система работает только с таблицами, доступными по ссылке. Пользователь сам отвечает за настройку доступа.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9.2. Приватность данных
|
||||||
|
|
||||||
|
Данные таблицы могут содержать персональную, коммерческую или внутреннюю информацию.
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
|
||||||
|
- Пользователь должен понимать, какие данные обрабатываются.
|
||||||
|
- Система должна явно сообщать, сохраняются ли данные.
|
||||||
|
- Для MVP желательно не хранить содержимое таблиц дольше времени сессии.
|
||||||
|
- Если данные сохраняются, пользователь должен знать зачем.
|
||||||
|
- Нужно предусмотреть возможность удаления данных в будущих версиях.
|
||||||
|
|
||||||
|
### Рекомендация для MVP
|
||||||
|
|
||||||
|
Не сохранять исходные данные таблицы в базе данных. Использовать их только для генерации результата в рамках текущей сессии.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9.3. Производительность
|
||||||
|
|
||||||
|
Система должна обрабатывать таблицы за приемлемое время.
|
||||||
|
|
||||||
|
### Ориентиры
|
||||||
|
|
||||||
|
- до 100 строк — быстро, почти мгновенно;
|
||||||
|
- до 1 000 строк — допустимо несколько секунд;
|
||||||
|
- до 5 000 строк — нужен индикатор прогресса;
|
||||||
|
- больше 5 000 строк — возможно ограничение MVP или предупреждение пользователя.
|
||||||
|
|
||||||
|
### Для MVP
|
||||||
|
|
||||||
|
Рекомендуемый лимит: до 1 000 строк и до 30 активных столбцов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9.4. UX
|
||||||
|
|
||||||
|
Интерфейс должен быть простым и понятным для нетехнического пользователя.
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
|
||||||
|
- один основной сценарий на главной странице;
|
||||||
|
- понятное поле для ссылки;
|
||||||
|
- подсказка, как открыть доступ к таблице;
|
||||||
|
- понятный выбор листа;
|
||||||
|
- показ краткого анализа данных;
|
||||||
|
- простые уточняющие вопросы;
|
||||||
|
- понятные сообщения об ошибках;
|
||||||
|
- минимум лишних настроек в MVP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9.5. Обработка ошибок
|
||||||
|
|
||||||
|
Система должна корректно обрабатывать ошибки.
|
||||||
|
|
||||||
|
### Возможные ошибки
|
||||||
|
|
||||||
|
1. Неверная ссылка.
|
||||||
|
2. Ссылка не ведёт на Google Таблицу.
|
||||||
|
3. Таблица закрыта.
|
||||||
|
4. Нет доступа к таблице.
|
||||||
|
5. Не удалось получить список листов.
|
||||||
|
6. Выбранный лист пустой.
|
||||||
|
7. В таблице слишком много данных.
|
||||||
|
8. Не удалось определить структуру данных.
|
||||||
|
9. Не удалось сгенерировать документ.
|
||||||
|
10. Пользователь не ответил на обязательные вопросы.
|
||||||
|
|
||||||
|
### Принцип сообщений об ошибках
|
||||||
|
|
||||||
|
Каждая ошибка должна отвечать на три вопроса:
|
||||||
|
|
||||||
|
1. Что произошло?
|
||||||
|
2. Почему это могло случиться?
|
||||||
|
3. Что пользователю сделать дальше?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Ограничения
|
||||||
|
|
||||||
|
### Ограничения MVP
|
||||||
|
|
||||||
|
В первую версию не входят:
|
||||||
|
|
||||||
|
- авторизация через Google;
|
||||||
|
- работа с приватными таблицами через OAuth;
|
||||||
|
- полноценный экспорт в `.pptx`;
|
||||||
|
- полноценный экспорт в `.docx`;
|
||||||
|
- личный кабинет;
|
||||||
|
- история генераций;
|
||||||
|
- командная работа;
|
||||||
|
- корпоративные шаблоны;
|
||||||
|
- автоматическое создание графиков;
|
||||||
|
- интеграция с CRM;
|
||||||
|
- поддержка Excel-файлов;
|
||||||
|
- обработка нескольких листов одновременно.
|
||||||
|
|
||||||
|
### Технические ограничения
|
||||||
|
|
||||||
|
- Google Sheets может ограничивать доступ к таблицам.
|
||||||
|
- Публичные таблицы могут содержать чувствительные данные.
|
||||||
|
- Большие таблицы могут обрабатываться медленно.
|
||||||
|
- Данные могут быть плохо структурированы.
|
||||||
|
- AI-модель может ошибаться в интерпретации данных.
|
||||||
|
- Пользователь должен проверять результат перед использованием.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. MVP
|
||||||
|
|
||||||
|
## 11.1. Цель MVP
|
||||||
|
|
||||||
|
Проверить, нужен ли пользователям простой сервис, который превращает данные из Google Таблицы в текстовую справку.
|
||||||
|
|
||||||
|
## 11.2. Обязательные функции MVP
|
||||||
|
|
||||||
|
1. Поле для вставки ссылки на Google Таблицу.
|
||||||
|
2. Проверка корректности ссылки.
|
||||||
|
3. Получение списка листов.
|
||||||
|
4. Выбор одного листа.
|
||||||
|
5. Загрузка данных выбранного листа.
|
||||||
|
6. Определение активных строк и столбцов.
|
||||||
|
7. Анализ структуры таблицы.
|
||||||
|
8. Генерация 3–7 уточняющих вопросов.
|
||||||
|
9. Форма ответов пользователя.
|
||||||
|
10. Генерация текстовой справки.
|
||||||
|
11. Просмотр результата на странице.
|
||||||
|
12. Копирование результата.
|
||||||
|
13. Обработка основных ошибок.
|
||||||
|
|
||||||
|
## 11.3. Необязательные функции MVP
|
||||||
|
|
||||||
|
- генерация структуры презентации;
|
||||||
|
- повторная генерация результата;
|
||||||
|
- изменение стиля документа;
|
||||||
|
- ограниченный предпросмотр таблицы;
|
||||||
|
- экспорт в Markdown.
|
||||||
|
|
||||||
|
## 11.4. Не входит в MVP
|
||||||
|
|
||||||
|
- экспорт в `.pptx`;
|
||||||
|
- экспорт в `.docx`;
|
||||||
|
- экспорт в `.pdf`;
|
||||||
|
- авторизация;
|
||||||
|
- личный кабинет;
|
||||||
|
- сохранение истории;
|
||||||
|
- интеграция с Google Drive;
|
||||||
|
- командные функции.
|
||||||
|
|
||||||
|
## 11.5. MVP-сценарий end-to-end
|
||||||
|
|
||||||
|
1. Пользователь вставляет публичную ссылку на Google Таблицу.
|
||||||
|
2. Система получает список листов.
|
||||||
|
3. Пользователь выбирает один лист.
|
||||||
|
4. Система анализирует активный диапазон.
|
||||||
|
5. Система показывает краткое описание данных.
|
||||||
|
6. Система задаёт уточняющие вопросы.
|
||||||
|
7. Пользователь отвечает.
|
||||||
|
8. Система генерирует текстовую справку.
|
||||||
|
9. Пользователь копирует результат.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Будущие улучшения
|
||||||
|
|
||||||
|
После проверки MVP можно добавить:
|
||||||
|
|
||||||
|
1. Авторизацию через Google.
|
||||||
|
2. Работу с приватными Google Таблицами.
|
||||||
|
3. Экспорт в `.docx`.
|
||||||
|
4. Экспорт в `.pdf`.
|
||||||
|
5. Экспорт в `.pptx`.
|
||||||
|
6. Генерацию полноценной презентации.
|
||||||
|
7. Автоматические графики и диаграммы.
|
||||||
|
8. Шаблоны документов.
|
||||||
|
9. Корпоративный стиль документов.
|
||||||
|
10. Личный кабинет.
|
||||||
|
11. Историю генераций.
|
||||||
|
12. Командную работу.
|
||||||
|
13. Интеграцию с CRM.
|
||||||
|
14. Поддержку Excel-файлов.
|
||||||
|
15. Поддержку нескольких листов.
|
||||||
|
16. Настройку промптов под компанию.
|
||||||
|
17. Проверку качества данных перед генерацией.
|
||||||
|
18. Автоматическое определение типа таблицы: заявки, продажи, финансы, HR, проекты.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Критерии готовности MVP
|
||||||
|
|
||||||
|
MVP можно считать готовым, если:
|
||||||
|
|
||||||
|
1. Пользователь может вставить ссылку на публичную Google Таблицу.
|
||||||
|
2. Система корректно определяет доступность таблицы.
|
||||||
|
3. Система показывает список листов.
|
||||||
|
4. Пользователь может выбрать лист.
|
||||||
|
5. Система определяет активные строки и столбцы.
|
||||||
|
6. Система анализирует заголовки и структуру данных.
|
||||||
|
7. Система задаёт релевантные уточняющие вопросы.
|
||||||
|
8. Пользователь может ответить на вопросы.
|
||||||
|
9. Система генерирует понятную текстовую справку.
|
||||||
|
10. Пользователь может скопировать результат.
|
||||||
|
11. Основные ошибки обрабатываются понятными сообщениями.
|
||||||
|
12. Пользователь без помощи разработчика может пройти сценарий от начала до конца.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Метрики успеха
|
||||||
|
|
||||||
|
### Продуктовые метрики
|
||||||
|
|
||||||
|
1. Доля пользователей, которые дошли от вставки ссылки до готового документа.
|
||||||
|
2. Среднее время от вставки ссылки до получения результата.
|
||||||
|
3. Доля успешных генераций.
|
||||||
|
4. Доля пользователей, которые скопировали результат.
|
||||||
|
5. Доля пользователей, которые повторно воспользовались сервисом.
|
||||||
|
6. Количество ошибок при обработке таблиц.
|
||||||
|
|
||||||
|
### Качественные метрики
|
||||||
|
|
||||||
|
1. Пользователь понимает, что делать на каждом шаге.
|
||||||
|
2. Пользователь считает результат полезным.
|
||||||
|
3. Пользователь готов использовать справку как черновик рабочего документа.
|
||||||
|
4. Пользователь хочет добавить экспорт в `.docx` или `.pptx`.
|
||||||
|
5. Пользователь готов загрузить вторую таблицу после первой генерации.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Критерии успешного выполнения PRD
|
||||||
|
|
||||||
|
Этот PRD можно считать достаточным для перехода к проектированию архитектуры, если:
|
||||||
|
|
||||||
|
1. Понятно, какую проблему решает продукт.
|
||||||
|
2. Понятно, кто целевая аудитория.
|
||||||
|
3. Описан основной пользовательский сценарий end-to-end.
|
||||||
|
4. Определены обязательные функции MVP.
|
||||||
|
5. Зафиксировано, что не входит в первую версию.
|
||||||
|
6. Продуманы ошибки и ограничения.
|
||||||
|
7. Есть понятные критерии готовности.
|
||||||
|
8. Требования можно превратить в backlog задач.
|
||||||
|
9. Документ не перегружен технической реализацией.
|
||||||
|
10. Команда может на его основе перейти к `Architecture.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Открытые вопросы
|
||||||
|
|
||||||
|
Перед проектированием архитектуры нужно дополнительно уточнить:
|
||||||
|
|
||||||
|
1. Кто будет первым целевым пользователем: офисный сотрудник, менеджер, руководитель или аналитик?
|
||||||
|
2. Какие таблицы будут использоваться чаще всего: заявки, продажи, клиенты, финансы, проекты или HR?
|
||||||
|
3. Нужно ли в MVP поддерживать только справки или ещё структуру презентации?
|
||||||
|
4. Должен ли пользователь редактировать результат прямо на сайте?
|
||||||
|
5. Нужно ли сохранять результат после закрытия страницы?
|
||||||
|
6. Нужен ли вход в аккаунт в первой версии?
|
||||||
|
7. Как обрабатывать приватные Google Таблицы?
|
||||||
|
8. Какие лимиты по строкам и столбцам принять для MVP?
|
||||||
|
9. Нужен ли экспорт в `.docx` уже в первой версии?
|
||||||
|
10. Нужно ли учитывать корпоративный стиль документа?
|
||||||
|
11. Должна ли система делать выводы и рекомендации или только структурировать данные?
|
||||||
|
12. Нужно ли предупреждать пользователя, если таблица содержит персональные данные?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Следующий документ после PRD
|
||||||
|
|
||||||
|
После утверждения PRD нужно подготовить файл:
|
||||||
|
|
||||||
|
**`Architecture.md`**
|
||||||
|
|
||||||
|
В нём следует описать:
|
||||||
|
|
||||||
|
- общий технический подход;
|
||||||
|
- frontend;
|
||||||
|
- backend;
|
||||||
|
- работу с Google Sheets;
|
||||||
|
- обработку данных;
|
||||||
|
- AI-логику;
|
||||||
|
- хранение данных;
|
||||||
|
- безопасность;
|
||||||
|
- структуру API;
|
||||||
|
- основные модули системы;
|
||||||
|
- план разработки MVP.
|
||||||
40
README.md
40
README.md
@ -24,3 +24,43 @@
|
|||||||
Изменения сохранятся в Gitea и будут доступны при следующем входе.
|
Изменения сохранятся в Gitea и будут доступны при следующем входе.
|
||||||
|
|
||||||
В этой папке уже инициализирован git и подключен remote `origin` → Gitea.
|
В этой папке уже инициализирован git и подключен remote `origin` → Gitea.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SheetDoc AI: запуск
|
||||||
|
|
||||||
|
Backend (локально):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python -m venv .venv && source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
export ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend (локально, dev-режим):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Dev-сервер на `http://localhost:5173`, проксирует `/api` на `http://localhost:8000`.
|
||||||
|
|
||||||
|
Тесты backend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
Деплой (`sheetdoc.telecom.quest`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # вписать ANTHROPIC_API_KEY
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Сеть `traefik` должна быть создана на сервере: `docker network create traefik` (если ещё нет).
|
||||||
|
|
||||||
|
|||||||
5
backend/app/cache.py
Normal file
5
backend/app/cache.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from cachetools import TTLCache
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
|
profile_cache: TTLCache = TTLCache(maxsize=128, ttl=settings.cache_ttl_seconds)
|
||||||
77
backend/app/column_profiler.py
Normal file
77
backend/app/column_profiler.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
from collections import Counter
|
||||||
|
from dateutil import parser as dateparser
|
||||||
|
|
||||||
|
from .models import ColumnProfile, ColumnType
|
||||||
|
|
||||||
|
|
||||||
|
def _is_empty(s: str) -> bool:
|
||||||
|
return s is None or str(s).strip() == ""
|
||||||
|
|
||||||
|
|
||||||
|
def _is_number(s: str) -> bool:
|
||||||
|
v = str(s).strip().replace(" ", "").replace(",", ".")
|
||||||
|
if not v:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
float(v)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_bool(s: str) -> bool:
|
||||||
|
return str(s).strip().lower() in {"true", "false", "да", "нет", "yes", "no", "0", "1"}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_date(s: str) -> bool:
|
||||||
|
v = str(s).strip()
|
||||||
|
if not v or len(v) < 4:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
dateparser.parse(v, dayfirst=True, fuzzy=False)
|
||||||
|
return True
|
||||||
|
except (ValueError, OverflowError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_type(values: list[str]) -> ColumnType:
|
||||||
|
non_empty = [v for v in values if not _is_empty(v)]
|
||||||
|
if not non_empty:
|
||||||
|
return "empty"
|
||||||
|
|
||||||
|
n = len(non_empty)
|
||||||
|
nums = sum(1 for v in non_empty if _is_number(v))
|
||||||
|
if nums / n >= 0.85:
|
||||||
|
return "number"
|
||||||
|
|
||||||
|
bools = sum(1 for v in non_empty if _is_bool(v))
|
||||||
|
if bools / n >= 0.85 and len({v.strip().lower() for v in non_empty}) <= 4:
|
||||||
|
return "bool"
|
||||||
|
|
||||||
|
dates = sum(1 for v in non_empty if _is_date(v))
|
||||||
|
if dates / n >= 0.7:
|
||||||
|
return "date"
|
||||||
|
|
||||||
|
return "text"
|
||||||
|
|
||||||
|
|
||||||
|
def profile_columns(headers: list[str], data: list[list[str]]) -> list[ColumnProfile]:
|
||||||
|
profiles: list[ColumnProfile] = []
|
||||||
|
total = len(data) if data else 1
|
||||||
|
|
||||||
|
for i, name in enumerate(headers):
|
||||||
|
col = [row[i] if i < len(row) else "" for row in data]
|
||||||
|
non_empty = [v for v in col if not _is_empty(v)]
|
||||||
|
fill = round(len(non_empty) / total, 3) if total else 0.0
|
||||||
|
counter = Counter(v.strip() for v in non_empty)
|
||||||
|
top = [val for val, _ in counter.most_common(3)]
|
||||||
|
profiles.append(
|
||||||
|
ColumnProfile(
|
||||||
|
name=name,
|
||||||
|
type=_infer_type(col),
|
||||||
|
fill=fill,
|
||||||
|
unique=len(counter),
|
||||||
|
top=top,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return profiles
|
||||||
17
backend/app/config.py
Normal file
17
backend/app/config.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
anthropic_api_key: str = ""
|
||||||
|
anthropic_model: str = "claude-sonnet-4-6"
|
||||||
|
allowed_origins: str = "*"
|
||||||
|
max_csv_bytes: int = 5 * 1024 * 1024
|
||||||
|
max_rows: int = 1000
|
||||||
|
max_cols: int = 30
|
||||||
|
cache_ttl_seconds: int = 600
|
||||||
|
static_dir: str = "static"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
42
backend/app/document_generator.py
Normal file
42
backend/app/document_generator.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from .llm_client import llm, load_prompt
|
||||||
|
from .models import GenerateRequest, GenerateResponse
|
||||||
|
|
||||||
|
|
||||||
|
SYSTEM = load_prompt("report_system.md")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_user(req: GenerateRequest, today: str) -> str:
|
||||||
|
sample = req.dataSample[:50]
|
||||||
|
payload = {
|
||||||
|
"today": today,
|
||||||
|
"mode": req.mode,
|
||||||
|
"profile": {
|
||||||
|
"sheetName": req.profile.sheetName,
|
||||||
|
"rows": req.profile.rows,
|
||||||
|
"cols": req.profile.cols,
|
||||||
|
"headers": req.profile.headers,
|
||||||
|
"columns": [c.model_dump() for c in req.profile.columns],
|
||||||
|
},
|
||||||
|
"answers": [a.model_dump() for a in req.answers],
|
||||||
|
"dataSample": sample,
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
"Контекст для генерации:\n\n"
|
||||||
|
f"{json.dumps(payload, ensure_ascii=False, indent=2)}\n\n"
|
||||||
|
"Сгенерируй документ согласно правилам системного промпта."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_document(req: GenerateRequest) -> GenerateResponse:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
today = now.date().isoformat()
|
||||||
|
user = _build_user(req, today)
|
||||||
|
text = await llm.complete(SYSTEM, user, max_tokens=4000, temperature=0.5)
|
||||||
|
return GenerateResponse(
|
||||||
|
document=text,
|
||||||
|
generatedAt=now.isoformat(),
|
||||||
|
mode=req.mode,
|
||||||
|
)
|
||||||
92
backend/app/errors.py
Normal file
92
backend/app/errors.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
|
||||||
|
class DomainError(Exception):
|
||||||
|
code: str
|
||||||
|
status: int = 400
|
||||||
|
what: str = ""
|
||||||
|
why: str = ""
|
||||||
|
next: str = ""
|
||||||
|
|
||||||
|
def __init__(self, what: str = "", why: str = "", next: str = ""):
|
||||||
|
if what:
|
||||||
|
self.what = what
|
||||||
|
if why:
|
||||||
|
self.why = why
|
||||||
|
if next:
|
||||||
|
self.next = next
|
||||||
|
super().__init__(self.what or self.code)
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidUrl(DomainError):
|
||||||
|
code = "INVALID_URL"
|
||||||
|
what = "Ссылка пустая или невалидная."
|
||||||
|
why = "Поле обязательно и должно содержать корректный URL."
|
||||||
|
next = "Скопируйте ссылку из адресной строки браузера."
|
||||||
|
|
||||||
|
|
||||||
|
class NotASheet(DomainError):
|
||||||
|
code = "NOT_A_SHEET"
|
||||||
|
what = "Ссылка не похожа на Google Sheets."
|
||||||
|
why = "Поддерживаются только ссылки вида docs.google.com/spreadsheets/d/<id>."
|
||||||
|
next = "Откройте таблицу в Google Sheets и скопируйте URL целиком."
|
||||||
|
|
||||||
|
|
||||||
|
class SheetForbidden(DomainError):
|
||||||
|
code = "SHEET_FORBIDDEN"
|
||||||
|
status = 403
|
||||||
|
what = "У сервиса нет доступа к таблице."
|
||||||
|
why = "Таблица закрыта или доступ ограничен."
|
||||||
|
next = "Откройте доступ «Все, у кого есть ссылка → Читатель»."
|
||||||
|
|
||||||
|
|
||||||
|
class SheetNotFound(DomainError):
|
||||||
|
code = "SHEET_NOT_FOUND"
|
||||||
|
status = 404
|
||||||
|
what = "Таблица не найдена."
|
||||||
|
why = "Идентификатор таблицы неверен или таблица удалена."
|
||||||
|
next = "Проверьте, что ссылка открывается в браузере."
|
||||||
|
|
||||||
|
|
||||||
|
class SheetEmpty(DomainError):
|
||||||
|
code = "SHEET_EMPTY"
|
||||||
|
what = "Выбранный лист пустой."
|
||||||
|
why = "В листе нет строк с данными."
|
||||||
|
next = "Выберите другой лист или добавьте данные."
|
||||||
|
|
||||||
|
|
||||||
|
class SheetTooLarge(DomainError):
|
||||||
|
code = "SHEET_TOO_LARGE"
|
||||||
|
what = "В таблице слишком много данных для MVP."
|
||||||
|
why = "Лимит MVP — 1000 строк × 30 колонок."
|
||||||
|
next = "Сузьте диапазон или удалите неиспользуемые колонки."
|
||||||
|
|
||||||
|
|
||||||
|
class StructureUnclear(DomainError):
|
||||||
|
code = "STRUCTURE_UNCLEAR"
|
||||||
|
what = "Не удалось уверенно определить структуру таблицы."
|
||||||
|
why = "Заголовки не однозначны или строки сильно разнородны."
|
||||||
|
next = "Ответьте на дополнительные вопросы о структуре."
|
||||||
|
|
||||||
|
|
||||||
|
class LlmFailed(DomainError):
|
||||||
|
code = "LLM_FAILED"
|
||||||
|
status = 502
|
||||||
|
what = "Не удалось сгенерировать ответ модели."
|
||||||
|
why = "Временная ошибка модели или превышено время ожидания."
|
||||||
|
next = "Повторите попытку через несколько секунд."
|
||||||
|
|
||||||
|
|
||||||
|
def to_response(err: DomainError) -> JSONResponse:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=err.status,
|
||||||
|
content={
|
||||||
|
"error": {
|
||||||
|
"code": err.code,
|
||||||
|
"what": err.what,
|
||||||
|
"why": err.why,
|
||||||
|
"next": err.next,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
82
backend/app/llm_client.py
Normal file
82
backend/app/llm_client.py
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from anthropic import AsyncAnthropic, APIError
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .errors import LlmFailed
|
||||||
|
|
||||||
|
|
||||||
|
PROMPTS_DIR = Path(__file__).resolve().parent.parent / "prompts"
|
||||||
|
|
||||||
|
|
||||||
|
def load_prompt(name: str) -> str:
|
||||||
|
return (PROMPTS_DIR / name).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class LlmClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._client: Optional[AsyncAnthropic] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client(self) -> AsyncAnthropic:
|
||||||
|
if self._client is None:
|
||||||
|
if not settings.anthropic_api_key:
|
||||||
|
raise LlmFailed(
|
||||||
|
what="API-ключ не настроен.",
|
||||||
|
why="ANTHROPIC_API_KEY не задан.",
|
||||||
|
next="Задайте переменную окружения и перезапустите сервис.",
|
||||||
|
)
|
||||||
|
self._client = AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
async def complete(
|
||||||
|
self,
|
||||||
|
system: str,
|
||||||
|
user: str,
|
||||||
|
max_tokens: int = 2048,
|
||||||
|
temperature: float = 0.4,
|
||||||
|
) -> str:
|
||||||
|
last_err: Optional[Exception] = None
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
msg = await self.client.messages.create(
|
||||||
|
model=settings.anthropic_model,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
temperature=temperature,
|
||||||
|
system=[
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": system,
|
||||||
|
"cache_control": {"type": "ephemeral"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
messages=[{"role": "user", "content": user}],
|
||||||
|
)
|
||||||
|
parts = [b.text for b in msg.content if getattr(b, "text", None)]
|
||||||
|
return "".join(parts).strip()
|
||||||
|
except APIError as e:
|
||||||
|
last_err = e
|
||||||
|
await asyncio.sleep(2 ** attempt)
|
||||||
|
raise LlmFailed(why=f"Anthropic API: {last_err}")
|
||||||
|
|
||||||
|
async def complete_json(self, system: str, user: str, max_tokens: int = 2048) -> dict:
|
||||||
|
raw = await self.complete(system, user, max_tokens=max_tokens, temperature=0.2)
|
||||||
|
raw = raw.strip()
|
||||||
|
if raw.startswith("```"):
|
||||||
|
raw = raw.strip("`")
|
||||||
|
if raw.startswith("json"):
|
||||||
|
raw = raw[4:]
|
||||||
|
raw = raw.strip()
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise LlmFailed(
|
||||||
|
why=f"Модель вернула невалидный JSON: {e}.",
|
||||||
|
next="Повторите попытку.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
llm = LlmClient()
|
||||||
18
backend/app/logging_setup.py
Normal file
18
backend/app/logging_setup.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import logging
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging() -> None:
|
||||||
|
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||||
|
structlog.configure(
|
||||||
|
processors=[
|
||||||
|
structlog.processors.add_log_level,
|
||||||
|
structlog.processors.TimeStamper(fmt="iso"),
|
||||||
|
structlog.processors.JSONRenderer(),
|
||||||
|
],
|
||||||
|
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
|
||||||
|
cache_logger_on_first_use=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
log = structlog.get_logger()
|
||||||
168
backend/app/main.py
Normal file
168
backend/app/main.py
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from slowapi import Limiter
|
||||||
|
from slowapi.errors import RateLimitExceeded
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .cache import profile_cache
|
||||||
|
from .errors import (
|
||||||
|
DomainError,
|
||||||
|
SheetEmpty,
|
||||||
|
SheetTooLarge,
|
||||||
|
to_response,
|
||||||
|
)
|
||||||
|
from .logging_setup import setup_logging, log
|
||||||
|
from .models import (
|
||||||
|
GenerateRequest,
|
||||||
|
GenerateResponse,
|
||||||
|
LoadRequest,
|
||||||
|
LoadResponse,
|
||||||
|
QuestionsRequest,
|
||||||
|
QuestionsResponse,
|
||||||
|
ResolveRequest,
|
||||||
|
ResolveResponse,
|
||||||
|
SheetInfo,
|
||||||
|
)
|
||||||
|
from . import sheets_client, range_detector, column_profiler
|
||||||
|
from .question_generator import generate_questions
|
||||||
|
from .document_generator import generate_document
|
||||||
|
|
||||||
|
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_: FastAPI):
|
||||||
|
setup_logging()
|
||||||
|
log.info("startup", model=settings.anthropic_model)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="SheetDoc AI", lifespan=lifespan)
|
||||||
|
app.state.limiter = limiter
|
||||||
|
|
||||||
|
origins = [o.strip() for o in settings.allowed_origins.split(",") if o.strip()]
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=origins or ["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def access_log(request: Request, call_next):
|
||||||
|
start = time.perf_counter()
|
||||||
|
response = await call_next(request)
|
||||||
|
latency = int((time.perf_counter() - start) * 1000)
|
||||||
|
log.info(
|
||||||
|
"request",
|
||||||
|
method=request.method,
|
||||||
|
path=request.url.path,
|
||||||
|
status=response.status_code,
|
||||||
|
latency_ms=latency,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(DomainError)
|
||||||
|
async def handle_domain(_: Request, exc: DomainError):
|
||||||
|
return to_response(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(RateLimitExceeded)
|
||||||
|
async def handle_rate(_: Request, exc: RateLimitExceeded):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=429,
|
||||||
|
content={
|
||||||
|
"error": {
|
||||||
|
"code": "RATE_LIMITED",
|
||||||
|
"what": "Слишком много запросов.",
|
||||||
|
"why": str(exc.detail),
|
||||||
|
"next": "Подождите минуту и повторите.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health() -> dict:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/sheets/resolve", response_model=ResolveResponse)
|
||||||
|
async def resolve(req: ResolveRequest) -> ResolveResponse:
|
||||||
|
sid, _ = sheets_client.parse_url(req.url)
|
||||||
|
title, sheets = await sheets_client.fetch_meta(sid)
|
||||||
|
return ResolveResponse(
|
||||||
|
spreadsheetId=sid,
|
||||||
|
title=title,
|
||||||
|
sheets=[SheetInfo(gid=gid, name=name) for gid, name in sheets],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/sheets/load", response_model=LoadResponse)
|
||||||
|
async def load(req: LoadRequest) -> LoadResponse:
|
||||||
|
cache_key = f"{req.spreadsheetId}:{req.gid}"
|
||||||
|
if cache_key in profile_cache:
|
||||||
|
return profile_cache[cache_key]
|
||||||
|
|
||||||
|
rows = await sheets_client.fetch_csv(req.spreadsheetId, req.gid)
|
||||||
|
headers, data = range_detector.detect_active_range(rows)
|
||||||
|
if not headers or not data:
|
||||||
|
raise SheetEmpty()
|
||||||
|
|
||||||
|
if len(data) > settings.max_rows or len(headers) > settings.max_cols:
|
||||||
|
raise SheetTooLarge(
|
||||||
|
why=(
|
||||||
|
f"Найдено {len(data)} строк × {len(headers)} колонок. "
|
||||||
|
f"Лимит MVP — {settings.max_rows} × {settings.max_cols}."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
columns = column_profiler.profile_columns(headers, data)
|
||||||
|
|
||||||
|
sheet_name = "Лист"
|
||||||
|
try:
|
||||||
|
_, sheets = await sheets_client.fetch_meta(req.spreadsheetId)
|
||||||
|
for gid, name in sheets:
|
||||||
|
if gid == req.gid:
|
||||||
|
sheet_name = name
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
resp = LoadResponse(
|
||||||
|
rows=len(data),
|
||||||
|
cols=len(headers),
|
||||||
|
sheetName=sheet_name,
|
||||||
|
headers=headers,
|
||||||
|
columns=columns,
|
||||||
|
preview=[[str(c) for c in row] for row in data[:10]],
|
||||||
|
)
|
||||||
|
profile_cache[cache_key] = resp
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/analyze/questions", response_model=QuestionsResponse)
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def questions(request: Request, req: QuestionsRequest) -> QuestionsResponse:
|
||||||
|
return await generate_questions(req.profile)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/generate", response_model=GenerateResponse)
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def generate(request: Request, req: GenerateRequest) -> GenerateResponse:
|
||||||
|
return await generate_document(req)
|
||||||
|
|
||||||
|
|
||||||
|
static_path = Path(__file__).resolve().parent.parent / settings.static_dir
|
||||||
|
if static_path.exists():
|
||||||
|
app.mount("/", StaticFiles(directory=static_path, html=True), name="static")
|
||||||
80
backend/app/models.py
Normal file
80
backend/app/models.py
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
from typing import Literal, Optional
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
ColumnType = Literal["text", "number", "date", "bool", "empty"]
|
||||||
|
QuestionKind = Literal["single", "multi", "text"]
|
||||||
|
GenerateMode = Literal["report", "presentation_outline"]
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveRequest(BaseModel):
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class SheetInfo(BaseModel):
|
||||||
|
gid: str
|
||||||
|
name: str
|
||||||
|
isEmpty: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveResponse(BaseModel):
|
||||||
|
spreadsheetId: str
|
||||||
|
title: str
|
||||||
|
sheets: list[SheetInfo]
|
||||||
|
|
||||||
|
|
||||||
|
class LoadRequest(BaseModel):
|
||||||
|
spreadsheetId: str
|
||||||
|
gid: str
|
||||||
|
|
||||||
|
|
||||||
|
class ColumnProfile(BaseModel):
|
||||||
|
name: str
|
||||||
|
type: ColumnType
|
||||||
|
fill: float
|
||||||
|
unique: int
|
||||||
|
top: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class LoadResponse(BaseModel):
|
||||||
|
rows: int
|
||||||
|
cols: int
|
||||||
|
sheetName: str
|
||||||
|
headers: list[str]
|
||||||
|
columns: list[ColumnProfile]
|
||||||
|
preview: list[list[str]]
|
||||||
|
|
||||||
|
|
||||||
|
class Question(BaseModel):
|
||||||
|
id: str
|
||||||
|
text: str
|
||||||
|
why: str
|
||||||
|
optional: bool = False
|
||||||
|
kind: QuestionKind = "text"
|
||||||
|
options: Optional[list[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionsRequest(BaseModel):
|
||||||
|
profile: LoadResponse
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionsResponse(BaseModel):
|
||||||
|
questions: list[Question]
|
||||||
|
|
||||||
|
|
||||||
|
class Answer(BaseModel):
|
||||||
|
id: str
|
||||||
|
value: str
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateRequest(BaseModel):
|
||||||
|
profile: LoadResponse
|
||||||
|
answers: list[Answer]
|
||||||
|
mode: GenerateMode = "report"
|
||||||
|
dataSample: list[list[str]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateResponse(BaseModel):
|
||||||
|
document: str
|
||||||
|
generatedAt: str
|
||||||
|
mode: GenerateMode
|
||||||
37
backend/app/question_generator.py
Normal file
37
backend/app/question_generator.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from .errors import LlmFailed
|
||||||
|
from .llm_client import llm, load_prompt
|
||||||
|
from .models import LoadResponse, Question, QuestionsResponse
|
||||||
|
|
||||||
|
|
||||||
|
SYSTEM = load_prompt("questions_system.md")
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_payload(profile: LoadResponse) -> str:
|
||||||
|
payload = {
|
||||||
|
"sheetName": profile.sheetName,
|
||||||
|
"rows": profile.rows,
|
||||||
|
"cols": profile.cols,
|
||||||
|
"headers": profile.headers,
|
||||||
|
"columns": [c.model_dump() for c in profile.columns],
|
||||||
|
"previewRows": profile.preview[:5],
|
||||||
|
}
|
||||||
|
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_questions(profile: LoadResponse) -> QuestionsResponse:
|
||||||
|
user = (
|
||||||
|
"Профиль таблицы:\n\n"
|
||||||
|
f"{_profile_payload(profile)}\n\n"
|
||||||
|
"Сформируй вопросы строго по схеме из системного промпта."
|
||||||
|
)
|
||||||
|
data = await llm.complete_json(SYSTEM, user, max_tokens=1500)
|
||||||
|
raw_qs = data.get("questions") if isinstance(data, dict) else None
|
||||||
|
if not isinstance(raw_qs, list) or not (3 <= len(raw_qs) <= 7):
|
||||||
|
raise LlmFailed(
|
||||||
|
why="Модель вернула неверное число вопросов.",
|
||||||
|
next="Повторите попытку.",
|
||||||
|
)
|
||||||
|
questions = [Question.model_validate(q) for q in raw_qs]
|
||||||
|
return QuestionsResponse(questions=questions)
|
||||||
65
backend/app/range_detector.py
Normal file
65
backend/app/range_detector.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
def _is_empty(cell: str) -> bool:
|
||||||
|
return cell is None or str(cell).strip() == ""
|
||||||
|
|
||||||
|
|
||||||
|
def _row_empty(row: list[str]) -> bool:
|
||||||
|
return all(_is_empty(c) for c in row)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_active_range(rows: list[list[str]]) -> tuple[list[str], list[list[str]]]:
|
||||||
|
if not rows:
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
width = max(len(r) for r in rows)
|
||||||
|
norm = [r + [""] * (width - len(r)) for r in rows]
|
||||||
|
|
||||||
|
start = 0
|
||||||
|
while start < len(norm) and _row_empty(norm[start]):
|
||||||
|
start += 1
|
||||||
|
end = len(norm)
|
||||||
|
while end > start and _row_empty(norm[end - 1]):
|
||||||
|
end -= 1
|
||||||
|
if start >= end:
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
body = norm[start:end]
|
||||||
|
|
||||||
|
col_has_data = [False] * width
|
||||||
|
for r in body:
|
||||||
|
for i, c in enumerate(r):
|
||||||
|
if not _is_empty(c):
|
||||||
|
col_has_data[i] = True
|
||||||
|
|
||||||
|
keep_cols = [i for i, ok in enumerate(col_has_data) if ok]
|
||||||
|
if not keep_cols:
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
trimmed = [[r[i] for i in keep_cols] for r in body]
|
||||||
|
|
||||||
|
header_idx = _find_header_row(trimmed)
|
||||||
|
headers = [str(c).strip() for c in trimmed[header_idx]]
|
||||||
|
headers = [h if h else f"Колонка {i + 1}" for i, h in enumerate(headers)]
|
||||||
|
data = trimmed[header_idx + 1:]
|
||||||
|
return headers, data
|
||||||
|
|
||||||
|
|
||||||
|
def _find_header_row(rows: list[list[str]]) -> int:
|
||||||
|
for i, row in enumerate(rows[:5]):
|
||||||
|
non_empty = [c for c in row if not _is_empty(c)]
|
||||||
|
if not non_empty:
|
||||||
|
continue
|
||||||
|
non_numeric = sum(1 for c in non_empty if not _looks_numeric(c))
|
||||||
|
if non_numeric / len(non_empty) >= 0.7:
|
||||||
|
return i
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_numeric(s: str) -> bool:
|
||||||
|
s = s.strip().replace(" ", "").replace(",", ".")
|
||||||
|
if not s:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
float(s)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
96
backend/app/sheets_client.py
Normal file
96
backend/app/sheets_client.py
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .errors import (
|
||||||
|
InvalidUrl,
|
||||||
|
NotASheet,
|
||||||
|
SheetForbidden,
|
||||||
|
SheetNotFound,
|
||||||
|
)
|
||||||
|
|
||||||
|
SHEET_ID_RE = re.compile(r"/spreadsheets/d/([a-zA-Z0-9-_]+)")
|
||||||
|
GID_RE = re.compile(r"[?#&]gid=([0-9]+)")
|
||||||
|
TITLE_RE = re.compile(r"<title>(.*?)</title>", re.IGNORECASE | re.DOTALL)
|
||||||
|
SHEET_NAME_RE = re.compile(
|
||||||
|
r'\{"name":"([^"]+)"[^\}]*"gid":"?(\d+)"?', re.DOTALL
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_url(url: str) -> tuple[str, Optional[str]]:
|
||||||
|
if not url or not url.strip():
|
||||||
|
raise InvalidUrl()
|
||||||
|
m = SHEET_ID_RE.search(url)
|
||||||
|
if not m:
|
||||||
|
raise NotASheet()
|
||||||
|
sid = m.group(1)
|
||||||
|
g = GID_RE.search(url)
|
||||||
|
gid = g.group(1) if g else None
|
||||||
|
return sid, gid
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_meta(spreadsheet_id: str) -> tuple[str, list[tuple[str, str]]]:
|
||||||
|
url = f"https://docs.google.com/spreadsheets/d/{spreadsheet_id}/htmlview"
|
||||||
|
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
|
||||||
|
r = await client.get(url)
|
||||||
|
if r.status_code in (401, 403):
|
||||||
|
raise SheetForbidden()
|
||||||
|
if r.status_code == 404:
|
||||||
|
raise SheetNotFound()
|
||||||
|
if r.status_code >= 400:
|
||||||
|
raise SheetNotFound(why=f"Google ответил {r.status_code}.")
|
||||||
|
|
||||||
|
html = r.text
|
||||||
|
title_match = TITLE_RE.search(html)
|
||||||
|
title = "Без названия"
|
||||||
|
if title_match:
|
||||||
|
title = title_match.group(1).split(" - Google ")[0].strip() or title
|
||||||
|
|
||||||
|
sheets: list[tuple[str, str]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for m in SHEET_NAME_RE.finditer(html):
|
||||||
|
name, gid = m.group(1), m.group(2)
|
||||||
|
if gid in seen:
|
||||||
|
continue
|
||||||
|
seen.add(gid)
|
||||||
|
sheets.append((gid, name))
|
||||||
|
|
||||||
|
if not sheets:
|
||||||
|
gid_m = re.search(r'"gid":"?(\d+)"?', html)
|
||||||
|
sheets = [(gid_m.group(1) if gid_m else "0", "Лист1")]
|
||||||
|
|
||||||
|
return title, sheets
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_csv(spreadsheet_id: str, gid: str) -> list[list[str]]:
|
||||||
|
url = (
|
||||||
|
f"https://docs.google.com/spreadsheets/d/{spreadsheet_id}/export"
|
||||||
|
f"?format=csv&gid={gid}"
|
||||||
|
)
|
||||||
|
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||||
|
async with client.stream("GET", url) as r:
|
||||||
|
if r.status_code in (401, 403):
|
||||||
|
raise SheetForbidden()
|
||||||
|
if r.status_code == 404:
|
||||||
|
raise SheetNotFound()
|
||||||
|
if r.status_code >= 400:
|
||||||
|
raise SheetNotFound(why=f"Google ответил {r.status_code}.")
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
total = 0
|
||||||
|
async for chunk in r.aiter_bytes():
|
||||||
|
total += len(chunk)
|
||||||
|
if total > settings.max_csv_bytes:
|
||||||
|
raise SheetForbidden(
|
||||||
|
what="Файл слишком большой.",
|
||||||
|
why=f"Лимит {settings.max_csv_bytes // 1024 // 1024} MB.",
|
||||||
|
next="Сузьте диапазон таблицы.",
|
||||||
|
)
|
||||||
|
chunks.append(chunk)
|
||||||
|
data = b"".join(chunks)
|
||||||
|
|
||||||
|
text = data.decode("utf-8", errors="replace")
|
||||||
|
reader = csv.reader(io.StringIO(text))
|
||||||
|
return [row for row in reader]
|
||||||
41
backend/prompts/questions_system.md
Normal file
41
backend/prompts/questions_system.md
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
Ты помощник, который готовит уточняющие вопросы пользователю перед генерацией документа из данных Google Таблицы.
|
||||||
|
|
||||||
|
Тебе на вход дают профиль таблицы: название листа, число строк/колонок, заголовки и типы колонок, топ-значения, заполненность.
|
||||||
|
|
||||||
|
Твоя задача — сформировать от 3 до 7 коротких уточняющих вопросов, которые помогут понять:
|
||||||
|
- для кого готовится документ;
|
||||||
|
- какой тип документа нужен (справка, отчёт, структура презентации);
|
||||||
|
- какой стиль изложения нужен;
|
||||||
|
- какие колонки ключевые;
|
||||||
|
- нужны ли выводы и рекомендации;
|
||||||
|
- какой период / фильтр учитывать.
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
- 3 ≤ количество вопросов ≤ 7;
|
||||||
|
- ровно один вопрос про тип документа (kind=single, options обязательны);
|
||||||
|
- ровно один вопрос про стиль (kind=single, options обязательны);
|
||||||
|
- не задавай уточнения, которые уже однозначно следуют из данных;
|
||||||
|
- помечай optional=true, если ответ не критичен;
|
||||||
|
- все тексты — на русском языке.
|
||||||
|
|
||||||
|
Отвечай строго JSON, без markdown-обёрток. Схема:
|
||||||
|
|
||||||
|
{
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"id": "q1",
|
||||||
|
"text": "Кратко: для кого готовится документ?",
|
||||||
|
"why": "Чтобы подобрать тон и уровень детализации.",
|
||||||
|
"optional": false,
|
||||||
|
"kind": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "q2",
|
||||||
|
"text": "Какой тип документа нужен?",
|
||||||
|
"why": "Определяет структуру результата.",
|
||||||
|
"optional": false,
|
||||||
|
"kind": "single",
|
||||||
|
"options": ["Справка", "Аналитический отчёт", "Структура презентации"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
27
backend/prompts/report_system.md
Normal file
27
backend/prompts/report_system.md
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
Ты помощник, который пишет деловые документы по данным из Google Таблицы.
|
||||||
|
|
||||||
|
Тебе передают:
|
||||||
|
- профиль таблицы (заголовки, типы колонок, заполненность, топ-значения, число строк);
|
||||||
|
- ответы пользователя на уточняющие вопросы;
|
||||||
|
- образец первых строк данных (до 50);
|
||||||
|
- режим: "report" (текстовая справка / аналитический отчёт) или "presentation_outline" (структура презентации).
|
||||||
|
|
||||||
|
Требования к режиму "report":
|
||||||
|
- структура: заголовок, краткое описание источника, основные показатели, ключевые наблюдения, выводы, рекомендации (если запрошены), дата генерации;
|
||||||
|
- стиль соответствует ответу пользователя (официальный / краткий / аналитический / управленческий);
|
||||||
|
- не выдумывай числа: используй только агрегированные данные из переданного образца;
|
||||||
|
- если данных недостаточно для вывода — честно скажи об этом.
|
||||||
|
|
||||||
|
Требования к режиму "presentation_outline":
|
||||||
|
- название презентации;
|
||||||
|
- цель презентации;
|
||||||
|
- 5–9 слайдов: заголовок слайда, 3–5 ключевых тезисов, предложение по визуализации (график/таблица).
|
||||||
|
|
||||||
|
Общие правила:
|
||||||
|
- язык документа — русский;
|
||||||
|
- формат — Markdown (заголовки `##`, списки `-`);
|
||||||
|
- никаких преамбул вида «вот ваш документ»;
|
||||||
|
- никаких пояснений после документа;
|
||||||
|
- дату генерации укажи сегодняшней датой, переданной в контексте.
|
||||||
|
|
||||||
|
Отвечай ТОЛЬКО Markdown-текстом документа, без обёрток в JSON или код-блоки.
|
||||||
3
backend/pyproject.toml
Normal file
3
backend/pyproject.toml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["."]
|
||||||
10
backend/requirements.txt
Normal file
10
backend/requirements.txt
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
httpx==0.27.2
|
||||||
|
pydantic==2.9.2
|
||||||
|
pydantic-settings==2.5.2
|
||||||
|
anthropic==0.39.0
|
||||||
|
python-dateutil==2.9.0
|
||||||
|
structlog==24.4.0
|
||||||
|
cachetools==5.5.0
|
||||||
|
slowapi==0.1.9
|
||||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
23
backend/tests/test_column_profiler.py
Normal file
23
backend/tests/test_column_profiler.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
from app.column_profiler import profile_columns
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic_types():
|
||||||
|
headers = ["Имя", "Сумма", "Дата", "Активен"]
|
||||||
|
data = [
|
||||||
|
["Иван", "100", "2024-01-05", "true"],
|
||||||
|
["Пётр", "200", "2024-02-15", "false"],
|
||||||
|
["Анна", "350", "2024-03-20", "true"],
|
||||||
|
]
|
||||||
|
profiles = profile_columns(headers, data)
|
||||||
|
by_name = {p.name: p for p in profiles}
|
||||||
|
assert by_name["Имя"].type == "text"
|
||||||
|
assert by_name["Сумма"].type == "number"
|
||||||
|
assert by_name["Дата"].type == "date"
|
||||||
|
assert by_name["Активен"].type == "bool"
|
||||||
|
assert by_name["Имя"].fill == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_fill_ratio():
|
||||||
|
profiles = profile_columns(["X"], [["a"], [""], ["b"], [""]])
|
||||||
|
assert profiles[0].fill == 0.5
|
||||||
|
assert profiles[0].unique == 2
|
||||||
27
backend/tests/test_range_detector.py
Normal file
27
backend/tests/test_range_detector.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
from app.range_detector import detect_active_range
|
||||||
|
|
||||||
|
|
||||||
|
def test_trims_empty_rows_and_cols():
|
||||||
|
rows = [
|
||||||
|
["", "", "", ""],
|
||||||
|
["", "Имя", "Сумма", ""],
|
||||||
|
["", "Иван", "100", ""],
|
||||||
|
["", "Пётр", "200", ""],
|
||||||
|
["", "", "", ""],
|
||||||
|
]
|
||||||
|
headers, data = detect_active_range(rows)
|
||||||
|
assert headers == ["Имя", "Сумма"]
|
||||||
|
assert data == [["Иван", "100"], ["Пётр", "200"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_returns_empty():
|
||||||
|
headers, data = detect_active_range([["", ""], ["", ""]])
|
||||||
|
assert headers == []
|
||||||
|
assert data == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_fills_blank_header_name():
|
||||||
|
rows = [["Имя", "", "Сумма"], ["Иван", "x", "100"]]
|
||||||
|
headers, data = detect_active_range(rows)
|
||||||
|
assert headers[1] == "Колонка 2"
|
||||||
|
assert len(data) == 1
|
||||||
29
backend/tests/test_sheets_url.py
Normal file
29
backend/tests/test_sheets_url.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import pytest
|
||||||
|
from app.sheets_client import parse_url
|
||||||
|
from app.errors import InvalidUrl, NotASheet
|
||||||
|
|
||||||
|
|
||||||
|
def test_parses_valid_url():
|
||||||
|
sid, gid = parse_url(
|
||||||
|
"https://docs.google.com/spreadsheets/d/1AbCDefGh-12345/edit#gid=42"
|
||||||
|
)
|
||||||
|
assert sid == "1AbCDefGh-12345"
|
||||||
|
assert gid == "42"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_gid():
|
||||||
|
sid, gid = parse_url(
|
||||||
|
"https://docs.google.com/spreadsheets/d/1AbCDefGh-12345/edit"
|
||||||
|
)
|
||||||
|
assert sid == "1AbCDefGh-12345"
|
||||||
|
assert gid is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_empty():
|
||||||
|
with pytest.raises(InvalidUrl):
|
||||||
|
parse_url("")
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_a_sheet():
|
||||||
|
with pytest.raises(NotASheet):
|
||||||
|
parse_url("https://example.com/foo")
|
||||||
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
services:
|
||||||
|
sheetdoc:
|
||||||
|
build: .
|
||||||
|
image: sheetdoc:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
|
||||||
|
ANTHROPIC_MODEL: ${ANTHROPIC_MODEL:-claude-sonnet-4-6}
|
||||||
|
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-https://sheetdoc.telecom.quest}
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=traefik"
|
||||||
|
- "traefik.http.routers.sheetdoc.rule=Host(`sheetdoc.telecom.quest`)"
|
||||||
|
- "traefik.http.routers.sheetdoc.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.sheetdoc.tls=true"
|
||||||
|
- "traefik.http.services.sheetdoc.loadbalancer.server.port=8000"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
traefik:
|
||||||
|
external: true
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>SheetDoc AI</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-slate-50">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2731
frontend/package-lock.json
generated
Normal file
2731
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
frontend/package.json
Normal file
27
frontend/package.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "sheetdoc-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"marked": "^14.1.3",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"zustand": "^4.5.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.11",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"tailwindcss": "^3.4.13",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"vite": "^5.4.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
58
frontend/src/App.tsx
Normal file
58
frontend/src/App.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { useStore, Step } from "./lib/store";
|
||||||
|
import { StepUrl } from "./components/StepUrl";
|
||||||
|
import { StepSheet } from "./components/StepSheet";
|
||||||
|
import { StepPreview } from "./components/StepPreview";
|
||||||
|
import { StepQuestions } from "./components/StepQuestions";
|
||||||
|
import { StepResult } from "./components/StepResult";
|
||||||
|
|
||||||
|
const STEPS: { key: Step; label: string }[] = [
|
||||||
|
{ key: "url", label: "Ссылка" },
|
||||||
|
{ key: "sheet", label: "Лист" },
|
||||||
|
{ key: "preview", label: "Анализ" },
|
||||||
|
{ key: "questions", label: "Вопросы" },
|
||||||
|
{ key: "result", label: "Результат" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { step } = useStore();
|
||||||
|
const activeIdx = STEPS.findIndex((s) => s.key === step);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<header className="bg-white border-b">
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||||
|
<h1 className="text-lg font-semibold">SheetDoc AI</h1>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
MVP · данные не сохраняются
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-3xl mx-auto px-4 py-6">
|
||||||
|
<nav className="flex items-center gap-2 mb-6 text-sm">
|
||||||
|
{STEPS.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={s.key}
|
||||||
|
className={
|
||||||
|
"px-2 py-1 rounded " +
|
||||||
|
(i === activeIdx
|
||||||
|
? "bg-indigo-600 text-white"
|
||||||
|
: i < activeIdx
|
||||||
|
? "bg-indigo-100 text-indigo-700"
|
||||||
|
: "bg-slate-100 text-slate-500")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{i + 1}. {s.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{step === "url" && <StepUrl />}
|
||||||
|
{step === "sheet" && <StepSheet />}
|
||||||
|
{step === "preview" && <StepPreview />}
|
||||||
|
{step === "questions" && <StepQuestions />}
|
||||||
|
{step === "result" && <StepResult />}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
frontend/src/components/ErrorBox.tsx
Normal file
20
frontend/src/components/ErrorBox.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { ApiError } from "../lib/types";
|
||||||
|
|
||||||
|
export function ErrorBox({ err, onRetry }: { err: ApiError | null; onRetry?: () => void }) {
|
||||||
|
if (!err) return null;
|
||||||
|
return (
|
||||||
|
<div className="border border-red-200 bg-red-50 text-red-900 rounded-md p-4 my-4">
|
||||||
|
<div className="font-semibold mb-1">{err.what}</div>
|
||||||
|
{err.why && <div className="text-sm opacity-80">{err.why}</div>}
|
||||||
|
{err.next && <div className="text-sm mt-2">→ {err.next}</div>}
|
||||||
|
{onRetry && (
|
||||||
|
<button
|
||||||
|
className="mt-3 px-3 py-1.5 bg-red-600 text-white rounded hover:bg-red-700 text-sm"
|
||||||
|
onClick={onRetry}
|
||||||
|
>
|
||||||
|
Повторить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
frontend/src/components/StepPreview.tsx
Normal file
106
frontend/src/components/StepPreview.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, SheetDocError } from "../lib/api";
|
||||||
|
import { useStore } from "../lib/store";
|
||||||
|
import { ApiError } from "../lib/types";
|
||||||
|
import { ErrorBox } from "./ErrorBox";
|
||||||
|
|
||||||
|
export function StepPreview() {
|
||||||
|
const {
|
||||||
|
resolve,
|
||||||
|
selectedGid,
|
||||||
|
profile,
|
||||||
|
setProfile,
|
||||||
|
setQuestions,
|
||||||
|
setStep,
|
||||||
|
} = useStore();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [genLoading, setGenLoading] = useState(false);
|
||||||
|
const [err, setErr] = useState<ApiError | null>(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!resolve || !selectedGid) return;
|
||||||
|
setErr(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const p = await api.load(resolve.spreadsheetId, selectedGid);
|
||||||
|
setProfile(p);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof SheetDocError) setErr(e.payload);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!profile) load();
|
||||||
|
}, [selectedGid]);
|
||||||
|
|
||||||
|
async function next() {
|
||||||
|
if (!profile) return;
|
||||||
|
setErr(null);
|
||||||
|
setGenLoading(true);
|
||||||
|
try {
|
||||||
|
const r = await api.questions(profile);
|
||||||
|
setQuestions(r.questions);
|
||||||
|
setStep("questions");
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof SheetDocError) setErr(e.payload);
|
||||||
|
} finally {
|
||||||
|
setGenLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-semibold">Шаг 3. Анализ данных</h2>
|
||||||
|
{loading && <div className="text-slate-600">Загружаем лист…</div>}
|
||||||
|
{profile && (
|
||||||
|
<div className="bg-white border rounded-md p-4 space-y-3">
|
||||||
|
<div className="text-sm text-slate-600">
|
||||||
|
Лист: <b>{profile.sheetName}</b> · {profile.rows} строк · {profile.cols} колонок
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="text-sm w-full">
|
||||||
|
<thead className="bg-slate-100">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-2">Колонка</th>
|
||||||
|
<th className="text-left p-2">Тип</th>
|
||||||
|
<th className="text-left p-2">Заполнено</th>
|
||||||
|
<th className="text-left p-2">Уникальных</th>
|
||||||
|
<th className="text-left p-2">Топ значений</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{profile.columns.map((c) => (
|
||||||
|
<tr key={c.name} className="border-t">
|
||||||
|
<td className="p-2 font-medium">{c.name}</td>
|
||||||
|
<td className="p-2">{c.type}</td>
|
||||||
|
<td className="p-2">{Math.round(c.fill * 100)}%</td>
|
||||||
|
<td className="p-2">{c.unique}</td>
|
||||||
|
<td className="p-2 text-slate-600">{c.top.join(", ")}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ErrorBox err={err} onRetry={load} />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setStep(resolve && resolve.sheets.length > 1 ? "sheet" : "url")}
|
||||||
|
className="px-4 py-2 border rounded-md hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={!profile || genLoading}
|
||||||
|
onClick={next}
|
||||||
|
className="px-4 py-2 bg-indigo-600 text-white rounded-md disabled:opacity-50 hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
{genLoading ? "Готовим вопросы…" : "К уточняющим вопросам"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
frontend/src/components/StepQuestions.tsx
Normal file
131
frontend/src/components/StepQuestions.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { api, SheetDocError } from "../lib/api";
|
||||||
|
import { useStore } from "../lib/store";
|
||||||
|
import { ApiError, GenerateMode } from "../lib/types";
|
||||||
|
import { ErrorBox } from "./ErrorBox";
|
||||||
|
|
||||||
|
export function StepQuestions() {
|
||||||
|
const {
|
||||||
|
questions,
|
||||||
|
answers,
|
||||||
|
setAnswer,
|
||||||
|
profile,
|
||||||
|
answersArray,
|
||||||
|
setResult,
|
||||||
|
setStep,
|
||||||
|
mode,
|
||||||
|
setMode,
|
||||||
|
} = useStore();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [err, setErr] = useState<ApiError | null>(null);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!profile) return;
|
||||||
|
const required = questions.filter((q) => !q.optional);
|
||||||
|
const missing = required.filter((q) => !answers[q.id]?.trim());
|
||||||
|
if (missing.length) {
|
||||||
|
setErr({
|
||||||
|
code: "VALIDATION_ERROR",
|
||||||
|
what: "Не все обязательные вопросы заполнены.",
|
||||||
|
why: `Осталось: ${missing.length}.`,
|
||||||
|
next: "Заполните выделенные поля.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setErr(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const r = await api.generate(profile, answersArray(), mode, profile.preview);
|
||||||
|
setResult(r);
|
||||||
|
setStep("result");
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof SheetDocError) setErr(e.payload);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-semibold">Шаг 4. Уточняющие вопросы</h2>
|
||||||
|
|
||||||
|
<div className="bg-white border rounded-md p-4">
|
||||||
|
<label className="block text-sm font-medium mb-2">Тип итогового документа</label>
|
||||||
|
<select
|
||||||
|
className="border rounded-md px-3 py-2 w-full"
|
||||||
|
value={mode}
|
||||||
|
onChange={(e) => setMode(e.target.value as GenerateMode)}
|
||||||
|
>
|
||||||
|
<option value="report">Текстовая справка / отчёт</option>
|
||||||
|
<option value="presentation_outline">Структура презентации</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{questions.map((q) => (
|
||||||
|
<div key={q.id} className="bg-white border rounded-md p-4 space-y-2">
|
||||||
|
<label className="block font-medium">
|
||||||
|
{q.text}
|
||||||
|
{q.optional && <span className="text-xs text-slate-500 ml-2">(необязательно)</span>}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-slate-500">{q.why}</p>
|
||||||
|
{q.kind === "single" && q.options ? (
|
||||||
|
<select
|
||||||
|
className="w-full border rounded-md px-3 py-2"
|
||||||
|
value={answers[q.id] ?? ""}
|
||||||
|
onChange={(e) => setAnswer(q.id, e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">— выберите —</option>
|
||||||
|
{q.options.map((o) => (
|
||||||
|
<option key={o} value={o}>{o}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : q.kind === "multi" && q.options ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{q.options.map((o) => {
|
||||||
|
const current = (answers[q.id] ?? "").split(",").filter(Boolean);
|
||||||
|
const checked = current.includes(o);
|
||||||
|
return (
|
||||||
|
<label key={o} className="flex items-center gap-1 text-sm border rounded px-2 py-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => {
|
||||||
|
const next = checked ? current.filter((x) => x !== o) : [...current, o];
|
||||||
|
setAnswer(q.id, next.join(","));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{o}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<textarea
|
||||||
|
className="w-full border rounded-md px-3 py-2 min-h-[60px]"
|
||||||
|
value={answers[q.id] ?? ""}
|
||||||
|
onChange={(e) => setAnswer(q.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<ErrorBox err={err} />
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setStep("preview")}
|
||||||
|
className="px-4 py-2 border rounded-md hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={loading}
|
||||||
|
onClick={submit}
|
||||||
|
className="px-4 py-2 bg-indigo-600 text-white rounded-md disabled:opacity-50 hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
{loading ? "Генерируем…" : "Сгенерировать"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
frontend/src/components/StepResult.tsx
Normal file
92
frontend/src/components/StepResult.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { marked } from "marked";
|
||||||
|
import { useStore } from "../lib/store";
|
||||||
|
import { api, SheetDocError } from "../lib/api";
|
||||||
|
import { ApiError } from "../lib/types";
|
||||||
|
import { ErrorBox } from "./ErrorBox";
|
||||||
|
|
||||||
|
export function StepResult() {
|
||||||
|
const { result, setResult, setStep, profile, answersArray, mode, reset } = useStore();
|
||||||
|
const [regen, setRegen] = useState(false);
|
||||||
|
const [err, setErr] = useState<ApiError | null>(null);
|
||||||
|
const [copied, setCopied] = useState<"md" | "plain" | null>(null);
|
||||||
|
|
||||||
|
const html = useMemo(
|
||||||
|
() => (result ? marked.parse(result.document, { breaks: true }) : ""),
|
||||||
|
[result]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
async function copy(kind: "md" | "plain") {
|
||||||
|
if (!result) return;
|
||||||
|
const text =
|
||||||
|
kind === "md"
|
||||||
|
? result.document
|
||||||
|
: result.document.replace(/[#*_`>-]/g, "").replace(/\n{3,}/g, "\n\n");
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(kind);
|
||||||
|
setTimeout(() => setCopied(null), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regenerate() {
|
||||||
|
if (!profile) return;
|
||||||
|
setErr(null);
|
||||||
|
setRegen(true);
|
||||||
|
try {
|
||||||
|
const r = await api.generate(profile, answersArray(), mode, profile.preview);
|
||||||
|
setResult(r);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof SheetDocError) setErr(e.payload);
|
||||||
|
} finally {
|
||||||
|
setRegen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-semibold">Шаг 5. Результат</h2>
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
Сгенерировано: {new Date(result.generatedAt).toLocaleString("ru-RU")} · режим: {result.mode}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => copy("md")}
|
||||||
|
className="px-3 py-1.5 bg-slate-800 text-white rounded hover:bg-slate-900 text-sm"
|
||||||
|
>
|
||||||
|
{copied === "md" ? "Скопировано ✓" : "Копировать Markdown"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => copy("plain")}
|
||||||
|
className="px-3 py-1.5 border rounded hover:bg-slate-50 text-sm"
|
||||||
|
>
|
||||||
|
{copied === "plain" ? "Скопировано ✓" : "Копировать как текст"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={regen}
|
||||||
|
onClick={regenerate}
|
||||||
|
className="px-3 py-1.5 border rounded hover:bg-slate-50 text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{regen ? "Генерируем…" : "Сгенерировать заново"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setStep("questions")}
|
||||||
|
className="px-3 py-1.5 border rounded hover:bg-slate-50 text-sm"
|
||||||
|
>
|
||||||
|
Изменить ответы
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="px-3 py-1.5 border rounded hover:bg-slate-50 text-sm"
|
||||||
|
>
|
||||||
|
Новая таблица
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ErrorBox err={err} />
|
||||||
|
<article
|
||||||
|
className="prose-doc bg-white border rounded-md p-6"
|
||||||
|
dangerouslySetInnerHTML={{ __html: html as string }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
frontend/src/components/StepSheet.tsx
Normal file
46
frontend/src/components/StepSheet.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { useStore } from "../lib/store";
|
||||||
|
|
||||||
|
export function StepSheet() {
|
||||||
|
const { resolve, selectedGid, setSelectedGid, setStep } = useStore();
|
||||||
|
if (!resolve) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-semibold">Шаг 2. Выберите лист</h2>
|
||||||
|
<p className="text-sm text-slate-600">Таблица: {resolve.title}</p>
|
||||||
|
<ul className="divide-y border rounded-md bg-white">
|
||||||
|
{resolve.sheets.map((s) => (
|
||||||
|
<li key={s.gid}>
|
||||||
|
<label className="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-slate-50">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="sheet"
|
||||||
|
checked={selectedGid === s.gid}
|
||||||
|
onChange={() => setSelectedGid(s.gid)}
|
||||||
|
/>
|
||||||
|
<span>{s.name}</span>
|
||||||
|
{s.isEmpty && (
|
||||||
|
<span className="text-xs text-slate-500">(пустой)</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setStep("url")}
|
||||||
|
className="px-4 py-2 border rounded-md hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={!selectedGid}
|
||||||
|
onClick={() => setStep("preview")}
|
||||||
|
className="px-4 py-2 bg-indigo-600 text-white rounded-md disabled:opacity-50 hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
Далее
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
frontend/src/components/StepUrl.tsx
Normal file
57
frontend/src/components/StepUrl.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { api, SheetDocError } from "../lib/api";
|
||||||
|
import { useStore } from "../lib/store";
|
||||||
|
import { ApiError } from "../lib/types";
|
||||||
|
import { ErrorBox } from "./ErrorBox";
|
||||||
|
|
||||||
|
export function StepUrl() {
|
||||||
|
const { url, setUrl, setResolve, setStep } = useStore();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [err, setErr] = useState<ApiError | null>(null);
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setErr(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const r = await api.resolve(url);
|
||||||
|
setResolve(r);
|
||||||
|
if (r.sheets.length === 1) {
|
||||||
|
useStore.getState().setSelectedGid(r.sheets[0].gid);
|
||||||
|
setStep("preview");
|
||||||
|
} else {
|
||||||
|
setStep("sheet");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof SheetDocError) setErr(e.payload);
|
||||||
|
else setErr({ code: "UNKNOWN", what: "Неизвестная ошибка.", why: String(e), next: "Повторите." });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<h2 className="text-xl font-semibold">Шаг 1. Ссылка на Google Таблицу</h2>
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
Откройте доступ «Все, у кого есть ссылка → Читатель» и вставьте URL.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
required
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="https://docs.google.com/spreadsheets/d/..."
|
||||||
|
className="w-full border border-slate-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !url}
|
||||||
|
className="px-4 py-2 bg-indigo-600 text-white rounded-md disabled:opacity-50 hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
{loading ? "Проверяем…" : "Далее"}
|
||||||
|
</button>
|
||||||
|
<ErrorBox err={err} />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
frontend/src/index.css
Normal file
12
frontend/src/index.css
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
.prose-doc h1 { @apply text-2xl font-bold mt-6 mb-3; }
|
||||||
|
.prose-doc h2 { @apply text-xl font-semibold mt-5 mb-2; }
|
||||||
|
.prose-doc h3 { @apply text-lg font-semibold mt-4 mb-2; }
|
||||||
|
.prose-doc p { @apply my-2 leading-relaxed; }
|
||||||
|
.prose-doc ul { @apply list-disc pl-6 my-2; }
|
||||||
|
.prose-doc ol { @apply list-decimal pl-6 my-2; }
|
||||||
|
.prose-doc strong { @apply font-semibold; }
|
||||||
|
.prose-doc code { @apply bg-slate-100 px-1 py-0.5 rounded; }
|
||||||
64
frontend/src/lib/api.ts
Normal file
64
frontend/src/lib/api.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
GenerateMode,
|
||||||
|
GenerateResponse,
|
||||||
|
LoadResponse,
|
||||||
|
QuestionsResponse,
|
||||||
|
ResolveResponse,
|
||||||
|
Answer,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const BASE = import.meta.env.VITE_API_BASE ?? "";
|
||||||
|
|
||||||
|
export class SheetDocError extends Error {
|
||||||
|
payload: ApiError;
|
||||||
|
constructor(payload: ApiError) {
|
||||||
|
super(payload.what || payload.code);
|
||||||
|
this.payload = payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const r = await fetch(`${BASE}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
let payload: ApiError = {
|
||||||
|
code: "NETWORK",
|
||||||
|
what: "Сетевая ошибка.",
|
||||||
|
why: `HTTP ${r.status}.`,
|
||||||
|
next: "Проверьте подключение и повторите.",
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const data = await r.json();
|
||||||
|
if (data?.error) payload = data.error;
|
||||||
|
} catch {
|
||||||
|
/* keep default */
|
||||||
|
}
|
||||||
|
throw new SheetDocError(payload);
|
||||||
|
}
|
||||||
|
return (await r.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
resolve: (url: string) =>
|
||||||
|
post<ResolveResponse>("/api/sheets/resolve", { url }),
|
||||||
|
load: (spreadsheetId: string, gid: string) =>
|
||||||
|
post<LoadResponse>("/api/sheets/load", { spreadsheetId, gid }),
|
||||||
|
questions: (profile: LoadResponse) =>
|
||||||
|
post<QuestionsResponse>("/api/analyze/questions", { profile }),
|
||||||
|
generate: (
|
||||||
|
profile: LoadResponse,
|
||||||
|
answers: Answer[],
|
||||||
|
mode: GenerateMode,
|
||||||
|
dataSample: string[][]
|
||||||
|
) =>
|
||||||
|
post<GenerateResponse>("/api/generate", {
|
||||||
|
profile,
|
||||||
|
answers,
|
||||||
|
mode,
|
||||||
|
dataSample,
|
||||||
|
}),
|
||||||
|
};
|
||||||
66
frontend/src/lib/store.ts
Normal file
66
frontend/src/lib/store.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import {
|
||||||
|
Answer,
|
||||||
|
GenerateMode,
|
||||||
|
GenerateResponse,
|
||||||
|
LoadResponse,
|
||||||
|
Question,
|
||||||
|
ResolveResponse,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export type Step = "url" | "sheet" | "preview" | "questions" | "result";
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
step: Step;
|
||||||
|
url: string;
|
||||||
|
resolve?: ResolveResponse;
|
||||||
|
selectedGid?: string;
|
||||||
|
profile?: LoadResponse;
|
||||||
|
questions: Question[];
|
||||||
|
answers: Record<string, string>;
|
||||||
|
mode: GenerateMode;
|
||||||
|
result?: GenerateResponse;
|
||||||
|
setStep: (s: Step) => void;
|
||||||
|
setUrl: (u: string) => void;
|
||||||
|
setResolve: (r: ResolveResponse) => void;
|
||||||
|
setSelectedGid: (g: string) => void;
|
||||||
|
setProfile: (p: LoadResponse) => void;
|
||||||
|
setQuestions: (q: Question[]) => void;
|
||||||
|
setAnswer: (id: string, v: string) => void;
|
||||||
|
setMode: (m: GenerateMode) => void;
|
||||||
|
setResult: (r: GenerateResponse) => void;
|
||||||
|
reset: () => void;
|
||||||
|
answersArray: () => Answer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useStore = create<State>((set, get) => ({
|
||||||
|
step: "url",
|
||||||
|
url: "",
|
||||||
|
questions: [],
|
||||||
|
answers: {},
|
||||||
|
mode: "report",
|
||||||
|
setStep: (step) => set({ step }),
|
||||||
|
setUrl: (url) => set({ url }),
|
||||||
|
setResolve: (resolve) => set({ resolve }),
|
||||||
|
setSelectedGid: (selectedGid) => set({ selectedGid }),
|
||||||
|
setProfile: (profile) => set({ profile }),
|
||||||
|
setQuestions: (questions) => set({ questions }),
|
||||||
|
setAnswer: (id, v) =>
|
||||||
|
set((s) => ({ answers: { ...s.answers, [id]: v } })),
|
||||||
|
setMode: (mode) => set({ mode }),
|
||||||
|
setResult: (result) => set({ result }),
|
||||||
|
reset: () =>
|
||||||
|
set({
|
||||||
|
step: "url",
|
||||||
|
url: "",
|
||||||
|
resolve: undefined,
|
||||||
|
selectedGid: undefined,
|
||||||
|
profile: undefined,
|
||||||
|
questions: [],
|
||||||
|
answers: {},
|
||||||
|
mode: "report",
|
||||||
|
result: undefined,
|
||||||
|
}),
|
||||||
|
answersArray: () =>
|
||||||
|
Object.entries(get().answers).map(([id, value]) => ({ id, value })),
|
||||||
|
}));
|
||||||
65
frontend/src/lib/types.ts
Normal file
65
frontend/src/lib/types.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
export type ColumnType = "text" | "number" | "date" | "bool" | "empty";
|
||||||
|
|
||||||
|
export interface SheetInfo {
|
||||||
|
gid: string;
|
||||||
|
name: string;
|
||||||
|
isEmpty?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveResponse {
|
||||||
|
spreadsheetId: string;
|
||||||
|
title: string;
|
||||||
|
sheets: SheetInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColumnProfile {
|
||||||
|
name: string;
|
||||||
|
type: ColumnType;
|
||||||
|
fill: number;
|
||||||
|
unique: number;
|
||||||
|
top: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadResponse {
|
||||||
|
rows: number;
|
||||||
|
cols: number;
|
||||||
|
sheetName: string;
|
||||||
|
headers: string[];
|
||||||
|
columns: ColumnProfile[];
|
||||||
|
preview: string[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QuestionKind = "single" | "multi" | "text";
|
||||||
|
|
||||||
|
export interface Question {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
why: string;
|
||||||
|
optional?: boolean;
|
||||||
|
kind: QuestionKind;
|
||||||
|
options?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestionsResponse {
|
||||||
|
questions: Question[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Answer {
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GenerateMode = "report" | "presentation_outline";
|
||||||
|
|
||||||
|
export interface GenerateResponse {
|
||||||
|
document: string;
|
||||||
|
generatedAt: string;
|
||||||
|
mode: GenerateMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
code: string;
|
||||||
|
what: string;
|
||||||
|
why: string;
|
||||||
|
next: string;
|
||||||
|
}
|
||||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
9
frontend/src/vite-env.d.ts
vendored
Normal file
9
frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
6
frontend/tailwind.config.js
Normal file
6
frontend/tailwind.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
|
theme: { extend: {} },
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
20
frontend/tsconfig.json
Normal file
20
frontend/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
15
frontend/vite.config.ts
Normal file
15
frontend/vite.config.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:8000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user