From 9dd869f79f90c9d9dd4490718e9ce1548029ca8d Mon Sep 17 00:00:00 2001 From: miro Date: Wed, 13 Nov 2024 15:30:00 +0000 Subject: [PATCH 1/5] feat: chat history --- ovos_plugin_manager/solvers.py | 29 ++++++++++---- ovos_plugin_manager/templates/solvers.py | 26 +++++++++++++ ovos_plugin_manager/utils/__init__.py | 49 +++++++++++++----------- 3 files changed, 74 insertions(+), 30 deletions(-) diff --git a/ovos_plugin_manager/solvers.py b/ovos_plugin_manager/solvers.py index 352d2319..bfa2f2a6 100644 --- a/ovos_plugin_manager/solvers.py +++ b/ovos_plugin_manager/solvers.py @@ -1,10 +1,26 @@ -from ovos_plugin_manager.utils import normalize_lang, \ - PluginTypes, PluginConfigTypes from ovos_plugin_manager.templates.solvers import QuestionSolver, TldrSolver, \ - EntailmentSolver, MultipleChoiceSolver, EvidenceSolver -from ovos_utils.log import LOG + EntailmentSolver, MultipleChoiceSolver, EvidenceSolver, ChatMessageSolver +from ovos_plugin_manager.utils import PluginTypes, PluginConfigTypes +def find_chat_solver_plugins() -> dict: + """ + Find all installed plugins + @return: dict plugin names to entrypoints + """ + from ovos_plugin_manager.utils import find_plugins + return find_plugins(PluginTypes.CHAT_SOLVER) + + +def load_chat_solver_plugin(module_name: str) -> type(ChatMessageSolver): + """ + Get an uninstantiated class for the requested module_name + @param module_name: Plugin entrypoint name to load + @return: Uninstantiated class + """ + from ovos_plugin_manager.utils import load_plugin + return load_plugin(module_name, PluginTypes.CHAT_SOLVER) + def find_question_solver_plugins() -> dict: """ @@ -172,7 +188,7 @@ def get_entailment_solver_module_configs(module_name: str) -> dict: def get_entailment_solver_lang_configs(lang: str, - include_dialects: bool = False) -> dict: + include_dialects: bool = False) -> dict: """ Get a dict of plugin names to list valid configurations for the requested lang. @@ -303,7 +319,7 @@ def get_reading_comprehension_solver_module_configs(module_name: str) -> dict: def get_reading_comprehension_solver_lang_configs(lang: str, - include_dialects: bool = False) -> dict: + include_dialects: bool = False) -> dict: """ Get a dict of plugin names to list valid configurations for the requested lang. @@ -324,4 +340,3 @@ def get_reading_comprehension_solver_supported_langs() -> dict: from ovos_plugin_manager.utils.config import get_plugin_supported_languages return get_plugin_supported_languages( PluginTypes.READING_COMPREHENSION_SOLVER) - diff --git a/ovos_plugin_manager/templates/solvers.py b/ovos_plugin_manager/templates/solvers.py index 5f0b7dbb..be873044 100644 --- a/ovos_plugin_manager/templates/solvers.py +++ b/ovos_plugin_manager/templates/solvers.py @@ -398,6 +398,32 @@ def long_answer(self, query: str, return steps +class ChatMessageSolver(QuestionSolver): + """take chat history as input LLM style + messages = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Knock knock."}, + {"role": "assistant", "content": "Who's there?"}, + {"role": "user", "content": "Orange."}, + ] + """ + @abc.abstractmethod + def continue_chat(self, messages: List[Dict[str, str]], + lang: Optional[str]) -> Optional[str]: + pass + + @auto_detect_lang(text_keys=["messages"]) + @auto_translate(translate_keys=["messages"]) + def get_chat_completion(self, messages: List[Dict[str, str]], + lang: Optional[str] = None) -> Optional[str]: + return self.continue_chat(messages=messages, lang=lang) + + @auto_detect_lang(text_keys=["query"]) + @auto_translate(translate_keys=["query"]) + def get_spoken_answer(self, query: str, lang: Optional[str] = None) -> Optional[str]: + return self.continue_chat(messages=[{"role": "user", "content": query}], lang=lang) + + class CorpusSolver(QuestionSolver): """Retrieval based question solver""" diff --git a/ovos_plugin_manager/utils/__init__.py b/ovos_plugin_manager/utils/__init__.py index 4dac2518..f8219305 100644 --- a/ovos_plugin_manager/utils/__init__.py +++ b/ovos_plugin_manager/utils/__init__.py @@ -28,36 +28,37 @@ class PluginTypes(str, Enum): FACE_EMBEDDINGS = "opm.embeddings.face" VOICE_EMBEDDINGS = "opm.embeddings.voice" TEXT_EMBEDDINGS = "opm.embeddings.text" - GUI = "ovos.plugin.gui" - PHAL = "ovos.plugin.phal" - ADMIN = "ovos.plugin.phal.admin" - SKILL = "ovos.plugin.skill" - MIC = "ovos.plugin.microphone" - VAD = "ovos.plugin.VAD" - PHONEME = "ovos.plugin.g2p" - AUDIO2IPA = "ovos.plugin.audio2ipa" + GUI = "ovos.plugin.gui" # TODO rename "opm.gui" + PHAL = "ovos.plugin.phal" # TODO rename "opm.phal" + ADMIN = "ovos.plugin.phal.admin" # TODO rename "opm.phal.admin" + SKILL = "ovos.plugin.skill" # TODO rename "opm.skill" + MIC = "ovos.plugin.microphone" # TODO rename "opm.microphone" + VAD = "ovos.plugin.VAD" # TODO rename "opm.vad" + PHONEME = "ovos.plugin.g2p" # TODO rename "opm.g2p" + AUDIO2IPA = "ovos.plugin.audio2ipa" # TODO rename "opm.audio2ipa" AUDIO = 'mycroft.plugin.audioservice' # DEPRECATED - STT = 'mycroft.plugin.stt' - TTS = 'mycroft.plugin.tts' - WAKEWORD = 'mycroft.plugin.wake_word' - TRANSLATE = "neon.plugin.lang.translate" - LANG_DETECT = "neon.plugin.lang.detect" - UTTERANCE_TRANSFORMER = "neon.plugin.text" - METADATA_TRANSFORMER = "neon.plugin.metadata" - AUDIO_TRANSFORMER = "neon.plugin.audio" + STT = 'mycroft.plugin.stt' # TODO rename "opm.stt" + TTS = 'mycroft.plugin.tts' # TODO rename "opm.tts" + WAKEWORD = 'mycroft.plugin.wake_word' # TODO rename "opm.wake_word" + TRANSLATE = "neon.plugin.lang.translate" # TODO rename "opm.lang.translate" + LANG_DETECT = "neon.plugin.lang.detect" # TODO rename "opm.lang.detect" + UTTERANCE_TRANSFORMER = "neon.plugin.text" # TODO rename "opm.transformer.text" + METADATA_TRANSFORMER = "neon.plugin.metadata" # TODO rename "opm.transformer.metadata" + AUDIO_TRANSFORMER = "neon.plugin.audio" # TODO rename "opm.transformer.audio" DIALOG_TRANSFORMER = "opm.transformer.dialog" TTS_TRANSFORMER = "opm.transformer.tts" - QUESTION_SOLVER = "neon.plugin.solver" + QUESTION_SOLVER = "neon.plugin.solver" # TODO rename "opm.solver.question" + CHAT_SOLVER = "opm.solver.chat" TLDR_SOLVER = "opm.solver.summarization" ENTAILMENT_SOLVER = "opm.solver.entailment" MULTIPLE_CHOICE_SOLVER = "opm.solver.multiple_choice" READING_COMPREHENSION_SOLVER = "opm.solver.reading_comprehension" - COREFERENCE_SOLVER = "intentbox.coreference" - KEYWORD_EXTRACTION = "intentbox.keywords" - UTTERANCE_SEGMENTATION = "intentbox.segmentation" - TOKENIZATION = "intentbox.tokenization" - POSTAG = "intentbox.postag" - STREAM_EXTRACTOR = "ovos.ocp.extractor" + COREFERENCE_SOLVER = "intentbox.coreference" # TODO rename "opm.coreference" + KEYWORD_EXTRACTION = "intentbox.keywords" # TODO rename "opm.keywords" + UTTERANCE_SEGMENTATION = "intentbox.segmentation" # TODO rename "opm.segmentation" + TOKENIZATION = "intentbox.tokenization" # TODO rename "opm.tokenization" + POSTAG = "intentbox.postag" # TODO rename "opm.postag" + STREAM_EXTRACTOR = "ovos.ocp.extractor" # TODO rename "opm.ocp.extractor" AUDIO_PLAYER = "opm.media.audio" VIDEO_PLAYER = "opm.media.video" WEB_PLAYER = "opm.media.web" @@ -91,6 +92,7 @@ class PluginConfigTypes(str, Enum): DIALOG_TRANSFORMER = "opm.transformer.dialog.config" TTS_TRANSFORMER = "opm.transformer.tts.config" QUESTION_SOLVER = "neon.plugin.solver.config" + CHAT_SOLVER = "opm.solver.chat.config" TLDR_SOLVER = "opm.solver.summarization.config" ENTAILMENT_SOLVER = "opm.solver.entailment.config" MULTIPLE_CHOICE_SOLVER = "opm.solver.multiple_choice.config" @@ -173,6 +175,7 @@ def load_plugin(plug_name: str, plug_type: Optional[PluginTypes] = None): LOG.warning(f'Could not find the plugin {plug_type}.{plug_name}') return None + @deprecated("normalize_lang has been deprecated! update to 'from ovos_utils.lang import standardize_lang_tag'", "1.0.0") def normalize_lang(lang): from ovos_utils.lang import standardize_lang_tag From 48143e6f3c1c743aea3c5cd515ea5460d3c87587 Mon Sep 17 00:00:00 2001 From: miro Date: Wed, 13 Nov 2024 15:45:52 +0000 Subject: [PATCH 2/5] streaming --- ovos_plugin_manager/templates/solvers.py | 39 +++++++++++++++++++----- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/ovos_plugin_manager/templates/solvers.py b/ovos_plugin_manager/templates/solvers.py index be873044..41615142 100644 --- a/ovos_plugin_manager/templates/solvers.py +++ b/ovos_plugin_manager/templates/solvers.py @@ -4,8 +4,8 @@ from typing import Optional, List, Iterable, Tuple, Dict, Union, Any from json_database import JsonStorageXDG -from ovos_utils.log import LOG, log_deprecation from ovos_utils.lang import standardize_lang_tag +from ovos_utils.log import LOG, log_deprecation from ovos_utils.xdg_utils import xdg_cache_home from ovos_plugin_manager.templates.language import LanguageTranslator, LanguageDetector @@ -407,21 +407,44 @@ class ChatMessageSolver(QuestionSolver): {"role": "user", "content": "Orange."}, ] """ + @abc.abstractmethod def continue_chat(self, messages: List[Dict[str, str]], - lang: Optional[str]) -> Optional[str]: + lang: Optional[str], + units: Optional[str] = None) -> Optional[str]: pass @auto_detect_lang(text_keys=["messages"]) @auto_translate(translate_keys=["messages"]) def get_chat_completion(self, messages: List[Dict[str, str]], - lang: Optional[str] = None) -> Optional[str]: - return self.continue_chat(messages=messages, lang=lang) + lang: Optional[str] = None, + units: Optional[str] = None) -> Optional[str]: + return self.continue_chat(messages=messages, lang=lang, units=units) - @auto_detect_lang(text_keys=["query"]) - @auto_translate(translate_keys=["query"]) - def get_spoken_answer(self, query: str, lang: Optional[str] = None) -> Optional[str]: - return self.continue_chat(messages=[{"role": "user", "content": query}], lang=lang) + @_deprecate_context2lang() + def stream_utterances(self, messages: List[Dict[str, str]], + lang: Optional[str] = None, + units: Optional[str] = None) -> Iterable[str]: + """ + Stream utterances for the given query as they become available. + + Args: + messages: The chat messages. + lang (Optional[str]): Optional language code. Defaults to None. + units (Optional[str]): Optional units for the query. Defaults to None. + + Returns: + Iterable[str]: An iterable of utterances. + """ + ans = _call_with_sanitized_kwargs(self.get_chat_completion, messages, lang=lang, units=units) + for utt in self.sentence_split(ans): + yield utt + + def get_spoken_answer(self, query: str, + lang: Optional[str] = None, + units: Optional[str] = None) -> Optional[str]: + # just for api compat since it's a subclass, shouldn't be directly used + return self.continue_chat(messages=[{"role": "user", "content": query}], lang=lang, units=units) class CorpusSolver(QuestionSolver): From 2a0b5d89a059d96177c74fbc353df8f769f50f23 Mon Sep 17 00:00:00 2001 From: miro Date: Wed, 13 Nov 2024 15:56:55 +0000 Subject: [PATCH 3/5] Liskov Substitution Principle --- ovos_plugin_manager/templates/solvers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ovos_plugin_manager/templates/solvers.py b/ovos_plugin_manager/templates/solvers.py index 41615142..b60b30c1 100644 --- a/ovos_plugin_manager/templates/solvers.py +++ b/ovos_plugin_manager/templates/solvers.py @@ -422,11 +422,11 @@ def get_chat_completion(self, messages: List[Dict[str, str]], return self.continue_chat(messages=messages, lang=lang, units=units) @_deprecate_context2lang() - def stream_utterances(self, messages: List[Dict[str, str]], - lang: Optional[str] = None, - units: Optional[str] = None) -> Iterable[str]: + def stream_chat_utterances(self, messages: List[Dict[str, str]], + lang: Optional[str] = None, + units: Optional[str] = None) -> Iterable[str]: """ - Stream utterances for the given query as they become available. + Stream utterances for the given chat history as they become available. Args: messages: The chat messages. From 89c2c49644b21d6dac73123959737ea5ff05632b Mon Sep 17 00:00:00 2001 From: miro Date: Wed, 13 Nov 2024 15:58:07 +0000 Subject: [PATCH 4/5] rm unused decorator --- ovos_plugin_manager/templates/solvers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ovos_plugin_manager/templates/solvers.py b/ovos_plugin_manager/templates/solvers.py index b60b30c1..777868d8 100644 --- a/ovos_plugin_manager/templates/solvers.py +++ b/ovos_plugin_manager/templates/solvers.py @@ -421,7 +421,6 @@ def get_chat_completion(self, messages: List[Dict[str, str]], units: Optional[str] = None) -> Optional[str]: return self.continue_chat(messages=messages, lang=lang, units=units) - @_deprecate_context2lang() def stream_chat_utterances(self, messages: List[Dict[str, str]], lang: Optional[str] = None, units: Optional[str] = None) -> Iterable[str]: From 9afae8053125aafe3df610b440413827415b28e2 Mon Sep 17 00:00:00 2001 From: miro Date: Wed, 13 Nov 2024 16:19:00 +0000 Subject: [PATCH 5/5] docstrs --- ovos_plugin_manager/templates/solvers.py | 37 ++++++++++++++++++------ 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/ovos_plugin_manager/templates/solvers.py b/ovos_plugin_manager/templates/solvers.py index 777868d8..3c04b5ed 100644 --- a/ovos_plugin_manager/templates/solvers.py +++ b/ovos_plugin_manager/templates/solvers.py @@ -399,20 +399,33 @@ def long_answer(self, query: str, class ChatMessageSolver(QuestionSolver): - """take chat history as input LLM style - messages = [ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": "Knock knock."}, - {"role": "assistant", "content": "Who's there?"}, - {"role": "user", "content": "Orange."}, - ] - """ + """A solver that processes chat history in LLM-style format to generate contextual responses. + + This class extends QuestionSolver to handle multi-turn conversations, maintaining + context across messages. It expects chat messages in a format similar to LLM APIs: + + messages = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Knock knock."}, + {"role": "assistant", "content": "Who's there?"}, + {"role": "user", "content": "Orange."}, + ] + """ @abc.abstractmethod def continue_chat(self, messages: List[Dict[str, str]], lang: Optional[str], units: Optional[str] = None) -> Optional[str]: - pass + """Generate a response based on the chat history. + + Args: + messages (List[Dict[str, str]]): List of chat messages, each containing 'role' and 'content'. + lang (Optional[str]): The language code for the response. If None, will be auto-detected. + units (Optional[str]): Optional unit system for numerical values. + + Returns: + Optional[str]: The generated response or None if no response could be generated. + """ @auto_detect_lang(text_keys=["messages"]) @auto_translate(translate_keys=["messages"]) @@ -442,6 +455,12 @@ def stream_chat_utterances(self, messages: List[Dict[str, str]], def get_spoken_answer(self, query: str, lang: Optional[str] = None, units: Optional[str] = None) -> Optional[str]: + """Override of QuestionSolver.get_spoken_answer for API compatibility. + + This implementation converts the single query into a chat message format + and delegates to continue_chat. While functional, direct use of chat-specific + methods is recommended for chat-based interactions. + """ # just for api compat since it's a subclass, shouldn't be directly used return self.continue_chat(messages=[{"role": "user", "content": query}], lang=lang, units=units)