From ee244eb73e9cd7531abf3d18add4121cfe221265 Mon Sep 17 00:00:00 2001 From: Repinoid Date: Sat, 31 Oct 2026 09:55:29 +0300 Subject: [PATCH] fix: handle dossier number and party aliases --- site/llm_prompt.py | 4 ++ site/services/grouping.py | 134 +++++++++++++++++++++++++++----------- 2 files changed, 99 insertions(+), 39 deletions(-) diff --git a/site/llm_prompt.py b/site/llm_prompt.py index da106c9..2201ec8 100644 --- a/site/llm_prompt.py +++ b/site/llm_prompt.py @@ -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". diff --git a/site/services/grouping.py b/site/services/grouping.py index f727075..1f58794 100644 --- a/site/services/grouping.py +++ b/site/services/grouping.py @@ -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,