Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c56b980501 | ||
|
|
776d831fb8 | ||
|
|
31e5ef7ac5 | ||
|
|
909468b5b7 | ||
|
|
a474b4324b | ||
|
|
465be4ca04 | ||
|
|
d95c17cc18 | ||
|
|
2764ab1cb5 | ||
|
|
4e59c83699 | ||
|
|
08779c3938 |
@@ -0,0 +1,6 @@
|
||||
⛔ ЗАМОРОЖЕНО 2026-06-28.
|
||||
|
||||
Весь код здесь — Lucee/прод (contracts.kube5s.ru, БД baza).
|
||||
НЕ ТРОГАТЬ без прямого приказа.
|
||||
|
||||
Активная разработка: /home/naeel/nubes/contracts/contracts-flask
|
||||
@@ -15,14 +15,12 @@
|
||||
<cfelseif NOT len(form.question)>
|
||||
<cfset result.error = "question required (POST field)">
|
||||
<cfelse>
|
||||
<!--- Собрать все spec_rows --->
|
||||
<!--- Собрать строки текущей спецификации --->
|
||||
<cfquery name="rowsData" datasource="baza">
|
||||
SELECT d.filename, sr.row_num, sr.name, sr.price, sr.qty, sr.sum, sr.date_start
|
||||
FROM spec_rows sr
|
||||
JOIN supplements s ON sr.supplement_id = s.id
|
||||
JOIN documents d ON s.document_id = d.id
|
||||
WHERE s.contract_id = <cfqueryparam value="#url.contract_id#" cfsqltype="cf_sql_varchar">
|
||||
ORDER BY s.created_at, sr.row_num
|
||||
SELECT name, price, qty, sum, date_start
|
||||
FROM spec_current
|
||||
WHERE contract_id = <cfqueryparam value="#url.contract_id#" cfsqltype="cf_sql_varchar">
|
||||
ORDER BY name
|
||||
</cfquery>
|
||||
|
||||
<cfif rowsData.recordCount EQ 0>
|
||||
@@ -32,7 +30,7 @@
|
||||
<cfset contextLines = []>
|
||||
<cfloop query="rowsData">
|
||||
<cfset arrayAppend(contextLines,
|
||||
"[" & rowsData.filename & "] строка " & rowsData.row_num & ": " & rowsData.name &
|
||||
rowsData.name &
|
||||
" | цена=" & rowsData.price & " | объём=" & rowsData.qty &
|
||||
" | сумма=" & rowsData.sum & " | начало=" & rowsData.date_start
|
||||
)>
|
||||
@@ -50,7 +48,7 @@
|
||||
Ответь кратко и по делу, опираясь ТОЛЬКО на данные выше. Если данных недостаточно — скажи об этом.">
|
||||
|
||||
<!--- Вызов LLM --->
|
||||
<cfset apiKey = "sk-ucI5YvOticoOQ9Kuj5K9mQ">
|
||||
<cfset apiKey = createObject("java", "java.lang.System").getenv("LLM_KEY")>
|
||||
<cfset llmPayload = {
|
||||
"model": "gpt-oss-120b",
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
|
||||
@@ -13,6 +13,7 @@ db_prompts.seed_defaults()
|
||||
# Schema migration: classify_raw column (idempotent)
|
||||
execute("ALTER TABLE documents ADD COLUMN IF NOT EXISTS classify_raw text")
|
||||
execute("ALTER TABLE documents ADD COLUMN IF NOT EXISTS classify_input text")
|
||||
execute("ALTER TABLE documents ADD COLUMN IF NOT EXISTS zip_source text")
|
||||
|
||||
# ── DB modules ────────────────────────────────────────────────────────────
|
||||
from db import supplements as db_supplements
|
||||
|
||||
+23
-6
@@ -2,12 +2,12 @@
|
||||
from .connection import query, execute, execute_returning
|
||||
|
||||
|
||||
def insert(filename, mime_type, original_bytes, status="uploaded", batch_id=None):
|
||||
def insert(filename, mime_type, original_bytes, status="uploaded", batch_id=None, zip_source=None):
|
||||
"""Insert document, return row dict."""
|
||||
return execute_returning(
|
||||
"""INSERT INTO documents (filename, mime_type, original_bytes, status, batch_id)
|
||||
VALUES (%s, %s, %s, %s, %s) RETURNING *""",
|
||||
(filename, mime_type, original_bytes, status, batch_id),
|
||||
"""INSERT INTO documents (filename, mime_type, original_bytes, status, batch_id, zip_source)
|
||||
VALUES (%s, %s, %s, %s, %s, %s) RETURNING *""",
|
||||
(filename, mime_type, original_bytes, status, batch_id, zip_source),
|
||||
)
|
||||
|
||||
|
||||
@@ -53,6 +53,14 @@ def set_classify_failed(doc_id, error):
|
||||
)
|
||||
|
||||
|
||||
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(
|
||||
@@ -62,18 +70,27 @@ def list_pending(batch_id):
|
||||
|
||||
|
||||
def reset_classify_status(batch_id):
|
||||
"""Сбросить classify_status на 'pending' для всех документов батча."""
|
||||
"""Сбросить classify_status на 'pending' для всех документов батча (включая 'processing' — crash recovery)."""
|
||||
return execute(
|
||||
"UPDATE documents SET classify_status='pending', error_message=NULL WHERE batch_id=%s",
|
||||
(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
|
||||
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,),
|
||||
)
|
||||
|
||||
+60
-14
@@ -53,10 +53,51 @@ function statusToHTML(st) {
|
||||
function renderFiles(state) {
|
||||
if (state.files.length === 0) {
|
||||
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .docx / .pdf</td></tr>';
|
||||
} else {
|
||||
fileTable.innerHTML = state.files.map(function(f, i) {
|
||||
var rows = '<tr id="row_' + i + '">' +
|
||||
'<td class="name-cell" style="cursor:pointer;" onclick="toggleClassifyDetail(' + i + ')">' +
|
||||
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>' +
|
||||
@@ -65,10 +106,12 @@ function renderFiles(state) {
|
||||
'<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>';
|
||||
return rows;
|
||||
}).join('');
|
||||
'<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();
|
||||
}
|
||||
|
||||
@@ -170,7 +213,7 @@ window.toggleClassifyDetail = async function(i) {
|
||||
* - Возвращает Promise<ответ API> с полями doc_id, contract_id, parsed
|
||||
* - Таймаут 180с (большие PDF)
|
||||
*/
|
||||
function uploadFile(file, onProgress) {
|
||||
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');
|
||||
@@ -183,6 +226,7 @@ function uploadFile(file, onProgress) {
|
||||
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));
|
||||
@@ -340,7 +384,7 @@ async function addZipFile(file) {
|
||||
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);
|
||||
await addRegularFile(extractedFile, file.name);
|
||||
}
|
||||
|
||||
// Шаг 3: убрать временную строку ZIP (только после успешной обработки всех файлов)
|
||||
@@ -365,14 +409,16 @@ async function addZipFile(file) {
|
||||
*
|
||||
* Мутирует state.files, вызывает render(state) после каждого изменения.
|
||||
*/
|
||||
async function addRegularFile(file) {
|
||||
async function addRegularFile(file, zipSource) {
|
||||
// Создать запись с начальным статусом
|
||||
var entry = { name: file.name, lastModified: file.lastModified, size: file.size, file: file, status: { kind: 'uploading', pct: 0 } };
|
||||
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++) {
|
||||
if (state.files[j].name === file.name) { existingIdx = j; break; }
|
||||
var ejKey = (state.files[j].zip_source || '') + '/' + state.files[j].name;
|
||||
if (ejKey === dupKey) { existingIdx = j; break; }
|
||||
}
|
||||
var rowIdx;
|
||||
if (existingIdx >= 0) {
|
||||
@@ -393,7 +439,7 @@ async function addRegularFile(file) {
|
||||
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;
|
||||
|
||||
@@ -172,6 +172,7 @@ function renderGroupCard(group, gi) {
|
||||
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>' +
|
||||
@@ -211,6 +212,7 @@ function renderGroupCardDone(group, gi) {
|
||||
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>' +
|
||||
|
||||
+44
-26
@@ -1,10 +1,8 @@
|
||||
"""llm_prompt.py — Формирование промпта для LLM-анализа ДС.
|
||||
Читает активный промпт из БД (через Lucee API). При ошибке — fallback на хардкод."""
|
||||
Читает активный промпт из БД напрямую (db.prompts). При ошибке — fallback на хардкод."""
|
||||
|
||||
import os
|
||||
import httpx
|
||||
|
||||
LUCEE_URL = os.environ.get("LUCEE_URL", "https://contractor.luceek8s.dev.nubes.ru")
|
||||
from db import prompts as db_prompts
|
||||
|
||||
# ── Fallback-промпты (если БД недоступна) ──────────────────────
|
||||
|
||||
@@ -102,7 +100,7 @@ FALLBACK_DIFF = """Ты — анализатор допсоглашений (Д
|
||||
7. Если в тексте есть и фраза о новой редакции, и точечные правки — приоритет за «новой редакцией»: full_replace.
|
||||
|
||||
EDGE-CASES:
|
||||
8. UNRESOLVED — если ДС упоминает изменение услуги, которой НЕТ в текущей спецификации, ИЛИ название настолько отличается, что нельзя уверенно сопоставить с конкретным id. В reason укажи причину.
|
||||
8. UNRESOLVED — если ДС упоминает изменение услуги, которой НЕТ в текущей спецификации, ИЛИ название настолько отличается, что нельзя уверенно сопоставить с конкретным id. ВАЖНО: если СОМНЕВАЕШЬСЯ в сопоставлении — делай UNRESOLVED, а НЕ ADD. Лучше unresolved, чем ложный дубликат. В reason укажи причину.
|
||||
9. Частичные данные: если в ДС нет цены/кол-ва/даты — ставь null для этих полей, не выдумывай.
|
||||
10. Пропускай итоговые строки («Итого», «НДС», «К оплате») и подписи/реквизиты.
|
||||
11. price, qty, sum — ЧИСЛА (без «руб.», без пробелов; «55 000,00» \u2192 55000). Не пересчитывай суммы сам — бери из ДС.
|
||||
@@ -178,19 +176,11 @@ EDGE-CASES:
|
||||
|
||||
|
||||
def _fetch_prompt(role: str) -> dict | None:
|
||||
"""Получить активный промпт из БД. Возвращает {id, body} или None."""
|
||||
"""Получить активный промпт из БД напрямую (а не через Lucee HTTP)."""
|
||||
try:
|
||||
with httpx.Client(http2=True, timeout=10) as client:
|
||||
resp = client.get(
|
||||
f"{LUCEE_URL}/prompt.cfm",
|
||||
params={"action": "get_active", "role": role},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
# Lucee serializeJSON → UPPERCASE keys, normalize to lowercase
|
||||
data = {k.lower(): v for k, v in data.items()}
|
||||
if data.get("ok") and data.get("body"):
|
||||
return {"id": data.get("id", ""), "body": data["body"]}
|
||||
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
|
||||
@@ -242,17 +232,45 @@ def build_classify_prompt(header_text):
|
||||
body = prompt["body"].replace("{header_text}", header_text)
|
||||
return body, prompt.get("id", "")
|
||||
# Fallback
|
||||
body = """Ты — классификатор договорных документов. Ниже фрагмент текста документа.
|
||||
body = """Ты — классификатор договорных документов облачного провайдера НУБЕС.
|
||||
|
||||
Определи:
|
||||
1. doc_type: "contract", "supplement", "specification", "other"
|
||||
2. own_number: номер ЭТОГО документа
|
||||
3. parent_number: номер родительского договора (для допников/спецификаций)
|
||||
4. doc_date: дата в формате YYYY-MM-DD
|
||||
5. counterparty: название контрагента
|
||||
6. confidence: "ok" или "low"
|
||||
Ниже фрагмент текста документа. Определи:
|
||||
|
||||
Верни СТРОГО JSON: {"doc_type":"...","own_number":"...","parent_number":"...","doc_date":"...","counterparty":"...","confidence":"..."}
|
||||
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":"ООО Ромашка"}
|
||||
|
||||
ДОКУМЕНТ:
|
||||
---
|
||||
|
||||
@@ -27,11 +27,36 @@ 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"
|
||||
|
||||
# ── 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 для классификации ОДНОГО документа.
|
||||
Возвращает (parsed_dict, raw_text).
|
||||
Возвращает (parsed_dict, raw_text, needed_fix).
|
||||
"""
|
||||
prompt, _ = build_classify_prompt(header_text)
|
||||
payload = {
|
||||
@@ -49,7 +74,8 @@ def _call_llm_classify(header_text):
|
||||
data = resp.json()
|
||||
|
||||
raw = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
return _safe_json_parse(raw), raw
|
||||
parsed, needed_fix = _safe_json_parse(raw)
|
||||
return parsed, raw, needed_fix
|
||||
|
||||
|
||||
def classify_batch(batch_id):
|
||||
@@ -68,12 +94,33 @@ def classify_batch(batch_id):
|
||||
total = len(pending)
|
||||
done = 0
|
||||
failed = 0
|
||||
garbage = 0
|
||||
json_fixes = 0
|
||||
json_total = 0
|
||||
|
||||
def _classify_one(doc):
|
||||
"""Классифицировать один документ: выжимка → LLM → сохранить результат."""
|
||||
"""Классифицировать один документ: фильтр → выжимка → LLM → сохранить."""
|
||||
nonlocal garbage, json_fixes, json_total
|
||||
try:
|
||||
# Stage 1: garbage by filename (0 tokens)
|
||||
if _is_garbage_by_filename(doc["filename"]):
|
||||
db_docs.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"])
|
||||
result, raw = _call_llm_classify(text)
|
||||
if _is_garbage_by_header(text):
|
||||
db_docs.set_classify_garbage(doc["id"], "header_keywords")
|
||||
garbage += 1
|
||||
return True
|
||||
|
||||
# Stage 3: LLM classification (only for remaining)
|
||||
db_docs.set_classify_processing(doc["id"]) # crash recovery marker
|
||||
result, raw, needed_fix = _call_llm_classify(text)
|
||||
json_total += 1
|
||||
if needed_fix:
|
||||
json_fixes += 1
|
||||
db_docs.set_classification(
|
||||
doc["id"],
|
||||
result.get("doc_type", "other"),
|
||||
@@ -97,11 +144,13 @@ def classify_batch(batch_id):
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
return {"ok": True, "total": total, "done": done, "failed": failed}
|
||||
return {"ok": True, "total": total, "done": done, "failed": failed,
|
||||
"garbage": garbage, "json_fix_rate": round(json_fixes / max(json_total, 1), 3)}
|
||||
|
||||
|
||||
def _safe_json_parse(raw):
|
||||
"""Parse LLM response, fixing common JSON errors."""
|
||||
"""Parse LLM response, fixing common JSON errors.
|
||||
Returns (parsed_dict, needed_fix: bool)."""
|
||||
if not raw:
|
||||
raise ValueError("empty LLM response")
|
||||
|
||||
@@ -120,7 +169,7 @@ def _safe_json_parse(raw):
|
||||
|
||||
# Try strict parse
|
||||
try:
|
||||
return json.loads(text)
|
||||
return json.loads(text), False
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
@@ -133,7 +182,7 @@ def _safe_json_parse(raw):
|
||||
|
||||
# Try again
|
||||
try:
|
||||
return json.loads(text)
|
||||
return json.loads(text), True
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
@@ -149,7 +198,7 @@ def _safe_json_parse(raw):
|
||||
text += '"'
|
||||
text += "}"
|
||||
|
||||
return json.loads(text)
|
||||
return json.loads(text), True
|
||||
|
||||
|
||||
def _smart_extract(elements_json):
|
||||
|
||||
@@ -16,7 +16,7 @@ def call_llm(current_spec, doc_text, build_prompt_fn):
|
||||
"max_tokens": 8000,
|
||||
"temperature": 0.1,
|
||||
}
|
||||
with httpx.Client(http2=True, timeout=120, verify=False) as client:
|
||||
with httpx.Client(http2=True, timeout=120, verify=True) as client:
|
||||
resp = client.post(
|
||||
LLM_URL,
|
||||
json=payload,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""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."""
|
||||
if val is None or val == "":
|
||||
return None
|
||||
try:
|
||||
return Decimal(str(val))
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Parse service — PDF (PyPDF2), DOCX (python-docx), plain text fallback."""
|
||||
"""Parse service — PDF (pdfplumber), DOCX (python-docx), plain text fallback."""
|
||||
import io
|
||||
from PyPDF2 import PdfReader
|
||||
|
||||
|
||||
def parse_file(filename, data):
|
||||
@@ -20,9 +19,24 @@ def parse_file(filename, data):
|
||||
|
||||
|
||||
def _parse_pdf(data):
|
||||
reader = PdfReader(io.BytesIO(data))
|
||||
"""Parse PDF with pdfplumber — preserves table structure (unlike PyPDF2)."""
|
||||
import pdfplumber
|
||||
elements = []
|
||||
for page in reader.pages:
|
||||
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"):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Process service — SSE pipeline: reset → supplements → LLM → apply."""
|
||||
import json, time, threading
|
||||
from db import supplements, spec_current, spec_events
|
||||
from .metrics import check_arithmetic
|
||||
|
||||
|
||||
def run_pipeline(contract_id, order_ids, sse_send, build_prompt_fn):
|
||||
@@ -108,6 +109,10 @@ def run_pipeline(contract_id, order_ids, sse_send, build_prompt_fn):
|
||||
summary = spec_events.apply_ops(
|
||||
contract_id, sid, s.get("document_id", ""), ops, prompt_id, llm_result
|
||||
)
|
||||
# Arithmetic quality check (free signal, no golden dataset needed)
|
||||
arith_mismatches = check_arithmetic(ops)
|
||||
if arith_mismatches:
|
||||
summary["arithmetic_mismatches"] = len(arith_mismatches)
|
||||
sse_send({"type": "applied", "supplement_id": sid, "summary": summary, "ops": ops})
|
||||
|
||||
total_time = round(time.time() - t0, 1)
|
||||
|
||||
@@ -59,6 +59,13 @@ def handle_upload(rfile, content_type, content_length):
|
||||
except (ValueError, AttributeError):
|
||||
batch_id = None
|
||||
|
||||
# zip_source — имя родительского ZIP-архива (для визуальной группировки)
|
||||
zip_source = None
|
||||
if "zip_source" in fs:
|
||||
raw_zip = fs.getfirst("zip_source", "")
|
||||
if raw_zip:
|
||||
zip_source = os.path.basename(raw_zip)[:255] # защита от path traversal + лимит
|
||||
|
||||
if not filename or not file_data:
|
||||
return {"ok": False, "error": "no file in request"}
|
||||
|
||||
@@ -71,7 +78,7 @@ def handle_upload(rfile, content_type, content_length):
|
||||
except Exception:
|
||||
pass # old record may not exist or FK issue — proceed with insert
|
||||
|
||||
doc = documents.insert(filename, mime, b64, batch_id=batch_id)
|
||||
doc = documents.insert(filename, mime, b64, batch_id=batch_id, zip_source=zip_source)
|
||||
|
||||
if not contract_id:
|
||||
from datetime import datetime
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
*
|
||||
* state.files — бывший fileQueue, массив загруженных файлов.
|
||||
* Каждый элемент: { name, size, lastModified, file, doc_id,
|
||||
* uploaded, parsed, parseInfo, supp_id, supp_type, status }
|
||||
* uploaded, parsed, parseInfo, supp_id, supp_type, status, zip_source }
|
||||
*
|
||||
* state.contractId — бывший contractId, ID контракта в БД.
|
||||
* Приходит с бэкенда после первого успешного upload.
|
||||
|
||||
Reference in New Issue
Block a user