Files
wehub-resource-sync 86db9aae8e
Documentation / build (push) Waiting to run
Documentation / deploy (push) Blocked by required conditions
chore: import upstream snapshot with attribution
2026-07-13 13:34:55 +08:00

1677 lines
59 KiB
Python
Executable File

# Copyright 2023-2026 llmware
# Licensed under the Apache License, Version 2.0 (the "License"); you
# may not use this file except in compliance with the License. You
# may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied. See the License for the specific language governing
# permissions and limitations under the License.
"""The agents module implements the two classes LLMfx and SQLTables, where LLMfx manages
Structured Language Instruction Models (SLIMs), the agents and SQLTables handles
creating and accessing external SQL data. LLmfx currently only supports SLIM models, other model
classes will be added over time. And SQLTables is an experimental feature for creating and accessing SQLite.
A Structured Language Instruction Model, SLIM for short, is a small specialized multi-modal LLM for function
calling and multi-step workflows.
"""
import shutil
import logging
import gc
import re
import csv
import os
import sqlite3
import json
from llmware.models import ModelCatalog, _ModelRegistry
from llmware.util import CorpTokenizer, AgentWriter
from llmware.resources import CustomTable
from llmware.configs import LLMWareConfig, SQLiteConfig, ModelNotFoundException
logger = logging.getLogger(__name__)
logger.setLevel(level=LLMWareConfig().get_logging_level_by_module(__name__))
class LLMfx:
"""
``LLMfx`` provides a high-level orchestration abstraction that implements multi-model, multi-step processes
with the ability to load and orchestrate multiple SLIM models as tools with centralized journaling,
structured work management and information aggregation. Currently, LLMfx only supports SLIM classifier
models, support for additional model classes will be added over time.
Parameters
----------
api_key : str, optional, default=None
Sets the API key that used by the ``ModelCatalog`` to load models and logs.
verbose : bool, optional, default=True
Sets whether ``agent_writer.write`` statements should be executed or not, e.g. if ```verbose=True```, then new
events that are written to the journal are written to stdout.
analyze_mode : bool, optional, default=True
Sets whether logits should be retrieved when a tool is called with ``exec_function_call``.
Returns
-------
llmfx : LLMfx
A new ``LLMfx`` object.
"""
def __init__(self, api_key=None, verbose=True, analyze_mode=True):
self.agent_writer = AgentWriter()
if self.agent_writer.mode == "file":
logger.info(f"update: AgentWriter mode set to file - writing agent work process to: "
f"{os.path.join(self.agent_writer.fp_base, self.agent_writer.fn)}"
f"\nTo change file: `LLMWareConfig().set_agent_file('new_file_name.txt')`"
f"\nTo change to screen: `LLMWareConfig().set_agent_log('screen')")
if verbose:
self.agent_writer.write("update: Launching LLMfx process")
self._supported_tools = _ModelRegistry().get_llm_fx_tools_list()
self._default_tool_map = _ModelRegistry().get_llm_fx_mapping()
for tools in self._supported_tools:
setattr(self, tools + "_model", None)
self.work_queue = []
self.work_iteration = 0
self.verbose = verbose
self.analyze_mode = analyze_mode
# report is a list of dictionaries, with each dictionary linked to a work item number
# reports are automatically aggregated through the lifecycle of the object
self.report = []
# response list provides a list of the llm tool responses
self.response_list = []
# research list provides a list of any research gathered (specifically from SQLTables currently)
self.research_list = []
# journal keeps a running journal output used in 'verbose' mode to the screen display
self.journal = []
self.step = 0
journal_update = f"creating object - ready to start processing."
self.write_to_journal(journal_update)
self.tools_deployed = []
self.inference_calls = 0
# set by default to localhost, 8080 and using 'demo-test' api_key
self.api_endpoint = "http://127.0.0.1/8080"
self.api_key = api_key
self.api_exec = False
self.sql_query = None
# check for llmware path & create if not already set up, e.g., "first time use"
if not os.path.exists(LLMWareConfig.get_llmware_path()):
LLMWareConfig.setup_llmware_workspace()
logger.info("Agent - Setting up LLMWare Workspace.")
def register_api_endpoint(self, api_endpoint = None, api_key=None, endpoint_on=True):
self.api_endpoint = api_endpoint
self.api_key=api_key
self.api_exec = endpoint_on
return True
def switch_endpoint_on(self):
self.api_exec = True
return True
def switch_endpoint_off(self):
self.api_exec = False
return True
def update_tool_map(self, tool_type, tool_name):
""" Updates tool mapping for LLMfx instance - enables swapping in other models. """
if tool_type:
if tool_type in self._supported_tools:
# unload tool if currently being used
self.unload_tool(tool_type)
# create new mapping
self._default_tool_map.update({tool_type: tool_name})
# load new tool
self.load_tool(tool_type)
return self
def clear_work(self):
""" Detaches any loaded text work and resets the iteration number. """
self.work_queue = []
self.work_iteration = 0
journal_update = f"clearing work queue - reset"
self.write_to_journal(journal_update)
return True
def set_work_iteration(self, num):
""" Sets the work iteration number. """
if num < len(self.work_queue):
self.work_iteration = num
journal_update = f"setting work iteration to entry - {str(num)}"
self.write_to_journal(journal_update)
return True
def top_of_work_queue(self):
""" Sets the work iteration number to the last item in the work queue and returns this value. """
self.work_iteration = len(self.work_queue) - 1
return self.work_iteration
def increment_work_iteration(self):
""" Increments the work iteration - will return None if nothing left in the processing queue. """
if (self.work_iteration + 1) < len(self.work_queue):
self.work_iteration += 1
output_value = self.work_iteration
journal_update = f"incrementing work iteration to entry - {str(self.work_iteration)}"
else:
journal_update = f"completed all work processing"
output_value = None
self.write_to_journal(journal_update)
return output_value
def _expand_report(self):
""" Creates an incremental empty report dictionary in line with creation of a new work item. """
self.report.append({})
return len(self.report)
def load_work(self, text, text_key="text"):
""" Flexible intake method accepts multiple forms of input text:
--if string, then packages as a dictionary, and adds to the work_queue
--if dictionary, then checks the keys and adds to the work_queue
--if list, then unpacks and iterates, adding each entry as a dictionary onto the work queue """
new_entries_created = 0
if isinstance(text, str):
new_entry = {"text": text, "file_source": "NA", "page_num": "NA"}
self.work_queue.append(new_entry)
new_entries_created += 1
self._expand_report()
if isinstance(text, dict):
if text_key in text and "file_source" in text and "page_num" in text:
self.work_queue.append(text)
new_entries_created += 1
self._expand_report()
else:
if text_key not in text:
logging.warning("could not identify dictionary type.")
return -1
else:
if "file_source" not in text:
text.update({"file_source": "NA"})
if "page_num" not in text:
text.update({"page_num": "NA"})
self.work_queue.append(text)
new_entries_created += 1
self._expand_report()
if isinstance(text, list):
# need to check the type of the entries in the list
for i, elements in enumerate(text):
if isinstance(elements, str):
new_entry = {"text": elements, "file_source": "NA", "page_num": "NA"}
self.work_queue.append(new_entry)
new_entries_created += 1
self._expand_report()
if isinstance(elements, dict):
if text_key in elements and "file_source" in elements and "page_num" in elements:
self.work_queue.append(elements)
new_entries_created += 1
self._expand_report()
else:
if text_key not in elements:
logging.warning("update: load - skipping - could not identify "
"dictionary type - %s", elements)
else:
if "file_source" not in elements:
elements.update({"file_source": "NA"})
if "page_num" not in elements:
elements.update({"page_num": "NA"})
self.work_queue.append(elements)
new_entries_created += 1
self._expand_report()
journal_update = f"loading new processing text - {str(new_entries_created)} new entries"
self.write_to_journal(journal_update)
return self.work_queue
def clear_state(self):
""" Resets key state variables of LLMfx instance """
self.journal = []
self.tools_deployed = []
self.inference_calls = 0
self.response_list = []
# self.report = {}
self.report = []
self.step = 0
return self
def activity_summary(self):
""" Provides an activity summary and writes to journal. """
activity_summary = {"inference_count": self.inference_calls, "tools_used": len(self.tools_deployed),
"tools": self.tools_deployed}
journal_update = f"generating activity_summary - {str(activity_summary)}"
self.write_to_journal(journal_update)
return activity_summary
def show_report(self, iteration_num=None,add_source=True):
""" Shows the gathered report so far, and writes to journal. """
output_report = []
if iteration_num:
if not isinstance(iteration_num,list):
iteration_num = [iteration_num]
# show specific report(s)
journal_update = f"showing selected reports - {str(iteration_num)}\n"
for n in iteration_num:
journal_update += f"showing gathered report - {str(self.report[n])}\n"
for key, value in self.report[n].items():
journal_update += f"\t\t\t\t -- {key.ljust(20)} - {str(value).ljust(40)}\n"
source_info = ""
if "file_source" in self.work_queue[n]:
source_info += self.work_queue[n]["file_source"]
if "page_num" in self.work_queue[n]:
source_info += " - page: " + str(self.work_queue[n]["page_num"])
key= "source_info"
value = source_info
if source_info:
journal_update += f"\t\t\t\t -- {key.ljust(20)} - {str(value).ljust(40)}\n"
base_report = self.report[n]
if add_source:
base_report.update({"source": self.work_queue[n]})
output_report.append(base_report)
self.write_to_journal(journal_update)
else:
# show all reports
output_report = []
journal_update = f"showing all gathered reports - {str(self.report)}\n"
for i, entries in enumerate(self.report):
journal_update += f"report - {str(i)} - {str(self.report[i])}\n"
for key, value in self.report[i].items():
journal_update += f"\t\t\t\t -- {key.ljust(20)} - {str(value).ljust(40)}\n"
if add_source:
entries.update({"source": self.work_queue[i]})
output_report.append(entries)
self.write_to_journal(journal_update)
# output_report = self.report
return output_report
def lookup_response_by_tool(self, tool_type):
""" Looks up an item in the response list by tool type. """
output = []
for i, response in enumerate(self.response_list):
if response["tool"] == tool_type:
output.append(response)
return output
def follow_up_list(self, key=None, value=None):
""" Analyzes response list and returns sub-set with matching 'key' and 'value' """
follow_up_list = []
if not key:
journal_update = f"building follow-up_list - looking for distinct work items\n"
else:
journal_update = f"building follow_up_list - looking for {key} - {value}\n"
key_value_str = f"{key} - {value}"
for i, response in enumerate(self.response_list):
if "llm_response" in response:
work_num = response["work_iteration"]
text = response["text"]
if key:
if key in response["llm_response"]:
if value in response["llm_response"][key]:
follow_up_list.append(work_num)
journal_update += f"\t\t\t\t -- {key_value_str.ljust(20)} - {str(work_num)} - {str(text)}\n"
else:
if work_num not in follow_up_list:
follow_up_list.append(work_num)
placeholder = "distinct_work_item"
journal_update += f"\t\t\t\t -- {placeholder.ljust(20)} - {str(work_num)} - {str(text)}\n"
self.write_to_journal(journal_update)
return follow_up_list
def analyze_responses(self, key,value):
""" Analyzes response list and returns sub-set with matching 'key' and 'value' """
journal_update = f"analyzing responses - looking for {key} - {value}\n"
output_list = []
key_value_str = f"{key} - {value}"
for i,response in enumerate(self.response_list):
if "llm_response" in response:
if key in response["llm_response"]:
if value in response["llm_response"][key]:
output_list.append(response)
cl = response["confidence_score"]
text = response["work_item"]["text"]
step = response["step"]
journal_update += f"\t\t\t\t -- {key_value_str.ljust(20)} - {str(step)} - {str(text)}\n"
self.write_to_journal(journal_update)
return output_list
def load_tool(self, tool_type,
# new options added
use_gpu=True, sample=False, get_logits=True,
max_output=100, temperature=0.0):
""" Loads a single tool """
model = None
if not self.api_exec:
if tool_type in self._supported_tools:
journal_update = f"loading tool - {tool_type}"
self.write_to_journal(journal_update)
setattr(self, tool_type + "_model",
ModelCatalog().load_model(self._default_tool_map[tool_type],api_key=self.api_key,
sample=sample,use_gpu=use_gpu,get_logits=get_logits,max_output=max_output,
temperature=temperature))
model = getattr(self, tool_type + "_model")
if tool_type not in self.tools_deployed:
self.tools_deployed.append(tool_type)
else:
journal_update = f"api_exec mode = 'ON' - skipping - local loading of tool - {tool_type}"
self.write_to_journal(journal_update)
return model
def load_tool_list(self, tool_list):
""" Loads a list of tool, typically at the start of a multi-step process. """
if not self.api_exec:
for tool_type in tool_list:
if tool_type in self._supported_tools:
model = getattr(self, tool_type + "_model")
if not model:
self.load_tool(tool_type)
else:
journal_update = f"api_exec mode = 'ON' - skipping - local loading of tool list - {str(tool_list)}"
self.write_to_journal(journal_update)
return self
def unload_tool(self, tool_type):
""" Unloads a tool, which removes it from memory - useful in long-running processes
to be able to load and unload different tools. """
if not self.api_exec:
if tool_type in self._supported_tools:
journal_update = f"unloading tool - {tool_type}"
self.write_to_journal(journal_update)
model = getattr(self, tool_type + "_model")
if model:
model.unload_model()
delattr(self, tool_type + "_model")
setattr(self, tool_type + "_model", None)
gc.collect()
else:
journal_update = f"api_exec mode = 'ON' - skipping - local 'unload' of model- {tool_type}"
self.write_to_journal(journal_update)
return 0
def write_to_journal(self, journal_update):
""" Adds an event to the running journal list and displays if in verbose mode. """
self.journal.append(journal_update)
self.step += 1
if self.verbose:
self.agent_writer.write(f"step - \t{str(self.step)} - \t{journal_update}")
return True
def exec_function_call(self, tool_type, text=None, function="classify", params=None, get_logits=True):
""" Executes a function call on the selected tool type. """
value_output = {}
if tool_type in self._supported_tools:
journal_update = f"executing function call - deploying - {tool_type} "
self.write_to_journal(journal_update)
if text:
# if text passed directly, then add to work queue
self.load_work(text)
# set work iteration to be the last item
self.top_of_work_queue()
# pull from the work queue
work_dict = self.work_queue[self.work_iteration]
work_iter = self.work_iteration
text = work_dict["text"]
if not self.analyze_mode:
get_logits = False
if not self.api_exec:
model = getattr(self, tool_type + "_model")
# if model not yet loaded, then load in-line
if not model:
model = self.load_tool(tool_type)
function_call = getattr(model, "function_call")
response = function_call(text, function=function, params=params, get_logits=get_logits)
else:
# send to api agent server
response = self.fx_over_api_endpoint(context=text,tool_type=tool_type, function=function,params=params,
get_logits=get_logits)
self.inference_calls += 1
output_response = {}
logit_analysis = {}
if response:
if "llm_response" in response:
llm_response = response["llm_response"]
output_type = response["usage"]["type"]
usage= response["usage"]
if response["usage"]["type"] == "dict":
dict_output = True
self.report[work_iter] = self.report[work_iter] | response["llm_response"]
elif response["usage"]["type"] == "list" and tool_type == "summary":
dict_output = True
self.report[work_iter] = self.report[work_iter] | {"summary": response["llm_response"]}
else:
logging.warning("update: could not automatically convert to dictionary - "
"keeping as string output")
dict_output = False
# assemble output
value_output.update({"llm_response": llm_response,"dict_output": dict_output})
# start journaling update
journal_update = f"executing function call - " \
f"getting response - {tool_type}\n"
journal_update += f"\t\t\t\t -- llm_response - {str(llm_response)}\n"
journal_update += f"\t\t\t\t -- output type - {output_type}\n"
journal_update += f"\t\t\t\t -- usage - {usage}"
self.write_to_journal(journal_update)
# end journaling
# default - if not found/applied
confidence_score = -1
# load the model card
model_name = _ModelRegistry().get_llm_fx_mapping()[tool_type]
model_card = ModelCatalog().lookup_model_card(model_name)
hf_tokenizer_name = model_card["tokenizer"]
if get_logits:
logit_analysis = ModelCatalog().logit_analysis(response, model_card,
hf_tokenizer_name,
api_key=self.api_key)
confidence_score = logit_analysis["confidence_score"]
ryg = logit_analysis["ryg_string"]
choices = logit_analysis["choices"]
# will display and add to journal only the 'first' token choice
# choices for each token captured in 'logit_analysis' metadata
if len(choices) > 1:
choices = choices[0]
marker_tokens = logit_analysis["marker_tokens"]
output_response.update({"logit_analysis": logit_analysis})
# start journaling update
journal_update = f"analyzing response - {tool_type}\n"
journal_update += f"\t\t\t\t -- confidence score - {str(confidence_score)}\n"
journal_update += f"\t\t\t\t -- analyzing response - {ryg}\n"
journal_update += f"\t\t\t\t -- analyzing response - {choices}"
if marker_tokens:
journal_update += "\n"
journal_update += f"\t\t\t\t -- analyzing response - {str(marker_tokens)}"
self.write_to_journal(journal_update)
value_output.update({"confidence_score": confidence_score})
if marker_tokens:
value_output.update({"choices": marker_tokens})
# assemble output response dictionary
output_response = {"step": self.step, "tool": tool_type, "inference": self.inference_calls,
"llm_response": llm_response}
if get_logits:
output_response.update({"confidence_score": confidence_score})
output_response.update({"llm_usage": usage, "work_iteration": work_iter, "dict_output": dict_output})
for keys, values in work_dict.items():
output_response.update({keys:values})
if get_logits:
output_response.update({"logit_analysis": logit_analysis})
# save to response list state tracker
self.response_list.append(output_response)
else:
raise ModelNotFoundException(tool_type)
return value_output
def exec_multitool_function_call(self, tool_type_list, text=None, function="classify", params=None,
get_logits=True):
""" Executes multiple function calls on the same text with a list of tools in tool_type_list """
output_list = []
for tool_type in tool_type_list:
response = self.exec_function_call(tool_type,text=text,get_logits=get_logits,
params=params, function=function)
output_list.append(response)
return output_list
def sentiment(self, text=None, params=None):
""" Executes sentiment analysis on text, if passed directly, or will pull current work item from the
queue. Returns value output dictionary with sentiment classification, confidence score and choices. """
if not params:
# default parameter key
params = ["sentiment"]
if isinstance(params, str):
params = [params]
return self.exec_function_call("sentiment", text=text, params=params)
def topics(self, text=None, params=None):
""" Executes topics analysis on text, if passed directly, or will pull current work item from the queue.
Returns value output dictionary with topics classification and confidence score. """
if not params:
# default parameter key
params = ["topic"]
if isinstance(params, str):
params = [params]
return self.exec_function_call("topics", text=text, params=params)
def named_entity_extraction(self, text=None, params=None):
""" Executes named entity classification analysis on a text, if passed directly, or will pull current
work item from the queue. Returns value output dictionary with named entity classification and
confidence score. """
if not params:
# default parameter key
params = ["people", "place", "company", "misc"]
if isinstance(params, str):
params = [params]
return self.exec_function_call("ner", text=text, params=params)
def ner(self, text=None, params=None):
""" Executes named entity classification analysis on a text, if passed directly, or will pull current
work item from the queue. Returns value output dictionary with named entity classification and
confidence score. """
#TODO: identical to "named_entity_extraction" method - should remove one of them
if not params:
# default parameter key
params = ["people", "place", "company", "misc"]
if isinstance(params, str):
params = [params]
return self.exec_function_call("ner", text=text, params=params)
def ratings(self, text=None, params=None):
""" Executes ratings classification analysis on a text of 1-5, if passed directly, or will pull current
work item from the queue. Returns value output dictionary with rating classification and
confidence score. """
if not params:
# default parameter key
params = ["rating"]
if isinstance(params, str):
params = [params]
return self.exec_function_call("ratings", text=text, params=params)
def emotions(self, text=None, params=None):
""" Executes emotions classification analysis on a text, if passed directly, or will pull current
work item from the queue. Returns value output dictionary with emotions classification and
confidence score. """
if not params:
# default parameter key
params = ["emotions"]
if isinstance(params, str):
params = [params]
return self.exec_function_call("emotions", text=text, params=params)
def intent(self, text=None, params=None):
""" Executes intent classification analysis on a text, if passed directly, or will pull current
work item from the queue. Returns value output dictionary with intent classification and
confidence score. """
if not params:
# default parameter key
params = ["intent"]
if isinstance(params, str):
params = [params]
return self.exec_function_call("intent", text=text, params=params)
def tags(self, text=None, params=None):
""" Generates a list of relevant 'tag' information data points from a text, if passed directly, or
will pull current work item from the queue. Returns value output dictionary with list of key
highlighted points. """
if not params:
# default parameter key
params = ["tags"]
if isinstance(params, str):
params = [params]
return self.exec_function_call("tags", text=text, params=params)
def category(self, text=None, params=None):
""" Generates a list of relevant business category information data points from a text, if passed
directly, or will pull current work item from the queue. Returns value output dictionary with list of
business category classification (usually a single entry, but possible for multiple entries). """
if not params:
# default parameter key
params = ["category"]
if isinstance(params, str):
params = [params]
return self.exec_function_call("category", text=text, params=params)
def sa_ner(self, text=None, params=None):
""" Generates a dictionary with keys corresponding to 'sentiment' and 'named entity recognition' (NER)
identifiers in the next, such as people, organization, and place. """
if not params:
# default parameter key
params = ["sentiment, people, organization, place"]
if isinstance(params, str):
params = [params]
return self.exec_function_call("sa-ner", text=text, params=params)
def extract(self, text=None, params=None):
""" Extract receives an input of a text passage and a custom parameter key, and generates a dictionary with
key corresponding to the 'custom parameter' key and a list of values associated with that key, extracted from
the text passage. """
if not params:
# default parameter key
params = ["key points"]
if isinstance(params, str):
params = [params]
return self.exec_function_call("extract", text=text, params=params)
def xsum(self, text=None, params=None):
""" XSum or 'extreme summarization' receives an input text passage, and returns a dictionary with a 'xsum'
key and a value of a list with one string element, with the string element consisting of a short phrase,
title, headline that provides a concise summary of the text passage. """
if not params:
# default parameter key
params = ["xsum"]
if isinstance(params, str):
params = [params]
return self.exec_function_call("xsum", text=text, params=params)
def summarize(self, text=None, params=None):
""" Summarizes receives an input text passage, and optional parameters to guide the summarization, and
returns a list of summary points from the text. """
if not params:
# default parameter key
params = ["key points (3)"]
if isinstance(params, str):
params = [params]
return self.exec_function_call("summary", text=text, params=params)
def boolean(self, text=None, params=None):
""" Boolean receives an input text passage, a yes/no question as its parameter, and then returns a
dictionary with two keys - 'answer' and 'explain' with the 'answer' providing a yes/no classification, and the
explanation providing text from the passage that was used as the basis for the classification.
Example:
text = "The stock was down sharply after the company announced an earnings miss."
params = "Is the stock down?"
response = boolean(text=text, params=params)
By default, the method will append the "explain" flag and include in the params to pass to the model
"""
if not params:
params = ["Is this true? (explain)"]
if isinstance(params, str):
params = params + " (explain)"
params = [params]
return self.exec_function_call("boolean", text=text, params=params)
def nli(self, text1, text2, params=None):
""" Executes a natural language inference classification on a text, if passed directly, or will pull current
work item from the queue. Returns value output dictionary with the NLI classification and
confidence score. """
if not params:
# default parameter key
params = ["evidence"]
if isinstance(params, str):
params = [params]
context = "Evidence: " + text1 + "\n" + "Conclusion: " + text2
return self.exec_function_call("nli", text=context, params=params)
def q_gen(self, text=None, params=None):
""" Executes a question-gen function call on a text, if passed directly, or will pull current work item from
the queue. Returns value output dictionary with the generated question. """
if not params:
params = ["question"]
if isinstance(params, str):
params = [params]
return self.exec_function_call("q_gen", text=text, params=params)
def qa_gen(self, text=None, params=None):
""" Executes a question-answer gen function call on a text, if passed directly, or will pull current work
item from the queue. Returns value output dictionary with two keys - "question" and "answer" generated. """
if not params:
# default parameter key
params = ["question, answer"]
if isinstance(params, str):
params = [params]
return self.exec_function_call("qa_gen", text=text, params=params)
def verify_llm_response(self, input_context, llm_response):
""" Utility function to apply NLI to compare llm_response with the input context. """
return self.nli(input_context, llm_response)
def answer(self, question, context=None, key=None):
""" Executes an inference """
journal_update = f"executing function call - deploying - question-answer tool "
self.write_to_journal(journal_update)
if context:
self.load_work(context)
work_dict = self.work_queue[self.work_iteration]
text = work_dict["text"]
work_iter = self.work_iteration
if not self.api_exec:
model = getattr(self, "answer" + "_model")
# insert change - load model in-line
# if model not yet loaded, then load in-line
if not model:
model = self.load_tool("answer")
# end - insert change
inference = getattr(model, "inference")
response = inference(question, add_context=text, add_prompt_engineering=True)
else:
# route answer request over API
response = self.fx_over_api_endpoint(tool_type="answer", context=text, prompt=question)
llm_response = re.sub("[\n\r]", "\t", response["llm_response"])
if not key:
self.report[work_iter].update({"answer": [llm_response]})
answer_key = "answer"
else:
self.report[work_iter].update({key:[llm_response]})
answer_key = key
usage = response["usage"]
self.inference_calls += 1
# start journaling update
journal_update = f"executing function call - " \
f"getting response - question - {answer_key}\n"
journal_update += f"\t\t\t\t -- llm_response - {str(llm_response)}\n"
journal_update += f"\t\t\t\t -- output type - text\n"
journal_update += f"\t\t\t\t -- usage - {usage}"
self.write_to_journal(journal_update)
# assemble output response dictionary
output_response = {"step": self.step, "tool": "answer", "inference": self.inference_calls,
"llm_response": llm_response}
get_logits=False
if get_logits:
confidence_score =-1
output_response.update({"confidence_score": confidence_score})
output_response.update({"llm_usage": usage, "work_iteration": work_iter, "dict_output": False})
for keys, values in work_dict.items():
output_response.update({keys:values})
if get_logits:
logit_analysis= {}
output_response.update({"logit_analysis": logit_analysis})
# save to response list state tracker
self.response_list.append(output_response)
return output_response
def sql(self, query, table_schema):
""" Executes Text2Sql tool to convert query into SQL """
if table_schema:
self.load_work(table_schema)
self.top_of_work_queue()
work_dict = self.work_queue[self.work_iteration]
table_schema = work_dict["text"]
work_iter = self.work_iteration
# initial journal update
journal_update = f"executing function call - deploying - text-to-sql\n"
journal_update += f"\t\t\t\t -- query - {query}\n"
journal_update += f"\t\t\t\t -- table_schema - {table_schema}"
self.write_to_journal(journal_update)
if not self.api_exec:
model = getattr(self, "sql" + "_model")
# insert change - load model in-line
# if model not yet loaded, then load in-line
if not model:
model = self.load_tool("sql")
# end - insert change
inference = getattr(model, "inference")
response = inference(query, add_context=table_schema, add_prompt_engineering=True)
else:
response = self.fx_over_api_endpoint(tool_type="sql", context=table_schema, prompt=query)
self.inference_calls += 1
llm_response = response["llm_response"]
# quick clean up response to replace any potential error-generating double-quotes and replace with
# correct sql syntax for single-quotes
llm_response = re.sub('"', "'", llm_response)
self.report[work_iter].update({"sql": [llm_response]})
usage = response["usage"]
self.inference_calls += 1
# start journaling update
journal_update = f"executing function call - getting response - sql\n"
journal_update += f"\t\t\t\t -- llm_response - {str(llm_response)}\n"
journal_update += f"\t\t\t\t -- output type - text\n"
journal_update += f"\t\t\t\t -- usage - {usage}"
self.write_to_journal(journal_update)
# end journaling
# assemble output response dictionary
output_response = {"step": self.step, "tool": "sql", "inference": self.inference_calls,
"llm_response": llm_response}
# logits not yet activated for inference calls - TBD - set 'get_logits = False" for now
get_logits=False
if get_logits:
confidence_score =-1
output_response.update({"confidence_score": confidence_score})
output_response.update({"llm_usage": usage, "work_iteration": work_iter, "dict_output": False})
for keys, values in work_dict.items():
output_response.update({keys:values})
if get_logits:
logit_analysis= {}
output_response.update({"logit_analysis": logit_analysis})
# save to response list state tracker
self.response_list.append(output_response)
return output_response
def sql_checker(self, sql_query, custom_sql_checker=None):
""" Implements a basic post processing check on text-2-sql generation to confirm that
the query is a SELECT statement and not a form of DB WRITE command.
By passing a custom_sql_checker function, you can enhance this basic check.
The custom_sql_checker function should accept a string sql_query as input,
and return two outputs:
1- confirmation: a boolean truth value of True/False to indicate whether to move ahead
2- sql_query_updated: a return string that may be identical/modification of original sql query
"""
# if no red-flags identified, then will return True and original sql_query
confirmation = True
sql_query_updated = sql_query
logger.debug(f"LLMfx - sql_checker - {sql_query} - being reviewed.")
if custom_sql_checker:
confirmation, sql_query_updated = custom_sql_checker(sql_query)
else:
# reviews any SQL statement that does not start with SELECT
if not sql_query.startswith("SELECT"):
sql_tokens = sql_query.split(" ")
logger.warning(f"LLMfx - sql_checker - sql query statement does not start "
f"with SELECT statement - {sql_query}")
# this list can be enhanced
basic_write_commands = ["DROP", "INSERT", "CREATE", "DELETE", "ALTER"]
for toks in sql_tokens:
if toks.upper() in basic_write_commands:
logger.warning(f"LLMfx - sql_checker - sql query statement appears to create "
f"WRITE elements - {toks} - stopping.")
confirmation = False
break
return confirmation, sql_query_updated
def query_custom_table(self, query, db=None,table=None,table_schema=None,db_name="llmware",
custom_sql_checker=None):
""" Executes a text-to-sql query on a CustomTable database table. """
custom_table = CustomTable(db=db,table_name=table)
if not table_schema:
if table:
table_schema = custom_table.sql_table_create_string()
# step 1 - convert question into sql
if not table_schema:
logging.warning("LLMfx - query_db - could not identify table schema - can not proceed")
return -1
# run inference with query and table schema to get SQL query response
response = self.sql(query, table_schema)
# step 2 - run query
sql_query = response["llm_response"]
self.sql_query = sql_query
# basic sql verification checker
confirmation, self.sql_query = self.sql_checker(self.sql_query, custom_sql_checker=custom_sql_checker)
if not confirmation:
logger.warning(f"LLMfx - query_custom_db - sql query generated appears to be potentially unsafe - "
f"{self.sql_query} so not moving ahead with query.")
empty_result = {"step": self.step, "tool": "sql", "db_response": [],
"sql_query": self.sql_query + "-NOT_EXECUTED",
"query": query, "db": db, "work_item": table_schema}
self.research_list.append(empty_result)
return empty_result
# initial journal update
journal_update = f"executing research call - executing query on db\n"
journal_update += f"\t\t\t\t -- db - {db}\n"
journal_update += f"\t\t\t\t -- sql_query - {self.sql_query}"
self.write_to_journal(journal_update)
db_output = custom_table.custom_lookup(self.sql_query)
output = []
db_response = list(db_output)
for rows in db_response:
output.append(rows)
result = {"step": self.step, "tool": "sql", "db_response": output, "sql_query": self.sql_query,
"query": query,"db": db, "work_item": table_schema}
self.research_list.append(result)
# start journaling update
journal_update = f"executing research - getting response - sql\n"
journal_update += f"\t\t\t\t -- result - {str(output)}"
# journal_update += f"\t\t\t\t -- output type - text"
self.write_to_journal(journal_update)
# end journaling
return result
def query_db(self, query, table=None, table_schema=None, db=None, db_name=None,
custom_sql_checker=None):
""" Executes two steps - converts input query into SQL, and then executes the SQL query on the DB. """
sql_db = SQLTables(db=db, db_name=db_name)
if not table_schema:
if table:
table_schema = sql_db.get_table_schema(table)
# step 1 - convert question into sql
if not table_schema:
logging.warning("LLMfx - query_db - could not identify table schema - can not proceed")
return -1
# run inference with query and table schema to get SQL query response
response = self.sql(query, table_schema)
# step 2 - run query
sql_query = response["llm_response"]
self.sql_query = sql_query
sql_db_name = sql_db.db_file
# basic sql safety check
confirmation, self.sql_query = self.sql_checker(self.sql_query, custom_sql_checker=custom_sql_checker)
if not confirmation:
logger.warning(f"LLMfx - query_db - sql query generated appears to be potentially unsafe - "
f"{self.sql_query} so not moving ahead with query.")
empty_result = {"step": self.step, "tool": "sql", "db_response": [],
"sql_query": self.sql_query + "-NOT_EXECUTED",
"query": query, "db": db, "work_item": table_schema}
self.research_list.append(empty_result)
return empty_result
# initial journal update
journal_update = f"executing research call - executing query on db\n"
journal_update += f"\t\t\t\t -- db - {sql_db_name}\n"
journal_update += f"\t\t\t\t -- sql_query - {self.sql_query}"
self.write_to_journal(journal_update)
db_output = sql_db.query_db(self.sql_query)
output = []
db_response = list(db_output)
for rows in db_response:
output.append(rows)
result = {"step": self.step, "tool": "sql", "db_response": output, "sql_query": self.sql_query,
"query": query,"db": sql_db_name, "work_item": table_schema}
self.research_list.append(result)
# start journaling update
journal_update = f"executing research - getting response - sql\n"
journal_update += f"\t\t\t\t -- result - {str(output)}"
# journal_update += f"\t\t\t\t -- output type - text"
self.write_to_journal(journal_update)
# end journaling
return result
def token_comparison (self, value_string, context):
""" Utility function to perform token-level comparison in llm_response with input source materials. """
# note: this is a more limited version of the QualityCheck tools used in Prompt class
c = CorpTokenizer(remove_stop_words=True, remove_numbers=False,
one_letter_removal=True, remove_punctuation=False)
llm_response_tokens = c.tokenize(value_string)
context_tokens = c.tokenize(context)
# iterate thru each key point and analyze comparison match
matched = []
unmatched = []
for i, tok in enumerate(llm_response_tokens):
if tok.endswith("."):
tok = tok[:-1]
if tok.endswith(";"):
tok = tok[:-1]
tok = re.sub("[,();$\"\n\r\t\u2022\u201c\u201d]", "", tok)
if len(tok) > 0:
match_found = False
for j, etoks in enumerate(context_tokens):
if etoks.endswith("."):
etoks = etoks[:-1]
if etoks.endswith(";"):
etoks = re.sub("[(),;$\n\r\t\"\u2022\u201c\u201d]", "", etoks)
if tok == etoks:
# found matching token
match_found = True
matched.append(tok)
break
if not match_found:
unmatched.append(tok)
# match_percent = 0.0
match_percent = "{0:.1f}%".format(0.0)
match_fr = 0.0
if (len(matched) + len(unmatched)) > 0:
match_fr = len(matched) / (len(matched) + len(unmatched))
if match_fr > 1.0:
match_fr = 1.0
match_percent = "{0:.1f}%".format((match_fr * 100))
comparison_stats = {"percent_display": match_percent,
"confirmed_words": matched,
"unconfirmed_words": unmatched,
"verified_token_match_ratio": match_fr,
}
return comparison_stats
def fx_over_api_endpoint(self, context="", tool_type="", model_name="", params="", prompt="",
function=None, endpoint_base=None, api_key=None, get_logits=False):
# send to api agent server
import ast
import requests
if endpoint_base:
self.api_endpoint = endpoint_base
if api_key:
# e.g., "demo-test"
self.api_key = api_key
if not params:
model_name = _ModelRegistry().get_llm_fx_mapping()[tool_type]
mc = ModelCatalog().lookup_model_card(model_name)
if "primary_keys" in mc:
params = mc["primary_keys"]
url = self.api_endpoint + "{}".format("/agent")
output_raw = requests.post(url, data={"model_name": model_name, "api_key": self.api_key, "tool_type": tool_type,
"function": function, "params": params, "max_output": 50,
"temperature": 0.0, "sample": False, "prompt": prompt,
"context": context, "get_logits": True})
try:
# output = ast.literal_eval(output_raw.text)
output = json.loads(output_raw.text)
if "logits" in output:
logits = ast.literal_eval(output["logits"])
self.agent_writer.write(f"logits: {logits}")
output["logits"] = logits
if "output_tokens" in output:
ot_int = [int(x) for x in output["output_tokens"]]
output["output_tokens"] = ot_int
# need to clean up logits
except:
logging.warning("warning: api inference was not successful")
output = {}
self.agent_writer.write(f"TEST: executed Agent call over API endpoint - {model_name} - {function} - {output}")
return output
class SQLTables:
""" SQLTables is a class for creating and accessing external SQL data, primarily as a resource that is
accessible via Text2SQL programmatic inferences.
This is an **experimental** feature, and currently supports only use of SQLite, configured as a separate
local file-based DB, e.g., sqlite-experimental.db
Use of this class will create a separate sqlite_experimental.db per the configs in SQLiteConfig
Please note that the CustomTables class in llmware.resources provides a superset of this functionality, and
offers support for Postgres, in addition to SQLite. This class is provided for a 'fast example' but
generally we would recommend using CustomTables for more complex use cases.
"""
def __init__(self, db=None, db_name=None, experimental=True):
self.db = "sqlite"
# check for llmware path & create if not already set up, e.g., "first time use"
if not os.path.exists(LLMWareConfig.get_llmware_path()):
LLMWareConfig.setup_llmware_workspace()
logger.info("SQLTables - Setting up LLMWare Workspace.")
# default config for "db_experimental" = "sqlite_experimental.db"
self.db_name = SQLiteConfig().get_config("db_experimental")
if experimental:
self.db_file = SQLiteConfig().get_uri_string_experimental_db()
logging.info("update: connecting to experimental sqlite db - %s", self.db_file)
else:
self.db_file = SQLiteConfig().get_uri_string()
logging.info("warning: connecting to main sqlite db - %s", self.db_file)
self.conn = sqlite3.connect(self.db_file)
self.tables = []
def get_table_schema(self,table_name):
""" Lookup of table_schema for an input table_name - outputs 'create table schema string' that can
be used directly as context in a text2sql inference """
table_schema = ""
sql_query = f"SELECT * FROM sqlite_master WHERE type = 'table' AND name = '{table_name}';"
table_schema_row = self.conn.cursor().execute(sql_query)
table_schema_row = list(table_schema_row)
if len(table_schema_row) > 0:
table_schema = table_schema_row[0][4]
return table_schema
def get_column_names(self, table_name):
""" Gets the column names from a table, and provides a list as output. """
column_names = []
sql_query_pragma = "PRAGMA table_info('{}')".format(table_name)
column_info = self.conn.cursor().execute(sql_query_pragma)
for entries in column_info:
column_names.append(entries[1])
return column_names
def query_db(self, sql_query):
""" Executes a query directly on database """
# note: security and access are left to the user to manage
try:
result = self.conn.cursor().execute(sql_query)
except:
logging.warning("update: query generated error - not successful - %s", sql_query)
# if sql query generates error, then an empty result is returned
result = []
return result
def delete_experimental_db(self, confirm_delete=False):
""" Deletes the experimental db """
# delete db and start fresh
if confirm_delete:
shutil.rmtree(self.db_file)
logging.warning("update: deleted sqlite experimental db - %s ", self.db_file)
return True
def delete_table(self, table_name, confirm_delete=False):
""" Deletes a table on the experimental db """
if confirm_delete:
sql_instruction = f"DROP TABLE {table_name};"
results = self.conn.cursor().execute(sql_instruction)
self.conn.commit()
logging.warning("update: delete sqlite experimental db - table - %s ", table_name)
return 0
def register_table(self, sql_table_create):
self.tables.append(sql_table_create)
return self.tables
def reset_tables(self):
self.tables = []
return True
def table_exists_check(self, table_name):
"""Checks if table exists - true if exists, false if does not exist. """
sql_query = f"SELECT * FROM sqlite_master WHERE type = 'table' AND name = '{table_name}';"
results = self.conn.cursor().execute(sql_query)
if len(list(results)) > 0:
table_exists = True
else:
table_exists = False
return table_exists
def load_csv(self, fp, fn):
""" Opens CSV file at folder_path fp and file_name fn and returns array-like output in memory """
in_path = os.path.join(fp,fn)
# csv encoding can vary - utf-8-sig and errors='ignore' seems to be the most resilient for wide range of csv
record_file = open(in_path, encoding='utf-8-sig',errors='ignore')
c = csv.reader(record_file, dialect='excel', doublequote=False, delimiter=',')
output = []
for lines in c:
output.append(lines)
record_file.close()
return output
def create_new_table(self, output, table_name):
""" Creates a new table, deriving the column names from an implied header row in the output,
and a sniff test on the value types. """
col_names = []
if len(output) > 1:
header_row = output[0]
test_row = output[1]
keys_list = "("
sql_create_table = f"CREATE TABLE {table_name} ("
for i, entry in enumerate(header_row):
col_name = re.sub("[\xfe\xff]","",entry)
try:
#TODO: build more robust type checking, e.g., float/decimal/currency
test_int = int(test_row[i])
type="integer"
except:
type="text"
col_names.append(col_name)
keys_list += col_name + ", "
sql_create_table += col_name + " " + type + ", "
if sql_create_table.endswith(", "):
sql_create_table = sql_create_table[:-2]
sql_create_table += " )"
if keys_list.endswith(", "):
keys_list = keys_list[:-2]
keys_list += " )"
self.conn.cursor().execute(sql_create_table)
return col_names
def insert_new_row(self, table_name, keys_list, new_row):
""" Inserts a new row into table. """
col_names = "("
for cols in keys_list:
col_names += cols + ", "
if col_names.endswith(", "):
col_names = col_names[:-2]
col_names += ")"
values_list = "("
for j in range(0, len(new_row)):
values_list += "$" + str(j + 1) + ", "
if values_list.endswith(", "):
values_list = values_list[:-2]
values_list += ")"
new_record = f"INSERT INTO {table_name} {col_names} VALUES {values_list};"
logging.info("update: inserting new_record - %s ", new_record)
self.conn.cursor().execute(new_record, new_row)
return True
def create_new_table_from_csv(self,fp=None, fn=None, table_name=None):
""" Designed for rapid prototyping - input is a well-formed csv file with assumed header row with
each entry representing a column name, and well-formed rows. """
# load csv
output = self.load_csv(fp,fn)
# check if table exists
if not self.table_exists_check(table_name):
logging.info("update: table does not exist - so creating")
# need to build the table
column_names = self.create_new_table(output, table_name)
logging.info("update: table created - column names - %s ", column_names)
else:
logging.info("update: table exists - getting column names")
column_names = self.get_column_names(table_name)
# insert records
new_record = ""
for i in range(1, len(output)):
self.insert_new_row(table_name,column_names,output[i])
self.conn.commit()
self.conn.close()
logging.info("update: done inserting records into new table")
return 0