feat: wire v3 dossier API and reconciliation

This commit is contained in:
Repinoid
2026-10-31 10:33:57 +03:00
parent 5e30020d60
commit 18730bf93e
4 changed files with 338 additions and 25 deletions
+15 -3
View File
@@ -306,10 +306,22 @@ def extract_document(document, llm_client=None, batch_id=None):
return {"ok": False, "document_id": document_id, "error": str(error)}
def extract_batch(batch_id, llm_client=None):
"""Обработать все документы batch и вернуть сводку без batch-wide failure."""
def extract_batch(batch_id, llm_client=None, progress_callback=None):
"""Обработать документы batch и вернуть сводку без batch-wide failure.
Optional progress callback получает (completed_count, document_result) после
каждого документа; ошибки callback не должны останавливать обработку batch.
"""
documents = db_docs.list_for_extraction(batch_id)
results = [extract_document(document, llm_client=llm_client, batch_id=batch_id) for document in documents]
results = []
for completed, document in enumerate(documents, start=1):
result = extract_document(document, llm_client=llm_client, batch_id=batch_id)
results.append(result)
if progress_callback is not None:
try:
progress_callback(completed, result)
except Exception:
log.exception("Extraction progress callback failed for batch %s", batch_id)
done = sum(1 for result in results if result["ok"])
failed = len(results) - done
classes = {}
+74 -11
View File
@@ -185,7 +185,39 @@ def _append_interval(dossier_id, service_item, row, section, document,
})
def _process_document(dossier, document, passport, items, counts):
def _resolved_option_for(document_id, row, resolved_exceptions):
"""Найти сохранённый выбор для точной строки и документа lineage исключения."""
quote = _row_quote(row)
for issue in resolved_exceptions:
if (
issue.get("stage") == "lineage_matching"
and issue.get("document_id") == document_id
and issue.get("source_quote") == quote
):
resolution = issue.get("resolution")
if isinstance(resolution, dict):
option = resolution.get("selected_option")
return option if isinstance(option, dict) else None
return None
def _reusable_service_item(identity, service_items):
"""Найти прежнюю устойчивую identity, не включая будущие строки в matching."""
family = identity.get("service_family")
anchor = identity.get("instance_anchor") or ""
candidates = [item for item in service_items if item.get("service_family") == family]
if anchor:
candidates = [item for item in candidates if item.get("instance_anchor", "") == anchor]
else:
candidates = [
item for item in candidates
if item.get("canonical_name") == identity.get("canonical_name")
]
return candidates[0] if len(candidates) == 1 else None
def _process_document(dossier, document, passport, items, counts,
resolved_exceptions=None, reusable_items=None):
"""Reconcile every section of one passport against current dossier services."""
doc_id = document["document_id"]
mode = passport.get("intent_mode")
@@ -216,23 +248,47 @@ def _process_document(dossier, document, passport, items, counts):
effective_dates.append(start)
identity = analyze_service_row(row.get("raw_name"), charge_type)
match = match_service_item(identity, items)
if match["status"] == "AMBIGUOUS":
selected_option = _resolved_option_for(
doc_id, row, resolved_exceptions or [],
)
if selected_option:
selected_id = selected_option.get("service_item_id")
candidates = [item for item in match["candidates"] if item.get("id") == selected_id]
if not candidates:
candidates = [
item for item in match["candidates"]
if item.get("service_family") == selected_option.get("service_family")
and item.get("instance_anchor", "") == selected_option.get("instance_anchor", "")
]
if len(candidates) == 1:
match = {
"status": "MATCHED", "service_item": candidates[0],
"candidates": candidates,
}
if match["status"] == "AMBIGUOUS":
_add_lineage_exception(
dossier["id"], dossier.get("batch_id"), doc_id,
"AMBIGUOUS_SERVICE_ITEM", _row_quote(row),
options=[{"service_item_id": item["id"], "canonical_name": item.get("canonical_name")}
for item in match["candidates"]],
options=[{
"service_item_id": item["id"],
"service_family": item.get("service_family"),
"instance_anchor": item.get("instance_anchor", ""),
"canonical_name": item.get("canonical_name"),
} for item in match["candidates"]],
)
counts["ambiguous"] += 1
continue
service_item = match["service_item"]
if service_item is None:
service_item = db_timeline.insert_service_item(
dossier_id=dossier["id"],
service_family=identity["service_family"],
canonical_name=identity["canonical_name"] or "(empty)",
instance_anchor=identity["instance_anchor"],
)
service_item = _reusable_service_item(identity, reusable_items or [])
if service_item is None:
service_item = db_timeline.insert_service_item(
dossier_id=dossier["id"],
service_family=identity["service_family"],
canonical_name=identity["canonical_name"] or "(empty)",
instance_anchor=identity["instance_anchor"],
)
items.append(service_item)
open_intervals = [
@@ -351,7 +407,11 @@ def build_dossier_timeline(dossier_id, contract_id=None):
if dossier is None:
return {"ok": False, "error": "dossier not found", "dossier_id": dossier_id}
documents = _ordered_documents(dossier_id)
resolved_exceptions = db_exceptions.list_exceptions(
dossier_id=dossier_id, status="RESOLVED",
)
items = []
reusable_items = db_timeline.list_service_items(dossier_id)
counts = {"created": 0, "updated": 0, "unchanged": 0, "closed": 0, "ambiguous": 0}
with transaction():
@@ -360,10 +420,13 @@ def build_dossier_timeline(dossier_id, contract_id=None):
WHERE dossier_id = %s AND stage = 'lineage_matching' AND status = 'OPEN'""",
(dossier_id,),
)
execute("DELETE FROM service_items WHERE dossier_id = %s", (dossier_id,))
execute("DELETE FROM timeline_intervals WHERE dossier_id = %s", (dossier_id,))
for document in documents:
passport = _passport_for(document)
_process_document(dossier, document, passport, items, counts)
_process_document(
dossier, document, passport, items, counts,
resolved_exceptions, reusable_items,
)
if contract_id:
sync_legacy_spec_current(dossier_id, contract_id)