feat: полный Flask — index.html + JS + чат + промпты (1:1 с Lucee)
This commit is contained in:
+3
-2
@@ -1,2 +1,3 @@
|
|||||||
flask>=3.0
|
flask
|
||||||
gunicorn>=21.0
|
gunicorn
|
||||||
|
httpx
|
||||||
|
|||||||
+98
-3
@@ -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__)
|
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("/")
|
@app.route("/")
|
||||||
def index():
|
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")
|
@app.route("/health")
|
||||||
@@ -14,4 +99,14 @@ def health():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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()
|
||||||
|
|||||||
@@ -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 = '<i data-lucide="tags" style="width:16px;height:16px;"></i> Классифицировать';
|
||||||
|
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 = '<span class="spinner"></span> Классификация... 0с';
|
||||||
|
stepActive('stepClassify');
|
||||||
|
|
||||||
|
var start = Date.now();
|
||||||
|
var timer = setInterval(function() {
|
||||||
|
btn.innerHTML = '<span class="spinner"></span> Классификация... ' + 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 = '<span class="spinner"></span> Классификация... ' + 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 = '<span class="spinner"></span> Применяю...';
|
||||||
|
// Заблокировать ВСЕ кнопки сравнения пока идёт процесс
|
||||||
|
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 = '<span class="spinner"></span> Сравнение...';
|
||||||
|
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 += '<div style="color:var(--destructive);">✗ ' + d.message + '</div>';
|
||||||
|
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 = '<span class="spinner"></span> Сравнение...';
|
||||||
|
|
||||||
|
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 = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово';
|
||||||
|
stepDone('stepCompare');
|
||||||
|
if (d.total_unresolved > 0) {
|
||||||
|
compareBody.innerHTML += '<div style="margin-top:8px;color:#eab308;">⚠ ' + d.total_unresolved + ' неразрешённых строк — <a href=\"/resolve.cfm?contract_id=' + state.contractId + '\">разобрать</a></div>';
|
||||||
|
}
|
||||||
|
document.getElementById('chatCard').style.display = 'block';
|
||||||
|
lucide.createIcons();
|
||||||
|
},
|
||||||
|
onError: function(d) {
|
||||||
|
compareBody.innerHTML += '<div style="color:var(--destructive);margin-top:8px;">✗ ' + d.message + '</div>';
|
||||||
|
btn.innerHTML = '<i data-lucide="scale" style="width:16px;height:16px;"></i> Общее сравнение';
|
||||||
|
btn.disabled = false;
|
||||||
|
},
|
||||||
|
onConnectionError: function() {
|
||||||
|
if (compareBody.innerHTML === '') {
|
||||||
|
compareBody.innerHTML = '<div style="color:var(--destructive);">✗ Ошибка соединения</div>';
|
||||||
|
}
|
||||||
|
btn.innerHTML = '<i data-lucide="scale" style="width:16px;height:16px;"></i> Общее сравнение';
|
||||||
|
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 += '<div style="color:var(--brand-primary);margin-top:4px;"><strong>Вы:</strong> ' + q + '</div>';
|
||||||
|
input.value = '';
|
||||||
|
input.disabled = true;
|
||||||
|
document.getElementById('chatSend').disabled = true;
|
||||||
|
msgs.innerHTML += '<div style="color:var(--muted);margin-top:2px;">⏳ Думаю...</div>';
|
||||||
|
|
||||||
|
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 += '<div style="margin-top:2px;"><strong>Ответ:</strong> ' + (d.OK ? d.ANSWER : (d.ERROR || '—')) + '</div>';
|
||||||
|
input.disabled = false;
|
||||||
|
document.getElementById('chatSend').disabled = false;
|
||||||
|
input.focus();
|
||||||
|
}).catch(function() {
|
||||||
|
msgs.lastChild.remove();
|
||||||
|
msgs.innerHTML += '<div style="color:var(--destructive);margin-top:2px;">Ошибка сети</div>';
|
||||||
|
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 = '<span style="color:var(--muted);">Загрузка...</span>';
|
||||||
|
var modal = document.querySelector('.modal');
|
||||||
|
modal.classList.add('wide');
|
||||||
|
document.getElementById('modalOverlay').classList.add('open');
|
||||||
|
|
||||||
|
var leftHtml = '<span style="color:var(--muted);">Загрузка...</span>';
|
||||||
|
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,'<').replace(/>/g,'>');
|
||||||
|
|
||||||
|
// Левая: сырой текст (textify)
|
||||||
|
leftHtml = '<pre>' + textify + '</pre>';
|
||||||
|
|
||||||
|
// Правая: статистика + textify
|
||||||
|
rightHtml += '<div class="kv"><span class="k">Размер</span><span class="v">' + formatSize(f.size) + '</span></div>';
|
||||||
|
rightHtml += '<div class="kv"><span class="k">Изменён</span><span class="v">' + formatDate(f.lastModified) + '</span></div>';
|
||||||
|
if (f.parseInfo) {
|
||||||
|
var d = f.parseInfo;
|
||||||
|
rightHtml += '<div class="kv"><span class="k">Элементов</span><span class="v">' + (d.element_count||0) + '</span></div>';
|
||||||
|
if (d.error) rightHtml += '<div style="margin-top:8px;color:var(--destructive);">' + d.error + '</div>';
|
||||||
|
}
|
||||||
|
rightHtml += '<div style="margin-top:10px;font-weight:600;font-size:12px;">Все элементы:</div>';
|
||||||
|
rightHtml += '<pre>' + textify + '</pre>';
|
||||||
|
if (f.supp_id) {
|
||||||
|
rightHtml += '<div class="kv"><span class="k">Тип ДС</span><span class="v">' + (f.supp_type || '—') + '</span></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Результаты классификации ──────────────────────────
|
||||||
|
if (qData.classify_status === 'classified' || qData.classify_raw) {
|
||||||
|
rightHtml += '<div style="margin-top:12px;font-weight:600;font-size:12px;border-top:1px solid var(--brand-gray);padding-top:8px;">🏷️ Классификация LLM</div>';
|
||||||
|
if (qData.doc_type) rightHtml += '<div class="kv"><span class="k">Тип</span><span class="v">' + escHtml(qData.doc_type) + '</span></div>';
|
||||||
|
if (qData.own_number) rightHtml += '<div class="kv"><span class="k">Номер</span><span class="v">' + escHtml(qData.own_number) + '</span></div>';
|
||||||
|
if (qData.parent_number) rightHtml += '<div class="kv"><span class="k">Родительский</span><span class="v">' + escHtml(qData.parent_number) + '</span></div>';
|
||||||
|
if (qData.doc_date) rightHtml += '<div class="kv"><span class="k">Дата</span><span class="v">' + escHtml(qData.doc_date) + '</span></div>';
|
||||||
|
if (qData.counterparty) rightHtml += '<div class="kv"><span class="k">Контрагент</span><span class="v">' + escHtml(qData.counterparty) + '</span></div>';
|
||||||
|
if (qData.classify_raw) {
|
||||||
|
var rawId = 'raw_' + docId.replace(/-/g,'');
|
||||||
|
rightHtml += '<details style="margin-top:8px;"><summary style="cursor:pointer;font-size:11px;color:var(--brand-primary);">Сырой ответ LLM</summary>';
|
||||||
|
rightHtml += '<pre style="font-size:10px;max-height:200px;overflow:auto;background:var(--brand-grey-light);padding:6px;border-radius:4px;margin-top:4px;">' + escHtml(qData.classify_raw) + '</pre>';
|
||||||
|
rightHtml += '</details>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
leftHtml = '<span style="color:var(--destructive);">Ошибка</span>';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
leftHtml = '<span style="color:var(--muted);">(ещё не загружен)</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var html = '<div class="text-panes">' +
|
||||||
|
'<div class="text-pane"><div class="pane-title">Сырой текст</div>' + leftHtml + '</div>' +
|
||||||
|
'<div class="text-pane"><div class="pane-title">Распарсено</div>' + rightHtml + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
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 = '<div style="color:var(--muted);">нет версий</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
promptHistory.innerHTML = d.versions.map(function(v) {
|
||||||
|
var isActive = v.is_active ? '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--green);margin-right:4px;" title="активная"></span> ' : '';
|
||||||
|
var style = v.is_active ? 'font-weight:600;' : '';
|
||||||
|
return '<div style="display:flex;align-items:center;padding:4px 0;border-bottom:1px solid var(--brand-gray);font-size:11px;' + style + '">'
|
||||||
|
+ '<span style="flex:1;">' + isActive + escHtml(v.name) + ' <span style="color:var(--muted);">' + fmtDate(v.created_at) + '</span>'
|
||||||
|
+ (v.notes ? ' <span style="color:var(--muted);font-style:italic;">— ' + escHtml(v.notes) + '</span>' : '')
|
||||||
|
+ '</span>'
|
||||||
|
+ (!v.is_active ? '<button class="btn" onclick="activatePrompt(\'' + v.id + '\')" style="font-size:11px;height:22px;padding:0 8px;margin-left:4px;">активировать</button>' : '')
|
||||||
|
+ (!v.is_active ? '<button class="btn" onclick="deletePrompt(\'' + v.id + '\')" style="font-size:11px;height:22px;padding:0 8px;margin-left:2px;color:var(--destructive);">удалить</button>' : '')
|
||||||
|
+ '</div>';
|
||||||
|
}).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();
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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-строка <table>
|
||||||
|
*/
|
||||||
|
function renderCompareOpsTable(ops) {
|
||||||
|
var html = '<div class="table-wrap"><table style="font-size:11px;"><thead><tr><th>Действие</th><th>Услуга</th><th>Цена</th><th>Кол-во</th><th>Сумма</th><th>Дата</th></tr></thead><tbody>';
|
||||||
|
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 += '<tr class="' + cls + '"><td>' + action + '</td><td>' + (nr.name||'') + '</td><td class="num-cell">' + (nr.price!=null?nr.price:'') + '</td><td class="num-cell">' + (nr.qty!=null?nr.qty:'') + '</td><td class="num-cell">' + (nr.sum!=null?nr.sum:'') + '</td><td>' + (nr.date_start||'') + '</td></tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
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 = '<div style="font-size:12px;margin-bottom:6px;">';
|
||||||
|
html += '+' + (s.added||0) + ' ~' + (s.updated||0) + ' -' + (s.deleted||0);
|
||||||
|
if (s.unresolved) html += ' ?' + s.unresolved;
|
||||||
|
html += '</div>';
|
||||||
|
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 =
|
||||||
|
'<div class="diff-section-header" style="padding:8px 12px;background:var(--brand-grey-light);cursor:pointer;font-size:13px;font-weight:600;"' +
|
||||||
|
' onclick="var b=this.nextElementSibling;b.style.display=b.style.display===\'none\'?\'block\':\'none\';">' +
|
||||||
|
renderCompareSectionHeader(sec) + '</div>' +
|
||||||
|
'<div class="diff-section-body" style="display:none;padding:8px 12px;"></div>';
|
||||||
|
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 += '<div style="font-size:12px;color:var(--destructive);">✗ ' + d.filename + ': ' + d.error + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 };
|
||||||
|
}
|
||||||
@@ -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 '<span class="status-ok">✓</span>';
|
||||||
|
case 'unzipping': return '⏳ распаковка...';
|
||||||
|
case 'parsing': return '⏳ парсинг...';
|
||||||
|
case 'parsed': return '<span class="status-ok">✓ ' + (st.count || 0) + ' эл.' + (st.elapsed ? ' (' + st.elapsed + 'с)' : '') + '</span>';
|
||||||
|
case 'error': return '<span class="status-err">✗ ' + (st.text || 'Неизвестная ошибка') + '</span>';
|
||||||
|
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 = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .docx / .pdf</td></tr>';
|
||||||
|
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(
|
||||||
|
'<tr class="zip-header" style="background:var(--brand-grey-light);">' +
|
||||||
|
'<td colspan="7" style="padding:6px 12px;font-size:12px;font-weight:600;">' +
|
||||||
|
'📦 ' + escHtml(g.zip) + ' — ' + g.files.length + ' файл.' +
|
||||||
|
(g.files.length === 1 ? '' : 'ов') +
|
||||||
|
'</td></tr>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Файлы внутри группы (с отступом если из 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(
|
||||||
|
'<tr id="row_' + i + '">' +
|
||||||
|
'<td class="name-cell" style="cursor:pointer;' + indent + '" onclick="toggleClassifyDetail(' + i + ')">' +
|
||||||
|
(f.doc_id ? '<span id="expand_' + i + '" style="font-size:10px;margin-right:4px;">▸</span>' : '') +
|
||||||
|
escHtml(f.name) + '</td>' +
|
||||||
|
'<td style="font-size:12px;color:var(--muted);">' + formatDate(f.lastModified) + '</td>' +
|
||||||
|
'<td class="num-cell" style="font-size:12px;color:var(--muted);">' + formatSize(f.size) + '</td>' +
|
||||||
|
'<td class="status-cell">' + statusToHTML(f.status) + '</td>' +
|
||||||
|
'<td><button class="info-btn' + (f.doc_id ? ' visible' : '') + '" onclick="showText(' + i + ')" title="Текст / Распарсено"><i data-lucide="file-text" style="width:16px;height:16px;"></i></button></td>' +
|
||||||
|
'<td><button class="remove-btn" onclick="removeFile(' + i + ')" title="Удалить"><i data-lucide="trash-2" style="width:16px;height:16px;"></i></button></td>' +
|
||||||
|
'</tr>' +
|
||||||
|
'<tr id="detail_' + i + '" style="display:none;"><td colspan="7" style="padding:4px 12px;font-size:11px;background:var(--brand-grey-light);"><span style="color:var(--muted);">Загрузка...</span></td></tr>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '<span style="color:var(--destructive);">Ошибка загрузки</span>'; return; }
|
||||||
|
|
||||||
|
var html = '';
|
||||||
|
if (qData.classify_status === 'classified') {
|
||||||
|
html += '<div style="font-weight:600;margin-bottom:4px;">🏷️ Результат классификации</div>';
|
||||||
|
html += '<div style="display:flex;gap:16px;flex-wrap:wrap;">';
|
||||||
|
if (qData.doc_type) html += '<span><b>Тип:</b> ' + escHtml(qData.doc_type) + '</span>';
|
||||||
|
if (qData.own_number) html += '<span><b>Номер:</b> ' + escHtml(qData.own_number) + '</span>';
|
||||||
|
if (qData.parent_number) html += '<span><b>Родительский:</b> ' + escHtml(qData.parent_number) + '</span>';
|
||||||
|
if (qData.doc_date) html += '<span><b>Дата:</b> ' + escHtml(qData.doc_date) + '</span>';
|
||||||
|
if (qData.counterparty) html += '<span><b>Контрагент:</b> ' + escHtml(qData.counterparty) + '</span>';
|
||||||
|
html += '</div>';
|
||||||
|
if (qData.classify_input) {
|
||||||
|
html += '<details style="margin-top:4px;"><summary style="cursor:pointer;color:var(--brand-primary);">📤 Текст отправленный в LLM</summary>';
|
||||||
|
html += '<pre style="font-size:10px;max-height:150px;overflow:auto;background:#fff;padding:4px;border-radius:3px;margin-top:2px;">' + escHtml(qData.classify_input) + '</pre>';
|
||||||
|
html += '</details>';
|
||||||
|
}
|
||||||
|
if (qData.classify_raw) {
|
||||||
|
html += '<details style="margin-top:4px;"><summary style="cursor:pointer;color:var(--brand-primary);">📥 Сырой ответ LLM</summary>';
|
||||||
|
html += '<pre style="font-size:10px;max-height:150px;overflow:auto;background:#fff;padding:4px;border-radius:3px;margin-top:2px;">' + escHtml(qData.classify_raw) + '</pre>';
|
||||||
|
html += '</details>';
|
||||||
|
}
|
||||||
|
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 += '<details style="margin-top:4px;"><summary style="cursor:pointer;color:var(--brand-primary);">📄 Распарсенные элементы (JSON)</summary>';
|
||||||
|
html += '<pre style="font-size:10px;max-height:200px;overflow:auto;background:#fff;padding:4px;border-radius:3px;margin-top:2px;">' + escHtml(ejStr.substring(0, 5000)) + (ejStr.length > 5000 ? '\n... (обрезано)' : '') + '</pre>';
|
||||||
|
html += '</details>';
|
||||||
|
}
|
||||||
|
} else if (qData.classify_status === 'failed') {
|
||||||
|
html += '<span style="color:var(--destructive);">✗ Ошибка классификации: ' + escHtml(qData.error_message || '?') + '</span>';
|
||||||
|
} else {
|
||||||
|
html += '<span style="color:var(--muted);">Ещё не классифицирован</span>';
|
||||||
|
}
|
||||||
|
detailRow.cells[0].innerHTML = html;
|
||||||
|
} catch(e) {
|
||||||
|
detailRow.cells[0].innerHTML = '<span style="color:var(--destructive);">Ошибка: ' + escHtml(e.message) + '</span>';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 от <input> (массив 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();
|
||||||
|
}
|
||||||
@@ -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 = '<div style="font-weight:600;margin-bottom:8px;">📋 Найдено групп: ' + realGroups.length +
|
||||||
|
(unresolvedGroup ? ' | ⚠ ' + unresolvedGroup.documents.length + ' файлов не распознано' : '') +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
// Нераспознанная группа — всегда первая
|
||||||
|
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 '<div style="padding:2px 0;">' +
|
||||||
|
'<span style="color:var(--muted);">[' + escHtml(d.doc_type || '?') + ']</span> ' +
|
||||||
|
escHtml(d.filename) +
|
||||||
|
(d.doc_date ? ' <span style="color:var(--muted);">' + escHtml(d.doc_date) + '</span>' : '') +
|
||||||
|
'<span style="color:var(--destructive);font-size:11px;">' + reason + '</span>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return '<div style="border:1px solid var(--destructive);border-radius:6px;padding:10px;margin-bottom:8px;">' +
|
||||||
|
'<div style="font-weight:600;margin-bottom:6px;">❓ Не распознано (' + group.documents.length + ' файлов)</div>' +
|
||||||
|
'<div style="font-size:12px;">' + docsHtml + '</div>' +
|
||||||
|
'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Загрузите недостающие базовые договоры для этих номеров.</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = '<div style="border:1px solid var(--brand-gray);border-radius:6px;padding:10px;margin-bottom:8px;">' +
|
||||||
|
'<div class="cmp-header" style="font-weight:600;margin-bottom:6px;">' +
|
||||||
|
'📄 Договор №' + escHtml(group.contract_number || '?') + ' — ' + escHtml(group.counterparty || 'контрагент не определён') +
|
||||||
|
'</div>' +
|
||||||
|
'<div style="font-size:12px;">' +
|
||||||
|
group.documents.map(function(d) {
|
||||||
|
var typeLabel = {contract: 'договор', supplement: 'допсоглашение', specification: 'спецификация'}[d.doc_type] || d.doc_type;
|
||||||
|
return '<div style="padding:2px 0;">' +
|
||||||
|
'<span style="color:var(--muted);">' + escHtml(typeLabel || '?') + '</span> ' +
|
||||||
|
escHtml(d.filename) +
|
||||||
|
(d.zip_source ? ' <span style="color:var(--brand-gray);font-size:10px;">[' + escHtml(d.zip_source) + ']</span>' : '') +
|
||||||
|
(d.doc_date ? ' <span style="color:var(--muted);">' + escHtml(d.doc_date) + '</span>' : '') +
|
||||||
|
'</div>';
|
||||||
|
}).join('') + '</div>' +
|
||||||
|
'<button class="btn btn-primary cmp-btn" style="margin-top:6px;font-size:12px;height:28px;" data-gi="' + gi + '"' +
|
||||||
|
(isRunning ? ' disabled' : '') + '>▶ Сравнить эту группу</button>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
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 = '<div style="border:1px solid var(--brand-gray);border-radius:6px;padding:10px;margin-bottom:8px;">' +
|
||||||
|
'<div class="cmp-header" style="font-weight:600;margin-bottom:6px;cursor:pointer;" data-gi="' + gi + '">' +
|
||||||
|
'<span class="cmp-arrow" id="cmpArrow_' + gi + '" style="font-size:10px;margin-right:4px;">▾</span>' +
|
||||||
|
'📄 Договор №' + escHtml(group.contract_number || '?') + ' — ' + escHtml(group.counterparty || 'контрагент не определён') +
|
||||||
|
' <span style="font-weight:400;color:var(--green);font-size:11px;">✓ Готово (' + time + ')</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div style="font-size:12px;">' +
|
||||||
|
group.documents.map(function(d, di) {
|
||||||
|
var typeLabel = {contract: 'договор', supplement: 'допсоглашение', specification: 'спецификация'}[d.doc_type] || d.doc_type;
|
||||||
|
return '<div style="padding:2px 0;cursor:pointer;" onclick="var b=document.querySelectorAll(\'#cmpBody_' + gi + ' .diff-section-body\');if(b[' + di + '])b[' + di + '].style.display=b[' + di + '].style.display===\'none\'?\'block\':\'none\';">' +
|
||||||
|
'<span style="color:var(--muted);">' + escHtml(typeLabel || '?') + '</span> ' +
|
||||||
|
escHtml(d.filename) +
|
||||||
|
(d.zip_source ? ' <span style="color:var(--brand-gray);font-size:10px;">[' + escHtml(d.zip_source) + ']</span>' : '') +
|
||||||
|
(d.doc_date ? ' <span style="color:var(--muted);">' + escHtml(d.doc_date) + '</span>' : '') +
|
||||||
|
'</div>';
|
||||||
|
}).join('') + '</div>' +
|
||||||
|
'<div class="cmp-body" id="cmpBody_' + gi + '" style="margin-top:8px;">' + bodyHTML + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
@@ -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: '○'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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/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-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="/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.179-flask</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>
|
||||||
Reference in New Issue
Block a user