From d3eda8ea78c9ef7cd22ec470b947078f768551f6 Mon Sep 17 00:00:00 2001 From: Ksenia Blokhina Date: Tue, 19 May 2026 14:45:26 +0200 Subject: [PATCH 01/25] 249-design-persona expand persona implementation --- .../simulator/llm/prompt/stop_prompt.py | 5 +++++ .../llm/prompt/utterance_generation_prompt.py | 5 +++++ usersimcrs/user_modeling/persona.py | 22 +++++++++++++++++-- usersimcrs/utils/simulation_utils.py | 14 +++++++++--- 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/usersimcrs/simulator/llm/prompt/stop_prompt.py b/usersimcrs/simulator/llm/prompt/stop_prompt.py index c776e27b..9565ca39 100644 --- a/usersimcrs/simulator/llm/prompt/stop_prompt.py +++ b/usersimcrs/simulator/llm/prompt/stop_prompt.py @@ -67,6 +67,11 @@ def build_new_prompt(self) -> str: ] ) initial_prompt += f"PERSONA: {stringified_characteristics}\n" + if self.persona.persona_description: + initial_prompt += ( + "PERSONA DESCRIPTION: " + f"{self.persona.persona_description}\n" + ) initial_prompt += "\nHISTORY:\n" return initial_prompt diff --git a/usersimcrs/simulator/llm/prompt/utterance_generation_prompt.py b/usersimcrs/simulator/llm/prompt/utterance_generation_prompt.py index 6f65a4e6..aba1420d 100644 --- a/usersimcrs/simulator/llm/prompt/utterance_generation_prompt.py +++ b/usersimcrs/simulator/llm/prompt/utterance_generation_prompt.py @@ -68,6 +68,11 @@ def build_new_prompt(self) -> str: ] ) initial_prompt += f"PERSONA: {stringified_characteristics}\n" + if self.persona.persona_description: + initial_prompt += ( + "PERSONA DESCRIPTION: " + f"{self.persona.persona_description}\n" + ) else: initial_prompt += ( "Be precise with the REQUIREMENTS, clear and concise.\n" diff --git a/usersimcrs/user_modeling/persona.py b/usersimcrs/user_modeling/persona.py index 33fc78fd..d04ba546 100644 --- a/usersimcrs/user_modeling/persona.py +++ b/usersimcrs/user_modeling/persona.py @@ -3,11 +3,29 @@ (e.g., patience, conscientiousness, or curiosity).""" from dataclasses import dataclass -from typing import Any, Dict +from typing import Any, Dict, Optional @dataclass class Persona: - """Represents personal user characteristics as key-value pairs.""" characteristics: Dict[str, Any] + persona_description: Optional[str] = None + + @classmethod + def from_config(cls, persona_config: Dict[str, Any]) -> "Persona": + """Creates a persona from a configuration dictionary. + + The configuration must provide characteristics under + ``characteristics`` and may include an optional ``persona_description``. + + Args: + persona_config: Persona configuration. + + Returns: + Persona instance. + """ + return cls( + persona_config.get("characteristics", {}), + persona_config.get("persona_description"), + ) diff --git a/usersimcrs/utils/simulation_utils.py b/usersimcrs/utils/simulation_utils.py index 003ffcae..05b13c69 100644 --- a/usersimcrs/utils/simulation_utils.py +++ b/usersimcrs/utils/simulation_utils.py @@ -30,6 +30,10 @@ ) from usersimcrs.simulator.agenda_based.interaction_model import InteractionModel from usersimcrs.llm_interfaces.llm_interface import LLMInterface +from usersimcrs.simulator.llm.prompt.stop_prompt import DEFAULT_STOP_DEFINITION +from usersimcrs.simulator.llm.prompt.utterance_generation_prompt import ( + DEFAULT_TASK_DEFINITION, +) from usersimcrs.user_modeling.persona import Persona from usersimcrs.user_modeling.simple_preference_model import ( SimplePreferenceModel, @@ -311,11 +315,13 @@ def _get_llm_single_prompt_user_simulator_config( llm_interface = get_llm_interface(config["llm_interface"].get()) - task_definition = config["task_definition"].get() + task_definition = config["task_definition"].get( + confuse.String(default=DEFAULT_TASK_DEFINITION) + ) persona = None if "persona" in config: - persona = Persona(config["persona"].get()) + persona = Persona.from_config(config["persona"].get()) return { "domain": domain, @@ -356,5 +362,7 @@ def _get_llm_dual_prompt_user_simulator_config( Configuration of the dual prompt user simulator. """ simulator_config = _get_llm_single_prompt_user_simulator_config(config) - simulator_config["stop_definition"] = config["stop_definition"].get() + simulator_config["stop_definition"] = config["stop_definition"].get( + confuse.String(default=DEFAULT_STOP_DEFINITION) + ) return simulator_config From eda77630255f1ee0e01826225b4df651902b7386 Mon Sep 17 00:00:00 2001 From: Ksenia Blokhina Date: Tue, 19 May 2026 14:53:29 +0200 Subject: [PATCH 02/25] 249-design-persona expand persona implementation --- usersimcrs/utils/simulation_utils.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/usersimcrs/utils/simulation_utils.py b/usersimcrs/utils/simulation_utils.py index 05b13c69..c6f38ca9 100644 --- a/usersimcrs/utils/simulation_utils.py +++ b/usersimcrs/utils/simulation_utils.py @@ -30,10 +30,6 @@ ) from usersimcrs.simulator.agenda_based.interaction_model import InteractionModel from usersimcrs.llm_interfaces.llm_interface import LLMInterface -from usersimcrs.simulator.llm.prompt.stop_prompt import DEFAULT_STOP_DEFINITION -from usersimcrs.simulator.llm.prompt.utterance_generation_prompt import ( - DEFAULT_TASK_DEFINITION, -) from usersimcrs.user_modeling.persona import Persona from usersimcrs.user_modeling.simple_preference_model import ( SimplePreferenceModel, @@ -315,9 +311,7 @@ def _get_llm_single_prompt_user_simulator_config( llm_interface = get_llm_interface(config["llm_interface"].get()) - task_definition = config["task_definition"].get( - confuse.String(default=DEFAULT_TASK_DEFINITION) - ) + task_definition = config["task_definition"].get() persona = None if "persona" in config: @@ -362,7 +356,5 @@ def _get_llm_dual_prompt_user_simulator_config( Configuration of the dual prompt user simulator. """ simulator_config = _get_llm_single_prompt_user_simulator_config(config) - simulator_config["stop_definition"] = config["stop_definition"].get( - confuse.String(default=DEFAULT_STOP_DEFINITION) - ) + simulator_config["stop_definition"] = config["stop_definition"].get() return simulator_config From 5b646dd30fd6545cd89ae9e107f5b1eaa93a0e69 Mon Sep 17 00:00:00 2001 From: Ksenia Blokhina Date: Tue, 19 May 2026 18:06:46 +0200 Subject: [PATCH 03/25] add preference model --- .../structured_preference_model.py | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 usersimcrs/user_modeling/structured_preference_model.py diff --git a/usersimcrs/user_modeling/structured_preference_model.py b/usersimcrs/user_modeling/structured_preference_model.py new file mode 100644 index 00000000..b5d8f49e --- /dev/null +++ b/usersimcrs/user_modeling/structured_preference_model.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from typing import Any, Dict, Iterable, List, Tuple + +from usersimcrs.core.simulation_domain import SimulationDomain +from usersimcrs.items.item_collection import ItemCollection +from usersimcrs.items.ratings import Ratings +from usersimcrs.user_modeling.preference_model import PreferenceModel + + +class StructuredPreferenceModel(PreferenceModel): + """Preference model based on historical ratings and item metadata.""" + + def __init__( + self, + domain: SimulationDomain, + item_collection: ItemCollection, + historical_ratings: Ratings, + historical_user_id: str = None, + preference_threshold: float = PreferenceModel.PREFERENCE_THRESHOLD, + ) -> None: + """Initializes structured preferences. + + Args: + domain: Domain. + item_collection: Item collection. + historical_ratings: Historical ratings. + historical_user_id: Historical user ID. Defaults to None. + preference_threshold: Minimum absolute score to store as + meaningful preference. + """ + super().__init__( + domain, item_collection, historical_ratings, historical_user_id + ) + self._preference_threshold = preference_threshold + self._preferences: Dict[str, Dict[str, float]] = {} + self._initialize_preferences() + + def _initialize_preferences(self) -> None: + """Initializes preferences from historical ratings.""" + slot_value_ratings = self._collect_slot_value_ratings() + self._preferences = self._average_slot_value_ratings(slot_value_ratings) + + def _collect_slot_value_ratings(self) -> Dict[str, Dict[str, List[float]]]: + """Collects historical ratings for each slot-value pair.""" + slot_value_ratings: Dict[str, Dict[str, List[float]]] = {} + for item_id, rating in self._get_user_ratings().items(): + item = self._item_collection.get_item(item_id) + if item is None: + continue + + for slot, value in self._iter_preference_values(item): + slot_value_ratings.setdefault(slot, {}).setdefault( + value, [] + ).append(rating) + return slot_value_ratings + + def _average_slot_value_ratings( + self, slot_value_ratings: Dict[str, Dict[str, List[float]]] + ) -> Dict[str, Dict[str, float]]: + """Converts collected ratings into average preference scores.""" + preferences: Dict[str, Dict[str, float]] = {} + for slot, value_ratings in slot_value_ratings.items(): + for value, ratings in value_ratings.items(): + score = sum(ratings) / len(ratings) + if abs(score) >= self._preference_threshold: + preferences.setdefault(slot, {})[value] = score + return preferences + + def _get_user_ratings(self) -> Dict[str, float]: + """Returns historical ratings for the selected user.""" + return self._historical_ratings.get_user_ratings( + self._historical_user_id + ) + + def _iter_preference_values(self, item) -> Iterable[Tuple[str, str]]: + """Yields preference-relevant slot-value pairs for an item.""" + for slot in self._domain.get_slot_names(): + if slot.upper() in {"TITLE", "NAME"}: + continue + value = item.get_property(slot) + if value is None: + continue + for normalized_value in self._normalize_values(value): + yield self._normalize_key(slot), self._normalize_key( + normalized_value + ) + + @staticmethod + def _normalize_values(value: Any) -> List[str]: + """Normalizes single and multi-value item properties.""" + if isinstance(value, list): + return [str(v) for v in value if str(v)] + return [str(value)] + + @staticmethod + def _normalize_key(value: str) -> str: + """Normalizes preference dictionary keys.""" + return str(value).strip().lower() + + def get_item_preference(self, item_id: str) -> float: + """Returns preference score inferred from the item's slot values.""" + self._assert_item_exists(item_id) + item = self._item_collection.get_item(item_id) + if item is None: + return 0 + + scores = [ + self._preferences[slot][value] + for slot, value in self._iter_preference_values(item) + if value in self._preferences.get(slot, {}) + ] + return sum(scores) / len(scores) if scores else 0 + + def get_slot_value_preference(self, slot: str, value: str) -> float: + """Returns preference score for a slot-value pair.""" + self._assert_slot_exists(slot) + slot_key = self._normalize_key(slot) + value_key = self._normalize_key(value) + return self._preferences.get(slot_key, {}).get(value_key, 0) + + def get_slot_preference(self, slot: str) -> Tuple[str, float]: + """Returns the strongest positive slot-value preference.""" + self._assert_slot_exists(slot) + slot_key = self._normalize_key(slot) + positive_preferences = [ + (value, score) + for value, score in self._preferences.get(slot_key, {}).items() + if score > self.PREFERENCE_THRESHOLD + ] + if not positive_preferences: + return None, 0 + return max(positive_preferences, key=lambda item: item[1]) From 1e98d4bdbe0bc7c08b651dd3185b2be4380ec88f Mon Sep 17 00:00:00 2001 From: Ksenia Blokhina Date: Tue, 26 May 2026 10:35:04 +0200 Subject: [PATCH 04/25] improve preference model --- usersimcrs/user_modeling/__init__.py | 4 + .../structured_preference_model.py | 86 +++++++------------ 2 files changed, 37 insertions(+), 53 deletions(-) diff --git a/usersimcrs/user_modeling/__init__.py b/usersimcrs/user_modeling/__init__.py index 233b4c33..7de59057 100644 --- a/usersimcrs/user_modeling/__init__.py +++ b/usersimcrs/user_modeling/__init__.py @@ -6,6 +6,9 @@ from usersimcrs.user_modeling.simple_preference_model import ( SimplePreferenceModel, ) +from usersimcrs.user_modeling.structured_preference_model import ( + StructuredPreferenceModel, +) from usersimcrs.user_modeling.persona import Persona __all__ = [ @@ -13,5 +16,6 @@ "PreferenceModel", "PKGPreferenceModel", "SimplePreferenceModel", + "StructuredPreferenceModel", "Persona", ] diff --git a/usersimcrs/user_modeling/structured_preference_model.py b/usersimcrs/user_modeling/structured_preference_model.py index b5d8f49e..594fde4a 100644 --- a/usersimcrs/user_modeling/structured_preference_model.py +++ b/usersimcrs/user_modeling/structured_preference_model.py @@ -1,11 +1,16 @@ from __future__ import annotations -from typing import Any, Dict, Iterable, List, Tuple +from typing import Dict, Iterable, List, Tuple + +from dialoguekit.participant.user_preferences import UserPreferences from usersimcrs.core.simulation_domain import SimulationDomain from usersimcrs.items.item_collection import ItemCollection from usersimcrs.items.ratings import Ratings -from usersimcrs.user_modeling.preference_model import PreferenceModel +from usersimcrs.user_modeling.preference_model import ( + KEY_ITEM_ID, + PreferenceModel, +) class StructuredPreferenceModel(PreferenceModel): @@ -33,13 +38,23 @@ def __init__( domain, item_collection, historical_ratings, historical_user_id ) self._preference_threshold = preference_threshold - self._preferences: Dict[str, Dict[str, float]] = {} + self._item_preferences = UserPreferences(self._user_id) + self._slot_value_preferences = UserPreferences(self._user_id) self._initialize_preferences() def _initialize_preferences(self) -> None: """Initializes preferences from historical ratings.""" + self._initialize_item_preferences() slot_value_ratings = self._collect_slot_value_ratings() - self._preferences = self._average_slot_value_ratings(slot_value_ratings) + self._initialize_slot_value_preferences(slot_value_ratings) + + def _initialize_item_preferences(self) -> None: + """Stores historical item ratings as item preferences.""" + for item_id, rating in self._get_user_ratings().items(): + if abs(rating) >= self._preference_threshold: + self._item_preferences.set_preference( + KEY_ITEM_ID, item_id, rating + ) def _collect_slot_value_ratings(self) -> Dict[str, Dict[str, List[float]]]: """Collects historical ratings for each slot-value pair.""" @@ -55,17 +70,17 @@ def _collect_slot_value_ratings(self) -> Dict[str, Dict[str, List[float]]]: ).append(rating) return slot_value_ratings - def _average_slot_value_ratings( + def _initialize_slot_value_preferences( self, slot_value_ratings: Dict[str, Dict[str, List[float]]] - ) -> Dict[str, Dict[str, float]]: - """Converts collected ratings into average preference scores.""" - preferences: Dict[str, Dict[str, float]] = {} + ) -> None: + """Stores average slot-value ratings as slot-value preferences.""" for slot, value_ratings in slot_value_ratings.items(): for value, ratings in value_ratings.items(): score = sum(ratings) / len(ratings) if abs(score) >= self._preference_threshold: - preferences.setdefault(slot, {})[value] = score - return preferences + self._slot_value_preferences.set_preference( + slot, value, score + ) def _get_user_ratings(self) -> Dict[str, float]: """Returns historical ratings for the selected user.""" @@ -81,53 +96,18 @@ def _iter_preference_values(self, item) -> Iterable[Tuple[str, str]]: value = item.get_property(slot) if value is None: continue - for normalized_value in self._normalize_values(value): - yield self._normalize_key(slot), self._normalize_key( - normalized_value - ) - - @staticmethod - def _normalize_values(value: Any) -> List[str]: - """Normalizes single and multi-value item properties.""" - if isinstance(value, list): - return [str(v) for v in value if str(v)] - return [str(value)] - - @staticmethod - def _normalize_key(value: str) -> str: - """Normalizes preference dictionary keys.""" - return str(value).strip().lower() + values = value if isinstance(value, list) else [value] + for slot_value in values: + yield slot, str(slot_value) def get_item_preference(self, item_id: str) -> float: - """Returns preference score inferred from the item's slot values.""" + """Returns preference score for an item.""" self._assert_item_exists(item_id) - item = self._item_collection.get_item(item_id) - if item is None: - return 0 - - scores = [ - self._preferences[slot][value] - for slot, value in self._iter_preference_values(item) - if value in self._preferences.get(slot, {}) - ] - return sum(scores) / len(scores) if scores else 0 + preference = self._item_preferences.get_preference(KEY_ITEM_ID, item_id) + return preference if preference is not None else 0 def get_slot_value_preference(self, slot: str, value: str) -> float: """Returns preference score for a slot-value pair.""" self._assert_slot_exists(slot) - slot_key = self._normalize_key(slot) - value_key = self._normalize_key(value) - return self._preferences.get(slot_key, {}).get(value_key, 0) - - def get_slot_preference(self, slot: str) -> Tuple[str, float]: - """Returns the strongest positive slot-value preference.""" - self._assert_slot_exists(slot) - slot_key = self._normalize_key(slot) - positive_preferences = [ - (value, score) - for value, score in self._preferences.get(slot_key, {}).items() - if score > self.PREFERENCE_THRESHOLD - ] - if not positive_preferences: - return None, 0 - return max(positive_preferences, key=lambda item: item[1]) + preference = self._slot_value_preferences.get_preference(slot, value) + return preference if preference is not None else 0 From 99a8af62d88796646dbe7b0b58b905c5818bae31 Mon Sep 17 00:00:00 2001 From: Ksenia Blokhina Date: Tue, 26 May 2026 18:03:11 +0200 Subject: [PATCH 05/25] dynamic model --- .../agenda_based/agenda_based_simulator.py | 1 + .../llm/llm_dual_prompt_user_simulator.py | 16 ++- .../llm/llm_single_prompt_user_simulator.py | 10 +- usersimcrs/simulator/llm/prompt/prompt.py | 24 +++- .../simulator/llm/prompt/stop_prompt.py | 10 +- .../llm/prompt/utterance_generation_prompt.py | 9 +- usersimcrs/simulator/user_simulator.py | 3 + usersimcrs/user_modeling/preference_model.py | 7 ++ .../structured_preference_model.py | 106 +++++++++++++++++- usersimcrs/utils/simulation_utils.py | 55 +++++++-- 10 files changed, 226 insertions(+), 15 deletions(-) diff --git a/usersimcrs/simulator/agenda_based/agenda_based_simulator.py b/usersimcrs/simulator/agenda_based/agenda_based_simulator.py index e4659b58..13be4b63 100644 --- a/usersimcrs/simulator/agenda_based/agenda_based_simulator.py +++ b/usersimcrs/simulator/agenda_based/agenda_based_simulator.py @@ -43,6 +43,7 @@ def __init__( """ super().__init__(id=id, domain=domain, item_collection=item_collection) self._preference_model = preference_model + self.preference_model = preference_model self._interaction_model = interaction_model self._interaction_model.initialize_agenda(self.information_need) self._nlu = nlu diff --git a/usersimcrs/simulator/llm/llm_dual_prompt_user_simulator.py b/usersimcrs/simulator/llm/llm_dual_prompt_user_simulator.py index 4036a57d..529b7238 100644 --- a/usersimcrs/simulator/llm/llm_dual_prompt_user_simulator.py +++ b/usersimcrs/simulator/llm/llm_dual_prompt_user_simulator.py @@ -19,6 +19,7 @@ UtteranceGenerationPrompt, ) from usersimcrs.simulator.user_simulator import UserSimulator +from usersimcrs.user_modeling.preference_model import PreferenceModel from usersimcrs.user_modeling.persona import Persona @@ -33,6 +34,7 @@ def __init__( task_definition: str = DEFAULT_TASK_DEFINITION, stop_definition: str = DEFAULT_STOP_DEFINITION, persona: Persona = None, + preference_model: PreferenceModel = None, ) -> None: """Initializes the user simulator. @@ -45,14 +47,24 @@ def __init__( stop_definition: Definition of the stop task. Defaults to DEFAULT_STOP_DEFINITION. persona: Persona of the user. Defaults to None. + preference_model: Preference model. Defaults to None. """ super().__init__(id, domain, item_collection) self.llm_interface = llm_interface + self.preference_model = preference_model self.generation_prompt = UtteranceGenerationPrompt( - self.information_need, item_type, task_definition, persona + self.information_need, + item_type, + task_definition, + persona, + preference_model, ) self.stop_prompt = StopPrompt( - self.information_need, item_type, stop_definition, persona + self.information_need, + item_type, + stop_definition, + persona, + preference_model, ) def _generate_response(self, agent_utterance: Utterance) -> Utterance: diff --git a/usersimcrs/simulator/llm/llm_single_prompt_user_simulator.py b/usersimcrs/simulator/llm/llm_single_prompt_user_simulator.py index 3d413242..c1934d0a 100644 --- a/usersimcrs/simulator/llm/llm_single_prompt_user_simulator.py +++ b/usersimcrs/simulator/llm/llm_single_prompt_user_simulator.py @@ -14,6 +14,7 @@ UtteranceGenerationPrompt, ) from usersimcrs.simulator.user_simulator import UserSimulator +from usersimcrs.user_modeling.preference_model import PreferenceModel from usersimcrs.user_modeling.persona import Persona @@ -27,6 +28,7 @@ def __init__( item_type: str, task_definition: str = DEFAULT_TASK_DEFINITION, persona: Persona = None, + preference_model: PreferenceModel = None, ) -> None: """Initializes the user simulator. @@ -37,11 +39,17 @@ def __init__( task_definition: Definition of the task to be performed. Defaults to DEFAULT_TASK_DEFINITION. persona: Persona of the user. Defaults to None. + preference_model: Preference model. Defaults to None. """ super().__init__(id, domain, item_collection) self.llm_interface = llm_interface + self.preference_model = preference_model self.prompt = UtteranceGenerationPrompt( - self.information_need, item_type, task_definition, persona + self.information_need, + item_type, + task_definition, + persona, + preference_model, ) def _generate_response(self, agent_utterance: Utterance) -> Utterance: diff --git a/usersimcrs/simulator/llm/prompt/prompt.py b/usersimcrs/simulator/llm/prompt/prompt.py index 619130ab..b58234f9 100644 --- a/usersimcrs/simulator/llm/prompt/prompt.py +++ b/usersimcrs/simulator/llm/prompt/prompt.py @@ -5,6 +5,7 @@ from dialoguekit.core.utterance import Utterance from dialoguekit.participant.participant import DialogueParticipant from usersimcrs.core.information_need import InformationNeed +from usersimcrs.user_modeling.preference_model import PreferenceModel from usersimcrs.user_modeling.persona import Persona @@ -15,6 +16,7 @@ def __init__( item_type: str, prompt_definition: str, persona: Persona = None, + preference_model: PreferenceModel = None, ) -> None: """Initializes the prompt. @@ -23,18 +25,38 @@ def __init__( item_type: The type of the item to be recommended. prompt_definition: The definition of the task to be performed. persona: The persona of the user. Defaults to None. + preference_model: Preference model. Defaults to None. """ self.information_need = information_need self.item_type = item_type self.prompt_definition = prompt_definition self.persona = persona + self.preference_model = preference_model self._initial_prompt = self.build_new_prompt() self._prompt_context = "" @property def prompt_text(self) -> str: """Prompt for the user simulator.""" - return self._initial_prompt + "\n" + self._prompt_context + return ( + self._initial_prompt + + "\n" + + self._preference_context + + "\n" + + self._prompt_context + ) + + @property + def _preference_context(self) -> str: + """Returns current preference context for the prompt.""" + if not self.preference_model: + return "" + + preference_summary = self.preference_model.get_preference_summary() + if not preference_summary: + return "" + + return f"USER PREFERENCES: {preference_summary}\n" @abstractmethod def build_new_prompt(self, **kwargs) -> str: diff --git a/usersimcrs/simulator/llm/prompt/stop_prompt.py b/usersimcrs/simulator/llm/prompt/stop_prompt.py index 9565ca39..b7dea761 100644 --- a/usersimcrs/simulator/llm/prompt/stop_prompt.py +++ b/usersimcrs/simulator/llm/prompt/stop_prompt.py @@ -2,6 +2,7 @@ from usersimcrs.core.information_need import InformationNeed from usersimcrs.simulator.llm.prompt.prompt import Prompt +from usersimcrs.user_modeling.preference_model import PreferenceModel from usersimcrs.user_modeling.persona import Persona DEFAULT_STOP_DEFINITION = ( @@ -22,6 +23,7 @@ def __init__( item_type: str, prompt_definition: str = DEFAULT_STOP_DEFINITION, persona: Persona = None, + preference_model: PreferenceModel = None, ) -> None: """Initializes the prompt. @@ -31,9 +33,14 @@ def __init__( prompt_definition: The definition of the task to be performed. Defaults to DEFAULT_STOP_DEFINITION. persona: The persona of the user. Defaults to None. + preference_model: Preference model. Defaults to None. """ super().__init__( - information_need, item_type, prompt_definition, persona + information_need, + item_type, + prompt_definition, + persona, + preference_model, ) @property @@ -42,6 +49,7 @@ def prompt_text(self) -> str: return ( self._initial_prompt + "\n" + + self._preference_context + self._prompt_context + "\n" + "CONTINUE: " diff --git a/usersimcrs/simulator/llm/prompt/utterance_generation_prompt.py b/usersimcrs/simulator/llm/prompt/utterance_generation_prompt.py index aba1420d..d1fb13cf 100644 --- a/usersimcrs/simulator/llm/prompt/utterance_generation_prompt.py +++ b/usersimcrs/simulator/llm/prompt/utterance_generation_prompt.py @@ -13,6 +13,7 @@ from dialoguekit.participant.participant import DialogueParticipant from usersimcrs.core.information_need import InformationNeed from usersimcrs.simulator.llm.prompt.prompt import Prompt +from usersimcrs.user_modeling.preference_model import PreferenceModel from usersimcrs.user_modeling.persona import Persona DEFAULT_TASK_DEFINITION = ( @@ -35,6 +36,7 @@ def __init__( item_type: str, prompt_definition: str = DEFAULT_TASK_DEFINITION, persona: Persona = None, + preference_model: PreferenceModel = None, ) -> None: """Initializes the prompt. @@ -44,9 +46,14 @@ def __init__( prompt_definition: The definition of the task to be performed. Defaults to DEFAULT_TASK_DEFINITION. persona: The persona of the user. Defaults to None. + preference_model: Preference model. Defaults to None. """ super().__init__( - information_need, item_type, prompt_definition, persona + information_need, + item_type, + prompt_definition, + persona, + preference_model, ) def build_new_prompt(self) -> str: diff --git a/usersimcrs/simulator/user_simulator.py b/usersimcrs/simulator/user_simulator.py index af238c4e..eff54677 100644 --- a/usersimcrs/simulator/user_simulator.py +++ b/usersimcrs/simulator/user_simulator.py @@ -53,4 +53,7 @@ def receive_utterance(self, utterance: Utterance) -> None: response = self._generate_response(utterance) if not isinstance(response, AnnotatedUtterance): response = AnnotatedUtterance.from_utterance(response) + preference_model = getattr(self, "preference_model", None) + if preference_model: + preference_model.update_from_dialogue(response) self._dialogue_connector.register_user_utterance(response) diff --git a/usersimcrs/user_modeling/preference_model.py b/usersimcrs/user_modeling/preference_model.py index a64cfe20..76d0c292 100644 --- a/usersimcrs/user_modeling/preference_model.py +++ b/usersimcrs/user_modeling/preference_model.py @@ -174,6 +174,13 @@ def get_slot_preference(self, slot: str) -> Tuple[str, float]: return value, preference + def get_preference_summary(self, max_preferences: int = 10) -> str: + """Returns a compact preference summary for prompts.""" + return "" + + def update_from_dialogue(self, dialogue) -> None: + """Updates preferences from dialogue if supported by the model.""" + @classmethod def load_preference_model(cls, path: str) -> PreferenceModel: """Loads preference model from a file. diff --git a/usersimcrs/user_modeling/structured_preference_model.py b/usersimcrs/user_modeling/structured_preference_model.py index 594fde4a..6dd0fbeb 100644 --- a/usersimcrs/user_modeling/structured_preference_model.py +++ b/usersimcrs/user_modeling/structured_preference_model.py @@ -16,6 +16,8 @@ class StructuredPreferenceModel(PreferenceModel): """Preference model based on historical ratings and item metadata.""" + UPDATE_WEIGHT = 0.3 + def __init__( self, domain: SimulationDomain, @@ -76,12 +78,17 @@ def _initialize_slot_value_preferences( """Stores average slot-value ratings as slot-value preferences.""" for slot, value_ratings in slot_value_ratings.items(): for value, ratings in value_ratings.items(): - score = sum(ratings) / len(ratings) + score = self._score_from_ratings(ratings) if abs(score) >= self._preference_threshold: self._slot_value_preferences.set_preference( slot, value, score ) + @staticmethod + def _score_from_ratings(ratings: List[float]) -> float: + """Computes preference importance as average normalized rating.""" + return sum(ratings) / len(ratings) + def _get_user_ratings(self) -> Dict[str, float]: """Returns historical ratings for the selected user.""" return self._historical_ratings.get_user_ratings( @@ -111,3 +118,100 @@ def get_slot_value_preference(self, slot: str, value: str) -> float: self._assert_slot_exists(slot) preference = self._slot_value_preferences.get_preference(slot, value) return preference if preference is not None else 0 + + def update_slot_value_preference( + self, slot: str, value: str, score: float + ) -> None: + """Updates a slot-value preference.""" + self._assert_slot_exists(slot) + updated_score = self._merge_scores( + self.get_slot_value_preference(slot, value), score + ) + self._slot_value_preferences.set_preference(slot, value, updated_score) + + def update_from_dialogue(self, dialogue) -> None: + """Updates preferences from a user utterance.""" + text = getattr(dialogue, "text", "") + score = self._score_text(text) + if score == 0: + return + + text_lower = text.lower() + for slot in self._domain.get_slot_names(): + if slot.upper() in {"TITLE", "NAME"}: + continue + for value in self._item_collection.get_possible_property_values( + slot + ): + value = str(value) + if len(value) >= 3 and value.lower() in text_lower: + self.update_slot_value_preference(slot, value, score) + + def _merge_scores(self, old_score: float, new_score: float) -> float: + """Merges old and new preference scores.""" + if old_score == 0: + return new_score + return ( + old_score * (1 - self.UPDATE_WEIGHT) + + new_score * self.UPDATE_WEIGHT + ) + + @staticmethod + def _score_text(text: str) -> float: + """Maps simple textual preference cues to a preference score.""" + text_lower = text.lower() + negative_cues = [ + "don't like", + "do not like", + "not like", + "dislike", + "hate", + "avoid", + "not interested", + ] + if any(cue in text_lower for cue in negative_cues): + return -0.7 + + positive_cues = [ + "like", + "love", + "prefer", + "want", + "looking for", + "interested in", + ] + if any(cue in text_lower for cue in positive_cues): + return 0.7 + return 0 + + def _rank_slot_value_preferences(self) -> List[Tuple[str, str, float]]: + """Ranks slot-value preferences by absolute preference strength.""" + ranked_preferences: List[Tuple[str, str, float]] = [] + for ( + slot, + value_preferences, + ) in self._slot_value_preferences._preferences.items(): + for value, score in value_preferences.items(): + ranked_preferences.append((slot, value, score)) + + return sorted( + ranked_preferences, + key=lambda preference: ( + -abs(preference[2]), + preference[0], + preference[1], + ), + ) + + def get_preference_summary(self, max_preferences: int = 10) -> str: + """Returns a compact preference summary for LLM prompts.""" + ranked_preferences = self._rank_slot_value_preferences() + if not ranked_preferences: + return "" + + summaries = [ + f"{slot}={value}:{score:.2f}" + for slot, value, score in ranked_preferences[:max_preferences] + ] + print("summaries from preference model: ", summaries) + return "; ".join(summaries) diff --git a/usersimcrs/utils/simulation_utils.py b/usersimcrs/utils/simulation_utils.py index c6f38ca9..1a034892 100644 --- a/usersimcrs/utils/simulation_utils.py +++ b/usersimcrs/utils/simulation_utils.py @@ -30,10 +30,8 @@ ) from usersimcrs.simulator.agenda_based.interaction_model import InteractionModel from usersimcrs.llm_interfaces.llm_interface import LLMInterface +from usersimcrs.user_modeling.preference_model import PreferenceModel from usersimcrs.user_modeling.persona import Persona -from usersimcrs.user_modeling.simple_preference_model import ( - SimplePreferenceModel, -) def map_path_to_class(cls_path: str) -> Type: @@ -149,11 +147,8 @@ def _get_agenda_based_simulator_config( config["historical_ratings_ratio"].get(confuse.Number(default=0.8)) ) - preference_model = SimplePreferenceModel( - domain, - item_collection, - historical_ratings, - historical_user_id="13", + preference_model = _get_preference_model( + config, domain, item_collection, historical_ratings ) # Loads dialogue sample @@ -309,6 +304,16 @@ def _get_llm_single_prompt_user_simulator_config( delimiter=config["csv_delimiter"].get(confuse.String(default=",")), ) + ratings = Ratings(item_collection) + if config["ratings"].get() is not None: + ratings.load_ratings_csv(file_path=config["ratings"].get()) + historical_ratings, _ = ratings.create_split( + config["historical_ratings_ratio"].get(confuse.Number(default=0.8)) + ) + preference_model = _get_preference_model( + config, domain, item_collection, historical_ratings + ) + llm_interface = get_llm_interface(config["llm_interface"].get()) task_definition = config["task_definition"].get() @@ -324,6 +329,7 @@ def _get_llm_single_prompt_user_simulator_config( "item_type": item_type, "task_definition": task_definition, "persona": persona, + "preference_model": preference_model, } @@ -344,6 +350,39 @@ def get_llm_interface(config: Dict[str, Any]) -> LLMInterface: return llm_interface +def _get_preference_model( + config: confuse.Configuration, + domain: SimulationDomain, + item_collection: ItemCollection, + historical_ratings: Ratings, +) -> PreferenceModel: + """Initializes the configured preference model.""" + preference_model_config = {} + if "preference_model" in config: + preference_model_config = config["preference_model"].get() + + preference_model_class_path = preference_model_config.get( + "class_path", + "usersimcrs.user_modeling.simple_preference_model." + "SimplePreferenceModel", + ) + preference_model_args = preference_model_config.get("args", {}) + + preference_model_class = map_path_to_class(preference_model_class_path) + if not issubclass(preference_model_class, PreferenceModel): + raise TypeError( + f"Preference model class {preference_model_class} must inherit " + "from PreferenceModel." + ) + + return preference_model_class( + domain, + item_collection, + historical_ratings, + **preference_model_args, + ) + + def _get_llm_dual_prompt_user_simulator_config( config: confuse.Configuration, ) -> Dict[str, Any]: From 7dd2da5d8339cbb260abf84eeab518337139e044 Mon Sep 17 00:00:00 2001 From: Ksenia Blokhina Date: Tue, 2 Jun 2026 17:30:53 +0200 Subject: [PATCH 06/25] develpe preference model, improve prompts --- usersimcrs/simulator/llm/prompt/prompt.py | 9 +- .../structured_preference_model.py | 382 ++++++++++++++---- 2 files changed, 305 insertions(+), 86 deletions(-) diff --git a/usersimcrs/simulator/llm/prompt/prompt.py b/usersimcrs/simulator/llm/prompt/prompt.py index b58234f9..5560a40e 100644 --- a/usersimcrs/simulator/llm/prompt/prompt.py +++ b/usersimcrs/simulator/llm/prompt/prompt.py @@ -56,7 +56,14 @@ def _preference_context(self) -> str: if not preference_summary: return "" - return f"USER PREFERENCES: {preference_summary}\n" + return ( + "USER PREFERENCES: " + f"{preference_summary}\n" + "Treat these preferences as soft background tastes, not as new " + "requirements. Use them to shape what the user accepts, " + "rejects, asks to avoid, or follows up on. Do not force every " + "liked preference into the opening request.\n" + ) @abstractmethod def build_new_prompt(self, **kwargs) -> str: diff --git a/usersimcrs/user_modeling/structured_preference_model.py b/usersimcrs/user_modeling/structured_preference_model.py index 6dd0fbeb..42375f4f 100644 --- a/usersimcrs/user_modeling/structured_preference_model.py +++ b/usersimcrs/user_modeling/structured_preference_model.py @@ -1,5 +1,7 @@ from __future__ import annotations +import math +import re from typing import Dict, Iterable, List, Tuple from dialoguekit.participant.user_preferences import UserPreferences @@ -16,7 +18,36 @@ class StructuredPreferenceModel(PreferenceModel): """Preference model based on historical ratings and item metadata.""" - UPDATE_WEIGHT = 0.3 + UPDATE_STEP = 0.25 + PREFERENCE_SCORE = 0.7 + DIALOGUE_STOP_TOKENS = { + "exit", + "goodbye", + "bye", + "quit", + "stop", + "end", + "giveup", + } + SEARCH_GOAL_CUES = { + "looking for", + "want a movie", + "want something", + "can you recommend", + "recommend me", + "i'm after", + "i am after", + } + NEGATIVE_CUES = ( + "don't like", + "do not like", + "not like", + "dislike", + "hate", + "avoid", + "not interested", + ) + POSITIVE_CUES = ("like", "love", "prefer", "enjoy") def __init__( self, @@ -42,26 +73,40 @@ def __init__( self._preference_threshold = preference_threshold self._item_preferences = UserPreferences(self._user_id) self._slot_value_preferences = UserPreferences(self._user_id) + self._slot_value_counts: Dict[Tuple[str, str], int] = {} + self._dialogue_buffer: List[str] = [] self._initialize_preferences() def _initialize_preferences(self) -> None: - """Initializes preferences from historical ratings.""" + """Initializes preferences from historical ratings. + + Builds the long-term item and slot-value profiles once at startup. + """ self._initialize_item_preferences() slot_value_ratings = self._collect_slot_value_ratings() self._initialize_slot_value_preferences(slot_value_ratings) def _initialize_item_preferences(self) -> None: - """Stores historical item ratings as item preferences.""" - for item_id, rating in self._get_user_ratings().items(): - if abs(rating) >= self._preference_threshold: - self._item_preferences.set_preference( - KEY_ITEM_ID, item_id, rating - ) + """Stores strong historical item ratings as item preferences.""" + user_ratings = self._historical_ratings.get_user_ratings( + self._historical_user_id + ) + for item_id, rating in user_ratings.items(): + if abs(rating) < self._preference_threshold: + continue + self._item_preferences.set_preference(KEY_ITEM_ID, item_id, rating) def _collect_slot_value_ratings(self) -> Dict[str, Dict[str, List[float]]]: - """Collects historical ratings for each slot-value pair.""" + """Collects historical ratings for each slot-value pair. + + Returns: + Nested mapping of slot -> value -> list of historical ratings. + """ slot_value_ratings: Dict[str, Dict[str, List[float]]] = {} - for item_id, rating in self._get_user_ratings().items(): + user_ratings = self._historical_ratings.get_user_ratings( + self._historical_user_id + ) + for item_id, rating in user_ratings.items(): item = self._item_collection.get_item(item_id) if item is None: continue @@ -75,28 +120,25 @@ def _collect_slot_value_ratings(self) -> Dict[str, Dict[str, List[float]]]: def _initialize_slot_value_preferences( self, slot_value_ratings: Dict[str, Dict[str, List[float]]] ) -> None: - """Stores average slot-value ratings as slot-value preferences.""" + """Stores average slot-value ratings as slot-value preferences. + + Args: + slot_value_ratings: Historical ratings grouped by slot and value. + """ for slot, value_ratings in slot_value_ratings.items(): for value, ratings in value_ratings.items(): - score = self._score_from_ratings(ratings) + score = sum(ratings) / len(ratings) + self._slot_value_counts[(slot, value)] = len(ratings) if abs(score) >= self._preference_threshold: self._slot_value_preferences.set_preference( slot, value, score ) - @staticmethod - def _score_from_ratings(ratings: List[float]) -> float: - """Computes preference importance as average normalized rating.""" - return sum(ratings) / len(ratings) - - def _get_user_ratings(self) -> Dict[str, float]: - """Returns historical ratings for the selected user.""" - return self._historical_ratings.get_user_ratings( - self._historical_user_id - ) - def _iter_preference_values(self, item) -> Iterable[Tuple[str, str]]: - """Yields preference-relevant slot-value pairs for an item.""" + """Yields preference-relevant slot-value pairs for an item. + + Excludes title/name fields and normalizes multi-valued properties. + """ for slot in self._domain.get_slot_names(): if slot.upper() in {"TITLE", "NAME"}: continue @@ -108,13 +150,28 @@ def _iter_preference_values(self, item) -> Iterable[Tuple[str, str]]: yield slot, str(slot_value) def get_item_preference(self, item_id: str) -> float: - """Returns preference score for an item.""" + """Returns preference score for an item. + + Args: + item_id: Item identifier. + + Returns: + Long-term item preference if available, otherwise 0. + """ self._assert_item_exists(item_id) preference = self._item_preferences.get_preference(KEY_ITEM_ID, item_id) return preference if preference is not None else 0 def get_slot_value_preference(self, slot: str, value: str) -> float: - """Returns preference score for a slot-value pair.""" + """Returns preference score for a slot-value pair. + + Args: + slot: Slot name. + value: Slot value. + + Returns: + Preference score for the slot-value pair, or 0 if unavailable. + """ self._assert_slot_exists(slot) preference = self._slot_value_preferences.get_preference(slot, value) return preference if preference is not None else 0 @@ -122,70 +179,190 @@ def get_slot_value_preference(self, slot: str, value: str) -> float: def update_slot_value_preference( self, slot: str, value: str, score: float ) -> None: - """Updates a slot-value preference.""" + """Updates a slot-value preference. + + Args: + slot: Slot name. + value: Slot value. + score: Preference score to store. + """ self._assert_slot_exists(slot) - updated_score = self._merge_scores( - self.get_slot_value_preference(slot, value), score - ) - self._slot_value_preferences.set_preference(slot, value, updated_score) + key = (slot, value) + old_score = self._slot_value_preferences.get_preference(slot, value) + old_count = self._slot_value_counts.get(key, 0) + if old_score is None or old_count == 0: + new_score = score + else: + if score > old_score: + new_score = min(old_score + self.UPDATE_STEP, score) + else: + new_score = max(old_score - self.UPDATE_STEP, score) + self._slot_value_preferences.set_preference(slot, value, new_score) + self._slot_value_counts[key] = old_count + 1 - def update_from_dialogue(self, dialogue) -> None: - """Updates preferences from a user utterance.""" - text = getattr(dialogue, "text", "") - score = self._score_text(text) - if score == 0: - return + def _apply_text_update(self, text: str) -> None: + """Applies preference updates for one buffered utterance. + Args: + text: User utterance collected during the dialogue. + """ text_lower = text.lower() - for slot in self._domain.get_slot_names(): - if slot.upper() in {"TITLE", "NAME"}: + global_score = self._score_text(text) + matched_values = self._extract_matched_values(text_lower) + if not matched_values: + return + + has_search_goal_cue = any( + cue in text_lower for cue in self.SEARCH_GOAL_CUES + ) + for slot, value in matched_values: + score = self._score_value_in_text( + text_lower, value.lower(), global_score, has_search_goal_cue + ) + if score != 0: + self.update_slot_value_preference(slot, value, score) + + def _extract_matched_values(self, text_lower: str) -> List[Tuple[str, str]]: + """Returns matched known preferences without shorter duplicate + fragments. + + Args: + text_lower: Lowercased user utterance. + + Returns: + Matched slot-value pairs, preferring longer values over nested + substrings. + """ + matches: List[Tuple[str, str]] = [] + for slot, value, _ in self._rank_long_term_preferences(): + if len(value) >= 3 and value.lower() in text_lower: + matches.append((slot, value)) + + filtered_matches: List[Tuple[str, str]] = [] + for slot, value in sorted(matches, key=lambda match: -len(match[1])): + value_lower = value.lower() + if any( + value_lower != kept_value.lower() + and value_lower in kept_value.lower() + for _, kept_value in filtered_matches + ): continue - for value in self._item_collection.get_possible_property_values( - slot + filtered_matches.append((slot, value)) + return filtered_matches + + @staticmethod + def _score_value_in_text( + text_lower: str, + value_lower: str, + global_score: float, + has_search_goal_cue: bool, + ) -> float: + """Scores one matched value using nearby cues. + + Args: + text_lower: Lowercased user utterance. + value_lower: Lowercased slot value being inspected. + global_score: Fallback score computed for the whole utterance. + has_search_goal_cue: Whether the utterance looks like a search + goal rather than a stable preference. + + Returns: + A local preference score for the matched value. + """ + contexts = [ + clause.strip() + for clause in re.split(r"[.!?;]", text_lower) + if value_lower in clause + ] + if not contexts: + return 0 if has_search_goal_cue else global_score + + negative_prefix_cues = ("avoid", "nothing", "not", "no", "without") + for context in contexts: + before_value, _, _ = context.partition(value_lower) + if any(cue in before_value for cue in negative_prefix_cues): + return -StructuredPreferenceModel.PREFERENCE_SCORE + + negative_patterns = ( + rf"\btoo much\b[^.?!;]{{0,40}}\b{re.escape(value_lower)}\b", + ) + for context in contexts: + if any( + re.search(pattern, context) for pattern in negative_patterns ): - value = str(value) - if len(value) >= 3 and value.lower() in text_lower: - self.update_slot_value_preference(slot, value, score) - - def _merge_scores(self, old_score: float, new_score: float) -> float: - """Merges old and new preference scores.""" - if old_score == 0: - return new_score - return ( - old_score * (1 - self.UPDATE_WEIGHT) - + new_score * self.UPDATE_WEIGHT + return -StructuredPreferenceModel.PREFERENCE_SCORE + + positive_patterns = ( + rf"\bi\s+like\b[^.?!;]{{0,20}}\b{re.escape(value_lower)}\b", + rf"\bi\s+love\b[^.?!;]{{0,20}}\b{re.escape(value_lower)}\b", + rf"\bi\s+enjoy\b[^.?!;]{{0,20}}\b{re.escape(value_lower)}\b", + rf"\bi\s+prefer\b[^.?!;]{{0,20}}\b{re.escape(value_lower)}\b", ) + for context in contexts: + if any( + re.search(pattern, context) for pattern in positive_patterns + ): + return StructuredPreferenceModel.PREFERENCE_SCORE + + if has_search_goal_cue: + return 0 + return global_score + + def update_from_dialogue(self, dialogue) -> None: + """Updates preferences after a completed dialogue. + + Buffers ordinary user turns and applies the accumulated updates once + the dialogue ends with a stop utterance. + + Args: + dialogue: Dialogue utterance object. + """ + text = getattr(dialogue, "text", "").strip() + if not text: + return + + if text.lower() in self.DIALOGUE_STOP_TOKENS: + for buffered_text in self._dialogue_buffer: + self._apply_text_update(buffered_text) + self._dialogue_buffer.clear() + return + + self._dialogue_buffer.append(text) @staticmethod def _score_text(text: str) -> float: - """Maps simple textual preference cues to a preference score.""" + """Maps simple textual preference cues to a preference score. + + Args: + text: User utterance. + + Returns: + Positive, negative, or neutral score inferred from coarse cues. + """ text_lower = text.lower() - negative_cues = [ - "don't like", - "do not like", - "not like", - "dislike", - "hate", - "avoid", - "not interested", - ] - if any(cue in text_lower for cue in negative_cues): - return -0.7 - - positive_cues = [ - "like", - "love", - "prefer", - "want", - "looking for", - "interested in", - ] - if any(cue in text_lower for cue in positive_cues): - return 0.7 + if any( + cue in text_lower for cue in StructuredPreferenceModel.NEGATIVE_CUES + ): + return -StructuredPreferenceModel.PREFERENCE_SCORE + + if any( + cue in text_lower + for cue in StructuredPreferenceModel.SEARCH_GOAL_CUES + ): + return 0 + + if any( + cue in text_lower for cue in StructuredPreferenceModel.POSITIVE_CUES + ): + return StructuredPreferenceModel.PREFERENCE_SCORE return 0 - def _rank_slot_value_preferences(self) -> List[Tuple[str, str, float]]: - """Ranks slot-value preferences by absolute preference strength.""" + def _rank_long_term_preferences(self) -> List[Tuple[str, str, float]]: + """Ranks long-term slot-value preferences by support and strength. + + Returns: + Sorted list of long-term slot-value preferences. + """ ranked_preferences: List[Tuple[str, str, float]] = [] for ( slot, @@ -197,21 +374,56 @@ def _rank_slot_value_preferences(self) -> List[Tuple[str, str, float]]: return sorted( ranked_preferences, key=lambda preference: ( + -math.log1p( + self._slot_value_counts.get( + (preference[0], preference[1]), 0 + ) + ), -abs(preference[2]), preference[0], preference[1], ), ) - def get_preference_summary(self, max_preferences: int = 10) -> str: - """Returns a compact preference summary for LLM prompts.""" - ranked_preferences = self._rank_slot_value_preferences() + def get_preference_summary(self, max_preferences: int = 20) -> str: + """Returns a preference summary for the LLM. + + Args: + max_preferences: Total number of preferences. + + Returns: + Summary for the LLM of long-term preferences. + """ + ranked_preferences = self._rank_long_term_preferences() if not ranked_preferences: return "" - summaries = [ - f"{slot}={value}:{score:.2f}" - for slot, value, score in ranked_preferences[:max_preferences] + max_long_term = max(1, max_preferences // 2) + sections = [ + ( + "Ppositive preferences", + [ + preference + for preference in ranked_preferences + if preference[2] >= self._preference_threshold + ][:max_long_term], + ), + ( + "Negative preferences", + [ + preference + for preference in ranked_preferences + if preference[2] <= -self._preference_threshold + ][:max_long_term], + ), + ] + summary_parts = [ + f"{title}: " + + "; ".join( + f"{slot}={value}:{score:.2f}" + for slot, value, score in preferences + ) + for title, preferences in sections + if preferences ] - print("summaries from preference model: ", summaries) - return "; ".join(summaries) + return " | ".join(summary_parts) From 4e68b748b808b80425374ab9049dbac4a85e2fb0 Mon Sep 17 00:00:00 2001 From: Ksenia Blokhina Date: Tue, 2 Jun 2026 17:42:01 +0200 Subject: [PATCH 07/25] remove preference model from agenda --- usersimcrs/simulator/agenda_based/agenda_based_simulator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/usersimcrs/simulator/agenda_based/agenda_based_simulator.py b/usersimcrs/simulator/agenda_based/agenda_based_simulator.py index 13be4b63..e4659b58 100644 --- a/usersimcrs/simulator/agenda_based/agenda_based_simulator.py +++ b/usersimcrs/simulator/agenda_based/agenda_based_simulator.py @@ -43,7 +43,6 @@ def __init__( """ super().__init__(id=id, domain=domain, item_collection=item_collection) self._preference_model = preference_model - self.preference_model = preference_model self._interaction_model = interaction_model self._interaction_model.initialize_agenda(self.information_need) self._nlu = nlu From 86c90cb2d3a2e75637761a8cbef91a9a9cc0a3e3 Mon Sep 17 00:00:00 2001 From: Ksenia Blokhina Date: Tue, 9 Jun 2026 14:01:38 +0200 Subject: [PATCH 08/25] improve preference model --- .../agenda_based/agenda_based_simulator.py | 9 +- .../llm/llm_dual_prompt_user_simulator.py | 9 +- .../llm/llm_single_prompt_user_simulator.py | 9 +- usersimcrs/simulator/llm/prompt/prompt.py | 7 +- .../simulator/llm/prompt/stop_prompt.py | 16 +- .../llm/prompt/utterance_generation_prompt.py | 16 +- usersimcrs/simulator/user_simulator.py | 9 +- usersimcrs/user_modeling/persona.py | 4 +- .../user_modeling/pkg_preference_model.py | 8 + usersimcrs/user_modeling/preference_model.py | 5 +- .../user_modeling/simple_preference_model.py | 8 + .../structured_preference_model.py | 164 +++++++----------- 12 files changed, 125 insertions(+), 139 deletions(-) diff --git a/usersimcrs/simulator/agenda_based/agenda_based_simulator.py b/usersimcrs/simulator/agenda_based/agenda_based_simulator.py index e4659b58..663e137a 100644 --- a/usersimcrs/simulator/agenda_based/agenda_based_simulator.py +++ b/usersimcrs/simulator/agenda_based/agenda_based_simulator.py @@ -41,8 +41,13 @@ def __init__( nlg: NLG module generating textual responses. ratings: Historical ratings. """ - super().__init__(id=id, domain=domain, item_collection=item_collection) - self._preference_model = preference_model + super().__init__( + id=id, + domain=domain, + item_collection=item_collection, + preference_model=preference_model, + ) + self._preference_model = self.preference_model self._interaction_model = interaction_model self._interaction_model.initialize_agenda(self.information_need) self._nlu = nlu diff --git a/usersimcrs/simulator/llm/llm_dual_prompt_user_simulator.py b/usersimcrs/simulator/llm/llm_dual_prompt_user_simulator.py index 529b7238..911ffbbd 100644 --- a/usersimcrs/simulator/llm/llm_dual_prompt_user_simulator.py +++ b/usersimcrs/simulator/llm/llm_dual_prompt_user_simulator.py @@ -5,6 +5,8 @@ the user response. """ +from typing import Optional + from dialoguekit.core.utterance import Utterance from dialoguekit.participant import DialogueParticipant from usersimcrs.core.simulation_domain import SimulationDomain @@ -33,8 +35,8 @@ def __init__( item_type: str, task_definition: str = DEFAULT_TASK_DEFINITION, stop_definition: str = DEFAULT_STOP_DEFINITION, - persona: Persona = None, - preference_model: PreferenceModel = None, + persona: Optional[Persona] = None, + preference_model: Optional[PreferenceModel] = None, ) -> None: """Initializes the user simulator. @@ -49,9 +51,8 @@ def __init__( persona: Persona of the user. Defaults to None. preference_model: Preference model. Defaults to None. """ - super().__init__(id, domain, item_collection) + super().__init__(id, domain, item_collection, preference_model) self.llm_interface = llm_interface - self.preference_model = preference_model self.generation_prompt = UtteranceGenerationPrompt( self.information_need, item_type, diff --git a/usersimcrs/simulator/llm/llm_single_prompt_user_simulator.py b/usersimcrs/simulator/llm/llm_single_prompt_user_simulator.py index c1934d0a..2cf35fab 100644 --- a/usersimcrs/simulator/llm/llm_single_prompt_user_simulator.py +++ b/usersimcrs/simulator/llm/llm_single_prompt_user_simulator.py @@ -4,6 +4,8 @@ model. """ +from typing import Optional + from dialoguekit.core.utterance import Utterance from dialoguekit.participant import DialogueParticipant from usersimcrs.core.simulation_domain import SimulationDomain @@ -27,8 +29,8 @@ def __init__( llm_interface: LLMInterface, item_type: str, task_definition: str = DEFAULT_TASK_DEFINITION, - persona: Persona = None, - preference_model: PreferenceModel = None, + persona: Optional[Persona] = None, + preference_model: Optional[PreferenceModel] = None, ) -> None: """Initializes the user simulator. @@ -41,9 +43,8 @@ def __init__( persona: Persona of the user. Defaults to None. preference_model: Preference model. Defaults to None. """ - super().__init__(id, domain, item_collection) + super().__init__(id, domain, item_collection, preference_model) self.llm_interface = llm_interface - self.preference_model = preference_model self.prompt = UtteranceGenerationPrompt( self.information_need, item_type, diff --git a/usersimcrs/simulator/llm/prompt/prompt.py b/usersimcrs/simulator/llm/prompt/prompt.py index 5560a40e..38db0f9b 100644 --- a/usersimcrs/simulator/llm/prompt/prompt.py +++ b/usersimcrs/simulator/llm/prompt/prompt.py @@ -1,6 +1,7 @@ """Interface for prompt.""" from abc import ABC, abstractmethod +from typing import Any, Optional from dialoguekit.core.utterance import Utterance from dialoguekit.participant.participant import DialogueParticipant @@ -15,8 +16,8 @@ def __init__( information_need: InformationNeed, item_type: str, prompt_definition: str, - persona: Persona = None, - preference_model: PreferenceModel = None, + persona: Optional[Persona] = None, + preference_model: Optional[PreferenceModel] = None, ) -> None: """Initializes the prompt. @@ -66,7 +67,7 @@ def _preference_context(self) -> str: ) @abstractmethod - def build_new_prompt(self, **kwargs) -> str: + def build_new_prompt(self, **kwargs: Any) -> str: """Builds the initial prompt without any context. Raises: diff --git a/usersimcrs/simulator/llm/prompt/stop_prompt.py b/usersimcrs/simulator/llm/prompt/stop_prompt.py index b7dea761..23a7c605 100644 --- a/usersimcrs/simulator/llm/prompt/stop_prompt.py +++ b/usersimcrs/simulator/llm/prompt/stop_prompt.py @@ -1,5 +1,7 @@ """Define the prompt for stopping the conversation.""" +from typing import Optional + from usersimcrs.core.information_need import InformationNeed from usersimcrs.simulator.llm.prompt.prompt import Prompt from usersimcrs.user_modeling.preference_model import PreferenceModel @@ -22,8 +24,8 @@ def __init__( information_need: InformationNeed, item_type: str, prompt_definition: str = DEFAULT_STOP_DEFINITION, - persona: Persona = None, - preference_model: PreferenceModel = None, + persona: Optional[Persona] = None, + preference_model: Optional[PreferenceModel] = None, ) -> None: """Initializes the prompt. @@ -74,12 +76,10 @@ def build_new_prompt(self) -> str: for key, value in self.persona.characteristics.items() ] ) - initial_prompt += f"PERSONA: {stringified_characteristics}\n" - if self.persona.persona_description: - initial_prompt += ( - "PERSONA DESCRIPTION: " - f"{self.persona.persona_description}\n" - ) + persona_text = ( + self.persona.persona_description or stringified_characteristics + ) + initial_prompt += f"PERSONA: {persona_text}\n" initial_prompt += "\nHISTORY:\n" return initial_prompt diff --git a/usersimcrs/simulator/llm/prompt/utterance_generation_prompt.py b/usersimcrs/simulator/llm/prompt/utterance_generation_prompt.py index d1fb13cf..a8fd4d8e 100644 --- a/usersimcrs/simulator/llm/prompt/utterance_generation_prompt.py +++ b/usersimcrs/simulator/llm/prompt/utterance_generation_prompt.py @@ -9,6 +9,8 @@ for Task-Oriented Dialog Systems", arXiv 2306.00774. """ +from typing import Optional + from dialoguekit.core.utterance import Utterance from dialoguekit.participant.participant import DialogueParticipant from usersimcrs.core.information_need import InformationNeed @@ -35,8 +37,8 @@ def __init__( information_need: InformationNeed, item_type: str, prompt_definition: str = DEFAULT_TASK_DEFINITION, - persona: Persona = None, - preference_model: PreferenceModel = None, + persona: Optional[Persona] = None, + preference_model: Optional[PreferenceModel] = None, ) -> None: """Initializes the prompt. @@ -74,12 +76,10 @@ def build_new_prompt(self) -> str: for key, value in self.persona.characteristics.items() ] ) - initial_prompt += f"PERSONA: {stringified_characteristics}\n" - if self.persona.persona_description: - initial_prompt += ( - "PERSONA DESCRIPTION: " - f"{self.persona.persona_description}\n" - ) + persona_text = ( + self.persona.persona_description or stringified_characteristics + ) + initial_prompt += f"PERSONA: {persona_text}\n" else: initial_prompt += ( "Be precise with the REQUIREMENTS, clear and concise.\n" diff --git a/usersimcrs/simulator/user_simulator.py b/usersimcrs/simulator/user_simulator.py index eff54677..6a005c0e 100644 --- a/usersimcrs/simulator/user_simulator.py +++ b/usersimcrs/simulator/user_simulator.py @@ -1,6 +1,7 @@ """User simulator abstract class.""" from abc import ABC, abstractmethod +from typing import Optional from dialoguekit.core.annotated_utterance import AnnotatedUtterance from dialoguekit.core.utterance import Utterance @@ -8,6 +9,7 @@ from usersimcrs.core.information_need import generate_random_information_need from usersimcrs.core.simulation_domain import SimulationDomain from usersimcrs.items.item_collection import ItemCollection +from usersimcrs.user_modeling.preference_model import PreferenceModel class UserSimulator(User, ABC): @@ -16,11 +18,13 @@ def __init__( id: str, domain: SimulationDomain, item_collection: ItemCollection, + preference_model: Optional[PreferenceModel] = None, ) -> None: """Initializes the user simulator.""" super().__init__(id, UserType.SIMULATOR) self._domain = domain self._item_collection = item_collection + self.preference_model = preference_model self.get_new_information_need() def get_new_information_need(self) -> None: @@ -53,7 +57,6 @@ def receive_utterance(self, utterance: Utterance) -> None: response = self._generate_response(utterance) if not isinstance(response, AnnotatedUtterance): response = AnnotatedUtterance.from_utterance(response) - preference_model = getattr(self, "preference_model", None) - if preference_model: - preference_model.update_from_dialogue(response) + if self.preference_model: + self.preference_model.update_from_dialogue(response) self._dialogue_connector.register_user_utterance(response) diff --git a/usersimcrs/user_modeling/persona.py b/usersimcrs/user_modeling/persona.py index d04ba546..e18d4979 100644 --- a/usersimcrs/user_modeling/persona.py +++ b/usersimcrs/user_modeling/persona.py @@ -1,3 +1,5 @@ +from __future__ import annotations + """Persona, which is a profile of the user to represent different backgrounds (e.g., age, gender, education), personality types, and behavioral tendencies (e.g., patience, conscientiousness, or curiosity).""" @@ -13,7 +15,7 @@ class Persona: persona_description: Optional[str] = None @classmethod - def from_config(cls, persona_config: Dict[str, Any]) -> "Persona": + def from_config(cls, persona_config: Dict[str, Any]) -> Persona: """Creates a persona from a configuration dictionary. The configuration must provide characteristics under diff --git a/usersimcrs/user_modeling/pkg_preference_model.py b/usersimcrs/user_modeling/pkg_preference_model.py index b424aa94..1ba0c964 100644 --- a/usersimcrs/user_modeling/pkg_preference_model.py +++ b/usersimcrs/user_modeling/pkg_preference_model.py @@ -70,3 +70,11 @@ def get_slot_value_preference(self, slot: str, value: str) -> float: # TODO: Query PKG to retrieve slot-value preference. preference = None return preference + + def get_preference_summary(self, max_preferences: int = 10) -> str: + """Returns an empty summary until PKG prompt support is added.""" + return "" + + def update_from_dialogue(self, dialogue) -> None: + """PKG-backed dialogue updates are not implemented yet.""" + return None diff --git a/usersimcrs/user_modeling/preference_model.py b/usersimcrs/user_modeling/preference_model.py index 76d0c292..7a31991a 100644 --- a/usersimcrs/user_modeling/preference_model.py +++ b/usersimcrs/user_modeling/preference_model.py @@ -174,12 +174,15 @@ def get_slot_preference(self, slot: str) -> Tuple[str, float]: return value, preference + @abstractmethod def get_preference_summary(self, max_preferences: int = 10) -> str: """Returns a compact preference summary for prompts.""" - return "" + raise NotImplementedError + @abstractmethod def update_from_dialogue(self, dialogue) -> None: """Updates preferences from dialogue if supported by the model.""" + raise NotImplementedError @classmethod def load_preference_model(cls, path: str) -> PreferenceModel: diff --git a/usersimcrs/user_modeling/simple_preference_model.py b/usersimcrs/user_modeling/simple_preference_model.py index acdc2469..5e3ee06d 100644 --- a/usersimcrs/user_modeling/simple_preference_model.py +++ b/usersimcrs/user_modeling/simple_preference_model.py @@ -98,3 +98,11 @@ def get_slot_value_preference(self, slot: str, value: str) -> float: preference = random.choice([-1, 1]) self._slot_value_preferences.set_preference(slot, value, preference) return preference + + def get_preference_summary(self, max_preferences: int = 10) -> str: + """Returns an empty summary for non-structured preferences.""" + return "" + + def update_from_dialogue(self, dialogue) -> None: + """Simple preferences do not update from dialogue.""" + return None diff --git a/usersimcrs/user_modeling/structured_preference_model.py b/usersimcrs/user_modeling/structured_preference_model.py index 42375f4f..46c132f4 100644 --- a/usersimcrs/user_modeling/structured_preference_model.py +++ b/usersimcrs/user_modeling/structured_preference_model.py @@ -2,7 +2,7 @@ import math import re -from typing import Dict, Iterable, List, Tuple +from typing import Dict, Iterable, List, Optional, Tuple from dialoguekit.participant.user_preferences import UserPreferences @@ -20,6 +20,16 @@ class StructuredPreferenceModel(PreferenceModel): UPDATE_STEP = 0.25 PREFERENCE_SCORE = 0.7 + NEW_PREFERENCE_MIN_CONFIRMATIONS = 2 + NEGATIVE_PATTERNS = ( + r"\b(?:avoid|nothing|not|no|without|not into|not too|too much)\b.*" + r"\b{value}\b", + r"\b{value}\s+(?:heavy|packed)\b", + ) + POSITIVE_PATTERNS = ( + r"\b(?:like|love|prefer|enjoy)\b.*\b{value}\b", + r"\b{value}\s+focused\b", + ) DIALOGUE_STOP_TOKENS = { "exit", "goodbye", @@ -29,32 +39,13 @@ class StructuredPreferenceModel(PreferenceModel): "end", "giveup", } - SEARCH_GOAL_CUES = { - "looking for", - "want a movie", - "want something", - "can you recommend", - "recommend me", - "i'm after", - "i am after", - } - NEGATIVE_CUES = ( - "don't like", - "do not like", - "not like", - "dislike", - "hate", - "avoid", - "not interested", - ) - POSITIVE_CUES = ("like", "love", "prefer", "enjoy") def __init__( self, domain: SimulationDomain, item_collection: ItemCollection, historical_ratings: Ratings, - historical_user_id: str = None, + historical_user_id: Optional[str] = None, preference_threshold: float = PreferenceModel.PREFERENCE_THRESHOLD, ) -> None: """Initializes structured preferences. @@ -74,9 +65,24 @@ def __init__( self._item_preferences = UserPreferences(self._user_id) self._slot_value_preferences = UserPreferences(self._user_id) self._slot_value_counts: Dict[Tuple[str, str], int] = {} + self._pending_slot_value_updates: Dict[ + Tuple[str, str], List[float] + ] = {} self._dialogue_buffer: List[str] = [] self._initialize_preferences() + @staticmethod + def _normalize_preference_value(value: str) -> str: + """Normalizes preference values for storage and matching. + + Args: + value: Raw preference value. + + Returns: + Value with normalized spacing and hyphens. + """ + return " ".join(str(value).lower().replace("-", " ").split()) + def _initialize_preferences(self) -> None: """Initializes preferences from historical ratings. @@ -147,7 +153,7 @@ def _iter_preference_values(self, item) -> Iterable[Tuple[str, str]]: continue values = value if isinstance(value, list) else [value] for slot_value in values: - yield slot, str(slot_value) + yield slot, self._normalize_preference_value(slot_value) def get_item_preference(self, item_id: str) -> float: """Returns preference score for an item. @@ -173,13 +179,14 @@ def get_slot_value_preference(self, slot: str, value: str) -> float: Preference score for the slot-value pair, or 0 if unavailable. """ self._assert_slot_exists(slot) + value = self._normalize_preference_value(value) preference = self._slot_value_preferences.get_preference(slot, value) return preference if preference is not None else 0 def update_slot_value_preference( self, slot: str, value: str, score: float ) -> None: - """Updates a slot-value preference. + """Updates one slot-value preference. Args: slot: Slot name. @@ -187,18 +194,30 @@ def update_slot_value_preference( score: Preference score to store. """ self._assert_slot_exists(slot) + value = self._normalize_preference_value(value) + key = (slot, value) old_score = self._slot_value_preferences.get_preference(slot, value) old_count = self._slot_value_counts.get(key, 0) - if old_score is None or old_count == 0: - new_score = score - else: + + if old_score is not None and old_count > 0: if score > old_score: new_score = min(old_score + self.UPDATE_STEP, score) else: new_score = max(old_score - self.UPDATE_STEP, score) + self._slot_value_preferences.set_preference(slot, value, new_score) + self._slot_value_counts[key] = old_count + 1 + return + + pending_scores = self._pending_slot_value_updates.setdefault(key, []) + pending_scores.append(score) + if len(pending_scores) < self.NEW_PREFERENCE_MIN_CONFIRMATIONS: + return + + new_score = sum(pending_scores) / len(pending_scores) self._slot_value_preferences.set_preference(slot, value, new_score) - self._slot_value_counts[key] = old_count + 1 + self._slot_value_counts[key] = len(pending_scores) + del self._pending_slot_value_updates[key] def _apply_text_update(self, text: str) -> None: """Applies preference updates for one buffered utterance. @@ -206,19 +225,9 @@ def _apply_text_update(self, text: str) -> None: Args: text: User utterance collected during the dialogue. """ - text_lower = text.lower() - global_score = self._score_text(text) - matched_values = self._extract_matched_values(text_lower) - if not matched_values: - return - - has_search_goal_cue = any( - cue in text_lower for cue in self.SEARCH_GOAL_CUES - ) - for slot, value in matched_values: - score = self._score_value_in_text( - text_lower, value.lower(), global_score, has_search_goal_cue - ) + text_lower = self._normalize_preference_value(text) + for slot, value in self._extract_matched_values(text_lower): + score = self._score_value_in_text(text_lower, value) if score != 0: self.update_slot_value_preference(slot, value, score) @@ -235,15 +244,13 @@ def _extract_matched_values(self, text_lower: str) -> List[Tuple[str, str]]: """ matches: List[Tuple[str, str]] = [] for slot, value, _ in self._rank_long_term_preferences(): - if len(value) >= 3 and value.lower() in text_lower: + if len(value) >= 3 and value in text_lower: matches.append((slot, value)) filtered_matches: List[Tuple[str, str]] = [] for slot, value in sorted(matches, key=lambda match: -len(match[1])): - value_lower = value.lower() if any( - value_lower != kept_value.lower() - and value_lower in kept_value.lower() + value != kept_value and value in kept_value for _, kept_value in filtered_matches ): continue @@ -251,20 +258,12 @@ def _extract_matched_values(self, text_lower: str) -> List[Tuple[str, str]]: return filtered_matches @staticmethod - def _score_value_in_text( - text_lower: str, - value_lower: str, - global_score: float, - has_search_goal_cue: bool, - ) -> float: + def _score_value_in_text(text_lower: str, value_lower: str) -> float: """Scores one matched value using nearby cues. Args: text_lower: Lowercased user utterance. value_lower: Lowercased slot value being inspected. - global_score: Fallback score computed for the whole utterance. - has_search_goal_cue: Whether the utterance looks like a search - goal rather than a stable preference. Returns: A local preference score for the matched value. @@ -275,38 +274,21 @@ def _score_value_in_text( if value_lower in clause ] if not contexts: - return 0 if has_search_goal_cue else global_score - - negative_prefix_cues = ("avoid", "nothing", "not", "no", "without") - for context in contexts: - before_value, _, _ = context.partition(value_lower) - if any(cue in before_value for cue in negative_prefix_cues): - return -StructuredPreferenceModel.PREFERENCE_SCORE + return 0 - negative_patterns = ( - rf"\btoo much\b[^.?!;]{{0,40}}\b{re.escape(value_lower)}\b", - ) for context in contexts: if any( - re.search(pattern, context) for pattern in negative_patterns + re.search(pattern.format(value=re.escape(value_lower)), context) + for pattern in StructuredPreferenceModel.NEGATIVE_PATTERNS ): return -StructuredPreferenceModel.PREFERENCE_SCORE - - positive_patterns = ( - rf"\bi\s+like\b[^.?!;]{{0,20}}\b{re.escape(value_lower)}\b", - rf"\bi\s+love\b[^.?!;]{{0,20}}\b{re.escape(value_lower)}\b", - rf"\bi\s+enjoy\b[^.?!;]{{0,20}}\b{re.escape(value_lower)}\b", - rf"\bi\s+prefer\b[^.?!;]{{0,20}}\b{re.escape(value_lower)}\b", - ) - for context in contexts: if any( - re.search(pattern, context) for pattern in positive_patterns + re.search(pattern.format(value=re.escape(value_lower)), context) + for pattern in StructuredPreferenceModel.POSITIVE_PATTERNS ): return StructuredPreferenceModel.PREFERENCE_SCORE - if has_search_goal_cue: - return 0 - return global_score + return 0 def update_from_dialogue(self, dialogue) -> None: """Updates preferences after a completed dialogue. @@ -329,34 +311,6 @@ def update_from_dialogue(self, dialogue) -> None: self._dialogue_buffer.append(text) - @staticmethod - def _score_text(text: str) -> float: - """Maps simple textual preference cues to a preference score. - - Args: - text: User utterance. - - Returns: - Positive, negative, or neutral score inferred from coarse cues. - """ - text_lower = text.lower() - if any( - cue in text_lower for cue in StructuredPreferenceModel.NEGATIVE_CUES - ): - return -StructuredPreferenceModel.PREFERENCE_SCORE - - if any( - cue in text_lower - for cue in StructuredPreferenceModel.SEARCH_GOAL_CUES - ): - return 0 - - if any( - cue in text_lower for cue in StructuredPreferenceModel.POSITIVE_CUES - ): - return StructuredPreferenceModel.PREFERENCE_SCORE - return 0 - def _rank_long_term_preferences(self) -> List[Tuple[str, str, float]]: """Ranks long-term slot-value preferences by support and strength. @@ -401,7 +355,7 @@ def get_preference_summary(self, max_preferences: int = 20) -> str: max_long_term = max(1, max_preferences // 2) sections = [ ( - "Ppositive preferences", + "Positive preferences", [ preference for preference in ranked_preferences From 81068342ec1fe3d044b7f28d4ccb062736f4721a Mon Sep 17 00:00:00 2001 From: Ksenia Blokhina Date: Tue, 9 Jun 2026 15:05:22 +0200 Subject: [PATCH 09/25] remove type --- usersimcrs/simulator/llm/prompt/prompt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/usersimcrs/simulator/llm/prompt/prompt.py b/usersimcrs/simulator/llm/prompt/prompt.py index 38db0f9b..703bc5eb 100644 --- a/usersimcrs/simulator/llm/prompt/prompt.py +++ b/usersimcrs/simulator/llm/prompt/prompt.py @@ -1,7 +1,7 @@ """Interface for prompt.""" from abc import ABC, abstractmethod -from typing import Any, Optional +from typing import Optional from dialoguekit.core.utterance import Utterance from dialoguekit.participant.participant import DialogueParticipant @@ -67,7 +67,7 @@ def _preference_context(self) -> str: ) @abstractmethod - def build_new_prompt(self, **kwargs: Any) -> str: + def build_new_prompt(self, **kwargs) -> str: """Builds the initial prompt without any context. Raises: From 1d5a2de9cea2691b6ae97844cb04ca4ba07745f2 Mon Sep 17 00:00:00 2001 From: Ksenia Blokhina Date: Tue, 9 Jun 2026 16:55:46 +0200 Subject: [PATCH 10/25] add session updates and preferences --- .../structured_preference_model.py | 215 ++++++++++++++---- 1 file changed, 175 insertions(+), 40 deletions(-) diff --git a/usersimcrs/user_modeling/structured_preference_model.py b/usersimcrs/user_modeling/structured_preference_model.py index 46c132f4..f683eb35 100644 --- a/usersimcrs/user_modeling/structured_preference_model.py +++ b/usersimcrs/user_modeling/structured_preference_model.py @@ -20,7 +20,7 @@ class StructuredPreferenceModel(PreferenceModel): UPDATE_STEP = 0.25 PREFERENCE_SCORE = 0.7 - NEW_PREFERENCE_MIN_CONFIRMATIONS = 2 + LONG_TERM_PROMOTION_MIN_CONFIRMATIONS = 2 NEGATIVE_PATTERNS = ( r"\b(?:avoid|nothing|not|no|without|not into|not too|too much)\b.*" r"\b{value}\b", @@ -63,12 +63,14 @@ def __init__( ) self._preference_threshold = preference_threshold self._item_preferences = UserPreferences(self._user_id) - self._slot_value_preferences = UserPreferences(self._user_id) - self._slot_value_counts: Dict[Tuple[str, str], int] = {} - self._pending_slot_value_updates: Dict[ + self._long_term_slot_value_preferences = UserPreferences(self._user_id) + self._long_term_slot_value_counts: Dict[Tuple[str, str], int] = {} + self._session_slot_value_preferences = UserPreferences(self._user_id) + self._session_slot_value_counts: Dict[Tuple[str, str], int] = {} + self._pending_session_slot_value_updates: Dict[ Tuple[str, str], List[float] ] = {} - self._dialogue_buffer: List[str] = [] + self._catalog_slot_values = self._collect_catalog_slot_values() self._initialize_preferences() @staticmethod @@ -92,6 +94,30 @@ def _initialize_preferences(self) -> None: slot_value_ratings = self._collect_slot_value_ratings() self._initialize_slot_value_preferences(slot_value_ratings) + def _collect_catalog_slot_values(self) -> Dict[str, List[str]]: + """Collects normalized slot-values available in the item catalog.""" + catalog_slot_values: Dict[str, List[str]] = {} + for slot in self._domain.get_slot_names(): + if slot.upper() in {"TITLE", "NAME"}: + continue + normalized_values = { + normalized_value + for value in self._item_collection.get_possible_property_values( + slot + ) + if value is not None + for normalized_value in [ + self._normalize_preference_value(value) + ] + if len(normalized_value) >= 3 + } + catalog_slot_values[slot] = sorted( + normalized_values, + key=len, + reverse=True, + ) + return catalog_slot_values + def _initialize_item_preferences(self) -> None: """Stores strong historical item ratings as item preferences.""" user_ratings = self._historical_ratings.get_user_ratings( @@ -134,9 +160,9 @@ def _initialize_slot_value_preferences( for slot, value_ratings in slot_value_ratings.items(): for value, ratings in value_ratings.items(): score = sum(ratings) / len(ratings) - self._slot_value_counts[(slot, value)] = len(ratings) + self._long_term_slot_value_counts[(slot, value)] = len(ratings) if abs(score) >= self._preference_threshold: - self._slot_value_preferences.set_preference( + self._long_term_slot_value_preferences.set_preference( slot, value, score ) @@ -180,7 +206,13 @@ def get_slot_value_preference(self, slot: str, value: str) -> float: """ self._assert_slot_exists(slot) value = self._normalize_preference_value(value) - preference = self._slot_value_preferences.get_preference(slot, value) + preference = self._session_slot_value_preferences.get_preference( + slot, value + ) + if preference is None: + preference = self._long_term_slot_value_preferences.get_preference( + slot, value + ) return preference if preference is not None else 0 def update_slot_value_preference( @@ -197,33 +229,39 @@ def update_slot_value_preference( value = self._normalize_preference_value(value) key = (slot, value) - old_score = self._slot_value_preferences.get_preference(slot, value) - old_count = self._slot_value_counts.get(key, 0) + old_score = self._session_slot_value_preferences.get_preference( + slot, value + ) + old_count = self._session_slot_value_counts.get(key, 0) if old_score is not None and old_count > 0: if score > old_score: new_score = min(old_score + self.UPDATE_STEP, score) else: new_score = max(old_score - self.UPDATE_STEP, score) - self._slot_value_preferences.set_preference(slot, value, new_score) - self._slot_value_counts[key] = old_count + 1 + self._session_slot_value_preferences.set_preference( + slot, value, new_score + ) + self._session_slot_value_counts[key] = old_count + 1 return - pending_scores = self._pending_slot_value_updates.setdefault(key, []) + pending_scores = self._pending_session_slot_value_updates.setdefault( + key, [] + ) pending_scores.append(score) - if len(pending_scores) < self.NEW_PREFERENCE_MIN_CONFIRMATIONS: - return new_score = sum(pending_scores) / len(pending_scores) - self._slot_value_preferences.set_preference(slot, value, new_score) - self._slot_value_counts[key] = len(pending_scores) - del self._pending_slot_value_updates[key] + self._session_slot_value_preferences.set_preference( + slot, value, new_score + ) + self._session_slot_value_counts[key] = len(pending_scores) + del self._pending_session_slot_value_updates[key] def _apply_text_update(self, text: str) -> None: - """Applies preference updates for one buffered utterance. + """Applies preference updates for one user utterance. Args: - text: User utterance collected during the dialogue. + text: User utterance. """ text_lower = self._normalize_preference_value(text) for slot, value in self._extract_matched_values(text_lower): @@ -231,9 +269,55 @@ def _apply_text_update(self, text: str) -> None: if score != 0: self.update_slot_value_preference(slot, value, score) + def _reset_session_preferences(self) -> None: + """Clears per-dialogue preference state.""" + self._session_slot_value_preferences = UserPreferences(self._user_id) + self._session_slot_value_counts.clear() + self._pending_session_slot_value_updates.clear() + + def _promote_session_preferences_to_long_term(self) -> None: + """Promotes well-confirmed session preferences to long-term memory.""" + for ( + slot, + value_preferences, + ) in self._session_slot_value_preferences._preferences.items(): + for value, session_score in value_preferences.items(): + key = (slot, value) + session_count = self._session_slot_value_counts.get(key, 0) + if session_count < self.LONG_TERM_PROMOTION_MIN_CONFIRMATIONS: + continue + + long_term_score = ( + self._long_term_slot_value_preferences.get_preference( + slot, value + ) + ) + if long_term_score is None: + self._long_term_slot_value_preferences.set_preference( + slot, value, session_score + ) + self._long_term_slot_value_counts[key] = session_count + continue + + if session_score > long_term_score: + new_score = min( + long_term_score + self.UPDATE_STEP, session_score + ) + else: + new_score = max( + long_term_score - self.UPDATE_STEP, session_score + ) + self._long_term_slot_value_preferences.set_preference( + slot, value, new_score + ) + self._long_term_slot_value_counts[key] = ( + self._long_term_slot_value_counts.get(key, 0) + + session_count + ) + def _extract_matched_values(self, text_lower: str) -> List[Tuple[str, str]]: - """Returns matched known preferences without shorter duplicate - fragments. + """Returns matched known or catalog preferences without shorter + duplicate fragments. Args: text_lower: Lowercased user utterance. @@ -243,9 +327,19 @@ def _extract_matched_values(self, text_lower: str) -> List[Tuple[str, str]]: substrings. """ matches: List[Tuple[str, str]] = [] - for slot, value, _ in self._rank_long_term_preferences(): + seen_matches = set() + for slot, value, _ in self._rank_preferences(): if len(value) >= 3 and value in text_lower: matches.append((slot, value)) + seen_matches.add((slot, value)) + + for slot, values in self._catalog_slot_values.items(): + for value in values: + if (slot, value) in seen_matches: + continue + if value in text_lower: + matches.append((slot, value)) + seen_matches.add((slot, value)) filtered_matches: List[Tuple[str, str]] = [] for slot, value in sorted(matches, key=lambda match: -len(match[1])): @@ -291,10 +385,7 @@ def _score_value_in_text(text_lower: str, value_lower: str) -> float: return 0 def update_from_dialogue(self, dialogue) -> None: - """Updates preferences after a completed dialogue. - - Buffers ordinary user turns and applies the accumulated updates once - the dialogue ends with a stop utterance. + """Updates preferences from one user turn. Args: dialogue: Dialogue utterance object. @@ -304,24 +395,55 @@ def update_from_dialogue(self, dialogue) -> None: return if text.lower() in self.DIALOGUE_STOP_TOKENS: - for buffered_text in self._dialogue_buffer: - self._apply_text_update(buffered_text) - self._dialogue_buffer.clear() + self._promote_session_preferences_to_long_term() + self._reset_session_preferences() return - self._dialogue_buffer.append(text) + self._apply_text_update(text) - def _rank_long_term_preferences(self) -> List[Tuple[str, str, float]]: - """Ranks long-term slot-value preferences by support and strength. + def _rank_preferences(self) -> List[Tuple[str, str, float]]: + """Ranks session and long-term slot-value preferences. Returns: - Sorted list of long-term slot-value preferences. + Sorted list of slot-value preferences, with session preferences + overriding long-term ones for the same slot-value pair. """ + ranked_preferences: Dict[Tuple[str, str], Tuple[str, str, float]] = {} + for preference_store in ( + self._long_term_slot_value_preferences, + self._session_slot_value_preferences, + ): + for ( + slot, + value_preferences, + ) in preference_store._preferences.items(): + for value, score in value_preferences.items(): + ranked_preferences[(slot, value)] = (slot, value, score) + + return sorted( + ranked_preferences.values(), + key=lambda preference: ( + -math.log1p( + self._session_slot_value_counts.get( + (preference[0], preference[1]), + self._long_term_slot_value_counts.get( + (preference[0], preference[1]), 0 + ), + ) + ), + -abs(preference[2]), + preference[0], + preference[1], + ), + ) + + def _rank_long_term_preferences(self) -> List[Tuple[str, str, float]]: + """Ranks long-term slot-value preferences by support and strength.""" ranked_preferences: List[Tuple[str, str, float]] = [] for ( slot, value_preferences, - ) in self._slot_value_preferences._preferences.items(): + ) in self._long_term_slot_value_preferences._preferences.items(): for value, score in value_preferences.items(): ranked_preferences.append((slot, value, score)) @@ -329,7 +451,7 @@ def _rank_long_term_preferences(self) -> List[Tuple[str, str, float]]: ranked_preferences, key=lambda preference: ( -math.log1p( - self._slot_value_counts.get( + self._long_term_slot_value_counts.get( (preference[0], preference[1]), 0 ) ), @@ -348,17 +470,30 @@ def get_preference_summary(self, max_preferences: int = 20) -> str: Returns: Summary for the LLM of long-term preferences. """ - ranked_preferences = self._rank_long_term_preferences() - if not ranked_preferences: + long_term_preferences = self._rank_long_term_preferences() + session_preferences = [ + preference + for preference in self._rank_preferences() + if self._session_slot_value_preferences.get_preference( + preference[0], preference[1] + ) + is not None + ] + if not long_term_preferences and not session_preferences: return "" max_long_term = max(1, max_preferences // 2) + max_session = max(1, max_preferences - max_long_term) sections = [ + ( + "Session preferences", + session_preferences[:max_session], + ), ( "Positive preferences", [ preference - for preference in ranked_preferences + for preference in long_term_preferences if preference[2] >= self._preference_threshold ][:max_long_term], ), @@ -366,7 +501,7 @@ def get_preference_summary(self, max_preferences: int = 20) -> str: "Negative preferences", [ preference - for preference in ranked_preferences + for preference in long_term_preferences if preference[2] <= -self._preference_threshold ][:max_long_term], ), From 8061d18b766d529e5f46f79e40fc88fbbf560ca8 Mon Sep 17 00:00:00 2001 From: Ksenia Blokhina Date: Tue, 9 Jun 2026 18:26:28 +0200 Subject: [PATCH 11/25] change score update --- .../structured_preference_model.py | 93 +++++++++++++++---- 1 file changed, 74 insertions(+), 19 deletions(-) diff --git a/usersimcrs/user_modeling/structured_preference_model.py b/usersimcrs/user_modeling/structured_preference_model.py index f683eb35..454c1364 100644 --- a/usersimcrs/user_modeling/structured_preference_model.py +++ b/usersimcrs/user_modeling/structured_preference_model.py @@ -24,10 +24,27 @@ class StructuredPreferenceModel(PreferenceModel): NEGATIVE_PATTERNS = ( r"\b(?:avoid|nothing|not|no|without|not into|not too|too much)\b.*" r"\b{value}\b", + r"\bnot interested in\b.*\b{value}\b", + r"\b(?:anything but|other than|except|rather than)\b.*\b{value}\b", + r"\bnot a[n]?\b.*\b{value}\b", + r"\bnot in\b.*\b{value}\b(?:\s+genre)?\b", + r"\bwithout\b.*\b{value}\b", r"\b{value}\s+(?:heavy|packed)\b", ) POSITIVE_PATTERNS = ( r"\b(?:like|love|prefer|enjoy)\b.*\b{value}\b", + r"\blooking for\b.*\b{value}\b", + r"\binterested in\b.*\b{value}\b", + r"\breally looking for\b.*\b{value}\b", + r"\bcan you recommend\b.*\b{value}\b", + r"\bcould you suggest\b.*\b{value}\b", + r"\b(?:with|about|featuring|set in|centered around|based on)\b.*" + r"\b{value}\b", + r"\bthemes? of\b.*\b{value}\b", + r"\bmore\b.*\b{value}\b", + r"\bwithin the\b.*\b{value}\b(?:\s+genre)?\b", + r"\bin the\b.*\b{value}\b(?:\s+genre)?\b", + r"\b{value}\b.*\bover other genres\b", r"\b{value}\s+focused\b", ) DIALOGUE_STOP_TOKENS = { @@ -67,9 +84,6 @@ def __init__( self._long_term_slot_value_counts: Dict[Tuple[str, str], int] = {} self._session_slot_value_preferences = UserPreferences(self._user_id) self._session_slot_value_counts: Dict[Tuple[str, str], int] = {} - self._pending_session_slot_value_updates: Dict[ - Tuple[str, str], List[float] - ] = {} self._catalog_slot_values = self._collect_catalog_slot_values() self._initialize_preferences() @@ -220,6 +234,10 @@ def update_slot_value_preference( ) -> None: """Updates one slot-value preference. + Existing long-term preferences are updated in place. New preferences + are first tracked in the session layer and may later be promoted to + long-term memory. + Args: slot: Slot name. value: Slot value. @@ -228,6 +246,50 @@ def update_slot_value_preference( self._assert_slot_exists(slot) value = self._normalize_preference_value(value) + if ( + self._long_term_slot_value_preferences.get_preference(slot, value) + is not None + ): + self._update_existing_preference( + self._long_term_slot_value_preferences, + self._long_term_slot_value_counts, + slot, + value, + score, + ) + return + + self._update_session_preference(slot, value, score) + + def _update_existing_preference( + self, + preference_store: UserPreferences, + preference_counts: Dict[Tuple[str, str], int], + slot: str, + value: str, + score: float, + ) -> None: + """Updates an already stored preference by moving it toward a signal.""" + key = (slot, value) + old_score = preference_store.get_preference(slot, value) + old_count = preference_counts.get(key, 0) + + if old_score is None: + preference_store.set_preference(slot, value, score) + preference_counts[key] = 1 + return + + direction = 1 if score > 0 else -1 + new_score = max( + -1.0, min(1.0, old_score + direction * self.UPDATE_STEP) + ) + preference_store.set_preference(slot, value, new_score) + preference_counts[key] = max(1, old_count) + 1 + + def _update_session_preference( + self, slot: str, value: str, score: float + ) -> None: + """Tracks a new preference inside the current dialogue session.""" key = (slot, value) old_score = self._session_slot_value_preferences.get_preference( slot, value @@ -235,27 +297,21 @@ def update_slot_value_preference( old_count = self._session_slot_value_counts.get(key, 0) if old_score is not None and old_count > 0: - if score > old_score: - new_score = min(old_score + self.UPDATE_STEP, score) - else: - new_score = max(old_score - self.UPDATE_STEP, score) - self._session_slot_value_preferences.set_preference( - slot, value, new_score + self._update_existing_preference( + self._session_slot_value_preferences, + self._session_slot_value_counts, + slot, + value, + score, ) - self._session_slot_value_counts[key] = old_count + 1 return - pending_scores = self._pending_session_slot_value_updates.setdefault( - key, [] - ) - pending_scores.append(score) - - new_score = sum(pending_scores) / len(pending_scores) + direction = 1 if score > 0 else -1 + new_score = direction * (2 * self.UPDATE_STEP) self._session_slot_value_preferences.set_preference( slot, value, new_score ) - self._session_slot_value_counts[key] = len(pending_scores) - del self._pending_session_slot_value_updates[key] + self._session_slot_value_counts[key] = 1 def _apply_text_update(self, text: str) -> None: """Applies preference updates for one user utterance. @@ -273,7 +329,6 @@ def _reset_session_preferences(self) -> None: """Clears per-dialogue preference state.""" self._session_slot_value_preferences = UserPreferences(self._user_id) self._session_slot_value_counts.clear() - self._pending_session_slot_value_updates.clear() def _promote_session_preferences_to_long_term(self) -> None: """Promotes well-confirmed session preferences to long-term memory.""" From 409840939ef4815e825147972eb5bfc9c4d2e780 Mon Sep 17 00:00:00 2001 From: Ksenia Blokhina Date: Tue, 23 Jun 2026 13:33:01 +0200 Subject: [PATCH 12/25] code refactoring --- .../agenda_based/agenda_based_simulator.py | 1 - usersimcrs/simulator/llm/prompt/prompt.py | 34 ++++++++++++++-- .../simulator/llm/prompt/stop_prompt.py | 39 +++++++++++-------- .../llm/prompt/utterance_generation_prompt.py | 24 +++++++----- .../user_modeling/pkg_preference_model.py | 19 ++++++++- usersimcrs/user_modeling/preference_model.py | 27 ++++++++++++- .../user_modeling/simple_preference_model.py | 19 ++++++++- .../structured_preference_model.py | 12 +++--- 8 files changed, 133 insertions(+), 42 deletions(-) diff --git a/usersimcrs/simulator/agenda_based/agenda_based_simulator.py b/usersimcrs/simulator/agenda_based/agenda_based_simulator.py index 663e137a..baa93c3b 100644 --- a/usersimcrs/simulator/agenda_based/agenda_based_simulator.py +++ b/usersimcrs/simulator/agenda_based/agenda_based_simulator.py @@ -47,7 +47,6 @@ def __init__( item_collection=item_collection, preference_model=preference_model, ) - self._preference_model = self.preference_model self._interaction_model = interaction_model self._interaction_model.initialize_agenda(self.information_need) self._nlu = nlu diff --git a/usersimcrs/simulator/llm/prompt/prompt.py b/usersimcrs/simulator/llm/prompt/prompt.py index 703bc5eb..314bea7a 100644 --- a/usersimcrs/simulator/llm/prompt/prompt.py +++ b/usersimcrs/simulator/llm/prompt/prompt.py @@ -58,12 +58,38 @@ def _preference_context(self) -> str: return "" return ( - "USER PREFERENCES: " + f"{self._preference_context_label}: " f"{preference_summary}\n" + f"{self._preference_context_guidance}\n" + ) + + @property + def _preference_context_label(self) -> str: + """Returns the label used for preference context.""" + return "YOUR PREFERENCES" + + @property + def _preference_context_guidance(self) -> str: + """Returns the guidance appended after the preference summary.""" + return ( "Treat these preferences as soft background tastes, not as new " - "requirements. Use them to shape what the user accepts, " - "rejects, asks to avoid, or follows up on. Do not force every " - "liked preference into the opening request.\n" + "requirements." + ) + + @property + def _persona_text(self) -> str: + """Returns a compact persona description.""" + if not self.persona: + return "" + + if self.persona.persona_description: + return self.persona.persona_description + + return ", ".join( + [ + f"{key}={value}" + for key, value in self.persona.characteristics.items() + ] ) @abstractmethod diff --git a/usersimcrs/simulator/llm/prompt/stop_prompt.py b/usersimcrs/simulator/llm/prompt/stop_prompt.py index 23a7c605..ae4f6d3a 100644 --- a/usersimcrs/simulator/llm/prompt/stop_prompt.py +++ b/usersimcrs/simulator/llm/prompt/stop_prompt.py @@ -48,13 +48,29 @@ def __init__( @property def prompt_text(self) -> str: """Prompt for the user simulator.""" + prompt_text = self._initial_prompt + + if self._preference_context: + prompt_text += self._preference_context.rstrip("\n") + + prompt_text += "\n" + + if self._prompt_context: + prompt_text += self._prompt_context.rstrip("\n") + + return prompt_text + "\nCONTINUE: " + + @property + def _preference_context_label(self) -> str: + """Returns the label used for preference context.""" + return "YOUR PREFERENCES" + + @property + def _preference_context_guidance(self) -> str: + """Returns the guidance appended after the preference summary.""" return ( - self._initial_prompt - + "\n" - + self._preference_context - + self._prompt_context - + "\n" - + "CONTINUE: " + "Use these preferences only as soft context when deciding " + "whether the conversation is still productive." ) def build_new_prompt(self) -> str: @@ -70,16 +86,7 @@ def build_new_prompt(self) -> str: " Take into account your PERSONA when deciding to stop the " "conversation.\n" ) - stringified_characteristics = ", ".join( - [ - f"{key}={value}" - for key, value in self.persona.characteristics.items() - ] - ) - persona_text = ( - self.persona.persona_description or stringified_characteristics - ) - initial_prompt += f"PERSONA: {persona_text}\n" + initial_prompt += f"PERSONA: {self._persona_text}\n" initial_prompt += "\nHISTORY:\n" return initial_prompt diff --git a/usersimcrs/simulator/llm/prompt/utterance_generation_prompt.py b/usersimcrs/simulator/llm/prompt/utterance_generation_prompt.py index a8fd4d8e..c7c6a406 100644 --- a/usersimcrs/simulator/llm/prompt/utterance_generation_prompt.py +++ b/usersimcrs/simulator/llm/prompt/utterance_generation_prompt.py @@ -70,16 +70,7 @@ def build_new_prompt(self) -> str: initial_prompt += ( " Adapt your responses considering your PERSONA.\n" ) - stringified_characteristics = ", ".join( - [ - f"{key}={value}" - for key, value in self.persona.characteristics.items() - ] - ) - persona_text = ( - self.persona.persona_description or stringified_characteristics - ) - initial_prompt += f"PERSONA: {persona_text}\n" + initial_prompt += f"PERSONA: {self._persona_text}\n" else: initial_prompt += ( "Be precise with the REQUIREMENTS, clear and concise.\n" @@ -102,6 +93,19 @@ def build_new_prompt(self) -> str: ) return initial_prompt + @property + def _preference_context_label(self) -> str: + """Returns the label used for preference context.""" + return "YOUR PREFERENCES" + + @property + def _preference_context_guidance(self) -> str: + """Returns the guidance appended after the preference summary.""" + return ( + "Use these preferences to shape what the user accepts, rejects, " + "asks to avoid, or follows up on." + ) + def update_prompt_context( self, utterance: Utterance, participant: DialogueParticipant ) -> None: diff --git a/usersimcrs/user_modeling/pkg_preference_model.py b/usersimcrs/user_modeling/pkg_preference_model.py index 1ba0c964..70079b5f 100644 --- a/usersimcrs/user_modeling/pkg_preference_model.py +++ b/usersimcrs/user_modeling/pkg_preference_model.py @@ -72,9 +72,24 @@ def get_slot_value_preference(self, slot: str, value: str) -> float: return preference def get_preference_summary(self, max_preferences: int = 10) -> str: - """Returns an empty summary until PKG prompt support is added.""" + """Returns an empty summary until PKG prompt support is added. + + Args: + max_preferences: Maximum number of preferences to include in the + summary. + + Returns: + Empty summary. + """ return "" def update_from_dialogue(self, dialogue) -> None: - """PKG-backed dialogue updates are not implemented yet.""" + """PKG-backed dialogue updates are not implemented yet. + + Args: + dialogue: Dialogue used to update preferences. + + Returns: + None. + """ return None diff --git a/usersimcrs/user_modeling/preference_model.py b/usersimcrs/user_modeling/preference_model.py index 7a31991a..557d9df1 100644 --- a/usersimcrs/user_modeling/preference_model.py +++ b/usersimcrs/user_modeling/preference_model.py @@ -176,12 +176,35 @@ def get_slot_preference(self, slot: str) -> Tuple[str, float]: @abstractmethod def get_preference_summary(self, max_preferences: int = 10) -> str: - """Returns a compact preference summary for prompts.""" + """Returns a compact preference summary for prompts. + + Args: + max_preferences: Maximum number of preferences to include in the + summary. + + Returns: + Compact preference summary. + + Raises: + NotImplementedError: If the method is not implemented in a + subclass. + """ raise NotImplementedError @abstractmethod def update_from_dialogue(self, dialogue) -> None: - """Updates preferences from dialogue if supported by the model.""" + """Updates preferences from dialogue if supported by the model. + + Args: + dialogue: Dialogue used to update preferences. + + Returns: + None. + + Raises: + NotImplementedError: If the method is not implemented in a + subclass. + """ raise NotImplementedError @classmethod diff --git a/usersimcrs/user_modeling/simple_preference_model.py b/usersimcrs/user_modeling/simple_preference_model.py index 5e3ee06d..18d3aa0d 100644 --- a/usersimcrs/user_modeling/simple_preference_model.py +++ b/usersimcrs/user_modeling/simple_preference_model.py @@ -100,9 +100,24 @@ def get_slot_value_preference(self, slot: str, value: str) -> float: return preference def get_preference_summary(self, max_preferences: int = 10) -> str: - """Returns an empty summary for non-structured preferences.""" + """Returns an empty summary for non-structured preferences. + + Args: + max_preferences: Maximum number of preferences to include in the + summary. + + Returns: + Empty summary. + """ return "" def update_from_dialogue(self, dialogue) -> None: - """Simple preferences do not update from dialogue.""" + """Simple preferences do not update from dialogue. + + Args: + dialogue: Dialogue used to update preferences. + + Returns: + None. + """ return None diff --git a/usersimcrs/user_modeling/structured_preference_model.py b/usersimcrs/user_modeling/structured_preference_model.py index 454c1364..7c6bc0ed 100644 --- a/usersimcrs/user_modeling/structured_preference_model.py +++ b/usersimcrs/user_modeling/structured_preference_model.py @@ -7,6 +7,7 @@ from dialoguekit.participant.user_preferences import UserPreferences from usersimcrs.core.simulation_domain import SimulationDomain +from usersimcrs.items.item import Item from usersimcrs.items.item_collection import ItemCollection from usersimcrs.items.ratings import Ratings from usersimcrs.user_modeling.preference_model import ( @@ -72,8 +73,9 @@ def __init__( item_collection: Item collection. historical_ratings: Historical ratings. historical_user_id: Historical user ID. Defaults to None. - preference_threshold: Minimum absolute score to store as - meaningful preference. + preference_threshold: Minimum absolute score to store as a + meaningful preference. Defaults to + `PreferenceModel.PREFERENCE_THRESHOLD`. """ super().__init__( domain, item_collection, historical_ratings, historical_user_id @@ -97,7 +99,7 @@ def _normalize_preference_value(value: str) -> str: Returns: Value with normalized spacing and hyphens. """ - return " ".join(str(value).lower().replace("-", " ").split()) + return " ".join(value.lower().replace("-", " ").split()) def _initialize_preferences(self) -> None: """Initializes preferences from historical ratings. @@ -133,7 +135,7 @@ def _collect_catalog_slot_values(self) -> Dict[str, List[str]]: return catalog_slot_values def _initialize_item_preferences(self) -> None: - """Stores strong historical item ratings as item preferences.""" + """Stores historical item ratings above the threshold as preferences.""" user_ratings = self._historical_ratings.get_user_ratings( self._historical_user_id ) @@ -180,7 +182,7 @@ def _initialize_slot_value_preferences( slot, value, score ) - def _iter_preference_values(self, item) -> Iterable[Tuple[str, str]]: + def _iter_preference_values(self, item: Item) -> Iterable[Tuple[str, str]]: """Yields preference-relevant slot-value pairs for an item. Excludes title/name fields and normalizes multi-valued properties. From c8a809e61aa3bcdd4483df7e2f45484a9fb88126 Mon Sep 17 00:00:00 2001 From: Ksenia Blokhina Date: Tue, 23 Jun 2026 13:40:08 +0200 Subject: [PATCH 13/25] add docstring --- usersimcrs/user_modeling/structured_preference_model.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/usersimcrs/user_modeling/structured_preference_model.py b/usersimcrs/user_modeling/structured_preference_model.py index 7c6bc0ed..46f85ecf 100644 --- a/usersimcrs/user_modeling/structured_preference_model.py +++ b/usersimcrs/user_modeling/structured_preference_model.py @@ -1,3 +1,5 @@ +"""Structured preference model backed by historical ratings and metadata.""" + from __future__ import annotations import math From 8fd213db3ddf6c12d796e418b4452ba50ceb3998 Mon Sep 17 00:00:00 2001 From: Ksenia Blokhina Date: Tue, 14 Jul 2026 16:46:38 +0200 Subject: [PATCH 14/25] add preference model interfaces --- .../structured_preference/__init__.py | 21 + .../heuristic_signal_extractor.py | 223 ++++++++ .../llm_signal_extractor.py | 20 + .../preference_memory.py | 100 ++++ .../structured_preference/signal_extractor.py | 51 ++ .../structured_preference/update_agent.py | 224 ++++++++ .../structured_preference_model.py | 507 ++++-------------- 7 files changed, 729 insertions(+), 417 deletions(-) create mode 100644 usersimcrs/user_modeling/structured_preference/__init__.py create mode 100644 usersimcrs/user_modeling/structured_preference/heuristic_signal_extractor.py create mode 100644 usersimcrs/user_modeling/structured_preference/llm_signal_extractor.py create mode 100644 usersimcrs/user_modeling/structured_preference/preference_memory.py create mode 100644 usersimcrs/user_modeling/structured_preference/signal_extractor.py create mode 100644 usersimcrs/user_modeling/structured_preference/update_agent.py diff --git a/usersimcrs/user_modeling/structured_preference/__init__.py b/usersimcrs/user_modeling/structured_preference/__init__.py new file mode 100644 index 00000000..96b13961 --- /dev/null +++ b/usersimcrs/user_modeling/structured_preference/__init__.py @@ -0,0 +1,21 @@ +"""Helpers for structured preference modeling.""" + +from .heuristic_signal_extractor import HeuristicPreferenceSignalsExtractor +from .llm_signal_extractor import LLMPreferenceSignalsExtractor +from .preference_memory import PreferenceMemory +from .signal_extractor import ( + PreferenceSignal, + PreferenceSignalsExtractor, + normalize_preference_value, +) +from .update_agent import PreferenceUpdateAgent + +__all__ = [ + "HeuristicPreferenceSignalsExtractor", + "LLMPreferenceSignalsExtractor", + "PreferenceMemory", + "PreferenceSignal", + "PreferenceSignalsExtractor", + "PreferenceUpdateAgent", + "normalize_preference_value", +] diff --git a/usersimcrs/user_modeling/structured_preference/heuristic_signal_extractor.py b/usersimcrs/user_modeling/structured_preference/heuristic_signal_extractor.py new file mode 100644 index 00000000..45ca0a0e --- /dev/null +++ b/usersimcrs/user_modeling/structured_preference/heuristic_signal_extractor.py @@ -0,0 +1,223 @@ +"""Heuristic signal extractor for structured preference modeling.""" + +from __future__ import annotations + +import re +from typing import Dict, List, Tuple + +from usersimcrs.core.simulation_domain import SimulationDomain +from usersimcrs.items.item_collection import ItemCollection +from usersimcrs.user_modeling.structured_preference.signal_extractor import ( + PreferenceSignal, + PreferenceSignalsExtractor, + normalize_preference_value, +) + + +class HeuristicPreferenceSignalsExtractor(PreferenceSignalsExtractor): + """Extracts preference signals with simple text heuristics.""" + + PREFERENCE_SCORE = 0.7 + NEGATIVE_PATTERNS = ( + r"\b(?:avoid|nothing|not|no|without|not into|not too|too much)\b.*" + r"\b{value}\b", + r"\bnot interested in\b.*\b{value}\b", + r"\b(?:anything but|other than|except|rather than)\b.*\b{value}\b", + ) + POSITIVE_PATTERNS = ( + r"\b(?:like|love|prefer|enjoy)\b.*\b{value}\b", + r"\blooking for\b.*\b{value}\b", + r"\binterested in\b.*\b{value}\b", + r"\b(?:with|about|featuring|set in)\b.*\b{value}\b", + r"\bmore\b.*\b{value}\b", + ) + + def __init__( + self, + domain: SimulationDomain, + item_collection: ItemCollection, + ) -> None: + """Initializes the heuristic signal extractor. + + Args: + domain: Simulation domain. + item_collection: Item collection. + """ + self._domain = domain + self._item_collection = item_collection + self._catalog_slot_values = self._build_catalog_slot_values() + self._item_names = self._build_item_names() + + def _build_catalog_slot_values(self) -> Dict[str, List[str]]: + """Builds normalized catalog slot values. + + Returns: + Mapping from slot to normalized values. + """ + catalog_slot_values: Dict[str, List[str]] = {} + for slot in self._domain.get_slot_names(): + if slot.upper() in {"TITLE", "NAME"}: + continue + values = { + normalize_preference_value(value) + for value in self._item_collection.get_possible_property_values( + slot + ) + if value is not None + and len(normalize_preference_value(value)) >= 3 + } + catalog_slot_values[slot] = sorted(values, key=len, reverse=True) + return catalog_slot_values + + def _build_item_names(self) -> Dict[str, List[Tuple[str, str]]]: + """Builds normalized item titles and names. + + Returns: + Mapping from normalized item name to item identifiers. + """ + item_names: Dict[str, List[Tuple[str, str]]] = {} + available_slots = [ + slot + for slot in ("TITLE", "NAME") + if slot in self._domain.get_slot_names() + ] + if not available_slots: + return item_names + + query = ( + f"SELECT id, {', '.join(available_slots)} " + f"FROM {self._item_collection._table_name}" + ) + self._item_collection._cursor.execute(query) + for row in self._item_collection._cursor.fetchall(): + for slot in available_slots: + value = row[slot] + if value is None: + continue + normalized = normalize_preference_value(value) + if len(normalized) < 3: + continue + item_names.setdefault(row["id"], []).append((slot, normalized)) + return item_names + + def _extract_matched_values(self, text: str) -> List[Tuple[str, str]]: + """Extracts matched slot values from text. + + Args: + text: Normalized text. + + Returns: + Matched slot-value pairs. + """ + matches: List[Tuple[str, str]] = [] + for slot, values in self._catalog_slot_values.items(): + for value in values: + if value in text: + matches.append((slot, value)) + return self._deduplicate_matches(matches) + + def _extract_matched_items(self, text: str) -> List[str]: + """Extracts matched item identifiers from text. + + Args: + text: Normalized text. + + Returns: + Matched item identifiers. + """ + matches: List[Tuple[str, str]] = [] + for item_id, item_name in self._item_names.items(): + if item_name and item_name[0][1] in text: + matches.append((item_id, item_name[0][1])) + return [item_id for item_id, _ in self._deduplicate_matches(matches)] + + def _deduplicate_matches( + self, matches: List[Tuple[str, str]] + ) -> List[Tuple[str, str]]: + """Removes shorter duplicate matches. + + Args: + matches: Raw matches. + + Returns: + Filtered matches. + """ + filtered: List[Tuple[str, str]] = [] + for key, value in sorted(matches, key=lambda match: -len(match[1])): + if any(value != kept and value in kept for _, kept in filtered): + continue + filtered.append((key, value)) + return filtered + + def _score_value_in_text(self, text: str, value: str) -> float: + """Scores a matched value in text. + + Args: + text: Normalized text. + value: Normalized matched value. + + Returns: + Preference score. + """ + contexts = [ + clause.strip() + for clause in re.split(r"[.!?;]", text) + if value in clause + ] + for context in contexts: + if any( + re.search(pattern.format(value=re.escape(value)), context) + for pattern in self.NEGATIVE_PATTERNS + ): + return -self.PREFERENCE_SCORE + if any( + re.search(pattern.format(value=re.escape(value)), context) + for pattern in self.POSITIVE_PATTERNS + ): + return self.PREFERENCE_SCORE + return 0 + + def extract( + self, + user_utterance: str, + rating: float | None = None, + past_dialogues: List[str] | None = None, + ) -> List[PreferenceSignal]: + text = normalize_preference_value(user_utterance) + signals: List[PreferenceSignal] = [] + + for slot, value in self._extract_matched_values(text): + score = self._score_value_in_text(text, value) + if score: + signals.append( + PreferenceSignal( + slot=slot, + value=value, + score=score, + source="attribute", + ) + ) + + for item_id in self._extract_matched_items(text): + item = self._item_collection.get_item(item_id) + if item is None: + continue + item_name = next( + ( + normalize_preference_value(item.get_property(slot)) + for slot in ("TITLE", "NAME") + if item.get_property(slot) + ), + "", + ) + score = self._score_value_in_text(text, item_name) + if score: + signals.append( + PreferenceSignal( + item_id=item_id, + score=score, + source="item", + ) + ) + + return signals diff --git a/usersimcrs/user_modeling/structured_preference/llm_signal_extractor.py b/usersimcrs/user_modeling/structured_preference/llm_signal_extractor.py new file mode 100644 index 00000000..24978cb0 --- /dev/null +++ b/usersimcrs/user_modeling/structured_preference/llm_signal_extractor.py @@ -0,0 +1,20 @@ +"""LLM signal extractor for structured preference modeling.""" + +from __future__ import annotations + +from typing import List, Optional + +from usersimcrs.user_modeling.structured_preference.signal_extractor import ( + PreferenceSignal, + PreferenceSignalsExtractor, +) + + +class LLMPreferenceSignalsExtractor(PreferenceSignalsExtractor): + def extract( + self, + user_utterance: str, + rating: float | None = None, + past_dialogues: Optional[List[str]] = None, + ) -> List[PreferenceSignal]: + raise NotImplementedError diff --git a/usersimcrs/user_modeling/structured_preference/preference_memory.py b/usersimcrs/user_modeling/structured_preference/preference_memory.py new file mode 100644 index 00000000..9f277a45 --- /dev/null +++ b/usersimcrs/user_modeling/structured_preference/preference_memory.py @@ -0,0 +1,100 @@ +"""Preference memory helpers for structured preference modeling.""" + +from __future__ import annotations + +from typing import Dict, Iterator, Tuple + +from dialoguekit.participant.user_preferences import UserPreferences + + +class PreferenceMemory: + """Stores slot-value preferences and their evidence counts.""" + + def __init__(self, user_id: str) -> None: + """Initializes preference memory. + + Args: + user_id: User identifier. + """ + self._user_id = user_id + self._preferences = UserPreferences(user_id) + self._counts: Dict[Tuple[str, str], int] = {} + + def get(self, slot: str, value: str) -> float | None: + """Returns a stored preference. + + Args: + slot: Slot name. + value: Slot value. + + Returns: + Stored preference or None. + """ + return self._preferences.get_preference(slot, value) + + def set( + self, slot: str, value: str, score: float, count: int | None = None + ) -> None: + """Stores a preference. + + Args: + slot: Slot name. + value: Slot value. + score: Preference score. + count: Optional evidence count. + """ + self._preferences.set_preference(slot, value, score) + if count is not None: + self._counts[(slot, value)] = count + + def increment_towards( + self, slot: str, value: str, score: float, step: float + ) -> float: + """Moves a stored preference toward a new signal. + + Args: + slot: Slot name. + value: Slot value. + score: New signal score. + step: Update step size. + + Returns: + Updated preference score. + """ + current = self.get(slot, value) + if current is None: + self.set(slot, value, score, 1) + return score + + direction = 1 if score > current else -1 + updated = max(-1.0, min(1.0, current + direction * step)) + self.set(slot, value, updated) + self._counts[(slot, value)] = self.get_count(slot, value) + 1 + return updated + + def get_count(self, slot: str, value: str) -> int: + """Returns evidence count for a preference. + + Args: + slot: Slot name. + value: Slot value. + + Returns: + Evidence count. + """ + return self._counts.get((slot, value), 0) + + def clear(self) -> None: + """Clears all stored preferences.""" + self._preferences = UserPreferences(self._user_id) + self._counts.clear() + + def items(self) -> Iterator[Tuple[str, str, float, int]]: + """Iterates over stored preferences. + + Returns: + Iterator of slot, value, score, and evidence count. + """ + for slot, value_preferences in self._preferences._preferences.items(): + for value, score in value_preferences.items(): + yield slot, value, score, self.get_count(slot, value) diff --git a/usersimcrs/user_modeling/structured_preference/signal_extractor.py b/usersimcrs/user_modeling/structured_preference/signal_extractor.py new file mode 100644 index 00000000..c3579dfc --- /dev/null +++ b/usersimcrs/user_modeling/structured_preference/signal_extractor.py @@ -0,0 +1,51 @@ +"""Signal extraction interfaces for structured preference modeling.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import List, Optional + + +def normalize_preference_value(value: str) -> str: + return " ".join(str(value).lower().replace("-", " ").split()) + + +@dataclass +class PreferenceSignal: + """Represents one extracted preference signal. + + Args: + score: Signal score in [-1, 1]. + slot: Slot name for an explicit attribute signal. + value: Slot value for an explicit attribute signal. + item_id: Item identifier for an item-based signal. + source: Signal source. + """ + + score: float + slot: Optional[str] = None + value: Optional[str] = None + item_id: Optional[str] = None + source: str = "attribute" + + +class PreferenceSignalsExtractor(ABC): + @abstractmethod + def extract( + self, + user_utterance: str, + rating: float | None = None, + past_dialogues: Optional[List[str]] = None, + ) -> List[PreferenceSignal]: + """Extracts preference signals from user input. + + Args: + user_utterance: User utterance. + rating: Optional rating signal. + past_dialogues: Optional past dialogue texts. + + Returns: + Extracted preference signals. + """ + raise NotImplementedError diff --git a/usersimcrs/user_modeling/structured_preference/update_agent.py b/usersimcrs/user_modeling/structured_preference/update_agent.py new file mode 100644 index 00000000..e883229e --- /dev/null +++ b/usersimcrs/user_modeling/structured_preference/update_agent.py @@ -0,0 +1,224 @@ +"""Preference update agent for structured preference modeling.""" + +from __future__ import annotations + +from typing import Iterable, Set, Tuple + +from usersimcrs.core.simulation_domain import SimulationDomain +from usersimcrs.items.item_collection import ItemCollection +from usersimcrs.user_modeling.structured_preference.preference_memory import ( + PreferenceMemory, +) +from usersimcrs.user_modeling.structured_preference.signal_extractor import ( + PreferenceSignal, + normalize_preference_value, +) + + +class PreferenceUpdateAgent: + """Applies extracted preference signals to preference memory.""" + + def __init__( + self, + domain: SimulationDomain, + item_collection: ItemCollection, + long_term_preferences: PreferenceMemory, + session_preferences: PreferenceMemory, + preference_threshold: float, + update_step: float, + promotion_min_confirmations: int, + ) -> None: + """Initializes the preference update agent. + + Args: + domain: Simulation domain. + item_collection: Item collection. + long_term_preferences: Long-term preference memory. + session_preferences: Session preference memory. + preference_threshold: Preference threshold. + update_step: Update step size. + promotion_min_confirmations: Minimum confirmations for promotion. + """ + self._domain = domain + self._item_collection = item_collection + self._long_term_preferences = long_term_preferences + self._session_preferences = session_preferences + self._preference_threshold = preference_threshold + self._update_step = update_step + self._promotion_min_confirmations = promotion_min_confirmations + + def update_slot_value_preference( + self, slot: str, value: str, score: float + ) -> None: + """Updates a slot-value preference. + + Args: + slot: Slot name. + value: Slot value. + score: Preference score. + """ + value = normalize_preference_value(value) + if self._long_term_preferences.get(slot, value) is not None: + self._update_existing_preference( + self._long_term_preferences, slot, value, score + ) + return + self._update_session_preference(slot, value, score) + + def _update_existing_preference( + self, + preference_memory: PreferenceMemory, + slot: str, + value: str, + score: float, + ) -> None: + """Updates an existing preference. + + Args: + preference_memory: Preference memory to update. + slot: Slot name. + value: Slot value. + score: Preference score. + """ + preference_memory.increment_towards( + slot, value, score, self._update_step + ) + + def _update_session_preference( + self, slot: str, value: str, score: float + ) -> None: + """Updates a session preference. + + Args: + slot: Slot name. + value: Slot value. + score: Preference score. + """ + current = self._session_preferences.get(slot, value) + if current is None: + initial_score = ( + score + if abs(score) < self._preference_threshold + else ( + self._update_step * 2 + if score > 0 + else -self._update_step * 2 + ) + ) + self._session_preferences.set(slot, value, initial_score, 1) + return + self._update_existing_preference( + self._session_preferences, slot, value, score + ) + + def _item_signals_to_slot_signals( + self, signals: Iterable[PreferenceSignal] + ) -> list[PreferenceSignal]: + """Converts item signals into slot-value signals. + + Args: + signals: Item signals. + + Returns: + Slot-value signals inferred from items. + """ + slot_signals: list[PreferenceSignal] = [] + for signal in signals: + if not signal.item_id: + continue + item = self._item_collection.get_item(signal.item_id) + if item is None: + continue + for slot in self._domain.get_informable_slots(): + value = item.get_property(slot) + if value is None: + continue + values = value if isinstance(value, list) else [value] + for entry in values: + slot_signals.append( + PreferenceSignal( + slot=slot, + value=normalize_preference_value(entry), + score=signal.score, + source="item", + ) + ) + return slot_signals + + def _combine_signals( + self, signals: Iterable[PreferenceSignal] + ) -> list[PreferenceSignal]: + """Combines repeated signals for the same slot-value pair. + + Args: + signals: Signals to combine. + + Returns: + Combined signals. + """ + combined: dict[Tuple[str, str], PreferenceSignal] = {} + for signal in signals: + if not signal.slot or signal.value is None: + continue + key = (signal.slot, signal.value) + if key not in combined: + combined[key] = PreferenceSignal( + slot=signal.slot, + value=signal.value, + score=signal.score, + source=signal.source, + ) + continue + combined[key].score += signal.score + if combined[key].source != "attribute": + combined[key].source = signal.source + return list(combined.values()) + + def apply(self, signals: Iterable[PreferenceSignal]) -> None: + """Applies extracted signals. + + Args: + signals: Signals to apply. + """ + explicit_signals = [ + signal + for signal in signals + if signal.slot and signal.value is not None + ] + explicit_keys: Set[Tuple[str, str]] = { + (signal.slot, signal.value) for signal in explicit_signals + } + + item_signals = [ + signal + for signal in self._item_signals_to_slot_signals(signals) + if (signal.slot, signal.value) not in explicit_keys + ] + + for signal in self._combine_signals([*explicit_signals, *item_signals]): + self.update_slot_value_preference( + signal.slot, + signal.value, + max(-1.0, min(1.0, signal.score)), + ) + + def promote_session_preferences_to_long_term(self) -> None: + """Promotes repeated session preferences to long-term memory.""" + for slot, value, score, count in self._session_preferences.items(): + if count < self._promotion_min_confirmations: + continue + if self._long_term_preferences.get(slot, value) is None: + self._long_term_preferences.set(slot, value, score, count) + continue + self._update_existing_preference( + self._long_term_preferences, slot, value, score + ) + updated_count = ( + self._long_term_preferences.get_count(slot, value) + count + ) + self._long_term_preferences.set( + slot, + value, + self._long_term_preferences.get(slot, value), + updated_count, + ) diff --git a/usersimcrs/user_modeling/structured_preference_model.py b/usersimcrs/user_modeling/structured_preference_model.py index 46f85ecf..8200d5dc 100644 --- a/usersimcrs/user_modeling/structured_preference_model.py +++ b/usersimcrs/user_modeling/structured_preference_model.py @@ -2,8 +2,7 @@ from __future__ import annotations -import math -import re +from collections import defaultdict from typing import Dict, Iterable, List, Optional, Tuple from dialoguekit.participant.user_preferences import UserPreferences @@ -16,40 +15,19 @@ KEY_ITEM_ID, PreferenceModel, ) +from usersimcrs.user_modeling.structured_preference import ( + HeuristicPreferenceSignalsExtractor, + PreferenceMemory, + PreferenceUpdateAgent, + normalize_preference_value, +) class StructuredPreferenceModel(PreferenceModel): """Preference model based on historical ratings and item metadata.""" UPDATE_STEP = 0.25 - PREFERENCE_SCORE = 0.7 LONG_TERM_PROMOTION_MIN_CONFIRMATIONS = 2 - NEGATIVE_PATTERNS = ( - r"\b(?:avoid|nothing|not|no|without|not into|not too|too much)\b.*" - r"\b{value}\b", - r"\bnot interested in\b.*\b{value}\b", - r"\b(?:anything but|other than|except|rather than)\b.*\b{value}\b", - r"\bnot a[n]?\b.*\b{value}\b", - r"\bnot in\b.*\b{value}\b(?:\s+genre)?\b", - r"\bwithout\b.*\b{value}\b", - r"\b{value}\s+(?:heavy|packed)\b", - ) - POSITIVE_PATTERNS = ( - r"\b(?:like|love|prefer|enjoy)\b.*\b{value}\b", - r"\blooking for\b.*\b{value}\b", - r"\binterested in\b.*\b{value}\b", - r"\breally looking for\b.*\b{value}\b", - r"\bcan you recommend\b.*\b{value}\b", - r"\bcould you suggest\b.*\b{value}\b", - r"\b(?:with|about|featuring|set in|centered around|based on)\b.*" - r"\b{value}\b", - r"\bthemes? of\b.*\b{value}\b", - r"\bmore\b.*\b{value}\b", - r"\bwithin the\b.*\b{value}\b(?:\s+genre)?\b", - r"\bin the\b.*\b{value}\b(?:\s+genre)?\b", - r"\b{value}\b.*\bover other genres\b", - r"\b{value}\s+focused\b", - ) DIALOGUE_STOP_TOKENS = { "exit", "goodbye", @@ -84,75 +62,41 @@ def __init__( ) self._preference_threshold = preference_threshold self._item_preferences = UserPreferences(self._user_id) - self._long_term_slot_value_preferences = UserPreferences(self._user_id) - self._long_term_slot_value_counts: Dict[Tuple[str, str], int] = {} - self._session_slot_value_preferences = UserPreferences(self._user_id) - self._session_slot_value_counts: Dict[Tuple[str, str], int] = {} - self._catalog_slot_values = self._collect_catalog_slot_values() - self._initialize_preferences() - - @staticmethod - def _normalize_preference_value(value: str) -> str: - """Normalizes preference values for storage and matching. - - Args: - value: Raw preference value. - - Returns: - Value with normalized spacing and hyphens. - """ - return " ".join(value.lower().replace("-", " ").split()) - - def _initialize_preferences(self) -> None: - """Initializes preferences from historical ratings. - - Builds the long-term item and slot-value profiles once at startup. - """ + self._long_term_preferences = PreferenceMemory(self._user_id) + self._session_preferences = PreferenceMemory(self._user_id) + self._signals_extractor = HeuristicPreferenceSignalsExtractor( + domain, item_collection + ) + self._update_agent = PreferenceUpdateAgent( + domain=domain, + item_collection=item_collection, + long_term_preferences=self._long_term_preferences, + session_preferences=self._session_preferences, + preference_threshold=preference_threshold, + update_step=self.UPDATE_STEP, + promotion_min_confirmations=( + self.LONG_TERM_PROMOTION_MIN_CONFIRMATIONS + ), + ) self._initialize_item_preferences() - slot_value_ratings = self._collect_slot_value_ratings() - self._initialize_slot_value_preferences(slot_value_ratings) - - def _collect_catalog_slot_values(self) -> Dict[str, List[str]]: - """Collects normalized slot-values available in the item catalog.""" - catalog_slot_values: Dict[str, List[str]] = {} - for slot in self._domain.get_slot_names(): - if slot.upper() in {"TITLE", "NAME"}: - continue - normalized_values = { - normalized_value - for value in self._item_collection.get_possible_property_values( - slot - ) - if value is not None - for normalized_value in [ - self._normalize_preference_value(value) - ] - if len(normalized_value) >= 3 - } - catalog_slot_values[slot] = sorted( - normalized_values, - key=len, - reverse=True, - ) - return catalog_slot_values + self._initialize_slot_value_preferences() def _initialize_item_preferences(self) -> None: - """Stores historical item ratings above the threshold as preferences.""" + """Stores strong historical item ratings.""" user_ratings = self._historical_ratings.get_user_ratings( self._historical_user_id ) for item_id, rating in user_ratings.items(): - if abs(rating) < self._preference_threshold: - continue - self._item_preferences.set_preference(KEY_ITEM_ID, item_id, rating) - - def _collect_slot_value_ratings(self) -> Dict[str, Dict[str, List[float]]]: - """Collects historical ratings for each slot-value pair. + if abs(rating) >= self._preference_threshold: + self._item_preferences.set_preference( + KEY_ITEM_ID, item_id, rating + ) - Returns: - Nested mapping of slot -> value -> list of historical ratings. - """ - slot_value_ratings: Dict[str, Dict[str, List[float]]] = {} + def _initialize_slot_value_preferences(self) -> None: + """Stores average historical slot-value ratings.""" + slot_value_ratings: Dict[str, Dict[str, List[float]]] = defaultdict( + lambda: defaultdict(list) + ) user_ratings = self._historical_ratings.get_user_ratings( self._historical_user_id ) @@ -160,44 +104,28 @@ def _collect_slot_value_ratings(self) -> Dict[str, Dict[str, List[float]]]: item = self._item_collection.get_item(item_id) if item is None: continue - for slot, value in self._iter_preference_values(item): - slot_value_ratings.setdefault(slot, {}).setdefault( - value, [] - ).append(rating) - return slot_value_ratings - - def _initialize_slot_value_preferences( - self, slot_value_ratings: Dict[str, Dict[str, List[float]]] - ) -> None: - """Stores average slot-value ratings as slot-value preferences. + slot_value_ratings[slot][value].append(rating) - Args: - slot_value_ratings: Historical ratings grouped by slot and value. - """ for slot, value_ratings in slot_value_ratings.items(): for value, ratings in value_ratings.items(): score = sum(ratings) / len(ratings) - self._long_term_slot_value_counts[(slot, value)] = len(ratings) if abs(score) >= self._preference_threshold: - self._long_term_slot_value_preferences.set_preference( - slot, value, score + self._long_term_preferences.set( + slot, value, score, len(ratings) ) def _iter_preference_values(self, item: Item) -> Iterable[Tuple[str, str]]: - """Yields preference-relevant slot-value pairs for an item. - - Excludes title/name fields and normalizes multi-valued properties. - """ - for slot in self._domain.get_slot_names(): + """Yields normalized preference-relevant slot-value pairs.""" + for slot in self._domain.get_informable_slots(): if slot.upper() in {"TITLE", "NAME"}: continue value = item.get_property(slot) if value is None: continue values = value if isinstance(value, list) else [value] - for slot_value in values: - yield slot, self._normalize_preference_value(slot_value) + for entry in values: + yield slot, normalize_preference_value(entry) def get_item_preference(self, item_id: str) -> float: """Returns preference score for an item. @@ -223,14 +151,10 @@ def get_slot_value_preference(self, slot: str, value: str) -> float: Preference score for the slot-value pair, or 0 if unavailable. """ self._assert_slot_exists(slot) - value = self._normalize_preference_value(value) - preference = self._session_slot_value_preferences.get_preference( - slot, value - ) + normalized_value = normalize_preference_value(value) + preference = self._session_preferences.get(slot, normalized_value) if preference is None: - preference = self._long_term_slot_value_preferences.get_preference( - slot, value - ) + preference = self._long_term_preferences.get(slot, normalized_value) return preference if preference is not None else 0 def update_slot_value_preference( @@ -238,282 +162,29 @@ def update_slot_value_preference( ) -> None: """Updates one slot-value preference. - Existing long-term preferences are updated in place. New preferences - are first tracked in the session layer and may later be promoted to - long-term memory. - Args: slot: Slot name. value: Slot value. score: Preference score to store. """ self._assert_slot_exists(slot) - value = self._normalize_preference_value(value) - - if ( - self._long_term_slot_value_preferences.get_preference(slot, value) - is not None - ): - self._update_existing_preference( - self._long_term_slot_value_preferences, - self._long_term_slot_value_counts, - slot, - value, - score, - ) - return - - self._update_session_preference(slot, value, score) - - def _update_existing_preference( - self, - preference_store: UserPreferences, - preference_counts: Dict[Tuple[str, str], int], - slot: str, - value: str, - score: float, - ) -> None: - """Updates an already stored preference by moving it toward a signal.""" - key = (slot, value) - old_score = preference_store.get_preference(slot, value) - old_count = preference_counts.get(key, 0) - - if old_score is None: - preference_store.set_preference(slot, value, score) - preference_counts[key] = 1 - return + self._update_agent.update_slot_value_preference(slot, value, score) - direction = 1 if score > 0 else -1 - new_score = max( - -1.0, min(1.0, old_score + direction * self.UPDATE_STEP) - ) - preference_store.set_preference(slot, value, new_score) - preference_counts[key] = max(1, old_count) + 1 - - def _update_session_preference( - self, slot: str, value: str, score: float - ) -> None: - """Tracks a new preference inside the current dialogue session.""" - key = (slot, value) - old_score = self._session_slot_value_preferences.get_preference( - slot, value - ) - old_count = self._session_slot_value_counts.get(key, 0) - - if old_score is not None and old_count > 0: - self._update_existing_preference( - self._session_slot_value_preferences, - self._session_slot_value_counts, - slot, - value, - score, - ) - return - - direction = 1 if score > 0 else -1 - new_score = direction * (2 * self.UPDATE_STEP) - self._session_slot_value_preferences.set_preference( - slot, value, new_score - ) - self._session_slot_value_counts[key] = 1 - - def _apply_text_update(self, text: str) -> None: - """Applies preference updates for one user utterance. + def _rank_preferences( + self, memory: PreferenceMemory + ) -> List[Tuple[str, str, float]]: + """Ranks preferences by evidence count and score. Args: - text: User utterance. - """ - text_lower = self._normalize_preference_value(text) - for slot, value in self._extract_matched_values(text_lower): - score = self._score_value_in_text(text_lower, value) - if score != 0: - self.update_slot_value_preference(slot, value, score) - - def _reset_session_preferences(self) -> None: - """Clears per-dialogue preference state.""" - self._session_slot_value_preferences = UserPreferences(self._user_id) - self._session_slot_value_counts.clear() - - def _promote_session_preferences_to_long_term(self) -> None: - """Promotes well-confirmed session preferences to long-term memory.""" - for ( - slot, - value_preferences, - ) in self._session_slot_value_preferences._preferences.items(): - for value, session_score in value_preferences.items(): - key = (slot, value) - session_count = self._session_slot_value_counts.get(key, 0) - if session_count < self.LONG_TERM_PROMOTION_MIN_CONFIRMATIONS: - continue - - long_term_score = ( - self._long_term_slot_value_preferences.get_preference( - slot, value - ) - ) - if long_term_score is None: - self._long_term_slot_value_preferences.set_preference( - slot, value, session_score - ) - self._long_term_slot_value_counts[key] = session_count - continue - - if session_score > long_term_score: - new_score = min( - long_term_score + self.UPDATE_STEP, session_score - ) - else: - new_score = max( - long_term_score - self.UPDATE_STEP, session_score - ) - self._long_term_slot_value_preferences.set_preference( - slot, value, new_score - ) - self._long_term_slot_value_counts[key] = ( - self._long_term_slot_value_counts.get(key, 0) - + session_count - ) - - def _extract_matched_values(self, text_lower: str) -> List[Tuple[str, str]]: - """Returns matched known or catalog preferences without shorter - duplicate fragments. - - Args: - text_lower: Lowercased user utterance. - - Returns: - Matched slot-value pairs, preferring longer values over nested - substrings. - """ - matches: List[Tuple[str, str]] = [] - seen_matches = set() - for slot, value, _ in self._rank_preferences(): - if len(value) >= 3 and value in text_lower: - matches.append((slot, value)) - seen_matches.add((slot, value)) - - for slot, values in self._catalog_slot_values.items(): - for value in values: - if (slot, value) in seen_matches: - continue - if value in text_lower: - matches.append((slot, value)) - seen_matches.add((slot, value)) - - filtered_matches: List[Tuple[str, str]] = [] - for slot, value in sorted(matches, key=lambda match: -len(match[1])): - if any( - value != kept_value and value in kept_value - for _, kept_value in filtered_matches - ): - continue - filtered_matches.append((slot, value)) - return filtered_matches - - @staticmethod - def _score_value_in_text(text_lower: str, value_lower: str) -> float: - """Scores one matched value using nearby cues. - - Args: - text_lower: Lowercased user utterance. - value_lower: Lowercased slot value being inspected. + memory: Preference memory to rank. Returns: - A local preference score for the matched value. - """ - contexts = [ - clause.strip() - for clause in re.split(r"[.!?;]", text_lower) - if value_lower in clause - ] - if not contexts: - return 0 - - for context in contexts: - if any( - re.search(pattern.format(value=re.escape(value_lower)), context) - for pattern in StructuredPreferenceModel.NEGATIVE_PATTERNS - ): - return -StructuredPreferenceModel.PREFERENCE_SCORE - if any( - re.search(pattern.format(value=re.escape(value_lower)), context) - for pattern in StructuredPreferenceModel.POSITIVE_PATTERNS - ): - return StructuredPreferenceModel.PREFERENCE_SCORE - - return 0 - - def update_from_dialogue(self, dialogue) -> None: - """Updates preferences from one user turn. - - Args: - dialogue: Dialogue utterance object. + Ranked slot-value preferences. """ - text = getattr(dialogue, "text", "").strip() - if not text: - return - - if text.lower() in self.DIALOGUE_STOP_TOKENS: - self._promote_session_preferences_to_long_term() - self._reset_session_preferences() - return - - self._apply_text_update(text) - - def _rank_preferences(self) -> List[Tuple[str, str, float]]: - """Ranks session and long-term slot-value preferences. - - Returns: - Sorted list of slot-value preferences, with session preferences - overriding long-term ones for the same slot-value pair. - """ - ranked_preferences: Dict[Tuple[str, str], Tuple[str, str, float]] = {} - for preference_store in ( - self._long_term_slot_value_preferences, - self._session_slot_value_preferences, - ): - for ( - slot, - value_preferences, - ) in preference_store._preferences.items(): - for value, score in value_preferences.items(): - ranked_preferences[(slot, value)] = (slot, value, score) - return sorted( - ranked_preferences.values(), + [(slot, value, score) for slot, value, score, _ in memory.items()], key=lambda preference: ( - -math.log1p( - self._session_slot_value_counts.get( - (preference[0], preference[1]), - self._long_term_slot_value_counts.get( - (preference[0], preference[1]), 0 - ), - ) - ), - -abs(preference[2]), - preference[0], - preference[1], - ), - ) - - def _rank_long_term_preferences(self) -> List[Tuple[str, str, float]]: - """Ranks long-term slot-value preferences by support and strength.""" - ranked_preferences: List[Tuple[str, str, float]] = [] - for ( - slot, - value_preferences, - ) in self._long_term_slot_value_preferences._preferences.items(): - for value, score in value_preferences.items(): - ranked_preferences.append((slot, value, score)) - - return sorted( - ranked_preferences, - key=lambda preference: ( - -math.log1p( - self._long_term_slot_value_counts.get( - (preference[0], preference[1]), 0 - ) - ), + -memory.get_count(preference[0], preference[1]), -abs(preference[2]), preference[0], preference[1], @@ -529,43 +200,29 @@ def get_preference_summary(self, max_preferences: int = 20) -> str: Returns: Summary for the LLM of long-term preferences. """ - long_term_preferences = self._rank_long_term_preferences() - session_preferences = [ - preference - for preference in self._rank_preferences() - if self._session_slot_value_preferences.get_preference( - preference[0], preference[1] - ) - is not None + session_preferences = self._rank_preferences(self._session_preferences)[ + : max(1, max_preferences // 2) ] - if not long_term_preferences and not session_preferences: - return "" + long_term_preferences = self._rank_preferences( + self._long_term_preferences + ) + positive_preferences = [ + preference + for preference in long_term_preferences + if preference[2] >= self._preference_threshold + ][: max(1, max_preferences // 2)] + negative_preferences = [ + preference + for preference in long_term_preferences + if preference[2] <= -self._preference_threshold + ][: max(1, max_preferences // 2)] - max_long_term = max(1, max_preferences // 2) - max_session = max(1, max_preferences - max_long_term) sections = [ - ( - "Session preferences", - session_preferences[:max_session], - ), - ( - "Positive preferences", - [ - preference - for preference in long_term_preferences - if preference[2] >= self._preference_threshold - ][:max_long_term], - ), - ( - "Negative preferences", - [ - preference - for preference in long_term_preferences - if preference[2] <= -self._preference_threshold - ][:max_long_term], - ), + ("Session preferences", session_preferences), + ("Positive preferences", positive_preferences), + ("Negative preferences", negative_preferences), ] - summary_parts = [ + return " | ".join( f"{title}: " + "; ".join( f"{slot}={value}:{score:.2f}" @@ -573,5 +230,21 @@ def get_preference_summary(self, max_preferences: int = 20) -> str: ) for title, preferences in sections if preferences - ] - return " | ".join(summary_parts) + ) + + def update_from_dialogue(self, dialogue) -> None: + """Updates preferences from one user turn. + + Args: + dialogue: Dialogue utterance object. + """ + text = getattr(dialogue, "text", "").strip() + if not text: + return + + if normalize_preference_value(text) in self.DIALOGUE_STOP_TOKENS: + self._update_agent.promote_session_preferences_to_long_term() + self._session_preferences.clear() + return + + self._update_agent.apply(self._signals_extractor.extract(text)) From 1bf1af88b8c8f667724081838308f18478c5dbb6 Mon Sep 17 00:00:00 2001 From: Ksenia Blokhina Date: Tue, 14 Jul 2026 17:43:30 +0200 Subject: [PATCH 15/25] restructure files --- .../heuristic_signal_extractor.py | 125 ++++++------------ .../structured_preference/update_agent.py | 52 ++------ .../structured_preference_model.py | 8 +- 3 files changed, 56 insertions(+), 129 deletions(-) diff --git a/usersimcrs/user_modeling/structured_preference/heuristic_signal_extractor.py b/usersimcrs/user_modeling/structured_preference/heuristic_signal_extractor.py index 45ca0a0e..9b997b2f 100644 --- a/usersimcrs/user_modeling/structured_preference/heuristic_signal_extractor.py +++ b/usersimcrs/user_modeling/structured_preference/heuristic_signal_extractor.py @@ -3,7 +3,7 @@ from __future__ import annotations import re -from typing import Dict, List, Tuple +from typing import Dict, List from usersimcrs.core.simulation_domain import SimulationDomain from usersimcrs.items.item_collection import ItemCollection @@ -58,24 +58,25 @@ def _build_catalog_slot_values(self) -> Dict[str, List[str]]: for slot in self._domain.get_slot_names(): if slot.upper() in {"TITLE", "NAME"}: continue - values = { - normalize_preference_value(value) - for value in self._item_collection.get_possible_property_values( - slot - ) - if value is not None - and len(normalize_preference_value(value)) >= 3 - } + values = set() + for value in self._item_collection.get_possible_property_values( + slot + ): + if value is None: + continue + normalized_value = normalize_preference_value(value) + if len(normalized_value) >= 3: + values.add(normalized_value) catalog_slot_values[slot] = sorted(values, key=len, reverse=True) return catalog_slot_values - def _build_item_names(self) -> Dict[str, List[Tuple[str, str]]]: + def _build_item_names(self) -> Dict[str, str]: """Builds normalized item titles and names. Returns: - Mapping from normalized item name to item identifiers. + Mapping from item identifier to normalized item name. """ - item_names: Dict[str, List[Tuple[str, str]]] = {} + item_names: Dict[str, str] = {} available_slots = [ slot for slot in ("TITLE", "NAME") @@ -97,58 +98,10 @@ def _build_item_names(self) -> Dict[str, List[Tuple[str, str]]]: normalized = normalize_preference_value(value) if len(normalized) < 3: continue - item_names.setdefault(row["id"], []).append((slot, normalized)) + item_names[row["id"]] = normalized + break return item_names - def _extract_matched_values(self, text: str) -> List[Tuple[str, str]]: - """Extracts matched slot values from text. - - Args: - text: Normalized text. - - Returns: - Matched slot-value pairs. - """ - matches: List[Tuple[str, str]] = [] - for slot, values in self._catalog_slot_values.items(): - for value in values: - if value in text: - matches.append((slot, value)) - return self._deduplicate_matches(matches) - - def _extract_matched_items(self, text: str) -> List[str]: - """Extracts matched item identifiers from text. - - Args: - text: Normalized text. - - Returns: - Matched item identifiers. - """ - matches: List[Tuple[str, str]] = [] - for item_id, item_name in self._item_names.items(): - if item_name and item_name[0][1] in text: - matches.append((item_id, item_name[0][1])) - return [item_id for item_id, _ in self._deduplicate_matches(matches)] - - def _deduplicate_matches( - self, matches: List[Tuple[str, str]] - ) -> List[Tuple[str, str]]: - """Removes shorter duplicate matches. - - Args: - matches: Raw matches. - - Returns: - Filtered matches. - """ - filtered: List[Tuple[str, str]] = [] - for key, value in sorted(matches, key=lambda match: -len(match[1])): - if any(value != kept and value in kept for _, kept in filtered): - continue - filtered.append((key, value)) - return filtered - def _score_value_in_text(self, text: str, value: str) -> float: """Scores a matched value in text. @@ -183,33 +136,39 @@ def extract( rating: float | None = None, past_dialogues: List[str] | None = None, ) -> List[PreferenceSignal]: + """Extracts preference signals from user text. + + Args: + user_utterance: User utterance. + rating: Optional rating signal. + past_dialogues: Optional past dialogue texts. + + Returns: + Extracted preference signals. + """ text = normalize_preference_value(user_utterance) + if not text: + return [] signals: List[PreferenceSignal] = [] - for slot, value in self._extract_matched_values(text): - score = self._score_value_in_text(text, value) - if score: - signals.append( - PreferenceSignal( - slot=slot, - value=value, - score=score, - source="attribute", + for slot, values in self._catalog_slot_values.items(): + for value in values: + if value not in text: + continue + score = self._score_value_in_text(text, value) + if score: + signals.append( + PreferenceSignal( + slot=slot, + value=value, + score=score, + source="attribute", + ) ) - ) - for item_id in self._extract_matched_items(text): - item = self._item_collection.get_item(item_id) - if item is None: + for item_id, item_name in self._item_names.items(): + if item_name not in text: continue - item_name = next( - ( - normalize_preference_value(item.get_property(slot)) - for slot in ("TITLE", "NAME") - if item.get_property(slot) - ), - "", - ) score = self._score_value_in_text(text, item_name) if score: signals.append( diff --git a/usersimcrs/user_modeling/structured_preference/update_agent.py b/usersimcrs/user_modeling/structured_preference/update_agent.py index e883229e..65076437 100644 --- a/usersimcrs/user_modeling/structured_preference/update_agent.py +++ b/usersimcrs/user_modeling/structured_preference/update_agent.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Iterable, Set, Tuple +from typing import Iterable from usersimcrs.core.simulation_domain import SimulationDomain from usersimcrs.items.item_collection import ItemCollection @@ -96,15 +96,9 @@ def _update_session_preference( """ current = self._session_preferences.get(slot, value) if current is None: - initial_score = ( - score - if abs(score) < self._preference_threshold - else ( - self._update_step * 2 - if score > 0 - else -self._update_step * 2 - ) - ) + initial_score = score + if abs(score) >= self._preference_threshold: + initial_score = self._update_step * 2 * (1 if score > 0 else -1) self._session_preferences.set(slot, value, initial_score, 1) return self._update_existing_preference( @@ -145,35 +139,6 @@ def _item_signals_to_slot_signals( ) return slot_signals - def _combine_signals( - self, signals: Iterable[PreferenceSignal] - ) -> list[PreferenceSignal]: - """Combines repeated signals for the same slot-value pair. - - Args: - signals: Signals to combine. - - Returns: - Combined signals. - """ - combined: dict[Tuple[str, str], PreferenceSignal] = {} - for signal in signals: - if not signal.slot or signal.value is None: - continue - key = (signal.slot, signal.value) - if key not in combined: - combined[key] = PreferenceSignal( - slot=signal.slot, - value=signal.value, - score=signal.score, - source=signal.source, - ) - continue - combined[key].score += signal.score - if combined[key].source != "attribute": - combined[key].source = signal.source - return list(combined.values()) - def apply(self, signals: Iterable[PreferenceSignal]) -> None: """Applies extracted signals. @@ -185,7 +150,7 @@ def apply(self, signals: Iterable[PreferenceSignal]) -> None: for signal in signals if signal.slot and signal.value is not None ] - explicit_keys: Set[Tuple[str, str]] = { + explicit_keys = { (signal.slot, signal.value) for signal in explicit_signals } @@ -195,15 +160,15 @@ def apply(self, signals: Iterable[PreferenceSignal]) -> None: if (signal.slot, signal.value) not in explicit_keys ] - for signal in self._combine_signals([*explicit_signals, *item_signals]): + for signal in [*explicit_signals, *item_signals]: self.update_slot_value_preference( signal.slot, signal.value, max(-1.0, min(1.0, signal.score)), ) - def promote_session_preferences_to_long_term(self) -> None: - """Promotes repeated session preferences to long-term memory.""" + def end_session(self) -> None: + """Promotes session preferences and clears session memory.""" for slot, value, score, count in self._session_preferences.items(): if count < self._promotion_min_confirmations: continue @@ -222,3 +187,4 @@ def promote_session_preferences_to_long_term(self) -> None: self._long_term_preferences.get(slot, value), updated_count, ) + self._session_preferences.clear() diff --git a/usersimcrs/user_modeling/structured_preference_model.py b/usersimcrs/user_modeling/structured_preference_model.py index 8200d5dc..4284b2ca 100644 --- a/usersimcrs/user_modeling/structured_preference_model.py +++ b/usersimcrs/user_modeling/structured_preference_model.py @@ -1,7 +1,6 @@ """Structured preference model backed by historical ratings and metadata.""" from __future__ import annotations - from collections import defaultdict from typing import Dict, Iterable, List, Optional, Tuple @@ -78,6 +77,10 @@ def __init__( self.LONG_TERM_PROMOTION_MIN_CONFIRMATIONS ), ) + self._initialize_preferences() + + def _initialize_preferences(self) -> None: + """Initializes preferences from historical ratings.""" self._initialize_item_preferences() self._initialize_slot_value_preferences() @@ -243,8 +246,7 @@ def update_from_dialogue(self, dialogue) -> None: return if normalize_preference_value(text) in self.DIALOGUE_STOP_TOKENS: - self._update_agent.promote_session_preferences_to_long_term() - self._session_preferences.clear() + self._update_agent.end_session() return self._update_agent.apply(self._signals_extractor.extract(text)) From bb95677f57c6001f16979d57daf371b2f031db88 Mon Sep 17 00:00:00 2001 From: Ksenia Blokhina Date: Tue, 4 Aug 2026 13:47:57 +0200 Subject: [PATCH 16/25] add historical dialogues support --- usersimcrs/simulator/llm/prompt/prompt.py | 7 +- .../simulator/llm/prompt/stop_prompt.py | 5 - .../llm/prompt/utterance_generation_prompt.py | 5 - .../user_modeling/pkg_preference_model.py | 19 +- usersimcrs/user_modeling/preference_model.py | 34 +- .../user_modeling/simple_preference_model.py | 18 +- .../structured_preference/__init__.py | 4 - .../heuristic_signal_extractor.py | 16 +- .../llm_signal_extractor.py | 17 +- .../preference_memory.py | 75 +++- .../structured_preference/signal_extractor.py | 25 +- .../structured_preference/update_agent.py | 190 ---------- .../structured_preference_model.py | 334 +++++++++++++----- 13 files changed, 370 insertions(+), 379 deletions(-) delete mode 100644 usersimcrs/user_modeling/structured_preference/update_agent.py diff --git a/usersimcrs/simulator/llm/prompt/prompt.py b/usersimcrs/simulator/llm/prompt/prompt.py index 314bea7a..d0e74899 100644 --- a/usersimcrs/simulator/llm/prompt/prompt.py +++ b/usersimcrs/simulator/llm/prompt/prompt.py @@ -58,16 +58,11 @@ def _preference_context(self) -> str: return "" return ( - f"{self._preference_context_label}: " + "PREFERENCES: " f"{preference_summary}\n" f"{self._preference_context_guidance}\n" ) - @property - def _preference_context_label(self) -> str: - """Returns the label used for preference context.""" - return "YOUR PREFERENCES" - @property def _preference_context_guidance(self) -> str: """Returns the guidance appended after the preference summary.""" diff --git a/usersimcrs/simulator/llm/prompt/stop_prompt.py b/usersimcrs/simulator/llm/prompt/stop_prompt.py index ae4f6d3a..5fc14d22 100644 --- a/usersimcrs/simulator/llm/prompt/stop_prompt.py +++ b/usersimcrs/simulator/llm/prompt/stop_prompt.py @@ -60,11 +60,6 @@ def prompt_text(self) -> str: return prompt_text + "\nCONTINUE: " - @property - def _preference_context_label(self) -> str: - """Returns the label used for preference context.""" - return "YOUR PREFERENCES" - @property def _preference_context_guidance(self) -> str: """Returns the guidance appended after the preference summary.""" diff --git a/usersimcrs/simulator/llm/prompt/utterance_generation_prompt.py b/usersimcrs/simulator/llm/prompt/utterance_generation_prompt.py index c7c6a406..0c76e31e 100644 --- a/usersimcrs/simulator/llm/prompt/utterance_generation_prompt.py +++ b/usersimcrs/simulator/llm/prompt/utterance_generation_prompt.py @@ -93,11 +93,6 @@ def build_new_prompt(self) -> str: ) return initial_prompt - @property - def _preference_context_label(self) -> str: - """Returns the label used for preference context.""" - return "YOUR PREFERENCES" - @property def _preference_context_guidance(self) -> str: """Returns the guidance appended after the preference summary.""" diff --git a/usersimcrs/user_modeling/pkg_preference_model.py b/usersimcrs/user_modeling/pkg_preference_model.py index 70079b5f..130c5bb5 100644 --- a/usersimcrs/user_modeling/pkg_preference_model.py +++ b/usersimcrs/user_modeling/pkg_preference_model.py @@ -9,6 +9,8 @@ See: https://github.com/iai-group/UserSimCRS/issues/110 """ +from dialoguekit.core.utterance import Utterance + from usersimcrs.core.simulation_domain import SimulationDomain from usersimcrs.items.item_collection import ItemCollection from usersimcrs.items.ratings import Ratings @@ -45,11 +47,11 @@ def get_item_preference(self, item_id: str) -> float: Args: item_id: Item ID. - Returns: - Item preference, which is in [-1,1]. - Raises: ValueError: If the item does not exist in the collection. + + Returns: + Item preference, which is in [-1,1]. """ self._assert_item_exists(item_id) # TODO: Query PKG to retrieve item preference. @@ -75,21 +77,18 @@ def get_preference_summary(self, max_preferences: int = 10) -> str: """Returns an empty summary until PKG prompt support is added. Args: - max_preferences: Maximum number of preferences to include in the - summary. + max_preferences: Maximum number of preferences to show in the + summary. Returns: Empty summary. """ return "" - def update_from_dialogue(self, dialogue) -> None: + def update_from_dialogue(self, utterance: Utterance) -> None: """PKG-backed dialogue updates are not implemented yet. Args: - dialogue: Dialogue used to update preferences. - - Returns: - None. + utterance: User utterance used to update preferences. """ return None diff --git a/usersimcrs/user_modeling/preference_model.py b/usersimcrs/user_modeling/preference_model.py index 557d9df1..b1e634f8 100644 --- a/usersimcrs/user_modeling/preference_model.py +++ b/usersimcrs/user_modeling/preference_model.py @@ -14,6 +14,7 @@ from typing import Tuple import joblib +from dialoguekit.core.utterance import Utterance from usersimcrs.core.simulation_domain import SimulationDomain from usersimcrs.items.item_collection import ItemCollection @@ -109,11 +110,11 @@ def get_item_preference(self, item_id: str) -> float: Args: item_id: Item ID. + Raises: + NotImplementedError: If the method is not implemented in a subclass. + Returns: Item preference, which is generally in [-1,1]. - - Raises: - NotImplementedError: If not implemented in derived class. """ raise NotImplementedError @@ -127,11 +128,11 @@ def get_slot_value_preference(self, slot: str, value: str) -> float: slot: Slot name (needs to exist in the domain). value: Slot value. + Raises: + NotImplementedError: If the method is not implemented in a subclass. + Returns: Slot-value preference. - - Raises: - NotImplementedError: If not implemented in derived class. """ raise NotImplementedError @@ -179,31 +180,26 @@ def get_preference_summary(self, max_preferences: int = 10) -> str: """Returns a compact preference summary for prompts. Args: - max_preferences: Maximum number of preferences to include in the - summary. + max_preferences: Maximum number of preferences to show in the + summary. + + Raises: + NotImplementedError: If the method is not implemented in a subclass. Returns: Compact preference summary. - - Raises: - NotImplementedError: If the method is not implemented in a - subclass. """ raise NotImplementedError @abstractmethod - def update_from_dialogue(self, dialogue) -> None: + def update_from_dialogue(self, utterance: Utterance) -> None: """Updates preferences from dialogue if supported by the model. Args: - dialogue: Dialogue used to update preferences. - - Returns: - None. + utterance: User utterance used to update preferences. Raises: - NotImplementedError: If the method is not implemented in a - subclass. + NotImplementedError: If the method is not implemented in a subclass. """ raise NotImplementedError diff --git a/usersimcrs/user_modeling/simple_preference_model.py b/usersimcrs/user_modeling/simple_preference_model.py index 18d3aa0d..c6ecba52 100644 --- a/usersimcrs/user_modeling/simple_preference_model.py +++ b/usersimcrs/user_modeling/simple_preference_model.py @@ -23,6 +23,7 @@ import random +from dialoguekit.core.utterance import Utterance from dialoguekit.participant.user_preferences import UserPreferences from usersimcrs.core.simulation_domain import SimulationDomain @@ -67,11 +68,11 @@ def get_item_preference(self, item_id: str) -> float: Args: item_id: Item ID. - Returns: - Randomly chosen preference, which is either -1 or +1. - Raises: ValueError: If the item does not exist in the collection. + + Returns: + Randomly chosen preference, which is either -1 or +1. """ self._assert_item_exists(item_id) preference = self._item_preferences.get_preference(KEY_ITEM_ID, item_id) @@ -103,21 +104,18 @@ def get_preference_summary(self, max_preferences: int = 10) -> str: """Returns an empty summary for non-structured preferences. Args: - max_preferences: Maximum number of preferences to include in the - summary. + max_preferences: Maximum number of preferences to show in the + summary. Returns: Empty summary. """ return "" - def update_from_dialogue(self, dialogue) -> None: + def update_from_dialogue(self, utterance: Utterance) -> None: """Simple preferences do not update from dialogue. Args: - dialogue: Dialogue used to update preferences. - - Returns: - None. + utterance: User utterance used to update preferences. """ return None diff --git a/usersimcrs/user_modeling/structured_preference/__init__.py b/usersimcrs/user_modeling/structured_preference/__init__.py index 96b13961..c111c613 100644 --- a/usersimcrs/user_modeling/structured_preference/__init__.py +++ b/usersimcrs/user_modeling/structured_preference/__init__.py @@ -6,9 +6,7 @@ from .signal_extractor import ( PreferenceSignal, PreferenceSignalsExtractor, - normalize_preference_value, ) -from .update_agent import PreferenceUpdateAgent __all__ = [ "HeuristicPreferenceSignalsExtractor", @@ -16,6 +14,4 @@ "PreferenceMemory", "PreferenceSignal", "PreferenceSignalsExtractor", - "PreferenceUpdateAgent", - "normalize_preference_value", ] diff --git a/usersimcrs/user_modeling/structured_preference/heuristic_signal_extractor.py b/usersimcrs/user_modeling/structured_preference/heuristic_signal_extractor.py index 9b997b2f..80794fcd 100644 --- a/usersimcrs/user_modeling/structured_preference/heuristic_signal_extractor.py +++ b/usersimcrs/user_modeling/structured_preference/heuristic_signal_extractor.py @@ -10,7 +10,6 @@ from usersimcrs.user_modeling.structured_preference.signal_extractor import ( PreferenceSignal, PreferenceSignalsExtractor, - normalize_preference_value, ) @@ -64,7 +63,7 @@ def _build_catalog_slot_values(self) -> Dict[str, List[str]]: ): if value is None: continue - normalized_value = normalize_preference_value(value) + normalized_value = self.normalize_preference_value(value) if len(normalized_value) >= 3: values.add(normalized_value) catalog_slot_values[slot] = sorted(values, key=len, reverse=True) @@ -95,7 +94,7 @@ def _build_item_names(self) -> Dict[str, str]: value = row[slot] if value is None: continue - normalized = normalize_preference_value(value) + normalized = self.normalize_preference_value(value) if len(normalized) < 3: continue item_names[row["id"]] = normalized @@ -130,23 +129,16 @@ def _score_value_in_text(self, text: str, value: str) -> float: return self.PREFERENCE_SCORE return 0 - def extract( - self, - user_utterance: str, - rating: float | None = None, - past_dialogues: List[str] | None = None, - ) -> List[PreferenceSignal]: + def extract(self, user_utterance: str) -> List[PreferenceSignal]: """Extracts preference signals from user text. Args: user_utterance: User utterance. - rating: Optional rating signal. - past_dialogues: Optional past dialogue texts. Returns: Extracted preference signals. """ - text = normalize_preference_value(user_utterance) + text = self.normalize_preference_value(user_utterance) if not text: return [] signals: List[PreferenceSignal] = [] diff --git a/usersimcrs/user_modeling/structured_preference/llm_signal_extractor.py b/usersimcrs/user_modeling/structured_preference/llm_signal_extractor.py index 24978cb0..fa8b846d 100644 --- a/usersimcrs/user_modeling/structured_preference/llm_signal_extractor.py +++ b/usersimcrs/user_modeling/structured_preference/llm_signal_extractor.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import List, Optional +from typing import List from usersimcrs.user_modeling.structured_preference.signal_extractor import ( PreferenceSignal, @@ -11,10 +11,13 @@ class LLMPreferenceSignalsExtractor(PreferenceSignalsExtractor): - def extract( - self, - user_utterance: str, - rating: float | None = None, - past_dialogues: Optional[List[str]] = None, - ) -> List[PreferenceSignal]: + def extract(self, user_utterance: str) -> List[PreferenceSignal]: + """Extracts preference signals from user text. + + Args: + user_utterance: User utterance. + + Returns: + Extracted preference signals. + """ raise NotImplementedError diff --git a/usersimcrs/user_modeling/structured_preference/preference_memory.py b/usersimcrs/user_modeling/structured_preference/preference_memory.py index 9f277a45..2ddeac0b 100644 --- a/usersimcrs/user_modeling/structured_preference/preference_memory.py +++ b/usersimcrs/user_modeling/structured_preference/preference_memory.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Dict, Iterator, Tuple +from typing import Dict, List, Tuple from dialoguekit.participant.user_preferences import UserPreferences @@ -18,7 +18,7 @@ def __init__(self, user_id: str) -> None: """ self._user_id = user_id self._preferences = UserPreferences(user_id) - self._counts: Dict[Tuple[str, str], int] = {} + self._counts: Dict[Tuple[str, str], float] = {} def get(self, slot: str, value: str) -> float | None: """Returns a stored preference. @@ -33,7 +33,7 @@ def get(self, slot: str, value: str) -> float | None: return self._preferences.get_preference(slot, value) def set( - self, slot: str, value: str, score: float, count: int | None = None + self, slot: str, value: str, score: float, count: float | None = None ) -> None: """Stores a preference. @@ -72,7 +72,7 @@ def increment_towards( self._counts[(slot, value)] = self.get_count(slot, value) + 1 return updated - def get_count(self, slot: str, value: str) -> int: + def get_count(self, slot: str, value: str) -> float: """Returns evidence count for a preference. Args: @@ -89,12 +89,67 @@ def clear(self) -> None: self._preferences = UserPreferences(self._user_id) self._counts.clear() - def items(self) -> Iterator[Tuple[str, str, float, int]]: - """Iterates over stored preferences. + def items(self) -> List[Tuple[str, str, float, float]]: + """Returns stored preferences. Returns: - Iterator of slot, value, score, and evidence count. + List of slot, value, score, and evidence count. """ - for slot, value_preferences in self._preferences._preferences.items(): - for value, score in value_preferences.items(): - yield slot, value, score, self.get_count(slot, value) + preferences = self._preferences._preferences.items() + return [ + (slot, value, score, self.get_count(slot, value)) + for slot, value_preferences in preferences + for value, score in value_preferences.items() + ] + + def ranked_items(self) -> List[Tuple[str, str, float]]: + """Returns preferences ranked by evidence count and score. + + Returns: + Ranked slot-value preferences. + """ + return sorted( + [(slot, value, score) for slot, value, score, _ in self.items()], + key=lambda preference: ( + -self.get_count(preference[0], preference[1]), + -abs(preference[2]), + preference[0], + preference[1], + ), + ) + + def set_weighted_scores( + self, + primary_scores: Dict[Tuple[str, str], List[float]], + secondary_scores: Dict[Tuple[str, str], List[float]], + primary_weight: float, + secondary_weight: float, + preference_threshold: float, + ) -> None: + """Stores weighted scores from two historical sources. + + Args: + primary_scores: Scores from the first source. + secondary_scores: Scores from the second source. + primary_weight: Weight for the first source. + secondary_weight: Weight for the second source. + preference_threshold: Minimum absolute score to store. + """ + for slot, value in set(primary_scores).union(secondary_scores): + primary = primary_scores.get((slot, value), []) + secondary = secondary_scores.get((slot, value), []) + primary_count = len(primary) + secondary_count = len(secondary) + weighted_count = ( + primary_weight * primary_count + + secondary_weight * secondary_count + ) + if weighted_count <= 0: + continue + + score = ( + primary_weight * sum(primary) + + secondary_weight * sum(secondary) + ) / weighted_count + if abs(score) >= preference_threshold: + self.set(slot, value, score, weighted_count) diff --git a/usersimcrs/user_modeling/structured_preference/signal_extractor.py b/usersimcrs/user_modeling/structured_preference/signal_extractor.py index c3579dfc..9507b8a3 100644 --- a/usersimcrs/user_modeling/structured_preference/signal_extractor.py +++ b/usersimcrs/user_modeling/structured_preference/signal_extractor.py @@ -7,10 +7,6 @@ from typing import List, Optional -def normalize_preference_value(value: str) -> str: - return " ".join(str(value).lower().replace("-", " ").split()) - - @dataclass class PreferenceSignal: """Represents one extracted preference signal. @@ -31,19 +27,24 @@ class PreferenceSignal: class PreferenceSignalsExtractor(ABC): + @staticmethod + def normalize_preference_value(value: str) -> str: + """Normalizes a preference value for matching and memory keys. + + Args: + value: Raw preference value. + + Returns: + Normalized preference value. + """ + return " ".join(str(value).lower().replace("-", " ").split()) + @abstractmethod - def extract( - self, - user_utterance: str, - rating: float | None = None, - past_dialogues: Optional[List[str]] = None, - ) -> List[PreferenceSignal]: + def extract(self, user_utterance: str) -> List[PreferenceSignal]: """Extracts preference signals from user input. Args: user_utterance: User utterance. - rating: Optional rating signal. - past_dialogues: Optional past dialogue texts. Returns: Extracted preference signals. diff --git a/usersimcrs/user_modeling/structured_preference/update_agent.py b/usersimcrs/user_modeling/structured_preference/update_agent.py deleted file mode 100644 index 65076437..00000000 --- a/usersimcrs/user_modeling/structured_preference/update_agent.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Preference update agent for structured preference modeling.""" - -from __future__ import annotations - -from typing import Iterable - -from usersimcrs.core.simulation_domain import SimulationDomain -from usersimcrs.items.item_collection import ItemCollection -from usersimcrs.user_modeling.structured_preference.preference_memory import ( - PreferenceMemory, -) -from usersimcrs.user_modeling.structured_preference.signal_extractor import ( - PreferenceSignal, - normalize_preference_value, -) - - -class PreferenceUpdateAgent: - """Applies extracted preference signals to preference memory.""" - - def __init__( - self, - domain: SimulationDomain, - item_collection: ItemCollection, - long_term_preferences: PreferenceMemory, - session_preferences: PreferenceMemory, - preference_threshold: float, - update_step: float, - promotion_min_confirmations: int, - ) -> None: - """Initializes the preference update agent. - - Args: - domain: Simulation domain. - item_collection: Item collection. - long_term_preferences: Long-term preference memory. - session_preferences: Session preference memory. - preference_threshold: Preference threshold. - update_step: Update step size. - promotion_min_confirmations: Minimum confirmations for promotion. - """ - self._domain = domain - self._item_collection = item_collection - self._long_term_preferences = long_term_preferences - self._session_preferences = session_preferences - self._preference_threshold = preference_threshold - self._update_step = update_step - self._promotion_min_confirmations = promotion_min_confirmations - - def update_slot_value_preference( - self, slot: str, value: str, score: float - ) -> None: - """Updates a slot-value preference. - - Args: - slot: Slot name. - value: Slot value. - score: Preference score. - """ - value = normalize_preference_value(value) - if self._long_term_preferences.get(slot, value) is not None: - self._update_existing_preference( - self._long_term_preferences, slot, value, score - ) - return - self._update_session_preference(slot, value, score) - - def _update_existing_preference( - self, - preference_memory: PreferenceMemory, - slot: str, - value: str, - score: float, - ) -> None: - """Updates an existing preference. - - Args: - preference_memory: Preference memory to update. - slot: Slot name. - value: Slot value. - score: Preference score. - """ - preference_memory.increment_towards( - slot, value, score, self._update_step - ) - - def _update_session_preference( - self, slot: str, value: str, score: float - ) -> None: - """Updates a session preference. - - Args: - slot: Slot name. - value: Slot value. - score: Preference score. - """ - current = self._session_preferences.get(slot, value) - if current is None: - initial_score = score - if abs(score) >= self._preference_threshold: - initial_score = self._update_step * 2 * (1 if score > 0 else -1) - self._session_preferences.set(slot, value, initial_score, 1) - return - self._update_existing_preference( - self._session_preferences, slot, value, score - ) - - def _item_signals_to_slot_signals( - self, signals: Iterable[PreferenceSignal] - ) -> list[PreferenceSignal]: - """Converts item signals into slot-value signals. - - Args: - signals: Item signals. - - Returns: - Slot-value signals inferred from items. - """ - slot_signals: list[PreferenceSignal] = [] - for signal in signals: - if not signal.item_id: - continue - item = self._item_collection.get_item(signal.item_id) - if item is None: - continue - for slot in self._domain.get_informable_slots(): - value = item.get_property(slot) - if value is None: - continue - values = value if isinstance(value, list) else [value] - for entry in values: - slot_signals.append( - PreferenceSignal( - slot=slot, - value=normalize_preference_value(entry), - score=signal.score, - source="item", - ) - ) - return slot_signals - - def apply(self, signals: Iterable[PreferenceSignal]) -> None: - """Applies extracted signals. - - Args: - signals: Signals to apply. - """ - explicit_signals = [ - signal - for signal in signals - if signal.slot and signal.value is not None - ] - explicit_keys = { - (signal.slot, signal.value) for signal in explicit_signals - } - - item_signals = [ - signal - for signal in self._item_signals_to_slot_signals(signals) - if (signal.slot, signal.value) not in explicit_keys - ] - - for signal in [*explicit_signals, *item_signals]: - self.update_slot_value_preference( - signal.slot, - signal.value, - max(-1.0, min(1.0, signal.score)), - ) - - def end_session(self) -> None: - """Promotes session preferences and clears session memory.""" - for slot, value, score, count in self._session_preferences.items(): - if count < self._promotion_min_confirmations: - continue - if self._long_term_preferences.get(slot, value) is None: - self._long_term_preferences.set(slot, value, score, count) - continue - self._update_existing_preference( - self._long_term_preferences, slot, value, score - ) - updated_count = ( - self._long_term_preferences.get_count(slot, value) + count - ) - self._long_term_preferences.set( - slot, - value, - self._long_term_preferences.get(slot, value), - updated_count, - ) - self._session_preferences.clear() diff --git a/usersimcrs/user_modeling/structured_preference_model.py b/usersimcrs/user_modeling/structured_preference_model.py index 4284b2ca..325b1394 100644 --- a/usersimcrs/user_modeling/structured_preference_model.py +++ b/usersimcrs/user_modeling/structured_preference_model.py @@ -2,8 +2,9 @@ from __future__ import annotations from collections import defaultdict -from typing import Dict, Iterable, List, Optional, Tuple +from typing import Any, Dict, Iterable, List, Optional, Tuple +from dialoguekit.core.utterance import Utterance from dialoguekit.participant.user_preferences import UserPreferences from usersimcrs.core.simulation_domain import SimulationDomain @@ -17,16 +18,17 @@ from usersimcrs.user_modeling.structured_preference import ( HeuristicPreferenceSignalsExtractor, PreferenceMemory, - PreferenceUpdateAgent, - normalize_preference_value, + PreferenceSignal, + PreferenceSignalsExtractor, ) class StructuredPreferenceModel(PreferenceModel): - """Preference model based on historical ratings and item metadata.""" UPDATE_STEP = 0.25 LONG_TERM_PROMOTION_MIN_CONFIRMATIONS = 2 + RATING_HISTORY_WEIGHT = 1.0 + DIALOGUE_HISTORY_WEIGHT = 1.0 DIALOGUE_STOP_TOKENS = { "exit", "goodbye", @@ -41,94 +43,259 @@ def __init__( self, domain: SimulationDomain, item_collection: ItemCollection, - historical_ratings: Ratings, + historical_ratings: Optional[Ratings] = None, historical_user_id: Optional[str] = None, preference_threshold: float = PreferenceModel.PREFERENCE_THRESHOLD, + dialogue_history: Optional[Iterable[Any]] = None, + signals_extractor: Optional[PreferenceSignalsExtractor] = None, + rating_history_weight: float = RATING_HISTORY_WEIGHT, + dialogue_history_weight: float = DIALOGUE_HISTORY_WEIGHT, ) -> None: """Initializes structured preferences. Args: domain: Domain. item_collection: Item collection. - historical_ratings: Historical ratings. + historical_ratings: Optional historical ratings. historical_user_id: Historical user ID. Defaults to None. preference_threshold: Minimum absolute score to store as a meaningful preference. Defaults to `PreferenceModel.PREFERENCE_THRESHOLD`. + dialogue_history: Optional previous dialogues or user utterances to + initialize session preferences from. Defaults to None. + signals_extractor: Optional preference signal extractor. Defaults + to a heuristic extractor. + rating_history_weight: Weight for evidence extracted from + historical ratings. Defaults to 1.0. + dialogue_history_weight: Weight for evidence extracted from + historical dialogues. Defaults to 1.0. """ + historical_ratings = historical_ratings or Ratings(item_collection) + if historical_user_id is None and not historical_ratings._user_ratings: + historical_user_id = "dialogue_history" super().__init__( domain, item_collection, historical_ratings, historical_user_id ) self._preference_threshold = preference_threshold + self._dialogue_history = list(dialogue_history or []) + self._rating_history_weight = rating_history_weight + self._dialogue_history_weight = dialogue_history_weight self._item_preferences = UserPreferences(self._user_id) self._long_term_preferences = PreferenceMemory(self._user_id) self._session_preferences = PreferenceMemory(self._user_id) - self._signals_extractor = HeuristicPreferenceSignalsExtractor( - domain, item_collection - ) - self._update_agent = PreferenceUpdateAgent( - domain=domain, - item_collection=item_collection, - long_term_preferences=self._long_term_preferences, - session_preferences=self._session_preferences, - preference_threshold=preference_threshold, - update_step=self.UPDATE_STEP, - promotion_min_confirmations=( - self.LONG_TERM_PROMOTION_MIN_CONFIRMATIONS - ), + self._signals_extractor = ( + signals_extractor + or HeuristicPreferenceSignalsExtractor(domain, item_collection) ) self._initialize_preferences() def _initialize_preferences(self) -> None: - """Initializes preferences from historical ratings.""" - self._initialize_item_preferences() - self._initialize_slot_value_preferences() + """Initializes preferences from historical ratings and dialogues.""" + self._long_term_preferences.set_weighted_scores( + self._rating_scores(), + self._dialogue_scores(), + self._rating_history_weight, + self._dialogue_history_weight, + self._preference_threshold, + ) + + def _rating_scores(self) -> Dict[Tuple[str, str], List[float]]: + """Returns preference scores extracted from historical ratings. - def _initialize_item_preferences(self) -> None: - """Stores strong historical item ratings.""" - user_ratings = self._historical_ratings.get_user_ratings( + Returns: + Rating scores keyed by slot-value pair. + """ + rating_scores: Dict[Tuple[str, str], List[float]] = defaultdict(list) + for item_id, rating in self._historical_ratings.get_user_ratings( self._historical_user_id - ) - for item_id, rating in user_ratings.items(): + ).items(): + item = self._item_collection.get_item(item_id) + if item is None: + continue if abs(rating) >= self._preference_threshold: self._item_preferences.set_preference( KEY_ITEM_ID, item_id, rating ) + for slot, value in self._item_slot_values(item): + rating_scores[(slot, value)].append(rating) + return rating_scores - def _initialize_slot_value_preferences(self) -> None: - """Stores average historical slot-value ratings.""" - slot_value_ratings: Dict[str, Dict[str, List[float]]] = defaultdict( - lambda: defaultdict(list) - ) - user_ratings = self._historical_ratings.get_user_ratings( - self._historical_user_id + def _dialogue_scores(self) -> Dict[Tuple[str, str], List[float]]: + """Returns preference scores extracted from historical dialogues. + + Returns: + Dialogue scores keyed by slot-value pair. + """ + dialogue_scores: Dict[Tuple[str, str], List[float]] = defaultdict(list) + for text in self._iter_dialogue_history_texts(): + for signal in self._extract_slot_signals_from_text(text): + if not signal.slot or signal.value is None: + continue + dialogue_scores[(signal.slot, signal.value)].append( + signal.score + ) + return dialogue_scores + + def _extract_slot_signals_from_text( + self, text: str + ) -> List[PreferenceSignal]: + """Extracts slot-value preference signals from one text. + + Args: + text: User utterance text. + + Returns: + Slot-value preference signals. + """ + text = text.strip() + if not text or self._is_stop_token(text): + return [] + + signals = self._signals_extractor.extract(text) + return list(self._slot_preference_signals(signals)) + + def _is_stop_token(self, text: str) -> bool: + """Checks whether text ends preference updates. + + Args: + text: User utterance text. + + Returns: + True if text is a stop token, otherwise False. + """ + normalized_text = self._signals_extractor.normalize_preference_value( + text ) - for item_id, rating in user_ratings.items(): - item = self._item_collection.get_item(item_id) - if item is None: - continue - for slot, value in self._iter_preference_values(item): - slot_value_ratings[slot][value].append(rating) - - for slot, value_ratings in slot_value_ratings.items(): - for value, ratings in value_ratings.items(): - score = sum(ratings) / len(ratings) - if abs(score) >= self._preference_threshold: - self._long_term_preferences.set( - slot, value, score, len(ratings) - ) + return normalized_text in self.DIALOGUE_STOP_TOKENS + + def _item_slot_values(self, item: Item) -> List[Tuple[str, str]]: + """Returns normalized preference slot values for an item. - def _iter_preference_values(self, item: Item) -> Iterable[Tuple[str, str]]: - """Yields normalized preference-relevant slot-value pairs.""" + Args: + item: Item to read. + + Returns: + Slot-value pairs from informable item properties. + """ + slot_values = [] for slot in self._domain.get_informable_slots(): - if slot.upper() in {"TITLE", "NAME"}: - continue value = item.get_property(slot) if value is None: continue values = value if isinstance(value, list) else [value] for entry in values: - yield slot, normalize_preference_value(entry) + normalized_value = ( + self._signals_extractor.normalize_preference_value(entry) + ) + slot_values.append((slot, normalized_value)) + return slot_values + + def _iter_dialogue_history_texts(self) -> List[str]: + """Returns user utterance texts from supported history formats. + + Returns: + User utterance texts. + """ + texts = [] + for entry in self._dialogue_history: + if isinstance(entry, str): + texts.append(entry) + continue + + utterances = getattr(entry, "utterances", None) + if utterances is not None: + for utterance in utterances: + participant = getattr(utterance, "participant", None) + participant_name = getattr(participant, "name", None) + if participant_name and participant_name != "USER": + continue + texts.append(getattr(utterance, "text", "")) + continue + + if isinstance(entry, dict): + if "conversation" in entry: + for utterance in entry["conversation"]: + if utterance.get("participant") != "USER": + continue + texts.append(utterance.get("utterance", "")) + continue + texts.append(entry.get("utterance", entry.get("text", ""))) + continue + + texts.append(getattr(entry, "text", "")) + return texts + + def _slot_preference_signals( + self, signals: Iterable[PreferenceSignal] + ) -> List[PreferenceSignal]: + """Converts extracted signals to slot-value preference signals. + + Args: + signals: Extracted preference signals. + + Returns: + Slot-value preference signals. + """ + signals = list(signals) + explicit_keys = set() + slot_signals = [] + for signal in signals: + if not signal.slot or signal.value is None: + continue + normalized_value = ( + self._signals_extractor.normalize_preference_value(signal.value) + ) + explicit_keys.add((signal.slot, normalized_value)) + slot_signals.append( + PreferenceSignal( + slot=signal.slot, + value=normalized_value, + score=max(-1.0, min(1.0, signal.score)), + source=signal.source, + ) + ) + + for signal in signals: + if not signal.item_id: + continue + item = self._item_collection.get_item(signal.item_id) + if item is None: + continue + for slot, value in self._item_slot_values(item): + if (slot, value) in explicit_keys: + continue + slot_signals.append( + PreferenceSignal( + slot=slot, + value=value, + score=max(-1.0, min(1.0, signal.score)), + source=signal.source, + ) + ) + return slot_signals + + def _promote_session_preferences(self) -> None: + """Promotes confirmed session preferences to long-term memory.""" + session_items = list(self._session_preferences.items()) + for slot, value, score, count in session_items: + if count < self.LONG_TERM_PROMOTION_MIN_CONFIRMATIONS: + continue + + long_term_score = self._long_term_preferences.get(slot, value) + if long_term_score is None: + self._long_term_preferences.set(slot, value, score, count) + continue + + long_term_count = self._long_term_preferences.get_count(slot, value) + total_count = long_term_count + count + merged_score = ( + long_term_score * long_term_count + score * count + ) / total_count + self._long_term_preferences.set( + slot, value, merged_score, total_count + ) + + self._session_preferences.clear() def get_item_preference(self, item_id: str) -> float: """Returns preference score for an item. @@ -146,6 +313,9 @@ def get_item_preference(self, item_id: str) -> float: def get_slot_value_preference(self, slot: str, value: str) -> float: """Returns preference score for a slot-value pair. + Session preferences are checked first because they capture the current + dialogue context; long-term preferences are used as a fallback. + Args: slot: Slot name. value: Slot value. @@ -154,7 +324,9 @@ def get_slot_value_preference(self, slot: str, value: str) -> float: Preference score for the slot-value pair, or 0 if unavailable. """ self._assert_slot_exists(slot) - normalized_value = normalize_preference_value(value) + normalized_value = self._signals_extractor.normalize_preference_value( + value + ) preference = self._session_preferences.get(slot, normalized_value) if preference is None: preference = self._long_term_preferences.get(slot, normalized_value) @@ -171,44 +343,26 @@ def update_slot_value_preference( score: Preference score to store. """ self._assert_slot_exists(slot) - self._update_agent.update_slot_value_preference(slot, value, score) - - def _rank_preferences( - self, memory: PreferenceMemory - ) -> List[Tuple[str, str, float]]: - """Ranks preferences by evidence count and score. - - Args: - memory: Preference memory to rank. - - Returns: - Ranked slot-value preferences. - """ - return sorted( - [(slot, value, score) for slot, value, score, _ in memory.items()], - key=lambda preference: ( - -memory.get_count(preference[0], preference[1]), - -abs(preference[2]), - preference[0], - preference[1], - ), + self._session_preferences.increment_towards( + slot, + self._signals_extractor.normalize_preference_value(value), + max(-1.0, min(1.0, score)), + self.UPDATE_STEP, ) def get_preference_summary(self, max_preferences: int = 20) -> str: """Returns a preference summary for the LLM. Args: - max_preferences: Total number of preferences. + max_preferences: Total number of preferences. Defaults to 20. Returns: Summary for the LLM of long-term preferences. """ - session_preferences = self._rank_preferences(self._session_preferences)[ + session_preferences = self._session_preferences.ranked_items()[ : max(1, max_preferences // 2) ] - long_term_preferences = self._rank_preferences( - self._long_term_preferences - ) + long_term_preferences = self._long_term_preferences.ranked_items() positive_preferences = [ preference for preference in long_term_preferences @@ -235,18 +389,20 @@ def get_preference_summary(self, max_preferences: int = 20) -> str: if preferences ) - def update_from_dialogue(self, dialogue) -> None: + def update_from_dialogue(self, utterance: Utterance) -> None: """Updates preferences from one user turn. Args: - dialogue: Dialogue utterance object. + utterance: User utterance used to update preferences. """ - text = getattr(dialogue, "text", "").strip() - if not text: - return - - if normalize_preference_value(text) in self.DIALOGUE_STOP_TOKENS: - self._update_agent.end_session() + text = getattr(utterance, "text", "") + if self._is_stop_token(text): + self._promote_session_preferences() return - self._update_agent.apply(self._signals_extractor.extract(text)) + for signal in self._extract_slot_signals_from_text(text): + if not signal.slot or signal.value is None: + continue + self.update_slot_value_preference( + signal.slot, signal.value, signal.score + ) From f06db1fb98b6652e8df4377dbf4caefb91ee3c84 Mon Sep 17 00:00:00 2001 From: Ksenia Blokhina Date: Tue, 18 Aug 2026 10:17:28 +0200 Subject: [PATCH 17/25] refactoring --- usersimcrs/items/ratings.py | 2 +- usersimcrs/simulator/user_simulator.py | 2 +- usersimcrs/user_modeling/persona.py | 4 +- .../user_modeling/pkg_preference_model.py | 4 +- usersimcrs/user_modeling/preference_model.py | 8 +- .../user_modeling/simple_preference_model.py | 4 +- .../structured_preference/__init__.py | 2 - .../heuristic_signal_extractor.py | 27 ++- .../llm_signal_extractor.py | 23 -- .../preference_memory.py | 49 ++--- .../structured_preference_model.py | 202 ++++++------------ 11 files changed, 111 insertions(+), 216 deletions(-) delete mode 100644 usersimcrs/user_modeling/structured_preference/llm_signal_extractor.py diff --git a/usersimcrs/items/ratings.py b/usersimcrs/items/ratings.py index 88fd347f..827eed92 100644 --- a/usersimcrs/items/ratings.py +++ b/usersimcrs/items/ratings.py @@ -144,7 +144,7 @@ def get_random_user_id(self) -> str: """Returns a random user ID. Returns: - User ID. + User ID, or None if no ratings are available. """ return random.choice(list(self._user_ratings.keys())) diff --git a/usersimcrs/simulator/user_simulator.py b/usersimcrs/simulator/user_simulator.py index 6a005c0e..a8750f1f 100644 --- a/usersimcrs/simulator/user_simulator.py +++ b/usersimcrs/simulator/user_simulator.py @@ -58,5 +58,5 @@ def receive_utterance(self, utterance: Utterance) -> None: if not isinstance(response, AnnotatedUtterance): response = AnnotatedUtterance.from_utterance(response) if self.preference_model: - self.preference_model.update_from_dialogue(response) + self.preference_model.update_from_utterance(response) self._dialogue_connector.register_user_utterance(response) diff --git a/usersimcrs/user_modeling/persona.py b/usersimcrs/user_modeling/persona.py index e18d4979..d60446b5 100644 --- a/usersimcrs/user_modeling/persona.py +++ b/usersimcrs/user_modeling/persona.py @@ -1,9 +1,9 @@ -from __future__ import annotations - """Persona, which is a profile of the user to represent different backgrounds (e.g., age, gender, education), personality types, and behavioral tendencies (e.g., patience, conscientiousness, or curiosity).""" +from __future__ import annotations + from dataclasses import dataclass from typing import Any, Dict, Optional diff --git a/usersimcrs/user_modeling/pkg_preference_model.py b/usersimcrs/user_modeling/pkg_preference_model.py index 130c5bb5..2ec3cec9 100644 --- a/usersimcrs/user_modeling/pkg_preference_model.py +++ b/usersimcrs/user_modeling/pkg_preference_model.py @@ -85,8 +85,8 @@ def get_preference_summary(self, max_preferences: int = 10) -> str: """ return "" - def update_from_dialogue(self, utterance: Utterance) -> None: - """PKG-backed dialogue updates are not implemented yet. + def update_from_utterance(self, utterance: Utterance) -> None: + """PKG-backed utterance updates are not implemented yet. Args: utterance: User utterance used to update preferences. diff --git a/usersimcrs/user_modeling/preference_model.py b/usersimcrs/user_modeling/preference_model.py index b1e634f8..042cd804 100644 --- a/usersimcrs/user_modeling/preference_model.py +++ b/usersimcrs/user_modeling/preference_model.py @@ -11,7 +11,7 @@ import random import string from abc import ABC, abstractmethod -from typing import Tuple +from typing import Optional, Tuple import joblib from dialoguekit.core.utterance import Utterance @@ -38,7 +38,7 @@ def __init__( domain: SimulationDomain, item_collection: ItemCollection, historical_ratings: Ratings, - historical_user_id: str = None, + historical_user_id: Optional[str] = None, ) -> None: """Initializes the preference model of a simulated user. @@ -192,8 +192,8 @@ def get_preference_summary(self, max_preferences: int = 10) -> str: raise NotImplementedError @abstractmethod - def update_from_dialogue(self, utterance: Utterance) -> None: - """Updates preferences from dialogue if supported by the model. + def update_from_utterance(self, utterance: Utterance) -> None: + """Updates preferences from an utterance if supported by the model. Args: utterance: User utterance used to update preferences. diff --git a/usersimcrs/user_modeling/simple_preference_model.py b/usersimcrs/user_modeling/simple_preference_model.py index c6ecba52..6c5bc348 100644 --- a/usersimcrs/user_modeling/simple_preference_model.py +++ b/usersimcrs/user_modeling/simple_preference_model.py @@ -112,8 +112,8 @@ def get_preference_summary(self, max_preferences: int = 10) -> str: """ return "" - def update_from_dialogue(self, utterance: Utterance) -> None: - """Simple preferences do not update from dialogue. + def update_from_utterance(self, utterance: Utterance) -> None: + """Simple preferences do not update from utterances. Args: utterance: User utterance used to update preferences. diff --git a/usersimcrs/user_modeling/structured_preference/__init__.py b/usersimcrs/user_modeling/structured_preference/__init__.py index c111c613..31135ca8 100644 --- a/usersimcrs/user_modeling/structured_preference/__init__.py +++ b/usersimcrs/user_modeling/structured_preference/__init__.py @@ -1,7 +1,6 @@ """Helpers for structured preference modeling.""" from .heuristic_signal_extractor import HeuristicPreferenceSignalsExtractor -from .llm_signal_extractor import LLMPreferenceSignalsExtractor from .preference_memory import PreferenceMemory from .signal_extractor import ( PreferenceSignal, @@ -10,7 +9,6 @@ __all__ = [ "HeuristicPreferenceSignalsExtractor", - "LLMPreferenceSignalsExtractor", "PreferenceMemory", "PreferenceSignal", "PreferenceSignalsExtractor", diff --git a/usersimcrs/user_modeling/structured_preference/heuristic_signal_extractor.py b/usersimcrs/user_modeling/structured_preference/heuristic_signal_extractor.py index 80794fcd..9288fef7 100644 --- a/usersimcrs/user_modeling/structured_preference/heuristic_signal_extractor.py +++ b/usersimcrs/user_modeling/structured_preference/heuristic_signal_extractor.py @@ -3,7 +3,7 @@ from __future__ import annotations import re -from typing import Dict, List +from typing import Dict, List, Tuple from usersimcrs.core.simulation_domain import SimulationDomain from usersimcrs.items.item_collection import ItemCollection @@ -47,17 +47,17 @@ def __init__( self._catalog_slot_values = self._build_catalog_slot_values() self._item_names = self._build_item_names() - def _build_catalog_slot_values(self) -> Dict[str, List[str]]: - """Builds normalized catalog slot values. + def _build_catalog_slot_values(self) -> Dict[str, List[Tuple[str, str]]]: + """Builds catalog slot values and normalized forms. Returns: - Mapping from slot to normalized values. + Mapping from slot to original and normalized values. """ - catalog_slot_values: Dict[str, List[str]] = {} + catalog_slot_values: Dict[str, List[Tuple[str, str]]] = {} for slot in self._domain.get_slot_names(): if slot.upper() in {"TITLE", "NAME"}: continue - values = set() + values = {} for value in self._item_collection.get_possible_property_values( slot ): @@ -65,8 +65,13 @@ def _build_catalog_slot_values(self) -> Dict[str, List[str]]: continue normalized_value = self.normalize_preference_value(value) if len(normalized_value) >= 3: - values.add(normalized_value) - catalog_slot_values[slot] = sorted(values, key=len, reverse=True) + values[normalized_value] = str(value) + catalog_slot_values[slot] = [ + (value, normalized_value) + for normalized_value, value in sorted( + values.items(), key=lambda item: len(item[0]), reverse=True + ) + ] return catalog_slot_values def _build_item_names(self) -> Dict[str, str]: @@ -144,10 +149,10 @@ def extract(self, user_utterance: str) -> List[PreferenceSignal]: signals: List[PreferenceSignal] = [] for slot, values in self._catalog_slot_values.items(): - for value in values: - if value not in text: + for value, normalized_value in values: + if normalized_value not in text: continue - score = self._score_value_in_text(text, value) + score = self._score_value_in_text(text, normalized_value) if score: signals.append( PreferenceSignal( diff --git a/usersimcrs/user_modeling/structured_preference/llm_signal_extractor.py b/usersimcrs/user_modeling/structured_preference/llm_signal_extractor.py deleted file mode 100644 index fa8b846d..00000000 --- a/usersimcrs/user_modeling/structured_preference/llm_signal_extractor.py +++ /dev/null @@ -1,23 +0,0 @@ -"""LLM signal extractor for structured preference modeling.""" - -from __future__ import annotations - -from typing import List - -from usersimcrs.user_modeling.structured_preference.signal_extractor import ( - PreferenceSignal, - PreferenceSignalsExtractor, -) - - -class LLMPreferenceSignalsExtractor(PreferenceSignalsExtractor): - def extract(self, user_utterance: str) -> List[PreferenceSignal]: - """Extracts preference signals from user text. - - Args: - user_utterance: User utterance. - - Returns: - Extracted preference signals. - """ - raise NotImplementedError diff --git a/usersimcrs/user_modeling/structured_preference/preference_memory.py b/usersimcrs/user_modeling/structured_preference/preference_memory.py index 2ddeac0b..09e6f179 100644 --- a/usersimcrs/user_modeling/structured_preference/preference_memory.py +++ b/usersimcrs/user_modeling/structured_preference/preference_memory.py @@ -49,7 +49,7 @@ def set( def increment_towards( self, slot: str, value: str, score: float, step: float - ) -> float: + ) -> None: """Moves a stored preference toward a new signal. Args: @@ -57,20 +57,16 @@ def increment_towards( value: Slot value. score: New signal score. step: Update step size. - - Returns: - Updated preference score. """ current = self.get(slot, value) if current is None: self.set(slot, value, score, 1) - return score + return direction = 1 if score > current else -1 updated = max(-1.0, min(1.0, current + direction * step)) self.set(slot, value, updated) self._counts[(slot, value)] = self.get_count(slot, value) + 1 - return updated def get_count(self, slot: str, value: str) -> float: """Returns evidence count for a preference. @@ -102,7 +98,7 @@ def items(self) -> List[Tuple[str, str, float, float]]: for value, score in value_preferences.items() ] - def ranked_items(self) -> List[Tuple[str, str, float]]: + def ranked_preferences(self) -> List[Tuple[str, str, float]]: """Returns preferences ranked by evidence count and score. Returns: @@ -120,36 +116,35 @@ def ranked_items(self) -> List[Tuple[str, str, float]]: def set_weighted_scores( self, - primary_scores: Dict[Tuple[str, str], List[float]], - secondary_scores: Dict[Tuple[str, str], List[float]], - primary_weight: float, - secondary_weight: float, + weighted_sources: List[ + Tuple[Dict[Tuple[str, str], List[float]], float] + ], preference_threshold: float, ) -> None: - """Stores weighted scores from two historical sources. + """Stores weighted scores from historical sources. Args: - primary_scores: Scores from the first source. - secondary_scores: Scores from the second source. - primary_weight: Weight for the first source. - secondary_weight: Weight for the second source. + weighted_sources: Score dictionaries paired with source weights. preference_threshold: Minimum absolute score to store. """ - for slot, value in set(primary_scores).union(secondary_scores): - primary = primary_scores.get((slot, value), []) - secondary = secondary_scores.get((slot, value), []) - primary_count = len(primary) - secondary_count = len(secondary) - weighted_count = ( - primary_weight * primary_count - + secondary_weight * secondary_count + keys: set[Tuple[str, str]] = set() + for scores, _ in weighted_sources: + keys.update(scores) + + for slot, value in keys: + weighted_count = sum( + weight * len(scores.get((slot, value), [])) + for scores, weight in weighted_sources ) if weighted_count <= 0: continue score = ( - primary_weight * sum(primary) - + secondary_weight * sum(secondary) - ) / weighted_count + sum( + weight * sum(scores.get((slot, value), [])) + for scores, weight in weighted_sources + ) + / weighted_count + ) if abs(score) >= preference_threshold: self.set(slot, value, score, weighted_count) diff --git a/usersimcrs/user_modeling/structured_preference_model.py b/usersimcrs/user_modeling/structured_preference_model.py index 325b1394..1bcf643d 100644 --- a/usersimcrs/user_modeling/structured_preference_model.py +++ b/usersimcrs/user_modeling/structured_preference_model.py @@ -1,10 +1,13 @@ """Structured preference model backed by historical ratings and metadata.""" from __future__ import annotations + from collections import defaultdict -from typing import Any, Dict, Iterable, List, Optional, Tuple +from typing import Dict, Iterable, List, Optional, Tuple +from dialoguekit.core.dialogue import Dialogue from dialoguekit.core.utterance import Utterance +from dialoguekit.participant.participant import DialogueParticipant from dialoguekit.participant.user_preferences import UserPreferences from usersimcrs.core.simulation_domain import SimulationDomain @@ -26,18 +29,8 @@ class StructuredPreferenceModel(PreferenceModel): UPDATE_STEP = 0.25 - LONG_TERM_PROMOTION_MIN_CONFIRMATIONS = 2 RATING_HISTORY_WEIGHT = 1.0 DIALOGUE_HISTORY_WEIGHT = 1.0 - DIALOGUE_STOP_TOKENS = { - "exit", - "goodbye", - "bye", - "quit", - "stop", - "end", - "giveup", - } def __init__( self, @@ -46,7 +39,7 @@ def __init__( historical_ratings: Optional[Ratings] = None, historical_user_id: Optional[str] = None, preference_threshold: float = PreferenceModel.PREFERENCE_THRESHOLD, - dialogue_history: Optional[Iterable[Any]] = None, + dialogue_history: Optional[List[Dialogue]] = None, signals_extractor: Optional[PreferenceSignalsExtractor] = None, rating_history_weight: float = RATING_HISTORY_WEIGHT, dialogue_history_weight: float = DIALOGUE_HISTORY_WEIGHT, @@ -56,23 +49,21 @@ def __init__( Args: domain: Domain. item_collection: Item collection. - historical_ratings: Optional historical ratings. + historical_ratings: Optional historical ratings. Defaults to None. historical_user_id: Historical user ID. Defaults to None. preference_threshold: Minimum absolute score to store as a meaningful preference. Defaults to `PreferenceModel.PREFERENCE_THRESHOLD`. - dialogue_history: Optional previous dialogues or user utterances to - initialize session preferences from. Defaults to None. + dialogue_history: Optional previous dialogues to initialize session + preferences from. Defaults to None. signals_extractor: Optional preference signal extractor. Defaults - to a heuristic extractor. + to None. rating_history_weight: Weight for evidence extracted from historical ratings. Defaults to 1.0. dialogue_history_weight: Weight for evidence extracted from historical dialogues. Defaults to 1.0. """ historical_ratings = historical_ratings or Ratings(item_collection) - if historical_user_id is None and not historical_ratings._user_ratings: - historical_user_id = "dialogue_history" super().__init__( domain, item_collection, historical_ratings, historical_user_id ) @@ -92,10 +83,10 @@ def __init__( def _initialize_preferences(self) -> None: """Initializes preferences from historical ratings and dialogues.""" self._long_term_preferences.set_weighted_scores( - self._rating_scores(), - self._dialogue_scores(), - self._rating_history_weight, - self._dialogue_history_weight, + [ + (self._rating_scores(), self._rating_history_weight), + (self._dialogue_scores(), self._dialogue_history_weight), + ], self._preference_threshold, ) @@ -127,15 +118,33 @@ def _dialogue_scores(self) -> Dict[Tuple[str, str], List[float]]: Dialogue scores keyed by slot-value pair. """ dialogue_scores: Dict[Tuple[str, str], List[float]] = defaultdict(list) - for text in self._iter_dialogue_history_texts(): - for signal in self._extract_slot_signals_from_text(text): - if not signal.slot or signal.value is None: - continue - dialogue_scores[(signal.slot, signal.value)].append( - signal.score - ) + for dialogue in self._dialogue_history: + for utterance in self._user_utterances(dialogue): + for signal in self._extract_slot_signals_from_text( + utterance.text + ): + if not signal.slot or signal.value is None: + continue + dialogue_scores[(signal.slot, signal.value)].append( + signal.score + ) return dialogue_scores + def _user_utterances(self, dialogue: Dialogue) -> List[Utterance]: + """Returns user utterances from a dialogue. + + Args: + dialogue: Dialogue to read. + + Returns: + User utterances. + """ + return [ + utterance + for utterance in dialogue.utterances + if utterance.participant is DialogueParticipant.USER + ] + def _extract_slot_signals_from_text( self, text: str ) -> List[PreferenceSignal]: @@ -148,28 +157,14 @@ def _extract_slot_signals_from_text( Slot-value preference signals. """ text = text.strip() - if not text or self._is_stop_token(text): + if not text: return [] signals = self._signals_extractor.extract(text) return list(self._slot_preference_signals(signals)) - def _is_stop_token(self, text: str) -> bool: - """Checks whether text ends preference updates. - - Args: - text: User utterance text. - - Returns: - True if text is a stop token, otherwise False. - """ - normalized_text = self._signals_extractor.normalize_preference_value( - text - ) - return normalized_text in self.DIALOGUE_STOP_TOKENS - def _item_slot_values(self, item: Item) -> List[Tuple[str, str]]: - """Returns normalized preference slot values for an item. + """Returns preference slot values for an item. Args: item: Item to read. @@ -184,47 +179,9 @@ def _item_slot_values(self, item: Item) -> List[Tuple[str, str]]: continue values = value if isinstance(value, list) else [value] for entry in values: - normalized_value = ( - self._signals_extractor.normalize_preference_value(entry) - ) - slot_values.append((slot, normalized_value)) + slot_values.append((slot, str(entry))) return slot_values - def _iter_dialogue_history_texts(self) -> List[str]: - """Returns user utterance texts from supported history formats. - - Returns: - User utterance texts. - """ - texts = [] - for entry in self._dialogue_history: - if isinstance(entry, str): - texts.append(entry) - continue - - utterances = getattr(entry, "utterances", None) - if utterances is not None: - for utterance in utterances: - participant = getattr(utterance, "participant", None) - participant_name = getattr(participant, "name", None) - if participant_name and participant_name != "USER": - continue - texts.append(getattr(utterance, "text", "")) - continue - - if isinstance(entry, dict): - if "conversation" in entry: - for utterance in entry["conversation"]: - if utterance.get("participant") != "USER": - continue - texts.append(utterance.get("utterance", "")) - continue - texts.append(entry.get("utterance", entry.get("text", ""))) - continue - - texts.append(getattr(entry, "text", "")) - return texts - def _slot_preference_signals( self, signals: Iterable[PreferenceSignal] ) -> List[PreferenceSignal]: @@ -236,26 +193,20 @@ def _slot_preference_signals( Returns: Slot-value preference signals. """ - signals = list(signals) explicit_keys = set() slot_signals = [] for signal in signals: - if not signal.slot or signal.value is None: - continue - normalized_value = ( - self._signals_extractor.normalize_preference_value(signal.value) - ) - explicit_keys.add((signal.slot, normalized_value)) - slot_signals.append( - PreferenceSignal( - slot=signal.slot, - value=normalized_value, - score=max(-1.0, min(1.0, signal.score)), - source=signal.source, + score = max(-1.0, min(1.0, signal.score)) + if signal.slot and signal.value is not None: + explicit_keys.add((signal.slot, signal.value)) + slot_signals.append( + PreferenceSignal( + slot=signal.slot, + value=signal.value, + score=score, + source=signal.source, + ) ) - ) - - for signal in signals: if not signal.item_id: continue item = self._item_collection.get_item(signal.item_id) @@ -268,35 +219,12 @@ def _slot_preference_signals( PreferenceSignal( slot=slot, value=value, - score=max(-1.0, min(1.0, signal.score)), + score=score, source=signal.source, ) ) return slot_signals - def _promote_session_preferences(self) -> None: - """Promotes confirmed session preferences to long-term memory.""" - session_items = list(self._session_preferences.items()) - for slot, value, score, count in session_items: - if count < self.LONG_TERM_PROMOTION_MIN_CONFIRMATIONS: - continue - - long_term_score = self._long_term_preferences.get(slot, value) - if long_term_score is None: - self._long_term_preferences.set(slot, value, score, count) - continue - - long_term_count = self._long_term_preferences.get_count(slot, value) - total_count = long_term_count + count - merged_score = ( - long_term_score * long_term_count + score * count - ) / total_count - self._long_term_preferences.set( - slot, value, merged_score, total_count - ) - - self._session_preferences.clear() - def get_item_preference(self, item_id: str) -> float: """Returns preference score for an item. @@ -324,12 +252,9 @@ def get_slot_value_preference(self, slot: str, value: str) -> float: Preference score for the slot-value pair, or 0 if unavailable. """ self._assert_slot_exists(slot) - normalized_value = self._signals_extractor.normalize_preference_value( - value - ) - preference = self._session_preferences.get(slot, normalized_value) + preference = self._session_preferences.get(slot, value) if preference is None: - preference = self._long_term_preferences.get(slot, normalized_value) + preference = self._long_term_preferences.get(slot, value) return preference if preference is not None else 0 def update_slot_value_preference( @@ -345,7 +270,7 @@ def update_slot_value_preference( self._assert_slot_exists(slot) self._session_preferences.increment_towards( slot, - self._signals_extractor.normalize_preference_value(value), + value, max(-1.0, min(1.0, score)), self.UPDATE_STEP, ) @@ -357,12 +282,12 @@ def get_preference_summary(self, max_preferences: int = 20) -> str: max_preferences: Total number of preferences. Defaults to 20. Returns: - Summary for the LLM of long-term preferences. + Summary for the LLM of session and long-term preferences. """ - session_preferences = self._session_preferences.ranked_items()[ + session_preferences = self._session_preferences.ranked_preferences()[ : max(1, max_preferences // 2) ] - long_term_preferences = self._long_term_preferences.ranked_items() + long_term_preferences = self._long_term_preferences.ranked_preferences() positive_preferences = [ preference for preference in long_term_preferences @@ -389,18 +314,13 @@ def get_preference_summary(self, max_preferences: int = 20) -> str: if preferences ) - def update_from_dialogue(self, utterance: Utterance) -> None: - """Updates preferences from one user turn. + def update_from_utterance(self, utterance: Utterance) -> None: + """Updates preferences from a user utterance. Args: utterance: User utterance used to update preferences. """ - text = getattr(utterance, "text", "") - if self._is_stop_token(text): - self._promote_session_preferences() - return - - for signal in self._extract_slot_signals_from_text(text): + for signal in self._extract_slot_signals_from_text(utterance.text): if not signal.slot or signal.value is None: continue self.update_slot_value_preference( From 1b9c6fef9a4b57974189d43fe63ea0d9f60519a6 Mon Sep 17 00:00:00 2001 From: Ksenia Blokhina Date: Tue, 18 Aug 2026 10:20:52 +0200 Subject: [PATCH 18/25] refactoring --- usersimcrs/items/ratings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/usersimcrs/items/ratings.py b/usersimcrs/items/ratings.py index 827eed92..88fd347f 100644 --- a/usersimcrs/items/ratings.py +++ b/usersimcrs/items/ratings.py @@ -144,7 +144,7 @@ def get_random_user_id(self) -> str: """Returns a random user ID. Returns: - User ID, or None if no ratings are available. + User ID. """ return random.choice(list(self._user_ratings.keys())) From 5f43a6f67887312013c122924dd0aa0e664cf8a8 Mon Sep 17 00:00:00 2001 From: Ksenia Blokhina Date: Tue, 18 Aug 2026 13:40:25 +0200 Subject: [PATCH 19/25] fix preference model --- .../heuristic_signal_extractor.py | 43 +++++-- .../preference_memory.py | 6 +- .../structured_preference/signal_extractor.py | 6 +- .../structured_preference_model.py | 105 ++++-------------- 4 files changed, 59 insertions(+), 101 deletions(-) diff --git a/usersimcrs/user_modeling/structured_preference/heuristic_signal_extractor.py b/usersimcrs/user_modeling/structured_preference/heuristic_signal_extractor.py index 9288fef7..e38a9331 100644 --- a/usersimcrs/user_modeling/structured_preference/heuristic_signal_extractor.py +++ b/usersimcrs/user_modeling/structured_preference/heuristic_signal_extractor.py @@ -5,6 +5,8 @@ import re from typing import Dict, List, Tuple +from dialoguekit.core.utterance import Utterance + from usersimcrs.core.simulation_domain import SimulationDomain from usersimcrs.items.item_collection import ItemCollection from usersimcrs.user_modeling.structured_preference.signal_extractor import ( @@ -106,6 +108,24 @@ def _build_item_names(self) -> Dict[str, str]: break return item_names + def _item_metadata_preference_candidates( + self, item_id: str + ) -> List[Tuple[str, str]]: + """Returns item metadata as slot-value preference candidates.""" + item = self._item_collection.get_item(item_id) + if item is None: + return [] + + slot_values = [] + for slot in self._domain.get_informable_slots(): + value = item.get_property(slot) + if value is None: + continue + values = value if isinstance(value, list) else [value] + for entry in values: + slot_values.append((slot, str(entry))) + return slot_values + def _score_value_in_text(self, text: str, value: str) -> float: """Scores a matched value in text. @@ -134,16 +154,16 @@ def _score_value_in_text(self, text: str, value: str) -> float: return self.PREFERENCE_SCORE return 0 - def extract(self, user_utterance: str) -> List[PreferenceSignal]: + def extract(self, utterance: Utterance) -> List[PreferenceSignal]: """Extracts preference signals from user text. Args: - user_utterance: User utterance. + utterance: User utterance. Returns: Extracted preference signals. """ - text = self.normalize_preference_value(user_utterance) + text = self.normalize_preference_value(utterance.text) if not text: return [] signals: List[PreferenceSignal] = [] @@ -168,12 +188,17 @@ def extract(self, user_utterance: str) -> List[PreferenceSignal]: continue score = self._score_value_in_text(text, item_name) if score: - signals.append( - PreferenceSignal( - item_id=item_id, - score=score, - source="item", + for slot, value in self._item_metadata_preference_candidates( + item_id + ): + signals.append( + PreferenceSignal( + slot=slot, + value=value, + score=score, + item_id=item_id, + source="item", + ) ) - ) return signals diff --git a/usersimcrs/user_modeling/structured_preference/preference_memory.py b/usersimcrs/user_modeling/structured_preference/preference_memory.py index 09e6f179..87d6abaa 100644 --- a/usersimcrs/user_modeling/structured_preference/preference_memory.py +++ b/usersimcrs/user_modeling/structured_preference/preference_memory.py @@ -99,11 +99,7 @@ def items(self) -> List[Tuple[str, str, float, float]]: ] def ranked_preferences(self) -> List[Tuple[str, str, float]]: - """Returns preferences ranked by evidence count and score. - - Returns: - Ranked slot-value preferences. - """ + """Returns preferences ranked by evidence count and score.""" return sorted( [(slot, value, score) for slot, value, score, _ in self.items()], key=lambda preference: ( diff --git a/usersimcrs/user_modeling/structured_preference/signal_extractor.py b/usersimcrs/user_modeling/structured_preference/signal_extractor.py index 9507b8a3..25511b50 100644 --- a/usersimcrs/user_modeling/structured_preference/signal_extractor.py +++ b/usersimcrs/user_modeling/structured_preference/signal_extractor.py @@ -6,6 +6,8 @@ from dataclasses import dataclass from typing import List, Optional +from dialoguekit.core.utterance import Utterance + @dataclass class PreferenceSignal: @@ -40,11 +42,11 @@ def normalize_preference_value(value: str) -> str: return " ".join(str(value).lower().replace("-", " ").split()) @abstractmethod - def extract(self, user_utterance: str) -> List[PreferenceSignal]: + def extract(self, utterance: Utterance) -> List[PreferenceSignal]: """Extracts preference signals from user input. Args: - user_utterance: User utterance. + utterance: User utterance. Returns: Extracted preference signals. diff --git a/usersimcrs/user_modeling/structured_preference_model.py b/usersimcrs/user_modeling/structured_preference_model.py index 1bcf643d..481898e5 100644 --- a/usersimcrs/user_modeling/structured_preference_model.py +++ b/usersimcrs/user_modeling/structured_preference_model.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections import defaultdict -from typing import Dict, Iterable, List, Optional, Tuple +from typing import Dict, List, Optional, Tuple from dialoguekit.core.dialogue import Dialogue from dialoguekit.core.utterance import Utterance @@ -19,9 +19,7 @@ PreferenceModel, ) from usersimcrs.user_modeling.structured_preference import ( - HeuristicPreferenceSignalsExtractor, PreferenceMemory, - PreferenceSignal, PreferenceSignalsExtractor, ) @@ -36,11 +34,11 @@ def __init__( self, domain: SimulationDomain, item_collection: ItemCollection, + signals_extractor: PreferenceSignalsExtractor, historical_ratings: Optional[Ratings] = None, historical_user_id: Optional[str] = None, preference_threshold: float = PreferenceModel.PREFERENCE_THRESHOLD, dialogue_history: Optional[List[Dialogue]] = None, - signals_extractor: Optional[PreferenceSignalsExtractor] = None, rating_history_weight: float = RATING_HISTORY_WEIGHT, dialogue_history_weight: float = DIALOGUE_HISTORY_WEIGHT, ) -> None: @@ -49,6 +47,7 @@ def __init__( Args: domain: Domain. item_collection: Item collection. + signals_extractor: Preference signal extractor. historical_ratings: Optional historical ratings. Defaults to None. historical_user_id: Historical user ID. Defaults to None. preference_threshold: Minimum absolute score to store as a @@ -56,8 +55,6 @@ def __init__( `PreferenceModel.PREFERENCE_THRESHOLD`. dialogue_history: Optional previous dialogues to initialize session preferences from. Defaults to None. - signals_extractor: Optional preference signal extractor. Defaults - to None. rating_history_weight: Weight for evidence extracted from historical ratings. Defaults to 1.0. dialogue_history_weight: Weight for evidence extracted from @@ -74,23 +71,26 @@ def __init__( self._item_preferences = UserPreferences(self._user_id) self._long_term_preferences = PreferenceMemory(self._user_id) self._session_preferences = PreferenceMemory(self._user_id) - self._signals_extractor = ( - signals_extractor - or HeuristicPreferenceSignalsExtractor(domain, item_collection) - ) + self._signals_extractor = signals_extractor self._initialize_preferences() def _initialize_preferences(self) -> None: """Initializes preferences from historical ratings and dialogues.""" self._long_term_preferences.set_weighted_scores( [ - (self._rating_scores(), self._rating_history_weight), - (self._dialogue_scores(), self._dialogue_history_weight), + ( + self._rating_preference_scores(), + self._rating_history_weight, + ), + ( + self._dialogue_preference_scores(), + self._dialogue_history_weight, + ), ], self._preference_threshold, ) - def _rating_scores(self) -> Dict[Tuple[str, str], List[float]]: + def _rating_preference_scores(self) -> Dict[Tuple[str, str], List[float]]: """Returns preference scores extracted from historical ratings. Returns: @@ -111,7 +111,7 @@ def _rating_scores(self) -> Dict[Tuple[str, str], List[float]]: rating_scores[(slot, value)].append(rating) return rating_scores - def _dialogue_scores(self) -> Dict[Tuple[str, str], List[float]]: + def _dialogue_preference_scores(self) -> Dict[Tuple[str, str], List[float]]: """Returns preference scores extracted from historical dialogues. Returns: @@ -120,14 +120,11 @@ def _dialogue_scores(self) -> Dict[Tuple[str, str], List[float]]: dialogue_scores: Dict[Tuple[str, str], List[float]] = defaultdict(list) for dialogue in self._dialogue_history: for utterance in self._user_utterances(dialogue): - for signal in self._extract_slot_signals_from_text( - utterance.text - ): + for signal in self._signals_extractor.extract(utterance): if not signal.slot or signal.value is None: continue - dialogue_scores[(signal.slot, signal.value)].append( - signal.score - ) + score = max(-1.0, min(1.0, signal.score)) + dialogue_scores[(signal.slot, signal.value)].append(score) return dialogue_scores def _user_utterances(self, dialogue: Dialogue) -> List[Utterance]: @@ -145,24 +142,6 @@ def _user_utterances(self, dialogue: Dialogue) -> List[Utterance]: if utterance.participant is DialogueParticipant.USER ] - def _extract_slot_signals_from_text( - self, text: str - ) -> List[PreferenceSignal]: - """Extracts slot-value preference signals from one text. - - Args: - text: User utterance text. - - Returns: - Slot-value preference signals. - """ - text = text.strip() - if not text: - return [] - - signals = self._signals_extractor.extract(text) - return list(self._slot_preference_signals(signals)) - def _item_slot_values(self, item: Item) -> List[Tuple[str, str]]: """Returns preference slot values for an item. @@ -182,49 +161,6 @@ def _item_slot_values(self, item: Item) -> List[Tuple[str, str]]: slot_values.append((slot, str(entry))) return slot_values - def _slot_preference_signals( - self, signals: Iterable[PreferenceSignal] - ) -> List[PreferenceSignal]: - """Converts extracted signals to slot-value preference signals. - - Args: - signals: Extracted preference signals. - - Returns: - Slot-value preference signals. - """ - explicit_keys = set() - slot_signals = [] - for signal in signals: - score = max(-1.0, min(1.0, signal.score)) - if signal.slot and signal.value is not None: - explicit_keys.add((signal.slot, signal.value)) - slot_signals.append( - PreferenceSignal( - slot=signal.slot, - value=signal.value, - score=score, - source=signal.source, - ) - ) - if not signal.item_id: - continue - item = self._item_collection.get_item(signal.item_id) - if item is None: - continue - for slot, value in self._item_slot_values(item): - if (slot, value) in explicit_keys: - continue - slot_signals.append( - PreferenceSignal( - slot=slot, - value=value, - score=score, - source=signal.source, - ) - ) - return slot_signals - def get_item_preference(self, item_id: str) -> float: """Returns preference score for an item. @@ -320,9 +256,8 @@ def update_from_utterance(self, utterance: Utterance) -> None: Args: utterance: User utterance used to update preferences. """ - for signal in self._extract_slot_signals_from_text(utterance.text): + for signal in self._signals_extractor.extract(utterance): if not signal.slot or signal.value is None: continue - self.update_slot_value_preference( - signal.slot, signal.value, signal.score - ) + score = max(-1.0, min(1.0, signal.score)) + self.update_slot_value_preference(signal.slot, signal.value, score) From 9f8abb1390baa8c621fb063d73ae584d6a576e4e Mon Sep 17 00:00:00 2001 From: Ksenia Blokhina Date: Mon, 24 Aug 2026 10:58:15 +0200 Subject: [PATCH 20/25] fix bug --- .../structured_preference/preference_memory.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/usersimcrs/user_modeling/structured_preference/preference_memory.py b/usersimcrs/user_modeling/structured_preference/preference_memory.py index 87d6abaa..04bef002 100644 --- a/usersimcrs/user_modeling/structured_preference/preference_memory.py +++ b/usersimcrs/user_modeling/structured_preference/preference_memory.py @@ -63,10 +63,11 @@ def increment_towards( self.set(slot, value, score, 1) return - direction = 1 if score > current else -1 - updated = max(-1.0, min(1.0, current + direction * step)) - self.set(slot, value, updated) - self._counts[(slot, value)] = self.get_count(slot, value) + 1 + count = self.get_count(slot, value) + weighted_step = step / max(1, count) + updated = current + weighted_step * (score - current) + updated = max(-1.0, min(1.0, updated)) + self.set(slot, value, updated, count + 1) def get_count(self, slot: str, value: str) -> float: """Returns evidence count for a preference. From 0b8e624367ab3a521356d1079c40f26a2d8be8d5 Mon Sep 17 00:00:00 2001 From: Ksenia Blokhina Date: Mon, 24 Aug 2026 11:44:27 +0200 Subject: [PATCH 21/25] fix preference model structure --- .../user_modeling/structured_preference_model.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/usersimcrs/user_modeling/structured_preference_model.py b/usersimcrs/user_modeling/structured_preference_model.py index 481898e5..4a109102 100644 --- a/usersimcrs/user_modeling/structured_preference_model.py +++ b/usersimcrs/user_modeling/structured_preference_model.py @@ -19,6 +19,7 @@ PreferenceModel, ) from usersimcrs.user_modeling.structured_preference import ( + HeuristicPreferenceSignalsExtractor, PreferenceMemory, PreferenceSignalsExtractor, ) @@ -34,20 +35,19 @@ def __init__( self, domain: SimulationDomain, item_collection: ItemCollection, - signals_extractor: PreferenceSignalsExtractor, historical_ratings: Optional[Ratings] = None, historical_user_id: Optional[str] = None, preference_threshold: float = PreferenceModel.PREFERENCE_THRESHOLD, dialogue_history: Optional[List[Dialogue]] = None, rating_history_weight: float = RATING_HISTORY_WEIGHT, dialogue_history_weight: float = DIALOGUE_HISTORY_WEIGHT, + signals_extractor: Optional[PreferenceSignalsExtractor] = None, ) -> None: """Initializes structured preferences. Args: domain: Domain. item_collection: Item collection. - signals_extractor: Preference signal extractor. historical_ratings: Optional historical ratings. Defaults to None. historical_user_id: Historical user ID. Defaults to None. preference_threshold: Minimum absolute score to store as a @@ -59,6 +59,8 @@ def __init__( historical ratings. Defaults to 1.0. dialogue_history_weight: Weight for evidence extracted from historical dialogues. Defaults to 1.0. + signals_extractor: Optional preference signal extractor. Defaults + to a heuristic extractor. """ historical_ratings = historical_ratings or Ratings(item_collection) super().__init__( @@ -71,7 +73,9 @@ def __init__( self._item_preferences = UserPreferences(self._user_id) self._long_term_preferences = PreferenceMemory(self._user_id) self._session_preferences = PreferenceMemory(self._user_id) - self._signals_extractor = signals_extractor + self._signals_extractor = signals_extractor or ( + HeuristicPreferenceSignalsExtractor(domain, item_collection) + ) self._initialize_preferences() def _initialize_preferences(self) -> None: From 18edbeeee4d4d26c2dd3d416702dc0963d53032d Mon Sep 17 00:00:00 2001 From: Ksenia Blokhina Date: Mon, 24 Aug 2026 13:18:52 +0200 Subject: [PATCH 22/25] fix structured model parsing --- .../structured_preference_model.py | 10 +-- usersimcrs/utils/simulation_utils.py | 65 +++++++++++++++++-- 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/usersimcrs/user_modeling/structured_preference_model.py b/usersimcrs/user_modeling/structured_preference_model.py index 4a109102..481898e5 100644 --- a/usersimcrs/user_modeling/structured_preference_model.py +++ b/usersimcrs/user_modeling/structured_preference_model.py @@ -19,7 +19,6 @@ PreferenceModel, ) from usersimcrs.user_modeling.structured_preference import ( - HeuristicPreferenceSignalsExtractor, PreferenceMemory, PreferenceSignalsExtractor, ) @@ -35,19 +34,20 @@ def __init__( self, domain: SimulationDomain, item_collection: ItemCollection, + signals_extractor: PreferenceSignalsExtractor, historical_ratings: Optional[Ratings] = None, historical_user_id: Optional[str] = None, preference_threshold: float = PreferenceModel.PREFERENCE_THRESHOLD, dialogue_history: Optional[List[Dialogue]] = None, rating_history_weight: float = RATING_HISTORY_WEIGHT, dialogue_history_weight: float = DIALOGUE_HISTORY_WEIGHT, - signals_extractor: Optional[PreferenceSignalsExtractor] = None, ) -> None: """Initializes structured preferences. Args: domain: Domain. item_collection: Item collection. + signals_extractor: Preference signal extractor. historical_ratings: Optional historical ratings. Defaults to None. historical_user_id: Historical user ID. Defaults to None. preference_threshold: Minimum absolute score to store as a @@ -59,8 +59,6 @@ def __init__( historical ratings. Defaults to 1.0. dialogue_history_weight: Weight for evidence extracted from historical dialogues. Defaults to 1.0. - signals_extractor: Optional preference signal extractor. Defaults - to a heuristic extractor. """ historical_ratings = historical_ratings or Ratings(item_collection) super().__init__( @@ -73,9 +71,7 @@ def __init__( self._item_preferences = UserPreferences(self._user_id) self._long_term_preferences = PreferenceMemory(self._user_id) self._session_preferences = PreferenceMemory(self._user_id) - self._signals_extractor = signals_extractor or ( - HeuristicPreferenceSignalsExtractor(domain, item_collection) - ) + self._signals_extractor = signals_extractor self._initialize_preferences() def _initialize_preferences(self) -> None: diff --git a/usersimcrs/utils/simulation_utils.py b/usersimcrs/utils/simulation_utils.py index 1a034892..7eccd7fd 100644 --- a/usersimcrs/utils/simulation_utils.py +++ b/usersimcrs/utils/simulation_utils.py @@ -1,7 +1,7 @@ """Utility functions to run the simulation.""" import json -from typing import Any, Dict, Tuple, Type +from typing import Any, Dict, Tuple, Type, cast import confuse from dialoguekit.core.intent import Intent @@ -23,6 +23,12 @@ from dialoguekit.utils.dialogue_reader import json_to_dialogues from usersimcrs.core.simulation_domain import SimulationDomain +from usersimcrs.information_need_management.information_need import ( + generate_random_information_need, +) +from usersimcrs.information_need_management.information_need_tracker import ( + InformationNeedTracker, +) from usersimcrs.items.item_collection import ItemCollection from usersimcrs.items.ratings import Ratings from usersimcrs.nlu.llm.llm_dialogue_act_extractor import ( @@ -34,6 +40,30 @@ from usersimcrs.user_modeling.persona import Persona +def _get_information_need_tracker( + config: confuse.Configuration, + domain: SimulationDomain, + item_collection: ItemCollection, +) -> InformationNeedTracker: + """Creates an information need tracker from configuration. + + Args: + config: Configuration of the run. + domain: Domain. + item_collection: Item collection. + + Returns: + Information need tracker. + """ + tracker_config = config["information_need_tracker"].get() + tracker_class = map_path_to_class(tracker_config["class_path"]) + tracker_args = dict(tracker_config.get("args", {})) + tracker_args["information_need"] = generate_random_information_need( + domain, item_collection + ) + return tracker_class(**tracker_args) + + def map_path_to_class(cls_path: str) -> Type: """Maps a class path to a class. @@ -175,6 +205,9 @@ def _get_agenda_based_simulator_config( return { "preference_model": preference_model, "interaction_model": interaction_model, + "information_need_tracker": _get_information_need_tracker( + config, domain, item_collection + ), "nlu": nlu, "nlg": nlg, "domain": domain, @@ -327,6 +360,9 @@ def _get_llm_single_prompt_user_simulator_config( "item_collection": item_collection, "llm_interface": llm_interface, "item_type": item_type, + "information_need_tracker": _get_information_need_tracker( + config, domain, item_collection + ), "task_definition": task_definition, "persona": persona, "preference_model": preference_model, @@ -366,7 +402,7 @@ def _get_preference_model( "usersimcrs.user_modeling.simple_preference_model." "SimplePreferenceModel", ) - preference_model_args = preference_model_config.get("args", {}) + preference_model_args = dict(preference_model_config.get("args", {})) preference_model_class = map_path_to_class(preference_model_class_path) if not issubclass(preference_model_class, PreferenceModel): @@ -375,11 +411,28 @@ def _get_preference_model( "from PreferenceModel." ) + if preference_model_class.__name__ == "StructuredPreferenceModel": + structured_preference_model_class = cast(Any, preference_model_class) + signals_extractor_config = preference_model_args.pop( + "signals_extractor" + ) + signals_extractor_class = map_path_to_class( + signals_extractor_config["class_path"] + ) + signals_extractor_args = dict(signals_extractor_config.get("args", {})) + signals_extractor = signals_extractor_class( + domain, item_collection, **signals_extractor_args + ) + return structured_preference_model_class( + domain, + item_collection, + signals_extractor, + historical_ratings, + **preference_model_args, + ) + return preference_model_class( - domain, - item_collection, - historical_ratings, - **preference_model_args, + domain, item_collection, historical_ratings, **preference_model_args ) From 270af44838dde9be51bf219b607f4db49fab31d5 Mon Sep 17 00:00:00 2001 From: Ksenia Blokhina Date: Mon, 24 Aug 2026 13:41:09 +0200 Subject: [PATCH 23/25] remove changes --- usersimcrs/utils/simulation_utils.py | 65 +++------------------------- 1 file changed, 6 insertions(+), 59 deletions(-) diff --git a/usersimcrs/utils/simulation_utils.py b/usersimcrs/utils/simulation_utils.py index 7eccd7fd..1a034892 100644 --- a/usersimcrs/utils/simulation_utils.py +++ b/usersimcrs/utils/simulation_utils.py @@ -1,7 +1,7 @@ """Utility functions to run the simulation.""" import json -from typing import Any, Dict, Tuple, Type, cast +from typing import Any, Dict, Tuple, Type import confuse from dialoguekit.core.intent import Intent @@ -23,12 +23,6 @@ from dialoguekit.utils.dialogue_reader import json_to_dialogues from usersimcrs.core.simulation_domain import SimulationDomain -from usersimcrs.information_need_management.information_need import ( - generate_random_information_need, -) -from usersimcrs.information_need_management.information_need_tracker import ( - InformationNeedTracker, -) from usersimcrs.items.item_collection import ItemCollection from usersimcrs.items.ratings import Ratings from usersimcrs.nlu.llm.llm_dialogue_act_extractor import ( @@ -40,30 +34,6 @@ from usersimcrs.user_modeling.persona import Persona -def _get_information_need_tracker( - config: confuse.Configuration, - domain: SimulationDomain, - item_collection: ItemCollection, -) -> InformationNeedTracker: - """Creates an information need tracker from configuration. - - Args: - config: Configuration of the run. - domain: Domain. - item_collection: Item collection. - - Returns: - Information need tracker. - """ - tracker_config = config["information_need_tracker"].get() - tracker_class = map_path_to_class(tracker_config["class_path"]) - tracker_args = dict(tracker_config.get("args", {})) - tracker_args["information_need"] = generate_random_information_need( - domain, item_collection - ) - return tracker_class(**tracker_args) - - def map_path_to_class(cls_path: str) -> Type: """Maps a class path to a class. @@ -205,9 +175,6 @@ def _get_agenda_based_simulator_config( return { "preference_model": preference_model, "interaction_model": interaction_model, - "information_need_tracker": _get_information_need_tracker( - config, domain, item_collection - ), "nlu": nlu, "nlg": nlg, "domain": domain, @@ -360,9 +327,6 @@ def _get_llm_single_prompt_user_simulator_config( "item_collection": item_collection, "llm_interface": llm_interface, "item_type": item_type, - "information_need_tracker": _get_information_need_tracker( - config, domain, item_collection - ), "task_definition": task_definition, "persona": persona, "preference_model": preference_model, @@ -402,7 +366,7 @@ def _get_preference_model( "usersimcrs.user_modeling.simple_preference_model." "SimplePreferenceModel", ) - preference_model_args = dict(preference_model_config.get("args", {})) + preference_model_args = preference_model_config.get("args", {}) preference_model_class = map_path_to_class(preference_model_class_path) if not issubclass(preference_model_class, PreferenceModel): @@ -411,28 +375,11 @@ def _get_preference_model( "from PreferenceModel." ) - if preference_model_class.__name__ == "StructuredPreferenceModel": - structured_preference_model_class = cast(Any, preference_model_class) - signals_extractor_config = preference_model_args.pop( - "signals_extractor" - ) - signals_extractor_class = map_path_to_class( - signals_extractor_config["class_path"] - ) - signals_extractor_args = dict(signals_extractor_config.get("args", {})) - signals_extractor = signals_extractor_class( - domain, item_collection, **signals_extractor_args - ) - return structured_preference_model_class( - domain, - item_collection, - signals_extractor, - historical_ratings, - **preference_model_args, - ) - return preference_model_class( - domain, item_collection, historical_ratings, **preference_model_args + domain, + item_collection, + historical_ratings, + **preference_model_args, ) From b0763820106e1f5e3878ccedaad2314274d5052c Mon Sep 17 00:00:00 2001 From: Ksenia Blokhina Date: Mon, 24 Aug 2026 16:19:37 +0200 Subject: [PATCH 24/25] fix utils --- usersimcrs/utils/simulation_utils.py | 54 ++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/usersimcrs/utils/simulation_utils.py b/usersimcrs/utils/simulation_utils.py index 1a034892..bc6986ce 100644 --- a/usersimcrs/utils/simulation_utils.py +++ b/usersimcrs/utils/simulation_utils.py @@ -23,6 +23,12 @@ from dialoguekit.utils.dialogue_reader import json_to_dialogues from usersimcrs.core.simulation_domain import SimulationDomain +from usersimcrs.information_need_management.information_need import ( + generate_random_information_need, +) +from usersimcrs.information_need_management.information_need_tracker import ( + InformationNeedTracker, +) from usersimcrs.items.item_collection import ItemCollection from usersimcrs.items.ratings import Ratings from usersimcrs.nlu.llm.llm_dialogue_act_extractor import ( @@ -34,6 +40,30 @@ from usersimcrs.user_modeling.persona import Persona +def _get_information_need_tracker( + config: confuse.Configuration, + domain: SimulationDomain, + item_collection: ItemCollection, +) -> InformationNeedTracker: + """Creates an information need tracker from configuration. + + Args: + config: Configuration of the run. + domain: Domain. + item_collection: Item collection. + + Returns: + Information need tracker. + """ + tracker_config = config["information_need_tracker"].get() + tracker_class = map_path_to_class(tracker_config["class_path"]) + tracker_args = dict(tracker_config.get("args", {})) + tracker_args["information_need"] = generate_random_information_need( + domain, item_collection + ) + return tracker_class(**tracker_args) + + def map_path_to_class(cls_path: str) -> Type: """Maps a class path to a class. @@ -175,6 +205,9 @@ def _get_agenda_based_simulator_config( return { "preference_model": preference_model, "interaction_model": interaction_model, + "information_need_tracker": _get_information_need_tracker( + config, domain, item_collection + ), "nlu": nlu, "nlg": nlg, "domain": domain, @@ -327,6 +360,9 @@ def _get_llm_single_prompt_user_simulator_config( "item_collection": item_collection, "llm_interface": llm_interface, "item_type": item_type, + "information_need_tracker": _get_information_need_tracker( + config, domain, item_collection + ), "task_definition": task_definition, "persona": persona, "preference_model": preference_model, @@ -368,6 +404,18 @@ def _get_preference_model( ) preference_model_args = preference_model_config.get("args", {}) + signals_extractor_config = preference_model_args.get("signals_extractor") + if signals_extractor_config: + signals_extractor_class = map_path_to_class( + signals_extractor_config["class_path"] + ) + signals_extractor_args = dict(signals_extractor_config.get("args", {})) + preference_model_args["signals_extractor"] = signals_extractor_class( + domain=domain, + item_collection=item_collection, + **signals_extractor_args, + ) + preference_model_class = map_path_to_class(preference_model_class_path) if not issubclass(preference_model_class, PreferenceModel): raise TypeError( @@ -376,9 +424,9 @@ def _get_preference_model( ) return preference_model_class( - domain, - item_collection, - historical_ratings, + domain=domain, + item_collection=item_collection, + historical_ratings=historical_ratings, **preference_model_args, ) From 291ec96048265f3128a7b164b8f7a76440b96293 Mon Sep 17 00:00:00 2001 From: Ksenia Blokhina Date: Mon, 24 Aug 2026 16:40:55 +0200 Subject: [PATCH 25/25] fix dict --- usersimcrs/utils/simulation_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/usersimcrs/utils/simulation_utils.py b/usersimcrs/utils/simulation_utils.py index bc6986ce..10f3db38 100644 --- a/usersimcrs/utils/simulation_utils.py +++ b/usersimcrs/utils/simulation_utils.py @@ -402,7 +402,7 @@ def _get_preference_model( "usersimcrs.user_modeling.simple_preference_model." "SimplePreferenceModel", ) - preference_model_args = preference_model_config.get("args", {}) + preference_model_args = dict(preference_model_config.get("args", {})) signals_extractor_config = preference_model_args.get("signals_extractor") if signals_extractor_config: