This commit is contained in:
parent
6cb3d7515c
commit
312df8536a
10 changed files with 370 additions and 9 deletions
|
|
@ -34,6 +34,7 @@ from sqlalchemy.orm import Session
|
|||
|
||||
from app.services.scrapers.avito import _clean_address
|
||||
from app.services.scrapers.avito_exceptions import AvitoBlockedError, AvitoRateLimitedError
|
||||
from app.services.scrapers.repair_state_normalizer import infer_repair_state_from_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -403,6 +404,10 @@ def parse_detail_html(html: str, source_url: str) -> DetailEnrichment:
|
|||
if "закрытая территория" in val.lower():
|
||||
closed_yard = True
|
||||
|
||||
# ── Fallback: repair_state из описания, если структурное поле пустое (#622) ──
|
||||
if repair_state is None and description:
|
||||
repair_state = infer_repair_state_from_text(description)
|
||||
|
||||
# ── Domoteka ─────────────────────────────────────────────────────────────
|
||||
owners_count: int | None = None
|
||||
owners_at_least: bool | None = None
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@ from curl_cffi.requests import AsyncSession
|
|||
from app.services.scraper_settings import get_scraper_delay
|
||||
from app.services.scrapers.base import BaseScraper, ScrapedLot
|
||||
from app.services.scrapers.cian_state_parser import extract_state
|
||||
from app.services.scrapers.repair_state_normalizer import normalize_repair_state
|
||||
from app.services.scrapers.repair_state_normalizer import (
|
||||
infer_repair_state_from_text,
|
||||
normalize_repair_state,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -372,6 +375,10 @@ class CianScraper(BaseScraper):
|
|||
description.lower().encode("utf-8", errors="replace")
|
||||
).hexdigest()[:32]
|
||||
|
||||
# Fallback: repair_state из описания, если decoration отсутствует (#622)
|
||||
if repair_state is None and description:
|
||||
repair_state = infer_repair_state_from_text(description)
|
||||
|
||||
# ── Фото ─────────────────────────────────────────────────────────
|
||||
photo_urls: list[str] = [
|
||||
p["fullUrl"]
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@ from sqlalchemy import text
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.scrapers.cian_state_parser import extract_all_states, extract_state
|
||||
from app.services.scrapers.repair_state_normalizer import normalize_repair_state
|
||||
from app.services.scrapers.repair_state_normalizer import (
|
||||
infer_repair_state_from_text,
|
||||
normalize_repair_state,
|
||||
)
|
||||
from app.services.scrapers.snapshot_writer import upsert_listing_snapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -162,8 +165,15 @@ def _extract_repair_type(offer: dict[str, Any]) -> str | None:
|
|||
|
||||
|
||||
def _extract_repair_state(offer: dict[str, Any]) -> str | None:
|
||||
"""Нормализованный enum из offer.repairType → needs_repair/standard/good/excellent."""
|
||||
return normalize_repair_state(offer.get("repairType"))
|
||||
"""Нормализованный enum из offer.repairType → needs_repair/standard/good/excellent.
|
||||
|
||||
Если структурное поле repairType пустое — fallback на инференс из описания (#622).
|
||||
Структурное значение всегда в приоритете.
|
||||
"""
|
||||
state = normalize_repair_state(offer.get("repairType"))
|
||||
if state is None:
|
||||
state = infer_repair_state_from_text(offer.get("description"))
|
||||
return state
|
||||
|
||||
|
||||
def _parse_float(value: Any) -> float | None:
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -66,3 +67,95 @@ def normalize_repair_state(raw: str | None) -> str | None:
|
|||
if result is None:
|
||||
logger.warning("repair_state_normalizer: unknown raw value %r — stored as NULL", raw)
|
||||
return result
|
||||
|
||||
|
||||
# ── Inference из текста описания (#622) ──────────────────────────────────────
|
||||
# Когда структурное поле repair отсутствует (avito ~1% / cian ~5% / yandex 0%),
|
||||
# извлекаем состояние из текста описания регэкспами по русским фразам.
|
||||
#
|
||||
# Порядок ВАЖЕН: проверяем от самого сильного сигнала к слабому
|
||||
# excellent → good → needs_repair → standard,
|
||||
# чтобы «после черновой сделали дизайнерский ремонт» дал excellent, а не
|
||||
# needs_repair. Первое совпадение по порядку выигрывает.
|
||||
#
|
||||
# Целевой enum выровнен по normalize_repair_state() — те же 4 значения.
|
||||
_TEXT_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
# ── excellent: дизайнерский / премиум / эксклюзивный ремонт ──────────────
|
||||
(
|
||||
re.compile(
|
||||
r"дизайнерск(?:ий|ого|ом|ая)\s+ремонт"
|
||||
r"|дизайнерск(?:ая|ой)\s+отделк"
|
||||
r"|премиальн\w*\s+ремонт"
|
||||
r"|эксклюзивн\w*\s+ремонт"
|
||||
r"|авторск\w*\s+ремонт",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
"excellent",
|
||||
),
|
||||
# ── good: евроремонт / отличный / свежий / современный ремонт ────────────
|
||||
(
|
||||
re.compile(
|
||||
r"евроремонт"
|
||||
r"|евро[\s-]?ремонт"
|
||||
r"|отличн\w*\s+ремонт"
|
||||
r"|свеж\w*\s+ремонт"
|
||||
r"|современн\w*\s+ремонт"
|
||||
r"|качественн\w*\s+ремонт"
|
||||
r"|ремонт\s+в\s+отличн",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
"good",
|
||||
),
|
||||
# ── needs_repair: без отделки / черновая / требует ремонта / под ремонт ──
|
||||
(
|
||||
re.compile(
|
||||
r"без\s+отделк"
|
||||
r"|без\s+ремонт"
|
||||
r"|черновая\s+отделк"
|
||||
r"|черновой\s+ремонт"
|
||||
r"|требует(?:ся)?\s+ремонт"
|
||||
r"|требует(?:ся)?\s+космет"
|
||||
r"|нужен\s+ремонт"
|
||||
r"|под\s+ремонт"
|
||||
r"|под\s+чистов\w+\s+отделк"
|
||||
r"|предчистов",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
"needs_repair",
|
||||
),
|
||||
# ── standard: косметический / обычный / жилое состояние ─────────────────
|
||||
(
|
||||
re.compile(
|
||||
r"косметическ\w*\s+ремонт"
|
||||
r"|косметическ\w*\s+отделк"
|
||||
r"|обычн\w*\s+ремонт"
|
||||
r"|жил(?:ое|ом)\s+состоян"
|
||||
r"|хорош\w*\s+состоян",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
"standard",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def infer_repair_state_from_text(description: str | None) -> str | None:
|
||||
"""Извлечь repair_state из текста описания объявления (#622).
|
||||
|
||||
Fallback-путь для повышения покрытия listings.repair_state, когда
|
||||
структурное поле источника отсутствует. Проверяет паттерны в порядке
|
||||
убывания силы сигнала (excellent → good → needs_repair → standard);
|
||||
первое совпадение выигрывает.
|
||||
|
||||
Args:
|
||||
description: текст описания объявления (avito/cian/yandex) или None.
|
||||
|
||||
Returns:
|
||||
Одно из needs_repair / standard / good / excellent, либо None если
|
||||
ни один паттерн не сработал (НЕ фабрикуем — unknown остаётся NULL).
|
||||
"""
|
||||
if not description:
|
||||
return None
|
||||
for pattern, enum_value in _TEXT_PATTERNS:
|
||||
if pattern.search(description):
|
||||
return enum_value
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from selectolax.parser import HTMLParser, Node
|
|||
|
||||
from app.services.scraper_settings import get_scraper_delay
|
||||
from app.services.scrapers.base import BaseScraper
|
||||
from app.services.scrapers.repair_state_normalizer import infer_repair_state_from_text
|
||||
from app.services.scrapers.yandex_helpers import (
|
||||
RE_AGENCY_FOUNDED,
|
||||
RE_AGENCY_OBJECTS,
|
||||
|
|
@ -66,6 +67,10 @@ class DetailEnrichment(BaseModel):
|
|||
# Sale type — raw RU phrase ('свободная продажа' / 'альтернативная')
|
||||
sale_type_text: str | None = None
|
||||
|
||||
# Repair state — enum, inferred from description text (#622).
|
||||
# Yandex не отдаёт структурного поля ремонта, поэтому только инференс.
|
||||
repair_state: str | None = None
|
||||
|
||||
# Stats
|
||||
views_total: int | None = None
|
||||
publish_date: date | None = None
|
||||
|
|
@ -210,6 +215,9 @@ class YandexDetailScraper(BaseScraper):
|
|||
# --- Description section (after H2 "Описание") ---
|
||||
description = _find_section_text(tree, "Описание")
|
||||
|
||||
# --- Repair state: инференс из описания (#622), Yandex без структурного поля ---
|
||||
repair_state = infer_repair_state_from_text(description or summary_text)
|
||||
|
||||
# --- Address ---
|
||||
address = _extract_address(summary_text)
|
||||
|
||||
|
|
@ -253,6 +261,7 @@ class YandexDetailScraper(BaseScraper):
|
|||
address=address,
|
||||
description=description,
|
||||
sale_type_text=sale_type_text,
|
||||
repair_state=repair_state,
|
||||
views_total=views_total,
|
||||
publish_date=publish_date,
|
||||
publish_date_relative=publish_date_relative,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from selectolax.parser import HTMLParser, Node
|
|||
|
||||
from app.services.scraper_settings import get_scraper_delay
|
||||
from app.services.scrapers.base import BaseScraper, ScrapedLot
|
||||
from app.services.scrapers.repair_state_normalizer import infer_repair_state_from_text
|
||||
from app.services.scrapers.yandex_helpers import (
|
||||
RE_FLOOR,
|
||||
RE_JK_ID,
|
||||
|
|
@ -164,6 +165,10 @@ class YandexRealtyScraper(BaseScraper):
|
|||
bargain = "торг" in text.lower()
|
||||
listing_date = parse_listing_date(text)
|
||||
|
||||
# Repair state — инференс из текста карточки (#622). Yandex SERP не
|
||||
# отдаёт структурного поля ремонта; берём сигнал из текста сниппета.
|
||||
repair_state = infer_repair_state_from_text(text)
|
||||
|
||||
# Address — prefer the full address container (street + house number);
|
||||
# fall back to the street aggregator link (street name only) if absent.
|
||||
# See _extract_address for details.
|
||||
|
|
@ -199,6 +204,7 @@ class YandexRealtyScraper(BaseScraper):
|
|||
area_m2=area_m2,
|
||||
floor=floor,
|
||||
total_floors=total_floors,
|
||||
repair_state=repair_state,
|
||||
price_rub=price_rub,
|
||||
price_per_m2=price_per_m2,
|
||||
bargain_allowed=bargain,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
-- Migration 075: backfill listings.repair_state из текста description (#622)
|
||||
--
|
||||
-- Покрытие структурного repair_state низкое (avito ~1% / cian ~5% / yandex 0%),
|
||||
-- что недостаточно для data-derived repair-коэффициентов (#7 v2). Эта миграция
|
||||
-- извлекает состояние ремонта из текста описания регэкспами по русским фразам —
|
||||
-- тот же набор паттернов, что и repair_state_normalizer.infer_repair_state_from_text().
|
||||
--
|
||||
-- Порядок UPDATE'ов ВАЖЕН (от сильного сигнала к слабому):
|
||||
-- excellent → good → needs_repair → standard.
|
||||
-- Каждый шаг трогает только оставшиеся NULL-строки (WHERE repair_state IS NULL),
|
||||
-- поэтому «дизайнерский ремонт после черновой» получит excellent, а не needs_repair.
|
||||
--
|
||||
-- Idempotent: повторный прогон ничего не меняет — все совпавшие строки уже
|
||||
-- получили не-NULL значение и отсекаются предикатом repair_state IS NULL.
|
||||
-- Не фабрикуем: строки без совпадения паттерна остаются NULL.
|
||||
-- Issue: #622
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── excellent: дизайнерский / премиальный / эксклюзивный / авторский ремонт ──
|
||||
UPDATE listings
|
||||
SET repair_state = 'excellent'
|
||||
WHERE repair_state IS NULL
|
||||
AND description ~* '(дизайнерск(ий|ого|ом|ая)\s+ремонт|дизайнерск(ая|ой)\s+отделк|премиальн\w*\s+ремонт|эксклюзивн\w*\s+ремонт|авторск\w*\s+ремонт)';
|
||||
|
||||
-- ── good: евроремонт / отличный / свежий / современный / качественный ремонт ─
|
||||
UPDATE listings
|
||||
SET repair_state = 'good'
|
||||
WHERE repair_state IS NULL
|
||||
AND description ~* '(евроремонт|евро[\s-]?ремонт|отличн\w*\s+ремонт|свеж\w*\s+ремонт|современн\w*\s+ремонт|качественн\w*\s+ремонт|ремонт\s+в\s+отличн)';
|
||||
|
||||
-- ── needs_repair: без отделки / черновая / требует ремонта / под ремонт ──────
|
||||
UPDATE listings
|
||||
SET repair_state = 'needs_repair'
|
||||
WHERE repair_state IS NULL
|
||||
AND description ~* '(без\s+отделк|без\s+ремонт|черновая\s+отделк|черновой\s+ремонт|требует(ся)?\s+ремонт|требует(ся)?\s+космет|нужен\s+ремонт|под\s+ремонт|под\s+чистов\w+\s+отделк|предчистов)';
|
||||
|
||||
-- ── standard: косметический / обычный / жилое (хорошее) состояние ────────────
|
||||
UPDATE listings
|
||||
SET repair_state = 'standard'
|
||||
WHERE repair_state IS NULL
|
||||
AND description ~* '(косметическ\w*\s+ремонт|косметическ\w*\s+отделк|обычн\w*\s+ремонт|жил(ое|ом)\s+состоян|хорош\w*\s+состоян)';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -173,3 +173,51 @@ def test_photo_urls_extracted() -> None:
|
|||
def test_source_url_preserved() -> None:
|
||||
result = parse_detail_html(MINIMAL_HTML, SOURCE_URL)
|
||||
assert result.source_url == SOURCE_URL
|
||||
|
||||
|
||||
# ── repair_state fallback из описания (#622) ──────────────────────────────────
|
||||
|
||||
|
||||
def test_repair_state_structured_wins_over_description() -> None:
|
||||
"""Структурное «Ремонт: евро» (good) в приоритете даже при сигнале в тексте."""
|
||||
html = """
|
||||
<html><body>
|
||||
<div data-marker="item-view/item-id">№ 3333333333</div>
|
||||
<span itemprop="price" content="6000000">6 000 000 ₽</span>
|
||||
<div data-marker="item-view/item-params">
|
||||
<ul><li>Ремонт: евро</li></ul>
|
||||
</div>
|
||||
<div data-marker="item-view/item-description">Квартира без отделки, под чистовую</div>
|
||||
</body></html>
|
||||
"""
|
||||
result = parse_detail_html(html, "https://www.avito.ru/test_3333333333")
|
||||
assert result.repair_state == "good" # структурное поле, не needs_repair из текста
|
||||
|
||||
|
||||
def test_repair_state_inferred_from_description_when_structured_absent() -> None:
|
||||
"""Нет блока «Ремонт» → инференс из описания (#622)."""
|
||||
html = """
|
||||
<html><body>
|
||||
<div data-marker="item-view/item-id">№ 4444444444</div>
|
||||
<span itemprop="price" content="8000000">8 000 000 ₽</span>
|
||||
<div data-marker="item-view/item-params">
|
||||
<ul><li>Количество комнат: 2</li></ul>
|
||||
</div>
|
||||
<div data-marker="item-view/item-description">Сделан свежий евроремонт, заезжай и живи</div>
|
||||
</body></html>
|
||||
"""
|
||||
result = parse_detail_html(html, "https://www.avito.ru/test_4444444444")
|
||||
assert result.repair_state == "good"
|
||||
|
||||
|
||||
def test_repair_state_none_when_no_signal() -> None:
|
||||
"""Нет структурного поля и нет сигнала в тексте → None (не фабрикуем)."""
|
||||
html = """
|
||||
<html><body>
|
||||
<div data-marker="item-view/item-id">№ 5555555555</div>
|
||||
<span itemprop="price" content="9000000">9 000 000 ₽</span>
|
||||
<div data-marker="item-view/item-description">Светлая квартира рядом с метро</div>
|
||||
</body></html>
|
||||
"""
|
||||
result = parse_detail_html(html, "https://www.avito.ru/test_5555555555")
|
||||
assert result.repair_state is None
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ from app.services.scrapers.cian_detail import (
|
|||
DetailEnrichment,
|
||||
_extract_agent_profile,
|
||||
_extract_price_changes,
|
||||
_extract_repair_state,
|
||||
_parse_views,
|
||||
fetch_detail,
|
||||
)
|
||||
|
||||
|
||||
# ── _parse_views ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -37,6 +37,33 @@ def test_parse_views_comma_formatted():
|
|||
assert _parse_views("1,234") == 1234
|
||||
|
||||
|
||||
# ── _extract_repair_state — структурное поле в приоритете, fallback на текст (#622) ──
|
||||
|
||||
|
||||
def test_repair_state_structured_wins_over_description():
|
||||
"""repairType присутствует → используется он, описание игнорируется."""
|
||||
offer = {
|
||||
"repairType": "cosmetic", # → standard
|
||||
"description": "Шикарный дизайнерский ремонт", # инференс дал бы excellent
|
||||
}
|
||||
assert _extract_repair_state(offer) == "standard"
|
||||
|
||||
|
||||
def test_repair_state_falls_back_to_description_when_structured_missing():
|
||||
"""repairType отсутствует → инференс из описания."""
|
||||
offer = {
|
||||
"repairType": None,
|
||||
"description": "Сделан свежий евроремонт, заезжай и живи",
|
||||
}
|
||||
assert _extract_repair_state(offer) == "good"
|
||||
|
||||
|
||||
def test_repair_state_none_when_both_missing():
|
||||
"""Нет structured и нет сигнала в тексте → None (не фабрикуем)."""
|
||||
offer = {"description": "Светлая квартира рядом с парком"}
|
||||
assert _extract_repair_state(offer) is None
|
||||
|
||||
|
||||
# ── _extract_price_changes ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
"""Unit-тесты для repair_state_normalizer.normalize_repair_state() (#621).
|
||||
"""Unit-тесты для repair_state_normalizer (#621, #622).
|
||||
|
||||
Покрывают все известные raw-значения → enum mapping,
|
||||
idempotency для уже нормализованных значений и None-guard.
|
||||
Покрывают:
|
||||
- normalize_repair_state(): все known raw → enum, idempotency, None-guard (#621).
|
||||
- infer_repair_state_from_text(): русские фразы → enum, приоритет сигналов,
|
||||
unknown → None, None-guard (#622).
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.scrapers.repair_state_normalizer import normalize_repair_state
|
||||
from app.services.scrapers.repair_state_normalizer import (
|
||||
infer_repair_state_from_text,
|
||||
normalize_repair_state,
|
||||
)
|
||||
|
||||
# ── needs_repair ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -69,3 +74,110 @@ def test_unknown_raw_returns_none(caplog: pytest.LogCaptureFixture) -> None:
|
|||
result = normalize_repair_state("whitebox")
|
||||
assert result is None
|
||||
assert "whitebox" in caplog.text
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# infer_repair_state_from_text() — извлечение из описания (#622)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
# ── excellent ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
[
|
||||
"Сделан дизайнерский ремонт с авторской мебелью",
|
||||
"Квартира с дизайнерской отделкой премиум-класса",
|
||||
"Премиальный ремонт от застройщика",
|
||||
"Эксклюзивный ремонт по индивидуальному проекту",
|
||||
"Авторский ремонт, всё новое",
|
||||
],
|
||||
)
|
||||
def test_infer_excellent(text: str) -> None:
|
||||
assert infer_repair_state_from_text(text) == "excellent"
|
||||
|
||||
|
||||
# ── good ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
[
|
||||
"Свежий евроремонт, заезжай и живи",
|
||||
"Сделан евро-ремонт в 2024 году",
|
||||
"Современный ремонт, всё в отличном состоянии — ремонт в отличном",
|
||||
"Отличный ремонт, продаётся с мебелью",
|
||||
"Качественный ремонт от собственника",
|
||||
],
|
||||
)
|
||||
def test_infer_good(text: str) -> None:
|
||||
assert infer_repair_state_from_text(text) == "good"
|
||||
|
||||
|
||||
# ── needs_repair ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
[
|
||||
"Квартира без отделки, под чистовую отделку",
|
||||
"Сдаётся без ремонта, черновая отделка",
|
||||
"Черновой ремонт, нужен ремонт",
|
||||
"Требует ремонта, торг уместен",
|
||||
"Требуется косметический ремонт",
|
||||
"Квартира под ремонт, цена снижена",
|
||||
"Предчистовая отделка от застройщика",
|
||||
],
|
||||
)
|
||||
def test_infer_needs_repair(text: str) -> None:
|
||||
assert infer_repair_state_from_text(text) == "needs_repair"
|
||||
|
||||
|
||||
# ── standard ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
[
|
||||
"Косметический ремонт, чисто и уютно",
|
||||
"Сделана косметическая отделка",
|
||||
"Обычный ремонт, всё работает",
|
||||
"Квартира в жилом состоянии",
|
||||
"Хорошее состояние, заезжай и живи",
|
||||
],
|
||||
)
|
||||
def test_infer_standard(text: str) -> None:
|
||||
assert infer_repair_state_from_text(text) == "standard"
|
||||
|
||||
|
||||
# ── приоритет сигналов (порядок excellent > good > needs_repair > standard) ───
|
||||
|
||||
|
||||
def test_infer_excellent_beats_needs_repair() -> None:
|
||||
"""Дизайнерский ремонт после черновой → excellent, не needs_repair."""
|
||||
text = "Была черновая отделка, теперь сделан дизайнерский ремонт"
|
||||
assert infer_repair_state_from_text(text) == "excellent"
|
||||
|
||||
|
||||
def test_infer_good_beats_standard() -> None:
|
||||
"""Евроремонт + косметический → good (сильнее)."""
|
||||
text = "Косметический ремонт обновлён до евроремонта"
|
||||
assert infer_repair_state_from_text(text) == "good"
|
||||
|
||||
|
||||
# ── None / unknown guard ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_infer_none_returns_none() -> None:
|
||||
assert infer_repair_state_from_text(None) is None
|
||||
|
||||
|
||||
def test_infer_empty_returns_none() -> None:
|
||||
assert infer_repair_state_from_text("") is None
|
||||
|
||||
|
||||
def test_infer_unknown_text_returns_none() -> None:
|
||||
"""Текст без repair-сигналов → None (не фабрикуем)."""
|
||||
text = "Светлая квартира рядом с метро, развитая инфраструктура, школа во дворе"
|
||||
assert infer_repair_state_from_text(text) is None
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue