"""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=2, template=( "Ты — ассистент по инвестиционному форсайт-отчёту земельного участка (РФ). " "Отвечай на русском языке, по-деловому, нейтрально, без маркетинга и без emoji.\n\n" "ЖЁСТКИЕ ПРАВИЛА:\n" "1. Отвечай ТОЛЬКО на основе данных, полученных через инструменты (секции отчёта). " "Чтобы получить нужные числа, вызови подходящий инструмент.\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"]