feat: DocAgent MVP — browser-only LLM document analyzer

Adds index.html, styles.css, app.js implementing the TRD v1.0 MVP:
file upload (PDF/DOCX/TXT/MD/CSV/JSON/XML/HTML/RTF), 6 analysis modes,
streaming Claude API call from browser, Markdown rendering, .md export,
API key persisted in localStorage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Baiken Zholdaskhan 2026-05-19 11:54:20 +05:00
parent 21e963d83f
commit fc1bc44b06
4 changed files with 1248 additions and 0 deletions

324
TRD.md
View File

@ -0,0 +1,324 @@
# TRD — Агент анализа документов и оценки реализуемости требований
**Версия:** 1.0
**Дата:** 19.05.2026
**Статус:** Draft
**Автор:** BA
---
## 1. Введение
### 1.1 Назначение документа
Настоящий документ описывает технические требования к разработке агента на базе LLM, предназначенного для автоматизированного анализа текстовых документов и файлов с оценкой реализуемости содержащихся в них требований.
### 1.2 Контекст
Специалисты (бизнес-аналитики, менеджеры проектов, технические эксперты) регулярно работают с объёмными документами — ТЗ, спецификациями, договорами, отчётами. Ручная разборка занимает значительное время. Агент автоматизирует первичный разбор, классификацию требований и их оценку по критериям реализуемости.
### 1.3 Область применения
- Корпоративные пользователи, работающие с документацией
- Бизнес-аналитики и системные аналитики
- Менеджеры проектов и продуктов
- Технические эксперты, проводящие экспертизу документов
---
## 2. Глоссарий
| Термин | Определение |
|---|---|
| LLM | Large Language Model — большая языковая модель |
| Агент | Программная система, управляемая LLM, способная выполнять задачи с доступом к инструментам |
| Реализуемость | Степень выполнимости требования с учётом технических, временных и ресурсных ограничений |
| ТЗ | Техническое задание |
| RAG | Retrieval-Augmented Generation — генерация с поиском по базе знаний |
| Паспорт кейса | Внутренний документ, описывающий назначение, входы/выходы и ограничения агента |
| Система-оркестратор | Компонент, управляющий последовательностью вызовов инструментов агента |
---
## 3. Функциональные требования
### 3.1 Ввод данных
#### 3.1.1 Загрузка файлов
| ID | Требование | Приоритет |
|---|---|---|
| FR-01 | Система должна принимать файлы форматов: `.txt`, `.pdf`, `.docx`, `.doc`, `.md`, `.csv`, `.json`, `.xml`, `.html`, `.rtf` | Must |
| FR-02 | Максимальный размер одного файла — 20 МБ | Must |
| FR-03 | Поддержка загрузки нескольких файлов одновременно (до 5 файлов) | Should |
| FR-04 | PDF с текстовым слоем обрабатывается напрямую; сканированный PDF — через OCR-препроцессор | Should |
| FR-05 | Файл `.docx` / `.doc` должен быть преобразован в plain text перед отправкой в LLM | Must |
#### 3.1.2 Ввод текста
| ID | Требование | Приоритет |
|---|---|---|
| FR-06 | Пользователь может вставить текст вручную объёмом до 200 000 символов | Must |
| FR-07 | Счётчик символов в режиме реального времени | Should |
| FR-08 | Если текст превышает контекстное окно модели — применяется стратегия чанкинга с агрегацией результатов | Must |
#### 3.1.3 Дополнительный контекст
| ID | Требование | Приоритет |
|---|---|---|
| FR-09 | Пользователь может ввести свободный запрос с уточнением задачи (дополнительный промпт) | Should |
| FR-10 | Пользователь может указать параметры команды / проекта (размер команды, технологический стек, сроки) для оценки реализуемости | Should |
---
### 3.2 Типы анализа
Система должна поддерживать следующие режимы:
| ID | Тип | Описание | Приоритет |
|---|---|---|---|
| FR-11 | **Полный анализ** | Запускает все нижеперечисленные типы последовательно, формирует сводный отчёт | Must |
| FR-12 | **Анализ требований** | Извлечение всех требований из текста, их классификация (функциональные / нефункциональные / бизнес / ограничения) и нумерация | Must |
| FR-13 | **Оценка реализуемости** | Для каждого извлечённого требования — оценка по шкале Высокая / Средняя / Низкая с обоснованием | Must |
| FR-14 | **Анализ рисков** | Выявление потенциальных проблем, противоречий, неоднозначностей и пробелов в документе | Must |
| FR-15 | **Краткое резюме** | Структурированное резюме документа: цель, ключевые положения, участники, сроки | Must |
| FR-16 | **Рекомендации** | Конкретные предложения по доработке / уточнению документа | Should |
---
### 3.3 Обработка и генерация ответа
| ID | Требование | Приоритет |
|---|---|---|
| FR-17 | Ответ агента должен быть структурирован в формате Markdown с заголовками, списками и таблицами | Must |
| FR-18 | Каждое извлечённое требование должно иметь уникальный идентификатор (например, REQ-001) | Must |
| FR-19 | Оценка реализуемости сопровождается кратким обоснованием (1–3 предложения) | Must |
| FR-20 | Агент должен явно указывать, если в документе недостаточно информации для уверенной оценки | Must |
| FR-21 | Итоговый отчёт содержит сводную таблицу требований с оценками реализуемости | Should |
| FR-22 | Поддержка потоковой передачи ответа (streaming) для улучшения UX | Should |
| FR-23 | Язык ответа соответствует языку входного документа (авто-определение) | Should |
---
### 3.4 Экспорт результатов
| ID | Требование | Приоритет |
|---|---|---|
| FR-24 | Пользователь может скопировать результат в буфер обмена | Must |
| FR-25 | Результат можно скачать в формате `.md` (Markdown) | Must |
| FR-26 | Экспорт в `.docx` — опциональная функция второй очереди | Could |
---
## 4. Нефункциональные требования
### 4.1 Производительность
| ID | Требование |
|---|---|
| NFR-01 | Время ответа для документа до 10 000 символов — не более 30 секунд |
| NFR-02 | Время ответа для документа 10 000–100 000 символов — не более 90 секунд |
| NFR-03 | При использовании чанкинга — промежуточные результаты отображаются пользователю по мере готовности |
### 4.2 Надёжность
| ID | Требование |
|---|---|
| NFR-04 | При ошибке API — система отображает понятное сообщение пользователю и предлагает повторить |
| NFR-05 | Таймаут на вызов LLM API — 120 секунд |
| NFR-06 | Обработка rate-limit ошибок с автоматическим retry (до 3 попыток с экспоненциальной задержкой) |
### 4.3 Безопасность и конфиденциальность
| ID | Требование |
|---|---|
| NFR-07 | Содержимое загруженных файлов и введённых текстов не сохраняется на сервере после завершения сессии |
| NFR-08 | API-ключ пользователя хранится только в `localStorage` браузера и не передаётся на серверную сторону (в browser-only реализации) |
| NFR-09 | Передача данных осуществляется только по HTTPS |
| NFR-10 | Обработка персональных данных соответствует внутренней политике ИБ организации |
### 4.4 Удобство использования
| ID | Требование |
|---|---|
| NFR-11 | Интерфейс поддерживает drag-and-drop загрузку файлов |
| NFR-12 | Визуальная индикация прогресса обработки (spinner + этапы) |
| NFR-13 | Адаптивный дизайн для мобильных устройств |
| NFR-14 | Поддержка браузеров: Chrome 100+, Firefox 100+, Safari 16+, Edge 100+ |
---
## 5. Архитектура агента
### 5.1 Общая схема
```
Пользователь
│
▼
[Веб-интерфейс / Лендинг]
│ (файл / текст + тип анализа + доп. контекст)
▼
[Препроцессор]
├── Парсинг файла (PDF → text, DOCX → text)
├── Нормализация текста
└── Чанкинг при превышении лимита токенов
│
▼
[Оркестратор агента]
├── Формирование системного промпта
├── Выбор стратегии анализа (одиночный / multi-chunk)
└── Управление вызовами LLM API
│
▼
[LLM — Claude claude-sonnet-4-20250514]
│ (структурированный ответ в Markdown)
▼
[Постпроцессор]
├── Парсинг и валидация Markdown
├── Агрегация чанков (если применимо)
└── Формирование финального отчёта
│
▼
[Рендер результата + Экспорт]
```
### 5.2 Стратегия чанкинга
Если размер текста превышает `~150 000 токенов`:
1. Текст разбивается на чанки по `~100 000 токенов` с перекрытием `~500 токенов`
2. Каждый чанк анализируется отдельно
3. Результаты агрегируются в финальный отчёт через отдельный вызов LLM (сводный промпт)
---
## 6. Системный промпт
### 6.1 Базовый системный промпт
```
Ты — эксперт-аналитик документов. Твоя задача — провести глубокий и структурированный анализ предоставленного документа.
ИНСТРУКЦИИ:
1. Всегда отвечай на языке входного документа
2. Используй структурированный Markdown: заголовки H2/H3, таблицы, списки
3. Не придумывай информацию — опирайся только на содержимое документа
4. Если информации недостаточно для уверенного вывода — явно укажи это
5. Каждому найденному требованию присваивай ID формата REQ-XXX
СТРУКТУРА ОТВЕТА (адаптируй под запрошенный тип анализа):
- ## Общее резюме документа
- ## Извлечённые требования (таблица с ID, типом, описанием)
- ## Оценка реализуемости (таблица: ID | Требование | Оценка | Обоснование)
- ## Выявленные риски и противоречия
- ## Рекомендации
- ## Итоговые выводы
```
### 6.2 Промпт для оценки реализуемости
Оценка проводится по трём критериям:
| Критерий | Высокая | Средняя | Низкая |
|---|---|---|---|
| **Техническая** | Стандартные технологии, понятная реализация | Требует нестандартных решений | Технически сложно / невозможно текущими средствами |
| **Ресурсная** | Укладывается в типичные рамки | Требует дополнительных ресурсов | Явно недостаточно ресурсов |
| **Временная** | Чёткие, реалистичные сроки | Сжатые, но выполнимые сроки | Нереалистичные или отсутствующие сроки |
---
## 7. Интерфейсные требования
### 7.1 Основные экраны / состояния
| Состояние | Описание |
|---|---|
| **Idle** | Пустая форма ввода, выбор типа анализа |
| **File selected** | Отображение загруженных файлов с возможностью удаления |
| **Processing** | Индикатор прогресса с отображением текущего этапа |
| **Result** | Структурированный результат с вкладками и кнопками экспорта |
| **Error** | Понятное сообщение об ошибке с инструкцией |
### 7.2 Требования к отображению результата
- Результат рендерится как Markdown (не plain text)
- Длинные таблицы допускают горизонтальную прокрутку
- Бейджи оценки реализуемости: 🟢 Высокая / 🟡 Средняя / 🔴 Низкая
---
## 8. Интеграции и зависимости
| Компонент | Технология / Решение | Назначение |
|---|---|---|
| LLM | Anthropic Claude API (`claude-sonnet-4-20250514`) | Основной движок анализа |
| PDF parsing | `pdf-parse` (Node.js) / `PyMuPDF` (Python) | Извлечение текста из PDF |
| DOCX parsing | `mammoth.js` (browser) / `python-docx` | Извлечение текста из DOCX |
| Markdown рендер | `marked.js` / `markdown-it` | Отображение результата |
| Экспорт `.docx` | `docx.js` (опционально, 2-я очередь) | Скачивание результата |
---
## 9. Ограничения и известные риски
| # | Риск / Ограничение | Митигация |
|---|---|---|
| R-01 | Сканированные PDF без текстового слоя не читаются | Интеграция OCR-сервиса (Tesseract) в 2-й очереди |
| R-02 | Очень длинные документы (>500 страниц) могут давать неточный агрегированный анализ | Ограничение на объём + предупреждение пользователю |
| R-03 | LLM может «галлюцинировать» требования, которых нет в документе | Промпт-инструкция + пользовательская проверка |
| R-04 | Секретные / конфиденциальные документы не должны передаваться через внешний API | Предупреждение в интерфейсе; on-premise вариант в roadmap |
| R-05 | Нестабильность API (rate limits, outages) | Retry-логика, graceful degradation |
| R-06 | Оценка реализуемости субъективна без конкретного контекста проекта | Поле «доп. контекст» для уточнения условий |
---
## 10. Roadmap
### v1.0 — MVP (текущий скоуп)
- [x] Ввод текста вручную
- [x] Загрузка TXT, PDF (текстовые), DOCX
- [x] 6 типов анализа
- [x] Стриминг ответа
- [x] Экспорт `.md`
### v1.1
- [ ] OCR для сканированных PDF
- [ ] Чанкинг для документов >150k токенов
- [ ] История сессий (localStorage)
- [ ] Экспорт в `.docx`
### v2.0
- [ ] Сравнительный анализ нескольких документов
- [ ] RAG по базе знаний организации
- [ ] Пользовательские шаблоны промптов
- [ ] On-premise / self-hosted вариант развёртывания
- [ ] Интеграция с Confluence, SharePoint
---
## 11. Критерии приёмки (DoD)
| # | Критерий |
|---|---|
| AC-01 | Агент корректно извлекает требования из тестового ТЗ на 10+ страниц |
| AC-02 | Каждое требование получает уникальный ID и оценку реализуемости с обоснованием |
| AC-03 | Анализ документа до 10 000 символов завершается за ≤ 30 секунд |
| AC-04 | Результат корректно рендерится в Markdown и доступен для экспорта |
| AC-05 | Система не падает при загрузке файла >5 МБ и не превышающего лимиты |
| AC-06 | При ошибке API отображается понятное сообщение без технических деталей |
| AC-07 | Интерфейс работает корректно в Chrome, Firefox, Safari, Edge последних версий |
---
## 12. Согласование
| Роль | ФИО | Дата | Статус |
|---|---|---|---|
| Бизнес-аналитик | | | Подготовлен |
| Технический руководитель | | | На согласовании |
| Заказчик / владелец продукта | | | На согласовании |
---
*Документ подготовлен в соответствии со стандартами BA-документации проекта.*

