Cisco Network Troubleshooting with Python
Build a rule-based diagnostic engine that identifies Cisco network faults.
Introduction
30 Second Summary
When a network goes down, every second counts. Network engineers follow a structured process to isolate the problem, matching symptoms against known failure patterns until the root cause clicks into place.
In this project, you will build a Python rule engine that reads Cisco network troubleshooting cases from a data file and diagnoses each one by matching evidence against known failure patterns. This is Project 1 of the NetSage AI series, where you build the deterministic foundation before adding AI-powered diagnosis.
What You'll Build
Run a single command and watch your terminal light up with 15 Cisco network failures diagnosed in under a second, each tagged with a root cause, a recommended fix command, and an accuracy score that proves your rule engine works.
By the end of this project, you'll have:
- A case loader that pulls 15 realistic Cisco troubleshooting scenarios from a CSV file and displays each one with device, symptom, CLI evidence, and severity.
- A rule engine that grows from 3 rules at 40% accuracy to 8 rules at 93%, showing you firsthand which failures go undiagnosed and why coverage gaps matter.
- A diagnostic report that scores every diagnosis against expected root causes, with per-category accuracy and severity distribution.
- Secret Mission: Add confidence scoring based on evidence strength and a NAT rule to reach 100% case coverage.
Are there any prerequisites?
This project uses only Python's built-in libraries, so there are no extra packages to install. You just need Python and VS Code on your machine.
Before We Start
Before writing any code, take a moment to lock in what you are about to build and why it matters.
This is the first project in a three-part series building the NetSage AI Cisco Troubleshooting Assistant. Understanding the foundation you are laying here will make every later step click into place.
Set Up Your Project and Case Dataset
Every diagnostic tool needs data to work with. The NetSage rule engine will analyze Cisco network failures, but before any diagnosis can happen, it needs a structured dataset of troubleshooting cases to run against.
In this step, you will create your project folder, set up two starter files, and populate a CSV dataset with 15 realistic Cisco network failure scenarios covering 8 categories.
In this step, get ready to:
- Create the netsage-checker project folder with two starter files.
- Populate cases.csv with 15 Cisco troubleshooting cases across 8 failure categories.
- Verify the dataset structure in VS Code.
Create the project folder and files
- In VS Code, click Terminal in the top menu bar.
- Click New Terminal.
A terminal panel opens at the bottom of VS Code.
- Move to your Desktop and create your project folder by running these commands:
cd ~/Desktop
mkdir netsage-checker
cd netsage-checker
- Create the two project files by running:
touch cases.csv checker.py
- Confirm the files exist by running:
ls
You should see cases.csv and checker.py in the output.
Don't see both files?
Make sure you ran cd netsage-checker before touch. If you are still on the Desktop, the files were created outside the project folder.
If the folder already exists from a previous attempt, delete it and start fresh:
rm -rf ~/Desktop/netsage-checker
help me create the netsage-checker project folder on my Desktop
Open the project folder in VS Code
Now that the folder and files exist, you need to open the project folder in VS Code so you can edit the files.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the netsage-checker folder, then click Open.
Seeing a trust dialog?
VS Code may ask if you trust the authors of the files in this folder. Click Yes, I trust the authors to continue.
The Explorer sidebar on the left now shows cases.csv and checker.py inside the netsage-checker folder.
Populate the case dataset
Each row in the dataset represents one Cisco troubleshooting case. The columns capture everything a network technician would collect: the device name, the symptom reported, the CLI evidence from a show command, and the expected diagnosis.
- In the Explorer sidebar, click cases.csv to open it.
- Copy the following CSV data and paste it into the file:
case_id,category,device,symptom,evidence,severity,expected_rule,expected_root_cause,expected_fix
1,interface,SW1,Users on port Gi0/1 have no connectivity,show ip interface brief: GigabitEthernet0/1 administratively down/down,critical,interface_down,Interface is administratively shut down,Enter interface config and run no shutdown
2,interface,R1,WAN link on Gi0/0 is not passing traffic,show ip interface brief: GigabitEthernet0/0 down/down,critical,interface_down,Interface is down due to physical layer issue,Check cable and SFP then run no shutdown
3,vlan,SW2,PC on port Fa0/5 cannot reach its gateway,show vlan brief: Fa0/5 is on VLAN 10 but should be on VLAN 20 - wrong VLAN assignment,high,vlan_mismatch,Host is on the wrong VLAN,Reconfigure with switchport access vlan 20
4,vlan,SW3,IP phone on Gi0/12 not registering with call server,show vlan brief: Gi0/12 shows VLAN mismatch - assigned VLAN 1 instead of voice VLAN 50,high,vlan_mismatch,Port has VLAN mismatch with expected voice VLAN,Set correct voice VLAN with switchport voice vlan 50
5,routing,R2,Hosts on 10.10.0.0/16 cannot reach the internet,show ip route: no route to 0.0.0.0/0 - default route not configured,high,missing_route,No default route in routing table,Add ip route 0.0.0.0 0.0.0.0 next-hop-ip
6,routing,R3,Branch office cannot reach HQ 192.168.1.0/24,show ip route: missing route to 192.168.1.0 - no matching route entry,medium,missing_route,No route to destination network,Add static route or verify routing protocol config
7,acl,R1,Web server is unreachable from VLAN 10,show ip access-lists: ACL 101 deny tcp 10.0.0.0 0.0.255.255 any eq 80 - 1845 matches,high,acl_blocking,ACL is denying HTTP traffic from VLAN 10,Modify ACL to permit required HTTP traffic
8,acl,R2,SSH to router blocked from management subnet,show ip access-lists: access-list 15 has implicit deny blocking 172.16.0.0 traffic,medium,acl_blocking,ACL implicit deny blocking management traffic,Add permit entry for management subnet in ACL
9,dhcp,R1,New devices on VLAN 30 cannot get IP addresses,show ip dhcp pool: DHCP pool VLAN30 has 0 available addresses - pool exhausted,high,dhcp_exhaustion,DHCP pool has no available addresses,Expand DHCP pool range or reduce lease time
10,dhcp,R2,Hosts on remote subnet not getting DHCP addresses,show running-config interface Gi0/1: no ip helper-address configured for DHCP relay,medium,dhcp_relay_missing,No DHCP relay configured on gateway interface,Add ip helper-address pointing to DHCP server
11,trunk,SW1,VLAN 30 traffic not passing between switches,show interfaces trunk: VLAN 30 not in allowed list on trunk Gi0/24,high,trunk_misconfig,Trunk is not carrying required VLAN,Run switchport trunk allowed vlan add 30
12,ospf,R1,Router R1 has no OSPF routes from R2,show ip ospf neighbor: OSPF neighbor table empty on Gi0/0 facing R2,critical,ospf_neighbor,OSPF adjacency not forming with neighbor,Check network statements and interface OSPF config
13,ospf,R3,OSPF routes from area 1 not appearing,show ip ospf interface: Gi0/1 in area 2 but neighbor expects area 1 - OSPF area mismatch,high,ospf_area_mismatch,OSPF area ID does not match neighbor,Correct OSPF area to match neighbor router
14,subnet,R2,Two routers connected but cannot ping each other,show ip interface brief: R2 Gi0/0 has 10.0.0.1/30 and R3 Gi0/0 has 10.0.0.5/30 - subnet mismatch on different subnets,medium,subnet_mismatch,Subnet mask mismatch puts devices on different subnets,Correct IP addressing to use same subnet
15,nat,R1,Internal hosts cannot browse internet despite routes,show ip nat translations: no NAT entries found - NAT not translating inside traffic,high,nat_missing,NAT is not configured or not matching inside traffic,Configure NAT with correct inside/outside interfaces and ACL
What do these columns mean?
- case_id and category identify the case and group it into a failure type (interface, vlan, routing, acl, dhcp, trunk, ospf, subnet, or nat).
- device and symptom describe which device is affected and what the user reported.
- evidence holds realistic Cisco IOS show command output that a technician would collect during troubleshooting.
- expected_rule, expected_root_cause, and expected_fix are the "answer key" your rule engine will check its diagnoses against later.
- Save cases.csv by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Data looks garbled or misaligned?
Make sure cases.csv was empty before you pasted. If there was leftover text, press Cmd+A (macOS) or Ctrl+A (Windows) to select everything, delete it, and paste the CSV data again.
Also check that there are no blank lines at the top of the file. The very first line must start with case_id,category,device.
help me fix my cases.csv file
Verify your dataset in VS Code
Before you run any code against this data, take a moment to confirm the file is structured correctly. How many data rows do you expect to see below the header?
- Scroll through cases.csv in the editor.
- Confirm 15 data rows below the header row (16 lines total).
- Check that the header row has 9 columns: case_id, category, device, symptom, evidence, severity, expected_rule, expected_root_cause, and expected_fix.
- Verify that the severity column contains only critical, high, or medium values.
You should see 15 cases spanning failure types like interface down, VLAN mismatch, missing routes, ACL blocks, DHCP failures, trunk issues, OSPF adjacency problems, and subnet errors.
✔️ Awesome, I've got everything!
Great. Make sure you have saved cases.csv before moving on.
ⓧ I'd like to double check the full code
Here is the complete cases.csv file. Make sure your file matches this exactly, starting with the header on line 1.
case_id,category,device,symptom,evidence,severity,expected_rule,expected_root_cause,expected_fix
1,interface,SW1,Users on port Gi0/1 have no connectivity,show ip interface brief: GigabitEthernet0/1 administratively down/down,critical,interface_down,Interface is administratively shut down,Enter interface config and run no shutdown
2,interface,R1,WAN link on Gi0/0 is not passing traffic,show ip interface brief: GigabitEthernet0/0 down/down,critical,interface_down,Interface is down due to physical layer issue,Check cable and SFP then run no shutdown
3,vlan,SW2,PC on port Fa0/5 cannot reach its gateway,show vlan brief: Fa0/5 is on VLAN 10 but should be on VLAN 20 - wrong VLAN assignment,high,vlan_mismatch,Host is on the wrong VLAN,Reconfigure with switchport access vlan 20
4,vlan,SW3,IP phone on Gi0/12 not registering with call server,show vlan brief: Gi0/12 shows VLAN mismatch - assigned VLAN 1 instead of voice VLAN 50,high,vlan_mismatch,Port has VLAN mismatch with expected voice VLAN,Set correct voice VLAN with switchport voice vlan 50
5,routing,R2,Hosts on 10.10.0.0/16 cannot reach the internet,show ip route: no route to 0.0.0.0/0 - default route not configured,high,missing_route,No default route in routing table,Add ip route 0.0.0.0 0.0.0.0 next-hop-ip
6,routing,R3,Branch office cannot reach HQ 192.168.1.0/24,show ip route: missing route to 192.168.1.0 - no matching route entry,medium,missing_route,No route to destination network,Add static route or verify routing protocol config
7,acl,R1,Web server is unreachable from VLAN 10,show ip access-lists: ACL 101 deny tcp 10.0.0.0 0.0.255.255 any eq 80 - 1845 matches,high,acl_blocking,ACL is denying HTTP traffic from VLAN 10,Modify ACL to permit required HTTP traffic
8,acl,R2,SSH to router blocked from management subnet,show ip access-lists: access-list 15 has implicit deny blocking 172.16.0.0 traffic,medium,acl_blocking,ACL implicit deny blocking management traffic,Add permit entry for management subnet in ACL
9,dhcp,R1,New devices on VLAN 30 cannot get IP addresses,show ip dhcp pool: DHCP pool VLAN30 has 0 available addresses - pool exhausted,high,dhcp_exhaustion,DHCP pool has no available addresses,Expand DHCP pool range or reduce lease time
10,dhcp,R2,Hosts on remote subnet not getting DHCP addresses,show running-config interface Gi0/1: no ip helper-address configured for DHCP relay,medium,dhcp_relay_missing,No DHCP relay configured on gateway interface,Add ip helper-address pointing to DHCP server
11,trunk,SW1,VLAN 30 traffic not passing between switches,show interfaces trunk: VLAN 30 not in allowed list on trunk Gi0/24,high,trunk_misconfig,Trunk is not carrying required VLAN,Run switchport trunk allowed vlan add 30
12,ospf,R1,Router R1 has no OSPF routes from R2,show ip ospf neighbor: OSPF neighbor table empty on Gi0/0 facing R2,critical,ospf_neighbor,OSPF adjacency not forming with neighbor,Check network statements and interface OSPF config
13,ospf,R3,OSPF routes from area 1 not appearing,show ip ospf interface: Gi0/1 in area 2 but neighbor expects area 1 - OSPF area mismatch,high,ospf_area_mismatch,OSPF area ID does not match neighbor,Correct OSPF area to match neighbor router
14,subnet,R2,Two routers connected but cannot ping each other,show ip interface brief: R2 Gi0/0 has 10.0.0.1/30 and R3 Gi0/0 has 10.0.0.5/30 - subnet mismatch on different subnets,medium,subnet_mismatch,Subnet mask mismatch puts devices on different subnets,Correct IP addressing to use same subnet
15,nat,R1,Internal hosts cannot browse internet despite routes,show ip nat translations: no NAT entries found - NAT not translating inside traffic,high,nat_missing,NAT is not configured or not matching inside traffic,Configure NAT with correct inside/outside interfaces and ACL
What about checker.py?
The checker.py file stays empty for now. You will write the Python code that reads and analyzes this dataset in the next step.
Your dataset is ready and waiting. In the next step, you will write a Python loader that reads these 15 cases from the CSV and displays each one in the terminal.
Build the Case Loader
Your dataset of 15 Cisco troubleshooting cases is locked in. From interface failures to OSPF mismatches, you have a realistic spread of network problems ready for analysis.
Right now, that data is sitting in a raw CSV file. In this step, you'll write Python functions that parse cases.csv into structured dictionaries and print each case in a clean, readable format.
In this step, get ready to:
- Parse CSV data into Python dictionaries using the built-in csv module.
- Build display functions that print each case in a formatted layout.
- Run the script and verify all 15 cases display correctly.
Write the load_cases() function
Python's csv module has a reader called DictReader that turns each CSV row into a dictionary, using the header row as keys. This is exactly what you need to access each case's fields by name.
- Open checker.py in VS Code and add the following imports and function:
import csv
import os
# --- Case Loader ---
def load_cases(filepath):
"""Load troubleshooting cases from a CSV file."""
cases = []
with open(filepath, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
cases.append(row)
return cases
What does this code do?
- csv.DictReader reads the CSV file and maps each row to a dictionary using the header row (case_id, category, device, etc.) as keys.
- newline="" prevents Python from interfering with how line endings are handled inside the CSV file. This is the recommended way to open CSV files in Python.
- The function collects every row into a list and returns it, so each case becomes a dictionary you can access by field name.
- Save checker.py.
- Verify the file has valid Python syntax by running this command:
python3 -c "import checker; print('OK')"
You should see OK printed in the terminal.
Seeing a SyntaxError?
- Check that you have matching parentheses and quotes in the with open(...) line.
- Make sure import csv and import os are at the very top of the file with no indentation.
I'm getting a SyntaxError when importing checker.py. Help me debug.
Write the display functions
The loaded cases are Python dictionaries, but printing raw dictionaries is hard to scan. You need two functions: one to format a single case and one to loop through all cases with a header.
- Below the load_cases() function in checker.py, add these two display functions:
def display_case(case):
"""Display a single case in formatted output."""
print(f"--- Case {case['case_id']}: {case['category'].upper()} ---")
print(f" Device: {case['device']}")
print(f" Symptom: {case['symptom']}")
print(f" Evidence: {case['evidence']}")
print(f" Severity: {case['severity']}")
print()
def display_all_cases(cases):
"""Display all cases with a header and count."""
print(f"\n{'='*60}")
print(f" CISCO TROUBLESHOOTING CASES: {len(cases)} loaded")
print(f"{'='*60}\n")
for case in cases:
display_case(case)
What do these functions do?
- display_case() prints one case with its ID, category (uppercased for emphasis), device name, symptom, evidence, and severity. Each field is indented and labelled for readability.
- display_all_cases() prints a banner header showing how many cases were loaded, then loops through the list and calls display_case() for each one.
- Save checker.py.
Write the main() function and run the script
The loader and display functions are ready, but nothing calls them yet. The main() function ties everything together: it builds the file path, checks the CSV exists, loads the cases, and displays them.
- Below the display functions in checker.py, add the main() function and the entry point guard:
# --- Main ---
def main():
script_dir = os.path.dirname(os.path.abspath(__file__))
csv_path = os.path.join(script_dir, "cases.csv")
if not os.path.exists(csv_path):
print(f"Error: {csv_path} not found.")
print("Make sure cases.csv is in the same folder as checker.py")
return
cases = load_cases(csv_path)
display_all_cases(cases)
if __name__ == "__main__":
main()
What does this code do?
- os.path.dirname(os.path.abspath(__file__)) finds the folder where checker.py lives. This ensures the script finds cases.csv regardless of where you run it from.
- The if not os.path.exists check prints a helpful error message if the CSV file is missing, instead of crashing with a traceback.
- if __name__ == "__main__" makes sure main() only runs when you execute the file directly, not when it is imported as a module.
- Save checker.py.
Before you run this, what do you expect to see in the terminal? Think about how many cases are in your CSV and what fields each case displays.
- Run the script by entering this command:
python3 checker.py
You should see a header reading CISCO TROUBLESHOOTING CASES: 15 loaded followed by all 15 cases. Each case shows its case ID, category, device, symptom, evidence, and severity.
Not seeing 15 cases?
- If you see an error mentioning the file not being found, make sure cases.csv is in the same netsage-checker folder as checker.py.
- If you see a KeyError, check that the column names in cases.csv match exactly: case_id, category, device, symptom, evidence, severity.
- If only some cases appear, open cases.csv and confirm you have 15 data rows plus the header row.
My checker.py is not displaying all 15 cases. Help me figure out what went wrong.
✔️ Awesome, I've got everything!
Great. Make sure you've saved checker.py before moving on.
ⓧ I'd like to double check the full code
Scroll through your checker.py and confirm these sections are present in this order:
- import csv and import os at the top.
- The load_cases(filepath) function using csv.DictReader.
- The display_case(case) function that prints a single case's fields.
- The display_all_cases(cases) function that prints the header and loops through all cases.
- The main() function that builds the CSV path, checks it exists, loads cases, and calls display_all_cases().
- The if __name__ == "__main__": guard at the bottom calling main().
Your case loader is working and all 15 troubleshooting scenarios display cleanly. Next up, you'll write diagnostic rules that analyze each case's evidence and identify what went wrong.
Write Your First Three Rules
Your case loader reads and displays all 15 troubleshooting cases. The data is ready, but the script has no idea what's actually wrong with each case.
You need diagnostic logic that reads each case's evidence field and identifies the root cause. Each rule function will pattern-match on keywords from Cisco IOS show command output, mapping specific indicators to a diagnosis and a recommended fix.
In this step, get ready to:
- Write three diagnostic rule functions for interface, VLAN, and routing failures.
- Build a checker engine that runs rules against every case.
- See the coverage gap when only three rules face fifteen cases.
Write three rule functions
Each rule function receives a case dictionary, checks the lowercased evidence string for specific keywords, and returns a diagnosis dictionary if it finds a match. If nothing matches, it returns None.
- In checker.py, find the # --- Main --- comment. Add a new # --- Rules --- section header and the interface down rule above it:
# --- Rules ---
def rule_interface_down(case):
"""Detect interface that is down or administratively shut."""
evidence = case["evidence"].lower()
if "down/down" in evidence or "administratively down" in evidence:
if "administratively down" in evidence:
return {
"rule": "interface_down",
"diagnosis": "Interface is administratively shut down",
"fix": "Enter interface config and run 'no shutdown'",
}
return {
"rule": "interface_down",
"diagnosis": "Interface is down - possible physical layer issue",
"fix": "Check cable connection and run 'no shutdown' on interface",
}
return None
What does this rule do?
- evidence.lower() normalizes the Cisco CLI output so keyword matching is case-insensitive.
- The outer if catches any interface showing "down/down" status from show ip interface brief output.
- The inner if distinguishes between an admin shutdown and a physical layer failure, returning a different diagnosis and fix for each.
- Save checker.py. You'll test this rule alongside the next one in a moment.
- Below rule_interface_down(), add the VLAN mismatch rule:
def rule_vlan_mismatch(case):
"""Detect host assigned to the wrong VLAN."""
evidence = case["evidence"].lower()
symptom = case["symptom"].lower()
has_vlan = "vlan" in evidence or "vlan" in symptom
has_indicator = (
"wrong vlan" in evidence
or "mismatch" in evidence
or "should be" in evidence
)
if has_vlan and has_indicator:
return {
"rule": "vlan_mismatch",
"diagnosis": "Host is assigned to the wrong VLAN",
"fix": "Reconfigure switchport to correct VLAN",
}
return None
Why check both evidence and symptom?
- VLAN issues sometimes appear in the symptom description, not just the CLI evidence. Checking both fields catches more cases.
- has_vlan confirms the case is VLAN-related. has_indicator looks for mismatch keywords like "wrong vlan", "mismatch", or "should be".
- Both conditions must be true to avoid false positives on cases that mention VLANs incidentally (like a DHCP pool named "VLAN30").
- Save checker.py.
- Run your script to confirm no syntax errors were introduced:
python3 checker.py
You should see all 15 cases displayed without any errors.
Seeing a SyntaxError?
- Check for missing colons at the end of def and if lines.
- Make sure all parentheses and curly braces are properly closed.
- Confirm both rule functions are defined above # --- Main ---, not inside main().
- Help me debug a SyntaxError in my checker.py rule functions.
- Below rule_vlan_mismatch(), add the missing route rule:
def rule_missing_route(case):
"""Detect missing route in the routing table."""
evidence = case["evidence"].lower()
has_no_route = "no route" in evidence
has_missing_route = "missing" in evidence and "route" in evidence
if has_no_route or has_missing_route:
return {
"rule": "missing_route",
"diagnosis": "Required route is missing from the routing table",
"fix": "Add the missing route with 'ip route <network> <mask> <next-hop>'",
}
return None
Why two conditions with or?
- has_no_route catches evidence containing the exact phrase "no route" (like Case 5's "no route to 0.0.0.0/0").
- has_missing_route catches evidence where "missing" and "route" appear separately (like Case 6's "missing route to 192.168.1.0").
- The or handles two different ways Cisco CLI output can describe the same underlying problem.
- Save checker.py. The next substep wires these three rules into a checker engine that runs them against every case.
Build the diagnostic engine
Three rules are defined, but nothing calls them yet. You need two functions: one that tries every rule against a single case, and one that loops through all cases.
- Below rule_missing_route(), add these two functions:
def diagnose_case(case, rules):
"""Run all rules against a case and return the first match."""
for rule in rules:
result = rule(case)
if result is not None:
return result
return None
def run_checker(cases, rules):
"""Run the rule checker on all cases and collect results."""
results = []
for case in cases:
diagnosis = diagnose_case(case, rules)
results.append({"case": case, "diagnosis": diagnosis})
return results
What do these functions do?
- diagnose_case() loops through the rule functions in order and returns the first match. This "first match wins" approach means rule order matters.
- run_checker() applies diagnose_case() to every case and collects the results as a list of dictionaries, each holding the original case and its diagnosis (or None).
- Save checker.py and run it to confirm no errors:
python3 checker.py
You should see the same 15-case listing as before, confirming the new functions didn't introduce any errors.
Seeing a NameError?
- Check that diagnose_case is defined before run_checker in your file.
- Make sure both functions are outside main(), at the top level of the file between the rules and # --- Main ---.
- Help me fix a NameError in my checker.py diagnostic engine.
Update main() and run the checker
The rules and engine are ready. Time to connect them in main() and see what three rules can do against fifteen cases.
- In checker.py, find the display_all_cases(cases) line inside main().
- Add the following code below it:
rules = [rule_interface_down, rule_vlan_mismatch, rule_missing_route]
results = run_checker(cases, rules)
matched = 0
for r in results:
case = r["case"]
diag = r["diagnosis"]
if diag:
matched += 1
print(f"Case {case['case_id']}: {diag['diagnosis']}")
print(f" Fix: {diag['fix']}")
else:
print(f"Case {case['case_id']}: [NO RULE MATCHED]")
print()
total = len(results)
pct = matched * 100 // total if total > 0 else 0
print(f"Results: {matched}/{total} cases diagnosed ({pct}%)")
What does this code do?
- The rules list holds references to your three rule functions. Python treats functions as values, so you can store them in a list and call them later.
- run_checker() runs all three rules against each case and returns a list of results.
- The for loop prints each diagnosis or [NO RULE MATCHED] for cases where no rule fired.
- The final line calculates and prints the match percentage using integer division.
Before you run this, think about it. You wrote rules for three failure categories: interface, VLAN, and routing. The dataset has 15 cases spread across 8 categories. How many cases do you expect these three rules to catch?
- Save checker.py and run the checker:
python3 checker.py
After the 15-case listing, you should see 6 cases diagnosed with a fix recommendation. The other 9 show [NO RULE MATCHED]. The summary at the bottom reads Results: 6/15 cases diagnosed (40%).
Why did 9 cases go undiagnosed?
Your three rules only cover interface, VLAN, and routing failures. Cases involving ACL blocks, DHCP issues, trunk misconfigurations, OSPF adjacency problems, and subnet mismatches have no matching rule.
This 40% coverage gap is intentional. Diagnostic accuracy depends directly on rule coverage. In the next step, you'll add five more rules and watch the match rate jump.
Seeing 0 cases diagnosed?
- Check that the rules list contains the actual function names without parentheses. Use rule_interface_down, not rule_interface_down().
- Make sure the new code is indented inside main() (4 spaces), not at the top level of the file.
- Verify that run_checker and diagnose_case are both defined above main().
- Help me figure out why my rule checker is not matching any cases.
✔️ Awesome, I've got everything!
Double check you've saved checker.py.
ⓧ I'd like to double check the full code
Scroll through checker.py and confirm it has these sections in order:
- Imports: csv and os.
- # --- Case Loader --- section with load_cases(), display_case(), and display_all_cases().
- # --- Rules --- section with rule_interface_down(), rule_vlan_mismatch(), and rule_missing_route().
- Engine functions: diagnose_case() and run_checker().
- # --- Main --- section with the updated main() function that creates a 3-rule list, runs the checker, and prints results with a match percentage.
- if __name__ == "__main__": main() guard at the bottom.
Your three rules diagnosed 6 out of 15 cases. That 40% coverage proves the pattern works, but nine cases are still going undiagnosed. Next up, you'll add five more rules to close that gap.
Expand to the Full Rule Engine
In Step 3, you built three rules and watched the checker diagnose 6 out of 15 cases. That 40% match rate left nine cases printing [NO RULE MATCHED] in the terminal.
Those nine undiagnosed cases span five failure types: ACL blocks, DHCP failures, trunk misconfigurations, OSPF adjacency problems, and subnet mismatches. In this step, you will write five more rules to close that coverage gap.
In this step, get ready to:
- Write ACL and DHCP diagnostic rules.
- Write trunk, OSPF, and subnet diagnostic rules.
- Combine all eight rules into a single ALL_RULES list.
Write the ACL and DHCP rules
An ACL can silently deny legitimate traffic while a DHCP failure leaves devices without an IP address. Both are common culprits in Cisco troubleshooting.
- In checker.py, add the ACL detection rule below your rule_missing_route() function by copying this code:
def rule_acl_blocking(case):
"""Detect ACL blocking legitimate traffic."""
evidence = case["evidence"].lower()
has_deny = "deny" in evidence
has_acl = "acl" in evidence or "access-list" in evidence
if has_deny and has_acl:
return {
"rule": "acl_blocking",
"diagnosis": "An ACL is blocking legitimate traffic",
"fix": "Review and modify the ACL to permit required traffic",
}
return None
What does this rule check?
This rule looks for two keywords together: "deny" (proving traffic is being dropped) AND "acl" or "access-list" (proving an ACL is involved). Both must be present to avoid false positives on unrelated deny messages.
- Save checker.py.
- Now add the DHCP detection rule directly below rule_acl_blocking() by copying this code:
def rule_dhcp_issue(case):
"""Detect DHCP pool exhaustion or missing relay."""
evidence = case["evidence"].lower()
symptom = case["symptom"].lower()
if "dhcp" in evidence or "dhcp" in symptom:
if "exhausted" in evidence or "0 available" in evidence:
return {
"rule": "dhcp_exhaustion",
"diagnosis": "DHCP address pool is exhausted",
"fix": "Expand DHCP pool range or reduce lease time",
}
if "helper" in evidence or "relay" in evidence:
return {
"rule": "dhcp_relay_missing",
"diagnosis": "DHCP relay (ip helper-address) is not configured",
"fix": "Add 'ip helper-address <dhcp-server-ip>' on gateway interface",
}
return None
Why does this rule have two sub-checks?
DHCP failures come in two flavors. Pool exhaustion means the server ran out of addresses to hand out. A missing relay means the gateway is not forwarding DHCP requests to the server at all.
The outer check confirms DHCP is involved first. Then the inner checks distinguish which sub-type matches, returning a different rule name and fix for each.
- Save checker.py.
Seeing a syntax error?
- Make sure each function starts with def and ends with return None at the same indentation level as the if statement.
- Check that the nested if statements inside rule_dhcp_issue() are indented one level deeper than the outer if "dhcp" check.
- Confirm you placed both functions below rule_missing_route() and above diagnose_case().
Still stuck? Ask for help.
You have two new rules, but main() still only uses the original three. Time for a quick test to prove these new rules actually work.
- In checker.py, scroll down to the main() function.
- Find this line:
rules = [rule_interface_down, rule_vlan_mismatch, rule_missing_route]
- Add rule_acl_blocking and rule_dhcp_issue to the list so it looks like this:
rules = [rule_interface_down, rule_vlan_mismatch, rule_missing_route, rule_acl_blocking, rule_dhcp_issue]
- Save checker.py and run the checker by running this command:
python3 checker.py
You should see 10 out of 15 cases diagnosed (66%). Cases 7 and 8 (ACL) and Cases 9 and 10 (DHCP) now show diagnoses instead of [NO RULE MATCHED]. Five cases remain undiagnosed.
Still seeing 6/15 instead of 10/15?
- Check that you added both rule_acl_blocking and rule_dhcp_issue to the rules list in main(). The function names must match exactly (no parentheses in the list).
- Verify the rule functions are defined above diagnose_case(). Python must see the function definition before the list references it.
Help me figure out why my ACL and DHCP rules are not matching any cases.
Write the trunk, OSPF, and subnet rules
Five cases are still undiagnosed. They cover trunk misconfiguration, OSPF adjacency failures, and subnet mask mismatches.
- In checker.py, add the trunk detection rule below rule_dhcp_issue() by copying this code:
def rule_trunk_misconfiguration(case):
"""Detect trunk port not carrying a required VLAN."""
evidence = case["evidence"].lower()
has_trunk = "trunk" in evidence
has_not_allowed = (
"not in allowed" in evidence
or "not allowed" in evidence
or "pruned" in evidence
)
if has_trunk and has_not_allowed:
return {
"rule": "trunk_misconfig",
"diagnosis": "Trunk port is not carrying the required VLAN",
"fix": "Add VLAN to trunk with 'switchport trunk allowed vlan add <vlan_id>'",
}
return None
What does this rule check?
A trunk link carries traffic for multiple VLANs between switches. If a VLAN is missing from the trunk's allowed list, its traffic silently stops flowing. This rule checks for "trunk" plus indicators like "not in allowed" or "pruned" to catch that scenario.
- Save checker.py.
- Add the OSPF detection rule directly below rule_trunk_misconfiguration() by copying this code:
def rule_ospf_neighbor(case):
"""Detect OSPF adjacency issues."""
evidence = case["evidence"].lower()
if "ospf" in evidence:
if "no neighbor" in evidence or "neighbor table empty" in evidence or "stuck" in evidence:
return {
"rule": "ospf_neighbor",
"diagnosis": "OSPF neighbor adjacency is not forming",
"fix": "Verify OSPF area, network statements, and interface settings on both sides",
}
if "area mismatch" in evidence or "wrong area" in evidence:
return {
"rule": "ospf_area_mismatch",
"diagnosis": "OSPF area ID mismatch between neighbors",
"fix": "Correct the OSPF area ID to match the neighboring router",
}
return None
Why does this rule return two different rule names?
OSPF problems split into two distinct failures. An adjacency that never forms (empty neighbor table, stuck state) is a different problem from an area ID mismatch. Each sub-case returns a different rule name so the accuracy report can track them separately.
- Save checker.py.
Two more rules added. Update your rules list and run a quick check to confirm they work before adding the last rule.
- In main(), find the rules list you updated earlier:
rules = [rule_interface_down, rule_vlan_mismatch, rule_missing_route, rule_acl_blocking, rule_dhcp_issue]
- Add rule_trunk_misconfiguration and rule_ospf_neighbor to the list so it looks like this:
rules = [rule_interface_down, rule_vlan_mismatch, rule_missing_route, rule_acl_blocking, rule_dhcp_issue, rule_trunk_misconfiguration, rule_ospf_neighbor]
- Save checker.py and run the checker:
python3 checker.py
You should see 13 out of 15 cases diagnosed (86%). Cases 11 (trunk), 12, and 13 (OSPF) are now covered. Only Cases 14 and 15 remain.
- Now add the final rule. Copy the subnet mismatch detection below rule_ospf_neighbor():
def rule_subnet_mismatch(case):
"""Detect subnet mask mismatch between connected devices."""
evidence = case["evidence"].lower()
has_subnet = "subnet" in evidence
has_mismatch = "mismatch" in evidence or "wrong mask" in evidence
if has_subnet and has_mismatch:
return {
"rule": "subnet_mismatch",
"diagnosis": "Subnet mask mismatch between connected devices",
"fix": "Correct the subnet mask to match on both devices",
}
return None
What does this rule check?
When two connected routers use different subnet masks, they end up on different subnets even though they share a cable. This rule requires both "subnet" and a mismatch indicator to avoid false matches on other cases that mention "mismatch" (like the OSPF area mismatch case).
- Save checker.py.
Rules not matching the expected cases?
- Double-check that each rule calls .lower() on the evidence string. Without it, uppercase letters in the CSV will not match your lowercase checks.
- Verify that multi-word checks like "not in allowed" and "neighbor table empty" are typed exactly as shown. A missing space or extra word breaks the substring match.
Help me debug why my trunk, OSPF, or subnet rules are not matching.
Create the ALL_RULES list and update main()
Your rules list in main() keeps growing longer with every new rule you add. A cleaner approach is a single module-level list that holds every rule in one place.
- In checker.py, add the following ALL_RULES list below rule_subnet_mismatch() and above diagnose_case():
ALL_RULES = [
rule_interface_down,
rule_vlan_mismatch,
rule_missing_route,
rule_acl_blocking,
rule_dhcp_issue,
rule_trunk_misconfiguration,
rule_ospf_neighbor,
rule_subnet_mismatch,
]
Why a module-level list?
Defining ALL_RULES at the module level means every part of the script can reference the same set of rules. If you add a rule later, you update one list instead of hunting through main().
Now update main() to use this new list instead of the inline one.
- In the main() function, find these two lines:
rules = [rule_interface_down, rule_vlan_mismatch, rule_missing_route, rule_acl_blocking, rule_dhcp_issue, rule_trunk_misconfiguration, rule_ospf_neighbor]
results = run_checker(cases, rules)
- Delete the rules = [...] line entirely.
- Replace run_checker(cases, rules) with run_checker(cases, ALL_RULES) so the line looks like this:
results = run_checker(cases, ALL_RULES)
- Save checker.py.
Before you run this, think about the result. You started at 40% coverage with 3 rules, hit 66% with 5 rules, then 86% with 7. Now you have 8 rules covering all categories except NAT. What percentage do you expect to see?
- Run the checker by running this command:
python3 checker.py
You should see 14 out of 15 cases diagnosed (93%). Every case now has a diagnosis except Case 15 (NAT), which still shows [NO RULE MATCHED]. That one remaining gap is left for the Secret Mission.
Not seeing 14/15 (93%)?
- Confirm ALL_RULES contains exactly 8 function names, in order: rule_interface_down through rule_subnet_mismatch. A missing entry means one category goes undiagnosed.
- Make sure you deleted the old rules = [...] line from main() and changed the run_checker() call to use ALL_RULES.
- Verify that ALL_RULES is defined at the module level (no indentation), not inside a function.
Help me figure out why my ALL_RULES list is not producing 93% coverage.
✔️ Awesome, I've got everything!
Great. Double check you have saved checker.py.
ⓧ I'd like to double check the full code
Your checker.py is now over 30 lines. Scroll through the file and confirm each section is present in this order:
- Imports: import csv and import os at the top.
- Case Loader: load_cases(), display_case(), display_all_cases().
- Rules (8 functions): rule_interface_down, rule_vlan_mismatch, rule_missing_route, rule_acl_blocking, rule_dhcp_issue, rule_trunk_misconfiguration, rule_ospf_neighbor, rule_subnet_mismatch.
- ALL_RULES: A module-level list containing all 8 rule functions.
- Checker: diagnose_case() and run_checker().
- Main: main() calls run_checker(cases, ALL_RULES) with no inline rules = [...] line.
Your rule engine jumped from 40% to 93% by adding five rules that cover ACLs, DHCP, trunks, OSPF, and subnets. Next up, you will build a structured diagnostic report that shows whether each diagnosis is correct, breaks down accuracy by category, and summarizes severity distribution.
Generate the Diagnostic Report
Your rule engine now diagnoses 14 of 15 cases at 93% coverage. That tells you how many cases the rules can reach, but not whether the diagnoses are actually correct.
A rule could fire on the wrong case and produce a plausible-sounding but incorrect diagnosis. In this step, you will build a structured report that compares each diagnosis against the expected root cause, breaks down accuracy by category, and shows severity distribution.
In this step, get ready to:
- Write a generate_report() function that checks each diagnosis against the expected root cause.
- Add summary statistics with a category breakdown table and severity distribution.
- Run the complete diagnostic report and verify per-case accuracy.
Write the per-case report logic
The current main() function prints each diagnosis but never checks whether the rule that fired was the right one. Each case in your CSV has an expected_rule column that records the correct answer.
- In checker.py, find the # --- Main --- comment. Add the following code above it to start the generate_report() function:
# --- Report ---
def generate_report(results):
"""Generate a diagnostic accuracy report."""
total = len(results)
matched = 0
correct = 0
category_stats = {}
print(f"\n{'='*60}")
print(f" DIAGNOSTIC REPORT")
print(f"{'='*60}\n")
for r in results:
case = r["case"]
diag = r["diagnosis"]
cat = case["category"]
if cat not in category_stats:
category_stats[cat] = {"total": 0, "matched": 0, "correct": 0}
category_stats[cat]["total"] += 1
if diag is not None:
matched += 1
category_stats[cat]["matched"] += 1
What does this code do?
The function initializes counters for matched (any rule fired) and correct (the right rule fired). The category_stats dictionary tracks these counts per failure category so you can see which categories the engine handles well.
The loop processes each result, creates a stats entry for each category on first sight, and increments the matched counter when a rule fired.
- Directly below the category_stats[cat]["matched"] += 1 line you just added, continue the function with the accuracy check and printing logic:
is_correct = diag["rule"] == case["expected_rule"]
if is_correct:
correct += 1
category_stats[cat]["correct"] += 1
status = "CORRECT"
else:
status = "WRONG RULE"
print(f"Case {case['case_id']} [{cat.upper()}]: {diag['diagnosis']}")
print(f" Expected: {case['expected_root_cause']}")
print(f" Rule: {diag['rule']} (expected: {case['expected_rule']})")
print(f" Status: {status}")
print(f" Fix: {diag['fix']}")
else:
print(f"Case {case['case_id']} [{cat.upper()}]: [NO RULE MATCHED]")
print(f" Expected: {case['expected_root_cause']}")
print(f" Status: UNDIAGNOSED")
print()
What does the accuracy check do?
The line diag["rule"] == case["expected_rule"] compares the rule that actually fired against the rule the CSV says should have fired. This catches cases where a rule matches by accident but diagnoses the wrong root cause.
Each case is marked CORRECT (right rule fired), WRONG RULE (a rule fired but the wrong one), or UNDIAGNOSED (no rule matched at all).
- Save checker.py.
Add the summary section
The per-case output shows each diagnosis, but a long list of 15 results is hard to scan. A summary section rolls up the numbers into totals, a per-category breakdown table, and a severity distribution.
- Still in checker.py, add the following code directly below the closing print() line at the end of the for loop, still inside the generate_report() function:
coverage = matched * 100 // total if total > 0 else 0
accuracy = correct * 100 // total if total > 0 else 0
print(f"{'='*60}")
print(f" SUMMARY")
print(f"{'='*60}")
print(f" Total cases: {total}")
print(f" Rules matched: {matched}/{total} ({coverage}%)")
print(f" Correct: {correct}/{total} ({accuracy}%)")
print(f" Undiagnosed: {total - matched}/{total}")
print()
print(f" Category Breakdown:")
print(f" {'Category':<12} {'Cases':<8} {'Matched':<10} {'Correct':<10}")
print(f" {'-'*40}")
for cat in sorted(category_stats):
s = category_stats[cat]
print(f" {cat:<12} {s['total']:<8} {s['matched']:<10} {s['correct']:<10}")
severity_counts = {}
for r in results:
sev = r["case"]["severity"]
severity_counts[sev] = severity_counts.get(sev, 0) + 1
print(f"\n Severity Distribution:")
for sev in ["critical", "high", "medium", "low"]:
if sev in severity_counts:
print(f" {sev.capitalize():<12} {severity_counts[sev]}")
print()
What does the summary section show?
The summary prints three views of the results. The top-level totals show overall coverage and accuracy as percentages. The category breakdown table shows how many cases exist per category and how many were matched and correct, so you can spot weak categories at a glance.
The severity distribution counts how many cases fall into each severity level (critical, high, medium). This tells you whether the undiagnosed or incorrect cases are the most urgent ones.
- Save checker.py.
Seeing an indentation error?
The summary code must be indented one level inside generate_report() (4 spaces), but outside the for r in results: loop. If the coverage = line is indented 8 spaces, it sits inside the loop and will try to print the summary 15 times.
help me fix the indentation in my generate_report function
Update main() and run the complete report
The generate_report() function is complete, but main() still uses the old print loop from Step 3. You need to replace that loop with a single call to your new function.
- In checker.py, scroll down to the main() function and find these lines:
cases = load_cases(csv_path)
display_all_cases(cases)
results = run_checker(cases, ALL_RULES)
matched = 0
for r in results:
case = r["case"]
diag = r["diagnosis"]
if diag:
matched += 1
print(f"Case {case['case_id']}: {diag['diagnosis']}")
print(f" Fix: {diag['fix']}")
else:
print(f"Case {case['case_id']}: [NO RULE MATCHED]")
print()
total = len(results)
pct = matched * 100 // total if total > 0 else 0
print(f"Results: {matched}/{total} cases diagnosed ({pct}%)")
- Replace everything from cases = load_cases(csv_path) down to the last print() line with the following:
cases = load_cases(csv_path)
display_all_cases(cases)
results = run_checker(cases, ALL_RULES)
generate_report(results)
What changed in main()?
The old 15-line print loop manually counted matches and printed each result. The new version replaces all of that with a single call to generate_report(results), which handles the per-case printing, accuracy comparison, and summary statistics.
- Save checker.py.
Before you run the script, think about what the report will show. You have 8 rules covering 14 of 15 cases. Will every diagnosed case be marked CORRECT, or could any show WRONG RULE?
- Run the checker to see the full diagnostic report:
python3 checker.py
You should see the case list, then a DIAGNOSTIC REPORT section. Each of the 14 diagnosed cases shows CORRECT, and Case 15 (NAT) shows UNDIAGNOSED. The summary at the bottom shows 14/15 rules matched (93%) and 14/15 correct (93%), followed by a category breakdown and severity distribution.
Seeing a NameError for generate_report?
Make sure generate_report() is defined above the # --- Main --- comment, not below main(). Python reads the file top to bottom, so main() must be able to see the function when it calls it.
help me fix the NameError in my checker script
Seeing WRONG RULE on cases you expected to pass?
Check that the string your rule returns in the "rule" key matches the expected_rule value in cases.csv exactly. For example, the DHCP rules return "dhcp_exhaustion" and "dhcp_relay_missing", not "dhcp_issue". A single character difference causes a WRONG RULE status.
help me debug why my rules show WRONG RULE in the report
✔️ Awesome, I've got everything!
Great. Double check you have saved checker.py.
ⓧ I'd like to double check the full code
Your checker.py is now too long to show in one block. Scroll through your file and confirm these sections are present, in this order:
- import csv and import os at the top.
- # --- Case Loader --- section with load_cases(), display_case(), and display_all_cases().
- # --- Rules --- section with all 8 rule functions, the ALL_RULES list, diagnose_case(), and run_checker().
- # --- Report --- section with generate_report() containing the per-case loop, summary, category breakdown, and severity distribution.
- # --- Main --- section where main() calls load_cases(), display_all_cases(), run_checker(), and generate_report(). The old print loop should be gone.
Your rule engine now produces a full diagnostic report with accuracy checking, category-level breakdowns, and severity awareness. One case remains undiagnosed, and the Secret Mission will challenge you to close that gap and add confidence scoring to each diagnosis.
Secret mission
Add Confidence Scoring and a NAT Rule
Your rule engine diagnoses 14 of 15 cases, but it has no idea how confident it is. One case slipped through undiagnosed, and every diagnosed case gets treated equally whether the evidence is overwhelming or borderline. Can you close the coverage gap and add evidence-strength scoring to every diagnosis?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with no cloud services or paid APIs, so there are no ongoing costs.
Resources you used:
- The netsage-checker project folder on your Desktop, containing cases.csv and checker.py.
Keep everything running
No action needed. Choose this if you are still actively building or plan to continue with the next project in the NetSage series.
- Your netsage-checker folder stays on your Desktop, ready to use whenever you need it.
- Python and VS Code remain installed on your machine. Both are useful for many other projects.
Pause - I'll come back to this later
Since this project uses only local files and no running processes, there is nothing to pause. Your files sit on disk and use minimal storage.
- Close the netsage-checker folder in VS Code to free up the editor tab.
- Your project folder stays on your Desktop. Open it again anytime to pick up where you left off.
Delete - I don't want to use this again
Remove the project folder to start fresh. This permanently deletes your code and dataset.
- Close the netsage-checker folder in VS Code if it is still open.
- Delete the project folder by running this command in your terminal:
macOS
rm -rf ~/Desktop/netsage-checker
Windows
rmdir /s /q %USERPROFILE%\Desktop\netsage-checker
- Confirm the folder is gone by running:
ls ~/Desktop
You should no longer see netsage-checker in the output.
Nice Work!
Nice Work!
Nice work. You just built a terminal-based Cisco network troubleshooting tool that loads real-world cases, diagnoses them with deterministic rules, and reports exactly how accurate those diagnoses are.
You've learned how to:
- Build a 15-case CSV dataset covering 8 Cisco network failure categories and load it into Python dictionaries with csv.DictReader.
- Write a rule-based diagnostic engine with 8 pattern-matching rules that detect common Cisco misconfigurations from CLI evidence keywords, and experience the coverage gap firsthand as 3 rules gave 40% accuracy and 8 rules jumped to 93%.
- Generate a structured diagnostic report with per-case accuracy status, a category breakdown table, and severity distribution.
- Secret Mission: Added confidence scoring based on evidence strength and a NAT rule to reach 100% case coverage.
Ready to quiz yourself?