Skip to content
175 changes: 156 additions & 19 deletions neon_llm_core/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,14 @@
# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

from abc import ABC, abstractmethod
from typing import List
from typing import List, Optional, Tuple, Union

from neon_data_models.models.api import LLMRequest, LLMResponse, \
LLMProposeRequest, LLMProposeResponse, LLMDiscussRequest, \
LLMDiscussResponse, LLMVoteRequest, LLMVoteResponse
from ovos_utils.log import LOG, log_deprecation


class NeonLLM(ABC):
Expand All @@ -36,8 +42,6 @@ def __init__(self, config: dict):
@param config: Dict LLM configuration for this specific LLM
"""
self._llm_config = config
self._tokenizer = None
self._model = None

@property
def llm_config(self):
Expand All @@ -48,75 +52,208 @@ def llm_config(self):

@property
@abstractmethod
def tokenizer(self):
def tokenizer(self) -> Optional[object]:
"""
Get a Tokenizer object for the loaded model, if available.
:return: optional transformers.PreTrainedTokenizerBase object
"""
pass

@property
@abstractmethod
def tokenizer_model_name(self) -> str:
"""
Get a string tokenizer model name (i.e. a Huggingface `model id`)
associated with `self.tokenizer`.
"""
pass

@property
@abstractmethod
def model(self):
def model(self) -> object:
"""
Get an OpenAI client object to send requests to.
"""
pass

@property
@abstractmethod
def llm_model_name(self) -> str:
"""
Get a string model name for the configured `model`
"""
pass

@property
@abstractmethod
def _system_prompt(self) -> str:
"""
Get a default string system prompt to use when not included in requests
"""
pass

def ask(self, message: str, chat_history: List[List[str]], persona: dict) -> str:
""" Generates llm response based on user message and (user, llm) chat history """
"""
Generates llm response based on user message and (user, llm) chat history
"""
log_deprecation("This method is replaced by `query_model` which "
"accepts a single `LLMRequest` arg", "1.0.0")
prompt = self._assemble_prompt(message, chat_history, persona)
llm_text_output = self._call_model(prompt)
return llm_text_output

def query_model(self, request: LLMRequest) -> LLMResponse:
"""
Calls `self._assemble_prompt` to allow subclass to modify the input
query and then passes the updated query to `self._call_model`
:param request: LLMRequest object to generate a response to
:return:
"""
if request.model != self.llm_model_name:
raise ValueError(f"Requested model ({request.model}) is not this "
f"model ({self.llm_model_name}")
request.query = self._assemble_prompt(request.query, request.history,
request.persona.model_dump())
response = self._call_model(request.query, request)
history = request.history + [("llm", response)]
return LLMResponse(response=response, history=history)


def ask_proposer(self, request: LLMProposeRequest) -> LLMProposeResponse:
"""
Override this method to implement CBF-specific logic
"""
return LLMProposeResponse(**self.query_model(request).model_dump(),
message_id=request.message_id,
routing_key=request.routing_key)

def ask_discusser(self, request: LLMDiscussRequest,
compose_prompt_method: Optional[callable] = None) -> \
LLMDiscussResponse:
"""
Override this method to implement CBF-specific logic
"""
if not request.options:
opinion = "Sorry, but I got no options to choose from."
else:
# Default opinion if the model fails to respond
opinion = "Sorry, but I experienced an issue trying to form "\
"an opinion on this topic"
try:
sorted_answer_indexes = self.get_sorted_answer_indexes(
question=request.query,
answers=list(request.options.values()),
persona=request.persona.model_dump())
best_respondent_nick, best_response = \
list(request.options.items())[sorted_answer_indexes[0]]
opinion = self._ask_model_for_opinion(
respondent_nick=best_respondent_nick,
llm_request=request, answer=best_response,
compose_opinion_prompt=compose_prompt_method)
except ValueError as err:
LOG.error(f'ValueError={err}')
except IndexError as err:
# Failed response will return an empty list
LOG.error(f'IndexError={err}')
except Exception as e:
LOG.exception(e)

return LLMDiscussResponse(message_id=request.message_id,
routing_key=request.routing_key,
opinion=opinion)

def ask_appraiser(self, request: LLMVoteRequest) -> LLMVoteResponse:
"""
Override this method to implement CBF-specific logic
"""
if not request.responses:
sorted_answer_indexes = []
else:
# Default opinion if the model fails to respond
sorted_answer_indexes = []
try:
sorted_answer_indexes = self.get_sorted_answer_indexes(
question=request.query,
answers=request.responses,
persona=request.persona.model_dump())
except ValueError as err:
LOG.error(f'ValueError={err}')
except IndexError as err:
# Failed response will return an empty list
LOG.error(f'IndexError={err}')
except Exception as e:
LOG.exception(e)

return LLMVoteResponse(message_id=request.message_id,
routing_key=request.routing_key,
sorted_answer_indexes=sorted_answer_indexes)

def _ask_model_for_opinion(self, llm_request: LLMRequest,
respondent_nick: str,
answer: str,
compose_opinion_prompt: callable) -> str:
llm_request.query = compose_opinion_prompt(
respondent_nick=respondent_nick, question=llm_request.query,
answer=answer)
opinion = self.model.query_model(llm_request)
LOG.info(f'Received LLM opinion={opinion}, prompt={llm_request.query}')
return opinion.response

@abstractmethod
def get_sorted_answer_indexes(self, question: str, answers: List[str], persona: dict) -> List[int]:
"""
Creates sorted list of answer indexes with respect to order provided in :param answers
Results should be sorted from best to worst
:param question: incoming question
:param answers: list of answers to rank
:returns list of indexes
Creates sorted list of answer indexes with respect to order provided in
`answers`. Results should be sorted from best to worst
:param question: incoming question
:param answers: list of answers to rank
:param persona: dict representation of Persona to use for sorting
:returns list of indexes
"""
pass

@abstractmethod
def _call_model(self, prompt: str) -> str:
def _call_model(self, prompt: str,
request: Optional[LLMRequest] = None) -> str:
"""
Wrapper for Model generation logic. This method may be called
asynchronously, so it is up to the extending class to use locks or
queue inputs as necessary.
:param prompt: Input text sequence
:param request: Optional LLMRequest object containing parameters to
include in model requests
:returns: Output text sequence generated by model
"""
pass

@abstractmethod
def _assemble_prompt(self, message: str, chat_history: List[List[str]], persona: dict):
def _assemble_prompt(self, message: str,
chat_history: List[Union[List[str], Tuple[str, str]]],
persona: dict) -> str:
"""
Assembles prompt engineering logic

:param message: Incoming prompt
:param chat_history: History of preceding conversation
:returns: assembled prompt
Assemble the prompt to send to the LLM
:param message: Input prompt to optionally modify
:param chat_history: History of preceding conversation
:param persona: dict representation of Persona that is requested
:returns: assembled prompt string
"""
pass

@abstractmethod
def _tokenize(self, prompt: str) -> List[str]:
"""
Tokenize the input prompt into a list of strings
:param prompt: Input to tokenize
:return: Tokenized representation of input prompt
"""
pass

@classmethod
def convert_role(cls, role: str) -> str:
""" Maps MQ role to LLM's internal domain """
"""
Maps MQ role to LLM's internal domain
:param role: Role in Neon LLM format
:return: Role in LLM internal format
"""
matching_llm_role = cls.mq_to_llm_role.get(role)
if not matching_llm_role:
raise ValueError(f"role={role} is undefined, supported are: "
Expand Down
Loading
Loading