403
app.js Normal file
View File

@ -0,0 +1,403 @@
// DocAgent — browser-only LLM document analysis client.
const MODEL = 'claude-sonnet-4-20250514';
const API_URL = 'https://api.anthropic.com/v1/messages';
const MAX_FILES = 5;
const MAX_FILE_SIZE = 20 * 1024 * 1024;
const MAX_TEXT_CHARS = 200000;
const MAX_TOKENS = 8192;
const SYSTEM_PROMPT = `Ты — эксперт-аналитик документов. Твоя задача — провести глубокий и структурированный анализ предоставленного документа.
ИНСТРУКЦИИ:
1. Всегда отвечай на языке входного документа.
2. Используй структурированный Markdown: заголовки H2/H3, таблицы, списки.
3. Не придумывай информацию — опирайся только на содержимое документа.
4. Если информации недостаточно для уверенного вывода — явно укажи это.
5. Каждому найденному требованию присваивай ID формата REQ-XXX.
ОЦЕНКА РЕАЛИЗУЕМОСТИ — три критерия:
- Техническая (стандартные технологии → высокая; нестандартные → средняя; технически сложно/невозможно → низкая).
- Ресурсная (типичные рамки → высокая; нужны доп. ресурсы → средняя; явно недостаточно → низкая).
- Временная (реалистичные сроки → высокая; сжатые но выполнимые → средняя; нереалистичные/отсутствуют → низкая).
Бейджи: 🟢 Высокая / 🟡 Средняя / 🔴 Низкая.`;
const ANALYSIS_PROMPTS = {
full: `Выполни ПОЛНЫЙ анализ документа. Сформируй отчёт со следующими разделами:
## Общее резюме документа
## Извлечённые требования
Таблица с колонками: ID | Тип (FR/NFR/Бизнес/Ограничение) | Описание.
## Оценка реализуемости
Таблица: ID | Требование | Техническая | Ресурсная | Временная | Итоговая | Обоснование.
## Выявленные риски и противоречия
## Рекомендации
## Итоговые выводы`,
requirements: `Извлеки ВСЕ требования из документа. Для каждого: ID (REQ-XXX), тип (функциональное/нефункциональное/бизнес/ограничение), описание, цитата-источник. Выведи таблицей.`,
feasibility: `Извлеки требования и оцени реализуемость каждого по трём критериям (Техническая / Ресурсная / Временная) с обоснованием 1–3 предложения. Сводная таблица + итоговая оценка по документу в целом.`,
risks: `Проведи АНАЛИЗ РИСКОВ документа: противоречия, неоднозначности, пробелы, потенциальные технические/организационные риски. Каждый риск: ID (R-XX), описание, последствие, рекомендация по митигации.`,
summary: `Сформируй КРАТКОЕ структурированное резюме: цель документа, ключевые положения, участники/роли, сроки, ограничения. Без лишних рассуждений.`,
recommendations: `Сформулируй КОНКРЕТНЫЕ рекомендации по доработке документа: что уточнить, что добавить, что переформулировать. Каждая рекомендация — actionable.`,
};
const state = {
files: [],
abortController: null,
lastResultMd: '',
};
// --- DOM ---
const $ = (sel) => document.querySelector(sel);
const els = {
apiKey: $('#apiKey'),
saveKey: $('#saveKey'),
clearKey: $('#clearKey'),
keyStatus: $('#keyStatus'),
dropZone: $('#dropZone'),
fileInput: $('#fileInput'),
fileList: $('#fileList'),
textInput: $('#textInput'),
charCount: $('#charCount'),
extraContext: $('#extraContext'),
runBtn: $('#runBtn'),
stopBtn: $('#stopBtn'),
resultSection: $('#resultSection'),
result: $('#result'),
progress: $('#progress'),
progressText: $('#progressText'),
copyBtn: $('#copyBtn'),
downloadBtn: $('#downloadBtn'),
errorSection: $('#errorSection'),
errorText: $('#errorText'),
};
// --- API key persistence ---
function loadKey() {
const k = localStorage.getItem('anthropic_api_key');
if (k) {
els.apiKey.value = k;
els.keyStatus.textContent = '✓ Ключ загружен';
els.keyStatus.className = 'status ok';
}
}
els.saveKey.addEventListener('click', () => {
const k = els.apiKey.value.trim();
if (!k.startsWith('sk-ant-')) {
els.keyStatus.textContent = 'Ключ должен начинаться с sk-ant-';
els.keyStatus.className = 'status err';
return;
}
localStorage.setItem('anthropic_api_key', k);
els.keyStatus.textContent = '✓ Сохранён';
els.keyStatus.className = 'status ok';
});
els.clearKey.addEventListener('click', () => {
localStorage.removeItem('anthropic_api_key');
els.apiKey.value = '';
els.keyStatus.textContent = 'Ключ удалён';
els.keyStatus.className = 'status';
});
// --- File handling ---
els.dropZone.addEventListener('click', (e) => {
if (e.target.tagName !== 'LABEL') els.fileInput.click();
});
els.dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
els.dropZone.classList.add('drag-over');
});
els.dropZone.addEventListener('dragleave', () => {
els.dropZone.classList.remove('drag-over');
});
els.dropZone.addEventListener('drop', (e) => {
e.preventDefault();
els.dropZone.classList.remove('drag-over');
addFiles(e.dataTransfer.files);
});
els.fileInput.addEventListener('change', (e) => {
addFiles(e.target.files);
e.target.value = '';
});
function addFiles(fileList) {
const incoming = Array.from(fileList);
for (const f of incoming) {
if (state.files.length >= MAX_FILES) {
showError(`Максимум ${MAX_FILES} файлов`);
break;
}
if (f.size > MAX_FILE_SIZE) {
showError(`Файл "${f.name}" больше 20 МБ`);
continue;
}
state.files.push(f);
}
renderFileList();
}
function renderFileList() {
els.fileList.innerHTML = '';
state.files.forEach((f, i) => {
const li = document.createElement('li');
li.innerHTML = `
<span>📄 ${escapeHtml(f.name)} <span style="color:var(--text-dim)">(${formatSize(f.size)})</span></span>
<button class="remove" data-i="${i}" title="Удалить">×</button>
`;
els.fileList.appendChild(li);
});
els.fileList.querySelectorAll('.remove').forEach((btn) => {
btn.addEventListener('click', () => {
state.files.splice(Number(btn.dataset.i), 1);
renderFileList();
});
});
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
}
function escapeHtml(s) {
return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
// --- File parsing ---
async function parseFile(file) {
const ext = file.name.toLowerCase().split('.').pop();
switch (ext) {
case 'txt': case 'md': case 'csv': case 'json': case 'xml': case 'html': case 'rtf':
return await file.text();
case 'pdf':
return await parsePdf(file);
case 'docx': case 'doc':
return await parseDocx(file);
default:
throw new Error(`Неподдерживаемый формат: .${ext}`);
}
}
async function parsePdf(file) {
const buf = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: buf }).promise;
const parts = [];
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const content = await page.getTextContent();
parts.push(content.items.map((it) => it.str).join(' '));
}
return parts.join('\n\n');
}
async function parseDocx(file) {
const buf = await file.arrayBuffer();
const result = await mammoth.extractRawText({ arrayBuffer: buf });
return result.value;
}
// --- Char counter ---
els.textInput.addEventListener('input', () => {
els.charCount.textContent = els.textInput.value.length.toLocaleString('ru');
});
// --- Run analysis ---
els.runBtn.addEventListener('click', runAnalysis);
els.stopBtn.addEventListener('click', () => {
if (state.abortController) state.abortController.abort();
});
async function runAnalysis() {
hideError();
const apiKey = (localStorage.getItem('anthropic_api_key') || els.apiKey.value || '').trim();
if (!apiKey.startsWith('sk-ant-')) {
showError('Введи и сохрани API-ключ Anthropic (sk-ant-...)');
return;
}
const analysisType = document.querySelector('input[name="analysis"]:checked').value;
const extraContext = els.extraContext.value.trim();
setRunning(true);
setProgress('Чтение документов...');
let combinedText = '';
try {
for (const f of state.files) {
setProgress(`Парсинг: ${f.name}`);
const txt = await parseFile(f);
combinedText += `\n\n===== ФАЙЛ: ${f.name} =====\n\n${txt}`;
}
const manual = els.textInput.value.trim();
if (manual) combinedText += `\n\n===== ВСТАВЛЕННЫЙ ТЕКСТ =====\n\n${manual}`;
combinedText = combinedText.trim();
} catch (err) {
setRunning(false);
showError('Ошибка чтения файла: ' + err.message);
return;
}
if (!combinedText) {
setRunning(false);
showError('Добавь хотя бы один файл или вставь текст');
return;
}
if (combinedText.length > MAX_TEXT_CHARS * 3) {
setRunning(false);
showError(`Текст слишком длинный (${combinedText.length.toLocaleString('ru')} символов). Лимит для MVP: ~600 000.`);
return;
}
const userPrompt = buildUserPrompt(analysisType, combinedText, extraContext);
setProgress('Запрос к Claude... (streaming)');
els.result.innerHTML = '';
state.lastResultMd = '';
try {
await streamCompletion(apiKey, userPrompt);
setProgress('Готово');
hideProgress();
} catch (err) {
if (err.name === 'AbortError') {
setProgress('Остановлено пользователем');
} else {
showError(err.message);
}
hideProgress();
} finally {
setRunning(false);
}
}
function buildUserPrompt(type, doc, extra) {
const taskInstruction = ANALYSIS_PROMPTS[type];
let prompt = `${taskInstruction}\n\n`;
if (extra) prompt += `ДОПОЛНИТЕЛЬНЫЙ КОНТЕКСТ ОТ ПОЛЬЗОВАТЕЛЯ:\n${extra}\n\n`;
prompt += `ДОКУМЕНТ ДЛЯ АНАЛИЗА:\n\`\`\`\n${doc}\n\`\`\``;
return prompt;
}
async function streamCompletion(apiKey, userPrompt) {
state.abortController = new AbortController();
const response = await fetch(API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true',
},
body: JSON.stringify({
model: MODEL,
max_tokens: MAX_TOKENS,
stream: true,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: userPrompt }],
}),
signal: state.abortController.signal,
});
if (!response.ok) {
let detail = response.statusText;
try {
const j = await response.json();
detail = j.error?.message || JSON.stringify(j);
} catch {}
throw new Error(`API ${response.status}: ${detail}`);
}
els.resultSection.classList.remove('hidden');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (!payload || payload === '[DONE]') continue;
try {
const evt = JSON.parse(payload);
if (evt.type === 'content_block_delta' && evt.delta?.type === 'text_delta') {
state.lastResultMd += evt.delta.text;
renderMarkdown();
}
} catch {}
}
}
}
function renderMarkdown() {
els.result.innerHTML = marked.parse(state.lastResultMd);
}
// --- UI helpers ---
function setRunning(running) {
els.runBtn.disabled = running;
els.stopBtn.disabled = !running;
if (running) {
els.progress.classList.remove('hidden');
els.resultSection.classList.remove('hidden');
}
}
function setProgress(text) {
els.progressText.textContent = text;
}
function hideProgress() {
els.progress.classList.add('hidden');
}
function showError(msg) {
els.errorText.textContent = msg;
els.errorSection.classList.remove('hidden');
}
function hideError() {
els.errorSection.classList.add('hidden');
}
// --- Export ---
els.copyBtn.addEventListener('click', async () => {
if (!state.lastResultMd) return;
try {
await navigator.clipboard.writeText(state.lastResultMd);
els.copyBtn.textContent = '✓ Скопировано';
setTimeout(() => (els.copyBtn.textContent = '📋 Копировать'), 1500);
} catch {
showError('Не удалось скопировать');
}
});
els.downloadBtn.addEventListener('click', () => {
if (!state.lastResultMd) return;
const blob = new Blob([state.lastResultMd], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `analysis-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.md`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
// --- Init ---
loadKey();

109
index.html Normal file
View File

@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DocAgent — Анализ документов и оценка реализуемости</title>
<link rel="stylesheet" href="styles.css" />
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
</head>
<body>
<header class="hero">
<div class="container">
<h1>DocAgent</h1>
<p class="tagline">LLM-агент для анализа документов и оценки реализуемости требований</p>
<ul class="features">
<li>📄 PDF, DOCX, TXT, MD, JSON, XML, HTML, CSV, RTF</li>
<li>🔍 6 режимов анализа</li>
<li>📊 Извлечение требований с ID + оценка реализуемости</li>
<li>⚡ Streaming-ответ, экспорт в Markdown</li>
</ul>
</div>
</header>
<main class="container">
<section class="card" id="setup">
<h2>1. API-ключ Anthropic</h2>
<p class="hint">Ключ хранится только в твоём браузере (localStorage). На сервер не отправляется.</p>
<div class="row">
<input type="password" id="apiKey" placeholder="sk-ant-..." autocomplete="off" />
<button id="saveKey" class="btn">Сохранить</button>
<button id="clearKey" class="btn btn-ghost">Очистить</button>
</div>
<div id="keyStatus" class="status"></div>
</section>
<section class="card">
<h2>2. Документ</h2>
<div id="dropZone" class="dropzone">
<p>📎 Перетащи файлы сюда или <label for="fileInput" class="link">выбери</label></p>
<input type="file" id="fileInput" multiple accept=".txt,.pdf,.docx,.doc,.md,.csv,.json,.xml,.html,.rtf" hidden />
<p class="hint">До 5 файлов, каждый ≤ 20 МБ</p>
</div>
<ul id="fileList" class="file-list"></ul>
<details class="text-block">
<summary>Или вставь текст вручную</summary>
<textarea id="textInput" placeholder="Вставь текст документа..." maxlength="200000"></textarea>
<div class="counter"><span id="charCount">0</span> / 200 000</div>
</details>
</section>
<section class="card">
<h2>3. Тип анализа</h2>
<div class="analysis-types">
<label><input type="radio" name="analysis" value="full" checked /> Полный анализ</label>
<label><input type="radio" name="analysis" value="requirements" /> Извлечение требований</label>
<label><input type="radio" name="analysis" value="feasibility" /> Оценка реализуемости</label>
<label><input type="radio" name="analysis" value="risks" /> Анализ рисков</label>
<label><input type="radio" name="analysis" value="summary" /> Краткое резюме</label>
<label><input type="radio" name="analysis" value="recommendations" /> Рекомендации</label>
</div>
<details class="extra-context">
<summary>Дополнительный контекст (опционально)</summary>
<textarea id="extraContext" placeholder="Уточнение задачи, состав команды, технологический стек, сроки..."></textarea>
</details>
</section>
<section class="card actions">
<button id="runBtn" class="btn btn-primary">▶ Запустить анализ</button>
<button id="stopBtn" class="btn btn-ghost" disabled>⏹ Остановить</button>
</section>
<section id="resultSection" class="card hidden">
<div class="result-header">
<h2>Результат</h2>
<div class="result-actions">
<button id="copyBtn" class="btn btn-ghost">📋 Копировать</button>
<button id="downloadBtn" class="btn btn-ghost">⬇ Скачать .md</button>
</div>
</div>
<div id="progress" class="progress hidden">
<div class="spinner"></div>
<span id="progressText">Обработка...</span>
</div>
<div id="result" class="result-content"></div>
</section>
<section id="errorSection" class="card error hidden">
<strong>Ошибка:</strong>
<span id="errorText"></span>
</section>
</main>
<footer>
<div class="container">
<p>Powered by Claude · Browser-only · Данные не покидают твой браузер кроме API-вызова</p>
</div>
</footer>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
</script>
<script src="app.js"></script>
</body>
</html>

412
styles.css Normal file
View File

@ -0,0 +1,412 @@
:root {
--bg: #0f1419;
--surface: #1a1f29;
--surface-2: #232a36;
--border: #2d3645;
--text: #e6edf3;
--text-dim: #9aa5b1;
--primary: #5b8def;
--primary-hover: #7aa3f5;
--success: #3fb950;
--warning: #d29922;
--danger: #f85149;
--radius: 10px;
--maxw: 880px;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Inter", sans-serif;
font-size: 15px;
line-height: 1.55;
}
.container {
max-width: var(--maxw);
margin: 0 auto;
padding: 0 20px;
}
.hero {
padding: 56px 0 32px;
background: linear-gradient(180deg, #1a2233 0%, var(--bg) 100%);
border-bottom: 1px solid var(--border);
}
.hero h1 {
font-size: 38px;
margin: 0 0 8px;
letter-spacing: -0.5px;
}
.tagline {
font-size: 17px;
color: var(--text-dim);
margin: 0 0 20px;
}
.features {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 8px;
}
.features li {
color: var(--text-dim);
font-size: 14px;
}
main {
padding: 24px 0 48px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px 22px;
margin-bottom: 16px;
}
.card h2 {
font-size: 16px;
font-weight: 600;
margin: 0 0 12px;
color: var(--text);
}
.hint {
color: var(--text-dim);
font-size: 13px;
margin: 0 0 12px;
}
.row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
input[type="password"],
input[type="text"],
textarea {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
padding: 10px 12px;
font-family: inherit;
font-size: 14px;
outline: none;
transition: border-color 0.15s;
}
input[type="password"]:focus,
input[type="text"]:focus,
textarea:focus {
border-color: var(--primary);
}
input[type="password"],
input[type="text"] {
flex: 1;
min-width: 220px;
}
textarea {
width: 100%;
min-height: 140px;
resize: vertical;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 13px;
}
.btn {
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--text);
padding: 9px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.btn:hover:not(:disabled) {
background: #2a3142;
border-color: #3a4458;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
.btn-ghost {
background: transparent;
}
.status {
margin-top: 10px;
font-size: 13px;
color: var(--text-dim);
}
.status.ok { color: var(--success); }
.status.err { color: var(--danger); }
.dropzone {
border: 2px dashed var(--border);
border-radius: var(--radius);
padding: 28px 16px;
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.dropzone.drag-over {
border-color: var(--primary);
background: rgba(91, 141, 239, 0.06);
}
.dropzone p {
margin: 4px 0;
}
.link {
color: var(--primary);
cursor: pointer;
text-decoration: underline;
}
.file-list {
list-style: none;
padding: 0;
margin: 12px 0 0;
}
.file-list li {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--surface-2);
padding: 8px 12px;
border-radius: 6px;
margin-bottom: 6px;
font-size: 13px;
}
.file-list .remove {
cursor: pointer;
color: var(--text-dim);
background: none;
border: none;
font-size: 16px;
padding: 0 4px;
}
.file-list .remove:hover { color: var(--danger); }
.text-block,
.extra-context {
margin-top: 14px;
}
.text-block summary,
.extra-context summary {
cursor: pointer;
color: var(--text-dim);
font-size: 14px;
padding: 6px 0;
user-select: none;
}
.text-block[open] summary,
.extra-context[open] summary {
margin-bottom: 8px;
}
.counter {
text-align: right;
font-size: 12px;
color: var(--text-dim);
margin-top: 4px;
}
.analysis-types {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 8px;
}
.analysis-types label {
display: flex;
align-items: center;
gap: 8px;
background: var(--surface-2);
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--border);
cursor: pointer;
font-size: 14px;
transition: border-color 0.15s;
}
.analysis-types label:hover {
border-color: #3a4458;
}
.analysis-types input[type="radio"] {
accent-color: var(--primary);
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.result-actions {
display: flex;
gap: 8px;
}
.progress {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0;
color: var(--text-dim);
font-size: 14px;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.result-content {
background: var(--surface-2);
border-radius: 8px;
padding: 18px 22px;
overflow-x: auto;
min-height: 60px;
}
.result-content h1,
.result-content h2,
.result-content h3,
.result-content h4 {
margin-top: 1.2em;
margin-bottom: 0.6em;
}
.result-content h2 { font-size: 20px; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
.result-content h3 { font-size: 17px; }
.result-content table {
border-collapse: collapse;
margin: 12px 0;
width: 100%;
font-size: 13px;
}
.result-content th,
.result-content td {
border: 1px solid var(--border);
padding: 8px 10px;
text-align: left;
vertical-align: top;
}
.result-content th {
background: var(--surface);
font-weight: 600;
}
.result-content code {
background: var(--surface);
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
}
.result-content pre {
background: var(--surface);
padding: 12px;
border-radius: 6px;
overflow-x: auto;
}
.result-content pre code {
background: transparent;
padding: 0;
}
.result-content blockquote {
border-left: 3px solid var(--primary);
padding-left: 12px;
margin: 12px 0;
color: var(--text-dim);
}
.error {
background: rgba(248, 81, 73, 0.08);
border-color: rgba(248, 81, 73, 0.3);
color: #ffb3ae;
}
.hidden { display: none !important; }
footer {
padding: 20px 0 28px;
border-top: 1px solid var(--border);
color: var(--text-dim);
font-size: 13px;
text-align: center;
}
@media (max-width: 600px) {
.hero { padding: 36px 0 24px; }
.hero h1 { font-size: 28px; }
.card { padding: 16px; }
}