Compare commits

20 Commits
Author SHA1 Message Date
naeel 1b6473dcc4 bump versions: upload v3.0.2, llm v3.2.1 2026-06-19 13:41:49 +04:00
naeel 51e509ce15 v3.3.2: документация session-09 — итоги Flask + Lucee 2026-06-18 14:22:41 +04:00
naeel ce4871227c v3.3.1: редактор промпта LLM — сохранить/сбросить/по умолчанию 2026-06-18 09:32:58 +04:00
naeel 958865c397 v3.3.0: чат с договором — задать вопрос по spec_rows 2026-06-18 09:23:09 +04:00
naeel e3b7f283f0 v3.2.5: сравнение первый vs остальные, таймер LLM 2026-06-18 09:14:32 +04:00
naeel 08c7af2a77 v3.2.4: libreoffice на ВМ, модалка — заголовок не скроллится 2026-06-18 09:09:18 +04:00
naeel dfa1d9278f v3.2.3: инфо и удаление — отдельные столбцы, иконки 16px 2026-06-18 09:06:40 +04:00
naeel 5ffc588935 v3.2.2: fix cid из FormData, иконки trash-2 + info 2026-06-18 09:04:12 +04:00
naeel f1495d07e3 v3.2.1: /llm — отдельная страница, / — без LLM 2026-06-18 09:01:30 +04:00
naeel e534d014ef v3.2.0: LLM-извлечение + сравнение допников — кнопка Сравнить 2026-06-18 08:54:39 +04:00
naeel 17413e65c4 v3.1.6: text strategy только при отсутствии рамок, мин 3 колонки 2026-06-18 08:23:29 +04:00
naeel bc5cbfe913 v3.1.5: мин 4 заполненных ячейки для таблицы 2026-06-18 08:22:50 +04:00
naeel 52fe7b92fb v3.1.4: таблицы — мин 2 строки, 40% заполнения 2026-06-18 08:22:18 +04:00
naeel 0151a380ea v3.1.3: pdfplumber — только рамки, фильтр 30% заполнения 2026-06-18 08:21:42 +04:00
naeel 2b851505c3 v3.1.2: фильтр пустых таблиц + text strategy для pdfplumber 2026-06-18 08:20:10 +04:00
naeel c3d891f7b7 v3.1.1: таймер при загрузке (↑ 12.3с 45%) 2026-06-18 08:14:18 +04:00
naeel 557a238fb8 v3.1.0: разбить app.py на routes/main,parse,misc — 52 строки вместо 330 2026-06-18 08:10:23 +04:00
naeel f1b4bf515f v3.0.1: вернуть lucide — без него JS падал 2026-06-18 08:04:47 +04:00
naeel 822b47bace v3.0.1: application = ContractsApp().app для gunicorn 2026-06-18 08:01:57 +04:00
naeel 12d67de7ed v3.0.0 VM: убрать v2/Redis/чанки, чистый FormData, PostgreSQL 2026-06-18 07:57:51 +04:00
18 changed files with 1647 additions and 1090 deletions
+181
View File
@@ -0,0 +1,181 @@
# Сессия 09 — Итоги: Flask + Lucee
Дата: 2026-06-18
## Общая архитектура
```
contracts-app/ — Flask прототип (ветка vm)
contractor/ — Lucee production (ветка master)
dogovora/ — тестовые файлы
примеры_договоров_для_ИИ/ — 5 файлов (docx, doc)
mix/ — 25 PDF для тестов парсера
```
## 1. Flask прототип (contracts-app, ветка vm)
URL: https://contracts.kube5s.ru/
ВМ: 5.172.178.213, SSH ключ ~/.ssh/naeel_vm_id_ed25519
Деплой: rsync site/ naeel@5.172.178.213:~/contracts/site/ && pm2 restart contracts
БД: PostgreSQL localhost, БД contracts, пользователь super, пароль kVTjPsCTT...
PM2: contracts (gunicorn, порт 5001)
nginx: /etc/nginx/sites-available/contracts → contracts.kube5s.ru
### Структура кода
```
site/
app.py — 65 строк, Flask + регистрация маршрутов
routes/
__init__.py
main.py — index, upload, show_contract (140 строк)
parse.py — SSE парсинг (160 строк)
misc.py — health, logs (35 строк)
llm.py — LLM-извлечение + сравнение + чат (200 строк)
db.py — PostgreSQL ThreadedConnectionPool
schema.py — DDL: documents, contracts, supplements, spec_rows, spec_history
parser.py — pdfplumber (PDF) + python-docx (DOCX) + libreoffice (.doc) + zipfile
textify.py — elements → текст для LLM
extractor.py — промпт → LLM API → structured rows
differ.py — сравнение строк спецификаций
llm_client.py — HTTP клиент к api.aillm.ru (gpt-oss-120b)
mimeutil.py — определение MIME по расширению
templates/
upload.html — главная: загрузка + парсинг (без LLM)
llm.html — /llm: загрузка + парсинг + LLM + сравнение + чат
```
### Эндпоинты
| URL | Метод | Описание |
|-----|-------|----------|
| / | GET | Страница загрузки |
| / | POST | Приём файлов (multipart) |
| /parse/<cid> | GET | SSE парсинг |
| /health | GET | Проверка живота |
| /llm | GET | Страница LLM |
| /llm/process/<cid> | GET | SSE: LLM-извлечение + сравнение |
| /llm/chat/<cid> | POST | Задать вопрос по spec_rows |
| /llm/prompt/<cid> | GET/POST | Сохранить/получить промпт |
### Что работает на Flask
- Загрузка любых форматов (PDF/DOCX/DOC/ZIP), до 100MB
- Парсинг: параграфы + таблицы, фильтр пустых/мусорных таблиц
- LLM-извлечение строк спецификации: №, услуга, цена, объём, сумма, дата
- Сравнение допников: added/changed/deleted с цветовой индикацией
- Чат с договором: вопрос → LLM на основе spec_rows
- Редактор промпта с сохранением в localStorage
- Таймер при загрузке файлов
### Результаты теста парсера PDF (25 файлов)
14 из 19 текстовых PDF — таблицы извлечены. 6 сканов — 0 элементов (нет текста).
21 из 25 проверено. Остальные 4 — большие файлы, не дождались.
### Результаты LLM-извлечения (5 тестовых файлов)
- спецификация-XXX001-03700.docx: 14 строк
- допник-1-XXX002-01200_3.doc: 2 строки
- допник-1-XXX003-01300_2.docx: 6 строк
- спецификация-XXX003-01300_2.docx: 1 строка
- спецификация-ХХХ002-01200_3.docx: 1 строка
- Всего: 24 строки
### Решённые проблемы
1. HTTP/2 стримы — платформа managed flask рвала соединение после 2-3 запросов. Решение: перенос на ВМ с HTTP/1.1 nginx.
2. 64KB лимит POST — на платформе нельзя загрузить файл >64KB. На ВМ — 100MB.
3. apply(null, bytes) — падал на файлах >125KB. Решение: subarray по 32KB.
4. Пустые таблицы PDF — фильтр: мин 2 строки, 3 колонки, 40% заполнения, мин 4 ячейки.
5. cid из FormData не читался — request.args vs request.form.
6. Модальное окно — заголовок скроллился, исправлен на sticky.
### Ключ LLM
```
LLM_API_KEY=sk-ucI5YvOticoOQ9Kuj5K9mQ
LLM_API_URL=https://api.aillm.ru/v1/chat/completions
Модель: gpt-oss-120b
```
## 2. Lucee (contractor, ветка master)
URL: https://contractor.luceek8s.dev.nubes.ru/
Платформа: Lucee 6.0, k8s-4-sandbox-nubes-ru
БД: PostgreSQL (внешний сервис), БД baza, пользователь super
Деплой: git push → автоматический
Репозиторий: https://gitea.services.ngcloud.ru/Nail/contractor.git
### Структура
```
contractor/
Application.cfc — 23 строки, datasource хардкод
index.cfm — health: OK v1.0.1
api.cfm — CRUD API (?action=test|tables|schema|query|execute)
upload.cfm — приём файлов (cffile, v1.0.1, ждёт деплоя)
test.cfm — тест datasource (устарел)
jars.cfm — проверка PDFBox/POI (ждёт деплоя)
db.cfc — REST API (не используется)
```
### Эндпоинты (v1.0.1)
| URL | Описание |
|-----|----------|
| / | OK v1.0.1 |
| /api.cfm?action=test | PostgreSQL connected |
| /api.cfm?action=schema | Создать таблицы |
| /api.cfm?action=tables | Список таблиц |
| /upload.cfm | Приём файлов (ждёт деплоя) |
### Конфигурация Lucee (личный кабинет)
- gitPath: https://gitea.services.ngcloud.ru/Nail/contractor.git
- healthPath: /api.cfm?action=test
- version: 6.0
- domain: contractor
### PostgreSQL (для Lucee)
- Хост: postgresqlk8s-master.418f9960-2eb7-4429-b2ec-ac64319d7268.svc.cluster.local
- БД: baza
- Пользователь: super
- Пароль: hoHdkA23yxlD9oMUlZ1bIbNyc6DE2mNJmyHMNkEpVD1F4suQs7O2lyN4t0qITyzt
### Проблемы Lucee
1. env vars — баг в ЛК: нельзя удалить/изменить. Префикс pg строчными.
2. datasource — хардкод в Application.cfc, работает.
3. Java библиотеки — PDFBox/POI не проверены.
4. pg_class typo: "Drive" вместо "Driver" — не можем исправить.
## 3. Что дальше (на Lucee)
- [x] БД подключена
- [x] CRUD API работает
- [ ] upload.cfm — проверить после деплоя
- [ ] Парсер PDF/DOCX (PDFBox/POI)
- [ ] LLM-извлечение (cfhttp → api.aillm.ru)
- [ ] Сравнение (differ на CFML)
- [ ] Чат
- [ ] UI (форма загрузки)
## 4. Гитовые репы
| Репа | Ветка | Назначение |
|------|-------|------------|
| contracts-app | vm | Flask прототип |
| contracts-app | master | Старая платформа (чанки) |
| contractor | master | Lucee production |
| contracts | master | Документы, ключи |
## 5. Деплой Flask
```bash
rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no" \
site/ naeel@5.172.178.213:~/contracts/site/
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 \
'pm2 restart contracts'
```
-3
View File
@@ -6,6 +6,3 @@ psycopg2-binary
python-dotenv python-dotenv
pdfplumber pdfplumber
camelot-py>=2.0 camelot-py>=2.0
redis
httpx
h2
+44 -502
View File
@@ -1,530 +1,72 @@
"""app.py — Точка входа и сборка слоёв.""" """app.py — Точка входа. Только Flask + регистрация маршрутов."""
from dotenv import load_dotenv from dotenv import load_dotenv
from flask import Flask, render_template, request, redirect, url_for, Response, jsonify from flask import Flask
import json
import sys as _sys, os as _os
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), '..'))
# Слои
import schema import schema
import db
import parser as parser_mod
import textify
import extractor
import differ
import mimeutil
from test_routes import test_bp from test_routes import test_bp
from upload import upload_bp from upload import upload_bp
from api import api_bp from api import api_bp
from v2.chunk_api import bp as v2_bp
import redis_client # Маршруты (разделены по файлам)
import redis_worker from routes.main import index
from routes.parse import parse
from routes.misc import health, logs
from routes.llm import process as llm_process, chat as llm_chat, save_prompt, get_prompt
load_dotenv() load_dotenv()
import time as _time
import re as _re
# Логи в памяти (быстро, не тормозит ответ)
_log_memory = []
# Хранилище чанков в памяти
_chunks_mem = {}
def _log(step, detail=""):
"""Писать лог в память (мгновенно)."""
_log_memory.append({"step": step, "detail": str(detail)[:500], "time": _time.time()})
if len(_log_memory) > 500:
_log_memory.pop(0)
def _save_file_to_db(filename, file_bytes, mime, contract_id, conn=None):
"""Сохранить файл в БД. Если conn передан — использовать его, иначе свои подключения."""
if conn:
cur = conn.cursor()
cur.execute(
"INSERT INTO documents (filename, mime_type, original_bytes, status) VALUES (%s,%s,%s,'uploaded')",
(filename, mime, file_bytes),
)
cur.execute(
"SELECT id FROM documents WHERE filename=%s AND status='uploaded' ORDER BY created_at DESC LIMIT 1",
(filename,),
)
row = cur.fetchone()
doc_id = row[0] if row else None
if doc_id:
cur.execute(
"INSERT INTO supplements (contract_id, document_id, type) VALUES (%s,%s,'initial')",
(str(contract_id), str(doc_id)),
)
conn.commit()
cur.close()
return doc_id
# Без соединения — каждое действие со своим (старый режим)
db.execute(
"INSERT INTO documents (filename, mime_type, original_bytes, status) VALUES (%s,%s,%s,'uploaded')",
(filename, mime, file_bytes),
)
doc, _ = db.query_one(
"SELECT id FROM documents WHERE filename=%s AND status='uploaded' ORDER BY created_at DESC LIMIT 1",
(filename,),
)
doc_id = doc["id"] if doc else None
if doc_id:
db.execute(
"INSERT INTO supplements (contract_id, document_id, type) VALUES (%s,%s,'initial')",
(str(contract_id), str(doc_id)),
)
return doc_id
class ContractsApp: class ContractsApp:
"""
Главный класс приложения.
Собирает Flask, регистрирует маршруты и blueprint'ы.
Запуск: ContractsApp().run() — dev-сервер
gunicorn app:application — production
"""
def __init__(self): def __init__(self):
self.app = Flask(__name__) self.app = Flask(__name__)
# Создать таблицы в БД (IF NOT EXISTS — безопасно)
schema.ensure_schema() schema.ensure_schema()
redis_worker.start_worker()
# Зарегистрировать маршруты
self._add_routes() self._add_routes()
def _add_routes(self): def _add_routes(self):
self.app.add_url_rule("/", "index", self._index, methods=["GET", "POST"]) """Подключить все URL к обработчикам."""
self.app.add_url_rule("/parse/<cid>", "parse", self._parse, methods=["GET"]) # Главные: загрузка, просмотр
self.app.add_url_rule("/chunk_json", "chunk_json", self._chunk_json, methods=["POST"]) self.app.add_url_rule("/", "index", index, methods=["GET", "POST"])
self.app.add_url_rule("/upload", "upload_json", self._upload_json, methods=["POST"])
self.app.add_url_rule("/health", "health", self._health) # Парсинг: SSE-поток
self.app.add_url_rule("/logs", "logs", self._logs) self.app.add_url_rule("/parse/<cid>", "parse", parse)
# LLM: страница + SSE-извлечение
self.app.add_url_rule("/llm", "llm_page", lambda: __import__('flask').render_template('llm.html'))
self.app.add_url_rule("/llm/process/<cid>", "llm_process", llm_process)
self.app.add_url_rule("/llm/chat/<cid>", "llm_chat", llm_chat, methods=["POST"])
self.app.add_url_rule("/llm/prompt/<cid>", "llm_prompt_get", get_prompt)
self.app.add_url_rule("/llm/prompt/<cid>", "llm_prompt_save", save_prompt, methods=["POST"])
# Служебные
self.app.add_url_rule("/health", "health", health)
self.app.add_url_rule("/logs", "logs", logs)
# Blueprint'ы
self.app.register_blueprint(test_bp) self.app.register_blueprint(test_bp)
self.app.register_blueprint(upload_bp) self.app.register_blueprint(upload_bp)
self.app.register_blueprint(api_bp) self.app.register_blueprint(api_bp)
self.app.register_blueprint(v2_bp)
def _health(self):
return "OK", 200, {"Content-Type": "text/plain"}
def _logs(self):
return jsonify(_log_memory[-50:]) # последние 50 записей
# ── Шаг 1: загрузка файлов ──────────────────────────────────
def _index(self):
if request.method == "POST":
return self._upload_files()
cid = request.args.get("id")
if cid:
return self._show_contract(cid)
return render_template("upload.html")
def _upload_files(self):
"""Сохранить файлы. ?cid=X — добавить к существующему договору."""
files = request.files.getlist("files")
if not files or not any(f.filename for f in files):
return jsonify({"error": "Нет файлов"}), 400
cid = request.args.get("cid")
if cid:
contract_id = cid
else:
conn, err = db.connect()
if err:
return jsonify({"error": f"БД: {err}"}), 500
cur = conn.cursor()
cur.execute(
"INSERT INTO contracts (number, client) VALUES (%s, %s) RETURNING id",
("б" + __import__("datetime").datetime.now().strftime("%Y%m%d-%H%M"), ""),
)
contract_id = cur.fetchone()[0]
conn.commit()
cur.close()
db.put_conn(conn)
for f in files:
if not f.filename:
continue
filename = f.filename
mime = mimeutil.guess_mime(filename) or f.content_type or "application/octet-stream"
_save_file_to_db(filename, f.read(), mime, str(contract_id))
return jsonify({"contract_id": str(contract_id)})
# ── Загрузка base64 (JSON) ──────────────────────────────────
def _upload_json(self):
"""Принять файл как base64. Без JSON-парсинга — сырой разбор."""
_log("upload_entry", "start")
try:
raw = request.get_data(as_text=True)
import re
m = re.search(r'"data"\s*:\s*"([^"]*)"', raw)
if not m:
return jsonify({"error": "Нет data"}), 400
b64 = m.group(1)
m = re.search(r'"filename"\s*:\s*"([^"]*)"', raw)
filename = m.group(1) if m else ""
m = re.search(r'"cid"\s*:\s*"([^"]*)"', raw)
cid = m.group(1) if m else None
if not filename or not b64:
return jsonify({"error": "Нет filename или data"}), 400
mime = mimeutil.guess_mime(filename) or "application/octet-stream"
# Контракт — синхронно (быстро, нужно для следующих загрузок)
if not cid:
conn, err = db.connect()
if err:
return jsonify({"error": f"БД: {err}"}), 500
try:
cur = conn.cursor()
cur.execute(
"INSERT INTO contracts (number, client) VALUES (%s, %s) RETURNING id",
("б" + __import__("datetime").datetime.now().strftime("%Y%m%d-%H%M"), ""),
)
cid = cur.fetchone()[0]
conn.commit()
cur.close()
finally:
db.put_conn(conn)
# Файл — в Redis (в фоне, не блокируем ответ)
import threading
task = {"filename": filename, "b64": b64, "mime": mime, "cid": str(cid)}
threading.Thread(target=redis_client.push, args=(task,), daemon=True).start()
return jsonify({"contract_id": str(cid)})
except Exception as e:
_log("upload_outer_error", str(e))
return jsonify({"error": f"Крах: {e}"}), 500
# ── Чанковая загрузка base64 (30KB) ───────────────────────
def _chunk_json(self):
"""Принять чанк base64 (в памяти). На последнем — в Redis."""
data = request.get_json(silent=True) or {}
upload_id = data.get("upload_id", "")
filename = data.get("filename", "")
chunk_index = data.get("chunk_index", 0)
total_chunks = data.get("total_chunks", 1)
b64_piece = data.get("data", "")
cid = data.get("cid")
if not upload_id or not b64_piece:
return jsonify({"error": "Нет upload_id или data"}), 400
if upload_id not in _chunks_mem:
_chunks_mem[upload_id] = {"chunks": [None]*total_chunks, "filename": filename, "cid": cid, "received": 0}
store = _chunks_mem[upload_id]
if store["chunks"][chunk_index] is None:
store["chunks"][chunk_index] = b64_piece
store["received"] += 1
if store["received"] == total_chunks:
full_b64 = "".join(store["chunks"])
fname = store["filename"]
fcid = store["cid"]
mime = mimeutil.guess_mime(fname) or "application/octet-stream"
import threading
task = {"filename": fname, "b64": full_b64, "mime": mime, "cid": fcid or ""}
threading.Thread(target=redis_client.push, args=(task,), daemon=True).start()
del _chunks_mem[upload_id]
return jsonify({"contract_id": fcid or "pending"})
return jsonify({"chunk": chunk_index, "received": store["received"], "total": total_chunks})
# ── Старая чанковая загрузка ───────────────────────────────
def _chunk_upload(self):
"""Принять чанк. Хранить в БД. На последнем — собрать и сохранить."""
upload_id = request.form.get("upload_id", "")
filename = request.form.get("filename", "")
chunk_index = int(request.form.get("chunk_index", 0))
total_chunks = int(request.form.get("total_chunks", 1))
cid = request.form.get("cid") or None
chunk_file = request.files.get("chunk")
if not chunk_file:
return jsonify({"error": "Нет чанка"}), 400
chunk_data = chunk_file.read()
mime = mimeutil.guess_mime(filename) or "application/octet-stream"
_log("chunk_start", f"{filename} idx={chunk_index}/{total_chunks} size={len(chunk_data)} cid={cid}")
# Сохранить чанк в БД (ON CONFLICT — идемпотентно)
rc, err = db.execute(
"INSERT INTO chunks (upload_id, chunk_index, chunk_data, filename, mime, total_chunks, cid) "
"VALUES (%s,%s,%s,%s,%s,%s,%s) ON CONFLICT (upload_id, chunk_index) DO NOTHING",
(upload_id, chunk_index, chunk_data, filename, mime, total_chunks, cid),
)
_log("chunk_insert", f"idx={chunk_index} rc={rc} err={err}")
# Сколько уже получено
count_res, cerr = db.query(
"SELECT COUNT(*) FROM chunks WHERE upload_id=%s", (upload_id,)
)
received = count_res["rows"][0][0] if count_res else 0
_log("chunk_count", f"received={received}/{total_chunks} err={cerr}")
if received == total_chunks:
_log("assembly_start", f"{filename} {total_chunks} chunks, {sum(len(c) if c else 0 for c in [chunk_data])}b this chunk")
try:
conn, err = db.connect()
_log("assembly_connect", f"err={err}")
if err:
return jsonify({"error": f"БД: {err}"}), 500
cur = conn.cursor()
cur.execute(
"SELECT chunk_data FROM chunks WHERE upload_id=%s ORDER BY chunk_index",
(upload_id,),
)
all_chunks = cur.fetchall()
_log("assembly_select", f"{len(all_chunks)} rows")
file_bytes = b"".join(r[0] for r in all_chunks)
_log("assembly_joined", f"{len(file_bytes)} bytes")
cur.execute(
"SELECT filename, mime, cid FROM chunks WHERE upload_id=%s LIMIT 1",
(upload_id,),
)
meta = cur.fetchone()
fname = meta[0] if meta else filename
fmime = meta[1] if meta else mime
fcid = meta[2] if meta else cid
_log("assembly_meta", f"fname={fname} mime={fmime} cid={fcid}")
if fcid:
contract_id = fcid
else:
cur.execute(
"INSERT INTO contracts (number, client) VALUES (%s, %s) RETURNING id",
("б" + __import__("datetime").datetime.now().strftime("%Y%m%d-%H%M"), ""),
)
contract_id = cur.fetchone()[0]
_log("assembly_contract", f"new cid={contract_id}")
conn.commit()
_log("assembly_commit", "ok")
doc_id = _save_file_to_db(fname, file_bytes, fmime, str(contract_id), conn=conn)
_log("save_file", f"doc_id={doc_id}")
if not doc_id:
conn.rollback()
cur.close()
db.put_conn(conn)
return jsonify({"error": "Не удалось сохранить файл в БД"}), 500
cur.close()
db.put_conn(conn)
_log("assembly_done", f"cid={contract_id}")
except Exception as e:
_log("assembly_error", str(e))
try: conn.rollback()
except: pass
try: cur.close()
except: pass
try: db.put_conn(conn)
except: pass
return jsonify({"error": f"Сборка: {e}"}), 500
finally:
db.execute("DELETE FROM chunks WHERE upload_id=%s", (upload_id,))
_log("chunks_cleanup", f"upload_id={upload_id}")
return jsonify({"contract_id": str(contract_id)})
_log("chunk_ok", f"idx={chunk_index} received={received}/{total_chunks}")
return jsonify({"chunk": chunk_index, "received": received, "total": total_chunks})
def _show_contract(self, cid):
"""Показать договор с кнопкой «Обработать»."""
contract, _ = db.query_one("SELECT id,number,created_at FROM contracts WHERE id=%s", (cid,))
if not contract:
return render_template("upload.html", error="Договор не найден")
supps, _ = db.query(
"SELECT s.id, s.created_at, d.filename, d.status, d.parsed_text IS NOT NULL as has_text "
"FROM supplements s JOIN documents d ON s.document_id=d.id "
"WHERE s.contract_id=%s ORDER BY s.created_at",
(cid,),
)
supp_list = [dict(zip(supps["columns"], r)) for r in supps["rows"]] if supps else []
# Есть ли уже результаты?
rows_res, _ = db.query(
"SELECT sr.row_num,sr.name,sr.price,sr.qty,sr.sum,sr.date_start "
"FROM spec_rows sr JOIN supplements s ON sr.supplement_id=s.id "
"WHERE s.contract_id=%s ORDER BY sr.row_num",
(cid,),
)
all_rows = [dict(zip(rows_res["columns"], r)) for r in rows_res["rows"]] if rows_res else []
# Есть необработанные допники?
unprocessed = [s for s in supp_list if s["status"] in ("uploaded", "parsed")]
return render_template("upload.html",
contract=contract, supplements=supp_list,
all_rows=all_rows, unprocessed=unprocessed)
# ── Шаг 2: Парсинг (SSE) ────────────────────────────────────
def _parse(self, cid):
"""SSE-поток: парсинг файлов договора (без LLM)."""
import time as time_mod
def generate():
start_time = time_mod.time()
total_bytes = 0
files_processed = 0
supps, _ = db.query(
"SELECT s.id, d.id as doc_id, d.filename, d.mime_type, "
"LENGTH(d.original_bytes) as file_size "
"FROM supplements s JOIN documents d ON s.document_id=d.id "
"WHERE s.contract_id=%s",
(cid,),
)
if not supps:
yield f"data: {json.dumps({'type': 'error', 'message': 'Нет файлов'})}\n\n"
return
supp_rows = [dict(zip(supps["columns"], r)) for r in supps["rows"]]
def _file_done(name, elapsed, elements, errors, text):
paragraphs = sum(1 for e in elements if e.get("type") == "paragraph")
tables = sum(1 for e in elements if e.get("type") == "table")
table_rows = sum(len(e.get("rows", [])) for e in elements if e.get("type") == "table")
table_headers = [
e["rows"][0] if e.get("rows") else []
for e in elements if e.get("type") == "table"
]
return {
"type": "file_done",
"name": name,
"time_s": elapsed,
"elements": len(elements),
"paragraphs": paragraphs,
"tables": tables,
"table_rows": table_rows,
"table_headers": table_headers,
"errors": errors,
"text_len": len(text) if text else 0,
"text_preview": text[:200] if text else "",
}
for s in supp_rows:
# Пропустить уже распарсенные
existing, _ = db.query(
"SELECT 1 FROM documents WHERE id=%s AND status IN ('parsed','expanded')", (s["doc_id"],)
)
if existing and existing["rows"]:
continue
file_start = time_mod.time()
file_bytes = s.get("file_size", 0) or 0
total_bytes += file_bytes
yield f"data: {json.dumps({'type': 'file_start', 'name': s['filename'], 'bytes': file_bytes})}\n\n"
# Получить байты: сначала original_bytes, иначе original_b64
doc, _ = db.query_one("SELECT original_bytes, original_b64 FROM documents WHERE id=%s", (s["doc_id"],))
raw = doc.get("original_bytes") if doc else None
b64 = doc.get("original_b64") if doc else None
if raw:
file_data = bytes(raw) if isinstance(raw, memoryview) else raw
elif b64:
import base64 as b64mod
file_data = b64mod.b64decode(b64)
else:
yield f"data: {json.dumps({'type': 'file_error', 'name': s['filename'], 'error': 'Нет данных'})}\n\n"
continue
# Парсинг: ZIP — распаковать и парсить каждый файл отдельно
if s["mime_type"] == "application/zip":
import io as io_mod, zipfile as zf_mod
zip_names = []
try:
with zf_mod.ZipFile(io_mod.BytesIO(file_data)) as zf:
for zname in zf.namelist():
if zname.endswith("/"):
continue
zdata = zf.read(zname)
zmime = mimeutil.guess_mime(zname) or "application/octet-stream"
# Только PDF и Word
if zmime not in (
"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/msword",
):
continue
zip_names.append(zname)
# Сохранить как отдельный документ
db.execute(
"INSERT INTO documents (filename, mime_type, original_bytes, status) VALUES (%s,%s,%s,'uploaded')",
(zname, zmime, zdata),
)
zdoc, _ = db.query_one(
"SELECT id FROM documents WHERE filename=%s ORDER BY created_at DESC LIMIT 1",
(zname,),
)
zdoc_id = zdoc["id"] if zdoc else None
if zdoc_id:
db.execute(
"INSERT INTO supplements (contract_id, document_id, type) VALUES (%s,%s,'initial')",
(cid, str(zdoc_id)),
)
# Парсинг внутреннего файла
zf_start = time_mod.time()
total_bytes += len(zdata)
yield f"data: {json.dumps({'type': 'file_start', 'name': zname, 'bytes': len(zdata)})}\n\n"
try:
zpr = parser_mod.parse(zdata, zmime)
zelements = zpr.get("elements", [])
ztext = textify.to_text(zelements) if zelements else ""
db.execute(
"UPDATE documents SET parsed_text=%s, elements_json=%s, status='parsed' WHERE id=%s",
(ztext, json.dumps(zelements, ensure_ascii=False), str(zdoc_id)),
)
zelapsed = round(time_mod.time() - zf_start, 2)
files_processed += 1
yield f"data: {json.dumps(_file_done(zname, zelapsed, zelements, zpr.get('errors',[]), ztext))}\n\n"
except Exception as e:
yield f"data: {json.dumps({'type': 'file_error', 'name': zname, 'error': str(e)})}\n\n"
except Exception as e:
yield f"data: {json.dumps({'type': 'file_error', 'name': s['filename'], 'error': 'ZIP: ' + str(e)})}\n\n"
# ZIP сам — expanded
db.execute("UPDATE documents SET status='expanded' WHERE id=%s", (s["doc_id"],))
yield f"data: {json.dumps({'type': 'zip_expanded', 'name': s['filename'], 'count': len(zip_names)})}\n\n"
else:
pr = parser_mod.parse(file_data, s["mime_type"])
elements = pr.get("elements", [])
text = textify.to_text(elements) if elements else ""
db.execute(
"UPDATE documents SET parsed_text=%s, elements_json=%s, status='parsed' WHERE id=%s",
(text, json.dumps(elements, ensure_ascii=False), str(s["doc_id"])),
)
elapsed = round(time_mod.time() - file_start, 2)
files_processed += 1
yield f"data: {json.dumps(_file_done(s['filename'], elapsed, elements, pr.get('errors',[]), text))}\n\n"
total_time = round(time_mod.time() - start_time, 2)
yield f"data: {json.dumps({'type': 'summary', 'total_time_s': total_time, 'total_bytes': total_bytes, 'files_processed': files_processed})}\n\n"
return Response(generate(), mimetype="text/event-stream")
def run(self): def run(self):
"""Dev-сервер на порту 5000."""
self.app.run(host="0.0.0.0", port=5000) self.app.run(host="0.0.0.0", port=5000)
if __name__ == "__main__": if __name__ == "__main__":
ContractsApp().run() ContractsApp().run()
# Для gunicorn: gunicorn app:application
application = ContractsApp().app
+37 -6
View File
@@ -12,21 +12,37 @@ import json
import llm_client import llm_client
def extract(parsed_text: str) -> dict: def extract(parsed_text: str, custom_prompt: str = None) -> dict:
""" """
Извлечь строки спецификации из текста документа через LLM. Извлечь строки спецификации из текста документа через LLM.
Args: Args:
parsed_text: текст документа (выдача textify.py) parsed_text: текст документа (выдача textify.py)
custom_prompt: свой промпт (если None — используется DEFAULT_PROMPT)
Returns: Returns:
{"rows": [{row_num, name, price, qty, sum, date_start}, ...], "unresolved": [...]} {"rows": [...], "unresolved": [...]} или {"error": "..."}
или
{"error": "...", "raw_response": "..."}
""" """
# ── Промпт ────────────────────────────────────────────────── prompt = custom_prompt or DEFAULT_PROMPT
prompt = f"""Ты — анализатор договоров. Ниже текст спецификации услуг из договора облачного провайдера. prompt = prompt.replace("{parsed_text}", parsed_text)
"""
extractor.py — Извлечение строк спецификации из текста документа.
Берёт распарсенный текст (parsed_text из documents), отправляет LLM,
получает структурированный список строк спецификации.
Не зависит от parser.py, textify.py, upload.py.
Зависит только от llm_client.py.
"""
import json
import llm_client
# ── Промпт по умолчанию ─────────────────────────────────────────
# {parsed_text} будет заменён на текст документа
DEFAULT_PROMPT = """Ты — анализатор договоров. Ниже текст спецификации услуг из договора облачного провайдера.
Найди ВСЕ таблицы со списком услуг. Извлеки каждую строку в JSON-массив. Найди ВСЕ таблицы со списком услуг. Извлеки каждую строку в JSON-массив.
@@ -53,6 +69,21 @@ def extract(parsed_text: str) -> dict:
--- ---
""" """
def extract(parsed_text: str, custom_prompt: str = None) -> dict:
"""
Извлечь строки спецификации из текста документа через LLM.
Args:
parsed_text: текст документа (выдача textify.py)
custom_prompt: свой промпт (если None — DEFAULT_PROMPT).
Должен содержать {parsed_text} для подстановки текста.
Returns:
{"rows": [...], "unresolved": [...]} или {"error": "..."}
"""
prompt = (custom_prompt or DEFAULT_PROMPT).replace("{parsed_text}", parsed_text)
# ── Вызов LLM ──────────────────────────────────────────────── # ── Вызов LLM ────────────────────────────────────────────────
result = llm_client.ask(prompt, max_tokens=8000) result = llm_client.ask(prompt, max_tokens=8000)
+74 -1
View File
@@ -122,12 +122,22 @@ def _parse_doc(file_bytes: bytes) -> dict:
# ── PDF ── # ── PDF ──
def _parse_pdf(file_bytes: bytes) -> dict: def _parse_pdf(file_bytes: bytes) -> dict:
"""
Извлечь текст и таблицы из PDF через pdfplumber.
Таблицы: только с видимыми рамками (lines). Безрамковые (text)
дают слишком много ложных срабатываний — не используем.
Отбрасываем таблицы где >50% ячеек пусты или где все ячейки
содержат фрагменты одной строки текста.
"""
result = {"elements": [], "errors": []} result = {"elements": [], "errors": []}
try: try:
import pdfplumber import pdfplumber
with pdfplumber.open(io.BytesIO(file_bytes)) as pdf: with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
for page in pdf.pages: for page in pdf.pages:
pn = page.page_number pn = page.page_number
# ── Текст: каждая непустая строка → paragraph ──
text = page.extract_text() text = page.extract_text()
if text: if text:
for line in text.split("\n"): for line in text.split("\n"):
@@ -139,19 +149,82 @@ def _parse_pdf(file_bytes: bytes) -> dict:
"text": line, "text": line,
"page": pn, "page": pn,
}) })
# ── Таблицы: с рамками + без рамок ──
tables = page.extract_tables() tables = page.extract_tables()
found_any = False
if tables:
for tbl in tables: for tbl in tables:
if tbl: if tbl and _table_has_content(tbl):
result["elements"].append({ result["elements"].append({
"type": "table", "type": "table",
"rows": tbl, "rows": tbl,
"page": pn, "page": pn,
}) })
found_any = True
# Если рамок нет — пробуем text strategy с жёстким фильтром
if not found_any:
tbl_text = page.extract_tables({
"vertical_strategy": "text",
"horizontal_strategy": "text",
})
if tbl_text:
for tbl in tbl_text:
if tbl and _table_has_content(tbl, min_cols=3):
result["elements"].append({
"type": "table",
"rows": tbl,
"page": pn,
})
except Exception as e: except Exception as e:
result["errors"].append(f"pdfplumber: {e}") result["errors"].append(f"pdfplumber: {e}")
return result return result
def _table_has_content(tbl: list, min_fill_ratio: float = 0.0, min_cols: int = 0) -> bool:
"""
Проверить что таблица содержит осмысленные ТАБЛИЧНЫЕ данные.
Отбрасываем:
- Полностью пустые
- Менее 2 строк
- Менее min_cols колонок (если задано)
- Менее 4 заполненных ячеек
- Менее 40% ячеек заполнены
"""
if not tbl or not tbl[0]:
return False
if len(tbl) <= 1:
return False
# Проверить минимальное число колонок
if min_cols > 0 and len(tbl[0]) < min_cols:
return False
total = 0
filled = 0
for row in tbl:
if not row:
continue
for cell in row:
total += 1
cell_str = str(cell).strip() if cell else ""
if cell_str:
filled += 1
if filled < 4:
return False
if total > 0 and filled / total < 0.4:
return False
return True
# ── ZIP ── # ── ZIP ──
def _parse_zip(file_bytes: bytes) -> dict: def _parse_zip(file_bytes: bytes) -> dict:
-33
View File
@@ -1,33 +0,0 @@
"""redis_client.py — Очередь загрузок через Redis."""
import os, json, redis
UPLOAD_QUEUE = "contracts:upload_queue"
_r = None
def _get():
global _r
if _r is None:
_r = redis.Redis(
host=os.getenv("REDIS_HOST", "redisk8s.f6303a9d-4ab1-4b2f-8aa8-08f933d02f82.svc.cluster.local"),
port=int(os.getenv("REDIS_PORT", "6379")),
password=os.getenv("REDIS_PASS", "aLITloRefJEiCPqUc2xB"),
decode_responses=True,
socket_connect_timeout=5,
socket_timeout=10,
)
return _r
def push(task: dict):
"""Добавить задание в очередь."""
_get().rpush(UPLOAD_QUEUE, json.dumps(task, ensure_ascii=False))
def pop(timeout=5):
"""Взять задание из очереди (блокирующий)."""
result = _get().blpop(UPLOAD_QUEUE, timeout=timeout)
if result:
return json.loads(result[1])
return None
-69
View File
@@ -1,69 +0,0 @@
"""redis_worker.py — Фоновый обработчик очереди загрузок."""
import threading, time, base64, json
import db, mimeutil, parser as parser_mod, textify
def start_worker():
"""Запустить фоновый поток обработки очереди."""
t = threading.Thread(target=_worker_loop, daemon=True)
t.start()
def _worker_loop():
from redis_client import pop as queue_pop
while True:
try:
task = queue_pop(timeout=5)
if task:
_process(task)
except Exception as e:
time.sleep(1)
def _process(task):
filename = task.get("filename", "")
b64 = task.get("b64", "")
mime = task.get("mime", "application/octet-stream")
cid = task.get("cid", "")
if not filename or not b64:
return
# Декодировать и сохранить в БД
file_bytes = base64.b64decode(b64)
conn, err = db.connect()
if err:
return
try:
cur = conn.cursor()
cur.execute(
"INSERT INTO documents (filename, mime_type, original_bytes, original_b64, status) VALUES (%s,%s,%s,%s,'uploaded')",
(filename, mime, file_bytes, b64),
)
cur.execute("SELECT id FROM documents WHERE filename=%s AND status='uploaded' ORDER BY created_at DESC LIMIT 1", (filename,))
row = cur.fetchone()
doc_id = row[0] if row else None
if doc_id:
cur.execute(
"INSERT INTO supplements (contract_id, document_id, type) VALUES (%s,%s,'initial')",
(cid, str(doc_id)),
)
# Парсинг
pr = parser_mod.parse(file_bytes, mime)
elements = pr.get("elements", [])
if mime == "application/zip" and "files" in pr:
for zf in pr["files"]:
elements.extend(zf.get("elements", []))
text = textify.to_text(elements) if elements else ""
cur.execute(
"UPDATE documents SET parsed_text=%s, elements_json=%s, status='parsed' WHERE id=%s",
(text, json.dumps(elements, ensure_ascii=False), str(doc_id)),
)
conn.commit()
cur.close()
except Exception as e:
try: conn.rollback()
except: pass
finally:
db.put_conn(conn)
+1
View File
@@ -0,0 +1 @@
"""routes/__init__.py — Пакет маршрутов."""
+216
View File
@@ -0,0 +1,216 @@
"""routes/llm.py — LLM-извлечение + сравнение + чат."""
import json as _json
import time as _time
from flask import Response, request, jsonify
import db
import extractor
import differ
import llm_client
def process(cid):
"""
GET /llm/process/<cid> — SSE-поток полного цикла:
1. Для каждого файла договора: parsed_text → LLM → spec_rows
2. Сравнение допников через differ.diff()
3. Результат → SSE-сообщения в браузер
"""
def generate():
start_time = _time.time()
# ── Получить все файлы договора ─────────────────────
supps, _ = db.query(
"SELECT s.id as supp_id, s.type, d.id as doc_id, d.filename, d.parsed_text "
"FROM supplements s JOIN documents d ON s.document_id = d.id "
"WHERE s.contract_id = %s AND d.parsed_text IS NOT NULL "
"ORDER BY s.created_at",
(cid,),
)
if not supps:
yield f"data: {_json.dumps({'type': 'error', 'message': 'Нет распарсенных файлов. Сначала нажмите Парсинг.'})}\n\n"
return
supp_rows = [dict(zip(supps["columns"], r)) for r in supps["rows"]]
extracted = {} # supp_id → [rows]
# ── Шаг 1: LLM-извлечение для каждого файла ─────────
for s in supp_rows:
# Пропустить уже извлечённые
existing, _ = db.query(
"SELECT 1 FROM spec_rows WHERE supplement_id = %s LIMIT 1",
(s["supp_id"],),
)
if existing and existing["rows"]:
# Загрузить существующие
rows_res, _ = db.query(
"SELECT row_num, name, price, qty, sum, date_start "
"FROM spec_rows WHERE supplement_id = %s ORDER BY row_num",
(s["supp_id"],),
)
rows = [dict(zip(rows_res["columns"], r)) for r in rows_res["rows"]] if rows_res else []
extracted[s["supp_id"]] = rows
yield f"data: {_json.dumps({'type': 'extract_skip', 'name': s['filename'], 'reason': 'уже извлечено', 'count': len(rows)})}\n\n"
continue
yield f"data: {_json.dumps({'type': 'extract_start', 'name': s['filename']})}\n\n"
t0 = _time.time()
custom_prompt = _prompts.get(cid) or None
result = extractor.extract(s["parsed_text"], custom_prompt=custom_prompt)
if "error" in result:
yield f"data: {_json.dumps({'type': 'extract_error', 'name': s['filename'], 'error': result['error']})}\n\n"
continue
rows = result.get("rows", [])
unresolved = result.get("unresolved", [])
elapsed = round(_time.time() - t0, 2)
# Сохранить в БД
db.execute("DELETE FROM spec_rows WHERE supplement_id = %s", (s["supp_id"],))
for row in rows:
db.execute(
"INSERT INTO spec_rows (supplement_id, row_num, name, price, qty, sum, date_start) "
"VALUES (%s, %s, %s, %s, %s, %s, %s)",
(
s["supp_id"],
row.get("row_num"),
row.get("name"),
row.get("price"),
row.get("qty"),
row.get("sum"),
row.get("date_start"),
),
)
extracted[s["supp_id"]] = rows
yield f"data: {_json.dumps({'type': 'extract_done', 'name': s['filename'], 'time_s': elapsed, 'count': len(rows), 'unresolved': unresolved})}\n\n"
# ── Шаг 2: Сравнение ────────────────────────────────
yield f"data: {_json.dumps({'type': 'diff_start', 'files': len(supp_rows)})}\n\n"
# Группируем: ищем amendments, если нет — сравниваем первый с остальными
initial_rows = None
amendments = []
for s in supp_rows:
if s["type"] != "initial":
amendments.append({
"supp_id": s["supp_id"],
"filename": s["filename"],
"type": s["type"],
"rows": extracted.get(s["supp_id"], []),
})
elif initial_rows is None:
# Первый initial — базовый
initial_rows = extracted.get(s["supp_id"], [])
else:
# Последующие initial — как допники (fallback)
amendments.append({
"supp_id": s["supp_id"],
"filename": s["filename"],
"type": "initial",
"rows": extracted.get(s["supp_id"], []),
})
all_changes = []
if initial_rows is not None:
for am in amendments:
if not am["rows"]:
continue
d = differ.diff(initial_rows, am["rows"])
changes = d.get("changes", [])
summary = d.get("summary", {})
all_changes.append({
"filename": am["filename"],
"type": am["type"],
"changes": changes,
"summary": summary,
})
yield f"data: {_json.dumps({'type': 'diff_file', 'name': am['filename'], 'changes': len(changes), 'summary': summary})}\n\n"
# ── Итог ────────────────────────────────────────────
total_time = round(_time.time() - start_time, 2)
yield f"data: {_json.dumps({'type': 'done', 'total_time_s': total_time, 'all_changes': all_changes})}\n\n"
return Response(generate(), mimetype="text/event-stream")
# ── Хранилище промптов (в памяти) ──────────────────────────────
_prompts = {} # cid → prompt
def save_prompt(cid):
"""POST /llm/prompt/<cid> — сохранить промпт. Тело: {"prompt": "..."}"""
data = request.get_json(silent=True) or {}
prompt = data.get("prompt", "").strip()
if prompt:
_prompts[cid] = prompt
return jsonify({"ok": True})
return jsonify({"error": "пустой промпт"}), 400
def get_prompt(cid):
"""GET /llm/prompt/<cid> — получить сохранённый промпт (или DEFAULT_PROMPT)"""
import extractor
return jsonify({"prompt": _prompts.get(cid, "")})
def chat(cid):
"""
POST /llm/chat/<cid> — задать вопрос по договору.
Тело: {"question": "..."}
Контекст: все строки spec_rows этого договора.
Ответ: {"answer": "..."}
"""
data = request.get_json(silent=True) or {}
question = data.get("question", "").strip()
if not question:
return jsonify({"error": "Введите вопрос"}), 400
# ── Собрать контекст: все строки спецификации ──────────
rows_res, _ = db.query(
"SELECT s.type, s.number, d.filename, sr.row_num, sr.name, sr.price, sr.qty, sr.sum, sr.date_start "
"FROM spec_rows sr "
"JOIN supplements s ON sr.supplement_id = s.id "
"JOIN documents d ON s.document_id = d.id "
"WHERE s.contract_id = %s ORDER BY s.created_at, sr.row_num",
(cid,),
)
if not rows_res or not rows_res["rows"]:
return jsonify({"answer": "Нет извлечённых данных. Сначала запустите сравнение."})
# Форматируем контекст
lines = []
for r in rows_res["rows"]:
row = dict(zip(rows_res["columns"], r))
lines.append(
f"[{row['filename']}] строка {row['row_num']}: {row['name']} | "
f"цена={row['price']} | объём={row['qty']} | сумма={row['sum']} | начало={row['date_start']}"
)
context = "\n".join(lines)
# ── Промпт ──────────────────────────────────────────────
prompt = f"""Ты — анализатор договоров облачного провайдера. У тебя есть извлечённые строки спецификации услуг.
ДАННЫЕ:
{context}
ВОПРОС ПОЛЬЗОВАТЕЛЯ:
{question}
Ответь кратко и по делу, опираясь ТОЛЬКО на данные выше. Если данных недостаточно — скажи об этом."""
# ── Вызов LLM ───────────────────────────────────────────
result = llm_client.ask(prompt, max_tokens=1000)
if "error" in result:
return jsonify({"answer": f"Ошибка LLM: {result['error']}"})
return jsonify({"answer": result.get("text", "Нет ответа")})
+156
View File
@@ -0,0 +1,156 @@
"""routes/main.py — Главные маршруты: загрузка файлов, просмотр договора."""
import datetime
from flask import render_template, request, jsonify
import db
import mimeutil
def _save_file_to_db(filename, file_bytes, mime, contract_id, conn=None):
"""
Сохранить загруженный файл в БД.
Создаёт запись в documents и привязывает к договору через supplements.
Если conn передан — используется одно соединение для всей транзакции.
Иначе — каждый запрос в своём соединении.
Возвращает doc_id или None при ошибке.
"""
if conn:
# Транзакционный режим: все операции в одном соединении
cur = conn.cursor()
cur.execute(
"INSERT INTO documents (filename, mime_type, original_bytes, status) VALUES (%s,%s,%s,'uploaded')",
(filename, mime, file_bytes),
)
cur.execute(
"SELECT id FROM documents WHERE filename=%s AND status='uploaded' ORDER BY created_at DESC LIMIT 1",
(filename,),
)
row = cur.fetchone()
doc_id = row[0] if row else None
if doc_id:
cur.execute(
"INSERT INTO supplements (contract_id, document_id, type) VALUES (%s,%s,'initial')",
(str(contract_id), str(doc_id)),
)
conn.commit()
cur.close()
return doc_id
# Автономный режим: каждый вызов db.execute сам управляет соединением
db.execute(
"INSERT INTO documents (filename, mime_type, original_bytes, status) VALUES (%s,%s,%s,'uploaded')",
(filename, mime, file_bytes),
)
doc, _ = db.query_one(
"SELECT id FROM documents WHERE filename=%s AND status='uploaded' ORDER BY created_at DESC LIMIT 1",
(filename,),
)
doc_id = doc["id"] if doc else None
if doc_id:
db.execute(
"INSERT INTO supplements (contract_id, document_id, type) VALUES (%s,%s,'initial')",
(str(contract_id), str(doc_id)),
)
return doc_id
def index():
"""
GET / — страница загрузки
GET /?id=<cid> — просмотр договора
POST / — приём файлов (multipart/form-data)
"""
if request.method == "POST":
return _upload_files()
cid = request.args.get("id")
if cid:
return _show_contract(cid)
return render_template("upload.html")
def _upload_files():
"""
Принять файлы через multipart/form-data.
Поле формы: 'files' (один или несколько).
?cid=X — добавить файлы к существующему договору.
Если cid не указан — создаётся новый договор.
"""
files = request.files.getlist("files")
if not files or not any(f.filename for f in files):
return jsonify({"error": "Нет файлов"}), 400
cid = request.args.get("cid") or request.form.get("cid")
if cid:
# Добавляем к существующему договору
contract_id = cid
else:
# Создаём новый договор
conn, err = db.connect()
if err:
return jsonify({"error": f"БД: {err}"}), 500
cur = conn.cursor()
cur.execute(
"INSERT INTO contracts (number, client) VALUES (%s, %s) RETURNING id",
("б" + datetime.datetime.now().strftime("%Y%m%d-%H%M"), ""),
)
contract_id = cur.fetchone()[0]
conn.commit()
cur.close()
db.put_conn(conn)
# Сохраняем каждый файл
for f in files:
if not f.filename:
continue
filename = f.filename
mime = mimeutil.guess_mime(filename) or f.content_type or "application/octet-stream"
_save_file_to_db(filename, f.read(), mime, str(contract_id))
return jsonify({"contract_id": str(contract_id)})
def _show_contract(cid):
"""
Показать страницу договора со списком файлов и кнопкой «Парсинг».
Загружает: информацию о договоре, список допников/файлов,
существующие строки спецификации (если уже извлечены).
"""
contract, _ = db.query_one(
"SELECT id, number, created_at FROM contracts WHERE id = %s",
(cid,),
)
if not contract:
return render_template("upload.html", error="Договор не найден")
# Все файлы, привязанные к договору
supps, _ = db.query(
"SELECT s.id, s.created_at, d.filename, d.status, d.parsed_text IS NOT NULL as has_text "
"FROM supplements s JOIN documents d ON s.document_id = d.id "
"WHERE s.contract_id = %s ORDER BY s.created_at",
(cid,),
)
supp_list = [dict(zip(supps["columns"], r)) for r in supps["rows"]] if supps else []
# Уже извлечённые строки спецификации
rows_res, _ = db.query(
"SELECT sr.row_num, sr.name, sr.price, sr.qty, sr.sum, sr.date_start "
"FROM spec_rows sr JOIN supplements s ON sr.supplement_id = s.id "
"WHERE s.contract_id = %s ORDER BY sr.row_num",
(cid,),
)
all_rows = [dict(zip(rows_res["columns"], r)) for r in rows_res["rows"]] if rows_res else []
# Файлы, готовые к парсингу
unprocessed = [s for s in supp_list if s["status"] in ("uploaded", "parsed")]
return render_template("upload.html",
contract=contract,
supplements=supp_list,
all_rows=all_rows,
unprocessed=unprocessed,
)
+33
View File
@@ -0,0 +1,33 @@
"""routes/misc.py — Служебные маршруты: health, логи."""
from flask import jsonify
# Логи в памяти — здесь чтобы routes/main.py тоже мог писать
import time as _time
_log_memory = []
def log(step, detail=""):
"""
Записать шаг в оперативную память (мгновенно, без БД).
Используется для отладки. Лимит: 500 записей, старые вытесняются.
"""
_log_memory.append({"step": step, "detail": str(detail)[:500], "time": _time.time()})
if len(_log_memory) > 500:
_log_memory.pop(0)
def health():
"""
GET /health — проверка живости.
Всегда возвращает 200 OK.
"""
return "OK", 200, {"Content-Type": "text/plain"}
def logs():
"""
GET /logs — последние 50 записей отладочного лога.
"""
return jsonify(_log_memory[-50:])
+193
View File
@@ -0,0 +1,193 @@
"""routes/parse.py — SSE-парсинг: потоковый разбор документов договора."""
import json as _json
import time as _time
import io as _io
import zipfile as _zipfile
import base64 as _b64
from flask import Response
import db
import parser as parser_mod
import textify
import mimeutil
def parse(cid):
"""
GET /parse/<cid> — Server-Sent Events поток.
Для каждого файла договора:
1. Достаёт байты из БД (original_bytes или original_b64)
2. Парсит через parser.parse()
3. Сохраняет parsed_text + elements_json в БД
4. Отправляет SSE-сообщения: file_start → file_done → summary
ZIP-файлы распаковываются, каждый внутренний файл парсится отдельно.
"""
def generate():
start_time = _time.time()
total_bytes = 0
files_processed = 0
# ── Получить все файлы договора ─────────────────────
supps, _ = db.query(
"SELECT s.id, d.id as doc_id, d.filename, d.mime_type, "
"LENGTH(d.original_bytes) as file_size "
"FROM supplements s JOIN documents d ON s.document_id = d.id "
"WHERE s.contract_id = %s",
(cid,),
)
if not supps:
yield f"data: {_json.dumps({'type': 'error', 'message': 'Нет файлов'})}\n\n"
return
supp_rows = [dict(zip(supps["columns"], r)) for r in supps["rows"]]
# ── Вспомогательная: собрать результат парсинга ─────
def _file_done(name, elapsed, elements, errors, text):
"""
Сформировать SSE-сообщение file_done со статистикой:
количество параграфов, таблиц, строк таблиц, заголовки таблиц.
"""
paragraphs = sum(1 for e in elements if e.get("type") == "paragraph")
tables = sum(1 for e in elements if e.get("type") == "table")
table_rows = sum(len(e.get("rows", [])) for e in elements if e.get("type") == "table")
table_headers = [
e["rows"][0] if e.get("rows") else []
for e in elements if e.get("type") == "table"
]
return {
"type": "file_done",
"name": name,
"time_s": elapsed,
"elements": len(elements),
"paragraphs": paragraphs,
"tables": tables,
"table_rows": table_rows,
"table_headers": table_headers,
"errors": errors,
"text_len": len(text) if text else 0,
"text_preview": text[:200] if text else "",
}
# ── Основной цикл: каждый файл ──────────────────────
for s in supp_rows:
# Пропустить уже распарсенные
existing, _ = db.query(
"SELECT 1 FROM documents WHERE id = %s AND status IN ('parsed', 'expanded')",
(s["doc_id"],),
)
if existing and existing["rows"]:
continue
file_start = _time.time()
file_bytes = s.get("file_size", 0) or 0
total_bytes += file_bytes
# SSE: начало обработки файла
yield f"data: {_json.dumps({'type': 'file_start', 'name': s['filename'], 'bytes': file_bytes})}\n\n"
# ── Получить байты файла ────────────────────────
doc, _ = db.query_one(
"SELECT original_bytes, original_b64 FROM documents WHERE id = %s",
(s["doc_id"],),
)
raw = doc.get("original_bytes") if doc else None
b64 = doc.get("original_b64") if doc else None
if raw:
# Обычный файл: original_bytes
file_data = bytes(raw) if isinstance(raw, memoryview) else raw
elif b64:
# Старый формат: base64
file_data = _b64.b64decode(b64)
else:
yield f"data: {_json.dumps({'type': 'file_error', 'name': s['filename'], 'error': 'Нет данных'})}\n\n"
continue
# ── ZIP: распаковать и парсить каждый файл ──────
if s["mime_type"] == "application/zip":
zip_names = []
try:
with _zipfile.ZipFile(_io.BytesIO(file_data)) as zf:
for zname in zf.namelist():
if zname.endswith("/"):
continue # пропускаем папки
zdata = zf.read(zname)
zmime = mimeutil.guess_mime(zname) or "application/octet-stream"
# Только PDF и Word внутри ZIP
if zmime not in (
"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/msword",
):
continue
zip_names.append(zname)
# Сохранить как отдельный документ
db.execute(
"INSERT INTO documents (filename, mime_type, original_bytes, status) VALUES (%s,%s,%s,'uploaded')",
(zname, zmime, zdata),
)
zdoc, _ = db.query_one(
"SELECT id FROM documents WHERE filename = %s ORDER BY created_at DESC LIMIT 1",
(zname,),
)
zdoc_id = zdoc["id"] if zdoc else None
if zdoc_id:
db.execute(
"INSERT INTO supplements (contract_id, document_id, type) VALUES (%s,%s,'initial')",
(cid, str(zdoc_id)),
)
# Парсинг внутреннего файла
zf_start = _time.time()
total_bytes += len(zdata)
yield f"data: {_json.dumps({'type': 'file_start', 'name': zname, 'bytes': len(zdata)})}\n\n"
try:
zpr = parser_mod.parse(zdata, zmime)
zelements = zpr.get("elements", [])
ztext = textify.to_text(zelements) if zelements else ""
db.execute(
"UPDATE documents SET parsed_text = %s, elements_json = %s, status = 'parsed' WHERE id = %s",
(ztext, _json.dumps(zelements, ensure_ascii=False), str(zdoc_id)),
)
zelapsed = round(_time.time() - zf_start, 2)
files_processed += 1
yield f"data: {_json.dumps(_file_done(zname, zelapsed, zelements, zpr.get('errors', []), ztext))}\n\n"
except Exception as e:
yield f"data: {_json.dumps({'type': 'file_error', 'name': zname, 'error': str(e)})}\n\n"
except Exception as e:
yield f"data: {_json.dumps({'type': 'file_error', 'name': s['filename'], 'error': 'ZIP: ' + str(e)})}\n\n"
# Пометить ZIP как expanded
db.execute("UPDATE documents SET status = 'expanded' WHERE id = %s", (s["doc_id"],))
yield f"data: {_json.dumps({'type': 'zip_expanded', 'name': s['filename'], 'count': len(zip_names)})}\n\n"
else:
# ── Обычный файл (PDF/DOCX) ─────────────────
pr = parser_mod.parse(file_data, s["mime_type"])
elements = pr.get("elements", [])
text = textify.to_text(elements) if elements else ""
# Сохранить результат в БД
db.execute(
"UPDATE documents SET parsed_text = %s, elements_json = %s, status = 'parsed' WHERE id = %s",
(text, _json.dumps(elements, ensure_ascii=False), str(s["doc_id"])),
)
elapsed = round(_time.time() - file_start, 2)
files_processed += 1
yield f"data: {_json.dumps(_file_done(s['filename'], elapsed, elements, pr.get('errors', []), text))}\n\n"
# ── Итоговое сообщение ──────────────────────────────
total_time = round(_time.time() - start_time, 2)
yield f"data: {_json.dumps({'type': 'summary', 'total_time_s': total_time, 'total_bytes': total_bytes, 'files_processed': files_processed})}\n\n"
return Response(generate(), mimetype="text/event-stream")
+673
View File
@@ -0,0 +1,673 @@
<!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="{{ url_for('static', filename='favicon.png') }}">
<script src="https://unpkg.com/lucide@latest"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></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; }
.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: hidden; 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; }
.zip-child .name-cell { padding-left: 28px; }
.zip-child .name-cell::before { content: "└ "; color: var(--muted); }
.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: var(--muted); background: none; border: none; padding: 2px 4px; font-size: 16px; line-height: 1; }
.remove-btn:hover { color: var(--destructive); }
.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; }
.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-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; }
.modal-body pre { background: var(--brand-grey-light); padding: 10px; border-radius: 6px; font-size: 12px; max-height: 200px; overflow-y: auto; white-space: pre-wrap; margin-top: 4px; }
.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; }
</style>
</head>
<body>
<div class="topbar">
<img src="{{ url_for('static', filename='nubes-logo.svg') }}" alt="Nubes">
<span class="title">Сверка договоров — LLM <span style="font-weight:400;color:var(--muted);font-size:12px;">v3.2.1</span></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:12px;width:100%;">
<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="parseBtn" style="width:100%;justify-content:center;margin-top:12px;" disabled>
<i data-lucide="play" style="width:16px;height:16px;"></i> Парсинг
</button>
<button class="btn btn-primary" id="llmBtn" style="width:100%;justify-content:center;margin-top:8px;display:none;">
<i data-lucide="scale" style="width:16px;height:16px;"></i> Сравнить (LLM)
</button>
<details style="margin-top:8px;font-size:12px;">
<summary style="cursor:pointer;color:var(--muted);">⚙ Промпт LLM</summary>
<textarea id="promptEditor" style="width:100%;height:200px;font-family:monospace;font-size:11px;border:1px solid var(--brand-gray);border-radius:6px;padding:8px;margin-top:4px;" placeholder="Промпт для LLM..."></textarea>
<div style="display:flex;gap:8px;margin-top:4px;">
<button class="btn" id="promptSave" style="font-size:11px;height:28px;">Сохранить</button>
<button class="btn" id="promptReset" style="font-size:11px;height:28px;">По умолчанию</button>
<span id="promptStatus" style="font-size:11px;color:var(--muted);line-height:28px;"></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="chatCard" style="display:none;margin-top:16px;">
<div class="card-header">
<i data-lucide="message-circle" style="width:18px;height:18px;"></i>
Задать вопрос по договору
</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>
<script>
lucide.createIcons();
const DEFAULT_PROMPT = `Ты — анализатор договоров. Ниже текст спецификации услуг из договора облачного провайдера.
Найди ВСЕ таблицы со списком услуг. Извлеки каждую строку в JSON-массив.
Для каждой строки верни:
- row_num: номер по порядку (целое число)
- name: полное наименование услуги (весь текст из ячейки)
- price: цена за единицу в рублях (число, из колонки "Цена")
- qty: количество/объём (число, из колонки "Объем")
- sum: итоговая сумма (число, из колонки "Сумма")
- date_start: дата начала оказания (строка YYYY-MM-DD)
ПРАВИЛА:
1. Пропускай итоговые строки ("Итого...") и строки с подписями.
2. Если ячейка пустая — ставь null.
3. Если не можешь определить значение — ставь null и добавь в unresolved.
4. Верни ТОЛЬКО JSON, без пояснений.
Пример ответа:
{"rows":[{"row_num":1,"name":"Аренда стойко-места","price":213905.44,"qty":3,"sum":641716.32,"date_start":"2026-04-01"}],"unresolved":[]}
Текст документа:
---
{parsed_text}
---`;
const fileInput = document.getElementById('fileInput');
const parseBtn = document.getElementById('parseBtn');
const fileTable = document.getElementById('fileTable');
let fileQueue = [];
let contractId = null;
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';
}
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'});
}
function renderTable() {
if (fileQueue.length === 0) {
fileTable.innerHTML = '<tr class="empty-row"><td colspan="6">Нет файлов — выберите .docx / .pdf / .zip</td></tr>';
parseBtn.disabled = true;
} else {
fileTable.innerHTML = fileQueue.map(function(f, i) {
var cls = f.isZipChild ? 'zip-child' : '';
return '<tr class="' + cls + '" id="row_' + i + '">' +
'<td class="name-cell">' + 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">' + (f.status || '') + '</td>' +
'<td><button class="info-btn" id="info_' + i + '" onclick="showInfo(' + i + ')" title="Инфо"><i data-lucide="info" 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>';
}).join('');
}
}
function removeFile(index) {
var f = fileQueue[index];
if (!f) return;
if (!f.isZipChild && (f.name||'').toLowerCase().endsWith('.zip')) {
for (var i = fileQueue.length - 1; i >= 0; i--) {
if (fileQueue[i].zipName === f.name) fileQueue.splice(i, 1);
}
}
fileQueue.splice(index, 1);
if (fileQueue.filter(function(x){return !x.isZipChild;}).length === 0) contractId = null;
renderTable();
}
window.removeFile = removeFile;
// ── Простая загрузка FormData (ВМ, без лимитов) ──────────
function uploadFile(file, onProgress) {
return new Promise(function(resolve, reject) {
var fd = new FormData();
fd.append('files', file);
if (contractId) fd.append('cid', contractId);
var xhr = new XMLHttpRequest();
xhr.open('POST', '/');
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.contract_id) 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 = 120000;
xhr.send(fd);
});
}
// ── Выбор файла → сразу загрузка ────────────────────────────
fileInput.addEventListener('change', async function() {
var newFiles = Array.from(fileInput.files);
if (newFiles.length === 0) return;
fileInput.disabled = true;
parseBtn.disabled = true;
parseBtn.innerHTML = '<span style="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;"></span> Загрузка...';
for (var i = 0; i < newFiles.length; i++) {
var f = newFiles[i];
if (fileQueue.find(function(q) { return q.name === f.name && q.size === f.size && !q.isZipChild; })) continue;
var entry = { name: f.name, lastModified: f.lastModified, size: f.size, status: '<span style="color:var(--muted);">↑ 0%</span>' };
fileQueue.push(entry);
var rowIdx = fileQueue.length - 1;
renderTable();
var startTime = Date.now();
try {
var resp = await uploadFile(f, function(pct) {
fileQueue[rowIdx].status = '<span style="color:var(--muted);">↑ ' + pct + '%</span>';
renderTable();
});
if (resp && resp.contract_id) contractId = resp.contract_id;
var elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
fileQueue[rowIdx].status = '<span class="status-ok">✓ ' + elapsed + 'с</span>';
fileQueue[rowIdx].uploaded = true;
renderTable();
if (f.type === 'application/zip' || f.name.toLowerCase().endsWith('.zip')) {
await showZipContents(f);
}
} catch(err) {
fileQueue[rowIdx].status = '<span class="status-err">✗ ' + err.message + '</span>';
renderTable();
}
}
fileInput.value = '';
fileInput.disabled = false;
parseBtn.disabled = (contractId === null);
parseBtn.innerHTML = '<i data-lucide="play" style="width:16px;height:16px;"></i> Парсинг';
lucide.createIcons();
});
async function showZipContents(zipFile) {
try {
var zip = await JSZip.loadAsync(zipFile);
zip.forEach(function(relativePath, zipEntry) {
if (!zipEntry.dir) {
var ext = relativePath.toLowerCase().split('.').pop();
if (['docx','doc','pdf'].indexOf(ext) === -1) return;
var zsize = (zipEntry._data && zipEntry._data.uncompressedSize) ? zipEntry._data.uncompressedSize : 0;
fileQueue.push({
name: relativePath,
lastModified: zipEntry.date ? zipEntry.date.getTime() : 0,
size: zsize,
status: '',
isZipChild: true,
zipName: zipFile.name
});
}
});
renderTable();
} catch(e) {
console.log('JSZip error:', e);
}
}
// ── Парсинг (SSE) ────────────────────────────────────────────
parseBtn.addEventListener('click', function() {
if (!contractId) return;
parseBtn.disabled = true;
parseBtn.innerHTML = '<span style="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;"></span> Парсинг...';
var es = new EventSource('/parse/' + contractId);
var timerInterval = setInterval(function() {
var now = Date.now();
document.querySelectorAll('.proc-timer').forEach(function(el) {
var start = parseInt(el.dataset.start);
if (start) el.textContent = '⏳ ' + ((now - start) / 1000).toFixed(1) + 'с';
});
}, 500);
es.onmessage = function(e) {
var d = JSON.parse(e.data);
if (d.type === 'file_start') {
var row = findRowByName(d.name);
if (row) {
row.querySelector('.status-cell').innerHTML = '<span class="proc-timer" data-start="' + Date.now() + '">⏳ 0.0с</span>';
if (d.bytes > 0) {
var sizeCell = row.querySelector('.num-cell');
if (sizeCell && sizeCell.textContent.trim() === '—') {
sizeCell.textContent = formatSize(d.bytes);
}
}
}
}
else if (d.type === 'file_done') {
var row = findRowByName(d.name);
if (row) {
row.querySelector('.status-cell').innerHTML = '<span class="status-ok">✓ ' + d.time_s + 'с</span>';
var idx = findIndexByName(d.name);
if (idx >= 0) { fileQueue[idx].parseInfo = d; }
var infoBtn = document.getElementById('info_' + (idx >= 0 ? idx : ''));
if (infoBtn) infoBtn.classList.add('visible');
}
}
else if (d.type === 'file_error') {
var row = findRowByName(d.name);
if (row) {
row.querySelector('.status-cell').innerHTML = '<span class="status-err">✗ ' + d.error + '</span>';
}
}
else if (d.type === 'zip_expanded') {
var row = findRowByName(d.name);
if (row) {
row.querySelector('.status-cell').innerHTML = '<span style="color:var(--muted);">↗ ' + d.count + ' файлов</span>';
}
}
else if (d.type === 'summary') {
clearInterval(timerInterval);
es.close();
parseBtn.disabled = true;
parseBtn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово';
var llmBtn = document.getElementById('llmBtn');
llmBtn.style.display = 'block';
llmBtn.disabled = false;
lucide.createIcons();
var summaryRow = document.createElement('tr');
summaryRow.className = 'summary-row';
summaryRow.innerHTML = '<td>✓ Готово: ' + d.files_processed + ' файлов</td>' +
'<td></td>' +
'<td class="num-cell">' + formatSize(d.total_bytes) + '</td>' +
'<td><strong>' + d.total_time_s + 'с</strong></td>' +
'<td></td><td></td>';
fileTable.appendChild(summaryRow);
}
};
es.addEventListener('error', function() {
clearInterval(timerInterval);
es.close();
parseBtn.disabled = false;
parseBtn.innerHTML = '<i data-lucide="play" style="width:16px;height:16px;"></i> Парсинг';
lucide.createIcons();
});
function findRowByName(name) {
for (var i = 0; i < fileQueue.length; i++) {
if (fileQueue[i].name === name) {
return document.getElementById('row_' + i);
}
}
return null;
}
function findIndexByName(name) {
for (var i = 0; i < fileQueue.length; i++) {
if (fileQueue[i].name === name) return i;
}
return -1;
}
});
// ── Модальное окно ──────────────────────────────────────────
function showInfo(index) {
var f = fileQueue[index];
if (!f || !f.parseInfo) return;
var d = f.parseInfo;
document.getElementById('modalTitle').textContent = f.name;
var rows = [
['Размер', formatSize(f.size)],
['В архиве', f.zipName || '—'],
['Время парсинга', d.time_s + 'с'],
['Элементов всего', d.elements],
['Параграфов', d.paragraphs],
['Таблиц', d.tables],
['Строк таблиц', d.table_rows],
['Текст (символов)', d.text_len],
];
var html = rows.map(function(r) {
return '<div class="kv"><span class="k">' + r[0] + '</span><span class="v">' + r[1] + '</span></div>';
}).join('');
if (d.errors && d.errors.length > 0) {
html += '<div style="margin-top:8px;color:var(--destructive);font-weight:600;">Ошибки парсера:</div>';
html += '<pre>' + d.errors.join('\n') + '</pre>';
}
if (d.text_preview) {
html += '<div style="margin-top:10px;font-weight:600;font-size:13px;">Превью текста:</div>';
html += '<pre>' + d.text_preview + '</pre>';
}
if (d.table_headers && d.table_headers.length > 0) {
html += '<div style="margin-top:10px;font-weight:600;font-size:13px;">Первые строки таблиц:</div>';
d.table_headers.forEach(function(hdr, ti) {
html += '<div style="margin-top:4px;font-size:11px;color:var(--muted);">Таблица ' + (ti+1) + ':</div>';
html += '<pre>' + hdr.map(function(c) { return c || '(пусто)'; }).join(' | ') + '</pre>';
});
}
document.getElementById('modalBody').innerHTML = html;
document.getElementById('modalOverlay').classList.add('open');
}
function closeModal(e) {
if (e && e.target !== document.getElementById('modalOverlay')) return;
document.getElementById('modalOverlay').classList.remove('open');
}
window.showInfo = showInfo;
window.closeModal = closeModal;
// ── LLM: Сравнить ────────────────────────────────────────────
document.getElementById('llmBtn').addEventListener('click', function() {
if (!contractId) return;
// Сохранить промпт на сервер перед стартом
var p = promptEditor.value.trim() || DEFAULT_PROMPT;
fetch('/llm/prompt/' + contractId, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({prompt: p})
});
var llmBtn = document.getElementById('llmBtn');
llmBtn.disabled = true;
llmBtn.innerHTML = '<span style="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;"></span> LLM-анализ...';
var diffCard = document.getElementById('diffCard');
var diffBody = document.getElementById('diffBody');
var diffStatus = document.getElementById('diffStatus');
diffCard.style.display = 'block';
diffBody.innerHTML = '<span style="color:var(--muted);">⏳ LLM-извлечение строк спецификации...</span>';
var es = new EventSource('/llm/process/' + contractId);
var llmStart = Date.now();
var timerInterval = setInterval(function() {
var elapsed = ((Date.now() - llmStart) / 1000).toFixed(1);
diffStatus.textContent = '⏳ ' + elapsed + 'с';
}, 200);
es.onmessage = function(e) {
var d = JSON.parse(e.data);
if (d.type === 'extract_start') {
diffBody.innerHTML += '<div style="font-size:12px;color:var(--muted);margin-top:2px;">🔍 ' + d.name + '</div>';
}
else if (d.type === 'extract_done') {
diffStatus.textContent = '✓ ' + d.count + ' строк из ' + d.name;
diffBody.innerHTML += '<div style="font-size:12px;color:var(--green);">✓ ' + d.name + ': ' + d.count + ' строк (' + d.time_s + 'с)</div>';
}
else if (d.type === 'extract_skip') {
diffBody.innerHTML += '<div style="font-size:12px;color:var(--muted);">⏭ ' + d.name + ' (уже извлечено)</div>';
}
else if (d.type === 'extract_error') {
diffBody.innerHTML += '<div style="font-size:12px;color:var(--destructive);">✗ ' + d.name + ': ' + d.error + '</div>';
}
else if (d.type === 'diff_start') {
diffBody.innerHTML += '<div style="margin-top:8px;font-weight:600;">📊 Сравнение:</div>';
}
else if (d.type === 'diff_file') {
var s = d.summary || {};
diffBody.innerHTML += '<div style="font-size:12px;margin-left:8px;">' + d.name + ': <span style="color:var(--green);">+' + (s.added||0) + '</span> <span style="color:#eab308;">~' + (s.changed||0) + '</span> <span style="color:var(--destructive);">-' + (s.deleted||0) + '</span></div>';
}
else if (d.type === 'done') {
clearInterval(timerInterval);
es.close();
llmBtn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово';
lucide.createIcons();
diffStatus.textContent = '✓ ' + d.total_time_s + 'с';
// Показать чат
document.getElementById('chatCard').style.display = 'block';
lucide.createIcons();
var all = d.all_changes || [];
if (all.length === 0) {
diffBody.innerHTML += '<div style="color:var(--muted);margin-top:8px;">Нет допников для сравнения.</div>';
return;
}
all.forEach(function(ch) {
diffBody.innerHTML += '<div style="font-weight:600;margin-top:12px;">📄 ' + ch.filename + ' (' + ch.type + ')</div>';
if (!ch.changes || ch.changes.length === 0) {
diffBody.innerHTML += '<div style="color:var(--muted);font-size:12px;">Нет изменений</div>';
return;
}
var tbl = '<div class="table-wrap" style="margin-top:4px;"><table><thead><tr><th>№</th><th>Услуга</th><th>Цена</th><th>Объём</th><th>Сумма</th><th>Начало</th></tr></thead><tbody>';
ch.changes.forEach(function(c) {
var cls = c.change_type === 'added' ? 'diff-added' : c.change_type === 'deleted' ? 'diff-deleted' : c.change_type === 'changed' ? 'diff-changed' : '';
var vals = c.new_values || c.old_values || {};
tbl += '<tr class="' + cls + '"><td>' + (c.row_num||'') + '</td><td>' + (vals.name||'') + '</td><td class="num-cell">' + _dc(c,'price') + '</td><td class="num-cell">' + _dc(c,'qty') + '</td><td class="num-cell">' + _dc(c,'sum') + '</td><td>' + (vals.date_start||'') + '</td></tr>';
});
tbl += '</tbody></table></div>';
diffBody.innerHTML += tbl;
});
}
else if (d.type === 'error') {
diffBody.innerHTML += '<div style="color:var(--destructive);margin-top:8px;">✗ ' + d.message + '</div>';
}
};
es.addEventListener('error', function() {
clearInterval(timerInterval);
es.close();
llmBtn.innerHTML = '<i data-lucide="scale" style="width:16px;height:16px;"></i> Сравнить (LLM)';
llmBtn.disabled = false;
lucide.createIcons();
});
});
function _dc(c, field) {
if (c.change_type === 'changed' && c.old_values && c.new_values) {
var ov = c.old_values[field], nv = c.new_values[field];
if (ov !== nv && ov !== undefined)
return '<span class="diff-field-old">' + (ov||'—') + '</span> → <span class="diff-field-new">' + (nv||'—') + '</span>';
}
var v = (c.new_values || c.old_values || {})[field];
return v != null ? v : '—';
}
// ── Чат ──────────────────────────────────────────────────────
document.getElementById('chatSend').addEventListener('click', function() {
var input = document.getElementById('chatInput');
var q = input.value.trim();
if (!q || !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>';
fetch('/llm/chat/' + contractId, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({question: q})
}).then(function(r) { return r.json(); })
.then(function(d) {
msgs.lastChild.remove(); // убрать "Думаю..."
msgs.innerHTML += '<div style="margin-top:2px;"><strong>Ответ:</strong> ' + (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();
});
// ── Редактор промпта ──────────────────────────────────────────
var promptEditor = document.getElementById('promptEditor');
// Загрузить из localStorage
var saved = localStorage.getItem('llm_prompt');
promptEditor.value = saved || DEFAULT_PROMPT;
document.getElementById('promptSave').addEventListener('click', function() {
var p = promptEditor.value.trim();
if (!p) return;
localStorage.setItem('llm_prompt', p);
document.getElementById('promptStatus').textContent = '✓ сохранено';
setTimeout(function() { document.getElementById('promptStatus').textContent = ''; }, 1500);
// Отправить на сервер
if (contractId) {
fetch('/llm/prompt/' + contractId, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({prompt: p})
});
}
});
document.getElementById('promptReset').addEventListener('click', function() {
promptEditor.value = DEFAULT_PROMPT;
localStorage.removeItem('llm_prompt');
document.getElementById('promptStatus').textContent = '✓ сброшен';
setTimeout(function() { document.getElementById('promptStatus').textContent = ''; }, 1500);
});
// При старте LLM — сохранить промпт для этого contractId
var origLlmClick = document.getElementById('llmBtn').onclick;
// Переопределим ниже...
var style = document.createElement('style');
style.textContent = '@keyframes spin { to { transform: rotate(360deg); } }';
document.head.appendChild(style);
</script>
</body>
</html>
+33 -86
View File
@@ -5,7 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Сверка договоров</title> <title>Сверка договоров</title>
<link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon.png') }}"> <link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon.png') }}">
<script src="https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.2/spark-md5.min.js"></script>
<script src="https://unpkg.com/lucide@latest"></script> <script src="https://unpkg.com/lucide@latest"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<style> <style>
@@ -48,9 +47,9 @@
.info-btn.visible { display: inline; } .info-btn.visible { display: inline; }
.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 { 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-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; overflow-y: auto; } .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-header { padding: 14px 16px; border-bottom: 1px solid var(--brand-gray); display: flex; align-items: center; gap: 8px; font-weight: 600; } .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; } .modal-body { padding: 16px; overflow-y: auto; }
.modal-body .kv { display: flex; margin-bottom: 6px; font-size: 13px; } .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 .k { color: var(--muted); width: 110px; flex-shrink: 0; }
.modal-body .kv .v { word-break: break-all; } .modal-body .kv .v { word-break: break-all; }
@@ -60,7 +59,7 @@
<body> <body>
<div class="topbar"> <div class="topbar">
<img src="{{ url_for('static', filename='nubes-logo.svg') }}" alt="Nubes"> <img src="{{ url_for('static', filename='nubes-logo.svg') }}" alt="Nubes">
<span class="title">Сверка договоров <span style="font-weight:400;color:var(--muted);font-size:12px;">v2.0.8</span></span> <span class="title">Сверка договоров <span style="font-weight:400;color:var(--muted);font-size:12px;">v3.0.2</span></span>
</div> </div>
<div class="content"> <div class="content">
@@ -75,7 +74,7 @@
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>
<tr><th>Имя</th><th style="width:130px;">Изменён</th><th style="width:90px;">Размер</th><th style="width:115px;">Статус</th><th style="width:55px;"></th></tr> <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> </thead>
<tbody id="fileTable"></tbody> <tbody id="fileTable"></tbody>
</table> </table>
@@ -124,7 +123,7 @@
function renderTable() { function renderTable() {
if (fileQueue.length === 0) { if (fileQueue.length === 0) {
fileTable.innerHTML = '<tr class="empty-row"><td colspan="5">Нет файлов — выберите .docx / .pdf / .zip</td></tr>'; fileTable.innerHTML = '<tr class="empty-row"><td colspan="6">Нет файлов — выберите .docx / .pdf / .zip</td></tr>';
parseBtn.disabled = true; parseBtn.disabled = true;
} else { } else {
fileTable.innerHTML = fileQueue.map(function(f, i) { fileTable.innerHTML = fileQueue.map(function(f, i) {
@@ -134,8 +133,8 @@
'<td style="font-size:12px;color:var(--muted);">' + formatDate(f.lastModified) + '</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="num-cell" style="font-size:12px;color:var(--muted);">' + formatSize(f.size) + '</td>' +
'<td class="status-cell">' + (f.status || '') + '</td>' + '<td class="status-cell">' + (f.status || '') + '</td>' +
'<td><button class="remove-btn" onclick="removeFile(' + i + ')" title="Удалить">×</button>' + '<td><button class="info-btn" id="info_' + i + '" onclick="showInfo(' + i + ')" title="Инфо"><i data-lucide="info" style="width:16px;height:16px;"></i></button></td>' +
'<button class="info-btn" id="info_' + i + '" onclick="showInfo(' + i + ')" title="Инфо"></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>';
}).join(''); }).join('');
} }
@@ -156,81 +155,35 @@
window.removeFile = removeFile; window.removeFile = removeFile;
// ── Чанковая загрузка v2 (30KB + MD5) ──────────────────── // ── Простая загрузка FormData (ВМ, без лимитов) ──────────
function uploadFileChunked(file, cid, onProgress) { function uploadFile(file, onProgress) {
return new Promise(function(resolve, reject) { return new Promise(function(resolve, reject) {
var reader = new FileReader(); var fd = new FormData();
reader.onload = function() { fd.append('files', file);
var buffer = reader.result; if (contractId) fd.append('cid', contractId);
var bytes = new Uint8Array(buffer);
// Чанковая конвертация в base64 — apply не тянет >125K аргументов
var b64Chunks = [];
var B64_CHUNK = 0x8000; // 32768 bytes
for (var bi = 0; bi < bytes.length; bi += B64_CHUNK) {
b64Chunks.push(String.fromCharCode.apply(null, bytes.subarray(bi, bi + B64_CHUNK)));
}
var b64 = btoa(b64Chunks.join(''));
var checksum = SparkMD5.ArrayBuffer.hash(buffer);
var CHUNK = 60 * 1024; // ближе к лимиту 64KB → меньше чанков
var totalChunks = Math.ceil(b64.length / CHUNK);
var uploadId = 'u' + Date.now().toString(36) + Math.random().toString(36).substr(2, 7);
var done = 0;
console.log('uploadFileChunked: ' + file.name + ' b64=' + b64.length + ' chunks=' + totalChunks + ' uploadId=' + uploadId); var xhr = new XMLHttpRequest();
xhr.open('POST', '/');
function fetchWithRetry(url, body, retries) { xhr.upload.onprogress = function(e) {
retries = retries || 3; if (e.lengthComputable && onProgress) {
return fetch(url, { onProgress(Math.round(e.loaded / e.total * 100));
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
}).catch(function(e) {
if (retries > 0) {
console.log('retry ' + url + ' (' + retries + ' left): ' + e.message);
return new Promise(function(r) { setTimeout(r, 1000); }).then(function() {
return fetchWithRetry(url, body, retries - 1);
});
} }
throw e;
});
}
function sendChunk(i) {
if (i >= totalChunks) {
console.log('finalize: ' + uploadId);
fetchWithRetry('/v2/finalize', {upload_id: uploadId, filename: file.name, total: totalChunks, checksum: checksum})
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}).then(function(data) {
if (data.ok) resolve({contract_id: data.contract_id});
else reject(new Error(data.error));
}).catch(function(e) { reject(e); });
return;
}
var piece = b64.substring(i * CHUNK, (i + 1) * CHUNK);
console.log('chunk ' + i + '/' + totalChunks + ' send ' + piece.length + ' bytes');
fetchWithRetry('/v2/chunk', {upload_id: uploadId, index: i, total: totalChunks, data: piece})
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}).then(function(data) {
if (!data.ok) { reject(new Error(data.error)); return; }
done++;
if (onProgress) onProgress({pct: Math.round(done / totalChunks * 100), done: done, total: totalChunks});
setTimeout(function() { sendChunk(i + 1); }, 3000);
}).catch(function(e) { reject(e); });
}
sendChunk(0);
}; };
reader.onerror = function() { reject(new Error('Read error')); }; xhr.onload = function() {
reader.readAsArrayBuffer(file); try {
var r = JSON.parse(xhr.responseText);
if (r.contract_id) 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 = 120000;
xhr.send(fd);
}); });
} }
// ── Старая загрузка (одним POST) ─────────────────────────────
// ── Выбор файла → сразу загрузка ──────────────────────────── // ── Выбор файла → сразу загрузка ────────────────────────────
fileInput.addEventListener('change', async function() { fileInput.addEventListener('change', async function() {
@@ -252,21 +205,15 @@
var startTime = Date.now(); var startTime = Date.now();
try { try {
var resp = await uploadFileChunked(f, contractId, function(info) { var resp = await uploadFile(f, function(pct) {
fileQueue[rowIdx].status = '<span style="color:var(--muted);">↑ чанк ' + info.done + '/' + info.total + ' (' + info.pct + '%)</span>'; fileQueue[rowIdx].status = '<span style="color:var(--muted);">↑ ' + pct + '%</span>';
renderTable(); renderTable();
}); });
// uploadFileChunked10k: прогресс по чанкам if (resp && resp.contract_id) contractId = resp.contract_id;
// но этот же resp приходит только с последнего чанка
if (resp && resp.contract_id) {
contractId = resp.contract_id;
}
// Если файл был один и он маленький (один чанк), contract_id уже есть
var elapsed = ((Date.now() - startTime) / 1000).toFixed(1); var elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
fileQueue[rowIdx].status = '<span class="status-ok">✓ ' + elapsed + 'с</span>'; fileQueue[rowIdx].status = '<span class="status-ok">✓ ' + elapsed + 'с</span>';
fileQueue[rowIdx].uploaded = true; fileQueue[rowIdx].uploaded = true;
renderTable(); renderTable();
if (f.type === 'application/zip' || f.name.toLowerCase().endsWith('.zip')) { if (f.type === 'application/zip' || f.name.toLowerCase().endsWith('.zip')) {
await showZipContents(f); await showZipContents(f);
} }
@@ -378,7 +325,7 @@
'<td></td>' + '<td></td>' +
'<td class="num-cell">' + formatSize(d.total_bytes) + '</td>' + '<td class="num-cell">' + formatSize(d.total_bytes) + '</td>' +
'<td><strong>' + d.total_time_s + 'с</strong></td>' + '<td><strong>' + d.total_time_s + 'с</strong></td>' +
'<td></td>'; '<td></td><td></td>';
fileTable.appendChild(summaryRow); fileTable.appendChild(summaryRow);
} }
}; };
-75
View File
@@ -1,75 +0,0 @@
"""v2/chunk_api.py — Приём чанков по 30KB, финализация."""
import sys, os, base64, hashlib, logging
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'site'))
import db, mimeutil
sys.path.insert(0, os.path.dirname(__file__))
import init as v2
from flask import Blueprint, request, jsonify
bp = Blueprint("v2", __name__, url_prefix="/v2")
@bp.route("/chunk", methods=["POST"])
def receive_chunk():
body = request.get_json(silent=True) or {}
if not body.get("upload_id") or not body.get("data"):
return jsonify({"ok": False, "error": "missing fields"}), 400
try:
v2.store_chunk(body["upload_id"], int(body.get("index", 0)), body["data"])
return jsonify({"ok": True, "received": int(body.get("index", 0))})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@bp.route("/finalize", methods=["POST"])
def finalize_upload():
body = request.get_json(silent=True) or {}
upload_id = body.get("upload_id", "")
filename = body.get("filename", "")
total = int(body.get("total", 0))
checksum = body.get("checksum", "")
if not all([upload_id, filename, total]):
return jsonify({"ok": False, "error": "missing fields"}), 400
chunks = v2.get_chunks(upload_id)
if len(chunks) != total:
return jsonify({"ok": False, "error": f"expected {total}, got {len(chunks)}"}), 409
try:
file_bytes = b"".join(base64.b64decode(c) for c in chunks)
except Exception as e:
return jsonify({"ok": False, "error": f"decode: {e}"}), 422
if checksum and hashlib.md5(file_bytes).hexdigest() != checksum:
return jsonify({"ok": False, "error": "checksum mismatch"}), 422
mime = mimeutil.guess_mime(filename) or "application/octet-stream"
# Сохранить в БД
conn, err = db.connect()
if err:
return jsonify({"ok": False, "error": f"db: {err}"}), 500
try:
cur = conn.cursor()
cur.execute(
"INSERT INTO contracts (number, client) VALUES (%s,%s) RETURNING id",
("б" + __import__("datetime").datetime.now().strftime("%Y%m%d-%H%M"), ""),
)
cid = cur.fetchone()[0]
cur.execute(
"INSERT INTO documents (filename, mime_type, original_bytes, status) VALUES (%s,%s,%s,'uploaded')",
(filename, mime, file_bytes),
)
cur.execute("SELECT id FROM documents WHERE filename=%s ORDER BY created_at DESC LIMIT 1", (filename,))
doc_id = cur.fetchone()[0]
cur.execute("INSERT INTO supplements (contract_id, document_id, type) VALUES (%s,%s,'initial')", (str(cid), str(doc_id)))
conn.commit()
cur.close()
finally:
db.put_conn(conn)
v2.delete_chunks(upload_id)
v2.push_finalize({"doc_id": str(doc_id), "filename": filename, "mime_type": mime})
return jsonify({"ok": True, "contract_id": str(cid), "doc_id": str(doc_id)})
-213
View File
@@ -1,213 +0,0 @@
/**
* v2/chunk_upload.js — Клиентская загрузка файла чанками по 32KB.
*
* Использование:
* <input type="file" id="file-input">
* <div id="upload-ui"></div>
* <script src="/static/v2/chunk_upload.js"></script>
*
* После успешной загрузки появляется кнопка «Парсинг».
*/
(function () {
"use strict";
const CHUNK_SIZE = 32 * 1024; // 32 KB
// ── MD5 (встроенная реализация без зависимостей) ──────────────────────────
// RFC 1321 — используется только для checksum, не для безопасности.
function md5(buffer) {
// SparkMD5 должен быть подключён отдельно, либо используем SubtleCrypto
// Здесь используем встроенный Web Crypto API (SHA-256 недостаточен — нужен MD5)
// Простая MD5 реализация:
return SparkMD5.ArrayBuffer.hash(buffer);
}
// ── UI ────────────────────────────────────────────────────────────────────
function getUI() {
return document.getElementById("upload-ui");
}
function setStatus(html) {
const ui = getUI();
if (ui) ui.innerHTML = html;
}
function renderProgress(filename, pct, elapsed) {
return `
<div class="upload-progress">
<strong>${escHtml(filename)}</strong><br>
<progress value="${pct}" max="100" style="width:100%"></progress>
<span>${pct}% &nbsp;|&nbsp; ${elapsed}с</span>
</div>`;
}
function escHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
// ── XHR helper ────────────────────────────────────────────────────────────
function post(url, body) {
return new Promise(function (resolve, reject) {
const xhr = new XMLHttpRequest();
xhr.open("POST", url, true);
xhr.setRequestHeader("Content-Type", "application/json");
xhr.timeout = 15000;
xhr.onload = function () {
if (xhr.status >= 200 && xhr.status < 300) {
try { resolve(JSON.parse(xhr.responseText)); }
catch (e) { reject(new Error("bad json: " + xhr.responseText)); }
} else {
reject(new Error("HTTP " + xhr.status + ": " + xhr.responseText));
}
};
xhr.onerror = function () { reject(new Error("network error")); };
xhr.ontimeout = function () { reject(new Error("timeout")); };
xhr.send(JSON.stringify(body));
});
}
// ── Base64 encode ArrayBuffer ─────────────────────────────────────────────
function toBase64(buffer) {
let binary = "";
const bytes = new Uint8Array(buffer);
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
// ── Генерация upload_id ───────────────────────────────────────────────────
function genId() {
return "u" + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
}
// ── Основная функция загрузки ─────────────────────────────────────────────
async function uploadFile(file) {
const uploadId = genId();
const total = Math.ceil(file.size / CHUNK_SIZE);
const startTs = Date.now();
// Считаем MD5 всего файла через SparkMD5
const fullBuffer = await file.arrayBuffer();
const checksum = md5(fullBuffer);
setStatus(renderProgress(file.name, 0, 0));
for (let i = 0; i < total; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const slice = fullBuffer.slice(start, end);
const b64 = toBase64(slice);
const elapsed = Math.round((Date.now() - startTs) / 1000);
const pct = Math.round(((i + 1) / total) * 90); // до 90%, финал = 100%
setStatus(renderProgress(file.name, pct, elapsed));
let res;
try {
res = await post("/v2/chunk", {
upload_id: uploadId,
index: i,
total: total,
data: b64,
});
} catch (e) {
setStatus(`<p class="error">Ошибка чанка ${i}: ${escHtml(e.message)}</p>`);
return;
}
if (!res.ok) {
setStatus(`<p class="error">Сервер отклонил чанк ${i}: ${escHtml(res.error)}</p>`);
return;
}
}
// Финализация
const elapsed = Math.round((Date.now() - startTs) / 1000);
setStatus(renderProgress(file.name, 95, elapsed));
let fin;
try {
fin = await post("/v2/finalize", {
upload_id: uploadId,
filename: file.name,
mime_type: file.type || "application/octet-stream",
total: total,
checksum: checksum,
});
} catch (e) {
setStatus(`<p class="error">Ошибка финализации: ${escHtml(e.message)}</p>`);
return;
}
if (!fin.ok) {
setStatus(`<p class="error">Финализация не удалась: ${escHtml(fin.error)}</p>`);
return;
}
const docId = fin.doc_id;
const done = Math.round((Date.now() - startTs) / 1000);
setStatus(`
<div class="upload-done">
<strong>${escHtml(file.name)}</strong> загружен за ${done}с.<br>
<button id="btn-parse" data-docid="${escHtml(docId)}">Парсинг</button>
<div id="parse-status"></div>
</div>`);
document.getElementById("btn-parse").addEventListener("click", function () {
pollStatus(docId);
});
}
// ── Опрос статуса парсинга ────────────────────────────────────────────────
function pollStatus(docId) {
const ps = document.getElementById("parse-status");
if (ps) ps.textContent = "Парсинг запущен, ожидаем…";
let attempts = 0;
const timer = setInterval(async function () {
attempts++;
try {
const r = await fetch("/v2/status/" + docId);
const d = await r.json();
if (d.status === "parsed") {
clearInterval(timer);
if (ps) ps.innerHTML = `<span style="color:green">Готово: ${escHtml(d.filename)}</span>`;
} else if (d.status === "error") {
clearInterval(timer);
if (ps) ps.innerHTML = `<span style="color:red">Ошибка: ${escHtml(d.error || "unknown")}</span>`;
} else {
if (ps) ps.textContent = `Статус: ${d.status} (${attempts * 2}с)`;
}
} catch (e) {
if (ps) ps.textContent = `Ошибка опроса: ${e.message}`;
}
if (attempts > 60) { clearInterval(timer); }
}, 2000);
}
// ── Инициализация ─────────────────────────────────────────────────────────
document.addEventListener("DOMContentLoaded", function () {
const input = document.getElementById("file-input");
if (!input) return;
input.addEventListener("change", function () {
const file = input.files[0];
if (!file) return;
uploadFile(file).catch(function (e) {
setStatus(`<p class="error">Критическая ошибка: ${escHtml(e.message)}</p>`);
});
});
});
})();
-45
View File
@@ -1,45 +0,0 @@
"""v2/init.py — Redis и хранилище чанков. БД — через site/db.py."""
import os, json, logging, redis
logger = logging.getLogger(__name__)
CHUNK_STORE_KEY = "v2:chunks:"
FINALIZE_QUEUE = "v2:finalize"
_r = None
def get_redis():
global _r
if _r is None:
_r = redis.Redis(
host=os.getenv("REDIS_HOST", "redisk8s.f6303a9d-4ab1-4b2f-8aa8-08f933d02f82.svc.cluster.local"),
port=int(os.getenv("REDIS_PORT", "6379")),
password=os.getenv("REDIS_PASS", "aLITloRefJEiCPqUc2xB"),
decode_responses=True,
socket_connect_timeout=5,
socket_timeout=10,
)
return _r
def store_chunk(upload_id, index, data_b64):
get_redis().rpush(f"{CHUNK_STORE_KEY}{upload_id}", json.dumps([index, data_b64]))
def get_chunks(upload_id):
raw = get_redis().lrange(f"{CHUNK_STORE_KEY}{upload_id}", 0, -1)
chunks = []
for r in raw:
idx, data = json.loads(r)
chunks.append((idx, data))
chunks.sort()
return [c[1] for c in chunks]
def delete_chunks(upload_id):
get_redis().delete(f"{CHUNK_STORE_KEY}{upload_id}")
def push_finalize(task):
get_redis().rpush(FINALIZE_QUEUE, json.dumps(task, ensure_ascii=False))
def pop_finalize(timeout=5):
result = get_redis().blpop(FINALIZE_QUEUE, timeout=timeout)
return json.loads(result[1]) if result else None
-51
View File
@@ -1,51 +0,0 @@
"""v2/worker.py — Фоновый парсинг через site/parser.py."""
import sys, os, json, logging, time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'site'))
import db, parser as parser_mod, textify
sys.path.insert(0, os.path.dirname(__file__))
import init as v2
logging.basicConfig(level=logging.INFO, format="[worker] %(message)s")
logger = logging.getLogger(__name__)
def run():
logger.info("started")
while True:
try:
task = v2.pop_finalize(timeout=5)
if task:
_process(task)
except Exception as e:
logger.error("loop: %s", e)
time.sleep(1)
def _process(task):
doc_id = task.get("doc_id")
filename = task.get("filename", "")
mime = task.get("mime_type", "")
doc, err = db.query_one("SELECT original_bytes, mime_type FROM documents WHERE id=%s", (doc_id,))
if not doc:
logger.info("%s: not found", doc_id)
return
raw = doc["original_bytes"]
file_bytes = bytes(raw) if isinstance(raw, memoryview) else raw
mime = doc["mime_type"] or mime
pr = parser_mod.parse(file_bytes, mime)
elements = pr.get("elements", [])
if mime == "application/zip" and "files" in pr:
for zf in pr["files"]:
elements.extend(zf.get("elements", []))
text = textify.to_text(elements) if elements else ""
db.execute(
"UPDATE documents SET parsed_text=%s, elements_json=%s, status='parsed' WHERE id=%s",
(text, json.dumps(elements, ensure_ascii=False), doc_id),
)
logger.info("%s: parsed, %d elements", filename, len(elements))
if __name__ == "__main__":
run()