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:
galymzhan 2026-05-22 12:43:23 +05:00
parent efd2323d05
commit 0f31b4517b
48 changed files with 5262 additions and 0 deletions

12
.dockerignore Normal file
View 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
View 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
View File

@ -15,11 +15,15 @@ node_modules/
venv/
__pycache__/
*.pyc
*.egg-info/
.pytest_cache/
.next/
.vite/
dist/
build/
out/
target/
backend/static/
# data
*.sqlite*

28
Dockerfile Normal file
View 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
View 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.

View File

@ -24,3 +24,43 @@
Изменения сохранятся в 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
View File

@ -0,0 +1,5 @@
from cachetools import TTLCache
from .config import settings
profile_cache: TTLCache = TTLCache(maxsize=128, ttl=settings.cache_ttl_seconds)

View 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
View 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()

View 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
View 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
View 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()

View 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
View 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
View 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

View 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)

View 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

View 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]

View 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": ["Справка", "Аналитический отчёт", "Структура презентации"]
}
]
}

View 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
View File

@ -0,0 +1,3 @@
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]

10
backend/requirements.txt Normal file
View 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

View File

View 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

View 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

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

27
frontend/package.json Normal file
View 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"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

58
frontend/src/App.tsx Normal file
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View 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
View 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
View 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",
},
});