10 Commits
Author SHA1 Message Date
naeel c56b980501 chore: маркер заморозки — активная работа в contracts-flask 2026-06-28 10:11:21 +04:00
naeel 776d831fb8 feat: PG-очередь — classify_status='processing' для crash recovery 2026-06-27 13:18:55 +04:00
naeel 31e5ef7ac5 feat: метрики качества — арифметика (sum==price*qty) + частота JSON-фиксов 2026-06-27 13:18:10 +04:00
naeel 909468b5b7 feat: трёхэтапный фильтр мусора — этапы 1-2 (0 токенов) в classify.py 2026-06-27 13:16:30 +04:00
naeel a474b4324b fix: убрать хардкод API-ключа (chat.cfm), verify=True (llm.py), отвязать llm_prompt от Lucee 2026-06-27 13:06:21 +04:00
naeel 465be4ca04 fix: PyPDF2 → pdfplumber — сохраняет структуру таблиц в PDF 2026-06-27 13:04:47 +04:00
naeel d95c17cc18 v1.0.178: [ZIP] в группах — показываем zip_source рядом с именем файла 2026-06-26 10:08:56 +04:00
naeel 2764ab1cb5 v1.0.178: группировка по ZIP в renderFiles — заголовок-секция 📦 + отступ 24px для вложенных файлов 2026-06-26 09:53:07 +04:00
naeel 4e59c83699 v1.0.178: zip_source — БД, Python, JS. Дедупликация по паре (zip_source, name). addZipFile передаёт имя архива. 2026-06-26 09:48:18 +04:00
naeel 08779c3938 v1.0.178: P0 — улучшены промпты classify и diff. Classify: блок НУБЕС=Исполнитель, parent_number=null для contract, примеры doc_type=other, 3 few-shot примера. Diff: UNRESOLVED вместо ADD при сомнении. 2026-06-26 09:44:31 +04:00
14 changed files with 312 additions and 77 deletions
+6
View File
@@ -0,0 +1,6 @@
⛔ ЗАМОРОЖЕНО 2026-06-28.
Весь код здесь — Lucee/прод (contracts.kube5s.ru, БД baza).
НЕ ТРОГАТЬ без прямого приказа.
Активная разработка: /home/naeel/nubes/contracts/contracts-flask
+7 -9
View File
@@ -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}],
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+2
View File
@@ -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
View File
@@ -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":"ООО Ромашка"}
ДОКУМЕНТ:
---
+58 -9
View File
@@ -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):
+1 -1
View File
@@ -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,
+72
View File
@@ -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
+24 -10
View File
@@ -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,15 +19,30 @@ 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:
text = page.extract_text()
if text:
for line in text.split("\n"):
line = line.strip()
if line:
elements.append({"type": "paragraph", "text": line, "style": ""})
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}
+5
View File
@@ -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)
+8 -1
View File
@@ -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
View File
@@ -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.