From b0cf5914bfb8b7f6b12effa6ff98720639cae5cc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Oct 2025 03:32:42 +0000 Subject: [PATCH 02/14] Add RAG support with Wikipedia/Kiwix and OpenWebUI integration Co-authored-by: SpudGunMan <12676665+SpudGunMan@users.noreply.github.com> --- config.template | 11 ++- modules/llm.py | 202 ++++++++++++++++++++++++++++++++++++++++---- modules/settings.py | 4 + modules/test_bot.py | 18 ++++ 4 files changed, 218 insertions(+), 17 deletions(-) diff --git a/config.template b/config.template index 36de8a4..f6874f5 100644 --- a/config.template +++ b/config.template @@ -83,7 +83,16 @@ ollamaHostName = http://localhost:11434 # If False, the LLM only replies to the "ask:" and "askai" commands. llmReplyToNonCommands = True # if True, the input is sent raw to the LLM, if False uses legacy template query -rawLLMQuery = True +rawLLMQuery = True +# Enable Wikipedia/Kiwix integration with LLM for RAG (Retrieval Augmented Generation) +# When enabled, LLM will automatically search Wikipedia/Kiwix and include context in responses +llmUseWikiContext = False +# Use OpenWebUI instead of direct Ollama API (enables advanced RAG features) +useOpenWebUI = False +# OpenWebUI server URL (e.g., http://localhost:3000) +openWebUIURL = http://localhost:3000 +# OpenWebUI API key/token (required when useOpenWebUI is True) +openWebUIAPIKey = # StoreForward Enabled and Limits StoreForward = True diff --git a/modules/llm.py b/modules/llm.py index 81d6aad..617a684 100644 --- a/modules/llm.py +++ b/modules/llm.py @@ -3,7 +3,8 @@ # This module is used to interact with LLM API to generate responses to user input # K7MHI Kelly Keeton 2024 from modules.log import logger -from modules.settings import llmModel, ollamaHostName, rawLLMQuery +from modules.settings import (llmModel, ollamaHostName, rawLLMQuery, + llmUseWikiContext, useOpenWebUI, openWebUIURL, openWebUIAPIKey) # Ollama Client # https://github.com/ollama/ollama/blob/main/docs/faq.md#how-do-i-configure-ollama-server @@ -17,6 +18,8 @@ if not rawLLMQuery: # LLM System Variables ollamaAPI = ollamaHostName + "/api/generate" +openWebUIChatAPI = openWebUIURL + "/api/chat/completions" +openWebUIOllamaProxy = openWebUIURL + "/ollama/api/generate" tokens = 450 # max charcters for the LLM response, this is the max length of the response also in prompts requestTruncation = True # if True, the LLM "will" truncate the response @@ -177,6 +180,120 @@ def get_google_context(input, num_results): googleResults = ['no other context provided'] return googleResults +def get_wiki_context(input): + """ + Get context from Wikipedia/Kiwix for RAG enhancement + :param input: The user query + :return: Wikipedia summary or empty string if not available + """ + try: + from modules.wiki import get_wikipedia_summary + # Extract potential search terms from the input + # Try to identify key topics/entities for Wikipedia search + search_terms = extract_search_terms(input) + + wiki_context = [] + for term in search_terms[:2]: # Limit to 2 searches to avoid excessive API calls + summary = get_wikipedia_summary(term) + if summary and "error" not in summary.lower(): + wiki_context.append(f"Wikipedia context for '{term}': {summary}") + + return '\n'.join(wiki_context) if wiki_context else '' + except Exception as e: + logger.debug(f"System: LLM Query: Wiki context gathering failed: {e}") + return '' + +def extract_search_terms(input): + """ + Extract potential search terms from user input + Simple implementation: look for capitalized words, proper nouns, etc. + :param input: The user query + :return: List of potential search terms + """ + # Remove common command prefixes + for trap in trap_list_llm: + if input.lower().startswith(trap): + input = input[len(trap):].strip() + break + + # Simple heuristic: extract capitalized words and phrases + words = input.split() + search_terms = [] + + # Look for multi-word capitalized phrases + temp_phrase = [] + for word in words: + # Remove punctuation for checking + clean_word = word.strip('.,!?;:') + if clean_word and clean_word[0].isupper() and len(clean_word) > 2: + temp_phrase.append(clean_word) + elif temp_phrase: + search_terms.append(' '.join(temp_phrase)) + temp_phrase = [] + + if temp_phrase: + search_terms.append(' '.join(temp_phrase)) + + # If no capitalized terms found, use the whole query + if not search_terms: + search_terms = [input.strip()] + + return search_terms[:3] # Limit to 3 terms + +def send_openwebui_query(prompt, model=None, max_tokens=450, context=''): + """ + Send query to OpenWebUI API for chat completion + :param prompt: The user prompt + :param model: Model name (optional, defaults to llmModel) + :param max_tokens: Max tokens for response + :param context: Additional context to include + :return: Response text or error message + """ + if model is None: + model = llmModel + + headers = { + 'Authorization': f'Bearer {openWebUIAPIKey}', + 'Content-Type': 'application/json' + } + + messages = [] + if context: + messages.append({ + "role": "system", + "content": f"Use the following context to help answer questions:\n{context}" + }) + + messages.append({ + "role": "user", + "content": prompt + }) + + data = { + "model": model, + "messages": messages, + "max_tokens": max_tokens, + "stream": False + } + + try: + result = requests.post(openWebUIChatAPI, headers=headers, json=data, timeout=10) + if result.status_code == 200: + result_json = result.json() + # OpenWebUI returns OpenAI-compatible format + if 'choices' in result_json and len(result_json['choices']) > 0: + response = result_json['choices'][0]['message']['content'] + return response.strip() + else: + logger.warning(f"System: OpenWebUI API returned unexpected format") + return "⛔️ Response Error" + else: + logger.warning(f"System: OpenWebUI API returned status code {result.status_code}") + return f"⛔️ Request Error" + except requests.exceptions.RequestException as e: + logger.warning(f"System: OpenWebUI API request failed: {e}") + return f"⛔️ Request Error" + def send_ollama_query(llmQuery): # Send the query to the Ollama API and return the response try: @@ -222,6 +339,7 @@ def send_ollama_tooling_query(prompt, functions, model=None, max_tokens=450): def llm_query(input, nodeID=0, location_name=None): global antiFloodLLM, llmChat_history googleResults = [] + wikiContext = '' # if this is the first initialization of the LLM the query of " " should bring meshbotAIinit OTA shouldnt reach this? # This is for LLM like gemma and others now? @@ -251,13 +369,20 @@ def llm_query(input, nodeID=0, location_name=None): else: antiFloodLLM.append(nodeID) + # Get Wikipedia/Kiwix context if enabled (RAG) + if llmUseWikiContext and input != meshbotAIinit: + wikiContext = get_wiki_context(input) + if wikiContext: + logger.debug(f"System: Wiki-Enhanced LLM Query with context") + + # Get Google context if enabled and not using raw query if llmContext_fromGoogle and not rawLLMQuery: googleResults = get_google_context(input, googleSearchResults) history = llmChat_history.get(nodeID, ["", ""]) - if googleResults: - logger.debug(f"System: Google-Enhanced LLM Query: {input} From:{nodeID}") + if googleResults or wikiContext: + logger.debug(f"System: Context-Enhanced LLM Query: {input} From:{nodeID}") else: logger.debug(f"System: LLM Query: {input} From:{nodeID}") @@ -266,19 +391,64 @@ def llm_query(input, nodeID=0, location_name=None): location_name += f" at the current time of {datetime.now().strftime('%Y-%m-%d %H:%M:%S %Z')}" try: - if rawLLMQuery: - # sanitize the input to remove tool call syntax - if '```' in input: - logger.warning("System: LLM Query: Code markdown detected, removing for raw query") - input = input.replace('```bash', '').replace('```python', '').replace('```', '') - modelPrompt = input - else: - # Build the query from the template - modelPrompt = meshBotAI.format(input=input, context='\n'.join(googleResults), location_name=location_name, llmModel=llmModel, history=history) + # Use OpenWebUI if enabled + if useOpenWebUI and openWebUIAPIKey: + logger.debug("System: Using OpenWebUI API") - llmQuery = {"model": llmModel, "prompt": modelPrompt, "stream": False, "max_tokens": tokens} - # Query the model via Ollama web API - result = send_ollama_query(llmQuery) + # Combine all context sources + combined_context = [] + if wikiContext: + combined_context.append(wikiContext) + if googleResults: + combined_context.append("Google search results: " + '\n'.join(googleResults)) + + context_str = '\n\n'.join(combined_context) + + # For OpenWebUI, we send a cleaner prompt + if rawLLMQuery: + result = send_openwebui_query(input, context=context_str, max_tokens=tokens) + else: + # Use the template for non-raw queries + modelPrompt = meshBotAI.format( + input=input, + context=context_str if combined_context else 'no other context provided', + location_name=location_name, + llmModel=llmModel, + history=history + ) + result = send_openwebui_query(modelPrompt, max_tokens=tokens) + else: + # Use standard Ollama API + if rawLLMQuery: + # sanitize the input to remove tool call syntax + if '```' in input: + logger.warning("System: LLM Query: Code markdown detected, removing for raw query") + input = input.replace('```bash', '').replace('```python', '').replace('```', '') + modelPrompt = input + + # Add wiki context to raw queries if available + if wikiContext: + modelPrompt = f"Context:\n{wikiContext}\n\nQuestion: {input}" + else: + # Build the query from the template + all_context = [] + if wikiContext: + all_context.append(wikiContext) + if googleResults: + all_context.extend(googleResults) + + context_text = '\n'.join(all_context) if all_context else 'no other context provided' + modelPrompt = meshBotAI.format( + input=input, + context=context_text, + location_name=location_name, + llmModel=llmModel, + history=history + ) + + llmQuery = {"model": llmModel, "prompt": modelPrompt, "stream": False, "max_tokens": tokens} + # Query the model via Ollama web API + result = send_ollama_query(llmQuery) #logger.debug(f"System: LLM Response: " + result.strip().replace('\n', ' ')) except Exception as e: @@ -296,7 +466,7 @@ def llm_query(input, nodeID=0, location_name=None): truncateResult = send_ollama_query(truncateQuery) # cleanup for message output - response = result.strip().replace('\n', ' ') + response = truncateResult.strip().replace('\n', ' ') # done with the query, remove the user from the anti flood list antiFloodLLM.remove(nodeID) diff --git a/modules/settings.py b/modules/settings.py index 4f7a6b6..ae89ae1 100644 --- a/modules/settings.py +++ b/modules/settings.py @@ -256,6 +256,10 @@ try: llmModel = config['general'].get('ollamaModel', 'gemma3:270m') # default gemma3:270m rawLLMQuery = config['general'].getboolean('rawLLMQuery', True) #default True llmReplyToNonCommands = config['general'].getboolean('llmReplyToNonCommands', True) # default True + llmUseWikiContext = config['general'].getboolean('llmUseWikiContext', False) # default False + useOpenWebUI = config['general'].getboolean('useOpenWebUI', False) # default False + openWebUIURL = config['general'].get('openWebUIURL', 'http://localhost:3000') # default localhost:3000 + openWebUIAPIKey = config['general'].get('openWebUIAPIKey', '') # default empty dont_retry_disconnect = config['general'].getboolean('dont_retry_disconnect', False) # default False, retry on disconnect favoriteNodeList = config['general'].get('favoriteNodeList', '').split(',') enableEcho = config['general'].getboolean('enableEcho', False) # default False diff --git a/modules/test_bot.py b/modules/test_bot.py index ae101dd..543edcd 100644 --- a/modules/test_bot.py +++ b/modules/test_bot.py @@ -97,6 +97,24 @@ class TestBot(unittest.TestCase): response = send_ollama_query("Hello, Ollama!") self.assertIsInstance(response, str) + def test_extract_search_terms(self): + from llm import extract_search_terms + # Test with capitalized terms + terms = extract_search_terms("What is Python programming?") + self.assertIsInstance(terms, list) + self.assertTrue(len(terms) > 0) + # Test with multiple capitalized words + terms2 = extract_search_terms("Tell me about Albert Einstein and Marie Curie") + self.assertIsInstance(terms2, list) + self.assertTrue(len(terms2) > 0) + + def test_get_wiki_context(self): + from llm import get_wiki_context + # Test with a well-known topic + context = get_wiki_context("Python programming language") + self.assertIsInstance(context, str) + # Context might be empty if wiki is disabled or fails, that's ok + def test_get_moon_phase(self): from space import get_moon phase = get_moon(lat, lon) From 70ab741746160036a208dfeb6b81de476ee5fb12 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Oct 2025 03:35:36 +0000 Subject: [PATCH 03/14] Update README with RAG and OpenWebUI documentation Co-authored-by: SpudGunMan <12676665+SpudGunMan@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 64b5cff..3b3769a 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Mesh Bot is a feature-rich Python bot designed to enhance your [Meshtastic](http ### Interactive AI and Data Lookup - **Weather, Earthquake, River, and Tide Data**: Get local alerts and info from NOAA/USGS; uses Open-Meteo for areas outside NOAA coverage. - **Wikipedia Search**: Retrieve summaries from Wikipedia. -- **Ollama LLM Integration**: Query the [Ollama](https://github.com/ollama/ollama/tree/main/docs) AI for advanced responses. +- **Ollama LLM Integration**: Query the [Ollama](https://github.com/ollama/ollama/tree/main/docs) AI for advanced responses. Supports RAG (Retrieval Augmented Generation) with Wikipedia/Kiwix context and [OpenWebUI](https://github.com/open-webui/open-webui) integration for enhanced AI capabilities. - **Satellite Passes**: Find upcoming satellite passes for your location. - **GeoMeasuring Tools**: Calculate distances and midpoints using collected GPS data; supports Fox & Hound direction finding. From 27789d75084a7a75e2507f4172c700c566acee13 Mon Sep 17 00:00:00 2001 From: SpudGunMan Date: Mon, 27 Oct 2025 17:23:23 -0700 Subject: [PATCH 04/14] patch --- config.template | 3 ++- mesh_bot.py | 17 ++++++++++++++--- requirements.txt | 3 +-- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/config.template b/config.template index f6874f5..783f7be 100644 --- a/config.template +++ b/config.template @@ -75,8 +75,9 @@ kiwixLibraryName = wikipedia_en_100_nopic_2025-09 # Enable ollama LLM see more at https://ollama.com ollama = False -# Ollama model to use (defaults to gemma3:270m) +# Ollama model to use (defaults to gemma3:270m) gemma2 is good for older SYSTEM prompt # ollamaModel = gemma3:latest +# ollamaModel = gemma2:2b # server instance to use (defaults to local machine install) ollamaHostName = http://localhost:11434 # Produce LLM replies to messages that aren't commands? diff --git a/mesh_bot.py b/mesh_bot.py index ceecb4c..7ed953c 100755 --- a/mesh_bot.py +++ b/mesh_bot.py @@ -1486,10 +1486,21 @@ def handle_boot(mesh=True): f"{get_name_from_number(myNodeNum, 'short', i)}. NodeID: {myNodeNum}, {decimal_to_hex(myNodeNum)}") if llm_enabled: - logger.debug(f"System: Ollama LLM Enabled, loading model {my_settings.llmModel} please wait") - llmLoad = llm_query(" ") + msg = f"System: LLM Enabled" + llmLoad = llm_query(" ", init=True) if "trouble" not in llmLoad: - logger.debug(f"System: LLM Model {my_settings.llmModel} loaded") + if my_settings.llmReplyToNonCommands: + msg += " | Reply to DM's Enabled" + if my_settings.llmUseWikiContext: + wiki_source = "Kiwixpedia" if my_settings.use_kiwix_server else "Wikipedia" + msg += f" | {wiki_source} Context Enabled" + if my_settings.useOpenWebUI: + msg += " | OpenWebUI API Enabled" + else: + msg += f" | Ollama API Model {my_settings.llmModel} loaded. Use {'RAW' if my_settings.rawLLMQuery else 'SYSTEM'} prompt mode." + logger.debug(msg) + else: + logger.debug(f"System: Bad response from LLM: {llmLoad}") if my_settings.bbs_enabled: logger.debug(f"System: BBS Enabled, {bbsdb} has {len(bbs_messages)} messages. Direct Mail Messages waiting: {(len(bbs_dm) - 1)}") diff --git a/requirements.txt b/requirements.txt index 4b8d80b..1b70d42 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,5 +7,4 @@ maidenhead beautifulsoup4 dadjokes geopy -schedule -googlesearch-python +schedule \ No newline at end of file From 8d82823ccc43bb7f0f3396fa5476954d8055f14d Mon Sep 17 00:00:00 2001 From: SpudGunMan Date: Mon, 27 Oct 2025 17:31:47 -0700 Subject: [PATCH 05/14] refactor1 --- modules/llm.py | 118 +++++++++---------------------------------------- 1 file changed, 21 insertions(+), 97 deletions(-) diff --git a/modules/llm.py b/modules/llm.py index 617a684..ef2a651 100644 --- a/modules/llm.py +++ b/modules/llm.py @@ -4,7 +4,7 @@ # K7MHI Kelly Keeton 2024 from modules.log import logger from modules.settings import (llmModel, ollamaHostName, rawLLMQuery, - llmUseWikiContext, useOpenWebUI, openWebUIURL, openWebUIAPIKey) + llmUseWikiContext, useOpenWebUI, openWebUIURL, openWebUIAPIKey, cmdBang, urlTimeoutSeconds) # Ollama Client # https://github.com/ollama/ollama/blob/main/docs/faq.md#how-do-i-configure-ollama-server @@ -12,10 +12,6 @@ import requests import json from datetime import datetime -if not rawLLMQuery: - # this may be removed in the future - from googlesearch import search # pip install googlesearch-python - # LLM System Variables ollamaAPI = ollamaHostName + "/api/generate" openWebUIChatAPI = openWebUIURL + "/api/chat/completions" @@ -23,13 +19,9 @@ openWebUIOllamaProxy = openWebUIURL + "/ollama/api/generate" tokens = 450 # max charcters for the LLM response, this is the max length of the response also in prompts requestTruncation = True # if True, the LLM "will" truncate the response -openaiAPI = "https://api.openai.com/v1/completions" # not used, if you do push a enhancement! - # Used in the meshBotAI template llmEnableHistory = True # enable last message history for the LLM model -llmContext_fromGoogle = True # enable context from google search results adds to compute time but really helps with responses accuracy -googleSearchResults = 3 # number of google search results to include in the context more results = more compute time antiFloodLLM = [] llmChat_history = {} trap_list_llm = ("ask:", "askai") @@ -55,24 +47,6 @@ meshBotAI = """ """ -if llmContext_fromGoogle: - meshBotAI = meshBotAI + """ - CONTEXT - The following is the location of the user - {location_name} - - The following is for context around the prompt to help guide your response. - {context} - - """ -else: - meshBotAI = meshBotAI + """ - CONTEXT - The following is the location of the user - {location_name} - - """ - if llmEnableHistory: meshBotAI = meshBotAI + """ HISTORY @@ -104,22 +78,6 @@ def llmTool_math_calculator(expression): except Exception as e: return f"Error in calculation: {e}" -def llmTool_get_google(query, num_results=3): - """ - Example tool function to perform a Google search and return results. - :param query: The search query string. - :param num_results: Number of search results to return. - :return: A list of search result titles and descriptions. - """ - results = [] - try: - googleSearch = search(query, advanced=True, num_results=num_results) - for result in googleSearch: - results.append(f"{result.title}: {result.description}") - return results - except Exception as e: - return [f"Error in Google search: {e}"] - llmFunctions = [ { @@ -144,42 +102,8 @@ llmFunctions = [ "required": ["expression"] } }, - { - "name": "llmTool_get_google", - "description": "Perform a Google search and return results.", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "The search query string." - }, - "num_results": { - "type": "integer", - "description": "Number of search results to return.", - "default": 3 - } - }, - "required": ["query"] - } - } ] -def get_google_context(input, num_results): - # Get context from Google search results - googleResults = [] - try: - googleSearch = search(input, advanced=True, num_results=num_results) - if googleSearch: - for result in googleSearch: - googleResults.append(f"{result.title} {result.description}") - else: - googleResults = ['no other context provided'] - except Exception as e: - logger.debug(f"System: LLM Query: context gathering failed, likely due to network issues") - googleResults = ['no other context provided'] - return googleResults - def get_wiki_context(input): """ Get context from Wikipedia/Kiwix for RAG enhancement @@ -297,7 +221,7 @@ def send_openwebui_query(prompt, model=None, max_tokens=450, context=''): def send_ollama_query(llmQuery): # Send the query to the Ollama API and return the response try: - result = requests.post(ollamaAPI, data=json.dumps(llmQuery), timeout=5) + result = requests.post(ollamaAPI, data=json.dumps(llmQuery), timeout= urlTimeoutSeconds * 4) if result.status_code == 200: result_json = result.json() result = result_json.get("response", "") @@ -336,20 +260,24 @@ def send_ollama_tooling_query(prompt, functions, model=None, max_tokens=450): else: raise Exception(f"HTTP Error: {result.status_code} - {result.text}") -def llm_query(input, nodeID=0, location_name=None): +def llm_query(input, nodeID=0, location_name=None, init=False): global antiFloodLLM, llmChat_history - googleResults = [] wikiContext = '' # if this is the first initialization of the LLM the query of " " should bring meshbotAIinit OTA shouldnt reach this? # This is for LLM like gemma and others now? - if input == " " and rawLLMQuery: + if init and rawLLMQuery: logger.warning("System: These LLM models lack a traditional system prompt, they can be verbose and not very helpful be advised.") input = meshbotAIinit - else: + elif init: input = input.strip() # classic model for gemma2, deepseek-r1, etc - logger.debug(f"System: Using classic LLM model framework, ideally for gemma2, deepseek-r1, etc") + logger.debug(f"System: Using SYSTEM model framework, ideally for gemma2, deepseek-r1, etc") + + + # Remove command bang if present + if cmdBang: + input = input[1:].strip() if not location_name: location_name = "no location provided " @@ -371,20 +299,20 @@ def llm_query(input, nodeID=0, location_name=None): # Get Wikipedia/Kiwix context if enabled (RAG) if llmUseWikiContext and input != meshbotAIinit: - wikiContext = get_wiki_context(input) + # get_wiki_context returns a string, but we want to count the items before joining + search_terms = extract_search_terms(input) + wiki_context_list = [] + for term in search_terms[:2]: + summary = get_wiki_context(term) + if summary and "error" not in summary.lower(): + wiki_context_list.append(f"Wikipedia context for '{term}': {summary}") + wikiContext = '\n'.join(wiki_context_list) if wiki_context_list else '' if wikiContext: - logger.debug(f"System: Wiki-Enhanced LLM Query with context") - - # Get Google context if enabled and not using raw query - if llmContext_fromGoogle and not rawLLMQuery: - googleResults = get_google_context(input, googleSearchResults) + logger.debug(f"System: using Wikipedia/Kiwix context for LLM query got {len(wiki_context_list)} results") history = llmChat_history.get(nodeID, ["", ""]) - if googleResults or wikiContext: - logger.debug(f"System: Context-Enhanced LLM Query: {input} From:{nodeID}") - else: - logger.debug(f"System: LLM Query: {input} From:{nodeID}") + logger.debug(f"System: LLM Query: {input} From:{nodeID}") response = "" result = "" @@ -399,8 +327,6 @@ def llm_query(input, nodeID=0, location_name=None): combined_context = [] if wikiContext: combined_context.append(wikiContext) - if googleResults: - combined_context.append("Google search results: " + '\n'.join(googleResults)) context_str = '\n\n'.join(combined_context) @@ -434,8 +360,6 @@ def llm_query(input, nodeID=0, location_name=None): all_context = [] if wikiContext: all_context.append(wikiContext) - if googleResults: - all_context.extend(googleResults) context_text = '\n'.join(all_context) if all_context else 'no other context provided' modelPrompt = meshBotAI.format( From 8e2c3a43fb401386eb448ba48a0a2882a57dc751 Mon Sep 17 00:00:00 2001 From: SpudGunMan Date: Mon, 27 Oct 2025 18:50:58 -0700 Subject: [PATCH 06/14] refactor2 --- config.template | 5 ++++- modules/llm.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ modules/llm.py | 13 ++++++------- 3 files changed, 55 insertions(+), 8 deletions(-) create mode 100644 modules/llm.md diff --git a/config.template b/config.template index 783f7be..c705252 100644 --- a/config.template +++ b/config.template @@ -80,14 +80,17 @@ ollama = False # ollamaModel = gemma2:2b # server instance to use (defaults to local machine install) ollamaHostName = http://localhost:11434 + # Produce LLM replies to messages that aren't commands? # If False, the LLM only replies to the "ask:" and "askai" commands. llmReplyToNonCommands = True -# if True, the input is sent raw to the LLM, if False uses legacy template query +# if True, the input is sent raw to the LLM, if False uses SYSTEM prompt rawLLMQuery = True + # Enable Wikipedia/Kiwix integration with LLM for RAG (Retrieval Augmented Generation) # When enabled, LLM will automatically search Wikipedia/Kiwix and include context in responses llmUseWikiContext = False + # Use OpenWebUI instead of direct Ollama API (enables advanced RAG features) useOpenWebUI = False # OpenWebUI server URL (e.g., http://localhost:3000) diff --git a/modules/llm.md b/modules/llm.md new file mode 100644 index 0000000..9298d42 --- /dev/null +++ b/modules/llm.md @@ -0,0 +1,45 @@ +# How do I use this thing? +This is not a full turnkey setup for Docker yet? + + +# Ollama local +```bash +# bash +curl -fsSL https://ollama.com/install.sh | sh +# docker +docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -e OLLAMA_API_BASE_URL=http://host.docker.internal:11434 open-webui/open-webui +``` + +```ini +#service file addition +# https://github.com/ollama/ollama/issues/703 +[Service] +Environment="OLLAMA_HOST=0.0.0.0:11434" +``` +## validation +http://IP::11434 +`Ollama is running` + +# OpenWebUI (docker) +```bash +## ollama in docker +docker run -d -p 3000:8080 --gpus all -v open-webui:/app/backend/data --name open-webui ghcr.io/open-webui/open-webui:cuda + +## external ollama +docker run -d -p 3000:8080 -e OLLAMA_BASE_URL=https://IP:11434 -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:main +``` +wait for engine to build, update the config.ini for the bot + +```ini +# Use OpenWebUI instead of direct Ollama API (enables advanced RAG features) +useOpenWebUI = True +# OpenWebUI server URL (e.g., http://localhost:3000) +openWebUIURL = http://IP:3000 +``` + +## validation +http://IP:3000 +make a new admin user. +validate you have models imported or that the system is working for query. +set api endpoint [OpenWebUI API](https://docs.openwebui.com/getting-started/api-endpoints) +to quickly get started, go to admin ->settings ->connections ->Manage OpenAI API Connections ->Auth Type None \ No newline at end of file diff --git a/modules/llm.py b/modules/llm.py index ef2a651..d4b3152 100644 --- a/modules/llm.py +++ b/modules/llm.py @@ -201,7 +201,7 @@ def send_openwebui_query(prompt, model=None, max_tokens=450, context=''): } try: - result = requests.post(openWebUIChatAPI, headers=headers, json=data, timeout=10) + result = requests.post(openWebUIChatAPI, headers=headers, json=data, timeout=urlTimeoutSeconds * 4) if result.status_code == 200: result_json = result.json() # OpenWebUI returns OpenAI-compatible format @@ -274,15 +274,14 @@ def llm_query(input, nodeID=0, location_name=None, init=False): # classic model for gemma2, deepseek-r1, etc logger.debug(f"System: Using SYSTEM model framework, ideally for gemma2, deepseek-r1, etc") - - # Remove command bang if present - if cmdBang: - input = input[1:].strip() - if not location_name: location_name = "no location provided " + + # Remove command bang if present + if cmdBang and input.startswith('!'): + input = input.strip('!').strip() - # remove askai: and ask: from the input + # Remove any trap words from the start of the input for trap in trap_list_llm: if input.lower().startswith(trap): input = input[len(trap):].strip() From 710342447fbc8a7e3110f9c83affef4219f8f814 Mon Sep 17 00:00:00 2001 From: SpudGunMan Date: Mon, 27 Oct 2025 19:26:53 -0700 Subject: [PATCH 07/14] Update llm.py --- modules/llm.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/modules/llm.py b/modules/llm.py index d4b3152..767625e 100644 --- a/modules/llm.py +++ b/modules/llm.py @@ -310,8 +310,6 @@ def llm_query(input, nodeID=0, location_name=None, init=False): logger.debug(f"System: using Wikipedia/Kiwix context for LLM query got {len(wiki_context_list)} results") history = llmChat_history.get(nodeID, ["", ""]) - - logger.debug(f"System: LLM Query: {input} From:{nodeID}") response = "" result = "" @@ -320,7 +318,7 @@ def llm_query(input, nodeID=0, location_name=None, init=False): try: # Use OpenWebUI if enabled if useOpenWebUI and openWebUIAPIKey: - logger.debug("System: Using OpenWebUI API") + logger.debug(f"System: LLM Query: Using OpenWebUI API for LLM query {input} From:{nodeID}") # Combine all context sources combined_context = [] @@ -343,6 +341,7 @@ def llm_query(input, nodeID=0, location_name=None, init=False): ) result = send_openwebui_query(modelPrompt, max_tokens=tokens) else: + logger.debug(f"System: LLM Query: Using Ollama API for LLM query {input} From:{nodeID}") # Use standard Ollama API if rawLLMQuery: # sanitize the input to remove tool call syntax From 9282c63206a40cfd14778d0cfb281e6b8e84d0d1 Mon Sep 17 00:00:00 2001 From: SpudGunMan Date: Mon, 27 Oct 2025 20:00:50 -0700 Subject: [PATCH 08/14] Update llm.md --- modules/llm.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/modules/llm.md b/modules/llm.md index 9298d42..cfdf2c6 100644 --- a/modules/llm.md +++ b/modules/llm.md @@ -41,5 +41,12 @@ openWebUIURL = http://IP:3000 http://IP:3000 make a new admin user. validate you have models imported or that the system is working for query. -set api endpoint [OpenWebUI API](https://docs.openwebui.com/getting-started/api-endpoints) -to quickly get started, go to admin ->settings ->connections ->Manage OpenAI API Connections ->Auth Type None \ No newline at end of file +make a new user for the bot + + +upper right settings for the user +settings -> account +get/create the API key for the user + + +set api endpoint [OpenWebUI API](https://docs.openwebui.com/getting-started/api-endpoints) \ No newline at end of file From 4daf087fa5f67e6979ae605d079878717d9ce377 Mon Sep 17 00:00:00 2001 From: SpudGunMan Date: Mon, 27 Oct 2025 20:03:14 -0700 Subject: [PATCH 09/14] Update llm.py --- modules/llm.py | 80 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 60 insertions(+), 20 deletions(-) diff --git a/modules/llm.py b/modules/llm.py index 767625e..00d60b4 100644 --- a/modules/llm.py +++ b/modules/llm.py @@ -17,7 +17,8 @@ ollamaAPI = ollamaHostName + "/api/generate" openWebUIChatAPI = openWebUIURL + "/api/chat/completions" openWebUIOllamaProxy = openWebUIURL + "/ollama/api/generate" tokens = 450 # max charcters for the LLM response, this is the max length of the response also in prompts -requestTruncation = True # if True, the LLM "will" truncate the response +requestTruncation = True # if True, the LLM "will" truncate the response +DEBUG_LLM = False # enable debug logging for LLM queries # Used in the meshBotAI template llmEnableHistory = True # enable last message history for the LLM model @@ -126,11 +127,37 @@ def get_wiki_context(input): except Exception as e: logger.debug(f"System: LLM Query: Wiki context gathering failed: {e}") return '' + +def llm_extract_topic(input): + """ + Use LLM to extract the main topic as a single word or short phrase. + Always uses raw mode and supports both Ollama and OpenWebUI. + :param input: The user query + :return: List with one topic string, or empty list on failure + """ + prompt = ( + "Summarize the following query into a single word or short phrase that best represents the main topic, " + "for use as a Wikipedia search term. Only return the word or phrase, nothing else:\n" + f"{input}" + ) + try: + if useOpenWebUI and openWebUIAPIKey: + result = send_openwebui_query(prompt, max_tokens=10) + else: + llmQuery = {"model": llmModel, "prompt": prompt, "stream": False, "max_tokens": 10} + result = send_ollama_query(llmQuery) + topic = result.strip().split('\n')[0] + topic = topic.strip(' "\'.,!?;:') + if topic: + return [topic] + except Exception as e: + logger.debug(f"LLM topic extraction failed: {e}") + return [] def extract_search_terms(input): """ - Extract potential search terms from user input - Simple implementation: look for capitalized words, proper nouns, etc. + Extract potential search terms from user input. + Enhanced: Try LLM-based topic extraction first, fallback to heuristic. :param input: The user query :return: List of potential search terms """ @@ -139,29 +166,29 @@ def extract_search_terms(input): if input.lower().startswith(trap): input = input[len(trap):].strip() break - - # Simple heuristic: extract capitalized words and phrases + + # Try LLM-based extraction first + terms = llm_extract_topic(input) + if terms: + return terms + + # Fallback: Simple heuristic (existing code) words = input.split() search_terms = [] - - # Look for multi-word capitalized phrases temp_phrase = [] for word in words: - # Remove punctuation for checking clean_word = word.strip('.,!?;:') if clean_word and clean_word[0].isupper() and len(clean_word) > 2: temp_phrase.append(clean_word) elif temp_phrase: search_terms.append(' '.join(temp_phrase)) temp_phrase = [] - if temp_phrase: search_terms.append(' '.join(temp_phrase)) - - # If no capitalized terms found, use the whole query if not search_terms: search_terms = [input.strip()] - + if DEBUG_LLM: + logger.debug(f"Extracted search terms: {search_terms}") return search_terms[:3] # Limit to 3 terms def send_openwebui_query(prompt, model=None, max_tokens=450, context=''): @@ -175,33 +202,42 @@ def send_openwebui_query(prompt, model=None, max_tokens=450, context=''): """ if model is None: model = llmModel - + headers = { 'Authorization': f'Bearer {openWebUIAPIKey}', 'Content-Type': 'application/json' } - + messages = [] if context: messages.append({ "role": "system", "content": f"Use the following context to help answer questions:\n{context}" }) - + messages.append({ "role": "user", "content": prompt }) - + data = { "model": model, "messages": messages, "max_tokens": max_tokens, "stream": False } - + + # Debug logging + if DEBUG_LLM: + logger.debug(f"OpenWebUI payload: {json.dumps(data)}") + logger.debug(f"OpenWebUI headers: {headers}") + logger.debug(f"OpenWebUI endpoint: {openWebUIChatAPI}") + try: result = requests.post(openWebUIChatAPI, headers=headers, json=data, timeout=urlTimeoutSeconds * 4) + if DEBUG_LLM: + logger.debug(f"OpenWebUI response status: {result.status_code}") + logger.debug(f"OpenWebUI response text: {result.text}") if result.status_code == 200: result_json = result.json() # OpenWebUI returns OpenAI-compatible format @@ -382,10 +418,14 @@ def llm_query(input, nodeID=0, location_name=None, init=False): response = result.strip().replace('\n', ' ') if rawLLMQuery and requestTruncation and len(response) > 450: - #retryy loop to truncate the response + # retry loop to truncate the response logger.warning(f"System: LLM Query: Response exceeded {tokens} characters, requesting truncation") - truncateQuery = {"model": llmModel, "prompt": truncatePrompt + response, "stream": False, "max_tokens": tokens} - truncateResult = send_ollama_query(truncateQuery) + truncate_prompt_full = truncatePrompt + response + if useOpenWebUI and openWebUIAPIKey: + truncateResult = send_openwebui_query(truncate_prompt_full, max_tokens=tokens) + else: + truncateQuery = {"model": llmModel, "prompt": truncate_prompt_full, "stream": False, "max_tokens": tokens} + truncateResult = send_ollama_query(truncateQuery) # cleanup for message output response = truncateResult.strip().replace('\n', ' ') From 12692142647afc32707462165e5fc1aabee0947b Mon Sep 17 00:00:00 2001 From: SpudGunMan Date: Mon, 27 Oct 2025 20:15:15 -0700 Subject: [PATCH 10/14] Update llm.py --- modules/llm.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/modules/llm.py b/modules/llm.py index 00d60b4..5fd6891 100644 --- a/modules/llm.py +++ b/modules/llm.py @@ -4,7 +4,7 @@ # K7MHI Kelly Keeton 2024 from modules.log import logger from modules.settings import (llmModel, ollamaHostName, rawLLMQuery, - llmUseWikiContext, useOpenWebUI, openWebUIURL, openWebUIAPIKey, cmdBang, urlTimeoutSeconds) + llmUseWikiContext, useOpenWebUI, openWebUIURL, openWebUIAPIKey, cmdBang, urlTimeoutSeconds, use_kiwix_server) # Ollama Client # https://github.com/ollama/ollama/blob/main/docs/faq.md#how-do-i-configure-ollama-server @@ -112,14 +112,18 @@ def get_wiki_context(input): :return: Wikipedia summary or empty string if not available """ try: - from modules.wiki import get_wikipedia_summary + from modules.wiki import get_wikipedia_summary, get_kiwix_summary # Extract potential search terms from the input # Try to identify key topics/entities for Wikipedia search search_terms = extract_search_terms(input) wiki_context = [] for term in search_terms[:2]: # Limit to 2 searches to avoid excessive API calls - summary = get_wikipedia_summary(term) + if use_kiwix_server: + summary = get_kiwix_summary(term, truncate=False) + else: + summary = get_wikipedia_summary(term, truncate=False) + if summary and "error" not in summary.lower(): wiki_context.append(f"Wikipedia context for '{term}': {summary}") @@ -234,7 +238,7 @@ def send_openwebui_query(prompt, model=None, max_tokens=450, context=''): logger.debug(f"OpenWebUI endpoint: {openWebUIChatAPI}") try: - result = requests.post(openWebUIChatAPI, headers=headers, json=data, timeout=urlTimeoutSeconds * 4) + result = requests.post(openWebUIChatAPI, headers=headers, json=data, timeout=urlTimeoutSeconds * 5) if DEBUG_LLM: logger.debug(f"OpenWebUI response status: {result.status_code}") logger.debug(f"OpenWebUI response text: {result.text}") @@ -257,7 +261,7 @@ def send_openwebui_query(prompt, model=None, max_tokens=450, context=''): def send_ollama_query(llmQuery): # Send the query to the Ollama API and return the response try: - result = requests.post(ollamaAPI, data=json.dumps(llmQuery), timeout= urlTimeoutSeconds * 4) + result = requests.post(ollamaAPI, data=json.dumps(llmQuery), timeout= urlTimeoutSeconds * 5) if result.status_code == 200: result_json = result.json() result = result_json.get("response", "") @@ -338,7 +342,10 @@ def llm_query(input, nodeID=0, location_name=None, init=False): search_terms = extract_search_terms(input) wiki_context_list = [] for term in search_terms[:2]: - summary = get_wiki_context(term) + if not use_kiwix_server: + summary = get_wiki_context(term) + else: + summary = get_wiki_context(term) if summary and "error" not in summary.lower(): wiki_context_list.append(f"Wikipedia context for '{term}': {summary}") wikiContext = '\n'.join(wiki_context_list) if wiki_context_list else '' From 128ac456eb2385319944e464b189918c2d26e1e7 Mon Sep 17 00:00:00 2001 From: SpudGunMan Date: Mon, 27 Oct 2025 20:15:22 -0700 Subject: [PATCH 11/14] Update wiki.py --- modules/wiki.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/modules/wiki.py b/modules/wiki.py index 37e2522..20fc1d9 100644 --- a/modules/wiki.py +++ b/modules/wiki.py @@ -23,7 +23,7 @@ def text_from_html(body): visible_texts = filter(tag_visible, texts) return " ".join(t.strip() for t in visible_texts if t.strip()) -def get_kiwix_summary(search_term): +def get_kiwix_summary(search_term, truncate=True): """Query local Kiwix server for Wikipedia article""" if search_term is None or search_term.strip() == "": return ERROR_FETCHING_DATA @@ -45,7 +45,10 @@ def get_kiwix_summary(search_term): summary = '. '.join(sentences[:wiki_return_limit]) if summary and not summary.endswith('.'): summary += '.' - return summary.strip()[:500] # Hard limit at 500 chars + if truncate: + return summary.strip()[:500] # Hard limit at 500 chars + else: + return summary.strip() # If direct access fails, try search logger.debug(f"System: Kiwix direct article not found for:{search_term} Status Code:{response.status_code}") @@ -71,7 +74,10 @@ def get_kiwix_summary(search_term): summary = '. '.join(sentences[:wiki_return_limit]) if summary and not summary.endswith('.'): summary += '.' - return summary.strip()[:500] + if truncate: + return summary.strip()[:500] + else: + return summary.strip() logger.warning(f"System: No Kiwix Results for:{search_term}") # try to fall back to online Wikipedia if available @@ -87,7 +93,7 @@ def get_kiwix_summary(search_term): logger.warning(f"System: Error with Kiwix for:{search_term} {e}") return ERROR_FETCHING_DATA -def get_wikipedia_summary(search_term, location=None, force=False): +def get_wikipedia_summary(search_term, location=None, force=False, truncate=True): if use_kiwix_server and not force: return get_kiwix_summary(search_term) @@ -120,7 +126,11 @@ def get_wikipedia_summary(search_term, location=None, force=False): summary = '. '.join(sentences[:wiki_return_limit]) if summary and not summary.endswith('.'): summary += '.' - return summary.strip()[:500] + if truncate: + # Truncate to 500 characters + return summary.strip()[:500] + else: + return summary.strip() except Exception as e: logger.warning(f"System: Wikipedia API error for:{search_term} {e}") return ERROR_FETCHING_DATA From b9eaf7deb0072b92a26e45d1e888159f7fc1b198 Mon Sep 17 00:00:00 2001 From: SpudGunMan Date: Mon, 27 Oct 2025 20:32:09 -0700 Subject: [PATCH 12/14] Update wiki.py --- modules/wiki.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/modules/wiki.py b/modules/wiki.py index 20fc1d9..b40b1f6 100644 --- a/modules/wiki.py +++ b/modules/wiki.py @@ -49,15 +49,19 @@ def get_kiwix_summary(search_term, truncate=True): return summary.strip()[:500] # Hard limit at 500 chars else: return summary.strip() + else: + logger.debug(f"System: Kiwix Library:{kiwix_library_name} failed for:{search_term} with status code {response.status_code}") # If direct access fails, try search - logger.debug(f"System: Kiwix direct article not found for:{search_term} Status Code:{response.status_code}") search_url = f"{kiwix_url}/search?content={kiwix_library_name}&pattern={search_encoded}" response = requests.get(search_url, timeout=urlTimeoutSeconds) if response.status_code == 200 and "No results were found" not in response.text: soup = bs.BeautifulSoup(response.text, 'html.parser') links = [a['href'] for a in soup.find_all('a', href=True) if "start=" not in a['href']] + else: + links = [] + logger.debug(f"System: Kiwix Search failed for:{search_term} with status code {response.status_code}") for link in links[:3]: # Check first 3 results article_name = link.split("/")[-1] From 908e84e15549826a45c47333c2a1c4db7befe608 Mon Sep 17 00:00:00 2001 From: SpudGunMan Date: Mon, 27 Oct 2025 20:32:14 -0700 Subject: [PATCH 13/14] Update README.md --- modules/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/README.md b/modules/README.md index b103871..43b0406 100644 --- a/modules/README.md +++ b/modules/README.md @@ -808,7 +808,7 @@ To set up a local Kiwix server: 1. Install Kiwix tools: https://kiwix.org/en/ `sudo apt install kiwix-tools -y` 2. Download a Wikipedia ZIM file to `data/`: https://library.kiwix.org/ `wget https://download.kiwix.org/zim/wikipedia/wikipedia_en_100_nopic_2025-09.zim` 3. Run the server: `kiwix-serve --port 8080 wikipedia_en_100_nopic_2025-09.zim` -4. Set `useKiwixServer = True` in your config.ini +4. Set `useKiwixServer = True` in your config.ini with `wikipedia = True` The bot will automatically extract and truncate content to fit Meshtastic's message size limits (~500 characters). From cb51cf921b1bbdb3d0acb494786b301896dd176b Mon Sep 17 00:00:00 2001 From: SpudGunMan Date: Mon, 27 Oct 2025 20:43:22 -0700 Subject: [PATCH 14/14] Update llm.py --- modules/llm.py | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/llm.py b/modules/llm.py index 5fd6891..eb90bae 100644 --- a/modules/llm.py +++ b/modules/llm.py @@ -234,7 +234,6 @@ def send_openwebui_query(prompt, model=None, max_tokens=450, context=''): # Debug logging if DEBUG_LLM: logger.debug(f"OpenWebUI payload: {json.dumps(data)}") - logger.debug(f"OpenWebUI headers: {headers}") logger.debug(f"OpenWebUI endpoint: {openWebUIChatAPI}") try: