Author SHA1 Message Date
naeel a7b53fde19 feat: PostgreSQL → SQLite — thread-local, WAL, session-key, os.remove cleanup
Deploy contracts-flask / validate (push) Successful in 0s
2026-07-15 10:56:42 +04:00
naeel 96718fc065 revert: app/ → site/ (drhider proves site/ works fine) 2026-07-14 22:56:19 +04:00
naeel 38547404f5 fix: add /chat and /chat.cfm endpoint (was missing) 2026-07-14 22:33:13 +04:00
naeel 8f688215cc Steps 3-8: config, blueprints (upload/pipeline/api/prompts/health/pages), new app.py (no VM proxy), process.py adapted to generator, VM_API='' 2026-07-14 22:30:58 +04:00
naeel e635cb855a Step 2: copy compare/ services to app/services/ with fixed imports 2026-07-14 22:27:30 +04:00
naeel fc08db0c76 Step 1: copy db/ modules + llm_prompt.py to app/ 2026-07-14 22:25:38 +04:00
naeel 976e59b496 Step 0: rename site/ → app/ (Python stdlib conflict fix) 2026-07-14 22:24:24 +04:00
naeel ca7b70fe41 fix: drhider modal header sticky (v1.15)
Deploy contracts-flask / validate (push) Successful in 0s
2026-07-07 17:01:40 +04:00
naeel 16ef92fc74 fix: drhider help — pdfplumber not LibreOffice (v1.14)
Deploy contracts-flask / validate (push) Successful in 0s
2026-07-07 16:59:49 +04:00
naeel 819193c6ad fix: drhider PDF indicator short (*.docx) v1.13
Deploy contracts-flask / validate (push) Successful in 0s
2026-07-07 16:43:47 +04:00
naeel f68597f680 fix: drhider PDF→DOCX via pdfplumber (LibreOffice cant do it) v1.12
Deploy contracts-flask / validate (push) Successful in 0s
2026-07-07 16:37:36 +04:00
naeel 88b63733b1 fix: drhider sticky header (v1.11)
Deploy contracts-flask / validate (push) Successful in 0s
2026-07-07 16:27:48 +04:00
naeel 42855fe9b3 feat: drhider PDF→DOCX indicator in file list (v1.10)
Deploy contracts-flask / validate (push) Successful in 0s
2026-07-07 16:23:57 +04:00
naeel 211f391c6c feat: drhider PDF→DOCX conversion (LibreOffice), collision dialog (v1.9)
Deploy contracts-flask / validate (push) Successful in 0s
2026-07-07 16:14:00 +04:00
30 changed files with 2872 additions and 143 deletions
+45 -4
View File
@@ -207,6 +207,8 @@ class TwoPassObfuscator:
""" """
# --- Распаковать ZIP-файлы --- # --- Распаковать ZIP-файлы ---
files = self._expand_zips(files) files = self._expand_zips(files)
# --- Конвертировать PDF → DOCX ---
files = self._convert_pdfs_to_docx(files)
try: try:
# --- Проход 1: сбор сущностей --- # --- Проход 1: сбор сущностей ---
@@ -236,10 +238,6 @@ class TwoPassObfuscator:
pass pass
elif fname in all_docx: elif fname in all_docx:
obf_content = self._replace_in_docx(all_docx[fname]) obf_content = self._replace_in_docx(all_docx[fname])
elif fname.endswith('.pdf'):
txt = all_texts.get(fname, '')
obf_content = self._replace_in_text(txt, fname)
fname = fname[:-4] + '.txt'
else: else:
txt = all_texts.get(fname, '') txt = all_texts.get(fname, '')
obf_content = self._replace_in_text(txt, fname) obf_content = self._replace_in_text(txt, fname)
@@ -253,6 +251,49 @@ class TwoPassObfuscator:
self._regex_replacements.clear() self._regex_replacements.clear()
self._sorted_keys.clear() self._sorted_keys.clear()
def _convert_pdfs_to_docx(self, files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]:
"""Конвертировать PDF в DOCX через pdfplumber. При совпадении имён — _из_pdf."""
import pdfplumber
from docx import Document as DocxDocument
result = []
existing_names = {f[0] for f in files}
for fname, content, ctype in files:
if not fname.lower().endswith('.pdf'):
result.append((fname, content, ctype))
continue
try:
doc = DocxDocument()
with pdfplumber.open(io.BytesIO(content)) as pdf:
for page in pdf.pages:
tables = page.extract_tables()
for table in tables:
if table:
rows = [[str(c or "").strip() for c in (row or [])] for row in table]
rows = [r for r in rows if any(r)]
if rows:
t = doc.add_table(rows=len(rows), cols=len(rows[0]))
t.style = 'Table Grid'
for ri, row in enumerate(rows):
for ci, cell_text in enumerate(row):
t.rows[ri].cells[ci].text = cell_text
text = page.extract_text()
if text:
for line in text.split('\n'):
line = line.strip()
if line:
doc.add_paragraph(line)
buf = io.BytesIO()
doc.save(buf)
new_name = fname[:-4] + '.docx'
if new_name in existing_names:
new_name = fname[:-4] + '_из_pdf.docx'
existing_names.add(new_name)
result.append((new_name, buf.getvalue(), ctype))
except Exception as e:
log.warning("PDF→DOCX error for %s: %s", fname, e)
result.append((fname, content, ctype))
return result
def _expand_zips(self, files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]: def _expand_zips(self, files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]:
"""Распаковать ZIP-файлы, заменив их содержимым. Остальные файлы — как есть. """Распаковать ZIP-файлы, заменив их содержимым. Остальные файлы — как есть.
Защита от ZIP-бомб: ratio + накопительный размер (как в compare/unzip.py).""" Защита от ZIP-бомб: ratio + накопительный размер (как в compare/unzip.py)."""
+34 -120
View File
@@ -1,128 +1,42 @@
"""contracts-flask — Flask-фронтенд для сверки договоров. """contracts-flask v2.0 — полный перенос с ВМ на Flask.
1:1 функционал с Lucee-версией. Больше никаких прокси на contracts.kube5s.ru — всё локально.
Все тяжёлые операции — на ВМ (contracts.kube5s.ru).
""" """
import os from flask import Flask
import httpx from site.config import VERSION, MAX_CONTENT_LENGTH
from flask import Flask, render_template, request, jsonify from site.routes import register_routes
app = Flask(__name__)
VM_API = os.environ.get("VM_API", "https://contracts.kube5s.ru")
LLM_URL = os.environ.get("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions")
LLM_KEY = os.environ.get("LLM_API_KEY", "")
LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-oss-120b")
@app.route("/") def create_app():
def index(): app = Flask(__name__)
return render_template("index.html", vm_api=VM_API) app.config["VERSION"] = VERSION
app.config["MAX_CONTENT_LENGTH"] = MAX_CONTENT_LENGTH
# DB auto-seed — schema migration + seed prompts + crash recovery
_init_db()
# Регистрация всех blueprint'ов
register_routes(app)
# no_cache на все ответы
@app.after_request
def no_cache(response):
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
return app
@app.route("/chat", methods=["POST"]) def _init_db():
def chat(): """Создать БД + схему + seed prompts. SQLite — всё в одном файле /tmp."""
contract_id = request.args.get("contract_id", "") from site.db.connection import init_db
question = request.form.get("question", "") init_db()
if not contract_id or not question:
return jsonify({"ok": False, "error": "contract_id and question required"})
try: try:
with httpx.Client(timeout=10) as client: from site.db import prompts as db_prompts
resp = client.get(f"{VM_API}/api/spec-current", params={"contract_id": contract_id}) db_prompts.seed_defaults()
resp.raise_for_status() except Exception:
rows = resp.json().get("rows", []) pass
except Exception as e:
return jsonify({"ok": False, "error": f"VM error: {e}"})
if not rows:
return jsonify({"ok": True, "answer": "Нет данных. Сначала запустите сравнение."})
ctx = "\n".join(f"{r.get('name','')} | цена={r.get('price')} | объём={r.get('qty')} | сумма={r.get('sum')} | начало={r.get('date_start')}" for r in rows)
prompt = f"Ты — анализатор договоров. Данные:\n{ctx}\n\nВопрос: {question}\nОтветь кратко, только по данным."
try:
with httpx.Client(http2=True, timeout=120) as client:
resp = client.post(LLM_URL, json={
"model": LLM_MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 1000, "temperature": 0.1,
}, headers={"Authorization": f"Bearer {LLM_KEY}", "Content-Type": "application/json"})
resp.raise_for_status()
return jsonify({"ok": True, "answer": resp.json()["choices"][0]["message"]["content"]})
except Exception as e:
return jsonify({"ok": False, "error": f"LLM error: {e}"})
@app.route("/api/prompts", methods=["GET"]) app = create_app()
def prompts_get():
try:
with httpx.Client(timeout=10) as client:
resp = client.get(f"{VM_API}/api/prompts", params=request.args)
return jsonify(resp.json())
except Exception as e:
return jsonify({"ok": False, "error": str(e)})
@app.route("/api/prompts/list", methods=["GET"])
def prompts_list():
try:
with httpx.Client(timeout=10) as client:
resp = client.get(f"{VM_API}/api/prompts/list")
return jsonify(resp.json())
except Exception as e:
return jsonify({"ok": False, "error": str(e)})
@app.route("/api/prompts/save", methods=["POST"])
def prompts_save():
try:
with httpx.Client(timeout=10) as client:
resp = client.post(f"{VM_API}/api/prompts/save", json=request.get_json())
return jsonify(resp.json())
except Exception as e:
return jsonify({"ok": False, "error": str(e)})
@app.route("/api/prompts/activate", methods=["POST"])
def prompts_activate():
try:
with httpx.Client(timeout=10) as client:
resp = client.post(f"{VM_API}/api/prompts/activate", json=request.get_json())
return jsonify(resp.json())
except Exception as e:
return jsonify({"ok": False, "error": str(e)})
@app.route("/architect")
def architect():
return render_template("architect.html")
@app.route("/ci-cd")
def ci_cd():
return render_template("ci-cd.html")
@app.route("/drhider")
@app.route("/DrHider")
def drhider():
return render_template("drhider.html", vm_api=VM_API)
@app.route("/health")
def health():
return {"ok": True, "service": "contracts-flask"}
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, threaded=True)
# ── Совместимость с Lucee-путями ──────────────────────────────
@app.route("/chat.cfm", methods=["POST"])
def chat_cfm():
return chat()
@app.route("/prompt.cfm", methods=["GET"])
def prompt_cfm():
return prompts_get()
+11
View File
@@ -0,0 +1,11 @@
"""Конфигурация приложения — все настройки в одном месте."""
import os
VERSION = "2.0.0"
LLM_URL = os.getenv("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions")
LLM_KEY = os.getenv("LLM_API_KEY", "")
LLM_MODEL = os.getenv("LLM_MODEL", "gpt-oss-120b")
MAX_CONTENT_LENGTH = 200 * 1024 * 1024 # 200 MB
API_KEY = os.getenv("API_KEY", "")
View File
+220
View File
@@ -0,0 +1,220 @@
"""Database connection — SQLite with thread-local connections.
Архитектура (Sonnet review 2026-07-15):
- threading.local() — каждому потоку своё соединение
- WAL-режим — readers не блокируют writer
- _db_session_key — защита от inode split-brain при cleanup+reuse потоков
- busy_timeout=5000 — ждать при конкурентной записи
"""
import sqlite3
import threading
import time
import os
DB_PATH = "/tmp/contracts.db"
_db_session_key = None
_local = threading.local()
def init_db():
"""Создать/пересоздать БД + схему. Вызывается при старте и после cleanup."""
global _db_session_key
_db_session_key = time.time()
# Удалить старый файл + WAL-сателлиты
for f in (DB_PATH, DB_PATH + "-wal", DB_PATH + "-shm"):
try:
os.remove(f)
except FileNotFoundError:
pass
conn = get_conn()
_create_schema(conn)
return conn
def _create_schema(conn):
"""Создать все таблицы (idempotent)."""
conn.executescript("""
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
mime_type TEXT DEFAULT 'application/octet-stream',
original_bytes TEXT,
status TEXT DEFAULT 'uploaded',
error_message TEXT,
elements_json TEXT,
batch_id TEXT,
zip_source TEXT,
content_hash TEXT,
classify_status TEXT DEFAULT 'pending',
doc_type TEXT,
own_number TEXT,
parent_number TEXT,
doc_date TEXT,
counterparty TEXT,
classify_raw TEXT,
classify_input TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS contracts (
id TEXT PRIMARY KEY,
number TEXT NOT NULL,
client TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS supplements (
id TEXT PRIMARY KEY,
contract_id TEXT NOT NULL REFERENCES contracts(id),
document_id TEXT NOT NULL REFERENCES documents(id),
type TEXT DEFAULT 'additional',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS spec_events (
id TEXT PRIMARY KEY,
contract_id TEXT NOT NULL,
supplement_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS spec_current (
id TEXT PRIMARY KEY,
contract_id TEXT NOT NULL,
name_hash TEXT NOT NULL,
name TEXT,
price REAL,
qty REAL,
sum REAL,
date_start TEXT,
last_event_id TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS prompts (
id TEXT PRIMARY KEY,
role TEXT NOT NULL,
name TEXT DEFAULT '',
body TEXT NOT NULL,
is_active INTEGER DEFAULT 0,
notes TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS upload_chunks (
id TEXT PRIMARY KEY,
upload_id TEXT NOT NULL,
chunk_index INTEGER NOT NULL,
data TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_documents_batch ON documents(batch_id);
CREATE INDEX IF NOT EXISTS idx_documents_hash ON documents(batch_id, content_hash);
CREATE INDEX IF NOT EXISTS idx_supplements_contract ON supplements(contract_id);
CREATE INDEX IF NOT EXISTS idx_spec_current_contract ON spec_current(contract_id);
CREATE INDEX IF NOT EXISTS idx_spec_events_supplement ON spec_events(supplement_id);
""")
def cleanup_db():
"""Удалить БД полностью. Вызывает init_db() для создания новой."""
init_db()
def get_conn():
"""Thread-local соединение. Пересоздаётся при смене сессии."""
global _db_session_key
conn = getattr(_local, "conn", None)
local_key = getattr(_local, "session_key", None)
if conn is None or local_key != _db_session_key:
if conn is not None:
try:
conn.close()
except Exception:
pass
conn = sqlite3.connect(DB_PATH, timeout=5, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute("PRAGMA foreign_keys=ON")
_local.conn = conn
_local.session_key = _db_session_key
return conn
def query(sql, params=None):
"""SELECT → list[dict]."""
conn = get_conn()
sql = _pg_to_sqlite(sql)
cur = conn.execute(sql, params or [])
return [dict(r) for r in cur.fetchall()]
def execute(sql, params=None):
"""INSERT/UPDATE/DELETE → rowcount."""
conn = get_conn()
sql = _pg_to_sqlite(sql)
cur = conn.execute(sql, params or [])
conn.commit()
return cur.rowcount
def execute_returning(sql, params=None):
"""INSERT с RETURNING → dict (эмулируется через lastrowid)."""
conn = get_conn()
table = _extract_table(sql)
sql = _pg_to_sqlite(sql)
if " RETURNING " in sql.upper():
sql = sql[:sql.upper().rfind(" RETURNING ")]
cur = conn.execute(sql, params or [])
conn.commit()
rowid = cur.lastrowid
if table and rowid:
row = conn.execute(f"SELECT * FROM {table} WHERE rowid = ?", (rowid,)).fetchone()
if row:
return dict(row)
# Fallback: если нет таблицы или rowid — просто вернуть последнюю строку
if table:
row = conn.execute(f"SELECT * FROM {table} ORDER BY rowid DESC LIMIT 1").fetchone()
return dict(row) if row else None
return None
def _pg_to_sqlite(sql):
"""Конвертировать PostgreSQL-специфичный SQL в SQLite."""
# %s → ?
sql = sql.replace("%s", "?")
# ::jsonb → убрать (SQLite не типизирует)
sql = sql.replace("::jsonb", "")
# gen_random_uuid() → хекс-UUID через randomblob
if "gen_random_uuid()" in sql:
import uuid
sql = sql.replace("gen_random_uuid()", "?")
# BOOLEAN → INTEGER
sql = sql.replace(" BOOLEAN ", " INTEGER ")
sql = sql.replace(" bool ", " INTEGER ")
# ILIKE → LIKE (SQLite LIKE case-insensitive для ASCII)
sql = sql.replace(" ILIKE ", " LIKE ")
# FALSE/TRUE → 0/1
sql = sql.replace(" FALSE", " 0").replace(" TRUE", " 1")
sql = sql.replace(" false", " 0").replace(" true", " 1")
return sql
def _extract_table(sql):
"""Извлечь имя таблицы из INSERT INTO <table>."""
import re
m = re.search(r"INSERT\s+INTO\s+(\w+)", sql, re.IGNORECASE)
return m.group(1) if m else None
+25
View File
@@ -0,0 +1,25 @@
"""Contracts CRUD."""
from .connection import query, execute, execute_returning
def insert(number, client=""):
return execute_returning(
"INSERT INTO contracts (number, client) VALUES (%s, %s) RETURNING *",
(number, client),
)
def get(contract_id):
rows = query("SELECT * FROM contracts WHERE id = %s", (contract_id,))
return rows[0] if rows else None
def delete(contract_id):
return execute("DELETE FROM contracts WHERE id = %s", (contract_id,))
def delete_orphaned():
"""Remove contracts with no supplements."""
execute(
"DELETE FROM contracts WHERE id NOT IN (SELECT DISTINCT contract_id FROM supplements)"
)
+115
View File
@@ -0,0 +1,115 @@
"""Documents CRUD."""
from .connection import query, execute, execute_returning
def insert(filename, mime_type, original_bytes, status="uploaded", batch_id=None, zip_source=None, content_hash=None):
"""Insert document, return row dict."""
return execute_returning(
"""INSERT INTO documents (filename, mime_type, original_bytes, status, batch_id, zip_source, content_hash)
VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING *""",
(filename, mime_type, original_bytes, status, batch_id, zip_source, content_hash),
)
def get(doc_id):
rows = query("SELECT * FROM documents WHERE id = %s", (doc_id,))
return rows[0] if rows else None
def get_by_hash(batch_id, content_hash):
"""Find document by content hash within batch."""
rows = query(
"SELECT id FROM documents WHERE batch_id = %s AND content_hash = %s LIMIT 1",
(batch_id, content_hash),
)
return rows[0] if rows else None
def set_parsed(doc_id, elements_json):
"""Update elements_json + status='parsed'."""
import json
return execute(
"UPDATE documents SET elements_json = %s::jsonb, status = 'parsed' WHERE id = %s",
(json.dumps(elements_json, ensure_ascii=False), doc_id),
)
def set_error(doc_id, error_message):
return execute(
"UPDATE documents SET status = 'error', error_message = %s WHERE id = %s",
(error_message, doc_id),
)
def delete(doc_id):
return execute("DELETE FROM documents WHERE id = %s", (doc_id,))
def set_classification(doc_id, doc_type, own_number, parent_number, doc_date, counterparty, classify_raw=None, classify_input=None):
"""Store LLM classification results + raw response + input text."""
return execute(
"""UPDATE documents SET doc_type=%s, own_number=%s, parent_number=%s,
doc_date=%s, counterparty=%s, classify_status='classified', classify_raw=%s, classify_input=%s
WHERE id=%s""",
(doc_type, own_number, parent_number, doc_date, counterparty, classify_raw, classify_input, doc_id),
)
def set_classify_failed(doc_id, error):
return execute(
"UPDATE documents SET classify_status='failed', error_message=%s WHERE id=%s",
(error, doc_id),
)
def set_classify_garbage(doc_id, reason=""):
"""Mark document as garbage (Stage 1-2 filter, no LLM call)."""
return execute(
"UPDATE documents SET doc_type='garbage', classify_status='garbage', error_message=%s WHERE id=%s",
(f"garbage: {reason}", doc_id),
)
def list_pending(batch_id):
"""Documents waiting for classification."""
return query(
"SELECT id, filename, elements_json FROM documents WHERE batch_id=%s AND classify_status='pending'",
(batch_id,),
)
def reset_classify_status(batch_id):
"""Сбросить classify_status на 'pending' только для 'processing' (crash recovery).
Уже классифицированные ('classified', 'garbage', 'failed') НЕ трогаем."""
return execute(
"UPDATE documents SET classify_status='pending', error_message=NULL WHERE batch_id=%s AND classify_status='processing'",
(batch_id,),
)
def set_classify_processing(doc_id):
"""Mark document as being processed (for crash recovery)."""
return execute(
"UPDATE documents SET classify_status='processing' WHERE id=%s",
(doc_id,),
)
def list_by_batch(batch_id):
"""All documents in a batch with classification fields."""
return query(
"""SELECT id, filename, status, doc_type, own_number, parent_number,
doc_date, counterparty, classify_status, error_message, classify_raw, classify_input,
zip_source
FROM documents WHERE batch_id=%s ORDER BY created_at""",
(batch_id,),
)
def count_by_status(batch_id):
"""Count documents by classify_status."""
rows = query(
"SELECT classify_status, count(*) as cnt FROM documents WHERE batch_id=%s GROUP BY classify_status",
(batch_id,),
)
return {r["classify_status"]: r["cnt"] for r in rows}
+151
View File
@@ -0,0 +1,151 @@
"""Prompts CRUD."""
from .connection import query, execute, execute_returning
def _serialize(row):
"""Convert datetime fields to strings for JSON serialization (non-mutating)."""
if row and row.get("created_at"):
row = dict(row)
row["created_at"] = str(row["created_at"])
return row
def get_active(role):
rows = query(
"SELECT * FROM prompts WHERE role = %s AND is_active = true ORDER BY created_at DESC LIMIT 1",
(role,),
)
return _serialize(rows[0]) if rows else None
def get(prompt_id):
rows = query("SELECT * FROM prompts WHERE id = %s", (prompt_id,))
return rows[0] if rows else None
def seed_defaults():
"""Auto-seed default prompts if table is empty."""
# Check each role separately — don't skip if one is missing
existing_roles = set(r["role"] for r in query("SELECT DISTINCT role FROM prompts"))
if "extract" not in existing_roles:
extract_body = (
"Ты — анализатор договоров облачного провайдера.\n\n"
"Ниже текст спецификации услуг из ПЕРВОГО документа (базовый договор).\n"
"Извлеки ВСЕ строки спецификации в JSON-массив.\n\n"
"Верни СТРОГО JSON без пояснений. Не используй markdown-блоки.\n\n"
"ФОРМАТ:\n"
'{\n "mode": "partial",\n "ops": [\n'
' {"action": "ADD", "new_row": {"name": "полное наименование", "price": число, "qty": число, "sum": число, "date_start": "YYYY-MM-DD"}, "comment": ""}\n'
" ]\n}\n\n"
"ПРАВИЛА:\n"
"1. Извлеки КАЖДУЮ строку таблицы спецификации как отдельную ADD-операцию.\n"
'2. Пропускай итоговые строки ("Итого...") и строки с подписями.\n'
"3. Если ячейка пустая — ставь null (не пиши 0).\n"
"4. price, qty, sum — ЧИСЛА, не строки.\n\n"
"ТЕКСТ ДОКУМЕНТА:\n---\n{doc_text}\n---"
)
execute(
"INSERT INTO prompts (role, name, body, is_active, notes) VALUES (%s, %s, %s, %s, %s)",
("extract", "default-v1", extract_body, True, "Авто-создан из llm_prompt.py _build_initial"),
)
if "diff" not in existing_roles:
diff_body = (
"Ты — анализатор допсоглашений к договорам облачного провайдера.\n\n"
"У тебя есть текущая спецификация услуг и текст нового допсоглашения (ДС).\n"
"Твоя задача — определить, какие изменения вносит ДС в текущую спецификацию.\n\n"
"Верни СТРОГО JSON без пояснений. Не используй markdown-блоки.\n\n"
"ФОРМАТ:\n"
'{\n "mode": "partial" | "full_replace",\n "ops": [\n'
' {"action": "ADD", "new_row": {"name": "...", "price": число, "qty": число, "sum": число, "date_start": "YYYY-MM-DD"}, "comment": "..."},\n'
' {"action": "UPDATE", "target_id": "rN", "new_values": {"price": число}, "comment": "..."},\n'
' {"action": "DELETE", "target_id": "rN", "comment": "..."},\n'
' {"action": "UNRESOLVED", "new_values": {"name": "...", "price": число, ...}, "reason": "почему не смог сопоставить"}\n'
" ]\n}\n\n"
"ПРАВИЛА:\n"
'1. mode = "full_replace" — если в тексте есть фразы: «в следующей редакции», «заменить приложение», «излагается в следующей редакции». При full_replace — опиши ВСЕ новые строки как ADD.\n'
'2. mode = "partial" — если ДС меняет только отдельные строки.\n'
"3. Для UPDATE/DELETE — укажи target_id (r1, r2...) из списка текущей спецификации. НЕ придумывай новые id.\n"
"4. new_values в UPDATE — только ИЗМЕНЁННЫЕ поля (не все).\n"
"5. Если не можешь однозначно сопоставить строку — action: UNRESOLVED с reason.\n"
"6. Пропускай итоговые строки и подписи.\n"
"7. price, qty, sum — ЧИСЛА (не строки).\n\n"
"ТЕКУЩАЯ СПЕЦИФИКАЦИЯ:\n{spec_current}\n\n"
"ТЕКСТ ДОПСОГЛАШЕНИЯ:\n---\n{doc_text}\n---"
)
execute(
"INSERT INTO prompts (role, name, body, is_active, notes) VALUES (%s, %s, %s, %s, %s)",
("diff", "default-v1", diff_body, True, "Авто-создан из llm_prompt.py _build_diff"),
)
_ensure_classify_prompt()
def list_by_role(role):
"""List all versions for a role, newest first."""
rows = query(
"SELECT id, role, name, is_active, created_by, notes, created_at FROM prompts WHERE role=%s ORDER BY created_at DESC",
(role,),
)
for r in rows:
if r.get("created_at"):
r["created_at"] = str(r["created_at"])
return rows
def save_new_version(role, name, body, notes="", is_active=True):
"""Save new prompt version. Deactivates all others for this role, inserts new one."""
if is_active:
execute("UPDATE prompts SET is_active=false WHERE role=%s", (role,))
return execute_returning(
"""INSERT INTO prompts (role, name, body, is_active, notes)
VALUES (%s, %s, %s, %s, %s) RETURNING *""",
(role, name, body, is_active, notes),
)
def activate(prompt_id):
"""Activate a prompt version (deactivates others for same role)."""
row = query("SELECT role FROM prompts WHERE id=%s", (prompt_id,))
if not row:
return False
role = row[0]["role"]
execute("UPDATE prompts SET is_active=false WHERE role=%s", (role,))
execute("UPDATE prompts SET is_active=true WHERE id=%s", (prompt_id,))
return True
def delete_prompt(prompt_id):
"""Delete a prompt version (cannot delete active one)."""
row = query("SELECT is_active FROM prompts WHERE id=%s", (prompt_id,))
if not row:
return False
if row[0]["is_active"]:
return False # cannot delete active
execute("DELETE FROM prompts WHERE id=%s", (prompt_id,))
return True
def _ensure_classify_prompt():
"""Ensure classify prompt exists (idempotent)."""
existing = query("SELECT id FROM prompts WHERE role = 'classify' AND is_active = true LIMIT 1")
if existing:
return
body = (
"Ты — система классификации договорных документов. "
"Проанализируй текст и верни СТРОГО ВАЛИДНЫЙ JSON ОДНОЙ СТРОКОЙ "
"(без переносов строк, без markdown, без лишних пробелов в начале/конце).\n\n"
"Поля:\n"
'- doc_type: "contract" (договор) / "supplement" (допсоглашение) / "specification" (спецификация/приложение) / "other"\n'
"- own_number: номер ЭТОГО документа, строка без лишних пробелов (или null)\n"
'- parent_number: номер родительского договора из фразы «к Договору №...» (или null)\n'
'- doc_date: дата в YYYY-MM-DD. «27 февраля 2026» → 2026-02-27 (или null)\n'
"- counterparty: полное название контрагента (Заказчик/Арендатор), без сокращений (или null)\n\n"
"Пример вывода:\n"
'{"doc_type":"supplement","own_number":"1","parent_number":"01300_2","doc_date":"2026-02-27","counterparty":"АО XXX003"}\n\n'
"ДОКУМЕНТ:\n---\n{header_text}\n---"
)
execute(
"INSERT INTO prompts (role, name, body, is_active, notes) VALUES (%s, %s, %s, %s, %s)",
("classify", "default-v1", body, True, "Авто-создан: classify prompt v2 — LLM сам предложил"),
)
+19
View File
@@ -0,0 +1,19 @@
"""spec_current — текущее состояние спецификации."""
from .connection import query
def list_by_contract(contract_id):
"""Return list of dicts with name_hash, name, price, qty, sum, date_start."""
return query(
"""SELECT name_hash, name, price, qty, sum, date_start
FROM spec_current WHERE contract_id = %s ORDER BY name""",
(contract_id,),
)
def get_elements_json(document_id):
"""Get elements_json for a document."""
rows = query(
"SELECT elements_json FROM documents WHERE id = %s", (document_id,)
)
return rows[0]["elements_json"] if rows and rows[0]["elements_json"] else None
+202
View File
@@ -0,0 +1,202 @@
"""spec_events — event sourcing: apply ops, reset contract."""
import json, uuid
from .connection import query, execute, get_pool
def reset(contract_id):
"""Clear spec_events + spec_current for contract."""
execute("DELETE FROM spec_current WHERE contract_id = %s", (contract_id,))
execute("DELETE FROM spec_events WHERE contract_id = %s", (contract_id,))
def get_next_seq(contract_id):
"""Get next sequence number with row lock to prevent race conditions."""
pool = get_pool()
conn = pool.getconn()
try:
conn.autocommit = False
with conn.cursor() as cur:
cur.execute(
"SELECT seq FROM spec_events WHERE contract_id = %s ORDER BY seq DESC LIMIT 1 FOR UPDATE",
(contract_id,),
)
row = cur.fetchone()
seq = (row[0] + 1) if row else 1
conn.commit()
return seq
except Exception:
conn.rollback()
raise
finally:
conn.autocommit = True
pool.putconn(conn)
def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_response):
"""Apply ADD/UPDATE/DELETE ops. Returns summary dict."""
added = 0
updated = 0
deleted = 0
seq = get_next_seq(contract_id)
for op in ops:
action = op.get("action", "").upper()
if action == "ADD":
nr = op.get("new_row", {})
name = nr.get("name", "")
if not name:
# ADD without name → UNRESOLVED
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "ADD with empty name")
seq += 1
continue
name_hash = _hash(name, nr.get("date_start"))
execute(
"""INSERT INTO spec_events (contract_id, supplement_id, seq, action, target_hash,
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
VALUES (%s, %s, %s, 'ADD', %s, %s, %s, 'applied', %s, %s, %s)""",
(
contract_id, supplement_id, seq, name_hash,
json.dumps(nr, ensure_ascii=False),
op.get("comment", ""), prompt_id, document_id,
json.dumps(raw_llm_response, ensure_ascii=False),
),
)
_upsert_spec_current(contract_id, name_hash, nr)
seq += 1
added += 1
elif action == "UPDATE":
nv = op.get("new_values", {})
th = op.get("target_hash", "")
if not th:
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "UPDATE with empty target_hash")
seq += 1
continue
execute(
"""INSERT INTO spec_events (contract_id, supplement_id, seq, action, target_hash,
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
VALUES (%s, %s, %s, 'UPDATE', %s, %s, %s, 'applied', %s, %s, %s)""",
(
contract_id, supplement_id, seq, th,
json.dumps(nv, ensure_ascii=False),
op.get("comment", ""), prompt_id, document_id,
json.dumps(raw_llm_response, ensure_ascii=False),
),
)
_update_spec_current(contract_id, th, nv)
seq += 1
updated += 1
elif action == "DELETE":
th = op.get("target_hash", "")
if not th:
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "DELETE with empty target_hash")
seq += 1
continue
execute(
"""INSERT INTO spec_events (contract_id, supplement_id, seq, action, target_hash,
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
VALUES (%s, %s, %s, 'DELETE', %s, %s, %s, 'applied', %s, %s, %s)""",
(
contract_id, supplement_id, seq, th,
json.dumps({}), op.get("comment", ""),
prompt_id, document_id,
json.dumps(raw_llm_response, ensure_ascii=False),
),
)
execute("DELETE FROM spec_current WHERE contract_id = %s AND name_hash = %s", (contract_id, th))
seq += 1
deleted += 1
elif action == "UNRESOLVED":
# Log but don't apply
execute(
"""INSERT INTO spec_events (contract_id, supplement_id, seq, action, target_hash,
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
VALUES (%s, %s, %s, 'UNRESOLVED', %s, %s, %s, 'unresolved', %s, %s, %s)""",
(
contract_id, supplement_id, seq,
op.get("target_hash", ""),
json.dumps(op.get("new_values", {}), ensure_ascii=False),
op.get("reason", op.get("comment", "")),
prompt_id, document_id,
json.dumps(raw_llm_response, ensure_ascii=False),
),
)
seq += 1
else:
# Unknown action — log as UNRESOLVED
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response,
f"unknown action: {action}")
return {"added": added, "updated": updated, "deleted": deleted}
def _log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, reason):
"""Log an op as UNRESOLVED instead of silently ignoring it."""
execute(
"""INSERT INTO spec_events (contract_id, supplement_id, seq, action, target_hash,
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
VALUES (%s, %s, %s, 'UNRESOLVED', %s, %s, %s, 'unresolved', %s, %s, %s)""",
(
contract_id, supplement_id, seq,
op.get("target_hash", ""),
json.dumps(op.get("new_values", op.get("new_row", {})) or {}, ensure_ascii=False),
reason,
prompt_id, document_id,
json.dumps(raw_llm_response, ensure_ascii=False),
),
)
def _hash(name, date_start=None):
"""Нормализованный хеш услуги. Включает нормализованный date_start чтобы различать периоды."""
import hashlib
key = name.strip().lower()
if date_start:
from compare.metrics import normalize_date
nd = normalize_date(str(date_start))
if nd:
key += "|" + nd
return hashlib.sha256(key.encode()).hexdigest()[:16]
def _upsert_spec_current(contract_id, name_hash, row):
"""INSERT or UPDATE spec_current."""
existing = query(
"SELECT id FROM spec_current WHERE contract_id = %s AND name_hash = %s",
(contract_id, name_hash),
)
if existing:
execute(
"""UPDATE spec_current SET name=%s, price=%s, qty=%s, sum=%s, date_start=%s, updated_at=now()
WHERE contract_id=%s AND name_hash=%s""",
(row.get("name"), row.get("price"), row.get("qty"), row.get("sum"),
row.get("date_start"), contract_id, name_hash),
)
else:
execute(
"""INSERT INTO spec_current (contract_id, name_hash, name, price, qty, sum, date_start)
VALUES (%s, %s, %s, %s, %s, %s, %s)""",
(contract_id, name_hash, row.get("name"), row.get("price"),
row.get("qty"), row.get("sum"), row.get("date_start")),
)
def _update_spec_current(contract_id, name_hash, new_values):
"""Update specific fields in spec_current."""
sets = []
params = []
for field in ("name", "price", "qty", "sum", "date_start"):
if field in new_values:
sets.append(f"{field} = %s")
params.append(new_values[field])
if sets:
sets.append("updated_at = now()")
params.extend([contract_id, name_hash])
execute(
f"UPDATE spec_current SET {', '.join(sets)} WHERE contract_id = %s AND name_hash = %s",
params,
)
+59
View File
@@ -0,0 +1,59 @@
"""Supplements CRUD."""
from .connection import query, execute, execute_returning
def insert(contract_id, document_id, supp_type="additional"):
return execute_returning(
"""INSERT INTO supplements (contract_id, document_id, type)
VALUES (%s, %s, %s) RETURNING *""",
(contract_id, document_id, supp_type),
)
def list_by_contract(contract_id):
"""Supplements with parsed documents, ordered by created_at."""
return query(
"""SELECT s.id, s.type, s.document_id, d.filename
FROM supplements s
JOIN documents d ON s.document_id = d.id
WHERE s.contract_id = %s AND d.elements_json IS NOT NULL
ORDER BY s.created_at""",
(contract_id,),
)
def get(supp_id):
rows = query("SELECT * FROM supplements WHERE id = %s", (supp_id,))
return rows[0] if rows else None
def delete_by_document(contract_id, filename):
"""Delete ALL supplements+documents by contract+filename (cascade: spec_events first).
Handles duplicates from previously failed uploads."""
rows = query(
"""SELECT s.id as sid, s.document_id FROM supplements s
JOIN documents d ON d.id = s.document_id
WHERE s.contract_id = %s AND d.filename = %s""",
(contract_id, filename),
)
if not rows:
return False
for r in rows:
# 1. Delete spec_current rows referencing this supplement's events
execute(
"""DELETE FROM spec_current WHERE contract_id = %s
AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)""",
(contract_id, r["sid"]),
)
# 2. Delete spec_events referencing this supplement
execute("DELETE FROM spec_events WHERE supplement_id = %s", (r["sid"],))
# 3. Delete supplement
execute("DELETE FROM supplements WHERE id = %s", (r["sid"],))
# 4. Delete document
execute("DELETE FROM documents WHERE id = %s", (r["document_id"],))
return True
def delete(supp_id):
return execute("DELETE FROM supplements WHERE id = %s", (supp_id,))
+281
View File
@@ -0,0 +1,281 @@
"""llm_prompt.py — Формирование промпта для LLM-анализа ДС.
Читает активный промпт из БД напрямую (db.prompts). При ошибке — fallback на хардкод."""
import os
from db import prompts as db_prompts
# ── Fallback-промпты (если БД недоступна) ──────────────────────
FALLBACK_EXTRACT = """Ты — анализатор договоров облачного провайдера и ЦОД (дата-центра).
Ты разбираешь спецификации услуг colocation, аренды стоек, питания, каналов связи и облачных ресурсов.
ЗАДАЧА: ниже текст спецификации услуг из ПЕРВОГО документа (базовый договор).
Извлеки ВСЕ строки спецификации, каждую как отдельную ADD-операцию.
Верни СТРОГО JSON без пояснений. Не используй markdown-блоки, не добавляй текст до или после JSON.
ФОРМАТ:
{{
"mode": "partial",
"ops": [
{{"action": "ADD", "new_row": {{"name": "полное наименование", "price": число, "qty": число, "sum": число, "date_start": "YYYY-MM-DD"}}, "comment": ""}}
]
}}
ДОМЕННЫЙ ГЛОССАРИЙ (для корректного разбора):
- Единицы измерения:
\u2022 кВт — мощность электропитания (номинальная/гарантированная).
\u2022 юнит, U — высота места в стойке (1U, 2U, 10U).
\u2022 шт. — счётные позиции (IP-адреса, кросс-соединения, порты).
\u2022 Мбит/с, Гбит/с — пропускная способность канала связи.
\u2022 ГБ, ТБ — объём диска/хранилища; vCPU — виртуальные ядра; RAM ГБ — память.
- Типичные услуги:
\u2022 «Стойко-место» / «Аренда стойко-места» / «Colocation» — размещение оборудования в стойке ЦОД.
\u2022 «Электропитание» / «Питание» — выделенная мощность в кВт.
\u2022 «IP-адрес» (IPv4/IPv6) — считается в шт.
\u2022 «Канал связи» / «Порт» / «Интернет» — пропускная способность.
\u2022 «Кросс-соединение» (cross-connect) — физическая коммутация, шт.
\u2022 «Облачные ресурсы» — vCPU, RAM, диск.
- Мощность и габариты часто входят В СОСТАВ названия услуги:
«Аренда стойко-места, в составе: Номинальная мощность – 10 кВт». Сохраняй такое название ЦЕЛИКОМ.
ПРАВИЛА:
1. Извлеки КАЖДУЮ строку таблицы спецификации как отдельную ADD-операцию.
2. name — полное наименование услуги дословно, со всеми уточнениями (мощность, объём, кол-во в составе). Не сокращай.
3. Пропускай итоговые строки («Итого», «Всего», «НДС», «К оплате») и строки с подписями/реквизитами.
4. Если ячейка пустая или значение не указано — ставь null (НЕ пиши 0).
5. price, qty, sum — ЧИСЛА (без пробелов, без «руб.», точка как десятичный разделитель). «50 000,00 руб.» \u2192 50000.
6. date_start — дата начала оказания услуги в формате YYYY-MM-DD. Если в документе нет — null.
7. Не вычисляй и не «исправляй» суммы. Бери значения как в документе.
ПРИМЕР:
Текст: «1. Аренда стойко-места, в составе: Номинальная мощность – 10 кВт — 1 шт. — 50 000,00 руб. — 50 000,00 руб. Дата начала: 01.01.2025
2. IP-адрес IPv4 — 8 шт. — 300,00 руб. — 2 400,00 руб.»
Ответ:
{{
"mode": "partial",
"ops": [
{{"action": "ADD", "new_row": {{"name": "Аренда стойко-места, в составе: Номинальная мощность – 10 кВт", "price": 50000, "qty": 1, "sum": 50000, "date_start": "2025-01-01"}}, "comment": ""}},
{{"action": "ADD", "new_row": {{"name": "IP-адрес IPv4", "price": 300, "qty": 8, "sum": 2400, "date_start": null}}, "comment": ""}}
]
}}
ТЕКСТ ДОКУМЕНТА:
---
{doc_text}
---"""
FALLBACK_DIFF = """Ты — анализатор допсоглашений (ДС) к договорам облачного провайдера и ЦОД.
У тебя есть ТЕКУЩАЯ спецификация услуг (с готовыми id строк) и текст нового ДС.
Задача — определить, какие изменения ДС вносит в текущую спецификацию.
Верни СТРОГО JSON без пояснений. Не используй markdown-блоки, не добавляй текст до или после JSON.
ФОРМАТ:
{{
"mode": "partial" | "full_replace",
"ops": [
{{"action": "ADD", "new_row": {{"name": "...", "price": число, "qty": число, "sum": число, "date_start": "YYYY-MM-DD"}}, "comment": "..."}},
{{"action": "UPDATE", "target_id": "rN", "new_values": {{"price": число}}, "comment": "..."}},
{{"action": "DELETE", "target_id": "rN", "comment": "..."}},
{{"action": "UNRESOLVED", "new_values": {{"name": "...", "price": число}}, "reason": "почему не смог сопоставить"}}
]
}}
ДОМЕННЫЙ ГЛОССАРИЙ:
- Единицы: кВт (мощность), юнит/U (высота в стойке), шт. (IP, кросс-соединения, порты), Мбит/с\u00b7Гбит/с (канал), ГБ\u00b7ТБ\u00b7vCPU (облако).
- Услуги: стойко-место / colocation (размещение в стойке); электропитание / питание (мощность кВт); IP-адрес IPv4/IPv6 (шт.); канал связи / порт (пропускная способность); кросс-соединение (шт.); облачные ресурсы (vCPU/RAM/диск).
- Мощность/объём часто ВНУТРИ названия услуги: «Аренда стойко-места, в составе: Номинальная мощность – 10 кВт».
Если ДС меняет мощность (10 кВт \u2192 15 кВт) — это UPDATE той же строки, причём меняется и name, и, как правило, price/sum.
СОПОСТАВЛЕНИЕ СТРОК:
1. Для UPDATE/DELETE укажи target_id (r1, r2\u2026) ИЗ списка текущей спецификации ниже. НЕ придумывай новые id.
2. Сопоставляй по СМЫСЛУ услуги, а не по точному совпадению символов. «Аренда стойко-места» = «Размещение оборудования в стойке» = одна услуга. Различие тире/пробелов/кавычек игнорируй.
3. new_values в UPDATE — ТОЛЬКО изменённые поля (не дублируй неизменные).
4. Если ДС увеличивает количество той же услуги (было 8 IP, стало 12) — это UPDATE qty (и sum), а не новая ADD.
РЕЖИМ mode:
5. mode = "full_replace" — если ДС полностью переиздаёт приложение/спецификацию. Признаки: «Приложение \u2026 излагается в следующей редакции», «изложить в новой редакции», «заменить приложение \u2116\u2026». При full_replace опиши ВСЕ строки новой редакции как ADD (UPDATE/DELETE не используй).
6. mode = "partial" — если ДС точечно меняет отдельные позиции (изменить цену, добавить/удалить услугу, изменить мощность/кол-во).
7. Если в тексте есть и фраза о новой редакции, и точечные правки — приоритет за «новой редакцией»: full_replace.
EDGE-CASES:
8. UNRESOLVED — если ДС упоминает изменение услуги, которой НЕТ в текущей спецификации, ИЛИ название настолько отличается, что нельзя уверенно сопоставить с конкретным id. ВАЖНО: если СОМНЕВАЕШЬСЯ в сопоставлении — делай UNRESOLVED, а НЕ ADD. Лучше unresolved, чем ложный дубликат. В reason укажи причину.
9. Частичные данные: если в ДС нет цены/кол-ва/даты — ставь null для этих полей, не выдумывай.
10. Пропускай итоговые строки («Итого», «НДС», «К оплате») и подписи/реквизиты.
11. price, qty, sum — ЧИСЛА (без «руб.», без пробелов; «55 000,00» \u2192 55000). Не пересчитывай суммы сам — бери из ДС.
12. date_start — YYYY-MM-DD; используй дату вступления изменения в силу из ДС, если она указана.
ПРИМЕР 1 (partial, UPDATE цены):
Текущая спецификация:
[id: r1] Аренда стойко-места, в составе: Номинальная мощность – 10 кВт | цена=50000 | объём=1 | сумма=50000 | начало=2025-01-01
[id: r2] IP-адрес IPv4 | цена=300 | объём=8 | сумма=2400 | начало=2025-01-01
Текст ДС: «С 01.03.2025 стоимость аренды стойко-места устанавливается в размере 55 000,00 руб. в месяц.»
Ответ:
{{
"mode": "partial",
"ops": [
{{"action": "UPDATE", "target_id": "r1", "new_values": {{"price": 55000, "sum": 55000, "date_start": "2025-03-01"}}, "comment": "Изменение стоимости аренды стойко-места"}}
]
}}
ПРИМЕР 2 (partial: ADD новая услуга + UPDATE количества + UPDATE мощности):
Текущая спецификация:
[id: r1] Аренда стойко-места, в составе: Номинальная мощность – 10 кВт | цена=50000 | объём=1 | сумма=50000 | начало=2025-01-01
[id: r2] IP-адрес IPv4 | цена=300 | объём=8 | сумма=2400 | начало=2025-01-01
Текст ДС: «С 01.04.2025: 1) увеличить номинальную мощность стойко-места до 15 кВт, стоимость — 70 000,00 руб.;
2) предоставить дополнительно 4 IP-адреса IPv4 (итого 12 шт., сумма 3 600,00 руб.);
3) предоставить услугу "Кросс-соединение" — 2 шт. по 1 500,00 руб., сумма 3 000,00 руб.»
Ответ:
{{
"mode": "partial",
"ops": [
{{"action": "UPDATE", "target_id": "r1", "new_values": {{"name": "Аренда стойко-места, в составе: Номинальная мощность – 15 кВт", "price": 70000, "sum": 70000, "date_start": "2025-04-01"}}, "comment": "Увеличение мощности 10\u219215 кВт"}},
{{"action": "UPDATE", "target_id": "r2", "new_values": {{"qty": 12, "sum": 3600, "date_start": "2025-04-01"}}, "comment": "Увеличение количества IP-адресов 8\u219212"}},
{{"action": "ADD", "new_row": {{"name": "Кросс-соединение", "price": 1500, "qty": 2, "sum": 3000, "date_start": "2025-04-01"}}, "comment": "Новая услуга"}}
]
}}
ПРИМЕР 3 (full_replace):
Текущая спецификация:
[id: r1] Аренда стойко-места, в составе: Номинальная мощность – 10 кВт | цена=50000 | объём=1 | сумма=50000 | начало=2025-01-01
[id: r2] IP-адрес IPv4 | цена=300 | объём=8 | сумма=2400 | начало=2025-01-01
Текст ДС: «Приложение №1 (Спецификация услуг) излагается в следующей редакции:
1. Аренда стойко-места, номинальная мощность 15 кВт — 1 шт. — 70 000,00 руб.
2. IP-адрес IPv4 — 12 шт. — 300,00 руб. — 3 600,00 руб.
3. Канал связи 1 Гбит/с — 1 шт. — 20 000,00 руб. Дата: 01.05.2025»
Ответ:
{{
"mode": "full_replace",
"ops": [
{{"action": "ADD", "new_row": {{"name": "Аренда стойко-места, номинальная мощность 15 кВт", "price": 70000, "qty": 1, "sum": 70000, "date_start": "2025-05-01"}}, "comment": "Новая редакция приложения"}},
{{"action": "ADD", "new_row": {{"name": "IP-адрес IPv4", "price": 300, "qty": 12, "sum": 3600, "date_start": "2025-05-01"}}, "comment": "Новая редакция приложения"}},
{{"action": "ADD", "new_row": {{"name": "Канал связи 1 Гбит/с", "price": 20000, "qty": 1, "sum": 20000, "date_start": "2025-05-01"}}, "comment": "Новая редакция приложения"}}
]
}}
ПРИМЕР 4 (UNRESOLVED):
Текущая спецификация:
[id: r1] Аренда стойко-места, в составе: Номинальная мощность – 10 кВт | цена=50000 | объём=1 | сумма=50000 | начало=2025-01-01
Текст ДС: «Снизить стоимость услуги резервного копирования до 4 000,00 руб.»
Ответ:
{{
"mode": "partial",
"ops": [
{{"action": "UNRESOLVED", "new_values": {{"name": "Резервное копирование", "price": 4000}}, "reason": "В текущей спецификации нет услуги резервного копирования — не с чем сопоставить"}}
]
}}
ТЕКУЩАЯ СПЕЦИФИКАЦИЯ:
{spec_current}
ТЕКСТ ДОПСОГЛАШЕНИЯ:
---
{doc_text}
---"""
def _fetch_prompt(role: str) -> dict | None:
"""Получить активный промпт из БД напрямую (а не через Lucee HTTP)."""
try:
row = db_prompts.get_active(role)
if row and row.get("body"):
return {"id": row.get("id", ""), "body": row["body"]}
except Exception:
pass
return None
def _build_spec_text(current_spec: list) -> str:
"""Перечисление строк спецификации для подстановки в {spec_current}."""
lines = []
for i, r in enumerate(current_spec):
lines.append(
f"[id: r{i+1}] {r.get('name', '?')} | "
f"цена={r.get('price', '')} | объём={r.get('qty', '')} | "
f"сумма={r.get('sum', '')} | начало={r.get('date_start', '')}"
)
return "\n".join(lines)
def build_prompt(current_spec: list, doc_text: str) -> tuple:
"""
Формирует промпт для LLM.
1. Пробует получить активный промпт из БД (Lucee API).
2. При неудаче — fallback на хардкод.
Возвращает (текст_промпта, prompt_id).
prompt_id — UUID версии промпта из БД, или "" если fallback.
"""
is_first = len(current_spec) == 0
role = "extract" if is_first else "diff"
db = _fetch_prompt(role)
if db:
template = db["body"]
prompt_id = db.get("id", "")
else:
template = FALLBACK_EXTRACT if is_first else FALLBACK_DIFF
prompt_id = ""
# Подстановка плейсхолдеров
result = template.replace("{doc_text}", doc_text)
result = result.replace("{spec_current}", _build_spec_text(current_spec))
return result, prompt_id
def build_classify_prompt(header_text):
"""Build classify prompt. Returns (prompt, prompt_id)."""
try:
from db import prompts as db_prompts
prompt = db_prompts.get_active("classify")
if prompt:
body = prompt["body"].replace("{header_text}", header_text)
return body, prompt.get("id", "")
except Exception:
pass # DB unavailable — use fallback
body = """Ты — классификатор договорных документов облачного провайдера НУБЕС.
Ниже фрагмент текста документа. Определи:
1. doc_type:
- "contract" — договор (заголовок «Договор», «Соглашение», преамбула с условиями)
- "supplement" — допсоглашение (ссылается на родительский договор, меняет условия)
- "specification" — спецификация / приложение с таблицей услуг (стойко-места, IP, каналы, питание)
- "other" — НЕ договорной документ: акт сверки, счёт, счёт-фактура, УПД, акт оказанных услуг, платёжное поручение, доверенность, письмо
2. own_number — номер ЭТОГО документа (например «XXX001-03700», «МЭС-123/2024», «1» для допника).
Если номер не указан — null.
3. parent_number — номер родительского договора (для supplement и specification).
Для doc_type="contract": ВСЕГДА null.
4. doc_date — дата документа в формате YYYY-MM-DD. Если дата прописью — переведи в цифры.
Если нет даты — null.
5. counterparty — название КОНТРАГЕНТА (Заказчика).
ВАЖНО: НУБЕС — всегда Исполнитель. НЕ возвращай НУБЕС как counterparty.
НУБЕС известен как: «НУБЕС», «ООО НУБЕС», «ООО "НУБЕС"», «Nubes».
counterparty — ВСЕГДА другая сторона (Заказчик/Покупатель/Абонент).
Если документ не содержит контрагента — null.
Верни СТРОГО JSON без пояснений:
{"doc_type":"...","own_number":"...","parent_number":"...","doc_date":"...","counterparty":"..."}
ПРИМЕР 1 (договор):
Текст: «Договор № XXX001-03700 от 15.03.2025. ООО "НУБЕС" (Исполнитель) и ЗАО "ТехноПлюс" (Заказчик)...»
Ответ: {"doc_type":"contract","own_number":"XXX001-03700","parent_number":null,"doc_date":"2025-03-15","counterparty":"ЗАО \"ТехноПлюс\""}
ПРИМЕР 2 (допсоглашение):
Текст: «Допсоглашение №1 к Договору № XXX003-01300 от 05.06.2024...»
Ответ: {"doc_type":"supplement","own_number":"1","parent_number":"XXX003-01300","doc_date":"2024-06-05","counterparty":"АО XXX003"}
ПРИМЕР 3 (мусор):
Текст: «Акт сверки взаимных расчётов за 1 квартал 2025 г. Стороны: НУБЕС и ООО Ромашка. Сальдо 150 000 руб.»
Ответ: {"doc_type":"other","own_number":null,"parent_number":null,"doc_date":"2025-03-31","counterparty":"ООО Ромашка"}
ДОКУМЕНТ:
---
{header_text}
---""".replace("{header_text}", header_text)
return body, ""
+268
View File
@@ -0,0 +1,268 @@
"""Repository facade — Protocol поверх db/*.py.
План decoupling Ф3:
- Repository (Protocol) — интерфейс доступа к данным
- PgRepository — реальная БД (обёртка над db/*.py)
- MemRepository — in-memory для юнит-тестов
- SQL не переписываем
"""
from typing import Protocol, Optional
from datetime import datetime
import uuid
# ── Протокол ────────────────────────────────────────────────────
class Repository(Protocol):
"""Фасад доступа к данным."""
def insert_document(self, filename: str, mime_type: str, original_bytes: str,
batch_id: str = None, zip_source: str = None) -> dict:
"""Вставить документ, вернуть row dict."""
...
def set_document_parsed(self, doc_id: str, elements: list) -> None:
"""Обновить elements_json + status='parsed'."""
...
def set_document_error(self, doc_id: str, error: str) -> None:
"""Обновить status='error'."""
...
def set_classification(self, doc_id: str, doc_type: str, own_number: str = None,
parent_number: str = None, doc_date: str = None,
counterparty: str = None,
classify_raw: str = None, classify_input: str = None) -> None:
"""Сохранить результат классификации."""
...
def set_classify_garbage(self, doc_id: str, reason: str = "") -> None:
"""Пометить документ как мусор."""
...
def set_classify_failed(self, doc_id: str, error: str) -> None:
"""Пометить классификацию как failed."""
...
def list_pending(self, batch_id: str) -> list[dict]:
"""Документы, ожидающие классификации."""
...
def insert_contract(self, number: str, client: str = "") -> str:
"""Создать контракт, вернуть contract_id."""
...
def insert_supplement(self, contract_id: str, doc_id: str, supp_type: str) -> None:
"""Создать связь contract↔document."""
...
def list_supplements(self, contract_id: str) -> list[dict]:
"""Список дополнений контракта."""
...
def get_spec_current(self, contract_id: str) -> list[dict]:
"""Текущая спецификация контракта."""
...
def get_document(self, doc_id: str) -> dict | None:
"""Получить документ по id."""
...
def list_by_batch(self, batch_id: str) -> list[dict]:
"""Все документы батча с полями классификации."""
...
def count_by_status(self, batch_id: str) -> dict[str, int]:
"""Количество документов по classify_status."""
...
def reset_classify_status(self, batch_id: str) -> None:
"""Сбросить classify_status на 'pending'."""
...
def set_classify_processing(self, doc_id: str) -> None:
"""Пометить документ как обрабатываемый."""
...
def delete_document(self, doc_id: str) -> None:
"""Удалить документ."""
...
# ── Продакшен: обёртка над db/*.py ──────────────────────────────
class PgRepository:
"""Реальный доступ к PostgreSQL через существующие db/*.py."""
def insert_document(self, filename, mime_type, original_bytes, batch_id=None, zip_source=None):
from site.db import documents
return documents.insert(filename, mime_type, original_bytes,
batch_id=batch_id, zip_source=zip_source)
def set_document_parsed(self, doc_id, elements):
from site.db import documents
documents.set_parsed(doc_id, elements) # documents.set_parsed уже делает json.dumps
def set_document_error(self, doc_id, error):
from site.db import documents
documents.set_error(doc_id, error)
def set_classification(self, doc_id, doc_type, own_number=None, parent_number=None,
doc_date=None, counterparty=None,
classify_raw=None, classify_input=None):
from site.db import documents
documents.set_classification(doc_id, doc_type, own_number, parent_number,
doc_date, counterparty,
classify_raw=classify_raw, classify_input=classify_input)
def set_classify_garbage(self, doc_id, reason=""):
from site.db import documents
documents.set_classify_garbage(doc_id, reason)
def set_classify_failed(self, doc_id, error):
from site.db import documents
documents.set_classify_failed(doc_id, error)
def list_pending(self, batch_id):
from site.db import documents
return documents.list_pending(batch_id)
def insert_contract(self, number, client=""):
from site.db import contracts
c = contracts.insert(number, client)
return c["id"] if c else ""
def insert_supplement(self, contract_id, doc_id, supp_type):
from site.db import supplements
supplements.insert(contract_id, doc_id, supp_type)
def list_supplements(self, contract_id):
from site.db import supplements
return supplements.list_by_contract(contract_id)
def get_spec_current(self, contract_id):
from site.db import spec_current
return spec_current.list_by_contract(contract_id)
def get_document(self, doc_id):
from site.db import documents
return documents.get(doc_id)
def list_by_batch(self, batch_id):
from site.db import documents
return documents.list_by_batch(batch_id)
def count_by_status(self, batch_id):
from site.db import documents
return documents.count_by_status(batch_id)
def reset_classify_status(self, batch_id):
from site.db import documents
documents.reset_classify_status(batch_id)
def set_classify_processing(self, doc_id):
from site.db import documents
documents.set_classify_processing(doc_id)
def delete_document(self, doc_id):
from site.db import documents
documents.delete(doc_id)
# ── Тестовый: in-memory заглушка ────────────────────────────────
class MemRepository:
"""In-memory хранилище для юнит-тестов."""
def __init__(self):
self.documents: dict[str, dict] = {}
self.contracts: dict[str, dict] = {}
self.supplements: list[dict] = []
self.spec_current: dict[str, list[dict]] = {}
def insert_document(self, filename, mime_type, original_bytes, batch_id=None, zip_source=None):
doc_id = str(uuid.uuid4())
self.documents[doc_id] = {
"id": doc_id, "filename": filename, "mime_type": mime_type,
"original_bytes": original_bytes, "status": "uploaded",
"elements_json": None, "doc_type": None, "own_number": None,
"parent_number": None, "doc_date": None, "counterparty": None,
"classify_status": "pending", "batch_id": batch_id, "zip_source": zip_source,
}
return self.documents[doc_id]
def set_document_parsed(self, doc_id, elements):
if doc_id in self.documents:
self.documents[doc_id]["elements_json"] = elements
self.documents[doc_id]["status"] = "parsed"
def set_document_error(self, doc_id, error):
if doc_id in self.documents:
self.documents[doc_id]["status"] = "error"
self.documents[doc_id]["error_message"] = error
def set_classification(self, doc_id, doc_type, own_number=None, parent_number=None,
doc_date=None, counterparty=None,
classify_raw=None, classify_input=None):
if doc_id in self.documents:
d = self.documents[doc_id]
d.update({"doc_type": doc_type, "own_number": own_number,
"parent_number": parent_number, "doc_date": doc_date,
"counterparty": counterparty, "classify_raw": classify_raw,
"classify_input": classify_input, "classify_status": "classified"})
def set_classify_garbage(self, doc_id, reason=""):
if doc_id in self.documents:
self.documents[doc_id]["doc_type"] = "garbage"
self.documents[doc_id]["classify_status"] = "garbage"
def set_classify_failed(self, doc_id, error):
if doc_id in self.documents:
self.documents[doc_id]["classify_status"] = "failed"
self.documents[doc_id]["error_message"] = error
def list_pending(self, batch_id):
return [d for d in self.documents.values()
if d.get("batch_id") == batch_id and d.get("classify_status") == "pending"]
def insert_contract(self, number, client=""):
cid = str(uuid.uuid4())
self.contracts[cid] = {"id": cid, "number": number, "client": client}
return cid
def insert_supplement(self, contract_id, doc_id, supp_type):
self.supplements.append({
"contract_id": contract_id, "document_id": doc_id, "type": supp_type,
})
def list_supplements(self, contract_id):
return [s for s in self.supplements if s["contract_id"] == contract_id]
def get_spec_current(self, contract_id):
return self.spec_current.get(contract_id, [])
def get_document(self, doc_id):
return self.documents.get(doc_id)
def list_by_batch(self, batch_id):
return [d for d in self.documents.values() if d.get("batch_id") == batch_id]
def count_by_status(self, batch_id):
counts = {}
for d in self.documents.values():
if d.get("batch_id") == batch_id:
s = d.get("classify_status", "unknown")
counts[s] = counts.get(s, 0) + 1
return counts
def reset_classify_status(self, batch_id):
for d in self.documents.values():
if d.get("batch_id") == batch_id:
d["classify_status"] = "pending"
def set_classify_processing(self, doc_id):
if doc_id in self.documents:
self.documents[doc_id]["classify_status"] = "processing"
def delete_document(self, doc_id):
self.documents.pop(doc_id, None)
+17
View File
@@ -0,0 +1,17 @@
"""Регистрация всех blueprint'ов."""
def register_routes(app):
from site.routes.upload_bp import upload_bp
from site.routes.pipeline_bp import pipeline_bp
from site.routes.api_bp import api_bp
from site.routes.prompts_bp import prompts_bp
from site.routes.health_bp import health_bp
from site.routes.pages_bp import pages_bp
app.register_blueprint(upload_bp)
app.register_blueprint(pipeline_bp)
app.register_blueprint(api_bp)
app.register_blueprint(prompts_bp)
app.register_blueprint(health_bp)
app.register_blueprint(pages_bp)
+182
View File
@@ -0,0 +1,182 @@
"""API blueprint — groups, documents, supplements, sync, cleanup, spec-current, chat."""
from flask import Blueprint, request, jsonify
from site.db import documents, supplements, spec_current
from site.db.connection import execute, query
from site.services.grouping import group_documents, apply_groups
from site.config import LLM_URL, LLM_KEY, LLM_MODEL
import httpx
api_bp = Blueprint("api", __name__)
# ── Supplements ──────────────────────────────────────────────────
@api_bp.route("/api/supplements")
def api_supplements():
cid = request.args.get("contract_id")
if not cid:
return jsonify(ok=False, error="contract_id required"), 400
rows = supplements.list_by_contract(cid)
return jsonify(ok=True, supplements=rows)
# ── Documents ────────────────────────────────────────────────────
@api_bp.route("/api/documents/<doc_id>")
def api_document(doc_id):
doc = documents.get(doc_id)
if not doc:
return jsonify(ok=False, error="not found"), 404
return jsonify(ok=True, **{
k: doc.get(k) for k in [
"id", "filename", "status", "elements_json", "doc_type",
"own_number", "parent_number", "doc_date", "counterparty",
"classify_status", "classify_raw", "classify_input",
]
})
@api_bp.route("/api/documents/<doc_id>", methods=["DELETE"])
def api_document_delete(doc_id):
supps = query("SELECT id, contract_id FROM supplements WHERE document_id = %s", (doc_id,))
for s in (supps or []):
execute(
"""DELETE FROM spec_current WHERE contract_id = %s
AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)""",
(s["contract_id"], s["id"]),
)
execute("DELETE FROM spec_events WHERE supplement_id = %s", (s["id"],))
execute("DELETE FROM supplements WHERE id = %s", (s["id"],))
execute("DELETE FROM documents WHERE id = %s", (doc_id,))
return jsonify(ok=True)
# ── Sync ─────────────────────────────────────────────────────────
@api_bp.route("/api/sync", methods=["POST"])
def api_sync():
"""Удалить документы, не входящие в keep_ids."""
body = request.get_json()
keep_ids = set(body.get("keep_ids", []))
if len(keep_ids) > 1000:
return jsonify(ok=False, error="too many keep_ids (max 1000)"), 400
docs = query("SELECT id FROM documents", ())
deleted = 0
for d in (docs or []):
if d["id"] in keep_ids:
continue
supps = query("SELECT id, contract_id FROM supplements WHERE document_id = %s", (d["id"],))
for s in (supps or []):
execute(
"""DELETE FROM spec_current WHERE contract_id = %s
AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)""",
(s["contract_id"], s["id"]),
)
execute("DELETE FROM spec_events WHERE supplement_id = %s", (s["id"],))
execute("DELETE FROM supplements WHERE id = %s", (s["id"],))
execute("DELETE FROM documents WHERE id = %s", (d["id"],))
deleted += 1
execute("DELETE FROM contracts WHERE id NOT IN (SELECT DISTINCT contract_id FROM supplements)")
return jsonify(ok=True, deleted=deleted)
# ── Groups ───────────────────────────────────────────────────────
@api_bp.route("/api/groups")
def api_groups():
batch_id = request.args.get("batch")
if not batch_id:
return jsonify(ok=False, error="batch required"), 400
result = group_documents(batch_id)
return jsonify(result)
@api_bp.route("/api/batch-progress")
def api_batch_progress():
batch_id = request.args.get("batch")
if not batch_id:
return jsonify(ok=False, error="batch required"), 400
counts = documents.count_by_status(batch_id)
docs = documents.list_by_batch(batch_id)
return jsonify(ok=True, counts=counts, total=len(docs))
@api_bp.route("/api/apply-groups", methods=["POST"])
def api_apply_groups():
body = request.get_json()
batch_id = body.get("batch_id")
groups = body.get("groups", [])
if not batch_id:
return jsonify(ok=False, error="batch_id required"), 400
result = apply_groups(batch_id, groups)
return jsonify(result)
# ── Spec current ─────────────────────────────────────────────────
@api_bp.route("/api/spec-current")
def api_spec_current():
cid = request.args.get("contract_id")
if not cid:
return jsonify(ok=False, error="contract_id required"), 400
rows = spec_current.list_by_contract(cid)
return jsonify(ok=True, rows=rows)
# ── Chat (совместимость с Lucee /chat.cfm) ──────────────────────
@api_bp.route("/chat", methods=["POST"])
@api_bp.route("/chat.cfm", methods=["POST"])
def chat():
"""Чат с LLM по данным спецификации. Совместим с Lucee-форматом ответа."""
contract_id = request.args.get("contract_id", "")
question = request.form.get("question", "")
if not contract_id or not question:
return jsonify(OK=False, ERROR="contract_id and question required")
rows = spec_current.list_by_contract(contract_id)
if not rows:
return jsonify(OK=True, ANSWER="Нет данных. Сначала запустите сравнение.")
ctx = "\n".join(
f"{r.get('name','')} | цена={r.get('price')} | объём={r.get('qty')} | "
f"сумма={r.get('sum')} | начало={r.get('date_start')}"
for r in rows
)
prompt = (
f"Ты — анализатор договоров. Данные:\n{ctx}\n\n"
f"Вопрос: {question}\nОтветь кратко, только по данным."
)
try:
with httpx.Client(http2=True, timeout=120) as client:
resp = client.post(
LLM_URL,
json={
"model": LLM_MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 1000,
"temperature": 0.1,
},
headers={
"Authorization": f"Bearer {LLM_KEY}",
"Content-Type": "application/json",
},
)
resp.raise_for_status()
answer = resp.json()["choices"][0]["message"]["content"]
return jsonify(OK=True, ANSWER=answer)
except Exception as e:
return jsonify(OK=False, ERROR=f"LLM error: {e}")
# ── Cleanup (атомарное удаление БД) ──────────────────────────────
@api_bp.route("/api/cleanup", methods=["POST"])
def api_cleanup():
"""Полная очистка: os.remove(DB) + init новой. Данные гарантированно стёрты."""
from site.db.connection import cleanup_db
cleanup_db()
return jsonify(ok=True, message="all data cleaned")
+10
View File
@@ -0,0 +1,10 @@
"""Health probe — обязательно для Штурвала."""
from flask import Blueprint, jsonify
from site.config import VERSION
health_bp = Blueprint("health", __name__)
@health_bp.route("/health")
def health():
return jsonify({"ok": True, "version": VERSION})
+14
View File
@@ -0,0 +1,14 @@
"""HTML-страницы."""
from flask import Blueprint, render_template
pages_bp = Blueprint("pages", __name__)
@pages_bp.route("/")
def index():
return render_template("index.html")
@pages_bp.route("/architect")
def architect():
return render_template("architect.html")
+91
View File
@@ -0,0 +1,91 @@
"""Pipeline blueprint — SSE-сравнение + classify."""
import json, re, os, threading
from flask import Blueprint, request, jsonify, Response, stream_with_context
from site.services.process import run_pipeline
from site.services.classify import classify_batch
from site.llm_prompt import build_prompt
from site.db import documents
pipeline_bp = Blueprint("pipeline", __name__)
# In-memory lock для classify (замена файлового lock)
_classify_locks: dict[str, threading.Thread] = {}
@pipeline_bp.route("/process-v2", methods=["GET"])
def process_v2():
"""SSE-стриминг сравнения договоров."""
cid = request.args.get("contract_id")
if not cid or not re.fullmatch(
r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', cid, re.I
):
return jsonify(ok=False, error="invalid contract_id"), 400
order_ids = request.args.get("order", "")
def generate():
# Heartbeat каждые 15 сек — держит соединение при медленном LLM
import time as _time
last_beat = _time.time()
yield ": ok\n\n"
try:
for event in run_pipeline(cid, order_ids, build_prompt):
now = _time.time()
if now - last_beat >= 15:
yield ": heartbeat\n\n"
last_beat = now
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
except GeneratorExit:
return
except Exception as e:
yield f"data: {json.dumps({'type': 'error', 'message': str(e)}, ensure_ascii=False)}\n\n"
return Response(
stream_with_context(generate()),
content_type="text/event-stream; charset=utf-8",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
@pipeline_bp.route("/api/classify-batch", methods=["POST"])
def classify_batch_route():
"""Запустить классификацию. ≤10 файлов — sync, >10 — async (Thread)."""
body = request.get_json()
batch_id = body.get("batch_id")
if not batch_id:
return jsonify(ok=False, error="batch_id required"), 400
# Guard: уже запущена?
if batch_id in _classify_locks:
t = _classify_locks[batch_id]
if t.is_alive():
return jsonify(ok=False, error="classify already running"), 409
else:
del _classify_locks[batch_id]
pending = documents.list_pending(batch_id)
total = len(pending)
if total == 0:
return jsonify(ok=False, error="no pending documents"), 400
# Sync для малых батчей
if total <= 10:
result = classify_batch(batch_id)
return jsonify(result)
# Async для больших
def _run():
try:
classify_batch(batch_id)
finally:
_classify_locks.pop(batch_id, None)
t = threading.Thread(target=_run, daemon=True)
_classify_locks[batch_id] = t
t.start()
return jsonify(ok=True, total=total), 202
+71
View File
@@ -0,0 +1,71 @@
"""Prompts blueprint — CRUD + activate для версионирования промптов."""
from flask import Blueprint, request, jsonify
from site.db import prompts as db_prompts
prompts_bp = Blueprint("prompts", __name__)
@prompts_bp.route("/api/prompts", methods=["GET"])
def prompts_get():
role = request.args.get("role")
if not role:
return jsonify(ok=False, error="role required"), 400
p = db_prompts.get_active(role)
if p:
return jsonify(ok=True, **{
"id": p["id"], "role": p["role"], "name": p["name"],
"body": p["body"], "is_active": p["is_active"],
"notes": p.get("notes"), "created_at": str(p.get("created_at", "")),
})
return jsonify(ok=False, error="not found"), 404
@prompts_bp.route("/api/prompts/list", methods=["GET"])
def prompts_list():
role = request.args.get("role")
if not role:
return jsonify(ok=False, error="role required"), 400
versions = db_prompts.list_by_role(role)
return jsonify(ok=True, versions=versions)
@prompts_bp.route("/api/prompts/save", methods=["POST"])
def prompts_save():
body = request.get_json()
if not body:
return jsonify(ok=False, error="body required"), 400
role = body.get("role", "")
name = body.get("name", "")
prompt_body = body.get("body", "")
notes = body.get("notes", "")
try:
p = db_prompts.insert(role, name, prompt_body, notes)
return jsonify(ok=True, id=p["id"])
except Exception as e:
return jsonify(ok=False, error=str(e)), 500
@prompts_bp.route("/api/prompts/activate", methods=["POST"])
def prompts_activate():
body = request.get_json()
if not body:
return jsonify(ok=False, error="body required"), 400
prompt_id = body.get("id")
try:
db_prompts.activate(prompt_id)
return jsonify(ok=True)
except Exception as e:
return jsonify(ok=False, error=str(e)), 500
@prompts_bp.route("/api/prompts/delete", methods=["POST"])
def prompts_delete():
body = request.get_json()
if not body:
return jsonify(ok=False, error="body required"), 400
prompt_id = body.get("id")
try:
db_prompts.delete(prompt_id)
return jsonify(ok=True)
except Exception as e:
return jsonify(ok=False, error=str(e)), 500
+145
View File
@@ -0,0 +1,145 @@
"""Upload blueprint — загрузка, конвертация, распаковка."""
import io, os, base64, hashlib, zipfile, tempfile, subprocess
from flask import Blueprint, request, jsonify, send_file
from site.services.parse import parse_file
from site.db import documents
from site.config import MAX_CONTENT_LENGTH
upload_bp = Blueprint("upload", __name__)
ALLOWED = {"pdf", "docx", "doc", "zip"}
def _check_ext(filename: str) -> str | None:
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
if ext not in ALLOWED:
return f"unsupported format: .{ext} (allowed: {', '.join(sorted(ALLOWED))})"
return None
@upload_bp.route("/upload", methods=["POST"])
def upload():
"""Загрузка одного файла + авто-парсинг → БД."""
f = request.files.get("files")
if not f:
return jsonify(ok=False, error="no file"), 400
err = _check_ext(f.filename)
if err:
return jsonify(ok=False, error=err), 400
data = f.read()
content_hash = hashlib.sha256(data).hexdigest()[:16]
batch_id = request.form.get("batch_id")
zip_source = request.form.get("zip_source")
# Дедупликация по хешу
if batch_id:
existing = documents.get_by_hash(batch_id, content_hash)
if existing:
return jsonify(ok=False, error="duplicate", doc_id=existing["id"])
doc = documents.insert(
filename=f.filename,
mime_type=f.content_type or "application/octet-stream",
original_bytes=base64.b64encode(data).decode(),
batch_id=batch_id,
zip_source=zip_source,
content_hash=content_hash,
)
# Авто-парсинг
try:
result = parse_file(f.filename, data)
if result["status"] == "parsed":
documents.set_parsed(doc["id"], result["elements"])
else:
documents.set_error(doc["id"], result.get("error", "parse failed"))
except Exception as e:
documents.set_error(doc["id"], str(e))
result = {"status": "error", "error": str(e)}
contract_id = request.form.get("contract_id")
return jsonify(
ok=True,
doc_id=doc["id"],
contract_id=contract_id,
parsed={"status": result["status"], "element_count": result.get("element_count", 0)},
)
@upload_bp.route("/convert-doc", methods=["POST"])
def convert_doc():
""".doc → .docx через libreoffice (без сохранения на диск)."""
f = request.files.get("files")
if not f:
return jsonify(ok=False, error="no file"), 400
data = f.read()
doc_path = None
tmpdir = None
try:
with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as tmp:
tmp.write(data)
doc_path = tmp.name
tmpdir = tempfile.mkdtemp()
subprocess.run(
["libreoffice", "--headless", "--convert-to", "docx", "--outdir", tmpdir, doc_path],
timeout=30, capture_output=True,
)
docx_files = [x for x in os.listdir(tmpdir) if x.endswith(".docx")]
if docx_files:
with open(os.path.join(tmpdir, docx_files[0]), "rb") as out:
return send_file(
io.BytesIO(out.read()),
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
return jsonify(ok=False, error="conversion produced no output"), 500
finally:
if doc_path and os.path.exists(doc_path):
os.unlink(doc_path)
if tmpdir and os.path.exists(tmpdir):
for x in os.listdir(tmpdir):
os.unlink(os.path.join(tmpdir, x))
os.rmdir(tmpdir)
@upload_bp.route("/unzip-upload", methods=["POST"])
def unzip_upload():
"""Распаковать ZIP → список файлов (base64 для фронтенда)."""
f = request.files.get("files")
if not f:
return jsonify(ok=False, error="no file"), 400
data = f.read()
MAX_FILES = 500
MAX_UNCOMPRESSED = 500 * 1024 * 1024 # 500 MB
files = []
total = 0
with zipfile.ZipFile(io.BytesIO(data)) as zf:
if len(zf.namelist()) > MAX_FILES:
return jsonify(ok=False, error=f"too many files in ZIP (max {MAX_FILES})"), 400
for info in zf.infolist():
if info.is_dir():
continue
name = os.path.basename(info.filename)
if not name or ".." in name or "/" in name or "\\" in name:
continue
raw = zf.read(info)
total += len(raw)
if total > MAX_UNCOMPRESSED:
return jsonify(ok=False, error="total uncompressed size exceeds 500 MB"), 400
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
files.append({
"filename": name,
"ext": ext,
"size": len(raw),
"data_b64": base64.b64encode(raw).decode(),
})
return jsonify(ok=True, files=files)
+267
View File
@@ -0,0 +1,267 @@
"""
Classify service — LLM-based document classification.
Архитектурное решение (Opus):
- Отдельный сервис, не встроен в upload. Upload быстрый (0.5с), classify — медленный (2-10с/файл).
- ThreadPoolExecutor(max_workers=4) — параллельная классификация с ограничением конкурентности,
чтобы не положить api.aillm.ru при 2000 файлах.
- Умная выжимка (_smart_extract): header ~1500 симв + regex-хиты по маркерам (договор/№/соглашение)
из всего документа. Экономия токенов в 5-10 раз при сохранении точности.
- Двухпроходная архитектура: LLM извлекает строки (тип/номер/дата/контрагент),
Python в grouping.py нормализует и группирует детерминированно.
"""
import json, re, os
from concurrent.futures import ThreadPoolExecutor, as_completed
import httpx
from site.db import documents as db_docs
from site.llm_prompt import build_classify_prompt
log = __import__("logging").getLogger(__name__)
# Лимит одновременных запросов к LLM API
# Увеличивать осторожно — api.aillm.ru может троттлить
MAX_WORKERS = 4
LLM_URL = "https://api.aillm.ru/v1/chat/completions"
LLM_KEY = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "")
LLM_MODEL = "gpt-oss-120b"
# Ленивый singleton — обратная совместимость
_classify_llm = None
def _get_classify_client():
global _classify_llm
if _classify_llm is None:
from site.services.llm_client import HttpxLLMClient
_classify_llm = HttpxLLMClient(url=LLM_URL, key=LLM_KEY, model=LLM_MODEL, max_tokens=1000, timeout=60)
return _classify_llm
# ── Garbage filter (Stage 1: filename regex) ────────────────────
_GARBAGE_FILENAME_RE = re.compile(
r'(сч[её]т|акт|плат[её]ж|УПД|сверк|инвойс|invoice|payment|act)',
re.IGNORECASE,
)
# ── Garbage filter (Stage 2: header keywords) ───────────────────
_GARBAGE_HEADER_MARKERS = [
'СЧЕТ-ФАКТУРА', 'СЧЕТ НА ОПЛАТУ', 'АКТ СВЕРКИ',
'АКТ ОКАЗАННЫХ УСЛУГ', 'АКТ ВЫПОЛНЕННЫХ РАБОТ',
'ПЛАТЁЖНОЕ ПОРУЧЕНИЕ', 'УНИВЕРСАЛЬНЫЙ ПЕРЕДАТОЧНЫЙ',
'УПД', 'ПЛАТЕЖНОЕ ПОРУЧЕНИЕ',
]
def _is_garbage_by_filename(filename: str) -> bool:
"""Stage 1: regex по имени файла — быстро, 0 токенов."""
return bool(_GARBAGE_FILENAME_RE.search(filename))
def _is_garbage_by_header(text: str) -> bool:
"""Stage 2: ключевые слова в первых 2KB текста — быстро, 0 токенов."""
header = text[:2000].upper()
return any(marker in header for marker in _GARBAGE_HEADER_MARKERS)
def _call_llm_classify(header_text, llm_client=None):
"""
Прямой вызов LLM для классификации ОДНОГО документа.
Возвращает (parsed_dict, raw_text, needed_fix).
llm_client: LLMClient (optional). Default — HttpxLLMClient.
"""
if llm_client is None:
llm_client = _get_classify_client()
prompt, _ = build_classify_prompt(header_text)
raw_text = llm_client.complete(prompt)
parsed, needed_fix = _safe_json_parse(raw_text)
return parsed, raw_text, needed_fix
def classify_batch(batch_id, llm_client=None, repo=None):
"""
Классифицировать все документы в batch.
Сбрасывает статус на 'pending' для всех перед началом.
Параллельно (ThreadPoolExecutor) обрабатывает до MAX_WORKERS документов.
Возвращает {ok, total, done, failed, garbage, json_fix_rate}.
llm_client: LLMClient (optional, default — HttpxLLMClient)
repo: Repository (optional, default — direct db.* calls)
"""
_db = repo if repo else db_docs
_llm = llm_client if llm_client else _get_classify_client()
# Сбросить статус — allow re-classify after file changes
_db.reset_classify_status(batch_id)
pending = _db.list_pending(batch_id)
if not pending:
return {"ok": False, "error": "no pending documents"}
total = len(pending)
done = 0
failed = 0
garbage = 0
json_fixes = 0
json_total = 0
type_counts = {} # doc_type → count for batch summary
def _classify_one(doc):
"""Классифицировать один документ: фильтр → выжимка → LLM → сохранить."""
nonlocal garbage, json_fixes, json_total, type_counts
try:
# Stage 1: garbage by filename (0 tokens)
if _is_garbage_by_filename(doc["filename"]):
_db.set_classify_garbage(doc["id"], "filename_regex")
garbage += 1
return True
# Stage 2: garbage by header keywords (0 tokens)
text = _smart_extract(doc["elements_json"])
if _is_garbage_by_header(text):
_db.set_classify_garbage(doc["id"], "header_keywords")
garbage += 1
return True
# Stage 3: LLM classification (only for remaining)
_db.set_classify_processing(doc["id"]) # crash recovery marker
result, raw, needed_fix = _call_llm_classify(text, _llm)
json_total += 1
if needed_fix:
json_fixes += 1
dtype = result.get("doc_type", "other")
type_counts[dtype] = type_counts.get(dtype, 0) + 1
_db.set_classification(
doc["id"],
result.get("doc_type", "other"),
result.get("own_number"),
result.get("parent_number"),
result.get("doc_date"),
result.get("counterparty"),
classify_raw=raw,
classify_input=text,
)
return True
except Exception as e:
_db.set_classify_failed(doc["id"], str(e))
return False
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
futures = {pool.submit(_classify_one, d): d for d in pending}
for f in as_completed(futures):
if f.result():
done += 1
else:
failed += 1
return {"ok": True, "total": total, "done": done, "failed": failed,
"garbage": garbage, "json_fix_rate": round(json_fixes / max(json_total, 1), 3),
"types": type_counts, "summary": f"{total} total, {done} classified, {failed} failed, {garbage} garbage"}
def _safe_json_parse(raw):
"""Parse LLM response, fixing common JSON errors.
Returns (parsed_dict, needed_fix: bool)."""
if not raw:
raise ValueError("empty LLM response")
text = raw.strip()
# Strip markdown
if "```json" in text:
text = text.split("```json")[1].split("```")[0].strip()
elif "```" in text:
text = text.split("```")[1].split("```")[0].strip()
# Remove non-JSON prefix/suffix (LLM chatter)
brace_start = text.find("{")
brace_end = text.rfind("}")
if brace_start >= 0 and brace_end > brace_start:
text = text[brace_start:brace_end + 1]
# Try strict parse
try:
return json.loads(text), False
except json.JSONDecodeError:
pass
import re as _re
# Collapse multiline
text = _re.sub(r"\n\s*", " ", text)
# Remove trailing commas
text = _re.sub(r",\s*}", "}", text)
text = _re.sub(r",\s*]", "]", text)
# Try again
try:
return json.loads(text), True
except json.JSONDecodeError:
pass
# Aggressive: try adding missing closing quotes/braces
text = text.rstrip()
if not text.endswith("}"):
# Count unclosed quotes
in_string = False
for i, ch in enumerate(text):
if ch == '"' and (i == 0 or text[i-1] != "\\"):
in_string = not in_string
if in_string:
text += '"'
text += "}"
return json.loads(text), True
def _smart_extract(elements_json):
"""
Умная выжимка текста для классификации (решение Q3 от Opus).
Вместо отправки всего документа (дорого) или только header (теряет зарытые номера),
используется гибрид:
1. Первые ~1500 симв (титул, преамбула, стороны)
2. Regex-хиты по маркерам «договор|№|соглашение|приложение|спецификация»
из ВСЕГО документа
3. Дедупликация, лимит 10 строк, склейка → ~3000 симв на вход LLM
Это покрывает и титульную зону, и зарытые ссылки в середине документа.
"""
if not elements_json:
return ""
if isinstance(elements_json, str):
try:
elements = json.loads(elements_json)
except json.JSONDecodeError:
return elements_json[:2000]
elif isinstance(elements_json, list):
elements = elements_json
else:
return str(elements_json)[:2000]
# Build full text
lines = []
for el in elements:
if isinstance(el, dict):
t = el.get("type") or el.get("TYPE", "")
if t == "paragraph":
txt = el.get("text") or el.get("TEXT", "")
if txt:
lines.append(txt)
elif t == "table":
rows = el.get("rows") or el.get("ROWS", [])
for row in rows:
lines.append(" | ".join(str(c) for c in row))
full_text = "\n".join(lines)
# Header: first ~1500 chars
header = full_text[:1500]
# Marker lines: grep for key patterns
markers = re.findall(
r'.{0,200}(?:договор|№|соглашен|приложен|специф|контрагент|заказчик|арендатор).{0,200}',
full_text, re.IGNORECASE,
)
unique_markers = list(dict.fromkeys(markers))[:10]
combined = header + "\n---\n" + "\n".join(unique_markers)
return combined[:3000]
+45 -4
View File
@@ -207,6 +207,8 @@ class TwoPassObfuscator:
""" """
# --- Распаковать ZIP-файлы --- # --- Распаковать ZIP-файлы ---
files = self._expand_zips(files) files = self._expand_zips(files)
# --- Конвертировать PDF → DOCX ---
files = self._convert_pdfs_to_docx(files)
try: try:
# --- Проход 1: сбор сущностей --- # --- Проход 1: сбор сущностей ---
@@ -236,10 +238,6 @@ class TwoPassObfuscator:
pass pass
elif fname in all_docx: elif fname in all_docx:
obf_content = self._replace_in_docx(all_docx[fname]) obf_content = self._replace_in_docx(all_docx[fname])
elif fname.endswith('.pdf'):
txt = all_texts.get(fname, '')
obf_content = self._replace_in_text(txt, fname)
fname = fname[:-4] + '.txt'
else: else:
txt = all_texts.get(fname, '') txt = all_texts.get(fname, '')
obf_content = self._replace_in_text(txt, fname) obf_content = self._replace_in_text(txt, fname)
@@ -253,6 +251,49 @@ class TwoPassObfuscator:
self._regex_replacements.clear() self._regex_replacements.clear()
self._sorted_keys.clear() self._sorted_keys.clear()
def _convert_pdfs_to_docx(self, files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]:
"""Конвертировать PDF в DOCX через pdfplumber. При совпадении имён — _из_pdf."""
import pdfplumber
from docx import Document as DocxDocument
result = []
existing_names = {f[0] for f in files}
for fname, content, ctype in files:
if not fname.lower().endswith('.pdf'):
result.append((fname, content, ctype))
continue
try:
doc = DocxDocument()
with pdfplumber.open(io.BytesIO(content)) as pdf:
for page in pdf.pages:
tables = page.extract_tables()
for table in tables:
if table:
rows = [[str(c or "").strip() for c in (row or [])] for row in table]
rows = [r for r in rows if any(r)]
if rows:
t = doc.add_table(rows=len(rows), cols=len(rows[0]))
t.style = 'Table Grid'
for ri, row in enumerate(rows):
for ci, cell_text in enumerate(row):
t.rows[ri].cells[ci].text = cell_text
text = page.extract_text()
if text:
for line in text.split('\n'):
line = line.strip()
if line:
doc.add_paragraph(line)
buf = io.BytesIO()
doc.save(buf)
new_name = fname[:-4] + '.docx'
if new_name in existing_names:
new_name = fname[:-4] + '_из_pdf.docx'
existing_names.add(new_name)
result.append((new_name, buf.getvalue(), ctype))
except Exception as e:
log.warning("PDF→DOCX error for %s: %s", fname, e)
result.append((fname, content, ctype))
return result
def _expand_zips(self, files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]: def _expand_zips(self, files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]:
"""Распаковать ZIP-файлы, заменив их содержимым. Остальные файлы — как есть.""" """Распаковать ZIP-файлы, заменив их содержимым. Остальные файлы — как есть."""
result = [] result = []
+160
View File
@@ -0,0 +1,160 @@
"""
Grouping service — match classified documents into contract groups.
Архитектурное решение (Opus, Q4 + Q6):
- Двухпроходный гибрид: LLM извлекает строки (classify.py), Python нормализует и группирует.
- Нормализация номеров: uppercase + только буквы/цифры.
"МЭС-123-2024" == "МЭС 123/2024" после нормализации.
- Группировка на бэкенде (не на фронте): Python regex/unicode надёжнее JS.
- apply_groups(): создаёт contracts + supplements с авто-порядком по дате.
"""
import re
from site.db import documents as db_docs
from site.db import contracts as db_contracts
from site.db import supplements as db_supplements
def normalize_number(num):
"""
Нормализация номера договора для сравнения.
Убирает всё кроме букв и цифр, приводит к uppercase.
Пример: "МЭС-123-2024""МЭС1232024", "МЭС 123/2024""МЭС1232024".
"""
if not num:
return ""
return re.sub(r"[^A-Z0-9А-Я]", "", num.upper())
def group_documents(batch_id):
"""
Сгруппировать классифицированные документы по контрактам.
Алгоритм:
1. Отделить contract от supplement/specification
2. Каждый contract → якорь группы
3. Для каждого supplement: найти contract по parent_number (нормализованный)
4. Оставшиеся supplement/spec → виртуальные группы по parent_number/own_number
5. Совсем без номеров → группа "__unresolved__"
6. Внутри группы сортировка по doc_date
Возвращает {ok, groups: [{contract_number, counterparty, documents: [...]}], total_docs}.
"""
docs = db_docs.list_by_batch(batch_id)
classified = [d for d in docs if d.get("classify_status") == "classified"]
# Separate contracts and supplements
contracts_list = []
supplements_list = []
for d in classified:
if d.get("doc_type") == "contract":
contracts_list.append(d)
else:
supplements_list.append(d)
groups = []
# Each contract becomes a group
for c in contracts_list:
group = {
"contract_number": c.get("own_number") or c.get("filename", ""),
"counterparty": c.get("counterparty") or "",
"documents": [c],
}
# Find supplements matching this contract
c_norm = normalize_number(c.get("own_number"))
for s in supplements_list:
parent = normalize_number(s.get("parent_number") or "")
own = normalize_number(s.get("own_number") or "")
if parent == c_norm or own == c_norm:
if s not in group["documents"]:
group["documents"].append(s)
# Sort by date
group["documents"].sort(key=lambda x: x.get("doc_date") or "")
# Remove matched supplements from the pool
for s in group["documents"]:
if s in supplements_list:
supplements_list.remove(s)
groups.append(group)
# ── Virtual groups: unmatched supplements grouped by number ──────────
# Group remaining supplements/specs by normalized parent_number (priority) or own_number
virtual = {}
for s in supplements_list:
num = normalize_number(s.get("parent_number") or s.get("own_number") or "")
if not num:
continue # no number → stays in supplements_list for unresolved
if num not in virtual:
# Use counterparty from first doc in group
cp = s.get("counterparty") or ""
virtual[num] = {
"contract_number": s.get("parent_number") or s.get("own_number") or "?",
"counterparty": cp,
"documents": [],
}
virtual[num]["documents"].append(s)
# Update counterparty if current doc has a better one
if not virtual[num]["counterparty"] and s.get("counterparty"):
virtual[num]["counterparty"] = s.get("counterparty")
for vnum, vgroup in virtual.items():
vgroup["documents"].sort(key=lambda x: x.get("doc_date") or "")
groups.append(vgroup)
# Remove grouped docs from supplements_list
for s in vgroup["documents"]:
supplements_list.remove(s)
# ── Unresolved: everything left (no number, failed, pending, other) ──
unmatched = [s for s in supplements_list] # remaining after virtual grouping
unmatched += [d for d in docs if d.get("classify_status") != "classified"]
if unmatched:
groups.append({
"contract_number": "__unresolved__",
"counterparty": "",
"documents": unmatched,
})
return {"ok": True, "groups": groups, "total_docs": len(docs)}
def apply_groups(batch_id, groups_data):
"""
Применить подтверждённые группы: создать contracts + supplements.
Вызывается из POST /api/apply-groups.
Для каждой группы (кроме __unresolved__):
1. Создать запись в contracts (number, client)
2. Для каждого документа создать supplement (type='initial' для первого, 'additional' для остальных)
3. Порядок supplements соответствует порядку документов в группе (сортировка по дате уже сделана)
Возвращает {ok, created: количество созданных supplements}.
"""
created = 0
contract_ids = []
for g in groups_data:
contract_number = g.get("contract_number", "")
if contract_number == "__unresolved__":
continue
counterparty = g.get("counterparty", "")
docs = g.get("documents", [])
try:
c = db_contracts.insert(contract_number, counterparty)
contract_id = c["id"]
contract_ids.append(contract_id)
for i, d in enumerate(docs):
doc_id = d.get("id")
if not doc_id:
continue
supp_type = "initial" if i == 0 else "additional"
db_supplements.insert(contract_id, doc_id, supp_type)
created += 1
except Exception as e:
return {"ok": False, "error": f"apply_groups failed at {contract_number}: {e}"}
return {"ok": True, "created": created, "contract_ids": contract_ids}
+35
View File
@@ -0,0 +1,35 @@
"""LLM service — call LLM API with optional DI."""
import os, json
LLM_URL = "https://api.aillm.ru/v1/chat/completions"
LLM_KEY = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "")
LLM_MODEL = "gpt-oss-120b"
# Ленивый singleton — обратная совместимость
_llm_client = None
def _get_default_client():
global _llm_client
if _llm_client is None:
from site.services.llm_client import HttpxLLMClient
_llm_client = HttpxLLMClient(url=LLM_URL, key=LLM_KEY, model=LLM_MODEL)
return _llm_client
def call_llm(current_spec, doc_text, build_prompt_fn, llm_client=None):
"""Call LLM. Returns (parsed_result, prompt_id).
llm_client: LLMClient (optional). Default — HttpxLLMClient (prod).
"""
if llm_client is None:
llm_client = _get_default_client()
prompt, prompt_id = build_prompt_fn(current_spec, doc_text)
raw_text = llm_client.complete(prompt)
json_text = raw_text
if "```json" in json_text:
json_text = json_text.split("```json")[1].split("```")[0]
elif "```" in json_text:
json_text = json_text.split("```")[1].split("```")[0]
return json.loads(json_text.strip()), prompt_id
+88
View File
@@ -0,0 +1,88 @@
"""Metrics — quality signals without golden dataset.
Checks that work immediately:
- Arithmetic: sum == price * qty (free signal of LLM/data errors)
- JSON fix rate: how often _safe_json_parse has to repair LLM output
"""
from decimal import Decimal, InvalidOperation
def check_arithmetic(ops: list) -> list[dict]:
"""Check sum == price * qty for ADD/UPDATE operations.
Returns list of mismatches: [{action, name, price, qty, expected_sum, actual_sum, diff}]
"""
mismatches = []
for op in ops:
action = op.get("action", "")
if action not in ("ADD", "UPDATE"):
continue
row = op.get("new_row") or op.get("new_values") or {}
price = _to_decimal(row.get("price"))
qty = _to_decimal(row.get("qty"))
actual_sum = _to_decimal(row.get("sum"))
if price is None or qty is None or actual_sum is None:
continue # can't check without all three
expected = price * qty
if expected != actual_sum:
mismatches.append({
"action": action,
"name": row.get("name", "")[:100],
"price": float(price),
"qty": float(qty),
"expected_sum": float(expected),
"actual_sum": float(actual_sum),
"diff": float(actual_sum - expected),
})
return mismatches
class ClassifyMetrics:
"""Track _safe_json_parse fix rate per batch."""
def __init__(self):
self.total = 0
self.fixes = 0 # how many times JSON needed repair
def record(self, needed_fix: bool):
self.total += 1
if needed_fix:
self.fixes += 1
@property
def fix_rate(self) -> float:
return self.fixes / self.total if self.total else 0.0
def summary(self) -> dict:
return {
"total_classifications": self.total,
"json_fixes": self.fixes,
"json_fix_rate": round(self.fix_rate, 3),
}
def _to_decimal(val) -> Decimal | None:
"""Safe conversion to Decimal with number normalization."""
if val is None or val == "":
return None
try:
s = str(val).replace("\xa0", "").replace(" ", "").replace(",", ".")
return Decimal(s)
except (InvalidOperation, ValueError):
return None
def normalize_date(ds: str) -> str | None:
"""Normalize date to YYYY-MM-DD. Handles DD.MM.YYYY, YYYY-MM-DD, etc."""
if not ds:
return None
import re
# DD.MM.YYYY → YYYY-MM-DD
m = re.match(r"(\d{2})\.(\d{2})\.(\d{4})", ds)
if m:
return f"{m.group(3)}-{m.group(2)}-{m.group(1)}"
# YYYY-MM-DD — already canonical
if re.match(r"\d{4}-\d{2}-\d{2}", ds):
return ds
return ds # return as-is if unrecognized format
+87
View File
@@ -0,0 +1,87 @@
"""Parse service — PDF (pdfplumber), DOCX (python-docx), plain text fallback."""
import io
def parse_file(filename, data):
"""Parse file bytes → {status, elements, element_count}."""
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
try:
if ext == "pdf":
return _parse_pdf(data)
elif ext == "docx":
return _parse_docx(data)
elif ext == "doc":
return _parse_docx(data) # python-docx handles most .doc
else:
return _parse_text(data)
except Exception as e:
return {"status": "error", "error": str(e), "element_count": 0}
def _parse_pdf(data):
"""Parse PDF with pdfplumber — preserves table structure (unlike PyPDF2)."""
import pdfplumber
elements = []
with pdfplumber.open(io.BytesIO(data)) as pdf:
for page in pdf.pages:
# Tables first — preserves column structure critical for specs
tables = page.extract_tables()
for table in tables:
if table:
rows = []
for row in table:
if row:
cells = [str(cell or "").strip() for cell in row]
if any(cells):
rows.append(cells)
if rows:
elements.append({"type": "table", "rows": rows})
# Remaining text as paragraphs
text = page.extract_text()
if text:
for line in text.split("\n"):
line = line.strip()
if line:
elements.append({"type": "paragraph", "text": line, "style": ""})
return {"status": "parsed", "element_count": len(elements), "elements": elements}
def _parse_docx(data):
import docx
doc = docx.Document(io.BytesIO(data))
elements = []
for block in doc.element.body:
tag = block.tag.split("}")[-1] if "}" in block.tag else block.tag
if tag == "p":
text = _extract_paragraph_text(block)
if text:
elements.append({"type": "paragraph", "text": text, "style": ""})
elif tag == "tbl":
rows = []
for tr in block.findall(".//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}tr"):
cells = []
for tc in tr.findall(".//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}tc"):
cell_text = "".join(t.text or "" for t in tc.findall(".//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t"))
cells.append(cell_text.strip())
if cells:
rows.append(cells)
if rows:
elements.append({"type": "table", "rows": rows})
return {"status": "parsed", "element_count": len(elements), "elements": elements}
def _extract_paragraph_text(p_elem):
texts = []
for t in p_elem.findall(".//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t"):
if t.text:
texts.append(t.text)
return "".join(texts).strip()
def _parse_text(data):
text = data.decode("utf-8", errors="ignore")[:5000]
lines = [l.strip() for l in text.split("\n") if l.strip()]
return {"status": "parsed", "element_count": len(lines),
"elements": [{"type": "paragraph", "text": l, "style": ""} for l in lines]}
+155
View File
@@ -0,0 +1,155 @@
"""Process service — SSE pipeline: reset → supplements → LLM → apply.
Адаптирован из deploy/compare/process.py: callback → generator.
"""
import json, time
from site.db import supplements, spec_current, spec_events
from site.services.metrics import check_arithmetic
def run_pipeline(contract_id, order_ids, build_prompt_fn):
"""Generator: yield SSE events вместо sse_send callback.
Использование:
for event in run_pipeline(cid, order_ids, build_prompt):
yield f"data: {json.dumps(event)}\\n\\n"
"""
t0 = time.time()
# 0. Reset
spec_events.reset(contract_id)
# 1. Get supplements with parsed documents
supps = supplements.list_by_contract(contract_id)
if order_ids:
order_list = [x.strip() for x in order_ids.split(",") if x.strip()]
order_map = {oid: i for i, oid in enumerate(order_list)}
supps.sort(key=lambda s: order_map.get(s["id"], 999999))
else:
supps.sort(key=lambda s: (s.get("doc_date") or "9999-99-99", s.get("created_at", "")))
if not supps:
yield {"type": "error", "message": "Нет распарсенных файлов"}
return
from site.services.llm import call_llm
for s in supps:
sid = s["id"]
filename = s.get("filename", "?")
# Current spec
cur = spec_current.list_by_contract(contract_id)
current_spec = []
for r in cur:
current_spec.append({
"hash": r["name_hash"],
"name": r["name"],
"price": float(r["price"]) if r.get("price") is not None else None,
"qty": float(r["qty"]) if r.get("qty") is not None else None,
"sum": float(r["sum"]) if r.get("sum") is not None else None,
"date_start": r["date_start"],
})
# Get elements_json
ej = spec_current.get_elements_json(s["document_id"])
if not ej:
yield {
"type": "extract_error",
"supplement_id": sid,
"filename": filename,
"error": "no elements_json",
}
continue
# Build doc text from elements
doc_text = _elements_to_text(ej)
yield {
"type": "extract_start",
"supplement_id": sid,
"filename": filename,
}
# LLM call
try:
t1 = time.time()
result, prompt_id = call_llm(current_spec, doc_text, build_prompt_fn)
ops = result.get("ops", [])
mode = result.get("mode", "llm")
yield {
"type": "llm_done",
"supplement_id": sid,
"filename": filename,
"ops_count": len(ops),
"mode": mode,
"time_s": round(time.time() - t1, 1),
}
# Apply ops to DB
applied_ops = []
summary = {"added": 0, "updated": 0, "deleted": 0, "unresolved": 0}
for op in ops:
action = op.get("action", "UNRESOLVED")
summary[action.lower()] = summary.get(action.lower(), 0) + 1
try:
if action == "ADD":
nr = op.get("new_row", {})
spec_events.add_row(contract_id, sid, nr)
elif action == "UPDATE":
nr = op.get("new_row", {})
nv = op.get("new_values", {})
target = op.get("target_hash", "")
spec_events.update_row(contract_id, sid, target, nr, nv)
elif action == "DELETE":
target = op.get("target_hash", "")
spec_events.delete_row(contract_id, sid, target)
applied_ops.append(op)
except Exception:
summary["unresolved"] = summary.get("unresolved", 0) + 1
# Arithmetic check
check_arithmetic(contract_id)
yield {
"type": "applied",
"supplement_id": sid,
"filename": filename,
"summary": summary,
"ops": applied_ops,
}
except Exception as e:
yield {
"type": "extract_error",
"supplement_id": sid,
"filename": filename,
"error": str(e),
}
total_time = round(time.time() - t0, 1)
yield {"type": "complete", "total_time_s": total_time}
def _elements_to_text(ej):
"""Extract flat text from elements_json for LLM prompt."""
if isinstance(ej, dict) and "Value" in ej:
ej = ej["Value"]
if isinstance(ej, str):
try:
ej = json.loads(ej)
except (json.JSONDecodeError, TypeError):
return ej
lines = []
if isinstance(ej, list):
for el in ej:
if isinstance(el, dict):
if el.get("type") == "paragraph":
lines.append(el.get("text", ""))
elif el.get("type") == "table":
for row in el.get("rows", []):
lines.append(" | ".join(str(c) for c in row))
elif isinstance(el, str):
lines.append(el)
return "\n".join(lines)
+6 -7
View File
@@ -1,10 +1,9 @@
// ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔ // ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔
// Contracts App — весь JS на VM (contracts.kube5s.ru) // Contracts App v2.0 — всё на Flask, ВМ больше нет
// Lucee: только домен + index.cfm-скелет var VM_API = '';
var VM_API = 'https://check.kube5s.ru'; var UPLOAD_URL = '/upload';
var UPLOAD_URL = VM_API + '/upload'; var CONVERT_URL = '/convert-doc';
var CONVERT_URL = VM_API + '/convert-doc'; var UNZIP_URL = '/unzip-upload';
var UNZIP_URL = VM_API + '/unzip-upload';
// SITE_URL удалён (Фаза 4) — не использовался // SITE_URL удалён (Фаза 4) — не использовался
var fileInput = document.getElementById('fileInput'); var fileInput = document.getElementById('fileInput');
@@ -328,7 +327,7 @@ document.getElementById('llmBtn').addEventListener('click', function() {
compareStatus.textContent = '⏳ 0.0с'; compareStatus.textContent = '⏳ 0.0с';
var order = state.files.map(function(f) { return f.supp_id; }).filter(Boolean).join(','); var order = state.files.map(function(f) { return f.supp_id; }).filter(Boolean).join(',');
var url = 'https://check.kube5s.ru/process-v2?contract_id=' + state.contractId + var url = '/process-v2?contract_id=' + state.contractId +
(order ? '&order=' + encodeURIComponent(order) : ''); (order ? '&order=' + encodeURIComponent(order) : '');
// Фаза 3: унифицированный SSE через startCompareSSE (compare.js) // Фаза 3: унифицированный SSE через startCompareSSE (compare.js)
+68 -7
View File
@@ -14,7 +14,7 @@
} }
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { font: 14px/1.6 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--fg); } body { font: 14px/1.6 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--fg); }
.header { background: var(--card); border-bottom: 1px solid var(--border); padding: 12px 24px; display: flex; align-items: center; gap: 12px; } .header { background: var(--card); border-bottom: 1px solid var(--border); padding: 12px 24px; display: flex; align-items: center; gap: 12px; position: sticky; top: 0; z-index: 100; }
.header img { height: 24px; } .header img { height: 24px; }
.header .sep { color: var(--muted); } .header .sep { color: var(--muted); }
.header a { color: var(--brand); text-decoration: none; font-size: 13px; } .header a { color: var(--brand); text-decoration: none; font-size: 13px; }
@@ -59,7 +59,7 @@
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 1000; justify-content: center; align-items: flex-start; padding-top: 60px; } .modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 1000; justify-content: center; align-items: flex-start; padding-top: 60px; }
.modal-overlay.show { display: flex; } .modal-overlay.show { display: flex; }
.modal { background: var(--card); border-radius: 12px; max-width: 600px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0,0,0,.15); } .modal { background: var(--card); border-radius: 12px; max-width: 600px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0,0,0,.15); }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border); } .modal-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--card); z-index: 1; }
.modal-header h2 { font-size: 16px; } .modal-header h2 { font-size: 16px; }
.modal-close { cursor: pointer; font-size: 20px; color: var(--muted); background: none; border: none; line-height: 1; } .modal-close { cursor: pointer; font-size: 20px; color: var(--muted); background: none; border: none; line-height: 1; }
.modal-close:hover { color: var(--fg); } .modal-close:hover { color: var(--fg); }
@@ -84,7 +84,7 @@
<a href="https://contractor.pythonk8s.services.ngcloud.ru/">Сверка договоров</a> <a href="https://contractor.pythonk8s.services.ngcloud.ru/">Сверка договоров</a>
<span class="sep">|</span> <span class="sep">|</span>
<strong>DrHider</strong> <strong>DrHider</strong>
<span style="font-size:11px;color:var(--muted);">v1.8</span> <span style="font-size:11px;color:var(--muted);">v1.15</span>
<button class="help-btn" onclick="openModal()" title="О сервисе">?</button> <button class="help-btn" onclick="openModal()" title="О сервисе">?</button>
</header> </header>
@@ -123,7 +123,7 @@
<h3>✅ Форматы файлов</h3> <h3>✅ Форматы файлов</h3>
<ul> <ul>
<li><strong>.docx</strong> — полная замена с сохранением структуры документа. ⚠️ Форматирование (жирный, курсив) заменённых фрагментов может сброситься.</li> <li><strong>.docx</strong> — полная замена с сохранением структуры документа. ⚠️ Форматирование (жирный, курсив) заменённых фрагментов может сброситься.</li>
<li><strong>.pdf</strong> — текст извлекается и обфусцируется. Результат: <code>.txt</code>. Форматирование, таблицы и графика не сохраняются.</li> <li><strong>.pdf</strong> — текст и таблицы извлекаются в .docx, затем обфусцируется. Шрифты, цвета и PDF-вёрстка не сохраняются. При совпадении имён с существующим .docx добавляется суффикс <code>_из_pdf</code>.</li>
<li><strong>.doc</strong> — бинарный формат, <strong>не обфусцируется</strong>. Файл возвращается как есть. Конвертируйте в .docx перед загрузкой.</li> <li><strong>.doc</strong> — бинарный формат, <strong>не обфусцируется</strong>. Файл возвращается как есть. Конвертируйте в .docx перед загрузкой.</li>
<li><strong>.zip</strong> — автоматически распаковывается, все файлы внутри обрабатываются.</li> <li><strong>.zip</strong> — автоматически распаковывается, все файлы внутри обрабатываются.</li>
</ul> </ul>
@@ -139,7 +139,7 @@
<strong>.doc не обрабатывается:</strong> старые Word-файлы возвращаются без изменений. <strong>.doc не обрабатывается:</strong> старые Word-файлы возвращаются без изменений.
</div> </div>
<div class="warn"> <div class="warn">
<strong>PDF → текст:</strong> таблицы, графика и форматирование теряются. <strong>PDF → DOCX:</strong> извлекаются только текст и таблицы. Шрифты, цвета и позиционирование теряются. Сканы (фотографии страниц) не распознаются.</div>
</div> </div>
<div class="warn"> <div class="warn">
<strong>Сканы/изображения:</strong> текст на картинках не распознаётся (нет OCR). <strong>Сканы/изображения:</strong> текст на картинках не распознаётся (нет OCR).
@@ -204,13 +204,57 @@ DZ.ondrop = e => { e.preventDefault(); DZ.classList.remove('active'); addFiles(e
function addFiles(fl) { for (let f of fl) { if (files.find(x => x.name===f.name && x.size===f.size)) continue; files.push(f); } render(); } function addFiles(fl) { for (let f of fl) { if (files.find(x => x.name===f.name && x.size===f.size)) continue; files.push(f); } render(); }
function remove(i) { files.splice(i, 1); render(); } function remove(i) { files.splice(i, 1); render(); }
function render() { function render() {
LIST.innerHTML = files.map((f,i) => `<div class="file-row"><span class="name">${esc(f.name)}</span><span class="size">${fmt(f.size)}</span><span class="remove" onclick="remove(${i})">×</span></div>`).join(''); LIST.innerHTML = files.map((f,i) => {
let display = esc(f.name);
if (f.name.toLowerCase().endsWith('.pdf')) {
display += ' → <span style="color:var(--brand-primary)">*.docx</span>';
}
return `<div class="file-row"><span class="name">${display}</span><span class="size">${fmt(f.size)}</span><span class="remove" onclick="remove(${i})">×</span></div>`;
}).join('');
LIST.className = files.length ? 'show' : ''; LIST.className = files.length ? 'show' : '';
BTN.disabled = !files.length; BTN.disabled = !files.length;
} }
BTN.onclick = () => { BTN.onclick = () => {
if (!files.length) return; if (!files.length) return;
// Проверить коллизии PDF→DOCX
const collisions = [];
const names = files.map(f => f.name);
files.forEach(f => {
if (f.name.toLowerCase().endsWith('.pdf')) {
const docxName = f.name.slice(0, -4) + '.docx';
if (names.includes(docxName)) {
collisions.push({pdf: f.name, docx: docxName});
}
}
});
if (collisions.length) {
showCollisionModal(collisions);
return;
}
doSubmit();
};
function showCollisionModal(collisions) {
const list = collisions.map(c => `<div style="margin-bottom:4px">📄 <b>${esc(c.pdf)}</b> → <b>${esc(c.docx)}</b> (уже есть)</div>`).join('');
document.getElementById('collisionList').innerHTML = list;
document.getElementById('collisionOverlay').classList.add('show');
window._collisions = collisions;
}
function closeCollision(skipAll) {
document.getElementById('collisionOverlay').classList.remove('show');
if (skipAll) {
// Удалить PDF-файлы из списка
const pdfNames = window._collisions.map(c => c.pdf);
files = files.filter(f => !pdfNames.includes(f.name));
render();
}
// В любом случае отправляем (бэкенд сам переименует)
setTimeout(doSubmit, 100);
}
function doSubmit() {
BTN.disabled = true; BTN.disabled = true;
setStatus('progress', '⏳ Отправка...'); setStatus('progress', '⏳ Отправка...');
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
@@ -248,7 +292,24 @@ function fmt(n) { return n>1e6 ? (n/1e6).toFixed(1)+' MB' : n>1e3 ? (n/1e3).toFi
// ── Модальное окно ───────────────────────────────────────────── // ── Модальное окно ─────────────────────────────────────────────
function openModal() { document.getElementById('modalOverlay').classList.add('show'); } function openModal() { document.getElementById('modalOverlay').classList.add('show'); }
function closeModal() { document.getElementById('modalOverlay').classList.remove('show'); } function closeModal() { document.getElementById('modalOverlay').classList.remove('show'); }
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); }); // ── Коллизия PDF→DOCX ──────────────────────────────────────────
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeCollision(true); });
</script> </script>
<!-- Модальное окно: коллизия PDF→DOCX -->
<div class="modal-overlay" id="collisionOverlay">
<div class="modal" style="max-width:480px">
<div class="modal-header">⚠️ Конфликт имён</div>
<div class="modal-body">
<p style="margin-bottom:10px">PDF-файлы конвертируются в DOCX. Имена совпадают:</p>
<div id="collisionList" style="margin-bottom:12px"></div>
<p style="font-size:12px;color:var(--muted)">«Перезаписать» — PDF-файл заменит DOCX.<br>«Пропустить» — PDF будут удалены из списка.</p>
</div>
<div style="display:flex;gap:8px;padding:12px 16px;border-top:1px solid var(--brand-gray)">
<button class="btn" onclick="closeCollision(true)" style="flex:1">Пропустить PDF</button>
<button class="btn btn-primary" onclick="closeCollision(false)" style="flex:1">Перезаписать</button>
</div>
</div>
</div>
</body> </body>
</html> </html>
+1 -1
View File
@@ -73,7 +73,7 @@
<body> <body>
<div class="topbar"> <div class="topbar">
<img src="/static/logo.svg" alt="Nubes"> <img src="/static/logo.svg" alt="Nubes">
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.195-flask</span></span> <span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v2.0.0</span></span>
<div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);"> <div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);">
<span id="stepUpload">○ Загрузка</span><span></span> <span id="stepUpload">○ Загрузка</span><span></span>
<span id="stepClassify">○ Классификация</span><span></span> <span id="stepClassify">○ Классификация</span><span></span>