"""Pydantic v2 контракты чата по §22-форсайту участка (#957, Step 1 — БЕЗ LLM). ДЕТЕРМИНИРОВАННЫЙ слой: эндпоинт `POST /api/v1/chat/ask` читает УЖЕ-ПЕРСИСТЕНТНЫЙ SiteFinderReport участка (`as_dict()`, 8 секций) и отдаёт шаблонный RU-ответ. LLM- композиция — Step 2 (#957), поэтому `llm_used` ВСЕГДА False, а `history` принимается, но в Step 1 НЕ используется (форвардится в LLM только на Step 2; длину кэпируем здесь, чтобы контракт был стабилен и не раздувался). advisory: зеркалит `report.advisory` — отчёт советующий, не основание для инвест-решения, поэтому каждый ответ несёт advisory-оговорку (см. services/chat/intents.py). """ from __future__ import annotations from enum import StrEnum from typing import Literal from pydantic import BaseModel, ConfigDict, Field, field_validator # История диалога принимается, но в Step 1 НЕ используется (LLM-контекст — Step 2). # Кэпируем длину, чтобы payload не раздувался до подключения LLM. _HISTORY_MAX_TURNS = 20 class ChatIntent(StrEnum): """Поддерживаемые типы вопросов по отчёту (детерминированный роутинг §22-чата). StrEnum → значение члена и есть строка (сериализуется как plain-строка в JSON- ответе, сравнимо со строкой). `unknown` — graceful-фолбэк: вопрос не распознан, отдаём меню поддерживаемых тем. """ summary = "summary" # что строить здесь / общий вердикт (exec_summary) what_to_build = "what_to_build" # рекомендация продукта (product_tz) why_forecast = "why_forecast" # почему такой прогноз (future_market + reasons) risks = "risks" # риски / спец-индексы §25 + уверенность scenarios = "scenarios" # разброс сценариев (conservative/base/aggressive) unknown = "unknown" # вопрос не распознан → меню тем class ChatTurn(BaseModel): """Один ход диалога (роль + текст). Принимается, но в Step 1 НЕ используется. Форвардится в LLM-контекст только на Step 2 (#957). frozen — это входной факт. """ model_config = ConfigDict(frozen=True) role: str = Field(description="Роль хода: 'user' | 'assistant'") content: str = Field(description="Текст хода диалога") class GroundedIn(BaseModel): """Привязка ответа к источнику — какой ран/схема/секции легли в основу ответа. Делает ответ explainable: фронт может показать «основано на ране N, секции X, Y». None в ответе, когда отчёта ещё нет (report_status='pending'). """ model_config = ConfigDict(frozen=True) run_id: int = Field(description="ID персистентного analysis_run, давшего отчёт") schema_version: str = Field(description="Версия схемы отчёта (контракт as_dict)") sections: list[str] = Field( default_factory=list, description="Секции отчёта, использованные для ответа (explainability)", ) class ChatAskRequest(BaseModel): """Запрос вопроса по §22-форсайту участка. `cad_num` + `message` обязательны. `intent` — опциональный явный intent (если задан, роутер его уважает и не угадывает по тексту). `history` — опциональная история диалога: принимается для совместимости контракта, но в Step 1 НЕ используется (LLM-контекст — Step 2); длину кэпируем (последние _HISTORY_MAX_TURNS ходов). """ cad_num: str = Field(min_length=1, description="Кадастровый номер участка") message: str = Field(min_length=1, description="Вопрос пользователя (RU, free-form)") intent: ChatIntent | None = Field( default=None, description="Явный intent — если задан, роутер его уважает (не угадывает по тексту)", ) history: list[ChatTurn] | None = Field( default=None, description="История диалога (Step 1: принимается, НЕ используется; LLM-контекст — Step 2)", ) @field_validator("history") @classmethod def _cap_history(cls, v: list[ChatTurn] | None) -> list[ChatTurn] | None: """Graceful-усечение: кэпируем хвост до последних _HISTORY_MAX_TURNS ходов. НЕ отклоняем длинный диалог 422 (контракт обещает усечение, не отказ) — оставляем самые свежие ходы, отбрасывая старые с головы. """ if v is not None and len(v) > _HISTORY_MAX_TURNS: return v[-_HISTORY_MAX_TURNS:] return v class ChatAskResponse(BaseModel): """Ответ чата по отчёту участка (детерминированный, шаблонный RU-текст). `answer` — готовый RU-ответ с числами ВЕРБАТИМ из отчёта (никогда не фабрикуем) + advisory-оговорка. `grounded_in` — привязка к рану/секциям (None если pending). `llm_used` ВСЕГДА False в Step 1. `fallback_reason` — почему ответ деградировал (отчёта нет / intent не распознан / секция отсутствует). `advisory` зеркалит report.advisory (пока всегда True). `report_status` — 'ready' (отчёт прочитан) или 'pending' (отчёта ещё нет — запустите анализ участка). """ answer: str = Field(description="Готовый RU-ответ (числа из отчёта + advisory-оговорка)") grounded_in: GroundedIn | None = Field( default=None, description="Привязка к рану/схеме/секциям (None если отчёта ещё нет)", ) llm_used: bool = Field( default=False, description="Использовался ли LLM (Step 1 — ВСЕГДА False; LLM — Step 2)", ) fallback_reason: str | None = Field( default=None, description="Почему ответ деградировал (нет отчёта / intent unknown / нет секции)", ) advisory: bool = Field( default=True, description="Отчёт советующий (зеркало report.advisory — пока всегда True)", ) report_status: Literal["ready", "pending"] = Field( description="Статус отчёта: 'ready' (прочитан) | 'pending' (ещё нет)", )