Перейти к содержанию

Шаблоны и версии промптов

Шаг 1

В прошлом разделе мы сформулировали developer-инструкцию AI Support Desk и проверили ее на разных обращениях. Сейчас она хранится в app/support_service.py как обычная строковая константа:

DEVELOPER_INSTRUCTION = """
...
""".strip()

Для одной неизменяемой инструкции этого достаточно, и не каждую строку с промптом нужно сразу превращать в отдельный класс или подключать к внешней системе.

Представим, что приложение должно формировать резюме на выбранном языке, например, после добавления мультиязычности, а в некоторых окружениях разработчик включает дополнительное правило. Если собирать инструкцию через конкатенацию строк в SupportService, вскоре станет трудно понять:

  • какие переменные обязательны
  • какие можно не передавать
  • что произойдет при опечатке в названии переменной
  • какая версия инструкции участвовала в запросе
  • где заканчивается шаблон и начинается пользовательский текст

Для решения этой задачи выполним еще один рефакторинг кода и создадим небольшой типизированный PromptTemplate. Он будет хранить текст промпта, его идентификатор и версию (об этом подробно поговорим в следующем шаге), проверять переменные и возвращать готовую строку.

Что такое шаблон промпта?

Шаблон промпта — это текст с заранее определенными местами для подстановки значений. Например:

Отвечай на языке: $response_language.

$response_language является переменной шаблона. При сборке запроса приложение заменит ее значением:

Отвечай на языке: русском.

Сам шаблон принадлежит разработчику и хранится вместе с кодом. Значения его переменных также должны поступать из контролируемой части приложения: конфигурации, аргументов сервиса или другого проверенного источника.

Обращение клиента не нужно вставлять внутрь developer-инструкции. Оно по-прежнему передается отдельным сообщением user и отделяется тегами customer_request. Шаблон помогает собирать постоянные правила, а не смешивает правила с недоверенными данными. В нашем случае недоверенные данные — это текст, который приходит от пользователя или из внешнего источника и может содержать ошибки, неожиданный текст или попытку повлиять на поведение модели.

Переменные шаблона мы разделим на две группы:

Тип переменной Поведение Пример
Обязательная Без значения промпт нельзя собрать response_language
Необязательная При отсутствии заменяется пустой строкой additional_rules

Необязательная переменная должна находиться в таком месте шаблона, где пустая строка не разрушит смысл инструкции. Например, отдельная строка с дополнительными правилами может отсутствовать. А язык ответа в нашем шаблоне обязателен: молча оставлять его пустым нельзя.

В каталоге app создайте пакет prompts и пустой файл __init__.py:

ai-support-desk/
├── app/
│   ├── prompts/
│   │   ├── __init__.py
│   │   ├── template.py
│   │   └── support_summary.py
│   ├── support_service.py
│   └── ...
└── main.py

template.py будет содержать универсальный механизм подстановки. В support_summary.py поместим конкретный промпт нашей AI-функции. Такое разделение не смешивает общий инструмент с правилами службы поддержки.

Создайте app/prompts/template.py:

from dataclasses import dataclass
from string import Template

class PromptTemplateError(ValueError):
    pass

@dataclass(frozen=True)
class PromptTemplate:
    prompt_id: str
    version: str
    body: str
    required_variables: frozenset[str]
    optional_variables: frozenset[str] = frozenset()

    def __post_init__(self) -> None:
        if not self.prompt_id.strip():
            raise PromptTemplateError("Идентификатор промпта не должен быть пустым.")
        if not self.version.strip():
            raise PromptTemplateError("Версия промпта не должна быть пустой.")

        duplicated = self.required_variables & self.optional_variables
        if duplicated:
            names = ", ".join(sorted(duplicated))
            raise PromptTemplateError(
                f"Переменные одновременно обязательные "
                f"и необязательные: {names}."
            )

        template = Template(self.body)
        if not template.is_valid():
            raise PromptTemplateError("Текст содержит некорректную переменную шаблона.")

        placeholders = frozenset(template.get_identifiers())
        declared = self.required_variables | self.optional_variables
        if placeholders != declared:
            expected = ", ".join(sorted(declared)) or "нет"
            actual = ", ".join(sorted(placeholders)) or "нет"
            raise PromptTemplateError(
                "Переменные body не совпадают с объявленными. "
                f"Объявлены: {expected}. Найдены: {actual}."
            )

    def render(self, **variables: str) -> str:
        provided = frozenset(variables)
        missing = self.required_variables - provided
        if missing:
            names = ", ".join(sorted(missing))
            raise PromptTemplateError(f"Не переданы обязательные переменные: {names}.")

        allowed = self.required_variables | self.optional_variables
        unexpected = provided - allowed
        if unexpected:
            names = ", ".join(sorted(unexpected))
            raise PromptTemplateError(f"Переданы неизвестные переменные: {names}.")

        invalid = sorted(
            name
            for name, value in variables.items()
            if not isinstance(value, str)
        )
        if invalid:
            names = ", ".join(invalid)
            raise PromptTemplateError(f"Значения должны быть строками: {names}.")

        values = {name: "" for name in self.optional_variables}
        values.update(variables)

        return Template(self.body).substitute(values).strip()

Класс является неизменяемым благодаря frozen=True. После создания нельзя случайно заменить его идентификатор, версию или текст.

Метод __post_init__() выполняется сразу после создания объекта. Он проверяет:

  • непустые идентификатор и версию
  • отсутствие переменной сразу в двух группах
  • корректный синтаксис шаблона
  • совпадение переменных в тексте с объявленными переменными

Метод render() проверяет конкретный набор значений. Обязательные переменные должны присутствовать, неизвестные запрещены, а необязательные при отсутствии получают пустую строку.

При возврате значения мы используем встроенный метод Template.substitute(). В Python есть еще метод Template.safe_substitute(), и его название может показаться надежнее, но он оставляет неизвестные переменные в итоговой строке. А для нашего приложения полезнее сразу получить понятную ошибку, чем отправить модели незаполненный $response_language.

Класс Template из стандартного модуля string использует переменные вида $name:

from string import Template

template = Template(body)
rendered = template.substitute(
    response_language="русском",
    additional_rules="",
)

substitute() разбирает переменные, находящиеся в body, и вставляет переданные значения. Если нужного значения нет, метод завершится с ошибкой, а не отправит модели незаполненный шаблон.

Вставленное значение не обрабатывается как второй шаблон. Например:

template = Template("Правило: $additional_rules")
rendered = template.substitute(additional_rules="Цена должна оставаться $100.")

В результате символ $ перед числом останется обычной частью значения:

Правило: Цена должна оставаться $100.

Template ищет переменные только в исходном body. Он не берет уже вставленную строку и не запускает подстановку внутри нее повторно.

Теперь создайте app/prompts/support_summary.py и перенесите промпт резюмирования:

from app.prompts.template import PromptTemplate

SUPPORT_SUMMARY_PROMPT = PromptTemplate(
    prompt_id="support_summary",
    version="1.0.0",
    body="""
Ты помогаешь оператору службы поддержки кратко пересказывать
обращения клиентов.

Язык ответа: $response_language.

Цель:
Сформулируй основную проблему клиента одним предложением.

Правила:
- Используй только факты, явно указанные в обращении.
- Если они указаны, сохраняй номер заказа, дату и название товара.
- Не добавляй советы, решения, обещания и оценки.
- Если деталь отсутствует или неясна, опусти ее.
$additional_rules

Порядок работы:
1. Определи основную проблему клиента.
2. Выбери только факты, необходимые для понимания проблемы.
3. Удали приветствия, повторы и второстепенные подробности.
4. Сформулируй итоговый ответ в требуемом формате.

Формат ответа:
Одно самостоятельное предложение без заголовка, списка и вступления.
""".strip(),
    required_variables=frozenset({"response_language"}),
    optional_variables=frozenset({"additional_rules"}),
)

prompt_id является стабильным именем сценария, а version обозначает конкретный вариант его поведения. Подробно разберем версию в следующем шаге. Обратите внимание: в тексте есть обязательная переменная $response_language и необязательная $additional_rules. Обе перечислены в метаданных объекта, поэтому опечатка обнаружится при запуске приложения.

В заключение шага подключим шаблон к SupportService. В app/support_service.py добавьте импорт:

from app.prompts.support_summary import SUPPORT_SUMMARY_PROMPT

Удалите строковую константу DEVELOPER_INSTRUCTION и вместо нее соберите инструкцию из шаблона:

DEVELOPER_INSTRUCTION = SUPPORT_SUMMARY_PROMPT.render(response_language="русском")

Необязательную переменную additional_rules мы не передаем, поэтому она заменится пустой строкой. Если позднее появится контролируемое правило для конкретного окружения, его можно передать явно:

DEVELOPER_INSTRUCTION = SUPPORT_SUMMARY_PROMPT.render(
    response_language="русском",
    additional_rules=(
        "- Не раскрывай внутренние названия подразделений."
    ),
)

Сейчас дополнительного требования у проекта нет, поэтому оставьте первый вариант. Не добавляйте переменную только ради того, чтобы заполнить каждое доступное место.

И теперь шаблон собирает developer-инструкцию только из контролируемых значений, а обращение добавляется отдельным сообщением user.

Проверим результат работы. Запустите проект и передайте уже знакомое обращение:

Заказ 991 с клавиатурой привезли 20 июля, но в коробке нет кабеля.

Поведение функции не должно заметно измениться. Мы не поменяли назначение промпта, а только вынесли его в отдельный объект и явно передали язык ответа.

Отдельно вызывать каждую ошибку сейчас не будем. Важно зафиксировать контракт:

  • без response_language метод render() завершится ошибкой
  • переменная language вместо response_language будет неизвестной
  • additional_rules можно не передавать
  • обращение клиента не является переменной этого шаблона

Теперь текст промпта отделен от сервиса, а его сборка стала проверяемой. В следующем шаге мы свяжем идентификатор и версию промпта с результатом запроса, разберем хранение промптов в проекте и определим, когда обычных файлов уже недостаточно.

Шаг 2

В прошлом шаге мы создали SUPPORT_SUMMARY_PROMPT с идентификатором support_summary и версией 1.0.0. Пока эти метаданные находятся только внутри объекта промпта, а сервис использует его текст для сборки developer-инструкции. Если заменить текст в support_summary.py и написать version="1.1.0", старая инструкция исчезнет из рабочего проекта. В сохраненных метриках останется название 1.0.0, но получить соответствующий ему текст уже не получится.

В этом шаге мы исправим это и сохраним каждый выпущенный вариант промпта отдельно, создадим реестр и научимся явно выбирать активную или старую версию.

Для чего вообще хранить версии промпта?

Промпт является частью поведения AI-функции. Один и тот же пользовательский текст может получить другой ответ после изменения:

  • developer-инструкции
  • модели
  • параметров генерации
  • переданного контекста

Если мы сохраняем название модели, но не сохраняем точный вариант промпта, воспроизвести старый запрос полностью нельзя.

Ситуация Зачем нужна сохраненная версия
После обновления ответы стали хуже Запустить прежний и новый промпт на одинаковых обращениях
В рабочем запросе найден неправильный ответ По prompt_version получить точный текст использованной инструкции
Новая версия не прошла проверочные сценарии Вернуть предыдущий вариант без восстановления текста вручную
Результаты двух проверок отличаются Убедиться, что сравнивались одинаковые или разные версии промпта
Нужно объяснить поведение старого запуска Увидеть действовавшие тогда правила, а не только текущую инструкцию

Версионирование не делает ответы модели одинаковыми и не гарантирует качество. Оно делает изменения наблюдаемыми и воспроизводимыми, ведь мы можем назвать вариант, получить его текст и снова проверить.

Для нашего проекта версия состоит из трех связанных частей:

prompt_id + version + body
  • prompt_id отвечает на вопрос «какая это AI-функция?»
  • version отвечает на вопрос «какой вариант этой функции?»
  • body хранит точные правила и переменные выбранного варианта

Для support_summary может существовать несколько версий:

support_summary@1.0.0
support_summary@1.1.0

У обеих одинаковое назначение — резюмировать обращение. Но текст правил отличается. Если назначение изменится полностью, например, вместо резюме мы начнем писать ответ клиенту, это будет уже другой prompt_id, а не очередная версия support_summary. После выпуска файл версии будем считать неизменяемым. Чтобы изменить поведение, создаем новый файл с новым номером, а старый оставляем доступным для повторной проверки.

Возникает практический вопрос: где хранить версии? Основные варианты выглядят так:

Способ Когда подходит Особенности
Отдельные файлы в проекте Промптов немного, их выпускают вместе с приложением Просто читать, импортировать, проверять и возвращать старую версию
База данных или объектное хранилище Промпты обновляются независимо от приложения Нужны схема данных, права доступа, кэш и обработка недоступности
Prompt сервис Много команд, экспериментов, согласований и вариантов Дает интерфейс управления, но добавляет внешнюю зависимость

Для нашего проекта AI Support Desk выберем отдельные Python-файлы. У нас один промпт резюмирования и две версии, поэтому внешнее хранилище пока не дает практической пользы.

В прошлом шаге наш промпт находился в одном файле:

app/prompts/support_summary.py

Теперь удалите этот файл и вместо него создайте пакет app/prompts/support_summary:

app/
└── prompts/
    ├── __init__.py
    ├── template.py
    └── support_summary/
        ├── __init__.py
        ├── registry.py
        ├── v1_0_0.py
        └── v1_1_0.py

Название файла повторяет номер версии, только точки заменены символами подчеркивания. Каждый файл содержит один законченный PromptTemplate. А уже registry.py будет связывать номера версий с объектами и определять активный вариант.

Создайте app/prompts/support_summary/v1_0_0.py. Перенесите туда первый вариант промпта, изменив только имя переменной:

from app.prompts.template import PromptTemplate

SUPPORT_SUMMARY_PROMPT_V1_0_0 = PromptTemplate(
    prompt_id="support_summary",
    version="1.0.0",
    body="""
Ты помогаешь оператору службы поддержки кратко пересказывать
обращения клиентов.

Язык ответа: $response_language.

Цель:
Сформулируй основную проблему клиента одним предложением.

Правила:
- Используй только факты, явно указанные в обращении.
- Если они указаны, сохраняй номер заказа, дату и название товара.
- Не добавляй советы, решения, обещания и оценки.
- Если деталь отсутствует или неясна, опусти ее.
$additional_rules

Порядок работы:
1. Определи основную проблему клиента.
2. Выбери только факты, необходимые для понимания проблемы.
3. Удали приветствия, повторы и второстепенные подробности.
4. Сформулируй итоговый ответ в требуемом формате.

Формат ответа:
Одно самостоятельное предложение без заголовка, списка и вступления.
""".strip(),
    required_variables=frozenset({"response_language"}),
    optional_variables=frozenset({"additional_rules"}),
)

Этот файл является снимком первой версии. Если позднее обнаружится, что правило сформулировано неудачно, не исправляйте v1_0_0.py, а лучше создайте следующую версию. Иначе старые результаты с отметкой 1.0.0 начнут ссылаться уже на другой текст.

Во время проверки инструкции мы обнаружили пограничный случай: если обращение содержит две равнозначные проблемы, модель может сохранить только одну из них.

Давайте добавим явное правило и выпустим новый вариант промпта.

Создайте app/prompts/support_summary/v1_1_0.py:

from app.prompts.template import PromptTemplate

SUPPORT_SUMMARY_PROMPT_V1_1_0 = PromptTemplate(
    prompt_id="support_summary",
    version="1.1.0",
    body="""
Ты помогаешь оператору службы поддержки кратко пересказывать
обращения клиентов.

Язык ответа: $response_language.

Цель:
Сформулируй основную проблему клиента одним предложением.

Правила:
- Используй только факты, явно указанные в обращении.
- Если они указаны, сохраняй номер заказа, дату и название товара.
- Если проблем несколько и они равнозначны, сохрани каждую.
- Не добавляй советы, решения, обещания и оценки.
- Если деталь отсутствует или неясна, опусти ее.
$additional_rules

Порядок работы:
1. Определи основную проблему клиента.
2. Выбери только факты, необходимые для понимания проблемы.
3. Удали приветствия, повторы и второстепенные подробности.
4. Сформулируй итоговый ответ в требуемом формате.

Формат ответа:
Одно самостоятельное предложение без заголовка, списка и вступления.
""".strip(),
    required_variables=frozenset({"response_language"}),
    optional_variables=frozenset({"additional_rules"}),
)

Между промптами есть одно содержательное отличие:

- Если проблем несколько и они равнозначны, сохрани каждую.

Инструкция продублирована намеренно: каждый файл версии должен содержать полный текст и не зависеть от других файлов. Благодаря этому версию 1.0.0 можно прочитать и использовать отдельно, даже если новые варианты промпта позднее изменятся.

