Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b6473dcc4 | ||
|
|
51e509ce15 | ||
|
|
ce4871227c | ||
|
|
958865c397 | ||
|
|
e3b7f283f0 | ||
|
|
08c7af2a77 | ||
|
|
dfa1d9278f | ||
|
|
5ffc588935 | ||
|
|
f1495d07e3 | ||
|
|
e534d014ef | ||
|
|
17413e65c4 | ||
|
|
bc5cbfe913 | ||
|
|
52fe7b92fb | ||
|
|
0151a380ea | ||
|
|
2b851505c3 | ||
|
|
c3d891f7b7 | ||
|
|
557a238fb8 | ||
|
|
f1b4bf515f | ||
|
|
822b47bace | ||
|
|
12d67de7ed |
@@ -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'
|
||||||
|
```
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""routes/__init__.py — Пакет маршрутов."""
|
||||||
@@ -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", "Нет ответа")})
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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:])
|
||||||
@@ -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")
|
||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)})
|
|
||||||
@@ -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}% | ${elapsed}с</span>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escHtml(s) {
|
|
||||||
return String(s)
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
Reference in New Issue
Block a user