Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
133 lines
7.5 KiB
Python
133 lines
7.5 KiB
Python
"""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' (ещё нет)",
|
||
)
|