Compare commits
6
Commits
v2.0.5-stable
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
956aed0971 | ||
|
|
3b2e576284 | ||
|
|
de05d746cc | ||
|
|
c677206d03 | ||
|
|
d1415d5d21 | ||
|
|
5d8bcd61ea |
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
"""Конфигурация приложения — все настройки в одном месте."""
|
"""Конфигурация приложения — все настройки в одном месте."""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
VERSION = "2.0.5"
|
VERSION = "2.0.9"
|
||||||
|
|
||||||
LLM_URL = os.getenv("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions")
|
LLM_URL = os.getenv("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions")
|
||||||
LLM_KEY = os.getenv("LLM_API_KEY", "")
|
LLM_KEY = os.getenv("LLM_API_KEY", "")
|
||||||
@@ -9,3 +9,4 @@ LLM_MODEL = os.getenv("LLM_MODEL", "gpt-oss-120b")
|
|||||||
|
|
||||||
MAX_CONTENT_LENGTH = 200 * 1024 * 1024 # 200 MB
|
MAX_CONTENT_LENGTH = 200 * 1024 * 1024 # 200 MB
|
||||||
API_KEY = os.getenv("API_KEY", "")
|
API_KEY = os.getenv("API_KEY", "")
|
||||||
|
CONVERT_SERVICE_URL = os.getenv("CONVERT_SERVICE_URL", "http://containerk8s.df36c8af-1a95-4623-b551-0d37b731ccca.svc.cluster.local:5000")
|
||||||
|
|||||||
+16
-25
@@ -1,9 +1,10 @@
|
|||||||
"""Upload blueprint — загрузка, конвертация, распаковка."""
|
"""Upload blueprint — загрузка, конвертация, распаковка."""
|
||||||
import io, os, base64, hashlib, zipfile, tempfile, subprocess
|
import io, os, base64, hashlib, zipfile
|
||||||
|
import httpx
|
||||||
from flask import Blueprint, request, jsonify, send_file
|
from flask import Blueprint, request, jsonify, send_file
|
||||||
from services.parse import parse_file
|
from services.parse import parse_file
|
||||||
from db import documents
|
from db import documents
|
||||||
from config import MAX_CONTENT_LENGTH
|
import config
|
||||||
|
|
||||||
upload_bp = Blueprint("upload", __name__)
|
upload_bp = Blueprint("upload", __name__)
|
||||||
|
|
||||||
@@ -70,38 +71,28 @@ def upload():
|
|||||||
|
|
||||||
@upload_bp.route("/convert-doc", methods=["POST"])
|
@upload_bp.route("/convert-doc", methods=["POST"])
|
||||||
def convert_doc():
|
def convert_doc():
|
||||||
""".doc → .docx через libreoffice (без сохранения на диск)."""
|
""".doc → .docx через внешний libreoffice-сервис (HTTP)."""
|
||||||
f = request.files.get("files")
|
f = request.files.get("files")
|
||||||
if not f:
|
if not f:
|
||||||
return jsonify(ok=False, error="no file"), 400
|
return jsonify(ok=False, error="no file"), 400
|
||||||
|
|
||||||
data = f.read()
|
|
||||||
doc_path = None
|
|
||||||
tmpdir = None
|
|
||||||
try:
|
try:
|
||||||
with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as tmp:
|
resp = httpx.post(
|
||||||
tmp.write(data)
|
config.CONVERT_SERVICE_URL + "/convert",
|
||||||
doc_path = tmp.name
|
files={"file": (f.filename, f.read(), f.content_type or "application/msword")},
|
||||||
tmpdir = tempfile.mkdtemp()
|
timeout=120,
|
||||||
subprocess.run(
|
|
||||||
["libreoffice", "--headless", "--convert-to", "docx", "--outdir", tmpdir, doc_path],
|
|
||||||
timeout=30, capture_output=True,
|
|
||||||
)
|
)
|
||||||
docx_files = [x for x in os.listdir(tmpdir) if x.endswith(".docx")]
|
if resp.status_code == 200:
|
||||||
if docx_files:
|
|
||||||
with open(os.path.join(tmpdir, docx_files[0]), "rb") as out:
|
|
||||||
return send_file(
|
return send_file(
|
||||||
io.BytesIO(out.read()),
|
io.BytesIO(resp.content),
|
||||||
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
)
|
)
|
||||||
return jsonify(ok=False, error="conversion produced no output"), 500
|
data = resp.json()
|
||||||
finally:
|
return jsonify(ok=False, error=data.get("error", "conversion failed")), 500
|
||||||
if doc_path and os.path.exists(doc_path):
|
except httpx.TimeoutException:
|
||||||
os.unlink(doc_path)
|
return jsonify(ok=False, error="conversion timeout"), 500
|
||||||
if tmpdir and os.path.exists(tmpdir):
|
except Exception as e:
|
||||||
for x in os.listdir(tmpdir):
|
return jsonify(ok=False, error=str(e)), 500
|
||||||
os.unlink(os.path.join(tmpdir, x))
|
|
||||||
os.rmdir(tmpdir)
|
|
||||||
|
|
||||||
|
|
||||||
@upload_bp.route("/unzip-upload", methods=["POST"])
|
@upload_bp.route("/unzip-upload", methods=["POST"])
|
||||||
|
|||||||
+55
-4
@@ -1,4 +1,55 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
<rect width="32" height="32" rx="4" fill="#001C34"/>
|
<svg
|
||||||
<text x="16" y="24" text-anchor="middle" font-size="22" font-weight="bold" fill="white" font-family="sans-serif">N</text>
|
width="57"
|
||||||
</svg>
|
height="57"
|
||||||
|
xml:space="preserve"
|
||||||
|
overflow="hidden"
|
||||||
|
version="1.1"
|
||||||
|
id="svg8"
|
||||||
|
sodipodi:docname="U_v3.svg"
|
||||||
|
inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||||
|
id="namedview8"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="16.226667"
|
||||||
|
inkscape:cx="27.146672"
|
||||||
|
inkscape:cy="28.317584"
|
||||||
|
inkscape:window-width="2560"
|
||||||
|
inkscape:window-height="1494"
|
||||||
|
inkscape:window-x="-11"
|
||||||
|
inkscape:window-y="-11"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg8" /><defs
|
||||||
|
id="defs2"><clipPath
|
||||||
|
id="clip0"><rect
|
||||||
|
x="652"
|
||||||
|
y="420"
|
||||||
|
width="64"
|
||||||
|
height="75"
|
||||||
|
id="rect1" /></clipPath><clipPath
|
||||||
|
id="clip1"><rect
|
||||||
|
x="652"
|
||||||
|
y="420"
|
||||||
|
width="64"
|
||||||
|
height="70"
|
||||||
|
id="rect2" /></clipPath></defs><g
|
||||||
|
clip-path="url(#clip0)"
|
||||||
|
transform="matrix(1.0135748,0,0,1.0135748,-664.97034,-440.30567)"
|
||||||
|
id="g8"><g
|
||||||
|
clip-path="url(#clip1)"
|
||||||
|
id="g7"><g
|
||||||
|
id="g6"><path
|
||||||
|
d="m 114.028,43.1492 v 7.6592 c 0,3.7843 -3.08,6.8632 -6.866,6.8632 L 85.3367,57.5419 c -3.7853,0 -6.8655,-3.0805 -6.8655,-6.8643 v -2.5279 l 0.0555,0.009 V 15.8148 l -10.3823,2.0127 0.0045,3.8772 -0.0045,0.0015 v 28.971 c 0,9.481 7.7132,17.1923 17.1867,17.1923 l 21.8359,0.1313 c 9.474,0 17.187,-7.7117 17.187,-17.1928 v -2.6541 l 0.027,0.0045 V 15.8145 l -10.382,2.0126 0.028,25.3214 z"
|
||||||
|
fill="#001c34"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
transform="matrix(1,0,0,1.01337,587.92,420.059)"
|
||||||
|
id="path6" /></g></g></g></svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 246 B After Width: | Height: | Size: 2.0 KiB |
+58
-3
@@ -12,6 +12,8 @@
|
|||||||
* - statusToHTML() — рендер статуса (↑ N%, ✓)
|
* - statusToHTML() — рендер статуса (↑ N%, ✓)
|
||||||
* - renderFiles() — рендер всей таблицы
|
* - renderFiles() — рендер всей таблицы
|
||||||
|
|
||||||
|
var CONVERT_URL = '/convert-doc';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* statusToHTML(status) — Чистая функция: структура → HTML (Фаза 1).
|
* statusToHTML(status) — Чистая функция: структура → HTML (Фаза 1).
|
||||||
*
|
*
|
||||||
@@ -26,6 +28,7 @@ function statusToHTML(st) {
|
|||||||
if (!st || !st.kind) return '';
|
if (!st || !st.kind) return '';
|
||||||
switch (st.kind) {
|
switch (st.kind) {
|
||||||
case 'connecting': return '⏳ соединение... ' + (st.elapsed || 0) + 'с';
|
case 'connecting': return '⏳ соединение... ' + (st.elapsed || 0) + 'с';
|
||||||
|
case 'converting': return '⏳ конвертация... ' + (st.elapsed || 0) + 'с';
|
||||||
case 'uploading': return '⏳ отправка... ' + (st.elapsed || 0) + 'с';
|
case 'uploading': return '⏳ отправка... ' + (st.elapsed || 0) + 'с';
|
||||||
case 'uploaded': return '<span class="status-ok">✓</span>';
|
case 'uploaded': return '<span class="status-ok">✓</span>';
|
||||||
case 'unzipping': return '⏳ распаковка...';
|
case 'unzipping': return '⏳ распаковка...';
|
||||||
@@ -51,7 +54,7 @@ function statusToHTML(st) {
|
|||||||
*/
|
*/
|
||||||
function renderFiles(state) {
|
function renderFiles(state) {
|
||||||
if (state.files.length === 0) {
|
if (state.files.length === 0) {
|
||||||
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .docx / .pdf</td></tr>';
|
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .doc / .docx / .pdf</td></tr>';
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -203,6 +206,40 @@ window.toggleClassifyDetail = async function(i) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* convertDoc(file, onProgress) — .doc → .docx через внешний libreoffice-сервис.
|
||||||
|
* С честным счётчиком времени.
|
||||||
|
*/
|
||||||
|
function convertDoc(file, onProgress) {
|
||||||
|
var startTime = Date.now();
|
||||||
|
if (onProgress) onProgress({ kind: 'converting', elapsed: 0 });
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('files', file, file.name);
|
||||||
|
|
||||||
|
var timer = setInterval(function() {
|
||||||
|
var elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||||
|
if (onProgress) onProgress({ kind: 'converting', elapsed: elapsed });
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return fetch(CONVERT_URL + '?_=' + Date.now(), { method: 'POST', body: fd })
|
||||||
|
.then(function(r) {
|
||||||
|
if (!r.ok) throw new Error('Конвертация: HTTP ' + r.status);
|
||||||
|
return r.blob();
|
||||||
|
})
|
||||||
|
.then(function(blob) {
|
||||||
|
clearInterval(timer);
|
||||||
|
if (blob.size === 0) throw new Error('Конвертация: пустой результат');
|
||||||
|
return new File([blob], file.name.replace(/\.doc$/i, '.docx'), {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
lastModified: Date.now()
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function(e) {
|
||||||
|
clearInterval(timer);
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ⛔ НЕ МЕНЯТЬ ⛔ uploadFile — fetch-загрузка с честным счётчиком времени.
|
* ⛔ НЕ МЕНЯТЬ ⛔ uploadFile — fetch-загрузка с честным счётчиком времени.
|
||||||
*
|
*
|
||||||
@@ -430,8 +467,17 @@ async function addRegularFile(file, zipSource) {
|
|||||||
render(state);
|
render(state);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// .doc → конвертация через libreoffice-сервис
|
||||||
|
var uploadTarget = file;
|
||||||
|
if (file.name.toLowerCase().endsWith('.doc')) {
|
||||||
|
uploadTarget = await convertDoc(file, function(st) {
|
||||||
|
state.files[rowIdx].status = st;
|
||||||
|
render(state);
|
||||||
|
});
|
||||||
|
state.files[rowIdx].name = uploadTarget.name;
|
||||||
|
}
|
||||||
// fetch-загрузка с честным счётчиком времени
|
// fetch-загрузка с честным счётчиком времени
|
||||||
var resp = await uploadFile(file, function(st) {
|
var resp = await uploadFile(uploadTarget, function(st) {
|
||||||
state.files[rowIdx].status = st;
|
state.files[rowIdx].status = st;
|
||||||
render(state);
|
render(state);
|
||||||
}, zipSource);
|
}, zipSource);
|
||||||
@@ -523,7 +569,16 @@ async function onFilesSelected(newFiles) {
|
|||||||
delete entry._pendingFile;
|
delete entry._pendingFile;
|
||||||
var f = pendingFile;
|
var f = pendingFile;
|
||||||
try {
|
try {
|
||||||
var resp = await uploadFile(f, function(st) {
|
// .doc → конвертация через libreoffice-сервис
|
||||||
|
var uploadTarget = f;
|
||||||
|
if (f.name.toLowerCase().endsWith('.doc')) {
|
||||||
|
uploadTarget = await convertDoc(f, function(st) {
|
||||||
|
entry.status = st;
|
||||||
|
render(state);
|
||||||
|
});
|
||||||
|
entry.name = uploadTarget.name;
|
||||||
|
}
|
||||||
|
var resp = await uploadFile(uploadTarget, function(st) {
|
||||||
entry.status = st;
|
entry.status = st;
|
||||||
render(state);
|
render(state);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<img src="/static/logo.svg" alt="Nubes">
|
<img src="/static/logo.svg" alt="Nubes">
|
||||||
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v2.0.5</span></span>
|
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v2.0.9</span></span>
|
||||||
<div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);">
|
<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="stepUpload">○ Загрузка</span><span>→</span>
|
||||||
<span id="stepClassify">○ Классификация</span><span>→</span>
|
<span id="stepClassify">○ Классификация</span><span>→</span>
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
Загрузка договоров/приложений/спецификаций
|
Загрузка договоров/приложений/спецификаций
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<input type="file" id="fileInput" accept=".docx,.pdf,.zip" multiple style="margin-bottom:6px;width:100%;">
|
<input type="file" id="fileInput" accept=".doc,.docx,.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="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 style="font-size:11px;color:var(--muted);margin-bottom:6px;">⚠ При совпадении имён — запрос на перезапись (OK / Отмена). Файлы из ZIP-архивов загружаются через тот же поток.</div>
|
||||||
|
|
||||||
@@ -216,11 +216,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/state.js?v=2.0.5"></script>
|
<script src="/static/state.js?v=2.0.8"></script>
|
||||||
<script src="/static/app_utils.js?v=2.0.5"></script>
|
<script src="/static/app_utils.js?v=2.0.8"></script>
|
||||||
<script src="/static/files.js?v=2.0.5"></script>
|
<script src="/static/files.js?v=2.0.8"></script>
|
||||||
<script src="/static/groups.js?v=2.0.5"></script>
|
<script src="/static/groups.js?v=2.0.8"></script>
|
||||||
<script src="/static/compare.js?v=2.0.5"></script>
|
<script src="/static/compare.js?v=2.0.8"></script>
|
||||||
<script src="/static/app.js?v=2.0.5"></script>
|
<script src="/static/app.js?v=2.0.8"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user