Теперь создадим реестр промптов. Два файла уже хранят старый и новый тексты, а реестр даст приложению единый способ получить нужный вариант по номеру. Создайте app/prompts/support_summary/registry.py:

from app.prompts.template import (
    PromptTemplate,
    PromptTemplateError,
)
from app.prompts.support_summary.v1_0_0 import (
    SUPPORT_SUMMARY_PROMPT_V1_0_0,
)
from app.prompts.support_summary.v1_1_0 import (
    SUPPORT_SUMMARY_PROMPT_V1_1_0,
)

ACTIVE_SUPPORT_SUMMARY_VERSION = "1.1.0"

_SUPPORT_SUMMARY_PROMPTS: dict[str, PromptTemplate] = {
    SUPPORT_SUMMARY_PROMPT_V1_0_0.version: SUPPORT_SUMMARY_PROMPT_V1_0_0,
    SUPPORT_SUMMARY_PROMPT_V1_1_0.version: SUPPORT_SUMMARY_PROMPT_V1_1_0,
}

def get_support_summary_prompt(
    version: str | None = None,
) -> PromptTemplate:
    selected_version = (
        ACTIVE_SUPPORT_SUMMARY_VERSION
        if version is None
        else version.strip()
    )

    try:
        return _SUPPORT_SUMMARY_PROMPTS[selected_version]
    except KeyError as error:
        available = ", ".join(_SUPPORT_SUMMARY_PROMPTS)
        raise PromptTemplateError(
            f"Неизвестная версия support_summary: "
            f"{selected_version}. "
            f"Доступны: {available}."
        ) from error

def list_support_summary_versions() -> tuple[str, ...]:
    return tuple(_SUPPORT_SUMMARY_PROMPTS)

Реестр выполняет три задачи:

  1. хранит соответствие «номер версии → объект промпта»
  2. обозначает активную версию
  3. не позволяет молча выбрать несуществующий вариант

Если вызвать get_support_summary_prompt() без аргумента, функция вернет последнюю активную версию 1.1.0. Если передать "1.0.0", она вернет сохраненный промпт этой версии. Неизвестный номер приведет к понятной PromptTemplateError.

Теперь настроим публичный импорт пакета, для этого в app/prompts/support_summary/__init__.py добавьте:

from app.prompts.support_summary.registry import (
    ACTIVE_SUPPORT_SUMMARY_VERSION,
    get_support_summary_prompt,
    list_support_summary_versions,
)

SUPPORT_SUMMARY_PROMPT = get_support_summary_prompt()

__all__ = [
    "ACTIVE_SUPPORT_SUMMARY_VERSION",
    "SUPPORT_SUMMARY_PROMPT",
    "get_support_summary_prompt",
    "list_support_summary_versions",
]

Строка:

SUPPORT_SUMMARY_PROMPT = get_support_summary_prompt()

выбирает активный вариант при запуске приложения. Благодаря публичному имени прежний импорт в app/support_service.py не меняется:

from app.prompts.support_summary import SUPPORT_SUMMARY_PROMPT

Сервис продолжает собирать инструкцию через:

DEVELOPER_INSTRUCTION = SUPPORT_SUMMARY_PROMPT.render(response_language="русском")

Но теперь SUPPORT_SUMMARY_PROMPT это не единственный навсегда перезаписываемый объект, а выбранная запись из реестра сохраненных версий.

Теперь давайте добавим версию в результат сервиса, для этого в app/support_service.py изменим:

@dataclass(frozen=True)
class SupportSummary:
    text: str
    model: str
    prompt_id: str
    prompt_version: str
    elapsed_seconds: float
    prompt_tokens: int | None
    completion_tokens: int | None
    total_tokens: int | None
    response_id: str

При создании результата добавьте значения из фактически выбранного объекта промпта:

return SupportSummary(
    text=summary,
    model=llm_result.model,
    prompt_id=SUPPORT_SUMMARY_PROMPT.prompt_id,
    prompt_version=SUPPORT_SUMMARY_PROMPT.version,
    elapsed_seconds=elapsed_seconds,
    prompt_tokens=llm_result.prompt_tokens,
    completion_tokens=llm_result.completion_tokens,
    total_tokens=llm_result.total_tokens,
    response_id=llm_result.response_id,
)

