diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..efdba8764 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto +*.sh text eol=lf diff --git a/.gitignore b/.gitignore index f321b0e7a..78c7f16d0 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ nghdl* tags build/ dist/ + +# Local deploy script (user-specific VM config) +scripts/deploy_to_vm.ps1 diff --git a/CHATBOT_ENHANCEMENT_PROPOSAL.md b/CHATBOT_ENHANCEMENT_PROPOSAL.md new file mode 100644 index 000000000..fb2640c50 --- /dev/null +++ b/CHATBOT_ENHANCEMENT_PROPOSAL.md @@ -0,0 +1,238 @@ +# eSim Copilot – Chatbot Enhancement Proposal + +**Focus:** Hariom's approach (PR 434) +**Branch:** Chatbot_Enhancements +**Date:** March 2025 + +--- + +# Part 1: Document Summary + +## 1.1 Four Source Documents + +| Document | Author | Focus | +|----------|--------|-------| +| **Project Context.pdf** | Synthesis | Rationale, problems, comparison of 3 interns, proposed Federated Knowledge Sync | +| **5 hariom.pdf** | Hariom Thakur | Full technical report: RAG, vision, FACT-based netlist, PyQt5 integration, automated error capture | +| **18 radhika goyal.pdf** | Radhika Goyal | Rule-based fault identification, static schematic/netlist analysis, cross-validation | +| **1 Nicholas_Coutinho.pdf** | Nicholas Coutinho | Conversational memory, topic discontinuity, context retention | + +## 1.2 Three Intern Approaches (from Project Context) + +| Aspect | Radhika | Nicholas | **Hariom** | +|--------|---------|----------|-------------| +| **Intelligence** | Proactive static analysis, rule-based | Conversational memory, topic discontinuity | **FACT-based netlist, strict RAG grounding** | +| **UI Integration** | Standard chat | Standard chat | **Deep PyQt5: dock, toolbar, context menus, auto error capture** | +| **Multimodal** | Text + image | Text + image | **Text + image + voice (Vosk)** | +| **Tech stack** | Broad | Broad | **Specific: qwen2.5:3b, minicpm-v, nomic-embed-text, ChromaDB** | + +**Project Context conclusion:** Hariom's approach is best for **practical deployment & user experience** due to deep integration, automated error capture, and voice STT. + +--- + +# Part 2: Current Chatbot Functionality (Hariom's Implementation) + +## 2.1 Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Presentation Layer (PyQt5) │ +│ • Application.py: toolbar, openChatbot(), errorDetectedSignal │ +│ • Chatbot.py: dock widget, chat UI, input, image/voice buttons │ +│ • ProjectExplorer.py: context menu "Analyze this Netlist" │ +│ • DockArea.py: createchatbotdock() │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ Processing Layer (chatbot_core.py) │ +│ • classify_question_type() → routing │ +│ • handle_esim_question(), handle_image_query(), handle_netlist_analysis()│ +│ • handle_follow_up(), handle_simple_question() │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ Data Layer │ +│ • knowledge_base.py: ChromaDB, search_knowledge() │ +│ • image_handler.py: PaddleOCR + MiniCPM-V │ +│ • Chatbot.py: FACT-based netlist detection (_detect_floating_nodes, etc) │ +│ • error_solutions.py: pattern → fixes mapping │ +│ • ollama_runner.py: run_ollama(), run_ollama_vision(), get_embedding() │ +│ • stt_handler.py: Vosk offline STT │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +## 2.2 Current Features (Implemented) + +| Feature | Status | Location | +|---------|--------|----------| +| **Intelligent Router** | ✅ | `chatbot_core.py` – classify_question_type() | +| **RAG (ChromaDB)** | ✅ | `knowledge_base.py` – search_knowledge() | +| **FACT-based netlist** | ✅ | `Chatbot.py` – _detect_floating_nodes, _detect_missing_models, etc. | +| **Vision (PaddleOCR + MiniCPM-V)** | ✅ | `image_handler.py` – analyze_and_extract() | +| **Voice (Vosk)** | ✅ (optional) | `stt_handler.py` | +| **Project Explorer context menu** | ✅ | `ProjectExplorer.py` – "Analyze this Netlist" | +| **Automated error capture** | ✅ | `Application.py` – errorDetectedSignal → send_error_to_chatbot | +| **Dockable chat** | ✅ | `Chatbot.py`, `DockArea.py` | +| **Error pattern matching** | ✅ | `error_solutions.py` – ERROR_SOLUTIONS dict | + +## 2.3 Current Gaps / Limitations (from Hariom's report) + +1. **Model capability constraints:** qwen2.5:3b struggles with complex multi-step reasoning; minicpm-v misinterprets complex topologies; 2048-token context limit. +2. **Performance on low-end hardware:** 8GB RAM minimum; vision can take 10+ seconds on older CPUs. +3. **Scope limitations:** No training mode; fixed knowledge base; cannot dynamically add new docs. +4. **PaddleOCR:** Often fails on first run (no `paddle` module) – vision falls back to partial OCR. +5. **Netlist contract:** Missing `manuals/esim_netlist_analysis_output_contract.txt` in some setups. + +--- + +# Part 3: Proposed Enhancements (Hariom’s Approach) + +## 3.1 Near-Term (Next 6 Months) – from Hariom's report + +### 3.1.1 One-Click Netlist Fix + +**Current:** Copilot suggests fixes; user must manually edit in Spice Editor. + +**Proposal:** Add "Apply fix" button that auto-inserts suggested fixes into `.cir.out`: + +- Add missing `.model` statements +- Add `.options gmin=1e-12 reltol=0.01` for singular matrix +- Add 1G resistors for floating nodes (as comments with copy-paste snippet) + +**Implementation:** Extend `Chatbot.py` – parse FACT output, generate patch, offer "Insert into netlist" action. + +--- + +### 3.1.2 Real-Time Suggestions in KiCad + +**Current:** User must open Copilot and ask. + +**Proposal:** Optional "live hints" during schematic capture – e.g. when floating pin detected, show small tooltip: "R1 pin 2 is unconnected." + +**Implementation:** Requires KiCad callback or periodic checks; may need eSim/KiCad integration points. + +--- + +### 3.1.3 Batch Processing for Multiple Images/Netlists + +**Current:** One image or netlist at a time. + +**Proposal:** Allow selecting multiple `.cir.out` files or pasting multiple images; run analysis in batch; report summary. + +**Implementation:** Extend `Chatbot.py` – `analyze_specific_netlist()` accepts list; `handle_image_query()` can process multiple paths. + +--- + +### 3.1.4 RAG Relevance Threshold + +**Current:** `knowledge_base.py` returns top 4 chunks; no explicit cosine similarity filter. + +**Proposal:** Add relevance threshold (e.g. cosine similarity > 0.3) – Hariom's report mentions this; filter out low-similarity chunks to reduce hallucination. + +**Implementation:** ChromaDB query returns `distances`; filter by threshold before context assembly. + +--- + +### 3.1.5 Stricter FACT-Based Prompting + +**Current:** Netlist analysis uses FACT blocks; contract file may be missing. + +**Proposal:** Ensure contract is always available; bundle `esim_netlist_analysis_output_contract.txt` in repo; add fallback inline prompt if file missing. + +**Implementation:** Copy contract to `src/manuals/` or `src/frontEnd/manuals/`; ensure `Chatbot.py` loads from correct path. + +--- + +### 3.1.6 Model Selection (Optional) + +**Current:** Hardcoded qwen2.5:3b, minicpm-v. + +**Proposal:** Allow user to choose model in settings (e.g. llama3, deepseek-coder for code-heavy tasks). + +**Implementation:** Add `ollama_model` config; pass to `run_ollama()`. + +--- + +## 3.2 Medium-Term (6–18 Months) + +### 3.2.1 Circuit Optimization Suggestions + +**Proposal:** After simulation succeeds, analyze output and suggest improvements (e.g. "Add capacitor for stability"). + +**Implementation:** Parse `.raw` or simulation output; integrate with plotting tools; add optional "optimization" analysis mode. + +--- + +### 3.2.2 Predictive Error Detection During Schematic Capture + +**Proposal:** (Radhika's strength) – cross-validate schematic vs netlist before simulation; detect mismatches early. + +**Implementation:** Hook into eSim's netlist generation; run static analysis on generated netlist before user runs simulation. + +--- + +### 3.2.3 Enhanced Conversation Memory (Nicholas's strength) + +**Proposal:** Improve topic discontinuity detection; add reference resolution for "this", "that", "it". + +**Implementation:** Refine `_is_follow_up_question()` and `is_semantic_topic_switch()`; use embedding similarity for pronoun resolution. + +--- + +### 3.2.4 Federated Knowledge Sync (Project Context proposal) + +**Proposal:** When user fixes an error after Copilot failed, prompt: "What did you change?" – store locally; optionally sync anonymously to FOSSEE server; server clusters fixes; push updates to ChromaDB. + +**Implementation:** Large; requires server, encryption, consent UI. Defer to long-term. + +--- + +## 3.3 Long-Term (18+ Months) + +- **Autonomous design assistance:** Circuit synthesis from specs. + +- **Research platform:** Dataset from anonymized interactions; benchmarking suite. + +- **Ecosystem expansion:** Port to OpenModelica, Scilab; plugin architecture. + +--- + +# Part 4: Prioritized Implementation Roadmap + +| Priority | Enhancement | Effort | Impact | +|----------|-------------|--------|--------| +| 1 | RAG relevance threshold | Low | High (reduces hallucination) | +| 2 | Netlist contract bundling | Low | Medium (fixes missing contract) | +| 3 | One-click netlist fix | Medium | High (UX) | +| 4 | Batch netlist analysis | Low | Medium | +| 5 | Model selection in settings | Low | Medium | +| 6 | Enhanced conversation memory | Medium | Medium | +| 7 | Batch image processing | Low | Low | +| 8 | Real-time KiCad hints | High | Medium | + +--- + +# Part 5: Quick Wins (Immediate) + +1. **Add relevance threshold to `search_knowledge()`** – filter by distance/similarity. +2. **Bundle netlist contract** – ensure `esim_netlist_analysis_output_contract.txt` is in `src/manuals/` or `src/frontEnd/manuals/` and loaded correctly. +3. **Improve error message clarity** – when PaddleOCR fails, show: "Vision analysis unavailable. Text and netlist analysis still work." +4. **Add "Copy to clipboard" for netlist fixes** – so user can paste without manual retyping. + +--- + +# Part 6: File Reference + +| File | Purpose | +|------|---------| +| `src/chatbot/chatbot_core.py` | Router, handlers, classification | +| `src/chatbot/knowledge_base.py` | ChromaDB, search_knowledge | +| `src/chatbot/ollama_runner.py` | Ollama API, embeddings | +| `src/chatbot/image_handler.py` | PaddleOCR, MiniCPM-V | +| `src/chatbot/stt_handler.py` | Vosk STT | +| `src/chatbot/error_solutions.py` | Error pattern → fixes | +| `src/frontEnd/Chatbot.py` | UI, netlist FACT detection, analyze_specific_netlist | +| `src/frontEnd/Application.py` | errorDetectedSignal, openChatbot | +| `src/frontEnd/ProjectExplorer.py` | Context menu | diff --git a/DEPLOY_UBUNTU.md b/DEPLOY_UBUNTU.md new file mode 100644 index 000000000..878bf9482 --- /dev/null +++ b/DEPLOY_UBUNTU.md @@ -0,0 +1,195 @@ +# Deploy eSim Copilot on Ubuntu (VM or WSL2) + +Use this guide for **first-time deployment** of the AI chatbot on a Linux environment. + +--- + +## VM setup checklist (Option A) + +| Step | Action | +|------|--------| +| 1 | Download [Ubuntu 22.04 Desktop](https://ubuntu.com/download/desktop) ISO | +| 2 | Create VM (VirtualBox/Hyper-V/VMware): 4–8 GB RAM, 25 GB disk | +| 3 | Install Ubuntu in VM, install Guest Additions (VirtualBox) if using shared folder | +| 4 | Copy eSim into VM (shared folder or zip) → `~/work/eSim` | +| 5 | Run `./scripts/setup_copilot_ubuntu.sh` | +| 6 | Start `ollama serve` in a second terminal | +| 7 | Run `python ingest.py` (optional) | +| 8 | Launch: `QT_QPA_PLATFORM=xcb python Application.py` from `src/frontEnd` | + +--- + +## Option A: Ubuntu VM (recommended for full GUI) + +### 1. Create Ubuntu VM + +#### 1a. Download Ubuntu 22.04 Desktop + +- Go to [ubuntu.com/download/desktop](https://ubuntu.com/download/desktop) +- Download **Ubuntu 22.04 LTS** (64-bit, ~4 GB ISO) + +#### 1b. Choose your hypervisor + +| Tool | Steps | +|------|-------| +| **VirtualBox** (free) | 1. Install [VirtualBox](https://www.virtualbox.org/wiki/Downloads)
2. New VM → Name: `eSim-Ubuntu`, Type: Linux, Version: Ubuntu (64-bit)
3. RAM: **4096 MB** (minimum), **8192 MB** recommended for Ollama
4. Create virtual disk: **VDI**, **Dynamically allocated**, **25 GB**
5. Settings → Storage → Empty → Choose Ubuntu ISO
6. Start VM → Install Ubuntu (normal install, minimal if offered) | +| **Hyper-V** (Windows Pro) | 1. Enable Hyper-V: `OptionalFeatures` → check Hyper-V
2. Hyper-V Manager → New → Virtual Machine
3. Generation 2, 4096 MB RAM, 25 GB disk
4. Connect to Ubuntu ISO, boot and install | +| **VMware Workstation** | Same as VirtualBox: New VM → Typical → Ubuntu ISO → 4 GB RAM, 25 GB disk | + +**Important:** Allocate at least **4 GB RAM**; 8 GB is better if you run Ollama models inside the VM. + +--- + +### 2. Get the code into Ubuntu + +#### Option 2a – Copy from Windows (shared folder or zip) + +**Using VirtualBox shared folder:** + +1. In VirtualBox: VM Settings → Shared Folders → Add +2. Folder path: `C:\Users\91900\Downloads\eSIM-Software-AIChatBot\repos` +3. Folder name: `repos` (or `esim_repo`) +4. Check **Auto-mount**, **Make permanent** +5. Inside Ubuntu VM, install Guest Additions (Devices → Insert Guest Additions CD, then run it) +6. Add your user to `vboxsf`: `sudo usermod -aG vboxsf $USER` → log out and back in +7. Copy eSim into your home: + ```bash + mkdir -p ~/work + cp -r /media/sf_repos/eSim ~/work/eSim + cd ~/work/eSim + git checkout Chatbot_Enhancements + ``` + +**Using zip (no shared folder):** + +1. On Windows, zip the folder: + - Right-click `repos\eSim` → Send to → Compressed folder + - Or in PowerShell (from workspace root): `.\repos\eSim\scripts\zip_for_vm.ps1` → creates `eSim-for-VM.zip` +2. Copy `eSim-for-VM.zip` into the VM (drag-drop, USB, or network share) +3. Inside Ubuntu: + ```bash + mkdir -p ~/work && cd ~/work + unzip /path/to/eSim-for-VM.zip # e.g. unzip ~/Downloads/eSim-for-VM.zip + cd eSim + git checkout Chatbot_Enhancements + ``` + +#### Option 2b – Clone from GitHub (if Chatbot_Enhancements is on your fork) + +```bash +sudo apt update && sudo apt install -y git +mkdir -p ~/work && cd ~/work +git clone https://github.com/FOSSEE/eSim.git +cd eSim +git fetch origin pull/434/head:pr-434 +git checkout -b Chatbot_Enhancements pr-434 +# If you pushed Chatbot_Enhancements to your fork: +# git remote add myfork https://github.com/YOUR_USER/eSim.git +# git fetch myfork Chatbot_Enhancements +# git checkout Chatbot_Enhancements +``` + +--- + +### 3. Run one-command setup + +```bash +cd ~/work/eSim +chmod +x scripts/setup_copilot_ubuntu.sh +./scripts/setup_copilot_ubuntu.sh +``` + +This installs system packages, Python venv, Ollama, models (qwen2.5:3b, minicpm-v, nomic-embed-text), and Vosk. It may take 15–30 minutes depending on your connection. + +--- + +### 4. Start Ollama (keep running in a separate terminal) + +Open a **new terminal** and run: + +```bash +ollama serve +``` + +Leave this running. The Copilot needs Ollama to answer questions. + +--- + +### 5. Ingest manuals for RAG (optional but recommended) + +```bash +cd ~/work/eSim +source .venv/bin/activate +cd src +python ingest.py +``` + +Add `.txt` manuals to `src/manuals/` first if you have them; otherwise ingest may find nothing (RAG will still work, but with less context). + +--- + +### 6. Launch eSim with Copilot + +```bash +cd ~/work/eSim +source .venv/bin/activate +cd src/frontEnd +QT_QPA_PLATFORM=xcb python Application.py +``` + +Click the **eSim Copilot** button in the toolbar to open the AI chat. + +--- + +### Quick copy-paste (after VM is ready and code is in ~/work/eSim) + +```bash +cd ~/work/eSim && git checkout Chatbot_Enhancements +chmod +x scripts/setup_copilot_ubuntu.sh && ./scripts/setup_copilot_ubuntu.sh +# When done: open a second terminal and run: ollama serve +# Then in first terminal: +source .venv/bin/activate && cd src && python ingest.py +cd ~/work/eSim && source .venv/bin/activate && cd src/frontEnd && QT_QPA_PLATFORM=xcb python Application.py +``` + +--- + +## Option B: WSL2 (Windows Subsystem for Linux) + +### 1. Install WSL2 + Ubuntu + +In **PowerShell (Admin)**: + +```powershell +wsl --install +wsl --set-default-version 2 +wsl --install -d Ubuntu-22.04 +``` + +Reboot if prompted, then open **Ubuntu 22.04** from Start. + +### 2. Follow steps 2–5 from Option A + +All commands are the same inside the Ubuntu terminal. + +--- + +## Troubleshooting + +| Issue | Fix | +|-------|-----| +| "Ollama not responding" | Run `ollama serve` in a separate terminal before launching eSim | +| GUI doesn't appear (WSL) | Ensure you use the Ubuntu app (not SSH). WSLg provides display automatically | +| Voice input fails | Microphone passthrough in WSL can be unreliable; text + image + netlist still work | +| `python ingest.py` finds no files | Add `.txt` manuals to `src/manuals/` before running ingest | + +--- + +## Branch: Chatbot_Enhancements + +This deployment uses the `Chatbot_Enhancements` branch, based on Hariom's PR 434, with: + +- Seamless install script for Ubuntu +- User-writable ChromaDB path +- Optional speech-to-text (graceful fallback if Vosk missing) +- Split requirements (base + copilot) diff --git a/PULL_REQUEST_GUIDE.md b/PULL_REQUEST_GUIDE.md new file mode 100644 index 000000000..78a5f5f85 --- /dev/null +++ b/PULL_REQUEST_GUIDE.md @@ -0,0 +1,79 @@ +# Pull Request Guide – eSim RAGbot Enhancements + +## 1. Git Configuration (Done ✓) + +- **Email:** `harvi.bhavinpatel2024@vitstudent.ac.in` +- **Name:** `Harvi-2215` + +## 2. Create Your Fork on GitHub + +If you don't have a fork yet: + +1. Go to **[https://github.com/FOSSEE/eSim](https://github.com/FOSSEE/eSim)** +2. Click **Fork** (top right) +3. Choose your account (ensure you're logged in as the account for `harvi.bhavinpatel2024@vitstudent.ac.in`) +4. The fork will be created at `https://github.com/YOUR_USERNAME/eSim` + +**Note:** Your GitHub username may be `Harvi-2215` or `HraviPatel` – use whichever matches your account. + +## 3. Add Your Fork as Remote (if needed) + +```powershell +cd C:\Users\91900\Downloads\eSIM-Software-AIChatBot\repos\eSim + +# If using Harvi-2215: +git remote add harvi-fork https://github.com/Harvi-2215/eSim.git + +# OR if using HraviPatel (already exists as hravipatel): +# git remote add hravipatel https://github.com/HraviPatel/eSim.git +``` + +## 4. Push Your Branch to the Fork + +```powershell +cd C:\Users\91900\Downloads\eSIM-Software-AIChatBot\repos\eSim + +# Push to Harvi-2215 fork: +git push harvi-fork esim-RAGbot-enhacements:esim-RAGbot-Enhacements-Harvi + +# OR push to HraviPatel fork: +git push hravipatel esim-RAGbot-enhacements:esim-RAGbot-Enhacements-Harvi +``` + +When prompted, sign in with `harvi.bhavinpatel2024@vitstudent.ac.in` (or your GitHub credentials). + +## 5. Create the Pull Request + +1. Open your fork: `https://github.com/YOUR_USERNAME/eSim` +2. You should see a banner: **"esim-RAGbot-Enhacements-Harvi had recent pushes"** with a **Compare & pull request** button +3. Click **Compare & pull request** +4. Set: + - **Base repository:** `FOSSEE/eSim` + - **Base branch:** `master` (or `main` if that's the default) + - **Head repository:** `YOUR_USERNAME/eSim` + - **Compare branch:** `esim-RAGbot-Enhacements-Harvi` +5. Add a title, e.g. **"Copilot enhancements: RAG threshold, netlist contract, PaddleOCR, copy button"** +6. Add a description of the changes +7. Click **Create pull request** + +## 6. Changes Included in This Branch + +| File | Change | +|------|--------| +| `.gitignore` | Added `scripts/deploy_to_vm.ps1` | +| `scripts/launch_esim.sh` | Vosk model path auto-detection | +| `src/chatbot/image_handler.py` | PaddleOCR error message | +| `src/chatbot/knowledge_base.py` | RAG relevance threshold | +| `src/frontEnd/Chatbot.py` | Netlist contract bundling, copy-to-clipboard | +| `scripts/README_TESTS.md` | Test instructions and deploy steps | +| `scripts/deploy_to_vm.ps1.example` | Deploy script template | +| `scripts/test_copilot_enhancements.py` | Enhancement tests | +| `scripts/test_copilot_enhancements.sh` | Shell wrapper for tests | + +## Troubleshooting + +| Issue | Fix | +|-------|-----| +| Push asks for credentials | Use GitHub CLI (`gh auth login`) or Git Credential Manager | +| "Repository not found" | Create the fork on GitHub first (Step 2) | +| Wrong GitHub account | Check `git config user.email` and ensure it matches your GitHub email | diff --git a/README_CHATBOT.md b/README_CHATBOT.md new file mode 100644 index 000000000..6a41ae272 --- /dev/null +++ b/README_CHATBOT.md @@ -0,0 +1,126 @@ +# eSim Copilot – AI-Assisted Electronics Simulation Tool + +eSim Copilot is an AI-powered assistant integrated into **eSim**, designed to help users analyze electronic circuits, debug SPICE netlists, understand simulation errors, and interact using text, voice, and images. + +This project combines **PyQt5**, **ngspice**, **Ollama (LLMs)**, **RAG (ChromaDB)**, **OCR**, and **offline speech-to-text** into a single desktop application. + +**→ First-time deployment on Ubuntu (VM or WSL2): see [DEPLOY_UBUNTU.md](DEPLOY_UBUNTU.md)** + +--- + +## Key Features + +- AI assistant for electronics & eSim +- Netlist analysis and error explanation +- ngspice simulation integration +- Circuit image analysis (OCR + vision models) +- Offline speech-to-text (no internet required) +- Knowledge base using RAG (manuals + docs) +- Fully offline-capable (except model downloads) + +## Supported Platform + +- **Linux only** (Recommended: Ubuntu 22.04 / 23.04 / 24.04) +- Tested on **Ubuntu 22.04 & 24.04** + +--- + +## Python Version (VERY IMPORTANT) + +## Supported +- **Python 3.9 – 3.10 (RECOMMENDED)** + +Check version: +```bash +python --version + +## System Dependencies (Install First) +```bash + +sudo apt update +sudo apt upgrade + +sudo apt update +sudo apt install -y \ + libxcb-xinerama0 \ + libxcb-cursor0 \ + libxkbcommon-x11-0 \ + libxcb-icccm4 \ + libxcb-image0 \ + libxcb-keysyms1 \ + libxcb-render-util0 \ + libxcb-xinput0 \ + libxcb-shape0 \ + libxcb-randr0 \ + libxcb-util1 \ + libgl1 \ + libglib2.0-0 + +## Clone the Repository + +git clone +cd eSim-master + +## Ollama (LLM Backend) +```bash + +curl -fsSL https://ollama.com/install.sh | sh +ollama serve +ollama pull qwen2.5:3b +ollama pull minicpm-v +ollama pull nomic-embed-text + +## Offline Speech-to-Text (VOSK) +```bash + +mkdir -p ~/vosk-models +cd ~/vosk-models +wget https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip +unzip vosk-model-small-en-us-0.15.zip + +export VOSK_MODEL_PATH=~/vosk-models/vosk-model-small-en-us-0.15 + +echo 'export VOSK_MODEL_PATH=~/vosk-models/vosk-model-small-en-us-0.15' >> ~/.bashrc +source ~/.bashrc + +## Python Virtual Environment (Recommended) +```bash + +python3.10 -m venv venv +source venv/bin/activate +pip uninstall -y pip +python -m ensurepip +python -m pip install pip==22.3.1 +python -m pip install setuptools==65.5.0 wheel==0.38.4 + +python -m pip install hdlparse==1.0.4 --no-build-isolation + +pip install -r requirements.txt + +pip install paddlepaddle==2.5.2 \ + -f https://www.paddlepaddle.org.cn/whl/linux/mkl/avx/stable.html + +pip uninstall -y opencv-python opencv-contrib-python opencv-python-headless +pip install opencv-python-headless==4.6.0.66 + +## Before running eSim + +unset QT_PLUGIN_PATH +export QT_QPA_PLATFORM=xcb + +## Ingest manuals for RAG +```bash +cd src +python ingest.py + +## Running the Application +```bash +cd src/frontEnd +python Application.py + +## Common Warnings (Safe to Ignore) + +PaddleOCR init failed: show_log +QSocketNotifier: Can only be used with threads started with QThread +libpng iCCP: incorrect sRGB profile +PyQt sipPyTypeDict() deprecation warnings diff --git a/SESSION_SUMMARY.md b/SESSION_SUMMARY.md new file mode 100644 index 000000000..45ec1bd75 --- /dev/null +++ b/SESSION_SUMMARY.md @@ -0,0 +1,154 @@ +# eSim Copilot Deployment – Complete Guide + +**Date:** March 3, 2025 +**Branch:** `Chatbot_Enhancements` (from `pr-434`) +**Goal:** First deployment of eSim AI Copilot on Ubuntu VM + +This document explains every step, every file, every command, and every fix from this deployment session. + +--- + +# Table of Contents + +1. [Project Overview](#1-project-overview) +2. [Branch Setup](#2-branch-setup) +3. [Branch Changes & Commit History](#3-branch-changes--commit-history) +4. [Technical Breakdown of Fixes](#4-technical-breakdown-of-fixes) +5. [Ubuntu VM Setup](#5-ubuntu-vm-setup) +6. [Networking & SSH](#6-networking--ssh) +7. [Code Transfer to VM](#7-code-transfer-to-vm) +8. [Setup Script – Full 7-Step Flow](#8-setup-script--full-7-step-flow) +9. [Ollama & Models](#9-ollama--models) +10. [Launch & Usage](#10-launch--usage) +11. [Known Issues & Troubleshooting](#11-known-issues--troubleshooting) +12. [Push to GitHub](#12-push-to-github) + +--- + +# 1. Project Overview + +## What is eSim? +**eSim** is an open-source electronics simulation tool (FOSSEE/eSim) that uses: +- **ngspice** – SPICE circuit simulator +- **KiCad** – Schematic capture +- **PyQt5** – Desktop GUI + +## What is eSim Copilot? +Enhanced assistant integrated into eSim, providing: +- **Text chat:** Ollama (LLM) +- **RAG:** ChromaDB +- **Vision:** PaddleOCR, MiniCPM-V +- **Speech-to-text:** Vosk + +--- + +# 2. Branch Setup + +```bash +# Created from Hariom's Copilot implementation +git fetch origin pull/434/head:pr-434 +git checkout -b Chatbot_Enhancements pr-434 +``` + +--- + +# 3. Branch Changes & Commit History + +### Key File Changes +- **`scripts/setup_copilot_ubuntu.sh`**: New comprehensive setup script. +- **`scripts/launch_esim.sh`**: New utility to launch with correct Qt backend. +- **`src/frontEnd/Workspace.py`**: Fixed directory creation bug. +- **`src/chatbot/stt_handler.py`**: Made STT optional with graceful fallback. +- **`.gitattributes`**: Added to force LF on shell scripts. + +### Recent Commits +- `71ed0f5c`: SESSION_SUMMARY: comprehensive guide with every item explained +- `4c67304d`: Ubuntu deployment, hdlparse fix, Workspace.py fix, launch script +- `67f08043`: Ubuntu deployment support, optional STT, ChromaDB path fix + +--- + +# 4. Technical Breakdown of Fixes + +### 4.1 hdlparse & setuptools Fix +**Problem:** `hdlparse==1.0.4` uses `use_2to3`, which was removed in `setuptools 58+`. +**Fix:** +1. Manually install `setuptools==57.5.0`. +2. Install `hdlparse` with `--no-build-isolation` to force it to use the downgrade. +3. Use a `/tmp/pip-constraints.txt` with `setuptools<58` for all subsequent installs. + +### 4.2 Workspace.py Crash +**Problem:** `FileNotFoundError` when writing `~/.esim/workspace.txt` because the `.esim` folder was missing. +**Fix:** Added `os.makedirs(esim_dir, exist_ok=True)` in `src/frontEnd/Workspace.py`. + +### 4.3 Mandatory LF for Scripts +**Problem:** `bash\r: No such file or directory` due to Windows CRLF. +**Fix:** Created `.gitattributes` to enforce `*.sh text eol=lf`. + +### 4.4 Graceful STT Fallback +**Problem:** App crashed if `vosk` or `sounddevice` were missing. +**Fix:** Wrapped imports in `try/except` and added `_HAS_STT` flag in `stt_handler.py`. + +--- + +# 5. Ubuntu VM Setup +- **ISO:** Ubuntu 22.04 LTS +- **Hardware:** 4-8 GB RAM, 25 GB Disk +- **Network:** Bridged Adapter (preferred) or NAT with Port Forwarding (2222 -> 22) + +--- + +# 6. Networking & SSH +```bash +sudo apt install -y openssh-server +sudo systemctl enable --now ssh +ip addr # Find IP +``` + +--- + +# 7. Code Transfer to VM +- **Method A (SCP):** `scp -r repos/eSim user@ip:~/work/` +- **Method B (Zip):** Use `./scripts/zip_for_vm.ps1` on Windows, then transfer zip. + +--- + +# 8. Setup Script – Full 7-Step Flow +Run `./scripts/setup_copilot_ubuntu.sh`: +1. **System Packages:** Install `ngspice`, `kicad`, `portaudio`, `libxcb` (Qt dependencies). +2. **Virtualenv:** Create isolated `.venv`. +3. **Python Deps:** Pip upgrade, `setuptools` downgrade, and RAG/AI requirements. +4. **PaddlePaddle:** Install CPU version for OCR. +5. **Ollama:** Download and install LLM server. +6. **Models:** Pull `qwen2.5:3b`, `minicpm-v`, and `nomic-embed-text`. +7. **Vosk:** Download offline English model to `~/.local/share/esim-copilot/`. + +--- + +# 9. Ollama & Models +- **API:** `http://127.0.0.1:11434` +- **Service:** Managed via `systemctl` or manual `ollama serve`. + +--- + +# 10. Launch & Usage +```bash +./scripts/launch_esim.sh +``` +*Note: Uses `QT_QPA_PLATFORM=xcb` for compatibility over SSH X11 forwarding.* + +--- + +# 11. Known Issues & Troubleshooting +| Issue | Fix | +|---|---| +| `bash\r` | `sed -i 's/\r$//' scripts/setup_copilot_ubuntu.sh` | +| `hdlparse` error | Ensure `setuptools<58` is used. | +| No GUI | Enable X11 forwarding in MobaXterm. | + +--- + +# 12. Push to GitHub +1. Fork `FOSSEE/eSim` on GitHub. +2. `git remote add myfork https://github.com/YOUR_USER/eSim.git` +3. `git push myfork Chatbot_Enhancements` diff --git a/images/chatbot.png b/images/chatbot.png new file mode 100644 index 000000000..4c36aee84 Binary files /dev/null and b/images/chatbot.png differ diff --git a/requirements-copilot.txt b/requirements-copilot.txt new file mode 100644 index 000000000..1100441b6 --- /dev/null +++ b/requirements-copilot.txt @@ -0,0 +1,18 @@ +# eSim Copilot AI extras (install after base requirements.txt) +ollama +chromadb +psutil +protobuf<5 +regex +requests +tqdm +pyyaml + +# Vision (PaddleOCR + OpenCV) +pillow==10.4.0 +opencv-python-headless==4.6.0.66 +paddleocr==2.7.0.3 + +# Speech-to-text +vosk +sounddevice diff --git a/requirements.txt b/requirements.txt index 5a638c20e..641442f69 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,4 +23,8 @@ python-dateutil==2.9.0.post0 scipy==1.10.1 six==1.17.0 watchdog==4.0.2 -zipp==3.20.2 \ No newline at end of file +zipp==3.20.2 +setuptools>=57.5.0,<58 +wheel +PyQtWebEngine +# Copilot AI deps: pip install -r requirements-copilot.txt diff --git a/scripts/README_TESTS.md b/scripts/README_TESTS.md new file mode 100644 index 000000000..b0d87d0d8 --- /dev/null +++ b/scripts/README_TESTS.md @@ -0,0 +1,97 @@ +# Copilot Enhancement Tests + +Run these tests on the Ubuntu VM after activating the venv. + +## Copy Updated Code to VM + +From your **local machine** (Windows), sync the updated code to the Ubuntu VM: + +### Automated: deploy_to_vm.ps1 (recommended) + +```powershell +# First time: copy template (if needed) and edit VM_HOST, VM_USER +copy scripts\deploy_to_vm.ps1.example scripts\deploy_to_vm.ps1 # if deploy_to_vm.ps1 doesn't exist + +# Run from eSim repo root: +.\scripts\deploy_to_vm.ps1 +``` + +This script syncs `src` and `scripts` via SCP, stops any running eSim on the VM, and prints the final step to run in MobaXterm. (`deploy_to_vm.ps1` is in `.gitignore`.) + +### Manual options + +### Option A: rsync (recommended) + +```powershell +# From eSim repo root on local machine +cd +rsync -avz --exclude ".venv" --exclude "__pycache__" --exclude ".git" . harvi@192.168.29.208:~/work/eSim/ +``` + +### Option B: scp (specific files/folders) + +```powershell +# Copy entire src and scripts +scp -r src scripts harvi@192.168.29.208:~/work/eSim/ + +# Or copy only changed files +scp src/chatbot/knowledge_base.py src/chatbot/image_handler.py src/frontEnd/Chatbot.py harvi@192.168.29.208:~/work/eSim/src/ +scp -r scripts harvi@192.168.29.208:~/work/eSim/ +``` + +### Option C: Git (if both sides use the same repo) + +```bash +# On local: commit and push +git add -A && git commit -m "Copilot enhancements" && git push + +# On VM: pull +ssh harvi@192.168.29.208 "cd ~/work/eSim && git pull" +``` + +**Note:** Replace `192.168.29.208` and `harvi` if your VM uses different IP/user. On Windows, `scp` is available with OpenSSH; for `rsync`, use WSL or install via Git for Windows. + +--- + +## Prerequisites + +- Ubuntu VM (e.g. 192.168.29.208, user `harvi`) +- Repo at `~/work/eSim` +- Virtual environment with dependencies: `source .venv/bin/activate` +- Optional: `ollama serve` running for Ollama connectivity test +- Optional: RAG ingest run (`cd src && python ingest.py`) for RAG test + +## Run Tests + +### Option 1: Python script (recommended) + +```bash +cd ~/work/eSim +source .venv/bin/activate +python scripts/test_copilot_enhancements.py +``` + +### Option 2: Shell script + +```bash +cd ~/work/eSim +chmod +x scripts/test_copilot_enhancements.sh +./scripts/test_copilot_enhancements.sh +``` + +## What Is Tested + +| Test | Description | +|------|-------------| +| Netlist contract | Contract loads from one of the bundled paths | +| RAG relevance | `search_knowledge()` filters by relevance threshold | +| PaddleOCR | `image_handler` imports; `HAS_PADDLE` set | +| Copy button | `ChatbotGUI.copy_last_response` exists | +| Ollama | Optional: Ollama responds to a short prompt | + +## Expected Output + +- `[PASS]` – test succeeded +- `[SKIP]` – test skipped (e.g. RAG empty if ingest not run) +- `[WARN]` – non-blocking (e.g. Ollama not running) +- `[FAIL]` – test failed (investigate) diff --git a/scripts/deploy_to_vm.ps1.example b/scripts/deploy_to_vm.ps1.example new file mode 100644 index 000000000..3c0ff1bcb --- /dev/null +++ b/scripts/deploy_to_vm.ps1.example @@ -0,0 +1,49 @@ +# Deploy eSim to VM and restart - COPY THIS FILE TO deploy_to_vm.ps1 and edit config below. +# The actual deploy_to_vm.ps1 is in .gitignore (user-specific). +# +# Usage: .\scripts\deploy_to_vm.ps1 +# Prereq: OpenSSH client (scp, ssh) - built into Windows 10+ + +# ============ CONFIG (edit these) ============ +$VM_HOST = "192.168.29.208" +$VM_USER = "harvi" +$VM_REPO = "~/work/eSim" +# ============================================ + +$ErrorActionPreference = "Stop" +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = Split-Path -Parent $ScriptDir + +Write-Host "=== eSim Deploy to VM ===" -ForegroundColor Cyan +Write-Host "" + +# 1. Sync code +Write-Host "[1] Syncing code to ${VM_USER}@${VM_HOST}:${VM_REPO}..." -ForegroundColor Yellow +$dest = "${VM_USER}@${VM_HOST}:${VM_REPO}/" +Push-Location $RepoRoot +try { + # Use scp - rsync may not be on Windows + scp -r -o ConnectTimeout=10 src scripts $dest + if ($LASTEXITCODE -ne 0) { throw "scp failed" } +} finally { + Pop-Location +} +Write-Host " Done." -ForegroundColor Green + +# 2. Kill old eSim process +Write-Host "[2] Stopping old eSim process on VM..." -ForegroundColor Yellow +$remoteCmd = "cd $VM_REPO && pkill -f 'python.*Application.py' 2>/dev/null; pkill -f 'python.*esim' 2>/dev/null; echo 'Done.'" +ssh -o ConnectTimeout=10 "${VM_USER}@${VM_HOST}" $remoteCmd +Write-Host " Done." -ForegroundColor Green + +# 3. MobaXterm instructions +Write-Host "" +Write-Host "=== LAST STEP (do this in MobaXterm) ===" -ForegroundColor Cyan +Write-Host "" +Write-Host " 1. Open MobaXterm and start an SSH session to: ${VM_USER}@${VM_HOST}" -ForegroundColor White +Write-Host " 2. Run:" -ForegroundColor White +Write-Host "" +Write-Host " cd ~/work/eSim && ./scripts/launch_esim.sh" -ForegroundColor Yellow +Write-Host "" +Write-Host " (MobaXterm provides X11, so the eSim GUI will display.)" -ForegroundColor Gray +Write-Host "" diff --git a/scripts/launch_esim.sh b/scripts/launch_esim.sh new file mode 100644 index 000000000..e4105d4da --- /dev/null +++ b/scripts/launch_esim.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Launch eSim with Copilot +# Usage: ./scripts/launch_esim.sh or bash scripts/launch_esim.sh + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +# Ensure .esim dir exists (avoids workspace error) +mkdir -p ~/.esim + +# Vosk STT model (if installed) +VOSK_DEFAULT="$HOME/.local/share/esim-copilot/vosk-model-small-en-us-0.15" +if [ -z "$VOSK_MODEL_PATH" ] && [ -d "$VOSK_DEFAULT" ]; then + export VOSK_MODEL_PATH="$VOSK_DEFAULT" +fi + +# Activate venv and launch +source .venv/bin/activate +cd src/frontEnd +QT_QPA_PLATFORM=xcb python Application.py diff --git a/scripts/setup_copilot_ubuntu.sh b/scripts/setup_copilot_ubuntu.sh new file mode 100644 index 000000000..3e3af20d5 --- /dev/null +++ b/scripts/setup_copilot_ubuntu.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +echo "[1/7] Installing system packages (Ubuntu/Debian)…" +sudo apt-get update +sudo apt-get install -y \ + python3.10 python3.10-venv python3-pip \ + curl wget unzip \ + ngspice kicad \ + portaudio19-dev \ + libgl1 libglib2.0-0 \ + libxcb-xinerama0 libxcb-cursor0 libxkbcommon-x11-0 \ + libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-render-util0 \ + libxcb-xinput0 libxcb-shape0 libxcb-randr0 libxcb-util1 + +echo "[2/7] Creating Python virtualenv…" +cd "$ROOT_DIR" +python3.10 -m venv .venv +source .venv/bin/activate + +echo "[3/7] Installing Python dependencies…" +python -m pip install --upgrade pip wheel +# hdlparse needs setuptools<58 (use_2to3 removed in setuptools 58+) +python -m pip install setuptools==57.5.0 +python -m pip install hdlparse==1.0.4 --no-build-isolation +echo "setuptools<58" > /tmp/pip-constraints.txt +python -m pip install -c /tmp/pip-constraints.txt -r requirements.txt +python -m pip install -c /tmp/pip-constraints.txt -r requirements-copilot.txt + +echo "[4/7] Installing PaddlePaddle (CPU, AVX, MKL)…" +python -m pip install "paddlepaddle==2.5.2" \ + -f https://www.paddlepaddle.org.cn/whl/linux/mkl/avx/stable.html + +echo "[5/7] Installing Ollama if missing…" +if ! command -v ollama >/dev/null 2>&1; then + curl -fsSL https://ollama.com/install.sh | sh +fi + +echo "[6/7] Pulling required Ollama models…" +ollama pull qwen2.5:3b +ollama pull minicpm-v +ollama pull nomic-embed-text + +echo "[7/7] Installing Vosk small English model…" +VOSK_BASE="${XDG_DATA_HOME:-$HOME/.local/share}/esim-copilot" +mkdir -p "$VOSK_BASE" +cd "$VOSK_BASE" +if [ ! -d "vosk-model-small-en-us-0.15" ]; then + wget -q https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip -O vosk-model-small-en-us-0.15.zip + unzip -q vosk-model-small-en-us-0.15.zip + rm -f vosk-model-small-en-us-0.15.zip +fi + +echo +echo "Done." +echo "- Activate venv: source \"$ROOT_DIR/.venv/bin/activate\"" +echo "- Run ingestion (optional for RAG): (cd \"$ROOT_DIR/src\" && python ingest.py)" +echo "- Run eSim: (cd \"$ROOT_DIR/src/frontEnd\" && QT_QPA_PLATFORM=xcb python Application.py)" +echo +echo "Optional env vars:" +echo "- export VOSK_MODEL_PATH=\"$VOSK_BASE/vosk-model-small-en-us-0.15\"" +echo "- export ESIM_COPILOT_DB_PATH=\"${XDG_DATA_HOME:-$HOME/.local/share}/esim-copilot/chroma\"" + diff --git a/scripts/test_copilot_enhancements.py b/scripts/test_copilot_enhancements.py new file mode 100644 index 000000000..20c6f9192 --- /dev/null +++ b/scripts/test_copilot_enhancements.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +""" +Test script for Copilot enhancements - run on Ubuntu VM. +Usage: cd ~/work/eSim && source .venv/bin/activate && python scripts/test_copilot_enhancements.py +""" +import os +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SRC = os.path.join(ROOT, "src") +if SRC not in sys.path: + sys.path.insert(0, SRC) + +os.chdir(SRC) + + +def test_netlist_contract(): + """Test that netlist contract loads from one of the bundled paths.""" + from frontEnd.Chatbot import NETLIST_CONTRACT + assert NETLIST_CONTRACT, "NETLIST_CONTRACT should not be empty" + assert "FACT" in NETLIST_CONTRACT or "SPICE" in NETLIST_CONTRACT + print("[PASS] Netlist contract loaded") + return True + + +def test_rag_relevance_threshold(): + """Test RAG search_knowledge with relevance threshold.""" + from chatbot.knowledge_base import search_knowledge, RELEVANCE_THRESHOLD + print(f" RELEVANCE_THRESHOLD = {RELEVANCE_THRESHOLD}") + result = search_knowledge("how to add ground", n_results=3) + # May be empty if ingest not run + if result: + assert "=== ESIM OFFICIAL DOCUMENTATION ===" in result + print("[PASS] RAG search returned filtered context") + else: + print("[SKIP] RAG empty (run: cd src && python ingest.py)") + return True + + +def test_paddleocr_message(): + """Test that image_handler imports and HAS_PADDLE is set.""" + from chatbot import image_handler + # Just verify it doesn't crash; message is printed at import + assert hasattr(image_handler, "HAS_PADDLE") + print(f"[PASS] image_handler.HAS_PADDLE = {image_handler.HAS_PADDLE}") + return True + + +def test_chatbot_copy_button(): + """Test that ChatbotGUI has copy_btn and copy_last_response.""" + from frontEnd.Chatbot import ChatbotGUI + assert hasattr(ChatbotGUI, "copy_last_response") + # Create instance would need QApplication - skip for headless + print("[PASS] ChatbotGUI has copy_last_response method") + return True + + +def test_ollama_connectivity(): + """Test Ollama is reachable (optional).""" + try: + from chatbot.ollama_runner import run_ollama + r = run_ollama("Reply with exactly: OK") + if r and "ok" in r.lower(): + print("[PASS] Ollama responded") + else: + print("[WARN] Ollama returned unexpected:", r[:50] if r else "empty") + except Exception as e: + print(f"[WARN] Ollama test failed: {e}") + return True + + +def main(): + print("=== eSim Copilot Enhancement Tests ===\n") + tests = [ + test_netlist_contract, + test_rag_relevance_threshold, + test_paddleocr_message, + test_chatbot_copy_button, + test_ollama_connectivity, + ] + for t in tests: + try: + t() + except Exception as e: + print(f"[FAIL] {t.__name__}: {e}") + print("\n=== Done ===") + + +if __name__ == "__main__": + main() diff --git a/scripts/test_copilot_enhancements.sh b/scripts/test_copilot_enhancements.sh new file mode 100644 index 000000000..3ab7f4569 --- /dev/null +++ b/scripts/test_copilot_enhancements.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Test script for Copilot enhancements - run on Ubuntu VM +# Usage: ./scripts/test_copilot_enhancements.sh +# Prereq: source .venv/bin/activate, ollama serve running + +set -e +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +echo "=== eSim Copilot Enhancement Tests ===" +echo "" + +# Ensure venv +if [ -z "$VIRTUAL_ENV" ]; then + echo "[1] Activating venv..." + source .venv/bin/activate +fi + +# Add src to path +export PYTHONPATH="$ROOT/src:$PYTHONPATH" +cd src + +echo "[2] Test: Netlist contract loading" +python3 -c " +from frontEnd.Chatbot import NETLIST_CONTRACT +assert NETLIST_CONTRACT, 'NETLIST_CONTRACT should not be empty' +print(' OK: Contract loaded, length:', len(NETLIST_CONTRACT)) +" + +echo "[3] Test: RAG relevance threshold" +python3 -c " +from chatbot.knowledge_base import search_knowledge, RELEVANCE_THRESHOLD +print(' RELEVANCE_THRESHOLD:', RELEVANCE_THRESHOLD) +result = search_knowledge('how to add ground in eSim', n_results=2) +if result: + print(' OK: RAG returned context, length:', len(result)) +else: + print(' SKIP: RAG empty (run ingest.py first if needed)') +" + +echo "[4] Test: PaddleOCR / image_handler import" +python3 -c " +from chatbot.image_handler import HAS_PADDLE, analyze_and_extract +if HAS_PADDLE: + print(' OK: PaddleOCR available') +else: + print(' OK: PaddleOCR unavailable (expected message shown at import)') +" + +echo "[5] Test: Ollama connectivity (optional)" +python3 -c " +try: + from chatbot.ollama_runner import run_ollama + r = run_ollama('Say OK in one word.') + if r and len(r) > 0: + print(' OK: Ollama responded') + else: + print(' WARN: Ollama returned empty (is ollama serve running?)') +except Exception as e: + print(' WARN: Ollama test failed:', e) +" + +echo "" +echo "=== All tests completed ===" diff --git a/scripts/zip_for_vm.ps1 b/scripts/zip_for_vm.ps1 new file mode 100644 index 000000000..1fac11573 --- /dev/null +++ b/scripts/zip_for_vm.ps1 @@ -0,0 +1,19 @@ +# Zip eSim for copying into Ubuntu VM +# Run from anywhere; script finds eSim repo relative to itself +param( + [string]$RepoPath = (Join-Path $PSScriptRoot ".."), + [string]$OutZip = (Join-Path (Split-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) -Parent) "eSim-for-VM.zip") +) +$ErrorActionPreference = "Stop" +if (-not (Test-Path $RepoPath)) { + Write-Host "Repo not found: $RepoPath" -ForegroundColor Red + exit 1 +} +Write-Host "Zipping: $RepoPath" -ForegroundColor Cyan +Write-Host "Output: $OutZip" -ForegroundColor Cyan +Compress-Archive -Path $RepoPath -DestinationPath $OutZip -Force +Write-Host "Done. Copy $OutZip into your Ubuntu VM, then:" -ForegroundColor Green +Write-Host " mkdir -p ~/work && cd ~/work" +Write-Host " unzip /path/to/eSim-for-VM.zip" +Write-Host " cd eSim && git checkout Chatbot_Enhancements" +Write-Host " chmod +x scripts/setup_copilot_ubuntu.sh && ./scripts/setup_copilot_ubuntu.sh" diff --git a/src/chatbot/__init__.py b/src/chatbot/__init__.py new file mode 100644 index 000000000..2157cc829 --- /dev/null +++ b/src/chatbot/__init__.py @@ -0,0 +1,11 @@ +""" +eSim Chatbot Package +""" + +from .chatbot_core import handle_input, ESIMCopilotWrapper, analyze_schematic + +__all__ = [ + 'handle_input', + 'ESIMCopilotWrapper', + 'analyze_schematic' +] diff --git a/src/chatbot/chatbot_core.py b/src/chatbot/chatbot_core.py new file mode 100644 index 000000000..24b8eef09 --- /dev/null +++ b/src/chatbot/chatbot_core.py @@ -0,0 +1,701 @@ +# chatbot_core.py + +import os +import re +import json +from typing import Dict, Any, Tuple, List +from .error_solutions import get_error_solution +from .image_handler import analyze_and_extract +from .ollama_runner import run_ollama +from .knowledge_base import search_knowledge +from .ollama_runner import get_embedding + +# ==================== ESIM WORKFLOW KNOWLEDGE ==================== + +ESIM_WORKFLOWS = """ +=== COMMON ESIM WORKFLOWS === + +HOW TO ADD GROUND: +1. In KiCad schematic, press 'A' key (Add Component) +2. Type "GND" in the search box +3. Select ground symbol from "power" library +4. Click to place it on schematic +5. Press 'W' to add wire and connect to circuit +6. Save (Ctrl+S) → eSim: Simulation → Convert KiCad to NgSpice + +HOW TO ADD ANY COMPONENT: +1. In KiCad schematic, press 'A' key +2. Type component name (e.g., "Q2N3904", "1N4148", "uA741") +3. Select from appropriate library (eSim_Devices, eSim_Subckt, etc.) +4. Place on schematic and connect with wires +5. Save → Convert KiCad to NgSpice + +HOW TO FIX MISSING SPICE MODELS (3 Methods): + +Method 1 - Direct Netlist Edit (FASTEST, but temporary): +1. eSim: Tools → Spice Editor (or Ctrl+E) +2. Open your_project.cir.out file +3. Scroll to bottom (before .end line) +4. Add model definition: + BJT: .model Q2N3904 NPN(Bf=200 Is=1e-14 Vaf=100) + Diode: .model 1N4148 D(Is=1e-14 Rs=1) + Zener: .model DZ5V1 D(Is=1e-14 Bv=5.1 Ibv=5m) +5. Save (Ctrl+S) → Run Simulation +NOTE: This gets overwritten when you "Convert KiCad to NgSpice" again + +Method 2 - Component Properties (PERMANENT): +1. Open KiCad schematic (double-click .proj in Project Explorer) +2. Find the component that uses the missing model (e.g., transistor Q1) +3. Right-click on it → Properties (or press E when hovering over it) +4. Click "Edit Spice Model" button in the Properties dialog +5. In the Spice Model field, paste the model definition: + .model Q2N3904 NPN(Bf=200 Is=1e-14 Vaf=100) +6. Click OK → Save schematic (Ctrl+S) +7. eSim: Simulation → Convert KiCad to NgSpice +NOTE: This permanently associates the model with the component + +Method 3 - Include Library: +1. Spice Editor → Open .cir.out +2. Add at top: .include /usr/share/ngspice/models/bjt.lib +3. Save → Simulate + +HOW TO FIX MISSING SUBCIRCUITS: +1. Spice Editor → Open .cir.out +2. Add before .end: + .subckt OPAMP_IDEAL inp inn out vdd vss + Rin inp inn 1Meg + E1 out 0 inp inn 100000 + Rout out 0 75 + .ends +3. Save → Simulate +OR: Replace with eSim library opamp (uA741, LM324) + +HOW TO FIX FLOATING NODES: +1. Open KiCad schematic +2. Find the unconnected pin/node +3. Either connect it with wire (press W) or delete component +4. For sense points: Add Rleak node 0 1Meg +5. Save → Convert to NgSpice + +KICAD SHORTCUTS: +A = Add component +W = Add wire +M = Move item +R = Rotate item +C = Copy item +Delete = Remove item +Ctrl+S = Save + +ESIM MENU PATHS: +Convert to NgSpice: Simulation → Convert KiCad to NgSpice +Run Simulation: Simulation → Simulate +Spice Editor: Tools → Spice Editor (Ctrl+E) +Model Editor: Tools → Model Editor +Open KiCad: Double-click .proj file in Project Explorer + +FILE LOCATIONS: +Project folder: ~/eSim-Workspace// +Netlist: .cir.out +Schematic: .proj +""" + +LAST_BOT_REPLY: str = "" +LAST_IMAGE_CONTEXT: Dict[str, Any] = {} +LAST_NETLIST_ISSUES: Dict[str, Any] = {} + + +def get_history() -> Dict[str, Any]: + return LAST_IMAGE_CONTEXT + + +def clear_history() -> None: + global LAST_IMAGE_CONTEXT, LAST_NETLIST_ISSUES + LAST_IMAGE_CONTEXT = {} + LAST_NETLIST_ISSUES = {} + +# ==================== ESIM ERROR LOGIC ==================== + +def answer_with_rag_fallback(user_input: str) -> str: + """ + Try to answer using eSim manuals (RAG). + If nothing relevant is found, fallback to Ollama. + """ + + rag_context = search_knowledge(user_input) + + if rag_context.strip(): + prompt = f""" +You are eSim Copilot. + +Use ONLY the following official eSim documentation +to answer the question. Do NOT invent information. + +{rag_context} + +Question: +{user_input} + +Answer clearly and step-by-step. +""" + return run_ollama(prompt) + + # Fallback: general LLM answer + prompt = f""" +Answer the following question clearly: + +{user_input} +""" + return run_ollama(prompt) + +def detect_esim_errors(image_context: Dict[str, Any], user_input: str) -> str: + """ + Display errors from hybrid analysis with SMART FILTERING to remove hallucinations. + """ + if not image_context: + return "" + + analysis = image_context.get("circuit_analysis", {}) + raw_errors = analysis.get("design_errors", []) + warnings = analysis.get("design_warnings", []) + + # === SMART FILTERING === + components_str = str(image_context.get("components", [])).lower() + summary_str = str(image_context.get("vision_summary", "")).lower() + context_text = components_str + summary_str + + filtered_errors: List[str] = [] + for err in raw_errors: + err_lower = err.lower() + + if "ground" in err_lower and ( + "gnd" in context_text or "ground" in context_text or " 0 " in context_text + ): + continue + + if "floating" in err_lower and ( + "vin" in err_lower or "vout" in err_lower or "label" in err_lower + ): + continue + + filtered_errors.append(err) + + output: List[str] = [] + + if filtered_errors: + output.append("**🚨 CRITICAL ERRORS:**") + for err in filtered_errors: + output.append(f"❌ {err}") + + if warnings: + output.append("\n**⚠️ WARNINGS:**") + for warn in warnings: + output.append(f"⚠️ {warn}") + + text = user_input.lower() + if "singular matrix" in text: + output.append("\n**🔧 FIX:** Add 1GΩ resistors to all nodes → GND") + if "timestep" in text: + output.append("\n**🔧 FIX:** Reduce timestep or add 0.1Ω series R") + + if not output: + return "**✅ No errors detected**" + + return "\n".join(output) + + +# ==================== UTILITIES ==================== + +VALID_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".gif") + + +def _is_image_file(path: str) -> bool: + if not path: + return False + clean = re.sub(r"\[Image:\s*(.*?)\]", r"\1", path).strip() + return clean.lower().endswith(VALID_EXTS) + + +def _is_image_query(user_input: str) -> bool: + if not user_input: + return False + if "[Image:" in user_input: + return True + if "|" in user_input: + parts = user_input.split("|", 1) + if len(parts) == 2 and _is_image_file(parts[1]): + return True + return _is_image_file(user_input) + + +def _parse_image_query(user_input: str) -> Tuple[str, str]: + user_input = user_input.strip() + + match = re.search(r"\[Image:\s*(.*?)\]", user_input) + if match: + return user_input.replace(match.group(0), "").strip(), match.group(1).strip() + + if "|" in user_input: + q, p = [x.strip() for x in user_input.split("|", 1)] + if _is_image_file(p): + return q, p + if _is_image_file(q): + return p, q + + if _is_image_file(user_input): + return "", user_input + + return user_input, "" + + +def clean_response_raw(raw: str) -> str: + cleaned = re.sub(r"<\|.*?\|>", "", raw.strip()) + cleaned = re.sub(r"\[Context:.*?\]", "", cleaned, flags=re.DOTALL) + cleaned = re.sub(r"\[FACT .*?\]", "", cleaned, flags=re.MULTILINE) + cleaned = re.sub( + r"\[ESIM_NETLIST_START\].*?\[ESIM_NETLIST_END\]", "", cleaned, flags=re.DOTALL + ) + return cleaned.strip() + + +def _history_to_text(history: List[Dict[str, str]] | None, max_turns: int = 6) -> str: + """Convert history to readable text with MORE context (6 turns).""" + if not history: + return "" + recent = history[-max_turns:] + lines: List[str] = [] + for i, t in enumerate(recent, 1): + u = (t.get("user") or "").strip() + b = (t.get("bot") or "").strip() + if u: + lines.append(f"[Turn {i}] User: {u}") + if b: + if len(b) > 300: + b = b[:300] + "..." + lines.append(f"[Turn {i}] Assistant: {b}") + return "\n".join(lines).strip() + + +def _is_follow_up_question(user_input: str, history: List[Dict[str, str]] | None) -> bool: + """ + Detect if this is a follow-up question that needs history context. + Returns True if question lacks standalone context. + """ + if not history: + return False + + user_lower = user_input.lower().strip() + words = user_lower.split() + + + if len(words) <= 7: + return True + + pronouns = ["it", "that", "this", "those", "these", "they", "them"] + if any(pronoun in words for pronoun in pronouns): + return True + + continuations = [ + "what next", "next step", "after that", "and then", "then what", + "what about", "how about", "what if", "but why", "why not" + ] + if any(phrase in user_lower for phrase in continuations): + return True + + question_starters = ["why", "how", "where", "when", "what", "which"] + if words[0] in question_starters and len(words) <= 5: + return True + + return False +import numpy as np + +def is_semantic_topic_switch( + user_input: str, + history: list, + threshold: float = 0.30 +) -> bool: + """ + Detect topic switch using embedding similarity. + Returns True if new question is unrelated to previous assistant reply. + """ + + if not history: + return False + + last_assistant_msg = None + for item in reversed(history): + if item.get("role") == "assistant": + last_assistant_msg = item.get("content") + break + + if not last_assistant_msg: + return False + + try: + emb_new = get_embedding(user_input) + emb_prev = get_embedding(last_assistant_msg) + + if not emb_new or not emb_prev: + return False + + emb_new = np.array(emb_new) + emb_prev = np.array(emb_prev) + + similarity = np.dot(emb_new, emb_prev) / ( + np.linalg.norm(emb_new) * np.linalg.norm(emb_prev) + ) + + print(f"[COPILOT] Semantic similarity = {similarity:.3f}") + + return similarity < threshold + + except Exception as e: + print(f"[COPILOT] Topic switch check failed: {e}") + return False + +# ==================== QUESTION CLASSIFICATION ==================== + +def classify_question_type(user_input: str, has_image_context: bool, + history: List[Dict[str, str]] | None = None) -> str: + """ + Classify question type for smart routing. + Returns: 'greeting', 'simple', 'esim', 'image_query', 'follow_up_image', + 'follow_up', 'netlist' + """ + user_lower = user_input.lower() + + if "[ESIM_NETLIST_START]" in user_input: + return "netlist" + + if _is_image_query(user_input): + return "image_query" + + if has_image_context: + follow_phrases = [ + "this circuit", "that circuit", "in this schematic", + "components here", "what is the value", "how many", + "the circuit", "this schematic","what","can","how" + ] + 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 + + esim_keywords = [ + "esim", "kicad", "ngspice", "spice", "simulation", "netlist", + "schematic", "convert", "gnd", "ground", ".model", ".subckt", + "singular matrix", "floating", "timestep", "convergence" + ] + if any(keyword in user_lower for keyword in esim_keywords): + return "esim" + + error_keywords = [ + "error", "fix", "problem", "issue", "warning", "missing", + "not working", "failed", "crash" + ] + if any(keyword in user_lower for keyword in error_keywords): + return "esim" + + return "simple" + + +# ==================== HANDLERS ==================== + +def handle_greeting() -> str: + return ( + "Hello! I'm eSim Copilot. I can help you with:\n" + "• Circuit analysis and netlist debugging\n" + "• Electronics concepts and SPICE simulation\n" + "• Component selection and circuit design\n\n" + "What would you like to know?" + ) + + +def handle_simple_question(user_input: str) -> str: + """ + Handles standalone questions. + Uses RAG first, then falls back to Ollama. + keep in mind that your a copilot of eSim an EDA tool + """ + return answer_with_rag_fallback(user_input) + + +def handle_follow_up(user_input: str, + image_context: Dict[str, Any], + history: List[Dict[str, str]] | None = None) -> str: + """ + Handle follow-up questions that depend on conversation history. + This handler PRIORITIZES history over RAG. + """ + history_text = _history_to_text(history, max_turns=6) + + if not history_text: + return "I need more context. Could you provide more details about your question?" + + rag_context = "" + user_lower = user_input.lower() + if any(kw in user_lower for kw in ["model", "spice", "ground", "error", "netlist"]): + rag_context = search_knowledge(user_input, n_results=2) + + prompt = ( + "You are an eSim expert assistant. The user is asking a follow-up question.\n\n" + "=== CONVERSATION HISTORY (MOST IMPORTANT) ===\n" + f"{history_text}\n" + "=============================================\n\n" + f"=== CURRENT USER QUESTION (FOLLOW-UP) ===\n{user_input}\n\n" + ) + + if rag_context: + prompt += f"=== REFERENCE MANUAL (if needed) ===\n{rag_context}\n\n" + + if image_context: + prompt += ( + f"=== CURRENT CIRCUIT CONTEXT ===\n" + f"Type: {image_context.get('circuit_analysis', {}).get('circuit_type', 'Unknown')}\n" + f"Components: {image_context.get('components', [])}\n\n" + ) + + prompt += ( + "CRITICAL INSTRUCTIONS:\n" + "1. The user's question refers to the CONVERSATION HISTORY above.\n" + "2. Identify what 'it', 'that', 'this', or 'next step' refers to by reading the history.\n" + "3. Answer based on the conversation context first, then use manual/workflows if needed.\n" + "4. If the user asks 'why', explain based on what was just discussed.\n" + "5. If the user asks 'what next' or 'next step', continue from the last instruction.\n" + "6. Be specific and reference what you're talking about (e.g., 'In the previous step, I mentioned...').\n" + "7. Keep answer concise (max 150 words).\n\n" + "Answer:" + ) + + return run_ollama(prompt, mode="default") + + +def handle_esim_question(user_input: str, + image_context: Dict[str, Any], + history: List[Dict[str, str]] | None = None) -> str: + """ + Handle eSim-specific questions with RAG + conversation history. + """ + user_lower = user_input.lower() + + sol = get_error_solution(user_input) + if sol and sol.get("description") != "General schematic error": + fixes = "\n".join(f"- {f}" for f in sol.get("fixes", [])) + cmd = sol.get("eSim_command", "") + answer = ( + f"**Detected issue:** {sol['description']}\n" + f"**Severity:** {sol.get('severity', 'unknown')}\n\n" + f"**Recommended fixes:**\n{fixes}\n\n" + ) + if cmd: + answer += f"**eSim action:** {cmd}\n" + return answer_with_rag_fallback(user_input) + + history_text = _history_to_text(history, max_turns=6) + + rag_context = search_knowledge(user_input, n_results=5) + + image_context_str = "" + if image_context: + image_context_str = ( + f"\n=== CURRENT CIRCUIT ===\n" + f"Type: {image_context.get('circuit_analysis', {}).get('circuit_type', 'Unknown')}\n" + f"Components: {image_context.get('components', [])}\n" + f"Values: {image_context.get('values', {})}\n" + ) + + prompt = ( + "You are an eSim expert. Answer using the workflows, manual, and conversation history.\n\n" + f"{ESIM_WORKFLOWS}\n\n" + f"=== MANUAL CONTEXT ===\n{rag_context}\n" + f"{image_context_str}\n" + ) + + if history_text: + prompt += f"=== CONVERSATION HISTORY ===\n{history_text}\n\n" + + prompt += ( + f"USER QUESTION: {user_input}\n\n" + "INSTRUCTIONS:\n" + "1. If the question refers to previous conversation, use the history.\n" + "2. Use exact menu paths and shortcuts from the workflows when relevant.\n" + "3. If the manual context does not contain the answer, say you need to check the manual.\n" + "4. Keep the answer concise (max 150 words).\n\n" + "Answer:" + ) + + return run_ollama(prompt, mode="default") + + +def handle_image_query(user_input: str) -> Tuple[str, Dict[str, Any]]: + """ + Handle image analysis queries. + Returns: (response_text, image_context_dict) + """ + question, image_path = _parse_image_query(user_input) + image_path = image_path.strip("'\"").strip() + + if not image_path or not os.path.exists(image_path): + return f"Error: Image not found: {image_path}", {} + + extraction = analyze_and_extract(image_path) + + if extraction.get("error"): + return f"Analysis Failed: {extraction['error']}", {} + + if not question: + error_report = detect_esim_errors(extraction, "") + + summary = ( + "**Image Analysis Complete**\n" + f"**Type:** {extraction.get('circuit_analysis', {}).get('circuit_type', 'Unknown')}\n" + f"**Components:** {extraction.get('component_counts', {})}\n" + f"**Description:** {extraction.get('vision_summary', '')}\n\n" + ) + + if extraction.get("components"): + summary += f"**Detected Components:** {', '.join(extraction['components'])}\n" + + if extraction.get("values"): + summary += "**Component Values:**\n" + for comp, val in extraction["values"].items(): + summary += f" • {comp}: {val}\n" + + summary += ( + "\n**Note:** Vision analysis may have errors. Use 'Analyze netlist' for precise results.\n" + ) + + if "🚨" in error_report or "⚠️" in error_report: + summary += f"\n{error_report}" + + return summary, extraction + + return handle_follow_up_image_question(question, extraction), extraction + + +def handle_follow_up_image_question(user_input: str, + image_context: Dict[str, Any]) -> str: + """ + Answer questions about an analyzed image using ONLY extracted data. + """ + image_context_str = ( + f"**Circuit Type:** {image_context.get('circuit_analysis', {}).get('circuit_type', 'Unknown')}\n" + f"**Components Detected:** {image_context.get('components', [])}\n" + f"**Component Values:** {image_context.get('values', {})}\n" + f"**Component Counts:** {image_context.get('component_counts', {})}\n" + f"**Description:** {image_context.get('vision_summary', '')}\n" + ) + + prompt = ( + "You are analyzing a circuit schematic. Answer using ONLY the circuit data below.\n\n" + "=== ANALYZED CIRCUIT DATA ===\n" + f"{image_context_str}\n" + "==============================\n\n" + f"USER QUESTION: {user_input}\n\n" + "STRICT INSTRUCTIONS:\n" + "1. Answer ONLY using the circuit data above - DO NOT use external knowledge.\n" + "2. For counts: use 'Component Counts'.\n" + "3. For values: use 'Component Values'.\n" + "4. For lists: use 'Components Detected'.\n" + "5. If data is missing, answer: 'The image analysis did not detect that information.'\n" + "6. Keep answer brief (2-3 sentences).\n\n" + "Answer:" + ) + + return run_ollama(prompt, mode="default") + + +def handle_netlist_analysis(user_input: str) -> str: + """ + Handle netlist analysis prompts (FACT-based prompt from GUI). + """ + raw_reply = run_ollama(user_input) + return clean_response_raw(raw_reply) + + +# ==================== MAIN ROUTER ==================== + +def handle_input(user_input: str, + history: List[Dict[str, str]] | None = None) -> str: + """ + Main router. Accepts optional conversation history for follow-up understanding. + """ + global LAST_IMAGE_CONTEXT, LAST_BOT_REPLY + + user_input = (user_input or "").strip() + if not user_input: + 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 + + question_type = classify_question_type( + user_input, bool(LAST_IMAGE_CONTEXT), history + ) + print(f"[COPILOT] Question type: {question_type}") + + try: + if question_type == "netlist": + response = handle_netlist_analysis(user_input) + + elif question_type == "greeting": + response = handle_greeting() + + elif question_type == "image_query": + response, LAST_IMAGE_CONTEXT = handle_image_query(user_input) + + elif question_type == "follow_up_image": + response = handle_follow_up_image_question(user_input, LAST_IMAGE_CONTEXT) + + elif question_type == "simple": + response = handle_simple_question(user_input) + + elif question_type == "follow_up" and history: + response = handle_follow_up(user_input, LAST_IMAGE_CONTEXT, history) + else: + response = handle_simple_question(user_input) + + LAST_BOT_REPLY = response + return response + + except Exception as e: + error_msg = f"Error processing question: {str(e)}" + print(f"[COPILOT ERROR] {error_msg}") + return error_msg + + +# ==================== WRAPPER ==================== + +class ESIMCopilotWrapper: + def __init__(self) -> None: + self.history: List[Dict[str, str]] = [] + + def handle_input(self, user_input: str) -> str: + reply = handle_input(user_input, self.history) + self.history.append({"user": user_input, "bot": reply}) + if len(self.history) > 12: + self.history = self.history[-12:] + return reply + + def analyze_schematic(self, query: str) -> str: + return self.handle_input(query) + +_GLOBAL_WRAPPER = ESIMCopilotWrapper() + + +def analyze_schematic(query: str) -> str: + return _GLOBAL_WRAPPER.handle_input(query) diff --git a/src/chatbot/error_solutions.py b/src/chatbot/error_solutions.py new file mode 100644 index 000000000..615a3d63c --- /dev/null +++ b/src/chatbot/error_solutions.py @@ -0,0 +1,106 @@ +# error_solutions.py +from typing import Dict,Any + +ERROR_SOLUTIONS = { + "no ground": { + "description": "Missing ground reference (Node 0)", + "severity": "critical", + "fixes": [ + "Add GND symbol (0) to schematic", + "Ensure all nodes have DC path to ground", + "Add 1GΩ resistors from floating nodes to GND for simulation stability", + "Use GND symbol from eSim power library" + ], + "eSim_command": "Add 'GND' symbol from 'power' library" + }, + + "floating pins": { + "description": "Unconnected component pins", + "severity": "moderate", + "fixes": [ + "Connect all unused pins to appropriate nets", + "For unused inputs: tie to VCC or GND through resistors", + "For unused outputs: leave unconnected but label properly" + ], + "eSim_command": "Use 'Place Wire' tool to connect pins" + }, + + "disconnected wires": { + "description": "Wires not properly connected to pins", + "severity": "critical", + "fixes": [ + "Zoom in and check wire endpoints touch pins", + "Use junction dots at wire intersections", + "Re-route wires to ensure proper connections" + ], + "eSim_command": "Press 'J' to add junction dots" + }, + + "missing spice model": { + "description": "Component lacks SPICE model definition", + "severity": "critical", + "fixes": [ + "Add .lib statement: .lib /usr/share/esim/models.lib", + "Check IC availability in Components/ICs.pdf", + "Use eSim library components only", + "Create custom model using Model Editor" + ], + "eSim_command": "Add '.lib /usr/share/esim/models.lib' in schematic" + }, + + "singular matrix": { + "description": "Simulation convergence error", + "severity": "critical", + "fixes": [ + "Add 1GΩ resistors from ALL nodes → GND", + "Add .options gmin=1e-12 reltol=0.01", + "Use .nodeset for initial voltages", + "Add 0.1Ω series resistors to voltage sources" + ], + "eSim_command": "Add '.options gmin=1e-12 reltol=0.01' in .cir file" + }, + + "missing component values": { + "description": "Components without specified values", + "severity": "moderate", + "fixes": [ + "Double-click components to edit values", + "Set R, C, L values before simulation", + "For ICs: specify model number", + "For sources: set voltage/current values" + ], + "eSim_command": "Double-click component → Edit Properties → Set Value" + }, + + "no load after rectifier": { + "description": "Rectifier output has no load capacitor", + "severity": "warning", + "fixes": [ + "Add filter capacitor after rectifier (100-1000μF)", + "Add load resistor to establish DC operating point", + "Add voltage regulator for stable output" + ], + "eSim_command": "Add capacitor between rectifier output and GND" + } +} + +def get_error_solution(error_message: str) -> Dict[str, Any]: + """Get detailed solution for specific error.""" + error_lower = error_message.lower() + + for error_key, solution in ERROR_SOLUTIONS.items(): + if error_key in error_lower: + return solution + + # Default solution for unknown errors + return { + "description": "General schematic error", + "severity": "unknown", + "fixes": [ + "Check all connections are proper", + "Verify component values are set", + "Ensure ground symbol is present", + "Check for duplicate component IDs" + ], + "eSim_command": "Run Design Rule Check (DRC) in KiCad" + } diff --git a/src/chatbot/image_handler.py b/src/chatbot/image_handler.py new file mode 100644 index 000000000..cd8744791 --- /dev/null +++ b/src/chatbot/image_handler.py @@ -0,0 +1,247 @@ +import os +import json +import base64 +import io +import time +from typing import Dict, Any +from PIL import Image +MAX_IMAGE_BYTES = int(0.5*1024 * 1024) +from .ollama_runner import run_ollama_vision + +# === IMPORT PADDLE OCR === +try: + from paddleocr import PaddleOCR + import logging + logging.getLogger("ppocr").setLevel(logging.ERROR) + + # CRITICAL FIX: Disabled MKLDNN and Angle Classification to prevent VM Crashes + ocr_engine = PaddleOCR( + use_angle_cls=False, # <--- MUST BE FALSE TO STOP SIGABRT + lang='en', + use_gpu=False, # Force CPU + enable_mkldnn=False, # <--- MUST BE FALSE FOR PADDLE v3 COMPATIBILITY + use_mp=False, # Disable multiprocessing + show_log=False + ) + HAS_PADDLE = True + print("[INIT] PaddleOCR initialized (Safe Mode).") +except Exception as e: + HAS_PADDLE = False + print(f"[INIT] PaddleOCR init failed: {e}") + print("[INIT] Vision analysis unavailable. Text and netlist analysis still work.") + + +def encode_image(image_path: str) -> str: + """Convert image to base64 string.""" + with open(image_path, "rb") as image_file: + return base64.b64encode(image_file.read()).decode("utf-8") + + +def optimize_image_for_vision(image_path: str) -> bytes: + """ + Resize large images to reduce vision model processing time. + Target: Max 1920x1080 while maintaining aspect ratio. + """ + try: + img = Image.open(image_path) + + if img.mode not in ('RGB', 'L'): + img = img.convert('RGB') + + max_width = 1920 + max_height = 1080 + + if img.width > max_width or img.height > max_height: + # Calculate scaling factor + scale = min(max_width / img.width, max_height / img.height) + new_size = (int(img.width * scale), int(img.height * scale)) + img = img.resize(new_size, Image.Resampling.LANCZOS) + print(f"[IMAGE] Resized from {img.width}x{img.height} to {new_size[0]}x{new_size[1]}") + + # Convert to bytes (PNG format prevents compression artifacts on text) + buffer = io.BytesIO() + img.save(buffer, format='PNG', optimize=True, quality=85) + return buffer.getvalue() + + except Exception as e: + print(f"[IMAGE] Optimization failed: {e}, using original") + with open(image_path, 'rb') as f: + return f.read() + + +def extract_text_with_paddle(image_path: str) -> str: + """Extract text using PaddleOCR (Handles rotated/vertical text excellently).""" + if not HAS_PADDLE: + return "" + try: + result = ocr_engine.ocr(image_path, cls=True) + detected_texts = [] + if result and result[0]: + for line in result[0]: + text = line[1][0] + conf = line[1][1] + + if conf > 0.6: + detected_texts.append(text) + + full_text = " ".join(detected_texts) + return full_text + + except Exception as e: + print(f"[OCR] PaddleOCR Failed: {e}") + return "" + +def analyze_and_extract(image_path: str) -> Dict[str, Any]: + """ + Analyze schematic with image optimization, PaddleOCR text injection, and timeout handling. + Rejects images larger than 0.5 MB. + """ + if not os.path.exists(image_path): + return { + "error": "Image file not found", + "vision_summary": "", + "component_counts": {}, + "circuit_analysis": { + "circuit_type": "Unknown", + "design_errors": [], + "design_warnings": [] + }, + "components": [], + "values": {} + } + + try: + file_size = os.path.getsize(image_path) + except OSError as e: + return { + "error": f"Could not read image size: {e}", + "vision_summary": "", + "component_counts": {}, + "circuit_analysis": { + "circuit_type": "Unknown", + "design_errors": [], + "design_warnings": [] + }, + "components": [], + "values": {} + } + + if file_size > MAX_IMAGE_BYTES: + size_mb = round(file_size / (1024 * 1024), 2) + return { + "error": f"Image too large ({size_mb} MB). Max allowed size is 0.5 MB.", + "vision_summary": "", + "component_counts": {}, + "circuit_analysis": { + "circuit_type": "Unknown", + "design_errors": ["Image file size exceeded 0.5 MB limit"], + "design_warnings": [] + }, + "components": [], + "values": {} + } + + # === OPTIMIZE IMAGE BEFORE SENDING === + print(f"[VISION] Processing image: {os.path.basename(image_path)}") + image_bytes = optimize_image_for_vision(image_path) + + # === EXTRACT OCR TEXT (CRITICAL STEP) === + ocr_text = extract_text_with_paddle(image_path) + + if ocr_text: + clean_ocr = ocr_text.strip() + print(f"[VISION] PaddleOCR Hints injected: {clean_ocr[:100]}...") + else: + clean_ocr = "No readable text detected." + + # === PROMPT WITH CONTEXT === + prompt = f""" +ANALYZE THIS ELECTRONICS SCHEMATIC IMAGE. + +CONTEXT FROM OCR SCAN (Text detected in image): +"{clean_ocr}" + +INSTRUCTIONS: +1. Use the OCR text to identify component labels (e.g., if you see "D1" text, there is a Diode, R1,R2,R3... for resistor). +2. Look for rotated text labels near symbols. +3. Identify the circuit topology. + +VERY IMPORTANT INSTRUCTIONS: +1. DON'T OVERCALCULATE MODEL COUNT LIKE MODEL COUNT + OCR COUNT +2. IF THERE IS ANY VALUE NOT PRESENT FOR ANY COMPONENT JUST ADD A QUESTION MARK IN FRONT OF IT + +OUTPUT RULES: +1. Return ONLY valid JSON. +2. Structure: + + +RESPOND WITH JSON ONLY. +""" + + max_retries = 2 + for attempt in range(max_retries): + try: + print(f"[VISION] Attempt {attempt + 1}/{max_retries}...") + + response_text = run_ollama_vision(prompt, image_bytes) + + cleaned_json = response_text.replace("```json", "").replace("```", "").strip() + + if "{" in cleaned_json and "}" in cleaned_json: + start = cleaned_json.index("{") + end = cleaned_json.rindex("}") + 1 + cleaned_json = cleaned_json[start:end] + + data = json.loads(cleaned_json) + + required_keys = ["vision_summary", "component_counts", "circuit_analysis", "components", "values"] + for key in required_keys: + if key not in data: + raise ValueError(f"Missing required key: {key}") + + if not isinstance(data.get("circuit_analysis"), dict): + data["circuit_analysis"] = {"circuit_type": "Unknown", "design_errors": [], "design_warnings": []} + + if "design_errors" not in data["circuit_analysis"]: + data["circuit_analysis"]["design_errors"] = [] + + if not data.get("component_counts") or all(v == 0 for v in data.get("component_counts", {}).values()): + counts = {"R": 0, "C": 0, "U": 0, "Q": 0, "D": 0, "L": 0, "Misc": 0} + for comp in data.get("components", []): + if isinstance(comp, str) and len(comp) > 0: + comp_type = comp[0].upper() + if comp_type in counts: + counts[comp_type] += 1 + elif "DIODE" in comp.upper() or comp.startswith("D"): + counts["D"] = counts.get("D", 0) + 1 + data["component_counts"] = counts + + if data.get("components"): + data["components"] = list(dict.fromkeys(data["components"])) + + print(f"[VISION] Success: {data.get('circuit_analysis', {}).get('circuit_type', 'Unknown')}") + return data + + except Exception as e: + print(f"[VISION] Attempt {attempt + 1} failed: {str(e)}") + if attempt == max_retries - 1: + return { + "error": f"Vision analysis failed: {str(e)}", + "vision_summary": "Unable to analyze circuit image", + "component_counts": {}, + "circuit_analysis": { + "circuit_type": "Unknown", + "design_errors": ["Analysis timed out or failed"], + "design_warnings": [] + }, + "components": [], + "values": {} + } + else: + import time + time.sleep(2) + + +def analyze_image(image_path: str, question: str | None = None, preprocess: bool = True) -> str: + """Helper for manual testing.""" + return str(analyze_and_extract(image_path)) \ No newline at end of file diff --git a/src/chatbot/knowledge_base.py b/src/chatbot/knowledge_base.py new file mode 100644 index 000000000..14ea4cc17 --- /dev/null +++ b/src/chatbot/knowledge_base.py @@ -0,0 +1,144 @@ +import os +import chromadb +from .ollama_runner import get_embedding + +# ==================== DATABASE SETUP ==================== + +def _default_db_path() -> str: + xdg_data_home = os.environ.get("XDG_DATA_HOME", "").strip() + if not xdg_data_home: + xdg_data_home = os.path.join(os.path.expanduser("~"), ".local", "share") + return os.path.join(xdg_data_home, "esim-copilot", "chroma") + +db_path = os.environ.get("ESIM_COPILOT_DB_PATH", "").strip() or _default_db_path() +os.makedirs(db_path, exist_ok=True) +chroma_client = chromadb.PersistentClient(path=db_path) + +collection = chroma_client.get_or_create_collection(name="esim_manuals") + +# ==================== INGESTION ==================== +def ingest_pdfs(manuals_directory: str) -> None: + """ + Read the single master text file and index it. + Call this once from src/ingest.py. + """ + if not os.path.exists(manuals_directory): + print("Directory not found.") + return + + # Clear existing DB to ensure no duplicates from old files + print("Clearing old database...") + try: + chroma_client.delete_collection("esim_manuals") + global collection + collection = chroma_client.get_or_create_collection(name="esim_manuals") + except Exception as e: + print(f"Warning clearing DB: {e}") + + # 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 + + 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() + if len(section) < 50: + continue + + # Further split large sections by double newlines if needed + sub_chunks = [c.strip() for c in section.split("\n\n") if len(c) > 50] + + 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}") + 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}") + + +# ==================== SEARCH ==================== + +# Relevance threshold: ChromaDB returns distances (L2 or cosine). +# Lower distance = more similar. Filter out chunks with distance > threshold. +RELEVANCE_THRESHOLD = float(os.environ.get("ESIM_RAG_RELEVANCE_THRESHOLD", "1.0")) + + +def search_knowledge(query: str, n_results: int = 4) -> str: + """ + Semantic search with relevance threshold to reduce hallucination. + Filters out chunks with distance > RELEVANCE_THRESHOLD. + """ + try: + query_embed = get_embedding(query) + if not query_embed: + return "" + + results = collection.query( + query_embeddings=[query_embed], + n_results=n_results, + include=["documents", "distances"], + ) + + docs_list = results.get("documents", [[]]) + distances_list = results.get("distances", [[]]) + + if not docs_list or not docs_list[0]: + return "" + + docs = docs_list[0] + distances = distances_list[0] if distances_list else [] + + # Filter by relevance threshold (lower distance = more similar) + if distances and len(distances) == len(docs): + filtered = [ + (doc, d) for doc, d in zip(docs, distances) + if d <= RELEVANCE_THRESHOLD + ] + if filtered: + selected_chunks = [doc for doc, _ in filtered] + else: + return "" + else: + selected_chunks = docs + + context_text = "\n\n...\n\n".join(selected_chunks) + if len(context_text) > 3500: + context_text = context_text[:3500] + + header = "=== ESIM OFFICIAL DOCUMENTATION ===\n" + return f"{header}{context_text}\n===================================\n" + + except Exception as e: + print(f"RAG Error: {e}") + return "" diff --git a/src/chatbot/ollama_runner.py b/src/chatbot/ollama_runner.py new file mode 100644 index 000000000..ae754bd0b --- /dev/null +++ b/src/chatbot/ollama_runner.py @@ -0,0 +1,192 @@ +import os +import ollama +import json +import time + +# ==================== CLIENT ==================== + +ollama_client = ollama.Client( + host="http://localhost:11434", + timeout=300.0, +) + +# ==================== SETTINGS ==================== + +_SETTINGS_DIR = os.path.join( + os.path.expanduser("~"), ".local", "share", "esim-copilot" +) +_SETTINGS_PATH = os.path.join(_SETTINGS_DIR, "settings.json") + +_DEFAULT_TEXT_MODEL = "qwen2.5:3b" +_DEFAULT_VISION_MODEL = "minicpm-v:latest" +EMBED_MODEL = "nomic-embed-text" + + +def load_model_settings() -> dict: + """Load persisted model preferences from disk.""" + try: + with open(_SETTINGS_PATH, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return {} + + +def save_model_settings(text_model: str, vision_model: str) -> None: + """Persist model preferences to disk.""" + os.makedirs(_SETTINGS_DIR, exist_ok=True) + try: + with open(_SETTINGS_PATH, "w", encoding="utf-8") as f: + json.dump({"text_model": text_model, "vision_model": vision_model}, f, indent=2) + except Exception as e: + print(f"[SETTINGS] Failed to save: {e}") + + +def list_available_models() -> list: + """Query Ollama for installed models. Returns list of model name strings.""" + try: + resp = ollama_client.list() + names = [m["name"] for m in resp.get("models", [])] + return names if names else [_DEFAULT_TEXT_MODEL, _DEFAULT_VISION_MODEL] + except Exception: + return [_DEFAULT_TEXT_MODEL, _DEFAULT_VISION_MODEL, EMBED_MODEL] + + +# Load settings and initialise model dicts +_settings = load_model_settings() + +VISION_MODELS = {"primary": _settings.get("vision_model", _DEFAULT_VISION_MODEL)} +TEXT_MODELS = {"default": _settings.get("text_model", _DEFAULT_TEXT_MODEL)} + + +def reload_model_settings() -> None: + """Re-read settings from disk and update running dicts (called after save).""" + s = load_model_settings() + VISION_MODELS["primary"] = s.get("vision_model", _DEFAULT_VISION_MODEL) + TEXT_MODELS["default"] = s.get("text_model", _DEFAULT_TEXT_MODEL) + + +# ==================== VISION ==================== + +def run_ollama_vision(prompt: str, image_input) -> str: + """Call vision model with Chain-of-Thought for better accuracy.""" + model = VISION_MODELS["primary"] + + try: + import base64 + + image_b64 = "" + if isinstance(image_input, bytes): + image_b64 = base64.b64encode(image_input).decode("utf-8") + elif isinstance(image_input, str) and os.path.isfile(image_input): + with open(image_input, "rb") as f: + image_b64 = base64.b64encode(f.read()).decode("utf-8") + elif isinstance(image_input, str) and len(image_input) > 100: + image_b64 = image_input + else: + raise ValueError("Invalid image input format") + + system_prompt = ( + "You are an expert Electronics Engineer using eSim.\n" + "Analyze the schematic image carefully.\n\n" + "STEP 1: THINKING PROCESS\n" + "- List visible components (e.g., 'I see 4 diodes in a bridge...').\n" + "- Trace connections (e.g., 'Resistor R1 is in series...').\n" + "- Check against the OCR text provided.\n\n" + "STEP 2: JSON OUTPUT\n" + "After your analysis, output a SINGLE JSON object wrapped in ```json ... ```.\n" + "Structure:\n" + "{\n" + ' "vision_summary": "Summary string",\n' + ' "component_counts": {"R": 0, "C": 0, "D": 0, "Q": 0, "U": 0},\n' + ' "circuit_analysis": {\n' + ' "circuit_type": "Rectifier/Amplifier/etc",\n' + ' "design_errors": [],\n' + ' "design_warnings": []\n' + ' },\n' + ' "components": ["R1", "D1"],\n' + ' "values": {"R1": "1k"}\n' + "}\n" + ) + + resp = ollama_client.chat( + model=model, + messages=[ + {"role": "system", "content": system_prompt}, + { + "role": "user", + "content": prompt, + "images": [image_b64], + }, + ], + options={ + "temperature": 0.0, + "num_ctx": 8192, + "num_predict": 1024, + }, + ) + + content = resp["message"]["content"] + + import re + json_match = re.search(r'```json\s*(\{.*?\})\s*```', content, re.DOTALL) + if json_match: + return json_match.group(1) + + start = content.find('{') + end = content.rfind('}') + 1 + if start != -1 and end != -1: + return content[start:end] + + return "{}" + + except Exception as e: + print(f"[VISION ERROR] {e}") + return json.dumps({ + "vision_summary": f"Vision failed: {str(e)[:50]}", + "component_counts": {}, + "circuit_analysis": {"circuit_type": "Error", "design_errors": [], "design_warnings": []}, + "components": [], + "values": {}, + }) + + +# ==================== TEXT ==================== + +def run_ollama(prompt: str, mode: str = "default") -> str: + """Run text model with focused parameters.""" + model = TEXT_MODELS.get(mode, TEXT_MODELS["default"]) + + try: + resp = ollama_client.chat( + model=model, + messages=[ + { + "role": "system", + "content": "You are an eSim and electronics expert. Be concise, accurate, and practical.", + }, + {"role": "user", "content": prompt}, + ], + options={ + "temperature": 0.05, + "num_ctx": 2048, + "num_predict": 400, + "top_p": 0.9, + "repeat_penalty": 1.1, + }, + ) + return resp["message"]["content"].strip() + + except Exception as e: + return f"[Error] {str(e)}" + + +# ==================== EMBEDDINGS ==================== + +def get_embedding(text: str): + """Get text embeddings for RAG.""" + try: + r = ollama_client.embeddings(model=EMBED_MODEL, prompt=text) + return r["embedding"] + except Exception as e: + print(f"[EMBED ERROR] {e}") + return None diff --git a/src/chatbot/stt_handler.py b/src/chatbot/stt_handler.py new file mode 100644 index 000000000..f2d536066 --- /dev/null +++ b/src/chatbot/stt_handler.py @@ -0,0 +1,92 @@ +import os +import json +import queue +import time + +try: + import sounddevice as sd + from vosk import Model, KaldiRecognizer + _HAS_STT = True +except Exception: + sd = None + Model = None + KaldiRecognizer = None + _HAS_STT = False + +_MODEL = None + +DEFAULT_VOSK_DIR = os.path.join( + os.path.expanduser("~"), ".local", "share", + "esim-copilot", "vosk-model-small-en-us-0.15", +) + +def _get_model(): + global _MODEL + if not _HAS_STT: + raise RuntimeError( + "Speech-to-text is not available (missing vosk/sounddevice)." + ) + model_path = os.environ.get("VOSK_MODEL_PATH", "").strip() + if not model_path: + model_path = DEFAULT_VOSK_DIR + if not os.path.isdir(model_path): + raise RuntimeError( + f"Vosk model path not found. Set VOSK_MODEL_PATH or install at: {model_path}" + ) + if _MODEL is None: + _MODEL = Model(model_path) + return _MODEL + +def listen_to_mic(should_stop=lambda: False, max_silence_sec=3, samplerate=16000, phrase_limit_sec=8) -> str: + """ + Offline STT using Vosk. + Returns recognized text, or "" if cancelled / timed out. + """ + if not _HAS_STT: + raise RuntimeError("Speech-to-text is not installed or failed to load.") + q = queue.Queue() + rec = KaldiRecognizer(_get_model(), samplerate) + + started = False + t0 = time.time() + t_speech = None + + def callback(indata, frames, time_info, status): + q.put(bytes(indata)) + + with sd.RawInputStream( + samplerate=samplerate, + channels=1, + dtype="int16", + blocksize=8000, + callback=callback, + ): + while True: + if should_stop(): + return "" + + now = time.time() + + # Stop after silence + if not started and (now - t0) >= max_silence_sec: + return "" + + if started and t_speech and (now - t_speech) >= phrase_limit_sec: + break + + try: + data = q.get(timeout=0.2) + except queue.Empty: + continue + + if rec.AcceptWaveform(data): + text = json.loads(rec.Result()).get("text", "").strip() + if text: + return text + else: + partial = json.loads(rec.PartialResult()).get("partial", "").strip() + if partial and not started: + started = True + t_speech = now + + return json.loads(rec.FinalResult()).get("text", "").strip() diff --git a/src/frontEnd/Application.py b/src/frontEnd/Application.py index 35aae135f..47c778a11 100644 --- a/src/frontEnd/Application.py +++ b/src/frontEnd/Application.py @@ -1,34 +1,56 @@ # ========================================================================= -# FILE: Application.py +# FILE: Application.py # -# USAGE: --- +# USAGE: --- # -# DESCRIPTION: This main file use to start the Application +# DESCRIPTION: This main file use to start the Application # -# OPTIONS: --- -# REQUIREMENTS: --- -# BUGS: --- -# NOTES: --- -# AUTHOR: Fahim Khan, fahim.elex@gmail.com -# MAINTAINED: Rahul Paknikar, rahulp@iitb.ac.in -# Sumanto Kar, sumantokar@iitb.ac.in -# Pranav P, pranavsdreams@gmail.com -# ORGANIZATION: eSim Team at FOSSEE, IIT Bombay -# CREATED: Tuesday 24 February 2015 -# REVISION: Wednesday 07 June 2023 +# OPTIONS: --- +# REQUIREMENTS: --- +# BUGS: --- +# NOTES: --- +# AUTHOR: Fahim Khan, fahim.elex@gmail.com +# MAINTAINED: Rahul Paknikar, rahulp@iitb.ac.in +# Sumanto Kar, sumantokar@iitb.ac.in +# Pranav P, pranavsdreams@gmail.com +# ORGANIZATION: eSim Team at FOSSEE, IIT Bombay +# CREATED: Tuesday 24 February 2015 +# REVISION: Wednesday 07 June 2023 # ========================================================================= import os import sys import traceback -import webbrowser -if os.name == 'nt': - from frontEnd import pathmagic # noqa:F401 - init_path = '' -else: - import pathmagic # noqa:F401 - init_path = '../../' +current_dir = os.path.dirname(os.path.abspath(__file__)) +if current_dir not in sys.path: + sys.path.insert(0, current_dir) + +# ================= GLOBAL STATE ================= +CHATBOT_AVAILABLE = False + + +try: + if os.name == 'nt': + from frontEnd import pathmagic + init_path = '' + else: + import pathmagic + init_path = '../../' + print(f"[BOOT] pathmagic imported successfully, init_path='{init_path}'") +except ImportError as e: + print(f"[BOOT WARNING] Could not import pathmagic: {e}") + print("[BOOT WARNING] Using fallback path settings") + + if os.name == 'nt': + init_path = '' + else: + init_path = '../../' + + +os.environ['DISABLE_MODEL_SOURCE_CHECK'] = 'True' +print("[BOOT] DISABLE_MODEL_SOURCE_CHECK set to True") + from PyQt5 import QtGui, QtCore, QtWidgets from PyQt5.Qt import QSize @@ -41,34 +63,38 @@ from projManagement.Kicad import Kicad from projManagement.Validation import Validation from projManagement import Worker +from PyQt5.QtCore import QTimer -# Its our main window of application. +try: + from frontEnd.Chatbot import ChatbotGUI + CHATBOT_AVAILABLE = True +except ImportError: + CHATBOT_AVAILABLE = False + print("Chatbot module not available. Chatbot features will be disabled.") class Application(QtWidgets.QMainWindow): + """This class initializes all objects used in this file.""" global project_name simulationEndSignal = QtCore.pyqtSignal(QtCore.QProcess.ExitStatus, int) + errorDetectedSignal = QtCore.pyqtSignal(str) def __init__(self, *args): """Initialize main Application window.""" - # Calling __init__ of super class QtWidgets.QMainWindow.__init__(self, *args) - # Set slot for simulation end signal to plot simulation data + # Set slot for simulation end signal self.simulationEndSignal.connect(self.plotSimulationData) + self.errorDetectedSignal.connect(self.handleError) - #the plotFlag - self.plotFlag = False - - # Creating require Object self.obj_workspace = Workspace.Workspace() self.obj_Mainview = MainView() self.obj_kicad = Kicad(self.obj_Mainview.obj_dockarea) self.obj_appconfig = Appconfig() self.obj_validation = Validation() - # Initialize all widget + self.setCentralWidget(self.obj_Mainview) self.initToolBar() @@ -86,16 +112,47 @@ def __init__(self, *args): self.systemTrayIcon.setIcon(QtGui.QIcon(init_path + 'images/logo.png')) self.systemTrayIcon.setVisible(True) + def initChatbot(self): + """Initialize chatbot with proper context.""" + if not CHATBOT_AVAILABLE: + return + + try: + self.chatbot_window = ChatbotGUI(self) + + self.errorDetectedSignal.connect(self.auto_debug_error) + + except Exception as e: + print(f"Failed to initialize chatbot: {e}") + + def auto_debug_error(self, error_message): + """Automatically send simulation errors to chatbot.""" + if not CHATBOT_AVAILABLE or not hasattr(self, 'chatbot_window'): + return + + self.projDir = self.obj_appconfig.current_project["ProjectName"] + if not self.projDir: + return + + # Look for error logs + error_log_path = os.path.join(self.projDir, "ngspice_error.log") + if os.path.exists(error_log_path): + + if (hasattr(self, 'chatbot_window') and + self.chatbot_window.isVisible()): + + QTimer.singleShot(1000, lambda: self.send_error_to_chatbot(error_log_path)) + def initToolBar(self): """ This function initializes Tool Bars. It setups the icons, short-cuts and defining functonality for: - - Top-tool-bar (New project, Open project, Close project, \ - Mode switch, Help option) - - Left-tool-bar (Open Schematic, Convert KiCad to Ngspice, \ - Simuation, Model Editor, Subcircuit, NGHDL, Modelica \ - Converter, OM Optimisation) + - Top-tool-bar (New project, Open project, Close project, \ + Mode switch, Help option) + - Left-tool-bar (Open Schematic, Convert KiCad to Ngspice, \ + Simuation, Model Editor, Subcircuit, NGHDL, Modelica \ + Converter, OM Optimisation) """ # Top Tool bar self.newproj = QtWidgets.QAction( @@ -133,23 +190,14 @@ def initToolBar(self): self.helpfile.setShortcut('Ctrl+H') self.helpfile.triggered.connect(self.help_project) - # added devDocs logo and called functions - self.devdocs = QtWidgets.QAction( - QtGui.QIcon(init_path + 'images/dev_docs.png'), - 'Dev Docs', self - ) - self.devdocs.setShortcut('Ctrl+D') - self.devdocs.triggered.connect(self.dev_docs) - self.topToolbar = self.addToolBar('Top Tool Bar') self.topToolbar.addAction(self.newproj) self.topToolbar.addAction(self.openproj) self.topToolbar.addAction(self.closeproj) self.topToolbar.addAction(self.wrkspce) self.topToolbar.addAction(self.helpfile) - self.topToolbar.addAction(self.devdocs) - # ## This part is meant for SoC Generation which is currently ## + # ## This part is meant for SoC Generation which is currently ## # ## under development and will be will be required in future. ## # self.soc = QtWidgets.QToolButton(self) # self.soc.setText('Generate SoC') @@ -160,7 +208,7 @@ def initToolBar(self): # '

