From fe1f76fa1d5b4abc62a179b3859d8f3229db3b7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Wed, 24 Jun 2026 08:46:40 +0400 Subject: [PATCH] v1.0.165: app_utils.js + detailed comments (Opus analysis) in classify/grouping/connection --- deploy/app.js | 49 ++----------------- deploy/app_utils.js | 94 +++++++++++++++++++++++++++++++++++++ deploy/db/connection.py | 29 ++++++++++-- deploy/services/classify.py | 49 ++++++++++++++++--- deploy/services/grouping.py | 42 +++++++++++++++-- index.cfm | 3 +- 6 files changed, 205 insertions(+), 61 deletions(-) create mode 100644 deploy/app_utils.js diff --git a/deploy/app.js b/deploy/app.js index b83c743..c79a12a 100644 --- a/deploy/app.js +++ b/deploy/app.js @@ -12,19 +12,8 @@ var fileTable = document.getElementById('fileTable'); var fileQueue = []; var contractId = null; var batchId = crypto.randomUUID(); // классификация: привязка всех файлов сессии - -function formatSize(bytes) { - if (!bytes || bytes === 0) return '—'; - if (bytes < 1024) return bytes + ' B'; - if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; - return (bytes / 1048576).toFixed(1) + ' MB'; -} - -function formatDate(ts) { - if (!ts) return '—'; - var d = new Date(ts); - return d.toLocaleDateString('ru-RU') + ' ' + d.toLocaleTimeString('ru-RU', {hour:'2-digit',minute:'2-digit'}); -} +// Утилиты (formatSize, formatDate, moveUp, moveDown, escHtml, fmtDate) — +// вынесены в app_utils.js для облегчения анализа и отладки. function renderTable() { if (fileQueue.length === 0) { @@ -44,36 +33,7 @@ function renderTable() { lucide.createIcons(); } -function removeFile(i) { - fileQueue.splice(i, 1); - if (fileQueue.length === 0) contractId = null; - renderTable(); -} -window.removeFile = removeFile; - -function moveUp(i) { - if (i <= 0) return; - var tmp = fileQueue[i]; fileQueue[i] = fileQueue[i-1]; fileQueue[i-1] = tmp; - renderTable(); -} -function moveDown(i) { - if (i >= fileQueue.length - 1) return; - var tmp = fileQueue[i]; fileQueue[i] = fileQueue[i+1]; fileQueue[i+1] = tmp; - renderTable(); -} -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; +// moveUp/moveDown/removeFile/openAbout/closeAbout — вынесены в app_utils.js // ── Автозагрузка при выборе файлов ────────────────────────── fileInput.addEventListener('change', async function() { @@ -649,8 +609,7 @@ function loadHistory(role) { .catch(function(e) { console.error('loadHistory:', e); }); } -function escHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; } -function fmtDate(d) { if (!d) return ''; return d.replace('T', ' ').substring(0, 16); } +// escHtml/fmtDate — вынесены в app_utils.js function activatePrompt(id) { fetch('/prompt.cfm?action=activate', { diff --git a/deploy/app_utils.js b/deploy/app_utils.js new file mode 100644 index 0000000..daaef95 --- /dev/null +++ b/deploy/app_utils.js @@ -0,0 +1,94 @@ +/** + * 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'}); +} + +/** + * Удаляет файл из очереди по индексу. + * Вызывается по кнопке ✕ в таблице файлов. + */ +function removeFile(i) { + fileQueue.splice(i, 1); + if (fileQueue.length === 0) contractId = null; + renderTable(); +} +window.removeFile = removeFile; + +/** + * Переместить файл на одну позицию ВВЕРХ (сохранено для возможного возврата ручной сортировки). + * В текущей версии (v1.0.164+) не используется — порядок определяет авто-классификация. + */ +function moveUp(i) { + if (i <= 0) return; + var tmp = fileQueue[i]; fileQueue[i] = fileQueue[i-1]; fileQueue[i-1] = tmp; + renderTable(); +} + +/** + * Переместить файл на одну позицию ВНИЗ (сохранено для возможного возврата ручной сортировки). + * В текущей версии (v1.0.164+) не используется — порядок определяет авто-классификация. + */ +function moveDown(i) { + if (i >= fileQueue.length - 1) return; + var tmp = fileQueue[i]; fileQueue[i] = fileQueue[i+1]; fileQueue[i+1] = tmp; + renderTable(); +} +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) { + 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); +} diff --git a/deploy/db/connection.py b/deploy/db/connection.py index eec9312..6afefb4 100644 --- a/deploy/db/connection.py +++ b/deploy/db/connection.py @@ -1,11 +1,22 @@ -"""Database connection — psycopg2 connection pool.""" +""" +Database connection — psycopg2 connection pool. + +Архитектурное решение (Opus): +- ThreadedConnectionPool(minconn=1, maxconn=10) — оптимально для ThreadingMixIn HTTP-сервера. + Каждый HTTP-запрос в отдельном потоке получает своё соединение из пула. +- RealDictCursor для query() — возвращает dict с lowercase ключами (консистентно с Python API). +- execute()/execute_returning() — для INSERT/UPDATE/DELETE с автокоммитом и откатом при ошибке. +- Пул создаётся лениво (при первом запросе) через get_pool(). +""" import os import psycopg2 import psycopg2.pool import psycopg2.extras +# Глобальный пул соединений (singleton) _pool = None +# Параметры подключения из переменных окружения (systemd Environment) DB_CONFIG = { "host": os.getenv("DB_HOST", "127.0.0.1"), "port": int(os.getenv("DB_PORT", "5432")), @@ -16,6 +27,7 @@ DB_CONFIG = { def get_pool(): + """Возвращает глобальный пул соединений. Создаёт при первом вызове.""" global _pool if _pool is None: _pool = psycopg2.pool.ThreadedConnectionPool( @@ -25,7 +37,10 @@ def get_pool(): def query(sql, params=None): - """SELECT → list of dicts (lowercase keys).""" + """ + SELECT → list[dict] с lowercase ключами. + Используется всеми db/*.py модулями для чтения данных. + """ pool = get_pool() conn = pool.getconn() try: @@ -38,7 +53,10 @@ def query(sql, params=None): def execute(sql, params=None): - """INSERT/UPDATE/DELETE → rowcount.""" + """ + INSERT/UPDATE/DELETE → количество затронутых строк. + Автокоммит. При ошибке — rollback и проброс исключения. + """ pool = get_pool() conn = pool.getconn() try: @@ -54,7 +72,10 @@ def execute(sql, params=None): def execute_returning(sql, params=None): - """INSERT/UPDATE/DELETE with RETURNING → first row dict.""" + """ + INSERT/UPDATE/DELETE с RETURNING → dict первой строки. + Используется для insert с автогенерацией UUID (gen_random_uuid()). + """ pool = get_pool() conn = pool.getconn() try: diff --git a/deploy/services/classify.py b/deploy/services/classify.py index 0274d06..68e41cc 100644 --- a/deploy/services/classify.py +++ b/deploy/services/classify.py @@ -1,4 +1,15 @@ -"""Classify service — LLM-based document classification.""" +""" +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 @@ -8,20 +19,27 @@ from 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" def _call_llm_classify(header_text): - """Call LLM for classification. Returns parsed JSON dict.""" + """ + Прямой вызов LLM для классификации ОДНОГО документа. + Не использует общий call_llm() из services/llm.py — здесь свой промпт и формат ответа. + Возвращает распарсенный JSON dict с полями: doc_type, own_number, parent_number, doc_date, counterparty, confidence. + """ prompt, _ = build_classify_prompt(header_text) payload = { "model": LLM_MODEL, "messages": [{"role": "user", "content": prompt}], - "max_tokens": 500, - "temperature": 0.1, + "max_tokens": 500, # классификация укладывается в ~100 токенов ответа + "temperature": 0.1, # минимальная температура для детерминированности } with httpx.Client(http2=True, timeout=60, verify=False) as client: resp = client.post( @@ -32,7 +50,7 @@ def _call_llm_classify(header_text): data = resp.json() raw = data.get("choices", [{}])[0].get("message", {}).get("content", "") - # Extract JSON from possible markdown wrapping + # LLM может обернуть JSON в markdown-блок ```json ... ``` json_text = raw if "```json" in json_text: json_text = json_text.split("```json")[1].split("```")[0] @@ -42,7 +60,12 @@ def _call_llm_classify(header_text): def classify_batch(batch_id): - """Classify all pending documents in a batch. Returns summary dict.""" + """ + Классифицировать все pending-документы в batch. + Вызывается из эндпоинта POST /api/classify-batch. + Параллельно (ThreadPoolExecutor) обрабатывает до MAX_WORKERS документов. + Возвращает {ok, total, done, failed}. + """ pending = db_docs.list_pending(batch_id) if not pending: return {"ok": False, "error": "no pending documents"} @@ -52,6 +75,7 @@ def classify_batch(batch_id): failed = 0 def _classify_one(doc): + """Классифицировать один документ: выжимка → LLM → сохранить результат.""" try: text = _smart_extract(doc["elements_json"]) result = _call_llm_classify(text) @@ -80,7 +104,18 @@ def classify_batch(batch_id): def _smart_extract(elements_json): - """Extract smart header: first ~1500 chars + marker lines from full doc.""" + """ + Умная выжимка текста для классификации (решение Q3 от Opus). + + Вместо отправки всего документа (дорого) или только header (теряет зарытые номера), + используется гибрид: + 1. Первые ~1500 симв (титул, преамбула, стороны) + 2. Regex-хиты по маркерам «договор|№|соглашение|приложение|спецификация» + из ВСЕГО документа + 3. Дедупликация, лимит 10 строк, склейка → ~3000 симв на вход LLM + + Это покрывает и титульную зону, и зарытые ссылки в середине документа. + """ if not elements_json: return "" diff --git a/deploy/services/grouping.py b/deploy/services/grouping.py index af0e614..2f0f2b1 100644 --- a/deploy/services/grouping.py +++ b/deploy/services/grouping.py @@ -1,4 +1,13 @@ -"""Grouping service — match classified documents into contract groups.""" +""" +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 db import documents as db_docs from db import contracts as db_contracts @@ -6,14 +15,29 @@ from db import supplements as db_supplements def normalize_number(num): - """Normalize contract number for matching: uppercase, only letters/digits.""" + """ + Нормализация номера договора для сравнения. + Убирает всё кроме букв и цифр, приводит к 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): - """Group classified documents by contract. Returns list of groups.""" + """ + Сгруппировать классифицированные документы по контрактам. + + Алгоритм: + 1. Отделить contract от supplement/specification + 2. Каждый contract → якорь группы + 3. Для каждого supplement: найти contract по parent_number (нормализованный) + 4. Несматченные → группа "__unresolved__" + 5. Внутри группы сортировка по 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"] @@ -72,7 +96,17 @@ def group_documents(batch_id): def apply_groups(batch_id, groups_data): - """Apply confirmed groups: create contracts + supplements.""" + """ + Применить подтверждённые группы: создать contracts + supplements. + + Вызывается из POST /api/apply-groups. + Для каждой группы (кроме __unresolved__): + 1. Создать запись в contracts (number, client) + 2. Для каждого документа создать supplement (type='initial' для первого, 'additional' для остальных) + 3. Порядок supplements соответствует порядку документов в группе (сортировка по дате уже сделана) + + Возвращает {ok, created: количество созданных supplements}. + """ created = 0 for g in groups_data: contract_number = g.get("contract_number", "") diff --git a/index.cfm b/index.cfm index 02800fc..55da409 100644 --- a/index.cfm +++ b/index.cfm @@ -75,7 +75,7 @@
Nubes - Сверка договоров — LLM AI-driven Event Sourcing v1.0.164 — Lucee + Сверка договоров — LLM AI-driven Event Sourcing v1.0.165 — Lucee
@@ -186,6 +186,7 @@
+