fix: handle dossier number and party aliases

This commit is contained in:
Repinoid
2026-10-31 09:55:29 +03:00
parent 1c1d64385c
commit ee244eb73e
2 changed files with 99 additions and 39 deletions
+4
View File
@@ -340,6 +340,10 @@ def build_stateless_extractor_prompt(structured_text):
counterparty; counterparty — только другая сторона, если она явно установлена.
3. Перечисли все номера и даты договоров из шапки, преамбулы и заголовков приложений
в referenced_contracts. Не объединяй разные номера, даже если один выглядит опечаткой.
Для contract_frame также продублируй номер самого договора в referenced_contracts.
Для specification и supplement обязательно помести родительский номер договора в
referenced_contracts; own_number содержит номер самого документа/приложения/ДС,
поэтому номер приложения «1» нельзя выдавать за номер договора.
4. Для рамочного договора без спецификации укажи intent_mode="no_spec". Для документа,
полностью задающего состав услуг, укажи "full_snapshot"; для точечных изменений —
"delta_amendment"; для прекращения обязательств — "termination".
+95 -39
View File
@@ -35,18 +35,38 @@ def normalize_number(num):
def canonicalize_contract_number(number):
"""Нормализовать полный номер, включая визуальные латинско-кириллические пары.
Функция намеренно не отбрасывает префиксы и не строит ключ только по
суффиксу: одинаковый короткий хвост у разных контрагентов не является
достаточным доказательством тождества договоров.
"""
"""Нормализовать полный номер, включая визуальные латинско-кириллические пары."""
if not number:
return ""
normalized = str(number).upper().translate(_HOMOGLYPHS)
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):
"""Вернуть ключи контрагента для сравнения с жёстким ограничением по ИНН."""
inn_key = re.sub(r"\D", "", str(inn or ""))
@@ -59,35 +79,47 @@ def canonicalize_counterparty(name, inn):
def _counterparties_compatible(left, right):
"""Разрешить связь при совпадении идентичности или отсутствующих данных."""
"""Запретить связь только при конфликте двух явно указанных ИНН."""
left_id = canonicalize_counterparty(left.get("name"), left.get("inn"))
right_id = canonicalize_counterparty(right.get("name"), right.get("inn"))
if left_id["inn"] and 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"])
return not (left_id["inn"] and right_id["inn"] and left_id["inn"] != right_id["inn"])
def _passport_keys(passport):
"""Собрать полные ключи договора, исключая номер самого допсоглашения."""
"""Собрать ключи родительских договоров, отфильтровав номера приложений."""
doc_class = passport.get("doc_class")
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"))
for reference in passport.get("referenced_numbers") or []:
if isinstance(reference, dict) and reference.get("number"):
values.append((reference["number"], reference.get("source") or "referenced_contract"))
unique = {}
for raw, source in values:
key = canonicalize_contract_number(raw)
if key:
unique.setdefault(key, {"raw": str(raw), "source": str(source)})
identity = _contract_number_identity(raw)
key = identity["full"]
if key and _is_contract_number(key):
unique.setdefault(key, {"raw": str(raw), "source": str(source), **identity})
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):
"""Разбить номер на текстовые и числовые части для сортировки ДС по номеру."""
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 right_index in range(left_index + 1, len(records)):
right = records[right_index]
shared = set(left["keys"]) & set(right["keys"])
if not shared or not _counterparties_compatible(left["counterparty"], right["counterparty"]):
match = _shared_contract_match(left["keys"], right["keys"])
if not match or not _counterparties_compatible(left["counterparty"], right["counterparty"]):
continue
shared_key = sorted(shared)[0]
candidates[left_index].append((right_index, shared_key))
candidates[right_index].append((left_index, shared_key))
shared_key, left_number, right_number = match
candidates[left_index].append((right_index, shared_key, left_number["raw"]))
candidates[right_index].append((left_index, shared_key, right_number["raw"]))
left_root, right_root = find(left_index), find(right_index)
if left_root == right_root:
continue
@@ -176,14 +208,13 @@ def build_dossiers_for_batch(batch_id):
ambiguous_indices = set()
for index, edges in enumerate(candidates):
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))
candidate_roots = set().union(*key_targets.values()) if key_targets else set()
if len(candidate_roots) > 1:
ambiguous_indices.add(index)
matching_numbers = sorted({
records[index]["keys"].get(key, {}).get("raw", key)
for _other, key in edges
raw_number for _other, _key, raw_number in edges
})
_dossier_exception(
batch_id, records[index]["document"]["id"],
@@ -216,23 +247,37 @@ def build_dossiers_for_batch(batch_id):
for key, value in record["keys"].items():
identifiers.append((key, value["raw"], value["source"]))
canonical_candidates = [
(r["passport"].get("own_number"), "frame") for r in frames
if r["passport"].get("own_number")
] + [
(r["passport"].get("own_number"), "specification") for r in specifications
if r["passport"].get("own_number")
] + [(raw, source) for _key, raw, source in identifiers]
canonical_number = canonical_candidates[0][0] if canonical_candidates else ""
r["passport"].get("own_number") for r in frames
if _is_contract_number(r["passport"].get("own_number"))
]
canonical_candidates.extend(
reference.get("number")
for record in [*frames, *specifications]
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)
aliases = list(dict.fromkeys(
raw for key, raw, _source in identifiers
if key != canonical_key and raw != canonical_number
))
counterparty_record = next(
(r["counterparty"] for r in members
if r["counterparty"].get("inn") or r["counterparty"].get("name")),
{},
)
party_candidates = [
record for record in members
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(
batch_id=batch_id,
canonical_number=canonical_number,
@@ -259,6 +304,17 @@ def build_dossiers_for_batch(batch_id):
else:
role = "document"
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(
dossier_id=dossier["id"], document_id=document["id"], role=role,
link_status="CONFIRMED", link_reason=reason, seq_order=sequence,