Thank you for your patience!!!' # ) # self.soc.setStyleSheet(" \ - # QWidget { border-radius: 15px; border: 1px \ + # QWidget { border-radius: 15px; border: 1px \ # solid gray; padding: 10px; margin-left: 20px; } \ # ") # self.soc.clicked.connect(self.showSoCRelease) @@ -201,7 +249,7 @@ def initToolBar(self): QtGui.QIcon(init_path + 'images/ngspice.png'), 'Simulate', self ) - self.ngspice.triggered.connect(self.plotFlagPopBox) + self.ngspice.triggered.connect(self.open_ngspice) self.model = QtWidgets.QAction( QtGui.QIcon(init_path + 'images/model.png'), @@ -240,9 +288,17 @@ def initToolBar(self): self.conToeSim = QtWidgets.QAction( QtGui.QIcon(init_path + 'images/icon.png'), - 'Schematic converter', self + 'Schematics converter', self ) self.conToeSim.triggered.connect(self.open_conToeSim) + # ... existing actions ... + + self.copilot_action = QtWidgets.QAction( + QtGui.QIcon(init_path + 'images/chatbot.png'), # Ensure this icon exists or use fallback + 'eSim Copilot', self + ) + self.copilot_action.setToolTip("AI Circuit Assistant") + self.copilot_action.triggered.connect(self.openChatbot) # Adding Action Widget to tool bar self.lefttoolbar = QtWidgets.QToolBar('Left ToolBar') @@ -257,47 +313,28 @@ def initToolBar(self): self.lefttoolbar.addAction(self.omedit) self.lefttoolbar.addAction(self.omoptim) self.lefttoolbar.addAction(self.conToeSim) + self.lefttoolbar.addSeparator() + self.lefttoolbar.addAction(self.copilot_action) self.lefttoolbar.setOrientation(QtCore.Qt.Vertical) self.lefttoolbar.setIconSize(QSize(40, 40)) - def plotFlagPopBox(self): - """This function displays a pop-up box with message- Do you want Ngspice plots? and oprions Yes and NO. - - If the user clicks on Yes, both the NgSpice and python plots are displayed and if No is clicked then only the python plots.""" - - msg_box = QtWidgets.QMessageBox(self) - msg_box.setWindowTitle("Ngspice Plots") - msg_box.setText("Do you want Ngspice plots?") - - yes_button = msg_box.addButton("Yes", QtWidgets.QMessageBox.YesRole) - no_button = msg_box.addButton("No", QtWidgets.QMessageBox.NoRole) - - msg_box.exec_() - - if msg_box.clickedButton() == yes_button: - self.plotFlag = True - else: - self.plotFlag = False - - self.open_ngspice() - def closeEvent(self, event): ''' This function closes the ongoing program (process). When exit button is pressed a Message box pops out with \ exit message and buttons 'Yes', 'No'. - 1. If 'Yes' is pressed: - - check that program (process) in procThread_list \ - (a list made in Appconfig.py): + 1. If 'Yes' is pressed: + - check that program (process) in procThread_list \ + (a list made in Appconfig.py): - - if available it terminates that program. - - if the program (process) is not available, \ - then check it in process_obj (a list made in \ - Appconfig.py) and if found, it closes the program. + - if available it terminates that program. + - if the program (process) is not available, \ + then check it in process_obj (a list made in \ + Appconfig.py) and if found, it closes the program. - 2. If 'No' is pressed: - - the program just continues as it was doing earlier. + 2. If 'No' is pressed: + - the program just continues as it was doing earlier. ''' exit_msg = "Are you sure you want to exit the program?" exit_msg += " All unsaved data will be lost." @@ -327,6 +364,11 @@ def closeEvent(self, event): self.project.close() except BaseException: pass + + # Close chatbot if open + if CHATBOT_AVAILABLE and hasattr(self, 'chatbot_window') and self.chatbot_window.isVisible(): + self.chatbot_window.close() + event.accept() self.systemTrayIcon.showMessage('Exit', 'eSim is Closed.') @@ -362,6 +404,41 @@ def new_project(self): except BaseException: pass + + def openChatbot(self): + if not CHATBOT_AVAILABLE: + QtWidgets.QMessageBox.warning( + self, "Error", + "Chatbot unavailable. Please check backend dependencies." + ) + return + + try: + if not hasattr(self, "chatbotDock") or self.chatbotDock is None: + from frontEnd.Chatbot import createchatbotdock + self.chatbotDock = createchatbotdock(self) + + self.chatbotDock.setAllowedAreas(QtCore.Qt.NoDockWidgetArea) + self.addDockWidget(QtCore.Qt.RightDockWidgetArea, self.chatbotDock) + + self.chatbotDock.setFloating(True) + g = self.geometry() + self.chatbotDock.resize(450, 600) + self.chatbotDock.move(g.x() + g.width() - 470, g.y() + 50) + self.chatbotDock.show() + self.chatbotDock.raise_() + + # Keep a reference to the widget for error‑debug integration + self.chatbot_window = self.chatbotDock.widget() + + # No need to call set_project_context here anymore + + except Exception as e: + print("Error opening chatbot:", e) + QtWidgets.QMessageBox.warning( + self, "Error", f"Could not open chatbot: {str(e)}" + ) + def open_project(self): """This project call Open Project Info class.""" print("Function : Open Project") @@ -378,12 +455,12 @@ def close_project(self): This function closes the saved project. It first checks whether project (file) is present in list. - - If present: - - it first kills that process-id. - - closes that file. - - Shows message "Current project is closed" + - If present: + - it first kills that process-id. + - closes that file. + - Shows message "Current project is closed" - - If not present: pass + - If not present: pass """ print("Function : Close Project") current_project = self.obj_appconfig.current_project['ProjectName'] @@ -415,29 +492,20 @@ def change_workspace(self): def help_project(self): """ This function opens usermanual in dockarea. - - It prints the message ""Function : Help"" - - Uses print_info() method of class Appconfig - from Configuration/Appconfig.py file. - - Call method usermanual() from ./DockArea.py. + - It prints the message ""Function : Help"" + - Uses print_info() method of class Appconfig + from Configuration/Appconfig.py file. + - Call method usermanual() from ./DockArea.py. """ print("Function : Help") self.obj_appconfig.print_info('Help is called') print("Current Project is : ", self.obj_appconfig.current_project) self.obj_Mainview.obj_dockarea.usermanual() - def dev_docs(self): - """ - This function guides the user to readthedocs website for the developer docs - """ - print("Function : DevDocs") - self.obj_appconfig.print_info('DevDocs is called') - print("Current Project is : ", self.obj_appconfig.current_project) - webbrowser.open("https://esim.readthedocs.io/en/latest/index.html") - @QtCore.pyqtSlot(QtCore.QProcess.ExitStatus, int) def plotSimulationData(self, exitCode, exitStatus): """Enables interaction for new simulation and - displays the plotter dock where graphs can be plotted. + displays the plotter dock where graphs can be plotted. """ self.ngspice.setEnabled(True) self.conversion.setEnabled(True) @@ -459,6 +527,40 @@ def plotSimulationData(self, exitCode, exitStatus): self.obj_appconfig.print_error('Exception Message : ' + str(e)) + self.errorDetectedSignal.emit("Simulation failed.") + + def handleError(self): + """Slot called when a simulation error happens.""" + if not CHATBOT_AVAILABLE: + return + + self.projDir = self.obj_appconfig.current_project["ProjectName"] + if not self.projDir: + return + + error_log_path = os.path.join(self.projDir, "ngspice_error.log") + + # Only try to send if chatbot is visible and has debug_error() + if (hasattr(self, 'chatbot_window') and + self.chatbot_window.isVisible() and + hasattr(self.chatbot_window, 'debug_error')): + # Use a small delay to ensure the error log is written + QTimer.singleShot( + 1000, + lambda: self.send_error_to_chatbot(error_log_path) + ) + + def send_error_to_chatbot(self, error_log_path: str): + """Send ngspice error log to chatbot for debugging.""" + try: + if os.path.exists(error_log_path): + with open(error_log_path, 'r') as f: + error_content = f.read() + if error_content.strip(): + self.chatbot_window.debug_error(error_log_path) + except Exception as e: + print(f"Error sending to chatbot: {e}") + def open_ngspice(self): """This Function execute ngspice on current project.""" projDir = self.obj_appconfig.current_project["ProjectName"] @@ -468,20 +570,24 @@ def open_ngspice(self): ngspiceNetlist = os.path.join(projDir, projName + ".cir.out") if not os.path.isfile(ngspiceNetlist): - print( - "Netlist file (*.cir.out) not found." - ) + print("Netlist file (*.cir.out) not found.") self.msg = QtWidgets.QErrorMessage() self.msg.setModal(True) self.msg.setWindowTitle("Error Message") - self.msg.showMessage( - 'Netlist (*.cir.out) not found.' - ) + self.msg.showMessage('Netlist (*.cir.out) not found.') self.msg.exec_() return + # Pass chatbot reference into ngspiceEditor + chatbot_ref = ( + self.chatbot_window + if CHATBOT_AVAILABLE and hasattr(self, "chatbot_window") + else None + ) + self.obj_Mainview.obj_dockarea.ngspiceEditor( - projName, ngspiceNetlist, self.simulationEndSignal, self.plotFlag) + projName, ngspiceNetlist, self.simulationEndSignal, chatbot_ref + ) self.ngspice.setEnabled(False) self.conversion.setEnabled(False) @@ -504,9 +610,9 @@ def open_subcircuit(self): When 'subcircuit' icon is clicked wich is present in left-tool-bar of main page: - - Meassge shown on screen "Subcircuit editor is called". - - 'subcircuiteditor()' function is called using object - 'obj_dockarea' of class 'Mainview'. + - Meassge shown on screen "Subcircuit editor is called". + - 'subcircuiteditor()' function is called using object + 'obj_dockarea' of class 'Mainview'. """ print("Function : Subcircuit editor") self.obj_appconfig.print_info('Subcircuit editor is called') @@ -517,10 +623,10 @@ def open_nghdl(self): This function calls NGHDL option in left-tool-bar. It uses validateTool() method from Validation.py: - - If 'nghdl' is present in executables list then - it passes command 'nghdl -e' to WorkerThread class of - Worker.py. - - If 'nghdl' is not present, then it shows error message. + - If 'nghdl' is present in executables list then + it passes command 'nghdl -e' to WorkerThread class of + Worker.py. + - If 'nghdl' is not present, then it shows error message. """ print("Function : NGHDL") self.obj_appconfig.print_info('NGHDL is called') @@ -545,9 +651,9 @@ def open_makerchip(self): When 'subcircuit' icon is clicked wich is present in left-tool-bar of main page: - - Meassge shown on screen "Subcircuit editor is called". - - 'subcircuiteditor()' function is called using object - 'obj_dockarea' of class 'Mainview'. + - Meassge shown on screen "Subcircuit editor is called". + - 'subcircuiteditor()' function is called using object + 'obj_dockarea' of class 'Mainview'. """ print("Function : Makerchip and Verilator to Ngspice Converter") self.obj_appconfig.print_info('Makerchip is called') @@ -559,9 +665,9 @@ def open_modelEditor(self): When model editor icon is clicked which is present in left-tool-bar of main page: - - Meassge shown on screen "Model editor is called". - - 'modeleditor()' function is called using object - 'obj_dockarea' of class 'Mainview'. + - Meassge shown on screen "Model editor is called". + - 'modeleditor()' function is called using object + 'obj_dockarea' of class 'Mainview'. """ print("Function : Model editor") self.obj_appconfig.print_info('Model editor is called') @@ -588,8 +694,8 @@ def open_OMedit(self): try: # Creating a command for Ngspice to Modelica converter self.cmd1 = " - python3 ../ngspicetoModelica/NgspicetoModelica.py "\ - + self.ngspiceNetlist + python3 ../ngspicetoModelica/NgspicetoModelica.py "\ + + self.ngspiceNetlist self.obj_workThread1 = Worker.WorkerThread(self.cmd1) self.obj_workThread1.start() if self.obj_validation.validateTool("OMEdit"): @@ -600,17 +706,17 @@ def open_OMedit(self): else: self.msg = QtWidgets.QMessageBox() self.msgContent = "There was an error while - opening OMEdit.
\ + opening OMEdit.
\ Please make sure OpenModelica is installed in your\ - system.
\ + system.
\ To install it on Linux : Go to\ - OpenModelica Linux and \ - install nigthly build release.
\ + OpenModelica Linux and \ + install nigthly build release.
\ To install it on Windows : Go to\ - OpenModelica Windows\ - and install latest version.
" + and install latest version.
" self.msg.setTextFormat(QtCore.Qt.RichText) self.msg.setText(self.msgContent) self.msg.setWindowTitle("Missing OpenModelica") @@ -624,7 +730,7 @@ def open_OMedit(self): "Ngspice to Modelica conversion error") self.msg.showMessage( 'Unable to convert NgSpice netlist to\ - Modelica netlist :'+str(e)) + Modelica netlist :'+str(e)) self.msg.exec_() self.obj_appconfig.print_error(str(e)) """ @@ -654,10 +760,10 @@ def open_OMoptim(self): """ This function uses validateTool() method from Validation.py: - - If 'OMOptim' is present in executables list then - it passes command 'OMOptim' to WorkerThread class of Worker.py - - If 'OMOptim' is not present, then it shows error message with - link to download it on Linux and Windows. + - If 'OMOptim' is present in executables list then + it passes command 'OMOptim' to WorkerThread class of Worker.py + - If 'OMOptim' is not present, then it shows error message with + link to download it on Linux and Windows. """ print("Function : OMOptim") self.obj_appconfig.print_info('OMOptim is called') @@ -688,24 +794,27 @@ def open_OMoptim(self): self.msg.exec_() def open_conToeSim(self): - print("Function : Schematic converter") - self.obj_appconfig.print_info('Schematic converter is called') + print("Function : Schematics converter") + self.obj_appconfig.print_info('Schematics converter is called') self.obj_Mainview.obj_dockarea.eSimConverter() # This class initialize the Main View of Application + + class MainView(QtWidgets.QWidget): """ This class defines whole view and style of main page: - - Position of tool bars: - - Top tool bar. - - Left tool bar. - - Project explorer Area. - - Dock area. - - Console area. + - Position of tool bars: + - Top tool bar. + - Left tool bar. + - Project explorer Area. + - Dock area. + - Console area. """ def __init__(self, *args): + # call init method of superclass QtWidgets.QWidget.__init__(self, *args) @@ -722,120 +831,16 @@ def __init__(self, *args): # Area to be included in MainView self.noteArea = QtWidgets.QTextEdit() self.noteArea.setReadOnly(True) - - # Set explicit scrollbar policy - self.noteArea.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) - self.noteArea.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) - self.obj_appconfig.noteArea['Note'] = self.noteArea self.obj_appconfig.noteArea['Note'].append( - ' eSim Started......') + ' eSim Started......') self.obj_appconfig.noteArea['Note'].append('Project Selected : None') self.obj_appconfig.noteArea['Note'].append('\n') - - # Enhanced CSS with proper scrollbar styling - self.noteArea.setStyleSheet(""" - QTextEdit { - border-radius: 15px; - border: 1px solid gray; - padding: 5px; - background-color: white; - } - - QScrollBar:vertical { - border: 1px solid #999999; - background: #f0f0f0; - width: 16px; - margin: 16px 0 16px 0; - border-radius: 3px; - } - - QScrollBar::handle:vertical { - background: #606060; - min-height: 20px; - border-radius: 3px; - margin: 1px; - } - - QScrollBar::handle:vertical:hover { - background: #505050; - } - - QScrollBar::add-line:vertical { - border: 1px solid #999999; - background: #d0d0d0; - height: 15px; - width: 16px; - subcontrol-position: bottom; - subcontrol-origin: margin; - border-radius: 2px; - } - - QScrollBar::sub-line:vertical { - border: 1px solid #999999; - background: #d0d0d0; - height: 15px; - width: 16px; - subcontrol-position: top; - subcontrol-origin: margin; - border-radius: 2px; - } - - QScrollBar::add-line:vertical:hover, - QScrollBar::sub-line:vertical:hover { - background: #c0c0c0; - } - - QScrollBar::add-page:vertical, - QScrollBar::sub-page:vertical { - background: none; - } - - QScrollBar::up-arrow:vertical { - width: 8px; - height: 8px; - background-color: #606060; - } - - QScrollBar::down-arrow:vertical { - width: 8px; - height: 8px; - background-color: #606060; - } - - QScrollBar:horizontal { - border: 1px solid #999999; - background: #f0f0f0; - height: 16px; - margin: 0 16px 0 16px; - border-radius: 3px; - } - - QScrollBar::handle:horizontal { - background: #606060; - min-width: 20px; - border-radius: 3px; - margin: 1px; - } - - QScrollBar::handle:horizontal:hover { - background: #505050; - } - - QScrollBar::add-line:horizontal, - QScrollBar::sub-line:horizontal { - border: 1px solid #999999; - background: #d0d0d0; - width: 15px; - height: 16px; - border-radius: 2px; - } - - QScrollBar::add-line:horizontal:hover, - QScrollBar::sub-line:horizontal:hover { - background: #c0c0c0; - } - """) + # CSS + self.noteArea.setStyleSheet(" \ + QWidget { border-radius: 15px; border: 1px \ + solid gray; padding: 5px; } \ + ") self.obj_dockarea = DockArea.DockArea() self.obj_projectExplorer = ProjectExplorer.ProjectExplorer() @@ -877,16 +882,16 @@ def restore_console_area(self): # It is main function of the module and starts the application def main(args): - """ - The splash screen opened at the starting of screen is performed - by this function. - """ + """The splash screen opened at the starting of screen is performed by this function.""" print("Starting eSim......") + # Set environment variable before creating QApplication to suppress model hoster warnings + os.environ['DISABLE_MODEL_SOURCE_CHECK'] = 'True' # Set non-native dialogs globally QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_DontUseNativeDialogs, True) app = QtWidgets.QApplication(args) app.setApplicationName("eSim") + appView = Application() appView.hide() @@ -920,7 +925,6 @@ def main(args): sys.exit(app.exec_()) - # Call main function if __name__ == '__main__': # Create and display the splash screen diff --git a/src/frontEnd/Chatbot.py b/src/frontEnd/Chatbot.py new file mode 100644 index 000000000..82abd28ec --- /dev/null +++ b/src/frontEnd/Chatbot.py @@ -0,0 +1,2239 @@ +import sys +import os +import re,threading +from configuration.Appconfig import Appconfig +from chatbot.stt_handler import listen_to_mic +from PyQt5.QtGui import QTextCursor +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QTextEdit, QLineEdit, + QPushButton, QLabel, QFileDialog, QMessageBox, QApplication, + QDialog, QComboBox, QFormLayout, QSizePolicy, +) +from PyQt5.QtCore import Qt, QThread, QTimer, pyqtSignal +from PyQt5.QtGui import QFont +# Try multiple paths for netlist contract (frontEnd/manual, frontEnd/manuals, src/manuals) +_CONTRACT_PATHS = [ + os.path.join(os.path.dirname(__file__), "manual", "esim_netlist_analysis_output_contract.txt"), + os.path.join(os.path.dirname(__file__), "manuals", "esim_netlist_analysis_output_contract.txt"), + os.path.join(os.path.dirname(os.path.dirname(__file__)), "manuals", "esim_netlist_analysis_output_contract.txt"), +] +NETLIST_CONTRACT = "" +for contract_path in _CONTRACT_PATHS: + try: + with open(contract_path, "r", encoding="utf-8") as f: + NETLIST_CONTRACT = f.read() + print(f"[COPILOT] Loaded netlist contract from {contract_path}") + break + except Exception: + continue +if not NETLIST_CONTRACT: + print("[COPILOT] Using fallback netlist contract (file not found in any path)") + NETLIST_CONTRACT = ( + "You are a SPICE netlist analyzer.\n" + "Use the FACT lines to detect issues.\n" + "Output sections:\n" + "1. Syntax / SPICE rule errors\n" + "2. Topology / connection problems\n" + "3. Simulation setup issues (.ac/.tran/.op etc.)\n" + "4. Summary\n" + "Do NOT invent issues not present in FACT lines.\n" + ) + +current_dir = os.path.dirname(os.path.abspath(__file__)) +src_dir = os.path.dirname(current_dir) +if src_dir not in sys.path: + sys.path.append(src_dir) + +from chatbot.chatbot_core import handle_input, ESIMCopilotWrapper, clear_history + +import subprocess +import tempfile + +def _validate_netlist_with_ngspice(netlist_text: str) -> bool: + """ + Run ngspice in batch mode to check for SYNTAX errors only. + Returns True if syntax is valid, False for actual parse errors. + Ignores model/library warnings. + """ + try: + with tempfile.NamedTemporaryFile( + mode='w', suffix='.cir', delete=False, encoding='utf-8' + ) as tmp: + tmp.write(netlist_text) + tmp_path = tmp.name + + result = subprocess.run( + ['ngspice', '-b', tmp_path], + capture_output=True, + text=True, + timeout=5 + ) + + try: + os.unlink(tmp_path) + except: + pass + + stderr_lower = result.stderr.lower() + + syntax_errors = [ + 'syntax error', + 'unrecognized', + 'parse error', + 'fatal', + ] + + ignore_patterns = [ + 'model', + 'library', + 'warning', + 'no such file', + 'cannot find', + ] + + for line in stderr_lower.split('\n'): + if any(pattern in line for pattern in ignore_patterns): + continue + if any(err in line for err in syntax_errors): + print(f"[COPILOT] Syntax error: {line}") + return False + + return True + + except Exception as e: + print(f"[COPILOT] Validation exception: {e}") + return True + + +def _detect_missing_subcircuits(netlist_text: str) -> list: + """ + Detect subcircuits that are referenced but not defined. + Returns list of (subckt_name, [(line_num, instance_name), ...]) tuples. + """ + import re + + referenced_subckts = {} + defined_subckts = set() + lines = netlist_text.split('\n') + + for line_num, line in enumerate(lines, start=1): + line = line.strip() + if not line or line.startswith('*'): + continue + + if line.lower().startswith('.subckt'): + tokens = line.split() + if len(tokens) >= 2: + defined_subckts.add(tokens[1].upper()) + + elif line.lower().startswith('.include') or line.lower().startswith('.lib'): + return [] + + elif line[0].upper() == 'X': + tokens = line.split() + if len(tokens) < 2: + continue + + instance_name = tokens[0] + subckt_name = tokens[-1].upper() + + if '=' in subckt_name: + for tok in reversed(tokens[1:]): + if '=' not in tok: + subckt_name = tok.upper() + break + + if subckt_name not in referenced_subckts: + referenced_subckts[subckt_name] = [] + referenced_subckts[subckt_name].append((line_num, instance_name)) + + missing = [] + for subckt, occurrences in referenced_subckts.items(): + if subckt not in defined_subckts: + missing.append((subckt, occurrences)) + + return missing + + +def _detect_voltage_source_conflicts(netlist_text: str) -> list: + """ + Detect multiple voltage sources connected to the same node pair. + Returns list of (node_pair, [(line_num, source_name, value), ...]) tuples. + """ + import re + + voltage_sources = {} + lines = netlist_text.split('\n') + + for line_num, line in enumerate(lines, start=1): + line = line.strip() + if not line or line.startswith('*') or line.startswith('.'): + continue + + tokens = line.split() + if len(tokens) < 4: + continue + + elem_name = tokens[0] + if elem_name[0].upper() != 'V': + continue + + node_plus = tokens[1] + node_minus = tokens[2] + + # Normalize node names + node_plus = re.sub(r'[^\w\-_]', '', node_plus) + node_minus = re.sub(r'[^\w\-_]', '', node_minus) + + if node_plus.lower() in ['0', 'gnd', 'ground', 'vss']: + node_plus = '0' + if node_minus.lower() in ['0', 'gnd', 'ground', 'vss']: + node_minus = '0' + + node_pair = tuple(sorted([node_plus, node_minus])) + + # Extract value + value = "?" + for i, tok in enumerate(tokens[3:], start=3): + tok_upper = tok.upper() + if tok_upper in ['DC', 'AC', 'PULSE', 'SIN', 'PWL']: + if i+1 < len(tokens): + value = tokens[i+1] + break + elif not tok_upper.startswith('.'): + value = tok + break + + if node_pair not in voltage_sources: + voltage_sources[node_pair] = [] + voltage_sources[node_pair].append((line_num, elem_name, value)) + + # Find node pairs with multiple sources + conflicts = [] + for node_pair, sources in voltage_sources.items(): + if len(sources) > 1: + conflicts.append((node_pair, sources)) + + return conflicts + +def _netlist_ground_info(netlist_text: str): + """ + Return (has_node0, has_gnd_label) based ONLY on actual node pins, + not on .tran/.ac parameters or numeric values. + """ + import re + + has_node0 = False + has_gnd_label = False + + lines = netlist_text.split('\n') + for line in lines: + line = line.strip() + # Skip comments, control lines, empty lines + if not line or line.startswith('*') or line.startswith('.'): + continue + + tokens = line.split() + if len(tokens) < 3: + continue + + elem_name = tokens[0] + elem_type = elem_name[0].upper() + nodes = [] + + # Extract nodes based on element type + if elem_type in ['R', 'C', 'L']: + nodes = [tokens[1], tokens[2]] + elif elem_type in ['V', 'I']: + nodes = [tokens[1], tokens[2]] + elif elem_type == 'D': + nodes = [tokens[1], tokens[2]] + elif elem_type == 'Q': + if len(tokens) >= 4: + nodes = [tokens[1], tokens[2], tokens[3]] + elif elem_type == 'M': + if len(tokens) >= 5: + nodes = [tokens[1], tokens[2], tokens[3], tokens[4]] + elif elem_type == 'S': + if len(tokens) >= 5: + nodes = [tokens[1], tokens[2], tokens[3], tokens[4]] + elif elem_type == 'W': + if len(tokens) >= 4: + nodes = [tokens[1], tokens[2]] + elif elem_type in ['E', 'G', 'H', 'F']: + # Controlled sources: check if VALUE-based or linear + if len(tokens) >= 3: + # Check if VALUE keyword exists + has_value = any(tok.upper() == 'VALUE' for tok in tokens) + if has_value: + # Behavioral source: only 2 output nodes + nodes = [tokens[1], tokens[2]] + elif len(tokens) >= 5: + # Linear source: 4 nodes (output pair + control pair) + nodes = [tokens[1], tokens[2], tokens[3], tokens[4]] + else: + # Fallback: at least output pair + nodes = [tokens[1], tokens[2]] + + elif elem_type == 'X': + if len(tokens) >= 3: + nodes = tokens[1:-1] + + for node in nodes: + node = re.sub(r'[=\(\)].*$', '', node) + node = re.sub(r'[^\w\-_]', '', node) + if not node: + continue + + nl = node.lower() + if nl == '0': + has_node0 = True + if nl in ['gnd', 'ground', 'vss']: + has_gnd_label = True + + return has_node0, has_gnd_label + +def _detect_floating_nodes(netlist_text: str) -> list: + """Detect nodes that appear only once (floating/unconnected).""" + import re + + floating_nodes = [] + node_counts = {} + lines = netlist_text.split('\n') + + for line_num, line in enumerate(lines, start=1): + line = line.strip() + if not line or line.startswith('*') or line.startswith('.'): + continue + + tokens = line.split() + if len(tokens) < 3: + continue + + elem_name = tokens[0] + elem_type = elem_name[0].upper() + nodes = [] + + # Extract ONLY nodes (not model names, keywords, or source names) + if elem_type in ['R', 'C', 'L']: + nodes = [tokens[1], tokens[2]] + + elif elem_type in ['V', 'I']: + nodes = [tokens[1], tokens[2]] + + elif elem_type == 'D': + nodes = [tokens[1], tokens[2]] + + elif elem_type == 'Q': + if len(tokens) >= 4: + nodes = [tokens[1], tokens[2], tokens[3]] + + elif elem_type == 'M': + if len(tokens) >= 5: + nodes = [tokens[1], tokens[2], tokens[3], tokens[4]] + + elif elem_type == 'S': + if len(tokens) >= 5: + nodes = [tokens[1], tokens[2], tokens[3], tokens[4]] + + elif elem_type == 'W': + # W n+ n- Vcontrol model + # Vcontrol is a voltage source NAME, not a node + if len(tokens) >= 3: + nodes = [tokens[1], tokens[2]] + + elif elem_type == 'T': + # T n1+ n1- n2+ n2- Z0=val TD=val + # Transmission line: 4 nodes + if len(tokens) >= 5: + nodes = [tokens[1], tokens[2], tokens[3], tokens[4]] + + elif elem_type == 'B': + # B n+ n- = {expr} + # Behavioral source: 2 output nodes + if len(tokens) >= 3: + nodes = [tokens[1], tokens[2]] + + elif elem_type in ['E', 'G']: + # Voltage-controlled sources + if len(tokens) >= 3: + # Check if VALUE keyword exists (behavioral) + line_upper = line.upper() + if 'VALUE' in line_upper or '=' in line: + # Behavioral: only 2 output nodes + nodes = [tokens[1], tokens[2]] + elif len(tokens) >= 5: + # Linear: 4 nodes (out+, out-, ctrl+, ctrl-) + nodes = [tokens[1], tokens[2], tokens[3], tokens[4]] + else: + nodes = [tokens[1], tokens[2]] + + elif elem_type == 'H': + if len(tokens) >= 3: + nodes = [tokens[1], tokens[2]] + + elif elem_type == 'F': + if len(tokens) >= 3: + nodes = [tokens[1], tokens[2]] + + elif elem_type == 'X': + # X node1 node2 ... subckt_name [params] + if len(tokens) >= 3: + candidate_nodes = tokens[1:-1] + nodes = [tok for tok in candidate_nodes if '=' not in tok] + + for node in nodes: + node = re.sub(r'[=\(\)].*$', '', node) + node = re.sub(r'[^\w\-_]', '', node) + + if not node or node[0].isdigit(): + continue + + if node.upper() in ['VALUE', 'V', 'I', 'IF', 'THEN', 'ELSE']: + continue + + # Normalize ground references + node_lower = node.lower() + if node_lower in ['0', 'gnd', 'ground', 'vss']: + node = '0' + + if node not in node_counts: + node_counts[node] = [] + node_counts[node].append((line_num, elem_name)) + + # Find nodes appearing only once (exclude ground) + for node, occurrences in node_counts.items(): + if len(occurrences) == 1 and node != '0': + line_num, elem = occurrences[0] + floating_nodes.append((node, line_num, elem)) + + return floating_nodes + +def _detect_missing_models(netlist_text: str) -> list: + """ + Detect device models that are referenced but not defined. + Returns list of (model_name, [(line_num, elem_name), ...]) tuples. + """ + import re + + referenced_models = {} + defined_models = set() + lines = netlist_text.split('\n') + + for line_num, line in enumerate(lines, start=1): + line = line.strip() + if not line or line.startswith('*'): + continue + + # Check for .model definitions + if line.lower().startswith('.model'): + tokens = line.split() + if len(tokens) >= 2: + defined_models.add(tokens[1].upper()) + + # Check for .include statements (external model libraries) + elif line.lower().startswith('.include') or line.lower().startswith('.lib'): + return [] + + # Extract model references from device lines + elif line[0].upper() in ['D', 'Q', 'M', 'J']: + tokens = line.split() + elem_name = tokens[0] + elem_type = elem_name[0].upper() + + if elem_type == 'D' and len(tokens) >= 4: + model = tokens[3].upper() + if model not in referenced_models: + referenced_models[model] = [] + referenced_models[model].append((line_num, elem_name)) + + elif elem_type == 'Q' and len(tokens) >= 5: + model = tokens[-1].upper() + if not model[0].isdigit(): + if model not in referenced_models: + referenced_models[model] = [] + referenced_models[model].append((line_num, elem_name)) + + elif elem_type == 'M' and len(tokens) >= 6: + model = tokens[5].upper() + if model not in referenced_models: + referenced_models[model] = [] + referenced_models[model].append((line_num, elem_name)) + + # Check for switch models + elif line[0].upper() in ['S', 'W']: + tokens = line.split() + if len(tokens) >= 5: + elem_name = tokens[0] + model = tokens[-1].upper() + if model not in referenced_models: + referenced_models[model] = [] + referenced_models[model].append((line_num, elem_name)) + + # Find models that are referenced but not defined + missing = [] + for model, occurrences in referenced_models.items(): + if model not in defined_models: + missing.append((model, occurrences)) + + return missing + + +class ChatWorker(QThread): + response_ready = pyqtSignal(str) + + def __init__(self, user_input, copilot): + super().__init__() + self.user_input = user_input + self.copilot = copilot + + def run(self): + response = self.copilot.handle_input(self.user_input) + self.response_ready.emit(response) + +class MicWorker(QThread): + result_ready = pyqtSignal(str) + error_occurred = pyqtSignal(str) + + def __init__(self): + super().__init__() + self._stop_requested = False + self._lock = threading.Lock() + + def request_stop(self): + with self._lock: + self._stop_requested = True + + def should_stop(self): + with self._lock: + return self._stop_requested + + def run(self): + try: + text = listen_to_mic(should_stop=self.should_stop, max_silence_sec=3) + self.result_ready.emit(text) + except Exception as e: + self.error_occurred.emit(f"[Error: {e}]") + +# ==================== BATCH WORKER ==================== + +class BatchWorker(QThread): + """Runs static FACT analysis on multiple netlists or vision on images — no LLM.""" + file_started = pyqtSignal(int, int, str) # idx, total, filename + file_done = pyqtSignal(int, int, str, str) # idx, total, filename, summary + all_done = pyqtSignal(list) # list of (filename, summary) + + def __init__(self, mode: str, file_paths: list): + super().__init__() + self.mode = mode # "netlist" or "image" + self.file_paths = file_paths + + def run(self): + results = [] + total = len(self.file_paths) + for i, path in enumerate(self.file_paths): + name = os.path.basename(path) + self.file_started.emit(i + 1, total, name) + if self.mode == "netlist": + summary = self._analyze_netlist(path) + else: + summary = self._analyze_image(path) + results.append((name, summary)) + self.file_done.emit(i + 1, total, name, summary) + self.all_done.emit(results) + + def _analyze_netlist(self, path: str) -> str: + try: + with open(path, "r", encoding="utf-8", errors="ignore") as f: + text = f.read() + floating = _detect_floating_nodes(text) + missing_m = _detect_missing_models(text) + missing_s = _detect_missing_subcircuits(text) + conflicts = _detect_voltage_source_conflicts(text) + has_n0, has_gnd = _netlist_ground_info(text) + issues = [] + if not has_n0 and not has_gnd: + issues.append("No ground ref") + if floating: + issues.append(f"{len(floating)} floating node(s): " + + ", ".join(n for n, _, _ in floating[:3])) + if missing_m: + issues.append(f"{len(missing_m)} missing model(s): " + + ", ".join(m for m, _ in missing_m[:3])) + if missing_s: + issues.append(f"{len(missing_s)} missing subckt(s): " + + ", ".join(s for s, _ in missing_s[:3])) + if conflicts: + issues.append(f"{len(conflicts)} voltage conflict(s)") + return "; ".join(issues) if issues else "OK — no static issues found" + except Exception as e: + return f"Error reading file: {e}" + + def _analyze_image(self, path: str) -> str: + try: + from chatbot.image_handler import analyze_and_extract + result = analyze_and_extract(path) + if result.get("error"): + return f"Vision error: {result['error']}" + ctype = result.get("circuit_analysis", {}).get("circuit_type", "Unknown") + components = result.get("components", []) + errors = result.get("circuit_analysis", {}).get("design_errors", []) + summary = f"Type: {ctype}; Components: {', '.join(components[:5])}" + if errors: + summary += f"; Errors: {'; '.join(errors[:2])}" + return summary + except Exception as e: + return f"Error: {e}" + + +# ==================== MAIN CHATBOT GUI ==================== + +class ChatbotGUI(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.copilot = ESIMCopilotWrapper() + self.current_image_path = None + self.worker = None + self._mic_worker = None + self._batch_worker = None + self._is_listening = False + + # Project context + self._project_dir = None + self._generation_id = 0 # used to ignore stale responses + self._last_assistant_response = "" # for Copy button + + # One-click fix state + self._last_netlist_path = None + self._last_facts = {} + self._pending_fix_check = None # set during netlist analysis + + # Real-time hints watcher + self._watch_active = False + self._watch_last_facts = None + self._watch_timer = QTimer(self) + self._watch_timer.setInterval(30_000) # 30 s + self._watch_timer.timeout.connect(self._kicad_watch_tick) + + self.initUI() + + def set_project_context(self, project_dir: str): + """Called by Application to tell chatbot which project is active.""" + if project_dir and os.path.isdir(project_dir): + self._project_dir = project_dir + proj_name = os.path.basename(project_dir) + self.append_message( + "eSim", + f"Project context set to: {proj_name}\nPath: {project_dir}", + is_user=False, + ) + else: + self._project_dir = None + self.append_message( + "eSim", + "Project context cleared or invalid.", + is_user=False, + ) + + def analyze_current_netlist(self): + """Analyze the active project's netlist.""" + + if self.is_bot_busy(): + return + + if not self._project_dir: + try: + from configuration.Appconfig import Appconfig + obj_appconfig = Appconfig() + active_project = obj_appconfig.current_project.get("ProjectName") + if active_project and os.path.isdir(active_project): + self._project_dir = active_project + proj_name = os.path.basename(active_project) + print(f"[COPILOT] Auto-detected active project: {active_project}") + self.append_message( + "eSim", + f"Auto-detected project: {proj_name}\nPath: {active_project}", + is_user=False, + ) + except Exception as e: + print(f"[COPILOT] Could not auto-detect project: {e}") + + if not self._project_dir: + QMessageBox.warning( + self, + "No project", + "No active eSim project set for the chatbot.", + ) + return + + proj_name = os.path.basename(self._project_dir) + + try: + all_files = os.listdir(self._project_dir) + except Exception as e: + QMessageBox.warning(self, "Error", f"Cannot read project directory:\n{e}") + return + + cir_candidates = [f for f in all_files if f.endswith('.cir') or f.endswith('.cir.out')] + + if not cir_candidates: + QMessageBox.warning( + self, + "Netlist not found", + f"Could not find any .cir or .cir.out files in:\n{self._project_dir}", + ) + return + + netlist_path = None + preferred_out = proj_name + ".cir.out" + if preferred_out in cir_candidates: + netlist_path = os.path.join(self._project_dir, preferred_out) + else: + preferred_cir = proj_name + ".cir" + if preferred_cir in cir_candidates: + netlist_path = os.path.join(self._project_dir, preferred_cir) + else: + if len(cir_candidates) > 1: + from PyQt5.QtWidgets import QInputDialog + item, ok = QInputDialog.getItem( + self, + "Select netlist file", + "Multiple .cir/.cir.out files found in this project.\n" + "Select the one you want to analyze:", + cir_candidates, + 0, + False, + ) + if ok and item: + netlist_path = os.path.join(self._project_dir, item) + elif len(cir_candidates) == 1: + netlist_path = os.path.join(self._project_dir, cir_candidates[0]) + + if not netlist_path or not os.path.exists(netlist_path): + QMessageBox.warning(self, "Netlist not found", "Could not determine which netlist to use.") + return + + netlist_name = os.path.basename(netlist_path) + self.append_message( + "eSim", + f"Using netlist file:\n{netlist_name}", + is_user=False, + ) + + try: + with open(netlist_path, "r", encoding="utf-8", errors="ignore") as f: + netlist_text = f.read() + except Exception as e: + QMessageBox.warning(self, "Error", f"Failed to read netlist:\n{e}") + return + + # === RUN ALL DETECTORS === + print(f"[COPILOT] Analyzing netlist: {netlist_path}") + is_syntax_valid = _validate_netlist_with_ngspice(netlist_text) + print(f"[COPILOT] Ngspice syntax check: {'PASS' if is_syntax_valid else 'FAIL'}") + + floating_nodes = _detect_floating_nodes(netlist_text) + if floating_nodes: + print(f"[COPILOT] Found {len(floating_nodes)} floating node(s):") + for node, line_num, elem in floating_nodes: + print(f" - Node '{node}' at line {line_num} ({elem})") + + missing_models = _detect_missing_models(netlist_text) + if missing_models: + print(f"[COPILOT] Found {len(missing_models)} missing model(s):") + for model, occurrences in missing_models: + print(f" - Model '{model}' used {len(occurrences)} time(s) but not defined") + + missing_subckts = _detect_missing_subcircuits(netlist_text) + if missing_subckts: + print(f"[COPILOT] Found {len(missing_subckts)} missing subcircuit(s):") + for subckt, occurrences in missing_subckts: + print(f" - Subcircuit '{subckt}' used {len(occurrences)} time(s) but not defined") + + voltage_conflicts = _detect_voltage_source_conflicts(netlist_text) + if voltage_conflicts: + print(f"[COPILOT] Found {len(voltage_conflicts)} voltage source conflict(s):") + for node_pair, sources in voltage_conflicts: + print(f" - Nodes {node_pair}: {len(sources)} sources") + for line_num, name, val in sources: + print(f" * {name} (line {line_num}, value={val})") + + import re + text_lower = netlist_text.lower() + + has_tran = ".tran" in text_lower + has_ac = ".ac" in text_lower + has_op = ".op" in text_lower + + has_node0, has_gnd_label = _netlist_ground_info(netlist_text) + + if not has_node0 and not has_gnd_label: + print("[COPILOT] WARNING: No ground reference (node 0 or GND) found!") + + # Build descriptions + if floating_nodes: + floating_desc = "; ".join([f"{node} (line {line_num}, {elem})" + for node, line_num, elem in floating_nodes]) + else: + floating_desc = "NONE" + + if missing_models: + missing_desc = "; ".join([f"{model} (used {len(occs)} times)" + for model, occs in missing_models]) + else: + missing_desc = "NONE" + + if missing_subckts: + subckt_desc = "; ".join([f"{subckt} (used {len(occs)} times)" + for subckt, occs in missing_subckts]) + else: + subckt_desc = "NONE" + + if voltage_conflicts: + conflict_parts = [] + for node_pair, sources in voltage_conflicts: + src_desc = ", ".join([f"{name}={val}" for _, name, val in sources]) + conflict_parts.append(f"{node_pair}: {src_desc}") + voltage_conflict_desc = "; ".join(conflict_parts) + else: + voltage_conflict_desc = "NONE" + + facts = [ + f"NET_SYNTAX_VALID={'YES' if is_syntax_valid else 'NO'}", + f"NET_HAS_NODE_0={'YES' if has_node0 else 'NO'}", + f"NET_HAS_GND_LABEL={'YES' if has_gnd_label else 'NO'}", + f"NET_HAS_TRAN={'YES' if has_tran else 'NO'}", + f"NET_HAS_AC={'YES' if has_ac else 'NO'}", + f"NET_HAS_OP={'YES' if has_op else 'NO'}", + f"FLOATING_NODES={floating_desc}", + f"MISSING_MODELS={missing_desc}", + f"MISSING_SUBCKTS={subckt_desc}", + f"VOLTAGE_CONFLICTS={voltage_conflict_desc}", + ] + + facts_block = "\n".join(f"[FACT {f}]" for f in facts) + print(f"[COPILOT] FACTS being sent:\n{facts_block}") + + # === BUILD PROMPT (SIMPLIFIED USING CONTRACT FILE) === + + full_query = ( + f"{NETLIST_CONTRACT}\n\n" + "=== NETLIST FACTS (MACHINE-GENERATED) ===\n" + "The following lines describe the analyzed netlist in a structured way.\n" + "Each line has the form [FACT KEY=VALUE].\n" + "You MUST rely ONLY on these FACTS, not on the raw netlist.\n\n" + f"{facts_block}\n\n" + "=== RAW NETLIST (FOR REFERENCE ONLY, DO NOT RE-ANALYZE TO FIND NEW ERRORS) ===\n" + "[ESIM_NETLIST_START]\n" + f"{netlist_text}\n" + "[ESIM_NETLIST_END]\n\n" + "REMINDERS:\n" + "- Do NOT invent issues that are not present in the FACT lines.\n" + "- If a FACT says NONE, you MUST NOT report any issue for that category.\n" + "- Follow the output format and rules described in the contract above.\n" + ) + + + # Store facts for one-click fix + facts_dict = { + "syntax_valid": is_syntax_valid, + "has_node0": has_node0, + "has_gnd_label": has_gnd_label, + "floating_nodes": floating_desc, + "missing_models": missing_desc, + "missing_subckts": subckt_desc, + "voltage_conflicts": voltage_conflict_desc, + } + self._pending_fix_check = (netlist_path, facts_dict) + + # Show synthetic user message + self.append_message( + "You", + f"Analyze current netlist of project '{proj_name}' for design mistakes, " + "missing connections, or bad values.", + is_user=True, + ) + + # Disable UI and run worker + self.input_field.setDisabled(True) + self.send_btn.setDisabled(True) + if hasattr(self, "attach_btn"): + self.attach_btn.setDisabled(True) + if hasattr(self, "mic_btn"): + self.mic_btn.setDisabled(True) + if hasattr(self, "analyze_netlist_btn"): + self.analyze_netlist_btn.setDisabled(True) + if hasattr(self, "clear_btn"): + self.clear_btn.setDisabled(True) + self.loading_label.show() + + self._generation_id += 1 + current_gen = self._generation_id + + self.worker = ChatWorker(full_query, self.copilot) + self.worker.response_ready.connect( + lambda resp, gen=current_gen: self._handle_response_with_id(resp, gen) + ) + self.worker.finished.connect(self.on_worker_finished) + self.worker.start() + + + def analyze_specific_netlist(self, netlist_path: str): + """Analyze a specific netlist file (called from ProjectExplorer context menu).""" + + if self.is_bot_busy(): + return + + if not os.path.exists(netlist_path): + QMessageBox.warning( + self, + "File not found", + f"Netlist file does not exist:\n{netlist_path}", + ) + return + + netlist_name = os.path.basename(netlist_path) + self.append_message( + "eSim", + f"Analyzing specific netlist:\n{netlist_name}", + is_user=False, + ) + + try: + with open(netlist_path, "r", encoding="utf-8", errors="ignore") as f: + netlist_text = f.read() + except Exception as e: + QMessageBox.warning(self, "Error", f"Failed to read netlist:\n{e}") + return + + # === RUN ALL DETECTORS (IDENTICAL TO analyze_current_netlist) === + print(f"[COPILOT] Analyzing netlist: {netlist_path}") + is_syntax_valid = _validate_netlist_with_ngspice(netlist_text) + print(f"[COPILOT] Ngspice syntax check: {'PASS' if is_syntax_valid else 'FAIL'}") + + floating_nodes = _detect_floating_nodes(netlist_text) + if floating_nodes: + print(f"[COPILOT] Found {len(floating_nodes)} floating node(s):") + for node, line_num, elem in floating_nodes: + print(f" - Node '{node}' at line {line_num} ({elem})") + + missing_models = _detect_missing_models(netlist_text) + if missing_models: + print(f"[COPILOT] Found {len(missing_models)} missing model(s):") + for model, occurrences in missing_models: + print(f" - Model '{model}' used {len(occurrences)} time(s) but not defined") + + missing_subckts = _detect_missing_subcircuits(netlist_text) + if missing_subckts: + print(f"[COPILOT] Found {len(missing_subckts)} missing subcircuit(s):") + for subckt, occurrences in missing_subckts: + print(f" - Subcircuit '{subckt}' used {len(occurrences)} time(s) but not defined") + + voltage_conflicts = _detect_voltage_source_conflicts(netlist_text) + if voltage_conflicts: + print(f"[COPILOT] Found {len(voltage_conflicts)} voltage source conflict(s):") + for node_pair, sources in voltage_conflicts: + print(f" - Nodes {node_pair}: {len(sources)} sources") + for line_num, name, val in sources: + print(f" * {name} (line {line_num}, value={val})") + + import re + text_lower = netlist_text.lower() + + has_tran = ".tran" in text_lower + has_ac = ".ac" in text_lower + has_op = ".op" in text_lower + + has_node0, has_gnd_label = _netlist_ground_info(netlist_text) + + if not has_node0 and not has_gnd_label: + print("[COPILOT] WARNING: No ground reference (node 0 or GND) found!") + + # Build descriptions (IDENTICAL TO analyze_current_netlist) + if floating_nodes: + floating_desc = "; ".join([f"{node} (line {line_num}, {elem})" + for node, line_num, elem in floating_nodes]) + else: + floating_desc = "NONE" + + if missing_models: + missing_desc = "; ".join([f"{model} (used {len(occs)} times)" + for model, occs in missing_models]) + else: + missing_desc = "NONE" + + if missing_subckts: + subckt_desc = "; ".join([f"{subckt} (used {len(occs)} times)" + for subckt, occs in missing_subckts]) + else: + subckt_desc = "NONE" + + if voltage_conflicts: + conflict_parts = [] + for node_pair, sources in voltage_conflicts: + src_desc = ", ".join([f"{name}={val}" for _, name, val in sources]) + conflict_parts.append(f"{node_pair}: {src_desc}") + voltage_conflict_desc = "; ".join(conflict_parts) + else: + voltage_conflict_desc = "NONE" + + facts = [ + f"NET_SYNTAX_VALID={'YES' if is_syntax_valid else 'NO'}", + f"NET_HAS_NODE_0={'YES' if has_node0 else 'NO'}", + f"NET_HAS_GND_LABEL={'YES' if has_gnd_label else 'NO'}", + f"NET_HAS_TRAN={'YES' if has_tran else 'NO'}", + f"NET_HAS_AC={'YES' if has_ac else 'NO'}", + f"NET_HAS_OP={'YES' if has_op else 'NO'}", + f"FLOATING_NODES={floating_desc}", + f"MISSING_MODELS={missing_desc}", + f"MISSING_SUBCKTS={subckt_desc}", + f"VOLTAGE_CONFLICTS={voltage_conflict_desc}", + ] + + facts_block = "\n".join(f"[FACT {f}]" for f in facts) + print(f"[COPILOT] FACTS being sent:\n{facts_block}") + + # === BUILD PROMPT (IDENTICAL TO analyze_current_netlist) === + # === BUILD PROMPT (SIMPLIFIED USING CONTRACT FILE) === + + full_query = ( + f"{NETLIST_CONTRACT}\n\n" + "=== NETLIST FACTS (MACHINE-GENERATED) ===\n" + "The following lines describe the analyzed netlist in a structured way.\n" + "Each line has the form [FACT KEY=VALUE].\n" + "You MUST rely ONLY on these FACTS, not on the raw netlist.\n\n" + f"{facts_block}\n\n" + "=== RAW NETLIST (FOR REFERENCE ONLY, DO NOT RE-ANALYZE TO FIND NEW ERRORS) ===\n" + "[ESIM_NETLIST_START]\n" + f"{netlist_text}\n" + "[ESIM_NETLIST_END]\n\n" + "REMINDERS:\n" + "- Do NOT invent issues that are not present in the FACT lines.\n" + "- If a FACT says NONE, you MUST NOT report any issue for that category.\n" + "- Follow the output format and rules described in the contract above.\n" + ) + + # Store facts for one-click fix + facts_dict = { + "syntax_valid": is_syntax_valid, + "has_node0": has_node0, + "has_gnd_label": has_gnd_label, + "floating_nodes": floating_desc, + "missing_models": missing_desc, + "missing_subckts": subckt_desc, + "voltage_conflicts": voltage_conflict_desc, + } + self._pending_fix_check = (netlist_path, facts_dict) + + # Show synthetic user message + self.append_message( + "You", + f"Analyze netlist '{netlist_name}' for design mistakes, " + "missing connections, or bad values.", + is_user=True, + ) + + # Disable UI and run worker + self.input_field.setDisabled(True) + self.send_btn.setDisabled(True) + if hasattr(self, "attach_btn"): + self.attach_btn.setDisabled(True) + if hasattr(self, "mic_btn"): + self.mic_btn.setDisabled(True) + if hasattr(self, "analyze_netlist_btn"): + self.analyze_netlist_btn.setDisabled(True) + if hasattr(self, "clear_btn"): + self.clear_btn.setDisabled(True) + self.loading_label.show() + + self._generation_id += 1 + current_gen = self._generation_id + + self.worker = ChatWorker(full_query, self.copilot) + self.worker.response_ready.connect( + lambda resp, gen=current_gen: self._handle_response_with_id(resp, gen) + ) + self.worker.finished.connect(self.on_worker_finished) + self.worker.start() + + + def stop_analysis(self): + """Stop chat worker and mic worker safely.""" + try: + # Stop mic + if getattr(self, "_mic_worker", None) and self._mic_worker.isRunning(): + self._mic_worker.request_stop() + self._mic_worker.quit() + self._mic_worker.wait(200) + if self._mic_worker.isRunning(): + self._mic_worker.terminate() + self._reset_mic_ui() + + # Stop chat worker + if self.worker and self.worker.isRunning(): + self.worker.quit() + self.worker.wait(500) + if self.worker.isRunning(): + self.worker.terminate() + except Exception as e: + print(f"Stop analysis error: {e}") + + def start_listening(self): + # If already listening -> stop + if self._mic_worker and self._mic_worker.isRunning(): + self._mic_worker.request_stop() + return + + # Start listening (do NOT disable mic button) + self.mic_btn.setStyleSheet(""" + QPushButton { background-color: #e74c3c; color: white; border-radius: 20px; font-size: 18px; } + """) + self.mic_btn.setEnabled(True) + self.input_field.setPlaceholderText("Listening... (click mic to stop)") + QApplication.processEvents() + + self._mic_worker = MicWorker() + self._mic_worker.result_ready.connect(self._on_mic_result) + self._mic_worker.error_occurred.connect(self._on_mic_error) + self._mic_worker.finished.connect(self._reset_mic_ui) + self._mic_worker.start() + + def _on_mic_result(self, text): + self._reset_mic_ui() + if text and text.strip(): + self.input_field.setText(text.strip()) + self.input_field.setFocus() + + def _on_mic_error(self, error_msg): + """Handle speech recognition errors.""" + # Only show popup for REAL errors, not timeouts + if "[Error:" in error_msg and "No speech" not in error_msg: + QMessageBox.warning(self, "Microphone Error", error_msg) + + def _reset_mic_ui(self): + self.mic_btn.setStyleSheet(""" + QPushButton { + background-color: #ffffff; + border: 1px solid #bdc3c7; + border-radius: 20px; + font-size: 18px; + } + QPushButton:hover { + background-color: #ffebee; + border-color: #e74c3c; + } + """) + self.mic_btn.setEnabled(True) + self.input_field.setPlaceholderText("Ask eSim Copilot...") + + def initUI(self): + """Initialize the Chatbot GUI Layout.""" + + # Main Layout + self.layout = QVBoxLayout() + self.layout.setContentsMargins(10, 10, 10, 10) + self.layout.setSpacing(10) + + # --- HEADER AREA (Title + Netlist + Clear Button) --- + header_layout = QHBoxLayout() + + title_label = QLabel("eSim Copilot") + title_label.setStyleSheet("font-weight: bold; font-size: 14px; color: #34495e;") + header_layout.addWidget(title_label) + + header_layout.addStretch() # Push buttons to the right + + # NEW: Analyze Netlist button + self.analyze_netlist_btn = QPushButton("Netlist ▶") + self.analyze_netlist_btn.setFixedHeight(30) + self.analyze_netlist_btn.setToolTip("Analyze active project's netlist") + self.analyze_netlist_btn.setCursor(Qt.PointingHandCursor) + self.analyze_netlist_btn.setStyleSheet(""" + QPushButton { + background-color: #2ecc71; + color: white; + border-radius: 15px; + padding: 0 10px; + font-size: 12px; + } + QPushButton:hover { + background-color: #27ae60; + } + """) + # This method should be defined in ChatbotGUI + # def analyze_current_netlist(self): ... + self.analyze_netlist_btn.clicked.connect(self.analyze_current_netlist) + header_layout.addWidget(self.analyze_netlist_btn) + + # Copy button (copy last assistant response to clipboard) + self.copy_btn = QPushButton("📋") + self.copy_btn.setFixedSize(30, 30) + self.copy_btn.setToolTip("Copy last response to clipboard") + self.copy_btn.setCursor(Qt.PointingHandCursor) + self.copy_btn.setStyleSheet(""" + QPushButton { + background-color: transparent; + border: 1px solid #ddd; + border-radius: 15px; + font-size: 14px; + } + QPushButton:hover { + background-color: #e3f2fd; + border-color: #2196f3; + } + """) + self.copy_btn.clicked.connect(self.copy_last_response) + header_layout.addWidget(self.copy_btn) + + # Settings button + self.settings_btn = QPushButton("⚙️") + self.settings_btn.setFixedSize(30, 30) + self.settings_btn.setToolTip("Model settings (text & vision model selection)") + self.settings_btn.setCursor(Qt.PointingHandCursor) + self.settings_btn.setStyleSheet(""" + QPushButton { + background-color: transparent; + border: 1px solid #ddd; + border-radius: 15px; + font-size: 14px; + } + QPushButton:hover { background-color: #e8f5e9; border-color: #4caf50; } + """) + self.settings_btn.clicked.connect(self.open_settings) + header_layout.addWidget(self.settings_btn) + + # Batch analysis button + self.batch_btn = QPushButton("📁") + self.batch_btn.setFixedSize(30, 30) + self.batch_btn.setToolTip("Batch analyze multiple netlists or images") + self.batch_btn.setCursor(Qt.PointingHandCursor) + self.batch_btn.setStyleSheet(""" + QPushButton { + background-color: transparent; + border: 1px solid #ddd; + border-radius: 15px; + font-size: 14px; + } + QPushButton:hover { background-color: #fff3e0; border-color: #ff9800; } + """) + self.batch_btn.clicked.connect(self.analyze_batch_files) + header_layout.addWidget(self.batch_btn) + + # Real-time hints watcher button + self.watch_btn = QPushButton("👁") + self.watch_btn.setFixedSize(30, 30) + self.watch_btn.setToolTip("Toggle real-time hints (polls active project every 30 s)") + self.watch_btn.setCursor(Qt.PointingHandCursor) + self.watch_btn.setStyleSheet(""" + QPushButton { + background-color: transparent; + border: 1px solid #ddd; + border-radius: 15px; + font-size: 14px; + } + QPushButton:hover { background-color: #e3f2fd; border-color: #2196f3; } + """) + self.watch_btn.clicked.connect(self.toggle_kicad_watch) + header_layout.addWidget(self.watch_btn) + + # Clear button + self.clear_btn = QPushButton("🗑️") + self.clear_btn.setFixedSize(30, 30) + self.clear_btn.setToolTip("Clear Chat History") + self.clear_btn.setCursor(Qt.PointingHandCursor) + self.clear_btn.setStyleSheet(""" + QPushButton { + background-color: transparent; + border: 1px solid #ddd; + border-radius: 15px; + font-size: 14px; + } + QPushButton:hover { + background-color: #ffebee; + border-color: #ef9a9a; + } + """) + self.clear_btn.clicked.connect(self.clear_chat) + header_layout.addWidget(self.clear_btn) + + self.layout.addLayout(header_layout) + + # --- CHAT DISPLAY AREA --- + self.chat_display = QTextEdit() + self.chat_display.setReadOnly(True) + self.chat_display.setFont(QFont("Segoe UI", 10)) + self.chat_display.setStyleSheet(""" + QTextEdit { + background-color: #f5f6fa; + border: 1px solid #dcdcdc; + border-radius: 8px; + padding: 10px; + } + """) + self.layout.addWidget(self.chat_display) + + # PROGRESS INDICATOR (Hidden by default) + self.loading_label = QLabel("⏳ eSim Copilot is thinking...") + self.loading_label.setAlignment(Qt.AlignCenter) + self.loading_label.setStyleSheet(""" + background-color: #fff3cd; + color: #856404; + border: 1px solid #ffeeba; + border-radius: 5px; + padding: 5px; + font-weight: bold; + """) + self.loading_label.hide() + self.layout.addWidget(self.loading_label) + + # --- ONE-CLICK FIX BUTTON (hidden until issues detected) --- + self._apply_fixes_btn = QPushButton("🔧 Apply Fixes to Netlist") + self._apply_fixes_btn.setFixedHeight(32) + self._apply_fixes_btn.setCursor(Qt.PointingHandCursor) + self._apply_fixes_btn.setToolTip( + "Auto-insert .options, .model stubs, and bleed resistors into the netlist" + ) + self._apply_fixes_btn.setStyleSheet(""" + QPushButton { + background-color: #e74c3c; + color: white; + border-radius: 6px; + font-weight: bold; + font-size: 12px; + } + QPushButton:hover { background-color: #c0392b; } + """) + self._apply_fixes_btn.clicked.connect(self._apply_netlist_fixes) + self._apply_fixes_btn.hide() + self.layout.addWidget(self._apply_fixes_btn) + + # --- INPUT AREA CONTAINER --- + input_layout = QHBoxLayout() + input_layout.setSpacing(8) + + # A. ATTACH BUTTON + self.attach_btn = QPushButton("📎") + self.attach_btn.setFixedSize(40, 40) + self.attach_btn.setToolTip("Attach Circuit Image") + self.attach_btn.setCursor(Qt.PointingHandCursor) + self.attach_btn.setStyleSheet(""" + QPushButton { + border: 1px solid #bdc3c7; + border-radius: 20px; + background-color: #ffffff; + color: #555; + font-size: 18px; + } + QPushButton:hover { + background-color: #ecf0f1; + border-color: #95a5a6; + } + """) + self.attach_btn.clicked.connect(self.browse_image) + input_layout.addWidget(self.attach_btn) + + # B. TEXT INPUT FIELD + self.input_field = QLineEdit() + self.input_field.setPlaceholderText("Ask eSim Copilot...") + self.input_field.setFixedHeight(40) + self.input_field.setStyleSheet(""" + QLineEdit { + border: 1px solid #bdc3c7; + border-radius: 20px; + padding-left: 15px; + padding-right: 15px; + background-color: #ffffff; + font-size: 14px; + } + QLineEdit:focus { + border: 2px solid #3498db; + } + """) + self.input_field.returnPressed.connect(self.send_message) + input_layout.addWidget(self.input_field) + + # --- MIC BUTTON --- + self.mic_btn = QPushButton("🎤") + self.mic_btn.setFixedSize(40, 40) + self.mic_btn.setToolTip("Speak to type") + self.mic_btn.setCursor(Qt.PointingHandCursor) + self.mic_btn.setStyleSheet(""" + QPushButton { + background-color: #ffffff; + border: 1px solid #bdc3c7; + border-radius: 20px; + font-size: 18px; + } + QPushButton:hover { + background-color: #ffebee; /* Light red hover */ + border-color: #e74c3c; + } + """) + self.mic_btn.clicked.connect(self.start_listening) + input_layout.addWidget(self.mic_btn) + + # C. SEND BUTTON + self.send_btn = QPushButton("➤") + self.send_btn.setFixedSize(40, 40) + self.send_btn.setToolTip("Send Message") + self.send_btn.setCursor(Qt.PointingHandCursor) + self.send_btn.setStyleSheet(""" + QPushButton { + background-color: #3498db; + color: white; + border: none; + border-radius: 20px; + font-size: 16px; + padding-bottom: 2px; + } + QPushButton:hover { + background-color: #2980b9; + } + QPushButton:pressed { + background-color: #1abc9c; + } + """) + self.send_btn.clicked.connect(self.send_message) + input_layout.addWidget(self.send_btn) + + self.layout.addLayout(input_layout) + + # --- IMAGE STATUS ROW (label + remove button) --- + status_layout = QHBoxLayout() + status_layout.setSpacing(5) + status_layout.setContentsMargins(0, 0, 0, 0) + + self.filename_status = QLabel("No image attached") + self.filename_status.setStyleSheet("color: gray; font-size: 12px;") + self.filename_status.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + status_layout.addWidget(self.filename_status) + + self.remove_btn = QPushButton("×") + self.remove_btn.setFixedSize(25, 25) + self.remove_btn.setStyleSheet(""" + QPushButton { + background: #ff6b6b; + color: white; + border: none; + border-radius: 12px; + font-weight: bold; + font-size: 14px; + } + QPushButton:hover { background: #ff5252; } + """) + self.remove_btn.clicked.connect(self.remove_image) + self.remove_btn.hide() # hidden by default + status_layout.addWidget(self.remove_btn) + + status_widget = QWidget() + status_widget.setLayout(status_layout) + self.layout.addWidget(status_widget) + + self.setLayout(self.layout) + + # Initial message + self.append_message( + "eSim Copilot", + "Hello! I am ready to help you analyze circuits.", + is_user=False, + ) + + # ---------- IMAGE HANDLING ---------- + + def browse_image(self): + """Open file dialog to select image (Updates Status Label ONLY).""" + options = QFileDialog.Options() + file_path, _ = QFileDialog.getOpenFileName( + self, + "Select Circuit Image", + "", + "Images (*.png *.jpg *.jpeg *.bmp *.tiff *.gif);;All Files (*)", + options=options + ) + + if file_path: + self.current_image_path = file_path # Store path internally + short_name = os.path.basename(file_path) + + # Update Status Row (Visual Feedback) + self.filename_status.setText(f"📎 {short_name} attached") + self.filename_status.setStyleSheet("color: green; font-weight: bold; font-size: 12px;") + self.remove_btn.show() + + # Focus input so user can start typing question immediately + self.input_field.setFocus() + + def is_bot_busy(self): + """Check if a background worker is currently running.""" + if hasattr(self, "worker") and self.worker is not None: + if self.worker.isRunning(): + QMessageBox.warning(self, "Busy", "Chatbot is currently busy processing a request.\nPlease wait.") + return True + return False + + + def remove_image(self): + """Clear selected image (status + input tag).""" + self.current_image_path = None + self.filename_status.setText("No image attached") + self.filename_status.setStyleSheet("color: gray; font-size: 12px;") + self.remove_btn.hide() + + # ---------- CHAT / HISTORY ---------- + + def clear_chat(self): + """Stop analysis, clear chat, and optionally export history.""" + # 1) Stop any ongoing analysis first + self.stop_analysis() + self._generation_id += 1 + + # 2) Ask user about exporting history + reply = QMessageBox.question( + self, + "Clear History", + "Clear chat history?\nPress 'Yes' to export to a file first, 'No' to clear without saving.", + QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel + ) + if reply == QMessageBox.Cancel: + return + if reply == QMessageBox.Yes: + self.export_history() + + # 3) Clear UI + self.chat_display.clear() + + # 4) Clear backend memory/context + try: + clear_history() + except Exception: + pass + + # 5) Reset welcome line + self.append_message("eSim Copilot", "Chat cleared. Ready for new queries.", is_user=False) + + + def export_history(self): + """Export chat to text file.""" + text = self.chat_display.toPlainText() + if not text.strip(): + return + + file_path, _ = QFileDialog.getSaveFileName( + self, + "Export Chat History", + "chat_history.txt", + "Text Files (*.txt)" + ) + if file_path: + with open(file_path, "w", encoding="utf-8") as f: + f.write(text) + QMessageBox.information(self, "Exported", f"History saved to:\n{file_path}") + + def send_message(self): + user_text = self.input_field.text().strip() + + # Don't send if empty and no image + if not user_text and not self.current_image_path: + return + + # Hide fix button when user sends a new message + if hasattr(self, "_apply_fixes_btn"): + self._apply_fixes_btn.hide() + self._pending_fix_check = None + + full_query = user_text + display_text = user_text + + if self.current_image_path: + short_name = os.path.basename(self.current_image_path) + + # 1) BACKEND QUERY (hidden tag with FULL PATH) + full_query = f"[Image: {self.current_image_path}] {user_text}".strip() + + # 2) USER-VISIBLE TEXT (show filename here, not in input box) + question_part = user_text if user_text else "" + if question_part: + display_text = f"📎 {short_name}\n\n{question_part}" + else: + display_text = f"📎 {short_name}" + + # Reset image state & status row + self.current_image_path = None + self.filename_status.setText("No image attached") + self.filename_status.setStyleSheet("color: gray; font-size: 12px;") + self.remove_btn.hide() + else: + full_query = user_text + display_text = user_text + + # Show user bubble with image name (if any) + self.append_message("You", display_text, is_user=True) + self.input_field.clear() + + # Disable while waiting + self.input_field.setDisabled(True) + self.send_btn.setDisabled(True) + if hasattr(self, "attach_btn"): + self.attach_btn.setDisabled(True) + if hasattr(self, 'mic_btn'): + self.mic_btn.setDisabled(True) + + # NEW: also disable Netlist and Clear during any answer + if hasattr(self, "analyze_netlist_btn"): + self.analyze_netlist_btn.setDisabled(True) + if hasattr(self, "clear_btn"): + self.clear_btn.setDisabled(True) + + self.loading_label.show() + + # NEW: bump generation id and use it to filter responses + self._generation_id += 1 + current_gen = self._generation_id + + self.worker = ChatWorker(full_query, self.copilot) + self.worker.response_ready.connect( + lambda resp, gen=current_gen: self._handle_response_with_id(resp, gen) + ) + self.worker.finished.connect(self.on_worker_finished) + self.worker.start() + + + def on_worker_finished(self): + """Re-enable UI after worker completes.""" + self.input_field.setEnabled(True) + self.send_btn.setEnabled(True) + if hasattr(self, 'attach_btn'): + self.attach_btn.setEnabled(True) + if hasattr(self, 'mic_btn'): + self.mic_btn.setEnabled(True) + if hasattr(self, "analyze_netlist_btn"): + self.analyze_netlist_btn.setEnabled(True) + if hasattr(self, "clear_btn"): + self.clear_btn.setEnabled(True) + + self.loading_label.hide() + + # Check whether to show the Apply Fixes button + if self._pending_fix_check: + path, facts = self._pending_fix_check + self._pending_fix_check = None + self._check_show_fixes_btn(path, facts) + + self.input_field.setFocus() + + def _handle_response_with_id(self, response: str, gen_id: int): + """Only accept responses from the current generation.""" + if gen_id != self._generation_id: + # Stale response from a cancelled/cleared analysis -> ignore + return + self.append_message("eSim Copilot", response, is_user=False) + + def handle_response(self, response): + # Kept for backward compatibility if used elsewhere, + # but route everything through _handle_response_with_id with current id. + self._handle_response_with_id(response, self._generation_id) + + + @staticmethod + def format_text_to_html(text): + """Helper to convert basic Markdown to HTML for the Qt TextEdit.""" + import html + # 1. Escape existing HTML to prevent injection + text = html.escape(text) + + # 2. Convert **bold** to bold + text = re.sub(r'\*\*(.*?)\*\*', r'\1', text) + + # 3. Convert headers ### to

+ text = re.sub(r'###\s*(.*)', r'

\1

', text) + + # 4. Convert newlines to
for HTML rendering + text = text.replace('\n', '
') + return text + + def copy_last_response(self): + """Copy last assistant response to clipboard for easy paste into netlist.""" + if self._last_assistant_response: + cb = QApplication.clipboard() + cb.setText(self._last_assistant_response) + QMessageBox.information( + self, "Copied", + "Last response copied to clipboard. Paste into Spice Editor (Ctrl+V).", + QMessageBox.Ok, + ) + else: + QMessageBox.information( + self, "Nothing to copy", + "No assistant response yet. Run a netlist analysis or ask a question first.", + QMessageBox.Ok, + ) + + def append_message(self, sender, text, is_user): + """Append message INSTANTLY (Text Only, No Image Rendering).""" + if not text: + return + if not is_user: + self._last_assistant_response = text + + # 1. Define Headers + if is_user: + header = "You" + else: + header = "eSim Copilot" + + cursor = self.chat_display.textCursor() + cursor.movePosition(QTextCursor.End) + + # 2. Insert Header + cursor.insertHtml(f"
{header}
") + + # 3. Format Text (Bold, Newlines) but NO Image generation + # Use the helper function if you added it inside the class + formatted_text = self.format_text_to_html(text) + + # 4. Insert Text Instantly + cursor.insertHtml(formatted_text) + + self.chat_display.setTextCursor(cursor) + self.chat_display.ensureCursorVisible() + + # ==================== PRIORITY 3: ONE-CLICK FIX ==================== + + def _check_show_fixes_btn(self, netlist_path: str, facts: dict): + """Show the Apply Fixes button if there are auto-fixable issues.""" + self._last_netlist_path = netlist_path + self._last_facts = facts + has_fixes = ( + (facts.get("missing_models", "NONE") not in ("NONE", "")) or + (facts.get("floating_nodes", "NONE") not in ("NONE", "")) or + (not facts.get("has_node0") and not facts.get("has_gnd_label")) + ) + if has_fixes: + self._apply_fixes_btn.show() + else: + self._apply_fixes_btn.hide() + + def _apply_netlist_fixes(self): + """Auto-insert fixes (options, model stubs, bleed resistors) into netlist.""" + path = self._last_netlist_path + facts = self._last_facts + + if not path or not os.path.exists(path): + QMessageBox.warning(self, "No netlist", + "No recently analyzed netlist found. Run a netlist analysis first.") + return + + try: + with open(path, "r", encoding="utf-8", errors="ignore") as f: + lines = f.readlines() + except Exception as e: + QMessageBox.warning(self, "Read error", f"Cannot read netlist:\n{e}") + return + + insertions = [] + applied = [] + + # 1. Convergence options (safe when no .options present) + has_options = any(".options" in l.lower() for l in lines) + if not has_options: + insertions.append(".options gmin=1e-12 reltol=0.01\n") + applied.append("Added `.options gmin=1e-12 reltol=0.01` (convergence helper)") + + # 2. Missing model stubs + missing_str = facts.get("missing_models", "NONE") + if missing_str and missing_str != "NONE": + for part in missing_str.split(";"): + part = part.strip() + model_name = part.split("(")[0].strip() if "(" in part else part + model_name = model_name.strip() + if model_name and model_name.upper() != "NONE": + stub = _model_stub(model_name) + insertions.append(stub + "\n") + applied.append(f"Added stub: {stub}") + + # 3. Floating-node bleed resistors + floating_str = facts.get("floating_nodes", "NONE") + if floating_str and floating_str != "NONE": + for part in floating_str.split(";"): + node = part.strip().split(" ")[0].split("(")[0].strip() + if node and node != "0" and node.upper() != "NONE": + line = f"Rleak_{node} {node} 0 1G\n" + insertions.append(line) + applied.append(f"Added bleed resistor: {line.strip()}") + + if not insertions: + QMessageBox.information(self, "Nothing to fix", + "No auto-fixable issues detected in the last analysis.") + self._apply_fixes_btn.hide() + return + + # Confirm with user + msg = (f"Apply the following fixes to:\n{os.path.basename(path)}\n\n" + + "\n".join(f" \u2022 {a}" for a in applied) + + "\n\nA backup (.bak) will be created first.") + reply = QMessageBox.question(self, "Apply Fixes?", msg, + QMessageBox.Yes | QMessageBox.No) + if reply != QMessageBox.Yes: + return + + # Backup original + import shutil + try: + shutil.copy2(path, path + ".bak") + except Exception: + pass + + # Insert before .end (or append) + new_lines = [] + inserted = False + for line in lines: + if line.strip().lower() == ".end" and not inserted: + new_lines.append("* [COPILOT AUTO-FIX]\n") + new_lines.extend(insertions) + inserted = True + new_lines.append(line) + if not inserted: + new_lines.append("\n* [COPILOT AUTO-FIX]\n") + new_lines.extend(insertions) + new_lines.append(".end\n") + + try: + with open(path, "w", encoding="utf-8") as f: + f.writelines(new_lines) + except Exception as e: + QMessageBox.warning(self, "Write error", f"Failed to write file:\n{e}") + return + + self._apply_fixes_btn.hide() + self._last_netlist_path = None + self._last_facts = {} + self.append_message( + "eSim", + (f"Applied {len(applied)} fix(es) to {os.path.basename(path)}:\n" + + "\n".join(f" \u2022 {a}" for a in applied) + + "\n\nBackup saved as .bak — run simulation to verify."), + is_user=False, + ) + + # ==================== PRIORITY 4 & 7: BATCH ANALYSIS ==================== + + def analyze_batch_files(self): + """Let user pick multiple netlists or images for batch static analysis.""" + if self.is_bot_busy(): + return + + dlg = QMessageBox(self) + dlg.setWindowTitle("Batch Analysis") + dlg.setText("Select the type of files to batch analyze:") + netlist_btn = dlg.addButton("Netlists (.cir / .cir.out)", QMessageBox.AcceptRole) + image_btn = dlg.addButton("Images (.png / .jpg)", QMessageBox.AcceptRole) + dlg.addButton("Cancel", QMessageBox.RejectRole) + dlg.exec_() + + clicked = dlg.clickedButton() + if clicked == netlist_btn: + files, _ = QFileDialog.getOpenFileNames( + self, "Select Netlist Files", "", + "Netlists (*.cir *.cir.out *.net);;All Files (*)" + ) + if files: + self._run_batch_analysis("netlist", files) + elif clicked == image_btn: + files, _ = QFileDialog.getOpenFileNames( + self, "Select Image Files", "", + "Images (*.png *.jpg *.jpeg *.bmp *.tiff);;All Files (*)" + ) + if files: + QMessageBox.information( + self, "Vision batch", + f"Queuing {len(files)} image(s) for vision analysis.\n" + "This may take several minutes.", + ) + self._run_batch_analysis("image", files) + + def _run_batch_analysis(self, mode: str, file_paths: list): + """Start BatchWorker and stream progress into the chat.""" + total = len(file_paths) + label = "netlist(s)" if mode == "netlist" else "image(s)" + self.append_message( + "eSim", + f"Starting batch analysis of {total} {label}…", + is_user=False, + ) + + self._disable_ui_for_analysis() + self.loading_label.show() + self._apply_fixes_btn.hide() + + self._batch_worker = BatchWorker(mode, file_paths) + self._batch_worker.file_started.connect(self._on_batch_file_started) + self._batch_worker.all_done.connect(lambda results: self._on_batch_done(results, mode)) + self._batch_worker.finished.connect(self._on_batch_worker_finished) + self._batch_worker.start() + + def _disable_ui_for_analysis(self): + for attr in ("input_field", "send_btn", "attach_btn", "mic_btn", + "analyze_netlist_btn", "clear_btn", "batch_btn"): + w = getattr(self, attr, None) + if w: + w.setDisabled(True) + + def _enable_ui_after_analysis(self): + for attr in ("input_field", "send_btn", "attach_btn", "mic_btn", + "analyze_netlist_btn", "clear_btn", "batch_btn"): + w = getattr(self, attr, None) + if w: + w.setEnabled(True) + + def _on_batch_file_started(self, idx: int, total: int, name: str): + self.loading_label.setText(f"⏳ Analyzing {idx}/{total}: {name}…") + + def _on_batch_done(self, results: list, mode: str): + label = "Netlist" if mode == "netlist" else "Image" + lines = [f"**Batch {label} Analysis — {len(results)} file(s)**\n"] + ok_count = sum(1 for _, s in results if s.startswith("OK")) + err_count = len(results) - ok_count + for name, summary in results: + icon = "OK" if summary.startswith("OK") else "ISSUES" + lines.append(f"[{icon}] {name}: {summary}") + lines.append(f"\nSummary: {ok_count} OK, {err_count} with issues.") + self.append_message("eSim", "\n".join(lines), is_user=False) + + def _on_batch_worker_finished(self): + self.loading_label.setText("⏳ eSim Copilot is thinking…") + self.loading_label.hide() + self._enable_ui_after_analysis() + self.input_field.setFocus() + + # ==================== PRIORITY 5: MODEL SETTINGS ==================== + + def open_settings(self): + """Open the model-selection settings dialog.""" + from chatbot.ollama_runner import ( + list_available_models, save_model_settings, reload_model_settings, + TEXT_MODELS, VISION_MODELS, + ) + import chatbot.ollama_runner as runner + + dlg = CopilotSettingsDialog( + current_text = TEXT_MODELS.get("default", "qwen2.5:3b"), + current_vision = VISION_MODELS.get("primary", "minicpm-v:latest"), + parent = self, + ) + if dlg.exec_() == QDialog.Accepted: + text_m, vis_m = dlg.get_selections() + save_model_settings(text_m, vis_m) + runner.TEXT_MODELS["default"] = text_m + runner.VISION_MODELS["primary"] = vis_m + self.append_message( + "eSim", + f"Model settings saved:\n Text/reasoning: {text_m}\n Vision: {vis_m}", + is_user=False, + ) + + # ==================== PRIORITY 8: REAL-TIME KICAD HINTS ==================== + + def toggle_kicad_watch(self): + """Start / stop the real-time hints watcher.""" + if self._watch_active: + self._watch_timer.stop() + self._watch_active = False + self.watch_btn.setStyleSheet(""" + QPushButton { + background-color: transparent; + border: 1px solid #ddd; + border-radius: 15px; + font-size: 14px; + } + QPushButton:hover { background-color: #e3f2fd; border-color: #2196f3; } + """) + self.watch_btn.setToolTip("Toggle real-time hints (polls active project every 30 s)") + self.append_message("eSim", "Real-time hints: OFF", is_user=False) + else: + self._watch_active = True + self._watch_last_facts = None + self.watch_btn.setStyleSheet(""" + QPushButton { + background-color: #2196f3; + color: white; + border-radius: 15px; + font-size: 14px; + } + QPushButton:hover { background-color: #1565c0; } + """) + self.watch_btn.setToolTip("Real-time hints: ON — click to disable") + self.append_message( + "eSim", + "Real-time hints: ON\nPolling active project every 30 s for static issues " + "(no LLM call — instant feedback).", + is_user=False, + ) + self._kicad_watch_tick() # run immediately + self._watch_timer.start() + + def _kicad_watch_tick(self): + """Timer callback: run FACT detectors on active project without calling the LLM.""" + try: + from configuration.Appconfig import Appconfig + proj_dir = Appconfig().current_project.get("ProjectName") + if not proj_dir or not os.path.isdir(proj_dir): + return + + proj_name = os.path.basename(proj_dir) + # Prefer .cir (pre-simulation) over .cir.out + candidates = [ + os.path.join(proj_dir, proj_name + ".cir"), + os.path.join(proj_dir, proj_name + ".cir.out"), + ] + netlist_path = next((p for p in candidates if os.path.exists(p)), None) + if not netlist_path: + return + + with open(netlist_path, "r", encoding="utf-8", errors="ignore") as f: + text = f.read() + + floating = _detect_floating_nodes(text) + missing_m = _detect_missing_models(text) + has_n0, has_gnd = _netlist_ground_info(text) + + new_facts = { + "no_ground": not has_n0 and not has_gnd, + "floating": tuple(n for n, _, _ in floating), + "missing_models": tuple(m for m, _ in missing_m), + } + + if new_facts == self._watch_last_facts: + return # nothing changed + + self._watch_last_facts = new_facts + + hints = [] + fname = os.path.basename(netlist_path) + if new_facts["no_ground"]: + hints.append(" No ground reference (node 0)") + for node in new_facts["floating"]: + hints.append(f" Floating node: {node}") + for model in new_facts["missing_models"]: + hints.append(f" Missing model: {model}") + + if hints: + self.append_message( + "Hints", + f"[{fname}]\n" + "\n".join(hints), + is_user=False, + ) + else: + self.append_message( + "Hints", + f"[{fname}] No static issues detected.", + is_user=False, + ) + except Exception as e: + print(f"[WATCH TICK] {e}") + + # ---------- CLEAN SHUTDOWN ---------- + + def closeEvent(self, event): + """Stop analysis when the chatbot window/dock is closed.""" + self.stop_analysis() + if self._watch_active: + self._watch_timer.stop() + if self._batch_worker and self._batch_worker.isRunning(): + self._batch_worker.quit() + self._batch_worker.wait(300) + try: + clear_history() + except Exception: + pass + event.accept() + + def debug_error(self, error_log_path: str): + """ + Called by Application when a simulation error happens. + Reads ngspice_error.log and asks the copilot to explain + fix it in eSim. + """ + if not error_log_path or not os.path.exists(error_log_path): + QMessageBox.warning( + self, + "Error log missing", + f"Could not find error log at:\n{error_log_path}", + ) + return + + try: + with open(error_log_path, "r", encoding="utf-8", errors="ignore") as f: + log_text = f.read() + except Exception as e: + QMessageBox.warning(self, "Error", f"Failed to read error log:\n{e}") + return + + # Show trimmed log in the chat for user visibility + tail_lines = "\n".join(log_text.splitlines()[-40:]) # last 40 lines + display = ( + "Automatic ngspice error captured from eSim:\n\n" + "```" + f"{tail_lines}\n" + "```" + ) + self.append_message("eSim", display, is_user=False) + + # Build a focused query for the backend + full_query = ( + "The following is an ngspice error log from an eSim simulation.\n" + "1) Explain the exact root cause in simple terms.\n" + "2) Give concrete, step‑by‑step instructions to fix it INSIDE eSim " + "(KiCad schematic / sources / analysis settings).\n\n" + "[NGSPICE_ERROR_LOG_START]\n" + f"{log_text}\n" + "[NGSPICE_ERROR_LOG_END]" + ) + + # Disable UI while analysis is running + self.input_field.setDisabled(True) + self.send_btn.setDisabled(True) + if hasattr(self, "attach_btn"): + self.attach_btn.setDisabled(True) + if hasattr(self, "mic_btn"): + self.mic_btn.setDisabled(True) + if hasattr(self, "analyze_netlist_btn"): + self.analyze_netlist_btn.setDisabled(True) + if hasattr(self, "clear_btn"): + self.clear_btn.setDisabled(True) + + self.loading_label.show() + + # NEW: bump generation and bind response with this gen + self._generation_id += 1 + current_gen = self._generation_id + + self.worker = ChatWorker(full_query, self.copilot) + self.worker.response_ready.connect( + lambda resp, gen=current_gen: self._handle_response_with_id(resp, gen) + ) + self.worker.finished.connect(self.on_worker_finished) + self.worker.start() + +# ==================== MODULE-LEVEL HELPERS ==================== + +def _model_stub(model_name: str) -> str: + """Return a minimal SPICE .model stub inferred from the model name.""" + n = model_name.upper() + if "PNP" in n: + return f".model {model_name} PNP(Is=1e-14 Bf=200 Vaf=100)" + if "NPN" in n or n.startswith("Q2N") or n.startswith("BC") or n.startswith("2N"): + return f".model {model_name} NPN(Is=1e-14 Bf=200 Vaf=100)" + if n.startswith("1N") or "DIODE" in n or (n.startswith("D") and len(n) <= 8): + return f".model {model_name} D(Is=1e-14 Rs=1)" + if "NMOS" in n or n.startswith("NMOS"): + return f".model {model_name} NMOS(Kp=120u Vto=1.0 Gamma=0)" + if "PMOS" in n or n.startswith("PMOS"): + return f".model {model_name} PMOS(Kp=60u Vto=-1.0 Gamma=0)" + # Default: assume NPN BJT + return f".model {model_name} NPN(Is=1e-14 Bf=200 Vaf=100)" + + +# ==================== SETTINGS DIALOG ==================== + +class CopilotSettingsDialog(QDialog): + """Simple dialog for choosing text and vision models.""" + + def __init__(self, current_text: str, current_vision: str, parent=None): + super().__init__(parent) + self.setWindowTitle("eSim Copilot — Model Settings") + self.setMinimumWidth(400) + self.setModal(True) + + from chatbot.ollama_runner import list_available_models + available = list_available_models() + + # Ensure current selections appear even if Ollama is offline + for m in (current_text, current_vision): + if m not in available: + available.insert(0, m) + + layout = QVBoxLayout(self) + + title = QLabel("Select AI models served by Ollama") + title.setStyleSheet("font-weight: bold; font-size: 13px; margin-bottom: 6px;") + layout.addWidget(title) + + form = QFormLayout() + + self._text_combo = QComboBox() + self._text_combo.addItems(available) + idx = self._text_combo.findText(current_text) + if idx >= 0: + self._text_combo.setCurrentIndex(idx) + form.addRow("Text / Reasoning model:", self._text_combo) + + self._vision_combo = QComboBox() + self._vision_combo.addItems(available) + idx = self._vision_combo.findText(current_vision) + if idx >= 0: + self._vision_combo.setCurrentIndex(idx) + form.addRow("Vision model:", self._vision_combo) + + layout.addLayout(form) + + note = QLabel( + "Changes take effect immediately.\n" + "Models must already be pulled in Ollama\n" + "(e.g. ollama pull qwen2.5:3b)." + ) + note.setStyleSheet("color: #666; font-size: 11px; margin-top: 6px;") + layout.addWidget(note) + + btn_row = QHBoxLayout() + save_btn = QPushButton("Save") + cancel_btn = QPushButton("Cancel") + save_btn.setDefault(True) + save_btn.clicked.connect(self.accept) + cancel_btn.clicked.connect(self.reject) + btn_row.addStretch() + btn_row.addWidget(save_btn) + btn_row.addWidget(cancel_btn) + layout.addLayout(btn_row) + + def get_selections(self): + """Return (text_model, vision_model) chosen by the user.""" + return self._text_combo.currentText(), self._vision_combo.currentText() + + +# ==================== DOCK FACTORY ==================== + +from PyQt5.QtWidgets import QDockWidget +from PyQt5.QtCore import Qt + +def createchatbotdock(parent=None): + """ + Factory function for DockArea / Application integration. + Returns a QDockWidget containing a ChatbotGUI instance. + """ + dock = QDockWidget("eSim Copilot", parent) + dock.setAllowedAreas(Qt.RightDockWidgetArea | Qt.LeftDockWidgetArea) + + chatbot_widget = ChatbotGUI(parent) + dock.setWidget(chatbot_widget) + return dock + + +# Standalone test +if __name__ == "__main__": + app = QApplication(sys.argv) + w = ChatbotGUI() + w.resize(500, 600) + w.show() + sys.exit(app.exec_()) + +def create_chatbot_dock(parent=None): + """Factory function for DockArea integration.""" + from PyQt5.QtWidgets import QDockWidget + from PyQt5.QtCore import Qt + + dock = QDockWidget("eSim Copilot", parent) + dock.setAllowedAreas(Qt.RightDockWidgetArea | Qt.LeftDockWidgetArea) + + chatbot_widget = ChatbotGUI(parent) + dock.setWidget(chatbot_widget) + + return dock diff --git a/src/frontEnd/DockArea.py b/src/frontEnd/DockArea.py index e09970f72..32d0682fb 100755 --- a/src/frontEnd/DockArea.py +++ b/src/frontEnd/DockArea.py @@ -12,6 +12,7 @@ from PyQt5.QtWidgets import QLineEdit, QLabel, QPushButton, QVBoxLayout, QHBoxLayout from PyQt5.QtCore import Qt import os +from frontEnd.Chatbot import create_chatbot_dock from converter.pspiceToKicad import PspiceConverter from converter.ltspiceToKicad import LTspiceConverter from converter.LtspiceLibConverter import LTspiceLibConverter @@ -164,14 +165,14 @@ def plottingEditor(self): ) count = count + 1 - def ngspiceEditor(self, projName, netlist, simEndSignal, plotFlag): + def ngspiceEditor(self, projName, netlist, simEndSignal,chatbot): """ This function creates widget for Ngspice window.""" global count self.ngspiceWidget = QtWidgets.QWidget() self.ngspiceLayout = QtWidgets.QVBoxLayout() self.ngspiceLayout.addWidget( - NgspiceWidget(netlist, simEndSignal, plotFlag) + NgspiceWidget(netlist, simEndSignal,chatbot) ) # Adding to main Layout @@ -209,7 +210,7 @@ def eSimConverter(self): """This function creates a widget for eSimConverter.""" global count - dockName = 'Schematic Converter-' + dockName = 'Schematics Converter-' self.eConWidget = QtWidgets.QWidget() self.eConLayout = QVBoxLayout() # QVBoxLayout for the main layout @@ -242,7 +243,7 @@ def eSimConverter(self): upload_button2.clicked.connect(lambda: self.pspiceLib_converter.upload_file_Pspice(file_path_text_box.text())) button_layout.addWidget(upload_button2) - upload_button1 = QPushButton("Convert Pspice schematic") + upload_button1 = QPushButton("Convert Pspice schematics") upload_button1.setFixedSize(180, 30) upload_button1.clicked.connect(lambda: self.pspice_converter.upload_file_Pspice(file_path_text_box.text())) button_layout.addWidget(upload_button1) @@ -252,7 +253,7 @@ def eSimConverter(self): upload_button3.clicked.connect(lambda: self.ltspiceLib_converter.upload_file_LTspice(file_path_text_box.text())) button_layout.addWidget(upload_button3) - upload_button = QPushButton("Convert LTspice schematic") + upload_button = QPushButton("Convert LTspice schematics") upload_button.setFixedSize(184, 30) upload_button.clicked.connect(lambda: self.ltspice_converter.upload_file_LTspice(file_path_text_box.text())) button_layout.addWidget(upload_button) @@ -304,9 +305,9 @@ def eSimConverter(self):

Pspice to eSim will convert the PSpice Schematic and Library files to KiCad Schematic and Library files respectively with proper mapping of the components and the wiring. By this way one - will be able to simulate their schematic in PSpice and get the PCB layout in KiCad. + will be able to simulate their schematics in PSpice and get the PCB layout in KiCad.

- LTspice to eSim will convert symbols and schematic from LTspice to Kicad.The goal is to design and + LTspice to eSim will convert symbols and schematics from LTspice to Kicad.The goal is to design and simulate under LTspice and to automatically transfer the circuit under Kicad to draw the PCB.

@@ -606,3 +607,17 @@ def closeDock(self): self.temp = self.obj_appconfig.current_project['ProjectName'] for dockwidget in self.obj_appconfig.dock_dict[self.temp]: dockwidget.close() + + def chatbotEditor(self): + """ + Creates the eSim Copilot (Chatbot) dock. + """ + global count + + self.chatbot_dock = create_chatbot_dock(self) + + self.addDockWidget(QtCore.Qt.BottomDockWidgetArea, self.chatbot_dock) + + self.chatbot_dock.setVisible(True) + self.chatbot_dock.raise_() + diff --git a/src/frontEnd/ProjectExplorer.py b/src/frontEnd/ProjectExplorer.py index 997723787..bc55dac9c 100755 --- a/src/frontEnd/ProjectExplorer.py +++ b/src/frontEnd/ProjectExplorer.py @@ -1,11 +1,10 @@ from PyQt5 import QtCore, QtWidgets +from PyQt5.QtWidgets import QDockWidget, QMessageBox,QMenu import os import json from configuration.Appconfig import Appconfig from projManagement.Validation import Validation - -# This is main class for Project Explorer Area. class ProjectExplorer(QtWidgets.QWidget): """ This class contains function: @@ -104,25 +103,45 @@ def addTreeNode(self, parents, children): ) = [] def openMenu(self, position): - indexes = self.treewidget.selectedIndexes() - if len(indexes) > 0: - level = 0 - index = indexes[0] - while index.parent().isValid(): - index = index.parent() - level += 1 - - menu = QtWidgets.QMenu() + """Handle right-click context menu using QTreeWidget items.""" + # 1. Use the correct widget name: self.treewidget + items = self.treewidget.selectedItems() + + level = -1 + file_path = "" + + if len(items) > 0: + item = items[0] + file_path = item.text(1) + + if item.parent() is None: + level = 0 + else: + level = 1 + + menu = QMenu() + if level == 0: - renameProject = menu.addAction(self.tr("Rename Project")) - renameProject.triggered.connect(self.renameProject) - deleteproject = menu.addAction(self.tr("Remove Project")) - deleteproject.triggered.connect(self.removeProject) - refreshproject = menu.addAction(self.tr("Refresh")) - refreshproject.triggered.connect(self.refreshProject) + + analyze_action = menu.addAction("Analyze Project Netlist") + + project_name = item.text(0) + netlist_path = os.path.join(file_path, f"{project_name}.cir.out") + analyze_action.triggered.connect(lambda: self._analyze_netlist_in_copilot(netlist_path)) + + rename_action = menu.addAction("Rename Project") + rename_action.triggered.connect(self.renameProject) + remove_action = menu.addAction("Remove Project") + remove_action.triggered.connect(self.removeProject) + elif level == 1: - openfile = menu.addAction(self.tr("Open")) - openfile.triggered.connect(self.openProject) + + if file_path.endswith((".cir", ".cir.out", ".net")): + analyze_file_action = menu.addAction("Analyze this Netlist") + analyze_file_action.triggered.connect(lambda: self._analyze_netlist_in_copilot(file_path)) + + refresh_action = menu.addAction("Refresh") + refresh_action.triggered.connect(self.refreshInstant) menu.exec_(self.treewidget.viewport().mapToGlobal(position)) @@ -430,3 +449,35 @@ def renameProject(self): 'contain space between them' ) msg.exec_() + + def _analyze_netlist_in_copilot(self, netlist_path: str): + """Send selected .cir file to chatbot for analysis.""" + try: + # Get the main Application window (traverse up the widget hierarchy) + main_window = self + while main_window.parent() is not None: + main_window = main_window.parent() + + # Find the chatbot dock + for dock in main_window.findChildren(QDockWidget): + if "Copilot" in dock.windowTitle(): + chatbot_widget = dock.widget() + if hasattr(chatbot_widget, 'analyze_specific_netlist'): + chatbot_widget.analyze_specific_netlist(netlist_path) + # Show the dock if it's hidden + if not dock.isVisible(): + dock.show() + return + + QMessageBox.information( + self, + "Chatbot not open", + "Please open the eSim Copilot window first (View → eSim Copilot)." + ) + except Exception as e: + print(f"[COPILOT] Failed to trigger analysis: {e}") + QMessageBox.warning( + self, + "Error", + f"Could not connect to chatbot:\n{e}" + ) diff --git a/src/frontEnd/TerminalUi.py b/src/frontEnd/TerminalUi.py index f838ae076..4c53548f1 100644 --- a/src/frontEnd/TerminalUi.py +++ b/src/frontEnd/TerminalUi.py @@ -94,7 +94,6 @@ def cancelSimulation(self): def redoSimulation(self): """This function reruns the ngspice simulation """ - self.Flag = "Flase" self.cancelSimulationButton.setEnabled(True) self.redoSimulationButton.setEnabled(False) @@ -108,23 +107,6 @@ def redoSimulation(self): self.simulationConsole.setText("") self.simulationCancelled = False - msg_box = QtWidgets.QMessageBox(self) - msg_box.setWindowTitle("Ngspice Plots") - msg_box.setText("Do you want Ngspice plots?") - - yes_button = msg_box.addButton("Yes", QtWidgets.QMessageBox.YesRole) - no_button = msg_box.addButton("No", QtWidgets.QMessageBox.NoRole) - - msg_box.exec_() - - if msg_box.clickedButton() == yes_button: - self.Flag = True - else: - self.Flag = False - - # Emit a custom signal with name plotFlag2 depending upon the Flag - self.qProcess.setProperty("plotFlag2", self.Flag) - self.qProcess.start('ngspice', self.args) def changeColor(self): diff --git a/src/frontEnd/Workspace.py b/src/frontEnd/Workspace.py index fca73e399..b6ebdd53a 100755 --- a/src/frontEnd/Workspace.py +++ b/src/frontEnd/Workspace.py @@ -130,7 +130,9 @@ def createWorkspace(self): else: user_home = os.path.expanduser('~') - file = open(os.path.join(user_home, ".esim/workspace.txt"), 'w') + esim_dir = os.path.join(user_home, ".esim") + os.makedirs(esim_dir, exist_ok=True) + file = open(os.path.join(esim_dir, "workspace.txt"), 'w') file.writelines( str(self.obj_appconfig.workspace_check) + " " + self.workspace_loc.text() diff --git a/src/frontEnd/manual/esim_netlist_analysis_output_contract.txt b/src/frontEnd/manual/esim_netlist_analysis_output_contract.txt new file mode 100644 index 000000000..6b33e4b16 --- /dev/null +++ b/src/frontEnd/manual/esim_netlist_analysis_output_contract.txt @@ -0,0 +1,52 @@ +ESIM COPILOT NETLIST ANALYSIS OUTPUT CONTRACT +============================================= + +This file defines HOW the chatbot MUST respond. + +-------------------------------------------------- +1. INPUT SOURCE +-------------------------------------------------- + +The chatbot MUST rely ONLY on FACT blocks like: + +[FACT NET_SYNTAX_VALID=YES] +[FACT FLOATING_NODES=NONE] +[FACT MISSING_MODELS=BC547] +... + +The raw netlist is FOR REFERENCE ONLY. + +-------------------------------------------------- +2. OUTPUT SECTIONS (MANDATORY) +-------------------------------------------------- + +The chatbot MUST output EXACTLY these sections: + +1. Syntax / SPICE rule errors +2. Topology / connection problems +3. Simulation setup issues (.ac/.tran/.op etc.) +4. Summary + +-------------------------------------------------- +3. RULES +-------------------------------------------------- + +• If a FACT is NONE → DO NOT invent issues +• If a FACT is present → MUST report it +• Ground issues only if BOTH node0 and GND missing +• Count ALL issues in Summary + +-------------------------------------------------- +4. SUMMARY FORMAT +-------------------------------------------------- + +"Total issues detected: X + - Floating nodes: Y + - Missing models: Z + - Missing subcircuits: A + - Voltage conflicts: B + - Missing analysis: C" + +-------------------------------------------------- +END OF FILE +-------------------------------------------------- diff --git a/src/ingest.py b/src/ingest.py new file mode 100644 index 000000000..c85474cfa --- /dev/null +++ b/src/ingest.py @@ -0,0 +1,30 @@ +import os +import sys +current_dir = os.path.dirname(os.path.abspath(__file__)) +sys.path.append(current_dir) + +from chatbot.knowledge_base import ingest_pdfs + +pdf_folder = os.path.join(current_dir, "manuals") + +if not os.path.exists(pdf_folder): + print(f"Error: Folder not found: {pdf_folder}") + sys.exit(1) + +print(f"📂 Scanning folder: {pdf_folder}") + +files = [f for f in os.listdir(pdf_folder) if f.endswith('.pdf') or f.endswith('.txt')] +print(f"📄 Found {len(files)} Document(s): {files}") + +if not files: + print("No PDFs or Text files found to ingest.") + sys.exit() + +print("\n🚀 Starting Ingestion... (Press Ctrl+C to stop)") +try: + ingest_pdfs(pdf_folder) + print("\n✅ Ingestion Complete!") +except KeyboardInterrupt: + print("\n⚠️ Ingestion stopped by user.") +except Exception as e: + print(f"\n❌ Error: {e}") diff --git a/src/manuals/esim_netlist_analysis_output_contract.txt b/src/manuals/esim_netlist_analysis_output_contract.txt new file mode 100644 index 000000000..10c03d74b --- /dev/null +++ b/src/manuals/esim_netlist_analysis_output_contract.txt @@ -0,0 +1,418 @@ +Reference +====================================== + +TABLE OF CONTENTS +1. eSim Overview & Workflow +2. Schematic Design (KiCad) & Netlist Generation +3. SPICE Netlist Rules & Syntax +4. Simulation Types & Commands +5. Components & Libraries +6. Common Errors & Troubleshooting +7. IC Availability & Knowledge + +====================================================================== +1. ESIM OVERVIEW & WORKFLOW +====================================================================== +eSim is an open-source EDA tool for circuit design, simulation, and PCB layout. +It integrates KiCad (schematic), NgSpice (simulation), and Python (automation). + +1.1 ESIM USER INTERFACE & TOOLBAR ICONS (FROM TOP TO BOTTOM): +---------------------------------------------------------------------- +1. NEW PROJECT (Menu > New Project) + - Function: Creates a new project folder in ~/eSim-Workspace. + - Note: Project name must not have spaces. + +2. OPEN SCHEMATIC (Icon: Circuit Diagram) + - Function: Launches KiCad Eeschema (Schematic Editor). + - Usage: + - If new project: Confirms creation of schematic. + - If existing: Opens last saved schematic. + - Key Step: Use "Place Symbol" (A) to add components from eSim_Devices. + +3. CONVERT KICAD TO NGSPICE (Icon: Gear/Converter) + - Function: Converts the KiCad netlist (.cir) into an NgSpice-compatible netlist (.cir.out). + - Prerequisite: You MUST generate the netlist in KiCad first! + - Features (Tabs inside this tool): + a. Analysis: Set simulation type (.tran, .dc, .ac, .op). + b. Source Details: Set values for SINE, PULSE, AC, DC sources. + c. Ngspice Model: Add parameters for logic gates/flip-flops. + d. Device Modeling: Link diode/transistor models to symbols. + e. Subcircuits: Link subcircuit files to 'X' components. + - Action: Click "Convert" to generate the final simulation file. + +4. SIMULATION (Icon: Play Button/Waveform) + - Function: Launches NgSpice console and plotting window. + - Usage: Click "Simulate" after successful conversion. + - Output: Shows plots and simulation logs. + +5. MODEL BUILDER / DEVICE MODELING (Icon: Diode/Graph) + - Function: Create custom SPICE models from datasheet parameters. + - Supported Devices: Diode, BJT, MOSFET, IGBT, JFET. + - Usage: Enter datasheet values (Is, Rs, Cjo) -> Calculate -> Save Model. + +6. SUBCIRCUIT MAKER (Icon: Chip/IC) + - Function: Convert a schematic into a reusable block (.sub file). + - Usage: Create a schematic with ports -> Generate Netlist -> Click Subcircuit Maker. + +7. OPENMODELICA (Icon: OM Logo) + - Function: Mixed-signal simulation for mechanical-electrical systems. + +8. MAKERCHIP (Icon: Chip with 'M') + - Function: Cloud-based Verilog/FPGA design. + +STANDARD WORKFLOW: +1. Open eSim → New Project. +2. Open Schematic (Icon 1) → Draw Circuit → Generate Netlist (File > Export > Netlist). +3. Convert (Icon 2) → Set Analysis/Source values → Click Convert. +4. Simulate (Icon 3) → View waveforms. + +KEY SHORTCUTS: +- A: Add Component +- W: Add Wire +- M: Move +- R: Rotate +- V: Edit Value +- P: Add Power/Ground +- Delete: Remove item +- Esc: Cancel action + +====================================================================== +1.2 HANDLING FOLLOW-UP QUESTIONS +====================================================================== +- Context Awareness: eSim workflow is linear (Schematic -> Netlist -> Convert -> Simulate). +- If user asks "What next?" after drawing a schematic, the answer is "Generate Netlist". +- If user asks "What next?" after converting, the answer is "Simulate". + +====================================================================== +2. SCHEMATIC DESIGN (KICAD) & NETLIST GENERATION +====================================================================== +GROUND REQUIREMENT: +- SPICE requires a node "0" as ground reference. +- ALWAYS use the "GND" symbol from the "power" library. +- Do NOT use other grounds (Earth, Chassis) for simulation reference. + +FLOATING NODES: +- Every node must connect to at least two components. +- A node connecting to only one pin is "floating" and causes errors. +- Fix: Connect the pin or use a "No Connect" flag (X) if intentional (but careful with simulation). + +ADDING SOURCES: +- DC Voltage: eSim_Sources:vsource (set DC value) +- AC Voltage: eSim_Sources:vac (set magnitude/phase) +- Sine Wave: eSim_Sources:vsin (set offset, amplitude, freq) +- Pulse: eSim_Sources:vpulse (set V1, V2, delay, rise/fall, width, period) + +HOW TO GENERATE THE NETLIST (STEP-BY-STEP): +This is the most critical step to bridge Schematic and Simulation. + +Method 1: Top Toolbar (Easiest) +1. Look for the "Generate Netlist" icon in the top toolbar. + (It typically looks like a page with text 'NET' or a green plug icon). +2. Click it to open the Export Netlist dialog. + +Method 2: Menu Bar (If icon is missing) +1. Go to "File" menu. +2. Select "Export". +3. Click "Netlist...". + (Note: In some older versions, this may be under "Tools" → "Generate Netlist File"). + +IN THE NETLIST DIALOG: +1. Click the "Spice" tab (Do not use Pcbnew tab). +2. Ensure "Default" format is selected. +3. Click the "Generate Netlist" button. +4. A save dialog appears: + - Ensure the filename is `.cir`. + - Save it inside your project folder. +5. Close the dialog and close Schematic Editor. + +BACK IN ESIM: +1. Select your project in the explorer. +2. Click the "Convert KiCad to NgSpice" button on the toolbar. +3. If successful, you can now proceed to "Simulate". + +====================================================================== +3. SPICE NETLIST RULES & SYNTAX +====================================================================== +A netlist is a text file describing connections. eSim generates it automatically. + +COMPONENT PREFIXES (First letter matters!): +- R: Resistor (R1, R2) +- C: Capacitor (C1) +- L: Inductor (L1) +- D: Diode (D1) +- Q: BJT Transistor (Q1) +- M: MOSFET (M1) +- V: Voltage Source (V1) +- I: Current Source (I1) +- X: Subcircuit/IC (X1) + +SYNTAX EXAMPLES: +Resistor: R1 node1 node2 1k +Capacitor: C1 node1 0 10u +Diode: D1 anode cathode 1N4007 +BJT (NPN): Q1 collector base emitter BC547 +MOSFET: M1 drain gate source bulk IRF540 +Subcircuit: X1 node1 node2 ... subckt_name + +RULES: +- Floating Nodes: Fatal error. +- Voltage Loop: Two ideal voltage sources in parallel = Error. +- Model Definitions: Every diode/transistor needs a .model statement. +- Subcircuits: Every 'X' component needs a .subckt definition. + +====================================================================== +4. SIMULATION TYPES & COMMANDS +====================================================================== +You must define at least one analysis type in your netlist. + +A. TRANSIENT ANALYSIS (.tran) +- Time-domain simulation (like an oscilloscope). +- Syntax: .tran +- Example: .tran 1u 10m (1ms to 10ms) +- Use for: waveforms, pulses, switching circuits. + +B. DC ANALYSIS (.dc) +- Sweeps a source voltage/current. +- Syntax: .dc +- Example: .dc V1 0 5 0.1 (Sweep V1 from 0 to 5V) +- Use for: I-V curves, transistor characteristics. + +C. AC ANALYSIS (.ac) +- Frequency response (Bode plot). +- Syntax: .ac +- Example: .ac dec 10 10 100k (10 points/decade, 10Hz-100kHz) +- Use for: Filters, amplifiers gain/phase. + +D. OPERATING POINT (.op) +- Calculates DC bias points (steady state). +- Syntax: .op +- Result: Lists voltage at every node and current in sources. + +====================================================================== +5. COMPONENTS & LIBRARIES +====================================================================== +LIBRARY PATH: /usr/share/kicad/library/ + +COMMON LIBRARIES: +- eSim_Devices: R, C, L, D, Q, M (Main library) +- power: GND, VCC, +5V (Power symbols) +- eSim_Sources: vsource, vsin, vpulse (Signal sources) +- eSim_Subckt: OpAmps (LM741, LM358), Timers (NE555), Regulators (LM7805) + +HOW TO ADD MODELS: +1. Right-click component → Properties +2. Edit "Spice_Model" field +3. Paste .model or .subckt reference + +MODEL EXAMPLES (Copy-Paste): +.model 1N4007 D(Is=1e-14 Rs=0.1 Bv=1000) +.model BC547 NPN(Bf=200 Is=1e-14 Vaf=100) +.model 2N2222 NPN(Bf=255 Is=1e-14) + +====================================================================== +6. COMMON ERRORS & TROUBLESHOOTING +====================================================================== +ERROR: "Singular Matrix" / "Gmin stepping failed" +- Cause: Floating node, perfect switch, or bad circuit loop. +- Fix 1: Check for unconnected pins. +- Fix 2: Add 1GΩ resistor to ground at floating nodes. +- Fix 3: Add .options gmin=1e-10 to netlist. + +ERROR: "Model not found" / "Subcircuit not found" +- Cause: Component used (e.g., Q1) but no .model defined. +- Fix: Add the missing .model or .subckt definition to the netlist or schematic. + +ERROR: "Project does not contain Kicad netlist file" +- Cause: You forgot to generate the netlist in KiCad or didn't save it as .cir. +- Fix: Go back to Schematic, click File > Export > Netlist, and save as .cir. + +ERROR: "Permission denied" +- Fix: Run eSim as administrator (sudo) or fix workspace permissions. + +====================================================================== +7. IC AVAILABILITY & KNOWLEDGE +====================================================================== +SUPPORTED ICs (via eSim_Subckt library): +- Op-Amps: LM741, LM358, LM324, TL082, AD844 +- Timers: NE555, LM555 +- Regulators: LM7805, LM7812, LM7905, LM317 +- Logic: 7400, 7402, 7404, 7408, 7432, 7486, 7474 (Flip-Flop) +- Comparators: LM311, LM339 +- Optocouplers: 4N35, PC817 + +Status: All listed above are "Completed" and verified for eSim. + + +====================================================================== +8. ABOUT ESIM PROJECT +====================================================================== + +WHO DEVELOPED eSim: +- eSim is developed and maintained by **FOSSEE (Free/Libre and Open Source Software in Education)**. +- FOSSEE is a project under the **Indian Institute of Technology (IIT) Bombay**. +- The goal of eSim is to promote **open-source Electronic Design Automation (EDA)** tools for education and research. + +FUNDING & SUPPORT: +- eSim is funded by the **Ministry of Education (MoE), Government of India**. +- It is part of the **National Mission on Education through ICT (NMEICT)**. + +WHY eSim EXISTS: +- To provide a **free alternative** to commercial EDA tools (like Proteus, Multisim). +- To help students learn circuit simulation, PCB design, and SPICE modeling. +- To integrate multiple open tools into one workflow: + - KiCad → Schematic & PCB + - NgSpice → Simulation + - Python → Automation & analysis + +OFFICIAL WEBSITE: +- https://esim.fossee.in + +====================================================================== +9. BASIC ELECTRONICS RULES (VERY IMPORTANT FOR SIMULATION) +====================================================================== + +These rules apply to **ALL circuits**, regardless of software. + +GENERAL CIRCUIT RULES: +1. Every circuit MUST have a closed loop. +2. Current always flows from higher potential to lower potential (conventional current). +3. Voltage is always measured between two nodes. +4. Power is consumed by loads, supplied by sources. + +GROUND RULE: +- Ground (node 0) is the **reference point** for all voltages. +- Without ground, SPICE cannot solve equations. +- One ground per circuit is enough (multiple grounds must be same node). + +KIRCHHOFF’S LAWS: +1. KCL (Current Law): + - Sum of currents entering a node = sum of currents leaving the node. +2. KVL (Voltage Law): + - Sum of voltages around a closed loop = 0. + +PASSIVE SIGN CONVENTION: +- If current enters the positive terminal of an element, power is absorbed. +- If current enters the negative terminal, power is delivered. + +====================================================================== +10. COMMON SPICE SIMULATION MISTAKES (STUDENTS OFTEN ASK) +====================================================================== + +MISTAKE 1: No ground in schematic +- Symptom: "singular matrix" error +- Fix: Add GND symbol from power library + +MISTAKE 2: Floating pins on ICs +- Symptom: convergence errors, random voltages +- Fix: Tie unused inputs to GND or VCC via resistors + +MISTAKE 3: Ideal voltage source loop +- Symptom: "Voltage source loop" error +- Cause: Two voltage sources directly connected +- Fix: Add small resistor (0.1Ω – 1Ω) + +MISTAKE 4: Missing simulation command +- Symptom: Simulation runs but no output +- Fix: Add .tran, .ac, .dc, or .op command + +MISTAKE 5: Extremely small timestep +- Symptom: Simulation very slow or fails +- Fix: Increase timestep or reduce stop time + +====================================================================== +11. HOW TO READ NGSPICE ERROR MESSAGES +====================================================================== + +ERROR: "Singular matrix" +Meaning: +- Circuit equations cannot be solved +Common causes: +- Floating nodes +- Missing ground +- Ideal switches +Fix: +- Add leakage resistors (1GΩ to ground) + +ERROR: "Time step too small" +Meaning: +- Solver cannot converge +Fix: +- Increase timestep +- Add series resistance +- Reduce frequency + +ERROR: "Model not found" +Meaning: +- Component model missing +Fix: +- Add .model statement +- Include model library +- Use eSim standard components + +====================================================================== +12. HOW eSim STORES FILES (USERS OFTEN ASK) +====================================================================== + +DEFAULT WORKSPACE: +- ~/eSim-Workspace/ + +PROJECT STRUCTURE: +/ + ├── .proj → KiCad project + ├── .sch → Schematic + ├── .cir → Raw netlist + ├── .cir.out → NgSpice netlist + ├── .raw → Simulation results + └── plots/ → Waveforms + +IMPORTANT: +- .cir.out file is overwritten every time you convert +- Manual edits in .cir.out are TEMPORARY unless added in schematic + +====================================================================== +13. FREQUENTLY ASKED QUESTIONS (FAQ) +====================================================================== + +Q: Can I use eSim offline? +A: Yes. eSim works fully offline once installed. + +Q: Is KiCad mandatory? +A: Yes. eSim uses KiCad for schematic and PCB design. + +Q: Can I edit netlist manually? +A: Yes, but changes will be lost after reconversion. + +Q: Why does simulation work in LTspice but not eSim? +A: eSim enforces stricter SPICE rules (ground, floating nodes). + +Q: Can eSim do PCB layout? +A: Yes, using KiCad PCB editor. + +====================================================================== +14. BEST PRACTICES FOR STUDENTS & PROJECTS +====================================================================== + +- Always name projects without spaces +- Always add ground first +- Simulate simple blocks before full circuit +- Save schematic before converting +- Use standard eSim components whenever possible +- Check netlist if simulation fails +- Keep backup of working projects + +====================================================================== +15. LIMITATIONS OF eSim (HONEST & IMPORTANT) +====================================================================== + +- Not all ICs are available by default +- Digital simulation is limited compared to Verilog tools +- Large circuits may simulate slowly +- PCB autorouting depends on KiCad + +These are normal limitations of SPICE-based tools. + +====================================================================== +END OF ESIM REFERENCE MANUAL +====================================================================== + +"""