fix: harden extraction batch handling
Deploy contracts-flask / validate (push) Waiting to run

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
VERSION = "3.0.2"
VERSION = "3.0.3"
def _load_env():
+3
View File
@@ -345,6 +345,9 @@ def execute_returning(sql, params=None):
sql = sql[:sql.upper().rfind(" RETURNING ")]
cur = conn.execute(sql, params or [])
# 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
+42 -6
View File
@@ -25,18 +25,54 @@ def analyze_batch(batch_id, documents, extractor, dossier_builder,
progress_callback=progress_callback,
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", {})
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"
)
return {
"ok": bool(extraction.get("ok")) and bool(dossier_result.get("ok")),
"ok": status != "failed",
"status": status,
"partial": status == "partial",
"v3": True,
"done": extraction.get("done", 0),
"total": len(documents),
"failed": extraction.get("failed", 0),
"done": done,
"total": total,
"failed": failed,
"failed_documents": failed_documents,
"classes": classes,
"ignored_accounting": classes.get("ignored_accounting", 0),
"math_checks_passed": math_checks_passed,
+17 -5
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):
"""Извлечь паспорт одного документа, изолируя ошибки на уровне документа."""
document_id = document["id"]
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)
document_id = document.get("id") if isinstance(document, dict) else None
raw_response = "{}"
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]):
extracted = {
"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}
except Exception as error:
log.exception("Stateless extraction failed for document %s", document_id)
result = {"ok": False, "document_id": document_id, "error": str(error)}
if document_id:
# Failure persistence is best-effort; a secondary write failure must
# not escape this handler and abort processing of remaining documents.
try:
_save_extraction(document_id, {
"doc_class": "unknown", "intent_mode": "no_spec", "counterparty": {},
"referenced_contracts": [], "sections": [],
}, {"status": "FAILED", "error": str(error)}, "FAILED", raw_response)
return {"ok": False, "document_id": document_id, "error": str(error)}
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):
+4 -2
View File
@@ -184,8 +184,10 @@ def _build_dossiers_for_batch(batch_id):
records = []
for document in documents:
passport = passport_by_id.get(document["id"])
if not passport:
passport = {"document_id": document["id"], "doc_class": "unknown"}
# A missing or failed passport says extraction produced no usable evidence;
# 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":
continue
counterparty = {
+7 -2
View File
@@ -162,7 +162,10 @@ async function runClassify() {
btn.disabled = false;
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');
stepActive('stepGroups');
loadGroupsAction();
@@ -212,7 +215,9 @@ async function runClassify() {
if (data.v3 && data.failed) summary += ' | ошибок: ' + data.failed;
if (data.garbage) summary += ' | 🗑️ ' + data.garbage + ' мусор';
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');
stepActive('stepGroups');
loadGroupsAction();