Теперь откроем app/console.py, и добавим новый вывод перед выводом модели ответа, чтобы видеть, какая версия промпта использовалась:

print(f"Промпт: {result.prompt_id}@{result.prompt_version}")

Запустите проект. Сейчас активная версия выбирается одной константой в registry.py:

ACTIVE_SUPPORT_SUMMARY_VERSION = "1.1.0"

Поэтому в выводе появится:

То есть сейчас выбор выполняется одной константой в registry.py. Чтобы временно вернуть старый вариант, можно выбрать:

ACTIVE_SUPPORT_SUMMARY_VERSION = "1.0.0"

После перезапуска приложения SUPPORT_SUMMARY_PROMPT будет указывать на сохраненный объект 1.0.0, а метрики снова покажут эту версию.

В заключение раздела зафиксируем правила:

  1. Не изменять файл версии, которая уже использовалась в запросах.
  2. Для содержательного изменения создавать новый файл и новый номер.
  3. Добавлять новую версию в реестр до выбора ее активной.
  4. Проверять новый вариант на повторяемом наборе обращений.
  5. Сохранять старые версии, пока на них ссылаются результаты, тесты или отчеты.
  6. Не хранить в файлах промптов секреты и пользовательские обращения.

Не обязательно хранить все экспериментальные черновики бесконечно. Версии, которые никогда не выпускались и не участвовали в сохраненных проверках, можно удалять. Но выпущенный вариант нельзя незаметно заменить другим текстом под тем же номером.

Файлы и реестр хорошо работают, пока:

  • промптов и версий немного
  • их меняют разработчики
  • новая активная версия выпускается вместе с приложением
  • не нужны одновременные варианты для разных групп пользователей
  • не требуется редактирование через отдельный интерфейс

Внешнее хранилище или промпт сервис становятся полезными, когда нужно менять активную версию без выпуска приложения, проводить A/B-тесты, согласовывать тексты несколькими командами или централизованно управлять большим количеством AI-функций. При переходе во внешнее хранилище основные понятия не меняются. Все равно нужны prompt_id, неизменяемая version, реестр доступных вариантов и запись выбранной версии рядом с результатом запроса.

Мы завершили тему управления промптами, и до этого момента мы рассматривали независимые запросы, где каждый вызов модели выполняется сам по себе. В следующем разделе мы перейдем к следующему этапу — работе с диалогами. Разберем состояние диалога, Session ID, хранение истории и изоляцию сообщений разных пользователей.

Шаг 3

Зачем выносить изменяемые части инструкции в переменные шаблона?

Правильный ответ
  • ✅ Чтобы один проверенный текст можно было безопасно использовать с разными значениями

Шаг 4

Какие проверки нужны объекту PromptTemplate перед рендерингом?

Правильный ответ
  • ✅ При рендеринге не переданы неизвестные переменные
  • ✅ Все переменные из текста объявлены в контракте шаблона
  • ✅ Обязательные и необязательные переменные не пересекаются
  • ✅ При рендеринге переданы все обязательные переменные

Шаг 5

В значение переменной additional_rules передали строку «Цена должна оставаться $100». Что произойдёт при Template.substitute()?

Правильный ответ
  • ✅ Строка подставится целиком, а знак $ внутри значения не будет обработан повторно

Шаг 6

Зачем хранить выпущенные версии промпта и записывать prompt_id@version в результат?

Правильный ответ
  • ✅ Чтобы быстро вернуть прежнюю активную версию
  • ✅ Чтобы связать сохранённые метрики с точным текстом инструкции
  • ✅ Чтобы сравнивать новую и предыдущую версии на одинаковых сценариях
  • ✅ Чтобы воспроизвести инструкцию, использованную в старом запуске

Шаг 7

Рендеринг шаблона инструкции

Текст промпта уже подготовлен в константе PROMPT_BODY. Подставьте язык ответа и необязательное дополнительное правило.

Задача

Реализуйте render_support_prompt(response_language, additional_rules="").

Требования

  • response_language и additional_rules должны быть строками. Для другого типа выбросьте TypeError.
  • Очистите оба значения через strip().
  • Пустой response_language должен приводить к ValueError.
  • additional_rules может быть пустым.
  • Создайте Template(PROMPT_BODY) и вызовите substitute() с обеими переменными.
  • Верните итоговую инструкцию после strip().
  • Не изменяйте PROMPT_BODY.

