v1.0.165: app_utils.js + detailed comments (Opus analysis) in classify/grouping/connection
This commit is contained in:
+4
-45
@@ -12,19 +12,8 @@ var fileTable = document.getElementById('fileTable');
|
|||||||
var fileQueue = [];
|
var fileQueue = [];
|
||||||
var contractId = null;
|
var contractId = null;
|
||||||
var batchId = crypto.randomUUID(); // классификация: привязка всех файлов сессии
|
var batchId = crypto.randomUUID(); // классификация: привязка всех файлов сессии
|
||||||
|
// Утилиты (formatSize, formatDate, moveUp, moveDown, escHtml, fmtDate) —
|
||||||
function formatSize(bytes) {
|
// вынесены в app_utils.js для облегчения анализа и отладки.
|
||||||
if (!bytes || bytes === 0) return '—';
|
|
||||||
if (bytes < 1024) return bytes + ' B';
|
|
||||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
|
||||||
return (bytes / 1048576).toFixed(1) + ' MB';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(ts) {
|
|
||||||
if (!ts) return '—';
|
|
||||||
var d = new Date(ts);
|
|
||||||
return d.toLocaleDateString('ru-RU') + ' ' + d.toLocaleTimeString('ru-RU', {hour:'2-digit',minute:'2-digit'});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTable() {
|
function renderTable() {
|
||||||
if (fileQueue.length === 0) {
|
if (fileQueue.length === 0) {
|
||||||
@@ -44,36 +33,7 @@ function renderTable() {
|
|||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeFile(i) {
|
// moveUp/moveDown/removeFile/openAbout/closeAbout — вынесены в app_utils.js
|
||||||
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;
|
|
||||||
|
|
||||||
// ── Автозагрузка при выборе файлов ──────────────────────────
|
// ── Автозагрузка при выборе файлов ──────────────────────────
|
||||||
fileInput.addEventListener('change', async function() {
|
fileInput.addEventListener('change', async function() {
|
||||||
@@ -649,8 +609,7 @@ function loadHistory(role) {
|
|||||||
.catch(function(e) { console.error('loadHistory:', e); });
|
.catch(function(e) { console.error('loadHistory:', e); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function escHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
// escHtml/fmtDate — вынесены в app_utils.js
|
||||||
function fmtDate(d) { if (!d) return ''; return d.replace('T', ' ').substring(0, 16); }
|
|
||||||
|
|
||||||
function activatePrompt(id) {
|
function activatePrompt(id) {
|
||||||
fetch('/prompt.cfm?action=activate', {
|
fetch('/prompt.cfm?action=activate', {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
+25
-4
@@ -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 os
|
||||||
import psycopg2
|
import psycopg2
|
||||||
import psycopg2.pool
|
import psycopg2.pool
|
||||||
import psycopg2.extras
|
import psycopg2.extras
|
||||||
|
|
||||||
|
# Глобальный пул соединений (singleton)
|
||||||
_pool = None
|
_pool = None
|
||||||
|
|
||||||
|
# Параметры подключения из переменных окружения (systemd Environment)
|
||||||
DB_CONFIG = {
|
DB_CONFIG = {
|
||||||
"host": os.getenv("DB_HOST", "127.0.0.1"),
|
"host": os.getenv("DB_HOST", "127.0.0.1"),
|
||||||
"port": int(os.getenv("DB_PORT", "5432")),
|
"port": int(os.getenv("DB_PORT", "5432")),
|
||||||
@@ -16,6 +27,7 @@ DB_CONFIG = {
|
|||||||
|
|
||||||
|
|
||||||
def get_pool():
|
def get_pool():
|
||||||
|
"""Возвращает глобальный пул соединений. Создаёт при первом вызове."""
|
||||||
global _pool
|
global _pool
|
||||||
if _pool is None:
|
if _pool is None:
|
||||||
_pool = psycopg2.pool.ThreadedConnectionPool(
|
_pool = psycopg2.pool.ThreadedConnectionPool(
|
||||||
@@ -25,7 +37,10 @@ def get_pool():
|
|||||||
|
|
||||||
|
|
||||||
def query(sql, params=None):
|
def query(sql, params=None):
|
||||||
"""SELECT → list of dicts (lowercase keys)."""
|
"""
|
||||||
|
SELECT → list[dict] с lowercase ключами.
|
||||||
|
Используется всеми db/*.py модулями для чтения данных.
|
||||||
|
"""
|
||||||
pool = get_pool()
|
pool = get_pool()
|
||||||
conn = pool.getconn()
|
conn = pool.getconn()
|
||||||
try:
|
try:
|
||||||
@@ -38,7 +53,10 @@ def query(sql, params=None):
|
|||||||
|
|
||||||
|
|
||||||
def execute(sql, params=None):
|
def execute(sql, params=None):
|
||||||
"""INSERT/UPDATE/DELETE → rowcount."""
|
"""
|
||||||
|
INSERT/UPDATE/DELETE → количество затронутых строк.
|
||||||
|
Автокоммит. При ошибке — rollback и проброс исключения.
|
||||||
|
"""
|
||||||
pool = get_pool()
|
pool = get_pool()
|
||||||
conn = pool.getconn()
|
conn = pool.getconn()
|
||||||
try:
|
try:
|
||||||
@@ -54,7 +72,10 @@ def execute(sql, params=None):
|
|||||||
|
|
||||||
|
|
||||||
def execute_returning(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()
|
pool = get_pool()
|
||||||
conn = pool.getconn()
|
conn = pool.getconn()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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
|
import json, re, os
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
@@ -8,20 +19,27 @@ from llm_prompt import build_classify_prompt
|
|||||||
|
|
||||||
log = __import__("logging").getLogger(__name__)
|
log = __import__("logging").getLogger(__name__)
|
||||||
|
|
||||||
|
# Лимит одновременных запросов к LLM API
|
||||||
|
# Увеличивать осторожно — api.aillm.ru может троттлить
|
||||||
MAX_WORKERS = 4
|
MAX_WORKERS = 4
|
||||||
|
|
||||||
LLM_URL = "https://api.aillm.ru/v1/chat/completions"
|
LLM_URL = "https://api.aillm.ru/v1/chat/completions"
|
||||||
LLM_KEY = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "")
|
LLM_KEY = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "")
|
||||||
LLM_MODEL = "gpt-oss-120b"
|
LLM_MODEL = "gpt-oss-120b"
|
||||||
|
|
||||||
|
|
||||||
def _call_llm_classify(header_text):
|
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)
|
prompt, _ = build_classify_prompt(header_text)
|
||||||
payload = {
|
payload = {
|
||||||
"model": LLM_MODEL,
|
"model": LLM_MODEL,
|
||||||
"messages": [{"role": "user", "content": prompt}],
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
"max_tokens": 500,
|
"max_tokens": 500, # классификация укладывается в ~100 токенов ответа
|
||||||
"temperature": 0.1,
|
"temperature": 0.1, # минимальная температура для детерминированности
|
||||||
}
|
}
|
||||||
with httpx.Client(http2=True, timeout=60, verify=False) as client:
|
with httpx.Client(http2=True, timeout=60, verify=False) as client:
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
@@ -32,7 +50,7 @@ def _call_llm_classify(header_text):
|
|||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
raw = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
raw = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||||
# Extract JSON from possible markdown wrapping
|
# LLM может обернуть JSON в markdown-блок ```json ... ```
|
||||||
json_text = raw
|
json_text = raw
|
||||||
if "```json" in json_text:
|
if "```json" in json_text:
|
||||||
json_text = json_text.split("```json")[1].split("```")[0]
|
json_text = json_text.split("```json")[1].split("```")[0]
|
||||||
@@ -42,7 +60,12 @@ def _call_llm_classify(header_text):
|
|||||||
|
|
||||||
|
|
||||||
def classify_batch(batch_id):
|
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)
|
pending = db_docs.list_pending(batch_id)
|
||||||
if not pending:
|
if not pending:
|
||||||
return {"ok": False, "error": "no pending documents"}
|
return {"ok": False, "error": "no pending documents"}
|
||||||
@@ -52,6 +75,7 @@ def classify_batch(batch_id):
|
|||||||
failed = 0
|
failed = 0
|
||||||
|
|
||||||
def _classify_one(doc):
|
def _classify_one(doc):
|
||||||
|
"""Классифицировать один документ: выжимка → LLM → сохранить результат."""
|
||||||
try:
|
try:
|
||||||
text = _smart_extract(doc["elements_json"])
|
text = _smart_extract(doc["elements_json"])
|
||||||
result = _call_llm_classify(text)
|
result = _call_llm_classify(text)
|
||||||
@@ -80,7 +104,18 @@ def classify_batch(batch_id):
|
|||||||
|
|
||||||
|
|
||||||
def _smart_extract(elements_json):
|
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:
|
if not elements_json:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
import re
|
||||||
from db import documents as db_docs
|
from db import documents as db_docs
|
||||||
from db import contracts as db_contracts
|
from db import contracts as db_contracts
|
||||||
@@ -6,14 +15,29 @@ from db import supplements as db_supplements
|
|||||||
|
|
||||||
|
|
||||||
def normalize_number(num):
|
def normalize_number(num):
|
||||||
"""Normalize contract number for matching: uppercase, only letters/digits."""
|
"""
|
||||||
|
Нормализация номера договора для сравнения.
|
||||||
|
Убирает всё кроме букв и цифр, приводит к uppercase.
|
||||||
|
Пример: "МЭС-123-2024" → "МЭС1232024", "МЭС 123/2024" → "МЭС1232024".
|
||||||
|
"""
|
||||||
if not num:
|
if not num:
|
||||||
return ""
|
return ""
|
||||||
return re.sub(r"[^A-Z0-9А-Я]", "", num.upper())
|
return re.sub(r"[^A-Z0-9А-Я]", "", num.upper())
|
||||||
|
|
||||||
|
|
||||||
def group_documents(batch_id):
|
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)
|
docs = db_docs.list_by_batch(batch_id)
|
||||||
classified = [d for d in docs if d.get("classify_status") == "classified"]
|
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):
|
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
|
created = 0
|
||||||
for g in groups_data:
|
for g in groups_data:
|
||||||
contract_number = g.get("contract_number", "")
|
contract_number = g.get("contract_number", "")
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<img src="/nubes-logo.svg" alt="Nubes">
|
<img src="/nubes-logo.svg" alt="Nubes">
|
||||||
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.164 — Lucee</span></span>
|
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.165 — Lucee</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
@@ -186,6 +186,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="https://contracts.kube5s.ru/static/app_utils.js"></script>
|
||||||
<script src="https://contracts.kube5s.ru/static/app.js"></script>
|
<script src="https://contracts.kube5s.ru/static/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user