"""Роутинг RU-вопроса в intent + сборка шаблонного RU-ответа (#957, Step 1, БЕЗ LLM). ДЕТЕРМИНИРОВАННО: • route_intent — явный intent (если задан) ИЛИ keyword/regex-матч RU-вопроса; нет совпадения → ChatIntent.unknown. • render_answer — тянет нужную(ые) секцию(и) `SiteFinderReport.as_dict()` и собирает краткий RU-ответ с числами движка ВЕРБАТИМ (числа берутся ИЗ отчёта, НИКОГДА не фабрикуются). Возвращает (answer_text, sections_used). ROBUST к частичному/пустому отчёту (отчёт может быть тонким — каждая секция Optional): никаких KeyError — отсутствует секция → честно говорим «данных по X в отчёте нет», НЕ выдумываем. Микрокопирайт: RU, нейтральный бизнес-тон (ui-microcopy.md), `₽/м²` с пробелами, тысячи через пробел, без emoji/«Готово!». Каждый ответ завершается advisory- оговоркой (отчёт советующий, не основание для инвест-решения). """ from __future__ import annotations import math import re from typing import Any from app.schemas.chat import ChatIntent # Неразрывный пробел — разделитель тысяч (зеркало toLocaleString("ru"), ui-microcopy.md: # '6 832 540'). Явная константа, чтобы separator не зависел от способа ввода в исходнике. _NBSP = " " # advisory-оговорка в конце КАЖДОГО ответа (зеркало report.advisory). Полное # предложение, не аббревиатура (ui-microcopy.md). _ADVISORY_NOTE = ( "Отчёт носит советующий характер и не является основанием для инвестиционного решения." ) # Сообщение, когда конкретной секции в отчёте нет (честный graceful, не фабрикуем). _NO_SECTION_TMPL = "Данных по разделу «{name}» в отчёте нет." # RU-уровни уверенности (movement из low/medium/high в человекочитаемое). _CONFIDENCE_RU = { "high": "высокая", "medium": "средняя", "low": "низкая", } # RU-метки сценариев. _SCENARIO_RU = { "conservative": "консервативный", "base": "базовый", "aggressive": "агрессивный", } # RU-метки ключей §25 спец-индексов (для intent=risks). _INDEX_RU = { "launch_window": "окно запуска", "product_void": "продуктовое «белое пятно»", "cannibalization": "каннибализация портфеля", "competitor_strength": "сила конкурентов", "artificial_demand": "искусственный (ипотечный) спрос", "cost_of_error": "цена ошибки выбора продукта", } # ── Keyword-роутинг intent'ов ─────────────────────────────────────────────────── # Каждый intent → список regex'ов по строчному RU-вопросу. Порядок проверки задан # _INTENT_ORDER (specific → general): «почему такой прогноз» матчим РАНЬШЕ голого # «прогноз», иначе why_forecast перехватится более общим правилом. _INTENT_PATTERNS: dict[ChatIntent, list[re.Pattern[str]]] = { ChatIntent.what_to_build: [ re.compile(r"что\s+(тут|здесь|строить|возвод)"), re.compile(r"какой\s+продукт"), re.compile(r"квартирограф|нарезк[аеуи]\b"), re.compile(r"какой\s+класс"), re.compile(r"\bтз\b|техзадан"), ], ChatIntent.why_forecast: [ re.compile(r"почему\s+(такой|так)\b"), re.compile(r"на\s+чём\s+основан"), re.compile(r"откуда\s+(такой|эти|цифр|прогноз)"), re.compile(r"чем\s+обоснован"), re.compile(r"(почему|обоснуй).{0,20}прогноз"), ], ChatIntent.risks: [ re.compile(r"риск"), re.compile(r"опасн|угроз"), re.compile(r"каннибал"), re.compile(r"подводн|что\s+может\s+пойти"), re.compile(r"спец.?индекс|индекс[ыаов]\b"), ], ChatIntent.scenarios: [ re.compile(r"сценари"), re.compile(r"консерватив|агрессивн|базов"), re.compile(r"разброс|варианты\s+развит"), ], ChatIntent.summary: [ re.compile(r"резюме|итог|кратк|сводк|вывод"), re.compile(r"вердикт|общая\s+картин"), re.compile(r"расскажи\s+про\s+участ|стоит\s+ли"), ], } # Порядок проверки: более СПЕЦИФИЧНЫЕ intent'ы раньше общих. _INTENT_ORDER: tuple[ChatIntent, ...] = ( ChatIntent.why_forecast, ChatIntent.what_to_build, ChatIntent.risks, ChatIntent.scenarios, ChatIntent.summary, ) def route_intent(message: str, explicit: ChatIntent | None) -> ChatIntent: """Определить intent: явный (приоритет) ИЛИ keyword-матч; нет совпадения → unknown. Явный intent уважаем как есть (фронт/клиент знает лучше). Иначе нормализуем текст в нижний регистр и идём по _INTENT_ORDER (specific → general): первый сматчившийся pattern выигрывает. Ничего не сматчилось → ChatIntent.unknown (отдадим меню тем). """ if explicit is not None: return explicit text = message.lower().strip() if not text: return ChatIntent.unknown for intent in _INTENT_ORDER: for pattern in _INTENT_PATTERNS[intent]: if pattern.search(text): return intent return ChatIntent.unknown # ── Хелперы форматирования (RU-микрокопирайт) ──────────────────────────────────── def _fmt_thousands(value: float | int) -> str: """Целое с разделителем-NBSP тысяч ('6 832 540', зеркало ru-locale). PURE.""" return f"{round(value):,}".replace(",", _NBSP) def _fmt_number(value: Any) -> str | None: """Привести число к RU-строке (тысячи NBSP), не-числа → str() или None. None → None (вызывающий пропустит поле). int → тысячи NBSP. float: целозначный → тысячи NBSP; дробный → запятая-десятичный БЕЗ потери точности (число из отчёта уже округлено движком — НЕ пере-округляем, лишь меняем '.'→','). Прочее (готовая строка- метка) → как есть. """ if value is None: return None if isinstance(value, bool): # bool — подтип int, не форматируем как число return str(value) if isinstance(value, int): return _fmt_thousands(value) if isinstance(value, float): if not math.isfinite(value): # NaN/Inf — не число: честно пропускаем (как None), return None # иначе в RU-прозу утёк бы англ. литерал 'nan'/'inf' (#1585) if value == int(value): return _fmt_thousands(value) # Точность отчёта сохраняем: repr float'а → '.'→','. (0.31 → '0,31'). return repr(value).replace(".", ",") return str(value) def _confidence_ru(level: Any) -> str | None: """RU-метка уровня уверенности или None (если нет/неизвестный). PURE.""" if not isinstance(level, str): return None return _CONFIDENCE_RU.get(level.lower()) def _assemble(lines: list[str]) -> str: """Склеить непустые строки-абзацы в ответ + advisory-оговорка в конце. PURE. Пустые/None-строки выкидываем (graceful: пропущенные поля не оставляют дыр). advisory-оговорка добавляется ВСЕГДА последней строкой. """ body = [ln for ln in lines if ln] body.append(_ADVISORY_NOTE) return "\n".join(body) # ── Рендереры по секциям ───────────────────────────────────────────────────────── def _render_summary(report: dict[str, Any]) -> tuple[str, list[str]]: """summary → exec_summary (headline / verdict / key_numbers / overall_confidence).""" section = report.get("exec_summary") if not isinstance(section, dict): return _assemble([_NO_SECTION_TMPL.format(name="резюме")]), [] lines: list[str] = [] headline = section.get("headline") if headline: lines.append(f"Вердикт: {headline}") verdict = section.get("verdict") if verdict: lines.append(str(verdict)) key_numbers = section.get("key_numbers") if isinstance(key_numbers, dict) and key_numbers: parts = [] for label, raw in key_numbers.items(): shown = _fmt_number(raw) if shown is not None: parts.append(f"{label}: {shown}") if parts: lines.append("Ключевые числа — " + "; ".join(parts) + ".") conf = _confidence_ru(section.get("overall_confidence")) if conf: lines.append(f"Общая уверенность: {conf}.") if not lines: lines.append("В отчёте пока нет заполненного резюме по участку.") return _assemble(lines), ["exec_summary"] def _render_what_to_build(report: dict[str, Any]) -> tuple[str, list[str]]: """what_to_build → product_tz (класс / mix / коммерция / USP) + exec_summary.headline.""" section = report.get("product_tz") sections_used: list[str] = [] lines: list[str] = [] exec_summary = report.get("exec_summary") if isinstance(exec_summary, dict) and exec_summary.get("headline"): lines.append(f"Вердикт: {exec_summary['headline']}") sections_used.append("exec_summary") if not isinstance(section, dict): lines.append(_NO_SECTION_TMPL.format(name="рекомендация продукта")) return _assemble(lines), sections_used sections_used.append("product_tz") obj_class = section.get("obj_class") if obj_class: lines.append(f"Рекомендованный класс: {obj_class}.") mix = section.get("mix") if isinstance(mix, list) and mix: lines.append(f"Квартирография проработана ({len(mix)} форматов).") commercial = section.get("commercial") if isinstance(commercial, dict) and commercial: lines.append("Заложен коммерческий блок (см. раздел продукта).") usp = section.get("usp") if isinstance(usp, list) and usp: lines.append(f"Выделено USP-преимуществ: {len(usp)}.") summary = section.get("summary") if summary: lines.append(str(summary)) if not any( section.get(k) for k in ("obj_class", "mix", "commercial", "usp", "summary") ): lines.append("Раздел рекомендации продукта в отчёте пуст.") return _assemble(lines), sections_used def _render_why_forecast(report: dict[str, Any]) -> tuple[str, list[str]]: """why_forecast → future_market (по горизонтам + future-supply) + product_tz.reasons + confidence.rationale.""" sections_used: list[str] = [] lines: list[str] = [] future = report.get("future_market") if isinstance(future, dict): future_emitted = False # секцию в provenance кладём только если выведена строка (#1630) horizons = future.get("forecasts_by_horizon") if isinstance(horizons, list) and horizons: lines.append(f"Прогноз построен по {len(horizons)} горизонтам спроса/предложения.") future_emitted = True future_supply = future.get("future_supply") if isinstance(future_supply, dict) and future_supply: lines.append("Учтено давление будущего предложения (выходящие проекты).") future_emitted = True summary = future.get("summary") if summary: lines.append(str(summary)) future_emitted = True if future_emitted: sections_used.append("future_market") else: lines.append(_NO_SECTION_TMPL.format(name="будущий рынок")) product = report.get("product_tz") if isinstance(product, dict): reasons = product.get("reasons") if isinstance(reasons, list) and reasons: lines.append(f"Рекомендация опирается на {len(reasons)} обоснований (§16).") sections_used.append("product_tz") confidence = report.get("confidence") if isinstance(confidence, dict): rationale = confidence.get("rationale") if rationale: lines.append(f"Обоснование уверенности: {rationale}") sections_used.append("confidence") if not lines or all(ln.startswith("Данных по") for ln in lines): lines.append("Детали обоснования прогноза в отчёте пока не заполнены.") return _assemble(lines), sections_used def _render_risks(report: dict[str, Any]) -> tuple[str, list[str]]: """risks → scoring.special_indices (§25, вкл. каннибализацию) + confidence (уровень + тянущие факторы).""" sections_used: list[str] = [] lines: list[str] = [] scoring = report.get("scoring") special = scoring.get("special_indices") if isinstance(scoring, dict) else None if isinstance(special, dict): indices = special.get("indices") if isinstance(indices, dict) and indices: sections_used.append("scoring") parts = [] for key, idx in indices.items(): if not isinstance(idx, dict): continue name = _INDEX_RU.get(key, key) value = idx.get("value") label = idx.get("label") if value is None and not label: continue # индекс не посчитан (тонкие данные) — пропускаем честно shown = label if label else _fmt_number(value) if shown is not None: parts.append(f"{name}: {shown}") if parts: lines.append("Специальные индексы §25 — " + "; ".join(parts) + ".") if not lines: lines.append("Специальные индексы §25 в отчёте не посчитаны (тонкие данные).") else: lines.append(_NO_SECTION_TMPL.format(name="специальные индексы §25")) confidence = report.get("confidence") if isinstance(confidence, dict): level = _confidence_ru(confidence.get("level")) if level: lines.append(f"Уверенность отчёта: {level}.") sections_used.append("confidence") factors = confidence.get("factors") if isinstance(factors, dict) and factors: lines.append(f"Факторы, влияющие на уверенность: {len(factors)}.") if "confidence" not in sections_used: sections_used.append("confidence") return _assemble(lines), sections_used def _render_scenarios(report: dict[str, Any]) -> tuple[str, list[str]]: """scenarios → scenarios (by_scenario + summary).""" section = report.get("scenarios") if not isinstance(section, dict): return _assemble([_NO_SECTION_TMPL.format(name="сценарии")]), [] lines: list[str] = [] by_scenario = section.get("by_scenario") if isinstance(by_scenario, dict) and by_scenario: names: list[str] = [_SCENARIO_RU.get(str(k), str(k)) for k in by_scenario] lines.append("Проработаны сценарии: " + ", ".join(names) + ".") else: lines.append("Разбивка по сценариям в отчёте не заполнена.") summary = section.get("summary") if summary: lines.append(str(summary)) return _assemble(lines), ["scenarios"] def _render_unknown(report: dict[str, Any]) -> tuple[str, list[str]]: """unknown → graceful RU-меню поддерживаемых типов вопросов (секции не трогаем).""" menu = _assemble( [ "Я отвечаю по форсайт-отчёту участка. Могу помочь с такими вопросами:", "• что здесь строить и какой продукт рекомендуется;", "• почему такой прогноз и на чём он основан;", "• какие риски и специальные индексы (включая каннибализацию);", "• как различаются сценарии (консервативный / базовый / агрессивный);", "• краткое резюме и общий вердикт по участку.", ] ) return menu, [] # Диспетч intent → рендерер. unknown не зависит от отчёта. _RENDERERS = { ChatIntent.summary: _render_summary, ChatIntent.what_to_build: _render_what_to_build, ChatIntent.why_forecast: _render_why_forecast, ChatIntent.risks: _render_risks, ChatIntent.scenarios: _render_scenarios, ChatIntent.unknown: _render_unknown, } def render_answer(intent: ChatIntent, report: dict[str, Any]) -> tuple[str, list[str]]: """Собрать RU-ответ под intent из секции(й) отчёта → (answer_text, sections_used). Числа берутся ВЕРБАТИМ из `report` (никогда не фабрикуются). ROBUST к частичному отчёту: отсутствует секция → честное «данных по X нет», без KeyError. Ответ всегда заканчивается advisory-оговоркой (см. _assemble). Неизвестный intent → меню тем. """ renderer = _RENDERERS.get(intent, _render_unknown) return renderer(report)