diff --git a/requirements.txt b/requirements.txt
index c3eee8e..5e95fec 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,3 @@
-flask>=3.0
-gunicorn>=21.0
+flask
+gunicorn
+httpx
diff --git a/site/app.py b/site/app.py
index 74f0236..48a148a 100644
--- a/site/app.py
+++ b/site/app.py
@@ -1,11 +1,96 @@
-from flask import Flask
+"""contracts-flask — Flask-фронтенд для сверки договоров.
+1:1 функционал с Lucee-версией.
+Все тяжёлые операции — на ВМ (contracts.kube5s.ru).
+"""
+import os
+import httpx
+from flask import Flask, render_template, request, jsonify
app = Flask(__name__)
+VM_API = os.environ.get("VM_API", "https://contracts.kube5s.ru")
+LLM_URL = os.environ.get("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions")
+LLM_KEY = os.environ.get("LLM_API_KEY", "")
+LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-oss-120b")
+
@app.route("/")
def index():
- return "contracts-flask OK"
+ return render_template("index.html", vm_api=VM_API)
+
+
+@app.route("/chat", methods=["POST"])
+def chat():
+ contract_id = request.args.get("contract_id", "")
+ question = request.form.get("question", "")
+ if not contract_id or not question:
+ return jsonify({"ok": False, "error": "contract_id and question required"})
+
+ try:
+ with httpx.Client(timeout=10) as client:
+ resp = client.get(f"{VM_API}/api/spec-current", params={"contract_id": contract_id})
+ resp.raise_for_status()
+ rows = resp.json().get("rows", [])
+ except Exception as e:
+ return jsonify({"ok": False, "error": f"VM error: {e}"})
+
+ if not rows:
+ return jsonify({"ok": True, "answer": "Нет данных. Сначала запустите сравнение."})
+
+ ctx = "\n".join(f"{r.get('name','')} | цена={r.get('price')} | объём={r.get('qty')} | сумма={r.get('sum')} | начало={r.get('date_start')}" for r in rows)
+ prompt = f"Ты — анализатор договоров. Данные:\n{ctx}\n\nВопрос: {question}\nОтветь кратко, только по данным."
+
+ try:
+ with httpx.Client(http2=True, timeout=120) as client:
+ resp = client.post(LLM_URL, json={
+ "model": LLM_MODEL,
+ "messages": [{"role": "user", "content": prompt}],
+ "max_tokens": 1000, "temperature": 0.1,
+ }, headers={"Authorization": f"Bearer {LLM_KEY}", "Content-Type": "application/json"})
+ resp.raise_for_status()
+ return jsonify({"ok": True, "answer": resp.json()["choices"][0]["message"]["content"]})
+ except Exception as e:
+ return jsonify({"ok": False, "error": f"LLM error: {e}"})
+
+
+@app.route("/api/prompts", methods=["GET"])
+def prompts_get():
+ try:
+ with httpx.Client(timeout=10) as client:
+ resp = client.get(f"{VM_API}/api/prompts", params=request.args)
+ return jsonify(resp.json())
+ except Exception as e:
+ return jsonify({"ok": False, "error": str(e)})
+
+
+@app.route("/api/prompts/list", methods=["GET"])
+def prompts_list():
+ try:
+ with httpx.Client(timeout=10) as client:
+ resp = client.get(f"{VM_API}/api/prompts/list")
+ return jsonify(resp.json())
+ except Exception as e:
+ return jsonify({"ok": False, "error": str(e)})
+
+
+@app.route("/api/prompts/save", methods=["POST"])
+def prompts_save():
+ try:
+ with httpx.Client(timeout=10) as client:
+ resp = client.post(f"{VM_API}/api/prompts/save", json=request.get_json())
+ return jsonify(resp.json())
+ except Exception as e:
+ return jsonify({"ok": False, "error": str(e)})
+
+
+@app.route("/api/prompts/activate", methods=["POST"])
+def prompts_activate():
+ try:
+ with httpx.Client(timeout=10) as client:
+ resp = client.post(f"{VM_API}/api/prompts/activate", json=request.get_json())
+ return jsonify(resp.json())
+ except Exception as e:
+ return jsonify({"ok": False, "error": str(e)})
@app.route("/health")
@@ -14,4 +99,14 @@ def health():
if __name__ == "__main__":
- app.run(debug=False, host="0.0.0.0", port=5000)
+ app.run(host="0.0.0.0", port=5000)
+
+# ── Совместимость с Lucee-путями ──────────────────────────────
+
+@app.route("/chat.cfm", methods=["POST"])
+def chat_cfm():
+ return chat()
+
+@app.route("/prompt.cfm", methods=["GET"])
+def prompt_cfm():
+ return prompts_get()
diff --git a/site/static/app.js b/site/static/app.js
new file mode 100644
index 0000000..2ce4f0f
--- /dev/null
+++ b/site/static/app.js
@@ -0,0 +1,623 @@
+// ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔
+// Contracts App — весь JS на VM (contracts.kube5s.ru)
+// Lucee: только домен + index.cfm-скелет
+var VM_API = 'https://contracts.kube5s.ru';
+var UPLOAD_URL = VM_API + '/upload';
+var CONVERT_URL = VM_API + '/convert-doc';
+var UNZIP_URL = VM_API + '/unzip-upload';
+// SITE_URL удалён (Фаза 4) — не использовался
+
+var fileInput = document.getElementById('fileInput');
+var fileTable = document.getElementById('fileTable');
+// state.files — теперь в state.js (Фаза 0: state + render)
+// state.contractId — теперь в state.js (Фаза 0: state + render)
+// state.batchId — теперь в state.js (Фаза 0: state + render)
+// (batchId = crypto.randomUUID() — уникальный ID сессии для классификации)
+
+// Автоочистка старых записей при загрузке страницы
+(async function cleanup() {
+ try {
+ await fetch(VM_API + '/api/cleanup', { method: 'POST' });
+ } catch(e) { /* ignore */ }
+})();
+
+/**
+ * render(state) — Главная функция рендеринга (Фаза 0, decoupling-final-plan.md).
+ *
+ * ПАТТЕРН: action → мутация state → render(state)
+ *
+ * Фаза 0: вызывает существующий renderTable().
+ * renderTable читает state.files напрямую (state — глобальный объект).
+ * Фаза 1+: будет вызывать renderFiles(state), renderGroups(state), renderStepper(state) и т.д.
+ *
+ * ПРАВИЛО: любое изменение state ДОЛЖНО завершаться вызовом render(state).
+ * Никакой прямой манипуляции DOM в обход render().
+ * console.log(state) показывает ВСЁ состояние в любой момент.
+ */
+function render(state) {
+ renderFiles(state);
+ renderStepper(state);
+}
+
+// syncDB, statusToHTML, renderFiles, toggleClassifyDetail, uploadFile, refreshSupps
+// → вынесены в files.js (Фаза 1)
+
+// ── Pipeline stepper (Фаза 4: state-driven) ──────────────────
+
+/**
+ * renderStepper(state) — Рендер степпера из state.ui.steps (Фаза 4).
+ *
+ * Читает state.ui.steps.{upload,classify,groups,compare}, обновляет DOM.
+ * Значения: '○' (не начат) | '⏳' (в процессе) | '✓' (завершён).
+ */
+function renderStepper(state) {
+ var steps = state.ui.steps;
+ var map = { upload: 'stepUpload', classify: 'stepClassify', groups: 'stepGroups', compare: 'stepCompare' };
+ var colors = { '○': 'var(--muted)', '⏳': 'var(--brand-primary)', '✓': 'var(--green)' };
+ Object.keys(map).forEach(function(key) {
+ var el = document.getElementById(map[key]);
+ if (el) {
+ el.textContent = steps[key] + ' ' + {upload:'Загрузка',classify:'Классификация',groups:'Группировка',compare:'Сравнение'}[key];
+ el.style.color = colors[steps[key]] || 'var(--muted)';
+ }
+ });
+}
+
+/**
+ * stepDone(id) — Отметить шаг как завершённый (Фаза 4).
+ * Мутирует state.ui.steps, вызывает renderStepper.
+ */
+function stepDone(id) {
+ var key = { stepUpload: 'upload', stepClassify: 'classify', stepGroups: 'groups', stepCompare: 'compare' }[id];
+ if (key) { state.ui.steps[key] = '✓'; renderStepper(state); }
+}
+
+/**
+ * stepActive(id) — Отметить шаг как активный (Фаза 4).
+ * Мутирует state.ui.steps, вызывает renderStepper.
+ */
+function stepActive(id) {
+ var key = { stepUpload: 'upload', stepClassify: 'classify', stepGroups: 'groups', stepCompare: 'compare' }[id];
+ if (key) { state.ui.steps[key] = '⏳'; renderStepper(state); }
+}
+
+/**
+ * resetStepper(fromId) — Сбросить шаги начиная с fromId (Фаза 4).
+ * Все шаги после (и включая) fromId получают '○'.
+ * Мутирует state.ui.steps, вызывает renderStepper.
+ */
+function resetStepper(fromId) {
+ var order = ['stepUpload','stepClassify','stepGroups','stepCompare'];
+ var keys = ['upload','classify','groups','compare'];
+ var reset = false;
+ order.forEach(function(id, i) {
+ if (id === fromId) reset = true;
+ if (reset) { state.ui.steps[keys[i]] = '○'; }
+ });
+ renderStepper(state);
+}
+// Утилиты (formatSize, formatDate, moveUp, moveDown, escHtml, fmtDate) —
+// вынесены в app_utils.js для облегчения анализа и отладки.
+// statusToHTML, renderFiles → files.js (Фаза 1)
+
+// ── Раскрыть/скрыть результат классификации под строкой файла ──
+// toggleClassifyDetail → files.js (Фаза 1)
+
+// moveUp/moveDown/removeFile/openAbout/closeAbout — вынесены в app_utils.js
+
+// ── Автозагрузка при выборе файлов ──────────────────────────
+// Фаза 1 (decoupling-final-plan.md): fileInput handler разбит на чистые функции
+// onFilesSelected → reconcileSelection → addZipFile/addRegularFile → finalizeUpload
+// Вся логика — в files.js. Здесь только тонкая обёртка.
+fileInput.addEventListener('change', async function() {
+ var newFiles = Array.from(fileInput.files);
+ onFilesSelected(newFiles);
+});
+
+// ── Классификация ──────────────────────────────────────────
+function showClassifyBtn() {
+ var btn = document.getElementById('classifyBtn');
+ if (!btn) {
+ btn = document.createElement('button');
+ btn.id = 'classifyBtn';
+ btn.className = 'btn btn-primary';
+ btn.style.cssText = 'width:100%;justify-content:center;margin-top:8px;background:var(--brand-primary);border-color:var(--brand-primary);';
+ btn.innerHTML = ' Классифицировать';
+ btn.addEventListener('click', runClassify);
+ document.getElementById('llmBtn').parentNode.insertBefore(btn, document.getElementById('llmBtn'));
+ }
+ btn.style.display = 'flex';
+ btn.disabled = false;
+}
+
+async function runClassify() {
+ var btn = document.getElementById('classifyBtn');
+ btn.disabled = true;
+ btn.innerHTML = ' Классификация... 0с';
+ stepActive('stepClassify');
+
+ var start = Date.now();
+ var timer = setInterval(function() {
+ btn.innerHTML = ' Классификация... ' + Math.round((Date.now() - start) / 1000) + 'с';
+ }, 1000);
+
+ var totalFiles = 0;
+ var classifyDone = false;
+
+ // Poll progress каждые 2с — работает и для sync, и для async classify
+ var pollTimer = setInterval(async function() {
+ try {
+ var r = await fetch(VM_API + '/api/batch-progress?batch=' + state.batchId);
+ var d = await r.json();
+ if (d.ok && d.counts) {
+ var done = (d.counts.classified || 0) + (d.counts.failed || 0);
+ var total = d.total || 0;
+ if (total > 0) totalFiles = total;
+ btn.innerHTML = ' Классификация... ' + done + '/' + total + ' (' + Math.round((Date.now() - start) / 1000) + 'с)';
+ if (done >= total && total > 0) {
+ clearInterval(pollTimer);
+ clearInterval(timer);
+ classifyDone = true;
+ btn.innerHTML = '✓ ' + done + '/' + total + ' классифицировано (' + Math.round((Date.now() - start) / 1000) + 'с)';
+ stepDone('stepClassify');
+ stepActive('stepGroups');
+ loadGroupsAction();
+ }
+ }
+ } catch(e) {}
+ }, 2000);
+
+ try {
+ var resp = await fetch(VM_API + '/api/classify-batch', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({batch_id: state.batchId})
+ });
+ var data = await resp.json();
+
+ // sync classify: ответ уже содержит done
+ if (data.ok && data.done !== undefined) {
+ clearInterval(timer);
+ clearInterval(pollTimer);
+ classifyDone = true;
+ btn.innerHTML = '✓ ' + data.done + '/' + data.total + ' классифицировано (' + Math.round((Date.now() - start) / 1000) + 'с)';
+ stepDone('stepClassify');
+ stepActive('stepGroups');
+ loadGroupsAction();
+ } else if (data.ok) {
+ // async classify: 202 — ждём poll до done>=total
+ totalFiles = data.total || 0;
+ if (totalFiles === 0) {
+ clearInterval(timer);
+ clearInterval(pollTimer);
+ btn.innerHTML = '✗ нет файлов для классификации';
+ btn.disabled = false;
+ }
+ } else {
+ clearInterval(timer);
+ clearInterval(pollTimer);
+ btn.innerHTML = '✗ ' + (data.error || 'ошибка');
+ btn.disabled = false;
+ }
+ } catch(e) {
+ if (!classifyDone) {
+ clearInterval(timer);
+ clearInterval(pollTimer);
+ btn.innerHTML = '✗ ' + e.message;
+ btn.disabled = false;
+ }
+ }
+}
+
+// loadGroups, buildGroupCard, markGroupDone → groups.js (Фаза 2)
+
+// Текущий активный EventSource (только один одновременно)
+// state._activeCompare — теперь в state.js (Фаза 0: state + render)
+
+window.runCompareForGroup = async function(gi, btn) {
+ var group = state.groups[gi];
+ if (!group || group.contract_number === '__unresolved__') return;
+
+ // Фаза 2: отметить группу как «в процессе»
+ // renderGroups НЕ вызываем — инкрементальные DOM-мутации внутри SSE
+ group.compare = { status: 'running' };
+
+ // Остановить предыдущее сравнение
+ if (state._activeCompare.es) { state._activeCompare.es.close(); state._activeCompare.es = null; }
+ if (state._activeCompare.timer) { clearInterval(state._activeCompare.timer); state._activeCompare.timer = null; }
+
+ // Свернуть ТОЛЬКО тело текущей группы
+ var curBody = document.getElementById('cmpBody_' + gi);
+ if (curBody) curBody.style.display = 'none';
+
+ btn.disabled = true;
+ btn.innerHTML = ' Применяю...';
+ // Заблокировать ВСЕ кнопки сравнения пока идёт процесс
+ document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = true; });
+
+ var ar = await fetch(VM_API + '/api/apply-groups', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({batch_id: state.batchId, groups: [group]})
+ });
+ var ad = await ar.json();
+ if (!ad.ok || !ad.contract_ids || !ad.contract_ids.length) {
+ btn.innerHTML = '✗ ошибка'; btn.disabled = false;
+ document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
+ return;
+ }
+
+ var cid = ad.contract_ids[0];
+
+ // Создать контейнер для результатов сравнения
+ var cmpBody = document.getElementById('cmpBody_' + gi);
+ if (!cmpBody) {
+ cmpBody = document.createElement('div');
+ cmpBody.className = 'cmp-body';
+ cmpBody.id = 'cmpBody_' + gi;
+ cmpBody.style.cssText = 'margin-top:8px;';
+ btn.parentNode.insertBefore(cmpBody, btn.nextSibling);
+ }
+ cmpBody.innerHTML = '';
+ cmpBody.style.display = 'block';
+ btn.innerHTML = ' Сравнение...';
+ stepActive('stepCompare');
+
+ var statusEl = document.createElement('div');
+ statusEl.style.cssText = 'font-size:11px;color:var(--muted);margin-bottom:4px;';
+ statusEl.textContent = '⏳ 0.0с';
+ cmpBody.appendChild(statusEl);
+
+ // Фаза 3: унифицированный SSE через startCompareSSE (compare.js)
+ var result = startCompareSSE(
+ VM_API + '/process-v2?contract_id=' + cid,
+ cmpBody,
+ statusEl,
+ {
+ onDone: function(d, sections) {
+ state._activeCompare.timer = null;
+ state._activeCompare.es = null;
+ group.compare = {
+ status: 'done',
+ totalTime: d.total_time_s + 'с',
+ bodyHTML: cmpBody.innerHTML,
+ sections: sections
+ };
+ renderGroups(state);
+ document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
+ stepDone('stepCompare');
+ },
+ onError: function(d) {
+ state._activeCompare.timer = null;
+ state._activeCompare.es = null;
+ group.compare = null;
+ cmpBody.innerHTML += '
✗ ' + d.message + '
';
+ btn.innerHTML = '✗'; btn.disabled = false;
+ document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
+ },
+ onConnectionError: function() {
+ state._activeCompare.timer = null;
+ state._activeCompare.es = null;
+ group.compare = null;
+ btn.innerHTML = '✗ соединение'; btn.disabled = false;
+ document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
+ }
+ }
+ );
+ state._activeCompare.es = result.es;
+ state._activeCompare.timer = result.timer;
+};
+
+// uploadFile → files.js (Фаза 1)
+
+// ── LLM: Общее сравнение (Event Sourcing) ────────────────────────
+document.getElementById('llmBtn').addEventListener('click', function() {
+ if (!state.contractId) return;
+ var btn = this;
+ btn.disabled = true;
+ btn.innerHTML = ' Сравнение...';
+
+ var compareCard = document.getElementById('compareCard');
+ var compareBody = document.getElementById('compareBody');
+ var compareStatus = document.getElementById('compareStatus');
+ compareCard.style.display = 'block';
+ compareBody.innerHTML = '';
+ compareStatus.textContent = '⏳ 0.0с';
+
+ var order = state.files.map(function(f) { return f.supp_id; }).filter(Boolean).join(',');
+ var url = 'https://contracts.kube5s.ru/process-v2?contract_id=' + state.contractId +
+ (order ? '&order=' + encodeURIComponent(order) : '');
+
+ // Фаза 3: унифицированный SSE через startCompareSSE (compare.js)
+ startCompareSSE(url, compareBody, compareStatus, {
+ onDone: function(d) {
+ btn.innerHTML = ' ✓ Готово';
+ stepDone('stepCompare');
+ if (d.total_unresolved > 0) {
+ compareBody.innerHTML += '⚠ ' + d.total_unresolved + ' неразрешённых строк —
разобрать ';
+ }
+ document.getElementById('chatCard').style.display = 'block';
+ lucide.createIcons();
+ },
+ onError: function(d) {
+ compareBody.innerHTML += '✗ ' + d.message + '
';
+ btn.innerHTML = ' Общее сравнение';
+ btn.disabled = false;
+ },
+ onConnectionError: function() {
+ if (compareBody.innerHTML === '') {
+ compareBody.innerHTML = '✗ Ошибка соединения
';
+ }
+ btn.innerHTML = ' Общее сравнение';
+ btn.disabled = false;
+ lucide.createIcons();
+ }
+ });
+});
+
+// refreshSupps → files.js (Фаза 1)
+
+// ── Чат ──────────────────────────────────────────────────────
+document.getElementById('chatSend').addEventListener('click', function() {
+ var input = document.getElementById('chatInput');
+ var q = input.value.trim();
+ if (!q || !state.contractId) return;
+ var msgs = document.getElementById('chatMessages');
+ msgs.innerHTML += 'Вы: ' + q + '
';
+ input.value = '';
+ input.disabled = true;
+ document.getElementById('chatSend').disabled = true;
+ msgs.innerHTML += '⏳ Думаю...
';
+
+ var fd = new FormData();
+ fd.append('question', q);
+ fetch('/chat.cfm?contract_id=' + state.contractId, { method: 'POST', body: fd })
+ .then(function(r) { return r.json(); })
+ .then(function(d) {
+ msgs.lastChild.remove();
+ msgs.innerHTML += 'Ответ: ' + (d.OK ? d.ANSWER : (d.ERROR || '—')) + '
';
+ input.disabled = false;
+ document.getElementById('chatSend').disabled = false;
+ input.focus();
+ }).catch(function() {
+ msgs.lastChild.remove();
+ msgs.innerHTML += 'Ошибка сети
';
+ input.disabled = false;
+ document.getElementById('chatSend').disabled = false;
+ });
+});
+document.getElementById('chatInput').addEventListener('keydown', function(e) {
+ if (e.key === 'Enter') document.getElementById('chatSend').click();
+});
+
+function closeModal(e) {
+ if (e && e.target !== document.getElementById('modalOverlay')) return;
+ document.querySelector('.modal').classList.remove('wide');
+ document.getElementById('modalOverlay').classList.remove('open');
+}
+
+async function showText(i) {
+ var f = state.files[i];
+ var docId = f.doc_id;
+ document.getElementById('modalTitle').textContent = f.name;
+ document.getElementById('modalBody').innerHTML = 'Загрузка... ';
+ var modal = document.querySelector('.modal');
+ modal.classList.add('wide');
+ document.getElementById('modalOverlay').classList.add('open');
+
+ var leftHtml = 'Загрузка... ';
+ var rightHtml = '';
+
+ if (docId) {
+ try {
+ var qResp = await fetch(VM_API + '/api/documents/' + docId);
+ var qData = await qResp.json();
+ var elements = [];
+ if (qData.ok && qData.elements_json) {
+ var ej = qData.elements_json;
+ if (typeof ej === 'object' && ej.Value) ej = ej.Value;
+ if (typeof ej === 'string') elements = JSON.parse(ej);
+ else elements = ej;
+ }
+
+ // Общий textify для обеих панелей
+ var textLines = [];
+ elements.forEach(function(el) {
+ var etype = el.type || el.TYPE || '?';
+ if (etype === 'paragraph') {
+ textLines.push(el.text || el.TEXT || '');
+ } else if (etype === 'table') {
+ var rows = el.rows || el.ROWS || [];
+ if (rows.length > 0) {
+ var ncols = (rows[0]||[]).length;
+ textLines.push('--- Таблица ' + rows.length + '×' + ncols + ' ---');
+ rows.forEach(function(row) {
+ var cells = (row||[]).map(function(c){ return String(c||'').replace(/\n/g,' ').replace(/\|/g,'\\|'); });
+ textLines.push('| ' + cells.join(' | ') + ' |');
+ });
+ textLines.push('');
+ }
+ }
+ });
+ var textify = textLines.join('\n').replace(//g,'>');
+
+ // Левая: сырой текст (textify)
+ leftHtml = '' + textify + ' ';
+
+ // Правая: статистика + textify
+ rightHtml += 'Размер ' + formatSize(f.size) + '
';
+ rightHtml += 'Изменён ' + formatDate(f.lastModified) + '
';
+ if (f.parseInfo) {
+ var d = f.parseInfo;
+ rightHtml += 'Элементов ' + (d.element_count||0) + '
';
+ if (d.error) rightHtml += '' + d.error + '
';
+ }
+ rightHtml += 'Все элементы:
';
+ rightHtml += '' + textify + ' ';
+ if (f.supp_id) {
+ rightHtml += 'Тип ДС ' + (f.supp_type || '—') + '
';
+ }
+
+ // ── Результаты классификации ──────────────────────────
+ if (qData.classify_status === 'classified' || qData.classify_raw) {
+ rightHtml += '🏷️ Классификация LLM
';
+ if (qData.doc_type) rightHtml += 'Тип ' + escHtml(qData.doc_type) + '
';
+ if (qData.own_number) rightHtml += 'Номер ' + escHtml(qData.own_number) + '
';
+ if (qData.parent_number) rightHtml += 'Родительский ' + escHtml(qData.parent_number) + '
';
+ if (qData.doc_date) rightHtml += 'Дата ' + escHtml(qData.doc_date) + '
';
+ if (qData.counterparty) rightHtml += 'Контрагент ' + escHtml(qData.counterparty) + '
';
+ if (qData.classify_raw) {
+ var rawId = 'raw_' + docId.replace(/-/g,'');
+ rightHtml += 'Сырой ответ LLM ';
+ rightHtml += '' + escHtml(qData.classify_raw) + ' ';
+ rightHtml += ' ';
+ }
+ }
+ } catch(e) {
+ leftHtml = 'Ошибка ';
+ }
+ } else {
+ leftHtml = '(ещё не загружен) ';
+ }
+
+ var html = '' +
+ '
Сырой текст
' + leftHtml + '
' +
+ '
Распарсено
' + rightHtml + '
' +
+ '
';
+ document.getElementById('modalBody').innerHTML = html;
+}
+
+window.closeModal = closeModal;
+window.showText = showText;
+
+// ── Промпты (версионные) ─────────────────────────────────────
+var promptRole = 'diff'; // текущая выбранная роль
+var promptEditor = document.getElementById('promptEditor');
+var promptStatus = document.getElementById('promptStatus');
+var promptHistory = document.getElementById('promptHistory');
+var promptModalOverlay = document.getElementById('promptModalOverlay');
+var promptModalTitle = document.getElementById('promptModalTitle');
+var promptName = document.getElementById('promptName');
+var promptNotes = document.getElementById('promptNotes');
+// Три роли промптов:
+// extract — извлечение строк из первого документа (базовый договор)
+// diff — сравнение допсоглашений (ADD/UPDATE/DELETE)
+// classify — классификация документа (тип, номер, контрагент, дата)
+var activePromptId = {extract: '', diff: '', classify: ''};
+
+function openPromptModal(role) {
+ promptRole = role;
+ var labels = {extract: 'Извлечение', diff: 'Сравнение', classify: 'Классификация'};
+ promptModalTitle.textContent = 'Промпт: ' + (labels[role] || role);
+ document.getElementById('promptTabExtract').style.background = role === 'extract' ? 'var(--brand-primary)' : '';
+ document.getElementById('promptTabExtract').style.color = role === 'extract' ? '#fff' : '';
+ document.getElementById('promptTabDiff').style.background = role === 'diff' ? 'var(--brand-primary)' : '';
+ document.getElementById('promptTabDiff').style.color = role === 'diff' ? '#fff' : '';
+ document.getElementById('promptTabClassify').style.background = role === 'classify' ? 'var(--brand-primary)' : '';
+ document.getElementById('promptTabClassify').style.color = role === 'classify' ? '#fff' : '';
+ promptStatus.textContent = '';
+ promptName.value = '';
+ promptNotes.value = '';
+ loadPrompt(role);
+ promptModalOverlay.classList.add('open');
+}
+
+function closePromptModal(e) {
+ if (e && e.target !== promptModalOverlay) return;
+ promptModalOverlay.classList.remove('open');
+}
+
+function loadPrompt(role) {
+ fetch(VM_API + '/api/prompts?role=' + role)
+ .then(function(r) { return r.json(); })
+ .then(function(d) {
+ if (d.ok) {
+ promptEditor.value = d.body || '';
+ activePromptId[role] = d.id || '';
+ loadHistory(role);
+ }
+ })
+ .catch(function(e) { console.error('loadPrompt:', e); });
+}
+
+function loadHistory(role) {
+ fetch(VM_API + '/api/prompts/list?role=' + role)
+ .then(function(r) { return r.json(); })
+ .then(function(d) {
+ if (!d.ok || !d.versions || !d.versions.length) {
+ promptHistory.innerHTML = 'нет версий
';
+ return;
+ }
+ promptHistory.innerHTML = d.versions.map(function(v) {
+ var isActive = v.is_active ? ' ' : '';
+ var style = v.is_active ? 'font-weight:600;' : '';
+ return ''
+ + '' + isActive + escHtml(v.name) + ' ' + fmtDate(v.created_at) + ' '
+ + (v.notes ? ' — ' + escHtml(v.notes) + ' ' : '')
+ + ' '
+ + (!v.is_active ? 'активировать ' : '')
+ + (!v.is_active ? 'удалить ' : '')
+ + '
';
+ }).join('');
+ })
+ .catch(function(e) { console.error('loadHistory:', e); });
+}
+
+function activatePrompt(id) {
+ fetch(VM_API + '/api/prompts/activate', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({id: id})
+ })
+ .then(function(r) { return r.json(); })
+ .then(function(d) {
+ if (d.ok) { loadPrompt(promptRole); promptStatus.textContent = '✓ активирован'; }
+ else { promptStatus.textContent = '✕ ' + (d.error || 'ошибка'); }
+ });
+}
+
+function deletePrompt(id) {
+ if (!confirm('Удалить эту версию промпта?')) return;
+ fetch(VM_API + '/api/prompts/delete', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({id: id})
+ })
+ .then(function(r) { return r.json(); })
+ .then(function(d) {
+ if (d.ok) { loadHistory(promptRole); promptStatus.textContent = '✓ удалено'; }
+ else { promptStatus.textContent = '✕ ' + (d.error || 'ошибка'); }
+ });
+}
+
+document.getElementById('promptSave').addEventListener('click', function() {
+ var body = promptEditor.value.trim();
+ if (!body) { promptStatus.textContent = '✕ тело промпта пустое'; return; }
+ var name = promptName.value.trim() || ('v' + new Date().toISOString().replace(/[:.]/g,'-').substring(0,16));
+ fetch(VM_API + '/api/prompts/save', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({
+ role: promptRole, name: name, body: body,
+ notes: promptNotes.value.trim(), is_active: true
+ })
+ })
+ .then(function(r) { return r.json(); })
+ .then(function(d) {
+ if (d.ok) {
+ promptStatus.textContent = '✓ сохранено (новая версия)';
+ promptName.value = ''; promptNotes.value = '';
+ loadPrompt(promptRole);
+ } else {
+ promptStatus.textContent = '✕ ' + (d.error || 'ошибка');
+ }
+ });
+});
+
+document.getElementById('promptTabExtract').addEventListener('click', function() { openPromptModal('extract'); });
+document.getElementById('promptTabDiff').addEventListener('click', function() { openPromptModal('diff'); });
+document.getElementById('promptTabClassify').addEventListener('click', function() { openPromptModal('classify'); });
+document.getElementById('promptBtnExtract').addEventListener('click', function() { openPromptModal('extract'); });
+document.getElementById('promptBtnDiff').addEventListener('click', function() { openPromptModal('diff'); });
+document.getElementById('promptBtnClassify').addEventListener('click', function() { openPromptModal('classify'); });
+lucide.createIcons();
diff --git a/site/static/app_utils.js b/site/static/app_utils.js
new file mode 100644
index 0000000..d65a8a5
--- /dev/null
+++ b/site/static/app_utils.js
@@ -0,0 +1,108 @@
+/**
+ * app_utils.js — Общие утилиты (форматирование, стрелки, модалки).
+ * Вынесены из app.js для облегчения анализа и отладки.
+ * Загружается ДО app.js.
+ */
+
+/**
+ * Форматирует размер файла в человекочитаемый вид.
+ * Используется в renderTable() для колонки "Размер".
+ */
+function formatSize(bytes) {
+ if (!bytes || bytes === 0) return '—';
+ if (bytes < 1024) return bytes + ' B';
+ if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
+ return (bytes / 1048576).toFixed(1) + ' MB';
+}
+
+/**
+ * Форматирует timestamp в российскую дату + время.
+ * Используется в renderTable() для колонки "Изменён".
+ */
+function formatDate(ts) {
+ if (!ts) return '—';
+ var d = new Date(ts);
+ return d.toLocaleDateString('ru-RU') + ' ' + d.toLocaleTimeString('ru-RU', {hour:'2-digit',minute:'2-digit'});
+}
+
+/**
+ * Удаляет файл из очереди по индексу.
+ * Вызывается по кнопке ✕ в таблице файлов.
+ *
+ * Фаза 0 (state + render): мутирует state.files, затем render(state).
+ * Если очередь опустела — сбрасывает state.contractId.
+ */
+function removeFile(i) {
+ state.files.splice(i, 1);
+ if (state.files.length === 0) state.contractId = null;
+ render(state);
+ // Синхронизировать БД с таблицей
+ if (typeof syncDB === 'function') syncDB();
+ // Сбросить прогресс при изменении состава файлов
+ if (typeof resetStepper === 'function') resetStepper('stepUpload');
+ // Разблокировать кнопку классификации при изменении состава файлов
+ if (typeof showClassifyBtn === 'function') showClassifyBtn();
+}
+window.removeFile = removeFile;
+
+/**
+ * Переместить файл на одну позицию ВВЕРХ (сохранено для возможного возврата ручной сортировки).
+ * В текущей версии (v1.0.164+) не используется — порядок определяет авто-классификация.
+ *
+ * Фаза 0 (state + render): мутирует state.files, затем render(state).
+ */
+function moveUp(i) {
+ if (i <= 0) return;
+ var tmp = state.files[i]; state.files[i] = state.files[i-1]; state.files[i-1] = tmp;
+ render(state);
+}
+
+/**
+ * Переместить файл на одну позицию ВНИЗ (сохранено для возможного возврата ручной сортировки).
+ * В текущей версии (v1.0.164+) не используется — порядок определяет авто-классификация.
+ *
+ * Фаза 0 (state + render): мутирует state.files, затем render(state).
+ */
+function moveDown(i) {
+ if (i >= state.files.length - 1) return;
+ var tmp = state.files[i]; state.files[i] = state.files[i+1]; state.files[i+1] = tmp;
+ render(state);
+}
+window.moveUp = moveUp;
+window.moveDown = moveDown;
+
+/**
+ * Открыть модальное окно "О сервисе".
+ */
+function openAbout() {
+ document.getElementById('aboutModalOverlay').classList.add('open');
+}
+
+/**
+ * Закрыть модальное окно "О сервисе" (по клику на оверлей или кнопку).
+ */
+function closeAbout(e) {
+ if (e && e.target !== document.getElementById('aboutModalOverlay')) return;
+ document.getElementById('aboutModalOverlay').classList.remove('open');
+}
+window.openAbout = openAbout;
+window.closeAbout = closeAbout;
+
+/**
+ * Экранировать HTML (для безопасного рендеринга пользовательских данных).
+ * Используется в истории промптов.
+ */
+function escHtml(s) {
+ if (s == null) return '';
+ var d = document.createElement('div');
+ d.textContent = s;
+ return d.innerHTML;
+}
+
+/**
+ * Форматировать дату для истории промптов (YYYY-MM-DD HH:MM).
+ */
+function fmtDate(d) {
+ if (!d) return '';
+ return d.replace('T', ' ').substring(0, 16);
+}
diff --git a/site/static/compare.js b/site/static/compare.js
new file mode 100644
index 0000000..c712478
--- /dev/null
+++ b/site/static/compare.js
@@ -0,0 +1,243 @@
+/**
+ * compare.js — Модуль сравнения через SSE (Фаза 3, decoupling-final-plan.md).
+ *
+ * УНИФИЦИРУЕТ дублирование (~160 строк) между runCompareForGroup и llmBtn.
+ *
+ * ПАТТЕРН:
+ * applyCompareEvent(sections, event) — чистая мутация: SSE-событие → state
+ * startCompareSSE(url, container, statusEl, callbacks) — жизненный цикл SSE
+ * renderCompareOpsTable(ops) — общий рендер таблицы операций
+ *
+ * ЗАВИСИМОСТИ:
+ * state.js → state (для _activeCompare)
+ */
+
+/**
+ * applyCompareEvent(sections, event) — Чистая функция (Фаза 3).
+ *
+ * Применяет ОДНО SSE-событие к состоянию sections.
+ * НЕ трогает DOM — только мутирует объект sections.
+ *
+ * Вход: sections — объект { [supplement_id]: sectionState }
+ * event — { type, supplement_id, filename, ops_count, mode, ... }
+ * Выход: нет (мутирует sections)
+ *
+ * Типы событий:
+ * extract_start → создаёт запись в sections
+ * llm_done → обновляет статус, ops_count, mode, time_s
+ * applied → сохраняет ops и summary
+ * extract_error/apply_error → сохраняет ошибку
+ */
+function applyCompareEvent(sections, event) {
+ var d = event;
+ var suppId = d.supplement_id;
+
+ if (d.type === 'extract_start') {
+ // Создать новую секцию: извлечение началось
+ sections[suppId] = {
+ filename: d.filename,
+ status: 'extracting',
+ ops_count: 0,
+ mode: '',
+ time_s: 0,
+ summary: null,
+ ops: null,
+ error: null
+ };
+ }
+ else if (d.type === 'llm_done') {
+ // LLM закончил — сохранить метаданные
+ var sec = sections[suppId];
+ if (sec) {
+ sec.status = 'llm_done';
+ sec.ops_count = d.ops_count || 0;
+ sec.mode = d.mode || '';
+ sec.time_s = d.time_s || 0;
+ }
+ }
+ else if (d.type === 'applied') {
+ // Результаты сравнения применены — сохранить ops и summary
+ var sec = sections[suppId];
+ if (sec) {
+ sec.status = 'applied';
+ sec.summary = d.summary || {};
+ sec.ops = d.ops || [];
+ }
+ }
+ else if (d.type === 'extract_error' || d.type === 'apply_error') {
+ // Ошибка извлечения или применения
+ var sec = sections[suppId];
+ if (sec) {
+ sec.status = 'error';
+ sec.error = d.error || 'неизвестная ошибка';
+ } else {
+ // Ошибка для ещё не созданной секции
+ sections[suppId] = {
+ filename: d.filename || '?',
+ status: 'error',
+ error: d.error || 'неизвестная ошибка',
+ ops_count: 0, mode: '', time_s: 0, summary: null, ops: null
+ };
+ }
+ }
+}
+
+/**
+ * renderCompareSectionHeader(sec) — Чистая функция: заголовок секции (Фаза 3).
+ *
+ * Вход: sec — { filename, status, ops_count, mode, time_s, error }
+ * Выход: HTML-строка для div.diff-section-header
+ */
+function renderCompareSectionHeader(sec) {
+ if (sec.status === 'extracting') {
+ return '⏳ ' + sec.filename;
+ }
+ if (sec.status === 'llm_done' || sec.status === 'applied') {
+ return '✓ ' + sec.filename + ' — ' + sec.ops_count + ' оп., ' + sec.mode + ' (' + sec.time_s + 'с)';
+ }
+ if (sec.status === 'error') {
+ return '✗ ' + sec.filename + ': ' + sec.error;
+ }
+ return sec.filename;
+}
+
+/**
+ * renderCompareOpsTable(ops) — Чистая функция: таблица операций (Фаза 3).
+ *
+ * Используется ОБОИМИ обработчиками (runCompareForGroup и llmBtn).
+ * Раньше этот код был продублирован ~30 строк × 2.
+ *
+ * Вход: ops — [{ action, new_row: { name, price, qty, sum, date_start } }]
+ * Выход: HTML-строка
+ */
+function renderCompareOpsTable(ops) {
+ var html = 'Действие Услуга Цена Кол-во Сумма Дата ';
+ ops.forEach(function(op) {
+ var action = op.action;
+ // Цвет строки зависит от действия: ADD=зелёный, DELETE=красный, UPDATE=жёлтый
+ var cls = action === 'ADD' ? 'diff-added' : action === 'DELETE' ? 'diff-deleted' : action === 'UPDATE' ? 'diff-changed' : '';
+ var nr = op.new_row || {};
+ html += '' + action + ' ' + (nr.name||'') + ' ' + (nr.price!=null?nr.price:'') + ' ' + (nr.qty!=null?nr.qty:'') + ' ' + (nr.sum!=null?nr.sum:'') + ' ' + (nr.date_start||'') + ' ';
+ });
+ html += '
';
+ return html;
+}
+
+/**
+ * renderCompareSectionBody(sec) — Чистая функция: тело секции (Фаза 3).
+ *
+ * Рендерит summary (статистику) + таблицу ops.
+ *
+ * Вход: sec — { summary: { added, updated, deleted, unresolved }, ops }
+ * Выход: HTML-строка
+ */
+function renderCompareSectionBody(sec) {
+ if (!sec || sec.status !== 'applied' || !sec.ops || !sec.ops.length) return '';
+ var s = sec.summary || {};
+ var html = '';
+ html += '+' + (s.added||0) + ' ~' + (s.updated||0) + ' -' + (s.deleted||0);
+ if (s.unresolved) html += ' ?' + s.unresolved;
+ html += '
';
+ html += renderCompareOpsTable(sec.ops);
+ return html;
+}
+
+/**
+ * startCompareSSE(url, container, statusEl, callbacks) — Жизненный цикл SSE (Фаза 3).
+ *
+ * УНИФИЦИРОВАННЫЙ обработчик SSE для ОБОИХ режимов сравнения.
+ * Раньше ~80 строк дублировалось в runCompareForGroup и llmBtn.
+ *
+ * Параметры:
+ * url — URL для EventSource (/process-v2?contract_id=...)
+ * container — DOM-элемент, куда добавлять секции (cmpBody или compareBody)
+ * statusEl — DOM-элемент для отображения таймера/статуса
+ * callbacks — { onDone(d, sections), onError(d), onConnectionError() }
+ * Все колбэки опциональны.
+ * Таймер и EventSource УЖЕ остановлены к моменту вызова колбэков.
+ *
+ * Возвращает: { es, timer, sections } — для сохранения в state._activeCompare
+ */
+function startCompareSSE(url, container, statusEl, callbacks) {
+ callbacks = callbacks || {};
+
+ // Таймер: обновляет statusEl каждые 200мс
+ var start = Date.now();
+ var timer = setInterval(function() {
+ statusEl.textContent = '⏳ ' + ((Date.now() - start) / 1000).toFixed(1) + 'с';
+ }, 200);
+
+ var es = new EventSource(url);
+ var sections = {}; // supplement_id → { filename, status, ops_count, mode, time_s, summary, ops, error, _el }
+
+ es.onmessage = function(e) {
+ var d = JSON.parse(e.data);
+
+ // ── extract_start: создать DOM-секцию ──
+ if (d.type === 'extract_start') {
+ applyCompareEvent(sections, d);
+ var sec = sections[d.supplement_id];
+ var secEl = document.createElement('div');
+ secEl.style.cssText = 'margin-top:8px;border:1px solid var(--brand-gray);border-radius:6px;overflow:hidden;';
+ secEl.innerHTML =
+ '' +
+ '
';
+ container.appendChild(secEl);
+ sec._el = secEl; // ссылка на DOM для последующих обновлений
+ }
+
+ // ── llm_done: обновить заголовок секции ──
+ else if (d.type === 'llm_done') {
+ applyCompareEvent(sections, d);
+ var sec = sections[d.supplement_id];
+ if (sec && sec._el) {
+ sec._el.querySelector('.diff-section-header').innerHTML = renderCompareSectionHeader(sec);
+ sec._el.querySelector('.diff-section-header').style.color = 'var(--green)';
+ }
+ }
+
+ // ── applied: заполнить тело секции таблицей ops ──
+ else if (d.type === 'applied') {
+ applyCompareEvent(sections, d);
+ var sec = sections[d.supplement_id];
+ if (sec && sec._el && sec.ops && sec.ops.length > 0) {
+ var body = sec._el.querySelector('.diff-section-body');
+ body.innerHTML = renderCompareSectionBody(sec);
+ body.style.display = 'block';
+ }
+ }
+
+ // ── extract_error / apply_error: показать ошибку ──
+ else if (d.type === 'extract_error' || d.type === 'apply_error') {
+ applyCompareEvent(sections, d);
+ container.innerHTML += '✗ ' + d.filename + ': ' + d.error + '
';
+ }
+
+ // ── done: завершение ──
+ else if (d.type === 'done') {
+ clearInterval(timer);
+ es.close();
+ statusEl.textContent = '✓ ' + d.total_time_s + 'с';
+ statusEl.style.color = 'var(--green)';
+ if (callbacks.onDone) callbacks.onDone(d, sections);
+ }
+
+ // ── error: фатальная ошибка ──
+ else if (d.type === 'error') {
+ clearInterval(timer);
+ es.close();
+ if (callbacks.onError) callbacks.onError(d);
+ }
+ };
+
+ // ── Сетевая ошибка ──
+ es.onerror = function() {
+ clearInterval(timer);
+ es.close();
+ if (callbacks.onConnectionError) callbacks.onConnectionError();
+ };
+
+ return { es: es, timer: timer, sections: sections };
+}
diff --git a/site/static/files.js b/site/static/files.js
new file mode 100644
index 0000000..a025050
--- /dev/null
+++ b/site/static/files.js
@@ -0,0 +1,519 @@
+/**
+ * files.js — Модуль работы с файлами (Фаза 1, decoupling-final-plan.md).
+ *
+ * ВЫНЕСЕНО из app.js:
+ * - statusToHTML(st) — чистая: структура → HTML
+ * - renderFiles(state) — рендер таблицы файлов
+ * - syncDB() — синхронизация БД с fileQueue
+ * - toggleClassifyDetail(i) — раскрыть результат классификации
+ * - uploadFile(file, cb) — XHR-загрузка одного файла (.doc/.docx/.pdf)
+ * - refreshSupps() — обновить supplement_id для файлов
+ *
+ * ЗАВИСИМОСТИ (глобальные, загружаются раньше):
+ * state.js → state (центральное состояние)
+ * app_utils.js → escHtml, formatSize, formatDate
+ * app.js → render(), stepDone, stepActive, resetStepper, showClassifyBtn
+ *
+ * ЗАГРУЖАЕТСЯ: после app_utils.js, перед app.js
+ */
+
+/**
+ * statusToHTML(status) — Чистая функция: структура → HTML (Фаза 1).
+ *
+ * Вход: { kind, pct?, text?, count?, elapsed? } — ни одного HTML-тега.
+ * kind = 'uploading' | 'uploaded' | 'unzipping' | 'parsing' | 'parsed' | 'error' | ''
+ * Выход: безопасная HTML-строка (статусы не содержат пользовательских данных).
+ *
+ * ПАТТЕРН (decoupling-final-plan.md): отделяем данные от представления.
+ * state хранит чистые данные, statusToHTML — чистая функция рендеринга.
+ */
+function statusToHTML(st) {
+ if (!st || !st.kind) return '';
+ switch (st.kind) {
+ case 'uploading': return '↑ ' + (st.pct || 0) + '%';
+ case 'uploaded': return '✓ ';
+ case 'unzipping': return '⏳ распаковка...';
+ case 'parsing': return '⏳ парсинг...';
+ case 'parsed': return '✓ ' + (st.count || 0) + ' эл.' + (st.elapsed ? ' (' + st.elapsed + 'с)' : '') + ' ';
+ case 'error': return '✗ ' + (st.text || 'Неизвестная ошибка') + ' ';
+ default: return '';
+ }
+}
+
+/**
+ * renderFiles(state) — Рендер таблицы файлов (бывший renderTable, Фаза 1).
+ *
+ * Читает state.files, генерирует HTML для #fileTable.
+ * Использует statusToHTML() для рендеринга статусов (чистые данные → HTML).
+ * Вызывает lucide.createIcons() для иконок.
+ *
+ * Вход: state (весь объект состояния, но читает только state.files).
+ * Выход: мутирует DOM (fileTable.innerHTML).
+ */
+function renderFiles(state) {
+ if (state.files.length === 0) {
+ fileTable.innerHTML = 'Нет файлов — выберите .docx / .pdf ';
+ lucide.createIcons();
+ return;
+ }
+
+ // Сгруппировать файлы по zip_source
+ var groups = []; // [{zip: "name.zip"|null, files: [f, ...]}]
+ var seen = {};
+ for (var i = 0; i < state.files.length; i++) {
+ var f = state.files[i];
+ var zip = f.zip_source || null;
+ var key = zip || '__naked__';
+ if (!seen[key]) {
+ seen[key] = { zip: zip, files: [] };
+ groups.push(seen[key]);
+ }
+ // Сохраняем оригинальный индекс для id строк
+ f._idx = i;
+ seen[key].files.push(f);
+ }
+
+ // Рендер: заголовок ZIP → файлы с отступом
+ var rows = [];
+ for (var gi = 0; gi < groups.length; gi++) {
+ var g = groups[gi];
+
+ if (g.zip) {
+ // Заголовок-секция ZIP
+ rows.push(
+ ''
+ );
+ }
+
+ // Файлы внутри группы (с отступом если из ZIP)
+ for (var fi = 0; fi < g.files.length; fi++) {
+ var f = g.files[fi];
+ var i = f._idx;
+ var indent = g.zip ? 'padding-left:24px;' : '';
+
+ rows.push(
+ '' +
+ '' +
+ (f.doc_id ? '▸ ' : '') +
+ escHtml(f.name) + ' ' +
+ '' + formatDate(f.lastModified) + ' ' +
+ '' + formatSize(f.size) + ' ' +
+ '' + statusToHTML(f.status) + ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ 'Загрузка... '
+ );
+ }
+ }
+
+ fileTable.innerHTML = rows.join('');
+ lucide.createIcons();
+}
+
+/**
+ * syncDB() — Синхронизация БД с текущим состоянием файлов.
+ *
+ * Отправляет на бэкенд список doc_id, которые должны остаться в БД.
+ * Всё, чего нет в state.files, будет удалено из БД (cascade).
+ * Вызывается после каждого изменения состава файлов (removeFile, upload).
+ */
+async function syncDB() {
+ var keepIds = state.files.map(function(f) { return f.doc_id; }).filter(Boolean);
+ try {
+ await fetch(VM_API + '/api/sync', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({keep_ids: keepIds})
+ });
+ } catch(e) { /* сервер недоступен — не критично */ }
+}
+
+/**
+ * toggleClassifyDetail(i) — Раскрыть/скрыть результат классификации под строкой файла.
+ *
+ * Вызывается по клику на имя файла в таблице.
+ * Загружает данные с бэкенда (/api/documents/:id) и показывает:
+ * - Тип, номер, родительский номер, дату, контрагента
+ * - Текст отправленный в LLM (📤)
+ * - Сырой ответ LLM (📥)
+ * - Распарсенные элементы JSON (📄)
+ */
+window.toggleClassifyDetail = async function(i) {
+ var detailRow = document.getElementById('detail_' + i);
+ var expandIcon = document.getElementById('expand_' + i);
+ if (!detailRow) return;
+
+ if (detailRow.style.display !== 'none') {
+ detailRow.style.display = 'none';
+ if (expandIcon) expandIcon.textContent = '▸';
+ return;
+ }
+
+ var f = state.files[i];
+ if (!f || !f.doc_id) return;
+
+ detailRow.style.display = '';
+ if (expandIcon) expandIcon.textContent = '▾';
+
+ try {
+ var resp = await fetch(VM_API + '/api/documents/' + f.doc_id);
+ var qData = await resp.json();
+ if (!qData.ok) { detailRow.cells[0].innerHTML = 'Ошибка загрузки '; return; }
+
+ var html = '';
+ if (qData.classify_status === 'classified') {
+ html += '🏷️ Результат классификации
';
+ html += '';
+ if (qData.doc_type) html += 'Тип: ' + escHtml(qData.doc_type) + ' ';
+ if (qData.own_number) html += 'Номер: ' + escHtml(qData.own_number) + ' ';
+ if (qData.parent_number) html += 'Родительский: ' + escHtml(qData.parent_number) + ' ';
+ if (qData.doc_date) html += 'Дата: ' + escHtml(qData.doc_date) + ' ';
+ if (qData.counterparty) html += 'Контрагент: ' + escHtml(qData.counterparty) + ' ';
+ html += '
';
+ if (qData.classify_input) {
+ html += '📤 Текст отправленный в LLM ';
+ html += '' + escHtml(qData.classify_input) + ' ';
+ html += ' ';
+ }
+ if (qData.classify_raw) {
+ html += '📥 Сырой ответ LLM ';
+ html += '' + escHtml(qData.classify_raw) + ' ';
+ html += ' ';
+ }
+ if (qData.elements_json) {
+ var ej = qData.elements_json;
+ if (typeof ej === 'object' && ej.Value) ej = ej.Value;
+ var ejStr = typeof ej === 'string' ? ej : JSON.stringify(ej, null, 2);
+ html += '📄 Распарсенные элементы (JSON) ';
+ html += '' + escHtml(ejStr.substring(0, 5000)) + (ejStr.length > 5000 ? '\n... (обрезано)' : '') + ' ';
+ html += ' ';
+ }
+ } else if (qData.classify_status === 'failed') {
+ html += '✗ Ошибка классификации: ' + escHtml(qData.error_message || '?') + ' ';
+ } else {
+ html += 'Ещё не классифицирован ';
+ }
+ detailRow.cells[0].innerHTML = html;
+ } catch(e) {
+ detailRow.cells[0].innerHTML = 'Ошибка: ' + escHtml(e.message) + ' ';
+ }
+};
+
+/**
+ * uploadFile(file, onProgress) — XHR-загрузка одного файла на бэкенд.
+ *
+ * Особенности:
+ * - .doc (не .docx!) конвертируется через CONVERT_URL перед загрузкой
+ * - onProgress(pct) — callback с процентом загрузки (0-100)
+ * - Возвращает Promise<ответ API> с полями doc_id, contract_id, parsed
+ * - Таймаут 180с (большие PDF)
+ */
+function uploadFile(file, onProgress, zipSource) {
+ return new Promise(function(resolve, reject) {
+ // .doc → конвертация в docx (старый формат Word)
+ var isDoc = file.name.toLowerCase().endsWith('.doc') && !file.name.toLowerCase().endsWith('.docx');
+ var uploadFile = file;
+ var uploadName = file.name;
+
+ function doUpload() {
+ var xhr = new XMLHttpRequest();
+ var fd = new FormData();
+ fd.append('files', uploadFile, uploadName);
+ if (state.contractId) fd.append('contract_id', state.contractId);
+ fd.append('batch_id', state.batchId);
+ if (zipSource) fd.append('zip_source', zipSource);
+ xhr.open('POST', UPLOAD_URL);
+ xhr.upload.onprogress = function(e) {
+ if (e.lengthComputable && onProgress) onProgress(Math.round(e.loaded / e.total * 100));
+ };
+ xhr.onload = function() {
+ try {
+ var r = JSON.parse(xhr.responseText);
+ if (r.ok) resolve(r);
+ else reject(new Error(r.error || 'Неизвестная ошибка'));
+ } catch(e) { reject(new Error('Некорректный ответ')); }
+ };
+ xhr.onerror = function() { reject(new Error('Сеть')); };
+ xhr.ontimeout = function() { reject(new Error('Таймаут')); };
+ xhr.timeout = 180000;
+ xhr.send(fd);
+ }
+
+ if (isDoc) {
+ var xhr = new XMLHttpRequest();
+ xhr.open('POST', CONVERT_URL);
+ xhr.responseType = 'blob';
+ xhr.onload = function() {
+ if (xhr.status === 200 && xhr.response.size > 100) {
+ uploadFile = xhr.response;
+ uploadName = file.name.replace(/\.doc$/i, '.docx');
+ doUpload();
+ } else {
+ reject(new Error('Конвертация .doc'));
+ }
+ };
+ xhr.onerror = function() { reject(new Error('Конвертер')); };
+ xhr.send(file);
+ } else {
+ doUpload();
+ }
+ });
+}
+
+/**
+ * refreshSupps() — Обновить supplement_id/type для файлов в state.files.
+ *
+ * Запрашивает с бэкенда список supplement-записей для текущего contractId,
+ * сопоставляет их с файлами по doc_id → document_id и проставляет supp_id, supp_type.
+ * Вызывается после каждого upload и после apply-groups.
+ */
+async function refreshSupps() {
+ if (!state.contractId) { console.log('refreshSupps: no state.contractId'); return; }
+ try {
+ var resp = await fetch(VM_API + '/api/supplements?contract_id=' + state.contractId);
+ var data = await resp.json();
+ console.log('refreshSupps: data=', data);
+ if (data.ok && data.supplements) {
+ data.supplements.forEach(function(supp) {
+ console.log('refreshSupps: supp=', supp);
+ for (var i = 0; i < state.files.length; i++) {
+ if (state.files[i].doc_id === supp.document_id) {
+ state.files[i].supp_id = supp.id;
+ state.files[i].supp_type = supp.type;
+ }
+ }
+ });
+ render(state);
+ }
+ } catch(e) { console.log('refreshSupps error:', e); }
+}
+
+/**
+ * reconcileSelection(files, newFiles) — Чистая функция (Фаза 1).
+ *
+ * Согласование состава: оставляет в массиве files только те,
+ * имена которых есть в newFiles. Остальные удаляются.
+ *
+ * Вход: files — текущий state.files (массив)
+ * newFiles — FileList от (массив File-объектов)
+ * Выход: новый массив (НЕ мутирует входной)
+ *
+ * Вызывается из onFilesSelected() ПЕРЕД обработкой каждого файла.
+ */
+function reconcileSelection(files, newFiles) {
+ var newNames = new Set(newFiles.map(function(f) { return f.name; }));
+ return files.filter(function(f) {
+ return newNames.has(f.name);
+ });
+}
+
+/**
+ * applyParseResult(entry, parsed, elapsed) — Чистая функция (Фаза 1).
+ *
+ * Применяет результат парсинга к файлу. Унифицирует две ветки (ZIP и обычную),
+ * которые раньше дублировали этот код.
+ *
+ * Вход: entry — элемент state.files (мутируется)
+ * parsed — { status, element_count, error } от бэкенда
+ * elapsed — время парсинга в секундах (только для обычных файлов)
+ * Выход: нет (мутирует entry на месте)
+ */
+function applyParseResult(entry, parsed, elapsed) {
+ if (parsed && parsed.status === 'parsed') {
+ entry.parsed = true;
+ entry.parseInfo = parsed;
+ entry.status = { kind: 'parsed', count: parsed.element_count };
+ if (elapsed) entry.status.elapsed = elapsed;
+ } else if (parsed && parsed.status === 'error') {
+ entry.parseInfo = parsed;
+ entry.status = { kind: 'error', text: parsed.error || 'ошибка парсинга' };
+ } else {
+ entry.status = { kind: 'error', text: 'Неизвестная ошибка' };
+ }
+}
+
+/**
+ * addZipFile(file) — Async-action: обработка ZIP-файла (Фаза 1, упрощена).
+ *
+ * 1. Отправляет ZIP на бэкенд (только распаковка, без БД/парсинга)
+ * 2. Для каждого файла из архива: base64 → Blob → File → addRegularFile()
+ *
+ * addRegularFile делает ВСЁ: upload, parse, дубликаты, статусы, render.
+ * Никакого дублирования логики — единый путь для обычных и ZIP-файлов.
+ */
+async function addZipFile(file) {
+ var zipEntry = { name: file.name, lastModified: file.lastModified, size: file.size, status: { kind: 'unzipping' } };
+ state.files.push(zipEntry);
+ render(state);
+
+ try {
+ // Шаг 1: распаковать ZIP на бэкенде
+ var zipResp = await new Promise(function(resolve, reject) {
+ var xhr = new XMLHttpRequest();
+ xhr.open('POST', UNZIP_URL);
+ xhr.responseType = 'json';
+ xhr.onload = function() { resolve(xhr.response); };
+ xhr.onerror = function() { reject(new Error('Сеть')); };
+ xhr.ontimeout = function() { reject(new Error('Таймаут')); };
+ xhr.timeout = 60000;
+ var fd = new FormData();
+ fd.append('files', file);
+ xhr.send(fd);
+ });
+ if (!zipResp.ok || !zipResp.files) throw new Error('unzip failed');
+
+ // Шаг 2: обработать каждый файл из архива
+ // Временная строка ZIP остаётся в таблице — обновляем её статус
+ var total = zipResp.files.length;
+ for (var zi = 0; zi < total; zi++) {
+ var zf = zipResp.files[zi];
+ zipEntry.status = { kind: 'unzipping' }; // обновляем статус
+ render(state);
+
+ if (zf.error) continue;
+
+ // base64 → File → addRegularFile (единый путь)
+ var byteStr = atob(zf.data_b64);
+ var bytes = new Uint8Array(byteStr.length);
+ for (var b = 0; b < byteStr.length; b++) bytes[b] = byteStr.charCodeAt(b);
+ var mime = {docx:'application/vnd.openxmlformats-officedocument.wordprocessingml.document',doc:'application/msword',pdf:'application/pdf'}[zf.ext] || 'application/octet-stream';
+ var extractedFile = new File([bytes], zf.filename, { type: mime, lastModified: Date.now() });
+
+ await addRegularFile(extractedFile, file.name);
+ }
+
+ // Шаг 3: убрать временную строку ZIP (только после успешной обработки всех файлов)
+ var zipPos = state.files.indexOf(zipEntry);
+ if (zipPos >= 0) state.files.splice(zipPos, 1);
+ render(state);
+
+ } catch(ze) {
+ // Ошибка — показать на строке ZIP (zipEntry всё ещё в state.files)
+ zipEntry.status = { kind: 'error', text: 'ZIP: ' + ze.message };
+ render(state);
+ }
+}
+
+/**
+ * addRegularFile(file) — Async-action: обработка обычного файла (Фаза 1).
+ *
+ * 1. Добавляет/заменяет файл в state.files
+ * 2. Загружает через XHR (uploadFile) с индикатором прогресса
+ * 3. После загрузки: проставляет doc_id, contractId
+ * 4. applyParseResult — применяет результат парсинга
+ *
+ * Мутирует state.files, вызывает render(state) после каждого изменения.
+ */
+async function addRegularFile(file, zipSource) {
+ // Создать запись с начальным статусом
+ var entry = { name: file.name, lastModified: file.lastModified, size: file.size, file: file, status: { kind: 'uploading', pct: 0 }, zip_source: zipSource || null };
+
+ // ДЕДУПЛИКАЦИЯ: ключ = (zip_source, name), чтобы одноимённые файлы из разных ZIP не затирались
+ var dupKey = (zipSource || '') + '/' + file.name;
+ var existingIdx = -1;
+ for (var j = 0; j < state.files.length; j++) {
+ var ejKey = (state.files[j].zip_source || '') + '/' + state.files[j].name;
+ if (ejKey === dupKey) { existingIdx = j; break; }
+ }
+ var rowIdx;
+ if (existingIdx >= 0) {
+ // Файл с таким именем уже есть — спросить пользователя
+ if (!confirm('Файл «' + file.name + '» уже есть в списке.\n\nOK — перезаписать\nОтмена — пропустить')) {
+ return; // пользователь выбрал «пропустить»
+ }
+ state.files[existingIdx] = entry;
+ rowIdx = existingIdx;
+ } else {
+ state.files.push(entry);
+ rowIdx = state.files.length - 1;
+ }
+ render(state);
+
+ try {
+ // XHR-загрузка с прогрессом
+ var resp = await uploadFile(file, function(pct) {
+ state.files[rowIdx].status = { kind: 'uploading', pct: pct };
+ render(state);
+ }, zipSource);
+ // Бэкенд возвращает doc_id и contract_id (нижний регистр — Python keys)
+ if (resp && resp.contract_id) state.contractId = resp.contract_id;
+ state.files[rowIdx].doc_id = resp.doc_id;
+ state.files[rowIdx].status = { kind: 'uploaded' };
+ state.files[rowIdx].uploaded = true;
+
+ // Авто-парсинг: бэкенд парсит всё (PDF + DOCX + DOC)
+ var t0 = Date.now();
+ var pr = resp.parsed;
+ var elapsed = pr && pr.status === 'parsed' ? ((Date.now() - t0) / 1000).toFixed(1) : null;
+ applyParseResult(state.files[rowIdx], pr, elapsed);
+ } catch(err) {
+ state.files[rowIdx].status = { kind: 'error', text: err.message };
+ }
+ render(state);
+}
+
+/**
+ * finalizeUpload() — Завершение цикла загрузки (Фаза 1).
+ *
+ * Вызывается ПОСЛЕ обработки всех файлов в onFilesSelected().
+ * 1. Обновляет supplement_id/type через refreshSupps()
+ * 2. Отмечает шаг «Загрузка» как готовый
+ * 3. Активирует шаг «Классификация»
+ * 4. Синхронизирует БД
+ * 5. Показывает кнопки (LLM, классификация)
+ */
+async function finalizeUpload() {
+ await refreshSupps();
+ stepDone('stepUpload');
+ stepActive('stepClassify');
+ syncDB();
+
+ fileInput.value = '';
+ fileInput.disabled = false;
+
+ var llmBtn = document.getElementById('llmBtn');
+ llmBtn.style.display = 'flex';
+ llmBtn.disabled = false;
+ showClassifyBtn();
+ lucide.createIcons();
+}
+
+/**
+ * onFilesSelected(newFiles) — Оркестратор загрузки (Фаза 1).
+ *
+ * ПАТТЕРН:
+ * 1. Для каждого файла: addZipFile (ZIP) или addRegularFile (обычный)
+ * 2. finalizeUpload — завершить цикл
+ *
+ * НЕ удаляет существующие файлы — только добавляет новые.
+ * Дубликаты обрабатываются через confirm() в addRegularFile.
+ * Удаление — только вручную (кнопка ✕).
+ *
+ * Вызывается из fileInput.addEventListener('change', ...).
+ */
+async function onFilesSelected(newFiles) {
+ if (newFiles.length === 0) return;
+
+ fileInput.disabled = true;
+
+ // Сбросить прогресс пайплайна при добавлении новых файлов
+ resetStepper('stepUpload');
+
+ // Обработать каждый файл
+ for (var i = 0; i < newFiles.length; i++) {
+ var f = newFiles[i];
+ if (f.name.toLowerCase().endsWith('.zip')) {
+ await addZipFile(f);
+ } else {
+ await addRegularFile(f);
+ }
+ }
+
+ // Завершить цикл
+ await finalizeUpload();
+}
diff --git a/site/static/groups.js b/site/static/groups.js
new file mode 100644
index 0000000..bc164ab
--- /dev/null
+++ b/site/static/groups.js
@@ -0,0 +1,223 @@
+/**
+ * groups.js — Модуль групп и карточек договоров (Фаза 2, decoupling-final-plan.md).
+ *
+ * ВЫНЕСЕНО из app.js:
+ * - loadGroupsAction() — fetch групп, сохранить в state.groups, renderGroups
+ * - renderGroups(state) — пересобрать ВСЕ карточки групп в diffBody
+ * - renderGroupCard(g, gi) — чистая HTML-функция: карточка необработанной группы
+ * - renderGroupCardDone(g, gi)— чистая HTML-функция: карточка обработанной группы
+ *
+ * ПАТТЕРН (decoupling-final-plan.md):
+ * renderGroups пересобирает ВСЕ карточки целиком из state.groups.
+ * Готовность группы: group.compare.status === 'done' (вместо markGroupDone).
+ * Никакого window._groupsData — всё в state.groups.
+ *
+ * ЗАВИСИМОСТИ:
+ * state.js → state (центральное состояние)
+ * app_utils.js → escHtml
+ */
+
+/**
+ * loadGroupsAction() — Загрузить группы с бэкенда (Фаза 2).
+ *
+ * 1. fetch /api/groups?batch=...
+ * 2. Сохранить в state.groups (бывший window._groupsData)
+ * 3. Вызвать renderGroups(state) — пересобрать карточки
+ *
+ * Вызывается из runClassify() после успешной классификации.
+ */
+async function loadGroupsAction() {
+ var resp = await fetch(VM_API + '/api/groups?batch=' + state.batchId);
+ var data = await resp.json();
+ if (!data.ok || !data.groups) return;
+
+ // Сохраняем в центральное состояние (вместо window._groupsData)
+ state.groups = data.groups;
+
+ // Пересобрать ВСЕ карточки
+ renderGroups(state);
+
+ stepDone('stepGroups');
+}
+
+/**
+ * renderGroups(state) — Пересобрать ВСЕ карточки групп (Фаза 2).
+ *
+ * ПЕРЕСОБИРАЕТ весь diffBody.innerHTML заново из state.groups.
+ * Никакого инкрементального обновления — всегда полная перестройка.
+ *
+ * Для каждой группы:
+ * - Необработанная (group.compare.status !== 'done') → renderGroupCard()
+ * - Обработанная (group.compare.status === 'done') → renderGroupCardDone()
+ * - Нераспознанная (contract_number === '__unresolved__') → отдельный рендер
+ */
+function renderGroups(state) {
+ var groups = state.groups;
+ if (!groups) return;
+
+ var diffBody = document.getElementById('diffBody');
+ var diffCard = document.getElementById('diffCard');
+ diffCard.style.display = 'block';
+
+ var realGroups = groups.filter(function(g) { return g.contract_number !== '__unresolved__'; });
+ var unresolvedGroup = groups.find(function(g) { return g.contract_number === '__unresolved__'; });
+
+ // Заголовок: количество групп + нераспознанные
+ var html = '📋 Найдено групп: ' + realGroups.length +
+ (unresolvedGroup ? ' | ⚠ ' + unresolvedGroup.documents.length + ' файлов не распознано' : '') +
+ '
';
+
+ // Нераспознанная группа — всегда первая
+ if (unresolvedGroup) {
+ html += renderUnresolvedCard(unresolvedGroup);
+ }
+
+ // Карточки обычных групп
+ groups.forEach(function(g, gi) {
+ if (g.contract_number === '__unresolved__') return;
+ if (g.compare && g.compare.status === 'done') {
+ html += renderGroupCardDone(g, gi);
+ } else {
+ html += renderGroupCard(g, gi);
+ }
+ });
+
+ diffBody.innerHTML = html;
+
+ // Один обработчик на все кнопки сравнения (event delegation не используется —
+ // кнопки пересоздаются при каждом renderGroups)
+ diffBody.querySelectorAll('.cmp-btn').forEach(function(btn) {
+ btn.addEventListener('click', function() {
+ var gi = parseInt(this.getAttribute('data-gi'));
+ runCompareForGroup(gi, this);
+ });
+ });
+
+ // Обработчики сворачивания для готовых карточек
+ diffBody.querySelectorAll('.cmp-header').forEach(function(header) {
+ var gi = parseInt(header.getAttribute('data-gi'));
+ if (isNaN(gi)) return;
+ header.addEventListener('click', function() {
+ var body = document.getElementById('cmpBody_' + gi);
+ var arrow = document.getElementById('cmpArrow_' + gi);
+ if (body) {
+ if (body.style.display === 'none') {
+ body.style.display = 'block';
+ if (arrow) arrow.textContent = '▾';
+ } else {
+ body.style.display = 'none';
+ if (arrow) arrow.textContent = '▸';
+ }
+ }
+ });
+ });
+
+ lucide.createIcons();
+}
+
+/**
+ * renderUnresolvedCard(group) — Чистая HTML-функция: карточка нераспознанных файлов.
+ *
+ * Вход: group — { contract_number: '__unresolved__', documents: [...] }
+ * Выход: HTML-строка
+ */
+function renderUnresolvedCard(group) {
+ var docsHtml = group.documents.map(function(d) {
+ // Определить причину, почему файл не попал ни в одну группу
+ var reason = '';
+ if (d.parent_number) {
+ reason = ' — нет базового договора №' + escHtml(d.parent_number);
+ } else if (d.classify_status === 'failed') {
+ reason = ' — ошибка классификации: ' + escHtml(d.error_message || '?');
+ } else if (!d.doc_type || d.doc_type === 'other') {
+ reason = ' — тип не определён';
+ } else {
+ reason = ' — нет matching-договора';
+ }
+ return '' +
+ '[' + escHtml(d.doc_type || '?') + '] ' +
+ escHtml(d.filename) +
+ (d.doc_date ? ' ' + escHtml(d.doc_date) + ' ' : '') +
+ '' + reason + ' ' +
+ '
';
+ }).join('');
+
+ return '' +
+ '
❓ Не распознано (' + group.documents.length + ' файлов)
' +
+ '
' + docsHtml + '
' +
+ '
Загрузите недостающие базовые договоры для этих номеров.
' +
+ '
';
+}
+
+/**
+ * renderGroupCard(group, gi) — Чистая HTML-функция: необработанная группа (Фаза 2).
+ *
+ * Показывает: номер договора, контрагента, список документов, кнопку «Сравнить».
+ * Если группа в процессе сравнения (group.compare.status === 'running') — кнопка disabled.
+ *
+ * Вход: group — элемент state.groups
+ * gi — индекс группы (для data-gi атрибута кнопки)
+ * Выход: HTML-строка
+ */
+function renderGroupCard(group, gi) {
+ var isRunning = group.compare && group.compare.status === 'running';
+
+ var html = '' +
+ '' +
+ '
' +
+ group.documents.map(function(d) {
+ var typeLabel = {contract: 'договор', supplement: 'допсоглашение', specification: 'спецификация'}[d.doc_type] || d.doc_type;
+ return '
' +
+ '' + escHtml(typeLabel || '?') + ' ' +
+ escHtml(d.filename) +
+ (d.zip_source ? ' [' + escHtml(d.zip_source) + '] ' : '') +
+ (d.doc_date ? ' ' + escHtml(d.doc_date) + ' ' : '') +
+ '
';
+ }).join('') + '
' +
+ '
▶ Сравнить эту группу ' +
+ '
';
+
+ return html;
+}
+
+/**
+ * renderGroupCardDone(group, gi) — Чистая HTML-функция: обработанная группа (Фаза 2).
+ *
+ * Показывает: ✓ Готово (время), список документов (кликабельные — раскрывают секции),
+ * тело сравнения (cmpBody) — сворачивается по клику на заголовок.
+ *
+ * Вход: group — элемент state.groups (group.compare.status === 'done')
+ * gi — индекс группы
+ * Выход: HTML-строка
+ *
+ * ЗАМЕНЯЕТ удалённую markGroupDone().
+ * Готовность определяется по group.compare.status, а не по наличию кнопки.
+ */
+function renderGroupCardDone(group, gi) {
+ var time = group.compare.totalTime || '?с';
+ var bodyHTML = group.compare.bodyHTML || '';
+
+ var html = '' +
+ '' +
+ '
' +
+ group.documents.map(function(d, di) {
+ var typeLabel = {contract: 'договор', supplement: 'допсоглашение', specification: 'спецификация'}[d.doc_type] || d.doc_type;
+ return '
' +
+ '' + escHtml(typeLabel || '?') + ' ' +
+ escHtml(d.filename) +
+ (d.zip_source ? ' [' + escHtml(d.zip_source) + '] ' : '') +
+ (d.doc_date ? ' ' + escHtml(d.doc_date) + ' ' : '') +
+ '
';
+ }).join('') + '
' +
+ '
' + bodyHTML + '
' +
+ '
';
+
+ return html;
+}
diff --git a/site/static/state.js b/site/static/state.js
new file mode 100644
index 0000000..e8c9509
--- /dev/null
+++ b/site/static/state.js
@@ -0,0 +1,73 @@
+/**
+ * state.js — Центральное состояние приложения.
+ *
+ * ПАТТЕРН (из decoupling-final-plan.md, Фаза 0):
+ * action → мутация state → render(state)
+ *
+ * ПРАВИЛА:
+ * 1. ВСЕ состояния приложения — только здесь, в объекте state.
+ * 2. DOM — проекция state, никто не читает данные из DOM.
+ * Если что-то нужно узнать — смотри в state, а не в element.innerHTML.
+ * 3. Мутировал любое поле state → обязан вызвать render(state).
+ * Никаких прямых манипуляций DOM в обход render().
+ * 4. В любой момент console.log(state) показывает ВСЁ состояние приложения.
+ *
+ * СТРУКТУРА (Фаза 0 — минимальная, будет расширяться в Фазах 1-4):
+ *
+ * state.files — бывший fileQueue, массив загруженных файлов.
+ * Каждый элемент: { name, size, lastModified, file, doc_id,
+ * uploaded, parsed, parseInfo, supp_id, supp_type, status, zip_source }
+ *
+ * state.contractId — бывший contractId, ID контракта в БД.
+ * Приходит с бэкенда после первого успешного upload.
+ * null пока не загружен ни один файл.
+ *
+ * state.batchId — бывший batchId, уникальный ID сессии (UUID).
+ * Используется для привязки всех файлов сессии при классификации.
+ * Генерируется один раз при загрузке страницы.
+ *
+ * state.groups — бывший window._groupsData.
+ * Массив групп после классификации. Каждая группа:
+ * { contract_number, counterparty, documents[], unresolved? }
+ * null если классификация ещё не запускалась.
+ *
+ * state._activeCompare — бывшие _activeCompareES и _activeCompareTimer.
+ * Управляет ОДНИМ активным SSE-сравнением.
+ * Правило: только одно сравнение одновременно — все кнопки блокируются.
+ * { es: EventSource|null, timer: intervalId|null }
+ *
+ * state.ui — UI-состояние, не связанное с данными.
+ * ui.steps: { upload, classify, groups, compare }
+ * Каждый шаг: '○' (не начат) | '⏳' (в процессе) | '✓' (завершён)
+ * В Фазе 4 будет renderStepper(state).
+ */
+var state = {
+ // ── Файлы (бывший fileQueue) ──────────────────────────────
+ files: [],
+
+ // ── Контракт (бывший contractId) ──────────────────────────
+ contractId: null,
+
+ // ── Сессия (бывший batchId) ───────────────────────────────
+ // crypto.randomUUID() — браузерный API, поддержка 92%+ (Chrome 92+, FF 95+, Safari 15.4+)
+ batchId: crypto.randomUUID(),
+
+ // ── Группы (бывший window._groupsData) ────────────────────
+ groups: null,
+
+ // ── Активное сравнение ────────────────────────────────────
+ _activeCompare: {
+ es: null, // EventSource (SSE-соединение с /process-v2)
+ timer: null // setInterval ID для индикатора времени
+ },
+
+ // ── UI-состояние ──────────────────────────────────────────
+ ui: {
+ steps: {
+ upload: '○',
+ classify: '○',
+ groups: '○',
+ compare: '○'
+ }
+ }
+};
diff --git a/site/templates/index.html b/site/templates/index.html
new file mode 100644
index 0000000..97efdcc
--- /dev/null
+++ b/site/templates/index.html
@@ -0,0 +1,226 @@
+
+
+
+
+
+ Сверка договоров — LLM
+
+
+
+
+
+
+
+
Сверка договоров — LLM AI-driven Event Sourcing v1.0.179-flask
+
+ ○ Загрузка →
+ ○ Классификация →
+ ○ Группировка →
+ ○ Сравнение
+
+
ℹ️ О сервисе
+
+
+
+
+
+
+
+
Порядок определяется автоматически при классификации
+
⚠ При совпадении имён — запрос на перезапись (OK / Отмена). Файлы из ZIP-архивов загружаются через тот же поток.
+
+
+
+
+ Имя Изменён Размер Парсинг Текст ✕
+
+
+
+
+
+
+ Общее сравнение
+
+
+
+ ⚙ Промпты LLM
+
+
+ 🔄 Сравнение
+ 🏷️ Классификация
+
+
+ для базового договора
+ для допсоглашений
+ автоопределение
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 🔄 Сравнение
+ 🏷️ Классификация
+
+
+ Извлечение — для базового договора | Сравнение — для допсоглашений | Классификация — автоопределение типа/даты/контрагента
+
+
+
+
+
+ Сохранить
+
+
+
История версий
+
+
+
+
+
+
+
+
+
+
Что это
+
Автоматический разбор и сравнение договоров с помощью ИИ (LLM). Загружаете договоры, допсоглашения и спецификации вперемешку — система сама разбирает что есть что, группирует по контрактам и находит все изменения между версиями.
+
Как пользоваться
+
1. Загрузка — выберите файлы (.docx, .doc, .pdf, .zip). Можно загружать всё вперемешку. Порядок не важен — классификация разберётся.
+
2. Классификация — нажмите «Классифицировать». Система через LLM определит тип, номер, дату, контрагента.
+
3. Группы — документы автоматически группируются по договорам.
+
4. Сравнение — для каждой группы нажмите «▶ Сравнить». Результат: ADD/UPDATE/DELETE.
+
5. Промпты — три вида инструкций для ИИ с версионированием.
+
На выходе
+
Структурированная спецификация + полная история изменений с привязкой к документам-источникам.
+
+
+
+
+
+
+
+
+
+
+
+