fix: site/ → src/ (stdlib conflict — site is built-in Python module)
Deploy contracts-flask / validate (push) Successful in 0s
Deploy contracts-flask / validate (push) Successful in 0s
This commit is contained in:
-42
@@ -1,42 +0,0 @@
|
||||
"""contracts-flask v2.0 — полный перенос с ВМ на Flask.
|
||||
Больше никаких прокси на contracts.kube5s.ru — всё локально.
|
||||
"""
|
||||
from flask import Flask
|
||||
from site.config import VERSION, MAX_CONTENT_LENGTH
|
||||
from site.routes import register_routes
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
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
|
||||
|
||||
|
||||
def _init_db():
|
||||
"""Создать БД + схему + seed prompts. SQLite — всё в одном файле /tmp."""
|
||||
from site.db.connection import init_db
|
||||
init_db()
|
||||
try:
|
||||
from site.db import prompts as db_prompts
|
||||
db_prompts.seed_defaults()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -1,11 +0,0 @@
|
||||
"""Конфигурация приложения — все настройки в одном месте."""
|
||||
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", "")
|
||||
@@ -1,220 +0,0 @@
|
||||
"""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
|
||||
@@ -1,25 +0,0 @@
|
||||
"""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)"
|
||||
)
|
||||
@@ -1,115 +0,0 @@
|
||||
"""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}
|
||||
@@ -1,151 +0,0 @@
|
||||
"""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 сам предложил"),
|
||||
)
|
||||
@@ -1,19 +0,0 @@
|
||||
"""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
|
||||
@@ -1,202 +0,0 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -1,59 +0,0 @@
|
||||
"""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,))
|
||||
@@ -1,281 +0,0 @@
|
||||
"""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, ""
|
||||
@@ -1,268 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,17 +0,0 @@
|
||||
"""Регистрация всех 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)
|
||||
@@ -1,182 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,10 +0,0 @@
|
||||
"""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})
|
||||
@@ -1,14 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,91 +0,0 @@
|
||||
"""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
|
||||
@@ -1,71 +0,0 @@
|
||||
"""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
|
||||
@@ -1,145 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,267 +0,0 @@
|
||||
"""
|
||||
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]
|
||||
@@ -1,549 +0,0 @@
|
||||
"""
|
||||
DrHider — обфускация документов (двухпроходная, в памяти, без БД).
|
||||
|
||||
Проход 1: собрать все сущности из всех файлов → глобальный словарь замен.
|
||||
Проход 2: применить замены → собрать ZIP с обфусцированными файлами + mapping.csv.
|
||||
|
||||
Согласованность: одна и та же сущность во всех файлах → одно и то же фиктивное значение.
|
||||
"""
|
||||
DRHIDER_VERSION = "1.3"
|
||||
import io
|
||||
import csv
|
||||
import re
|
||||
import random
|
||||
import string
|
||||
import zipfile
|
||||
import logging
|
||||
import os
|
||||
from typing import Dict, List, Tuple, Callable, Optional
|
||||
|
||||
log = logging.getLogger("drhider")
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# Regex-паттерны для обнаружения сущностей
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
ENTITY_PATTERNS: Dict[str, str] = {
|
||||
"phone": r'(?<!\d)(?:\+7|8)[\s\-]?\(?\d{3}\)?[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}(?!\d)',
|
||||
"email": r'\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b',
|
||||
# 12-значный ИНН ДО 10-значного (иначе 12-значный матчится как 10)
|
||||
"inn_fl": r'ИНН\s*\d{12}',
|
||||
"inn_ul": r'ИНН\s*\d{10}',
|
||||
"ogrn": r'ОГРН\s*\d{13}',
|
||||
"kpp": r'КПП\s*\d{9}',
|
||||
"bik": r'БИК\s*\d{9}',
|
||||
"rs": r'(?:р/с|расч[её]тный\s*сч[её]т)\s*\d{20}',
|
||||
"ks": r'(?:к/с|кор/сч|корр?[еи]?спондентский\s*сч[её]т)\s*\d{20}',
|
||||
"passport": r'(?:паспорт|серия\s+номер)[\s:№]*\d{2}\s*\d{2}\s*\d{6,7}',
|
||||
}
|
||||
|
||||
# Фирмы — обнаружение по шаблону
|
||||
COMPANY_PATTERN = re.compile(
|
||||
r'(?:ООО|ЗАО|ОАО|АО|ПАО|ИП|ТОО)\s+(?:«[^»]+»|"[^"]+"|\u201c[^\u201d]+\u201d|[А-ЯA-Z][\w\-\.]{2,40})',
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
# ФИО — Фамилия + инициалы или полное имя (только кириллица)
|
||||
PERSON_PATTERN = re.compile(
|
||||
r'\b[А-Я][а-я]+\s+[А-Я]\.[А-Я]\.'
|
||||
r'|\b[А-Я][а-я]+\s+[А-Я][а-я]+\s+[А-Я][а-я]+'
|
||||
)
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# Генераторы фиктивных значений
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
# Словари русских имён
|
||||
RU_SURNAMES = ["Иванов", "Смирнов", "Кузнецов", "Попов", "Васильев", "Петров",
|
||||
"Соколов", "Михайлов", "Новиков", "Фёдоров", "Морозов", "Волков", "Алексеев",
|
||||
"Лебедев", "Семёнов", "Егоров", "Павлов", "Козлов", "Степанов", "Николаев"]
|
||||
RU_NAMES = ["Александр", "Дмитрий", "Сергей", "Андрей", "Алексей", "Максим",
|
||||
"Евгений", "Иван", "Михаил", "Николай", "Владимир", "Павел", "Виктор", "Олег"]
|
||||
RU_PATRONYMICS = ["Александрович", "Дмитриевич", "Сергеевич", "Андреевич",
|
||||
"Алексеевич", "Иванович", "Михайлович", "Николаевич", "Владимирович",
|
||||
"Павлович", "Викторович", "Олегович", "Евгеньевич", "Максимович"]
|
||||
|
||||
RU_CITIES = ["Москва", "Санкт-Петербург", "Новосибирск", "Екатеринбург",
|
||||
"Казань", "Нижний Новгород", "Челябинск", "Самара", "Омск", "Ростов-на-Дону",
|
||||
"Уфа", "Красноярск", "Воронеж", "Пермь", "Волгоград"]
|
||||
RU_STREETS = ["Ленина", "Мира", "Пушкина", "Гагарина", "Советская",
|
||||
"Кирова", "Октябрьская", "Молодёжная", "Садовая", "Центральная"]
|
||||
|
||||
FAKE_DOMAINS = ["example.ru", "mail.test", "company.local", "org.example.ru"]
|
||||
|
||||
|
||||
def _checksum_inn10(inn: str) -> str:
|
||||
"""Контрольная сумма для 10-значного ИНН."""
|
||||
coeffs = [2, 4, 10, 3, 5, 9, 4, 6, 8]
|
||||
s = sum(int(inn[i]) * coeffs[i] for i in range(9))
|
||||
return str((s % 11) % 10)
|
||||
|
||||
|
||||
def _checksum_inn12(inn: str) -> str:
|
||||
"""Контрольные суммы для 12-значного ИНН (первые 10 цифр)."""
|
||||
c1 = [7, 2, 4, 10, 3, 5, 9, 4, 6, 8]
|
||||
c2 = [3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8]
|
||||
s1 = sum(int(inn[i]) * c1[i] for i in range(10))
|
||||
n1 = (s1 % 11) % 10
|
||||
inn2 = inn + str(n1)
|
||||
s2 = sum(int(inn2[i]) * c2[i] for i in range(11))
|
||||
n2 = (s2 % 11) % 10
|
||||
return str(n1) + str(n2)
|
||||
|
||||
|
||||
def _checksum_ogrn(ogrn: str) -> str:
|
||||
"""Контрольная сумма для ОГРН (12 цифр → остаток от деления на 11)."""
|
||||
s = int(ogrn) % 11
|
||||
return str(s % 10)
|
||||
|
||||
|
||||
def _random_digits(n: int) -> str:
|
||||
return ''.join(random.choice(string.digits) for _ in range(n))
|
||||
|
||||
|
||||
def _random_letters(n: int) -> str:
|
||||
return ''.join(random.choice(string.ascii_lowercase) for _ in range(n))
|
||||
|
||||
|
||||
def generate_phone(_: str) -> str:
|
||||
code = random.choice(["495", "499", "812", "383", "343"])
|
||||
return f"+7 ({code}) {_random_digits(3)}-{_random_digits(2)}-{_random_digits(2)}"
|
||||
|
||||
|
||||
def generate_email(original: str) -> str:
|
||||
"""Генерирует email с тем же форматом."""
|
||||
domain = random.choice(FAKE_DOMAINS)
|
||||
local = _random_letters(random.randint(5, 10))
|
||||
return f"{local}@{domain}"
|
||||
|
||||
|
||||
def generate_inn10(original: str) -> str:
|
||||
prefix = re.match(r'ИНН\s*', original, re.IGNORECASE).group(0)
|
||||
base = f"{random.randint(1,9)}{_random_digits(8)}"
|
||||
return prefix + base + _checksum_inn10(base)
|
||||
|
||||
|
||||
def generate_inn12(original: str) -> str:
|
||||
prefix = re.match(r'ИНН\s*', original, re.IGNORECASE).group(0)
|
||||
base = f"{random.randint(1,9)}{_random_digits(9)}"
|
||||
return prefix + base + _checksum_inn12(base)
|
||||
|
||||
|
||||
def generate_ogrn(original: str) -> str:
|
||||
prefix = re.match(r'ОГРН\s*', original, re.IGNORECASE).group(0)
|
||||
base = "1" + _random_digits(11)
|
||||
return prefix + base + _checksum_ogrn(base)
|
||||
|
||||
|
||||
def generate_kpp(original: str) -> str:
|
||||
prefix = re.match(r'КПП\s*', original, re.IGNORECASE).group(0)
|
||||
return prefix + _random_digits(4) + random.choice(["01", "43", "77"]) + _random_digits(3)
|
||||
|
||||
|
||||
def generate_bik(original: str) -> str:
|
||||
prefix = re.match(r'БИК\s*', original, re.IGNORECASE).group(0)
|
||||
return prefix + "04" + _random_digits(7)
|
||||
|
||||
|
||||
def generate_rs(original: str) -> str:
|
||||
prefix = re.match(r'(?:р/с|расч[её]тный\s*сч[её]т)\s*', original, re.IGNORECASE).group(0)
|
||||
return prefix + "40702" + _random_digits(15)
|
||||
|
||||
|
||||
def generate_ks(original: str) -> str:
|
||||
prefix = re.match(r'(?:к/с|кор/сч|корр?[еи]?спондентский\s*сч[её]т)\s*', original, re.IGNORECASE).group(0)
|
||||
return prefix + "30101" + _random_digits(15)
|
||||
|
||||
|
||||
def generate_passport(original: str) -> str:
|
||||
m = re.match(r'(?:паспорт|серия\s+номер)[\s:№]*', original, re.IGNORECASE)
|
||||
prefix = m.group(0) if m else ""
|
||||
return prefix + f"{random.randint(10,99)} {random.randint(10,99)} {_random_digits(6)}"
|
||||
|
||||
|
||||
def generate_company(_: str) -> str:
|
||||
forms = ["ООО", "ЗАО", "АО"]
|
||||
nouns = ["Технология", "Прогресс", "Гарант", "Стандарт", "Импульс",
|
||||
"Вектор", "Сфера", "Альянс", "Синтез", "Меридиан", "Спектр", "Формат"]
|
||||
return f'{random.choice(forms)} «{random.choice(nouns)}»'
|
||||
|
||||
|
||||
def generate_person(_: str) -> str:
|
||||
s = random.choice(RU_SURNAMES)
|
||||
n = random.choice(RU_NAMES)
|
||||
p = random.choice(RU_PATRONYMICS)
|
||||
return f"{s} {n[0]}.{p[0]}."
|
||||
|
||||
|
||||
def generate_address(_: str) -> str:
|
||||
city = random.choice(RU_CITIES)
|
||||
street = random.choice(RU_STREETS)
|
||||
house = random.randint(1, 200)
|
||||
return f"{city}, ул. {street}, д. {house}"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# Основной класс
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TwoPassObfuscator:
|
||||
"""Двухпроходный обфускатор: сбор сущностей → замена."""
|
||||
|
||||
def __init__(self, llm_client=None):
|
||||
self._mapping: Dict[str, str] = {} # оригинал → замена (только для точных текстовых совпадений)
|
||||
self._regex_replacements: List[Tuple[str, str, Callable]] = [] # (pattern, type, generator)
|
||||
self._sorted_keys: List[str] = [] # ключи mapping, отсортированные по длине (убывание)
|
||||
self._llm_client = llm_client
|
||||
|
||||
def obfuscate(self, files: List[Tuple[str, bytes, str]]) -> Tuple[bytes, str]:
|
||||
"""
|
||||
Главная точка входа.
|
||||
|
||||
Args:
|
||||
files: [(original_filename, content_bytes, content_type), ...]
|
||||
|
||||
Returns:
|
||||
(zip_bytes, mapping_csv_string)
|
||||
"""
|
||||
# --- Распаковать ZIP-файлы ---
|
||||
files = self._expand_zips(files)
|
||||
# --- Конвертировать PDF → DOCX ---
|
||||
files = self._convert_pdfs_to_docx(files)
|
||||
|
||||
try:
|
||||
# --- Проход 1: сбор сущностей ---
|
||||
all_texts: Dict[str, str] = {}
|
||||
all_docx: Dict[str, object] = {}
|
||||
|
||||
for fname, content, ctype in files:
|
||||
text, doc = self._extract_text(fname, content, ctype)
|
||||
all_texts[fname] = text
|
||||
if doc is not None:
|
||||
all_docx[fname] = doc
|
||||
if text and text != "[DOC binary — not parsed]":
|
||||
self._scan_regex(text)
|
||||
|
||||
if self._llm_client:
|
||||
self._scan_llm_ner(all_texts)
|
||||
|
||||
# Предсортировать ключи один раз для _apply_replacements
|
||||
self._sorted_keys = sorted(self._mapping.keys(), key=len, reverse=True)
|
||||
|
||||
# --- Проход 2: замена ---
|
||||
results = []
|
||||
for fname, content, ctype in files:
|
||||
obf_content = content
|
||||
if fname.endswith('.doc'):
|
||||
# .doc — бинарный, оставляем как есть
|
||||
pass
|
||||
elif fname in all_docx:
|
||||
obf_content = self._replace_in_docx(all_docx[fname])
|
||||
else:
|
||||
txt = all_texts.get(fname, '')
|
||||
obf_content = self._replace_in_text(txt, fname)
|
||||
results.append((fname, obf_content))
|
||||
|
||||
csv_str = self._build_mapping_csv()
|
||||
return self._build_zip(results, csv_str), csv_str
|
||||
|
||||
finally:
|
||||
self._mapping.clear()
|
||||
self._regex_replacements.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]]:
|
||||
"""Распаковать ZIP-файлы, заменив их содержимым. Остальные файлы — как есть."""
|
||||
result = []
|
||||
for fname, content, ctype in files:
|
||||
if fname.lower().endswith('.zip'):
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(content)) as zf:
|
||||
total_size = sum(info.file_size for info in zf.infolist())
|
||||
if total_size > 500 * 1024 * 1024: # 500 MB
|
||||
log.warning("ZIP too large, skipping expansion: %s", fname)
|
||||
result.append((fname, content, ctype))
|
||||
continue
|
||||
if len(zf.infolist()) > 500:
|
||||
log.warning("ZIP too many files, skipping: %s", fname)
|
||||
result.append((fname, content, ctype))
|
||||
continue
|
||||
for info in zf.infolist():
|
||||
if info.is_dir():
|
||||
continue
|
||||
# cp437 → utf8 (как в services/unzip.py)
|
||||
name = info.filename
|
||||
try:
|
||||
name = name.encode("cp437").decode("utf-8", errors="replace")
|
||||
except (UnicodeDecodeError, UnicodeEncodeError):
|
||||
pass
|
||||
# Убрать path traversal
|
||||
name = os.path.basename(name)
|
||||
if not name or name.endswith("/") or ".." in name or "/" in name or "\\" in name:
|
||||
continue
|
||||
inner_data = zf.read(info)
|
||||
result.append((name, inner_data, ""))
|
||||
except Exception as e:
|
||||
log.warning("Failed to expand ZIP %s: %s", fname, e)
|
||||
result.append((fname, content, ctype))
|
||||
else:
|
||||
result.append((fname, content, ctype))
|
||||
return result
|
||||
|
||||
# --- Проход 1: обнаружение ---
|
||||
|
||||
def _extract_text(self, fname: str, content: bytes, ctype: str) -> Tuple[str, Optional[object]]:
|
||||
"""Извлечь текст из файла. Возвращает (text, docx_document_or_None)."""
|
||||
doc = None
|
||||
text = ""
|
||||
|
||||
ext = os.path.splitext(fname)[1].lower()
|
||||
|
||||
if ext == '.docx':
|
||||
try:
|
||||
from docx import Document
|
||||
except ImportError:
|
||||
text = content.decode('utf-8', errors='replace')
|
||||
return text, None
|
||||
doc = Document(io.BytesIO(content))
|
||||
text = "\n".join(p.text for p in doc.paragraphs)
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
text += "\n" + " | ".join(cell.text for cell in row.cells)
|
||||
|
||||
elif ext == '.pdf':
|
||||
try:
|
||||
import pdfplumber
|
||||
except ImportError:
|
||||
text = content.decode('utf-8', errors='replace')
|
||||
return text, None
|
||||
with pdfplumber.open(io.BytesIO(content)) as pdf:
|
||||
for page in pdf.pages:
|
||||
t = page.extract_text()
|
||||
if t:
|
||||
text += t + "\n"
|
||||
for table in page.extract_tables():
|
||||
for row in table:
|
||||
text += "\n" + " | ".join(str(c) if c else "" for c in row)
|
||||
|
||||
elif ext == '.doc':
|
||||
# .doc — бинарный формат, без libreoffice не парсим
|
||||
# Пропускаем без изменений (не обфусцируем)
|
||||
text = "[DOC binary — not parsed]"
|
||||
return text, None
|
||||
|
||||
else:
|
||||
text = content.decode('utf-8', errors='replace')
|
||||
|
||||
return text, doc
|
||||
|
||||
def _scan_regex(self, text: str):
|
||||
"""Сканировать текст regex-паттернами, заполнить словарь замен."""
|
||||
for entity_type, pattern in ENTITY_PATTERNS.items():
|
||||
for match in re.finditer(pattern, text, re.IGNORECASE | re.MULTILINE):
|
||||
original = match.group(0).strip()
|
||||
if original and original not in self._mapping:
|
||||
generator = ENTITY_GENERATORS.get(entity_type, lambda x: "XXX")
|
||||
self._mapping[original] = generator(original)
|
||||
|
||||
# Компании (кроме НУБЕС — это Исполнитель)
|
||||
for match in COMPANY_PATTERN.finditer(text):
|
||||
original = match.group(0).strip()
|
||||
if original and original not in self._mapping:
|
||||
if re.search(r'НУБЕС|NUBES', original, re.IGNORECASE):
|
||||
continue # Исполнитель — не заменяем
|
||||
self._mapping[original] = generate_company(original)
|
||||
|
||||
# ФИО (Фамилия И.О.)
|
||||
for match in PERSON_PATTERN.finditer(text):
|
||||
original = match.group(0).strip()
|
||||
if original and original not in self._mapping:
|
||||
self._mapping[original] = generate_person(original)
|
||||
|
||||
def _scan_llm_ner(self, all_texts: Dict[str, str]):
|
||||
"""LLM NER для обнаружения имён и адресов во всех файлах."""
|
||||
combined = "\n\n---FILE---\n\n".join(
|
||||
f"FILE: {fname}\n{t[:3000]}" for fname, t in all_texts.items()
|
||||
)
|
||||
prompt = (
|
||||
"Ты — система обнаружения персональных данных в документах. "
|
||||
"Найди ВСЕ следующие сущности в тексте ниже:\n\n"
|
||||
"1. ФИО (полные и сокращённые — 'Иванов И.И.', 'Петров А.С.')\n"
|
||||
"2. Названия компаний-контрагентов (не 'НУБЕС')\n"
|
||||
"3. Почтовые адреса\n"
|
||||
"4. Паспортные данные\n\n"
|
||||
"Формат ответа — JSON-массив:\n"
|
||||
'[{"type": "person"|"company"|"address"|"passport", "value": "найденный текст"}]\n\n'
|
||||
f"Текст:\n{combined[:8000]}"
|
||||
)
|
||||
try:
|
||||
raw = self._llm_client.complete(prompt)
|
||||
import json
|
||||
# Игнорируем markdown-обёртку
|
||||
raw = raw.strip()
|
||||
if raw.startswith("```"):
|
||||
raw = raw.split("\n", 1)[1]
|
||||
if raw.endswith("```"):
|
||||
raw = raw[:-3]
|
||||
entities = json.loads(raw)
|
||||
for ent in entities:
|
||||
val = ent.get("value", "").strip()
|
||||
if val and val not in self._mapping:
|
||||
if ent.get("type") == "person":
|
||||
self._mapping[val] = generate_person(val)
|
||||
elif ent.get("type") == "company":
|
||||
self._mapping[val] = generate_company(val)
|
||||
elif ent.get("type") == "address":
|
||||
self._mapping[val] = generate_address(val)
|
||||
elif ent.get("type") == "passport":
|
||||
self._mapping[val] = generate_passport(val)
|
||||
except Exception as e:
|
||||
log.warning("LLM NER failed: %s", e)
|
||||
|
||||
# --- Проход 2: замена ---
|
||||
|
||||
def _replace_in_docx(self, doc) -> bytes:
|
||||
"""Заменить сущности в docx. Склеиваем runs → заменяем → пишем в первый run, очищаем остальные."""
|
||||
for para in doc.paragraphs:
|
||||
if not para.runs:
|
||||
continue
|
||||
full_text = "".join(run.text for run in para.runs)
|
||||
replaced = self._apply_replacements(full_text)
|
||||
if replaced != full_text:
|
||||
para.runs[0].text = replaced
|
||||
for run in para.runs[1:]:
|
||||
run.text = ""
|
||||
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
for para in cell.paragraphs:
|
||||
if not para.runs:
|
||||
continue
|
||||
full_text = "".join(run.text for run in para.runs)
|
||||
replaced = self._apply_replacements(full_text)
|
||||
if replaced != full_text:
|
||||
para.runs[0].text = replaced
|
||||
for run in para.runs[1:]:
|
||||
run.text = ""
|
||||
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
def _replace_in_text(self, text: str, fname: str) -> bytes:
|
||||
"""Заменить сущности в plain text (для PDF и прочих)."""
|
||||
replaced = self._apply_replacements(text)
|
||||
# Для PDF пока отдаём текст (MVP — без сохранения форматирования PDF)
|
||||
return replaced.encode('utf-8')
|
||||
|
||||
def _apply_replacements(self, text: str) -> str:
|
||||
"""Применить все замены из словаря mapping к строке. Сначала длинные, потом короткие."""
|
||||
result = text
|
||||
for original in self._sorted_keys:
|
||||
replacement = self._mapping[original]
|
||||
# Границы слова — если сущность начинается и заканчивается на \w
|
||||
if original and original[0].isalnum() and original[-1].isalnum():
|
||||
pattern = r'(?<!\w)' + re.escape(original) + r'(?!\w)'
|
||||
else:
|
||||
pattern = re.escape(original)
|
||||
result = re.sub(pattern, replacement, result)
|
||||
return result
|
||||
|
||||
# --- Сборка выдачи ---
|
||||
|
||||
def _build_zip(self, files: List[Tuple[str, bytes]], mapping_csv: str = "") -> bytes:
|
||||
"""Собрать ZIP с обфусцированными файлами + mapping.csv."""
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for fname, content in files:
|
||||
info = zipfile.ZipInfo(fname)
|
||||
info.flag_bits |= 0x800 # UTF-8 filename
|
||||
zf.writestr(info, content)
|
||||
if mapping_csv:
|
||||
zf.writestr("mapping.csv", '\ufeff'.encode('utf-8') + mapping_csv.encode('utf-8'))
|
||||
return buf.getvalue()
|
||||
|
||||
def _build_mapping_csv(self) -> str:
|
||||
"""Собрать mapping.csv."""
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(["тип_данных", "оригинал", "замена"])
|
||||
for original, replacement in sorted(self._mapping.items()):
|
||||
etype = "text"
|
||||
# inn_fl ДО inn_ul (12 цифр vs 10)
|
||||
for t in ["phone", "email", "inn_fl", "inn_ul", "ogrn", "kpp", "bik", "rs", "ks", "passport"]:
|
||||
pat = ENTITY_PATTERNS.get(t, "")
|
||||
if pat and re.match(pat, original, re.IGNORECASE):
|
||||
etype = t
|
||||
break
|
||||
if COMPANY_PATTERN.match(original):
|
||||
etype = "company"
|
||||
writer.writerow([etype, original, replacement])
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# Маппинг entity_type → генератор
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
ENTITY_GENERATORS: Dict[str, Callable] = {
|
||||
"phone": generate_phone,
|
||||
"email": generate_email,
|
||||
"inn_ul": generate_inn10,
|
||||
"inn_fl": generate_inn12,
|
||||
"ogrn": generate_ogrn,
|
||||
"kpp": generate_kpp,
|
||||
"bik": generate_bik,
|
||||
"rs": generate_rs,
|
||||
"ks": generate_ks,
|
||||
"passport": generate_passport,
|
||||
}
|
||||
|
||||
|
||||
def obfuscate_files(files: List[Tuple[str, bytes, str]], llm_client=None) -> Tuple[bytes, str]:
|
||||
"""Удобная функция: обфусцировать список файлов → (zip_bytes, csv_string)."""
|
||||
obf = TwoPassObfuscator(llm_client=llm_client)
|
||||
return obf.obfuscate(files)
|
||||
@@ -1,160 +0,0 @@
|
||||
"""
|
||||
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}
|
||||
@@ -1,35 +0,0 @@
|
||||
"""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
|
||||
@@ -1,74 +0,0 @@
|
||||
"""LLM Client — протокол + httpx-реализация + fake для тестов.
|
||||
|
||||
План decoupling Ф2:
|
||||
- LLMClient.complete(prompt) -> str — единственный метод
|
||||
- Парсинг JSON остаётся у вызывающего (call_llm / _call_llm_classify)
|
||||
- HttpxLLMClient — продакшен
|
||||
- FakeLLMClient — тесты (возвращает сохранённые ответы)
|
||||
"""
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class LLMClient(Protocol):
|
||||
"""Протокол LLM-клиента. Один метод — сырой вызов."""
|
||||
def complete(self, prompt: str) -> str:
|
||||
"""Отправить prompt в LLM, вернуть raw_text."""
|
||||
...
|
||||
|
||||
|
||||
class HttpxLLMClient:
|
||||
"""Продакшен-клиент через httpx → api.aillm.ru."""
|
||||
|
||||
def __init__(self, url: str, key: str, model: str = "gpt-oss-120b",
|
||||
max_tokens: int = 8000, temperature: float = 0.1, timeout: int = 120):
|
||||
self.url = url
|
||||
self.key = key
|
||||
self.model = model
|
||||
self.max_tokens = max_tokens
|
||||
self.temperature = temperature
|
||||
self.timeout = timeout
|
||||
|
||||
def complete(self, prompt: str) -> str:
|
||||
import httpx
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": self.max_tokens,
|
||||
"temperature": self.temperature,
|
||||
}
|
||||
with httpx.Client(http2=True, timeout=self.timeout, verify=True) as client:
|
||||
resp = client.post(
|
||||
self.url,
|
||||
json=payload,
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data["choices"][0]["message"]["content"]
|
||||
|
||||
|
||||
class FakeLLMClient:
|
||||
"""Тестовый клиент — возвращает сохранённые ответы из словаря."""
|
||||
|
||||
def __init__(self, responses: dict = None):
|
||||
self.responses = responses or {}
|
||||
self.calls: list[str] = [] # история вызовов для проверок
|
||||
|
||||
def add(self, prompt_key: str, response: str):
|
||||
"""Зарегистрировать ответ для конкретного prompt_key."""
|
||||
self.responses[prompt_key] = response
|
||||
|
||||
def complete(self, prompt: str) -> str:
|
||||
self.calls.append(prompt)
|
||||
# Ищем точное совпадение
|
||||
if prompt in self.responses:
|
||||
return self.responses[prompt]
|
||||
# Ищем по частичному ключу
|
||||
for key, resp in self.responses.items():
|
||||
if key in prompt:
|
||||
return resp
|
||||
# Fallback
|
||||
return self.responses.get("default", '{"error": "no response registered"}')
|
||||
@@ -1,88 +0,0 @@
|
||||
"""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
|
||||
@@ -1,87 +0,0 @@
|
||||
"""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]}
|
||||
@@ -1,155 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,625 +0,0 @@
|
||||
// ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔
|
||||
// Contracts App v2.0 — всё на Flask, ВМ больше нет
|
||||
var VM_API = '';
|
||||
var UPLOAD_URL = '/upload';
|
||||
var CONVERT_URL = '/convert-doc';
|
||||
var UNZIP_URL = '/unzip-upload';
|
||||
// SITE_URL удалён (Фаза 4) — не использовался
|
||||
|
||||
var fileInput = document.getElementById('fileInput');
|
||||
var fileTable = document.getElementById('fileTable');
|
||||
// state.files — теперь в state.js (Фаза 0: state + render)
|
||||
// state.contractId — теперь в state.js (Фаза 0: state + render)
|
||||
// state.batchId — теперь в state.js (Фаза 0: state + render)
|
||||
// (batchId = crypto.randomUUID() — уникальный ID сессии для классификации)
|
||||
|
||||
// Автоочистка старых записей при загрузке страницы
|
||||
(async function cleanup() {
|
||||
try {
|
||||
await fetch(VM_API + '/api/cleanup', { method: 'POST' });
|
||||
} catch(e) { /* ignore */ }
|
||||
})();
|
||||
|
||||
/**
|
||||
* render(state) — Главная функция рендеринга (Фаза 0, decoupling-final-plan.md).
|
||||
*
|
||||
* ПАТТЕРН: action → мутация state → render(state)
|
||||
*
|
||||
* Фаза 0: вызывает существующий renderTable().
|
||||
* renderTable читает state.files напрямую (state — глобальный объект).
|
||||
* Фаза 1+: будет вызывать renderFiles(state), renderGroups(state), renderStepper(state) и т.д.
|
||||
*
|
||||
* ПРАВИЛО: любое изменение state ДОЛЖНО завершаться вызовом render(state).
|
||||
* Никакой прямой манипуляции DOM в обход render().
|
||||
* console.log(state) показывает ВСЁ состояние в любой момент.
|
||||
*/
|
||||
function render(state) {
|
||||
renderFiles(state);
|
||||
renderStepper(state);
|
||||
}
|
||||
|
||||
// syncDB, statusToHTML, renderFiles, toggleClassifyDetail, uploadFile, refreshSupps
|
||||
// → вынесены в files.js (Фаза 1)
|
||||
|
||||
// ── Pipeline stepper (Фаза 4: state-driven) ──────────────────
|
||||
|
||||
/**
|
||||
* renderStepper(state) — Рендер степпера из state.ui.steps (Фаза 4).
|
||||
*
|
||||
* Читает state.ui.steps.{upload,classify,groups,compare}, обновляет DOM.
|
||||
* Значения: '○' (не начат) | '⏳' (в процессе) | '✓' (завершён).
|
||||
*/
|
||||
function renderStepper(state) {
|
||||
var steps = state.ui.steps;
|
||||
var map = { upload: 'stepUpload', classify: 'stepClassify', groups: 'stepGroups', compare: 'stepCompare' };
|
||||
var colors = { '○': 'var(--muted)', '⏳': 'var(--brand-primary)', '✓': 'var(--green)' };
|
||||
Object.keys(map).forEach(function(key) {
|
||||
var el = document.getElementById(map[key]);
|
||||
if (el) {
|
||||
el.textContent = steps[key] + ' ' + {upload:'Загрузка',classify:'Классификация',groups:'Группировка',compare:'Сравнение'}[key];
|
||||
el.style.color = colors[steps[key]] || 'var(--muted)';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* stepDone(id) — Отметить шаг как завершённый (Фаза 4).
|
||||
* Мутирует state.ui.steps, вызывает renderStepper.
|
||||
*/
|
||||
function stepDone(id) {
|
||||
var key = { stepUpload: 'upload', stepClassify: 'classify', stepGroups: 'groups', stepCompare: 'compare' }[id];
|
||||
if (key) { state.ui.steps[key] = '✓'; renderStepper(state); }
|
||||
}
|
||||
|
||||
/**
|
||||
* stepActive(id) — Отметить шаг как активный (Фаза 4).
|
||||
* Мутирует state.ui.steps, вызывает renderStepper.
|
||||
*/
|
||||
function stepActive(id) {
|
||||
var key = { stepUpload: 'upload', stepClassify: 'classify', stepGroups: 'groups', stepCompare: 'compare' }[id];
|
||||
if (key) { state.ui.steps[key] = '⏳'; renderStepper(state); }
|
||||
}
|
||||
|
||||
/**
|
||||
* resetStepper(fromId) — Сбросить шаги начиная с fromId (Фаза 4).
|
||||
* Все шаги после (и включая) fromId получают '○'.
|
||||
* Мутирует state.ui.steps, вызывает renderStepper.
|
||||
*/
|
||||
function resetStepper(fromId) {
|
||||
var order = ['stepUpload','stepClassify','stepGroups','stepCompare'];
|
||||
var keys = ['upload','classify','groups','compare'];
|
||||
var reset = false;
|
||||
order.forEach(function(id, i) {
|
||||
if (id === fromId) reset = true;
|
||||
if (reset) { state.ui.steps[keys[i]] = '○'; }
|
||||
});
|
||||
renderStepper(state);
|
||||
}
|
||||
// Утилиты (formatSize, formatDate, moveUp, moveDown, escHtml, fmtDate) —
|
||||
// вынесены в app_utils.js для облегчения анализа и отладки.
|
||||
// statusToHTML, renderFiles → files.js (Фаза 1)
|
||||
|
||||
// ── Раскрыть/скрыть результат классификации под строкой файла ──
|
||||
// toggleClassifyDetail → files.js (Фаза 1)
|
||||
|
||||
// moveUp/moveDown/removeFile/openAbout/closeAbout — вынесены в app_utils.js
|
||||
|
||||
// ── Автозагрузка при выборе файлов ──────────────────────────
|
||||
// Фаза 1 (decoupling-final-plan.md): fileInput handler разбит на чистые функции
|
||||
// onFilesSelected → reconcileSelection → addZipFile/addRegularFile → finalizeUpload
|
||||
// Вся логика — в files.js. Здесь только тонкая обёртка.
|
||||
fileInput.addEventListener('change', async function() {
|
||||
var newFiles = Array.from(fileInput.files);
|
||||
onFilesSelected(newFiles);
|
||||
});
|
||||
|
||||
// ── Классификация ──────────────────────────────────────────
|
||||
function showClassifyBtn() {
|
||||
var btn = document.getElementById('classifyBtn');
|
||||
if (!btn) {
|
||||
btn = document.createElement('button');
|
||||
btn.id = 'classifyBtn';
|
||||
btn.className = 'btn btn-primary';
|
||||
btn.style.cssText = 'width:100%;justify-content:center;margin-top:8px;background:var(--brand-primary);border-color:var(--brand-primary);';
|
||||
btn.innerHTML = '<i data-lucide="tags" style="width:16px;height:16px;"></i> Классифицировать';
|
||||
btn.addEventListener('click', runClassify);
|
||||
document.getElementById('llmBtn').parentNode.insertBefore(btn, document.getElementById('llmBtn'));
|
||||
}
|
||||
btn.style.display = 'flex';
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
async function runClassify() {
|
||||
var btn = document.getElementById('classifyBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> Классификация... 0с';
|
||||
stepActive('stepClassify');
|
||||
|
||||
var start = Date.now();
|
||||
var timer = setInterval(function() {
|
||||
btn.innerHTML = '<span class="spinner"></span> Классификация... ' + Math.round((Date.now() - start) / 1000) + 'с';
|
||||
}, 1000);
|
||||
|
||||
var totalFiles = 0;
|
||||
var classifyDone = false;
|
||||
|
||||
// Poll progress каждые 2с — работает и для sync, и для async classify
|
||||
var pollTimer = setInterval(async function() {
|
||||
try {
|
||||
var r = await fetch(VM_API + '/api/batch-progress?batch=' + state.batchId);
|
||||
var d = await r.json();
|
||||
if (d.ok && d.counts) {
|
||||
var done = (d.counts.classified || 0) + (d.counts.failed || 0);
|
||||
var total = d.total || 0;
|
||||
if (total > 0) totalFiles = total;
|
||||
btn.innerHTML = '<span class="spinner"></span> Классификация... ' + done + '/' + total + ' (' + Math.round((Date.now() - start) / 1000) + 'с)';
|
||||
if (done >= total && total > 0) {
|
||||
clearInterval(pollTimer);
|
||||
clearInterval(timer);
|
||||
classifyDone = true;
|
||||
btn.innerHTML = '✓ ' + done + '/' + total + ' классифицировано (' + Math.round((Date.now() - start) / 1000) + 'с)';
|
||||
stepDone('stepClassify');
|
||||
stepActive('stepGroups');
|
||||
loadGroupsAction();
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
}, 2000);
|
||||
|
||||
try {
|
||||
var resp = await fetch(VM_API + '/api/classify-batch', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({batch_id: state.batchId})
|
||||
});
|
||||
var data = await resp.json();
|
||||
|
||||
// sync classify: ответ уже содержит done
|
||||
if (data.ok && data.done !== undefined) {
|
||||
clearInterval(timer);
|
||||
clearInterval(pollTimer);
|
||||
classifyDone = true;
|
||||
var summary = data.summary || (data.done + '/' + data.total + ' классифицировано');
|
||||
if (data.garbage) summary += ' | 🗑️ ' + data.garbage + ' мусор';
|
||||
if (data.types) summary += ' | ' + Object.entries(data.types).map(function(e){return e[1]+'×'+e[0];}).join(', ');
|
||||
btn.innerHTML = '✓ ' + summary + ' (' + Math.round((Date.now() - start) / 1000) + 'с)';
|
||||
stepDone('stepClassify');
|
||||
stepActive('stepGroups');
|
||||
loadGroupsAction();
|
||||
} else if (data.ok) {
|
||||
// async classify: 202 — ждём poll до done>=total
|
||||
totalFiles = data.total || 0;
|
||||
if (totalFiles === 0) {
|
||||
clearInterval(timer);
|
||||
clearInterval(pollTimer);
|
||||
btn.innerHTML = '✗ нет файлов для классификации';
|
||||
btn.disabled = false;
|
||||
}
|
||||
} else {
|
||||
clearInterval(timer);
|
||||
clearInterval(pollTimer);
|
||||
btn.innerHTML = '✗ ' + (data.error || 'ошибка');
|
||||
btn.disabled = false;
|
||||
}
|
||||
} catch(e) {
|
||||
if (!classifyDone) {
|
||||
clearInterval(timer);
|
||||
clearInterval(pollTimer);
|
||||
btn.innerHTML = '✗ ' + escHtml(e.message);
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// loadGroups, buildGroupCard, markGroupDone → groups.js (Фаза 2)
|
||||
|
||||
// Текущий активный EventSource (только один одновременно)
|
||||
// state._activeCompare — теперь в state.js (Фаза 0: state + render)
|
||||
|
||||
window.runCompareForGroup = async function(gi, btn) {
|
||||
var group = state.groups[gi];
|
||||
if (!group || group.contract_number === '__unresolved__') return;
|
||||
|
||||
// Фаза 2: отметить группу как «в процессе»
|
||||
// renderGroups НЕ вызываем — инкрементальные DOM-мутации внутри SSE
|
||||
group.compare = { status: 'running' };
|
||||
|
||||
// Остановить предыдущее сравнение
|
||||
if (state._activeCompare.es) { state._activeCompare.es.close(); state._activeCompare.es = null; }
|
||||
if (state._activeCompare.timer) { clearInterval(state._activeCompare.timer); state._activeCompare.timer = null; }
|
||||
|
||||
// Свернуть ТОЛЬКО тело текущей группы
|
||||
var curBody = document.getElementById('cmpBody_' + gi);
|
||||
if (curBody) curBody.style.display = 'none';
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> Применяю...';
|
||||
// Заблокировать ВСЕ кнопки сравнения пока идёт процесс
|
||||
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = true; });
|
||||
|
||||
var ar = await fetch(VM_API + '/api/apply-groups', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({batch_id: state.batchId, groups: [group]})
|
||||
});
|
||||
var ad = await ar.json();
|
||||
if (!ad.ok || !ad.contract_ids || !ad.contract_ids.length) {
|
||||
btn.innerHTML = '✗ ошибка'; btn.disabled = false;
|
||||
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
|
||||
return;
|
||||
}
|
||||
|
||||
var cid = ad.contract_ids[0];
|
||||
|
||||
// Создать контейнер для результатов сравнения
|
||||
var cmpBody = document.getElementById('cmpBody_' + gi);
|
||||
if (!cmpBody) {
|
||||
cmpBody = document.createElement('div');
|
||||
cmpBody.className = 'cmp-body';
|
||||
cmpBody.id = 'cmpBody_' + gi;
|
||||
cmpBody.style.cssText = 'margin-top:8px;';
|
||||
btn.parentNode.insertBefore(cmpBody, btn.nextSibling);
|
||||
}
|
||||
cmpBody.innerHTML = '';
|
||||
cmpBody.style.display = 'block';
|
||||
btn.innerHTML = '<span class="spinner"></span> Сравнение...';
|
||||
stepActive('stepCompare');
|
||||
|
||||
var statusEl = document.createElement('div');
|
||||
statusEl.style.cssText = 'font-size:11px;color:var(--muted);margin-bottom:4px;';
|
||||
statusEl.textContent = '⏳ 0.0с';
|
||||
cmpBody.appendChild(statusEl);
|
||||
|
||||
// Фаза 3: унифицированный SSE через startCompareSSE (compare.js)
|
||||
var result = startCompareSSE(
|
||||
VM_API + '/process-v2?contract_id=' + cid,
|
||||
cmpBody,
|
||||
statusEl,
|
||||
{
|
||||
onDone: function(d, sections) {
|
||||
state._activeCompare.timer = null;
|
||||
state._activeCompare.es = null;
|
||||
group.compare = {
|
||||
status: 'done',
|
||||
totalTime: d.total_time_s + 'с',
|
||||
bodyHTML: cmpBody.innerHTML,
|
||||
sections: sections
|
||||
};
|
||||
renderGroups(state);
|
||||
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
|
||||
stepDone('stepCompare');
|
||||
},
|
||||
onError: function(d) {
|
||||
state._activeCompare.timer = null;
|
||||
state._activeCompare.es = null;
|
||||
group.compare = null;
|
||||
cmpBody.innerHTML += '<div style="color:var(--destructive);">✗ ' + escHtml(d.message) + '</div>';
|
||||
btn.innerHTML = '✗'; btn.disabled = false;
|
||||
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
|
||||
},
|
||||
onConnectionError: function() {
|
||||
state._activeCompare.timer = null;
|
||||
state._activeCompare.es = null;
|
||||
group.compare = null;
|
||||
btn.innerHTML = '✗ соединение'; btn.disabled = false;
|
||||
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
|
||||
}
|
||||
}
|
||||
);
|
||||
state._activeCompare.es = result.es;
|
||||
state._activeCompare.timer = result.timer;
|
||||
};
|
||||
|
||||
// uploadFile → files.js (Фаза 1)
|
||||
|
||||
// ── LLM: Общее сравнение (Event Sourcing) ────────────────────────
|
||||
document.getElementById('llmBtn').addEventListener('click', function() {
|
||||
if (!state.contractId) return;
|
||||
var btn = this;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> Сравнение...';
|
||||
|
||||
var compareCard = document.getElementById('compareCard');
|
||||
var compareBody = document.getElementById('compareBody');
|
||||
var compareStatus = document.getElementById('compareStatus');
|
||||
compareCard.style.display = 'block';
|
||||
compareBody.innerHTML = '';
|
||||
compareStatus.textContent = '⏳ 0.0с';
|
||||
|
||||
var order = state.files.map(function(f) { return f.supp_id; }).filter(Boolean).join(',');
|
||||
var url = '/process-v2?contract_id=' + state.contractId +
|
||||
(order ? '&order=' + encodeURIComponent(order) : '');
|
||||
|
||||
// Фаза 3: унифицированный SSE через startCompareSSE (compare.js)
|
||||
startCompareSSE(url, compareBody, compareStatus, {
|
||||
onDone: function(d) {
|
||||
btn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово';
|
||||
stepDone('stepCompare');
|
||||
if (d.total_unresolved > 0) {
|
||||
compareBody.innerHTML += '<div style="margin-top:8px;color:#eab308;">⚠ ' + d.total_unresolved + ' неразрешённых строк — <a href=\"/resolve.cfm?contract_id=' + state.contractId + '\">разобрать</a></div>';
|
||||
}
|
||||
document.getElementById('chatCard').style.display = 'block';
|
||||
lucide.createIcons();
|
||||
},
|
||||
onError: function(d) {
|
||||
compareBody.innerHTML += '<div style="color:var(--destructive);margin-top:8px;">✗ ' + escHtml(d.message) + '</div>';
|
||||
btn.innerHTML = '<i data-lucide="scale" style="width:16px;height:16px;"></i> Общее сравнение';
|
||||
btn.disabled = false;
|
||||
},
|
||||
onConnectionError: function() {
|
||||
if (compareBody.innerHTML === '') {
|
||||
compareBody.innerHTML = '<div style="color:var(--destructive);">✗ Ошибка соединения</div>';
|
||||
}
|
||||
btn.innerHTML = '<i data-lucide="scale" style="width:16px;height:16px;"></i> Общее сравнение';
|
||||
btn.disabled = false;
|
||||
lucide.createIcons();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// refreshSupps → files.js (Фаза 1)
|
||||
|
||||
// ── Чат ──────────────────────────────────────────────────────
|
||||
document.getElementById('chatSend').addEventListener('click', function() {
|
||||
var input = document.getElementById('chatInput');
|
||||
var q = input.value.trim();
|
||||
if (!q || !state.contractId) return;
|
||||
var msgs = document.getElementById('chatMessages');
|
||||
msgs.innerHTML += '<div style="color:var(--brand-primary);margin-top:4px;"><strong>Вы:</strong> ' + q + '</div>';
|
||||
input.value = '';
|
||||
input.disabled = true;
|
||||
document.getElementById('chatSend').disabled = true;
|
||||
msgs.innerHTML += '<div style="color:var(--muted);margin-top:2px;">⏳ Думаю...</div>';
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('question', q);
|
||||
fetch('/chat.cfm?contract_id=' + state.contractId, { method: 'POST', body: fd })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
msgs.lastChild.remove();
|
||||
msgs.innerHTML += '<div style="margin-top:2px;"><strong>Ответ:</strong> ' + (d.OK ? d.ANSWER : (d.ERROR || '—')) + '</div>';
|
||||
input.disabled = false;
|
||||
document.getElementById('chatSend').disabled = false;
|
||||
input.focus();
|
||||
}).catch(function() {
|
||||
msgs.lastChild.remove();
|
||||
msgs.innerHTML += '<div style="color:var(--destructive);margin-top:2px;">Ошибка сети</div>';
|
||||
input.disabled = false;
|
||||
document.getElementById('chatSend').disabled = false;
|
||||
});
|
||||
});
|
||||
document.getElementById('chatInput').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') document.getElementById('chatSend').click();
|
||||
});
|
||||
|
||||
function closeModal(e) {
|
||||
if (e && e.target !== document.getElementById('modalOverlay')) return;
|
||||
document.querySelector('.modal').classList.remove('wide');
|
||||
document.getElementById('modalOverlay').classList.remove('open');
|
||||
}
|
||||
|
||||
async function showText(i) {
|
||||
var f = state.files[i];
|
||||
var docId = f.doc_id;
|
||||
document.getElementById('modalTitle').textContent = f.name;
|
||||
document.getElementById('modalBody').innerHTML = '<span style="color:var(--muted);">Загрузка...</span>';
|
||||
var modal = document.querySelector('.modal');
|
||||
modal.classList.add('wide');
|
||||
document.getElementById('modalOverlay').classList.add('open');
|
||||
|
||||
var leftHtml = '<span style="color:var(--muted);">Загрузка...</span>';
|
||||
var rightHtml = '';
|
||||
|
||||
if (docId) {
|
||||
try {
|
||||
var qResp = await fetch(VM_API + '/api/documents/' + docId);
|
||||
var qData = await qResp.json();
|
||||
var elements = [];
|
||||
if (qData.ok && qData.elements_json) {
|
||||
var ej = qData.elements_json;
|
||||
if (typeof ej === 'object' && ej.Value) ej = ej.Value;
|
||||
if (typeof ej === 'string') elements = JSON.parse(ej);
|
||||
else elements = ej;
|
||||
}
|
||||
|
||||
// Общий textify для обеих панелей
|
||||
var textLines = [];
|
||||
elements.forEach(function(el) {
|
||||
var etype = el.type || el.TYPE || '?';
|
||||
if (etype === 'paragraph') {
|
||||
textLines.push(el.text || el.TEXT || '');
|
||||
} else if (etype === 'table') {
|
||||
var rows = el.rows || el.ROWS || [];
|
||||
if (rows.length > 0) {
|
||||
var ncols = (rows[0]||[]).length;
|
||||
textLines.push('--- Таблица ' + rows.length + '×' + ncols + ' ---');
|
||||
rows.forEach(function(row) {
|
||||
var cells = (row||[]).map(function(c){ return String(c||'').replace(/\n/g,' ').replace(/\|/g,'\\|'); });
|
||||
textLines.push('| ' + cells.join(' | ') + ' |');
|
||||
});
|
||||
textLines.push('');
|
||||
}
|
||||
}
|
||||
});
|
||||
var textify = textLines.join('\n').replace(/</g,'<').replace(/>/g,'>');
|
||||
|
||||
// Левая: сырой текст (textify)
|
||||
leftHtml = '<pre>' + textify + '</pre>';
|
||||
|
||||
// Правая: статистика + textify
|
||||
rightHtml += '<div class="kv"><span class="k">Размер</span><span class="v">' + formatSize(f.size) + '</span></div>';
|
||||
rightHtml += '<div class="kv"><span class="k">Изменён</span><span class="v">' + formatDate(f.lastModified) + '</span></div>';
|
||||
if (f.parseInfo) {
|
||||
var d = f.parseInfo;
|
||||
rightHtml += '<div class="kv"><span class="k">Элементов</span><span class="v">' + (d.element_count||0) + '</span></div>';
|
||||
if (d.error) rightHtml += '<div style="margin-top:8px;color:var(--destructive);">' + d.error + '</div>';
|
||||
}
|
||||
rightHtml += '<div style="margin-top:10px;font-weight:600;font-size:12px;">Все элементы:</div>';
|
||||
rightHtml += '<pre>' + textify + '</pre>';
|
||||
if (f.supp_id) {
|
||||
rightHtml += '<div class="kv"><span class="k">Тип ДС</span><span class="v">' + (f.supp_type || '—') + '</span></div>';
|
||||
}
|
||||
|
||||
// ── Результаты классификации ──────────────────────────
|
||||
if (qData.classify_status === 'classified' || qData.classify_raw) {
|
||||
rightHtml += '<div style="margin-top:12px;font-weight:600;font-size:12px;border-top:1px solid var(--brand-gray);padding-top:8px;">🏷️ Классификация LLM</div>';
|
||||
if (qData.doc_type) rightHtml += '<div class="kv"><span class="k">Тип</span><span class="v">' + escHtml(qData.doc_type) + '</span></div>';
|
||||
if (qData.own_number) rightHtml += '<div class="kv"><span class="k">Номер</span><span class="v">' + escHtml(qData.own_number) + '</span></div>';
|
||||
if (qData.parent_number) rightHtml += '<div class="kv"><span class="k">Родительский</span><span class="v">' + escHtml(qData.parent_number) + '</span></div>';
|
||||
if (qData.doc_date) rightHtml += '<div class="kv"><span class="k">Дата</span><span class="v">' + escHtml(qData.doc_date) + '</span></div>';
|
||||
if (qData.counterparty) rightHtml += '<div class="kv"><span class="k">Контрагент</span><span class="v">' + escHtml(qData.counterparty) + '</span></div>';
|
||||
if (qData.classify_raw) {
|
||||
var rawId = 'raw_' + docId.replace(/-/g,'');
|
||||
rightHtml += '<details style="margin-top:8px;"><summary style="cursor:pointer;font-size:11px;color:var(--brand-primary);">Сырой ответ LLM</summary>';
|
||||
rightHtml += '<pre style="font-size:10px;max-height:200px;overflow:auto;background:var(--brand-grey-light);padding:6px;border-radius:4px;margin-top:4px;">' + escHtml(qData.classify_raw) + '</pre>';
|
||||
rightHtml += '</details>';
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
leftHtml = '<span style="color:var(--destructive);">Ошибка</span>';
|
||||
}
|
||||
} else {
|
||||
leftHtml = '<span style="color:var(--muted);">(ещё не загружен)</span>';
|
||||
}
|
||||
|
||||
var html = '<div class="text-panes">' +
|
||||
'<div class="text-pane"><div class="pane-title">Сырой текст</div>' + leftHtml + '</div>' +
|
||||
'<div class="text-pane"><div class="pane-title">Распарсено</div>' + rightHtml + '</div>' +
|
||||
'</div>';
|
||||
document.getElementById('modalBody').innerHTML = html;
|
||||
}
|
||||
|
||||
window.closeModal = closeModal;
|
||||
window.showText = showText;
|
||||
|
||||
// ── Промпты (версионные) ─────────────────────────────────────
|
||||
var promptRole = 'diff'; // текущая выбранная роль
|
||||
var promptEditor = document.getElementById('promptEditor');
|
||||
var promptStatus = document.getElementById('promptStatus');
|
||||
var promptHistory = document.getElementById('promptHistory');
|
||||
var promptModalOverlay = document.getElementById('promptModalOverlay');
|
||||
var promptModalTitle = document.getElementById('promptModalTitle');
|
||||
var promptName = document.getElementById('promptName');
|
||||
var promptNotes = document.getElementById('promptNotes');
|
||||
// Три роли промптов:
|
||||
// extract — извлечение строк из первого документа (базовый договор)
|
||||
// diff — сравнение допсоглашений (ADD/UPDATE/DELETE)
|
||||
// classify — классификация документа (тип, номер, контрагент, дата)
|
||||
var activePromptId = {extract: '', diff: '', classify: ''};
|
||||
|
||||
function openPromptModal(role) {
|
||||
promptRole = role;
|
||||
var labels = {extract: 'Извлечение', diff: 'Сравнение', classify: 'Классификация'};
|
||||
promptModalTitle.textContent = 'Промпт: ' + (labels[role] || role);
|
||||
document.getElementById('promptTabExtract').style.background = role === 'extract' ? 'var(--brand-primary)' : '';
|
||||
document.getElementById('promptTabExtract').style.color = role === 'extract' ? '#fff' : '';
|
||||
document.getElementById('promptTabDiff').style.background = role === 'diff' ? 'var(--brand-primary)' : '';
|
||||
document.getElementById('promptTabDiff').style.color = role === 'diff' ? '#fff' : '';
|
||||
document.getElementById('promptTabClassify').style.background = role === 'classify' ? 'var(--brand-primary)' : '';
|
||||
document.getElementById('promptTabClassify').style.color = role === 'classify' ? '#fff' : '';
|
||||
promptStatus.textContent = '';
|
||||
promptName.value = '';
|
||||
promptNotes.value = '';
|
||||
loadPrompt(role);
|
||||
promptModalOverlay.classList.add('open');
|
||||
}
|
||||
|
||||
function closePromptModal(e) {
|
||||
if (e && e.target !== promptModalOverlay) return;
|
||||
promptModalOverlay.classList.remove('open');
|
||||
}
|
||||
|
||||
function loadPrompt(role) {
|
||||
fetch(VM_API + '/api/prompts?role=' + role)
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.ok) {
|
||||
promptEditor.value = d.body || '';
|
||||
activePromptId[role] = d.id || '';
|
||||
loadHistory(role);
|
||||
}
|
||||
})
|
||||
.catch(function(e) { console.error('loadPrompt:', e); });
|
||||
}
|
||||
|
||||
function loadHistory(role) {
|
||||
fetch(VM_API + '/api/prompts/list?role=' + role)
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (!d.ok || !d.versions || !d.versions.length) {
|
||||
promptHistory.innerHTML = '<div style="color:var(--muted);">нет версий</div>';
|
||||
return;
|
||||
}
|
||||
promptHistory.innerHTML = d.versions.map(function(v) {
|
||||
var isActive = v.is_active ? '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--green);margin-right:4px;" title="активная"></span> ' : '';
|
||||
var style = v.is_active ? 'font-weight:600;' : '';
|
||||
return '<div style="display:flex;align-items:center;padding:4px 0;border-bottom:1px solid var(--brand-gray);font-size:11px;' + style + '">'
|
||||
+ '<span style="flex:1;">' + isActive + escHtml(v.name) + ' <span style="color:var(--muted);">' + fmtDate(v.created_at) + '</span>'
|
||||
+ (v.notes ? ' <span style="color:var(--muted);font-style:italic;">— ' + escHtml(v.notes) + '</span>' : '')
|
||||
+ '</span>'
|
||||
+ (!v.is_active ? '<button class="btn" onclick="activatePrompt(\'' + v.id + '\')" style="font-size:11px;height:22px;padding:0 8px;margin-left:4px;">активировать</button>' : '')
|
||||
+ (!v.is_active ? '<button class="btn" onclick="deletePrompt(\'' + v.id + '\')" style="font-size:11px;height:22px;padding:0 8px;margin-left:2px;color:var(--destructive);">удалить</button>' : '')
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
})
|
||||
.catch(function(e) { console.error('loadHistory:', e); });
|
||||
}
|
||||
|
||||
function activatePrompt(id) {
|
||||
fetch(VM_API + '/api/prompts/activate', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({id: id})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.ok) { loadPrompt(promptRole); promptStatus.textContent = '✓ активирован'; }
|
||||
else { promptStatus.textContent = '✕ ' + (d.error || 'ошибка'); }
|
||||
});
|
||||
}
|
||||
|
||||
function deletePrompt(id) {
|
||||
if (!confirm('Удалить эту версию промпта?')) return;
|
||||
fetch(VM_API + '/api/prompts/delete', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({id: id})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.ok) { loadHistory(promptRole); promptStatus.textContent = '✓ удалено'; }
|
||||
else { promptStatus.textContent = '✕ ' + (d.error || 'ошибка'); }
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('promptSave').addEventListener('click', function() {
|
||||
var body = promptEditor.value.trim();
|
||||
if (!body) { promptStatus.textContent = '✕ тело промпта пустое'; return; }
|
||||
var name = promptName.value.trim() || ('v' + new Date().toISOString().replace(/[:.]/g,'-').substring(0,16));
|
||||
fetch(VM_API + '/api/prompts/save', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
role: promptRole, name: name, body: body,
|
||||
notes: promptNotes.value.trim(), is_active: true
|
||||
})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.ok) {
|
||||
promptStatus.textContent = '✓ сохранено (новая версия)';
|
||||
promptName.value = ''; promptNotes.value = '';
|
||||
loadPrompt(promptRole);
|
||||
} else {
|
||||
promptStatus.textContent = '✕ ' + (d.error || 'ошибка');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('promptTabExtract').addEventListener('click', function() { openPromptModal('extract'); });
|
||||
document.getElementById('promptTabDiff').addEventListener('click', function() { openPromptModal('diff'); });
|
||||
document.getElementById('promptTabClassify').addEventListener('click', function() { openPromptModal('classify'); });
|
||||
document.getElementById('promptBtnExtract').addEventListener('click', function() { openPromptModal('extract'); });
|
||||
document.getElementById('promptBtnDiff').addEventListener('click', function() { openPromptModal('diff'); });
|
||||
document.getElementById('promptBtnClassify').addEventListener('click', function() { openPromptModal('classify'); });
|
||||
lucide.createIcons();
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* app_utils.js — Общие утилиты (форматирование, стрелки, модалки).
|
||||
* Вынесены из app.js для облегчения анализа и отладки.
|
||||
* Загружается ДО app.js.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Форматирует размер файла в человекочитаемый вид.
|
||||
* Используется в renderTable() для колонки "Размер".
|
||||
*/
|
||||
function formatSize(bytes) {
|
||||
if (!bytes || bytes === 0) return '—';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует timestamp в российскую дату + время.
|
||||
* Используется в renderTable() для колонки "Изменён".
|
||||
*/
|
||||
function formatDate(ts) {
|
||||
if (!ts) return '—';
|
||||
var d = new Date(ts);
|
||||
return d.toLocaleDateString('ru-RU') + ' ' + d.toLocaleTimeString('ru-RU', {hour:'2-digit',minute:'2-digit'});
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет файл из очереди по индексу.
|
||||
* Вызывается по кнопке ✕ в таблице файлов.
|
||||
*
|
||||
* Фаза 0 (state + render): мутирует state.files, затем render(state).
|
||||
* Если очередь опустела — сбрасывает state.contractId.
|
||||
*/
|
||||
function removeFile(i) {
|
||||
state.files.splice(i, 1);
|
||||
if (state.files.length === 0) state.contractId = null;
|
||||
render(state);
|
||||
// Синхронизировать БД с таблицей
|
||||
if (typeof syncDB === 'function') syncDB();
|
||||
// Сбросить прогресс при изменении состава файлов
|
||||
if (typeof resetStepper === 'function') resetStepper('stepUpload');
|
||||
// Разблокировать кнопку классификации при изменении состава файлов
|
||||
if (typeof showClassifyBtn === 'function') showClassifyBtn();
|
||||
}
|
||||
window.removeFile = removeFile;
|
||||
|
||||
/**
|
||||
* Переместить файл на одну позицию ВВЕРХ (сохранено для возможного возврата ручной сортировки).
|
||||
* В текущей версии (v1.0.164+) не используется — порядок определяет авто-классификация.
|
||||
*
|
||||
* Фаза 0 (state + render): мутирует state.files, затем render(state).
|
||||
*/
|
||||
function moveUp(i) {
|
||||
if (i <= 0) return;
|
||||
var tmp = state.files[i]; state.files[i] = state.files[i-1]; state.files[i-1] = tmp;
|
||||
render(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Переместить файл на одну позицию ВНИЗ (сохранено для возможного возврата ручной сортировки).
|
||||
* В текущей версии (v1.0.164+) не используется — порядок определяет авто-классификация.
|
||||
*
|
||||
* Фаза 0 (state + render): мутирует state.files, затем render(state).
|
||||
*/
|
||||
function moveDown(i) {
|
||||
if (i >= state.files.length - 1) return;
|
||||
var tmp = state.files[i]; state.files[i] = state.files[i+1]; state.files[i+1] = tmp;
|
||||
render(state);
|
||||
}
|
||||
window.moveUp = moveUp;
|
||||
window.moveDown = moveDown;
|
||||
|
||||
/**
|
||||
* Открыть модальное окно "О сервисе".
|
||||
*/
|
||||
function openAbout() {
|
||||
document.getElementById('aboutModalOverlay').classList.add('open');
|
||||
}
|
||||
|
||||
/**
|
||||
* Закрыть модальное окно "О сервисе" (по клику на оверлей или кнопку).
|
||||
*/
|
||||
function closeAbout(e) {
|
||||
if (e && e.target !== document.getElementById('aboutModalOverlay')) return;
|
||||
document.getElementById('aboutModalOverlay').classList.remove('open');
|
||||
}
|
||||
window.openAbout = openAbout;
|
||||
window.closeAbout = closeAbout;
|
||||
|
||||
/**
|
||||
* Экранировать HTML (для безопасного рендеринга пользовательских данных).
|
||||
* Используется в истории промптов.
|
||||
*/
|
||||
function escHtml(s) {
|
||||
if (s == null) return '';
|
||||
var d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматировать дату для истории промптов (YYYY-MM-DD HH:MM).
|
||||
*/
|
||||
function fmtDate(d) {
|
||||
if (!d) return '';
|
||||
return d.replace('T', ' ').substring(0, 16);
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
/**
|
||||
* compare.js — Модуль сравнения через SSE (Фаза 3, decoupling-final-plan.md).
|
||||
*
|
||||
* УНИФИЦИРУЕТ дублирование (~160 строк) между runCompareForGroup и llmBtn.
|
||||
*
|
||||
* ПАТТЕРН:
|
||||
* applyCompareEvent(sections, event) — чистая мутация: SSE-событие → state
|
||||
* startCompareSSE(url, container, statusEl, callbacks) — жизненный цикл SSE
|
||||
* renderCompareOpsTable(ops) — общий рендер таблицы операций
|
||||
*
|
||||
* ЗАВИСИМОСТИ:
|
||||
* state.js → state (для _activeCompare)
|
||||
*/
|
||||
|
||||
/**
|
||||
* applyCompareEvent(sections, event) — Чистая функция (Фаза 3).
|
||||
*
|
||||
* Применяет ОДНО SSE-событие к состоянию sections.
|
||||
* НЕ трогает DOM — только мутирует объект sections.
|
||||
*
|
||||
* Вход: sections — объект { [supplement_id]: sectionState }
|
||||
* event — { type, supplement_id, filename, ops_count, mode, ... }
|
||||
* Выход: нет (мутирует sections)
|
||||
*
|
||||
* Типы событий:
|
||||
* extract_start → создаёт запись в sections
|
||||
* llm_done → обновляет статус, ops_count, mode, time_s
|
||||
* applied → сохраняет ops и summary
|
||||
* extract_error/apply_error → сохраняет ошибку
|
||||
*/
|
||||
function applyCompareEvent(sections, event) {
|
||||
var d = event;
|
||||
var suppId = d.supplement_id;
|
||||
|
||||
if (d.type === 'extract_start') {
|
||||
// Создать новую секцию: извлечение началось
|
||||
sections[suppId] = {
|
||||
filename: d.filename,
|
||||
status: 'extracting',
|
||||
ops_count: 0,
|
||||
mode: '',
|
||||
time_s: 0,
|
||||
summary: null,
|
||||
ops: null,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
else if (d.type === 'llm_done') {
|
||||
// LLM закончил — сохранить метаданные
|
||||
var sec = sections[suppId];
|
||||
if (sec) {
|
||||
sec.status = 'llm_done';
|
||||
sec.ops_count = d.ops_count || 0;
|
||||
sec.mode = d.mode || '';
|
||||
sec.time_s = d.time_s || 0;
|
||||
}
|
||||
}
|
||||
else if (d.type === 'applied') {
|
||||
// Результаты сравнения применены — сохранить ops и summary
|
||||
var sec = sections[suppId];
|
||||
if (sec) {
|
||||
sec.status = 'applied';
|
||||
sec.summary = d.summary || {};
|
||||
sec.ops = d.ops || [];
|
||||
}
|
||||
}
|
||||
else if (d.type === 'extract_error' || d.type === 'apply_error') {
|
||||
// Ошибка извлечения или применения
|
||||
var sec = sections[suppId];
|
||||
if (sec) {
|
||||
sec.status = 'error';
|
||||
sec.error = d.error || 'неизвестная ошибка';
|
||||
} else {
|
||||
// Ошибка для ещё не созданной секции
|
||||
sections[suppId] = {
|
||||
filename: d.filename || '?',
|
||||
status: 'error',
|
||||
error: d.error || 'неизвестная ошибка',
|
||||
ops_count: 0, mode: '', time_s: 0, summary: null, ops: null
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* renderCompareSectionHeader(sec) — Чистая функция: заголовок секции (Фаза 3).
|
||||
*
|
||||
* Вход: sec — { filename, status, ops_count, mode, time_s, error }
|
||||
* Выход: HTML-строка для div.diff-section-header
|
||||
*/
|
||||
function renderCompareSectionHeader(sec) {
|
||||
if (sec.status === 'extracting') {
|
||||
return '⏳ ' + sec.filename;
|
||||
}
|
||||
if (sec.status === 'llm_done' || sec.status === 'applied') {
|
||||
return '✓ ' + sec.filename + ' — ' + sec.ops_count + ' оп., ' + sec.mode + ' (' + sec.time_s + 'с)';
|
||||
}
|
||||
if (sec.status === 'error') {
|
||||
return '✗ ' + sec.filename + ': ' + sec.error;
|
||||
}
|
||||
return sec.filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* renderCompareOpsTable(ops) — Чистая функция: таблица операций (Фаза 3).
|
||||
*
|
||||
* Используется ОБОИМИ обработчиками (runCompareForGroup и llmBtn).
|
||||
* Раньше этот код был продублирован ~30 строк × 2.
|
||||
*
|
||||
* Вход: ops — [{ action, new_row: { name, price, qty, sum, date_start } }]
|
||||
* Выход: HTML-строка <table>
|
||||
*/
|
||||
function renderCompareOpsTable(ops) {
|
||||
var html = '<div class="table-wrap"><table style="font-size:11px;"><thead><tr><th>Действие</th><th>Услуга</th><th>Цена</th><th>Кол-во</th><th>Сумма</th><th>Дата</th></tr></thead><tbody>';
|
||||
ops.forEach(function(op) {
|
||||
var action = op.action;
|
||||
// Цвет строки зависит от действия: ADD=зелёный, DELETE=красный, UPDATE=жёлтый
|
||||
var cls = action === 'ADD' ? 'diff-added' : action === 'DELETE' ? 'diff-deleted' : action === 'UPDATE' ? 'diff-changed' : '';
|
||||
var nr = op.new_row || {};
|
||||
html += '<tr class="' + cls + '"><td>' + action + '</td><td>' + (nr.name||'') + '</td><td class="num-cell">' + (nr.price!=null?nr.price:'') + '</td><td class="num-cell">' + (nr.qty!=null?nr.qty:'') + '</td><td class="num-cell">' + (nr.sum!=null?nr.sum:'') + '</td><td>' + (nr.date_start||'') + '</td></tr>';
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* renderCompareSectionBody(sec) — Чистая функция: тело секции (Фаза 3).
|
||||
*
|
||||
* Рендерит summary (статистику) + таблицу ops.
|
||||
*
|
||||
* Вход: sec — { summary: { added, updated, deleted, unresolved }, ops }
|
||||
* Выход: HTML-строка
|
||||
*/
|
||||
function renderCompareSectionBody(sec) {
|
||||
if (!sec || sec.status !== 'applied' || !sec.ops || !sec.ops.length) return '';
|
||||
var s = sec.summary || {};
|
||||
var html = '<div style="font-size:12px;margin-bottom:6px;">';
|
||||
html += '+' + (s.added||0) + ' ~' + (s.updated||0) + ' -' + (s.deleted||0);
|
||||
if (s.unresolved) html += ' ?' + s.unresolved;
|
||||
html += '</div>';
|
||||
html += renderCompareOpsTable(sec.ops);
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* startCompareSSE(url, container, statusEl, callbacks) — Жизненный цикл SSE (Фаза 3).
|
||||
*
|
||||
* УНИФИЦИРОВАННЫЙ обработчик SSE для ОБОИХ режимов сравнения.
|
||||
* Раньше ~80 строк дублировалось в runCompareForGroup и llmBtn.
|
||||
*
|
||||
* Параметры:
|
||||
* url — URL для EventSource (/process-v2?contract_id=...)
|
||||
* container — DOM-элемент, куда добавлять секции (cmpBody или compareBody)
|
||||
* statusEl — DOM-элемент для отображения таймера/статуса
|
||||
* callbacks — { onDone(d, sections), onError(d), onConnectionError() }
|
||||
* Все колбэки опциональны.
|
||||
* Таймер и EventSource УЖЕ остановлены к моменту вызова колбэков.
|
||||
*
|
||||
* Возвращает: { es, timer, sections } — для сохранения в state._activeCompare
|
||||
*/
|
||||
function startCompareSSE(url, container, statusEl, callbacks) {
|
||||
callbacks = callbacks || {};
|
||||
|
||||
// Таймер: обновляет statusEl каждые 200мс
|
||||
var start = Date.now();
|
||||
var timer = setInterval(function() {
|
||||
statusEl.textContent = '⏳ ' + ((Date.now() - start) / 1000).toFixed(1) + 'с';
|
||||
}, 200);
|
||||
|
||||
var es = new EventSource(url);
|
||||
var sections = {}; // supplement_id → { filename, status, ops_count, mode, time_s, summary, ops, error, _el }
|
||||
|
||||
es.onmessage = function(e) {
|
||||
var d = JSON.parse(e.data);
|
||||
|
||||
// ── extract_start: создать DOM-секцию ──
|
||||
if (d.type === 'extract_start') {
|
||||
applyCompareEvent(sections, d);
|
||||
var sec = sections[d.supplement_id];
|
||||
var secEl = document.createElement('div');
|
||||
secEl.style.cssText = 'margin-top:8px;border:1px solid var(--brand-gray);border-radius:6px;overflow:hidden;';
|
||||
secEl.innerHTML =
|
||||
'<div class="diff-section-header" style="padding:8px 12px;background:var(--brand-grey-light);cursor:pointer;font-size:13px;font-weight:600;"' +
|
||||
' onclick="var b=this.nextElementSibling;b.style.display=b.style.display===\'none\'?\'block\':\'none\';">' +
|
||||
renderCompareSectionHeader(sec) + '</div>' +
|
||||
'<div class="diff-section-body" style="display:none;padding:8px 12px;"></div>';
|
||||
container.appendChild(secEl);
|
||||
sec._el = secEl; // ссылка на DOM для последующих обновлений
|
||||
}
|
||||
|
||||
// ── llm_done: обновить заголовок секции ──
|
||||
else if (d.type === 'llm_done') {
|
||||
applyCompareEvent(sections, d);
|
||||
var sec = sections[d.supplement_id];
|
||||
if (sec && sec._el) {
|
||||
sec._el.querySelector('.diff-section-header').innerHTML = renderCompareSectionHeader(sec);
|
||||
sec._el.querySelector('.diff-section-header').style.color = 'var(--green)';
|
||||
}
|
||||
}
|
||||
|
||||
// ── applied: заполнить тело секции таблицей ops ──
|
||||
else if (d.type === 'applied') {
|
||||
applyCompareEvent(sections, d);
|
||||
var sec = sections[d.supplement_id];
|
||||
if (sec && sec._el && sec.ops && sec.ops.length > 0) {
|
||||
var body = sec._el.querySelector('.diff-section-body');
|
||||
body.innerHTML = renderCompareSectionBody(sec);
|
||||
body.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// ── extract_error / apply_error: показать ошибку ──
|
||||
else if (d.type === 'extract_error' || d.type === 'apply_error') {
|
||||
applyCompareEvent(sections, d);
|
||||
container.innerHTML += '<div style="font-size:12px;color:var(--destructive);">✗ ' + escHtml(d.filename) + ': ' + escHtml(d.error) + '</div>';
|
||||
}
|
||||
|
||||
// ── done: завершение ──
|
||||
else if (d.type === 'done') {
|
||||
clearInterval(timer);
|
||||
es.close();
|
||||
statusEl.textContent = '✓ ' + d.total_time_s + 'с';
|
||||
statusEl.style.color = 'var(--green)';
|
||||
if (callbacks.onDone) callbacks.onDone(d, sections);
|
||||
}
|
||||
|
||||
// ── error: фатальная ошибка ──
|
||||
else if (d.type === 'error') {
|
||||
clearInterval(timer);
|
||||
es.close();
|
||||
if (callbacks.onError) callbacks.onError(d);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Сетевая ошибка ──
|
||||
es.onerror = function() {
|
||||
clearInterval(timer);
|
||||
es.close();
|
||||
if (callbacks.onConnectionError) callbacks.onConnectionError();
|
||||
};
|
||||
|
||||
return { es: es, timer: timer, sections: sections };
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="4" fill="#001C34"/>
|
||||
<text x="16" y="24" text-anchor="middle" font-size="22" font-weight="bold" fill="white" font-family="sans-serif">N</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 246 B |
@@ -1,546 +0,0 @@
|
||||
/**
|
||||
* files.js — Модуль работы с файлами (Фаза 1, decoupling-final-plan.md).
|
||||
*
|
||||
* ВЫНЕСЕНО из app.js:
|
||||
* - statusToHTML(st) — чистая: структура → HTML
|
||||
* - renderFiles(state) — рендер таблицы файлов
|
||||
* - syncDB() — синхронизация БД с fileQueue
|
||||
* - toggleClassifyDetail(i) — раскрыть результат классификации
|
||||
* - uploadFile(file, cb) — XHR-загрузка одного файла (.doc/.docx/.pdf)
|
||||
* - refreshSupps() — обновить supplement_id для файлов
|
||||
*
|
||||
* ЗАВИСИМОСТИ (глобальные, загружаются раньше):
|
||||
* state.js → state (центральное состояние)
|
||||
* app_utils.js → escHtml, formatSize, formatDate
|
||||
* app.js → render(), stepDone, stepActive, resetStepper, showClassifyBtn
|
||||
*
|
||||
* ЗАГРУЖАЕТСЯ: после app_utils.js, перед app.js
|
||||
*/
|
||||
|
||||
/**
|
||||
* statusToHTML(status) — Чистая функция: структура → HTML (Фаза 1).
|
||||
*
|
||||
* Вход: { kind, pct?, text?, count?, elapsed? } — ни одного HTML-тега.
|
||||
* kind = 'uploading' | 'uploaded' | 'unzipping' | 'parsing' | 'parsed' | 'error' | ''
|
||||
* Выход: безопасная HTML-строка (статусы не содержат пользовательских данных).
|
||||
*
|
||||
* ПАТТЕРН (decoupling-final-plan.md): отделяем данные от представления.
|
||||
* state хранит чистые данные, statusToHTML — чистая функция рендеринга.
|
||||
*/
|
||||
function statusToHTML(st) {
|
||||
if (!st || !st.kind) return '';
|
||||
switch (st.kind) {
|
||||
case 'uploading': return '↑ ' + (st.pct || 0) + '%';
|
||||
case 'uploaded': return '<span class="status-ok">✓</span>';
|
||||
case 'unzipping': return '⏳ распаковка...';
|
||||
case 'parsing': return '⏳ парсинг...';
|
||||
case 'parsed': return '<span class="status-ok">✓ ' + (st.count || 0) + ' эл.' + (st.elapsed ? ' (' + st.elapsed + 'с)' : '') + '</span>';
|
||||
case 'error': return '<span class="status-err">✗ ' + escHtml(st.text || 'Неизвестная ошибка') + '</span>';
|
||||
case 'duplicate': return '<span style="color:var(--muted);">⤿ дубликат</span>';
|
||||
case 'warning': return '<span style="color:#eab308;">⚠ ' + escHtml(st.text || 'пустой') + '</span>';
|
||||
case 'no_format': return '<span class="status-err">✗ формат не поддерживается</span>';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* renderFiles(state) — Рендер таблицы файлов (бывший renderTable, Фаза 1).
|
||||
*
|
||||
* Читает state.files, генерирует HTML для #fileTable.
|
||||
* Использует statusToHTML() для рендеринга статусов (чистые данные → HTML).
|
||||
* Вызывает lucide.createIcons() для иконок.
|
||||
*
|
||||
* Вход: state (весь объект состояния, но читает только state.files).
|
||||
* Выход: мутирует DOM (fileTable.innerHTML).
|
||||
*/
|
||||
function renderFiles(state) {
|
||||
if (state.files.length === 0) {
|
||||
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .docx / .pdf</td></tr>';
|
||||
lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
// Сгруппировать файлы по zip_source
|
||||
var groups = []; // [{zip: "name.zip"|null, files: [f, ...]}]
|
||||
var seen = {};
|
||||
for (var i = 0; i < state.files.length; i++) {
|
||||
var f = state.files[i];
|
||||
var zip = f.zip_source || null;
|
||||
var key = zip || '__naked__';
|
||||
if (!seen[key]) {
|
||||
seen[key] = { zip: zip, files: [] };
|
||||
groups.push(seen[key]);
|
||||
}
|
||||
// Сохраняем оригинальный индекс для id строк
|
||||
f._idx = i;
|
||||
seen[key].files.push(f);
|
||||
}
|
||||
|
||||
// Рендер: заголовок ZIP → файлы с отступом
|
||||
var rows = [];
|
||||
for (var gi = 0; gi < groups.length; gi++) {
|
||||
var g = groups[gi];
|
||||
|
||||
if (g.zip) {
|
||||
// Заголовок-секция ZIP
|
||||
rows.push(
|
||||
'<tr class="zip-header" style="background:var(--brand-grey-light);">' +
|
||||
'<td colspan="7" style="padding:6px 12px;font-size:12px;font-weight:600;">' +
|
||||
'📦 ' + escHtml(g.zip) + ' — ' + g.files.length + ' файл.' +
|
||||
(g.files.length === 1 ? '' : 'ов') +
|
||||
'</td></tr>'
|
||||
);
|
||||
}
|
||||
|
||||
// Файлы внутри группы (с отступом если из ZIP)
|
||||
for (var fi = 0; fi < g.files.length; fi++) {
|
||||
var f = g.files[fi];
|
||||
var i = f._idx;
|
||||
var indent = g.zip ? 'padding-left:24px;' : '';
|
||||
|
||||
rows.push(
|
||||
'<tr id="row_' + i + '">' +
|
||||
'<td class="name-cell" style="cursor:pointer;' + indent + '" onclick="toggleClassifyDetail(' + i + ')">' +
|
||||
(f.doc_id ? '<span id="expand_' + i + '" style="font-size:10px;margin-right:4px;">▸</span>' : '') +
|
||||
escHtml(f.name) + '</td>' +
|
||||
'<td style="font-size:12px;color:var(--muted);">' + formatDate(f.lastModified) + '</td>' +
|
||||
'<td class="num-cell" style="font-size:12px;color:var(--muted);">' + formatSize(f.size) + '</td>' +
|
||||
'<td class="status-cell">' + statusToHTML(f.status) + '</td>' +
|
||||
'<td><button class="info-btn' + (f.doc_id ? ' visible' : '') + '" onclick="showText(' + i + ')" title="Текст / Распарсено"><i data-lucide="file-text" style="width:16px;height:16px;"></i></button></td>' +
|
||||
'<td><button class="remove-btn" onclick="removeFile(' + i + ')" title="Удалить"><i data-lucide="trash-2" style="width:16px;height:16px;"></i></button></td>' +
|
||||
'</tr>' +
|
||||
'<tr id="detail_' + i + '" style="display:none;"><td colspan="7" style="padding:4px 12px;font-size:11px;background:var(--brand-grey-light);"><span style="color:var(--muted);">Загрузка...</span></td></tr>'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fileTable.innerHTML = rows.join('');
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
/**
|
||||
* syncDB() — Синхронизация БД с текущим состоянием файлов.
|
||||
*
|
||||
* Отправляет на бэкенд список doc_id, которые должны остаться в БД.
|
||||
* Всё, чего нет в state.files, будет удалено из БД (cascade).
|
||||
* Вызывается после каждого изменения состава файлов (removeFile, upload).
|
||||
*/
|
||||
async function syncDB() {
|
||||
var keepIds = state.files.map(function(f) { return f.doc_id; }).filter(Boolean);
|
||||
try {
|
||||
await fetch(VM_API + '/api/sync', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({keep_ids: keepIds})
|
||||
});
|
||||
} catch(e) { /* сервер недоступен — не критично */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* toggleClassifyDetail(i) — Раскрыть/скрыть результат классификации под строкой файла.
|
||||
*
|
||||
* Вызывается по клику на имя файла в таблице.
|
||||
* Загружает данные с бэкенда (/api/documents/:id) и показывает:
|
||||
* - Тип, номер, родительский номер, дату, контрагента
|
||||
* - Текст отправленный в LLM (📤)
|
||||
* - Сырой ответ LLM (📥)
|
||||
* - Распарсенные элементы JSON (📄)
|
||||
*/
|
||||
window.toggleClassifyDetail = async function(i) {
|
||||
var detailRow = document.getElementById('detail_' + i);
|
||||
var expandIcon = document.getElementById('expand_' + i);
|
||||
if (!detailRow) return;
|
||||
|
||||
if (detailRow.style.display !== 'none') {
|
||||
detailRow.style.display = 'none';
|
||||
if (expandIcon) expandIcon.textContent = '▸';
|
||||
return;
|
||||
}
|
||||
|
||||
var f = state.files[i];
|
||||
if (!f || !f.doc_id) return;
|
||||
|
||||
detailRow.style.display = '';
|
||||
if (expandIcon) expandIcon.textContent = '▾';
|
||||
|
||||
try {
|
||||
var resp = await fetch(VM_API + '/api/documents/' + f.doc_id);
|
||||
var qData = await resp.json();
|
||||
if (!qData.ok) { detailRow.cells[0].innerHTML = '<span style="color:var(--destructive);">Ошибка загрузки</span>'; return; }
|
||||
|
||||
var html = '';
|
||||
if (qData.classify_status === 'classified') {
|
||||
html += '<div style="font-weight:600;margin-bottom:4px;">🏷️ Результат классификации</div>';
|
||||
html += '<div style="display:flex;gap:16px;flex-wrap:wrap;">';
|
||||
if (qData.doc_type) html += '<span><b>Тип:</b> ' + escHtml(qData.doc_type) + '</span>';
|
||||
if (qData.own_number) html += '<span><b>Номер:</b> ' + escHtml(qData.own_number) + '</span>';
|
||||
if (qData.parent_number) html += '<span><b>Родительский:</b> ' + escHtml(qData.parent_number) + '</span>';
|
||||
if (qData.doc_date) html += '<span><b>Дата:</b> ' + escHtml(qData.doc_date) + '</span>';
|
||||
if (qData.counterparty) html += '<span><b>Контрагент:</b> ' + escHtml(qData.counterparty) + '</span>';
|
||||
html += '</div>';
|
||||
if (qData.classify_input) {
|
||||
html += '<details style="margin-top:4px;"><summary style="cursor:pointer;color:var(--brand-primary);">📤 Текст отправленный в LLM</summary>';
|
||||
html += '<pre style="font-size:10px;max-height:150px;overflow:auto;background:#fff;padding:4px;border-radius:3px;margin-top:2px;">' + escHtml(qData.classify_input) + '</pre>';
|
||||
html += '</details>';
|
||||
}
|
||||
if (qData.classify_raw) {
|
||||
html += '<details style="margin-top:4px;"><summary style="cursor:pointer;color:var(--brand-primary);">📥 Сырой ответ LLM</summary>';
|
||||
html += '<pre style="font-size:10px;max-height:150px;overflow:auto;background:#fff;padding:4px;border-radius:3px;margin-top:2px;">' + escHtml(qData.classify_raw) + '</pre>';
|
||||
html += '</details>';
|
||||
}
|
||||
if (qData.elements_json) {
|
||||
var ej = qData.elements_json;
|
||||
if (typeof ej === 'object' && ej.Value) ej = ej.Value;
|
||||
var ejStr = typeof ej === 'string' ? ej : JSON.stringify(ej, null, 2);
|
||||
html += '<details style="margin-top:4px;"><summary style="cursor:pointer;color:var(--brand-primary);">📄 Распарсенные элементы (JSON)</summary>';
|
||||
html += '<pre style="font-size:10px;max-height:200px;overflow:auto;background:#fff;padding:4px;border-radius:3px;margin-top:2px;">' + escHtml(ejStr.substring(0, 5000)) + (ejStr.length > 5000 ? '\n... (обрезано)' : '') + '</pre>';
|
||||
html += '</details>';
|
||||
}
|
||||
} else if (qData.classify_status === 'failed') {
|
||||
html += '<span style="color:var(--destructive);">✗ Ошибка классификации: ' + escHtml(qData.error_message || '?') + '</span>';
|
||||
} else {
|
||||
html += '<span style="color:var(--muted);">Ещё не классифицирован</span>';
|
||||
}
|
||||
detailRow.cells[0].innerHTML = html;
|
||||
} catch(e) {
|
||||
detailRow.cells[0].innerHTML = '<span style="color:var(--destructive);">Ошибка: ' + escHtml(e.message) + '</span>';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* uploadFile(file, onProgress) — XHR-загрузка одного файла на бэкенд.
|
||||
*
|
||||
* Особенности:
|
||||
* - .doc (не .docx!) конвертируется через CONVERT_URL перед загрузкой
|
||||
* - onProgress(pct) — callback с процентом загрузки (0-100)
|
||||
* - Возвращает Promise<ответ API> с полями doc_id, contract_id, parsed
|
||||
* - Таймаут 180с (большие PDF)
|
||||
*/
|
||||
function uploadFile(file, onProgress, zipSource) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
// .doc → конвертация в docx (старый формат Word)
|
||||
var isDoc = file.name.toLowerCase().endsWith('.doc') && !file.name.toLowerCase().endsWith('.docx');
|
||||
var uploadFile = file;
|
||||
var uploadName = file.name;
|
||||
|
||||
function doUpload() {
|
||||
var xhr = new XMLHttpRequest();
|
||||
var fd = new FormData();
|
||||
fd.append('files', uploadFile, uploadName);
|
||||
if (state.contractId) fd.append('contract_id', state.contractId);
|
||||
fd.append('batch_id', state.batchId);
|
||||
if (zipSource) fd.append('zip_source', zipSource);
|
||||
xhr.open('POST', UPLOAD_URL);
|
||||
xhr.upload.onprogress = function(e) {
|
||||
if (e.lengthComputable && onProgress) onProgress(Math.round(e.loaded / e.total * 100));
|
||||
};
|
||||
xhr.onload = function() {
|
||||
try {
|
||||
var r = JSON.parse(xhr.responseText);
|
||||
if (r.ok) resolve(r);
|
||||
else reject(new Error(r.error || 'Неизвестная ошибка'));
|
||||
} catch(e) { reject(new Error('Некорректный ответ')); }
|
||||
};
|
||||
xhr.onerror = function() { reject(new Error('Сеть')); };
|
||||
xhr.ontimeout = function() { reject(new Error('Таймаут')); };
|
||||
xhr.timeout = 180000;
|
||||
xhr.send(fd);
|
||||
}
|
||||
|
||||
if (isDoc) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', CONVERT_URL);
|
||||
xhr.responseType = 'blob';
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200 && xhr.response.size > 100) {
|
||||
uploadFile = xhr.response;
|
||||
uploadName = file.name.replace(/\.doc$/i, '.docx');
|
||||
doUpload();
|
||||
} else {
|
||||
reject(new Error('Конвертация .doc'));
|
||||
}
|
||||
};
|
||||
xhr.onerror = function() { reject(new Error('Конвертер')); };
|
||||
xhr.send(file);
|
||||
} else {
|
||||
doUpload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* refreshSupps() — Обновить supplement_id/type для файлов в state.files.
|
||||
*
|
||||
* Запрашивает с бэкенда список supplement-записей для текущего contractId,
|
||||
* сопоставляет их с файлами по doc_id → document_id и проставляет supp_id, supp_type.
|
||||
* Вызывается после каждого upload и после apply-groups.
|
||||
*/
|
||||
async function refreshSupps() {
|
||||
if (!state.contractId) { console.log('refreshSupps: no state.contractId'); return; }
|
||||
try {
|
||||
var resp = await fetch(VM_API + '/api/supplements?contract_id=' + state.contractId);
|
||||
var data = await resp.json();
|
||||
console.log('refreshSupps: data=', data);
|
||||
if (data.ok && data.supplements) {
|
||||
data.supplements.forEach(function(supp) {
|
||||
console.log('refreshSupps: supp=', supp);
|
||||
for (var i = 0; i < state.files.length; i++) {
|
||||
if (state.files[i].doc_id === supp.document_id) {
|
||||
state.files[i].supp_id = supp.id;
|
||||
state.files[i].supp_type = supp.type;
|
||||
}
|
||||
}
|
||||
});
|
||||
render(state);
|
||||
}
|
||||
} catch(e) { console.log('refreshSupps error:', e); }
|
||||
}
|
||||
|
||||
/**
|
||||
* reconcileSelection(files, newFiles) — Чистая функция (Фаза 1).
|
||||
*
|
||||
* Согласование состава: оставляет в массиве files только те,
|
||||
* имена которых есть в newFiles. Остальные удаляются.
|
||||
*
|
||||
* Вход: files — текущий state.files (массив)
|
||||
* newFiles — FileList от <input> (массив File-объектов)
|
||||
* Выход: новый массив (НЕ мутирует входной)
|
||||
*
|
||||
* Вызывается из onFilesSelected() ПЕРЕД обработкой каждого файла.
|
||||
*/
|
||||
function reconcileSelection(files, newFiles) {
|
||||
var newNames = new Set(newFiles.map(function(f) { return f.name; }));
|
||||
return files.filter(function(f) {
|
||||
return newNames.has(f.name);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* applyParseResult(entry, parsed, elapsed) — Чистая функция (Фаза 1).
|
||||
*
|
||||
* Применяет результат парсинга к файлу. Унифицирует две ветки (ZIP и обычную),
|
||||
* которые раньше дублировали этот код.
|
||||
*
|
||||
* Вход: entry — элемент state.files (мутируется)
|
||||
* parsed — { status, element_count, error } от бэкенда
|
||||
* elapsed — время парсинга в секундах (только для обычных файлов)
|
||||
* Выход: нет (мутирует entry на месте)
|
||||
*/
|
||||
function applyParseResult(entry, parsed, elapsed) {
|
||||
if (parsed && parsed.status === 'parsed') {
|
||||
entry.parsed = true;
|
||||
entry.parseInfo = parsed;
|
||||
entry.status = { kind: 'parsed', count: parsed.element_count };
|
||||
if (elapsed) entry.status.elapsed = elapsed;
|
||||
} else if (parsed && parsed.status === 'error') {
|
||||
entry.parseInfo = parsed;
|
||||
entry.status = { kind: 'error', text: parsed.error || 'ошибка парсинга' };
|
||||
} else {
|
||||
entry.status = { kind: 'error', text: 'Неизвестная ошибка' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* addZipFile(file) — Async-action: обработка ZIP-файла (Фаза 1, упрощена).
|
||||
*
|
||||
* 1. Отправляет ZIP на бэкенд (только распаковка, без БД/парсинга)
|
||||
* 2. Для каждого файла из архива: base64 → Blob → File → addRegularFile()
|
||||
*
|
||||
* addRegularFile делает ВСЁ: upload, parse, дубликаты, статусы, render.
|
||||
* Никакого дублирования логики — единый путь для обычных и ZIP-файлов.
|
||||
*/
|
||||
async function addZipFile(file) {
|
||||
var zipEntry = { name: file.name, lastModified: file.lastModified, size: file.size, status: { kind: 'unzipping' } };
|
||||
state.files.push(zipEntry);
|
||||
render(state);
|
||||
|
||||
try {
|
||||
// Шаг 1: распаковать ZIP на бэкенде
|
||||
var zipResp = await new Promise(function(resolve, reject) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', UNZIP_URL);
|
||||
xhr.responseType = 'json';
|
||||
xhr.onload = function() { resolve(xhr.response); };
|
||||
xhr.onerror = function() { reject(new Error('Сеть')); };
|
||||
xhr.ontimeout = function() { reject(new Error('Таймаут')); };
|
||||
xhr.timeout = 60000;
|
||||
var fd = new FormData();
|
||||
fd.append('files', file);
|
||||
xhr.send(fd);
|
||||
});
|
||||
if (!zipResp.ok || !zipResp.files) throw new Error('unzip failed');
|
||||
|
||||
// Подтверждение: показать первые 10 файлов + итог
|
||||
var preview = zipResp.files.slice(0, 10).map(function(f) { return ' • ' + f.filename + ' (' + (f.size/1024).toFixed(1) + ' KB)'; }).join('\n');
|
||||
if (total > 10) preview += '\n ... и ещё ' + (total - 10) + ' файлов';
|
||||
if (!confirm('Архив «' + file.name + '» — ' + total + ' файлов:\n\n' + preview + '\n\nЗагрузить эти файлы?')) {
|
||||
// Отмена — убрать строку ZIP
|
||||
var zipPos2 = state.files.indexOf(zipEntry);
|
||||
if (zipPos2 >= 0) state.files.splice(zipPos2, 1);
|
||||
render(state);
|
||||
return;
|
||||
}
|
||||
|
||||
// Шаг 2: обработать каждый файл из архива
|
||||
// Временная строка ZIP остаётся в таблице — обновляем её статус
|
||||
var total = zipResp.files.length;
|
||||
for (var zi = 0; zi < total; zi++) {
|
||||
var zf = zipResp.files[zi];
|
||||
zipEntry.status = { kind: 'unzipping' }; // обновляем статус
|
||||
render(state);
|
||||
|
||||
if (zf.error) continue;
|
||||
|
||||
// base64 → File → addRegularFile (единый путь)
|
||||
var byteStr = atob(zf.data_b64);
|
||||
var bytes = new Uint8Array(byteStr.length);
|
||||
for (var b = 0; b < byteStr.length; b++) bytes[b] = byteStr.charCodeAt(b);
|
||||
var mime = {docx:'application/vnd.openxmlformats-officedocument.wordprocessingml.document',doc:'application/msword',pdf:'application/pdf'}[zf.ext] || 'application/octet-stream';
|
||||
var extractedFile = new File([bytes], zf.filename, { type: mime, lastModified: Date.now() });
|
||||
|
||||
await addRegularFile(extractedFile, file.name);
|
||||
}
|
||||
|
||||
// Шаг 3: убрать временную строку ZIP (только после успешной обработки всех файлов)
|
||||
var zipPos = state.files.indexOf(zipEntry);
|
||||
if (zipPos >= 0) state.files.splice(zipPos, 1);
|
||||
render(state);
|
||||
|
||||
} catch(ze) {
|
||||
// Ошибка — показать на строке ZIP (zipEntry всё ещё в state.files)
|
||||
zipEntry.status = { kind: 'error', text: 'ZIP: ' + ze.message };
|
||||
render(state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* addRegularFile(file) — Async-action: обработка обычного файла (Фаза 1).
|
||||
*
|
||||
* 1. Добавляет/заменяет файл в state.files
|
||||
* 2. Загружает через XHR (uploadFile) с индикатором прогресса
|
||||
* 3. После загрузки: проставляет doc_id, contractId
|
||||
* 4. applyParseResult — применяет результат парсинга
|
||||
*
|
||||
* Мутирует state.files, вызывает render(state) после каждого изменения.
|
||||
*/
|
||||
async function addRegularFile(file, zipSource) {
|
||||
// Создать запись с начальным статусом
|
||||
var entry = { name: file.name, lastModified: file.lastModified, size: file.size, file: file, status: { kind: 'uploading', pct: 0 }, zip_source: zipSource || null };
|
||||
|
||||
// ДЕДУПЛИКАЦИЯ: ключ = (zip_source, name), чтобы одноимённые файлы из разных ZIP не затирались
|
||||
var dupKey = (zipSource || '') + '/' + file.name;
|
||||
var existingIdx = -1;
|
||||
for (var j = 0; j < state.files.length; j++) {
|
||||
var ejKey = (state.files[j].zip_source || '') + '/' + state.files[j].name;
|
||||
if (ejKey === dupKey) { existingIdx = j; break; }
|
||||
}
|
||||
var rowIdx;
|
||||
if (existingIdx >= 0) {
|
||||
// Файл с таким именем уже есть — спросить пользователя
|
||||
if (!confirm('Файл «' + file.name + '» уже есть в списке.\n\nOK — перезаписать\nОтмена — пропустить')) {
|
||||
return; // пользователь выбрал «пропустить»
|
||||
}
|
||||
state.files[existingIdx] = entry;
|
||||
rowIdx = existingIdx;
|
||||
} else {
|
||||
state.files.push(entry);
|
||||
rowIdx = state.files.length - 1;
|
||||
}
|
||||
render(state);
|
||||
|
||||
try {
|
||||
// XHR-загрузка с прогрессом
|
||||
var resp = await uploadFile(file, function(pct) {
|
||||
state.files[rowIdx].status = { kind: 'uploading', pct: pct };
|
||||
render(state);
|
||||
}, zipSource);
|
||||
// Бэкенд возвращает doc_id и contract_id (нижний регистр — Python keys)
|
||||
if (resp && resp.contract_id) state.contractId = resp.contract_id;
|
||||
state.files[rowIdx].doc_id = resp.doc_id;
|
||||
|
||||
// Дубликат?
|
||||
if (resp && resp.duplicate_of) {
|
||||
state.files[rowIdx].status = { kind: 'duplicate' };
|
||||
render(state);
|
||||
return;
|
||||
}
|
||||
// Warning от парсинга?
|
||||
if (resp && resp.warning) {
|
||||
state.files[rowIdx].status = { kind: 'warning', text: resp.warning };
|
||||
render(state);
|
||||
} else {
|
||||
state.files[rowIdx].status = { kind: 'uploaded' };
|
||||
}
|
||||
state.files[rowIdx].uploaded = true;
|
||||
|
||||
// Авто-парсинг: бэкенд парсит всё (PDF + DOCX + DOC)
|
||||
var t0 = Date.now();
|
||||
var pr = resp.parsed;
|
||||
var elapsed = pr && pr.status === 'parsed' ? ((Date.now() - t0) / 1000).toFixed(1) : null;
|
||||
applyParseResult(state.files[rowIdx], pr, elapsed);
|
||||
} catch(err) {
|
||||
state.files[rowIdx].status = { kind: 'error', text: err.message };
|
||||
}
|
||||
render(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* finalizeUpload() — Завершение цикла загрузки (Фаза 1).
|
||||
*
|
||||
* Вызывается ПОСЛЕ обработки всех файлов в onFilesSelected().
|
||||
* 1. Обновляет supplement_id/type через refreshSupps()
|
||||
* 2. Отмечает шаг «Загрузка» как готовый
|
||||
* 3. Активирует шаг «Классификация»
|
||||
* 4. Синхронизирует БД
|
||||
* 5. Показывает кнопки (LLM, классификация)
|
||||
*/
|
||||
async function finalizeUpload() {
|
||||
await refreshSupps();
|
||||
stepDone('stepUpload');
|
||||
stepActive('stepClassify');
|
||||
syncDB();
|
||||
|
||||
fileInput.value = '';
|
||||
fileInput.disabled = false;
|
||||
|
||||
var llmBtn = document.getElementById('llmBtn');
|
||||
llmBtn.style.display = 'flex';
|
||||
llmBtn.disabled = false;
|
||||
showClassifyBtn();
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
/**
|
||||
* onFilesSelected(newFiles) — Оркестратор загрузки (Фаза 1).
|
||||
*
|
||||
* ПАТТЕРН:
|
||||
* 1. Для каждого файла: addZipFile (ZIP) или addRegularFile (обычный)
|
||||
* 2. finalizeUpload — завершить цикл
|
||||
*
|
||||
* НЕ удаляет существующие файлы — только добавляет новые.
|
||||
* Дубликаты обрабатываются через confirm() в addRegularFile.
|
||||
* Удаление — только вручную (кнопка ✕).
|
||||
*
|
||||
* Вызывается из fileInput.addEventListener('change', ...).
|
||||
*/
|
||||
async function onFilesSelected(newFiles) {
|
||||
if (newFiles.length === 0) return;
|
||||
|
||||
fileInput.disabled = true;
|
||||
|
||||
// Сбросить прогресс пайплайна при добавлении новых файлов
|
||||
resetStepper('stepUpload');
|
||||
|
||||
// Обработать каждый файл
|
||||
for (var i = 0; i < newFiles.length; i++) {
|
||||
var f = newFiles[i];
|
||||
if (f.name.toLowerCase().endsWith('.zip')) {
|
||||
await addZipFile(f);
|
||||
} else {
|
||||
await addRegularFile(f);
|
||||
}
|
||||
}
|
||||
|
||||
// Завершить цикл
|
||||
await finalizeUpload();
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
/**
|
||||
* groups.js — Модуль групп и карточек договоров (Фаза 2, decoupling-final-plan.md).
|
||||
*
|
||||
* ВЫНЕСЕНО из app.js:
|
||||
* - loadGroupsAction() — fetch групп, сохранить в state.groups, renderGroups
|
||||
* - renderGroups(state) — пересобрать ВСЕ карточки групп в diffBody
|
||||
* - renderGroupCard(g, gi) — чистая HTML-функция: карточка необработанной группы
|
||||
* - renderGroupCardDone(g, gi)— чистая HTML-функция: карточка обработанной группы
|
||||
*
|
||||
* ПАТТЕРН (decoupling-final-plan.md):
|
||||
* renderGroups пересобирает ВСЕ карточки целиком из state.groups.
|
||||
* Готовность группы: group.compare.status === 'done' (вместо markGroupDone).
|
||||
* Никакого window._groupsData — всё в state.groups.
|
||||
*
|
||||
* ЗАВИСИМОСТИ:
|
||||
* state.js → state (центральное состояние)
|
||||
* app_utils.js → escHtml
|
||||
*/
|
||||
|
||||
/**
|
||||
* loadGroupsAction() — Загрузить группы с бэкенда (Фаза 2).
|
||||
*
|
||||
* 1. fetch /api/groups?batch=...
|
||||
* 2. Сохранить в state.groups (бывший window._groupsData)
|
||||
* 3. Вызвать renderGroups(state) — пересобрать карточки
|
||||
*
|
||||
* Вызывается из runClassify() после успешной классификации.
|
||||
*/
|
||||
async function loadGroupsAction() {
|
||||
var resp = await fetch(VM_API + '/api/groups?batch=' + state.batchId);
|
||||
var data = await resp.json();
|
||||
if (!data.ok || !data.groups) return;
|
||||
|
||||
// Сохраняем в центральное состояние (вместо window._groupsData)
|
||||
state.groups = data.groups;
|
||||
|
||||
// Пересобрать ВСЕ карточки
|
||||
renderGroups(state);
|
||||
|
||||
stepDone('stepGroups');
|
||||
}
|
||||
|
||||
/**
|
||||
* renderGroups(state) — Пересобрать ВСЕ карточки групп (Фаза 2).
|
||||
*
|
||||
* ПЕРЕСОБИРАЕТ весь diffBody.innerHTML заново из state.groups.
|
||||
* Никакого инкрементального обновления — всегда полная перестройка.
|
||||
*
|
||||
* Для каждой группы:
|
||||
* - Необработанная (group.compare.status !== 'done') → renderGroupCard()
|
||||
* - Обработанная (group.compare.status === 'done') → renderGroupCardDone()
|
||||
* - Нераспознанная (contract_number === '__unresolved__') → отдельный рендер
|
||||
*/
|
||||
function renderGroups(state) {
|
||||
var groups = state.groups;
|
||||
if (!groups) return;
|
||||
|
||||
var diffBody = document.getElementById('diffBody');
|
||||
var diffCard = document.getElementById('diffCard');
|
||||
diffCard.style.display = 'block';
|
||||
|
||||
var realGroups = groups.filter(function(g) { return g.contract_number !== '__unresolved__'; });
|
||||
var unresolvedGroup = groups.find(function(g) { return g.contract_number === '__unresolved__'; });
|
||||
|
||||
// Заголовок: количество групп + нераспознанные
|
||||
var html = '<div style="font-weight:600;margin-bottom:8px;">📋 Найдено групп: ' + realGroups.length +
|
||||
(unresolvedGroup ? ' | ⚠ ' + unresolvedGroup.documents.length + ' файлов не распознано' : '') +
|
||||
'</div>';
|
||||
|
||||
// Нераспознанная группа — всегда первая
|
||||
if (unresolvedGroup) {
|
||||
html += renderUnresolvedCard(unresolvedGroup);
|
||||
}
|
||||
|
||||
// Карточки обычных групп
|
||||
groups.forEach(function(g, gi) {
|
||||
if (g.contract_number === '__unresolved__') return;
|
||||
if (g.compare && g.compare.status === 'done') {
|
||||
html += renderGroupCardDone(g, gi);
|
||||
} else {
|
||||
html += renderGroupCard(g, gi);
|
||||
}
|
||||
});
|
||||
|
||||
diffBody.innerHTML = html;
|
||||
|
||||
// Один обработчик на все кнопки сравнения (event delegation не используется —
|
||||
// кнопки пересоздаются при каждом renderGroups)
|
||||
diffBody.querySelectorAll('.cmp-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var gi = parseInt(this.getAttribute('data-gi'));
|
||||
runCompareForGroup(gi, this);
|
||||
});
|
||||
});
|
||||
|
||||
// Обработчики сворачивания для готовых карточек
|
||||
diffBody.querySelectorAll('.cmp-header').forEach(function(header) {
|
||||
var gi = parseInt(header.getAttribute('data-gi'));
|
||||
if (isNaN(gi)) return;
|
||||
header.addEventListener('click', function() {
|
||||
var body = document.getElementById('cmpBody_' + gi);
|
||||
var arrow = document.getElementById('cmpArrow_' + gi);
|
||||
if (body) {
|
||||
if (body.style.display === 'none') {
|
||||
body.style.display = 'block';
|
||||
if (arrow) arrow.textContent = '▾';
|
||||
} else {
|
||||
body.style.display = 'none';
|
||||
if (arrow) arrow.textContent = '▸';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
/**
|
||||
* renderUnresolvedCard(group) — Чистая HTML-функция: карточка нераспознанных файлов.
|
||||
*
|
||||
* Вход: group — { contract_number: '__unresolved__', documents: [...] }
|
||||
* Выход: HTML-строка
|
||||
*/
|
||||
function renderUnresolvedCard(group) {
|
||||
var docsHtml = group.documents.map(function(d) {
|
||||
// Определить причину, почему файл не попал ни в одну группу
|
||||
var reason = '';
|
||||
if (d.parent_number) {
|
||||
reason = ' — нет базового договора №' + escHtml(d.parent_number);
|
||||
} else if (d.classify_status === 'failed') {
|
||||
reason = ' — ошибка классификации: ' + escHtml(d.error_message || '?');
|
||||
} else if (!d.doc_type || d.doc_type === 'other') {
|
||||
reason = ' — тип не определён';
|
||||
} else {
|
||||
reason = ' — нет matching-договора';
|
||||
}
|
||||
return '<div style="padding:2px 0;">' +
|
||||
'<span style="color:var(--muted);">[' + escHtml(d.doc_type || '?') + ']</span> ' +
|
||||
escHtml(d.filename) +
|
||||
(d.doc_date ? ' <span style="color:var(--muted);">' + escHtml(d.doc_date) + '</span>' : '') +
|
||||
'<span style="color:var(--destructive);font-size:11px;">' + reason + '</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
return '<div style="border:1px solid var(--destructive);border-radius:6px;padding:10px;margin-bottom:8px;">' +
|
||||
'<div style="font-weight:600;margin-bottom:6px;">❓ Не распознано (' + group.documents.length + ' файлов)</div>' +
|
||||
'<div style="font-size:12px;">' + docsHtml + '</div>' +
|
||||
'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Загрузите недостающие базовые договоры для этих номеров.</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* renderGroupCard(group, gi) — Чистая HTML-функция: необработанная группа (Фаза 2).
|
||||
*
|
||||
* Показывает: номер договора, контрагента, список документов, кнопку «Сравнить».
|
||||
* Если группа в процессе сравнения (group.compare.status === 'running') — кнопка disabled.
|
||||
*
|
||||
* Вход: group — элемент state.groups
|
||||
* gi — индекс группы (для data-gi атрибута кнопки)
|
||||
* Выход: HTML-строка
|
||||
*/
|
||||
function renderGroupCard(group, gi) {
|
||||
var isRunning = group.compare && group.compare.status === 'running';
|
||||
|
||||
var html = '<div style="border:1px solid var(--brand-gray);border-radius:6px;padding:10px;margin-bottom:8px;">' +
|
||||
'<div class="cmp-header" style="font-weight:600;margin-bottom:6px;">' +
|
||||
'📄 Договор №' + escHtml(group.contract_number || '?') + ' — ' + escHtml(group.counterparty || 'контрагент не определён') +
|
||||
'</div>' +
|
||||
'<div style="font-size:12px;">' +
|
||||
group.documents.map(function(d) {
|
||||
var typeLabel = {contract: 'договор', supplement: 'допсоглашение', specification: 'спецификация'}[d.doc_type] || d.doc_type;
|
||||
return '<div style="padding:2px 0;">' +
|
||||
'<span style="color:var(--muted);">' + escHtml(typeLabel || '?') + '</span> ' +
|
||||
escHtml(d.filename) +
|
||||
(d.zip_source ? ' <span style="color:var(--brand-gray);font-size:10px;">[' + escHtml(d.zip_source) + ']</span>' : '') +
|
||||
(d.doc_date ? ' <span style="color:var(--muted);">' + escHtml(d.doc_date) + '</span>' : '') +
|
||||
'</div>';
|
||||
}).join('') + '</div>' +
|
||||
'<button class="btn btn-primary cmp-btn" style="margin-top:6px;font-size:12px;height:28px;" data-gi="' + gi + '"' +
|
||||
(isRunning ? ' disabled' : '') + '>▶ Сравнить эту группу</button>' +
|
||||
'</div>';
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* renderGroupCardDone(group, gi) — Чистая HTML-функция: обработанная группа (Фаза 2).
|
||||
*
|
||||
* Показывает: ✓ Готово (время), список документов (кликабельные — раскрывают секции),
|
||||
* тело сравнения (cmpBody) — сворачивается по клику на заголовок.
|
||||
*
|
||||
* Вход: group — элемент state.groups (group.compare.status === 'done')
|
||||
* gi — индекс группы
|
||||
* Выход: HTML-строка
|
||||
*
|
||||
* ЗАМЕНЯЕТ удалённую markGroupDone().
|
||||
* Готовность определяется по group.compare.status, а не по наличию кнопки.
|
||||
*/
|
||||
function renderGroupCardDone(group, gi) {
|
||||
var time = group.compare.totalTime || '?с';
|
||||
var bodyHTML = group.compare.bodyHTML || '';
|
||||
|
||||
var html = '<div style="border:1px solid var(--brand-gray);border-radius:6px;padding:10px;margin-bottom:8px;">' +
|
||||
'<div class="cmp-header" style="font-weight:600;margin-bottom:6px;cursor:pointer;" data-gi="' + gi + '">' +
|
||||
'<span class="cmp-arrow" id="cmpArrow_' + gi + '" style="font-size:10px;margin-right:4px;">▾</span>' +
|
||||
'📄 Договор №' + escHtml(group.contract_number || '?') + ' — ' + escHtml(group.counterparty || 'контрагент не определён') +
|
||||
' <span style="font-weight:400;color:var(--green);font-size:11px;">✓ Готово (' + time + ')</span>' +
|
||||
'</div>' +
|
||||
'<div style="font-size:12px;">' +
|
||||
group.documents.map(function(d, di) {
|
||||
var typeLabel = {contract: 'договор', supplement: 'допсоглашение', specification: 'спецификация'}[d.doc_type] || d.doc_type;
|
||||
return '<div style="padding:2px 0;cursor:pointer;" onclick="var b=document.querySelectorAll(\'#cmpBody_' + gi + ' .diff-section-body\');if(b[' + di + '])b[' + di + '].style.display=b[' + di + '].style.display===\'none\'?\'block\':\'none\';">' +
|
||||
'<span style="color:var(--muted);">' + escHtml(typeLabel || '?') + '</span> ' +
|
||||
escHtml(d.filename) +
|
||||
(d.zip_source ? ' <span style="color:var(--brand-gray);font-size:10px;">[' + escHtml(d.zip_source) + ']</span>' : '') +
|
||||
(d.doc_date ? ' <span style="color:var(--muted);">' + escHtml(d.doc_date) + '</span>' : '') +
|
||||
'</div>';
|
||||
}).join('') + '</div>' +
|
||||
'<div class="cmp-body" id="cmpBody_' + gi + '" style="margin-top:8px;">' + bodyHTML + '</div>' +
|
||||
'</div>';
|
||||
|
||||
return html;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Creator: CorelDRAW -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="82.3711mm" height="18.2443mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
|
||||
viewBox="0 0 8221.93 1821.07"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
|
||||
<defs>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
.fil0 {fill:#001C34}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Слой_x0020_1">
|
||||
<metadata id="CorelCorpID_0Corel-Layer"/>
|
||||
<g id="_2283355517888">
|
||||
<path class="fil0" d="M477.49 479.69l-413.51 0 -63.98 276.48 178.44 0 1.17 1028.71 0 26.75 0.03 0 276.39 0 0 -871.72c0,-101.28 82.43,-183.69 183.75,-183.69l589.25 3.44c101.34,0 183.76,82.46 183.76,183.74l0 871.72 276.44 0 0 -871.72c0,-253.77 -206.46,-460.15 -460.05,-460.15l-589.52 -3.53 -162.17 0.45 0 -0.48z"/>
|
||||
<path class="fil0" d="M6664.65 1813.37l1181.04 0c212.14,0 376.24,-175.01 376.24,-387.08 0,-212.13 -172.55,-384.68 -384.69,-384.68l-653.66 0c-60.22,0 -109.16,-48.94 -109.16,-109.18l0 -66.18c0,-60.19 48.94,-109.14 109.16,-109.14l695.76 0 63.74 -275.49 -759.5 0c-212.13,0 -384.66,172.53 -384.66,384.63l0 66.18c0,212.13 172.53,384.67 384.66,384.67l653.66 0c60.2,0 109.2,49 109.2,109.19 0,60.13 -49,116.1 -109.2,116.1l-1118.9 0 -53.68 270.98z"/>
|
||||
<path class="fil0" d="M6200.79 483.5l-723.21 0c-215.88,0 -391.47,175.6 -391.47,391.51l0 485.5c0,248.38 202.05,450.4 450.4,450.4l989.38 0 62.13 -268.51 -1051.51 0c-96.41,0 -174.95,-85.37 -174.95,-181.88l-1.5 -39.46 923.62 0 308.51 -1.84 0 -237.95 0 -206.26c0,-215.91 -175.59,-391.51 -391.41,-391.51zm115.96 560.28l-955.19 0 0 -168.77c0,-63.96 52.07,-116.05 116.02,-116.05l723.21 0c63.91,0 115.96,52.08 115.96,116.05l0 168.77z"/>
|
||||
<path class="fil0" d="M4683.49 1194.24l0 168.28c0,100.92 -81.7,178.37 -182.67,178.37l-589.87 1.23c-93.18,0 -170.28,-69.96 -181.63,-160.09l0 -458.13c11.36,-90.1 88.46,-160.07 181.63,-160.07l589.44 3.5c100.97,0 183.1,82.18 183.1,183.1l0 30.42 0 213.4zm-1230.2 -345.53l-0.89 -795.03 276.91 -53.68 0 526.07c55.74,-24.16 117.03,-37.74 181.5,-37.74l589.71 3.5c252.69,0 458.42,205.72 458.42,458.59l0 30.42 0 213.4 0 168.28c0,252.86 -205.73,458.54 -458.42,458.54l-589.71 -3.5c-252.7,0 -458.41,-205.67 -458.41,-458.56l0 -171.61 0 -240.48 0.89 -98.2z"/>
|
||||
<path class="fil0" d="M3041.25 1150.84l0 204.28c0,100.93 -82.15,183.05 -183.11,183.05l-582.11 -3.46c-100.96,0 -183.11,-82.16 -183.11,-183.08l0 -67.42 1.48 0.24 0 -862.65 -276.91 53.68 0.12 103.41 -0.12 0.04 0 772.69c0,252.87 205.72,458.54 458.39,458.54l582.4 3.5c252.67,0 458.4,-205.68 458.4,-458.55l0 -70.79 0.71 0.12 0 -862.65 -276.91 53.68 0.76 675.35z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.8 KiB |
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* state.js — Центральное состояние приложения.
|
||||
*
|
||||
* ПАТТЕРН (из decoupling-final-plan.md, Фаза 0):
|
||||
* action → мутация state → render(state)
|
||||
*
|
||||
* ПРАВИЛА:
|
||||
* 1. ВСЕ состояния приложения — только здесь, в объекте state.
|
||||
* 2. DOM — проекция state, никто не читает данные из DOM.
|
||||
* Если что-то нужно узнать — смотри в state, а не в element.innerHTML.
|
||||
* 3. Мутировал любое поле state → обязан вызвать render(state).
|
||||
* Никаких прямых манипуляций DOM в обход render().
|
||||
* 4. В любой момент console.log(state) показывает ВСЁ состояние приложения.
|
||||
*
|
||||
* СТРУКТУРА (Фаза 0 — минимальная, будет расширяться в Фазах 1-4):
|
||||
*
|
||||
* state.files — бывший fileQueue, массив загруженных файлов.
|
||||
* Каждый элемент: { name, size, lastModified, file, doc_id,
|
||||
* uploaded, parsed, parseInfo, supp_id, supp_type, status, zip_source }
|
||||
*
|
||||
* state.contractId — бывший contractId, ID контракта в БД.
|
||||
* Приходит с бэкенда после первого успешного upload.
|
||||
* null пока не загружен ни один файл.
|
||||
*
|
||||
* state.batchId — бывший batchId, уникальный ID сессии (UUID).
|
||||
* Используется для привязки всех файлов сессии при классификации.
|
||||
* Генерируется один раз при загрузке страницы.
|
||||
*
|
||||
* state.groups — бывший window._groupsData.
|
||||
* Массив групп после классификации. Каждая группа:
|
||||
* { contract_number, counterparty, documents[], unresolved? }
|
||||
* null если классификация ещё не запускалась.
|
||||
*
|
||||
* state._activeCompare — бывшие _activeCompareES и _activeCompareTimer.
|
||||
* Управляет ОДНИМ активным SSE-сравнением.
|
||||
* Правило: только одно сравнение одновременно — все кнопки блокируются.
|
||||
* { es: EventSource|null, timer: intervalId|null }
|
||||
*
|
||||
* state.ui — UI-состояние, не связанное с данными.
|
||||
* ui.steps: { upload, classify, groups, compare }
|
||||
* Каждый шаг: '○' (не начат) | '⏳' (в процессе) | '✓' (завершён)
|
||||
* В Фазе 4 будет renderStepper(state).
|
||||
*/
|
||||
var state = {
|
||||
// ── Файлы (бывший fileQueue) ──────────────────────────────
|
||||
files: [],
|
||||
|
||||
// ── Контракт (бывший contractId) ──────────────────────────
|
||||
contractId: null,
|
||||
|
||||
// ── Сессия (бывший batchId) ───────────────────────────────
|
||||
// crypto.randomUUID() — браузерный API, поддержка 92%+ (Chrome 92+, FF 95+, Safari 15.4+)
|
||||
batchId: crypto.randomUUID(),
|
||||
|
||||
// ── Группы (бывший window._groupsData) ────────────────────
|
||||
groups: null,
|
||||
|
||||
// ── Активное сравнение ────────────────────────────────────
|
||||
_activeCompare: {
|
||||
es: null, // EventSource (SSE-соединение с /process-v2)
|
||||
timer: null // setInterval ID для индикатора времени
|
||||
},
|
||||
|
||||
// ── UI-состояние ──────────────────────────────────────────
|
||||
ui: {
|
||||
steps: {
|
||||
upload: '○',
|
||||
classify: '○',
|
||||
groups: '○',
|
||||
compare: '○'
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,518 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Архитектура — Сверка договоров</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117; --fg: #c9d1d9; --muted: #8b949e;
|
||||
--accent: #58a6ff; --green: #3fb950; --orange: #d2991d;
|
||||
--yellow: #e3b341; --red: #f85149; --border: #30363d;
|
||||
--code-bg: #161b22; --card-bg: #161b22;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font: 15px/1.65 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--fg); max-width: 860px; margin: 0 auto; padding: 32px 20px 80px; }
|
||||
h1 { font-size: 26px; margin-bottom: 8px; }
|
||||
h2 { font-size: 19px; margin-top: 36px; margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid var(--border); color: var(--accent); }
|
||||
h3 { font-size: 16px; margin-top: 24px; margin-bottom: 6px; color: var(--green); }
|
||||
p { margin: 8px 0; }
|
||||
blockquote { border-left: 3px solid var(--orange); margin: 12px 0; padding: 8px 16px; background: var(--card-bg); border-radius: 0 6px 6px 0; font-style: italic; color: var(--yellow); }
|
||||
blockquote strong { color: var(--orange); }
|
||||
ul, ol { padding-left: 24px; margin: 8px 0; }
|
||||
li { margin: 4px 0; }
|
||||
code { background: var(--code-bg); padding: 1px 5px; border-radius: 4px; font-size: 13px; border: 1px solid var(--border); }
|
||||
pre { background: var(--code-bg); padding: 12px 16px; border-radius: 6px; overflow-x: auto; font-size: 13px; border: 1px solid var(--border); margin: 12px 0; }
|
||||
.tag { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; font-weight: 600; margin-right: 6px; }
|
||||
.tag-ok { background: #1a3a2a; color: var(--green); }
|
||||
.tag-llm { background: #1a2a3a; color: var(--accent); }
|
||||
.tag-warn { background: #3a2a1a; color: var(--orange); }
|
||||
.tag-future { background: #2a1a3a; color: #bc8cff; }
|
||||
.nav { margin-bottom: 24px; }
|
||||
.nav a { color: var(--accent); text-decoration: none; }
|
||||
.nav a:hover { text-decoration: underline; }
|
||||
hr { border: none; border-top: 1px solid var(--border); margin: 32px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="nav"><a href="/">← Вернуться к загрузке</a></div>
|
||||
|
||||
<h1>🏗️ Архитектура — сверка договоров</h1>
|
||||
|
||||
<svg viewBox="0 0 860 280" style="width:100%;max-width:860px;margin:16px 0;" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<marker id="arrow" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#58a6ff"/></marker>
|
||||
<linearGradient id="g1" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#1a3a2a"/><stop offset="100%" stop-color="#0d2818"/></linearGradient>
|
||||
<linearGradient id="g2" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#1a2a3a"/><stop offset="100%" stop-color="#0d1a28"/></linearGradient>
|
||||
<linearGradient id="g3" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#2a1a3a"/><stop offset="100%" stop-color="#1a0d28"/></linearGradient>
|
||||
<linearGradient id="g4" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#3a2a1a"/><stop offset="100%" stop-color="#281a0d"/></linearGradient>
|
||||
<filter id="shadow"><feDropShadow dx="0" dy="1" stdDeviation="2" flood-opacity="0.3"/></filter>
|
||||
</defs>
|
||||
|
||||
<!-- Row 1: Pipeline -->
|
||||
<!-- 1. Upload -->
|
||||
<rect x="10" y="30" width="120" height="80" rx="8" fill="url(#g1)" stroke="#3fb950" stroke-width="1.5" filter="url(#shadow)"/>
|
||||
<text x="70" y="60" text-anchor="middle" fill="#3fb950" font-size="13" font-weight="bold">📤 Загрузка</text>
|
||||
<text x="70" y="80" text-anchor="middle" fill="#8b949e" font-size="10">.docx .pdf .doc .zip</text>
|
||||
<text x="70" y="96" text-anchor="middle" fill="#8b949e" font-size="9">Whitelist форматов</text>
|
||||
|
||||
<line x1="132" y1="70" x2="160" y2="70" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- 2. Parse -->
|
||||
<rect x="165" y="30" width="120" height="80" rx="8" fill="url(#g2)" stroke="#58a6ff" stroke-width="1.5" filter="url(#shadow)"/>
|
||||
<text x="225" y="60" text-anchor="middle" fill="#58a6ff" font-size="13" font-weight="bold">🔍 Парсинг</text>
|
||||
<text x="225" y="80" text-anchor="middle" fill="#8b949e" font-size="10">pdfplumber</text>
|
||||
<text x="225" y="96" text-anchor="middle" fill="#8b949e" font-size="10">python-docx</text>
|
||||
|
||||
<line x1="287" y1="70" x2="315" y2="70" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- 3. Classify -->
|
||||
<rect x="320" y="30" width="130" height="80" rx="8" fill="url(#g3)" stroke="#bc8cff" stroke-width="1.5" filter="url(#shadow)"/>
|
||||
<text x="385" y="55" text-anchor="middle" fill="#bc8cff" font-size="13" font-weight="bold">🏷️ Классификация</text>
|
||||
<text x="385" y="73" text-anchor="middle" fill="#8b949e" font-size="9">3-этапный фильтр</text>
|
||||
<text x="385" y="88" text-anchor="middle" fill="#8b949e" font-size="9">имя → заголовки → LLM</text>
|
||||
<text x="385" y="103" text-anchor="middle" fill="#f85149" font-size="8">мусор отсеивается</text>
|
||||
|
||||
<line x1="452" y1="70" x2="480" y2="70" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- 4. Group -->
|
||||
<rect x="485" y="30" width="120" height="80" rx="8" fill="url(#g4)" stroke="#d2991d" stroke-width="1.5" filter="url(#shadow)"/>
|
||||
<text x="545" y="60" text-anchor="middle" fill="#d2991d" font-size="13" font-weight="bold">📋 Группировка</text>
|
||||
<text x="545" y="80" text-anchor="middle" fill="#8b949e" font-size="10">по номерам</text>
|
||||
<text x="545" y="96" text-anchor="middle" fill="#8b949e" font-size="10">договоров</text>
|
||||
|
||||
<line x1="607" y1="70" x2="635" y2="70" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- 5. Compare -->
|
||||
<rect x="640" y="30" width="130" height="80" rx="8" fill="url(#g2)" stroke="#58a6ff" stroke-width="1.5" filter="url(#shadow)"/>
|
||||
<text x="705" y="55" text-anchor="middle" fill="#58a6ff" font-size="13" font-weight="bold">🤖 LLM-сравнение</text>
|
||||
<text x="705" y="73" text-anchor="middle" fill="#8b949e" font-size="9">extract + diff</text>
|
||||
<text x="705" y="88" text-anchor="middle" fill="#8b949e" font-size="9">gpt-oss-120b</text>
|
||||
<text x="705" y="103" text-anchor="middle" fill="#f85149" font-size="8">ADD UPDATE DELETE</text>
|
||||
|
||||
<!-- Row 2: Protection layer -->
|
||||
<rect x="10" y="140" width="860" height="55" rx="8" fill="#1a0a0a" stroke="#30363d" stroke-width="1" stroke-dasharray="4,3"/>
|
||||
<text x="440" y="162" text-anchor="middle" fill="#f85149" font-size="12" font-weight="bold">🛡️ Защита на каждом этапе</text>
|
||||
<text x="440" y="182" text-anchor="middle" fill="#8b949e" font-size="10">ZIP-бомба (500 файлов, 500MB, ratio 100×) | Path traversal | Whitelist форматов | content_hash дедупликация | UNRESOLVED для битых данных | авто-нормализация чисел/дат | арифметическая проверка</text>
|
||||
|
||||
<line x1="440" y1="128" x2="440" y2="138" stroke="#f85149" stroke-width="1" stroke-dasharray="2,2"/>
|
||||
|
||||
<!-- Row 3: Event Sourcing output -->
|
||||
<rect x="10" y="220" width="860" height="40" rx="8" fill="#161b22" stroke="#3fb950" stroke-width="1.5" filter="url(#shadow)"/>
|
||||
<text x="440" y="245" text-anchor="middle" fill="#3fb950" font-size="12" font-weight="bold">📊 Результат: spec_events (аудит) → spec_current (текущая спецификация) | Промпты с версионированием и редактором | Промежуточные результаты по каждому файлу</text>
|
||||
</svg>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>1. Загрузка файлов</h2>
|
||||
<p>Пользователь выбирает <code>.docx</code> / <code>.pdf</code> / <code>.doc</code> / <code>.zip</code>.</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>PDF:</strong> <code>pdfplumber</code> извлекает текст и таблицы с сохранением структуры колонок</li>
|
||||
<li><strong>DOCX:</strong> <code>python-docx</code> читает XML — извлекает параграфы и таблицы построчно</li>
|
||||
<li><strong>DOC:</strong> обрабатывается как DOCX (python-docx читает большинство .doc)</li>
|
||||
<li><strong>ZIP:</strong> архив распаковывается с лимитом 500 файлов и проверкой на сжатие (защита от ZIP-бомбы). Каждый файл внутри проходит тот же путь загрузки</li>
|
||||
<li>Файлы не поддерживаемых форматов — отклоняются с пометкой <code>unknown_format</code></li>
|
||||
</ul>
|
||||
|
||||
<p><strong>Результат:</strong> <code>elements_json</code> — массив элементов двух типов:</p>
|
||||
<ul>
|
||||
<li><code>{type: "paragraph", text: "...", style: "..."}</code></li>
|
||||
<li><code>{type: "table", rows: [["колонка1", "колонка2"], ...]}</code></li>
|
||||
</ul>
|
||||
|
||||
<p>Документ сохраняется в БД, считается <code>content_hash</code> (SHA-256) — если в этом же батче уже есть файл с таким содержимым, он помечается как <code>duplicate</code> и не дублируется.</p>
|
||||
|
||||
<blockquote>
|
||||
📋 <strong>Заказчик:</strong> «100+ файлов за раз. Файлы: .docx/.pdf, возможно ZIP.<br>
|
||||
Загрузка из локали (файловая шара / облачный диск), НЕ S3 (конфиденциальность).»
|
||||
</blockquote>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>2. Классификация документов</h2>
|
||||
<p>Каждый загруженный файл проходит <strong>трёхэтапный фильтр</strong>.</p>
|
||||
|
||||
<blockquote>
|
||||
📋 <strong>Заказчик:</strong> «Оставлять только: договоры, доп. соглашения, спецификации.<br>
|
||||
Игнорировать: акты сверки, счета/счета-фактуры/УПД, акты услуг, платёжки.»
|
||||
</blockquote>
|
||||
|
||||
<h3>Этап 1 — имя файла <span class="tag tag-ok">0 токенов</span></h3>
|
||||
<p>Проверка имени файла на мусорные ключевые слова: «счёт», «акт», «платёж», «УПД», «сверка», «invoice» и др. Совпадение → <code>garbage</code>, дальше не идёт.</p>
|
||||
|
||||
<h3>Этап 2 — заголовки в тексте <span class="tag tag-ok">0 токенов</span></h3>
|
||||
<p>Проверка первых 2000 символов текста на заголовки: «СЧЕТ-ФАКТУРА», «АКТ СВЕРКИ», «АКТ ОКАЗАННЫХ УСЛУГ» и т.п. Совпадение → <code>garbage</code>.</p>
|
||||
|
||||
<blockquote>
|
||||
📋 <strong>Заказчик:</strong> «Названия документов неинформативные. Есть лишние документы.<br>
|
||||
По всем контрагентам.»
|
||||
</blockquote>
|
||||
|
||||
<h3>Этап 3 — LLM <span class="tag tag-llm">LLM</span></h3>
|
||||
<p>Оставшиеся документы отправляются в LLM. Модель получает выжимку текста (первые 1500 символов + строки с маркерами «договор», «соглашение», «спецификация») и возвращает JSON:</p>
|
||||
|
||||
<ul>
|
||||
<li><code>doc_type</code> — <code>contract</code> / <code>supplement</code> / <code>specification</code> / <code>other</code></li>
|
||||
<li><code>own_number</code> — номер этого документа</li>
|
||||
<li><code>parent_number</code> — номер родительского договора (для допников и спецификаций)</li>
|
||||
<li><code>doc_date</code> — дата документа YYYY-MM-DD</li>
|
||||
<li><code>counterparty</code> — название контрагента</li>
|
||||
</ul>
|
||||
|
||||
<blockquote>
|
||||
📋 <strong>Заказчик:</strong> «НУБЕС = Исполнитель во всех целевых договорах.»<br>
|
||||
→ counterparty = <strong>другая сторона</strong> (Заказчик).
|
||||
</blockquote>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>3. Группировка по договорам</h2>
|
||||
<p><strong>Детерминированная (без LLM).</strong> Документы группируются по номерам договоров.</p>
|
||||
|
||||
<blockquote>
|
||||
📋 <strong>Заказчик:</strong> «Система сама должна разбираться, кто к кому.<br>
|
||||
зачем нам базовый договор? Вся информация есть в допниках и спеках.»<br>
|
||||
«Я не должен распознавать ничего. Система должна сделать всё.»
|
||||
</blockquote>
|
||||
|
||||
<ul>
|
||||
<li>Номера нормализуются: uppercase, удаляются все символы кроме букв и цифр.<br>
|
||||
«МЭС-123/2024» → «МЭС1232024»</li>
|
||||
<li>Допники и спецификации с одинаковым <code>parent_number</code> попадают в <strong>одну виртуальную группу</strong> — даже если сам договор не загружен</li>
|
||||
<li>Неопознанные файлы → отдельная группа «Не распознано» с указанием причины</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>4. Сравнение — извлечение + дифф</h2>
|
||||
<p>Для каждой группы запускается последовательная обработка.</p>
|
||||
|
||||
<blockquote>
|
||||
📋 <strong>Заказчик:</strong> «Важно. В произвольной, не повторяющейся.» (порядок допников)<br>
|
||||
«Главное — точность разбора, не UI. Сначала базовая функция → потом юзабельность.»
|
||||
</blockquote>
|
||||
|
||||
<h3>4a. Сортировка</h3>
|
||||
<p>Документы внутри группы сортируются по <code>doc_date</code> (из классификации), затем по времени загрузки.</p>
|
||||
|
||||
<h3>4b. Базовый договор (первый в группе) <span class="tag tag-llm">LLM</span></h3>
|
||||
<ul>
|
||||
<li>Текст спецификации услуг → LLM с промптом <code>extract</code></li>
|
||||
<li>LLM получает текст и инструкцию «найди таблицу услуг, извлеки каждую строку как ADD»</li>
|
||||
<li>Ответ: массив операций ADD — каждая услуга с полями: название, цена, количество, сумма, дата начала</li>
|
||||
</ul>
|
||||
|
||||
<h3>4c. Дополнительные соглашения <span class="tag tag-llm">LLM</span></h3>
|
||||
<ul>
|
||||
<li>Текущая спецификация + текст допника → LLM с промптом <code>diff</code></li>
|
||||
<li>LLM определяет изменения:
|
||||
<ul>
|
||||
<li><code>ADD</code> — новая услуга</li>
|
||||
<li><code>UPDATE</code> — изменение цены/количества/суммы</li>
|
||||
<li><code>DELETE</code> — услуга исключена</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3>4d. Режим «изложить в новой редакции»</h3>
|
||||
<p>Если допник говорит «изложить в новой редакции» — LLM возвращает полный новый список (<code>full_replace</code>). Все старые строки заменяются новыми.</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>5. Защита от дурака и злонамеренности <span class="tag tag-warn">защита</span></h2>
|
||||
|
||||
<table style="width:100%;border-collapse:collapse;margin:12px 0;border:1px solid var(--border)">
|
||||
<tr style="background:var(--code-bg)">
|
||||
<th style="padding:8px;text-align:left;border:1px solid var(--border)">Угроза</th>
|
||||
<th style="padding:8px;text-align:left;border:1px solid var(--border)">Защита</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">ZIP-бомба (архив 1 KB → 10 GB)</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Лимит 500 файлов, 500 MB, ratio сжатия ≤ 100×</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Path traversal в ZIP (<code>../../etc/passwd</code>)</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Проверка имени файла ДО <code>os.path.basename</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Битые кодировки имён в ZIP</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">cp437 → utf8 перекодировка</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Файлы не-Word/PDF (exe, картинки, etc.)</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Whitelist: только <code>pdf/docx/doc/zip</code> — остальные <code>unknown_format</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Дубликаты файлов (тот же контент, другое имя)</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)"><code>sha256(original_bytes)</code> → пропуск с пометкой <code>duplicate</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Дубликаты услуг (одна услуга в разных допниках)</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">UPSERT по <code>name_hash</code> (имя + дата начала) — разные периоды = разные строки</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">ADD без названия услуги</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">→ <code>UNRESOLVED</code>, не применяется</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">UPDATE/DELETE без идентификатора цели</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Пустой <code>target_hash</code> → <code>UNRESOLVED</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Неизвестный тип операции от LLM</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">→ <code>UNRESOLVED</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Кривые числа (<code>1 000,50</code>)</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Авто-нормализация: пробелы → удалить, запятая → точка</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Кривые даты (<code>01.03.2026</code>)</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Авто-нормализация: DD.MM.YYYY → YYYY-MM-DD</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Арифметические ошибки</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Проверка <code>price × qty = sum</code> — расхождение → флаг</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Мусорные документы (счета, акты, платёжки)</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Трёхэтапный фильтр: имя файла → заголовки (2000 симв.) → LLM</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Тихие ошибки (<code>try/except: pass</code>)</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Везде <code>logging.warning</code> с контекстом</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<blockquote>
|
||||
📋 <strong>Заказчик:</strong> «кривых документов можно ожидать.<br>
|
||||
Если не может разобраться — пусть зовет на помощь юзера.»
|
||||
</blockquote>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>6. Event Sourcing — аудит <span class="tag tag-ok">аудит</span></h2>
|
||||
|
||||
<blockquote>
|
||||
📋 <strong>Заказчик:</strong> «Наверно я захочу иметь возможность посмотреть любые промежуточные результаты.<br>
|
||||
Было бы хорошо пояснить или показать процесс, что в каком порядке происходит.»
|
||||
</blockquote>
|
||||
|
||||
<ul>
|
||||
<li><strong>spec_events:</strong> полный лог всех операций. Каждое событие содержит:
|
||||
<ul>
|
||||
<li>тип (<code>ADD/UPDATE/DELETE/UNRESOLVED</code>)</li>
|
||||
<li>источник — какой документ</li>
|
||||
<li>версия промпта</li>
|
||||
<li>полный сырой ответ LLM</li>
|
||||
<li>временную метку</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>spec_current:</strong> материализованное представление = сумма всех событий</li>
|
||||
<li><strong>Промежуточные результаты:</strong> для каждого файла доступны:
|
||||
<ul>
|
||||
<li>текст, отправленный в LLM</li>
|
||||
<li>сырой ответ LLM</li>
|
||||
<li>распарсенный JSON операций</li>
|
||||
<li>какие операции были применены</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>7. Промпты <span class="tag tag-llm">LLM</span></h2>
|
||||
<p>Три роли промптов, хранятся в БД с версионированием:</p>
|
||||
|
||||
<table style="width:100%;border-collapse:collapse;margin:12px 0;border:1px solid var(--border)">
|
||||
<tr style="background:var(--code-bg)">
|
||||
<th style="padding:8px;text-align:left;border:1px solid var(--border)">Роль</th>
|
||||
<th style="padding:8px;text-align:left;border:1px solid var(--border)">Назначение</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)"><code>extract</code></td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Извлечение строк спецификации из договора</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)"><code>diff</code></td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Сравнение допников с текущей спецификацией</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)"><code>classify</code></td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Определение типа/номера/даты/контрагента документа</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p><strong>Встроенный редактор промптов</strong> с полным CRUD и историей версий:</p>
|
||||
<ul>
|
||||
<li>Создание, редактирование, удаление промптов — через веб-интерфейс</li>
|
||||
<li>Активация версии — один клик, и она сразу используется LLM</li>
|
||||
<li>История версий — все предыдущие редакции сохраняются, можно откатиться</li>
|
||||
<li>Без правки кода и без редеплоя — юрист может сам экспериментировать с промптами</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>8. Хранение документов <span class="tag tag-ok">конфиденциальность</span></h2>
|
||||
|
||||
<blockquote>
|
||||
📋 <strong>Заказчик:</strong> «Загрузка из локали, НЕ S3 (конфиденциальность).»
|
||||
</blockquote>
|
||||
|
||||
<p><strong>Исходные бинарные файлы (.docx/.pdf/.doc/zip) удаляются с сервера сразу после парсинга.</strong> На диске не остаются.</p>
|
||||
<p><strong>В БД сохраняются</strong> — это и есть рабочие данные системы:</p>
|
||||
<ul>
|
||||
<li>Метаданные: имя файла, дата, хеш содержимого</li>
|
||||
<li>Результат парсинга: <code>elements_json</code> (текст и таблицы)</li>
|
||||
<li>Результат классификации: тип, номер, дата, контрагент</li>
|
||||
<li>События спецификации: ADD/UPDATE/DELETE</li>
|
||||
</ul>
|
||||
|
||||
<p style="color:var(--orange);"><strong>⚠️ Пока не реализовано:</strong> удаление промежуточных данных после сессии. Варианты: кнопка «Очистить» в интерфейсе, либо Redis с TTL. Надо решать.</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>9. Инженерные практики <span class="tag tag-ok">качество кода</span></h2>
|
||||
|
||||
<h3>Decoupling — разделение ответственности</h3>
|
||||
<p>Пайплайн разбит на независимые слои с чёткими интерфейсами:</p>
|
||||
|
||||
<table style="width:100%;border-collapse:collapse;margin:12px 0;border:1px solid var(--border)">
|
||||
<tr style="background:var(--code-bg)">
|
||||
<th style="padding:8px;text-align:left;border:1px solid var(--border)">Слой</th>
|
||||
<th style="padding:8px;text-align:left;border:1px solid var(--border)">Что делает</th>
|
||||
<th style="padding:8px;text-align:left;border:1px solid var(--border)">Можно заменить отдельно</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Контракты данных</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">5 frozen dataclass'ов: <code>ParseResult</code>, <code>ClassifyResult</code>, <code>GroupingResult</code>, <code>BatchGroupingResult</code>, <code>CompareOp</code></td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Формат данных не привязан к хранилищу</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">LLM-клиент</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Протокол <code>LLMClient.complete(prompt) → str</code></td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">HttpxLLMClient ↔ FakeLLMClient ↔ другая модель</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Репозиторий</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Протокол <code>Repository</code> (17 методов)</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">PgRepository ↔ MemRepository (тесты)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Загрузка</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)"><code>parse_multipart()</code> — чистая функция</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Парсер не зависит от HTTP-фреймворка</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Классификация</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)"><code>classify_batch(batch_id, llm_client, repo)</code> — Dependency Injection</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Любой LLM + любое хранилище</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3>Dependency Injection</h3>
|
||||
<p>Все внешние зависимости (LLM, БД) пробрасываются через параметры, а не через глобальные <code>import</code>:</p>
|
||||
<ul>
|
||||
<li><code>call_llm(prompt, llm_client=None)</code> — DI с обратной совместимостью</li>
|
||||
<li><code>classify_batch(batch_id, llm_client=None, repo=None)</code> — можно подставить любой backend</li>
|
||||
<li>В продакшене: HttpxLLMClient + PgRepository</li>
|
||||
<li>В тестах: FakeLLMClient (record/replay) + MemRepository (в памяти)</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>10. Тестирование <span class="tag tag-ok">26/26 PASS</span></h2>
|
||||
|
||||
<table style="width:100%;border-collapse:collapse;margin:12px 0;border:1px solid var(--border)">
|
||||
<tr style="background:var(--code-bg)">
|
||||
<th style="padding:8px;text-align:left;border:1px solid var(--border)">Модуль</th>
|
||||
<th style="padding:8px;text-align:left;border:1px solid var(--border)">Тестов</th>
|
||||
<th style="padding:8px;text-align:left;border:1px solid var(--border)">Что проверяют</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Контракты + загрузка</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">13</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Создание dataclass'ов, garbage-фильтр, multipart-парсинг, path-traversal</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">LLM + Репозиторий</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">9</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">FakeLLMClient, MemRepository CRUD, все 17 методов</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Классификация</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">4</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Garbage по имени файла, garbage по заголовкам, classify с FakeLLM, no pending</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>Все тесты проходят без доступа к БД и LLM — через <code>FakeLLMClient</code> (record/replay) и <code>MemRepository</code>. Запуск: <code>pytest deploy/tests/ -v</code>.</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>11. Стек технологий</h2>
|
||||
|
||||
<table style="width:100%;border-collapse:collapse;margin:12px 0;border:1px solid var(--border)">
|
||||
<tr style="background:var(--code-bg)">
|
||||
<th style="padding:8px;text-align:left;border:1px solid var(--border)">Компонент</th>
|
||||
<th style="padding:8px;text-align:left;border:1px solid var(--border)">Где</th>
|
||||
<th style="padding:8px;text-align:left;border:1px solid var(--border)">Технология</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Веб-интерфейс</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Managed Python (Nubes)</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Python 3.12, Jinja2, ванильный JS</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Механизм обработки</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Выделенная VM</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Python 3.12, httpx, pdfplumber, python-docx</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">База данных</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">VM PostgreSQL 15</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">JSONB, UUID, advisory locks</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">LLM</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">api.aillm.ru</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">gpt-oss-120b (бесплатно, OpenAI API)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Деплой</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Managed Python + VM</td>
|
||||
<td style="padding:8px;border:1px solid var(--border)">Dockerfile, Nginx + Let's Encrypt</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>12. Конечная цель <span class="tag tag-future">будущее</span></h2>
|
||||
|
||||
<blockquote>
|
||||
📋 <strong>Заказчик:</strong> «Конечная цель: построчное сравнение CRM ↔ фискальная система.<br>
|
||||
Особый фокус: даты начала услуг.<br>
|
||||
PAYG (суффикс -m) — особый случай. Сверку с CRM тоже можно делегировать AI.»
|
||||
</blockquote>
|
||||
|
||||
<p>Текущий пайплайн завершается на этапе <strong>извлечения спецификации из договоров и отслеживания изменений по допникам</strong>. Модуль сравнения с CRM — спроектирован как <code>CanonicalRow</code> адаптер, ожидает формата данных от заказчика.</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>13. Что дальше <span class="tag tag-future">требует участия Заказчика</span></h2>
|
||||
|
||||
<blockquote>
|
||||
📋 <strong>Заказчик:</strong> «Это всё пока опыты и набивание шишек.<br>
|
||||
Не надеюсь с первого захода на идеальный результат.»
|
||||
</blockquote>
|
||||
|
||||
<ol>
|
||||
<li><strong>Золотой набор:</strong> 30–50 реальных документов с ручной разметкой — измерить точность LLM</li>
|
||||
<li><strong>Формат CRM:</strong> в каком виде данные из системы учёта клиентов — для модуля сверки</li>
|
||||
<li><strong>Приоритет:</strong> точность vs скорость обработки — влияет на выбор модели LLM (локальная vs облачная)</li>
|
||||
</ol>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,300 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Архитектура — блок-схемы</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117; --fg: #c9d1d9; --muted: #8b949e;
|
||||
--accent: #58a6ff; --green: #3fb950; --border: #30363d;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font: 15px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--fg); max-width: 960px; margin: 0 auto; padding: 32px 20px 80px; }
|
||||
h1 { font-size: 26px; margin-bottom: 8px; }
|
||||
h2 { font-size: 18px; margin-top: 48px; margin-bottom: 16px; color: var(--accent); }
|
||||
.nav { margin-bottom: 24px; }
|
||||
.nav a { color: var(--accent); text-decoration: none; }
|
||||
.nav a:hover { text-decoration: underline; }
|
||||
svg { display: block; max-width: 100%; height: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="nav"><a href="/docs/architect">← Архитектура (текст)</a></div>
|
||||
|
||||
<h1>🏗️ Архитектура — блок-схемы</h1>
|
||||
|
||||
<h2>1. Система — компоненты и связи</h2>
|
||||
|
||||
<svg viewBox="0 0 920 340" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<marker id="a1" markerWidth="7" markerHeight="5" refX="7" refY="2.5" orient="auto"><path d="M0,0 L7,2.5 L0,5 Z" fill="#58a6ff"/></marker>
|
||||
<marker id="a2" markerWidth="7" markerHeight="5" refX="7" refY="2.5" orient="auto"><path d="M0,0 L7,2.5 L0,5 Z" fill="#3fb950"/></marker>
|
||||
<filter id="sh"><feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.25"/></filter>
|
||||
<linearGradient id="gradFlask" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#1a2a3a"/><stop offset="100%" stop-color="#0d1a28"/></linearGradient>
|
||||
<linearGradient id="gradVM" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#1a2a1a"/><stop offset="100%" stop-color="#0d1a0d"/></linearGradient>
|
||||
<linearGradient id="gradDB" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#2a1a2a"/><stop offset="100%" stop-color="#1a0d1a"/></linearGradient>
|
||||
<linearGradient id="gradLLM" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#3a2a1a"/><stop offset="100%" stop-color="#281a0d"/></linearGradient>
|
||||
<linearGradient id="gradUser" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#2a1a1a"/><stop offset="100%" stop-color="#1a0d0d"/></linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- User -->
|
||||
<rect x="30" y="100" width="140" height="90" rx="10" fill="url(#gradUser)" stroke="#f85149" stroke-width="1.5" filter="url(#sh)"/>
|
||||
<text x="100" y="135" text-anchor="middle" fill="#f85149" font-size="15" font-weight="bold">👤 Пользователь</text>
|
||||
<text x="100" y="155" text-anchor="middle" fill="#8b949e" font-size="11">Браузер</text>
|
||||
<text x="100" y="172" text-anchor="middle" fill="#8b949e" font-size="11">Загрузка файлов</text>
|
||||
|
||||
<!-- Arrow User → Flask -->
|
||||
<line x1="172" y1="130" x2="230" y2="80" stroke="#f85149" stroke-width="1.5" marker-end="url(#a1)"/>
|
||||
<text x="205" y="95" fill="#8b949e" font-size="9">HTTPS</text>
|
||||
|
||||
<line x1="172" y1="150" x2="230" y2="150" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#a1)"/>
|
||||
<text x="205" y="143" fill="#8b949e" font-size="9">SSE</text>
|
||||
|
||||
<!-- Flask -->
|
||||
<rect x="235" y="50" width="160" height="120" rx="10" fill="url(#gradFlask)" stroke="#58a6ff" stroke-width="1.5" filter="url(#sh)"/>
|
||||
<text x="315" y="80" text-anchor="middle" fill="#58a6ff" font-size="14" font-weight="bold">⚡ Flask-фронтенд</text>
|
||||
<text x="315" y="100" text-anchor="middle" fill="#8b949e" font-size="10">Managed Python (Nubes)</text>
|
||||
<text x="315" y="118" text-anchor="middle" fill="#8b949e" font-size="10">Jinja2-шаблоны</text>
|
||||
<text x="315" y="136" text-anchor="middle" fill="#8b949e" font-size="10">Промпт-редактор</text>
|
||||
<text x="315" y="154" text-anchor="middle" fill="#8b949e" font-size="10">Чат с LLM</text>
|
||||
|
||||
<!-- Arrow Flask → VM -->
|
||||
<line x1="397" y1="110" x2="455" y2="110" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#a1)"/>
|
||||
<text x="430" y="103" fill="#8b949e" font-size="9">REST API</text>
|
||||
|
||||
<!-- VM box -->
|
||||
<rect x="460" y="15" width="230" height="210" rx="10" fill="none" stroke="#30363d" stroke-width="1" stroke-dasharray="6,4"/>
|
||||
<text x="575" y="35" text-anchor="middle" fill="#8b949e" font-size="11">Выделенная VM</text>
|
||||
|
||||
<!-- Parsing -->
|
||||
<rect x="480" y="50" width="190" height="50" rx="8" fill="url(#gradVM)" stroke="#3fb950" stroke-width="1" filter="url(#sh)"/>
|
||||
<text x="575" y="72" text-anchor="middle" fill="#3fb950" font-size="12" font-weight="bold">📄 Парсинг документов</text>
|
||||
<text x="575" y="90" text-anchor="middle" fill="#8b949e" font-size="10">pdfplumber · python-docx</text>
|
||||
|
||||
<!-- Classification -->
|
||||
<rect x="480" y="110" width="190" height="50" rx="8" fill="url(#gradVM)" stroke="#bc8cff" stroke-width="1" filter="url(#sh)"/>
|
||||
<text x="575" y="132" text-anchor="middle" fill="#bc8cff" font-size="12" font-weight="bold">🏷️ Классификация</text>
|
||||
<text x="575" y="150" text-anchor="middle" fill="#8b949e" font-size="10">3-этапный фильтр мусора</text>
|
||||
|
||||
<!-- Comparison -->
|
||||
<rect x="480" y="170" width="190" height="45" rx="8" fill="url(#gradVM)" stroke="#d2991d" stroke-width="1" filter="url(#sh)"/>
|
||||
<text x="575" y="190" text-anchor="middle" fill="#d2991d" font-size="12" font-weight="bold">🤖 LLM-сравнение</text>
|
||||
<text x="575" y="207" text-anchor="middle" fill="#8b949e" font-size="10">extract + diff · группировка</text>
|
||||
|
||||
<!-- Internal arrows -->
|
||||
<line x1="575" y1="100" x2="575" y2="108" stroke="#58a6ff" stroke-width="1" marker-end="url(#a1)"/>
|
||||
<line x1="575" y1="160" x2="575" y2="168" stroke="#58a6ff" stroke-width="1" marker-end="url(#a1)"/>
|
||||
|
||||
<!-- Arrow VM → LLM -->
|
||||
<line x1="692" y1="140" x2="750" y2="90" stroke="#d2991d" stroke-width="1.5" marker-end="url(#a1)"/>
|
||||
<text x="725" y="108" fill="#8b949e" font-size="9">HTTP/2</text>
|
||||
|
||||
<!-- LLM -->
|
||||
<rect x="755" y="60" width="140" height="80" rx="10" fill="url(#gradLLM)" stroke="#d2991d" stroke-width="1.5" filter="url(#sh)"/>
|
||||
<text x="825" y="90" text-anchor="middle" fill="#d2991d" font-size="13" font-weight="bold">🧠 LLM</text>
|
||||
<text x="825" y="110" text-anchor="middle" fill="#8b949e" font-size="10">api.aillm.ru</text>
|
||||
<text x="825" y="128" text-anchor="middle" fill="#8b949e" font-size="10">gpt-oss-120b</text>
|
||||
|
||||
<!-- Arrow VM ↔ DB -->
|
||||
<line x1="575" y1="228" x2="575" y2="260" stroke="#bc8cff" stroke-width="1.5" marker-end="url(#a1)"/>
|
||||
|
||||
<!-- DB -->
|
||||
<rect x="470" y="265" width="210" height="55" rx="10" fill="url(#gradDB)" stroke="#bc8cff" stroke-width="1.5" filter="url(#sh)"/>
|
||||
<text x="575" y="290" text-anchor="middle" fill="#bc8cff" font-size="13" font-weight="bold">🗄️ PostgreSQL 15</text>
|
||||
<text x="575" y="310" text-anchor="middle" fill="#8b949e" font-size="10">JSONB · Event Sourcing · spec_events</text>
|
||||
</svg>
|
||||
|
||||
<h2>2. Пайплайн обработки одного файла</h2>
|
||||
|
||||
<svg viewBox="0 0 920 280" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<marker id="b1" markerWidth="6" markerHeight="4" refX="6" refY="2" orient="auto"><path d="M0,0 L6,2 L0,4 Z" fill="#58a6ff"/></marker>
|
||||
<marker id="b2" markerWidth="6" markerHeight="4" refX="6" refY="2" orient="auto"><path d="M0,0 L6,2 L0,4 Z" fill="#f85149"/></marker>
|
||||
<filter id="sh2"><feDropShadow dx="0" dy="1" stdDeviation="2" flood-opacity="0.2"/></filter>
|
||||
</defs>
|
||||
|
||||
<!-- Stage 1 -->
|
||||
<rect x="10" y="30" width="135" height="90" rx="8" fill="#1a2a1a" stroke="#3fb950" stroke-width="1.5" filter="url(#sh2)"/>
|
||||
<text x="77" y="55" text-anchor="middle" fill="#3fb950" font-size="12" font-weight="bold">📤 Загрузка</text>
|
||||
<text x="77" y="73" text-anchor="middle" fill="#8b949e" font-size="9">Multipart-парсинг</text>
|
||||
<text x="77" y="88" text-anchor="middle" fill="#8b949e" font-size="9">SHA-256 хеш</text>
|
||||
<text x="77" y="103" text-anchor="middle" fill="#8b949e" font-size="9">Формат: pdf/docx/zip</text>
|
||||
|
||||
<line x1="147" y1="75" x2="172" y2="75" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#b1)"/>
|
||||
|
||||
<!-- Stage 2 -->
|
||||
<rect x="177" y="30" width="135" height="90" rx="8" fill="#1a2a3a" stroke="#58a6ff" stroke-width="1.5" filter="url(#sh2)"/>
|
||||
<text x="244" y="55" text-anchor="middle" fill="#58a6ff" font-size="12" font-weight="bold">🔍 Парсинг</text>
|
||||
<text x="244" y="73" text-anchor="middle" fill="#8b949e" font-size="9">Извлечение текста</text>
|
||||
<text x="244" y="88" text-anchor="middle" fill="#8b949e" font-size="9">Извлечение таблиц</text>
|
||||
<text x="244" y="103" text-anchor="middle" fill="#8b949e" font-size="9">→ elements_json</text>
|
||||
|
||||
<line x1="314" y1="75" x2="339" y2="75" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#b1)"/>
|
||||
|
||||
<!-- Stage 3a -->
|
||||
<rect x="344" y="30" width="145" height="90" rx="8" fill="#2a1a3a" stroke="#bc8cff" stroke-width="1.5" filter="url(#sh2)"/>
|
||||
<text x="416" y="52" text-anchor="middle" fill="#bc8cff" font-size="12" font-weight="bold">🏷️ Фильтр #1-#2</text>
|
||||
<text x="416" y="68" text-anchor="middle" fill="#8b949e" font-size="9">Имя файла → мусор?</text>
|
||||
<text x="416" y="83" text-anchor="middle" fill="#8b949e" font-size="9">Заголовки → мусор?</text>
|
||||
<text x="416" y="98" text-anchor="middle" fill="#8b949e" font-size="9">0 токенов LLM</text>
|
||||
<text x="416" y="113" text-anchor="middle" fill="#f85149" font-size="8">мусор → стоп</text>
|
||||
|
||||
<line x1="491" y1="75" x2="516" y2="75" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#b1)"/>
|
||||
|
||||
<!-- Stage 3b -->
|
||||
<rect x="521" y="30" width="145" height="90" rx="8" fill="#2a2a1a" stroke="#d2991d" stroke-width="1.5" filter="url(#sh2)"/>
|
||||
<text x="593" y="52" text-anchor="middle" fill="#d2991d" font-size="12" font-weight="bold">🤖 Фильтр #3 — LLM</text>
|
||||
<text x="593" y="68" text-anchor="middle" fill="#8b949e" font-size="9">Тип: договор/допник/</text>
|
||||
<text x="593" y="83" text-anchor="middle" fill="#8b949e" font-size="9">спецификация/мусор</text>
|
||||
<text x="593" y="98" text-anchor="middle" fill="#8b949e" font-size="9">Номер, дата, контрагент</text>
|
||||
<text x="593" y="113" text-anchor="middle" fill="#f85149" font-size="8">мусор → стоп</text>
|
||||
|
||||
<line x1="668" y1="75" x2="693" y2="75" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#b1)"/>
|
||||
|
||||
<!-- Stage 4 -->
|
||||
<rect x="698" y="30" width="100" height="90" rx="8" fill="#3a2a1a" stroke="#d2991d" stroke-width="1.5" filter="url(#sh2)"/>
|
||||
<text x="748" y="60" text-anchor="middle" fill="#d2991d" font-size="12" font-weight="bold">📋 Группи-</text>
|
||||
<text x="748" y="78" text-anchor="middle" fill="#d2991d" font-size="12" font-weight="bold">ровка</text>
|
||||
<text x="748" y="96" text-anchor="middle" fill="#8b949e" font-size="9">По номерам</text>
|
||||
<text x="748" y="111" text-anchor="middle" fill="#8b949e" font-size="9">договоров</text>
|
||||
|
||||
<line x1="800" y1="75" x2="825" y2="75" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#b1)"/>
|
||||
|
||||
<!-- Stage 5 -->
|
||||
<rect x="830" y="30" width="80" height="90" rx="8" fill="#1a3a2a" stroke="#3fb950" stroke-width="1.5" filter="url(#sh2)"/>
|
||||
<text x="870" y="60" text-anchor="middle" fill="#3fb950" font-size="12" font-weight="bold">Срав-</text>
|
||||
<text x="870" y="78" text-anchor="middle" fill="#3fb950" font-size="12" font-weight="bold">нение</text>
|
||||
<text x="870" y="100" text-anchor="middle" fill="#8b949e" font-size="9">ADD</text>
|
||||
<text x="870" y="114" text-anchor="middle" fill="#8b949e" font-size="9">UPDATE</text>
|
||||
|
||||
<!-- Protection Bar -->
|
||||
<rect x="10" y="145" width="900" height="40" rx="6" fill="#1a0a0a" stroke="#f85149" stroke-width="1" stroke-dasharray="3,2"/>
|
||||
<text x="460" y="170" text-anchor="middle" fill="#f85149" font-size="11" font-weight="bold">🛡️ Защита: ZIP-бомба (500/500/100×) · Path traversal · Whitelist форматов · content_hash · UNRESOLVED · нормализация чисел/дат · price×qty=sum</text>
|
||||
|
||||
<!-- Output Bar -->
|
||||
<rect x="10" y="210" width="900" height="50" rx="6" fill="#161b22" stroke="#3fb950" stroke-width="1.5" filter="url(#sh2)"/>
|
||||
<text x="460" y="233" text-anchor="middle" fill="#3fb950" font-size="12" font-weight="bold">📊 База: documents (метаданные + elements_json) · classify_results · spec_events (аудит) · spec_current (спецификация) · prompts (версионирование)</text>
|
||||
<text x="460" y="250" text-anchor="middle" fill="#8b949e" font-size="9">Бинарные файлы удаляются после парсинга. Промежуточные данные — требуется автоочистка.</text>
|
||||
</svg>
|
||||
|
||||
<h2>3. Event Sourcing — как работает сравнение</h2>
|
||||
|
||||
<svg viewBox="0 0 920 380" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<marker id="c1" markerWidth="6" markerHeight="4" refX="6" refY="2" orient="auto"><path d="M0,0 L6,2 L0,4 Z" fill="#58a6ff"/></marker>
|
||||
<filter id="sh3"><feDropShadow dx="0" dy="1" stdDeviation="2" flood-opacity="0.2"/></filter>
|
||||
</defs>
|
||||
|
||||
<!-- Documents row -->
|
||||
<text x="460" y="24" text-anchor="middle" fill="#8b949e" font-size="12">Документы группы (отсортированы по doc_date)</text>
|
||||
|
||||
<rect x="10" y="38" width="100" height="55" rx="6" fill="#1a2a3a" stroke="#58a6ff" stroke-width="1" filter="url(#sh3)"/>
|
||||
<text x="60" y="60" text-anchor="middle" fill="#58a6ff" font-size="10" font-weight="bold">📄 Договор</text>
|
||||
<text x="60" y="78" text-anchor="middle" fill="#8b949e" font-size="9">базовый</text>
|
||||
|
||||
<rect x="130" y="38" width="80" height="55" rx="6" fill="#1a2a3a" stroke="#58a6ff" stroke-width="1" filter="url(#sh3)"/>
|
||||
<text x="170" y="60" text-anchor="middle" fill="#58a6ff" font-size="10" font-weight="bold">📄 Допник</text>
|
||||
<text x="170" y="78" text-anchor="middle" fill="#8b949e" font-size="9">#1</text>
|
||||
|
||||
<rect x="230" y="38" width="80" height="55" rx="6" fill="#1a2a3a" stroke="#58a6ff" stroke-width="1" filter="url(#sh3)"/>
|
||||
<text x="270" y="60" text-anchor="middle" fill="#58a6ff" font-size="10" font-weight="bold">📄 Допник</text>
|
||||
<text x="270" y="78" text-anchor="middle" fill="#8b949e" font-size="9">#2</text>
|
||||
|
||||
<text x="350" y="70" fill="#8b949e" font-size="20">...</text>
|
||||
|
||||
<!-- Arrow to Extract -->
|
||||
<line x1="60" y1="95" x2="60" y2="130" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#c1)"/>
|
||||
|
||||
<!-- Extract box -->
|
||||
<rect x="10" y="135" width="200" height="70" rx="8" fill="#1a2a3a" stroke="#d2991d" stroke-width="1.5" filter="url(#sh3)"/>
|
||||
<text x="110" y="158" text-anchor="middle" fill="#d2991d" font-size="12" font-weight="bold">🤖 LLM: промпт extract</text>
|
||||
<text x="110" y="176" text-anchor="middle" fill="#8b949e" font-size="10">Извлечение всех услуг</text>
|
||||
<text x="110" y="194" text-anchor="middle" fill="#8b949e" font-size="10">→ массив ADD-операций</text>
|
||||
|
||||
<line x1="212" y1="170" x2="242" y2="170" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#c1)"/>
|
||||
|
||||
<!-- Events box -->
|
||||
<rect x="247" y="130" width="200" height="80" rx="8" fill="#2a1a2a" stroke="#bc8cff" stroke-width="1.5" filter="url(#sh3)"/>
|
||||
<text x="347" y="153" text-anchor="middle" fill="#bc8cff" font-size="12" font-weight="bold">🗄️ spec_events</text>
|
||||
<text x="347" y="171" text-anchor="middle" fill="#8b949e" font-size="10">ADD услуга X</text>
|
||||
<text x="347" y="187" text-anchor="middle" fill="#8b949e" font-size="10">ADD услуга Y</text>
|
||||
<text x="347" y="203" text-anchor="middle" fill="#8b949e" font-size="10">(неизменяемый лог)</text>
|
||||
|
||||
<line x1="347" y1="212" x2="347" y2="242" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#c1)"/>
|
||||
|
||||
<!-- Current spec -->
|
||||
<rect x="247" y="247" width="200" height="70" rx="8" fill="#1a2a1a" stroke="#3fb950" stroke-width="1.5" filter="url(#sh3)"/>
|
||||
<text x="347" y="272" text-anchor="middle" fill="#3fb950" font-size="12" font-weight="bold">📊 spec_current</text>
|
||||
<text x="347" y="292" text-anchor="middle" fill="#8b949e" font-size="10">Услуга X | 100₽ | 10 шт</text>
|
||||
<text x="347" y="308" text-anchor="middle" fill="#8b949e" font-size="10">Услуга Y | 200₽ | 5 шт</text>
|
||||
|
||||
<!-- Arrow back to LLM for diff -->
|
||||
<line x1="449" y1="282" x2="500" y2="282" stroke="#3fb950" stroke-width="1.5" marker-end="url(#c1)"/>
|
||||
|
||||
<!-- Diff box -->
|
||||
<rect x="505" y="135" width="200" height="70" rx="8" fill="#3a2a1a" stroke="#d2991d" stroke-width="1.5" filter="url(#sh3)"/>
|
||||
<text x="605" y="158" text-anchor="middle" fill="#d2991d" font-size="12" font-weight="bold">🤖 LLM: промпт diff</text>
|
||||
<text x="605" y="176" text-anchor="middle" fill="#8b949e" font-size="10">(допник #1) vs spec_current</text>
|
||||
<text x="605" y="194" text-anchor="middle" fill="#8b949e" font-size="10">→ ADD / UPDATE / DELETE</text>
|
||||
|
||||
<line x1="707" y1="170" x2="737" y2="170" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#c1)"/>
|
||||
|
||||
<!-- Events 2 -->
|
||||
<rect x="742" y="130" width="168" height="80" rx="8" fill="#2a1a2a" stroke="#bc8cff" stroke-width="1.5" filter="url(#sh3)"/>
|
||||
<text x="826" y="153" text-anchor="middle" fill="#bc8cff" font-size="12" font-weight="bold">🗄️ spec_events</text>
|
||||
<text x="826" y="171" text-anchor="middle" fill="#8b949e" font-size="10">UPDATE услуга X</text>
|
||||
<text x="826" y="187" text-anchor="middle" fill="#8b949e" font-size="10">DELETE услуга Y</text>
|
||||
<text x="826" y="203" text-anchor="middle" fill="#8b949e" font-size="10">ADD услуга Z</text>
|
||||
|
||||
<line x1="826" y1="212" x2="826" y2="242" stroke="#58a6ff" stroke-width="1.5" marker-end="url(#c1)"/>
|
||||
|
||||
<!-- Updated spec -->
|
||||
<rect x="726" y="247" width="184" height="70" rx="8" fill="#1a2a1a" stroke="#3fb950" stroke-width="1.5" filter="url(#sh3)"/>
|
||||
<text x="818" y="272" text-anchor="middle" fill="#3fb950" font-size="12" font-weight="bold">📊 spec_current (обн.)</text>
|
||||
<text x="818" y="292" text-anchor="middle" fill="#8b949e" font-size="10">Услуга X | 150₽ | 10 шт</text>
|
||||
<text x="818" y="308" text-anchor="middle" fill="#8b949e" font-size="10">Услуга Z | 300₽ | 2 шт</text>
|
||||
|
||||
<!-- Arrows between events -->
|
||||
<line x1="473" y1="170" x2="505" y2="150" stroke="#d2991d" stroke-width="1" stroke-dasharray="3,2"/>
|
||||
<text x="492" y="148" fill="#8b949e" font-size="8">цикл по допникам</text>
|
||||
|
||||
<!-- Legend -->
|
||||
<rect x="10" y="340" width="900" height="30" rx="4" fill="#161b22" stroke="#30363d" stroke-width="0.5"/>
|
||||
<text x="460" y="360" text-anchor="middle" fill="#8b949e" font-size="10">Каждый допник сравнивается с актуальной spec_current → операции накапливаются в spec_events → spec_current пересчитывается</text>
|
||||
</svg>
|
||||
|
||||
<h2>4. Защита — эшелоны</h2>
|
||||
|
||||
<svg viewBox="0 0 920 220" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<marker id="d1" markerWidth="5" markerHeight="3" refX="5" refY="1.5" orient="auto"><path d="M0,0 L5,1.5 L0,3 Z" fill="#f85149"/></marker>
|
||||
<filter id="sh4"><feDropShadow dx="0" dy="1" stdDeviation="2" flood-opacity="0.2"/></filter>
|
||||
</defs>
|
||||
|
||||
<!-- Layer 1: Upload -->
|
||||
<rect x="10" y="10" width="900" height="40" rx="6" fill="#1a0a0a" stroke="#f85149" stroke-width="1" filter="url(#sh4)"/>
|
||||
<text x="460" y="35" text-anchor="middle" fill="#f85149" font-size="12" font-weight="bold">🔴 Уровень 1 — Загрузка: ZIP-бомба (500 файлов / 500MB / ratio 100×) · Path traversal · Whitelist .pdf .docx .doc .zip</text>
|
||||
|
||||
<line x1="460" y1="50" x2="460" y2="58" stroke="#f85149" stroke-width="1" marker-end="url(#d1)"/>
|
||||
|
||||
<!-- Layer 2: Parse -->
|
||||
<rect x="30" y="62" width="860" height="40" rx="6" fill="#2a1a0a" stroke="#d2991d" stroke-width="1" filter="url(#sh4)"/>
|
||||
<text x="460" y="87" text-anchor="middle" fill="#d2991d" font-size="12" font-weight="bold">🟠 Уровень 2 — Парсинг: cp437→utf8 для битых имён · content_hash (SHA-256) · дедупликация · try/except → logging.warning</text>
|
||||
|
||||
<line x1="460" y1="102" x2="460" y2="110" stroke="#f85149" stroke-width="1" marker-end="url(#d1)"/>
|
||||
|
||||
<!-- Layer 3: Classify -->
|
||||
<rect x="50" y="114" width="820" height="40" rx="6" fill="#2a2a0a" stroke="#e3b341" stroke-width="1" filter="url(#sh4)"/>
|
||||
<text x="460" y="139" text-anchor="middle" fill="#e3b341" font-size="12" font-weight="bold">🟡 Уровень 3 — Классификация: фильтр по имени файла · фильтр по заголовкам (2000 симв.) · LLM-фильтр · мусор → garbage</text>
|
||||
|
||||
<line x1="460" y1="154" x2="460" y2="162" stroke="#f85149" stroke-width="1" marker-end="url(#d1)"/>
|
||||
|
||||
<!-- Layer 4: Compare -->
|
||||
<rect x="70" y="166" width="780" height="40" rx="6" fill="#1a2a0a" stroke="#3fb950" stroke-width="1" filter="url(#sh4)"/>
|
||||
<text x="460" y="191" text-anchor="middle" fill="#3fb950" font-size="12" font-weight="bold">🟢 Уровень 4 — Сравнение: ADD без имени → UNRESOLVED · UPDATE/DELETE без цели → UNRESOLVED · нормализация 1 000,50→1000.50 · дат · price×qty=sum</text>
|
||||
</svg>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,166 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Gitea Actions — настройка CI/CD</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117; --fg: #c9d1d9; --muted: #8b949e;
|
||||
--accent: #58a6ff; --green: #3fb950; --orange: #d2991d;
|
||||
--border: #30363d; --code-bg: #161b22; --card-bg: #161b22;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font: 15px/1.65 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--fg); max-width: 860px; margin: 0 auto; padding: 32px 20px 80px; }
|
||||
h1 { font-size: 26px; margin-bottom: 8px; }
|
||||
h2 { font-size: 19px; margin-top: 36px; margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid var(--border); color: var(--accent); }
|
||||
h3 { font-size: 16px; margin-top: 24px; margin-bottom: 6px; color: var(--green); }
|
||||
p { margin: 8px 0; }
|
||||
ul, ol { padding-left: 24px; margin: 8px 0; }
|
||||
li { margin: 4px 0; }
|
||||
code { background: var(--code-bg); padding: 1px 5px; border-radius: 4px; font-size: 13px; border: 1px solid var(--border); }
|
||||
pre { background: var(--code-bg); padding: 12px 16px; border-radius: 6px; overflow-x: auto; font-size: 13px; border: 1px solid var(--border); margin: 12px 0; }
|
||||
.nav { margin-bottom: 24px; }
|
||||
.nav a { color: var(--accent); text-decoration: none; }
|
||||
.nav a:hover { text-decoration: underline; }
|
||||
.step { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 16px 20px; margin: 16px 0; }
|
||||
.step h3 { margin-top: 0; }
|
||||
hr { border: none; border-top: 1px solid var(--border); margin: 32px 0; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 12px 0; border: 1px solid var(--border); }
|
||||
th, td { padding: 8px; text-align: left; border: 1px solid var(--border); }
|
||||
th { background: var(--code-bg); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="nav"><a href="/docs/architect">Архитектура</a></div>
|
||||
|
||||
<h1>🔄 Gitea Actions — настройка CI/CD</h1>
|
||||
|
||||
<blockquote style="border-left-color: #f85149; background: #1a0a0a; color: #f85149;">
|
||||
<strong>⚠️ Не решает проблему ручного редеплоя managed-сервисов Nubes.</strong><br>
|
||||
Если сервис деплоится только через Nubes UI — Gitea Actions может максимум валидировать код и синхронизировать VM-часть. Кнопку «Redeploy» всё равно жать руками.
|
||||
</blockquote>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>Как это работает</h2>
|
||||
|
||||
<p>Gitea Actions — встроенный CI/CD (аналог GitHub Actions), доступен с Gitea 1.19+. При <code>git push</code> запускается workflow, который выполняет заданные шаги на <strong>раннере</strong> — отдельной машине, которая слушает задачи.</p>
|
||||
|
||||
<table>
|
||||
<tr><th>Компонент</th><th>Где</th><th>Что делает</th></tr>
|
||||
<tr><td>Workflow-файл</td><td><code>.gitea/workflows/*.yaml</code></td><td>Описывает шаги (steps) при пуше</td></tr>
|
||||
<tr><td>Act Runner</td><td>Любая машина с доступом к Gitea</td><td>Выполняет джобы (systemd-сервис)</td></tr>
|
||||
<tr><td>Gitea</td><td>Ваш инстанс</td><td>Отправляет задачи раннеру при пуше</td></tr>
|
||||
</table>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>Пример workflow</h2>
|
||||
|
||||
<pre>name: Validate
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: linux_amd64
|
||||
steps:
|
||||
- name: Syntax check
|
||||
run: python3 -m py_compile *.py</pre>
|
||||
|
||||
<p>Что можно автоматизировать:</p>
|
||||
<ul>
|
||||
<li>Синтаксическую проверку</li>
|
||||
<li>Запуск тестов (<code>pytest</code>)</li>
|
||||
<li>Синхронизацию кода на свою VM (<code>rsync</code> / <code>scp</code>)</li>
|
||||
<li>Уведомления (Telegram, email)</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>Как это настраивалось</h2>
|
||||
|
||||
<div class="step">
|
||||
<h3>1. Установка act_runner на VM</h3>
|
||||
<pre># Скачать бинарник
|
||||
curl -sL -o /usr/local/bin/act_runner \
|
||||
https://dl.gitea.com/act_runner/0.2.11/act_runner-0.2.11-linux-amd64
|
||||
chmod +x /usr/local/bin/act_runner</pre>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<h3>2. Получение токена</h3>
|
||||
<p>В Gitea: <code>Репозиторий → Settings → Actions → Runners → Create new runner</code></p>
|
||||
<p>Выдаётся токен вида <code>D0gvfu2iHfUj...</code> — он используется для регистрации.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<h3>3. Регистрация раннера</h3>
|
||||
<pre>act_runner register \
|
||||
--no-interactive \
|
||||
--instance https://<ваш-gitea> \
|
||||
--token <TOKEN> \
|
||||
--name <имя-раннера> \
|
||||
--labels linux_amd64:host</pre>
|
||||
<p><code>linux_amd64:host</code> — означает host-режим (без Docker). Джобы выполняются прямо на машине.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<h3>4. Генерация конфига</h3>
|
||||
<pre>act_runner generate-config > /etc/act_runner/config.yaml
|
||||
# Важно: поправить labels → ["linux_amd64:host"]</pre>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<h3>5. Systemd-сервис</h3>
|
||||
<pre>[Unit]
|
||||
Description=Gitea Actions Runner
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/act_runner daemon --config /etc/act_runner/config.yaml
|
||||
WorkingDirectory=/etc/act_runner
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
User=runner
|
||||
Environment=HOME=/home/runner
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target</pre>
|
||||
<pre>sudo systemctl daemon-reload
|
||||
sudo systemctl enable act_runner --now
|
||||
sudo systemctl status act_runner</pre>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<h3>6. Workflow-файл</h3>
|
||||
<p>Создать <code>.gitea/workflows/deploy.yaml</code> в репозитории. При пуше — workflow запустится автоматически.</p>
|
||||
<p><strong>Важно:</strong> <code>runs-on</code> должен совпадать с лейблом раннера (без <code>:host</code>). То есть <code>runs-on: linux_amd64</code>.</p>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>Проверка статуса</h2>
|
||||
|
||||
<pre># Логи раннера на VM
|
||||
sudo journalctl -u act_runner -f
|
||||
|
||||
# Статус сервиса
|
||||
sudo systemctl status act_runner</pre>
|
||||
|
||||
<p>Результаты выполнения workflow видны в Gitea на вкладке <strong>Actions</strong> репозитория.</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>Полезные ссылки</h2>
|
||||
<ul>
|
||||
<li><a href="https://docs.gitea.com/usage/actions/overview/" target="_blank">Gitea Actions — Overview</a></li>
|
||||
<li><a href="https://docs.gitea.com/usage/actions/act-runner" target="_blank">Act Runner — документация</a></li>
|
||||
<li><a href="https://docs.gitea.com/usage/actions/quickstart/" target="_blank">Quick Start</a></li>
|
||||
</ul>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,315 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DrHider — обфускация документов</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='4' fill='%23001C34'/%3E%3Ctext x='16' y='24' text-anchor='middle' font-size='22' font-weight='bold' fill='white' font-family='sans-serif'%3EN%3C/text%3E%3C/svg%3E" type="image/svg+xml">
|
||||
<style>
|
||||
:root {
|
||||
--brand: #2563eb; --brand-dark: #1d4ed8;
|
||||
--bg: #f8fafc; --card: #fff; --fg: #1e293b; --muted: #64748b;
|
||||
--border: #e2e8f0; --red: #ef4444; --green: #22c55e;
|
||||
--grey-light: #f3f4f6; --radius: 8px;
|
||||
}
|
||||
* { 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); }
|
||||
.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 .sep { color: var(--muted); }
|
||||
.header a { color: var(--brand); text-decoration: none; font-size: 13px; }
|
||||
.header a:hover { color: var(--brand-dark); }
|
||||
main { max-width: 700px; margin: 0 auto; padding: 40px 20px 60px; }
|
||||
h1 { font-size: 22px; font-weight: 600; margin-bottom: 4px; }
|
||||
.sub { color: var(--muted); font-size: 14px; margin-bottom: 24px; }
|
||||
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,.04); }
|
||||
.card-header { background: var(--grey-light); padding: 12px 16px; border-radius: 12px 12px 0 0; font-size: 14px; font-weight: 600; border-bottom: 1px solid var(--border); }
|
||||
.card-body { padding: 16px; }
|
||||
|
||||
#dropZone {
|
||||
border: 2px dashed var(--border); border-radius: 12px; padding: 36px 20px;
|
||||
text-align: center; cursor: pointer; transition: all .2s;
|
||||
background: var(--grey-light); margin-bottom: 16px;
|
||||
}
|
||||
#dropZone:hover, #dropZone.active { border-color: var(--brand); background: #eff6ff; }
|
||||
#dropZone p { color: var(--muted); margin: 4px 0; font-size: 14px; }
|
||||
#dropZone .icon { font-size: 32px; margin-bottom: 8px; }
|
||||
|
||||
#fileList { display: none; }
|
||||
#fileList.show { display: block; }
|
||||
.file-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: var(--grey-light); border-radius: 6px; margin-bottom: 4px; font-size: 13px; }
|
||||
.file-row .name { flex: 1; }
|
||||
.file-row .size { color: var(--muted); }
|
||||
.file-row .remove { cursor: pointer; color: var(--red); font-weight: bold; font-size: 16px; }
|
||||
|
||||
#status { display: none; margin-top: 12px; padding: 12px 16px; border-radius: 8px; font-size: 13px; }
|
||||
#status.show { display: block; }
|
||||
#status.progress { background: #eff6ff; border: 1px solid var(--brand); color: var(--brand); }
|
||||
#status.done { background: #f0fdf4; border: 1px solid var(--green); color: #16a34a; }
|
||||
#status.error { background: #fef2f2; border: 1px solid var(--red); color: var(--red); }
|
||||
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; height: 36px; padding: 0 16px; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; border: 1px solid var(--border); background: var(--card); transition: all .15s; }
|
||||
.btn:hover { background: var(--grey-light); }
|
||||
.btn-primary { background: var(--brand); color: #fff; border-color: var(--brand); width: 100%; margin-top: 8px; }
|
||||
.btn-primary:hover { background: var(--brand-dark); }
|
||||
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||
|
||||
/* ── Модальное окно ──────────────────────────────── */
|
||||
.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 { 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); position: sticky; top: 0; background: var(--card); z-index: 1; }
|
||||
.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:hover { color: var(--fg); }
|
||||
.modal-body { padding: 20px; font-size: 13px; line-height: 1.7; color: var(--fg); }
|
||||
.modal-body h3 { font-size: 14px; margin: 16px 0 6px; }
|
||||
.modal-body h3:first-child { margin-top: 0; }
|
||||
.modal-body ul { margin: 4px 0 12px 20px; }
|
||||
.modal-body li { margin-bottom: 4px; }
|
||||
.modal-body .warn { background: #fef9e7; border-left: 3px solid #f59e0b; padding: 8px 12px; border-radius: 0 6px 6px 0; margin: 8px 0; font-size: 12px; }
|
||||
.modal-body code { background: var(--grey-light); padding: 1px 4px; border-radius: 3px; font-size: 12px; }
|
||||
|
||||
/* ── Кнопка ? в шапке ───────────────────────────── */
|
||||
.header .help-btn { margin-left: auto; width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--border); background: var(--card); cursor: pointer; font-size: 15px; font-weight: 600; color: var(--muted); display: flex; align-items: center; justify-content: center; transition: all .15s; }
|
||||
.header .help-btn:hover { border-color: var(--brand); color: var(--brand); background: #eff6ff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="header">
|
||||
<svg width="82" height="18" viewBox="0 0 82 18" style="display:block"><rect width="82" height="18" rx="2" fill="#001C34"/><text x="41" y="14" text-anchor="middle" font-size="12" font-weight="bold" fill="white" font-family="sans-serif">NUBES</text></svg>
|
||||
<span class="sep">|</span>
|
||||
<a href="https://contractor.pythonk8s.services.ngcloud.ru/">Сверка договоров</a>
|
||||
<span class="sep">|</span>
|
||||
<strong>DrHider</strong>
|
||||
<span style="font-size:11px;color:var(--muted);">v1.15</span>
|
||||
<button class="help-btn" onclick="openModal()" title="О сервисе">?</button>
|
||||
</header>
|
||||
|
||||
<!-- Модальное окно -->
|
||||
<div class="modal-overlay" id="modalOverlay" onclick="if(event.target===this)closeModal()">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2>🛡️ DrHider — справка</h2>
|
||||
<button class="modal-close" onclick="closeModal()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h3>📌 Что делает</h3>
|
||||
<p>Обезличивает документы перед загрузкой в сервис сверки договоров. Заменяет персональные данные, реквизиты компаний, телефоны, email и другие чувствительные данные на фиктивные, но реалистичные значения. <strong>Одна и та же сущность во всех файлах заменяется одинаково</strong> — можно сопоставлять документы между собой.</p>
|
||||
|
||||
<h3>⚙️ Как работает</h3>
|
||||
<ul>
|
||||
<li><strong>Два прохода:</strong> сначала сбор всех сущностей из всех файлов → построение единого словаря замен → применение замен.</li>
|
||||
<li><strong>Всё в памяти:</strong> файлы не записываются на диск и не сохраняются в базе. После обработки данные удаляются.</li>
|
||||
<li><strong>mapping.csv</strong> — внутри ZIP лежит таблица «тип → оригинал → замена» для аудита.</li>
|
||||
</ul>
|
||||
|
||||
<h3>✅ Что распознаётся и заменяется</h3>
|
||||
<ul>
|
||||
<li>📞 Телефоны (+7, 8-800, ...)</li>
|
||||
<li>📧 Email-адреса</li>
|
||||
<li>🏢 Компании: ООО, ЗАО, АО, ПАО, ИП, ТОО (включая юникод-кавычки «» и "")</li>
|
||||
<li>👤 ФИО (кириллица): Иванов А.П., Петрова Мария Сергеевна</li>
|
||||
<li>🔢 Реквизиты: ИНН (10/12), ОГРН, КПП, БИК</li>
|
||||
<li>💳 Банковские счета: р/с, кор/сч, корреспондентский счёт</li>
|
||||
<li>📄 Паспортные данные: серия + номер</li>
|
||||
</ul>
|
||||
|
||||
<h3>🧠 LLM-NER (нейросеть)</h3>
|
||||
<p>Для поиска имён, названий компаний и адресов, которые не попадают под регулярные выражения, используется языковая модель. Работает не мгновенно — для больших документов добавляет 5–30 секунд.</p>
|
||||
|
||||
<h3>✅ Форматы файлов</h3>
|
||||
<ul>
|
||||
<li><strong>.docx</strong> — полная замена с сохранением структуры документа. ⚠️ Форматирование (жирный, курсив) заменённых фрагментов может сброситься.</li>
|
||||
<li><strong>.pdf</strong> — текст и таблицы извлекаются в .docx, затем обфусцируется. Шрифты, цвета и PDF-вёрстка не сохраняются. При совпадении имён с существующим .docx добавляется суффикс <code>_из_pdf</code>.</li>
|
||||
<li><strong>.doc</strong> — бинарный формат, <strong>не обфусцируется</strong>. Файл возвращается как есть. Конвертируйте в .docx перед загрузкой.</li>
|
||||
<li><strong>.zip</strong> — автоматически распаковывается, все файлы внутри обрабатываются.</li>
|
||||
</ul>
|
||||
|
||||
<h3>⚠️ Ограничения и возможные ошибки</h3>
|
||||
<div class="warn">
|
||||
<strong>Ложные пропуски:</strong> сущность может быть не найдена, если она записана в нестандартном формате (например, телефон через точку: <code>+7.495.123.44.55</code>).
|
||||
</div>
|
||||
<div class="warn">
|
||||
<strong>Ложные срабатывания:</strong> короткие слова (например, фамилия «Иванов») могут быть заменены внутри более длинных слов («Ивановский» → ...ский). Минимизировано, но не исключено полностью.
|
||||
</div>
|
||||
<div class="warn">
|
||||
<strong>.doc не обрабатывается:</strong> старые Word-файлы возвращаются без изменений.
|
||||
</div>
|
||||
<div class="warn">
|
||||
<strong>PDF → DOCX:</strong> извлекаются только текст и таблицы. Шрифты, цвета и позиционирование теряются. Сканы (фотографии страниц) не распознаются.</div>
|
||||
</div>
|
||||
<div class="warn">
|
||||
<strong>Сканы/изображения:</strong> текст на картинках не распознаётся (нет OCR).
|
||||
</div>
|
||||
|
||||
<h3>🔒 Приватность</h3>
|
||||
<ul>
|
||||
<li>Файлы не покидают сервер Nubes.</li>
|
||||
<li>Ничего не записывается в базу данных.</li>
|
||||
<li>После отправки ZIP-архива все данные в памяти удаляются.</li>
|
||||
</ul>
|
||||
|
||||
<h3>📊 mapping.csv</h3>
|
||||
<p>В выходном ZIP всегда есть файл <code>mapping.csv</code> с колонками: <code>тип_данных, оригинал, замена</code>. Позволяет при необходимости восстановить исходные значения (храните этот файл отдельно и безопасно).</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<h1>🛡️ DrHider — обфускация документов</h1>
|
||||
<p class="sub">Загрузите документы — получите ZIP с обезличенными копиями и таблицей соответствия. Без сохранения на сервере.</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">📁 Загрузка файлов</div>
|
||||
<div class="card-body">
|
||||
<div id="dropZone">
|
||||
<div class="icon">📄</div>
|
||||
<p>Выберите файлы или перетащите сюда</p>
|
||||
<p style="font-size:12px;">.docx .pdf .doc .zip</p>
|
||||
<input type="file" id="fileInput" multiple accept=".docx,.pdf,.doc,.zip" style="display:none">
|
||||
</div>
|
||||
<div id="fileList"></div>
|
||||
<button class="btn btn-primary" id="btnGo" disabled>Обфусцировать</button>
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:16px;">
|
||||
<div class="card-header">ℹ️ Как это работает</div>
|
||||
<div class="card-body" style="font-size:13px;color:var(--muted);">
|
||||
<p><strong>⚡ Всё в памяти.</strong> Файлы не сохраняются на сервер. После обработки данные удаляются.</p>
|
||||
<p><strong>📋 mapping.csv</strong> — таблица «оригинал → замена» внутри ZIP.</p>
|
||||
<p><strong>🔒 Результат</strong> — обезличенные файлы можно загружать в основной сервис сверки.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const DZ = document.getElementById('dropZone');
|
||||
const INPUT = document.getElementById('fileInput');
|
||||
const LIST = document.getElementById('fileList');
|
||||
const BTN = document.getElementById('btnGo');
|
||||
const STATUS = document.getElementById('status');
|
||||
let files = [];
|
||||
|
||||
DZ.onclick = () => INPUT.click();
|
||||
INPUT.onchange = () => { addFiles(INPUT.files); INPUT.value = ''; };
|
||||
DZ.ondragover = e => { e.preventDefault(); DZ.classList.add('active'); };
|
||||
DZ.ondragleave = () => DZ.classList.remove('active');
|
||||
DZ.ondrop = e => { e.preventDefault(); DZ.classList.remove('active'); addFiles(e.dataTransfer.files); };
|
||||
|
||||
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 render() {
|
||||
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' : '';
|
||||
BTN.disabled = !files.length;
|
||||
}
|
||||
|
||||
BTN.onclick = () => {
|
||||
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;
|
||||
setStatus('progress', '⏳ Отправка...');
|
||||
const xhr = new XMLHttpRequest();
|
||||
const fd = new FormData();
|
||||
files.forEach(f => fd.append('files', f, f.name));
|
||||
xhr.open('POST', 'https://contracts.kube5s.ru/api/drhider');
|
||||
xhr.responseType = 'blob';
|
||||
const startTime = Date.now();
|
||||
const timer = setInterval(() => {
|
||||
setStatus('progress', '⏳ Обработка... ' + Math.round((Date.now() - startTime) / 1000) + 'с');
|
||||
}, 1000);
|
||||
xhr.onload = () => {
|
||||
clearInterval(timer);
|
||||
if (xhr.status === 200) {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(xhr.response);
|
||||
a.download = 'drhider_output.zip';
|
||||
a.click();
|
||||
setStatus('done', '✅ Готово!');
|
||||
} else {
|
||||
setStatus('error', '❌ Ошибка ' + xhr.status);
|
||||
}
|
||||
BTN.disabled = false;
|
||||
};
|
||||
xhr.onerror = () => { clearInterval(timer); setStatus('error', '❌ Сеть'); BTN.disabled = false; };
|
||||
xhr.ontimeout = () => { clearInterval(timer); setStatus('error', '❌ Таймаут'); BTN.disabled = false; };
|
||||
xhr.timeout = 600000;
|
||||
xhr.send(fd);
|
||||
};
|
||||
|
||||
function setStatus(c,m) { STATUS.className = 'show '+c; STATUS.textContent = m; }
|
||||
function esc(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function fmt(n) { return n>1e6 ? (n/1e6).toFixed(1)+' MB' : n>1e3 ? (n/1e3).toFixed(0)+' KB' : n+' B'; }
|
||||
|
||||
// ── Модальное окно ─────────────────────────────────────────────
|
||||
function openModal() { document.getElementById('modalOverlay').classList.add('show'); }
|
||||
function closeModal() { document.getElementById('modalOverlay').classList.remove('show'); }
|
||||
// ── Коллизия PDF→DOCX ──────────────────────────────────────────
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeCollision(true); });
|
||||
</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>
|
||||
</html>
|
||||
@@ -1,226 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Сверка договоров — LLM</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<style>
|
||||
:root { --brand-primary: #2563eb; --brand-primary-dark: #1d4ed8; --brand-gray: #d1d5db; --brand-grey-light: #f3f4f6; --background: #ffffff; --foreground: #1a1a1a; --muted: #6b7280; --destructive: #ef4444; --green: #22c55e; }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, sans-serif; font-size: 14px; color: var(--foreground); background: var(--brand-grey-light); min-height: 100vh; }
|
||||
.topbar { background: var(--background); border-bottom: 1px solid var(--brand-gray); padding: 0 24px; height: 56px; display: flex; align-items: center; gap: 16px; position: sticky; top: 0; z-index: 50; }
|
||||
.topbar img { height: 28px; }
|
||||
.topbar .title { font-weight: 600; font-size: 16px; }
|
||||
.content { max-width: 900px; margin: 32px auto; padding: 0 16px; }
|
||||
.card { background: var(--background); border-radius: 12px; border: 1px solid var(--brand-gray); box-shadow: 0 1px 2px rgba(0,0,0,.05); overflow: hidden; margin-bottom: 16px; }
|
||||
.card-header { background: var(--brand-grey-light); padding: 12px 16px; font-weight: 600; font-size: 16px; display: flex; align-items: center; gap: 8px; }
|
||||
.card-body { padding: 16px; }
|
||||
.btn { height: 36px; border-radius: 6px; gap: 6px; padding: 0 14px; font-size: 14px; font-family: inherit; cursor: pointer; display: inline-flex; align-items: center; border: 1px solid var(--brand-gray); background: var(--background); }
|
||||
.btn-primary { background: var(--brand-primary); border-color: var(--brand-primary); color: #fff; }
|
||||
.btn-primary:hover { background: var(--brand-primary-dark); }
|
||||
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.table-wrap { border-radius: 6px; border: 1px solid var(--brand-gray); overflow-y: auto; max-height: 50vh; margin-top: 12px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th { background: var(--brand-grey-light); text-transform: uppercase; padding: 6px 10px; border-right: 1px solid var(--brand-gray); text-align: left; font-weight: 600; font-size: 11px; color: var(--muted); }
|
||||
td { padding: 6px 10px; border-right: 1px solid var(--brand-gray); border-bottom: 1px solid var(--brand-gray); }
|
||||
tr:hover td { background: rgba(243,244,246,.5); }
|
||||
.name-cell { max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.num-cell { text-align: right; white-space: nowrap; }
|
||||
.status-ok { color: var(--green); }
|
||||
.status-err { color: var(--destructive); }
|
||||
.summary-row td { background: rgba(34,197,94,.05); font-weight: 600; }
|
||||
.empty-row td { color: var(--muted); text-align: center; padding: 24px; }
|
||||
.remove-btn { cursor: pointer; color: #f87171; background: none; border: none; padding: 2px 4px; font-size: 16px; line-height: 1; }
|
||||
.remove-btn:hover { color: #ef4444; }
|
||||
.info-btn { cursor: pointer; color: var(--muted); background: none; border: none; padding: 2px 4px; font-size: 14px; display: none; }
|
||||
.info-btn:hover { color: var(--brand-primary); }
|
||||
.info-btn.visible { display: inline; }
|
||||
.first-row td { background: rgba(37,99,235,.06); font-weight: 600; }
|
||||
.arrow-btn { cursor: pointer; color: var(--muted); background: none; border: none; padding: 2px; font-size: 12px; line-height: 1; }
|
||||
.arrow-btn:hover { color: var(--brand-primary); }
|
||||
.arrow-btn:disabled { opacity: .3; cursor: default; color: var(--muted); }
|
||||
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,.4); z-index: 100; justify-content: center; align-items: center; }
|
||||
.modal-overlay.open { display: flex; }
|
||||
.modal { background: var(--background); border-radius: 12px; border: 1px solid var(--brand-gray); box-shadow: 0 4px 24px rgba(0,0,0,.15); max-width: 520px; width: 90%; max-height: 80vh; display: flex; flex-direction: column; }
|
||||
.modal.wide { max-width: 900px; }
|
||||
.modal-header { padding: 14px 16px; border-bottom: 1px solid var(--brand-gray); display: flex; align-items: center; gap: 8px; font-weight: 600; flex-shrink: 0; }
|
||||
.modal-body { padding: 16px; overflow-y: auto; }
|
||||
.modal-body .kv { display: flex; margin-bottom: 6px; font-size: 13px; }
|
||||
.modal-body .kv .k { color: var(--muted); width: 110px; flex-shrink: 0; }
|
||||
.modal-body .kv .v { word-break: break-all; }
|
||||
.diff-added { background: rgba(34,197,94,.1); }
|
||||
.diff-deleted { background: rgba(239,68,68,.1); }
|
||||
.diff-changed { background: rgba(234,179,8,.1); }
|
||||
.diff-added td { color: var(--green); }
|
||||
.diff-deleted td { color: var(--destructive); text-decoration: line-through; }
|
||||
.diff-field-old { color: var(--destructive); text-decoration: line-through; font-size: 11px; }
|
||||
.diff-field-new { color: var(--green); font-weight: 600; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: spin .6s linear infinite; }
|
||||
.text-panes { display: flex; gap: 0; }
|
||||
.text-pane { flex: 1; min-width: 0; overflow-y: auto; max-height: 65vh; }
|
||||
.text-pane:first-child { border-right: 1px solid var(--brand-gray); padding-right: 12px; }
|
||||
.text-pane:last-child { padding-left: 12px; }
|
||||
.text-pane pre { background: var(--brand-grey-light); padding: 10px; border-radius: 6px; font-size: 11px; white-space: pre-wrap; word-break: break-word; overflow-x: auto; margin: 0; max-height: 60vh; overflow-y: auto; }
|
||||
.text-pane table { font-size: 11px; width: 100%; }
|
||||
.text-pane th { font-size: 10px; padding: 4px 6px; }
|
||||
.text-pane td { padding: 3px 6px; font-size: 11px; }
|
||||
.text-pane .pane-title { font-weight: 600; font-size: 12px; margin-bottom: 8px; color: var(--muted); text-transform: uppercase; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<img src="/static/logo.svg" alt="Nubes">
|
||||
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v2.0.0</span></span>
|
||||
<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="stepClassify">○ Классификация</span><span>→</span>
|
||||
<span id="stepGroups">○ Группировка</span><span>→</span>
|
||||
<span id="stepCompare">○ Сравнение</span>
|
||||
</div>
|
||||
<span style="margin-left:auto;font-size:12px;color:var(--brand-primary);cursor:pointer;white-space:nowrap;" onclick="openAbout()">ℹ️ О сервисе</span>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i data-lucide="folder-open" style="width:18px;height:18px;"></i>
|
||||
Загрузка договоров/приложений/спецификаций
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<input type="file" id="fileInput" accept=".docx,.doc,.pdf,.zip" multiple style="margin-bottom:6px;width:100%;">
|
||||
<div style="text-align:right;font-size:11px;color:var(--muted);margin-bottom:6px;">Порядок определяется автоматически при классификации</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px;">⚠ При совпадении имён — запрос на перезапись (OK / Отмена). Файлы из ZIP-архивов загружаются через тот же поток.</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Имя</th><th style="width:130px;">Изменён</th><th style="width:90px;">Размер</th><th style="width:115px;">Парсинг</th><th style="width:30px;">Текст</th><th style="width:30px;">✕</th></tr>
|
||||
</thead>
|
||||
<tbody id="fileTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" id="llmBtn" style="width:100%;justify-content:center;margin-top:8px;display:none;background:var(--green);border-color:var(--green);">
|
||||
<i data-lucide="scale" style="width:16px;height:16px;"></i> Общее сравнение
|
||||
</button>
|
||||
|
||||
<details style="margin-top:8px;font-size:12px;">
|
||||
<summary style="cursor:pointer;color:var(--muted);">⚙ Промпты LLM</summary>
|
||||
<div style="display:flex;gap:8px;margin-top:6px;">
|
||||
<button class="btn" id="promptBtnExtract" style="font-size:11px;height:28px;flex:1;">📄 Извлечение</button>
|
||||
<button class="btn" id="promptBtnDiff" style="font-size:11px;height:28px;flex:1;">🔄 Сравнение</button>
|
||||
<button class="btn" id="promptBtnClassify" style="font-size:11px;height:28px;flex:1;">🏷️ Классификация</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:3px;font-size:10px;color:var(--muted);">
|
||||
<span style="flex:1;">для базового договора</span>
|
||||
<span style="flex:1;">для допсоглашений</span>
|
||||
<span style="flex:1;">автоопределение</span>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="diffCard" style="display:none;">
|
||||
<div class="card-header">
|
||||
<i data-lucide="file-diff" style="width:18px;height:18px;"></i>
|
||||
Классификация и группы <span id="diffStatus" style="font-weight:400;font-size:12px;margin-left:8px;"></span>
|
||||
</div>
|
||||
<div class="card-body" id="diffBody"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="compareCard" style="display:none;margin-top:16px;">
|
||||
<div class="card-header">
|
||||
<i data-lucide="scale" style="width:18px;height:18px;"></i>
|
||||
Результаты сравнения <span id="compareStatus" style="font-weight:400;font-size:12px;margin-left:8px;"></span>
|
||||
</div>
|
||||
<div class="card-body" id="compareBody"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="chatCard" style="display:none;margin-top:16px;">
|
||||
<div class="card-header">
|
||||
<i data-lucide="message-circle" style="width:18px;height:18px;"></i>
|
||||
Задать вопрос по договору <span style="font-weight:400;font-size:11px;color:var(--muted);">— используются все загруженные документы</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="chatMessages" style="margin-bottom:8px;font-size:13px;"></div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input type="text" id="chatInput" placeholder="Какая общая сумма? Что изменилось? ..." style="flex:1;height:32px;border:1px solid var(--brand-gray);border-radius:6px;padding:0 8px;font-size:13px;">
|
||||
<button class="btn btn-primary" id="chatSend" style="height:32px;font-size:13px;">→</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="modalOverlay" onclick="closeModal(event)">
|
||||
<div class="modal" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<i data-lucide="file-text" style="width:18px;height:18px;"></i>
|
||||
<span id="modalTitle">Информация о файле</span>
|
||||
<button class="remove-btn" onclick="closeModal()" style="margin-left:auto;font-size:18px;">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="promptModalOverlay" onclick="closePromptModal(event)">
|
||||
<div class="modal" style="max-width:680px;" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<i data-lucide="settings" style="width:18px;height:18px;"></i>
|
||||
<span id="promptModalTitle">Промпт: Сравнение</span>
|
||||
<button class="remove-btn" onclick="closePromptModal()" style="margin-left:auto;font-size:18px;">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding:16px;">
|
||||
<div style="display:flex;gap:6px;margin-bottom:4px;">
|
||||
<button class="btn" id="promptTabExtract" style="font-size:11px;height:26px;">📄 Извлечение</button>
|
||||
<button class="btn" id="promptTabDiff" style="font-size:11px;height:26px;background:var(--brand-primary);color:#fff;">🔄 Сравнение</button>
|
||||
<button class="btn" id="promptTabClassify" style="font-size:11px;height:26px;">🏷️ Классификация</button>
|
||||
</div>
|
||||
<div style="font-size:10px;color:var(--muted);margin-bottom:10px;">
|
||||
Извлечение — для базового договора | Сравнение — для допсоглашений | Классификация — автоопределение типа/даты/контрагента
|
||||
</div>
|
||||
<textarea id="promptEditor" style="width:100%;height:220px;font-family:monospace;font-size:11px;border:1px solid var(--brand-gray);border-radius:6px;padding:8px;box-sizing:border-box;"></textarea>
|
||||
<div style="display:flex;gap:8px;margin-top:8px;align-items:center;">
|
||||
<input id="promptName" placeholder="Имя версии" style="width:140px;font-size:11px;border:1px solid var(--brand-gray);border-radius:4px;padding:4px 6px;">
|
||||
<input id="promptNotes" placeholder="Что изменили" style="flex:1;font-size:11px;border:1px solid var(--brand-gray);border-radius:4px;padding:4px 6px;">
|
||||
<button class="btn" id="promptSave" style="font-size:11px;height:28px;background:var(--green);color:#fff;border-color:var(--green);">Сохранить</button>
|
||||
</div>
|
||||
<span id="promptStatus" style="font-size:11px;color:var(--muted);display:block;margin-top:4px;"></span>
|
||||
<div style="margin-top:12px;font-size:11px;color:var(--muted);font-weight:600;">История версий</div>
|
||||
<div id="promptHistory" style="max-height:180px;overflow-y:auto;margin-top:4px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="aboutModalOverlay" onclick="closeAbout(event)">
|
||||
<div class="modal" style="max-width:720px;" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<i data-lucide="info" style="width:18px;height:18px;"></i>
|
||||
<span>О сервисе — архитектура и принцип работы</span>
|
||||
<button class="remove-btn" onclick="closeAbout()" style="margin-left:auto;font-size:18px;">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding:16px;font-size:13px;line-height:1.6;max-height:65vh;overflow-y:auto;" id="aboutBody">
|
||||
<h3 style="margin-top:0;">Что это</h3>
|
||||
<p>Автоматический разбор и сравнение договоров с помощью ИИ (LLM). Загружаете договоры, допсоглашения и спецификации вперемешку — система сама разбирает что есть что, группирует по контрактам и находит все изменения между версиями.</p>
|
||||
<h3>Как пользоваться</h3>
|
||||
<p><b>1. Загрузка</b> — выберите файлы (.docx, .doc, .pdf, .zip). Можно загружать всё вперемешку. Порядок не важен — классификация разберётся.</p>
|
||||
<p><b>2. Классификация</b> — нажмите «Классифицировать». Система через LLM определит тип, номер, дату, контрагента.</p>
|
||||
<p><b>3. Группы</b> — документы автоматически группируются по договорам.</p>
|
||||
<p><b>4. Сравнение</b> — для каждой группы нажмите «▶ Сравнить». Результат: ADD/UPDATE/DELETE.</p>
|
||||
<p><b>5. Промпты</b> — три вида инструкций для ИИ с версионированием.</p>
|
||||
<h3>На выходе</h3>
|
||||
<p>Структурированная спецификация + полная история изменений с привязкой к документам-источникам.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/state.js?v=1.0.179-flask"></script>
|
||||
<script src="/static/app_utils.js?v=1.0.179-flask"></script>
|
||||
<script src="/static/files.js?v=1.0.179-flask"></script>
|
||||
<script src="/static/groups.js?v=1.0.179-flask"></script>
|
||||
<script src="/static/compare.js?v=1.0.179-flask"></script>
|
||||
<script src="/static/app.js?v=1.0.179-flask"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,104 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Пайплайн сверки договоров</title>
|
||||
<style>
|
||||
:root { --bg: #0d1117; --fg: #c9d1d9; --muted: #8b949e; --accent: #58a6ff; }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font: 15px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--fg); max-width: 980px; margin: 0 auto; padding: 40px 20px 60px; }
|
||||
h1 { font-size: 24px; margin-bottom: 24px; text-align: center; }
|
||||
.nav { margin-bottom: 16px; text-align: center; }
|
||||
.nav a { color: var(--accent); text-decoration: none; font-size: 13px; }
|
||||
svg { display: block; max-width: 100%; height: auto; margin: 0 auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="nav"><a href="/docs/architect">← Архитектура (подробно)</a> | <a href="/docs/architecture-diagram">Блок-схемы (все)</a></div>
|
||||
|
||||
<h1>Пайплайн сверки договоров</h1>
|
||||
|
||||
<svg viewBox="0 0 940 280" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<marker id="ar" markerWidth="7" markerHeight="5" refX="7" refY="2.5" orient="auto"><path d="M0,0 L7,2.5 L0,5 Z" fill="#58a6ff"/></marker>
|
||||
<filter id="sh"><feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.3"/></filter>
|
||||
<linearGradient id="gUpload" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#2a1a1a"/><stop offset="100%" stop-color="#1a0a0a"/></linearGradient>
|
||||
<linearGradient id="gParse" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#1a2a3a"/><stop offset="100%" stop-color="#0d1a28"/></linearGradient>
|
||||
<linearGradient id="gClassify" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#2a1a3a"/><stop offset="100%" stop-color="#1a0d28"/></linearGradient>
|
||||
<linearGradient id="gGroup" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#3a2a1a"/><stop offset="100%" stop-color="#281a0d"/></linearGradient>
|
||||
<linearGradient id="gLLM" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#1a3a2a"/><stop offset="100%" stop-color="#0d2818"/></linearGradient>
|
||||
<linearGradient id="gResult" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#1a2a2a"/><stop offset="100%" stop-color="#0d1a1a"/></linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- ===== MAIN PIPELINE ===== -->
|
||||
|
||||
<!-- 1. Upload -->
|
||||
<rect x="10" y="30" width="135" height="110" rx="10" fill="url(#gUpload)" stroke="#f85149" stroke-width="1.5" filter="url(#sh)"/>
|
||||
<text x="77" y="58" text-anchor="middle" fill="#f85149" font-size="28">📤</text>
|
||||
<text x="77" y="82" text-anchor="middle" fill="#f85149" font-size="14" font-weight="bold">Загрузка</text>
|
||||
<text x="77" y="102" text-anchor="middle" fill="#8b949e" font-size="11">100+ файлов</text>
|
||||
<text x="77" y="120" text-anchor="middle" fill="#8b949e" font-size="11">.docx .pdf .zip</text>
|
||||
|
||||
<line x1="147" y1="85" x2="180" y2="85" stroke="#58a6ff" stroke-width="2" marker-end="url(#ar)"/>
|
||||
|
||||
<!-- 2. Parse -->
|
||||
<rect x="185" y="30" width="135" height="110" rx="10" fill="url(#gParse)" stroke="#58a6ff" stroke-width="1.5" filter="url(#sh)"/>
|
||||
<text x="252" y="58" text-anchor="middle" fill="#58a6ff" font-size="28">🔍</text>
|
||||
<text x="252" y="82" text-anchor="middle" fill="#58a6ff" font-size="14" font-weight="bold">Парсинг</text>
|
||||
<text x="252" y="102" text-anchor="middle" fill="#8b949e" font-size="11">Извлечение</text>
|
||||
<text x="252" y="120" text-anchor="middle" fill="#8b949e" font-size="11">текста и таблиц</text>
|
||||
|
||||
<line x1="322" y1="85" x2="355" y2="85" stroke="#58a6ff" stroke-width="2" marker-end="url(#ar)"/>
|
||||
|
||||
<!-- 3. Classify -->
|
||||
<rect x="360" y="30" width="145" height="110" rx="10" fill="url(#gClassify)" stroke="#bc8cff" stroke-width="1.5" filter="url(#sh)"/>
|
||||
<text x="432" y="58" text-anchor="middle" fill="#bc8cff" font-size="28">🏷️</text>
|
||||
<text x="432" y="82" text-anchor="middle" fill="#bc8cff" font-size="14" font-weight="bold">Классификация</text>
|
||||
<text x="432" y="102" text-anchor="middle" fill="#8b949e" font-size="11">3 фильтра</text>
|
||||
<text x="432" y="120" text-anchor="middle" fill="#8b949e" font-size="11">мусор — в сторону</text>
|
||||
|
||||
<line x1="507" y1="85" x2="540" y2="85" stroke="#58a6ff" stroke-width="2" marker-end="url(#ar)"/>
|
||||
|
||||
<!-- 4. Group -->
|
||||
<rect x="545" y="30" width="135" height="110" rx="10" fill="url(#gGroup)" stroke="#d2991d" stroke-width="1.5" filter="url(#sh)"/>
|
||||
<text x="612" y="58" text-anchor="middle" fill="#d2991d" font-size="28">📋</text>
|
||||
<text x="612" y="82" text-anchor="middle" fill="#d2991d" font-size="14" font-weight="bold">Группировка</text>
|
||||
<text x="612" y="102" text-anchor="middle" fill="#8b949e" font-size="11">По договорам</text>
|
||||
<text x="612" y="120" text-anchor="middle" fill="#8b949e" font-size="11">автоматически</text>
|
||||
|
||||
<line x1="682" y1="85" x2="715" y2="85" stroke="#58a6ff" stroke-width="2" marker-end="url(#ar)"/>
|
||||
|
||||
<!-- 5. LLM Compare -->
|
||||
<rect x="720" y="30" width="140" height="110" rx="10" fill="url(#gLLM)" stroke="#3fb950" stroke-width="1.5" filter="url(#sh)"/>
|
||||
<text x="790" y="54" text-anchor="middle" fill="#3fb950" font-size="28">🤖</text>
|
||||
<text x="790" y="78" text-anchor="middle" fill="#3fb950" font-size="13" font-weight="bold">Сравнение</text>
|
||||
<text x="790" y="96" text-anchor="middle" fill="#3fb950" font-size="13" font-weight="bold">(ИИ)</text>
|
||||
<text x="790" y="118" text-anchor="middle" fill="#8b949e" font-size="11">ADD UPDATE</text>
|
||||
<text x="790" y="134" text-anchor="middle" fill="#8b949e" font-size="11">DELETE</text>
|
||||
|
||||
<!-- ===== SUPPORT ROW ===== -->
|
||||
|
||||
<!-- Protection -->
|
||||
<rect x="10" y="165" width="460" height="45" rx="8" fill="#1a0a0a" stroke="#f85149" stroke-width="1" stroke-dasharray="5,3"/>
|
||||
<text x="240" y="183" text-anchor="middle" fill="#f85149" font-size="12" font-weight="bold">🛡️ Защита на каждом шаге</text>
|
||||
<text x="240" y="200" text-anchor="middle" fill="#8b949e" font-size="10">ZIP-бомба · дубликаты · битые числа/даты → автоисправление · непонятное → на проверку</text>
|
||||
|
||||
<line x1="240" y1="163" x2="240" y2="140" stroke="#f85149" stroke-width="1" stroke-dasharray="3,2"/>
|
||||
|
||||
<!-- Confidentiality -->
|
||||
<rect x="480" y="165" width="450" height="45" rx="8" fill="#1a2a0a" stroke="#3fb950" stroke-width="1" stroke-dasharray="5,3"/>
|
||||
<text x="705" y="183" text-anchor="middle" fill="#3fb950" font-size="12" font-weight="bold">🔒 Конфиденциальность</text>
|
||||
<text x="705" y="200" text-anchor="middle" fill="#8b949e" font-size="10">Файлы удаляются после обработки · без облачного хранения · данные только на время сессии</text>
|
||||
|
||||
<line x1="705" y1="163" x2="705" y2="140" stroke="#3fb950" stroke-width="1" stroke-dasharray="3,2"/>
|
||||
|
||||
<!-- ===== BOTTOM BAR: Result ===== -->
|
||||
|
||||
<rect x="10" y="235" width="920" height="40" rx="8" fill="#161b22" stroke="#58a6ff" stroke-width="1.5" filter="url(#sh)"/>
|
||||
<text x="470" y="260" text-anchor="middle" fill="#58a6ff" font-size="13" font-weight="bold">📊 Результат: итоговая спецификация + полная история изменений (что, когда и откуда добавилось / изменилось / удалилось)</text>
|
||||
</svg>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user