Пример результата

render_support_prompt(" русском ")
# "Язык ответа: русском.\n\nПравила:"

render_support_prompt("русском", "- Не раскрывай внутренние названия.")
# дополнительное правило появится после строки "Правила:"

Импорт Template, константа PROMPT_BODY и заготовка функции уже находятся в редакторе. Не изменяйте значение PROMPT_BODY.

Правильный ответ
from string import Template

PROMPT_BODY = (
    "\nЯзык ответа: $response_language.\n\n"
    "Правила:\n"
    "$additional_rules\n"
)

def render_support_prompt(
    response_language: str,
    additional_rules: str = "",
) -> str:
    if not isinstance(response_language, str) or not isinstance(additional_rules, str):
        raise TypeError

    response_language = response_language.strip()
    additional_rules = additional_rules.strip()

    if not response_language:
        raise ValueError

    template = Template(PROMPT_BODY)

    return template.substitute(
        response_language=response_language,
        additional_rules=additional_rules,
    ).strip()

Шаг 8

Проверка контракта переменных шаблона

Перед использованием промпта нужно убедиться, что переменные в его тексте совпадают с объявленным контрактом. В этой задаче вы отработаете предсказуемую работу с шаблонами и версиями промптов.

Задача

Реализуйте validate_template_variables(body, required, optional). Функция должна проверить шаблон и вернуть найденные имена переменных.

Требования

  • body должен быть строкой. Для другого типа выбросьте TypeError.
  • required и optional — множества имён переменных.
  • Если одно имя находится в обоих множествах, выбросьте PromptTemplateError.
  • Создайте Template(body). Если is_valid() возвращает False, выбросьте PromptTemplateError.
  • Получите переменные через get_identifiers().
  • Найденные имена должны точно совпадать с объединением required | optional. Иначе выбросьте PromptTemplateError.
  • Верните найденные имена как frozenset[str].

Пример результата

validate_template_variables(
    "Язык: $language.\n$rules",
    {"language"},
    {"rules"},
)
# frozenset({"language", "rules"})

validate_template_variables("Язык: $language", set(), set())
# PromptTemplateError

Импорт Template и класс PromptTemplateError уже находятся в заготовке. Не удаляйте и не переименовывайте этот класс.

Правильный ответ
from string import Template

class PromptTemplateError(ValueError):
    pass

def validate_template_variables(
    body: str,
    required: set[str],
    optional: set[str],
) -> frozenset[str]:
    if not isinstance(body, str):
        raise TypeError

    if required & optional:
        raise PromptTemplateError

    template = Template(body)

    if not template.is_valid():
        raise PromptTemplateError

    identifiers = frozenset(template.get_identifiers())

    if identifiers != required | optional:
        raise PromptTemplateError

    return identifiers

Шаг 9

Выбор версии промпта из реестра

Реестр хранит выпущенные версии промпта. Если версия не указана, приложение использует активную.

Задача

Реализуйте select_prompt(registry, active_version, requested_version=None).

Требования

  • Если requested_version is None, выберите active_version.
  • Если requested_version передан явно, он должен быть строкой. Для другого типа выбросьте TypeError.
  • Удалите внешние пробелы из явно переданной версии. Для пустого результата выбросьте PromptVersionError.
  • Верните объект из registry с выбранным ключом.
  • Если выбранной версии нет в registry, выбросьте PromptVersionError.
  • Не изменяйте реестр.

Пример результата

registry = {"1.0.0": old_prompt, "1.1.0": new_prompt}

select_prompt(registry, "1.1.0")
# new_prompt

select_prompt(registry, "1.1.0", " 1.0.0 ")
# old_prompt

Класс PromptVersionError, импорт Any и заготовка функции уже находятся в редакторе. Не удаляйте и не переименовывайте предоставленный класс ошибки.

Правильный ответ
from typing import Any

class PromptVersionError(ValueError):
    pass

def select_prompt(
    registry: dict[str, Any],
    active_version: str,
    requested_version: str | None = None,
) -> Any:
    if requested_version is None:
        version = active_version
    else:
        if not isinstance(requested_version, str):
            raise TypeError

        version = requested_version.strip()

        if not version:
            raise PromptVersionError

    try:
        return registry[version]
    except KeyError:
        raise PromptVersionError