237 lines
18 KiB
Plaintext
237 lines
18 KiB
Plaintext
<cfsetting showdebugoutput="no" enablecfoutputonly="no">
|
||
<cfheader name="Content-Type" value="text/html; charset=utf-8">
|
||
<!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/png" href="/favicon.png">
|
||
<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: hidden; 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="/nubes-logo.svg" alt="Nubes">
|
||
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.171 — Lucee</span></span>
|
||
</div>
|
||
|
||
<div class="content">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<i data-lucide="folder-open" style="width:18px;height:18px;"></i>
|
||
Загрузка договоров/приложений/спецификаций
|
||
<span style="margin-left:auto;font-size:11px;color:var(--muted);cursor:pointer;" onclick="openAbout()">О сервисе</span>
|
||
</div>
|
||
<div class="card-body">
|
||
<input type="file" id="fileInput" accept=".docx,.doc,.pdf" multiple style="margin-bottom:6px;width:100%;">
|
||
<div style="text-align:right;font-size:11px;color:var(--muted);margin-bottom:6px;">Порядок определяется автоматически при классификации</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>
|
||
</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="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:10px;">
|
||
<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>
|
||
</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>Автоматический разбор и сравнение договоров облачного провайдера (colocation, ЦОД, аренда стоек, каналы связи, облачные ресурсы). Юрист загружает файлы — система сама определяет что есть что, группирует по контрактам и через LLM находит изменения между версиями.</p>
|
||
|
||
<h3>Архитектура</h3>
|
||
<p><b>Два сервера:</b></p>
|
||
<ul>
|
||
<li><b>Lucee CFML (k8s)</b> — только HTML-оболочка (index.cfm, 191 строка). Домен: contractor.luceek8s.dev.nubes.ru</li>
|
||
<li><b>Python 3.12 (VM, 5.172.178.213)</b> — вся бизнес-логика: загрузка, парсинг, LLM-классификация, сравнение, Event Sourcing. Домен: contracts.kube5s.ru</li>
|
||
</ul>
|
||
<p><b>База данных:</b> PostgreSQL 16 на VM (127.0.0.1:5432/baza). Конфиденциальные данные не покидают ВМ.</p>
|
||
<p><b>LLM:</b> gpt-oss-120b через api.aillm.ru (бесплатный доступ, HTTP/2).</p>
|
||
|
||
<h3>Пайплайн обработки</h3>
|
||
<ol>
|
||
<li><b>Загрузка</b> — .docx, .doc, .pdf. DOC→DOCX через LibreOffice на VM.</li>
|
||
<li><b>Парсинг</b> — python-docx (Word) и PyPDF2 (PDF). Результат: структурированный JSON (параграфы + таблицы).</li>
|
||
<li><b>LLM-классификация</b> — каждый файл анализируется LLM (gpt-oss-120b): определяется тип (договор/допсоглашение/спецификация), номер, дата, контрагент. Умная выжимка текста: ~3000 символов (header + маркерные строки) для экономии токенов.</li>
|
||
<li><b>Авто-группировка</b> — Python-код нормализует номера договоров и группирует документы по контрактам. Хронологический порядок по датам.</li>
|
||
<li><b>LLM-сравнение</b> — для каждой группы: первый документ → извлечение спецификации (extract). Каждый следующий → сравнение с накопленной спецификацией (diff). Операции: ADD (новая услуга), UPDATE (изменение), DELETE (исключена), UNRESOLVED (не удалось сопоставить).</li>
|
||
</ol>
|
||
|
||
<h3>Event Sourcing</h3>
|
||
<p>Изменения не перезаписывают спецификацию. Каждая операция — событие в таблице <code>spec_events</code>. Текущее состояние — <code>spec_current</code>. Это даёт:</p>
|
||
<ul>
|
||
<li><b>Аудит</b> — полная история изменений: кто, когда, на основании какого документа</li>
|
||
<li><b>Provenance</b> — для каждого события хранится: ID документа-источника, версия промпта LLM, полный сырой ответ LLM</li>
|
||
<li><b>Откат</b> — можно вернуться к любому предыдущему состоянию спецификации</li>
|
||
</ul>
|
||
|
||
<h3>Промпты LLM</h3>
|
||
<p>Хранятся в БД, версионируются. Три вида:</p>
|
||
<ul>
|
||
<li><b>extract</b> — извлечение спецификации из первого документа (базовый договор)</li>
|
||
<li><b>diff</b> — сравнение допсоглашения с текущей спецификацией</li>
|
||
<li><b>classify</b> — авто-классификация: определение типа/номера/даты/контрагента документа</li>
|
||
</ul>
|
||
|
||
<h3>Схема БД</h3>
|
||
<p>7 таблиц: <code>documents</code> (файлы + elements_json + поля классификации), <code>contracts</code>, <code>supplements</code> (связь документ→договор), <code>spec_events</code> (события), <code>spec_current</code> (текущая спецификация), <code>prompts</code> (версионные промпты), <code>upload_chunks</code> (временные чанки).</p>
|
||
|
||
<h3>Безопасность</h3>
|
||
<p>Все файлы и тексты договоров хранятся ТОЛЬКО на ВМ (5.172.178.213). При перезагрузке страницы все данные автоматически удаляются из БД. Промпты (код системы) сохраняются.</p>
|
||
|
||
<h3>Стек</h3>
|
||
<p>Lucee 6.0 (CFML, k8s) + Python 3.12 (http.server, psycopg2, httpx, python-docx, PyPDF2) + PostgreSQL 16 + gpt-oss-120b + nginx + systemd.</p>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="https://contracts.kube5s.ru/static/app_utils.js"></script>
|
||
<script src="https://contracts.kube5s.ru/static/app.js"></script>
|
||
</body>
|
||
</html>
|