Deploy contracts-flask / validate (push) Canceled after 0s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
429 lines
18 KiB
JavaScript
429 lines
18 KiB
JavaScript
/**
|
|
* files.js — Модуль работы с файлами.
|
|
*
|
|
* ⛔⛔⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔⛔⛔
|
|
*
|
|
* FilePicker owns selection/tree rendering; this module connects it to the
|
|
* Contracts batch, upload sink, and document pipeline.
|
|
*
|
|
* Состав и обработка документов синхронизируются с FilePicker в app.js.
|
|
*/
|
|
|
|
var filePicker = null;
|
|
var uploadQueue = Promise.resolve();
|
|
var uploadTasks = new Map();
|
|
var queuedUploads = new Set();
|
|
var pendingUploads = 0;
|
|
var FILE_PICKER_LIMITS = {
|
|
maxEntries: 1000,
|
|
maxTotalBytes: 500 * 1024 * 1024,
|
|
maxEntryBytes: 50 * 1024 * 1024,
|
|
maxDepth: 20,
|
|
};
|
|
|
|
/**
|
|
* renderFiles(state) — обновить отображение дерева и статусов picker.
|
|
*/
|
|
function renderFiles(state) {
|
|
if (filePicker) filePicker.render();
|
|
}
|
|
|
|
/**
|
|
* syncDB() — Синхронизация БД с текущим состоянием файлов.
|
|
*
|
|
* Синхронизирует keep_ids только для текущего batch_id.
|
|
*/
|
|
async function syncDB() {
|
|
var keepIds = Array.from(new Set(state.files.map(function(f) { return f.doc_id; }).filter(Boolean)));
|
|
var response = await fetch(VM_API + '/api/sync', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({batch_id: state.batchId, keep_ids: keepIds})
|
|
});
|
|
var data = await response.json();
|
|
if (!response.ok || !data.ok) {
|
|
throw new Error(data.error || 'Не удалось синхронизировать документы');
|
|
}
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
var file = state.files[i];
|
|
if (!file || !file.doc_id) return;
|
|
document.getElementById('modalTitle').textContent = 'Классификация — ' + file.name;
|
|
document.getElementById('modalBody').innerHTML = '<span style="color:var(--muted);">Загрузка...</span>';
|
|
document.getElementById('modalOverlay').classList.add('open');
|
|
try {
|
|
var detailResponse = await fetch(VM_API + '/api/documents/' + encodeURIComponent(file.doc_id));
|
|
var detailData = await detailResponse.json();
|
|
if (!detailResponse.ok || !detailData.ok) {
|
|
throw new Error(detailData.error || 'Ошибка загрузки документа');
|
|
}
|
|
var detailHtml = '<div style="font-weight:600;margin-bottom:8px;">Результат классификации</div>';
|
|
[['Тип', detailData.doc_type], ['Номер', detailData.own_number],
|
|
['Родительский', detailData.parent_number], ['Дата', detailData.doc_date],
|
|
['Контрагент', detailData.counterparty]].forEach(function(pair) {
|
|
if (pair[1]) detailHtml += '<div class="kv"><span class="k">' + pair[0]
|
|
+ '</span><span class="v">' + escHtml(pair[1]) + '</span></div>';
|
|
});
|
|
if (detailData.classify_input) {
|
|
detailHtml += '<details><summary>Текст, отправленный в LLM</summary><pre>'
|
|
+ escHtml(detailData.classify_input) + '</pre></details>';
|
|
}
|
|
if (detailData.classify_raw) {
|
|
detailHtml += '<details><summary>Сырой ответ LLM</summary><pre>'
|
|
+ escHtml(detailData.classify_raw) + '</pre></details>';
|
|
}
|
|
if (detailData.elements_json) {
|
|
var parsedElements = typeof detailData.elements_json === 'string'
|
|
? detailData.elements_json : JSON.stringify(detailData.elements_json, null, 2);
|
|
detailHtml += '<details><summary>Распарсенные элементы</summary><pre>'
|
|
+ escHtml(parsedElements.substring(0, 5000))
|
|
+ (parsedElements.length > 5000 ? '\n... (обрезано)' : '')
|
|
+ '</pre></details>';
|
|
}
|
|
document.getElementById('modalBody').innerHTML = detailHtml;
|
|
} catch (error) {
|
|
document.getElementById('modalBody').textContent = 'Ошибка: ' + error.message;
|
|
}
|
|
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>';
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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); }
|
|
}
|
|
|
|
/**
|
|
* 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: 'Неизвестная ошибка' };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* finalizeUpload() — Завершение цикла загрузки (Фаза 1).
|
|
*
|
|
* Вызывается после завершения очереди выбранных файлов.
|
|
* 1. Обновляет supplement_id/type через refreshSupps()
|
|
* 2. Отмечает шаг «Загрузка» как готовый
|
|
* 3. Активирует шаг «Классификация»
|
|
* 4. Синхронизирует БД
|
|
* 5. Показывает кнопки (LLM, классификация)
|
|
*/
|
|
async function finalizeUpload() {
|
|
if (!state.files.some(function(file) { return file.doc_id; })) {
|
|
var llmButton = document.getElementById('llmBtn');
|
|
var classifyButton = document.getElementById('classifyBtn');
|
|
if (llmButton) llmButton.style.display = 'none';
|
|
if (classifyButton) classifyButton.style.display = 'none';
|
|
return;
|
|
}
|
|
await refreshSupps();
|
|
await syncDB();
|
|
stepDone('stepUpload');
|
|
stepActive('stepClassify');
|
|
|
|
var llmBtn = document.getElementById('llmBtn');
|
|
llmBtn.style.display = 'flex';
|
|
llmBtn.disabled = false;
|
|
showClassifyBtn();
|
|
lucide.createIcons();
|
|
}
|
|
|
|
function zipSourceFromPath(path) {
|
|
var parts = String(path || '').split('/');
|
|
var lastZip = -1;
|
|
parts.forEach(function(part, index) {
|
|
if (part.toLowerCase().endsWith('.zip')) lastZip = index;
|
|
});
|
|
return lastZip < 0 ? null : parts.slice(0, lastZip + 1).join('/');
|
|
}
|
|
|
|
function isPickerEntrySelected(entry) {
|
|
return state.files.some(function(file) { return file.pickerId === entry.pickerId; });
|
|
}
|
|
|
|
function setPickerFileStatus(entry, kind, text) {
|
|
entry.status = { kind: kind, text: text };
|
|
if (filePicker) filePicker.setStatus(entry.pickerId, entry.status);
|
|
}
|
|
|
|
function showPickerError(error) {
|
|
if (filePicker) filePicker.setMessage(error.message || String(error));
|
|
}
|
|
|
|
async function uploadPickerEntry(entry) {
|
|
if (!isPickerEntrySelected(entry) || entry.uploadBlocked) return;
|
|
|
|
var task = { controller: new AbortController(), phase: 'put' };
|
|
uploadTasks.set(entry.pickerId, task);
|
|
setPickerFileStatus(entry, 'uploading', '⏳ Подготовка к загрузке');
|
|
var sinkResult;
|
|
try {
|
|
var response = await window.FilePicker.uploadViaVM([{
|
|
name: entry.file.name,
|
|
size: entry.file.size,
|
|
file: entry.file,
|
|
path: entry.pickerPath
|
|
}], {
|
|
vmUploadUrl: VM_UPLOAD_URL,
|
|
backendUploadUrl: VM_API + '/api/upload_refs',
|
|
signal: task.controller.signal,
|
|
getExtraPayload: function() {
|
|
var payload = { batch_id: state.batchId };
|
|
if (state.contractId) payload.contract_id = state.contractId;
|
|
if (entry.zip_source) payload.zip_source = entry.zip_source;
|
|
return payload;
|
|
},
|
|
onFileStatus: function(index, message) {
|
|
if (message.indexOf('⚡') === 0) task.phase = 'backend';
|
|
var kind = message.indexOf('✗') === 0 ? 'error'
|
|
: message.indexOf('✓') === 0 ? 'parsing'
|
|
: task.phase === 'backend' ? 'parsing' : 'uploading';
|
|
if (isPickerEntrySelected(entry)) setPickerFileStatus(entry, kind, message);
|
|
},
|
|
onFileComplete: function(fileResult) {
|
|
sinkResult = fileResult.result;
|
|
}
|
|
});
|
|
|
|
if (!response.ok) throw new Error(response.error || 'Ошибка загрузки файла');
|
|
if (!sinkResult) throw new Error('Сервис не вернул результат обработки файла');
|
|
if (sinkResult.ok && !sinkResult.doc_id) {
|
|
throw new Error('Сервис обработал файл без идентификатора документа');
|
|
}
|
|
|
|
if (sinkResult.error === 'duplicate' && sinkResult.doc_id) {
|
|
entry.doc_id = sinkResult.doc_id;
|
|
entry.uploaded = true;
|
|
entry.status = { kind: 'duplicate', text: '⤿ дубликат' };
|
|
} else if (sinkResult.ok) {
|
|
entry.doc_id = sinkResult.doc_id;
|
|
entry.uploaded = true;
|
|
if (sinkResult.contract_id) state.contractId = sinkResult.contract_id;
|
|
applyParseResult(entry, sinkResult.parsed);
|
|
} else {
|
|
throw new Error(sinkResult.error || 'Не удалось обработать файл');
|
|
}
|
|
|
|
if (isPickerEntrySelected(entry)) {
|
|
filePicker.setDocument(entry.pickerId, entry.doc_id);
|
|
setPickerFileStatus(entry, entry.status.kind, entry.status.text
|
|
|| (entry.status.kind === 'parsed'
|
|
? '✓ ' + (entry.status.count || 0) + ' эл.'
|
|
: entry.status.text || 'Ошибка обработки'));
|
|
}
|
|
} catch (error) {
|
|
if (!(task.controller.signal.aborted && !isPickerEntrySelected(entry))) {
|
|
setPickerFileStatus(entry, 'error', '✗ ' + error.message);
|
|
}
|
|
} finally {
|
|
uploadTasks.delete(entry.pickerId);
|
|
}
|
|
}
|
|
|
|
function enqueuePickerUpload(entry) {
|
|
if (queuedUploads.has(entry.pickerId) || uploadTasks.has(entry.pickerId)) return;
|
|
queuedUploads.add(entry.pickerId);
|
|
pendingUploads += 1;
|
|
var run = function() { return uploadPickerEntry(entry); };
|
|
uploadQueue = uploadQueue.then(run, run).finally(async function() {
|
|
queuedUploads.delete(entry.pickerId);
|
|
pendingUploads -= 1;
|
|
if (pendingUploads === 0) {
|
|
try {
|
|
await finalizeUpload();
|
|
} catch (error) {
|
|
showPickerError(error);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function scheduleBatchSync() {
|
|
var sync = async function() {
|
|
await syncDB();
|
|
if (state.files.some(function(file) { return file.doc_id; })) {
|
|
showClassifyBtn();
|
|
var compareButton = document.getElementById('llmBtn');
|
|
if (compareButton) compareButton.style.display = 'flex';
|
|
}
|
|
};
|
|
uploadQueue = uploadQueue.then(sync, sync).catch(showPickerError);
|
|
}
|
|
|
|
function onPickerFilesChange(pickerFiles) {
|
|
var previous = new Map(state.files.map(function(file) { return [file.pickerId, file]; }));
|
|
var incomingIds = new Set(pickerFiles.map(function(file) { return file.id; }));
|
|
var added = [];
|
|
var nextFiles = [];
|
|
var toQueue = [];
|
|
var selectedBytes = 0;
|
|
pickerFiles.forEach(function(pickerFile) {
|
|
selectedBytes += pickerFile.size;
|
|
var entry = previous.get(pickerFile.id);
|
|
if (!entry) {
|
|
entry = {
|
|
pickerId: pickerFile.id,
|
|
pickerPath: pickerFile.path,
|
|
name: pickerFile.name,
|
|
size: pickerFile.size,
|
|
lastModified: pickerFile.file.lastModified,
|
|
file: pickerFile.file,
|
|
zip_source: zipSourceFromPath(pickerFile.path),
|
|
status: { kind: 'connecting' }
|
|
};
|
|
added.push(entry);
|
|
}
|
|
if (!entry.doc_id && !uploadTasks.has(entry.pickerId)) {
|
|
var limitError = entry.size > FILE_PICKER_LIMITS.maxEntryBytes
|
|
? 'Файл превышает лимит 50 МБ'
|
|
: selectedBytes > FILE_PICKER_LIMITS.maxTotalBytes ? 'Превышен общий лимит 500 МБ' : '';
|
|
if (limitError) {
|
|
entry.uploadBlocked = true;
|
|
entry.status = { kind: 'error', text: limitError };
|
|
filePicker.setStatus(entry.pickerId, entry.status);
|
|
} else if (entry.uploadBlocked) {
|
|
delete entry.uploadBlocked;
|
|
entry.status = { kind: 'connecting' };
|
|
if (!queuedUploads.has(entry.pickerId)) toQueue.push(entry);
|
|
} else if (added.indexOf(entry) !== -1) {
|
|
toQueue.push(entry);
|
|
}
|
|
}
|
|
nextFiles.push(entry);
|
|
});
|
|
|
|
var removed = state.files.filter(function(file) { return !incomingIds.has(file.pickerId); });
|
|
removed.forEach(function(file) {
|
|
var task = uploadTasks.get(file.pickerId);
|
|
if (task && task.phase === 'put') task.controller.abort();
|
|
});
|
|
|
|
var changed = added.length > 0 || removed.length > 0 || toQueue.length > 0;
|
|
state.files = nextFiles;
|
|
if (!state.files.some(function(file) { return file.doc_id; })) state.contractId = null;
|
|
if (changed) {
|
|
resetStepper('stepUpload');
|
|
var classifyButton = document.getElementById('classifyBtn');
|
|
if (classifyButton) classifyButton.style.display = 'none';
|
|
var compareButton = document.getElementById('llmBtn');
|
|
if (compareButton) compareButton.style.display = 'none';
|
|
}
|
|
render(state);
|
|
|
|
toQueue.forEach(enqueuePickerUpload);
|
|
if (removed.length) scheduleBatchSync();
|
|
}
|