Skip to content

Commit cbee91f

Browse files
authored
Feature PromptConfig (#97)
* Base implementation of PromptConfig * Add example files for test * Enhance LLMFunctionTemplated * WIP structured prompt implementation * Code restructuring * WIP * Code clean up with StringConstraints * Updates - move formatter to prompt template instead of object * Add tests for structured prompt * Test tweaks for LLMFunction * Pass kwargs from prompt config into LLMFunctionTemplated * Minor cleanup * Address comments
1 parent 5adf6b2 commit cbee91f

12 files changed

Lines changed: 697 additions & 13 deletions

File tree

alphaswarm/core/llm/llm_function.py

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from litellm.types.utils import ModelResponse
1010
from pydantic import BaseModel
1111

12+
from ..prompt import PromptConfig
1213
from .message import Message
1314

1415
litellm.modify_params = True # for calls with system message only for anthropic
@@ -169,6 +170,7 @@ def __init__(
169170
user_prompt_template: Optional[str] = None,
170171
system_prompt_params: Optional[Dict[str, Any]] = None,
171172
max_retries: int = 3,
173+
llm_params: Optional[Dict[str, Any]] = None,
172174
) -> None:
173175
"""Initialize an LLMFunctionTemplated instance.
174176
@@ -179,12 +181,15 @@ def __init__(
179181
user_prompt_template: Optional template for the user message
180182
system_prompt_params: Parameters for formatting the system prompt if any
181183
max_retries: Maximum number of retry attempts
184+
llm_params: Additional keyword arguments to pass to the LLM client
182185
"""
183186
super().__init__(model_id=model_id, response_model=response_model, max_retries=max_retries)
184187
self.system_prompt_template = system_prompt_template
185188
self.system_prompt = self._format(system_prompt_template, system_prompt_params)
186189
self.user_prompt_template = user_prompt_template
187190

191+
self._llm_params = llm_params or {}
192+
188193
def execute_with_completion(
189194
self,
190195
user_prompt_params: Optional[Dict[str, Any]] = None,
@@ -211,7 +216,7 @@ def execute_with_completion(
211216

212217
user_prompt = self._format(self.user_prompt_template, user_prompt_params)
213218
messages.append(Message.user(user_prompt))
214-
return self._execute_with_completion(messages=messages, **kwargs)
219+
return self._execute_with_completion(messages=messages, **self._llm_params, **kwargs)
215220

216221
@classmethod
217222
def from_files(
@@ -223,7 +228,7 @@ def from_files(
223228
system_prompt_params: Optional[Dict[str, Any]] = None,
224229
max_retries: int = 3,
225230
) -> LLMFunctionTemplated[T_Response]:
226-
"""Create an instance from template files.
231+
"""Create an instance from template text files.
227232
228233
Args:
229234
model_id: LiteLLM model ID to use
@@ -250,6 +255,62 @@ def from_files(
250255
max_retries=max_retries,
251256
)
252257

258+
@classmethod
259+
def from_prompt_config(
260+
cls,
261+
response_model: Type[T_Response],
262+
prompt_config: PromptConfig,
263+
system_prompt_params: Optional[Dict[str, Any]] = None,
264+
max_retries: int = 3,
265+
) -> LLMFunctionTemplated[T_Response]:
266+
"""Create an instance from prompt config object.
267+
268+
Args:
269+
response_model: Pydantic model class for structuring responses
270+
prompt_config: PromptConfig object
271+
system_prompt_params: Parameters for formatting the system prompt
272+
max_retries: Maximum number of retry attempts
273+
"""
274+
system_prompt_template = prompt_config.prompt.system.get_template()
275+
user_prompt_template = prompt_config.prompt.user.get_template() if prompt_config.prompt.user else None
276+
277+
if prompt_config.llm is None:
278+
raise ValueError("LLMConfig in PromptConfig is required to create an LLMFunction but was not set")
279+
model_id = prompt_config.llm.model
280+
281+
return cls(
282+
model_id=model_id,
283+
response_model=response_model,
284+
system_prompt_template=system_prompt_template,
285+
user_prompt_template=user_prompt_template,
286+
system_prompt_params=system_prompt_params,
287+
max_retries=max_retries,
288+
llm_params=prompt_config.llm.params,
289+
)
290+
291+
@classmethod
292+
def from_prompt_config_file(
293+
cls,
294+
response_model: Type[T_Response],
295+
prompt_config_path: str,
296+
system_prompt_params: Optional[Dict[str, Any]] = None,
297+
max_retries: int = 3,
298+
) -> LLMFunctionTemplated[T_Response]:
299+
"""Create an instance from prompt config file.
300+
301+
Args:
302+
response_model: Pydantic model class for structuring responses
303+
prompt_config_path: Path to the prompt config yaml file
304+
system_prompt_params: Parameters for formatting the system prompt
305+
max_retries: Maximum number of retry attempts
306+
"""
307+
return cls.from_prompt_config(
308+
response_model=response_model,
309+
prompt_config=PromptConfig.from_yaml(prompt_config_path),
310+
system_prompt_params=system_prompt_params,
311+
max_retries=max_retries,
312+
)
313+
253314
@staticmethod
254315
def _format(template: str, params: Optional[Dict[str, Any]] = None) -> str:
255316
"""Format the template string with the given optional parameters."""

alphaswarm/core/prompt/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .prompt import PromptConfig
2+
3+
__all__ = ["PromptConfig"]

alphaswarm/core/prompt/base.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import abc
2+
from typing import Annotated, Generic, Optional, TypeVar
3+
4+
from pydantic import BaseModel, StringConstraints
5+
6+
# helper class alias for str that's automatically stripped
7+
StrippedStr = Annotated[str, StringConstraints(strip_whitespace=True)]
8+
9+
10+
class PromptTemplateBase(BaseModel, abc.ABC):
11+
@abc.abstractmethod
12+
def get_template(self) -> str:
13+
pass
14+
15+
16+
T = TypeVar("T", bound="BaseModel")
17+
18+
19+
class PromptPairBase(BaseModel, Generic[T]):
20+
system: T
21+
user: Optional[T] = None

alphaswarm/core/prompt/prompt.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from __future__ import annotations
2+
3+
from typing import Any, Dict, Literal, Optional, Union
4+
5+
import yaml
6+
from pydantic import BaseModel
7+
8+
from .base import PromptPairBase, PromptTemplateBase, StrippedStr
9+
from .structured import StructuredPromptPair
10+
11+
12+
class PromptTemplate(PromptTemplateBase):
13+
template: StrippedStr
14+
15+
def get_template(self) -> str:
16+
return self.template
17+
18+
19+
class PromptPair(PromptPairBase[PromptTemplate]):
20+
system: PromptTemplate
21+
user: Optional[PromptTemplate] = None
22+
23+
24+
class LLMConfig(BaseModel):
25+
model: str
26+
params: Optional[Dict[str, Any]] = None
27+
28+
29+
class PromptConfig(BaseModel):
30+
"""
31+
Prompt configuration object.
32+
Contains prompt pair, optional metadata, and optional LLM configuration.
33+
If LLM configuration is specified, it could be used to generate an LLMFunction.
34+
"""
35+
36+
kind: Literal["Prompt", "StructuredPrompt"]
37+
prompt: Union[PromptPair, StructuredPromptPair]
38+
metadata: Optional[Dict[str, Any]] = None
39+
llm: Optional[LLMConfig] = None
40+
41+
@property
42+
def has_llm_config(self) -> bool:
43+
return self.llm is not None
44+
45+
@classmethod
46+
def from_yaml(cls, path: str) -> PromptConfig:
47+
with open(path, "r", encoding="utf-8") as f:
48+
data = yaml.safe_load(f)
49+
return cls(**data)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
from __future__ import annotations
2+
3+
import abc
4+
from typing import List, Mapping, Optional, Sequence, Type
5+
6+
from pydantic import BaseModel, model_validator
7+
8+
from .base import PromptPairBase, PromptTemplateBase, StrippedStr
9+
10+
11+
class PromptSection(BaseModel):
12+
name: str
13+
content: Optional[StrippedStr] = None
14+
sections: List[PromptSection] = []
15+
16+
17+
class PromptFormatterBase(abc.ABC):
18+
def format(self, sections: Sequence[PromptSection]) -> str:
19+
return "\n".join(self._format_section(section) for section in sections)
20+
21+
@abc.abstractmethod
22+
def _format_section(self, section: PromptSection) -> str:
23+
pass
24+
25+
26+
class StringPromptFormatter(PromptFormatterBase):
27+
def __init__(self, section_prefix: str = "") -> None:
28+
self.section_prefix = section_prefix
29+
30+
def _format_section(self, section: PromptSection) -> str:
31+
parts = [f"{self.section_prefix}{section.name}"]
32+
if section.content:
33+
parts.append(section.content)
34+
parts.extend([self._format_section(sec) for sec in section.sections])
35+
return "\n".join(parts)
36+
37+
38+
class MarkdownPromptFormatter(PromptFormatterBase):
39+
def _format_section(self, section: PromptSection, indent: int = 1) -> str:
40+
parts = ["", f"{'#' * indent} {section.name}", ""]
41+
if section.content:
42+
parts.append(section.content)
43+
parts.extend([self._format_section(sec, indent + 1) for sec in section.sections])
44+
return "\n".join(parts).strip()
45+
46+
47+
class XMLPromptFormatter(PromptFormatterBase):
48+
INDENT_DIFF: str = " "
49+
50+
def _format_section(self, section: PromptSection, indent: str = "") -> str:
51+
name_snake_case = section.name.lower().replace(" ", "_")
52+
parts = [f"{indent}<{name_snake_case}>"]
53+
54+
if section.content:
55+
content_lines = section.content.split("\n")
56+
content = "\n".join([f"{indent}{self.INDENT_DIFF}{line}" for line in content_lines])
57+
parts.append(content)
58+
59+
parts.extend([self._format_section(sec, indent + self.INDENT_DIFF) for sec in section.sections])
60+
parts.append(f"{indent}</{name_snake_case}>")
61+
return "\n".join(parts)
62+
63+
64+
FORMATTER_REGISTRY: Mapping[str, Type[PromptFormatterBase]] = {
65+
"string": StringPromptFormatter,
66+
"markdown": MarkdownPromptFormatter,
67+
"xml": XMLPromptFormatter,
68+
}
69+
70+
71+
class StructuredPromptTemplate(PromptTemplateBase):
72+
sections: List[PromptSection]
73+
formatter: str = "string"
74+
_formatter_obj: PromptFormatterBase = StringPromptFormatter() # default for mypy, will be overridden
75+
76+
def get_template(self) -> str:
77+
return self._formatter_obj.format(self.sections)
78+
79+
def set_formatter(self, formatter: PromptFormatterBase) -> None:
80+
self._formatter_obj = formatter
81+
82+
@model_validator(mode="after")
83+
def formatter_obj_validator(self) -> StructuredPromptTemplate:
84+
formatter_obj = self.formatter_string_to_obj(self.formatter)
85+
self.set_formatter(formatter_obj)
86+
return self
87+
88+
@staticmethod
89+
def formatter_string_to_obj(formatter: str) -> PromptFormatterBase:
90+
formatter = formatter.lower()
91+
if formatter not in FORMATTER_REGISTRY:
92+
raise ValueError(
93+
f"Unknown formatter: `{formatter}`. Available formatters: {', '.join(FORMATTER_REGISTRY.keys())}"
94+
)
95+
96+
formatter_cls = FORMATTER_REGISTRY[formatter]
97+
return formatter_cls()
98+
99+
100+
class StructuredPromptPair(PromptPairBase[StructuredPromptTemplate]):
101+
system: StructuredPromptTemplate
102+
user: Optional[StructuredPromptTemplate] = None

tests/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
import os
2+
from typing import Final
3+
from enum import Enum
4+
5+
DATA_PATH: Final[str] = os.path.join(os.path.dirname(__file__), "data")
6+
7+
8+
class PromptPath(str, Enum):
9+
basic = os.path.join(DATA_PATH, "prompts", "prompt.yaml")
10+
structured = os.path.join(DATA_PATH, "prompts", "structured_prompt.yaml")
211

312

413
def get_data_filename(filename: str) -> str:
5-
return os.path.join(os.path.dirname(__file__), "data", filename)
14+
return os.path.join(DATA_PATH, filename)

tests/data/prompts/prompt.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
kind: Prompt
2+
metadata:
3+
description: |
4+
This is a prompt doing abc
5+
llm:
6+
model: gpt-4o-mini
7+
params:
8+
temperature: 0.3
9+
prompt:
10+
system:
11+
template: |
12+
You are a helpful assistant.
13+
user:
14+
template: |
15+
Answer the following questions: {question}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
kind: StructuredPrompt
2+
metadata:
3+
description: |
4+
This is a prompt doing xyz
5+
llm:
6+
model: claude-3-5-haiku-20241022
7+
params:
8+
temperature: 0.2
9+
prompt:
10+
system:
11+
sections:
12+
- name: Instructions
13+
content: |
14+
You are a helpful assistant.
15+
sections:
16+
- name: Hints
17+
content: |
18+
Answer the question in a concise manner.
19+
formatter: XML
20+
user:
21+
sections:
22+
- name: Question
23+
content: |
24+
What's the capital of France?
25+
formatter: XML

tests/integration/core/llm/test_llm_function_from_files.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,16 @@
55
from pydantic import BaseModel, Field
66

77
from alphaswarm.core.llm import LLMFunctionTemplated
8+
from alphaswarm.core.prompt import PromptConfig
9+
from tests import PromptPath
810

911
dotenv.load_dotenv()
1012

1113

14+
class Response(BaseModel):
15+
answer: str = Field(..., description="The answer to the question")
16+
17+
1218
class SimpleResponse(BaseModel):
1319
reasoning: str = Field(..., description="Reasoning behind the response")
1420
number: int = Field(..., ge=1, le=10, description="The random number between 1 and 10.")
@@ -55,3 +61,23 @@ def test_llm_function_from_user_file() -> None:
5561
result = llm_func.execute(user_prompt_params={"min_value": 3, "max_value": 8})
5662
assert isinstance(result, SimpleResponse)
5763
assert 3 <= result.number <= 8
64+
65+
66+
def test_llm_function_from_prompt_config() -> None:
67+
llm_func = LLMFunctionTemplated.from_prompt_config(
68+
response_model=Response,
69+
prompt_config=PromptConfig.from_yaml(PromptPath.basic),
70+
)
71+
72+
result = llm_func.execute(user_prompt_params={"question": "What's the capital of France?"})
73+
assert "Paris" in result.answer
74+
75+
76+
def test_llm_function_from_structured_prompt_config() -> None:
77+
llm_func = LLMFunctionTemplated.from_prompt_config_file(
78+
response_model=Response,
79+
prompt_config_path=PromptPath.structured,
80+
)
81+
82+
result = llm_func.execute()
83+
assert "Paris" in result.answer

0 commit comments

Comments
 (0)