revert: app/ → site/ (drhider proves site/ works fine)
This commit is contained in:
@@ -0,0 +1,518 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Архитектура — Сверка договоров</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117; --fg: #c9d1d9; --muted: #8b949e;
|
||||
--accent: #58a6ff; --green: #3fb950; --orange: #d2991d;
|
||||
--yellow: #e3b341; --red: #f85149; --border: #30363d;
|
||||
--code-bg: #161b22; --card-bg: #161b22;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font: 15px/1.65 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--fg); max-width: 860px; margin: 0 auto; padding: 32px 20px 80px; }
|
||||
h1 { font-size: 26px; margin-bottom: 8px; }
|
||||
h2 { font-size: 19px; margin-top: 36px; margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid var(--border); color: var(--accent); }
|
||||
h3 { font-size: 16px; margin-top: 24px; margin-bottom: 6px; color: var(--green); }
|
||||
p { margin: 8px 0; }
|
||||
blockquote { border-left: 3px solid var(--orange); margin: 12px 0; padding: 8px 16px; background: var(--card-bg); border-radius: 0 6px 6px 0; font-style: italic; color: var(--yellow); }
|
||||
blockquote strong { color: var(--orange); }
|
||||
ul, ol { padding-left: 24px; margin: 8px 0; }
|
||||
li { margin: 4px 0; }
|
||||
code { background: var(--code-bg); padding: 1px 5px; border-radius: 4px; font-size: 13px; border: 1px solid var(--border); }
|
||||
pre { background: var(--code-bg); padding: 12px 16px; border-radius: 6px; overflow-x: auto; font-size: 13px; border: 1px solid var(--border); margin: 12px 0; }
|
||||
.tag { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; font-weight: 600; margin-right: 6px; }
|
||||
.tag-ok { background: #1a3a2a; color: var(--green); }
|
||||
.tag-llm { background: #1a2a3a; color: var(--accent); }
|
||||
.tag-warn { background: #3a2a1a; color: var(--orange); }
|
||||
.tag-future { background: #2a1a3a; color: #bc8cff; }
|
||||
.nav { margin-bottom: 24px; }
|
||||
.nav a { color: var(--accent); text-decoration: none; }
|
||||
.nav a:hover { text-decoration: underline; }
|
||||
hr { border: none; border-top: 1px solid var(--border); margin: 32px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="nav"><a href="/">← Вернуться к загрузке</a></div>
|
||||
|
||||
<h1>🏗️ Архитектура — сверка договоров</h1>
|
||||
|
||||
<svg viewBox="0 0 860 280" style="width:100%;max-width:860px;margin:16px 0;" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<marker id="arrow" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#58a6ff"/></marker>
|
||||
<linearGradient id="g1" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#1a3a2a"/><stop offset="100%" stop-color="#0d2818"/></linearGradient>
|
||||
<linearGradient id="g2" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#1a2a3a"/><stop offset="100%" stop-color="#0d1a28"/></linearGradient>
|
||||
<linearGradient id="g3" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#2a1a3a"/><stop offset="100%" stop-color="#1a0d28"/></linearGradient>
|
||||
<linearGradient id="g4" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#3a2a1a"/><stop offset="100%" stop-color="#281a0d"/></linearGradient>
|
||||
<filter id="shadow"><feDropShadow dx="0" dy="1" stdDeviation="2" flood-opacity="0.3"/></filter>
|
||||
</defs>
|
||||
|
||||
<!-- Row 1: Pipeline -->
|
||||
<!-- 1. Upload -->
|
||||
<rect x="10" y="30" width="120" height="80" rx="8" fill="url(#g1)" stroke="#3fb950" stroke-width="1.5" filter="url(#shadow)"/>
|
||||
<text x="70" y="60" text-anchor="middle" fill="#3fb950" font-size="13" font-weight="bold">📤 Загрузка</text>
|
||||
<text x="70" y="80" text-anchor="middle" fill="#8b949e" font-size="10">.docx .pdf .doc .zip</text>
|
||||
<text x="70" y="96" text-anchor="middle" fill="#8b949e" font-size="9">Whitelist форматов</text>
|
||||
|
||||
<line x1="132" y1="70" x2="160" y2="70" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- 2. Parse -->
|
||||
<rect x="165" y="30" width="120" height="80" rx="8" fill="url(#g2)" stroke="#58a6ff" stroke-width="1.5" filter="url(#shadow)"/>
|
||||
<text x="225" y="60" text-anchor="middle" fill="#58a6ff" font-size="13" font-weight="bold">🔍 Парсинг</text>
|
||||
<text x="225" y="80" text-anchor="middle" fill="#8b949e" font-size="10">pdfplumber</text>
|
||||
<text x="225" y="96" text-anchor="middle" fill="#8b949e" font-size="10">python-docx</text>
|
||||
|
||||
<line x1="287" y1="70" x2="315" y2="70" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- 3. Classify -->
|
||||
<rect x="320" y="30" width="130" height="80" rx="8" fill="url(#g3)" stroke="#bc8cff" stroke-width="1.5" filter="url(#shadow)"/>
|
||||
<text x="385" y="55" text-anchor="middle" fill="#bc8cff" font-size="13" font-weight="bold">🏷️ Классификация</text>
|
||||
<text x="385" y="73" text-anchor="middle" fill="#8b949e" font-size="9">3-этапный фильтр</text>
|
||||
<text x="385" y="88" text-anchor="middle" fill="#8b949e" font-size="9">имя → заголовки → LLM</text>
|
||||
<text x="385" y="103" text-anchor="middle" fill="#f85149" font-size="8">мусор отсеивается</text>
|
||||
|
||||
<line x1="452" y1="70" x2="480" y2="70" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- 4. Group -->
|
||||
<rect x="485" y="30" width="120" height="80" rx="8" fill="url(#g4)" stroke="#d2991d" stroke-width="1.5" filter="url(#shadow)"/>
|
||||
<text x="545" y="60" text-anchor="middle" fill="#d2991d" font-size="13" font-weight="bold">📋 Группировка</text>
|
||||
<text x="545" y="80" text-anchor="middle" fill="#8b949e" font-size="10">по номерам</text>
|
||||
<text x="545" y="96" text-anchor="middle" fill="#8b949e" font-size="10">договоров</text>
|
||||
|
||||
<line x1="607" y1="70" x2="635" y2="70" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- 5. Compare -->
|
||||
<rect x="640" y="30" width="130" height="80" rx="8" fill="url(#g2)" stroke="#58a6ff" stroke-width="1.5" filter="url(#shadow)"/>
|
||||
<text x="705" y="55" text-anchor="middle" fill="#58a6ff" font-size="13" font-weight="bold">🤖 LLM-сравнение</text>
|
||||
<text x="705" y="73" text-anchor="middle" fill="#8b949e" font-size="9">extract + diff</text>
|
||||
<text x="705" y="88" text-anchor="middle" fill="#8b949e" font-size="9">gpt-oss-120b</text>
|
||||
<text x="705" y="103" text-anchor="middle" fill="#f85149" font-size="8">ADD UPDATE DELETE</text>
|
||||
|
||||
<!-- Row 2: Protection layer -->
|
||||
<rect x="10" y="140" width="860" height="55" rx="8" fill="#1a0a0a" stroke="#30363d" stroke-width="1" stroke-dasharray="4,3"/>
|
||||
<text x="440" y="162" text-anchor="middle" fill="#f85149" font-size="12" font-weight="bold">🛡️ Защита на каждом этапе</text>
|
||||
<text x="440" y="182" text-anchor="middle" fill="#8b949e" font-size="10">ZIP-бомба (500 файлов, 500MB, ratio 100×) | Path traversal | Whitelist форматов | content_hash дедупликация | UNRESOLVED для битых данных | авто-нормализация чисел/дат | арифметическая проверка</text>
|
||||
|
||||
<line x1="440" y1="128" x2="440" y2="138" stroke="#f85149" stroke-width="1" stroke-dasharray="2,2"/>
|
||||
|
||||
<!-- Row 3: Event Sourcing output -->
|
||||
<rect x="10" y="220" width="860" height="40" rx="8" fill="#161b22" stroke="#3fb950" stroke-width="1.5" filter="url(#shadow)"/>
|
||||
<text x="440" y="245" text-anchor="middle" fill="#3fb950" font-size="12" font-weight="bold">📊 Результат: spec_events (аудит) → spec_current (текущая спецификация) | Промпты с версионированием и редактором | Промежуточные результаты по каждому файлу</text>
|
||||
</svg>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>1. Загрузка файлов</h2>
|
||||
<p>Пользователь выбирает <code>.docx</code> / <code>.pdf</code> / <code>.doc</code> / <code>.zip</code>.</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>PDF:</strong> <code>pdfplumber</code> извлекает текст и таблицы с сохранением структуры колонок</li>
|
||||
<li><strong>DOCX:</strong> <code>python-docx</code> читает XML — извлекает параграфы и таблицы построчно</li>
|
||||
<li><strong>DOC:</strong> обрабатывается как DOCX (python-docx читает большинство .doc)</li>
|
||||
<li><strong>ZIP:</strong> архив распаковывается с лимитом 500 файлов и проверкой на сжатие (защита от ZIP-бомбы). Каждый файл внутри проходит тот же путь загрузки</li>
|
||||
<li>Файлы не поддерживаемых форматов — отклоняются с пометкой <code>unknown_format</code></li>
|
||||
</ul>
|
||||
|
||||
<p><strong>Результат:</strong> <code>elements_json</code> — массив элементов двух типов:</p>
|
||||
<ul>
|
||||
<li><code>{type: "paragraph", text: "...", style: "..."}</code></li>
|
||||
<li><code>{type: "table", rows: [["колонка1", "колонка2"], ...]}</code></li>
|
||||
</ul>
|
||||
|
||||
<p>Документ сохраняется в БД, считается <code>content_hash</code> (SHA-256) — если в этом же батче уже есть файл с таким содержимым, он помечается как <code>duplicate</code> и не дублируется.</p>
|
||||
|
||||
<blockquote>
|
||||
📋 <strong>Заказчик:</strong> «100+ файлов за раз. Файлы: .docx/.pdf, возможно ZIP.<br>
|
||||
Загрузка из локали (файловая шара / облачный диск), НЕ S3 (конфиденциальность).»
|
||||
</blockquote>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>2. Классификация документов</h2>
|
||||
<p>Каждый загруженный файл проходит <strong>трёхэтапный фильтр</strong>.</p>
|
||||
|
||||
<blockquote>
|
||||
📋 <strong>Заказчик:</strong> «Оставлять только: договоры, доп. соглашения, спецификации.<br>
|
||||
Игнорировать: акты сверки, счета/счета-фактуры/УПД, акты услуг, платёжки.»
|
||||
</blockquote>
|
||||
|
||||
<h3>Этап 1 — имя файла <span class="tag tag-ok">0 токенов</span></h3>
|
||||
<p>Проверка имени файла на мусорные ключевые слова: «счёт», «акт», «платёж», «УПД», «сверка», «invoice» и др. Совпадение → <code>garbage</code>, дальше не идёт.</p>
|
||||
|
||||
<h3>Этап 2 — заголовки в тексте <span class="tag tag-ok">0 токенов</span></h3>
|
||||
<p>Проверка первых 2000 символов текста на заголовки: «СЧЕТ-ФАКТУРА», «АКТ СВЕРКИ», «АКТ ОКАЗАННЫХ УСЛУГ» и т.п. Совпадение → <code>garbage</code>.</p>
|
||||
|
||||
<blockquote>
|
||||
📋 <strong>Заказчик:</strong> «Названия документов неинформативные. Есть лишние документы.<br>
|
||||
По всем контрагентам.»
|
||||
</blockquote>
|
||||
|
||||
<h3>Этап 3 — LLM <span class="tag tag-llm">LLM</span></h3>
|
||||
<p>Оставшиеся документы отправляются в LLM. Модель получает выжимку текста (первые 1500 символов + строки с маркерами «договор», «соглашение», «спецификация») и возвращает JSON:</p>
|
||||
|
||||
<ul>
|
||||
<li><code>doc_type</code> — <code>contract</code> / <code>supplement</code> / <code>specification</code> / <code>other</code></li>
|
||||
<li><code>own_number</code> — номер этого документа</li>
|
||||
<li><code>parent_number</code> — номер родительского договора (для допников и спецификаций)</li>
|
||||
<li><code>doc_date</code> — дата документа YYYY-MM-DD</li>
|
||||
<li><code>counterparty</code> — название контрагента</li>
|
||||
</ul>
|
||||
|
||||
<blockquote>
|
||||
📋 <strong>Заказчик:</strong> «НУБЕС = Исполнитель во всех целевых договорах.»<br>
|
||||
→ counterparty = <strong>другая сторона</strong> (Заказчик).
|
||||
</blockquote>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>3. Группировка по договорам</h2>
|
||||
<p><strong>Детерминированная (без LLM).</strong> Документы группируются по номерам договоров.</p>
|
||||
|
||||
<blockquote>
|
||||
📋 <strong>Заказчик:</strong> «Система сама должна разбираться, кто к кому.<br>
|
||||
зачем нам базовый договор? Вся информация есть в допниках и спеках.»<br>
|
||||
«Я не должен распознавать ничего. Система должна сделать всё.»
|
||||
</blockquote>
|
||||
|
||||
<ul>
|
||||
<li>Номера нормализуются: uppercase, удаляются все символы кроме букв и цифр.<br>
|
||||
«МЭС-123/2024» → «МЭС1232024»</li>
|
||||
<li>Допники и спецификации с одинаковым <code>parent_number</code> попадают в <strong>одну виртуальную группу</strong> — даже если сам договор не загружен</li>
|
||||
<li>Неопознанные файлы → отдельная группа «Не распознано» с указанием причины</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>4. Сравнение — извлечение + дифф</h2>
|
||||
<p>Для каждой группы запускается последовательная обработка.</p>
|
||||
|
||||
<blockquote>
|
||||
📋 <strong>Заказчик:</strong> «Важно. В произвольной, не повторяющейся.» (порядок допников)<br>
|
||||
«Главное — точность разбора, не UI. Сначала базовая функция → потом юзабельность.»
|
||||
</blockquote>
|
||||
|
||||
<h3>4a. Сортировка</h3>
|
||||
<p>Документы внутри группы сортируются по <code>doc_date</code> (из классификации), затем по времени загрузки.</p>
|
||||
|
||||
<h3>4b. Базовый договор (первый в группе) <span class="tag tag-llm">LLM</span></h3>
|
||||
<ul>
|
||||
<li>Текст спецификации услуг → LLM с промптом <code>extract</code></li>
|
||||
<li>LLM получает текст и инструкцию «найди таблицу услуг, извлеки каждую строку как ADD»</li>
|
||||
<li>Ответ: массив операций ADD — каждая услуга с полями: название, цена, количество, сумма, дата начала</li>
|
||||
</ul>
|
||||
|
||||
<h3>4c. Дополнительные соглашения <span class="tag tag-llm">LLM</span></h3>
|
||||
<ul>
|
||||
<li>Текущая спецификация + текст допника → LLM с промптом <code>diff</code></li>
|
||||
<li>LLM определяет изменения:
|
||||
<ul>
|
||||
<li><code>ADD</code> — новая услуга</li>
|
||||
<li><code>UPDATE</code> — изменение цены/количества/суммы</li>
|
||||
<li><code>DELETE</code> — услуга исключена</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3>4d. Режим «изложить в новой редакции»</h3>
|
||||
<p>Если допник говорит «изложить в новой редакции» — LLM возвращает полный новый список (<code>full_replace</code>). Все старые строки заменяются новыми.</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>5. Защита от дурака и злонамеренности <span class="tag tag-warn">защита</span></h2>
|
||||
|
||||
<table style="width:100%;border-collapse:collapse;margin:12px 0;border:1px solid var(--border)">
|
||||
<tr style="background:var(--code-bg)">
|
||||
<th style="padding:8px;text-align:left;border:1px solid var(--border)">Угроза</th>
|
||||
<th style="padding:8px;text-align:left;border:1px solid var(--border)">Защита</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">ZIP-бомба (архив 1 KB → 10 GB)</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Лимит 500 файлов, 500 MB, ratio сжатия ≤ 100×</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Path traversal в ZIP (<code>../../etc/passwd</code>)</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Проверка имени файла ДО <code>os.path.basename</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Битые кодировки имён в ZIP</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">cp437 → utf8 перекодировка</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Файлы не-Word/PDF (exe, картинки, etc.)</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Whitelist: только <code>pdf/docx/doc/zip</code> — остальные <code>unknown_format</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Дубликаты файлов (тот же контент, другое имя)</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)"><code>sha256(original_bytes)</code> → пропуск с пометкой <code>duplicate</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Дубликаты услуг (одна услуга в разных допниках)</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">UPSERT по <code>name_hash</code> (имя + дата начала) — разные периоды = разные строки</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">ADD без названия услуги</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">→ <code>UNRESOLVED</code>, не применяется</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">UPDATE/DELETE без идентификатора цели</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Пустой <code>target_hash</code> → <code>UNRESOLVED</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Неизвестный тип операции от LLM</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">→ <code>UNRESOLVED</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Кривые числа (<code>1 000,50</code>)</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Авто-нормализация: пробелы → удалить, запятая → точка</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Кривые даты (<code>01.03.2026</code>)</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Авто-нормализация: DD.MM.YYYY → YYYY-MM-DD</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Арифметические ошибки</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Проверка <code>price × qty = sum</code> — расхождение → флаг</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Мусорные документы (счета, акты, платёжки)</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Трёхэтапный фильтр: имя файла → заголовки (2000 симв.) → LLM</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Тихие ошибки (<code>try/except: pass</code>)</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Везде <code>logging.warning</code> с контекстом</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<blockquote>
|
||||
📋 <strong>Заказчик:</strong> «кривых документов можно ожидать.<br>
|
||||
Если не может разобраться — пусть зовет на помощь юзера.»
|
||||
</blockquote>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>6. Event Sourcing — аудит <span class="tag tag-ok">аудит</span></h2>
|
||||
|
||||
<blockquote>
|
||||
📋 <strong>Заказчик:</strong> «Наверно я захочу иметь возможность посмотреть любые промежуточные результаты.<br>
|
||||
Было бы хорошо пояснить или показать процесс, что в каком порядке происходит.»
|
||||
</blockquote>
|
||||
|
||||
<ul>
|
||||
<li><strong>spec_events:</strong> полный лог всех операций. Каждое событие содержит:
|
||||
<ul>
|
||||
<li>тип (<code>ADD/UPDATE/DELETE/UNRESOLVED</code>)</li>
|
||||
<li>источник — какой документ</li>
|
||||
<li>версия промпта</li>
|
||||
<li>полный сырой ответ LLM</li>
|
||||
<li>временную метку</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>spec_current:</strong> материализованное представление = сумма всех событий</li>
|
||||
<li><strong>Промежуточные результаты:</strong> для каждого файла доступны:
|
||||
<ul>
|
||||
<li>текст, отправленный в LLM</li>
|
||||
<li>сырой ответ LLM</li>
|
||||
<li>распарсенный JSON операций</li>
|
||||
<li>какие операции были применены</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>7. Промпты <span class="tag tag-llm">LLM</span></h2>
|
||||
<p>Три роли промптов, хранятся в БД с версионированием:</p>
|
||||
|
||||
<table style="width:100%;border-collapse:collapse;margin:12px 0;border:1px solid var(--border)">
|
||||
<tr style="background:var(--code-bg)">
|
||||
<th style="padding:8px;text-align:left;border:1px solid var(--border)">Роль</th>
|
||||
<th style="padding:8px;text-align:left;border:1px solid var(--border)">Назначение</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)"><code>extract</code></td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Извлечение строк спецификации из договора</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)"><code>diff</code></td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Сравнение допников с текущей спецификацией</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)"><code>classify</code></td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Определение типа/номера/даты/контрагента документа</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p><strong>Встроенный редактор промптов</strong> с полным CRUD и историей версий:</p>
|
||||
<ul>
|
||||
<li>Создание, редактирование, удаление промптов — через веб-интерфейс</li>
|
||||
<li>Активация версии — один клик, и она сразу используется LLM</li>
|
||||
<li>История версий — все предыдущие редакции сохраняются, можно откатиться</li>
|
||||
<li>Без правки кода и без редеплоя — юрист может сам экспериментировать с промптами</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>8. Хранение документов <span class="tag tag-ok">конфиденциальность</span></h2>
|
||||
|
||||
<blockquote>
|
||||
📋 <strong>Заказчик:</strong> «Загрузка из локали, НЕ S3 (конфиденциальность).»
|
||||
</blockquote>
|
||||
|
||||
<p><strong>Исходные бинарные файлы (.docx/.pdf/.doc/zip) удаляются с сервера сразу после парсинга.</strong> На диске не остаются.</p>
|
||||
<p><strong>В БД сохраняются</strong> — это и есть рабочие данные системы:</p>
|
||||
<ul>
|
||||
<li>Метаданные: имя файла, дата, хеш содержимого</li>
|
||||
<li>Результат парсинга: <code>elements_json</code> (текст и таблицы)</li>
|
||||
<li>Результат классификации: тип, номер, дата, контрагент</li>
|
||||
<li>События спецификации: ADD/UPDATE/DELETE</li>
|
||||
</ul>
|
||||
|
||||
<p style="color:var(--orange);"><strong>⚠️ Пока не реализовано:</strong> удаление промежуточных данных после сессии. Варианты: кнопка «Очистить» в интерфейсе, либо Redis с TTL. Надо решать.</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>9. Инженерные практики <span class="tag tag-ok">качество кода</span></h2>
|
||||
|
||||
<h3>Decoupling — разделение ответственности</h3>
|
||||
<p>Пайплайн разбит на независимые слои с чёткими интерфейсами:</p>
|
||||
|
||||
<table style="width:100%;border-collapse:collapse;margin:12px 0;border:1px solid var(--border)">
|
||||
<tr style="background:var(--code-bg)">
|
||||
<th style="padding:8px;text-align:left;border:1px solid var(--border)">Слой</th>
|
||||
<th style="padding:8px;text-align:left;border:1px solid var(--border)">Что делает</th>
|
||||
<th style="padding:8px;text-align:left;border:1px solid var(--border)">Можно заменить отдельно</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Контракты данных</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">5 frozen dataclass'ов: <code>ParseResult</code>, <code>ClassifyResult</code>, <code>GroupingResult</code>, <code>BatchGroupingResult</code>, <code>CompareOp</code></td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Формат данных не привязан к хранилищу</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">LLM-клиент</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Протокол <code>LLMClient.complete(prompt) → str</code></td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">HttpxLLMClient ↔ FakeLLMClient ↔ другая модель</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Репозиторий</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Протокол <code>Repository</code> (17 методов)</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">PgRepository ↔ MemRepository (тесты)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Загрузка</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)"><code>parse_multipart()</code> — чистая функция</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Парсер не зависит от HTTP-фреймворка</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Классификация</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)"><code>classify_batch(batch_id, llm_client, repo)</code> — Dependency Injection</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Любой LLM + любое хранилище</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3>Dependency Injection</h3>
|
||||
<p>Все внешние зависимости (LLM, БД) пробрасываются через параметры, а не через глобальные <code>import</code>:</p>
|
||||
<ul>
|
||||
<li><code>call_llm(prompt, llm_client=None)</code> — DI с обратной совместимостью</li>
|
||||
<li><code>classify_batch(batch_id, llm_client=None, repo=None)</code> — можно подставить любой backend</li>
|
||||
<li>В продакшене: HttpxLLMClient + PgRepository</li>
|
||||
<li>В тестах: FakeLLMClient (record/replay) + MemRepository (в памяти)</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>10. Тестирование <span class="tag tag-ok">26/26 PASS</span></h2>
|
||||
|
||||
<table style="width:100%;border-collapse:collapse;margin:12px 0;border:1px solid var(--border)">
|
||||
<tr style="background:var(--code-bg)">
|
||||
<th style="padding:8px;text-align:left;border:1px solid var(--border)">Модуль</th>
|
||||
<th style="padding:8px;text-align:left;border:1px solid var(--border)">Тестов</th>
|
||||
<th style="padding:8px;text-align:left;border:1px solid var(--border)">Что проверяют</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Контракты + загрузка</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">13</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Создание dataclass'ов, garbage-фильтр, multipart-парсинг, path-traversal</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">LLM + Репозиторий</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">9</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">FakeLLMClient, MemRepository CRUD, все 17 методов</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Классификация</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">4</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Garbage по имени файла, garbage по заголовкам, classify с FakeLLM, no pending</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>Все тесты проходят без доступа к БД и LLM — через <code>FakeLLMClient</code> (record/replay) и <code>MemRepository</code>. Запуск: <code>pytest deploy/tests/ -v</code>.</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>11. Стек технологий</h2>
|
||||
|
||||
<table style="width:100%;border-collapse:collapse;margin:12px 0;border:1px solid var(--border)">
|
||||
<tr style="background:var(--code-bg)">
|
||||
<th style="padding:8px;text-align:left;border:1px solid var(--border)">Компонент</th>
|
||||
<th style="padding:8px;text-align:left;border:1px solid var(--border)">Где</th>
|
||||
<th style="padding:8px;text-align:left;border:1px solid var(--border)">Технология</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Веб-интерфейс</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Managed Python (Nubes)</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Python 3.12, Jinja2, ванильный JS</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Механизм обработки</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Выделенная VM</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Python 3.12, httpx, pdfplumber, python-docx</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">База данных</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">VM PostgreSQL 15</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">JSONB, UUID, advisory locks</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">LLM</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">api.aillm.ru</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">gpt-oss-120b (бесплатно, OpenAI API)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Деплой</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Managed Python + VM</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Dockerfile, Nginx + Let's Encrypt</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>12. Конечная цель <span class="tag tag-future">будущее</span></h2>
|
||||
|
||||
<blockquote>
|
||||
📋 <strong>Заказчик:</strong> «Конечная цель: построчное сравнение CRM ↔ фискальная система.<br>
|
||||
Особый фокус: даты начала услуг.<br>
|
||||
PAYG (суффикс -m) — особый случай. Сверку с CRM тоже можно делегировать AI.»
|
||||
</blockquote>
|
||||
|
||||
<p>Текущий пайплайн завершается на этапе <strong>извлечения спецификации из договоров и отслеживания изменений по допникам</strong>. Модуль сравнения с CRM — спроектирован как <code>CanonicalRow</code> адаптер, ожидает формата данных от заказчика.</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>13. Что дальше <span class="tag tag-future">требует участия Заказчика</span></h2>
|
||||
|
||||
<blockquote>
|
||||
📋 <strong>Заказчик:</strong> «Это всё пока опыты и набивание шишек.<br>
|
||||
Не надеюсь с первого захода на идеальный результат.»
|
||||
</blockquote>
|
||||
|
||||
<ol>
|
||||
<li><strong>Золотой набор:</strong> 30–50 реальных документов с ручной разметкой — измерить точность LLM</li>
|
||||
<li><strong>Формат CRM:</strong> в каком виде данные из системы учёта клиентов — для модуля сверки</li>
|
||||
<li><strong>Приоритет:</strong> точность vs скорость обработки — влияет на выбор модели LLM (локальная vs облачная)</li>
|
||||
</ol>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,300 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Архитектура — блок-схемы</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117; --fg: #c9d1d9; --muted: #8b949e;
|
||||
--accent: #58a6ff; --green: #3fb950; --border: #30363d;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font: 15px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--fg); max-width: 960px; margin: 0 auto; padding: 32px 20px 80px; }
|
||||
h1 { font-size: 26px; margin-bottom: 8px; }
|
||||
h2 { font-size: 18px; margin-top: 48px; margin-bottom: 16px; color: var(--accent); }
|
||||
.nav { margin-bottom: 24px; }
|
||||
.nav a { color: var(--accent); text-decoration: none; }
|
||||
.nav a:hover { text-decoration: underline; }
|
||||
svg { display: block; max-width: 100%; height: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="nav"><a href="/docs/architect">← Архитектура (текст)</a></div>
|
||||
|
||||
<h1>🏗️ Архитектура — блок-схемы</h1>
|
||||
|
||||
<h2>1. Система — компоненты и связи</h2>
|
||||
|
||||
<svg viewBox="0 0 920 340" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<marker id="a1" markerWidth="7" markerHeight="5" refX="7" refY="2.5" orient="auto"><path d="M0,0 L7,2.5 L0,5 Z" fill="#58a6ff"/></marker>
|
||||
<marker id="a2" markerWidth="7" markerHeight="5" refX="7" refY="2.5" orient="auto"><path d="M0,0 L7,2.5 L0,5 Z" fill="#3fb950"/></marker>
|
||||
<filter id="sh"><feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.25"/></filter>
|
||||
<linearGradient id="gradFlask" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#1a2a3a"/><stop offset="100%" stop-color="#0d1a28"/></linearGradient>
|
||||
<linearGradient id="gradVM" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#1a2a1a"/><stop offset="100%" stop-color="#0d1a0d"/></linearGradient>
|
||||
<linearGradient id="gradDB" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#2a1a2a"/><stop offset="100%" stop-color="#1a0d1a"/></linearGradient>
|
||||
<linearGradient id="gradLLM" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#3a2a1a"/><stop offset="100%" stop-color="#281a0d"/></linearGradient>
|
||||
<linearGradient id="gradUser" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#2a1a1a"/><stop offset="100%" stop-color="#1a0d0d"/></linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- User -->
|
||||
<rect x="30" y="100" width="140" height="90" rx="10" fill="url(#gradUser)" stroke="#f85149" stroke-width="1.5" filter="url(#sh)"/>
|
||||
<text x="100" y="135" text-anchor="middle" fill="#f85149" font-size="15" font-weight="bold">👤 Пользователь</text>
|
||||
<text x="100" y="155" text-anchor="middle" fill="#8b949e" font-size="11">Браузер</text>
|
||||
<text x="100" y="172" text-anchor="middle" fill="#8b949e" font-size="11">Загрузка файлов</text>
|
||||
|
||||
<!-- Arrow User → Flask -->
|
||||
<line x1="172" y1="130" x2="230" y2="80" stroke="#f85149" stroke-width="1.5" marker-end="url(#a1)"/>
|
||||
<text x="205" y="95" fill="#8b949e" font-size="9">HTTPS</text>
|
||||
|
||||
<line x1="172" y1="150" x2="230" y2="150" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#a1)"/>
|
||||
<text x="205" y="143" fill="#8b949e" font-size="9">SSE</text>
|
||||
|
||||
<!-- Flask -->
|
||||
<rect x="235" y="50" width="160" height="120" rx="10" fill="url(#gradFlask)" stroke="#58a6ff" stroke-width="1.5" filter="url(#sh)"/>
|
||||
<text x="315" y="80" text-anchor="middle" fill="#58a6ff" font-size="14" font-weight="bold">⚡ Flask-фронтенд</text>
|
||||
<text x="315" y="100" text-anchor="middle" fill="#8b949e" font-size="10">Managed Python (Nubes)</text>
|
||||
<text x="315" y="118" text-anchor="middle" fill="#8b949e" font-size="10">Jinja2-шаблоны</text>
|
||||
<text x="315" y="136" text-anchor="middle" fill="#8b949e" font-size="10">Промпт-редактор</text>
|
||||
<text x="315" y="154" text-anchor="middle" fill="#8b949e" font-size="10">Чат с LLM</text>
|
||||
|
||||
<!-- Arrow Flask → VM -->
|
||||
<line x1="397" y1="110" x2="455" y2="110" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#a1)"/>
|
||||
<text x="430" y="103" fill="#8b949e" font-size="9">REST API</text>
|
||||
|
||||
<!-- VM box -->
|
||||
<rect x="460" y="15" width="230" height="210" rx="10" fill="none" stroke="#30363d" stroke-width="1" stroke-dasharray="6,4"/>
|
||||
<text x="575" y="35" text-anchor="middle" fill="#8b949e" font-size="11">Выделенная VM</text>
|
||||
|
||||
<!-- Parsing -->
|
||||
<rect x="480" y="50" width="190" height="50" rx="8" fill="url(#gradVM)" stroke="#3fb950" stroke-width="1" filter="url(#sh)"/>
|
||||
<text x="575" y="72" text-anchor="middle" fill="#3fb950" font-size="12" font-weight="bold">📄 Парсинг документов</text>
|
||||
<text x="575" y="90" text-anchor="middle" fill="#8b949e" font-size="10">pdfplumber · python-docx</text>
|
||||
|
||||
<!-- Classification -->
|
||||
<rect x="480" y="110" width="190" height="50" rx="8" fill="url(#gradVM)" stroke="#bc8cff" stroke-width="1" filter="url(#sh)"/>
|
||||
<text x="575" y="132" text-anchor="middle" fill="#bc8cff" font-size="12" font-weight="bold">🏷️ Классификация</text>
|
||||
<text x="575" y="150" text-anchor="middle" fill="#8b949e" font-size="10">3-этапный фильтр мусора</text>
|
||||
|
||||
<!-- Comparison -->
|
||||
<rect x="480" y="170" width="190" height="45" rx="8" fill="url(#gradVM)" stroke="#d2991d" stroke-width="1" filter="url(#sh)"/>
|
||||
<text x="575" y="190" text-anchor="middle" fill="#d2991d" font-size="12" font-weight="bold">🤖 LLM-сравнение</text>
|
||||
<text x="575" y="207" text-anchor="middle" fill="#8b949e" font-size="10">extract + diff · группировка</text>
|
||||
|
||||
<!-- Internal arrows -->
|
||||
<line x1="575" y1="100" x2="575" y2="108" stroke="#58a6ff" stroke-width="1" marker-end="url(#a1)"/>
|
||||
<line x1="575" y1="160" x2="575" y2="168" stroke="#58a6ff" stroke-width="1" marker-end="url(#a1)"/>
|
||||
|
||||
<!-- Arrow VM → LLM -->
|
||||
<line x1="692" y1="140" x2="750" y2="90" stroke="#d2991d" stroke-width="1.5" marker-end="url(#a1)"/>
|
||||
<text x="725" y="108" fill="#8b949e" font-size="9">HTTP/2</text>
|
||||
|
||||
<!-- LLM -->
|
||||
<rect x="755" y="60" width="140" height="80" rx="10" fill="url(#gradLLM)" stroke="#d2991d" stroke-width="1.5" filter="url(#sh)"/>
|
||||
<text x="825" y="90" text-anchor="middle" fill="#d2991d" font-size="13" font-weight="bold">🧠 LLM</text>
|
||||
<text x="825" y="110" text-anchor="middle" fill="#8b949e" font-size="10">api.aillm.ru</text>
|
||||
<text x="825" y="128" text-anchor="middle" fill="#8b949e" font-size="10">gpt-oss-120b</text>
|
||||
|
||||
<!-- Arrow VM ↔ DB -->
|
||||
<line x1="575" y1="228" x2="575" y2="260" stroke="#bc8cff" stroke-width="1.5" marker-end="url(#a1)"/>
|
||||
|
||||
<!-- DB -->
|
||||
<rect x="470" y="265" width="210" height="55" rx="10" fill="url(#gradDB)" stroke="#bc8cff" stroke-width="1.5" filter="url(#sh)"/>
|
||||
<text x="575" y="290" text-anchor="middle" fill="#bc8cff" font-size="13" font-weight="bold">🗄️ PostgreSQL 15</text>
|
||||
<text x="575" y="310" text-anchor="middle" fill="#8b949e" font-size="10">JSONB · Event Sourcing · spec_events</text>
|
||||
</svg>
|
||||
|
||||
<h2>2. Пайплайн обработки одного файла</h2>
|
||||
|
||||
<svg viewBox="0 0 920 280" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<marker id="b1" markerWidth="6" markerHeight="4" refX="6" refY="2" orient="auto"><path d="M0,0 L6,2 L0,4 Z" fill="#58a6ff"/></marker>
|
||||
<marker id="b2" markerWidth="6" markerHeight="4" refX="6" refY="2" orient="auto"><path d="M0,0 L6,2 L0,4 Z" fill="#f85149"/></marker>
|
||||
<filter id="sh2"><feDropShadow dx="0" dy="1" stdDeviation="2" flood-opacity="0.2"/></filter>
|
||||
</defs>
|
||||
|
||||
<!-- Stage 1 -->
|
||||
<rect x="10" y="30" width="135" height="90" rx="8" fill="#1a2a1a" stroke="#3fb950" stroke-width="1.5" filter="url(#sh2)"/>
|
||||
<text x="77" y="55" text-anchor="middle" fill="#3fb950" font-size="12" font-weight="bold">📤 Загрузка</text>
|
||||
<text x="77" y="73" text-anchor="middle" fill="#8b949e" font-size="9">Multipart-парсинг</text>
|
||||
<text x="77" y="88" text-anchor="middle" fill="#8b949e" font-size="9">SHA-256 хеш</text>
|
||||
<text x="77" y="103" text-anchor="middle" fill="#8b949e" font-size="9">Формат: pdf/docx/zip</text>
|
||||
|
||||
<line x1="147" y1="75" x2="172" y2="75" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#b1)"/>
|
||||
|
||||
<!-- Stage 2 -->
|
||||
<rect x="177" y="30" width="135" height="90" rx="8" fill="#1a2a3a" stroke="#58a6ff" stroke-width="1.5" filter="url(#sh2)"/>
|
||||
<text x="244" y="55" text-anchor="middle" fill="#58a6ff" font-size="12" font-weight="bold">🔍 Парсинг</text>
|
||||
<text x="244" y="73" text-anchor="middle" fill="#8b949e" font-size="9">Извлечение текста</text>
|
||||
<text x="244" y="88" text-anchor="middle" fill="#8b949e" font-size="9">Извлечение таблиц</text>
|
||||
<text x="244" y="103" text-anchor="middle" fill="#8b949e" font-size="9">→ elements_json</text>
|
||||
|
||||
<line x1="314" y1="75" x2="339" y2="75" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#b1)"/>
|
||||
|
||||
<!-- Stage 3a -->
|
||||
<rect x="344" y="30" width="145" height="90" rx="8" fill="#2a1a3a" stroke="#bc8cff" stroke-width="1.5" filter="url(#sh2)"/>
|
||||
<text x="416" y="52" text-anchor="middle" fill="#bc8cff" font-size="12" font-weight="bold">🏷️ Фильтр #1-#2</text>
|
||||
<text x="416" y="68" text-anchor="middle" fill="#8b949e" font-size="9">Имя файла → мусор?</text>
|
||||
<text x="416" y="83" text-anchor="middle" fill="#8b949e" font-size="9">Заголовки → мусор?</text>
|
||||
<text x="416" y="98" text-anchor="middle" fill="#8b949e" font-size="9">0 токенов LLM</text>
|
||||
<text x="416" y="113" text-anchor="middle" fill="#f85149" font-size="8">мусор → стоп</text>
|
||||
|
||||
<line x1="491" y1="75" x2="516" y2="75" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#b1)"/>
|
||||
|
||||
<!-- Stage 3b -->
|
||||
<rect x="521" y="30" width="145" height="90" rx="8" fill="#2a2a1a" stroke="#d2991d" stroke-width="1.5" filter="url(#sh2)"/>
|
||||
<text x="593" y="52" text-anchor="middle" fill="#d2991d" font-size="12" font-weight="bold">🤖 Фильтр #3 — LLM</text>
|
||||
<text x="593" y="68" text-anchor="middle" fill="#8b949e" font-size="9">Тип: договор/допник/</text>
|
||||
<text x="593" y="83" text-anchor="middle" fill="#8b949e" font-size="9">спецификация/мусор</text>
|
||||
<text x="593" y="98" text-anchor="middle" fill="#8b949e" font-size="9">Номер, дата, контрагент</text>
|
||||
<text x="593" y="113" text-anchor="middle" fill="#f85149" font-size="8">мусор → стоп</text>
|
||||
|
||||
<line x1="668" y1="75" x2="693" y2="75" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#b1)"/>
|
||||
|
||||
<!-- Stage 4 -->
|
||||
<rect x="698" y="30" width="100" height="90" rx="8" fill="#3a2a1a" stroke="#d2991d" stroke-width="1.5" filter="url(#sh2)"/>
|
||||
<text x="748" y="60" text-anchor="middle" fill="#d2991d" font-size="12" font-weight="bold">📋 Группи-</text>
|
||||
<text x="748" y="78" text-anchor="middle" fill="#d2991d" font-size="12" font-weight="bold">ровка</text>
|
||||
<text x="748" y="96" text-anchor="middle" fill="#8b949e" font-size="9">По номерам</text>
|
||||
<text x="748" y="111" text-anchor="middle" fill="#8b949e" font-size="9">договоров</text>
|
||||
|
||||
<line x1="800" y1="75" x2="825" y2="75" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#b1)"/>
|
||||
|
||||
<!-- Stage 5 -->
|
||||
<rect x="830" y="30" width="80" height="90" rx="8" fill="#1a3a2a" stroke="#3fb950" stroke-width="1.5" filter="url(#sh2)"/>
|
||||
<text x="870" y="60" text-anchor="middle" fill="#3fb950" font-size="12" font-weight="bold">Срав-</text>
|
||||
<text x="870" y="78" text-anchor="middle" fill="#3fb950" font-size="12" font-weight="bold">нение</text>
|
||||
<text x="870" y="100" text-anchor="middle" fill="#8b949e" font-size="9">ADD</text>
|
||||
<text x="870" y="114" text-anchor="middle" fill="#8b949e" font-size="9">UPDATE</text>
|
||||
|
||||
<!-- Protection Bar -->
|
||||
<rect x="10" y="145" width="900" height="40" rx="6" fill="#1a0a0a" stroke="#f85149" stroke-width="1" stroke-dasharray="3,2"/>
|
||||
<text x="460" y="170" text-anchor="middle" fill="#f85149" font-size="11" font-weight="bold">🛡️ Защита: ZIP-бомба (500/500/100×) · Path traversal · Whitelist форматов · content_hash · UNRESOLVED · нормализация чисел/дат · price×qty=sum</text>
|
||||
|
||||
<!-- Output Bar -->
|
||||
<rect x="10" y="210" width="900" height="50" rx="6" fill="#161b22" stroke="#3fb950" stroke-width="1.5" filter="url(#sh2)"/>
|
||||
<text x="460" y="233" text-anchor="middle" fill="#3fb950" font-size="12" font-weight="bold">📊 База: documents (метаданные + elements_json) · classify_results · spec_events (аудит) · spec_current (спецификация) · prompts (версионирование)</text>
|
||||
<text x="460" y="250" text-anchor="middle" fill="#8b949e" font-size="9">Бинарные файлы удаляются после парсинга. Промежуточные данные — требуется автоочистка.</text>
|
||||
</svg>
|
||||
|
||||
<h2>3. Event Sourcing — как работает сравнение</h2>
|
||||
|
||||
<svg viewBox="0 0 920 380" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<marker id="c1" markerWidth="6" markerHeight="4" refX="6" refY="2" orient="auto"><path d="M0,0 L6,2 L0,4 Z" fill="#58a6ff"/></marker>
|
||||
<filter id="sh3"><feDropShadow dx="0" dy="1" stdDeviation="2" flood-opacity="0.2"/></filter>
|
||||
</defs>
|
||||
|
||||
<!-- Documents row -->
|
||||
<text x="460" y="24" text-anchor="middle" fill="#8b949e" font-size="12">Документы группы (отсортированы по doc_date)</text>
|
||||
|
||||
<rect x="10" y="38" width="100" height="55" rx="6" fill="#1a2a3a" stroke="#58a6ff" stroke-width="1" filter="url(#sh3)"/>
|
||||
<text x="60" y="60" text-anchor="middle" fill="#58a6ff" font-size="10" font-weight="bold">📄 Договор</text>
|
||||
<text x="60" y="78" text-anchor="middle" fill="#8b949e" font-size="9">базовый</text>
|
||||
|
||||
<rect x="130" y="38" width="80" height="55" rx="6" fill="#1a2a3a" stroke="#58a6ff" stroke-width="1" filter="url(#sh3)"/>
|
||||
<text x="170" y="60" text-anchor="middle" fill="#58a6ff" font-size="10" font-weight="bold">📄 Допник</text>
|
||||
<text x="170" y="78" text-anchor="middle" fill="#8b949e" font-size="9">#1</text>
|
||||
|
||||
<rect x="230" y="38" width="80" height="55" rx="6" fill="#1a2a3a" stroke="#58a6ff" stroke-width="1" filter="url(#sh3)"/>
|
||||
<text x="270" y="60" text-anchor="middle" fill="#58a6ff" font-size="10" font-weight="bold">📄 Допник</text>
|
||||
<text x="270" y="78" text-anchor="middle" fill="#8b949e" font-size="9">#2</text>
|
||||
|
||||
<text x="350" y="70" fill="#8b949e" font-size="20">...</text>
|
||||
|
||||
<!-- Arrow to Extract -->
|
||||
<line x1="60" y1="95" x2="60" y2="130" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#c1)"/>
|
||||
|
||||
<!-- Extract box -->
|
||||
<rect x="10" y="135" width="200" height="70" rx="8" fill="#1a2a3a" stroke="#d2991d" stroke-width="1.5" filter="url(#sh3)"/>
|
||||
<text x="110" y="158" text-anchor="middle" fill="#d2991d" font-size="12" font-weight="bold">🤖 LLM: промпт extract</text>
|
||||
<text x="110" y="176" text-anchor="middle" fill="#8b949e" font-size="10">Извлечение всех услуг</text>
|
||||
<text x="110" y="194" text-anchor="middle" fill="#8b949e" font-size="10">→ массив ADD-операций</text>
|
||||
|
||||
<line x1="212" y1="170" x2="242" y2="170" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#c1)"/>
|
||||
|
||||
<!-- Events box -->
|
||||
<rect x="247" y="130" width="200" height="80" rx="8" fill="#2a1a2a" stroke="#bc8cff" stroke-width="1.5" filter="url(#sh3)"/>
|
||||
<text x="347" y="153" text-anchor="middle" fill="#bc8cff" font-size="12" font-weight="bold">🗄️ spec_events</text>
|
||||
<text x="347" y="171" text-anchor="middle" fill="#8b949e" font-size="10">ADD услуга X</text>
|
||||
<text x="347" y="187" text-anchor="middle" fill="#8b949e" font-size="10">ADD услуга Y</text>
|
||||
<text x="347" y="203" text-anchor="middle" fill="#8b949e" font-size="10">(неизменяемый лог)</text>
|
||||
|
||||
<line x1="347" y1="212" x2="347" y2="242" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#c1)"/>
|
||||
|
||||
<!-- Current spec -->
|
||||
<rect x="247" y="247" width="200" height="70" rx="8" fill="#1a2a1a" stroke="#3fb950" stroke-width="1.5" filter="url(#sh3)"/>
|
||||
<text x="347" y="272" text-anchor="middle" fill="#3fb950" font-size="12" font-weight="bold">📊 spec_current</text>
|
||||
<text x="347" y="292" text-anchor="middle" fill="#8b949e" font-size="10">Услуга X | 100₽ | 10 шт</text>
|
||||
<text x="347" y="308" text-anchor="middle" fill="#8b949e" font-size="10">Услуга Y | 200₽ | 5 шт</text>
|
||||
|
||||
<!-- Arrow back to LLM for diff -->
|
||||
<line x1="449" y1="282" x2="500" y2="282" stroke="#3fb950" stroke-width="1.5" marker-end="url(#c1)"/>
|
||||
|
||||
<!-- Diff box -->
|
||||
<rect x="505" y="135" width="200" height="70" rx="8" fill="#3a2a1a" stroke="#d2991d" stroke-width="1.5" filter="url(#sh3)"/>
|
||||
<text x="605" y="158" text-anchor="middle" fill="#d2991d" font-size="12" font-weight="bold">🤖 LLM: промпт diff</text>
|
||||
<text x="605" y="176" text-anchor="middle" fill="#8b949e" font-size="10">(допник #1) vs spec_current</text>
|
||||
<text x="605" y="194" text-anchor="middle" fill="#8b949e" font-size="10">→ ADD / UPDATE / DELETE</text>
|
||||
|
||||
<line x1="707" y1="170" x2="737" y2="170" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#c1)"/>
|
||||
|
||||
<!-- Events 2 -->
|
||||
<rect x="742" y="130" width="168" height="80" rx="8" fill="#2a1a2a" stroke="#bc8cff" stroke-width="1.5" filter="url(#sh3)"/>
|
||||
<text x="826" y="153" text-anchor="middle" fill="#bc8cff" font-size="12" font-weight="bold">🗄️ spec_events</text>
|
||||
<text x="826" y="171" text-anchor="middle" fill="#8b949e" font-size="10">UPDATE услуга X</text>
|
||||
<text x="826" y="187" text-anchor="middle" fill="#8b949e" font-size="10">DELETE услуга Y</text>
|
||||
<text x="826" y="203" text-anchor="middle" fill="#8b949e" font-size="10">ADD услуга Z</text>
|
||||
|
||||
<line x1="826" y1="212" x2="826" y2="242" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#c1)"/>
|
||||
|
||||
<!-- Updated spec -->
|
||||
<rect x="726" y="247" width="184" height="70" rx="8" fill="#1a2a1a" stroke="#3fb950" stroke-width="1.5" filter="url(#sh3)"/>
|
||||
<text x="818" y="272" text-anchor="middle" fill="#3fb950" font-size="12" font-weight="bold">📊 spec_current (обн.)</text>
|
||||
<text x="818" y="292" text-anchor="middle" fill="#8b949e" font-size="10">Услуга X | 150₽ | 10 шт</text>
|
||||
<text x="818" y="308" text-anchor="middle" fill="#8b949e" font-size="10">Услуга Z | 300₽ | 2 шт</text>
|
||||
|
||||
<!-- Arrows between events -->
|
||||
<line x1="473" y1="170" x2="505" y2="150" stroke="#d2991d" stroke-width="1" stroke-dasharray="3,2"/>
|
||||
<text x="492" y="148" fill="#8b949e" font-size="8">цикл по допникам</text>
|
||||
|
||||
<!-- Legend -->
|
||||
<rect x="10" y="340" width="900" height="30" rx="4" fill="#161b22" stroke="#30363d" stroke-width="0.5"/>
|
||||
<text x="460" y="360" text-anchor="middle" fill="#8b949e" font-size="10">Каждый допник сравнивается с актуальной spec_current → операции накапливаются в spec_events → spec_current пересчитывается</text>
|
||||
</svg>
|
||||
|
||||
<h2>4. Защита — эшелоны</h2>
|
||||
|
||||
<svg viewBox="0 0 920 220" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<marker id="d1" markerWidth="5" markerHeight="3" refX="5" refY="1.5" orient="auto"><path d="M0,0 L5,1.5 L0,3 Z" fill="#f85149"/></marker>
|
||||
<filter id="sh4"><feDropShadow dx="0" dy="1" stdDeviation="2" flood-opacity="0.2"/></filter>
|
||||
</defs>
|
||||
|
||||
<!-- Layer 1: Upload -->
|
||||
<rect x="10" y="10" width="900" height="40" rx="6" fill="#1a0a0a" stroke="#f85149" stroke-width="1" filter="url(#sh4)"/>
|
||||
<text x="460" y="35" text-anchor="middle" fill="#f85149" font-size="12" font-weight="bold">🔴 Уровень 1 — Загрузка: ZIP-бомба (500 файлов / 500MB / ratio 100×) · Path traversal · Whitelist .pdf .docx .doc .zip</text>
|
||||
|
||||
<line x1="460" y1="50" x2="460" y2="58" stroke="#f85149" stroke-width="1" marker-end="url(#d1)"/>
|
||||
|
||||
<!-- Layer 2: Parse -->
|
||||
<rect x="30" y="62" width="860" height="40" rx="6" fill="#2a1a0a" stroke="#d2991d" stroke-width="1" filter="url(#sh4)"/>
|
||||
<text x="460" y="87" text-anchor="middle" fill="#d2991d" font-size="12" font-weight="bold">🟠 Уровень 2 — Парсинг: cp437→utf8 для битых имён · content_hash (SHA-256) · дедупликация · try/except → logging.warning</text>
|
||||
|
||||
<line x1="460" y1="102" x2="460" y2="110" stroke="#f85149" stroke-width="1" marker-end="url(#d1)"/>
|
||||
|
||||
<!-- Layer 3: Classify -->
|
||||
<rect x="50" y="114" width="820" height="40" rx="6" fill="#2a2a0a" stroke="#e3b341" stroke-width="1" filter="url(#sh4)"/>
|
||||
<text x="460" y="139" text-anchor="middle" fill="#e3b341" font-size="12" font-weight="bold">🟡 Уровень 3 — Классификация: фильтр по имени файла · фильтр по заголовкам (2000 симв.) · LLM-фильтр · мусор → garbage</text>
|
||||
|
||||
<line x1="460" y1="154" x2="460" y2="162" stroke="#f85149" stroke-width="1" marker-end="url(#d1)"/>
|
||||
|
||||
<!-- Layer 4: Compare -->
|
||||
<rect x="70" y="166" width="780" height="40" rx="6" fill="#1a2a0a" stroke="#3fb950" stroke-width="1" filter="url(#sh4)"/>
|
||||
<text x="460" y="191" text-anchor="middle" fill="#3fb950" font-size="12" font-weight="bold">🟢 Уровень 4 — Сравнение: ADD без имени → UNRESOLVED · UPDATE/DELETE без цели → UNRESOLVED · нормализация 1 000,50→1000.50 · дат · price×qty=sum</text>
|
||||
</svg>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,166 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Gitea Actions — настройка CI/CD</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117; --fg: #c9d1d9; --muted: #8b949e;
|
||||
--accent: #58a6ff; --green: #3fb950; --orange: #d2991d;
|
||||
--border: #30363d; --code-bg: #161b22; --card-bg: #161b22;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font: 15px/1.65 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--fg); max-width: 860px; margin: 0 auto; padding: 32px 20px 80px; }
|
||||
h1 { font-size: 26px; margin-bottom: 8px; }
|
||||
h2 { font-size: 19px; margin-top: 36px; margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid var(--border); color: var(--accent); }
|
||||
h3 { font-size: 16px; margin-top: 24px; margin-bottom: 6px; color: var(--green); }
|
||||
p { margin: 8px 0; }
|
||||
ul, ol { padding-left: 24px; margin: 8px 0; }
|
||||
li { margin: 4px 0; }
|
||||
code { background: var(--code-bg); padding: 1px 5px; border-radius: 4px; font-size: 13px; border: 1px solid var(--border); }
|
||||
pre { background: var(--code-bg); padding: 12px 16px; border-radius: 6px; overflow-x: auto; font-size: 13px; border: 1px solid var(--border); margin: 12px 0; }
|
||||
.nav { margin-bottom: 24px; }
|
||||
.nav a { color: var(--accent); text-decoration: none; }
|
||||
.nav a:hover { text-decoration: underline; }
|
||||
.step { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 16px 20px; margin: 16px 0; }
|
||||
.step h3 { margin-top: 0; }
|
||||
hr { border: none; border-top: 1px solid var(--border); margin: 32px 0; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 12px 0; border: 1px solid var(--border); }
|
||||
th, td { padding: 8px; text-align: left; border: 1px solid var(--border); }
|
||||
th { background: var(--code-bg); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="nav"><a href="/docs/architect">Архитектура</a></div>
|
||||
|
||||
<h1>🔄 Gitea Actions — настройка CI/CD</h1>
|
||||
|
||||
<blockquote style="border-left-color: #f85149; background: #1a0a0a; color: #f85149;">
|
||||
<strong>⚠️ Не решает проблему ручного редеплоя managed-сервисов Nubes.</strong><br>
|
||||
Если сервис деплоится только через Nubes UI — Gitea Actions может максимум валидировать код и синхронизировать VM-часть. Кнопку «Redeploy» всё равно жать руками.
|
||||
</blockquote>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>Как это работает</h2>
|
||||
|
||||
<p>Gitea Actions — встроенный CI/CD (аналог GitHub Actions), доступен с Gitea 1.19+. При <code>git push</code> запускается workflow, который выполняет заданные шаги на <strong>раннере</strong> — отдельной машине, которая слушает задачи.</p>
|
||||
|
||||
<table>
|
||||
<tr><th>Компонент</th><th>Где</th><th>Что делает</th></tr>
|
||||
<tr><td>Workflow-файл</td><td><code>.gitea/workflows/*.yaml</code></td><td>Описывает шаги (steps) при пуше</td></tr>
|
||||
<tr><td>Act Runner</td><td>Любая машина с доступом к Gitea</td><td>Выполняет джобы (systemd-сервис)</td></tr>
|
||||
<tr><td>Gitea</td><td>Ваш инстанс</td><td>Отправляет задачи раннеру при пуше</td></tr>
|
||||
</table>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>Пример workflow</h2>
|
||||
|
||||
<pre>name: Validate
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: linux_amd64
|
||||
steps:
|
||||
- name: Syntax check
|
||||
run: python3 -m py_compile *.py</pre>
|
||||
|
||||
<p>Что можно автоматизировать:</p>
|
||||
<ul>
|
||||
<li>Синтаксическую проверку</li>
|
||||
<li>Запуск тестов (<code>pytest</code>)</li>
|
||||
<li>Синхронизацию кода на свою VM (<code>rsync</code> / <code>scp</code>)</li>
|
||||
<li>Уведомления (Telegram, email)</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>Как это настраивалось</h2>
|
||||
|
||||
<div class="step">
|
||||
<h3>1. Установка act_runner на VM</h3>
|
||||
<pre># Скачать бинарник
|
||||
curl -sL -o /usr/local/bin/act_runner \
|
||||
https://dl.gitea.com/act_runner/0.2.11/act_runner-0.2.11-linux-amd64
|
||||
chmod +x /usr/local/bin/act_runner</pre>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<h3>2. Получение токена</h3>
|
||||
<p>В Gitea: <code>Репозиторий → Settings → Actions → Runners → Create new runner</code></p>
|
||||
<p>Выдаётся токен вида <code>D0gvfu2iHfUj...</code> — он используется для регистрации.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<h3>3. Регистрация раннера</h3>
|
||||
<pre>act_runner register \
|
||||
--no-interactive \
|
||||
--instance https://<ваш-gitea> \
|
||||
--token <TOKEN> \
|
||||
--name <имя-раннера> \
|
||||
--labels linux_amd64:host</pre>
|
||||
<p><code>linux_amd64:host</code> — означает host-режим (без Docker). Джобы выполняются прямо на машине.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<h3>4. Генерация конфига</h3>
|
||||
<pre>act_runner generate-config > /etc/act_runner/config.yaml
|
||||
# Важно: поправить labels → ["linux_amd64:host"]</pre>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<h3>5. Systemd-сервис</h3>
|
||||
<pre>[Unit]
|
||||
Description=Gitea Actions Runner
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/act_runner daemon --config /etc/act_runner/config.yaml
|
||||
WorkingDirectory=/etc/act_runner
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
User=runner
|
||||
Environment=HOME=/home/runner
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target</pre>
|
||||
<pre>sudo systemctl daemon-reload
|
||||
sudo systemctl enable act_runner --now
|
||||
sudo systemctl status act_runner</pre>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<h3>6. Workflow-файл</h3>
|
||||
<p>Создать <code>.gitea/workflows/deploy.yaml</code> в репозитории. При пуше — workflow запустится автоматически.</p>
|
||||
<p><strong>Важно:</strong> <code>runs-on</code> должен совпадать с лейблом раннера (без <code>:host</code>). То есть <code>runs-on: linux_amd64</code>.</p>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>Проверка статуса</h2>
|
||||
|
||||
<pre># Логи раннера на VM
|
||||
sudo journalctl -u act_runner -f
|
||||
|
||||
# Статус сервиса
|
||||
sudo systemctl status act_runner</pre>
|
||||
|
||||
<p>Результаты выполнения workflow видны в Gitea на вкладке <strong>Actions</strong> репозитория.</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>Полезные ссылки</h2>
|
||||
<ul>
|
||||
<li><a href="https://docs.gitea.com/usage/actions/overview/" target="_blank">Gitea Actions — Overview</a></li>
|
||||
<li><a href="https://docs.gitea.com/usage/actions/act-runner" target="_blank">Act Runner — документация</a></li>
|
||||
<li><a href="https://docs.gitea.com/usage/actions/quickstart/" target="_blank">Quick Start</a></li>
|
||||
</ul>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,315 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DrHider — обфускация документов</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='4' fill='%23001C34'/%3E%3Ctext x='16' y='24' text-anchor='middle' font-size='22' font-weight='bold' fill='white' font-family='sans-serif'%3EN%3C/text%3E%3C/svg%3E" type="image/svg+xml">
|
||||
<style>
|
||||
:root {
|
||||
--brand: #2563eb; --brand-dark: #1d4ed8;
|
||||
--bg: #f8fafc; --card: #fff; --fg: #1e293b; --muted: #64748b;
|
||||
--border: #e2e8f0; --red: #ef4444; --green: #22c55e;
|
||||
--grey-light: #f3f4f6; --radius: 8px;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font: 14px/1.6 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--fg); }
|
||||
.header { background: var(--card); border-bottom: 1px solid var(--border); padding: 12px 24px; display: flex; align-items: center; gap: 12px; position: sticky; top: 0; z-index: 100; }
|
||||
.header img { height: 24px; }
|
||||
.header .sep { color: var(--muted); }
|
||||
.header a { color: var(--brand); text-decoration: none; font-size: 13px; }
|
||||
.header a:hover { color: var(--brand-dark); }
|
||||
main { max-width: 700px; margin: 0 auto; padding: 40px 20px 60px; }
|
||||
h1 { font-size: 22px; font-weight: 600; margin-bottom: 4px; }
|
||||
.sub { color: var(--muted); font-size: 14px; margin-bottom: 24px; }
|
||||
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,.04); }
|
||||
.card-header { background: var(--grey-light); padding: 12px 16px; border-radius: 12px 12px 0 0; font-size: 14px; font-weight: 600; border-bottom: 1px solid var(--border); }
|
||||
.card-body { padding: 16px; }
|
||||
|
||||
#dropZone {
|
||||
border: 2px dashed var(--border); border-radius: 12px; padding: 36px 20px;
|
||||
text-align: center; cursor: pointer; transition: all .2s;
|
||||
background: var(--grey-light); margin-bottom: 16px;
|
||||
}
|
||||
#dropZone:hover, #dropZone.active { border-color: var(--brand); background: #eff6ff; }
|
||||
#dropZone p { color: var(--muted); margin: 4px 0; font-size: 14px; }
|
||||
#dropZone .icon { font-size: 32px; margin-bottom: 8px; }
|
||||
|
||||
#fileList { display: none; }
|
||||
#fileList.show { display: block; }
|
||||
.file-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: var(--grey-light); border-radius: 6px; margin-bottom: 4px; font-size: 13px; }
|
||||
.file-row .name { flex: 1; }
|
||||
.file-row .size { color: var(--muted); }
|
||||
.file-row .remove { cursor: pointer; color: var(--red); font-weight: bold; font-size: 16px; }
|
||||
|
||||
#status { display: none; margin-top: 12px; padding: 12px 16px; border-radius: 8px; font-size: 13px; }
|
||||
#status.show { display: block; }
|
||||
#status.progress { background: #eff6ff; border: 1px solid var(--brand); color: var(--brand); }
|
||||
#status.done { background: #f0fdf4; border: 1px solid var(--green); color: #16a34a; }
|
||||
#status.error { background: #fef2f2; border: 1px solid var(--red); color: var(--red); }
|
||||
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; height: 36px; padding: 0 16px; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; border: 1px solid var(--border); background: var(--card); transition: all .15s; }
|
||||
.btn:hover { background: var(--grey-light); }
|
||||
.btn-primary { background: var(--brand); color: #fff; border-color: var(--brand); width: 100%; margin-top: 8px; }
|
||||
.btn-primary:hover { background: var(--brand-dark); }
|
||||
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||
|
||||
/* ── Модальное окно ──────────────────────────────── */
|
||||
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 1000; justify-content: center; align-items: flex-start; padding-top: 60px; }
|
||||
.modal-overlay.show { display: flex; }
|
||||
.modal { background: var(--card); border-radius: 12px; max-width: 600px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0,0,0,.15); }
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--card); z-index: 1; }
|
||||
.modal-header h2 { font-size: 16px; }
|
||||
.modal-close { cursor: pointer; font-size: 20px; color: var(--muted); background: none; border: none; line-height: 1; }
|
||||
.modal-close:hover { color: var(--fg); }
|
||||
.modal-body { padding: 20px; font-size: 13px; line-height: 1.7; color: var(--fg); }
|
||||
.modal-body h3 { font-size: 14px; margin: 16px 0 6px; }
|
||||
.modal-body h3:first-child { margin-top: 0; }
|
||||
.modal-body ul { margin: 4px 0 12px 20px; }
|
||||
.modal-body li { margin-bottom: 4px; }
|
||||
.modal-body .warn { background: #fef9e7; border-left: 3px solid #f59e0b; padding: 8px 12px; border-radius: 0 6px 6px 0; margin: 8px 0; font-size: 12px; }
|
||||
.modal-body code { background: var(--grey-light); padding: 1px 4px; border-radius: 3px; font-size: 12px; }
|
||||
|
||||
/* ── Кнопка ? в шапке ───────────────────────────── */
|
||||
.header .help-btn { margin-left: auto; width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--border); background: var(--card); cursor: pointer; font-size: 15px; font-weight: 600; color: var(--muted); display: flex; align-items: center; justify-content: center; transition: all .15s; }
|
||||
.header .help-btn:hover { border-color: var(--brand); color: var(--brand); background: #eff6ff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="header">
|
||||
<svg width="82" height="18" viewBox="0 0 82 18" style="display:block"><rect width="82" height="18" rx="2" fill="#001C34"/><text x="41" y="14" text-anchor="middle" font-size="12" font-weight="bold" fill="white" font-family="sans-serif">NUBES</text></svg>
|
||||
<span class="sep">|</span>
|
||||
<a href="https://contractor.pythonk8s.services.ngcloud.ru/">Сверка договоров</a>
|
||||
<span class="sep">|</span>
|
||||
<strong>DrHider</strong>
|
||||
<span style="font-size:11px;color:var(--muted);">v1.15</span>
|
||||
<button class="help-btn" onclick="openModal()" title="О сервисе">?</button>
|
||||
</header>
|
||||
|
||||
<!-- Модальное окно -->
|
||||
<div class="modal-overlay" id="modalOverlay" onclick="if(event.target===this)closeModal()">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2>🛡️ DrHider — справка</h2>
|
||||
<button class="modal-close" onclick="closeModal()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h3>📌 Что делает</h3>
|
||||
<p>Обезличивает документы перед загрузкой в сервис сверки договоров. Заменяет персональные данные, реквизиты компаний, телефоны, email и другие чувствительные данные на фиктивные, но реалистичные значения. <strong>Одна и та же сущность во всех файлах заменяется одинаково</strong> — можно сопоставлять документы между собой.</p>
|
||||
|
||||
<h3>⚙️ Как работает</h3>
|
||||
<ul>
|
||||
<li><strong>Два прохода:</strong> сначала сбор всех сущностей из всех файлов → построение единого словаря замен → применение замен.</li>
|
||||
<li><strong>Всё в памяти:</strong> файлы не записываются на диск и не сохраняются в базе. После обработки данные удаляются.</li>
|
||||
<li><strong>mapping.csv</strong> — внутри ZIP лежит таблица «тип → оригинал → замена» для аудита.</li>
|
||||
</ul>
|
||||
|
||||
<h3>✅ Что распознаётся и заменяется</h3>
|
||||
<ul>
|
||||
<li>📞 Телефоны (+7, 8-800, ...)</li>
|
||||
<li>📧 Email-адреса</li>
|
||||
<li>🏢 Компании: ООО, ЗАО, АО, ПАО, ИП, ТОО (включая юникод-кавычки «» и "")</li>
|
||||
<li>👤 ФИО (кириллица): Иванов А.П., Петрова Мария Сергеевна</li>
|
||||
<li>🔢 Реквизиты: ИНН (10/12), ОГРН, КПП, БИК</li>
|
||||
<li>💳 Банковские счета: р/с, кор/сч, корреспондентский счёт</li>
|
||||
<li>📄 Паспортные данные: серия + номер</li>
|
||||
</ul>
|
||||
|
||||
<h3>🧠 LLM-NER (нейросеть)</h3>
|
||||
<p>Для поиска имён, названий компаний и адресов, которые не попадают под регулярные выражения, используется языковая модель. Работает не мгновенно — для больших документов добавляет 5–30 секунд.</p>
|
||||
|
||||
<h3>✅ Форматы файлов</h3>
|
||||
<ul>
|
||||
<li><strong>.docx</strong> — полная замена с сохранением структуры документа. ⚠️ Форматирование (жирный, курсив) заменённых фрагментов может сброситься.</li>
|
||||
<li><strong>.pdf</strong> — текст и таблицы извлекаются в .docx, затем обфусцируется. Шрифты, цвета и PDF-вёрстка не сохраняются. При совпадении имён с существующим .docx добавляется суффикс <code>_из_pdf</code>.</li>
|
||||
<li><strong>.doc</strong> — бинарный формат, <strong>не обфусцируется</strong>. Файл возвращается как есть. Конвертируйте в .docx перед загрузкой.</li>
|
||||
<li><strong>.zip</strong> — автоматически распаковывается, все файлы внутри обрабатываются.</li>
|
||||
</ul>
|
||||
|
||||
<h3>⚠️ Ограничения и возможные ошибки</h3>
|
||||
<div class="warn">
|
||||
<strong>Ложные пропуски:</strong> сущность может быть не найдена, если она записана в нестандартном формате (например, телефон через точку: <code>+7.495.123.44.55</code>).
|
||||
</div>
|
||||
<div class="warn">
|
||||
<strong>Ложные срабатывания:</strong> короткие слова (например, фамилия «Иванов») могут быть заменены внутри более длинных слов («Ивановский» → ...ский). Минимизировано, но не исключено полностью.
|
||||
</div>
|
||||
<div class="warn">
|
||||
<strong>.doc не обрабатывается:</strong> старые Word-файлы возвращаются без изменений.
|
||||
</div>
|
||||
<div class="warn">
|
||||
<strong>PDF → DOCX:</strong> извлекаются только текст и таблицы. Шрифты, цвета и позиционирование теряются. Сканы (фотографии страниц) не распознаются.</div>
|
||||
</div>
|
||||
<div class="warn">
|
||||
<strong>Сканы/изображения:</strong> текст на картинках не распознаётся (нет OCR).
|
||||
</div>
|
||||
|
||||
<h3>🔒 Приватность</h3>
|
||||
<ul>
|
||||
<li>Файлы не покидают сервер Nubes.</li>
|
||||
<li>Ничего не записывается в базу данных.</li>
|
||||
<li>После отправки ZIP-архива все данные в памяти удаляются.</li>
|
||||
</ul>
|
||||
|
||||
<h3>📊 mapping.csv</h3>
|
||||
<p>В выходном ZIP всегда есть файл <code>mapping.csv</code> с колонками: <code>тип_данных, оригинал, замена</code>. Позволяет при необходимости восстановить исходные значения (храните этот файл отдельно и безопасно).</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<h1>🛡️ DrHider — обфускация документов</h1>
|
||||
<p class="sub">Загрузите документы — получите ZIP с обезличенными копиями и таблицей соответствия. Без сохранения на сервере.</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">📁 Загрузка файлов</div>
|
||||
<div class="card-body">
|
||||
<div id="dropZone">
|
||||
<div class="icon">📄</div>
|
||||
<p>Выберите файлы или перетащите сюда</p>
|
||||
<p style="font-size:12px;">.docx .pdf .doc .zip</p>
|
||||
<input type="file" id="fileInput" multiple accept=".docx,.pdf,.doc,.zip" style="display:none">
|
||||
</div>
|
||||
<div id="fileList"></div>
|
||||
<button class="btn btn-primary" id="btnGo" disabled>Обфусцировать</button>
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:16px;">
|
||||
<div class="card-header">ℹ️ Как это работает</div>
|
||||
<div class="card-body" style="font-size:13px;color:var(--muted);">
|
||||
<p><strong>⚡ Всё в памяти.</strong> Файлы не сохраняются на сервер. После обработки данные удаляются.</p>
|
||||
<p><strong>📋 mapping.csv</strong> — таблица «оригинал → замена» внутри ZIP.</p>
|
||||
<p><strong>🔒 Результат</strong> — обезличенные файлы можно загружать в основной сервис сверки.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const DZ = document.getElementById('dropZone');
|
||||
const INPUT = document.getElementById('fileInput');
|
||||
const LIST = document.getElementById('fileList');
|
||||
const BTN = document.getElementById('btnGo');
|
||||
const STATUS = document.getElementById('status');
|
||||
let files = [];
|
||||
|
||||
DZ.onclick = () => INPUT.click();
|
||||
INPUT.onchange = () => { addFiles(INPUT.files); INPUT.value = ''; };
|
||||
DZ.ondragover = e => { e.preventDefault(); DZ.classList.add('active'); };
|
||||
DZ.ondragleave = () => DZ.classList.remove('active');
|
||||
DZ.ondrop = e => { e.preventDefault(); DZ.classList.remove('active'); addFiles(e.dataTransfer.files); };
|
||||
|
||||
function addFiles(fl) { for (let f of fl) { if (files.find(x => x.name===f.name && x.size===f.size)) continue; files.push(f); } render(); }
|
||||
function remove(i) { files.splice(i, 1); render(); }
|
||||
function render() {
|
||||
LIST.innerHTML = files.map((f,i) => {
|
||||
let display = esc(f.name);
|
||||
if (f.name.toLowerCase().endsWith('.pdf')) {
|
||||
display += ' → <span style="color:var(--brand-primary)">*.docx</span>';
|
||||
}
|
||||
return `<div class="file-row"><span class="name">${display}</span><span class="size">${fmt(f.size)}</span><span class="remove" onclick="remove(${i})">×</span></div>`;
|
||||
}).join('');
|
||||
LIST.className = files.length ? 'show' : '';
|
||||
BTN.disabled = !files.length;
|
||||
}
|
||||
|
||||
BTN.onclick = () => {
|
||||
if (!files.length) return;
|
||||
// Проверить коллизии PDF→DOCX
|
||||
const collisions = [];
|
||||
const names = files.map(f => f.name);
|
||||
files.forEach(f => {
|
||||
if (f.name.toLowerCase().endsWith('.pdf')) {
|
||||
const docxName = f.name.slice(0, -4) + '.docx';
|
||||
if (names.includes(docxName)) {
|
||||
collisions.push({pdf: f.name, docx: docxName});
|
||||
}
|
||||
}
|
||||
});
|
||||
if (collisions.length) {
|
||||
showCollisionModal(collisions);
|
||||
return;
|
||||
}
|
||||
doSubmit();
|
||||
};
|
||||
|
||||
function showCollisionModal(collisions) {
|
||||
const list = collisions.map(c => `<div style="margin-bottom:4px">📄 <b>${esc(c.pdf)}</b> → <b>${esc(c.docx)}</b> (уже есть)</div>`).join('');
|
||||
document.getElementById('collisionList').innerHTML = list;
|
||||
document.getElementById('collisionOverlay').classList.add('show');
|
||||
window._collisions = collisions;
|
||||
}
|
||||
|
||||
function closeCollision(skipAll) {
|
||||
document.getElementById('collisionOverlay').classList.remove('show');
|
||||
if (skipAll) {
|
||||
// Удалить PDF-файлы из списка
|
||||
const pdfNames = window._collisions.map(c => c.pdf);
|
||||
files = files.filter(f => !pdfNames.includes(f.name));
|
||||
render();
|
||||
}
|
||||
// В любом случае отправляем (бэкенд сам переименует)
|
||||
setTimeout(doSubmit, 100);
|
||||
}
|
||||
|
||||
function doSubmit() {
|
||||
BTN.disabled = true;
|
||||
setStatus('progress', '⏳ Отправка...');
|
||||
const xhr = new XMLHttpRequest();
|
||||
const fd = new FormData();
|
||||
files.forEach(f => fd.append('files', f, f.name));
|
||||
xhr.open('POST', 'https://contracts.kube5s.ru/api/drhider');
|
||||
xhr.responseType = 'blob';
|
||||
const startTime = Date.now();
|
||||
const timer = setInterval(() => {
|
||||
setStatus('progress', '⏳ Обработка... ' + Math.round((Date.now() - startTime) / 1000) + 'с');
|
||||
}, 1000);
|
||||
xhr.onload = () => {
|
||||
clearInterval(timer);
|
||||
if (xhr.status === 200) {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(xhr.response);
|
||||
a.download = 'drhider_output.zip';
|
||||
a.click();
|
||||
setStatus('done', '✅ Готово!');
|
||||
} else {
|
||||
setStatus('error', '❌ Ошибка ' + xhr.status);
|
||||
}
|
||||
BTN.disabled = false;
|
||||
};
|
||||
xhr.onerror = () => { clearInterval(timer); setStatus('error', '❌ Сеть'); BTN.disabled = false; };
|
||||
xhr.ontimeout = () => { clearInterval(timer); setStatus('error', '❌ Таймаут'); BTN.disabled = false; };
|
||||
xhr.timeout = 600000;
|
||||
xhr.send(fd);
|
||||
};
|
||||
|
||||
function setStatus(c,m) { STATUS.className = 'show '+c; STATUS.textContent = m; }
|
||||
function esc(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function fmt(n) { return n>1e6 ? (n/1e6).toFixed(1)+' MB' : n>1e3 ? (n/1e3).toFixed(0)+' KB' : n+' B'; }
|
||||
|
||||
// ── Модальное окно ─────────────────────────────────────────────
|
||||
function openModal() { document.getElementById('modalOverlay').classList.add('show'); }
|
||||
function closeModal() { document.getElementById('modalOverlay').classList.remove('show'); }
|
||||
// ── Коллизия PDF→DOCX ──────────────────────────────────────────
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeCollision(true); });
|
||||
</script>
|
||||
|
||||
<!-- Модальное окно: коллизия PDF→DOCX -->
|
||||
<div class="modal-overlay" id="collisionOverlay">
|
||||
<div class="modal" style="max-width:480px">
|
||||
<div class="modal-header">⚠️ Конфликт имён</div>
|
||||
<div class="modal-body">
|
||||
<p style="margin-bottom:10px">PDF-файлы конвертируются в DOCX. Имена совпадают:</p>
|
||||
<div id="collisionList" style="margin-bottom:12px"></div>
|
||||
<p style="font-size:12px;color:var(--muted)">«Перезаписать» — PDF-файл заменит DOCX.<br>«Пропустить» — PDF будут удалены из списка.</p>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;padding:12px 16px;border-top:1px solid var(--brand-gray)">
|
||||
<button class="btn" onclick="closeCollision(true)" style="flex:1">Пропустить PDF</button>
|
||||
<button class="btn btn-primary" onclick="closeCollision(false)" style="flex:1">Перезаписать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,226 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Сверка договоров — LLM</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<style>
|
||||
:root { --brand-primary: #2563eb; --brand-primary-dark: #1d4ed8; --brand-gray: #d1d5db; --brand-grey-light: #f3f4f6; --background: #ffffff; --foreground: #1a1a1a; --muted: #6b7280; --destructive: #ef4444; --green: #22c55e; }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, sans-serif; font-size: 14px; color: var(--foreground); background: var(--brand-grey-light); min-height: 100vh; }
|
||||
.topbar { background: var(--background); border-bottom: 1px solid var(--brand-gray); padding: 0 24px; height: 56px; display: flex; align-items: center; gap: 16px; position: sticky; top: 0; z-index: 50; }
|
||||
.topbar img { height: 28px; }
|
||||
.topbar .title { font-weight: 600; font-size: 16px; }
|
||||
.content { max-width: 900px; margin: 32px auto; padding: 0 16px; }
|
||||
.card { background: var(--background); border-radius: 12px; border: 1px solid var(--brand-gray); box-shadow: 0 1px 2px rgba(0,0,0,.05); overflow: hidden; margin-bottom: 16px; }
|
||||
.card-header { background: var(--brand-grey-light); padding: 12px 16px; font-weight: 600; font-size: 16px; display: flex; align-items: center; gap: 8px; }
|
||||
.card-body { padding: 16px; }
|
||||
.btn { height: 36px; border-radius: 6px; gap: 6px; padding: 0 14px; font-size: 14px; font-family: inherit; cursor: pointer; display: inline-flex; align-items: center; border: 1px solid var(--brand-gray); background: var(--background); }
|
||||
.btn-primary { background: var(--brand-primary); border-color: var(--brand-primary); color: #fff; }
|
||||
.btn-primary:hover { background: var(--brand-primary-dark); }
|
||||
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.table-wrap { border-radius: 6px; border: 1px solid var(--brand-gray); overflow-y: auto; max-height: 50vh; margin-top: 12px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th { background: var(--brand-grey-light); text-transform: uppercase; padding: 6px 10px; border-right: 1px solid var(--brand-gray); text-align: left; font-weight: 600; font-size: 11px; color: var(--muted); }
|
||||
td { padding: 6px 10px; border-right: 1px solid var(--brand-gray); border-bottom: 1px solid var(--brand-gray); }
|
||||
tr:hover td { background: rgba(243,244,246,.5); }
|
||||
.name-cell { max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.num-cell { text-align: right; white-space: nowrap; }
|
||||
.status-ok { color: var(--green); }
|
||||
.status-err { color: var(--destructive); }
|
||||
.summary-row td { background: rgba(34,197,94,.05); font-weight: 600; }
|
||||
.empty-row td { color: var(--muted); text-align: center; padding: 24px; }
|
||||
.remove-btn { cursor: pointer; color: #f87171; background: none; border: none; padding: 2px 4px; font-size: 16px; line-height: 1; }
|
||||
.remove-btn:hover { color: #ef4444; }
|
||||
.info-btn { cursor: pointer; color: var(--muted); background: none; border: none; padding: 2px 4px; font-size: 14px; display: none; }
|
||||
.info-btn:hover { color: var(--brand-primary); }
|
||||
.info-btn.visible { display: inline; }
|
||||
.first-row td { background: rgba(37,99,235,.06); font-weight: 600; }
|
||||
.arrow-btn { cursor: pointer; color: var(--muted); background: none; border: none; padding: 2px; font-size: 12px; line-height: 1; }
|
||||
.arrow-btn:hover { color: var(--brand-primary); }
|
||||
.arrow-btn:disabled { opacity: .3; cursor: default; color: var(--muted); }
|
||||
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,.4); z-index: 100; justify-content: center; align-items: center; }
|
||||
.modal-overlay.open { display: flex; }
|
||||
.modal { background: var(--background); border-radius: 12px; border: 1px solid var(--brand-gray); box-shadow: 0 4px 24px rgba(0,0,0,.15); max-width: 520px; width: 90%; max-height: 80vh; display: flex; flex-direction: column; }
|
||||
.modal.wide { max-width: 900px; }
|
||||
.modal-header { padding: 14px 16px; border-bottom: 1px solid var(--brand-gray); display: flex; align-items: center; gap: 8px; font-weight: 600; flex-shrink: 0; }
|
||||
.modal-body { padding: 16px; overflow-y: auto; }
|
||||
.modal-body .kv { display: flex; margin-bottom: 6px; font-size: 13px; }
|
||||
.modal-body .kv .k { color: var(--muted); width: 110px; flex-shrink: 0; }
|
||||
.modal-body .kv .v { word-break: break-all; }
|
||||
.diff-added { background: rgba(34,197,94,.1); }
|
||||
.diff-deleted { background: rgba(239,68,68,.1); }
|
||||
.diff-changed { background: rgba(234,179,8,.1); }
|
||||
.diff-added td { color: var(--green); }
|
||||
.diff-deleted td { color: var(--destructive); text-decoration: line-through; }
|
||||
.diff-field-old { color: var(--destructive); text-decoration: line-through; font-size: 11px; }
|
||||
.diff-field-new { color: var(--green); font-weight: 600; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: spin .6s linear infinite; }
|
||||
.text-panes { display: flex; gap: 0; }
|
||||
.text-pane { flex: 1; min-width: 0; overflow-y: auto; max-height: 65vh; }
|
||||
.text-pane:first-child { border-right: 1px solid var(--brand-gray); padding-right: 12px; }
|
||||
.text-pane:last-child { padding-left: 12px; }
|
||||
.text-pane pre { background: var(--brand-grey-light); padding: 10px; border-radius: 6px; font-size: 11px; white-space: pre-wrap; word-break: break-word; overflow-x: auto; margin: 0; max-height: 60vh; overflow-y: auto; }
|
||||
.text-pane table { font-size: 11px; width: 100%; }
|
||||
.text-pane th { font-size: 10px; padding: 4px 6px; }
|
||||
.text-pane td { padding: 3px 6px; font-size: 11px; }
|
||||
.text-pane .pane-title { font-weight: 600; font-size: 12px; margin-bottom: 8px; color: var(--muted); text-transform: uppercase; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<img src="/static/logo.svg" alt="Nubes">
|
||||
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v2.0.0</span></span>
|
||||
<div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);">
|
||||
<span id="stepUpload">○ Загрузка</span><span>→</span>
|
||||
<span id="stepClassify">○ Классификация</span><span>→</span>
|
||||
<span id="stepGroups">○ Группировка</span><span>→</span>
|
||||
<span id="stepCompare">○ Сравнение</span>
|
||||
</div>
|
||||
<span style="margin-left:auto;font-size:12px;color:var(--brand-primary);cursor:pointer;white-space:nowrap;" onclick="openAbout()">ℹ️ О сервисе</span>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i data-lucide="folder-open" style="width:18px;height:18px;"></i>
|
||||
Загрузка договоров/приложений/спецификаций
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<input type="file" id="fileInput" accept=".docx,.doc,.pdf,.zip" multiple style="margin-bottom:6px;width:100%;">
|
||||
<div style="text-align:right;font-size:11px;color:var(--muted);margin-bottom:6px;">Порядок определяется автоматически при классификации</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px;">⚠ При совпадении имён — запрос на перезапись (OK / Отмена). Файлы из ZIP-архивов загружаются через тот же поток.</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Имя</th><th style="width:130px;">Изменён</th><th style="width:90px;">Размер</th><th style="width:115px;">Парсинг</th><th style="width:30px;">Текст</th><th style="width:30px;">✕</th></tr>
|
||||
</thead>
|
||||
<tbody id="fileTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" id="llmBtn" style="width:100%;justify-content:center;margin-top:8px;display:none;background:var(--green);border-color:var(--green);">
|
||||
<i data-lucide="scale" style="width:16px;height:16px;"></i> Общее сравнение
|
||||
</button>
|
||||
|
||||
<details style="margin-top:8px;font-size:12px;">
|
||||
<summary style="cursor:pointer;color:var(--muted);">⚙ Промпты LLM</summary>
|
||||
<div style="display:flex;gap:8px;margin-top:6px;">
|
||||
<button class="btn" id="promptBtnExtract" style="font-size:11px;height:28px;flex:1;">📄 Извлечение</button>
|
||||
<button class="btn" id="promptBtnDiff" style="font-size:11px;height:28px;flex:1;">🔄 Сравнение</button>
|
||||
<button class="btn" id="promptBtnClassify" style="font-size:11px;height:28px;flex:1;">🏷️ Классификация</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:3px;font-size:10px;color:var(--muted);">
|
||||
<span style="flex:1;">для базового договора</span>
|
||||
<span style="flex:1;">для допсоглашений</span>
|
||||
<span style="flex:1;">автоопределение</span>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="diffCard" style="display:none;">
|
||||
<div class="card-header">
|
||||
<i data-lucide="file-diff" style="width:18px;height:18px;"></i>
|
||||
Классификация и группы <span id="diffStatus" style="font-weight:400;font-size:12px;margin-left:8px;"></span>
|
||||
</div>
|
||||
<div class="card-body" id="diffBody"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="compareCard" style="display:none;margin-top:16px;">
|
||||
<div class="card-header">
|
||||
<i data-lucide="scale" style="width:18px;height:18px;"></i>
|
||||
Результаты сравнения <span id="compareStatus" style="font-weight:400;font-size:12px;margin-left:8px;"></span>
|
||||
</div>
|
||||
<div class="card-body" id="compareBody"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="chatCard" style="display:none;margin-top:16px;">
|
||||
<div class="card-header">
|
||||
<i data-lucide="message-circle" style="width:18px;height:18px;"></i>
|
||||
Задать вопрос по договору <span style="font-weight:400;font-size:11px;color:var(--muted);">— используются все загруженные документы</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="chatMessages" style="margin-bottom:8px;font-size:13px;"></div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input type="text" id="chatInput" placeholder="Какая общая сумма? Что изменилось? ..." style="flex:1;height:32px;border:1px solid var(--brand-gray);border-radius:6px;padding:0 8px;font-size:13px;">
|
||||
<button class="btn btn-primary" id="chatSend" style="height:32px;font-size:13px;">→</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="modalOverlay" onclick="closeModal(event)">
|
||||
<div class="modal" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<i data-lucide="file-text" style="width:18px;height:18px;"></i>
|
||||
<span id="modalTitle">Информация о файле</span>
|
||||
<button class="remove-btn" onclick="closeModal()" style="margin-left:auto;font-size:18px;">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="promptModalOverlay" onclick="closePromptModal(event)">
|
||||
<div class="modal" style="max-width:680px;" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<i data-lucide="settings" style="width:18px;height:18px;"></i>
|
||||
<span id="promptModalTitle">Промпт: Сравнение</span>
|
||||
<button class="remove-btn" onclick="closePromptModal()" style="margin-left:auto;font-size:18px;">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding:16px;">
|
||||
<div style="display:flex;gap:6px;margin-bottom:4px;">
|
||||
<button class="btn" id="promptTabExtract" style="font-size:11px;height:26px;">📄 Извлечение</button>
|
||||
<button class="btn" id="promptTabDiff" style="font-size:11px;height:26px;background:var(--brand-primary);color:#fff;">🔄 Сравнение</button>
|
||||
<button class="btn" id="promptTabClassify" style="font-size:11px;height:26px;">🏷️ Классификация</button>
|
||||
</div>
|
||||
<div style="font-size:10px;color:var(--muted);margin-bottom:10px;">
|
||||
Извлечение — для базового договора | Сравнение — для допсоглашений | Классификация — автоопределение типа/даты/контрагента
|
||||
</div>
|
||||
<textarea id="promptEditor" style="width:100%;height:220px;font-family:monospace;font-size:11px;border:1px solid var(--brand-gray);border-radius:6px;padding:8px;box-sizing:border-box;"></textarea>
|
||||
<div style="display:flex;gap:8px;margin-top:8px;align-items:center;">
|
||||
<input id="promptName" placeholder="Имя версии" style="width:140px;font-size:11px;border:1px solid var(--brand-gray);border-radius:4px;padding:4px 6px;">
|
||||
<input id="promptNotes" placeholder="Что изменили" style="flex:1;font-size:11px;border:1px solid var(--brand-gray);border-radius:4px;padding:4px 6px;">
|
||||
<button class="btn" id="promptSave" style="font-size:11px;height:28px;background:var(--green);color:#fff;border-color:var(--green);">Сохранить</button>
|
||||
</div>
|
||||
<span id="promptStatus" style="font-size:11px;color:var(--muted);display:block;margin-top:4px;"></span>
|
||||
<div style="margin-top:12px;font-size:11px;color:var(--muted);font-weight:600;">История версий</div>
|
||||
<div id="promptHistory" style="max-height:180px;overflow-y:auto;margin-top:4px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="aboutModalOverlay" onclick="closeAbout(event)">
|
||||
<div class="modal" style="max-width:720px;" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<i data-lucide="info" style="width:18px;height:18px;"></i>
|
||||
<span>О сервисе — архитектура и принцип работы</span>
|
||||
<button class="remove-btn" onclick="closeAbout()" style="margin-left:auto;font-size:18px;">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding:16px;font-size:13px;line-height:1.6;max-height:65vh;overflow-y:auto;" id="aboutBody">
|
||||
<h3 style="margin-top:0;">Что это</h3>
|
||||
<p>Автоматический разбор и сравнение договоров с помощью ИИ (LLM). Загружаете договоры, допсоглашения и спецификации вперемешку — система сама разбирает что есть что, группирует по контрактам и находит все изменения между версиями.</p>
|
||||
<h3>Как пользоваться</h3>
|
||||
<p><b>1. Загрузка</b> — выберите файлы (.docx, .doc, .pdf, .zip). Можно загружать всё вперемешку. Порядок не важен — классификация разберётся.</p>
|
||||
<p><b>2. Классификация</b> — нажмите «Классифицировать». Система через LLM определит тип, номер, дату, контрагента.</p>
|
||||
<p><b>3. Группы</b> — документы автоматически группируются по договорам.</p>
|
||||
<p><b>4. Сравнение</b> — для каждой группы нажмите «▶ Сравнить». Результат: ADD/UPDATE/DELETE.</p>
|
||||
<p><b>5. Промпты</b> — три вида инструкций для ИИ с версионированием.</p>
|
||||
<h3>На выходе</h3>
|
||||
<p>Структурированная спецификация + полная история изменений с привязкой к документам-источникам.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/state.js?v=1.0.179-flask"></script>
|
||||
<script src="/static/app_utils.js?v=1.0.179-flask"></script>
|
||||
<script src="/static/files.js?v=1.0.179-flask"></script>
|
||||
<script src="/static/groups.js?v=1.0.179-flask"></script>
|
||||
<script src="/static/compare.js?v=1.0.179-flask"></script>
|
||||
<script src="/static/app.js?v=1.0.179-flask"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,104 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Пайплайн сверки договоров</title>
|
||||
<style>
|
||||
:root { --bg: #0d1117; --fg: #c9d1d9; --muted: #8b949e; --accent: #58a6ff; }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font: 15px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--fg); max-width: 980px; margin: 0 auto; padding: 40px 20px 60px; }
|
||||
h1 { font-size: 24px; margin-bottom: 24px; text-align: center; }
|
||||
.nav { margin-bottom: 16px; text-align: center; }
|
||||
.nav a { color: var(--accent); text-decoration: none; font-size: 13px; }
|
||||
svg { display: block; max-width: 100%; height: auto; margin: 0 auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="nav"><a href="/docs/architect">← Архитектура (подробно)</a> | <a href="/docs/architecture-diagram">Блок-схемы (все)</a></div>
|
||||
|
||||
<h1>Пайплайн сверки договоров</h1>
|
||||
|
||||
<svg viewBox="0 0 940 280" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<marker id="ar" markerWidth="7" markerHeight="5" refX="7" refY="2.5" orient="auto"><path d="M0,0 L7,2.5 L0,5 Z" fill="#58a6ff"/></marker>
|
||||
<filter id="sh"><feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.3"/></filter>
|
||||
<linearGradient id="gUpload" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#2a1a1a"/><stop offset="100%" stop-color="#1a0a0a"/></linearGradient>
|
||||
<linearGradient id="gParse" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#1a2a3a"/><stop offset="100%" stop-color="#0d1a28"/></linearGradient>
|
||||
<linearGradient id="gClassify" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#2a1a3a"/><stop offset="100%" stop-color="#1a0d28"/></linearGradient>
|
||||
<linearGradient id="gGroup" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#3a2a1a"/><stop offset="100%" stop-color="#281a0d"/></linearGradient>
|
||||
<linearGradient id="gLLM" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#1a3a2a"/><stop offset="100%" stop-color="#0d2818"/></linearGradient>
|
||||
<linearGradient id="gResult" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#1a2a2a"/><stop offset="100%" stop-color="#0d1a1a"/></linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- ===== MAIN PIPELINE ===== -->
|
||||
|
||||
<!-- 1. Upload -->
|
||||
<rect x="10" y="30" width="135" height="110" rx="10" fill="url(#gUpload)" stroke="#f85149" stroke-width="1.5" filter="url(#sh)"/>
|
||||
<text x="77" y="58" text-anchor="middle" fill="#f85149" font-size="28">📤</text>
|
||||
<text x="77" y="82" text-anchor="middle" fill="#f85149" font-size="14" font-weight="bold">Загрузка</text>
|
||||
<text x="77" y="102" text-anchor="middle" fill="#8b949e" font-size="11">100+ файлов</text>
|
||||
<text x="77" y="120" text-anchor="middle" fill="#8b949e" font-size="11">.docx .pdf .zip</text>
|
||||
|
||||
<line x1="147" y1="85" x2="180" y2="85" stroke="#58a6ff" stroke-width="2" marker-end="url(#ar)"/>
|
||||
|
||||
<!-- 2. Parse -->
|
||||
<rect x="185" y="30" width="135" height="110" rx="10" fill="url(#gParse)" stroke="#58a6ff" stroke-width="1.5" filter="url(#sh)"/>
|
||||
<text x="252" y="58" text-anchor="middle" fill="#58a6ff" font-size="28">🔍</text>
|
||||
<text x="252" y="82" text-anchor="middle" fill="#58a6ff" font-size="14" font-weight="bold">Парсинг</text>
|
||||
<text x="252" y="102" text-anchor="middle" fill="#8b949e" font-size="11">Извлечение</text>
|
||||
<text x="252" y="120" text-anchor="middle" fill="#8b949e" font-size="11">текста и таблиц</text>
|
||||
|
||||
<line x1="322" y1="85" x2="355" y2="85" stroke="#58a6ff" stroke-width="2" marker-end="url(#ar)"/>
|
||||
|
||||
<!-- 3. Classify -->
|
||||
<rect x="360" y="30" width="145" height="110" rx="10" fill="url(#gClassify)" stroke="#bc8cff" stroke-width="1.5" filter="url(#sh)"/>
|
||||
<text x="432" y="58" text-anchor="middle" fill="#bc8cff" font-size="28">🏷️</text>
|
||||
<text x="432" y="82" text-anchor="middle" fill="#bc8cff" font-size="14" font-weight="bold">Классификация</text>
|
||||
<text x="432" y="102" text-anchor="middle" fill="#8b949e" font-size="11">3 фильтра</text>
|
||||
<text x="432" y="120" text-anchor="middle" fill="#8b949e" font-size="11">мусор — в сторону</text>
|
||||
|
||||
<line x1="507" y1="85" x2="540" y2="85" stroke="#58a6ff" stroke-width="2" marker-end="url(#ar)"/>
|
||||
|
||||
<!-- 4. Group -->
|
||||
<rect x="545" y="30" width="135" height="110" rx="10" fill="url(#gGroup)" stroke="#d2991d" stroke-width="1.5" filter="url(#sh)"/>
|
||||
<text x="612" y="58" text-anchor="middle" fill="#d2991d" font-size="28">📋</text>
|
||||
<text x="612" y="82" text-anchor="middle" fill="#d2991d" font-size="14" font-weight="bold">Группировка</text>
|
||||
<text x="612" y="102" text-anchor="middle" fill="#8b949e" font-size="11">По договорам</text>
|
||||
<text x="612" y="120" text-anchor="middle" fill="#8b949e" font-size="11">автоматически</text>
|
||||
|
||||
<line x1="682" y1="85" x2="715" y2="85" stroke="#58a6ff" stroke-width="2" marker-end="url(#ar)"/>
|
||||
|
||||
<!-- 5. LLM Compare -->
|
||||
<rect x="720" y="30" width="140" height="110" rx="10" fill="url(#gLLM)" stroke="#3fb950" stroke-width="1.5" filter="url(#sh)"/>
|
||||
<text x="790" y="54" text-anchor="middle" fill="#3fb950" font-size="28">🤖</text>
|
||||
<text x="790" y="78" text-anchor="middle" fill="#3fb950" font-size="13" font-weight="bold">Сравнение</text>
|
||||
<text x="790" y="96" text-anchor="middle" fill="#3fb950" font-size="13" font-weight="bold">(ИИ)</text>
|
||||
<text x="790" y="118" text-anchor="middle" fill="#8b949e" font-size="11">ADD UPDATE</text>
|
||||
<text x="790" y="134" text-anchor="middle" fill="#8b949e" font-size="11">DELETE</text>
|
||||
|
||||
<!-- ===== SUPPORT ROW ===== -->
|
||||
|
||||
<!-- Protection -->
|
||||
<rect x="10" y="165" width="460" height="45" rx="8" fill="#1a0a0a" stroke="#f85149" stroke-width="1" stroke-dasharray="5,3"/>
|
||||
<text x="240" y="183" text-anchor="middle" fill="#f85149" font-size="12" font-weight="bold">🛡️ Защита на каждом шаге</text>
|
||||
<text x="240" y="200" text-anchor="middle" fill="#8b949e" font-size="10">ZIP-бомба · дубликаты · битые числа/даты → автоисправление · непонятное → на проверку</text>
|
||||
|
||||
<line x1="240" y1="163" x2="240" y2="140" stroke="#f85149" stroke-width="1" stroke-dasharray="3,2"/>
|
||||
|
||||
<!-- Confidentiality -->
|
||||
<rect x="480" y="165" width="450" height="45" rx="8" fill="#1a2a0a" stroke="#3fb950" stroke-width="1" stroke-dasharray="5,3"/>
|
||||
<text x="705" y="183" text-anchor="middle" fill="#3fb950" font-size="12" font-weight="bold">🔒 Конфиденциальность</text>
|
||||
<text x="705" y="200" text-anchor="middle" fill="#8b949e" font-size="10">Файлы удаляются после обработки · без облачного хранения · данные только на время сессии</text>
|
||||
|
||||
<line x1="705" y1="163" x2="705" y2="140" stroke="#3fb950" stroke-width="1" stroke-dasharray="3,2"/>
|
||||
|
||||
<!-- ===== BOTTOM BAR: Result ===== -->
|
||||
|
||||
<rect x="10" y="235" width="920" height="40" rx="8" fill="#161b22" stroke="#58a6ff" stroke-width="1.5" filter="url(#sh)"/>
|
||||
<text x="470" y="260" text-anchor="middle" fill="#58a6ff" font-size="13" font-weight="bold">📊 Результат: итоговая спецификация + полная история изменений (что, когда и откуда добавилось / изменилось / удалилось)</text>
|
||||
</svg>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user