Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4a6d78656 | ||
|
|
8b04eb93bd | ||
|
|
bec16def95 | ||
|
|
863026822c |
@@ -1,181 +0,0 @@
|
||||
# Сессия 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,3 +6,6 @@ psycopg2-binary
|
||||
python-dotenv
|
||||
pdfplumber
|
||||
camelot-py>=2.0
|
||||
redis
|
||||
httpx
|
||||
h2
|
||||
|
||||
+507
-44
@@ -1,72 +1,535 @@
|
||||
"""app.py — Точка входа. Только Flask + регистрация маршрутов."""
|
||||
"""app.py — Точка входа и сборка слоёв."""
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from flask import Flask
|
||||
from flask import Flask, render_template, request, redirect, url_for, Response, jsonify
|
||||
import json
|
||||
|
||||
import sys as _sys, os as _os
|
||||
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), '..'))
|
||||
|
||||
# Слои
|
||||
import schema
|
||||
import db
|
||||
import parser as parser_mod
|
||||
import textify
|
||||
import extractor
|
||||
import differ
|
||||
import mimeutil
|
||||
from test_routes import test_bp
|
||||
from upload import upload_bp
|
||||
from api import api_bp
|
||||
|
||||
# Маршруты (разделены по файлам)
|
||||
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
|
||||
from v2.chunk_api import bp as v2_bp
|
||||
import redis_client
|
||||
import redis_worker
|
||||
from teach import teach_bp
|
||||
|
||||
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:
|
||||
"""
|
||||
Главный класс приложения.
|
||||
|
||||
Собирает Flask, регистрирует маршруты и blueprint'ы.
|
||||
Запуск: ContractsApp().run() — dev-сервер
|
||||
gunicorn app:application — production
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.app = Flask(__name__)
|
||||
|
||||
# Создать таблицы в БД (IF NOT EXISTS — безопасно)
|
||||
schema.ensure_schema()
|
||||
|
||||
# Зарегистрировать маршруты
|
||||
try:
|
||||
redis_worker.start_worker()
|
||||
except Exception as e:
|
||||
_log("redis_worker_start_error", str(e))
|
||||
self._add_routes()
|
||||
|
||||
def _add_routes(self):
|
||||
"""Подключить все URL к обработчикам."""
|
||||
# Главные: загрузка, просмотр
|
||||
self.app.add_url_rule("/", "index", index, methods=["GET", "POST"])
|
||||
|
||||
# Парсинг: SSE-поток
|
||||
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.add_url_rule("/", "index", self._index, methods=["GET", "POST"])
|
||||
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("/upload", "upload_json", self._upload_json, methods=["POST"])
|
||||
self.app.add_url_rule("/health", "health", self._health)
|
||||
self.app.add_url_rule("/logs", "logs", self._logs)
|
||||
self.app.register_blueprint(test_bp)
|
||||
self.app.register_blueprint(upload_bp)
|
||||
self.app.register_blueprint(api_bp)
|
||||
self.app.register_blueprint(v2_bp)
|
||||
self.app.register_blueprint(teach_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):
|
||||
"""Dev-сервер на порту 5000."""
|
||||
self.app.run(host="0.0.0.0", port=5000)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ContractsApp().run()
|
||||
|
||||
# Для gunicorn: gunicorn app:application
|
||||
application = ContractsApp().app
|
||||
|
||||
+6
-37
@@ -12,37 +12,21 @@ import json
|
||||
import llm_client
|
||||
|
||||
|
||||
def extract(parsed_text: str, custom_prompt: str = None) -> dict:
|
||||
def extract(parsed_text: str) -> dict:
|
||||
"""
|
||||
Извлечь строки спецификации из текста документа через LLM.
|
||||
|
||||
Args:
|
||||
parsed_text: текст документа (выдача textify.py)
|
||||
custom_prompt: свой промпт (если None — используется DEFAULT_PROMPT)
|
||||
|
||||
Returns:
|
||||
{"rows": [...], "unresolved": [...]} или {"error": "..."}
|
||||
{"rows": [{row_num, name, price, qty, sum, date_start}, ...], "unresolved": [...]}
|
||||
или
|
||||
{"error": "...", "raw_response": "..."}
|
||||
"""
|
||||
|
||||
prompt = custom_prompt or DEFAULT_PROMPT
|
||||
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 = """Ты — анализатор договоров. Ниже текст спецификации услуг из договора облачного провайдера.
|
||||
# ── Промпт ──────────────────────────────────────────────────
|
||||
prompt = f"""Ты — анализатор договоров. Ниже текст спецификации услуг из договора облачного провайдера.
|
||||
|
||||
Найди ВСЕ таблицы со списком услуг. Извлеки каждую строку в JSON-массив.
|
||||
|
||||
@@ -69,21 +53,6 @@ DEFAULT_PROMPT = """Ты — анализатор договоров. Ниже
|
||||
---
|
||||
"""
|
||||
|
||||
|
||||
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 ────────────────────────────────────────────────
|
||||
result = llm_client.ask(prompt, max_tokens=8000)
|
||||
|
||||
|
||||
+7
-80
@@ -122,22 +122,12 @@ def _parse_doc(file_bytes: bytes) -> dict:
|
||||
# ── PDF ──
|
||||
|
||||
def _parse_pdf(file_bytes: bytes) -> dict:
|
||||
"""
|
||||
Извлечь текст и таблицы из PDF через pdfplumber.
|
||||
|
||||
Таблицы: только с видимыми рамками (lines). Безрамковые (text)
|
||||
дают слишком много ложных срабатываний — не используем.
|
||||
Отбрасываем таблицы где >50% ячеек пусты или где все ячейки
|
||||
содержат фрагменты одной строки текста.
|
||||
"""
|
||||
result = {"elements": [], "errors": []}
|
||||
try:
|
||||
import pdfplumber
|
||||
with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
|
||||
for page in pdf.pages:
|
||||
pn = page.page_number
|
||||
|
||||
# ── Текст: каждая непустая строка → paragraph ──
|
||||
text = page.extract_text()
|
||||
if text:
|
||||
for line in text.split("\n"):
|
||||
@@ -149,82 +139,19 @@ def _parse_pdf(file_bytes: bytes) -> dict:
|
||||
"text": line,
|
||||
"page": pn,
|
||||
})
|
||||
|
||||
# ── Таблицы: с рамками + без рамок ──
|
||||
tables = page.extract_tables()
|
||||
found_any = False
|
||||
if tables:
|
||||
for tbl in tables:
|
||||
if tbl and _table_has_content(tbl):
|
||||
result["elements"].append({
|
||||
"type": "table",
|
||||
"rows": tbl,
|
||||
"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,
|
||||
})
|
||||
|
||||
for tbl in tables:
|
||||
if tbl:
|
||||
result["elements"].append({
|
||||
"type": "table",
|
||||
"rows": tbl,
|
||||
"page": pn,
|
||||
})
|
||||
except Exception as e:
|
||||
result["errors"].append(f"pdfplumber: {e}")
|
||||
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 ──
|
||||
|
||||
def _parse_zip(file_bytes: bytes) -> dict:
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""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
|
||||
@@ -0,0 +1,69 @@
|
||||
"""redis_worker.py — Фоновый обработчик очереди загрузок."""
|
||||
|
||||
import threading, time, base64, json
|
||||
import db, mimeutil, parser as parser_mod, textify
|
||||
|
||||
def start_worker():
|
||||
"""Запустить фоновый поток обработки очереди."""
|
||||
t = threading.Thread(target=_worker_loop, daemon=True)
|
||||
t.start()
|
||||
|
||||
def _worker_loop():
|
||||
from redis_client import pop as queue_pop
|
||||
while True:
|
||||
try:
|
||||
task = queue_pop(timeout=5)
|
||||
if task:
|
||||
_process(task)
|
||||
except Exception as e:
|
||||
time.sleep(1)
|
||||
|
||||
def _process(task):
|
||||
filename = task.get("filename", "")
|
||||
b64 = task.get("b64", "")
|
||||
mime = task.get("mime", "application/octet-stream")
|
||||
cid = task.get("cid", "")
|
||||
|
||||
if not filename or not b64:
|
||||
return
|
||||
|
||||
# Декодировать и сохранить в БД
|
||||
file_bytes = base64.b64decode(b64)
|
||||
|
||||
conn, err = db.connect()
|
||||
if err:
|
||||
return
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"INSERT INTO documents (filename, mime_type, original_bytes, original_b64, status) VALUES (%s,%s,%s,%s,'uploaded')",
|
||||
(filename, mime, file_bytes, b64),
|
||||
)
|
||||
cur.execute("SELECT id FROM documents WHERE filename=%s AND status='uploaded' ORDER BY created_at DESC LIMIT 1", (filename,))
|
||||
row = cur.fetchone()
|
||||
doc_id = row[0] if row else None
|
||||
if doc_id:
|
||||
cur.execute(
|
||||
"INSERT INTO supplements (contract_id, document_id, type) VALUES (%s,%s,'initial')",
|
||||
(cid, str(doc_id)),
|
||||
)
|
||||
|
||||
# Парсинг
|
||||
pr = parser_mod.parse(file_bytes, mime)
|
||||
elements = pr.get("elements", [])
|
||||
if mime == "application/zip" and "files" in pr:
|
||||
for zf in pr["files"]:
|
||||
elements.extend(zf.get("elements", []))
|
||||
text = textify.to_text(elements) if elements else ""
|
||||
|
||||
cur.execute(
|
||||
"UPDATE documents SET parsed_text=%s, elements_json=%s, status='parsed' WHERE id=%s",
|
||||
(text, json.dumps(elements, ensure_ascii=False), str(doc_id)),
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
except Exception as e:
|
||||
try: conn.rollback()
|
||||
except: pass
|
||||
finally:
|
||||
db.put_conn(conn)
|
||||
@@ -1 +0,0 @@
|
||||
"""routes/__init__.py — Пакет маршрутов."""
|
||||
@@ -1,216 +0,0 @@
|
||||
"""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", "Нет ответа")})
|
||||
@@ -1,156 +0,0 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -1,33 +0,0 @@
|
||||
"""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:])
|
||||
@@ -1,193 +0,0 @@
|
||||
"""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")
|
||||
@@ -98,6 +98,34 @@ CREATE TABLE IF NOT EXISTS spec_history (
|
||||
);
|
||||
"""
|
||||
|
||||
# ── Таблица feedback ──────────────────────────────────────────
|
||||
# Изолированный журнал замечаний по строкам сравнения.
|
||||
|
||||
DDL_FEEDBACK = """
|
||||
CREATE TABLE IF NOT EXISTS feedback (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
|
||||
contract_id UUID,
|
||||
supplement_id UUID,
|
||||
event_seq INTEGER,
|
||||
|
||||
scope TEXT NOT NULL,
|
||||
verdict TEXT NOT NULL,
|
||||
error_type TEXT,
|
||||
field TEXT,
|
||||
|
||||
service_name TEXT,
|
||||
llm_value JSONB,
|
||||
correct_value JSONB,
|
||||
|
||||
prompt_version TEXT,
|
||||
model_name TEXT,
|
||||
doc_mode TEXT,
|
||||
comment TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
# ── Таблица chunks ─────────────────────────────────────────────
|
||||
# Чанковая загрузка: части файла до сборки.
|
||||
# upload_id связывает чанки одного файла.
|
||||
@@ -135,6 +163,7 @@ ALL_DDL = [
|
||||
("supplements", DDL_SUPPLEMENTS),
|
||||
("spec_rows", DDL_SPEC_ROWS),
|
||||
("spec_history", DDL_SPEC_HISTORY),
|
||||
("feedback", DDL_FEEDBACK),
|
||||
("chunks", DDL_CHUNKS),
|
||||
("debug_log", DDL_DEBUG_LOG),
|
||||
]
|
||||
@@ -159,3 +188,5 @@ def ensure_schema():
|
||||
db.execute("ALTER TABLE documents ADD COLUMN IF NOT EXISTS elements_json JSONB")
|
||||
db.execute("ALTER TABLE documents ADD COLUMN IF NOT EXISTS original_b64 TEXT")
|
||||
db.execute("ALTER TABLE documents ALTER COLUMN original_bytes DROP NOT NULL")
|
||||
db.execute("ALTER TABLE feedback ADD COLUMN IF NOT EXISTS prompt_version TEXT")
|
||||
db.execute("ALTER TABLE feedback ADD COLUMN IF NOT EXISTS model_name TEXT")
|
||||
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
teach.py — Isolated teaching/feedback page.
|
||||
|
||||
This blueprint is intentionally separate from the working compare pipeline.
|
||||
It renders a standalone UI and stores feedback only in the feedback table.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from flask import Blueprint, jsonify, render_template, request
|
||||
|
||||
import db
|
||||
|
||||
teach_bp = Blueprint("teach", __name__)
|
||||
|
||||
|
||||
@teach_bp.route("/teach/api/meta", methods=["GET"])
|
||||
def teach_meta_api():
|
||||
"""Return default metadata for the isolated teaching page."""
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"prompt_version": os.getenv("TEACH_PROMPT_VERSION", "vm-teach-v1"),
|
||||
"model_name": os.getenv("TEACH_MODEL_NAME", "gpt-oss-120b"),
|
||||
})
|
||||
|
||||
|
||||
@teach_bp.route("/teach", methods=["GET"])
|
||||
def teach_page():
|
||||
"""Standalone feedback page for reviewing comparison output."""
|
||||
return render_template("teach.html")
|
||||
|
||||
|
||||
@teach_bp.route("/teach/api/contracts", methods=["GET"])
|
||||
def teach_contracts_api():
|
||||
"""List contracts and supplements for the teaching page."""
|
||||
result, err = db.query(
|
||||
"SELECT c.id AS contract_id, c.number AS contract_number, c.client, c.date_signed, "
|
||||
"s.id AS supplement_id, s.number AS supplement_number, s.date_signed AS supplement_date, "
|
||||
"s.type AS supplement_type, s.document_id, d.filename AS document_filename "
|
||||
"FROM contracts c "
|
||||
"LEFT JOIN supplements s ON s.contract_id = c.id "
|
||||
"LEFT JOIN documents d ON d.id = s.document_id "
|
||||
"ORDER BY c.created_at DESC, s.date_signed DESC NULLS LAST, s.created_at DESC"
|
||||
)
|
||||
if err:
|
||||
return jsonify({"ok": False, "error": err}), 500
|
||||
|
||||
contracts = {}
|
||||
for row in result["rows"]:
|
||||
item = dict(zip(result["columns"], row))
|
||||
cid = str(item["contract_id"])
|
||||
if cid not in contracts:
|
||||
contracts[cid] = {
|
||||
"contract_id": cid,
|
||||
"contract_number": item.get("contract_number"),
|
||||
"client": item.get("client"),
|
||||
"date_signed": item.get("date_signed"),
|
||||
"supplements": [],
|
||||
}
|
||||
if item.get("supplement_id"):
|
||||
contracts[cid]["supplements"].append({
|
||||
"supplement_id": str(item["supplement_id"]),
|
||||
"supplement_number": item.get("supplement_number"),
|
||||
"supplement_date": item.get("supplement_date"),
|
||||
"supplement_type": item.get("supplement_type"),
|
||||
"document_id": str(item["document_id"]) if item.get("document_id") else None,
|
||||
"document_filename": item.get("document_filename"),
|
||||
})
|
||||
|
||||
return jsonify({"ok": True, "contracts": list(contracts.values())})
|
||||
|
||||
|
||||
@teach_bp.route("/teach/api/context", methods=["GET"])
|
||||
def teach_context_api():
|
||||
"""Load rows and history for a chosen supplement."""
|
||||
supplement_id = request.args.get("supplement_id")
|
||||
if not supplement_id:
|
||||
return jsonify({"ok": False, "error": "supplement_id required"}), 400
|
||||
|
||||
supp, err = db.query_one(
|
||||
"SELECT s.id AS supplement_id, s.contract_id, s.number, s.date_signed, s.type, "
|
||||
"c.number AS contract_number, c.client, d.filename AS document_filename "
|
||||
"FROM supplements s "
|
||||
"JOIN contracts c ON c.id = s.contract_id "
|
||||
"LEFT JOIN documents d ON d.id = s.document_id "
|
||||
"WHERE s.id = %s",
|
||||
(supplement_id,),
|
||||
)
|
||||
if err:
|
||||
return jsonify({"ok": False, "error": err}), 500
|
||||
if not supp:
|
||||
return jsonify({"ok": False, "error": "not found"}), 404
|
||||
|
||||
rows_result, err = db.query(
|
||||
"SELECT id, row_num, name, price, qty, sum, date_start, date_end, created_at "
|
||||
"FROM spec_rows WHERE supplement_id = %s ORDER BY row_num",
|
||||
(supplement_id,),
|
||||
)
|
||||
if err:
|
||||
return jsonify({"ok": False, "error": err}), 500
|
||||
|
||||
prev_rows_result, err = 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 s.id = sr.supplement_id "
|
||||
"WHERE s.contract_id = %s AND s.id <> %s "
|
||||
"ORDER BY s.date_signed DESC NULLS LAST, sr.row_num",
|
||||
(supp["contract_id"], supplement_id),
|
||||
)
|
||||
if err:
|
||||
return jsonify({"ok": False, "error": err}), 500
|
||||
|
||||
rows = [dict(zip(rows_result["columns"], row)) for row in rows_result["rows"]]
|
||||
prev_rows = [dict(zip(prev_rows_result["columns"], row)) for row in prev_rows_result["rows"]]
|
||||
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"supplement": supp,
|
||||
"current_rows": rows,
|
||||
"previous_rows": prev_rows,
|
||||
})
|
||||
|
||||
|
||||
@teach_bp.route("/teach/api/feedback", methods=["GET", "POST"])
|
||||
def teach_feedback_api():
|
||||
"""Read or append isolated feedback records."""
|
||||
if request.method == "GET":
|
||||
contract_id = request.args.get("contract_id")
|
||||
supplement_id = request.args.get("supplement_id")
|
||||
sql = (
|
||||
"SELECT id, created_at, contract_id, supplement_id, event_seq, scope, verdict, "
|
||||
"error_type, field, service_name, llm_value, correct_value, prompt_version, model_name, doc_mode, comment "
|
||||
"FROM feedback"
|
||||
)
|
||||
params = []
|
||||
where = []
|
||||
if contract_id:
|
||||
where.append("contract_id = %s")
|
||||
params.append(contract_id)
|
||||
if supplement_id:
|
||||
where.append("supplement_id = %s")
|
||||
params.append(supplement_id)
|
||||
if where:
|
||||
sql += " WHERE " + " AND ".join(where)
|
||||
sql += " ORDER BY created_at DESC LIMIT 200"
|
||||
result, err = db.query(sql, tuple(params) if params else None)
|
||||
if err:
|
||||
return jsonify({"ok": False, "error": err}), 500
|
||||
rows = [dict(zip(result["columns"], row)) for row in result["rows"]]
|
||||
return jsonify({"ok": True, "rows": rows, "count": len(rows)})
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
required = ["scope", "verdict"]
|
||||
for key in required:
|
||||
if not data.get(key):
|
||||
return jsonify({"ok": False, "error": f"{key} is required"}), 400
|
||||
|
||||
sql = (
|
||||
"INSERT INTO feedback (contract_id, supplement_id, event_seq, scope, verdict, error_type, field, "
|
||||
"service_name, llm_value, correct_value, prompt_version, model_name, doc_mode, comment) "
|
||||
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, %s, %s, %s, %s) RETURNING id"
|
||||
)
|
||||
|
||||
params = (
|
||||
data.get("contract_id"),
|
||||
data.get("supplement_id"),
|
||||
data.get("event_seq"),
|
||||
data.get("scope"),
|
||||
data.get("verdict"),
|
||||
data.get("error_type"),
|
||||
data.get("field"),
|
||||
data.get("service_name"),
|
||||
_json_or_none(data.get("llm_value")),
|
||||
_json_or_none(data.get("correct_value")),
|
||||
data.get("prompt_version"),
|
||||
data.get("model_name"),
|
||||
data.get("doc_mode"),
|
||||
data.get("comment"),
|
||||
)
|
||||
|
||||
conn, err = db.connect()
|
||||
if err:
|
||||
return jsonify({"ok": False, "error": err}), 500
|
||||
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(sql, params)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return jsonify({"ok": True, "id": str(row[0]) if row else None})
|
||||
except Exception as exc:
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({"ok": False, "error": str(exc)}), 500
|
||||
finally:
|
||||
db.put_conn(conn)
|
||||
|
||||
|
||||
def _json_or_none(value):
|
||||
"""Prepare JSONB payload for psycopg2."""
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
@@ -1,673 +0,0 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,503 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Сверка договоров — обучение</title>
|
||||
<link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon.png') }}">
|
||||
<script src="https://unpkg.com/lucide@latest"></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; --amber: #f59e0b;
|
||||
}
|
||||
* { 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; position: sticky; top: 0; z-index: 50; }
|
||||
.topbar img { height: 28px; }
|
||||
.topbar .title { font-weight: 600; font-size: 16px; }
|
||||
.content { max-width: 1200px; 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; }
|
||||
.split { display: grid; grid-template-columns: 320px 1fr; gap: 16px; align-items: start; }
|
||||
.sidebar { max-height: 72vh; overflow-y: auto; }
|
||||
.list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.item { border: 1px solid var(--brand-gray); border-radius: 8px; padding: 10px 12px; background: #fff; cursor: pointer; }
|
||||
.item:hover { border-color: var(--brand-primary); }
|
||||
.item.active { border-color: var(--brand-primary); box-shadow: inset 0 0 0 1px var(--brand-primary); }
|
||||
.item .small { font-size: 12px; color: var(--muted); margin-top: 2px; }
|
||||
.table-wrap { border-radius: 6px; border: 1px solid var(--brand-gray); overflow-y: auto; max-height: 50vh; }
|
||||
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); vertical-align: top; }
|
||||
tr:hover td { background: rgba(243,244,246,.5); }
|
||||
.name-cell { max-width: 420px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.num-cell { text-align: right; white-space: nowrap; }
|
||||
.diff-added { background: rgba(34,197,94,.08); }
|
||||
.diff-deleted { background: rgba(239,68,68,.08); }
|
||||
.diff-changed { background: rgba(245,158,11,.08); }
|
||||
.feedback-btn { cursor: pointer; border: 1px solid var(--brand-gray); border-radius: 6px; background: #fff; height: 28px; padding: 0 8px; font-size: 12px; }
|
||||
.feedback-btn:hover { border-color: var(--brand-primary); color: var(--brand-primary); }
|
||||
.feedback-btn.good { border-color: var(--green); color: var(--green); }
|
||||
.feedback-btn.warn { border-color: var(--amber); color: var(--amber); }
|
||||
.feedback-panel { margin-top: 8px; padding: 10px; border: 1px solid var(--brand-gray); border-radius: 8px; background: #fff; }
|
||||
.row-form { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
|
||||
.field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; }
|
||||
.field input, .field select, .field textarea { width: 100%; border: 1px solid var(--brand-gray); border-radius: 6px; padding: 8px; font: inherit; }
|
||||
.field textarea { min-height: 70px; resize: vertical; }
|
||||
.row-actions { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
|
||||
.muted { color: var(--muted); }
|
||||
.ok { color: var(--green); }
|
||||
.err { color: var(--destructive); }
|
||||
.empty-row td { color: var(--muted); text-align: center; padding: 24px; }
|
||||
.preview { margin-top: 12px; font-size: 12px; color: var(--muted); }
|
||||
.pill { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border-radius: 999px; background: #eef2ff; color: var(--brand-primary); font-size: 11px; }
|
||||
.notice { background: #eff6ff; border: 1px solid #bfdbfe; color: #1d4ed8; padding: 10px 12px; border-radius: 8px; font-size: 13px; margin-bottom: 12px; }
|
||||
.loading { color: var(--muted); font-size: 13px; }
|
||||
@media (max-width: 980px) { .split { grid-template-columns: 1fr; } .sidebar { max-height: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<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;">изолированная страница</span></span>
|
||||
<span style="margin-left:auto;font-size:12px;color:var(--brand-primary);white-space:nowrap;">feedback only</span>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="notice">
|
||||
Здесь не запускается рабочий compare-пайплайн. Страница только читает уже разобранные данные и пишет замечания в отдельную таблицу <b>feedback</b>.
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i data-lucide="book-open" style="width:18px;height:18px;"></i>
|
||||
Выбор договора и допника
|
||||
</div>
|
||||
<div class="card-body split">
|
||||
<div class="sidebar">
|
||||
<div class="loading" id="contractsLoading">Загрузка списка договоров...</div>
|
||||
<div class="list" id="contractsList"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div id="contextMeta" class="preview muted">Выберите допник слева</div>
|
||||
<div class="row-actions" style="margin-top:10px;">
|
||||
<button class="btn btn-primary" id="markCorrectBtn" disabled>✓ Всё верно</button>
|
||||
<button class="btn" id="markMissedBtn" disabled>➕ Пропущена позиция</button>
|
||||
<span class="pill" id="saveStatus">0 замечаний</span>
|
||||
</div>
|
||||
<div class="table-wrap" style="margin-top:12px;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:56px;">№</th>
|
||||
<th>Действие</th>
|
||||
<th>Услуга</th>
|
||||
<th style="width:110px;">Цена</th>
|
||||
<th style="width:90px;">Кол-во</th>
|
||||
<th style="width:110px;">Сумма</th>
|
||||
<th style="width:110px;">Дата</th>
|
||||
<th style="width:60px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rowsTableBody">
|
||||
<tr class="empty-row"><td colspan="8">Выберите допник для просмотра строк</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="feedback-panel" id="feedbackPanel" style="display:none;">
|
||||
<div style="font-weight:600;">Замечание по строке <span id="feedbackRowLabel"></span></div>
|
||||
<div class="row-form">
|
||||
<div class="field">
|
||||
<label>Тип ошибки</label>
|
||||
<select id="errorType">
|
||||
<option value="wrong_price">неверная цена</option>
|
||||
<option value="wrong_qty">неверное количество</option>
|
||||
<option value="wrong_name">неверное наименование</option>
|
||||
<option value="wrong_date">неверная дата</option>
|
||||
<option value="extra_row">лишняя строка</option>
|
||||
<option value="missed_row">пропущена строка</option>
|
||||
<option value="wrong_action">неверное действие</option>
|
||||
<option value="wrong_mode">неверный режим</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Поле</label>
|
||||
<select id="errorField">
|
||||
<option value="price">price</option>
|
||||
<option value="qty">qty</option>
|
||||
<option value="sum">sum</option>
|
||||
<option value="name">name</option>
|
||||
<option value="date_start">date_start</option>
|
||||
<option value="action">action</option>
|
||||
<option value="mode">mode</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-form">
|
||||
<div class="field">
|
||||
<label>Правильное значение</label>
|
||||
<textarea id="correctValue" placeholder='{"price":68000}'></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Комментарий</label>
|
||||
<textarea id="comment" placeholder="Необязательно"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<button class="btn btn-primary" id="saveFeedbackBtn">Сохранить</button>
|
||||
<button class="btn" id="cancelFeedbackBtn">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview" id="feedbackPreview"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
|
||||
const contractsList = document.getElementById('contractsList');
|
||||
const contractsLoading = document.getElementById('contractsLoading');
|
||||
const rowsTableBody = document.getElementById('rowsTableBody');
|
||||
const contextMeta = document.getElementById('contextMeta');
|
||||
const feedbackPanel = document.getElementById('feedbackPanel');
|
||||
const feedbackRowLabel = document.getElementById('feedbackRowLabel');
|
||||
const errorType = document.getElementById('errorType');
|
||||
const errorField = document.getElementById('errorField');
|
||||
const correctValue = document.getElementById('correctValue');
|
||||
const comment = document.getElementById('comment');
|
||||
const saveFeedbackBtn = document.getElementById('saveFeedbackBtn');
|
||||
const cancelFeedbackBtn = document.getElementById('cancelFeedbackBtn');
|
||||
const markCorrectBtn = document.getElementById('markCorrectBtn');
|
||||
const markMissedBtn = document.getElementById('markMissedBtn');
|
||||
const saveStatus = document.getElementById('saveStatus');
|
||||
const feedbackPreview = document.getElementById('feedbackPreview');
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
let promptVersion = urlParams.get('prompt_version') || 'vm-teach-v1';
|
||||
let modelName = urlParams.get('model') || 'gpt-oss-120b';
|
||||
|
||||
const appState = {
|
||||
contracts: [],
|
||||
currentSupplement: null,
|
||||
currentRows: [],
|
||||
previousRows: [],
|
||||
selectedRow: null,
|
||||
feedbackMode: 'row',
|
||||
feedbackCount: 0,
|
||||
};
|
||||
|
||||
function esc(text) {
|
||||
return String(text == null ? '' : text)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function fmtDate(value) {
|
||||
if (!value) return '—';
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return String(value);
|
||||
return d.toLocaleDateString('ru-RU');
|
||||
}
|
||||
|
||||
function fmtNum(value) {
|
||||
if (value == null || value === '') return '—';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function updateSaveStatus() {
|
||||
saveStatus.textContent = appState.feedbackCount + ' замечаний';
|
||||
}
|
||||
|
||||
function renderContracts() {
|
||||
if (!appState.contracts.length) {
|
||||
contractsList.innerHTML = '<div class="loading">Нет договоров с допниками</div>';
|
||||
return;
|
||||
}
|
||||
contractsList.innerHTML = appState.contracts.map(function(contract, ci) {
|
||||
const supplements = contract.supplements || [];
|
||||
return `
|
||||
<div class="item" data-contract-index="${ci}">
|
||||
<div style="font-weight:600;">${esc(contract.contract_number || '—')}</div>
|
||||
<div class="small">${esc(contract.client || '—')} ${contract.date_signed ? '· ' + esc(fmtDate(contract.date_signed)) : ''}</div>
|
||||
<div style="margin-top:8px; display:flex; flex-direction:column; gap:6px;">
|
||||
${supplements.map(function(supp, si) {
|
||||
return `
|
||||
<div class="item" style="margin-left:0;" data-supplement-id="${esc(supp.supplement_id)}" data-contract-index="${ci}" data-supp-index="${si}">
|
||||
<div style="font-weight:600;">${esc(supp.supplement_number || 'допник')}</div>
|
||||
<div class="small">${esc(supp.supplement_type || '—')} · ${supp.supplement_date ? esc(fmtDate(supp.supplement_date)) : '—'}</div>
|
||||
<div class="small">${esc(supp.document_filename || '—')}</div>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
contractsList.querySelectorAll('[data-supplement-id]').forEach(function(el) {
|
||||
el.addEventListener('click', function() {
|
||||
const supplementId = el.getAttribute('data-supplement-id');
|
||||
loadContext(supplementId);
|
||||
contractsList.querySelectorAll('[data-supplement-id]').forEach(function(node) { node.classList.remove('active'); });
|
||||
el.classList.add('active');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderRows() {
|
||||
if (!appState.currentRows.length) {
|
||||
rowsTableBody.innerHTML = '<tr class="empty-row"><td colspan="8">У допника нет строк спецификации</td></tr>';
|
||||
return;
|
||||
}
|
||||
rowsTableBody.innerHTML = appState.currentRows.map(function(row) {
|
||||
const cls = row.change_type === 'added' ? 'diff-added' : row.change_type === 'deleted' ? 'diff-deleted' : row.change_type === 'changed' ? 'diff-changed' : '';
|
||||
return `
|
||||
<tr class="${cls}" data-row-num="${esc(row.row_num)}">
|
||||
<td class="num-cell">${esc(row.row_num)}</td>
|
||||
<td>${esc(row.change_type || 'row')}</td>
|
||||
<td class="name-cell">${esc(row.name)}</td>
|
||||
<td class="num-cell">${esc(fmtNum(row.price))}</td>
|
||||
<td class="num-cell">${esc(fmtNum(row.qty))}</td>
|
||||
<td class="num-cell">${esc(fmtNum(row.sum))}</td>
|
||||
<td class="num-cell">${esc(fmtDate(row.date_start))}</td>
|
||||
<td><button class="feedback-btn" data-row-num="${esc(row.row_num)}">⚠</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
rowsTableBody.querySelectorAll('[data-row-num]').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
const rowNum = Number(btn.getAttribute('data-row-num'));
|
||||
openFeedback(rowNum);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openFeedback(rowNum) {
|
||||
const row = appState.currentRows.find(function(r) { return Number(r.row_num) === Number(rowNum); });
|
||||
if (!row) return;
|
||||
appState.selectedRow = row;
|
||||
appState.feedbackMode = 'row';
|
||||
feedbackPanel.style.display = 'block';
|
||||
feedbackRowLabel.textContent = '#' + row.row_num + ' · ' + (row.name || 'строка');
|
||||
errorType.value = 'wrong_price';
|
||||
errorField.value = 'price';
|
||||
correctValue.value = row.price != null ? JSON.stringify({ price: row.price }, null, 2) : '';
|
||||
comment.value = '';
|
||||
feedbackPreview.textContent = 'LLM: ' + JSON.stringify({ action: row.change_type, row_num: row.row_num, name: row.name, price: row.price, qty: row.qty, sum: row.sum, date_start: row.date_start }, null, 2);
|
||||
}
|
||||
|
||||
function closeFeedback() {
|
||||
feedbackPanel.style.display = 'none';
|
||||
appState.selectedRow = null;
|
||||
appState.feedbackMode = 'row';
|
||||
}
|
||||
|
||||
async function loadContracts() {
|
||||
contractsLoading.textContent = 'Загрузка списка договоров...';
|
||||
const resp = await fetch('/teach/api/contracts');
|
||||
const data = await resp.json();
|
||||
if (!data.ok) {
|
||||
contractsLoading.textContent = 'Ошибка: ' + (data.error || 'неизвестно');
|
||||
return;
|
||||
}
|
||||
appState.contracts = data.contracts || [];
|
||||
contractsLoading.style.display = 'none';
|
||||
renderContracts();
|
||||
}
|
||||
|
||||
async function loadContext(supplementId) {
|
||||
const resp = await fetch('/teach/api/context?supplement_id=' + encodeURIComponent(supplementId));
|
||||
const data = await resp.json();
|
||||
if (!data.ok) {
|
||||
contextMeta.innerHTML = '<span class="err">Ошибка: ' + esc(data.error || 'неизвестно') + '</span>';
|
||||
return;
|
||||
}
|
||||
appState.currentSupplement = data.supplement;
|
||||
appState.currentRows = (data.current_rows || []).map(function(row) {
|
||||
return {
|
||||
row_num: row.row_num,
|
||||
name: row.name,
|
||||
price: row.price,
|
||||
qty: row.qty,
|
||||
sum: row.sum,
|
||||
date_start: row.date_start,
|
||||
date_end: row.date_end,
|
||||
change_type: inferChangeType(row, data.previous_rows || []),
|
||||
};
|
||||
});
|
||||
appState.previousRows = data.previous_rows || [];
|
||||
contextMeta.innerHTML = '<span class="pill">' + esc(data.supplement.contract_number || '—') + '</span> ' +
|
||||
'<span class="pill">' + esc(data.supplement.number || 'допник') + '</span> ' +
|
||||
'<span class="muted">' + esc(data.supplement.document_filename || '—') + '</span>';
|
||||
markCorrectBtn.disabled = false;
|
||||
markMissedBtn.disabled = false;
|
||||
renderRows();
|
||||
closeFeedback();
|
||||
}
|
||||
|
||||
function inferChangeType(row, previousRows) {
|
||||
const prev = previousRows.find(function(r) { return Number(r.row_num) === Number(row.row_num); });
|
||||
if (!prev) return 'added';
|
||||
const keys = ['name', 'price', 'qty', 'sum', 'date_start'];
|
||||
const same = keys.every(function(key) { return String(prev[key] ?? '') === String(row[key] ?? ''); });
|
||||
return same ? 'unchanged' : 'changed';
|
||||
}
|
||||
|
||||
async function saveFeedback(payload) {
|
||||
const resp = await fetch('/teach/api/feedback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!data.ok) throw new Error(data.error || 'save failed');
|
||||
appState.feedbackCount += 1;
|
||||
updateSaveStatus();
|
||||
return data;
|
||||
}
|
||||
|
||||
cancelFeedbackBtn.addEventListener('click', closeFeedback);
|
||||
|
||||
markCorrectBtn.addEventListener('click', async function() {
|
||||
if (!appState.currentSupplement) return;
|
||||
try {
|
||||
await saveFeedback({
|
||||
contract_id: appState.currentSupplement.contract_id,
|
||||
supplement_id: appState.currentSupplement.supplement_id,
|
||||
event_seq: null,
|
||||
scope: 'document',
|
||||
verdict: 'correct',
|
||||
error_type: null,
|
||||
field: null,
|
||||
service_name: null,
|
||||
llm_value: null,
|
||||
correct_value: null,
|
||||
prompt_version: promptVersion,
|
||||
model_name: modelName,
|
||||
doc_mode: appState.currentSupplement.supplement_type || null,
|
||||
comment: null,
|
||||
});
|
||||
feedbackPreview.innerHTML = '<span class="ok">Документ помечен как верный</span>';
|
||||
} catch (err) {
|
||||
feedbackPreview.innerHTML = '<span class="err">' + esc(err.message) + '</span>';
|
||||
}
|
||||
});
|
||||
|
||||
markMissedBtn.addEventListener('click', function() {
|
||||
if (!appState.currentSupplement) return;
|
||||
appState.feedbackMode = 'missed';
|
||||
appState.selectedRow = null;
|
||||
feedbackPanel.style.display = 'block';
|
||||
feedbackRowLabel.textContent = 'пропущенная позиция';
|
||||
errorType.value = 'missed_row';
|
||||
errorField.value = 'name';
|
||||
correctValue.value = '';
|
||||
feedbackPreview.textContent = 'Добавьте пропущенную строку в correct value (JSON)';
|
||||
});
|
||||
|
||||
saveFeedbackBtn.addEventListener('click', async function() {
|
||||
if (!appState.currentSupplement) return;
|
||||
if (!appState.selectedRow && appState.feedbackMode !== 'missed') return;
|
||||
if (appState.feedbackMode === 'missed') {
|
||||
try {
|
||||
await saveFeedback({
|
||||
contract_id: appState.currentSupplement.contract_id,
|
||||
supplement_id: appState.currentSupplement.supplement_id,
|
||||
event_seq: null,
|
||||
scope: 'missed',
|
||||
verdict: 'error',
|
||||
error_type: 'missed_row',
|
||||
field: errorField.value,
|
||||
service_name: null,
|
||||
llm_value: null,
|
||||
correct_value: parseJsonOrText(correctValue.value),
|
||||
prompt_version: promptVersion,
|
||||
model_name: modelName,
|
||||
doc_mode: appState.currentSupplement.supplement_type || null,
|
||||
comment: comment.value || null,
|
||||
});
|
||||
feedbackPreview.innerHTML = '<span class="ok">Сохранено</span>';
|
||||
closeFeedback();
|
||||
} catch (err) {
|
||||
feedbackPreview.innerHTML = '<span class="err">' + esc(err.message) + '</span>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await saveFeedback({
|
||||
contract_id: appState.currentSupplement.contract_id,
|
||||
supplement_id: appState.currentSupplement.supplement_id,
|
||||
event_seq: appState.selectedRow.row_num,
|
||||
scope: 'row',
|
||||
verdict: 'error',
|
||||
error_type: errorType.value,
|
||||
field: errorField.value,
|
||||
service_name: appState.selectedRow.name,
|
||||
llm_value: {
|
||||
row_num: appState.selectedRow.row_num,
|
||||
action: appState.selectedRow.change_type,
|
||||
name: appState.selectedRow.name,
|
||||
price: appState.selectedRow.price,
|
||||
qty: appState.selectedRow.qty,
|
||||
sum: appState.selectedRow.sum,
|
||||
date_start: appState.selectedRow.date_start,
|
||||
},
|
||||
correct_value: parseJsonOrText(correctValue.value),
|
||||
prompt_version: promptVersion,
|
||||
model_name: modelName,
|
||||
doc_mode: appState.currentSupplement.supplement_type || null,
|
||||
comment: comment.value || null,
|
||||
});
|
||||
feedbackPreview.innerHTML = '<span class="ok">Сохранено</span>';
|
||||
closeFeedback();
|
||||
} catch (err) {
|
||||
feedbackPreview.innerHTML = '<span class="err">' + esc(err.message) + '</span>';
|
||||
}
|
||||
});
|
||||
|
||||
function parseJsonOrText(value) {
|
||||
const trimmed = String(value || '').trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch (err) {
|
||||
return { text: trimmed };
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMeta() {
|
||||
try {
|
||||
const resp = await fetch('/teach/api/meta');
|
||||
const data = await resp.json();
|
||||
if (data && data.ok) {
|
||||
if (!urlParams.get('prompt_version') && data.prompt_version) {
|
||||
promptVersion = data.prompt_version;
|
||||
}
|
||||
if (!urlParams.get('model') && data.model_name) {
|
||||
modelName = data.model_name;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Keep defaults if metadata endpoint is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
loadMeta()
|
||||
.then(function() { return loadContracts(); })
|
||||
.catch(function(err) {
|
||||
contractsLoading.textContent = 'Ошибка: ' + err.message;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+15
-11
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Сверка договоров</title>
|
||||
<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://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
||||
<style>
|
||||
@@ -47,9 +48,9 @@
|
||||
.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 { 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-header { padding: 14px 16px; border-bottom: 1px solid var(--brand-gray); display: flex; align-items: center; gap: 8px; font-weight: 600; }
|
||||
.modal-body { padding: 16px; }
|
||||
.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; }
|
||||
@@ -59,7 +60,7 @@
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<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;">v3.0.2</span></span>
|
||||
<span class="title">Сверка договоров <span style="font-weight:400;color:var(--muted);font-size:12px;">v2.0.10</span></span>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
@@ -74,7 +75,7 @@
|
||||
<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>
|
||||
<tr><th>Имя</th><th style="width:130px;">Изменён</th><th style="width:90px;">Размер</th><th style="width:115px;">Статус</th><th style="width:55px;"></th></tr>
|
||||
</thead>
|
||||
<tbody id="fileTable"></tbody>
|
||||
</table>
|
||||
@@ -123,7 +124,7 @@
|
||||
|
||||
function renderTable() {
|
||||
if (fileQueue.length === 0) {
|
||||
fileTable.innerHTML = '<tr class="empty-row"><td colspan="6">Нет файлов — выберите .docx / .pdf / .zip</td></tr>';
|
||||
fileTable.innerHTML = '<tr class="empty-row"><td colspan="5">Нет файлов — выберите .docx / .pdf / .zip</td></tr>';
|
||||
parseBtn.disabled = true;
|
||||
} else {
|
||||
fileTable.innerHTML = fileQueue.map(function(f, i) {
|
||||
@@ -133,8 +134,8 @@
|
||||
'<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>' +
|
||||
'<td><button class="remove-btn" onclick="removeFile(' + i + ')" title="Удалить">×</button>' +
|
||||
'<button class="info-btn" id="info_' + i + '" onclick="showInfo(' + i + ')" title="Инфо">ℹ</button></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
}
|
||||
@@ -155,7 +156,7 @@
|
||||
|
||||
window.removeFile = removeFile;
|
||||
|
||||
// ── Простая загрузка FormData (ВМ, без лимитов) ──────────
|
||||
// ── Прямая загрузка FormData ─────────────────────────────
|
||||
|
||||
function uploadFile(file, onProgress) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
@@ -209,11 +210,14 @@
|
||||
fileQueue[rowIdx].status = '<span style="color:var(--muted);">↑ ' + pct + '%</span>';
|
||||
renderTable();
|
||||
});
|
||||
if (resp && resp.contract_id) contractId = resp.contract_id;
|
||||
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);
|
||||
}
|
||||
@@ -325,7 +329,7 @@
|
||||
'<td></td>' +
|
||||
'<td class="num-cell">' + formatSize(d.total_bytes) + '</td>' +
|
||||
'<td><strong>' + d.total_time_s + 'с</strong></td>' +
|
||||
'<td></td><td></td>';
|
||||
'<td></td>';
|
||||
fileTable.appendChild(summaryRow);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""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)})
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 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
@@ -0,0 +1,45 @@
|
||||
"""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
|
||||
@@ -0,0 +1,51 @@
|
||||
"""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