From c2151e6d2e4271f06148f12a44696963a11d2580 Mon Sep 17 00:00:00 2001 From: Saura-4 Date: Sat, 4 Jul 2026 02:05:40 +0530 Subject: [PATCH 1/4] feat(chatbot): implement deterministic netlist analysis pipeline Introduce static netlist analysis to parse NgSpice circuit netlists deterministically without relying on LLM reasoning. Extract component values, circuit topology, exact node connectivity, and simulation setup directives into structured streaming Markdown tables. --- src/chatbot/netlist_analysis.py | 790 ++++++++++++++++++++++++++++++++ 1 file changed, 790 insertions(+) create mode 100644 src/chatbot/netlist_analysis.py diff --git a/src/chatbot/netlist_analysis.py b/src/chatbot/netlist_analysis.py new file mode 100644 index 000000000..369b61640 --- /dev/null +++ b/src/chatbot/netlist_analysis.py @@ -0,0 +1,790 @@ +"""Deterministic helpers for grounding AI netlist analysis. + +This module intentionally performs lightweight SPICE fact extraction only. It +does not simulate the circuit or try to prove electrical correctness. +""" + +from dataclasses import dataclass +import os +import re +from typing import Dict, Iterable, List, Sequence, Tuple + + +MAX_NETLIST_CONTEXT_CHARS = 7000 +MAX_NETLIST_FACT_ITEMS = 40 + +_COMPONENT_PREFIXES = set("RCLVIDQMEFGHJKTUWXZ") +_ANALYSIS_DIRECTIVES = { + ".op", ".dc", ".ac", ".tran", ".noise", ".tf", ".pz", ".sens", +} +_OUTPUT_DIRECTIVES = { + ".plot", ".print", ".probe", ".save", ".meas", ".measure", +} +_CONTROL_OUTPUT_COMMANDS = { + "plot", "print", "wrdata", "write", "save", "meas", "measure", +} + + +@dataclass(frozen=True) +class ComponentFact: + reference: str + prefix: str + nodes: Tuple[str, ...] + value_or_model: str + raw: str + + +@dataclass(frozen=True) +class IncludeFact: + token: str + resolved_path: str + exists: bool + + +@dataclass(frozen=True) +class SubcircuitCallFact: + reference: str + subcircuit: str + nodes: Tuple[str, ...] + raw: str + + +@dataclass(frozen=True) +class ParsedNetlist: + filename: str + path: str + total_lines: int + active_lines: Tuple[str, ...] + comment_lines: Tuple[str, ...] + ignored_comment_component_like_lines: Tuple[str, ...] + components: Tuple[ComponentFact, ...] + nodes: Tuple[str, ...] + ordinary_directives: Tuple[str, ...] + analysis_directives: Tuple[str, ...] + control_block_lines: Tuple[str, ...] + control_commands: Tuple[str, ...] + output_commands: Tuple[str, ...] + includes: Tuple[IncludeFact, ...] + model_names: Tuple[str, ...] + subckt_definitions: Tuple[str, ...] + subckt_calls: Tuple[SubcircuitCallFact, ...] + unresolved_subckt_calls: Tuple[SubcircuitCallFact, ...] + voltage_sources: Tuple[str, ...] + load_candidates: Tuple[str, ...] + reference_node_0_present: bool + gnd_label_present: bool + tran_fields: Tuple[Dict[str, str], ...] + + +def parse_spice_netlist(raw_lines: Sequence[str], netlist_path: str = "") -> ParsedNetlist: + """Parse a SPICE netlist into conservative deterministic facts.""" + active_lines: List[str] = [] + comment_lines: List[str] = [] + ignored_comment_component_like_lines: List[str] = [] + components: List[ComponentFact] = [] + nodes = set() + ordinary_directives: List[str] = [] + analysis_directives: List[str] = [] + control_block_lines: List[str] = [] + control_commands: List[str] = [] + output_commands: List[str] = [] + includes: List[IncludeFact] = [] + model_names: List[str] = [] + subckt_definitions: List[str] = [] + subckt_calls: List[SubcircuitCallFact] = [] + voltage_sources: List[str] = [] + load_candidates: List[str] = [] + tran_fields: List[Dict[str, str]] = [] + in_control_block = False + netlist_dir = os.path.dirname(os.path.abspath(netlist_path)) if netlist_path else "" + + for raw in _logical_lines(raw_lines): + stripped_raw = raw.strip() + if not stripped_raw: + continue + + if stripped_raw.startswith("*"): + comment_lines.append(stripped_raw) + if _is_component_like_line(stripped_raw): + ignored_comment_component_like_lines.append(stripped_raw) + continue + + line = _strip_spice_inline_comment(stripped_raw) + if not line: + continue + + tokens = line.split() + first = tokens[0] + lower_first = first.lower() + active_lines.append(line) + + if in_control_block: + control_block_lines.append(line) + if lower_first != ".endc": + control_commands.append(line) + if lower_first in _CONTROL_OUTPUT_COMMANDS: + output_commands.append(line) + if lower_first == ".endc": + in_control_block = False + continue + + if lower_first == ".control": + control_block_lines.append(line) + in_control_block = True + continue + + if first.startswith("."): + if lower_first in _ANALYSIS_DIRECTIVES: + analysis_directives.append(line) + if lower_first == ".tran": + tran_fields.append(_parse_tran_directive(line)) + else: + ordinary_directives.append(line) + + if lower_first == ".include" and len(tokens) >= 2: + path_part = line[len(first):].strip().strip('\'"') + includes.append(_include_fact(path_part, netlist_dir)) + elif lower_first == ".model" and len(tokens) >= 2: + model_names.append(tokens[1]) + elif lower_first == ".subckt" and len(tokens) >= 2: + subckt_definitions.append(tokens[1]) + elif lower_first in _OUTPUT_DIRECTIVES: + output_commands.append(line) + continue + + if first[0].upper() in _COMPONENT_PREFIXES: + component = _component_fact(tokens, line) + components.append(component) + nodes.update(component.nodes) + + if component.prefix == "V" and len(tokens) >= 4: + voltage_sources.append( + f"{component.reference} between {tokens[1]} and {tokens[2]} uses {' '.join(tokens[3:])}" + ) + if component.prefix == "R": + load = _load_candidate_fact(tokens) + if load: + load_candidates.append(load) + if component.value_or_model and component.prefix in {"D", "Q", "J", "M"}: + model_names.append(component.value_or_model) + if component.prefix == "X" and component.value_or_model: + subckt_calls.append( + SubcircuitCallFact( + reference=component.reference, + subcircuit=component.value_or_model, + nodes=component.nodes, + raw=component.raw, + ) + ) + + defined_subckts = {name.lower() for name in subckt_definitions} + unresolved_subckt_calls: List[SubcircuitCallFact] = [] + if not includes: + unresolved_subckt_calls = [ + call for call in subckt_calls + if call.subcircuit.lower() not in defined_subckts + ] + + sorted_nodes = tuple(sorted(nodes, key=lambda item: item.lower())) + return ParsedNetlist( + filename=os.path.basename(netlist_path) if netlist_path else "", + path=netlist_path, + total_lines=len(raw_lines), + active_lines=tuple(active_lines), + comment_lines=tuple(comment_lines), + ignored_comment_component_like_lines=tuple(ignored_comment_component_like_lines), + components=tuple(components), + nodes=sorted_nodes, + ordinary_directives=tuple(ordinary_directives), + analysis_directives=tuple(analysis_directives), + control_block_lines=tuple(control_block_lines), + control_commands=tuple(control_commands), + output_commands=tuple(output_commands), + includes=tuple(includes), + model_names=tuple(sorted(set(model_names), key=lambda item: item.lower())), + subckt_definitions=tuple(sorted(set(subckt_definitions), key=lambda item: item.lower())), + subckt_calls=tuple(subckt_calls), + unresolved_subckt_calls=tuple(unresolved_subckt_calls), + voltage_sources=tuple(voltage_sources), + load_candidates=tuple(load_candidates), + reference_node_0_present=any(node == "0" for node in sorted_nodes), + gnd_label_present=any(node.lower() == "gnd" for node in sorted_nodes), + tran_fields=tuple(tran_fields), + ) + + +NETLIST_SYSTEM_PROMPT = """ +You are an electronics assistant inside eSim. + +Your goal is to help users understand circuits from SPICE netlists. + +Use the extracted facts to explain: +1. The overall structure of the circuit. +2. The likely role of important components and subcircuits. +3. How signals or power flow through the circuit. + +Guidelines: +- Explain only circuit structure, block interactions, and signal/power flow. +- Because these are HIGH confidence blocks, use direct language (e.g., "The circuit contains...") and avoid uncertainty words like "likely", "probably", or "appears to". +- You MAY use standard component knowledge (e.g., describing an LM7805 as intended to provide a regulated 5V output, or a bridge rectifier as converting AC to DC). +- You MUST NOT provide strict performance guarantees. Forbidden phrases/concepts include: "guarantees 5V output", "ensures stable voltage under all conditions", "regardless of load conditions", "regardless of input variations", "delivers exactly X volts", or "provides Y amps". +- The goal is an educational explanation of intended structure and flow, rather than strict formal verification. +- Keep the explanation concise (3-5 sentences). + +Output plain text only. +No markdown. +No bullet points. +No JSON. +""" + + +def detect_circuit_blocks(parsed: ParsedNetlist) -> List[Tuple[str, str, str]]: + """Deterministically identify common circuit structures with strict confidence levels and node connections.""" + blocks = [] + + diodes = [c for c in parsed.components if c.prefix == 'D'] + caps = [c for c in parsed.components if c.prefix == 'C'] + v_sources = [c for c in parsed.components if c.prefix == 'V'] + + dc_plus_node = None + dc_minus_node = None + + if len(diodes) >= 4: + from itertools import combinations + for combo in combinations(diodes, 4): + anodes = [d.nodes[0] for d in combo if len(d.nodes) >= 2] + cathodes = [d.nodes[1] for d in combo if len(d.nodes) >= 2] + if len(anodes) == 4 and len(cathodes) == 4: + common_cathodes = [n for n in set(cathodes) if cathodes.count(n) == 2 and anodes.count(n) == 0] + common_anodes = [n for n in set(anodes) if anodes.count(n) == 2 and cathodes.count(n) == 0] + + if len(common_cathodes) == 1 and len(common_anodes) == 1: + dc_plus_node = common_cathodes[0] + dc_minus_node = common_anodes[0] + blocks.append(("Bridge rectifier stage", "HIGH", f"DC+ node is {dc_plus_node}, DC- node is {dc_minus_node}")) + break + + for x in parsed.subckt_calls: + sub = x.subcircuit.lower() + if '7805' in sub or '7809' in sub or '7812' in sub: + nodes_str = ", ".join(x.nodes) + blocks.append((f"{x.subcircuit.upper()} regulator stage", "HIGH", f"nodes are ({nodes_str})")) + break + + for c in caps: + val = c.value_or_model.lower() + if 'u' in val or 'm' in val or 'f' in val: + is_gnd = '0' in c.nodes or 'gnd' in [n.lower() for n in c.nodes] + is_across_bridge = False + if dc_plus_node and dc_minus_node: + if dc_plus_node in c.nodes and dc_minus_node in c.nodes: + is_across_bridge = True + + if is_gnd or is_across_bridge: + n1 = c.nodes[0] if len(c.nodes) > 0 else 'unknown' + n2 = c.nodes[1] if len(c.nodes) > 1 else 'unknown' + blocks.append(("Filter capacitor stage", "HIGH", f"connected between {n1} and {n2}")) + break + + if parsed.load_candidates: + for load_ref in parsed.load_candidates: + if load_ref.lower().startswith('r'): + load_comp = next((c for c in parsed.components if c.reference.lower() == load_ref.lower()), None) + if load_comp and len(load_comp.nodes) >= 2: + n1, n2 = load_comp.nodes[0], load_comp.nodes[1] + blocks.append(("Output load resistor", "HIGH", f"connected between {n1} and {n2}")) + else: + blocks.append(("Output load resistor", "HIGH", "connected between output and ground")) + break + + return blocks + + +def build_netlist_summary_prompt( + parsed: ParsedNetlist, raw_lines: Sequence[str]) -> str: + """Build a data-only grounding prompt for the LLM from deterministic facts. + + Instructions are in NETLIST_SYSTEM_PROMPT (sent as the system message). + This prompt contains only structured facts. The raw netlist is intentionally + excluded to prevent the LLM from hallucinating fixes for syntax errors. + """ + COMPONENT_NAMES = { + 'R': 'Resistors', 'C': 'Capacitors', 'L': 'Inductors', + 'V': 'Voltage Sources', 'I': 'Current Sources', + 'D': 'Diodes', 'Q': 'Bipolar Transistors', + 'M': 'MOSFETs', 'J': 'JFETs', 'X': 'Subcircuits' + } + + comp_counts = _component_type_counts(parsed.components) + comp_list = [] + for prefix, count in sorted(comp_counts.items()): + name = COMPONENT_NAMES.get(prefix, f"{prefix} components") + comp_list.append(f"{count} {name}") + comp_str = ", ".join(comp_list) if comp_list else "None" + + subckt_calls = ", ".join([f"{call.reference} instantiates {call.subcircuit} with nodes ({', '.join(call.nodes)})" for call in parsed.subckt_calls]) or "None" + voltage_sources = ", ".join(parsed.voltage_sources) or "None" + nodes = ", ".join(parsed.nodes) or "None" + load_candidates = ", ".join(parsed.load_candidates) or "None" + + blocks = detect_circuit_blocks(parsed) + high_conf_blocks = [b for b in blocks if b[1] == "HIGH"] + + blocks_str = "\n".join(f"- {b[0]}" for b in high_conf_blocks) if high_conf_blocks else "None explicitly detected" + rels_str = "\n".join(f"- {b[0]}: {b[2]}" for b in high_conf_blocks) if high_conf_blocks else "None explicitly detected" + + return ( + "Circuit facts:\n" + f"The circuit contains the following components: {comp_str}.\n" + f"Subcircuit details: {subckt_calls}\n" + f"Input source details: {voltage_sources}\n" + f"Key nodes in the circuit: {nodes}\n" + f"Identified load: {load_candidates}\n\n" + f"Detected circuit blocks (HIGH confidence):\n{blocks_str}\n\n" + f"Detected relationships:\n{rels_str}\n\n" + "Task:\n" + "Explain how the detected circuit blocks are connected and how signals or power flow through them.\n\n" + "You may:\n" + "- Explain the flow of power/signals between the blocks.\n" + "- Explain the intended role of known components (e.g. 'acts as a rectifier stage', 'smooths the rectified voltage', 'intended to provide a regulated 5V output').\n\n" + "You must not:\n" + "- State absolute performance guarantees ('ensures 5V regardless of conditions', 'delivers exactly 5V').\n" + "- Predict strict simulation results.\n" + "- Guess the final application of the circuit (e.g. 'used to power a microprocessor').\n" + "- Claim the circuit definitely performs a function that is not supported by the facts." + ) + + +def _explain_analysis_directive(line: str) -> str: + tokens = line.strip().split() + if not tokens: + return line + + cmd = tokens[0].lower() + + if cmd == '.dc' and len(tokens) >= 5: + src, start, stop, step = tokens[1], tokens[2], tokens[3], tokens[4] + return f"**DC Sweep Analysis (.dc):** This simulation gradually changes the value of source `{src}` starting from {start} up to {stop}, taking a measurement every {step}." + + elif cmd == '.ac' and len(tokens) >= 5: + variation, points, fstart, fstop = tokens[1], tokens[2], tokens[3], tokens[4] + var_name = {"dec": "decade", "oct": "octave", "lin": "linear"}.get(variation.lower(), variation) + return f"**AC Analysis (.ac):** This simulation sweeps the frequency from {fstart} up to {fstop} using a {var_name} scale, capturing {points} data points per {var_name}." + + elif cmd == '.op': + return "**Operating Point (.op):** This calculates the steady-state DC voltages and currents of the circuit before any time-varying signals are applied." + + elif cmd == '.noise' and len(tokens) >= 5: + out_v, in_src, variation, points = tokens[1], tokens[2], tokens[3], tokens[4] + return f"**Noise Analysis (.noise):** This simulates noise at output `{out_v}` relative to input `{in_src}` across a {variation} frequency sweep." + + elif cmd == '.tf' and len(tokens) >= 3: + out_var, in_src = tokens[1], tokens[2] + return f"**Transfer Function (.tf):** This computes the DC small-signal transfer function from input `{in_src}` to output `{out_var}`." + + elif cmd == '.pz' and len(tokens) >= 5: + n1, n2, n3, n4 = tokens[1], tokens[2], tokens[3], tokens[4] + return f"**Pole-Zero Analysis (.pz):** This calculates the poles and zeros of the transfer function between input nodes ({n1}, {n2}) and output nodes ({n3}, {n4})." + + elif cmd == '.sens' and len(tokens) >= 2: + out_var = tokens[1] + return f"**Sensitivity Analysis (.sens):** This computes the DC small-signal sensitivity of `{out_var}` with respect to circuit parameters." + + return f"**Analysis:** `{line}`" + +def _format_spice_value(prefix: str, value: str) -> str: + """Format SPICE value to human-readable with units.""" + if not value or prefix not in ('R', 'C', 'L', 'V', 'I'): + return value + + unit = {'R': 'Ξ©', 'C': 'F', 'L': 'H', 'V': 'V', 'I': 'A'}[prefix] + + match = re.match(r"^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)(meg|[kmunpfgt])?$", value, re.IGNORECASE) + if match: + num = match.group(1) + scale = match.group(2) + + scale_map = { + 'meg': ' M', 'k': ' k', 'm': ' m', 'u': ' Β΅', + 'n': ' n', 'p': ' p', 'f': ' f', 'g': ' G', 't': ' T' + } + + scale_str = scale_map.get(scale.lower(), " ") if scale else " " + return f"{num}{scale_str}{unit}".strip() + + return f"{value} {unit}".strip() + +def format_netlist_table(parsed: ParsedNetlist) -> str: + """Deterministically format the components table and simulation setup facts into Markdown.""" + total_count = len(parsed.components) + + COMPONENT_NAMES = { + 'R': 'Resistors', 'C': 'Capacitors', 'L': 'Inductors', + 'V': 'Voltage Sources', 'I': 'Current Sources', + 'D': 'Diodes', 'Q': 'Bipolar Transistors (BJT)', + 'M': 'MOSFETs', 'J': 'JFETs', 'X': 'Subcircuits', + 'E': 'Voltage-Controlled Voltage Sources (E)', + 'G': 'Voltage-Controlled Current Sources (G)', + 'F': 'Current-Controlled Current Sources (F)', + 'H': 'Current-Controlled Voltage Sources (H)', + 'K': 'Coupled Inductors', 'T': 'Transmission Lines', + 'U': 'Uniform Distributed RC Lines', 'W': 'Current-Controlled Switches', + 'Z': 'IGBTs / Switches' + } + + from collections import defaultdict + comp_groups = defaultdict(list) + for comp in parsed.components: + comp_groups[comp.prefix].append(comp) + + comp_lines = [] + # Sort for deterministic output + for prefix in sorted(comp_groups.keys()): + comps = comp_groups[prefix] + count = len(comps) + name = COMPONENT_NAMES.get(prefix, f"Other ({prefix})") + + items = [] + for c in comps[:5]: + val = _format_spice_value(prefix, c.value_or_model) + if val: + items.append(f"{c.reference}: {val}") + else: + items.append(f"{c.reference}") + + items_str = ", ".join(items) + if count > 5: + items_str += f"... and {count - 5} more" + + comp_lines.append(f"- **{name} ({prefix})**: {count} `({items_str})`") + + components_str = "\n".join(comp_lines) if comp_lines else "None" + + sim_setup_lines = [] + if parsed.tran_fields: + tran = parsed.tran_fields[0] + tstart = tran.get("TRAN_TSTART", "0s").split("=")[-1].strip() + tstop = tran.get("TRAN_TSTOP", "Unknown").split("=")[-1].strip() + tstep = tran.get("TRAN_TSTEP", "Unknown").split("=")[-1].strip() + sim_setup_lines.append(f"**Transient Analysis (.tran):** This simulates the circuit over time, starting from {tstart} and running until {tstop}, recording data every {tstep}.") + + # Include other analysis directives like .dc, .ac + for directive in parsed.analysis_directives: + if not directive.lower().startswith('.tran'): + sim_setup_lines.append(_explain_analysis_directive(directive)) + + if not sim_setup_lines: + sim_setup_lines.append("**No simulation directives found.**") + + sim_setup_str = "\n".join(sim_setup_lines) + + plots = [] + saves = [] + for cmd in parsed.output_commands: + cmd_lower = cmd.lower() + if cmd_lower.startswith("plot "): + plots.append(cmd[5:].strip().upper()) + elif "allv" in cmd_lower: + saves.append("all voltages") + elif "alli" in cmd_lower: + saves.append("all currents") + else: + saves.append(cmd) + + outputs_list = [] + if plots: + outputs_list.append(f"**Plots:** {', '.join(plots)}") + if saves: + outputs_list.append(f"**Saving:** {', '.join(saves)}") + + outputs_str = "\n".join(outputs_list) if outputs_list else "**Outputs:** None" + + markdown = f"""### Components ({total_count} total) +{components_str} + +### Simulation Setup +{sim_setup_str} +{outputs_str} + +πŸ’‘ **Note:** This overview is based on static netlist analysis. Run a simulation to verify circuit behavior and identify issues that may not be apparent from the netlist alone.""" + + return markdown + + +def build_netlist_facts(parsed: ParsedNetlist, raw_lines: Sequence[str]) -> List[str]: + include_statuses = [ + f"{item.token}: {'FOUND' if item.exists else 'MISSING'} at {item.resolved_path}" + for item in parsed.includes + ] + component_lines = [component.raw for component in parsed.components] + component_type_counts = _component_type_counts(parsed.components) + subckt_calls = [ + f"{call.reference} instantiates {call.subcircuit} with nodes ({', '.join(call.nodes)})" + for call in parsed.subckt_calls + ] + unresolved_subckt_calls = [ + f"{call.reference} instantiates {call.subcircuit}" + for call in parsed.unresolved_subckt_calls + ] + tran_fields = [] + for tran in parsed.tran_fields: + for key in ("TRAN_RAW", "TRAN_TSTEP", "TRAN_TSTOP", "TRAN_TSTART", "TRAN_TMAX"): + if key in tran: + tran_fields.append(f"{key}: {tran[key]}") + + obvious_issues = [] + if not parsed.reference_node_0_present and not parsed.gnd_label_present: + obvious_issues.append("Missing reference ground (node '0' or 'GND').") + if not parsed.analysis_directives: + obvious_issues.append("No simulation directives (e.g. .tran, .dc, .ac) found.") + if parsed.unresolved_subckt_calls: + obvious_issues.append(f"Unresolved subcircuits: {', '.join(c.subcircuit for c in parsed.unresolved_subckt_calls)}") + missing_includes = [item.token for item in parsed.includes if not item.exists] + if missing_includes: + obvious_issues.append(f"Missing included files: {', '.join(missing_includes)}") + + if not obvious_issues: + obvious_issues = ["None"] + + return [ + _fact_line("NETLIST_FILE", parsed.filename), + _fact_line("NETLIST_PATH", parsed.path), + _fact_line("TOTAL_LINES", len(raw_lines)), + _fact_line("ACTIVE_LINE_COUNT", len(parsed.active_lines)), + _fact_line("COMMENT_LINE_COUNT", len(parsed.comment_lines)), + _fact_line("IGNORED_COMMENT_COMPONENT_LIKE_LINES", parsed.ignored_comment_component_like_lines), + _fact_line("COMPONENT_COUNT", len(parsed.components)), + _fact_line("COMPONENT_TYPE_COUNTS", component_type_counts), + _fact_line("CURRENT_SOURCE_COUNT", component_type_counts.get("I", 0)), + _fact_line("COMPONENT_LINES", component_lines), + _fact_line("NODES", parsed.nodes), + _fact_line("ORDINARY_DIRECTIVES", parsed.ordinary_directives), + _fact_line("ANALYSIS_DIRECTIVES", parsed.analysis_directives), + _fact_line("CONTROL_BLOCK_LINES", parsed.control_block_lines), + _fact_line("CONTROL_COMMANDS", parsed.control_commands), + _fact_line("OUTPUT_COMMANDS", parsed.output_commands), + _fact_line("INCLUDE_FILES", [item.token for item in parsed.includes]), + _fact_line("INCLUDE_FILE_STATUSES", include_statuses), + _fact_line("MODEL_NAMES", parsed.model_names), + _fact_line("SUBCKT_DEFINITIONS", parsed.subckt_definitions), + _fact_line("SUBCKT_CALLS", subckt_calls), + _fact_line("VOLTAGE_SOURCES", parsed.voltage_sources), + _fact_line("LOAD_CANDIDATES", parsed.load_candidates), + _fact_line("UNRESOLVED_SUBCKT_CALLS", unresolved_subckt_calls), + _fact_line("SPICE_REFERENCE_NODE_0_PRESENT", parsed.reference_node_0_present), + _fact_line("GND_LABEL_PRESENT", parsed.gnd_label_present), + _fact_line("TRAN_FIELDS", tran_fields), + _fact_line("OBVIOUS_ISSUES", obvious_issues), + ] + + +def _component_type_counts(components: Sequence[ComponentFact]) -> Dict[str, int]: + counts: Dict[str, int] = {} + for component in components: + counts[component.prefix] = counts.get(component.prefix, 0) + 1 + return counts + + +def _logical_lines(raw_lines: Sequence[str]) -> Iterable[str]: + logical: List[str] = [] + for raw in raw_lines: + line = raw.rstrip("\r\n") + stripped = line.lstrip() + if stripped.startswith("+") and logical: + logical[-1] = f"{logical[-1]} {stripped[1:].strip()}" + else: + logical.append(line) + return logical + + +def _strip_spice_inline_comment(line: str) -> str: + for index, char in enumerate(line): + if char == ";": + return line[:index].strip() + if char == "$" and (index == 0 or line[index - 1].isspace()): + return line[:index].strip() + return line.strip() + + +def _component_fact(tokens: Sequence[str], raw: str) -> ComponentFact: + reference = tokens[0] + prefix = reference[0].upper() + nodes, value_or_model = _component_nodes_and_value(tokens) + return ComponentFact( + reference=reference, + prefix=prefix, + nodes=tuple(nodes), + value_or_model=value_or_model, + raw=raw, + ) + + +def _component_nodes_and_value(tokens: Sequence[str]) -> Tuple[List[str], str]: + if len(tokens) < 2: + return [], "" + + prefix = tokens[0][0].upper() + if prefix == "X" and len(tokens) >= 3: + return list(tokens[1:-1]), tokens[-1] + if prefix in {"R", "C", "L", "V", "I"} and len(tokens) >= 4: + return list(tokens[1:3]), " ".join(tokens[3:]) + if prefix == "D" and len(tokens) >= 4: + return list(tokens[1:3]), tokens[3] + if prefix in {"Q", "J"} and len(tokens) >= 5: + return list(tokens[1:4]), tokens[4] + if prefix == "M" and len(tokens) >= 6: + return list(tokens[1:5]), tokens[5] + if prefix in {"E", "G"} and len(tokens) >= 5: + return list(tokens[1:5]), " ".join(tokens[5:]) + if prefix in {"F", "H"} and len(tokens) >= 4: + return list(tokens[1:3]), " ".join(tokens[3:]) + + return list(tokens[1:-1]) if len(tokens) > 2 else list(tokens[1:]), ( + tokens[-1] if len(tokens) > 2 else "" + ) + + +def _normalize_include_path(token: str) -> str: + return token.strip().strip('"').strip("'") + + +def _include_fact(include_token: str, netlist_dir: str) -> IncludeFact: + include_path = _normalize_include_path(include_token) + if netlist_dir and include_path and not os.path.isabs(include_path): + resolved_path = os.path.abspath(os.path.join(netlist_dir, include_path)) + else: + resolved_path = include_path + return IncludeFact( + token=include_path, + resolved_path=resolved_path, + exists=bool(resolved_path and os.path.exists(resolved_path)), + ) + + +def _is_component_like_line(line: str) -> bool: + stripped = line.lstrip() + if stripped.startswith("*"): + stripped = stripped[1:].lstrip() + if not stripped: + return False + tokens = stripped.split() + first = tokens[0] + if first.startswith("."): + return True + + min_tokens = { + "R": 4, "C": 4, "L": 4, "V": 4, "I": 4, "D": 4, + "Q": 5, "J": 5, "M": 6, "E": 5, "G": 5, "F": 4, + "H": 4, "K": 4, "T": 4, "U": 3, "W": 4, "X": 4, + "Z": 4, + } + return len(tokens) >= min_tokens.get(first[0].upper(), 99) + + +def _parse_tran_directive(line: str) -> Dict[str, str]: + tokens = line.split() + parsed = {"TRAN_RAW": line} + fields = [ + ("TRAN_TSTEP", 1), + ("TRAN_TSTOP", 2), + ("TRAN_TSTART", 3), + ("TRAN_TMAX", 4), + ] + for name, index in fields: + if len(tokens) > index: + parsed[name] = _format_time_fact(tokens[index]) + return parsed + + +def _format_time_fact(value: str) -> str: + seconds = _spice_number_to_float(value) + if seconds is None: + return value + return f"{value} = {seconds:g} s ({_format_seconds(seconds)})" + + +def _spice_number_to_float(value: str): + text = value.strip() + try: + return float(text) + except ValueError: + pass + + match = re.match( + r"^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)([a-z]+)$", + text, + re.IGNORECASE, + ) + if not match: + return None + + base = float(match.group(1)) + suffix = match.group(2).lower() + if suffix.startswith("meg"): + scale_char = "meg" + else: + scale_char = suffix[0] + + scale = { + "f": 1e-15, + "p": 1e-12, + "n": 1e-9, + "u": 1e-6, + "m": 1e-3, + "k": 1e3, + "meg": 1e6, + "g": 1e9, + "t": 1e12, + }.get(scale_char) + return None if scale is None else base * scale + + +def _format_seconds(seconds: float) -> str: + if seconds == 0: + return "0 s" + + abs_seconds = abs(seconds) + units = [ + (1.0, "s"), + (1e-3, "ms"), + (1e-6, "us"), + (1e-9, "ns"), + (1e-12, "ps"), + ] + for scale, unit in units: + value = seconds / scale + if abs_seconds >= scale and abs(value) < 1000: + return f"{value:g} {unit}" + return f"{seconds:g} s" + + +def _load_candidate_fact(tokens: Sequence[str]) -> str: + if len(tokens) < 4: + return "" + node_a, node_b = tokens[1], tokens[2] + if node_a.lower() not in ("0", "gnd") and node_b.lower() not in ("0", "gnd"): + return "" + return f"{tokens[0]} {node_a}-{node_b} {tokens[3]}" + + +def _fact_line(name: str, values) -> str: + if isinstance(values, bool): + rendered = "Yes" if values else "No" + elif isinstance(values, (list, tuple, set)): + values = list(values) + rendered = "None" if not values else ", ".join(str(v) for v in values[:MAX_NETLIST_FACT_ITEMS]) + if len(values) > MAX_NETLIST_FACT_ITEMS: + rendered += f", ... ({len(values) - MAX_NETLIST_FACT_ITEMS} more)" + else: + rendered = str(values) + + # Format name nicely (e.g. replace underscores with spaces and title case) + formatted_name = name.replace("_", " ").title() + return f"{formatted_name}: {rendered}" + + +def _bounded_text(lines: Sequence[str]) -> Tuple[str, bool]: + text = "\n".join(lines) + if len(text) <= MAX_NETLIST_CONTEXT_CHARS: + return text, False + return text[:MAX_NETLIST_CONTEXT_CHARS], True From 358517f4b231f5b6b52946875793c5b6beae62d4 Mon Sep 17 00:00:00 2001 From: Saura-4 Date: Sat, 4 Jul 2026 02:06:09 +0530 Subject: [PATCH 2/4] feat(chatbot): add deterministic NgSpice error log analysis and root-cause detection Add structured root-cause identification for simulation failures. Include multi-root-cause extraction, transient loop regex fixes, typo detection via circuit fact injection, and deterministic tip boxes. Integrate netlist and error analysis pipelines into chatbot backend threads and UI. --- .gitignore | 12 + src/chatbot/chatbot_core.py | 22 +- src/chatbot/chatbot_thread.py | 36 ++- src/chatbot/error_log_analysis.py | 359 +++++++++++++++++++++++++ src/chatbot/error_patterns.py | 372 ++++++++++++++++++++++++++ src/chatbot/error_solutions.py | 399 ++++++++++++++++++++++------ src/frontEnd/Application.py | 30 ++- src/frontEnd/Chatbot.py | 150 +++++------ src/frontEnd/DockArea.py | 4 +- src/frontEnd/ProjectExplorer.py | 4 +- src/library/config/.esim/config.ini | 7 + 11 files changed, 1221 insertions(+), 174 deletions(-) create mode 100644 src/chatbot/error_log_analysis.py create mode 100644 src/chatbot/error_patterns.py create mode 100644 src/library/config/.esim/config.ini diff --git a/.gitignore b/.gitignore index 876734ed4..71e348b58 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,15 @@ library/config/.nghdl/ **/__pycache__/ **/*.pyc +# Local AI test scripts +test_ai_features.py +test_exact_log.py +test_regex2.py + +# Multi-Agent Teamwork System artifacts +.agents/ +code_review.md + +# Local test projects +Examples/ErrorTests/ + diff --git a/src/chatbot/chatbot_core.py b/src/chatbot/chatbot_core.py index 24b8eef09..05b641c71 100644 --- a/src/chatbot/chatbot_core.py +++ b/src/chatbot/chatbot_core.py @@ -4,7 +4,8 @@ import re import json from typing import Dict, Any, Tuple, List -from .error_solutions import get_error_solution +from .error_patterns import match_error_patterns +from .error_solutions import get_solution_for_category from .image_handler import analyze_and_extract from .ollama_runner import run_ollama from .knowledge_base import search_knowledge @@ -489,18 +490,23 @@ def handle_esim_question(user_input: str, """ user_lower = user_input.lower() - sol = get_error_solution(user_input) - if sol and sol.get("description") != "General schematic error": + matches = match_error_patterns(user_input) + if matches: + root_match = min(matches, key=lambda m: m.causal_priority) + sol = get_solution_for_category(root_match.category) + fixes = "\n".join(f"- {f}" for f in sol.get("fixes", [])) - cmd = sol.get("eSim_command", "") + steps = "\n".join(f"- {s}" for s in sol.get("esim_steps", [])) + answer = ( - f"**Detected issue:** {sol['description']}\n" + f"**Detected issue:** {sol['category']}\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) + if steps: + answer += f"**eSim Steps:**\n{steps}\n" + + return answer history_text = _history_to_text(history, max_turns=6) diff --git a/src/chatbot/chatbot_thread.py b/src/chatbot/chatbot_thread.py index 1c8d8f671..d7cd63305 100644 --- a/src/chatbot/chatbot_thread.py +++ b/src/chatbot/chatbot_thread.py @@ -6,7 +6,7 @@ import time import threading import ollama -from PyQt6.QtCore import QThread, pyqtSignal +from PyQt5.QtCore import QThread, pyqtSignal from chatbot.knowledge_base import search_knowledge # ── Optional imports ────────────────────────────────────────────────────────── @@ -293,7 +293,7 @@ def _smart_num_predict(user_messages: list, user_override: int = 1024) -> int: if is_simple and not is_long: budget = 128 elif is_complex or is_long: - budget = 512 + budget = 1024 else: budget = 256 @@ -308,12 +308,14 @@ class OllamaWorker(QThread): chunk_signal = pyqtSignal(str) def __init__(self, chat_history, model="", - temperature=0.25, num_predict=1024): + temperature=0.25, num_predict=1024, system_prompt=None, netlist_formatter_context=None): super().__init__() self.chat_history = chat_history self.model = model self.temperature = temperature self.num_predict = num_predict + self.system_prompt = system_prompt + self.netlist_formatter_context = netlist_formatter_context self._stop_requested = False def stop(self): @@ -327,7 +329,8 @@ def run(self): # config-driven history window + system prompt max_lines = int(CONFIG.get("history", {}).get("max_lines", 6)) - messages = [{"role": "system", "content": _SYSTEM_PROMPT}] + active_system_prompt = self.system_prompt if self.system_prompt else _SYSTEM_PROMPT + messages = [{"role": "system", "content": active_system_prompt}] for line in self.chat_history[-max_lines:]: if line.startswith("User:"): messages.append({"role": "user", "content": line[5:].strip()}) @@ -341,6 +344,9 @@ def run(self): repeat_pen = float(CONFIG.get("sampling", {}).get("repeat_penalty", 1.08)) keep_alive = CONFIG.get("runtime", {}).get("keep_alive", "-1m") + if self.netlist_formatter_context: + self.chunk_signal.emit("### Circuit Overview\n") + stream = ollama.chat( model=self.model, messages=messages, @@ -350,8 +356,8 @@ def run(self): "num_predict": budget, "num_ctx": num_ctx, "repeat_penalty": repeat_pen, - "keep_alive": keep_alive, - } + }, + keep_alive=keep_alive ) bot_response = "" @@ -365,18 +371,24 @@ def run(self): bot_response = bot_response.strip() if not bot_response: - bot_response = ( - "⚠️ Received an empty response. " - "The model may still be loading β€” please try again." - ) + bot_response = "Circuit analysis unavailable. See components and simulation details below." if self.netlist_formatter_context else "⚠️ Received an empty response. Please verify the AI model is downloaded and try again." + + if self.netlist_formatter_context and not self._stop_requested: + from src.chatbot.netlist_analysis import format_netlist_table + table_md = format_netlist_table(self.netlist_formatter_context) + bot_response += "\n\n" + table_md + + if self.netlist_formatter_context: + bot_response = "### Circuit Overview\n" + bot_response + + self.response_signal.emit(bot_response) except Exception as e: bot_response = ( f"❌ Error: {str(e)}\n" "Make sure Ollama is installed and 'ollama serve' is running." ) - - self.response_signal.emit(bot_response) + self.response_signal.emit(bot_response) # ── Vision model helpers ────────────────────────────────────────────────────── diff --git a/src/chatbot/error_log_analysis.py b/src/chatbot/error_log_analysis.py new file mode 100644 index 000000000..7b24e0a91 --- /dev/null +++ b/src/chatbot/error_log_analysis.py @@ -0,0 +1,359 @@ +"""Structured pre-processing for NgSpice simulation error logs. + +Extracts deterministic facts from NgSpice output to provide structured +context for the LLM, reducing hallucination on weak local models. +""" + +import re +from dataclasses import dataclass, field +from typing import Dict, List, Sequence, Tuple, Any, Optional + +from chatbot.error_patterns import match_error_patterns, ErrorMatch +from chatbot.error_solutions import get_solution_for_category + + +# ── System prompt for error analysis ───────────────────────────────────────── + +ERROR_ANALYSIS_SYSTEM_PROMPT = ( + "You are an expert circuit debugger inside eSim.\n\n" + "TASK: Explain the pre-diagnosed error to the user in plain language.\n\n" + "CRITICAL INSTRUCTIONS:\n" + "- The error has ALREADY been diagnosed. Do NOT re-diagnose.\n" + "- Use ONLY the [DETECTED ERROR] facts provided.\n" + "- Focus on explaining WHY this error occurs in simple terms.\n" + "- Reference the specific nodes/components mentioned.\n" + "- Guide the user through the recommended fixes step-by-step.\n" + "- Do NOT assume missing include files or filesystem problems unless " + "explicitly stated in the error facts.\n" + "- Keep your explanation concise (max 150 words).\n\n" + "OUTPUT FORMAT:\n" + "1. **Error** β€” One-line summary\n" + "2. **Why** β€” Brief root cause explanation\n" + "3. **Fix** β€” Step-by-step using the provided eSim steps\n" +) + + +# ── Ranked error with suppression metadata ─────────────────────────────────── + +@dataclass +class RankedError: + """An error match annotated with its role and optional suppression info.""" + role: str # "Root Cause", "Secondary Issue", "Consequence" + match: ErrorMatch + suppressed_by: Optional[str] = None # error_id of the suppressor, if any + + +# ── Suppression rules ──────────────────────────────────────────────────────── +# Each rule: (suppressor_error_id, set_of_suppressed_error_ids) +# When the suppressor is present, the suppressed errors lose their root-cause +# status and are demoted to secondary/consequence. + +_SUPPRESSION_RULES: List[Tuple[str, set]] = [ + # Invalid Model Syntax β†’ suppresses Missing Model as root cause + ("ERR006", {"ERR001"}), + # Source Loop β†’ suppresses Singular Matrix and Timestep Too Small as root causes + ("ERR011", {"ERR004", "ERR005"}), +] + +# Generic NgSpice messages that should never be promoted to root cause when +# a specific error (especially ERR002) is present. +_GENERIC_NOISE_PATTERNS = [ + re.compile(r"there\s+aren'?t\s+any\s+circuits\s+loaded", re.IGNORECASE), + re.compile(r"no\s+valid\s+circuits", re.IGNORECASE), + re.compile(r"simulation\s+failed!?", re.IGNORECASE), + re.compile(r"circuit\s+not\s+parsed", re.IGNORECASE), +] + +# Explicit include/file-error patterns β€” only when these appear should the +# LLM mention missing files. +_EXPLICIT_FILE_ERROR_PATTERNS = [ + re.compile(r"cannot\s+open\s+include\s+file", re.IGNORECASE), + re.compile(r"can'?t\s+open\s+file", re.IGNORECASE), + re.compile(r"include\s+file\s+not\s+found", re.IGNORECASE), +] + + +# ── Log fact extraction ────────────────────────────────────────────────────── + +def extract_log_facts(log_lines: Sequence[str]) -> Dict[str, object]: + """Extract structured facts from NgSpice log output.""" + full_text = "".join(log_lines) + + facts = { + "total_lines": len(log_lines), + "has_error": False, + "error_patterns": [], + "circuit_name": "", + "simulation_type": "", + "failed_nodes": [], + "mentioned_components": [], + } + + # Extract circuit name + circuit_match = re.search(r"Circuit:\s*(.+)", full_text) + if circuit_match: + facts["circuit_name"] = circuit_match.group(1).strip() + + # Detect simulation type + for sim_type in ("tran", "ac", "dc", "op", "noise"): + if re.search(rf"\.{sim_type}\b", full_text, re.IGNORECASE): + facts["simulation_type"] = sim_type + break + + # Check for errors + error_indicators = [ + "error", "failed", "singular", "convergence", "abort", + "cannot", "not found", "undefined", "too small", + ] + facts["has_error"] = any( + indicator in full_text.lower() for indicator in error_indicators + ) + + # Match known patterns + facts["error_patterns"] = match_error_patterns(full_text) + + # Extract mentioned node names + node_matches = re.findall(r"node\s+['\"]?(\S+)['\"]?", full_text, re.IGNORECASE) + facts["failed_nodes"] = list(dict.fromkeys(node_matches))[:10] + + # Extract mentioned component references + comp_matches = re.findall( + r"\b([RCLVIDQMX]\d+)\b", full_text, re.IGNORECASE + ) + facts["mentioned_components"] = list(dict.fromkeys(comp_matches))[:20] + + return facts + + +def rank_errors(matches: List[ErrorMatch]) -> List[RankedError]: + """Rank errors with suppression logic. + + 1. Apply explicit suppression rules (ERR006β†’ERR001, ERR011β†’ERR004/ERR005). + 2. Suppress generic noise when any specific error is present. + 3. All non-suppressed errors sharing the minimum causal_priority + are labelled "Root Cause" (supports multiple root causes). + 4. Remaining errors are "Secondary Issue" (priority ≀ 2) or "Consequence". + + Each RankedError carries a ``suppressed_by`` field for traceability. + """ + if not matches: + return [] + + present_ids = {m.error_id for m in matches} + + # Build suppression map: error_id β†’ suppressor_error_id + suppression_map: Dict[str, str] = {} + for suppressor_id, suppressed_ids in _SUPPRESSION_RULES: + if suppressor_id in present_ids: + for sid in suppressed_ids: + if sid in present_ids: + suppression_map[sid] = suppressor_id + + # Suppress generic noise when *any* specific pattern matched + has_specific = any( + m.error_id not in ("ERR008", "ERR013") for m in matches + ) + + # Partition into active and suppressed + active: List[ErrorMatch] = [] + suppressed_entries: List[Tuple[ErrorMatch, str]] = [] + + for m in matches: + if m.error_id in suppression_map: + suppressed_entries.append((m, suppression_map[m.error_id])) + else: + active.append(m) + + if not active: + # Everything got suppressed β€” fall back to full list + active = list(matches) + suppressed_entries = [] + + # Sort active by causal priority + active.sort(key=lambda m: m.causal_priority) + min_priority = active[0].causal_priority + + ranked: List[RankedError] = [] + + for m in active: + if m.causal_priority == min_priority: + ranked.append(RankedError(role="Root Cause", match=m)) + elif m.causal_priority <= 2: + ranked.append(RankedError(role="Secondary Issue", match=m)) + else: + ranked.append(RankedError(role="Consequence", match=m)) + + # Append suppressed errors as secondary/consequence with tracking + for m, suppressor in suppressed_entries: + if m.causal_priority <= 2: + ranked.append(RankedError( + role="Secondary Issue", match=m, suppressed_by=suppressor + )) + else: + ranked.append(RankedError( + role="Consequence", match=m, suppressed_by=suppressor + )) + + return ranked + + +def _has_explicit_file_error(log_text: str) -> bool: + """Return True only if the log explicitly mentions a missing file/include.""" + return any(p.search(log_text) for p in _EXPLICIT_FILE_ERROR_PATTERNS) + + +def _filter_likely_causes( + causes: List[str], has_file_error: bool +) -> List[str]: + """Remove include/file-related causes unless a file error was explicitly logged.""" + if has_file_error: + return causes + file_keywords = ("include", "library inclusion", "missing file", "open file") + return [ + c for c in causes + if not any(kw in c.lower() for kw in file_keywords) + ] + + +def build_error_analysis_prompt( + log_lines: Sequence[str], + max_lines: int = 10, +) -> Tuple[str, List[Dict[str, Any]]]: + """Build a structured prompt for the LLM from an NgSpice error log. + + Returns: + A tuple of (prompt_string, list_of_tips_for_gui) + """ + # Filter out harmless eSim default model warnings to prevent LLM hallucinations + harmless_patterns = [ + re.compile(r"unable to find definition of model esim_", re.IGNORECASE), + re.compile(r"-\s*default assumed", re.IGNORECASE), + re.compile(r"^\*\* ngspice-\d+", re.IGNORECASE), + re.compile(r"^\*\* The U\. C\. Berkeley CAD Group", re.IGNORECASE), + re.compile(r"^\*\* Copyright", re.IGNORECASE), + re.compile(r"^\*\* Please get your ngspice manual", re.IGNORECASE), + re.compile(r"^\*\* Please file your bug-reports", re.IGNORECASE), + re.compile(r"^\*{6,}$"), # Matches the ****** banner separators + ] + + filtered_lines: List[str] = [] + error_line_count = 0 + for line in log_lines: + if not any(p.search(line) for p in harmless_patterns): + filtered_lines.append(line) + if "error" in line.lower() or "failed" in line.lower(): + error_line_count += 1 + + facts = extract_log_facts(filtered_lines) + matches: List[ErrorMatch] = facts["error_patterns"] + ranked_errors = rank_errors(matches) + + full_text = "".join(filtered_lines) + has_file_error = _has_explicit_file_error(full_text) + + # Coverage tracking + matched_lines = len(set(m.matched_text for m in matches)) + if error_line_count == 0 and matched_lines > 0: + coverage = "High" + elif matched_lines >= error_line_count and error_line_count > 0: + coverage = "High" + elif matched_lines > 0: + coverage = "Medium" + else: + coverage = "Low" + + sections: List[str] = [] + tips: List[Dict[str, Any]] = [] + + # ── Detected Errors (all root causes) ──────────────────────────────── + root_causes = [r for r in ranked_errors if r.role == "Root Cause"] + non_roots = [r for r in ranked_errors if r.role != "Root Cause"] + + if root_causes: + if len(root_causes) > 1: + sections.append("[CRITICAL ERRORS]") + sections.append( + "Multiple critical issues detected. " + "Simulation cannot proceed until all are resolved." + ) + sections.append("") + + for idx, rc in enumerate(root_causes, 1): + m = rc.match + solution = get_solution_for_category(m.category) + likely_causes = _filter_likely_causes( + solution.get("likely_causes", []), has_file_error + ) + + if len(root_causes) > 1: + sections.append(f"--- Critical Error {idx} ---") + sections.append("[DETECTED ERROR]") + sections.append(f"Error ID: {m.error_id}") + sections.append(f"Category: {m.category}") + sections.append(f"Role: Root Cause") + sections.append(f"Diagnosis: {m.diagnosis}") + + for k, v in m.extracted_facts.items(): + sections.append(f"Detected {k}: {v}") + + if likely_causes: + sections.append("\nLikely Causes:") + for cause in likely_causes: + sections.append(f"- {cause}") + + sections.append("\nRecommended Fixes:") + for fix in solution.get("fixes", []): + sections.append(f"- {fix}") + + sections.append("\neSim Steps:") + for step in solution.get("esim_steps", []): + sections.append(f"- {step}") + + if solution.get("prevention"): + sections.append("\nPrevention:") + for prev in solution["prevention"]: + sections.append(f"- {prev}") + sections.append("[END DETECTED ERROR]") + + if solution.get("fixes"): + tips.append({"fix": solution["fixes"][0]}) + + if len(root_causes) > 1: + sections.append("[END CRITICAL ERRORS]") + + # ── Secondary / Consequence issues ─────────────────────────────── + if non_roots: + sections.append("\n[SECONDARY ISSUES]") + for r in non_roots: + label = f"{r.match.category} ({r.role})" + if r.suppressed_by: + label += f" [suppressed by {r.suppressed_by}]" + sections.append(f"- {label}") + sections.append("[END SECONDARY ISSUES]") + + # ── Coverage tracking ──────────────────────────────────────────────── + sections.append("\n[COVERAGE TRACKING]") + sections.append(f"Detected Patterns: {len(matches)}") + sections.append(f"Coverage: {coverage}") + sections.append("[END COVERAGE TRACKING]") + + # ── Circuit Context ────────────────────────────────────────────────── + sections.append("\n[CIRCUIT CONTEXT]") + if facts["circuit_name"]: + sections.append(f"Circuit: {facts['circuit_name']}") + if facts["simulation_type"]: + sections.append(f"Simulation type: {facts['simulation_type']}") + if facts["failed_nodes"]: + sections.append(f"Mentioned nodes: {', '.join(facts['failed_nodes'])}") + if facts["mentioned_components"]: + sections.append(f"Mentioned components: {', '.join(facts['mentioned_components'])}") + sections.append("[END CIRCUIT CONTEXT]") + + # ── Raw Error Snippet Fallback ─────────────────────────────────────── + sections.append("\n[RAW ERROR SNIPPET]") + snippet_lines = filtered_lines[-max_lines:] if len(filtered_lines) > max_lines else filtered_lines + sections.append("".join(snippet_lines).strip()) + sections.append("[END RAW ERROR SNIPPET]") + + return ("\n".join(sections), tips) + diff --git a/src/chatbot/error_patterns.py b/src/chatbot/error_patterns.py new file mode 100644 index 000000000..e5a61dfbe --- /dev/null +++ b/src/chatbot/error_patterns.py @@ -0,0 +1,372 @@ +"""Deterministic pattern matching for common NgSpice simulation errors. + +This module matches known error patterns in NgSpice log output and provides +structured diagnoses + fix suggestions. Results are included in the LLM +prompt so even weak local models can give accurate advice. +""" + +import re +from dataclasses import dataclass, field +from typing import Dict, List, Tuple, Any + + +@dataclass +class ErrorMatch: + error_id: str + category: str + causal_priority: int # 1 = root cause, 2 = intermediate, 3 = consequence + diagnosis: str + matched_text: str + extracted_facts: Dict[str, str] = field(default_factory=dict) + + +# ── Entity extraction helpers ──────────────────────────────────────────────── + +_NODE_PATTERN = re.compile( + r"(?:node|net)\s+['\"]?([^'\"\s]+)['\"]?", re.IGNORECASE +) + +# Matches: "can't find model 'NAME'" β€” captures NAME in group 1. +_CANT_FIND_MODEL_RE = re.compile( + r"can'?t\s+find\s+model\s+['\"]?(\S+?)['\"]?(?:\s|$)", re.IGNORECASE +) + +# Matches the component line that follows a model warning, e.g.: +# d1 in2 net-_c1-pad1_ esim_diode +_COMPONENT_LINE_RE = re.compile( + r"^\s+([a-zA-Z]\S*)\s+", re.MULTILINE +) + +# Matches: "unknown subckt: x1 net-*c1-pad1* 0 out lm7805" +# group 1 = component name (e.g. x1), rest of tokens β†’ last token = subckt name +_UNKNOWN_SUBCKT_RE = re.compile( + r"unknown\s+subckt:\s+(\S+)\s+(.*)", re.IGNORECASE +) + +# Matches: "subcircuit 'NAME' not found" +_SUBCKT_NOT_FOUND_RE = re.compile( + r"subcircuit\s+['\"]?(\S+)['\"]?\s+not\s+found", re.IGNORECASE +) + +# Matches: "Unknown model type XYZ - ignored" +_UNKNOWN_MODEL_TYPE_RE = re.compile( + r"Unknown\s+model\s+type\s+(\S+)", re.IGNORECASE +) + +# Matches voltage/current source names in log text (e.g. v1, vloop1, i2) +_VSOURCE_RE = re.compile(r"\b([vViI]\w+)\b") + +# Matches: "unknown parameter 'foo'" or "no such parameter 'bar'" +_PARAM_NAME_RE = re.compile( + r"(?:unknown\s+parameter|no\s+such\s+parameter)\s+['\"]?(\S+?)['\"]?(?:\s|$)", + re.IGNORECASE, +) + + +def _extract_missing_model_facts(log_text: str, match_str: str) -> Dict[str, str]: + """Extract model name and component from a Missing Model match. + + Component is set to 'Unknown' unless explicitly available in the + immediate context (the indented line that follows the warning). + """ + facts: Dict[str, str] = {} + + # Search in a window around the match in the full log text + pos = log_text.find(match_str) + if pos >= 0: + # Get the full line containing the match + some context after + line_start = log_text.rfind("\n", 0, pos) + 1 + context_window = log_text[line_start:pos + len(match_str) + 300] + else: + context_window = match_str + + # Try to get model name from "can't find model 'NAME'" + m = _CANT_FIND_MODEL_RE.search(context_window) + if m: + facts["Model"] = m.group(1) + else: + # Fallback: "model NAME not found" style + m2 = re.search( + r"(?:model|device)\s+['\"]?(\S+)['\"]?", context_window, re.IGNORECASE + ) + if m2: + facts["Model"] = m2.group(1) + + # Try to find the component on the indented line *following* the warning. + # NgSpice format: "warning, can't find model ...\n d1 in2 ..." + if pos >= 0: + after = log_text[pos + len(match_str):pos + len(match_str) + 200] + comp_m = _COMPONENT_LINE_RE.match(after) + if comp_m: + # Only accept component if it's different from the model name + comp_name = comp_m.group(1) + if comp_name != facts.get("Model"): + facts["Component"] = comp_name + + if "Component" not in facts: + facts["Component"] = "Unknown" + + return facts + + +def _extract_subckt_facts_v2(log_text: str, match_str: str) -> Dict[str, str]: + """Extract subcircuit and component names from an ERR002 match. + + Handles two NgSpice formats: + 1. "unknown subckt: x1 net-*c1* 0 out lm7805" + 2. "subcircuit 'NAME' not found" + """ + facts: Dict[str, str] = {} + + # Search the full line in log_text for richer context + pos = log_text.find(match_str) + if pos >= 0: + line_start = log_text.rfind("\n", 0, pos) + 1 + line_end = log_text.find("\n", pos) + if line_end == -1: + line_end = len(log_text) + full_line = log_text[line_start:line_end] + else: + full_line = match_str + + # Format 1: "unknown subckt: " + m = _UNKNOWN_SUBCKT_RE.search(full_line) + if m: + facts["Component"] = m.group(1) + # The subcircuit name is always the last token in the rest + tokens = m.group(2).strip().split() + if tokens: + facts["Subcircuit"] = tokens[-1] + return facts + + # Format 2: "subcircuit 'NAME' not found" + m2 = _SUBCKT_NOT_FOUND_RE.search(full_line) + if m2: + facts["Subcircuit"] = m2.group(1) + facts["Component"] = "Unknown" + return facts + + return facts + + +def _extract_invalid_model_facts(log_text: str, match_str: str) -> Dict[str, str]: + """Extract the invalid model type from an ERR006 match.""" + facts: Dict[str, str] = {} + m = _UNKNOWN_MODEL_TYPE_RE.search(match_str) + if m: + facts["Invalid Type"] = m.group(1) + return facts + + +def _extract_node_facts(log_text: str, match_str: str, match_pos: int) -> Dict[str, str]: + """Extract the node name from a No DC Path match.""" + facts: Dict[str, str] = {} + context = log_text[max(0, match_pos - 80):min(len(log_text), match_pos + 80)] + m = _NODE_PATTERN.search(context) + if m: + facts["Node"] = m.group(1) + return facts + + +def _extract_source_loop_facts(log_text: str, match_str: str) -> Dict[str, str]: + """Extract voltage/current source names from Source Loop context.""" + facts: Dict[str, str] = {} + # Search a wider window around the match for source names + pos = log_text.find(match_str) + if pos >= 0: + window = log_text[max(0, pos - 200):min(len(log_text), pos + 200)] + else: + window = match_str + sources = _VSOURCE_RE.findall(window) + # De-duplicate while preserving order + seen = set() + unique = [] + for s in sources: + sl = s.lower() + if sl not in seen: + seen.add(sl) + unique.append(s) + if unique: + facts["Sources"] = ", ".join(unique) + return facts + + +def _extract_param_facts(log_text: str, match_str: str) -> Dict[str, str]: + """Extract parameter details from an ERR012 match. + + Distinguishes between 'unknown parameter', 'missing parameter', + and 'invalid numeric value' based on what the log explicitly says. + """ + facts: Dict[str, str] = {} + + # Search in the full line from the log for richer context + pos = log_text.find(match_str) + if pos >= 0: + line_start = log_text.rfind("\n", 0, pos) + 1 + line_end = log_text.find("\n", pos) + if line_end == -1: + line_end = len(log_text) + full_line = log_text[line_start:line_end] + else: + full_line = match_str + + match_lower = full_line.lower() + if "unknown" in match_lower or "no such" in match_lower: + facts["Reason"] = "Unknown parameter" + elif "missing" in match_lower: + facts["Reason"] = "Missing parameter" + else: + facts["Reason"] = "Invalid parameter" + + # Try to extract the offending parameter name + m = _PARAM_NAME_RE.search(full_line) + if m: + facts["Parameter"] = m.group(1) + + return facts + + +# ── Pattern table ──────────────────────────────────────────────────────────── +# Each entry: (compiled_regex, error_id, category, causal_priority, diagnosis, extractor) +# extractor signature: (log_text, match_str) -> Dict OR (log_text, match_str, pos) -> Dict + +_ERROR_PATTERNS: List[Tuple[re.Pattern, str, str, int, str, Any]] = [ + ( + re.compile(r"singular matrix", re.IGNORECASE), + "ERR004", "Singular Matrix", 2, + "The circuit matrix is singular β€” NgSpice cannot solve the DC operating point.", + None, + ), + ( + re.compile(r"timestep too small", re.IGNORECASE), + "ERR005", "Timestep Too Small", 2, + "Transient analysis failed because the simulator could not converge within the minimum timestep.", + None, + ), + ( + re.compile(r"no dc path to ground", re.IGNORECASE), + "ERR003", "No DC Path to Ground", 1, + "A node has no DC path to ground (node 0). Every node must have a resistive path to ground.", + _extract_node_facts, + ), + # ERR001 β€” Missing Model (priority 1, but may be suppressed by ERR006) + ( + re.compile( + r"(?:(?:model|device)\s+['\"]?\S+['\"]?\s+(?:not found|undefined|unknown)" + r"|can'?t\s+find\s+model" + r"|could\s+not\s+find\s+a\s+valid\s+modelname)", + re.IGNORECASE, + ), + "ERR001", "Missing Model", 1, + "A component references a model/device that is not defined in the netlist.", + _extract_missing_model_facts, + ), + # ERR006 β€” Invalid Model Syntax (priority 1, suppresses ERR001 during ranking) + ( + re.compile( + r"(?:Unknown\s+model\s+type\s+\S+\s*(?:-\s*ignored)?" + r"|Invalid\s+model" + r"|Model\s+issue\s+on\s+line)", + re.IGNORECASE, + ), + "ERR006", "Invalid Model Syntax", 1, + "A .model statement has an invalid type or incorrect parameter ordering. " + "The type must immediately follow the model name.", + _extract_invalid_model_facts, + ), + ( + re.compile(r"can'?t\s+find\s+init\s+file", re.IGNORECASE), + "ERR007", "Init File Missing", 1, + "NgSpice cannot find its initialization file (.spiceinit or spinit).", + None, + ), + ( + re.compile(r"doAnalyses:\s+TRAN\s+?.*failed", re.IGNORECASE), + "ERR008", "Transient Analysis Failed", 3, + "The transient (.tran) analysis did not complete successfully.", + None, + ), + ( + re.compile( + r"(?:non-?convergence|failed\s+to\s+converge|convergence\s+fail)", + re.IGNORECASE, + ), + "ERR009", "Convergence Failure", 2, + "The simulator could not converge to a solution.", + None, + ), + ( + re.compile(r"too\s+many\s+iterations", re.IGNORECASE), + "ERR010", "Too Many Iterations", 2, + "The DC operating point or transient step needed more iterations than allowed.", + None, + ), + ( + re.compile( + r"(?:voltage|current)\s+source\s+loop|singular\s+matrix.*?#branch", + re.IGNORECASE, + ), + "ERR011", "Source Loop", 1, + "Voltage sources form a loop, or current sources feed each other without a path.", + _extract_source_loop_facts, + ), + # ERR002 β€” Missing Subcircuit: two NgSpice phrasings + ( + re.compile( + r"(?:unknown\s+subckt:\s+\S+|subcircuit\s+['\"]?\S+['\"]?\s+not\s+found)", + re.IGNORECASE, + ), + "ERR002", "Missing Subcircuit", 1, + "A subcircuit instantiation (X component) references a .subckt that is not defined.", + _extract_subckt_facts_v2, + ), + ( + re.compile( + r"(?:no\s+such\s+parameter|parameter\s+is\s+missing|unknown\s+parameter)", + re.IGNORECASE, + ), + "ERR012", "Invalid Parameter / Syntax Error", 1, + "A component has an invalid or missing parameter.", + _extract_param_facts, + ), + ( + re.compile(r"Simulation Completed Successfully!", re.IGNORECASE), + "ERR013", "No Plot Data (Simulation Succeeded)", 0, + "The simulation actually completed successfully with no errors, but there is no data to plot.", + None, + ), +] + + +def match_error_patterns(log_text: str) -> List[ErrorMatch]: + """Match known NgSpice error patterns in the log text. + + Returns a list of ErrorMatch objects. + """ + matches: List[ErrorMatch] = [] + seen_categories: set = set() + + for pattern, error_id, category, causal_priority, diagnosis, extractor in _ERROR_PATTERNS: + m = pattern.search(log_text) + if m and category not in seen_categories: + seen_categories.add(category) + matched = m.group(0) + + facts: Dict[str, str] = {} + if extractor: + if extractor is _extract_node_facts: + facts = extractor(log_text, matched, m.start()) + else: + facts = extractor(log_text, matched) + + matches.append(ErrorMatch( + error_id=error_id, + category=category, + causal_priority=causal_priority, + diagnosis=diagnosis, + matched_text=matched, + extracted_facts=facts, + )) + + return matches + diff --git a/src/chatbot/error_solutions.py b/src/chatbot/error_solutions.py index 615a3d63c..4d01be544 100644 --- a/src/chatbot/error_solutions.py +++ b/src/chatbot/error_solutions.py @@ -1,106 +1,357 @@ -# error_solutions.py -from typing import Dict,Any +from typing import Dict, Any, List -ERROR_SOLUTIONS = { - "no ground": { - "description": "Missing ground reference (Node 0)", +ERROR_SOLUTIONS: Dict[str, Dict[str, Any]] = { + "Singular Matrix": { + "category": "Singular Matrix", "severity": "critical", + "likely_causes": [ + "A node has no DC path to ground (floating node)", + "Two or more ideal voltage sources are connected in a loop with zero series resistance", + "A component pin is unconnected, creating an open circuit in the netlist", + "An inductor or transformer winding has no parallel resistance to ground" + ], "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" + "Verify every component pin is connected in the KiCad schematic", + "Check for floating nodes β€” every node must have a resistive DC path to ground", + "Add a small series resistor (0.1 ohm) to break any voltage source loop", + "Add a high-value resistor (1G ohm) from floating nodes to ground if needed for simulation stability", + "If the circuit is correct, add '.options gmin=1e-12' to increase minimum conductance" + ], + "esim_steps": [ + "In KiCad, run Inspect -> Electrical Rules Check (ERC) to find unconnected pins", + "Fix all ERC errors, then go to Simulation -> Convert KiCad to NgSpice", + "If the circuit is correct but still fails, open Simulation -> Spice Editor and add '.options gmin=1e-12 reltol=0.01'", + "For stubborn cases, add '.nodeset V(node)=value' to provide initial operating point guesses" ], - "eSim_command": "Add 'GND' symbol from 'power' library" + "prevention": [ + "Always place a GND symbol from the power library before wiring", + "Never leave component pins floating β€” tie unused inputs through resistors to a known potential", + "Run ERC in KiCad before every simulation" + ] }, - - "floating pins": { - "description": "Unconnected component pins", - "severity": "moderate", + "Timestep Too Small": { + "category": "Timestep Too Small", + "severity": "critical", + "likely_causes": [ + "A source has zero rise/fall time, causing an infinitely fast edge the simulator cannot resolve", + "Unrealistic component values (e.g., zero-ohm resistance, zero capacitance)", + "Non-linear component models (diodes, transistors) creating sharp switching discontinuities", + "Simulation step size is too large relative to the circuit's fastest time constant" + ], "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" + "Check all sources for zero rise/fall times and set realistic values (e.g., 1ns minimum)", + "Verify component values are physically realistic β€” no zero resistance or capacitance", + "If the circuit is correct, increase TSTEP in the .tran statement to give the solver more room", + "If still failing, add '.options method=gear' for better numerical stability", + "As a last resort, relax tolerances: '.options reltol=0.01 abstol=1e-9'" + ], + "esim_steps": [ + "In KiCad, double-click each source and verify rise/fall times are non-zero", + "Go to Simulation -> Convert KiCad to NgSpice to regenerate the netlist", + "If the circuit is correct, open Simulation -> Spice Editor", + "Modify the .tran line to use a larger step (e.g., .tran 1u 10m)", + "Add '.options method=gear reltol=0.01' if needed" ], - "eSim_command": "Use 'Place Wire' tool to connect pins" + "prevention": [ + "Always set non-zero rise and fall times for pulse and PWL sources", + "Use realistic parasitic values β€” real components always have some resistance and capacitance", + "Start with a relaxed timestep and tighten only after a successful initial simulation" + ] }, - - "disconnected wires": { - "description": "Wires not properly connected to pins", + "No DC Path to Ground": { + "category": "No DC Path to Ground", "severity": "critical", + "likely_causes": [ + "The circuit has no GND symbol (node 0) β€” NgSpice requires a ground reference", + "A node is connected only through capacitors, which block DC", + "A component pin is unconnected, isolating part of the circuit from ground" + ], "fixes": [ - "Zoom in and check wire endpoints touch pins", - "Use junction dots at wire intersections", - "Re-route wires to ensure proper connections" + "Verify the circuit contains a GND reference symbol connected to the common return path", + "For capacitively-coupled nodes, add a high-value resistor (1G ohm) to ground for DC bias", + "Check all component pins for proper connections β€” look for unconnected wires" ], - "eSim_command": "Press 'J' to add junction dots" + "esim_steps": [ + "In KiCad, press 'A' to add a component, search for 'GND' in the power library, and place it", + "Connect the GND symbol to your circuit's common return path (negative terminal of main supply)", + "Go to Simulation -> Convert KiCad to NgSpice and simulate again" + ], + "prevention": [ + "Start every new schematic by placing a GND symbol first", + "Run Inspect -> Electrical Rules Check (ERC) to catch floating pins early", + "Remember: every node in a SPICE netlist must have a DC path to node 0" + ] }, - - "missing spice model": { - "description": "Component lacks SPICE model definition", + "Missing Model": { + "category": "Missing Model", "severity": "critical", + "likely_causes": [ + "The component references a .model name that is not defined anywhere in the netlist", + "The .lib or .mod file containing the model was not included", + "The model name in the component does not match the name in the .model definition (case-sensitive)" + ], "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" + "Verify the model name on the component exactly matches the .model or .subckt definition", + "Include the library file containing the model definition", + "If no external model exists, use eSim's built-in standard library components" + ], + "esim_steps": [ + "Go to Simulation -> Convert KiCad to NgSpice -> Device Modeling tab", + "Click 'Add' and upload the missing .lib or .mod file for the component", + "Verify the model name shown in Device Modeling matches the component's value field", + "If using a custom model, add '.lib /path/to/your/model.lib' via Simulation -> Spice Editor" ], - "eSim_command": "Add '.lib /usr/share/esim/models.lib' in schematic" + "prevention": [ + "Before placing a component, check that its SPICE model is available (see eSim docs or Components/ICs.pdf)", + "Verify model name assignments in component properties after placement", + "Keep all custom .lib/.mod files in the project directory for portability" + ] }, - - "singular matrix": { - "description": "Simulation convergence error", + "Invalid Model Syntax": { + "category": "Invalid Model Syntax", "severity": "critical", + "likely_causes": [ + "Incorrect parameter ordering in the .model statement", + "Missing model type keyword (e.g., D for diode, NPN/PNP for BJT, NMOS/PMOS for MOSFET)", + "Syntax errors such as missing parentheses or invalid parameter names" + ], "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" + "Correct the .model syntax: .model ()", + "Ensure the type keyword (D, NPN, PNP, NMOS, PMOS) immediately follows the model name", + "Check for typos in parameter names by consulting the NgSpice manual for valid parameters" ], - "eSim_command": "Add '.options gmin=1e-12 reltol=0.01' in .cir file" + "esim_steps": [ + "Open Simulation -> Spice Editor to view the generated netlist", + "Locate the .model statement and correct the syntax", + "Save and re-run the simulation" + ], + "prevention": [ + "Use eSim's built-in Device Modeling tab to set models instead of editing .model lines manually", + "Refer to the NgSpice manual for correct .model syntax for each device type" + ] }, - - "missing component values": { - "description": "Components without specified values", - "severity": "moderate", + "Init File Missing": { + "category": "Init File Missing", + "severity": "warning", + "likely_causes": [ + "The NgSpice initialization file (.spiceinit or spinit) is missing or was accidentally deleted", + "The eSim or NgSpice installation is incomplete" + ], "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" + "Verify the init file exists at the expected path for your OS", + "On Linux, check that /usr/share/ngspice/scripts/spinit exists", + "On Windows, check the NgSpice installation directory for spinit" ], - "eSim_command": "Double-click component β†’ Edit Properties β†’ Set Value" + "esim_steps": [ + "On Linux, run: ls /usr/share/ngspice/scripts/spinit", + "If missing, reinstall eSim to restore the default initialization files", + "Do not manually edit spinit unless you understand NgSpice configuration" + ], + "prevention": [ + "Do not modify or delete files inside the NgSpice installation directory" + ] }, - - "no load after rectifier": { - "description": "Rectifier output has no load capacitor", - "severity": "warning", + "Transient Analysis Failed": { + "category": "Transient Analysis Failed", + "severity": "critical", + "likely_causes": [ + "A source has zero rise/fall time creating an infinitely steep edge", + "Component values are unrealistic (e.g., zero resistance in series with an inductor)", + "The simulation stop time (TSTOP) is too long relative to circuit dynamics, exhausting solver resources", + "Positive feedback without limiting causes oscillation the solver cannot track" + ], + "fixes": [ + "Set non-zero rise/fall times on all pulse and PWL sources", + "Check all component values for physical realism β€” no zero-ohm or near-zero values", + "Reduce TSTOP or increase TSTEP to give the solver an easier time stepping", + "If the circuit is correct, add '.options method=gear' for improved numerical stability", + "For oscillating circuits, provide initial conditions with '.ic V(node)=value'" + ], + "esim_steps": [ + "In KiCad, double-click each source and verify parameters are realistic", + "Go to Simulation -> Convert KiCad to NgSpice to regenerate the netlist", + "Open Simulation -> Spice Editor and modify the .tran line (reduce TSTOP or increase TSTEP)", + "If still failing, add '.options method=gear' in the Spice Editor" + ], + "prevention": [ + "Start with short simulation times and relaxed timesteps for new circuits", + "Always use non-zero rise/fall times on switching sources", + "Verify the DC operating point is valid before running transient analysis" + ] + }, + "Convergence Failure": { + "category": "Convergence Failure", + "severity": "critical", + "likely_causes": [ + "Unrealistic component values preventing the solver from finding a valid operating point", + "Positive feedback loops without saturation or limiting mechanisms", + "Missing bias resistors on transistor bases or gates", + "Highly non-linear circuits (e.g., oscillators, schmitt triggers) without initial conditions" + ], "fixes": [ - "Add filter capacitor after rectifier (100-1000ΞΌF)", - "Add load resistor to establish DC operating point", - "Add voltage regulator for stable output" + "Check all component values for physical realism β€” no zero or near-infinite values", + "Ensure all transistor bases/gates have proper bias paths (resistors to supply or ground)", + "Provide initial conditions using '.ic V(node)=value' or '.nodeset V(node)=value'", + "If the circuit design is correct, relax tolerances: '.options reltol=0.01 abstol=1e-9 vntol=1e-6'", + "As a last resort, increase iteration limits: '.options itl1=200 itl4=50'" + ], + "esim_steps": [ + "In KiCad, verify all transistor bias networks are complete (no floating bases/gates)", + "Go to Simulation -> Convert KiCad to NgSpice to regenerate the netlist", + "Open Simulation -> Spice Editor and add '.nodeset V(node)=expected_voltage' for key nodes", + "If still failing, add '.options reltol=0.01 abstol=1e-9 gmin=1e-12' in the Spice Editor" ], - "eSim_command": "Add capacitor between rectifier output and GND" + "prevention": [ + "Ensure the DC operating point is well-defined before running AC or transient analysis", + "Use initial conditions for oscillators, flip-flops, and bistable circuits", + "Start with simple circuits and add complexity incrementally" + ] + }, + "Too Many Iterations": { + "category": "Too Many Iterations", + "severity": "critical", + "likely_causes": [ + "The DC operating point is ambiguous β€” the solver cannot decide between multiple valid states", + "Bistable circuits (flip-flops, latches) without defined initial conditions", + "Missing or incorrect bias resistors causing the solver to hunt for a stable point", + "Extremely stiff circuits with widely varying time constants" + ], + "fixes": [ + "Provide initial conditions using '.nodeset V(node)=value' to guide the DC solver", + "Verify all bias networks are complete β€” no floating gates or bases", + "Check for unrealistic component ratios (e.g., 1 ohm in series with 1G ohm)", + "If the circuit is correct, increase iteration limits: '.options itl1=300 itl4=100'" + ], + "esim_steps": [ + "Open Simulation -> Spice Editor", + "Add '.nodeset V(output)=0' (or expected voltage) to help the solver find the operating point", + "If that is not enough, add '.options itl1=300 itl4=100' to allow more solver iterations", + "Save and re-run the simulation" + ], + "prevention": [ + "Always provide '.ic' or '.nodeset' for oscillators, flip-flops, and memory circuits", + "Verify bias resistor values create a well-defined DC operating point", + "Start with a DC analysis (.op) before running transient to confirm the operating point" + ] + }, + "Source Loop": { + "category": "Source Loop", + "severity": "critical", + "likely_causes": [ + "Two or more ideal voltage sources are connected in a closed loop (conflicting voltages)", + "Ideal current sources are connected in series (conflicting currents)", + "An inductor forms a loop with a voltage source, creating a short at DC" + ], + "fixes": [ + "Add a small series resistor (1m ohm to 1 ohm) to break the voltage source loop", + "For current sources in series, add a large parallel resistor (1G ohm) across one source", + "For inductor-voltage source loops, add a small series resistance to the inductor" + ], + "esim_steps": [ + "In KiCad, identify the loop by tracing from one voltage source back to itself", + "Insert a small resistor component in series with one of the sources in the loop", + "Save and go to Simulation -> Convert KiCad to NgSpice" + ], + "prevention": [ + "Never connect ideal voltage sources directly in parallel", + "Never connect ideal current sources directly in series", + "Use realistic source models that include small internal resistance" + ] + }, + "Missing Subcircuit": { + "category": "Missing Subcircuit", + "severity": "critical", + "likely_causes": [ + "A component (X-prefixed instance) references a .subckt name that is not defined in the netlist", + "The .lib or .sub file containing the subcircuit definition was not included", + "The subcircuit name on the component does not match the .subckt definition (case-sensitive)" + ], + "fixes": [ + "Verify the subcircuit name on the component exactly matches the .subckt definition in the library file", + "Include the file containing the subcircuit using the Device Modeling tab or a .include directive", + "Check that the number of pins on the component matches the .subckt port count" + ], + "esim_steps": [ + "Go to Simulation -> Convert KiCad to NgSpice -> Device Modeling tab", + "Click 'Add' and upload the missing subcircuit file (.sub or .lib)", + "Verify the subcircuit name in the file matches the component's model/value field", + "Alternatively, open Simulation -> Spice Editor and add '.include /path/to/subcircuit.sub'" + ], + "prevention": [ + "Keep all subcircuit files (.sub, .lib) in the project directory", + "Verify subcircuit availability before placing hierarchical or IC components", + "Check pin count matches between KiCad symbol and .subckt definition" + ] + }, + "Invalid Parameter / Syntax Error": { + "category": "Invalid Parameter / Syntax Error", + "severity": "critical", + "likely_causes": [ + "A component value contains letters instead of a valid number (e.g., 'AA' instead of '5')", + "Missing required parameters for a source definition (e.g., SINE, PULSE missing arguments)", + "Invalid SPICE value suffix (valid: k, meg, m, u, n, p, f)", + "Extra or misplaced tokens in a component line" + ], + "fixes": [ + "Open the component properties and set the value to a valid number with proper suffix", + "For sources, verify all required parameters are present (e.g., SINE needs offset, amplitude, frequency)", + "Check that SPICE value suffixes are correct: k=1e3, meg=1e6, m=1e-3, u=1e-6, n=1e-9, p=1e-12", + "Remove any extra spaces or invalid characters from component value fields" + ], + "esim_steps": [ + "In KiCad, double-click the flagged component to open its properties", + "Set the Value field to a valid number (e.g., '1k' for 1 kilo-ohm, '10u' for 10 microfarad)", + "For sources, verify parameters: e.g., SINE(0 5 1k) means offset=0, amplitude=5V, frequency=1kHz", + "Save and go to Simulation -> Convert KiCad to NgSpice" + ], + "prevention": [ + "Double-check all source parameters (SINE, PULSE, PWL, DC) before converting to NgSpice", + "Use eSim's built-in source configuration dialogs where available", + "Refer to the NgSpice manual for required parameter lists for each source type" + ] + }, + "No Plot Data (Simulation Succeeded)": { + "category": "No Plot Data (Simulation Succeeded)", + "severity": "info", + "likely_causes": [ + "The simulation completed successfully but no plot probes were added to the schematic", + "NgSpice has no output variables to display because no measurements were requested" + ], + "fixes": [ + "Add voltage or current plot components to the schematic on the wires you want to measure", + "Verify that plot components are properly connected to circuit nodes (not floating)" + ], + "esim_steps": [ + "In KiCad, press 'A' and search for 'plot_v1' (voltage probe) or 'plot_i2' (current probe)", + "Place the probe on the wire or node you want to measure", + "Save and go to Simulation -> Convert KiCad to NgSpice, then re-simulate" + ], + "prevention": [ + "Always add plot probes to nodes of interest before running a simulation", + "For current measurement, place the current probe in series with the component" + ] } } -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 +def get_solution_for_category(category: str) -> Dict[str, Any]: + """Get detailed solution for specific error category. - # Default solution for unknown errors - return { - "description": "General schematic error", + This replaces substring-based matching with exact category mapping + to ensure deterministic solution retrieval. + """ + return ERROR_SOLUTIONS.get(category, { + "category": category, "severity": "unknown", + "likely_causes": ["Unknown schematic or simulator error"], "fixes": [ - "Check all connections are proper", - "Verify component values are set", - "Ensure ground symbol is present", - "Check for duplicate component IDs" + "Verify every component pin is connected in the schematic", + "Verify the circuit contains a GND reference (node 0)", + "Check for floating nodes and unrealistic component values" ], - "eSim_command": "Run Design Rule Check (DRC) in KiCad" - } + "esim_steps": [ + "Run Inspect -> Electrical Rules Check (ERC) in KiCad", + "Fix any reported errors, then go to Simulation -> Convert KiCad to NgSpice" + ], + "prevention": [] + }) + diff --git a/src/frontEnd/Application.py b/src/frontEnd/Application.py index 4890f0466..13a9e0775 100644 --- a/src/frontEnd/Application.py +++ b/src/frontEnd/Application.py @@ -522,6 +522,8 @@ def plotSimulationData(self, exitCode, exitStatus): self.closeproj.setEnabled(True) self.wrkspce.setEnabled(True) + + if exitStatus == QtCore.QProcess.NormalExit and exitCode == 0: try: self.obj_Mainview.obj_dockarea.plottingEditor() @@ -538,6 +540,8 @@ def plotSimulationData(self, exitCode, exitStatus): + str(e)) self.errorDetectedSignal.emit("Simulation failed.") + else: + self.errorDetectedSignal.emit("Simulation failed.") def handleError(self): self.projDir = self.obj_appconfig.current_project["ProjectName"] @@ -548,7 +552,29 @@ def handleError(self): self.delayed_function_call() def delayed_function_call(self): - QTimer.singleShot(2000, lambda: self.chatbot_window.debug_error(self.output_file)) + def _trigger_debug(): + # Save the console output now, giving the Qt event loop 2 seconds + # to finish flushing NgSpice's final error messages to the UI. + try: + projDir = self.obj_appconfig.current_project["ProjectName"] + log_path = os.path.join(projDir, "ngspice_error.log") + + # Find all simulation consoles and grab the most recently created one. + # DO NOT use findChild(QtWidgets.QTextEdit) without a name, because + # it will grab the text from the 'Welcome / About eSim' tab instead! + consoles = self.obj_Mainview.obj_dockarea.findChildren( + QtWidgets.QTextEdit, "simulationConsole" + ) + console = consoles[-1] if consoles else None + console_text = console.toPlainText() if console else "" + + with open(log_path, "w") as f: + f.write(console_text) + except Exception: + pass + self.chatbot_window.debug_error(self.output_file) + + QTimer.singleShot(2000, _trigger_debug) def open_ngspice(self): """This Function execute ngspice on current project.""" @@ -572,7 +598,7 @@ def open_ngspice(self): return self.obj_Mainview.obj_dockarea.ngspiceEditor( - projName, ngspiceNetlist, self.simulationEndSignal, self.chatbot_window) + projName, ngspiceNetlist, self.simulationEndSignal) self.ngspice.setEnabled(False) self.conversion.setEnabled(False) diff --git a/src/frontEnd/Chatbot.py b/src/frontEnd/Chatbot.py index 860aa0f2a..43f281538 100644 --- a/src/frontEnd/Chatbot.py +++ b/src/frontEnd/Chatbot.py @@ -25,14 +25,18 @@ detect_topic_switch, get_stt_backend, VISION_MODEL_KEYWORDS, # EXTRACTED: shared constant, avoids duplicate keyword list ) -from PyQt6.QtWidgets import ( +from chatbot.netlist_analysis import parse_spice_netlist, build_netlist_summary_prompt, NETLIST_SYSTEM_PROMPT +from chatbot.error_log_analysis import ( + build_error_analysis_prompt, ERROR_ANALYSIS_SYSTEM_PROMPT +) +from PyQt5.QtWidgets import ( QWidget, QHBoxLayout, QTextBrowser, QVBoxLayout, QLineEdit, QPushButton, QLabel, QComboBox, QApplication, QFileDialog, QDialog, QListWidget, QListWidgetItem, QFrame, QScrollArea, QSlider, QInputDialog ) -from PyQt6.QtCore import QTimer, Qt, pyqtSignal, QSize -from PyQt6.QtGui import QTextCursor, QKeyEvent, QDragEnterEvent, QDropEvent +from PyQt5.QtCore import QTimer, Qt, pyqtSignal, QSize +from PyQt5.QtGui import QTextCursor, QKeyEvent, QDragEnterEvent, QDropEvent from configuration.Appconfig import Appconfig from datetime import datetime import re @@ -1019,8 +1023,8 @@ class ChatbotGUI(QWidget): # Sentinel anchor names β€” used by find_*_anchor_cursor to locate the # typing/streaming bubble in the document regardless of reflow position. - _TYPING_ANCHOR = '' - _STREAM_ANCHOR = '' + _TYPING_ANCHOR = '' + _STREAM_ANCHOR = '' def __init__(self): super().__init__() @@ -2040,7 +2044,7 @@ def _refresh_staging_strip(self): self._staging_area.setVisible(bool(self._staged_images)) def _make_thumbnail(self, image_path: str) -> QWidget: - from PyQt6.QtGui import QPixmap + from PyQt5.QtGui import QPixmap card = QWidget() card.setFixedSize(80, 64) @@ -2254,6 +2258,16 @@ def _reset_mic_button(self): # ── Netlist analysis ───────────────────────────────────────────── def analyse_netlist(self, netlist_path: str): + """Alias kept for backward compatibility.""" + self.analyze_specific_netlist(netlist_path) + + def analyze_specific_netlist(self, netlist_path: str): + """Analyse a .cir.out netlist using structured AI parsing. + + Uses the deterministic fact-extraction pipeline from + chatbot.netlist_analysis to ground the LLM response, + reducing hallucination on small local models. + """ if not os.path.exists(netlist_path): self.chat_display.append( f'
' @@ -2274,43 +2288,25 @@ def analyse_netlist(self, netlist_path: str): f'❌ Could not read file: {_escape_text_preserve_breaks(str(e))}
' ) return - components, nodes, directives = [], set(), [] - for line in raw_lines: - s = line.strip() - if not s or s.startswith('*'): - continue - first = s[0].upper() - if first in 'RCLVIDQMEFGHJKTUWXZ': - components.append(s) - parts = s.split() - if len(parts) >= 3: - nodes.update([parts[1], parts[2]]) - elif first == '.': - directives.append(s) - summary = ( - f"Netlist file: {filename}\n" - f"Total lines: {len(raw_lines)}\n" - f"Components ({len(components)}): " - f"{', '.join(components[:15])}{'...' if len(components) > 15 else ''}\n" - f"Unique nodes: {', '.join(sorted(nodes)[:20])}\n" - f"SPICE directives: {', '.join(directives[:10])}\n\n" - f"Full netlist:\n{''.join(raw_lines[:80])}" - f"{'[truncated]' if len(raw_lines) > 80 else ''}" - ) - prompt = ( - f"Analyse this NgSpice netlist for me.\n\n{summary}\n\n" - "Please: (1) identify all components and their roles, " - "(2) describe what circuit this is and what it does, " - "(3) highlight any potential simulation issues, " - "(4) suggest any improvements." - ) - self.chat_history = (self.chat_history + [f"User: {prompt}"])[-20:] + + # Use the structured parser to build a grounded prompt + try: + parsed = parse_spice_netlist(raw_lines, netlist_path) + prompt = build_netlist_summary_prompt(parsed, raw_lines) + except Exception as e: + self.chat_display.append( + f'
' + f'❌ Failed to parse netlist: {_escape_text_preserve_breaks(str(e))}
' + ) + return + + self.chat_history = [f"User: {prompt}"][-20:] self._retry_history = list(self.chat_history) self._last_user_text = prompt self._start_thinking() - # EXTRACTED: helper method to launch OllamaWorker (with streaming hookup) - self._launch_text_worker(self.chat_history) + # Launch the text worker with the structured prompt and strict low temperature + self._launch_text_worker(self.chat_history, system_prompt=NETLIST_SYSTEM_PROMPT, temperature_override=0.1, netlist_formatter_context=parsed) # ── Topic switch ───────────────────────────────────────────────── @@ -2500,19 +2496,23 @@ def _reset_session_state(self): self._images_store = {} self._last_image_paths = [] self._current_session_kind = "text" + self._current_tips = [] self._session_title_override = None self._current_session_id = str(uuid.uuid4()) self._session_created_at = datetime.now().strftime("%Y-%m-%d %H:%M") # MERGED: also reset streaming-related state so the next message starts clean self._reset_stream_state() - def _launch_text_worker(self, chat_history): + def _launch_text_worker(self, chat_history, system_prompt=None, temperature_override=None, netlist_formatter_context=None): """EXTRACTED: Launch OllamaWorker with correct configuration and signal mappings (streaming-aware).""" + temp = temperature_override if temperature_override is not None else self._temperature self.worker = OllamaWorker( chat_history, model=self.model_combo.currentText(), - temperature=self._temperature, + temperature=temp, num_predict=self._num_predict, + system_prompt=system_prompt, + netlist_formatter_context=netlist_formatter_context ) self.worker.response_signal.connect(self.display_response) self.worker.status_signal.connect(self._on_status_update) @@ -2738,6 +2738,10 @@ def display_response(self, bot_response): self._stop_thinking() ts = self._stream_ts or _get_time() + if getattr(self, '_current_session_kind', None) == "simulation_error" and getattr(self, '_current_tips', None): + for tip in self._current_tips: + bot_response += f"\n\nπŸ’‘ **eSim Tip:**\n*{tip['fix']}*" + if self._stream_buf is not None: idx = self._stream_idx anchor_cursor = self._find_stream_anchor_cursor() @@ -2792,7 +2796,7 @@ def clear_session(self): # ── Debug helpers ──────────────────────────────────────────────── - def debug_ollama(self): + def debug_ollama(self, system_prompt=None, temperature_override=None): self._current_session_kind = "simulation_error" self.chat_display.append( '' @@ -2806,10 +2810,16 @@ def debug_ollama(self): self._retry_history = list(self.chat_history) self._start_thinking() # EXTRACTED: helper method to launch OllamaWorker - self._launch_text_worker(self.chat_history) + self._launch_text_worker(self.chat_history, system_prompt=system_prompt, temperature_override=temperature_override) self.user_input.clear() def debug_error(self, log): + """Analyse an NgSpice error log using structured AI parsing. + + Uses the deterministic fact-extraction pipeline from + chatbot.error_log_analysis to ground the LLM response, + reducing hallucination on small local models. + """ self.setWindowFlags(self.windowFlags()) self.show() self.raise_() @@ -2819,36 +2829,28 @@ def debug_error(self, log): if os.path.exists(log): with open(log, "r") as f: lines = [ln for ln in f.readlines() if ln.strip()] - no_compat_index = next( - (i for i, ln in enumerate(lines) if "No compatibility mode selected!" in ln), None - ) - circuit_index = next((i for i, ln in enumerate(lines) if "Circuit:" in ln), None) - total_cpu_index = next( - (i for i, ln in enumerate(lines) if "Total CPU time (seconds)" in ln), None - ) - before_no_compat = lines[:no_compat_index] if no_compat_index else [] - between = ( - lines[circuit_index + 1:total_cpu_index] - if circuit_index is not None and total_cpu_index is not None - else [] - ) - filtered_lines = before_no_compat + between - if len(filtered_lines) > _MAX_ERROR_LOG_LINES: - truncated_notice = [ - f"[Log truncated: showing last {_MAX_ERROR_LOG_LINES} " - f"of {len(filtered_lines)} lines]\n" - ] - filtered_lines = truncated_notice + filtered_lines[-_MAX_ERROR_LOG_LINES:] - combined_text = "".join(filtered_lines) + + if not lines: + self.chat_display.append( + '
' + '❌ Error log is empty.
' + ) + return + self.status_label.setText( - f"πŸ” Analysing error log ({len(filtered_lines)} lines)…" + f"πŸ” Analysing error log ({len(lines)} lines)…" ) - self.obj_appconfig = Appconfig() - self.projDir = self.obj_appconfig.current_project["ProjectName"] - output_file = os.path.join(self.projDir, "erroroutput.txt") - with open(output_file, "w") as f: - f.writelines(filtered_lines) - self.chat_history.append( - f"User: I got a simulation error. Here is the log:\n{combined_text}" - ) - self.debug_ollama() + + prompt, tips = build_error_analysis_prompt(lines) + self._current_tips = tips + + self.chat_history = [f"User: {prompt}"] + + # DEBUG DUMP + try: + with open(os.path.join(self.projDir, "debug_prompt.txt"), "w") as df: + df.write(prompt) + except Exception: + pass + + self.debug_ollama(system_prompt=ERROR_ANALYSIS_SYSTEM_PROMPT, temperature_override=0.1) diff --git a/src/frontEnd/DockArea.py b/src/frontEnd/DockArea.py index a63c87379..35c7af053 100755 --- a/src/frontEnd/DockArea.py +++ b/src/frontEnd/DockArea.py @@ -164,14 +164,14 @@ def plottingEditor(self): ) count = count + 1 - def ngspiceEditor(self, projName, netlist, simEndSignal,chatbot): + def ngspiceEditor(self, projName, netlist, simEndSignal): """ This function creates widget for Ngspice window.""" global count self.ngspiceWidget = QtWidgets.QWidget() self.ngspiceLayout = QtWidgets.QVBoxLayout() self.ngspiceLayout.addWidget( - NgspiceWidget(netlist, simEndSignal,chatbot) + NgspiceWidget(netlist, simEndSignal) ) # Adding to main Layout diff --git a/src/frontEnd/ProjectExplorer.py b/src/frontEnd/ProjectExplorer.py index bc55dac9c..41615afcf 100755 --- a/src/frontEnd/ProjectExplorer.py +++ b/src/frontEnd/ProjectExplorer.py @@ -460,7 +460,7 @@ def _analyze_netlist_in_copilot(self, netlist_path: str): # Find the chatbot dock for dock in main_window.findChildren(QDockWidget): - if "Copilot" in dock.windowTitle(): + if "AI Assistant" in dock.windowTitle(): chatbot_widget = dock.widget() if hasattr(chatbot_widget, 'analyze_specific_netlist'): chatbot_widget.analyze_specific_netlist(netlist_path) @@ -472,7 +472,7 @@ def _analyze_netlist_in_copilot(self, netlist_path: str): QMessageBox.information( self, "Chatbot not open", - "Please open the eSim Copilot window first (View β†’ eSim Copilot)." + "Please open the eSim AI Assistant window first (View β†’ eSim AI Assistant)." ) except Exception as e: print(f"[COPILOT] Failed to trigger analysis: {e}") diff --git a/src/library/config/.esim/config.ini b/src/library/config/.esim/config.ini new file mode 100644 index 000000000..c01a8f594 --- /dev/null +++ b/src/library/config/.esim/config.ini @@ -0,0 +1,7 @@ +[eSim] +eSim_HOME = S:\project\esim-internship\esim +LICENSE = %(eSim_HOME)s/LICENSE +KicadLib = %(eSim_HOME)s/library/kicadLibrary.tar.xz +IMAGES = %(eSim_HOME)s/images +VERSION = %(eSim_HOME)s/VERSION +MODELICA_MAP_JSON = %(eSim_HOME)s/library/ngspicetoModelica/Mapping.json From 949a0b20d057de66d69edf87c3b6bb477f5440ba Mon Sep 17 00:00:00 2001 From: Saura-4 Date: Sat, 4 Jul 2026 02:06:41 +0530 Subject: [PATCH 3/4] refactor(chatbot): resolve UI title mismatch and rename Copilot to eSim AI Assistant Refactor branding from 'Copilot' to 'eSim AI Assistant' across documentation, config files, knowledge base, and dock titles. Fix window dock lookup failures caused by naming mismatches. Update .gitignore to untrack local config and test projects. --- .gitignore | 14 +++++++------ README_CHATBOT.md | 2 +- src/chatbot/chatbot_core.py | 20 +++++++++---------- src/chatbot/config.json | 2 +- src/chatbot/knowledge_base.py | 5 ++--- src/chatbot/ollama_runner.py | 2 +- src/chatbot/stt_handler.py | 2 +- src/frontEnd/ProjectExplorer.py | 8 ++++---- .../esim_netlist_analysis_output_contract.txt | 2 +- src/library/config/.esim/config.ini | 7 ------- 10 files changed, 29 insertions(+), 35 deletions(-) delete mode 100644 src/library/config/.esim/config.ini diff --git a/.gitignore b/.gitignore index 71e348b58..01c414e18 100644 --- a/.gitignore +++ b/.gitignore @@ -25,11 +25,11 @@ scripts/deploy_to_vm.ps1 venv/ # User-specific local workspace configuration -library/config/.esim/workspace.txt +**/library/config/.esim/workspace.txt # Local configuration files generated at runtime -library/config/.esim/config.ini -library/config/.nghdl/ +**/library/config/.esim/config.ini +**/library/config/.nghdl/ # Ensure compiled files and caches under nghdl/ are also ignored **/__pycache__/ @@ -39,10 +39,12 @@ library/config/.nghdl/ test_ai_features.py test_exact_log.py test_regex2.py +test_error_pipeline_regression.py +test_netlist_pipeline_regression.py +test_ollama_error.py +test_ollama_netlist.py + -# Multi-Agent Teamwork System artifacts -.agents/ -code_review.md # Local test projects Examples/ErrorTests/ diff --git a/README_CHATBOT.md b/README_CHATBOT.md index 6a41ae272..6e04679e8 100644 --- a/README_CHATBOT.md +++ b/README_CHATBOT.md @@ -1,4 +1,4 @@ -# eSim Copilot – AI-Assisted Electronics Simulation Tool +# eSim AI Assistant – 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. diff --git a/src/chatbot/chatbot_core.py b/src/chatbot/chatbot_core.py index 05b641c71..58bbebaea 100644 --- a/src/chatbot/chatbot_core.py +++ b/src/chatbot/chatbot_core.py @@ -126,7 +126,7 @@ def answer_with_rag_fallback(user_input: str) -> str: if rag_context.strip(): prompt = f""" -You are eSim Copilot. +You are eSim AI Assistant. Use ONLY the following official eSim documentation to answer the question. Do NOT invent information. @@ -345,12 +345,12 @@ def is_semantic_topic_switch( np.linalg.norm(emb_new) * np.linalg.norm(emb_prev) ) - print(f"[COPILOT] Semantic similarity = {similarity:.3f}") + print(f"[AI ASSISTANT] Semantic similarity = {similarity:.3f}") return similarity < threshold except Exception as e: - print(f"[COPILOT] Topic switch check failed: {e}") + print(f"[AI ASSISTANT] Topic switch check failed: {e}") return False # ==================== QUESTION CLASSIFICATION ==================== @@ -386,7 +386,7 @@ def classify_question_type(user_input: str, has_image_context: bool, is_followup = _is_follow_up_question(user_input, history) if is_semantic_topic_switch(user_input, history): - print("[COPILOT] Topic switch detected (semantic)") + print("[AI ASSISTANT] Topic switch detected (semantic)") is_followup = False if not is_followup: @@ -415,7 +415,7 @@ def classify_question_type(user_input: str, has_image_context: bool, def handle_greeting() -> str: return ( - "Hello! I'm eSim Copilot. I can help you with:\n" + "Hello! I'm eSim AI Assistant. 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" @@ -427,7 +427,7 @@ 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 + keep in mind that you are an AI assistant of eSim an EDA tool """ return answer_with_rag_fallback(user_input) @@ -652,7 +652,7 @@ def handle_input(user_input: str, question_type = classify_question_type( user_input, bool(LAST_IMAGE_CONTEXT), history ) - print(f"[COPILOT] Question type: {question_type}") + print(f"[AI ASSISTANT] Question type: {question_type}") try: if question_type == "netlist": @@ -680,13 +680,13 @@ def handle_input(user_input: str, except Exception as e: error_msg = f"Error processing question: {str(e)}" - print(f"[COPILOT ERROR] {error_msg}") + print(f"[AI ASSISTANT ERROR] {error_msg}") return error_msg # ==================== WRAPPER ==================== -class ESIMCopilotWrapper: +class ESIMAssistantWrapper: def __init__(self) -> None: self.history: List[Dict[str, str]] = [] @@ -700,7 +700,7 @@ def handle_input(self, user_input: str) -> str: def analyze_schematic(self, query: str) -> str: return self.handle_input(query) -_GLOBAL_WRAPPER = ESIMCopilotWrapper() +_GLOBAL_WRAPPER = ESIMAssistantWrapper() def analyze_schematic(query: str) -> str: diff --git a/src/chatbot/config.json b/src/chatbot/config.json index de710d58f..396d95907 100644 --- a/src/chatbot/config.json +++ b/src/chatbot/config.json @@ -1,6 +1,6 @@ { "system_rules": { - "text_system_prompt": "You are the eSim AI assistant. Rules:\n- ALWAYS answer in exactly 3 bullet points.\n- ALWAYS end every reply with this exact line: powered by eSim Copilot", + "text_system_prompt": "You are the eSim AI assistant. Rules:\n- ALWAYS answer in exactly 3 bullet points.\n- ALWAYS end every reply with this exact line: powered by eSim AI Assistant", "vision_system_prompt": "You are an expert electronics engineer and the AI assistant inside eSim, an open-source EDA tool by FOSSEE at IIT Bombay. You are given schematic images from eSim or KiCad. Read every visible label, net name, component reference, value and pin number, and answer the user's question accurately. Never refuse to analyse. Be concise and use the visible reference designators (R1, C3, U2, etc.)." }, "context_window": { diff --git a/src/chatbot/knowledge_base.py b/src/chatbot/knowledge_base.py index 4c3928c73..512fc4854 100644 --- a/src/chatbot/knowledge_base.py +++ b/src/chatbot/knowledge_base.py @@ -8,9 +8,9 @@ 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") + return os.path.join(xdg_data_home, "esim-ai-assistant", "chroma") -db_path = os.environ.get("ESIM_COPILOT_DB_PATH", "").strip() or _default_db_path() +db_path = os.environ.get("ESIM_AI_ASSISTANT_DB_PATH", "").strip() or _default_db_path() os.makedirs(db_path, exist_ok=True) def _get_collection(): @@ -100,7 +100,6 @@ def ingest_pdfs(manuals_directory: str) -> None: RELEVANCE_THRESHOLD = float( os.environ.get("ESIM_RAG_RELEVANCE_THRESHOLD", "500") ) -RELEVANCE_THRESHOLD = float(os.environ.get("ESIM_RAG_RELEVANCE_THRESHOLD", "500")) def search_knowledge(query: str, n_results: int = 4) -> str: diff --git a/src/chatbot/ollama_runner.py b/src/chatbot/ollama_runner.py index ae754bd0b..14e9a21f0 100644 --- a/src/chatbot/ollama_runner.py +++ b/src/chatbot/ollama_runner.py @@ -13,7 +13,7 @@ # ==================== SETTINGS ==================== _SETTINGS_DIR = os.path.join( - os.path.expanduser("~"), ".local", "share", "esim-copilot" + os.path.expanduser("~"), ".local", "share", "esim-ai-assistant" ) _SETTINGS_PATH = os.path.join(_SETTINGS_DIR, "settings.json") diff --git a/src/chatbot/stt_handler.py b/src/chatbot/stt_handler.py index f2d536066..4c435bed9 100644 --- a/src/chatbot/stt_handler.py +++ b/src/chatbot/stt_handler.py @@ -17,7 +17,7 @@ DEFAULT_VOSK_DIR = os.path.join( os.path.expanduser("~"), ".local", "share", - "esim-copilot", "vosk-model-small-en-us-0.15", + "esim-ai-assistant", "vosk-model-small-en-us-0.15", ) def _get_model(): diff --git a/src/frontEnd/ProjectExplorer.py b/src/frontEnd/ProjectExplorer.py index 41615afcf..52ac2f74f 100755 --- a/src/frontEnd/ProjectExplorer.py +++ b/src/frontEnd/ProjectExplorer.py @@ -127,7 +127,7 @@ def openMenu(self, position): 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)) + analyze_action.triggered.connect(lambda: self._analyze_netlist_in_assistant(netlist_path)) rename_action = menu.addAction("Rename Project") rename_action.triggered.connect(self.renameProject) @@ -138,7 +138,7 @@ def openMenu(self, position): 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)) + analyze_file_action.triggered.connect(lambda: self._analyze_netlist_in_assistant(file_path)) refresh_action = menu.addAction("Refresh") refresh_action.triggered.connect(self.refreshInstant) @@ -450,7 +450,7 @@ def renameProject(self): ) msg.exec_() - def _analyze_netlist_in_copilot(self, netlist_path: str): + def _analyze_netlist_in_assistant(self, netlist_path: str): """Send selected .cir file to chatbot for analysis.""" try: # Get the main Application window (traverse up the widget hierarchy) @@ -475,7 +475,7 @@ def _analyze_netlist_in_copilot(self, netlist_path: str): "Please open the eSim AI Assistant window first (View β†’ eSim AI Assistant)." ) except Exception as e: - print(f"[COPILOT] Failed to trigger analysis: {e}") + print(f"[AI ASSISTANT] Failed to trigger analysis: {e}") QMessageBox.warning( self, "Error", diff --git a/src/frontEnd/manual/esim_netlist_analysis_output_contract.txt b/src/frontEnd/manual/esim_netlist_analysis_output_contract.txt index 6b33e4b16..d1f8e5a89 100644 --- a/src/frontEnd/manual/esim_netlist_analysis_output_contract.txt +++ b/src/frontEnd/manual/esim_netlist_analysis_output_contract.txt @@ -1,4 +1,4 @@ -ESIM COPILOT NETLIST ANALYSIS OUTPUT CONTRACT +ESIM AI ASSISTANT NETLIST ANALYSIS OUTPUT CONTRACT ============================================= This file defines HOW the chatbot MUST respond. diff --git a/src/library/config/.esim/config.ini b/src/library/config/.esim/config.ini deleted file mode 100644 index c01a8f594..000000000 --- a/src/library/config/.esim/config.ini +++ /dev/null @@ -1,7 +0,0 @@ -[eSim] -eSim_HOME = S:\project\esim-internship\esim -LICENSE = %(eSim_HOME)s/LICENSE -KicadLib = %(eSim_HOME)s/library/kicadLibrary.tar.xz -IMAGES = %(eSim_HOME)s/images -VERSION = %(eSim_HOME)s/VERSION -MODELICA_MAP_JSON = %(eSim_HOME)s/library/ngspicetoModelica/Mapping.json From 45c006d8d721f03822db59844c5afb38be8d3237 Mon Sep 17 00:00:00 2001 From: Saura-4 Date: Sat, 4 Jul 2026 02:07:19 +0530 Subject: [PATCH 4/4] feat(chatbot): add LLM-free offline analysis mode for netlist and error troubleshooting Introduce an offline formatting engine allowing users without Ollama or local LLM installations to receive formatted, deterministic static netlist analysis reports and error debugging solutions directly in the UI. --- src/chatbot/offline_formatter.py | 375 +++++++++++++++++++++++++++++++ src/frontEnd/Chatbot.py | 62 ++++- 2 files changed, 434 insertions(+), 3 deletions(-) create mode 100644 src/chatbot/offline_formatter.py diff --git a/src/chatbot/offline_formatter.py b/src/chatbot/offline_formatter.py new file mode 100644 index 000000000..2f50df8e2 --- /dev/null +++ b/src/chatbot/offline_formatter.py @@ -0,0 +1,375 @@ +"""Offline (LLM-free) response formatters for the eSim AI Assistant. + +When Ollama is unavailable, these functions format the deterministic +pipeline output into structured plain-text responses. All diagnostic +data comes from the existing error and netlist analysis pipelines; +no LLM call is made. +""" + +import re +from typing import Dict, List, Sequence, Tuple, Any + +from chatbot.error_log_analysis import ( + extract_log_facts, + rank_errors, + _has_explicit_file_error, + _filter_likely_causes, +) +from chatbot.error_solutions import get_solution_for_category +from chatbot.netlist_analysis import ( + ParsedNetlist, + detect_circuit_blocks, + format_netlist_table, +) + + +# ── Harmless log patterns (same as in build_error_analysis_prompt) ──────────── +# These are filtered out before analysis to prevent false positives. + +_HARMLESS_PATTERNS = [ + re.compile(r"unable to find definition of model esim_", re.IGNORECASE), + re.compile(r"-\s*default assumed", re.IGNORECASE), + re.compile(r"^\*\* ngspice-\d+", re.IGNORECASE), + re.compile(r"^\*\* The U\. C\. Berkeley CAD Group", re.IGNORECASE), + re.compile(r"^\*\* Copyright", re.IGNORECASE), + re.compile(r"^\*\* Please get your ngspice manual", re.IGNORECASE), + re.compile(r"^\*\* Please file your bug-reports", re.IGNORECASE), + re.compile(r"^\*{6,}$"), +] + +_OFFLINE_FOOTNOTE = ( + "\n\n---\n" + "πŸ’‘ **Note:** This analysis was generated using the deterministic " + "pipeline without an LLM. *For a more detailed natural-language " + "explanation, start **Ollama** with a local model and retry.*" +) + +_NETLIST_OFFLINE_FOOTNOTE = ( + "\n\n---\n" + "πŸ’‘ **Note:** This analysis is based only on static SPICE netlist inspection. " + "Circuit operation, voltages, currents, transient behavior, convergence, " + "and performance can only be verified by running a simulation.\n\n" + "*For a more detailed natural-language explanation, start **Ollama** with " + "a local model and retry.*" +) + + +# ── Error analysis offline formatter ───────────────────────────────────────── + +def format_error_analysis_offline( + log_lines: Sequence[str], +) -> str: + """Format error analysis results as structured text without an LLM. + + Runs the full deterministic error pipeline (regex matching, ranking, + suppression, solution lookup) and formats the output directly. + + Returns: + A plain-text response string ready to display in the chat UI. + """ + # Filter out harmless NgSpice banners and default-model warnings + filtered_lines: List[str] = [ + line for line in log_lines + if not any(p.search(line) for p in _HARMLESS_PATTERNS) + ] + + if not filtered_lines: + return "⚠️ **No meaningful error output found in the log.**" + _OFFLINE_FOOTNOTE + + facts = extract_log_facts(filtered_lines) + matches = facts["error_patterns"] + ranked_errors = rank_errors(matches) + + if not ranked_errors: + return ( + "⚠️ **No known error patterns were detected in the log.**\n\n" + "*The simulation output may contain warnings or non-standard " + "messages that the deterministic pipeline does not yet cover.*" + + _OFFLINE_FOOTNOTE + ) + + full_text = "".join(filtered_lines) + has_file_error = _has_explicit_file_error(full_text) + + root_causes = [r for r in ranked_errors if r.role == "Root Cause"] + non_roots = [r for r in ranked_errors if r.role != "Root Cause"] + total_errors = len(ranked_errors) + + sections: List[str] = [] + sections.append("### πŸ› οΈ Error Analysis Results\n") + + # ── Root causes ────────────────────────────────────────────────── + for idx, rc in enumerate(root_causes, 1): + m = rc.match + solution = get_solution_for_category(m.category) + likely_causes = _filter_likely_causes( + solution.get("likely_causes", []), has_file_error + ) + + role_label = "Root Cause" if rc.role == "Root Cause" else rc.role + if total_errors > 1: + sections.append( + f"#### πŸ”΄ Error {idx} of {total_errors}: **{m.category}** `({role_label})`" + ) + else: + sections.append(f"#### πŸ”΄ Detected Error: **{m.category}** `({role_label})`") + + severity = solution.get('severity', 'unknown').upper() + sections.append(f"- **Severity:** `{severity}`") + sections.append("") + sections.append(f"**πŸ“‹ Diagnosis:**\n{m.diagnosis}") + + # Entity extraction (node name, model name, etc.) + if m.extracted_facts: + sections.append("") + for key, value in m.extracted_facts.items(): + sections.append(f"- **Detected {key.title()}:** `{value}`") + + if likely_causes: + sections.append("\n**❓ Likely Causes:**") + for cause in likely_causes: + sections.append(f"- {cause}") + + fixes = solution.get("fixes", []) + if fixes: + sections.append("\n**πŸ”§ Recommended Fixes:**") + for i, fix in enumerate(fixes, 1): + sections.append(f"{i}. **{fix}**") + + esim_steps = solution.get("esim_steps", []) + if esim_steps: + sections.append("\n**πŸ“ eSim Steps:**") + for i, step in enumerate(esim_steps, 1): + sections.append(f"{i}. {step}") + + prevention = solution.get("prevention", []) + if prevention: + sections.append("\n**πŸ›‘οΈ Prevention:**") + for tip in prevention: + sections.append(f"- *{tip}*") + + sections.append("") + + # ── Secondary / consequence issues ─────────────────────────────── + if non_roots: + sections.append("#### ⚠️ Secondary Issues") + for r in non_roots: + label = f"**{r.match.category}** `({r.role})`" + if r.suppressed_by: + label += f" *(related to `{r.suppressed_by}`)*" + solution = get_solution_for_category(r.match.category) + fix = solution.get("fixes", [""])[0] + sections.append(f"- {label}") + if fix: + sections.append(f" - *Fix:* {fix}") + sections.append("") + + # ── Circuit context ────────────────────────────────────────────── + context_parts = [] + if facts.get("circuit_name"): + context_parts.append(f"- **Circuit:** `{facts['circuit_name']}`") + if facts.get("simulation_type"): + context_parts.append(f"- **Simulation Type:** `{facts['simulation_type']}`") + if facts.get("failed_nodes"): + nodes_str = ", ".join(f"`{n}`" for n in facts['failed_nodes']) + context_parts.append(f"- **Mentioned Nodes:** {nodes_str}") + if facts.get("mentioned_components"): + comps_str = ", ".join(f"`{c}`" for c in facts['mentioned_components']) + context_parts.append(f"- **Mentioned Components:** {comps_str}") + if context_parts: + sections.append("#### πŸ”Œ Circuit Context") + sections.extend(context_parts) + sections.append("") + + sections.append(_OFFLINE_FOOTNOTE) + + return "\n".join(sections) + + +# ── Netlist analysis offline formatter ─────────────────────────────────────── + +def _get_block_friendly_name(block_name: str) -> str: + name_lower = block_name.lower() + if "bridge rectifier" in name_lower: + return "a bridge rectifier" + elif "regulator" in name_lower: + for model in ["7805", "7809", "7812"]: + if model in name_lower: + return f"an LM{model} regulator" + return "a voltage regulator" + elif "filter capacitor" in name_lower: + return "a filter capacitor" + elif "output load" in name_lower: + return "an output load" + else: + clean_name = block_name.replace(" stage", "").replace(" STAGE", "").strip() + return f"a {clean_name.lower()}" + + +def _generate_circuit_overview( + parsed: ParsedNetlist, high_conf_blocks: List[Tuple[str, str, str]] +) -> str: + if high_conf_blocks: + has_rectifier = any("rectifier" in b[0].lower() for b in high_conf_blocks) + has_regulator = any("regulator" in b[0].lower() for b in high_conf_blocks) + has_filter = any("capacitor" in b[0].lower() for b in high_conf_blocks) + + detected_type = None + if has_rectifier and has_regulator: + detected_type = "AC to regulated DC power supply" + elif has_rectifier and has_filter: + detected_type = "AC to DC rectifier and filter circuit" + elif has_rectifier: + detected_type = "AC to DC rectifier circuit" + elif has_regulator: + detected_type = "DC voltage regulation circuit" + + friendly_names = [_get_block_friendly_name(b[0]) for b in high_conf_blocks] + if len(friendly_names) == 1: + blocks_sentence = f"The circuit contains {friendly_names[0]}." + elif len(names_list := friendly_names) == 2: + blocks_sentence = f"The circuit contains {names_list[0]} and {names_list[1]}." + else: + blocks_sentence = f"The circuit contains {', '.join(friendly_names[:-1])}, and {friendly_names[-1]}." + + if detected_type: + return f"Detected circuit: {detected_type}.\n\n{blocks_sentence}" + else: + return blocks_sentence + else: + comp_type_map = { + 'R': 'resistors', 'C': 'capacitors', 'L': 'inductors', + 'V': 'voltage sources', 'I': 'current sources', + 'D': 'diodes', 'Q': 'bipolar transistors', + 'M': 'MOSFETs', 'J': 'JFETs', 'X': 'subcircuits' + } + present_prefixes = sorted(list(set(c.prefix for c in parsed.components if c.prefix in comp_type_map))) + if present_prefixes: + names = [comp_type_map[p] for p in present_prefixes] + if len(names) == 1: + return f"The circuit is a basic network consisting of {names[0]}." + elif len(names) == 2: + return f"The circuit is a basic network consisting of {names[0]} and {names[1]}." + else: + return f"The circuit is a general network consisting of {', '.join(names[:-1])}, and {names[-1]}." + else: + return "The netlist contains no standard circuit blocks or recognized components." + + +def _format_detected_block(block_name: str, relationship: str) -> str: + name_lower = block_name.lower() + lines = [f"- **{block_name}**"] + + if "rectifier" in name_lower: + lines.append(" Converts the AC input into pulsating DC.") + conn = relationship.replace("DC+ node is ", "DC+ = ").replace("DC- node is ", "DCβˆ’ = ") + lines.append(f" Connection: {conn}") + elif "regulator" in name_lower: + if "7805" in name_lower: + reg_model = "an LM7805" + elif "7809" in name_lower: + reg_model = "an LM7809" + elif "7812" in name_lower: + reg_model = "an LM7812" + else: + reg_model = "a voltage" + lines.append(f" Regulates the filtered DC using {reg_model} regulator.") + conn = relationship.replace("nodes are ", "") + lines.append(f" Nodes: {conn.upper() if conn.replace(',', '').replace(' ', '').isalpha() else conn}") + elif "capacitor" in name_lower: + lines.append(" Smooths the rectified voltage.") + lines.append(f" {relationship.capitalize()}") + elif "load" in name_lower: + lines.append(" Represents the output load connected across the regulated output.") + else: + lines.append(" Circuit functional stage.") + lines.append(f" {relationship.capitalize()}") + + return "\n".join(lines) + + +def format_netlist_analysis_offline( + parsed: ParsedNetlist, + raw_lines: Sequence[str], +) -> str: + """Format netlist analysis results as structured text without an LLM. + + Uses the existing ``format_netlist_table()`` for component and simulation + setup formatting, then appends obvious issues and detected circuit blocks. + + Returns: + A Markdown-formatted response string ready to display in the chat UI. + """ + sections: List[str] = [] + + # ── 1. Circuit Overview (New Section) ──────────────────────────── + blocks = detect_circuit_blocks(parsed) + high_conf = [b for b in blocks if b[1] == "HIGH"] + + overview_text = _generate_circuit_overview(parsed, high_conf) + sections.append("### Circuit Overview\n" + overview_text) + + # ── 2. Component table and simulation setup (already implemented) ──── + table_md = format_netlist_table(parsed) + note_marker = "\n\nπŸ’‘ **Note:** This overview is based on static netlist analysis. Run a simulation to verify circuit behavior and identify issues that may not be apparent from the netlist alone." + if note_marker in table_md: + table_md = table_md.split(note_marker)[0] + sections.append(table_md) + + # ── 3. Obvious issues ─────────────────────────────────────────────── + issues: List[str] = [] + if not parsed.reference_node_0_present and not parsed.gnd_label_present: + issues.append("Missing reference ground (node `0` or `GND`).") + if not parsed.analysis_directives: + issues.append( + "No simulation directives (e.g., `.tran`, `.dc`, `.ac`) found." + ) + if parsed.unresolved_subckt_calls: + names = ", ".join( + f"`{c.subcircuit}`" for c in parsed.unresolved_subckt_calls + ) + issues.append(f"Unresolved subcircuits: {names}") + missing_includes = [ + f"`{item.token}`" for item in parsed.includes if not item.exists + ] + if missing_includes: + issues.append( + f"Missing included files: {', '.join(missing_includes)}" + ) + + if issues: + sections.append("\n### Issues Found") + for issue in issues: + sections.append(f"- **{issue}**") + else: + sections.append("\n### Issues Found") + sections.append("- *No obvious issues detected.*") + + # ── 4. Detected circuit blocks ────────────────────────────────────── + sections.append("\n### Detected Circuit Blocks") + if high_conf: + block_strings = [ + _format_detected_block(block_name, relationship) + for block_name, _, relationship in high_conf + ] + sections.append("\n\n".join(block_strings)) + else: + sections.append( + "- *No standard circuit blocks detected with high confidence.*" + ) + + # ── 5. Model and subcircuit definitions ───────────────────────────── + if parsed.model_names: + models_str = ", ".join(f"`{m}`" for m in parsed.model_names) + sections.append( + f"\n**Defined Models:** {models_str}" + ) + if parsed.subckt_definitions: + subckts_str = ", ".join(f"`{s}`" for s in parsed.subckt_definitions) + sections.append( + f"**Defined Subcircuits:** {subckts_str}" + ) + + sections.append(_NETLIST_OFFLINE_FOOTNOTE) + + return "\n".join(sections) diff --git a/src/frontEnd/Chatbot.py b/src/frontEnd/Chatbot.py index 43f281538..d6847d3ee 100644 --- a/src/frontEnd/Chatbot.py +++ b/src/frontEnd/Chatbot.py @@ -23,12 +23,17 @@ OllamaWorker, OllamaVisionWorker, MicWorker, OllamaStatusWorker, ModelFetchWorker, detect_topic_switch, get_stt_backend, + is_ollama_running, VISION_MODEL_KEYWORDS, # EXTRACTED: shared constant, avoids duplicate keyword list ) from chatbot.netlist_analysis import parse_spice_netlist, build_netlist_summary_prompt, NETLIST_SYSTEM_PROMPT from chatbot.error_log_analysis import ( build_error_analysis_prompt, ERROR_ANALYSIS_SYSTEM_PROMPT ) +from chatbot.offline_formatter import ( + format_error_analysis_offline, + format_netlist_analysis_offline, +) from PyQt5.QtWidgets import ( QWidget, QHBoxLayout, QTextBrowser, QVBoxLayout, QLineEdit, QPushButton, QLabel, QComboBox, QApplication, @@ -2292,14 +2297,26 @@ def analyze_specific_netlist(self, netlist_path: str): # Use the structured parser to build a grounded prompt try: parsed = parse_spice_netlist(raw_lines, netlist_path) - prompt = build_netlist_summary_prompt(parsed, raw_lines) except Exception as e: self.chat_display.append( f'
' - f'❌ Failed to parse netlist: {_escape_text_preserve_breaks(str(e))}
' + f'Could not parse netlist: {_escape_text_preserve_breaks(str(e))}' + ) + return + + # Offline fallback: if Ollama is unavailable, format the + # deterministic analysis directly without an LLM call. + if not is_ollama_running(): + offline_response = format_netlist_analysis_offline( + parsed, raw_lines ) + self.chat_history = [f"User: [Netlist analysis: {filename}]"] + self.display_response(offline_response) return + # Online path: build a grounding prompt and send to the LLM. + prompt = build_netlist_summary_prompt(parsed, raw_lines) + self.chat_history = [f"User: {prompt}"][-20:] self._retry_history = list(self.chat_history) self._last_user_text = prompt @@ -2692,6 +2709,19 @@ def ask_ollama(self): self._scroll_to_bottom() self._last_image_paths = list(staged_paths) self._clear_staged_images() + + # Guard: Ollama must be running for vision analysis + if not is_ollama_running(): + offline_msg = ( + "**Ollama is not running.**\n\n" + "Image analysis requires a running Ollama server with a vision model.\n\n" + "To start Ollama, open a terminal and run:\n" + "```\nollama serve\n```\n\n" + "Then send your message again." + ) + self.display_response(offline_msg) + return + self._start_thinking() # EXTRACTED: helper method to launch OllamaVisionWorker @@ -2715,6 +2745,21 @@ def ask_ollama(self): self.user_input.clear() self._last_user_text = user_text self._retry_history = list(self.chat_history) + + # Guard: Ollama must be running for follow-up chat + if not is_ollama_running(): + offline_msg = ( + "**Ollama is not running.**\n\n" + "Follow-up questions and general chat require a running Ollama server.\n\n" + "To start Ollama, open a terminal and run:\n" + "```\nollama serve\n```\n\n" + "Then send your message again.\n\n" + "*Note: Error analysis and netlist analysis work offline " + "using the deterministic pipeline β€” use the toolbar buttons above.*" + ) + self.display_response(offline_msg) + return + self._start_thinking() # EXTRACTED: helper method to launch OllamaWorker @@ -2838,9 +2883,20 @@ def debug_error(self, log): return self.status_label.setText( - f"πŸ” Analysing error log ({len(lines)} lines)…" + f"Analysing error log ({len(lines)} lines)..." ) + # Offline fallback: if Ollama is unavailable, format the + # deterministic analysis directly without an LLM call. + if not is_ollama_running(): + offline_response = format_error_analysis_offline(lines) + self._current_tips = [] # fixes are already in the response + self.chat_history = [f"User: [Error log analysis]"] + self.status_label.setText("") + self.display_response(offline_response) + return + + # Online path: build a grounding prompt and send to the LLM. prompt, tips = build_error_analysis_prompt(lines) self._current_tips = tips