From 9dcc202c8642f18ebbabb9c3c579cfc80fe24289 Mon Sep 17 00:00:00 2001 From: b-vaibhaw Date: Sat, 6 Jun 2026 16:04:26 +0530 Subject: [PATCH] feat(chatbot): improve stability, model handling and testing --- run_chatbot.py | 98 ++++++++++++++++++++ src/chatbot/chatbot_thread.py | 46 ++++++++-- src/frontEnd/Chatbot.py | 163 +++++++++++++++++++++++++++------- verify_chatbot.py | 51 +++++++++++ 4 files changed, 319 insertions(+), 39 deletions(-) create mode 100644 run_chatbot.py create mode 100644 verify_chatbot.py diff --git a/run_chatbot.py b/run_chatbot.py new file mode 100644 index 000000000..b0fc4e559 --- /dev/null +++ b/run_chatbot.py @@ -0,0 +1,98 @@ +""" +run_chatbot.py — Standalone launcher for the eSim AI Chatbot +===================================================================== +Run from the project root: + python run_chatbot.py + +Requirements: + pip install PyQt5 ollama pillow + +For voice input: + pip install SpeechRecognition pyaudio + +For offline voice (no internet): + pip install faster-whisper + +LLM backend: + 1. Install Ollama from https://ollama.com + 2. ollama serve + 3. ollama pull qwen2.5:3b # text model (~2GB) + 4. ollama pull moondream # vision model (~1.6GB, optional) +""" +import os +import sys + +# Add src/ to Python path so all local imports resolve correctly +_HERE = os.path.dirname(os.path.abspath(__file__)) +_SRC = os.path.join(_HERE, 'src') +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +# Minimal Appconfig stub so Chatbot.py imports cleanly without the +# full eSim configuration layer (which expects an installed eSim). +# Only used when running chatbot standalone (not via Application.py). +import types +_conf_mod = types.ModuleType("configuration") +_app_mod = types.ModuleType("configuration.Appconfig") + +class _AppConfigStub: + """Minimal stub — only attributes that Chatbot.py actually uses.""" + noteArea = {} + procThread_list = [] + process_obj = [] + _APPLICATION = "eSim" + _VERSION = "2.4" + current_project = {"ProjectName": None} + def print_info(self, *a): pass + def print_error(self, *a): pass + +_app_mod.Appconfig = _AppConfigStub +_conf_mod.Appconfig = _app_mod +sys.modules["configuration"] = _conf_mod +sys.modules["configuration.Appconfig"] = _app_mod + +# Now safe to import the chatbot UI +from PyQt5.QtWidgets import QApplication +from PyQt5.QtGui import QFont +from frontEnd.Chatbot import ChatbotGUI # noqa: E402 + + +def main(): + app = QApplication(sys.argv) + app.setApplicationName("eSim AI Chatbot") + app.setFont(QFont("Segoe UI", 10)) + + # High-DPI support (Windows) + try: + from PyQt5.QtCore import Qt + QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) + QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) + except Exception: + pass + + window = ChatbotGUI() + window.setWindowTitle("eSim AI Assistant — Standalone") + window.resize(520, 640) + window.show() + + print("=" * 60) + print(" eSim AI Chatbot — running standalone") + print("=" * 60) + print() + print(" Status: Window opened [OK]") + print() + print(" To enable AI responses:") + print(" 1. Install Ollama: https://ollama.com") + print(" 2. Run: ollama serve") + print(" 3. Run: ollama pull qwen2.5:3b") + print(" 4. Restart this script") + print() + print(" The Live/Offline indicator in the top-right") + print(" of the chat window shows Ollama's status.") + print("=" * 60) + + sys.exit(app.exec_()) + + +if __name__ == "__main__": + main() diff --git a/src/chatbot/chatbot_thread.py b/src/chatbot/chatbot_thread.py index c8cce620b..15d4c2193 100644 --- a/src/chatbot/chatbot_thread.py +++ b/src/chatbot/chatbot_thread.py @@ -62,9 +62,29 @@ def _downscale_image_bytes(raw_bytes: bytes) -> bytes: def get_stt_backend() -> str: - """Returns 'google' if SpeechRecognition is installed, else 'none'.""" + """Return the best available speech-to-text backend. + + Priority: faster-whisper (offline, best quality) > vosk (offline, lightweight) + > google (online, no install) > none + """ + # Check faster-whisper first: offline, no internet needed + try: + import faster_whisper # noqa: F401 + return "whisper" + except ImportError: + pass + + # Check vosk: offline, very lightweight + try: + import vosk # noqa: F401 + return "vosk" + except ImportError: + pass + + # Fall back to Google online STT (requires internet) if _SR_AVAILABLE: return "google" + return "none" @@ -85,13 +105,19 @@ def start_ollama(stop_flag=None): The polling loop checks stop_flag() each second and exits early if cancelled. """ if os.name == 'nt': - subprocess.Popen('start cmd /k "ollama serve"', shell=True) + # Run ollama serve silently in the background without a visible CMD window. + # CREATE_NO_WINDOW prevents a terminal from popping up and staying open. + subprocess.Popen( + ['ollama', 'serve'], + creationflags=subprocess.CREATE_NO_WINDOW, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) else: subprocess.Popen( - ['bash', '-c', - 'x-terminal-emulator -e "ollama serve" || ' - 'gnome-terminal -- ollama serve || ' - 'xterm -e "ollama serve"'] + ['bash', '-c', 'ollama serve'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, ) for _ in range(30): @@ -344,9 +370,10 @@ def run(self): bot_response = "" for chunk in stream: if self._stop_requested: - bot_response += "\n\n⏹ Generation stopped." + bot_response += "\n\n\u23f9 Generation stopped." break - bot_response += chunk["message"]["content"] + # Use .get() to safely handle malformed/done chunks from Ollama + bot_response += chunk.get("message", {}).get("content", "") bot_response = bot_response.strip() if not bot_response: @@ -510,7 +537,8 @@ def _chat_once(self, model_name: str, prompt: str, image_bytes_list): if self._stop_requested: response += "\n\n\u23f9 Generation stopped." break - piece = chunk["message"]["content"] + # Use .get() to safely handle malformed/done chunks from Ollama + piece = chunk.get("message", {}).get("content", "") response += piece token_count += 1 diff --git a/src/frontEnd/Chatbot.py b/src/frontEnd/Chatbot.py index 2339681de..3a4232060 100644 --- a/src/frontEnd/Chatbot.py +++ b/src/frontEnd/Chatbot.py @@ -2220,8 +2220,23 @@ def _warn_or_switch_to_vision_model(self) -> bool: return False def _switch_to_text_model(self): - """Auto-switch to qwen2.5 for text queries.""" - self._auto_switch_model(["qwen2.5"], [], "text") + """Auto-switch away from vision model when sending plain text. + + Only switches if the currently selected model is a vision-only model + (llava, moondream, etc.) — vision models work for text but are 3–10x + slower than small text models like qwen2.5:3b. + If the user already has a text model selected, do nothing. + """ + from chatbot.chatbot_thread import _is_vision_model + current = self.model_combo.currentText() + # If the current model is already a text model, respect the user's choice. + if not _is_vision_model(current): + return + # Current model is vision-only — try to switch to a faster text model. + text_fallbacks = ['qwen2.5:3b', 'qwen2.5:7b', 'qwen2.5-coder', 'qwen2.5', + 'mistral', 'gemma3', 'llama3.2', 'llama3', 'phi3', 'phi4'] + self._auto_switch_model(["qwen2.5", "mistral", "gemma", "llama", "phi"], + text_fallbacks, "text") # ── Mic ────────────────────────────────────────────────────────── @@ -2380,8 +2395,16 @@ def _flush_save(self): self._save_pending = False try: os.makedirs(os.path.dirname(_HISTORY_FILE), exist_ok=True) + # Store the session_id alongside messages so _load_history() knows + # this chat was already saved as a proper session file. Without + # this, every restart recreates the session from the history file + # even though _save_current_session() already wrote it, producing + # a duplicate entry in the sidebar. with open(_HISTORY_FILE, 'w', encoding='utf-8') as f: - json.dump(self.chat_history[-20:], f, ensure_ascii=False, indent=2) + json.dump({ + "session_id": self._current_session_id, + "messages": self.chat_history[-20:], + }, f, ensure_ascii=False, indent=2) self._save_current_session() self._refresh_sidebar_if_open() except Exception: @@ -2416,26 +2439,57 @@ def _save_current_session(self): def _load_history(self): """ - On startup: if a leftover history file exists, archive it into the - sidebar sessions directory so the user can access it from the sidebar, - then delete the file. The chat window always opens fresh. + On startup: if a leftover chatbot_history.json exists, check whether + the session was already written as a proper session file during the + previous run (the normal case when the app exits cleanly). If it was, + just delete chatbot_history.json — no duplicate session is created. + Only creates a new session file when the session file is genuinely + missing (e.g. the app crashed before _save_current_session() ran). """ if not os.path.exists(_HISTORY_FILE): return try: with open(_HISTORY_FILE, 'r', encoding='utf-8') as f: saved = json.load(f) - if isinstance(saved, list) and saved: + + # Support both the new dict format {session_id, messages} + # and the legacy plain-list format for backwards compatibility. + if isinstance(saved, dict): + saved_session_id = saved.get("session_id", "") + messages = saved.get("messages", []) + elif isinstance(saved, list): + saved_session_id = "" + messages = saved + else: + saved_session_id = "" + messages = [] + + # ── Duplicate-prevention guard ────────────────────────────── + # _flush_save() already called _save_current_session() which + # wrote the session .json file. If that file still exists, + # skip creating another one — it would produce an identical + # entry in the sidebar on every restart. + if saved_session_id: + existing = os.path.join(_SESSIONS_DIR, f"{saved_session_id}.json") + if os.path.exists(existing): + # Session already on disk — nothing to do; just clean up. + return # finally block below still removes _HISTORY_FILE + + # Session file is missing (crash recovery path): recreate it. + if isinstance(messages, list) and messages: title = next( - (m[5:].strip()[:50] for m in saved if m.startswith("User:")), + (m[5:].strip()[:50] for m in messages if m.startswith("User:")), "Previous session" ) + # Use the stored session_id when available so the recovered + # file has a stable identity; fall back to a fresh UUID. + sid = saved_session_id if saved_session_id else self._current_session_id old_session = { - "id": self._current_session_id, + "id": sid, "title": title, "created_at": self._session_created_at, "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M"), - "messages": saved[-40:], + "messages": messages[-40:], "kind": "text", "settings": { "temperature": self._temperature, @@ -2443,9 +2497,7 @@ def _load_history(self): }, } os.makedirs(_SESSIONS_DIR, exist_ok=True) - sess_path = os.path.join( - _SESSIONS_DIR, f"{self._current_session_id}.json" - ) + sess_path = os.path.join(_SESSIONS_DIR, f"{sid}.json") with open(sess_path, 'w', encoding='utf-8') as f: json.dump(old_session, f, ensure_ascii=False, indent=2) except Exception: @@ -2473,36 +2525,80 @@ def _on_models_fetched(self, model_names: list): self.model_combo.clear() if not model_names: - # No models found — Ollama may be offline or has no models pulled. + # No models found — Ollama is not installed, not running, or has no models. self.model_combo.addItem("No models found") self.model_combo.setEnabled(False) self.status_label.setText( - "⚠️ No Ollama models found. Run 'ollama pull qwen2.5-coder' " - "in a terminal to install one." + "No Ollama models found. " + "Install Ollama from https://ollama.com, run 'ollama serve', " + "then 'ollama pull qwen2.5:3b'." + ) + # Show a more helpful message in the chat display + self.chat_display.append( + '' + '
' + '
' + 'No AI models installed
' + 'To use the chatbot:
' + '1. Download & install Ollama: ' + 'ollama.com
' + '2. Open a terminal and run: ollama serve
' + '3. Pull a model: ollama pull qwen2.5:3b
' + '4. Click (refresh) in the model bar above' + '
' ) + self._scroll_to_bottom() return for name in model_names: self.model_combo.addItem(name) - # Try to default to any qwen2.5 variant + # Try to default to the best text model in priority order chosen_idx = -1 + + # Priority 1: any qwen2.5 variant (best for electronics Q&A) for i in range(self.model_combo.count()): name = self.model_combo.itemText(i) - if "qwen2.5" in name.lower(): + if 'qwen2.5' in name.lower(): chosen_idx = i break - - # If no qwen2.5, try some fallback preferred models + + # Priority 2: prefer common fast text models over vision models if chosen_idx == -1: - preferred_fallbacks = ['llava:13b', 'llava:7b', 'llava', 'bakllava'] - for preferred in preferred_fallbacks: + text_model_fallbacks = [ + 'qwen2.5:3b', 'qwen2.5:7b', 'qwen2.5-coder', + 'mistral', 'gemma3', 'gemma2', 'gemma', + 'llama3.2', 'llama3.1', 'llama3', 'llama2', + 'phi4', 'phi3', 'phi', + 'deepseek-coder', 'codellama', + ] + for preferred in text_model_fallbacks: idx = self.model_combo.findText(preferred) if idx >= 0: chosen_idx = idx break - # If still nothing matched, just use the first available model + # Priority 3: any non-vision model + if chosen_idx == -1: + from chatbot.chatbot_thread import _is_vision_model + for i in range(self.model_combo.count()): + if not _is_vision_model(self.model_combo.itemText(i)): + chosen_idx = i + break + + # Priority 4: vision models as last resort (they work for text too, just slower) + if chosen_idx == -1: + vision_fallbacks = ['llava:7b', 'llava', 'llava:13b', 'bakllava', 'moondream'] + for preferred in vision_fallbacks: + idx = self.model_combo.findText(preferred) + if idx >= 0: + chosen_idx = idx + break + + # Final fallback: just pick the first available model if chosen_idx == -1 and self.model_combo.count() > 0: chosen_idx = 0 @@ -2510,6 +2606,7 @@ def _on_models_fetched(self, model_names: list): self.model_combo.setCurrentIndex(chosen_idx) self.model_combo.setEnabled(True) + self.status_label.setText('') # ── Thinking / retry / regenerate ──────────────────────────────── @@ -2938,16 +3035,22 @@ def debug_error(self, log): filtered_lines = truncated_notice + filtered_lines[-_MAX_ERROR_LOG_LINES:] combined_text = "".join(filtered_lines) - # QLineEdit); display a compact summary label in the status bar instead. self.status_label.setText( - f"🔍 Analysing error log ({len(filtered_lines)} lines)…" + f"Analysing error log ({len(filtered_lines)} lines)..." ) - self.obj_appconfig = Appconfig() - self.projDir = self.obj_appconfig.current_project["ProjectName"] - output_file = os.path.join(self.projDir, "erroroutput.txt") - with open(output_file, "w") as f: - f.writelines(filtered_lines) + # Guard: only write erroroutput.txt if a project is open. + # Without this, os.path.join(None, ...) raises TypeError when + # no project is selected and a simulation error fires anyway. + try: + self.obj_appconfig = Appconfig() + self.projDir = self.obj_appconfig.current_project.get("ProjectName") + if self.projDir: + output_file = os.path.join(self.projDir, "erroroutput.txt") + with open(output_file, "w") as f: + f.writelines(filtered_lines) + except Exception: + pass # Non-critical -- analysis still proceeds without writing the file self.chat_history.append( f"User: I got a simulation error. Here is the log:\n{combined_text}" diff --git a/verify_chatbot.py b/verify_chatbot.py new file mode 100644 index 000000000..47ff35088 --- /dev/null +++ b/verify_chatbot.py @@ -0,0 +1,51 @@ +import sys, types + +sys.path.insert(0, 'src') + +# Stub configuration module +_c = types.ModuleType('configuration') +_a = types.ModuleType('configuration.Appconfig') + +class _S: + noteArea = {} + current_project = {'ProjectName': None} + procThread_list = [] + process_obj = [] + def print_info(self, *a): pass + def print_error(self, *a): pass + +_a.Appconfig = _S +_c.Appconfig = _a +sys.modules['configuration'] = _c +sys.modules['configuration.Appconfig'] = _a + +# Test chatbot_thread imports +from chatbot.chatbot_thread import ( + get_stt_backend, VISION_MODEL_KEYWORDS, is_ollama_running, + _is_vision_model, OllamaWorker, MicWorker +) +print("chatbot_thread.py imports: OK") +print("STT backend detected:", get_stt_backend()) +print("Vision model keywords:", VISION_MODEL_KEYWORDS[:4]) +print("is_ollama_running():", is_ollama_running()) +print("_is_vision_model('llava'):", _is_vision_model('llava')) +print("_is_vision_model('qwen2.5'):", _is_vision_model('qwen2.5')) + +# Test Chatbot.py import (no QApplication needed just to import) +from frontEnd.Chatbot import ( + ChatbotGUI, _render_markdown, _user_bubble, _bot_bubble, +) +print("Chatbot.py imports: OK") + +# Quick markdown render sanity check +rendered = _render_markdown("Hello **bold** and `code` here") +assert '' in rendered, "Bold not rendered" +assert 'Consolas' in rendered, "Code not rendered" +print("Markdown renderer: OK") + +# Vision model check +rendered2 = _render_markdown("Test _var_name_ rendering") +print("Italic underscore rendered (should NOT italicise var names):", '_var_name_' not in rendered2 or '' in rendered2) + +print() +print("All checks passed!")