feat: wire v3 dossier API and reconciliation
This commit is contained in:
@@ -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 = {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user