From 1bd8558315c4f738600a30045ca74fcd50a21e48 Mon Sep 17 00:00:00 2001 From: NotBioWaste905 Date: Wed, 9 Apr 2025 13:10:30 +0300 Subject: [PATCH 01/19] Update LLMSlot and LLMGroupSlot to use LLM_API instead of model --- chatsky/slots/llm.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/chatsky/slots/llm.py b/chatsky/slots/llm.py index 285cece2bf..ddb1bed474 100644 --- a/chatsky/slots/llm.py +++ b/chatsky/slots/llm.py @@ -9,9 +9,12 @@ from __future__ import annotations +import json + from typing import Union, Dict, TYPE_CHECKING import logging +from chatsky.core.message import Message from pydantic import BaseModel, Field, create_model from chatsky.slots.slots import ValueSlot, SlotNotExtracted, GroupSlot, ExtractedGroupSlot, ExtractedValueSlot @@ -49,10 +52,13 @@ async def extract_value(self, ctx: Context) -> Union[str, SlotNotExtracted]: class DynamicModel(BaseModel): value: self.return_type = Field(description=self.caption) - structured_model = model_instance.with_structured_output(DynamicModel) + result: Message = await ctx.pipeline.models.get(self.llm_model_name, None).respond( + history=[request_text], + message_schema=DynamicModel + ) + result_json = json.loads(result.text) - result = await structured_model.ainvoke(request_text) - return result.value + return result.get("value", "") class LLMGroupSlot(GroupSlot): @@ -78,10 +84,14 @@ async def get_value(self, ctx: Context) -> ExtractedGroupSlot: DynamicGroupModel = create_model("DynamicGroupModel", **captions) logger.debug(f"DynamicGroupModel: {DynamicGroupModel}") - model_instance = ctx.pipeline.models[self.llm_model_name].model - structured_model = model_instance.with_structured_output(DynamicGroupModel) - result = await structured_model.ainvoke(request_text) - result_json = result.model_dump() + # model_instance = ctx.pipeline.models[self.llm_model_name].model + # structured_model = model_instance.with_structured_output(DynamicGroupModel) + # result = await structured_model.ainvoke(request_text) + result: Message = await ctx.pipeline.models.get(self.llm_model_name, None).respond( + history=[request_text], + message_schema=DynamicGroupModel + ) + result_json = json.loads(result.text) logger.debug(f"Result JSON: {result_json}") # Convert flat dict to nested structure From f8eac43abc0bea92627efdde5023d27c1e9c06a2 Mon Sep 17 00:00:00 2001 From: NotBioWaste905 Date: Thu, 10 Apr 2025 19:52:57 +0300 Subject: [PATCH 02/19] add _ainvoke method to LLM_API, add history usage in LLM Slots --- chatsky/llm/filters.py | 2 +- chatsky/llm/llm_api.py | 19 ++++++++++++++--- chatsky/slots/llm.py | 48 +++++++++++++++++++++++------------------- 3 files changed, 43 insertions(+), 26 deletions(-) diff --git a/chatsky/llm/filters.py b/chatsky/llm/filters.py index 2a60d20d33..a5b1d48b22 100644 --- a/chatsky/llm/filters.py +++ b/chatsky/llm/filters.py @@ -149,7 +149,7 @@ def single_message_filter_call(self, ctx: Context, message: Optional[Message], l class FromModel(BaseHistoryFilter): """ - Filter that checks if the response of the turn is generated by the currently + Filter that checks if the response of the turn is generated by the currently used model. """ def call( diff --git a/chatsky/llm/llm_api.py b/chatsky/llm/llm_api.py index 3170d4bb93..f052d16914 100644 --- a/chatsky/llm/llm_api.py +++ b/chatsky/llm/llm_api.py @@ -60,6 +60,21 @@ async def respond( result = await self.parser.ainvoke(await self.model.ainvoke(history)) return Message(text=result) elif issubclass(message_schema, Message): + result = await self._ainvoke(history, message_schema) + return Message.model_validate(result) + elif issubclass(message_schema, BaseModel): + result = await self._ainvoke(history, message_schema) + return Message(text=result.model_dump_json()) + else: + raise ValueError + + async def _ainvoke( + self, + history: list[BaseMessage], + message_schema: Union[Type[Message], Type[BaseModel]], + ) -> Union[Message, BaseModel]: + # call the model and return result as BaseMessage or BaseModel + if issubclass(message_schema, Message): # Case if the message_schema describes Message structure structured_model = self.model.with_structured_output(message_schema, method="json_mode") model_result = await structured_model.ainvoke(history) @@ -69,9 +84,7 @@ async def respond( # Case if the message_schema describes Message.text structure structured_model = self.model.with_structured_output(message_schema) model_result = await structured_model.ainvoke(history) - return Message(text=message_schema.model_validate(model_result).model_dump_json()) - else: - raise ValueError + return message_schema.model_validate(model_result) async def condition(self, history: list[BaseMessage], method: BaseMethod) -> bool: """ diff --git a/chatsky/slots/llm.py b/chatsky/slots/llm.py index ddb1bed474..b0433432ab 100644 --- a/chatsky/slots/llm.py +++ b/chatsky/slots/llm.py @@ -9,18 +9,18 @@ from __future__ import annotations -import json - from typing import Union, Dict, TYPE_CHECKING import logging -from chatsky.core.message import Message from pydantic import BaseModel, Field, create_model +from chatsky.llm.langchain_context import context_to_history, message_to_langchain +from chatsky.llm.filters import FromModel from chatsky.slots.slots import ValueSlot, SlotNotExtracted, GroupSlot, ExtractedGroupSlot, ExtractedValueSlot if TYPE_CHECKING: from chatsky.core import Context + from chatsky.core.message import Message logger = logging.getLogger(__name__) @@ -32,33 +32,35 @@ class LLMSlot(ValueSlot, frozen=True): `caption` parameter using LLM. """ - # TODO: - # add history (and overall update the class) - caption: str return_type: type = str llm_model_name: str = "" + history: int = 0 - def __init__(self, caption, llm_model_name=""): - super().__init__(caption=caption, llm_model_name=llm_model_name) + def __init__(self, caption, return_type, llm_model_name="", history=0): + super().__init__(caption=caption, return_type=return_type, llm_model_name=llm_model_name, history=history) async def extract_value(self, ctx: Context) -> Union[str, SlotNotExtracted]: request_text = ctx.last_request.text if request_text == "": return SlotNotExtracted() - model_instance = ctx.pipeline.models[self.llm_model_name].model + history_messages = context_to_history( + ctx, self.history, filter_func=FromModel(), llm_model_name=self.llm_model_name, max_size=1000 + ) + if history_messages == []: + history_messages = [message_to_langchain(ctx.last_request, ctx)] # Dynamically create a Pydantic model based on the caption + return_type = self.return_type + class DynamicModel(BaseModel): - value: self.return_type = Field(description=self.caption) + value: return_type = Field(description=self.caption) - result: Message = await ctx.pipeline.models.get(self.llm_model_name, None).respond( - history=[request_text], - message_schema=DynamicModel + result: DynamicModel = await ctx.pipeline.models[self.llm_model_name]._ainvoke( + history=history_messages, message_schema=DynamicModel ) - result_json = json.loads(result.text) - return result.get("value", "") + return result.value class LLMGroupSlot(GroupSlot): @@ -84,14 +86,16 @@ async def get_value(self, ctx: Context) -> ExtractedGroupSlot: DynamicGroupModel = create_model("DynamicGroupModel", **captions) logger.debug(f"DynamicGroupModel: {DynamicGroupModel}") - # model_instance = ctx.pipeline.models[self.llm_model_name].model - # structured_model = model_instance.with_structured_output(DynamicGroupModel) - # result = await structured_model.ainvoke(request_text) - result: Message = await ctx.pipeline.models.get(self.llm_model_name, None).respond( - history=[request_text], - message_schema=DynamicGroupModel + history_messages = context_to_history( + ctx, self.history, filter_func=FromModel(), llm_model_name=self.llm_model_name, max_size=1000 + ) + if history_messages == []: + history_messages = [message_to_langchain(ctx.last_request, ctx)] + + result: Message = await ctx.pipeline.models.get(self.llm_model_name, None)._ainvoke( + history=history_messages, message_schema=DynamicGroupModel ) - result_json = json.loads(result.text) + result_json = result.model_dump() logger.debug(f"Result JSON: {result_json}") # Convert flat dict to nested structure From 80c0ffbe625775c03a0b1f57aac6f516563c72d7 Mon Sep 17 00:00:00 2001 From: NotBioWaste905 Date: Mon, 14 Apr 2025 13:47:43 +0300 Subject: [PATCH 03/19] move imports in llm/filters inder TYPE_CHECKING, add llm slots tutorial --- chatsky/llm/filters.py | 7 +- tutorials/llm/5_llm_slots.py | 121 +++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 tutorials/llm/5_llm_slots.py diff --git a/chatsky/llm/filters.py b/chatsky/llm/filters.py index a5b1d48b22..808e2b3394 100644 --- a/chatsky/llm/filters.py +++ b/chatsky/llm/filters.py @@ -7,12 +7,13 @@ import abc from enum import Enum from logging import Logger -from typing import Union, Optional +from typing import Union, Optional, TYPE_CHECKING from pydantic import BaseModel -from chatsky.core.message import Message -from chatsky.core.context import Context +if TYPE_CHECKING: + from chatsky.core import Context + from chatsky.core.message import Message logger = Logger(name=__name__) diff --git a/tutorials/llm/5_llm_slots.py b/tutorials/llm/5_llm_slots.py new file mode 100644 index 0000000000..4426f6d054 --- /dev/null +++ b/tutorials/llm/5_llm_slots.py @@ -0,0 +1,121 @@ +# %% [markdown] +""" +# LLM: 5. LLM Slots + +When we need to retrieve specific information from user input—such as a name, +address, or email we can use Chatsky's Slot system along with regexes or other +formally specified data retrieval techniques. +However, if the data is more nuanced or not explicitly stated in the user's +utterance, we recommend using Chatsky's **LLM Slots**. + +In this tutorial, we will explore how to set up Slots that leverage LLMs +to extract more complex or implicit information from user input. +""" +# %pip install chatsky[llm] langchain-openai +# %% +from chatsky import ( + RESPONSE, + TRANSITIONS, + PRE_TRANSITION, + GLOBAL, + LOCAL, + Pipeline, + Transition as Tr, + conditions as cnd, + processing as proc, + responses as rsp, +) +from langchain_openai import ChatOpenAI + +from chatsky.utils.testing import ( + is_interactive_mode, +) +from chatsky.slots.llm import LLMSlot, LLMGroupSlot +from chatsky.llm import LLM_API + +import os + +openai_api_key = os.getenv("OPENAI_API_KEY") + +# %% [markdown] +""" +In this example, we define an **LLM Group Slot** containing two **LLM Slots**. +While these slots can be used independently as regular slots, +grouping them together is recommended when extracting multiple LLM Slots +simultaneously. This approach optimizes performance and improves convenience. + +- In the `LLMSlot.caption` parameter, provide a description of the data you +want to retrieve. More specific descriptions yield better results, +especially when using smaller models. +- Note that we pass the name of the model from the `pipeline.models` +dictionary to the `LLMGroupSlot.model` field. +- Additionally, the `allow_partial_extraction` flag is set to `True` for the +"person" slot. This allows the slot to be filled across multiple messages. +For more details on partial extraction, +refer to the tutorial: %mddoclink(tutorial,slots.2_partial_extraction). +""" + +# %% +slot_model = LLM_API( + ChatOpenAI(model="gpt-4o-mini", api_key=openai_api_key, temperature=0) +) + +SLOTS = { + "person": LLMGroupSlot( + username=LLMSlot(caption="User's username in uppercase"), + job=LLMSlot(caption="User's occupation, job, profession"), + age=LLMSlot(caption="User's age", return_type=int), + model="slot_model", + allow_partial_extraction=True, + ) +} + +script = { + GLOBAL: { + TRANSITIONS: [ + Tr(dst=("user_flow", "ask"), cnd=cnd.Regexp(r"^[sS]tart")) + ] + }, + "user_flow": { + LOCAL: { + PRE_TRANSITION: {"get_slot": proc.Extract("person")}, + TRANSITIONS: [ + Tr( + dst=("user_flow", "tell"), + cnd=cnd.SlotsExtracted("person"), + priority=1.2, + ), + Tr(dst=("user_flow", "repeat_question"), priority=0.8), + ], + }, + "start": {RESPONSE: "", TRANSITIONS: [Tr(dst=("user_flow", "ask"))]}, + "ask": { + RESPONSE: "Hello! Tell me about yourself: what are you doing for " + "the living or your hobbies, your age... " + "And don't forget to introduce yourself!", + }, + "tell": { + RESPONSE: rsp.FilledTemplate( + "So you are {person.username}, {person.age} and your " + "occupation is {person.job}, right?" + ), + TRANSITIONS: [Tr(dst=("user_flow", "ask"))], + }, + "repeat_question": { + RESPONSE: "I didn't quite understand you...", + }, + }, +} + +pipeline = Pipeline( + script=script, + start_label=("user_flow", "start"), + fallback_label=("user_flow", "repeat_question"), + slots=SLOTS, + models={"slot_model": slot_model}, +) + + +if __name__ == "__main__": + if is_interactive_mode(): + pipeline.run() From 4567fbff5b0a2c0191f143941f86def9d63349b1 Mon Sep 17 00:00:00 2001 From: NotBioWaste905 Date: Mon, 14 Apr 2025 14:08:13 +0300 Subject: [PATCH 04/19] add __future__.annotations import --- chatsky/llm/filters.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/chatsky/llm/filters.py b/chatsky/llm/filters.py index 808e2b3394..ecf3e84d83 100644 --- a/chatsky/llm/filters.py +++ b/chatsky/llm/filters.py @@ -4,6 +4,8 @@ This module contains a collection of basic functions for history filtering to avoid cluttering LLMs context window. """ +from __future__ import annotations + import abc from enum import Enum from logging import Logger From 16a990f033c02fbf1e0a2829bc35797af45b1981 Mon Sep 17 00:00:00 2001 From: NotBioWaste905 Date: Wed, 16 Apr 2025 13:44:54 +0300 Subject: [PATCH 05/19] rework LLMGroupSlot to extract values from nested slots via models specified in these slots --- chatsky/slots/llm.py | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/chatsky/slots/llm.py b/chatsky/slots/llm.py index b0433432ab..e309082b07 100644 --- a/chatsky/slots/llm.py +++ b/chatsky/slots/llm.py @@ -9,7 +9,7 @@ from __future__ import annotations -from typing import Union, Dict, TYPE_CHECKING +from typing import Union, Dict, TYPE_CHECKING, Tuple import logging from pydantic import BaseModel, Field, create_model @@ -77,7 +77,8 @@ async def get_value(self, ctx: Context) -> ExtractedGroupSlot: request_text = ctx.last_request.text if request_text == "": return ExtractedGroupSlot() - flat_items = self._flatten_llm_group_slot(self) + + flat_items, items_with_models = self._flatten_llm_group_slot(self) captions = {} for child_name, slot_item in flat_items.items(): captions[child_name] = (slot_item.return_type, Field(description=slot_item.caption, default=None)) @@ -92,6 +93,16 @@ async def get_value(self, ctx: Context) -> ExtractedGroupSlot: if history_messages == []: history_messages = [message_to_langchain(ctx.last_request, ctx)] + extracted_items = {} + for key, item in items_with_models.items(): + if isinstance(item, LLMSlot): + res = await item.extract_value(ctx) + elif isinstance(item, LLMGroupSlot): + res = await item.get_value(ctx) + else: + res = SlotNotExtracted + extracted_items[key] = res + result: Message = await ctx.pipeline.models.get(self.llm_model_name, None)._ainvoke( history=history_messages, message_schema=DynamicGroupModel ) @@ -118,6 +129,15 @@ async def get_value(self, ctx: Context) -> ExtractedGroupSlot: current[final] = ExtractedValueSlot.model_construct( is_slot_extracted=value is not None, extracted_value=value ) + + # Combine extracted_items with the nested result + for key, value in extracted_items.items(): + if isinstance(value, ExtractedValueSlot): + nested_result[key] = value + elif isinstance(value, ExtractedGroupSlot): + nested_result[key] = self._dict_to_extracted_slots(value) + else: + nested_result[key] = SlotNotExtracted return self._dict_to_extracted_slots(nested_result) @@ -129,7 +149,7 @@ def _dict_to_extracted_slots(self, d): return d return ExtractedGroupSlot(**{k: self._dict_to_extracted_slots(v) for k, v in d.items()}) - def _flatten_llm_group_slot(self, slot, parent_key="") -> Dict[str, LLMSlot]: + def _flatten_llm_group_slot(self, slot, parent_key="") -> Tuple[Dict[str, LLMSlot], list[Union[LLMSlot, LLMGroupSlot]]]: """ Convert potentially nested group slot into a dictionary with flat keys. @@ -138,10 +158,16 @@ def _flatten_llm_group_slot(self, slot, parent_key="") -> Dict[str, LLMSlot]: As such, values in the returned dictionary are only of type :py:class:`LLMSlot`. """ items = {} + items_with_models = {} + # filter out items with `llm_model_name` specified + # to a separate list. Other should go to the flattening list for key, value in slot.__pydantic_extra__.items(): new_key = f"{parent_key}.{key}" if parent_key else key + if value.llm_model_name: + items_with_models[new_key] = value + continue if isinstance(value, LLMGroupSlot): items.update(self._flatten_llm_group_slot(value, new_key)) else: items[new_key] = value - return items + return items, items_with_models From 13e7dd42c8c083f73b738ad958c4eee897876e23 Mon Sep 17 00:00:00 2001 From: NotBioWaste905 Date: Wed, 16 Apr 2025 13:45:18 +0300 Subject: [PATCH 06/19] format --- chatsky/slots/llm.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/chatsky/slots/llm.py b/chatsky/slots/llm.py index e309082b07..201dd04ca9 100644 --- a/chatsky/slots/llm.py +++ b/chatsky/slots/llm.py @@ -129,7 +129,7 @@ async def get_value(self, ctx: Context) -> ExtractedGroupSlot: current[final] = ExtractedValueSlot.model_construct( is_slot_extracted=value is not None, extracted_value=value ) - + # Combine extracted_items with the nested result for key, value in extracted_items.items(): if isinstance(value, ExtractedValueSlot): @@ -149,7 +149,9 @@ def _dict_to_extracted_slots(self, d): return d return ExtractedGroupSlot(**{k: self._dict_to_extracted_slots(v) for k, v in d.items()}) - def _flatten_llm_group_slot(self, slot, parent_key="") -> Tuple[Dict[str, LLMSlot], list[Union[LLMSlot, LLMGroupSlot]]]: + def _flatten_llm_group_slot( + self, slot, parent_key="" + ) -> Tuple[Dict[str, LLMSlot], list[Union[LLMSlot, LLMGroupSlot]]]: """ Convert potentially nested group slot into a dictionary with flat keys. From 6f22311c62f55b3003c315d0f6d7006dc40a100d Mon Sep 17 00:00:00 2001 From: NotBioWaste905 Date: Wed, 23 Apr 2025 11:40:44 +0300 Subject: [PATCH 07/19] group slots by model --- chatsky/slots/llm.py | 122 +++++++++++++++++++++---------------------- 1 file changed, 61 insertions(+), 61 deletions(-) diff --git a/chatsky/slots/llm.py b/chatsky/slots/llm.py index 201dd04ca9..575a2df56a 100644 --- a/chatsky/slots/llm.py +++ b/chatsky/slots/llm.py @@ -78,40 +78,47 @@ async def get_value(self, ctx: Context) -> ExtractedGroupSlot: if request_text == "": return ExtractedGroupSlot() - flat_items, items_with_models = self._flatten_llm_group_slot(self) - captions = {} - for child_name, slot_item in flat_items.items(): - captions[child_name] = (slot_item.return_type, Field(description=slot_item.caption, default=None)) + # Get all slots grouped by their model names + model_groups = self._group_slots_by_model(self) + + # Process each model group separately + all_results = {} + for model_name, slots in model_groups.items(): + if not slots: + continue + + # Create dynamic model for this group + captions = {} + for child_name, slot_item in slots.items(): + captions[child_name] = (slot_item.return_type, Field(description=slot_item.caption, default=None)) - logger.debug(f"Flattened group slot: {flat_items}") - DynamicGroupModel = create_model("DynamicGroupModel", **captions) - logger.debug(f"DynamicGroupModel: {DynamicGroupModel}") + DynamicGroupModel = create_model("DynamicGroupModel", **captions) + logger.debug(f"DynamicGroupModel for {model_name}: {DynamicGroupModel}") - history_messages = context_to_history( - ctx, self.history, filter_func=FromModel(), llm_model_name=self.llm_model_name, max_size=1000 - ) - if history_messages == []: - history_messages = [message_to_langchain(ctx.last_request, ctx)] + history_messages = context_to_history( + ctx, self.history, filter_func=FromModel(), llm_model_name=model_name, max_size=1000 + ) + if history_messages == []: + history_messages = [message_to_langchain(ctx.last_request, ctx)] - extracted_items = {} - for key, item in items_with_models.items(): - if isinstance(item, LLMSlot): - res = await item.extract_value(ctx) - elif isinstance(item, LLMGroupSlot): - res = await item.get_value(ctx) - else: - res = SlotNotExtracted - extracted_items[key] = res + # Get model and process request + model = ctx.pipeline.models.get(model_name) + if model is None: + logger.warning(f"Model {model_name} not found in pipeline.models") + continue - result: Message = await ctx.pipeline.models.get(self.llm_model_name, None)._ainvoke( - history=history_messages, message_schema=DynamicGroupModel - ) - result_json = result.model_dump() - logger.debug(f"Result JSON: {result_json}") + result: Message = await model._ainvoke( + history=history_messages, message_schema=DynamicGroupModel + ) + result_json = result.model_dump() + logger.debug(f"Result JSON for {model_name}: {result_json}") + + # Add results to all_results + all_results.update(result_json) # Convert flat dict to nested structure nested_result = {} - for key, value in result_json.items(): + for key, value in all_results.items(): if value is None and self.allow_partial_extraction: continue @@ -130,17 +137,35 @@ async def get_value(self, ctx: Context) -> ExtractedGroupSlot: is_slot_extracted=value is not None, extracted_value=value ) - # Combine extracted_items with the nested result - for key, value in extracted_items.items(): - if isinstance(value, ExtractedValueSlot): - nested_result[key] = value - elif isinstance(value, ExtractedGroupSlot): - nested_result[key] = self._dict_to_extracted_slots(value) - else: - nested_result[key] = SlotNotExtracted - return self._dict_to_extracted_slots(nested_result) + def _group_slots_by_model(self, slot, parent_key="") -> Dict[str, Dict[str, LLMSlot]]: + """ + Group slots by their llm_model_name. + Returns a dictionary where keys are model names and values are dictionaries + of slot paths to slot objects. + """ + model_groups = {} + + for key, value in slot.__pydantic_extra__.items(): + new_key = f"{parent_key}.{key}" if parent_key else key + + if isinstance(value, LLMGroupSlot): + # Recursively process nested group slots + nested_groups = self._group_slots_by_model(value, new_key) + for model_name, slots in nested_groups.items(): + if model_name not in model_groups: + model_groups[model_name] = {} + model_groups[model_name].update(slots) + else: + # Use the slot's model name or fall back to the group's model name + model_name = value.llm_model_name or self.llm_model_name + if model_name not in model_groups: + model_groups[model_name] = {} + model_groups[model_name][new_key] = value + + return model_groups + def _dict_to_extracted_slots(self, d): """ Convert nested dictionary of ExtractedValueSlots into an ExtractedGroupSlot. @@ -148,28 +173,3 @@ def _dict_to_extracted_slots(self, d): if not isinstance(d, dict): return d return ExtractedGroupSlot(**{k: self._dict_to_extracted_slots(v) for k, v in d.items()}) - - def _flatten_llm_group_slot( - self, slot, parent_key="" - ) -> Tuple[Dict[str, LLMSlot], list[Union[LLMSlot, LLMGroupSlot]]]: - """ - Convert potentially nested group slot into a dictionary with - flat keys. - Nested keys are flattened as concatenations via ".". - - As such, values in the returned dictionary are only of type :py:class:`LLMSlot`. - """ - items = {} - items_with_models = {} - # filter out items with `llm_model_name` specified - # to a separate list. Other should go to the flattening list - for key, value in slot.__pydantic_extra__.items(): - new_key = f"{parent_key}.{key}" if parent_key else key - if value.llm_model_name: - items_with_models[new_key] = value - continue - if isinstance(value, LLMGroupSlot): - items.update(self._flatten_llm_group_slot(value, new_key)) - else: - items[new_key] = value - return items, items_with_models From e56ae428ab7963fcba80ae9619908f3ab3b7dcb9 Mon Sep 17 00:00:00 2001 From: NotBioWaste905 Date: Mon, 28 Apr 2025 13:09:56 +0300 Subject: [PATCH 08/19] Refactor imports to use TYPE_CHECKING and enhance LLM slot functionality in tutorials --- chatsky/llm/langchain_context.py | 8 ++++++-- chatsky/llm/methods.py | 7 ++++++- chatsky/slots/llm.py | 2 +- tests/llm/test_llm.py | 8 ++++++++ tutorials/llm/5_llm_slots.py | 9 +++++++-- 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/chatsky/llm/langchain_context.py b/chatsky/llm/langchain_context.py index e0f0a19a96..69f8bf9d9e 100644 --- a/chatsky/llm/langchain_context.py +++ b/chatsky/llm/langchain_context.py @@ -4,16 +4,20 @@ The Utils module contains functions for converting Chatsky's objects to an LLM_API and langchain compatible versions. """ +from __future__ import annotations + import re import logging -from typing import Literal, Union +from typing import Literal, Union, TYPE_CHECKING import asyncio -from chatsky.core import Context, Message from chatsky.llm._langchain_imports import HumanMessage, SystemMessage, AIMessage, check_langchain_available from chatsky.llm.filters import BaseHistoryFilter, Return from chatsky.llm.prompt import Prompt, PositionConfig +if TYPE_CHECKING: + from chatsky.core import Context, Message + logger = logging.getLogger(__name__) diff --git a/chatsky/llm/methods.py b/chatsky/llm/methods.py index 8867a0c3b5..c7a89e9dc6 100644 --- a/chatsky/llm/methods.py +++ b/chatsky/llm/methods.py @@ -5,13 +5,18 @@ These methods return bool values based on LLM result. """ +from __future__ import annotations + import abc +from typing import TYPE_CHECKING from pydantic import BaseModel -from chatsky.core.context import Context from chatsky.llm._langchain_imports import LLMResult +if TYPE_CHECKING: + from chatsky.core.context import Context + class BaseMethod(BaseModel, abc.ABC): """ diff --git a/chatsky/slots/llm.py b/chatsky/slots/llm.py index 575a2df56a..8fe3c609dd 100644 --- a/chatsky/slots/llm.py +++ b/chatsky/slots/llm.py @@ -9,7 +9,7 @@ from __future__ import annotations -from typing import Union, Dict, TYPE_CHECKING, Tuple +from typing import Union, Dict, TYPE_CHECKING import logging from pydantic import BaseModel, Field, create_model diff --git a/tests/llm/test_llm.py b/tests/llm/test_llm.py index 8e0df51cee..c1c8fbf03d 100644 --- a/tests/llm/test_llm.py +++ b/tests/llm/test_llm.py @@ -459,6 +459,14 @@ async def test_llm_slot(self, pipeline, context): result = await slot.extract_value(context) assert isinstance(result, str) + # Test request with history + slot = LLMSlot(caption="test_caption", llm_model_name="test_model", history=2) + context.requests[5] = "test request with history" + result = await slot.extract_value(context) + print(f"Extracted result: {result}") + assert isinstance(result, str) + + async def test_llm_group_slot(self, pipeline, context): slot = LLMGroupSlot( llm_model_name="test_model", diff --git a/tutorials/llm/5_llm_slots.py b/tutorials/llm/5_llm_slots.py index 4426f6d054..5a4522ecbc 100644 --- a/tutorials/llm/5_llm_slots.py +++ b/tutorials/llm/5_llm_slots.py @@ -60,10 +60,15 @@ ChatOpenAI(model="gpt-4o-mini", api_key=openai_api_key, temperature=0) ) +another_slot_model = LLM_API( + ChatOpenAI(model="gpt-4.1-nano", api_key=openai_api_key, temperature=0) +) + SLOTS = { "person": LLMGroupSlot( username=LLMSlot(caption="User's username in uppercase"), - job=LLMSlot(caption="User's occupation, job, profession"), + job=LLMSlot(llm_model_name="another_slot_model", + caption="User's occupation, job, profession"), age=LLMSlot(caption="User's age", return_type=int), model="slot_model", allow_partial_extraction=True, @@ -112,7 +117,7 @@ start_label=("user_flow", "start"), fallback_label=("user_flow", "repeat_question"), slots=SLOTS, - models={"slot_model": slot_model}, + models={"slot_model": slot_model, "another_slot_model": another_slot_model}, ) From 8b42740bd3bc5c96984367123529f6235f9cad7e Mon Sep 17 00:00:00 2001 From: NotBioWaste905 Date: Mon, 28 Apr 2025 14:13:33 +0300 Subject: [PATCH 09/19] move typehints under TYPE_CHECKING, fix default return_type value in LLMSlot --- chatsky/__rebuild_pydantic_models__.py | 1 + chatsky/core/ctx_utils.py | 4 ++-- chatsky/core/pipeline.py | 5 +++-- chatsky/slots/llm.py | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/chatsky/__rebuild_pydantic_models__.py b/chatsky/__rebuild_pydantic_models__.py index 88815530c3..87b91e0ad9 100644 --- a/chatsky/__rebuild_pydantic_models__.py +++ b/chatsky/__rebuild_pydantic_models__.py @@ -12,6 +12,7 @@ from chatsky.core.service import PipelineComponent from chatsky.llm import LLM_API from chatsky.messengers.telegram.abstract import TelegramMetadata +from chatsky.slots import GroupSlot ContextMainInfo.model_rebuild() ContextDict.model_rebuild() diff --git a/chatsky/core/ctx_utils.py b/chatsky/core/ctx_utils.py index a00738e134..aa26204fed 100644 --- a/chatsky/core/ctx_utils.py +++ b/chatsky/core/ctx_utils.py @@ -15,9 +15,9 @@ from pydantic import BaseModel, Field, PrivateAttr, TypeAdapter, field_serializer, field_validator -from chatsky.slots.slots import SlotManager if TYPE_CHECKING: + from chatsky.slots.slots import SlotManager from chatsky.core.service import ComponentExecutionState from chatsky.core.script import Node from chatsky.core.pipeline import Pipeline @@ -62,7 +62,7 @@ class FrameworkData(BaseModel, arbitrary_types_allowed=True): """ stats: Dict[str, Any] = Field(default_factory=dict) "Enables complex stats collection across multiple turns." - slot_manager: SlotManager = Field(default_factory=SlotManager) + slot_manager: SlotManager = Field(default_factory=dict, validate_default=True) "Stores extracted slots." diff --git a/chatsky/core/pipeline.py b/chatsky/core/pipeline.py index 5c3ee93bc8..c9c146bfe0 100644 --- a/chatsky/core/pipeline.py +++ b/chatsky/core/pipeline.py @@ -22,7 +22,7 @@ from chatsky.context_storages import DBContextStorage, MemoryContextStorage from chatsky.messengers.console import CLIMessengerInterface from chatsky.messengers.common import MessengerInterface -from chatsky.slots.slots import GroupSlot +# to TYPE_CHECKING from chatsky.core.service.group import ServiceGroup, ServiceGroupInitTypes from chatsky.core.service.extra import ComponentExtraHandlerInitTypes, BeforeHandler, AfterHandler from .service import Service @@ -32,6 +32,7 @@ from chatsky.core.script_parsing import JSONImporter, Path if TYPE_CHECKING: + from chatsky.slots.slots import GroupSlot from chatsky.llm.llm_api import LLM_API logger = logging.getLogger(__name__) @@ -78,7 +79,7 @@ class Pipeline(BaseModel, extra="forbid", arbitrary_types_allowed=True): Defaults to ``1.0``. """ - slots: GroupSlot = Field(default_factory=GroupSlot) + slots: GroupSlot = Field(default_factory=dict, validate_default=True) """ Slots configuration. """ diff --git a/chatsky/slots/llm.py b/chatsky/slots/llm.py index 8fe3c609dd..9b18f00326 100644 --- a/chatsky/slots/llm.py +++ b/chatsky/slots/llm.py @@ -37,7 +37,7 @@ class LLMSlot(ValueSlot, frozen=True): llm_model_name: str = "" history: int = 0 - def __init__(self, caption, return_type, llm_model_name="", history=0): + def __init__(self, caption, return_type=str, llm_model_name="", history=0): super().__init__(caption=caption, return_type=return_type, llm_model_name=llm_model_name, history=history) async def extract_value(self, ctx: Context) -> Union[str, SlotNotExtracted]: From 3048fa678f0e834852779d838f845067a266fba4 Mon Sep 17 00:00:00 2001 From: NotBioWaste905 Date: Wed, 30 Apr 2025 11:25:17 +0300 Subject: [PATCH 10/19] fix missing awaits, update tutorial --- chatsky/core/pipeline.py | 1 + chatsky/slots/llm.py | 25 ++++++++++++------------- tests/llm/test_llm.py | 1 - tutorials/llm/5_llm_slots.py | 8 +++++--- 4 files changed, 18 insertions(+), 17 deletions(-) diff --git a/chatsky/core/pipeline.py b/chatsky/core/pipeline.py index c9c146bfe0..d02667be82 100644 --- a/chatsky/core/pipeline.py +++ b/chatsky/core/pipeline.py @@ -22,6 +22,7 @@ from chatsky.context_storages import DBContextStorage, MemoryContextStorage from chatsky.messengers.console import CLIMessengerInterface from chatsky.messengers.common import MessengerInterface + # to TYPE_CHECKING from chatsky.core.service.group import ServiceGroup, ServiceGroupInitTypes from chatsky.core.service.extra import ComponentExtraHandlerInitTypes, BeforeHandler, AfterHandler diff --git a/chatsky/slots/llm.py b/chatsky/slots/llm.py index 9b18f00326..e7120467bc 100644 --- a/chatsky/slots/llm.py +++ b/chatsky/slots/llm.py @@ -45,11 +45,11 @@ async def extract_value(self, ctx: Context) -> Union[str, SlotNotExtracted]: if request_text == "": return SlotNotExtracted() - history_messages = context_to_history( + history_messages = await context_to_history( ctx, self.history, filter_func=FromModel(), llm_model_name=self.llm_model_name, max_size=1000 ) if history_messages == []: - history_messages = [message_to_langchain(ctx.last_request, ctx)] + history_messages = [await message_to_langchain(ctx.last_request, ctx)] # Dynamically create a Pydantic model based on the caption return_type = self.return_type @@ -72,6 +72,7 @@ class LLMGroupSlot(GroupSlot): __pydantic_extra__: Dict[str, Union[LLMSlot, "LLMGroupSlot"]] llm_model_name: str + history: int = 0 async def get_value(self, ctx: Context) -> ExtractedGroupSlot: request_text = ctx.last_request.text @@ -80,13 +81,13 @@ async def get_value(self, ctx: Context) -> ExtractedGroupSlot: # Get all slots grouped by their model names model_groups = self._group_slots_by_model(self) - + # Process each model group separately all_results = {} for model_name, slots in model_groups.items(): if not slots: continue - + # Create dynamic model for this group captions = {} for child_name, slot_item in slots.items(): @@ -95,11 +96,11 @@ async def get_value(self, ctx: Context) -> ExtractedGroupSlot: DynamicGroupModel = create_model("DynamicGroupModel", **captions) logger.debug(f"DynamicGroupModel for {model_name}: {DynamicGroupModel}") - history_messages = context_to_history( + history_messages = await context_to_history( ctx, self.history, filter_func=FromModel(), llm_model_name=model_name, max_size=1000 ) if history_messages == []: - history_messages = [message_to_langchain(ctx.last_request, ctx)] + history_messages = [await message_to_langchain(ctx.last_request, ctx)] # Get model and process request model = ctx.pipeline.models.get(model_name) @@ -107,12 +108,10 @@ async def get_value(self, ctx: Context) -> ExtractedGroupSlot: logger.warning(f"Model {model_name} not found in pipeline.models") continue - result: Message = await model._ainvoke( - history=history_messages, message_schema=DynamicGroupModel - ) + result: Message = await model._ainvoke(history=history_messages, message_schema=DynamicGroupModel) result_json = result.model_dump() logger.debug(f"Result JSON for {model_name}: {result_json}") - + # Add results to all_results all_results.update(result_json) @@ -146,10 +145,10 @@ def _group_slots_by_model(self, slot, parent_key="") -> Dict[str, Dict[str, LLMS of slot paths to slot objects. """ model_groups = {} - + for key, value in slot.__pydantic_extra__.items(): new_key = f"{parent_key}.{key}" if parent_key else key - + if isinstance(value, LLMGroupSlot): # Recursively process nested group slots nested_groups = self._group_slots_by_model(value, new_key) @@ -163,7 +162,7 @@ def _group_slots_by_model(self, slot, parent_key="") -> Dict[str, Dict[str, LLMS if model_name not in model_groups: model_groups[model_name] = {} model_groups[model_name][new_key] = value - + return model_groups def _dict_to_extracted_slots(self, d): diff --git a/tests/llm/test_llm.py b/tests/llm/test_llm.py index c1c8fbf03d..f30d99ba63 100644 --- a/tests/llm/test_llm.py +++ b/tests/llm/test_llm.py @@ -466,7 +466,6 @@ async def test_llm_slot(self, pipeline, context): print(f"Extracted result: {result}") assert isinstance(result, str) - async def test_llm_group_slot(self, pipeline, context): slot = LLMGroupSlot( llm_model_name="test_model", diff --git a/tutorials/llm/5_llm_slots.py b/tutorials/llm/5_llm_slots.py index 5a4522ecbc..f3e6a66de1 100644 --- a/tutorials/llm/5_llm_slots.py +++ b/tutorials/llm/5_llm_slots.py @@ -67,10 +67,12 @@ SLOTS = { "person": LLMGroupSlot( username=LLMSlot(caption="User's username in uppercase"), - job=LLMSlot(llm_model_name="another_slot_model", - caption="User's occupation, job, profession"), + job=LLMSlot( + llm_model_name="another_slot_model", + caption="User's occupation, job, profession", + ), age=LLMSlot(caption="User's age", return_type=int), - model="slot_model", + llm_model_name="slot_model", allow_partial_extraction=True, ) } From d679927764b2f516471caa0f5d4bd88393066579 Mon Sep 17 00:00:00 2001 From: NotBioWaste905 Date: Mon, 12 May 2025 13:46:40 +0300 Subject: [PATCH 11/19] fix groupslot test --- tests/llm/test_llm.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/llm/test_llm.py b/tests/llm/test_llm.py index f30d99ba63..ab8afd191d 100644 --- a/tests/llm/test_llm.py +++ b/tests/llm/test_llm.py @@ -72,7 +72,10 @@ def __init__(self, root_model): async def ainvoke(self, history): if isinstance(history, list): - inst = self.root(history=history) + fields = {} + for field in self.root.model_fields: + fields[field] = f"history: {len(history)}" + inst = self.root(**fields) else: # For LLMSlot fields = {} @@ -457,13 +460,14 @@ async def test_llm_slot(self, pipeline, context): # Test normal request context.requests[5] = "test request" result = await slot.extract_value(context) + print(f"Extracted normal request result: {result}") assert isinstance(result, str) # Test request with history slot = LLMSlot(caption="test_caption", llm_model_name="test_model", history=2) context.requests[5] = "test request with history" result = await slot.extract_value(context) - print(f"Extracted result: {result}") + print(f"Extracted request with history result: {result}") assert isinstance(result, str) async def test_llm_group_slot(self, pipeline, context): @@ -482,6 +486,6 @@ async def test_llm_group_slot(self, pipeline, context): print(f"Extracted result: {result}") - assert result.name.extracted_value == "test_data" - assert result.age.extracted_value == "test_data" - assert result.nested.city.extracted_value == "test_data" + assert result.name.extracted_value == "history: 1" + assert result.age.extracted_value == "history: 1" + assert result.nested.city.extracted_value == "history: 1" From 34d4ee538d43a78d6453dbf03885715d92926f9a Mon Sep 17 00:00:00 2001 From: NotBioWaste905 Date: Thu, 15 May 2025 19:05:52 +0300 Subject: [PATCH 12/19] Enhance LLMSlot and LLMGroupSlot functionality by updating prompt handling and integrating DefaultFilter for context retrieval --- chatsky/slots/llm.py | 28 +++++++++++++++----- tests/llm/test_llm.py | 60 ++++++++++++++++++++++++++++++------------- 2 files changed, 64 insertions(+), 24 deletions(-) diff --git a/chatsky/slots/llm.py b/chatsky/slots/llm.py index e7120467bc..15fcb23d92 100644 --- a/chatsky/slots/llm.py +++ b/chatsky/slots/llm.py @@ -14,9 +14,10 @@ from pydantic import BaseModel, Field, create_model -from chatsky.llm.langchain_context import context_to_history, message_to_langchain -from chatsky.llm.filters import FromModel +from chatsky.llm.langchain_context import context_to_history, message_to_langchain, get_langchain_context +from chatsky.llm.filters import DefaultFilter from chatsky.slots.slots import ValueSlot, SlotNotExtracted, GroupSlot, ExtractedGroupSlot, ExtractedValueSlot +from chatsky.llm.prompt import Prompt if TYPE_CHECKING: from chatsky.core import Context @@ -35,7 +36,13 @@ class LLMSlot(ValueSlot, frozen=True): caption: str return_type: type = str llm_model_name: str = "" - history: int = 0 + prompt: Prompt = Field( + default="You are an expert extraction algorithm. " + "Only extract relevant information from the text. " + "If you do not know the value of an attribute asked to extract, " + "return null for the attribute's value.", + validate_default=True, + ) def __init__(self, caption, return_type=str, llm_model_name="", history=0): super().__init__(caption=caption, return_type=return_type, llm_model_name=llm_model_name, history=history) @@ -45,10 +52,17 @@ async def extract_value(self, ctx: Context) -> Union[str, SlotNotExtracted]: if request_text == "": return SlotNotExtracted() - history_messages = await context_to_history( - ctx, self.history, filter_func=FromModel(), llm_model_name=self.llm_model_name, max_size=1000 + history_messages = await get_langchain_context( + system_prompt=ctx.pipeline.models[self.llm_model_name].system_prompt, + call_prompt=self.prompt, + ctx=ctx, + length=self.history, + filter_func=DefaultFilter(), + llm_model_name=self.llm_model_name, + max_size=1000, ) if history_messages == []: + print("No history messages found, using last request") history_messages = [await message_to_langchain(ctx.last_request, ctx)] # Dynamically create a Pydantic model based on the caption return_type = self.return_type @@ -56,6 +70,8 @@ async def extract_value(self, ctx: Context) -> Union[str, SlotNotExtracted]: class DynamicModel(BaseModel): value: return_type = Field(description=self.caption) + print(f"History messages: {history_messages}") + result: DynamicModel = await ctx.pipeline.models[self.llm_model_name]._ainvoke( history=history_messages, message_schema=DynamicModel ) @@ -97,7 +113,7 @@ async def get_value(self, ctx: Context) -> ExtractedGroupSlot: logger.debug(f"DynamicGroupModel for {model_name}: {DynamicGroupModel}") history_messages = await context_to_history( - ctx, self.history, filter_func=FromModel(), llm_model_name=model_name, max_size=1000 + ctx, self.history, filter_func=DefaultFilter(), llm_model_name=model_name, max_size=1000 ) if history_messages == []: history_messages = [await message_to_langchain(ctx.last_request, ctx)] diff --git a/tests/llm/test_llm.py b/tests/llm/test_llm.py index ab8afd191d..e0d070d6c9 100644 --- a/tests/llm/test_llm.py +++ b/tests/llm/test_llm.py @@ -17,7 +17,7 @@ if not langchain_available: pytest.skip(allow_module_level=True, reason="Langchain not available.") -from chatsky.llm._langchain_imports import AIMessage, LLMResult, HumanMessage, SystemMessage +from chatsky.llm._langchain_imports import AIMessage, LLMResult, HumanMessage, SystemMessage, BaseMessage from langchain_core.outputs.chat_generation import ChatGeneration @@ -71,25 +71,22 @@ def __init__(self, root_model): self.root = root_model async def ainvoke(self, history): - if isinstance(history, list): - fields = {} - for field in self.root.model_fields: - fields[field] = f"history: {len(history)}" - inst = self.root(**fields) - else: - # For LLMSlot - fields = {} - for field in self.root.model_fields: - fields[field] = "test_data" - inst = self.root(**fields) + fields = {} + print(f"Root model fields: {self.root}") + print(f"History: {history}") + for field in self.root.model_fields: + if field == "history": + fields[field] = history + elif self.root.model_fields[field].annotation is int: + fields[field] = len(history) + elif self.root.model_fields[field].annotation is str: + fields[field] = str(history) + inst = self.root(**fields) return inst - def with_structured_output(self, message_schema): - return message_schema - class MessageSchema(BaseModel): - history: list[str] + history: list[BaseMessage] def __call__(self): return self.model_dump() @@ -131,13 +128,17 @@ async def test_structured_output(self, monkeypatch, mock_structured_model): llm_api = LLM_API(MockChatOpenAI()) # Test data - history = ["message1", "message2"] + history = [HumanMessage("message1"), AIMessage("message2")] # Call the respond method result = await llm_api.respond(message_schema=MessageSchema, history=history) + print(f"Result: {result}") + # Assert the result - expected_result = Message(text='{"history":["message1","message2"]}') + expected_result = Message( + text='{"history":[{"content":"message1","additional_kwargs":{},"response_metadata":{},"type":"human","name":null,"id":null},{"content":"message2","additional_kwargs":{},"response_metadata":{},"type":"ai","name":null,"id":null}]}' + ) assert result == expected_result @@ -262,6 +263,17 @@ async def test_context_to_history(self, context): ] assert res == expected + res = await context_to_history( + ctx=context, length=2, filter_func=DefaultFilter(), llm_model_name="test_model", max_size=100 + ) + expected = [ + HumanMessage(content=[{"type": "text", "text": "Request 2"}]), + AIMessage(content=[{"type": "text", "text": "Response 2"}]), + HumanMessage(content=[{"type": "text", "text": "Request 3"}]), + AIMessage(content=[{"type": "text", "text": "Response 3"}]), + ] + assert res == expected + async def test_context_with_response_to_history(self, filter_context): res = await context_to_history( ctx=filter_context, length=-1, filter_func=DefaultFilter(), llm_model_name="test_model", max_size=100 @@ -457,12 +469,16 @@ async def test_llm_slot(self, pipeline, context): context.requests[5] = "" assert isinstance(await slot.extract_value(context), SlotNotExtracted) + print("------Test with history=1-------") + # Test normal request context.requests[5] = "test request" result = await slot.extract_value(context) print(f"Extracted normal request result: {result}") assert isinstance(result, str) + print("------Test with history=2-------") + # Test request with history slot = LLMSlot(caption="test_caption", llm_model_name="test_model", history=2) context.requests[5] = "test request with history" @@ -470,6 +486,14 @@ async def test_llm_slot(self, pipeline, context): print(f"Extracted request with history result: {result}") assert isinstance(result, str) + print("------Test with history=2 and return_type=int-------") + + slot = LLMSlot(caption="test_caption", return_type=int, llm_model_name="test_model", history=2) + context.requests[5] = "test request with history" + result = await slot.extract_value(context) + print(f"Extracted request with history result: {result}") + assert result == 5 + async def test_llm_group_slot(self, pipeline, context): slot = LLMGroupSlot( llm_model_name="test_model", From ca0e5f0d6e17ea60d0c9ce8f7b309f6bbefb3f1b Mon Sep 17 00:00:00 2001 From: NotBioWaste905 Date: Thu, 15 May 2025 19:27:43 +0300 Subject: [PATCH 13/19] Add history attribute to LLMSlot and update prompt handling in tests --- chatsky/slots/llm.py | 3 ++- tests/llm/test_llm.py | 41 +++++++++++++++++++++++++++-------------- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/chatsky/slots/llm.py b/chatsky/slots/llm.py index 15fcb23d92..888196514a 100644 --- a/chatsky/slots/llm.py +++ b/chatsky/slots/llm.py @@ -43,6 +43,7 @@ class LLMSlot(ValueSlot, frozen=True): "return null for the attribute's value.", validate_default=True, ) + history: int = 0 def __init__(self, caption, return_type=str, llm_model_name="", history=0): super().__init__(caption=caption, return_type=return_type, llm_model_name=llm_model_name, history=history) @@ -53,7 +54,7 @@ async def extract_value(self, ctx: Context) -> Union[str, SlotNotExtracted]: return SlotNotExtracted() history_messages = await get_langchain_context( - system_prompt=ctx.pipeline.models[self.llm_model_name].system_prompt, + system_prompt=await ctx.pipeline.models[self.llm_model_name].system_prompt(ctx), call_prompt=self.prompt, ctx=ctx, length=self.history, diff --git a/tests/llm/test_llm.py b/tests/llm/test_llm.py index e0d070d6c9..a4d17af177 100644 --- a/tests/llm/test_llm.py +++ b/tests/llm/test_llm.py @@ -137,7 +137,10 @@ async def test_structured_output(self, monkeypatch, mock_structured_model): # Assert the result expected_result = Message( - text='{"history":[{"content":"message1","additional_kwargs":{},"response_metadata":{},"type":"human","name":null,"id":null},{"content":"message2","additional_kwargs":{},"response_metadata":{},"type":"ai","name":null,"id":null}]}' + text='{"history":[{"content":"message1","additional_kwargs":{},' + '"response_metadata":{},"type":"human","name":null,"id":null},' + '{"content":"message2","additional_kwargs":{},' + '"response_metadata":{},"type":"ai","name":null,"id":null}]}' ) assert result == expected_result @@ -462,23 +465,22 @@ async def test_logprob_method(self, filter_context, llmresult): class TestSlots: - async def test_llm_slot(self, pipeline, context): + async def test_empty_llm_slot(self, context): + # Test empty request slot = LLMSlot(caption="test_caption", llm_model_name="test_model") context.current_turn_id = 5 - # Test empty request context.requests[5] = "" assert isinstance(await slot.extract_value(context), SlotNotExtracted) - print("------Test with history=1-------") - + async def test_llm_slot(self, context): # Test normal request + slot = LLMSlot(caption="test_caption", llm_model_name="test_model") context.requests[5] = "test request" result = await slot.extract_value(context) print(f"Extracted normal request result: {result}") assert isinstance(result, str) - print("------Test with history=2-------") - + async def test_llm_slot_with_history(self, context): # Test request with history slot = LLMSlot(caption="test_caption", llm_model_name="test_model", history=2) context.requests[5] = "test request with history" @@ -486,15 +488,14 @@ async def test_llm_slot(self, pipeline, context): print(f"Extracted request with history result: {result}") assert isinstance(result, str) - print("------Test with history=2 and return_type=int-------") - + async def test_int_llm_slot(self, context): slot = LLMSlot(caption="test_caption", return_type=int, llm_model_name="test_model", history=2) context.requests[5] = "test request with history" result = await slot.extract_value(context) print(f"Extracted request with history result: {result}") - assert result == 5 + assert result == 8 - async def test_llm_group_slot(self, pipeline, context): + async def test_llm_group_slot(self, context): slot = LLMGroupSlot( llm_model_name="test_model", name=LLMSlot(caption="Extract person's name"), @@ -510,6 +511,18 @@ async def test_llm_group_slot(self, pipeline, context): print(f"Extracted result: {result}") - assert result.name.extracted_value == "history: 1" - assert result.age.extracted_value == "history: 1" - assert result.nested.city.extracted_value == "history: 1" + assert ( + result.name.extracted_value == "[HumanMessage(content=[{'type': 'text', " + "'text': 'John is 25 years old and lives in New York'}], " + "additional_kwargs={}, response_metadata={})]" + ) + assert ( + result.age.extracted_value == "[HumanMessage(content=[{'type': 'text', 'text': " + "'John is 25 years old and lives in New York'}], " + "additional_kwargs={}, response_metadata={})]" + ) + assert ( + result.nested.city.extracted_value == "[HumanMessage(content=[{'type': 'text', 'text': '" + "John is 25 years old and lives in New York'}], " + "additional_kwargs={}, response_metadata={})]" + ) From 53f4c7128592101725123bd7c3a625ebcfacaa65 Mon Sep 17 00:00:00 2001 From: NotBioWaste905 Date: Thu, 15 May 2025 20:10:30 +0300 Subject: [PATCH 14/19] fix condition --- tutorials/llm/5_llm_slots.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tutorials/llm/5_llm_slots.py b/tutorials/llm/5_llm_slots.py index f3e6a66de1..71edbd8449 100644 --- a/tutorials/llm/5_llm_slots.py +++ b/tutorials/llm/5_llm_slots.py @@ -80,7 +80,7 @@ script = { GLOBAL: { TRANSITIONS: [ - Tr(dst=("user_flow", "ask"), cnd=cnd.Regexp(r"^[sS]tart")) + Tr(dst=("user_flow", "ask"), cnd=cnd.Regexp(pattern=r"^[sS]tart")) ] }, "user_flow": { From 453bd05def2a95904e38ff9dcf322364dfbc9d8f Mon Sep 17 00:00:00 2001 From: NotBioWaste905 Date: Fri, 16 May 2025 16:51:47 +0300 Subject: [PATCH 15/19] add a few words about llm slot prompting --- tutorials/llm/5_llm_slots.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tutorials/llm/5_llm_slots.py b/tutorials/llm/5_llm_slots.py index 71edbd8449..63e9fd3d96 100644 --- a/tutorials/llm/5_llm_slots.py +++ b/tutorials/llm/5_llm_slots.py @@ -64,9 +64,15 @@ ChatOpenAI(model="gpt-4.1-nano", api_key=openai_api_key, temperature=0) ) +# You can pass additional prompts to the LLMSlot and LLMGroupSlot +# using the `prompt` parameter to fine-tune the extraction process. SLOTS = { "person": LLMGroupSlot( - username=LLMSlot(caption="User's username in uppercase"), + username=LLMSlot( + caption="User's username in uppercase", + prompt="You are an expert extraction algorithm." + "Extract the user's full name that can be scattered troughout the text.", + ), job=LLMSlot( llm_model_name="another_slot_model", caption="User's occupation, job, profession", @@ -85,7 +91,7 @@ }, "user_flow": { LOCAL: { - PRE_TRANSITION: {"get_slot": proc.Extract("person")}, + PRE_TRANSITION: {"get_slot": proc.Extract(slots="person")}, TRANSITIONS: [ Tr( dst=("user_flow", "tell"), From 79329b30ef136ff586098a6ec0dffe800fabab34 Mon Sep 17 00:00:00 2001 From: NotBioWaste905 Date: Fri, 16 May 2025 16:54:17 +0300 Subject: [PATCH 16/19] Update documentation to clarify prompt usage for LLMSlot only --- tutorials/llm/5_llm_slots.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tutorials/llm/5_llm_slots.py b/tutorials/llm/5_llm_slots.py index 63e9fd3d96..62eb56d72a 100644 --- a/tutorials/llm/5_llm_slots.py +++ b/tutorials/llm/5_llm_slots.py @@ -64,7 +64,7 @@ ChatOpenAI(model="gpt-4.1-nano", api_key=openai_api_key, temperature=0) ) -# You can pass additional prompts to the LLMSlot and LLMGroupSlot +# You can pass additional prompts to the LLMSlot # using the `prompt` parameter to fine-tune the extraction process. SLOTS = { "person": LLMGroupSlot( From ac91cd0d5ef8e2d08b69ee1b35fe81bcd0ffe971 Mon Sep 17 00:00:00 2001 From: NotBioWaste905 Date: Fri, 16 May 2025 17:04:10 +0300 Subject: [PATCH 17/19] remove init from LLMSlot --- chatsky/slots/llm.py | 3 --- tutorials/llm/5_llm_slots.py | 3 ++- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/chatsky/slots/llm.py b/chatsky/slots/llm.py index 888196514a..0cf2050af7 100644 --- a/chatsky/slots/llm.py +++ b/chatsky/slots/llm.py @@ -45,9 +45,6 @@ class LLMSlot(ValueSlot, frozen=True): ) history: int = 0 - def __init__(self, caption, return_type=str, llm_model_name="", history=0): - super().__init__(caption=caption, return_type=return_type, llm_model_name=llm_model_name, history=history) - async def extract_value(self, ctx: Context) -> Union[str, SlotNotExtracted]: request_text = ctx.last_request.text if request_text == "": diff --git a/tutorials/llm/5_llm_slots.py b/tutorials/llm/5_llm_slots.py index 62eb56d72a..1dbb1a4285 100644 --- a/tutorials/llm/5_llm_slots.py +++ b/tutorials/llm/5_llm_slots.py @@ -71,7 +71,8 @@ username=LLMSlot( caption="User's username in uppercase", prompt="You are an expert extraction algorithm." - "Extract the user's full name that can be scattered troughout the text.", + "Extract the user's full name that can be " + "scattered troughout the text.", ), job=LLMSlot( llm_model_name="another_slot_model", From 343200d711d57dfa2ccde5d844f39ea42b244f6e Mon Sep 17 00:00:00 2001 From: NotBioWaste905 Date: Thu, 22 May 2025 20:10:01 +0300 Subject: [PATCH 18/19] Add prompt field to LLMGroupSlot and update slot extraction in tutorial --- chatsky/slots/llm.py | 8 ++++++++ tutorials/llm/5_llm_slots.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/chatsky/slots/llm.py b/chatsky/slots/llm.py index 0cf2050af7..fc6fefc050 100644 --- a/chatsky/slots/llm.py +++ b/chatsky/slots/llm.py @@ -86,6 +86,13 @@ class LLMGroupSlot(GroupSlot): __pydantic_extra__: Dict[str, Union[LLMSlot, "LLMGroupSlot"]] llm_model_name: str + prompt: Prompt = Field( + default="You are an expert extraction algorithm. " + "Only extract relevant information from the text. " + "If you do not know the value of an attribute asked to extract, " + "return null for the attribute's value.", + validate_default=True, + ) history: int = 0 async def get_value(self, ctx: Context) -> ExtractedGroupSlot: @@ -110,6 +117,7 @@ async def get_value(self, ctx: Context) -> ExtractedGroupSlot: DynamicGroupModel = create_model("DynamicGroupModel", **captions) logger.debug(f"DynamicGroupModel for {model_name}: {DynamicGroupModel}") + # swith to get_langchain_context history_messages = await context_to_history( ctx, self.history, filter_func=DefaultFilter(), llm_model_name=model_name, max_size=1000 ) diff --git a/tutorials/llm/5_llm_slots.py b/tutorials/llm/5_llm_slots.py index 1dbb1a4285..ef163abf92 100644 --- a/tutorials/llm/5_llm_slots.py +++ b/tutorials/llm/5_llm_slots.py @@ -92,7 +92,7 @@ }, "user_flow": { LOCAL: { - PRE_TRANSITION: {"get_slot": proc.Extract(slots="person")}, + PRE_TRANSITION: {"get_slot": proc.Extract(slots=["person"])}, TRANSITIONS: [ Tr( dst=("user_flow", "tell"), From 14980f73ab831e00ddbbaffbac1d720e16ccec8d Mon Sep 17 00:00:00 2001 From: NotBioWaste905 Date: Fri, 23 May 2025 14:20:47 +0300 Subject: [PATCH 19/19] fixed parameters related bugs --- tutorials/llm/5_llm_slots.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tutorials/llm/5_llm_slots.py b/tutorials/llm/5_llm_slots.py index ef163abf92..0fff12cdb8 100644 --- a/tutorials/llm/5_llm_slots.py +++ b/tutorials/llm/5_llm_slots.py @@ -96,7 +96,7 @@ TRANSITIONS: [ Tr( dst=("user_flow", "tell"), - cnd=cnd.SlotsExtracted("person"), + cnd=cnd.SlotsExtracted(slots=["person"]), priority=1.2, ), Tr(dst=("user_flow", "repeat_question"), priority=0.8), @@ -110,7 +110,7 @@ }, "tell": { RESPONSE: rsp.FilledTemplate( - "So you are {person.username}, {person.age} and your " + template="So you are {person.username}, {person.age} and your " "occupation is {person.job}, right?" ), TRANSITIONS: [Tr(dst=("user_flow", "ask"))],