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/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.next/
|
||||
.vite/
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
target/
|
||||
backend/static/
|
||||
|
||||
# data
|
||||
*.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 и будут доступны при следующем входе.
|
||||
|
||||
В этой папке уже инициализирован 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