fix: handle dossier number and party aliases
This commit is contained in:
@@ -340,6 +340,10 @@ def build_stateless_extractor_prompt(structured_text):
|
|||||||
counterparty; counterparty — только другая сторона, если она явно установлена.
|
counterparty; counterparty — только другая сторона, если она явно установлена.
|
||||||
3. Перечисли все номера и даты договоров из шапки, преамбулы и заголовков приложений
|
3. Перечисли все номера и даты договоров из шапки, преамбулы и заголовков приложений
|
||||||
в referenced_contracts. Не объединяй разные номера, даже если один выглядит опечаткой.
|
в referenced_contracts. Не объединяй разные номера, даже если один выглядит опечаткой.
|
||||||
|
Для contract_frame также продублируй номер самого договора в referenced_contracts.
|
||||||
|
Для specification и supplement обязательно помести родительский номер договора в
|
||||||
|
referenced_contracts; own_number содержит номер самого документа/приложения/ДС,
|
||||||
|
поэтому номер приложения «1» нельзя выдавать за номер договора.
|
||||||
4. Для рамочного договора без спецификации укажи intent_mode="no_spec". Для документа,
|
4. Для рамочного договора без спецификации укажи intent_mode="no_spec". Для документа,
|
||||||
полностью задающего состав услуг, укажи "full_snapshot"; для точечных изменений —
|
полностью задающего состав услуг, укажи "full_snapshot"; для точечных изменений —
|
||||||
"delta_amendment"; для прекращения обязательств — "termination".
|
"delta_amendment"; для прекращения обязательств — "termination".
|
||||||
|
|||||||
+95
-39
@@ -35,18 +35,38 @@ def normalize_number(num):
|
|||||||
|
|
||||||
|
|
||||||
def canonicalize_contract_number(number):
|
def canonicalize_contract_number(number):
|
||||||
"""Нормализовать полный номер, включая визуальные латинско-кириллические пары.
|
"""Нормализовать полный номер, включая визуальные латинско-кириллические пары."""
|
||||||
|
|
||||||
Функция намеренно не отбрасывает префиксы и не строит ключ только по
|
|
||||||
суффиксу: одинаковый короткий хвост у разных контрагентов не является
|
|
||||||
достаточным доказательством тождества договоров.
|
|
||||||
"""
|
|
||||||
if not number:
|
if not number:
|
||||||
return ""
|
return ""
|
||||||
normalized = str(number).upper().translate(_HOMOGLYPHS)
|
normalized = str(number).upper().translate(_HOMOGLYPHS)
|
||||||
return re.sub(r"[^0-9А-ЯЁ]", "", normalized)
|
return re.sub(r"[^0-9А-ЯЁ]", "", normalized)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_contract_number(number):
|
||||||
|
"""Отличить номер договора от короткого номера приложения или допсоглашения."""
|
||||||
|
key = canonicalize_contract_number(number)
|
||||||
|
return len(key) >= 4 and not (key.isdigit() and int(key) < 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def _contract_number_identity(number):
|
||||||
|
"""Вернуть полный ключ и ограниченный alias-ключ для формата XXXnnn-number.
|
||||||
|
|
||||||
|
Короткое ядро допускается только при наличии явного клиентского префикса и
|
||||||
|
минимум четырёх цифр, чтобы случайные хвосты вроде «1» не объединяли договоры.
|
||||||
|
Пары двух разных prefixed-номеров по одному ядру не считаются совпадением.
|
||||||
|
"""
|
||||||
|
full_key = canonicalize_contract_number(number)
|
||||||
|
normalized = str(number or "").upper().translate(_HOMOGLYPHS)
|
||||||
|
prefix_match = re.match(r"^(?:XXX|ХХХ)\d+[-_/](.+)$", normalized)
|
||||||
|
if not prefix_match:
|
||||||
|
return {"full": full_key, "suffix": full_key, "prefixed": False}
|
||||||
|
suffix_key = canonicalize_contract_number(prefix_match.group(1))
|
||||||
|
digit_count = sum(character.isdigit() for character in suffix_key)
|
||||||
|
if digit_count < 4:
|
||||||
|
return {"full": full_key, "suffix": full_key, "prefixed": True}
|
||||||
|
return {"full": full_key, "suffix": suffix_key, "prefixed": True}
|
||||||
|
|
||||||
|
|
||||||
def canonicalize_counterparty(name, inn):
|
def canonicalize_counterparty(name, inn):
|
||||||
"""Вернуть ключи контрагента для сравнения с жёстким ограничением по ИНН."""
|
"""Вернуть ключи контрагента для сравнения с жёстким ограничением по ИНН."""
|
||||||
inn_key = re.sub(r"\D", "", str(inn or ""))
|
inn_key = re.sub(r"\D", "", str(inn or ""))
|
||||||
@@ -59,35 +79,47 @@ def canonicalize_counterparty(name, inn):
|
|||||||
|
|
||||||
|
|
||||||
def _counterparties_compatible(left, right):
|
def _counterparties_compatible(left, right):
|
||||||
"""Разрешить связь при совпадении идентичности или отсутствующих данных."""
|
"""Запретить связь только при конфликте двух явно указанных ИНН."""
|
||||||
left_id = canonicalize_counterparty(left.get("name"), left.get("inn"))
|
left_id = canonicalize_counterparty(left.get("name"), left.get("inn"))
|
||||||
right_id = canonicalize_counterparty(right.get("name"), right.get("inn"))
|
right_id = canonicalize_counterparty(right.get("name"), right.get("inn"))
|
||||||
if left_id["inn"] and right_id["inn"]:
|
return not (left_id["inn"] and right_id["inn"] and left_id["inn"] != right_id["inn"])
|
||||||
return left_id["inn"] == right_id["inn"]
|
|
||||||
left_has_identity = bool(left_id["inn"] or left_id["name"])
|
|
||||||
right_has_identity = bool(right_id["inn"] or right_id["name"])
|
|
||||||
if not left_has_identity or not right_has_identity:
|
|
||||||
return True
|
|
||||||
return bool(left_id["name"] and left_id["name"] == right_id["name"])
|
|
||||||
|
|
||||||
|
|
||||||
def _passport_keys(passport):
|
def _passport_keys(passport):
|
||||||
"""Собрать полные ключи договора, исключая номер самого допсоглашения."""
|
"""Собрать ключи родительских договоров, отфильтровав номера приложений."""
|
||||||
doc_class = passport.get("doc_class")
|
doc_class = passport.get("doc_class")
|
||||||
values = []
|
values = []
|
||||||
if doc_class in {"contract_frame", "specification"} and passport.get("own_number"):
|
own_number = passport.get("own_number")
|
||||||
|
if doc_class == "contract_frame" and _is_contract_number(own_number):
|
||||||
|
values.append((own_number, "own_number"))
|
||||||
|
elif doc_class == "specification" and _is_contract_number(own_number):
|
||||||
values.append((passport["own_number"], "own_number"))
|
values.append((passport["own_number"], "own_number"))
|
||||||
for reference in passport.get("referenced_numbers") or []:
|
for reference in passport.get("referenced_numbers") or []:
|
||||||
if isinstance(reference, dict) and reference.get("number"):
|
if isinstance(reference, dict) and reference.get("number"):
|
||||||
values.append((reference["number"], reference.get("source") or "referenced_contract"))
|
values.append((reference["number"], reference.get("source") or "referenced_contract"))
|
||||||
unique = {}
|
unique = {}
|
||||||
for raw, source in values:
|
for raw, source in values:
|
||||||
key = canonicalize_contract_number(raw)
|
identity = _contract_number_identity(raw)
|
||||||
if key:
|
key = identity["full"]
|
||||||
unique.setdefault(key, {"raw": str(raw), "source": str(source)})
|
if key and _is_contract_number(key):
|
||||||
|
unique.setdefault(key, {"raw": str(raw), "source": str(source), **identity})
|
||||||
return unique
|
return unique
|
||||||
|
|
||||||
|
|
||||||
|
def _shared_contract_match(left_keys, right_keys):
|
||||||
|
"""Найти безопасное совпадение полного ключа или его допустимого ядра."""
|
||||||
|
for left_key, left in left_keys.items():
|
||||||
|
for right_key, right in right_keys.items():
|
||||||
|
if left_key == right_key:
|
||||||
|
return left_key, left, right
|
||||||
|
same_suffix = left.get("suffix") == right.get("suffix")
|
||||||
|
if same_suffix and (left.get("prefixed") or right.get("prefixed")):
|
||||||
|
if left.get("prefixed") and right.get("prefixed"):
|
||||||
|
continue
|
||||||
|
return f"alias:{left['suffix']}", left, right
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _number_sort_key(number):
|
def _number_sort_key(number):
|
||||||
"""Разбить номер на текстовые и числовые части для сортировки ДС по номеру."""
|
"""Разбить номер на текстовые и числовые части для сортировки ДС по номеру."""
|
||||||
return tuple((0, int(part)) if part.isdigit() else (1, part.casefold())
|
return tuple((0, int(part)) if part.isdigit() else (1, part.casefold())
|
||||||
@@ -158,12 +190,12 @@ def build_dossiers_for_batch(batch_id):
|
|||||||
for left_index, left in enumerate(records):
|
for left_index, left in enumerate(records):
|
||||||
for right_index in range(left_index + 1, len(records)):
|
for right_index in range(left_index + 1, len(records)):
|
||||||
right = records[right_index]
|
right = records[right_index]
|
||||||
shared = set(left["keys"]) & set(right["keys"])
|
match = _shared_contract_match(left["keys"], right["keys"])
|
||||||
if not shared or not _counterparties_compatible(left["counterparty"], right["counterparty"]):
|
if not match or not _counterparties_compatible(left["counterparty"], right["counterparty"]):
|
||||||
continue
|
continue
|
||||||
shared_key = sorted(shared)[0]
|
shared_key, left_number, right_number = match
|
||||||
candidates[left_index].append((right_index, shared_key))
|
candidates[left_index].append((right_index, shared_key, left_number["raw"]))
|
||||||
candidates[right_index].append((left_index, shared_key))
|
candidates[right_index].append((left_index, shared_key, right_number["raw"]))
|
||||||
left_root, right_root = find(left_index), find(right_index)
|
left_root, right_root = find(left_index), find(right_index)
|
||||||
if left_root == right_root:
|
if left_root == right_root:
|
||||||
continue
|
continue
|
||||||
@@ -176,14 +208,13 @@ def build_dossiers_for_batch(batch_id):
|
|||||||
ambiguous_indices = set()
|
ambiguous_indices = set()
|
||||||
for index, edges in enumerate(candidates):
|
for index, edges in enumerate(candidates):
|
||||||
key_targets = {}
|
key_targets = {}
|
||||||
for other, shared_key in edges:
|
for other, shared_key, _raw_number in edges:
|
||||||
key_targets.setdefault(shared_key, set()).add(find(other))
|
key_targets.setdefault(shared_key, set()).add(find(other))
|
||||||
candidate_roots = set().union(*key_targets.values()) if key_targets else set()
|
candidate_roots = set().union(*key_targets.values()) if key_targets else set()
|
||||||
if len(candidate_roots) > 1:
|
if len(candidate_roots) > 1:
|
||||||
ambiguous_indices.add(index)
|
ambiguous_indices.add(index)
|
||||||
matching_numbers = sorted({
|
matching_numbers = sorted({
|
||||||
records[index]["keys"].get(key, {}).get("raw", key)
|
raw_number for _other, _key, raw_number in edges
|
||||||
for _other, key in edges
|
|
||||||
})
|
})
|
||||||
_dossier_exception(
|
_dossier_exception(
|
||||||
batch_id, records[index]["document"]["id"],
|
batch_id, records[index]["document"]["id"],
|
||||||
@@ -216,23 +247,37 @@ def build_dossiers_for_batch(batch_id):
|
|||||||
for key, value in record["keys"].items():
|
for key, value in record["keys"].items():
|
||||||
identifiers.append((key, value["raw"], value["source"]))
|
identifiers.append((key, value["raw"], value["source"]))
|
||||||
canonical_candidates = [
|
canonical_candidates = [
|
||||||
(r["passport"].get("own_number"), "frame") for r in frames
|
r["passport"].get("own_number") for r in frames
|
||||||
if r["passport"].get("own_number")
|
if _is_contract_number(r["passport"].get("own_number"))
|
||||||
] + [
|
]
|
||||||
(r["passport"].get("own_number"), "specification") for r in specifications
|
canonical_candidates.extend(
|
||||||
if r["passport"].get("own_number")
|
reference.get("number")
|
||||||
] + [(raw, source) for _key, raw, source in identifiers]
|
for record in [*frames, *specifications]
|
||||||
canonical_number = canonical_candidates[0][0] if canonical_candidates else ""
|
for reference in (record["passport"].get("referenced_numbers") or [])
|
||||||
|
if isinstance(reference, dict) and _is_contract_number(reference.get("number"))
|
||||||
|
)
|
||||||
|
canonical_candidates.extend(
|
||||||
|
r["passport"].get("own_number") for r in specifications
|
||||||
|
if _is_contract_number(r["passport"].get("own_number"))
|
||||||
|
)
|
||||||
|
canonical_candidates.extend(
|
||||||
|
raw for _key, raw, _source in identifiers if _is_contract_number(raw)
|
||||||
|
)
|
||||||
|
canonical_number = canonical_candidates[0] if canonical_candidates else ""
|
||||||
canonical_key = canonicalize_contract_number(canonical_number)
|
canonical_key = canonicalize_contract_number(canonical_number)
|
||||||
aliases = list(dict.fromkeys(
|
aliases = list(dict.fromkeys(
|
||||||
raw for key, raw, _source in identifiers
|
raw for key, raw, _source in identifiers
|
||||||
if key != canonical_key and raw != canonical_number
|
if key != canonical_key and raw != canonical_number
|
||||||
))
|
))
|
||||||
counterparty_record = next(
|
party_candidates = [
|
||||||
(r["counterparty"] for r in members
|
record for record in members
|
||||||
if r["counterparty"].get("inn") or r["counterparty"].get("name")),
|
if record["counterparty"].get("inn") or record["counterparty"].get("name")
|
||||||
{},
|
]
|
||||||
)
|
party_rank = {"contract_frame": 0, "supplement": 1, "specification": 2}
|
||||||
|
party_candidates.sort(key=lambda record: party_rank.get(
|
||||||
|
record["passport"].get("doc_class"), 3,
|
||||||
|
))
|
||||||
|
counterparty_record = party_candidates[0]["counterparty"] if party_candidates else {}
|
||||||
dossier = db_dossiers.create_dossier(
|
dossier = db_dossiers.create_dossier(
|
||||||
batch_id=batch_id,
|
batch_id=batch_id,
|
||||||
canonical_number=canonical_number,
|
canonical_number=canonical_number,
|
||||||
@@ -259,6 +304,17 @@ def build_dossiers_for_batch(batch_id):
|
|||||||
else:
|
else:
|
||||||
role = "document"
|
role = "document"
|
||||||
reason = _link_reason(record, canonical_key, canonical_number)
|
reason = _link_reason(record, canonical_key, canonical_number)
|
||||||
|
linked_party = canonicalize_counterparty(
|
||||||
|
record["counterparty"].get("name"), record["counterparty"].get("inn"),
|
||||||
|
)
|
||||||
|
selected_party = canonicalize_counterparty(
|
||||||
|
counterparty_record.get("name"), counterparty_record.get("inn"),
|
||||||
|
)
|
||||||
|
if linked_party["name"] and selected_party["name"] and linked_party["name"] != selected_party["name"]:
|
||||||
|
reason += (
|
||||||
|
f"; расхождение контрагента: {record['counterparty']['name']}"
|
||||||
|
f" (в досье: {counterparty_record['name']})"
|
||||||
|
)
|
||||||
link = db_dossiers.link_document(
|
link = db_dossiers.link_document(
|
||||||
dossier_id=dossier["id"], document_id=document["id"], role=role,
|
dossier_id=dossier["id"], document_id=document["id"], role=role,
|
||||||
link_status="CONFIRMED", link_reason=reason, seq_order=sequence,
|
link_status="CONFIRMED", link_reason=reason, seq_order=sequence,
|
||||||
|
|||||||
Reference in New Issue
Block a user