"""Unit tests for the Stage-1 heuristic extractor (regex-free port). Ports 100% of the regex-v1 fixtures from test_prescreen_extractor.py onto HeuristicExtractor (plan Phase 1) and adds coverage for the string helpers (`_scan_number`, header window, trigger scans). """ from __future__ import annotations import pytest from contract_check.worker_prescreen.extractor_heuristic import ( EXTRACTOR_VERSION, HeuristicExtractor, _header_window, _scan_number, ) SIMPLE_SUPPLY = """\ ДОГОВОР ПОСТАВКИ № 42 г. Москва Общество с ограниченной ответственностью «Продавец», именуемое в дальнейшем «Поставщик», в лице директора Иванова И.И., с одной стороны, и Общество с ограниченной ответственностью «Покупатель», именуемое в дальнейшем «Покупатель», с другой стороны, заключили настоящий договор о нижеследующем. 1. Предмет договора Поставщик обязуется передать в собственность Покупателю товар. 2. Цена и порядок расчётов 2.1. Общая стоимость товара составляет 1 250 000 (один миллион двести пятьдесят тысяч) рублей. 3. Срок действия договора 3.1. Настоящий договор вступает в силу с 01.09.2025 и действует по 31.08.2026. 4. Ответственность сторон 4.1. За просрочку поставки Поставщик уплачивает неустойку в размере 0,1% от стоимости. 5. Порядок разрешения споров 5.1. Споры подлежат рассмотрению в Арбитражном суде г. Москвы. """ MINIMAL = """\ Договор оказания услуг между ИП Петров Петр Петрович и ООО «Клиент». Стоимость услуг составляет 50 000 рублей. """ @pytest.fixture def extractor() -> HeuristicExtractor: return HeuristicExtractor() def test_version_is_heuristic_v2() -> None: assert EXTRACTOR_VERSION == "heuristic-v2" assert HeuristicExtractor.extractor_version == "heuristic-v2" def test_supply_extraction(extractor: HeuristicExtractor) -> None: meta = extractor.extract(SIMPLE_SUPPLY) assert meta.contract_type == "supply" assert "Продавец" in (meta.party_a or "") assert "Покупатель" in (meta.party_b or "") assert meta.total_amount == 1250000.0 assert meta.currency == "RUB" assert meta.start_date == "2025-09-01" assert meta.end_date == "2026-08-31" assert meta.has_penalty_clause is True assert meta.has_termination_clause is False assert meta.has_arbitration is True assert meta.confidence_score > 0.6 def test_minimal_extraction(extractor: HeuristicExtractor) -> None: meta = extractor.extract(MINIMAL) assert meta.contract_type == "services" assert "ИП Петров" in (meta.party_a or "") assert "ООО «Клиент»" in (meta.party_b or "") assert meta.total_amount == 50000.0 assert meta.currency == "RUB" def test_confidence_score_bounds(extractor: HeuristicExtractor) -> None: meta = extractor.extract("") assert meta.confidence_score == 0.0 assert 0.0 <= meta.confidence_score <= 1.0 def test_party_extraction_handles_whitespace(extractor: HeuristicExtractor) -> None: text = "Договор между ООО «Альфа» и ООО «Бета»" meta = extractor.extract(text) assert "Альфа" in (meta.party_a or "") assert "Бета" in (meta.party_b or "") @pytest.mark.parametrize( ("clause", "expected"), [ ("Стороны вправе расторгнуть договор в одностороннем порядке", "has_termination_clause"), ("В случае нарушения Покупатель уплачивает штраф", "has_penalty_clause"), ("Споры рассматриваются в Арбитражном суде", "has_arbitration"), ], ) def test_boolean_flags(clause: str, expected: str, extractor: HeuristicExtractor) -> None: meta = extractor.extract(f"Договор поставки. {clause}.") assert getattr(meta, expected) is True # ── helper coverage ────────────────────────────────────────────────────────── @pytest.mark.parametrize( ("raw", "expected"), [ ("1 250 000 рублей", 1250000.0), ("1250000", 1250000.0), ("50 000 руб", 50000.0), ("0,1% от стоимости", 0.1), ("1 250 000,50", 1250000.5), ("999", 999.0), ], ) def test_scan_number(raw: str, expected: float) -> None: parsed = _scan_number(raw, 0) assert parsed is not None assert parsed[0] == expected def test_scan_number_rejects_non_digits() -> None: assert _scan_number("abc", 0) is None def test_scan_number_nbsp_thousands() -> None: parsed = _scan_number("1\u00a0250\u00a0000", 0) assert parsed is not None assert parsed[0] == 1250000.0 def test_header_window_cuts_at_numbered_heading() -> None: text = "ДОГОВОР № 1\nООО «Альфа» и ООО «Бета»\n1. Предмет договора\nООО «Гамма»" window = _header_window(text) assert "Альфа" in window assert "Бета" in window assert "Гамма" not in window def test_parties_only_scanned_in_header(extractor: HeuristicExtractor) -> None: text = ( "ДОГОВОР № 1\nООО «Альфа» и ООО «Бета»\n\n" "1. Предмет договора\nПоставщик обязуется...\n\n" "10. Реквизиты сторон\nООО «Гамма»" ) meta = extractor.extract(text) assert "Альфа" in (meta.party_a or "") assert "Бета" in (meta.party_b or "") def test_end_date_only_via_do_fallback(extractor: HeuristicExtractor) -> None: meta = extractor.extract("Соглашение действует до 31.12.2026.") assert meta.start_date is None assert meta.end_date == "2026-12-31" def test_start_date_via_ot_token(extractor: HeuristicExtractor) -> None: meta = extractor.extract("Договор заключён от 15.03.2024.") assert meta.start_date == "2024-03-15" def test_amount_skips_trigger_without_digits(extractor: HeuristicExtractor) -> None: meta = extractor.extract("Общая стоимость товара определяется расчётом. Сумма 300 BYN.") assert meta.total_amount == 300.0 assert meta.currency == "BYN" def test_no_false_entity_match_inside_words(extractor: HeuristicExtractor) -> None: meta = extractor.extract("Ипотека оформлена. Сумма 100 рублей.") assert meta.party_a is None assert meta.party_b is None def test_bare_entity_form_without_name_dropped(extractor: HeuristicExtractor) -> None: meta = extractor.extract("Договор между ООО и АО.\nСумма 100 рублей.") assert meta.party_a is None assert meta.party_b is None