Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 25 additions & 15 deletions src/chatbot/chatbot_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ def classify_question_type(user_input: str, has_image_context: bool,
Returns: 'greeting', 'simple', 'esim', 'image_query', 'follow_up_image',
'follow_up', 'netlist'
"""
global LAST_IMAGE_CONTEXT
user_lower = user_input.lower()

if "[ESIM_NETLIST_START]" in user_input:
Expand All @@ -369,6 +370,19 @@ def classify_question_type(user_input: str, has_image_context: bool,
if _is_image_query(user_input):
return "image_query"

greetings = ["hello", "hi", "hey", "howdy", "greetings"]
user_words = user_lower.strip().split()
if len(user_words) <= 3 and any(g in user_words for g in greetings):
return "greeting"

# Topic switch check before checking follow-up phrases
if is_semantic_topic_switch(user_input, history):
print("[COPILOT] Topic switch detected (semantic)")
if history is not None:
history.clear()
LAST_IMAGE_CONTEXT = {}
has_image_context = False

if has_image_context:
follow_phrases = [
"this circuit", "that circuit", "in this schematic",
Expand All @@ -378,19 +392,13 @@ def classify_question_type(user_input: str, has_image_context: bool,
if any(p in user_lower for p in follow_phrases):
return "follow_up_image"

greetings = ["hello", "hi", "hey", "howdy", "greetings"]
user_words = user_lower.strip().split()
if len(user_words) <= 3 and any(g in user_words for g in greetings):
return "greeting"

is_followup = _is_follow_up_question(user_input, history)
if is_semantic_topic_switch(user_input, history):
print("[COPILOT] Topic switch detected (semantic)")
is_followup = False

if not is_followup:
history.clear()
LAST_IMAGE_CONTEXT = None
if history is not None:
history.clear()
LAST_IMAGE_CONTEXT = {}
else:
return "follow_up"

esim_keywords = [
"esim", "kicad", "ngspice", "spice", "simulation", "netlist",
Expand Down Expand Up @@ -638,10 +646,9 @@ def handle_input(user_input: str,
return "Please enter a query."

if "[ESIM_NETLIST_START]" in user_input:
raw_reply = run_ollama(user_input)
cleaned = clean_response_raw(raw_reply)
LAST_BOT_REPLY = cleaned
return cleaned
response = handle_netlist_analysis(user_input)
LAST_BOT_REPLY = response
return response

question_type = classify_question_type(
user_input, bool(LAST_IMAGE_CONTEXT), history
Expand All @@ -661,6 +668,9 @@ def handle_input(user_input: str,
elif question_type == "follow_up_image":
response = handle_follow_up_image_question(user_input, LAST_IMAGE_CONTEXT)

elif question_type == "esim":
response = handle_esim_question(user_input, LAST_IMAGE_CONTEXT, history)

elif question_type == "simple":
response = handle_simple_question(user_input)

Expand Down
83 changes: 74 additions & 9 deletions src/chatbot/chatbot_thread.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,15 +162,41 @@ def is_ollama_running():


def start_ollama(stop_flag=None):
"""Start Ollama server silently in the background (no terminal window)."""
cmd = ["ollama", "serve"]
if os.name == 'nt':
subprocess.Popen('start cmd /k "ollama serve"', shell=True)
else:
subprocess.Popen(
['bash', '-c',
'x-terminal-emulator -e "ollama serve" || '
'gnome-terminal -- ollama serve || '
'xterm -e "ollama serve"']
)
import shutil
# If 'ollama' is not directly callable from PATH, check default install locations
if not shutil.which("ollama"):
local_appdata = os.environ.get("LOCALAPPDATA", "")
possible_paths = [
os.path.join(local_appdata, "Programs", "Ollama", "ollama.exe"),
r"C:\Program Files\Ollama\ollama.exe",
r"C:\Program Files (x86)\Ollama\ollama.exe",
]
for path in possible_paths:
if os.path.exists(path):
cmd = [path, "serve"]
break
try:
if os.name == 'nt':
# Windows: CREATE_NO_WINDOW flag prevents a cmd popup
subprocess.Popen(
cmd,
creationflags=subprocess.CREATE_NO_WINDOW,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
else:
# Linux/macOS: redirect output to /dev/null, no terminal emulator needed
subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except FileNotFoundError:
# ollama binary not found in PATH or disk
return False
for _ in range(30):
if stop_flag is not None and stop_flag():
return False
Expand All @@ -179,7 +205,6 @@ def start_ollama(stop_flag=None):
return True
return False


# ── Topic switch detection ───────────────────────────────────────────────────

_STOP_WORDS = {
Expand Down Expand Up @@ -264,6 +289,46 @@ def run(self):
self.result_signal.emit([])


# ── Model Pull Worker ─────────────────────────────────────────────────────────
# Required models for the chatbot to function correctly
REQUIRED_MODELS = ["qwen2.5-coder:3b", "nomic-embed-text"]
VISION_MODEL = "minicpm-v" # optional — only needed for image analysis

class ModelPullWorker(QThread):
"""
Downloads a single Ollama model in the background.
Emits:
progress_signal(str) — human-readable status/percentage string
done_signal(bool) — True on success, False on failure
"""
progress_signal = pyqtSignal(str)
done_signal = pyqtSignal(bool)

def __init__(self, model_name: str):
super().__init__()
self.model_name = model_name

def run(self):
try:
self.progress_signal.emit(f"⬇️ Downloading {self.model_name}… 0%")
for update in ollama.pull(self.model_name, stream=True):
# update is a dict with keys: status, completed, total
status = update.get("status", "")
completed = update.get("completed", 0)
total = update.get("total", 0)
if total and completed:
pct = int((completed / total) * 100)
self.progress_signal.emit(
f"⬇️ Downloading {self.model_name}… {pct}%"
)
elif status:
self.progress_signal.emit(
f"⬇️ {self.model_name}: {status}"
)
self.done_signal.emit(True)
except Exception as e:
self.progress_signal.emit(f"❌ Failed to download {self.model_name}: {e}")
self.done_signal.emit(False)
# ── Smart token budget ───────────────────────────────────────────────────────

_COMPLEX_KEYWORDS = {
Expand Down
28 changes: 25 additions & 3 deletions src/chatbot/image_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from typing import Dict, Any
from PIL import Image
MAX_IMAGE_BYTES = int(0.5*1024 * 1024)
MAX_IMAGE_PIXELS = 10000000
Image.MAX_IMAGE_PIXELS = MAX_IMAGE_PIXELS
from .ollama_runner import run_ollama_vision

# === IMPORT PADDLE OCR ===
Expand Down Expand Up @@ -45,6 +47,10 @@ def optimize_image_for_vision(image_path: str) -> bytes:
try:
img = Image.open(image_path)

# Validate dimensions to prevent decompression bombs / excessive resource usage
if img.width * img.height > MAX_IMAGE_PIXELS:
raise ValueError(f"Decompression bomb detected: Image pixels ({img.width * img.height}) exceed maximum allowed ({MAX_IMAGE_PIXELS}).")

if img.mode not in ('RGB', 'L'):
img = img.convert('RGB')

Expand All @@ -63,6 +69,8 @@ def optimize_image_for_vision(image_path: str) -> bytes:
img.save(buffer, format='PNG', optimize=True, quality=85)
return buffer.getvalue()

except (Image.DecompressionBombError, ValueError) as e:
raise ValueError(f"Image validation failed: {str(e)}")
except Exception as e:
print(f"[IMAGE] Optimization failed: {e}, using original")
with open(image_path, 'rb') as f:
Expand Down Expand Up @@ -141,9 +149,23 @@ def analyze_and_extract(image_path: str) -> Dict[str, Any]:
"values": {}
}

# === OPTIMIZE IMAGE BEFORE SENDING ===
print(f"[VISION] Processing image: {os.path.basename(image_path)}")
image_bytes = optimize_image_for_vision(image_path)
try:
# === OPTIMIZE IMAGE BEFORE SENDING ===
print(f"[VISION] Processing image: {os.path.basename(image_path)}")
image_bytes = optimize_image_for_vision(image_path)
except ValueError as e:
return {
"error": str(e),
"vision_summary": "",
"component_counts": {},
"circuit_analysis": {
"circuit_type": "Unknown",
"design_errors": [str(e)],
"design_warnings": []
},
"components": [],
"values": {}
}

# === EXTRACT OCR TEXT (CRITICAL STEP) ===
ocr_text = extract_text_with_paddle(image_path)
Expand Down
80 changes: 45 additions & 35 deletions src/chatbot/knowledge_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,21 @@ def _get_collection():
client = chromadb.PersistentClient(path=db_path)
return client.get_or_create_collection(name="esim_manuals")

# ==================== INGESTION ====================
def read_paragraphs(file_path: str):
"""Lazily read a file paragraph-by-paragraph to avoid high memory usage."""
with open(file_path, "r", encoding="utf-8") as f:
paragraph = []
for line in f:
if line.strip() == "":
if paragraph:
yield "".join(paragraph)
paragraph = []
else:
paragraph.append(line)
if paragraph:
yield "".join(paragraph)

# ==================== INGESTION ====================
def ingest_pdfs(manuals_directory: str) -> None:
"""
Expand All @@ -28,38 +43,23 @@ def ingest_pdfs(manuals_directory: str) -> None:
print("Directory not found.")
return

# Clear existing DB to ensure no duplicates from old files
print("Clearing old database...")
try:
client = chromadb.PersistentClient(path=db_path)
client.delete_collection("esim_manuals")
collection = client.get_or_create_collection(name="esim_manuals")
except Exception as e:
print(f"Warning clearing DB: {e}")
collection = _get_collection()

# Look for .txt files only
files = [f for f in os.listdir(manuals_directory) if f.lower().endswith(".txt")]

if not files:
print("❌ No .txt files found to ingest!")
return

all_documents, all_embeddings, all_metadatas, all_ids = [], [], [], []
chunk_counter = 0

for filename in files:
path = os.path.join(manuals_directory, filename)
print(f"\n📄 Processing Master File: {filename}")

try:
with open(path, "r", encoding="utf-8") as f:
text = f.read()

raw_sections = text.split("\n\n")

documents, embeddings, metadatas, ids = [], [], [], []

chunk_counter = 0
for section in raw_sections:
section = section.strip()
for paragraph in read_paragraphs(path):
section = paragraph.strip()
if len(section) < 50:
continue

Expand All @@ -69,26 +69,36 @@ def ingest_pdfs(manuals_directory: str) -> None:
for chunk in sub_chunks:
embed = get_embedding(chunk)
if embed:
documents.append(chunk)
embeddings.append(embed)
metadatas.append({"source": filename, "type": "master_ref"})
ids.append(f"{filename}_{chunk_counter}")
all_documents.append(chunk)
all_embeddings.append(embed)
all_metadatas.append({"source": filename, "type": "master_ref"})
all_ids.append(f"{filename}_{chunk_counter}")
chunk_counter += 1

if documents:
collection.add(
documents=documents,
embeddings=embeddings,
metadatas=metadatas,
ids=ids,
)
print(f" ✅ Indexed {len(documents)} chunks from {filename}")
else:
print(f" ⚠️ No valid chunks found in {filename}")

except Exception as e:
print(f" ❌ Failed to process {filename}: {e}")

if all_documents:
# Clear existing DB only after successfully generating all embeddings
print("Clearing old database...")
try:
client = chromadb.PersistentClient(path=db_path)
client.delete_collection("esim_manuals")
collection = client.get_or_create_collection(name="esim_manuals")
except Exception as e:
print(f"Warning clearing DB: {e}")
collection = _get_collection()

collection.add(
documents=all_documents,
embeddings=all_embeddings,
metadatas=all_metadatas,
ids=all_ids,
)
print(f" ✅ Successfully indexed {len(all_documents)} total chunks.")
else:
print(" ⚠️ No valid chunks found to index. Database was not cleared.")


# ==================== SEARCH ====================

Expand Down
Loading