108 lines
6.5 KiB
Python
108 lines
6.5 KiB
Python
"""Minimal versioned prompt-template scaffolding (#960).
|
||
|
||
Полная библиотека промптов приедет вместе с консьюмерами (#956 граддок-extraction,
|
||
#957 chat). Здесь — только каркас: версионированные шаблоны + ``render`` с явной
|
||
подстановкой переменных. Шаблоны намеренно НЕ содержат данных — данные вставляются
|
||
вызывающим в ``SafePayload`` (см. redaction), а не зашиваются в prompt-литералы.
|
||
|
||
Версионирование: ключ шаблона включает версию (``name@vN``), чтобы изменение
|
||
формулировки было трассируемым (важно для воспроизводимости LLM-вывода).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
|
||
|
||
class PromptNotFoundError(KeyError):
|
||
"""Запрошен неизвестный template_name."""
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class PromptTemplate:
|
||
"""Версионированный шаблон. ``required_vars`` валидируется в render."""
|
||
|
||
name: str
|
||
version: int
|
||
template: str
|
||
required_vars: tuple[str, ...] = ()
|
||
|
||
@property
|
||
def key(self) -> str:
|
||
return f"{self.name}@v{self.version}"
|
||
|
||
|
||
# Системный шаблон-заглушка. Без данных — только инструкция-роль. Консьюмеры
|
||
# добавят свои предметные шаблоны (extraction-schema / chat-system) здесь же.
|
||
_SYSTEM_BASE = PromptTemplate(
|
||
name="system_base",
|
||
version=1,
|
||
template=(
|
||
"Ты ассистент в системе анализа недвижимости. Отвечай кратко и по делу. "
|
||
"Не выдумывай факты — если данных недостаточно, скажи об этом."
|
||
),
|
||
)
|
||
|
||
# Системный шаблон чата по §22-форсайту (#957, Step 2). Версионируется (@vN) — изменение
|
||
# формулировки трассируемо. БЕЗ данных: данные приходят секционными tool'ами в SafePayload
|
||
# (см. services/chat/{tools,safe_payload}.py), НЕ зашиваются в литерал промпта.
|
||
_CHAT_SYSTEM = PromptTemplate(
|
||
name="chat_system",
|
||
version=3,
|
||
template=(
|
||
"Ты — ассистент по инвестиционному форсайт-отчёту земельного участка (РФ). "
|
||
"Отвечай на русском языке, по-деловому, нейтрально, без маркетинга и без emoji.\n\n"
|
||
"ЖЁСТКИЕ ПРАВИЛА:\n"
|
||
"1. Отвечай ТОЛЬКО на основе данных, полученных через инструменты (секции отчёта). "
|
||
"Чтобы получить нужные числа, вызови подходящий инструмент. Для вопросов про сам "
|
||
"участок — адрес, площадь, категорию земель, ВРИ, территориальную зону ПЗЗ и её "
|
||
"код/название, лимиты застройки, ЗОУИТ-обременения — вызови get_parcel_info.\n"
|
||
"2. НИКОГДА не выдумывай числа, классы, доли или выводы. Если в полученной секции "
|
||
"данных нет (или помечено available=false) — честно скажи, что этих данных в "
|
||
"отчёте нет. Не подставляй правдоподобные значения.\n"
|
||
"3. Все числа в ответе бери ВЕРБАТИМ из секций инструментов, ничего не пересчитывай.\n"
|
||
"4. Тон советующий: отчёт помогает принять решение, но НЕ является основанием для "
|
||
"инвестиционного решения. Не давай гарантий доходности.\n"
|
||
"5. Вопросы, выходящие за рамки данных отчёта и паспорта участка (сравнение с другими "
|
||
"участками, юридические заключения, получение разрешений/согласований) — вежливо "
|
||
"скажи, что это вне области отчёта, и предложи вопросы по участку и его форсайту.\n"
|
||
"6. При перечислении квартирографии / сегментов указывай ТИП понятно "
|
||
"(студия, 1-к, 2-к, 3-к, евро-форматы, 80+ м²) и долю в % если она есть. "
|
||
"НЕ нумеруй порядковыми номерами и НЕ склеивай номер с типом через дефис "
|
||
"(никаких «2-1-к»); перечисляй по одному типу в строке или через запятую.\n"
|
||
"Отвечай кратко и по существу заданного вопроса."
|
||
),
|
||
)
|
||
|
||
_TEMPLATES: dict[str, PromptTemplate] = {
|
||
_SYSTEM_BASE.name: _SYSTEM_BASE,
|
||
_CHAT_SYSTEM.name: _CHAT_SYSTEM,
|
||
}
|
||
|
||
|
||
def get_template(name: str) -> PromptTemplate:
|
||
"""Вернуть шаблон по имени (последняя версия). PromptNotFoundError если нет."""
|
||
tpl = _TEMPLATES.get(name)
|
||
if tpl is None:
|
||
raise PromptNotFoundError(name)
|
||
return tpl
|
||
|
||
|
||
def render(template_name: str, **vars: object) -> str:
|
||
"""Отрендерить шаблон по имени с подстановкой переменных.
|
||
|
||
Использует ``str.format`` (не f-string/concat) — placeholder'ы вида ``{var}``.
|
||
Проверяет, что все ``required_vars`` переданы.
|
||
|
||
Raises:
|
||
PromptNotFoundError: неизвестное имя.
|
||
KeyError: отсутствует обязательная переменная или placeholder без значения.
|
||
"""
|
||
tpl = get_template(template_name)
|
||
missing = [v for v in tpl.required_vars if v not in vars]
|
||
if missing:
|
||
raise KeyError(f"prompt '{tpl.key}' missing required vars: {', '.join(missing)}")
|
||
return tpl.template.format(**vars)
|
||
|
||
|
||
__all__ = ["PromptNotFoundError", "PromptTemplate", "get_template", "render"]
|