fix: harden extraction batch handling
Deploy contracts-flask / validate (push) Canceled after 0s

This commit is contained in:
Repinoid
2026-10-31 19:06:34 +03:00
parent 9c73a730d1
commit 79de42da96
6 changed files with 79 additions and 21 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
"""Конфигурация приложения — все настройки в одном месте.""" """Конфигурация приложения — все настройки в одном месте."""
import os import os
VERSION = "3.0.2" VERSION = "3.0.3"
def _load_env(): def _load_env():
+4 -1
View File
@@ -345,7 +345,10 @@ def execute_returning(sql, params=None):
sql = sql[:sql.upper().rfind(" RETURNING ")] sql = sql[:sql.upper().rfind(" RETURNING ")]
cur = conn.execute(sql, params or []) cur = conn.execute(sql, params or [])
conn.commit() # The outer transaction owns commit and rollback; committing here would make
# its rollback ineffective and could expose only part of a multi-write update.
if not getattr(_local, "in_transaction", False):
conn.commit()
rowid = cur.lastrowid rowid = cur.lastrowid
if table and rowid: if table and rowid:
+42 -6
View File
@@ -25,18 +25,54 @@ def analyze_batch(batch_id, documents, extractor, dossier_builder,
progress_callback=progress_callback, progress_callback=progress_callback,
documents=documents, documents=documents,
) )
dossier_result = dossier_builder(batch_id) if extraction.get("ok") else {"ok": False} # Extractor-level `ok` means every input succeeded; dossier construction only
# requires at least one persisted passport and can proceed for that subset.
extraction_results = extraction.get("results") or []
total = extraction.get("total", len(documents))
done = extraction.get("done")
if done is None:
done = sum(1 for result in extraction_results if result.get("ok"))
failed = extraction.get("failed")
if failed is None:
failed = max(total - done, 0)
dossier_result = dossier_builder(batch_id) if done > 0 else {"ok": False}
dossier_ok = bool(dossier_result.get("ok"))
# Keep result usability separate from completeness so clients can continue
# with valid dossiers while still reporting failures explicitly.
status = (
"failed" if done == 0 or not dossier_ok
else "partial" if failed > 0
else "completed"
)
documents_by_id = {document.get("id"): document for document in documents}
failed_documents = []
for result in extraction_results:
if result.get("ok"):
continue
document_id = result.get("document_id")
source_document = documents_by_id.get(document_id, {})
failure = {
"document_id": document_id,
"filename": source_document.get("filename"),
"error": result.get("error") or "extraction failed",
}
if result.get("persistence_error"):
failure["persistence_error"] = result["persistence_error"]
failed_documents.append(failure)
classes = extraction.get("classes", {}) classes = extraction.get("classes", {})
math_checks_passed = sum( math_checks_passed = sum(
1 for result in extraction.get("results", []) 1 for result in extraction_results
if (result.get("extraction") or {}).get("math_validation", {}).get("status") == "VERIFIED" if (result.get("extraction") or {}).get("math_validation", {}).get("status") == "VERIFIED"
) )
return { return {
"ok": bool(extraction.get("ok")) and bool(dossier_result.get("ok")), "ok": status != "failed",
"status": status,
"partial": status == "partial",
"v3": True, "v3": True,
"done": extraction.get("done", 0), "done": done,
"total": len(documents), "total": total,
"failed": extraction.get("failed", 0), "failed": failed,
"failed_documents": failed_documents,
"classes": classes, "classes": classes,
"ignored_accounting": classes.get("ignored_accounting", 0), "ignored_accounting": classes.get("ignored_accounting", 0),
"math_checks_passed": math_checks_passed, "math_checks_passed": math_checks_passed,
+21 -9
View File
@@ -250,12 +250,16 @@ def _save_extraction(document_id, extracted, validation, status, raw_response="{
def extract_document(document, llm_client=None, batch_id=None): def extract_document(document, llm_client=None, batch_id=None):
"""Извлечь паспорт одного документа, изолируя ошибки на уровне документа.""" """Извлечь паспорт одного документа, изолируя ошибки на уровне документа."""
document_id = document["id"] document_id = document.get("id") if isinstance(document, dict) else None
batch_id = batch_id if batch_id is not None else document.get("batch_id")
elements = _decode_elements(document.get("elements", document.get("elements_json")))
structured_text = format_elements_with_structure(elements)
raw_response = "{}" raw_response = "{}"
try: try:
if not document_id:
raise ValueError("document id is required")
batch_id = batch_id if batch_id is not None else document.get("batch_id")
elements = _decode_elements(document.get("elements", document.get("elements_json")))
# Formatting is part of this document's failure boundary: corrupt stored
# elements must not escape and stop extraction of later batch members.
structured_text = format_elements_with_structure(elements)
if _is_garbage_by_filename(document.get("filename") or "") or _is_garbage_by_header(structured_text[:2000]): if _is_garbage_by_filename(document.get("filename") or "") or _is_garbage_by_header(structured_text[:2000]):
extracted = { extracted = {
"doc_class": "ignored_accounting", "intent_mode": "no_spec", "doc_class": "ignored_accounting", "intent_mode": "no_spec",
@@ -299,11 +303,19 @@ def extract_document(document, llm_client=None, batch_id=None):
return {"ok": True, "document_id": document_id, "status": result["status"], "extraction": saved} return {"ok": True, "document_id": document_id, "status": result["status"], "extraction": saved}
except Exception as error: except Exception as error:
log.exception("Stateless extraction failed for document %s", document_id) log.exception("Stateless extraction failed for document %s", document_id)
_save_extraction(document_id, { result = {"ok": False, "document_id": document_id, "error": str(error)}
"doc_class": "unknown", "intent_mode": "no_spec", "counterparty": {}, if document_id:
"referenced_contracts": [], "sections": [], # Failure persistence is best-effort; a secondary write failure must
}, {"status": "FAILED", "error": str(error)}, "FAILED", raw_response) # not escape this handler and abort processing of remaining documents.
return {"ok": False, "document_id": document_id, "error": str(error)} try:
_save_extraction(document_id, {
"doc_class": "unknown", "intent_mode": "no_spec", "counterparty": {},
"referenced_contracts": [], "sections": [],
}, {"status": "FAILED", "error": str(error)}, "FAILED", raw_response)
except Exception as persistence_error:
log.exception("Failed to persist extraction failure for document %s", document_id)
result["persistence_error"] = str(persistence_error)
return result
def extract_batch(batch_id, llm_client=None, progress_callback=None, documents=None): def extract_batch(batch_id, llm_client=None, progress_callback=None, documents=None):
+4 -2
View File
@@ -184,8 +184,10 @@ def _build_dossiers_for_batch(batch_id):
records = [] records = []
for document in documents: for document in documents:
passport = passport_by_id.get(document["id"]) passport = passport_by_id.get(document["id"])
if not passport: # A missing or failed passport says extraction produced no usable evidence;
passport = {"document_id": document["id"], "doc_class": "unknown"} # do not misreport that technical failure as a missing contract number.
if not passport or passport.get("status") == "FAILED":
continue
if passport.get("doc_class") == "ignored_accounting": if passport.get("doc_class") == "ignored_accounting":
continue continue
counterparty = { counterparty = {
+7 -2
View File
@@ -162,7 +162,10 @@ async function runClassify() {
btn.disabled = false; btn.disabled = false;
return; return;
} }
btn.innerHTML = '✓ ' + completed + '/' + total + ' документов извлечено'; // Partial jobs are terminal and usable, but must not look like full success.
var failed = d.failed || 0;
btn.innerHTML = (failed ? '⚠ ' : '✓ ') + completed + '/' + total + ' документов извлечено'
+ (failed ? ' | ошибок: ' + failed : '');
stepDone('stepClassify'); stepDone('stepClassify');
stepActive('stepGroups'); stepActive('stepGroups');
loadGroupsAction(); loadGroupsAction();
@@ -212,7 +215,9 @@ async function runClassify() {
if (data.v3 && data.failed) summary += ' | ошибок: ' + data.failed; if (data.v3 && data.failed) summary += ' | ошибок: ' + data.failed;
if (data.garbage) summary += ' | 🗑️ ' + data.garbage + ' мусор'; if (data.garbage) summary += ' | 🗑️ ' + data.garbage + ' мусор';
if (data.types) summary += ' | ' + Object.entries(data.types).map(function (e) { return e[1] + '×' + e[0]; }).join(', '); 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) + 'с)'; // Continue to dossier rendering for partial success while keeping its warning visible.
btn.innerHTML = (data.v3 && data.partial ? '⚠ ' : '✓ ') + summary
+ ' (' + Math.round((Date.now() - start) / 1000) + 'с)';
stepDone('stepClassify'); stepDone('stepClassify');
stepActive('stepGroups'); stepActive('stepGroups');
loadGroupsAction(); loadGroupsAction();