gendesign/backend/app/services/chat/intents.py
Light1YT 4847a2eb01
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / backend-tests (pull_request) Successful in 6m23s
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m25s
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m43s
Deploy / deploy (push) Successful in 1m38s
Deploy / build-worker (push) Successful in 7m39s
feat(chat): deterministic §22 chat foundation — POST /api/v1/chat/ask (no LLM)
Step 1 of #957. Answers parcel questions by reading the already-persisted
§22 SiteFinderReport (latest_run_for, schema "1.0", read-only) and returning
templated RU answers with engine numbers verbatim (§16, never fabricated).
Intent routing (explicit or RU keyword match) -> per-section renderers;
graceful on partial/missing sections and pending (no run / DB error) without
500s. Works with llm_enabled=False (llm_used always False); LLM composition
is Step 2. Mounted off /api/v1/chat so rbac_guard auto-requires an authed
known user.

Refs #957
2026-06-08 17:21:51 +05:00

398 lines
19 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Роутинг 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 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 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 len(sections_used) == 1 and 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):
sections_used.append("future_market")
horizons = future.get("forecasts_by_horizon")
if isinstance(horizons, list) and horizons:
lines.append(f"Прогноз построен по {len(horizons)} горизонтам спроса/предложения.")
future_supply = future.get("future_supply")
if isinstance(future_supply, dict) and future_supply:
lines.append("Учтено давление будущего предложения (выходящие проекты).")
summary = future.get("summary")
if summary:
lines.append(str(summary))
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)