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 — только другая сторона, если она явно установлена.
|
||||
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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user