v1.0.118: ZIP upload — unzip on VM, add files to queue individually, flat structure only
This commit is contained in:
@@ -60,6 +60,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
if self.path == "/llm-ops":
|
if self.path == "/llm-ops":
|
||||||
self._handle_llm_ops()
|
self._handle_llm_ops()
|
||||||
|
elif self.path == "/unzip-upload":
|
||||||
|
self._handle_unzip_upload()
|
||||||
else:
|
else:
|
||||||
self._handle_doc_convert()
|
self._handle_doc_convert()
|
||||||
|
|
||||||
@@ -275,6 +277,57 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(json.dumps({"error": str(e)}, ensure_ascii=False).encode())
|
self.wfile.write(json.dumps({"error": str(e)}, ensure_ascii=False).encode())
|
||||||
|
|
||||||
|
def _handle_unzip_upload(self):
|
||||||
|
"""Распаковать ZIP и загрузить каждый файл в Lucee. Только файлы из корня архива."""
|
||||||
|
import zipfile, io
|
||||||
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
|
data = self.rfile.read(length)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||||
|
for name in zf.namelist():
|
||||||
|
# Только файлы из корня (без папок), пропускаем директории
|
||||||
|
if name.endswith('/') or '/' in name:
|
||||||
|
continue
|
||||||
|
content = zf.read(name)
|
||||||
|
ext = name.rsplit('.', 1)[-1].lower() if '.' in name else ''
|
||||||
|
if ext not in ('docx', 'doc', 'pdf'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Загрузить в Lucee
|
||||||
|
resp = httpx.post(
|
||||||
|
f"{LUCEE_URL}/upload.cfm",
|
||||||
|
data={"filename": name, "data": content},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
rj = resp.json()
|
||||||
|
if rj.get("OK"):
|
||||||
|
results.append({
|
||||||
|
"filename": name,
|
||||||
|
"doc_id": rj.get("DOC_ID", ""),
|
||||||
|
"contract_id": rj.get("CONTRACT_ID", ""),
|
||||||
|
"size": len(content),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
results.append({"filename": name, "error": rj.get("ERROR", "upload failed")})
|
||||||
|
else:
|
||||||
|
results.append({"filename": name, "error": f"HTTP {resp.status_code}"})
|
||||||
|
|
||||||
|
except zipfile.BadZipFile:
|
||||||
|
self.send_error(400, "not a valid ZIP file")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
self.send_error(500, str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps({"ok": True, "files": results}, ensure_ascii=False).encode())
|
||||||
|
|
||||||
def _handle_doc_convert(self):
|
def _handle_doc_convert(self):
|
||||||
length = int(self.headers.get("Content-Length", 0))
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
data = self.rfile.read(length)
|
data = self.rfile.read(length)
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ server {
|
|||||||
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
|
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /unzip-upload {
|
||||||
|
add_header Access-Control-Allow-Origin "*";
|
||||||
|
add_header Access-Control-Allow-Methods "POST, OPTIONS";
|
||||||
|
add_header Access-Control-Allow-Headers "*";
|
||||||
|
if ($request_method = OPTIONS) { return 200; }
|
||||||
|
proxy_pass http://127.0.0.1:8766;
|
||||||
|
client_max_body_size 100m;
|
||||||
|
}
|
||||||
|
|
||||||
listen 443 ssl; # managed by Certbot
|
listen 443 ssl; # managed by Certbot
|
||||||
ssl_certificate /etc/letsencrypt/live/contracts.kube5s.ru/fullchain.pem; # managed by Certbot
|
ssl_certificate /etc/letsencrypt/live/contracts.kube5s.ru/fullchain.pem; # managed by Certbot
|
||||||
ssl_certificate_key /etc/letsencrypt/live/contracts.kube5s.ru/privkey.pem; # managed by Certbot
|
ssl_certificate_key /etc/letsencrypt/live/contracts.kube5s.ru/privkey.pem; # managed by Certbot
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<img src="/nubes-logo.svg" alt="Nubes">
|
<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.117 — Lucee</span></span>
|
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.118 — Lucee</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
@@ -85,7 +85,8 @@
|
|||||||
Загрузка договоров/приложений/спецификаций
|
Загрузка договоров/приложений/спецификаций
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<input type="file" id="fileInput" accept=".docx,.doc,.pdf,.zip" multiple style="margin-bottom:12px;width:100%;">
|
<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 class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
@@ -121,7 +122,7 @@
|
|||||||
<div class="card" id="chatCard" style="display:none;margin-top:16px;">
|
<div class="card" id="chatCard" style="display:none;margin-top:16px;">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<i data-lucide="message-circle" style="width:18px;height:18px;"></i>
|
<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>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div id="chatMessages" style="margin-bottom:8px;font-size:13px;"></div>
|
<div id="chatMessages" style="margin-bottom:8px;font-size:13px;"></div>
|
||||||
@@ -197,7 +198,7 @@ function formatDate(ts) {
|
|||||||
|
|
||||||
function renderTable() {
|
function renderTable() {
|
||||||
if (fileQueue.length === 0) {
|
if (fileQueue.length === 0) {
|
||||||
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .docx / .pdf / .zip</td></tr>';
|
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .docx / .pdf / .zip (файлы из корня архива)</td></tr>';
|
||||||
} else {
|
} else {
|
||||||
fileTable.innerHTML = fileQueue.map(function(f, i) {
|
fileTable.innerHTML = fileQueue.map(function(f, i) {
|
||||||
var isFirst = (i === 0);
|
var isFirst = (i === 0);
|
||||||
@@ -247,8 +248,76 @@ fileInput.addEventListener('change', async function() {
|
|||||||
|
|
||||||
for (var i = 0; i < newFiles.length; i++) {
|
for (var i = 0; i < newFiles.length; i++) {
|
||||||
var f = newFiles[i];
|
var f = newFiles[i];
|
||||||
|
var isZip = f.name.toLowerCase().endsWith('.zip');
|
||||||
|
|
||||||
|
if (isZip) {
|
||||||
|
// ZIP: показать временную строку, распаковать, убрать
|
||||||
|
var zipEntry = { name: f.name, lastModified: f.lastModified, size: f.size, status: '⏳ распаковка...' };
|
||||||
|
fileQueue.push(zipEntry);
|
||||||
|
var zipIdx = fileQueue.length - 1;
|
||||||
|
renderTable();
|
||||||
|
|
||||||
|
try {
|
||||||
|
var zipResp = await fetch('/unzip-upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: f,
|
||||||
|
headers: {'Content-Type': 'application/zip'}
|
||||||
|
});
|
||||||
|
var zipData = await zipResp.json();
|
||||||
|
if (!zipData.ok || !zipData.files) throw new Error('unzip failed');
|
||||||
|
|
||||||
|
// Убрать строку ZIP из таблицы
|
||||||
|
fileQueue.splice(zipIdx, 1);
|
||||||
|
renderTable();
|
||||||
|
|
||||||
|
// Добавить файлы из архива, пропуская дубликаты
|
||||||
|
for (var zi = 0; zi < zipData.files.length; zi++) {
|
||||||
|
var zf = zipData.files[zi];
|
||||||
|
if (zf.error) continue; // пропускаем ошибки
|
||||||
|
|
||||||
|
// Проверить дубликат по имени
|
||||||
|
var dup = -1;
|
||||||
|
for (var dj = 0; dj < fileQueue.length; dj++) {
|
||||||
|
if (fileQueue[dj].name === zf.filename) { dup = dj; break; }
|
||||||
|
}
|
||||||
|
if (dup >= 0) continue; // уже есть — не добавляем
|
||||||
|
|
||||||
|
var zEntry = {
|
||||||
|
name: zf.filename, size: zf.size,
|
||||||
|
doc_id: zf.doc_id, uploaded: true,
|
||||||
|
status: '⏳ парсинг...'
|
||||||
|
};
|
||||||
|
fileQueue.push(zEntry);
|
||||||
|
var zIdx = fileQueue.length - 1;
|
||||||
|
if (!contractId && zf.contract_id) contractId = zf.contract_id;
|
||||||
|
renderTable();
|
||||||
|
|
||||||
|
// Авто-парсинг
|
||||||
|
var zt0 = Date.now();
|
||||||
|
try {
|
||||||
|
var zpr = await fetch('/parser.cfm?doc_id=' + zf.doc_id);
|
||||||
|
var zpd = await zpr.json();
|
||||||
|
var zelapsed = ((Date.now() - zt0) / 1000).toFixed(1);
|
||||||
|
if (zpd.OK && zpd.STATUS === 'parsed') {
|
||||||
|
fileQueue[zIdx].parsed = true;
|
||||||
|
fileQueue[zIdx].status = '<span class="status-ok">✓ ' + zpd.ELEMENT_COUNT + ' эл. (' + zelapsed + 'с)</span>';
|
||||||
|
} else {
|
||||||
|
fileQueue[zIdx].status = '<span class="status-err">✗ ' + (zpd.ERROR_COUNT > 0 ? (zpd.ERRORS && zpd.ERRORS[0] || 'ошибка') : 'ошибка') + '</span>';
|
||||||
|
}
|
||||||
|
} catch(zpe) {
|
||||||
|
fileQueue[zIdx].status = '<span class="status-err">✗ парсинг: сеть</span>';
|
||||||
|
}
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
} catch(ze) {
|
||||||
|
fileQueue[zipIdx].status = '<span class="status-err">✗ ZIP: ' + ze.message + '</span>';
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
continue; // ZIP обработан, переходим к следующему файлу
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обычный файл (не ZIP)
|
||||||
var entry = { name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: '↑ 0%' };
|
var entry = { name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: '↑ 0%' };
|
||||||
// Заменить старый файл с тем же именем, если есть
|
|
||||||
var existingIdx = -1;
|
var existingIdx = -1;
|
||||||
for (var j = 0; j < fileQueue.length; j++) {
|
for (var j = 0; j < fileQueue.length; j++) {
|
||||||
if (fileQueue[j].name === f.name) { existingIdx = j; break; }
|
if (fileQueue[j].name === f.name) { existingIdx = j; break; }
|
||||||
|
|||||||
Reference in New Issue
Block a user