Graham Paasch commited on
Commit
e609fed
·
1 Parent(s): 4363b41

Make site count telemetry authoritative and tested

Browse files
Files changed (3) hide show
  1. agent/pipeline_engine.py +122 -69
  2. app.py +10 -3
  3. tests/test_site_counts.py +61 -0
agent/pipeline_engine.py CHANGED
@@ -29,6 +29,8 @@ from .netbox_client import NetBoxClient
29
  from agent.network_ops import create_gns3_project, build_network_from_description, get_lab_topology
30
 
31
  logger = logging.getLogger(__name__)
 
 
32
 
33
 
34
  @dataclass
@@ -249,29 +251,29 @@ class OvergrowthPipeline:
249
  text_lower = text.lower()
250
  for word, val in words.items():
251
  if f"{word}-site" in text_lower or f"{word} site" in text_lower:
252
- return max(1, min(10, val))
253
  m = re.search(r"(\d+)[-\s]?site", text_lower)
254
  if m:
255
  try:
256
- return max(1, min(10, int(m.group(1))))
257
  except Exception:
258
  pass
259
  m = re.search(r"(\d+)\s*sites?", text_lower)
260
  if m:
261
  try:
262
- return max(1, min(10, int(m.group(1))))
263
  except Exception:
264
  pass
265
  m = re.search(r"(\d+)\s*(locations?|branches?)", text_lower)
266
  if m:
267
  try:
268
- return max(1, min(10, int(m.group(1))))
269
  except Exception:
270
  pass
271
  m = re.search(r"(\d+)\s*(stores?|shops?)", text_lower)
272
  if m:
273
  try:
274
- return max(1, min(10, int(m.group(1))))
275
  except Exception:
276
  pass
277
  return 3
@@ -280,6 +282,51 @@ class OvergrowthPipeline:
280
  # Match the naming used by the GNS3 builder (store clouds)
281
  return [f"☕-Store-{idx}" for idx in range(1, site_cnt + 1)]
282
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  def _ensure_wireguard(self):
284
  """
285
  If WG_CONFIG_B64 is provided, bring up wg0 using that config.
@@ -343,6 +390,40 @@ class OvergrowthPipeline:
343
  logger.error(f"WireGuard setup error: {e}")
344
  self.wg_status = f"error: {e}"
345
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  def _prepare_lab_info(self, intent: NetworkIntent, create_project: bool = False) -> Dict[str, Any]:
347
  """
348
  Return lab link info and optionally create a project via MCP to get the project_id.
@@ -1782,27 +1863,8 @@ Be specific and practical. Use RFC1918 addressing. Consider scalability and secu
1782
  return []
1783
  return []
1784
 
1785
- def _count_branch_sites(nodes: List[Dict[str, Any]]) -> Dict[str, Any]:
1786
- import re
1787
- pattern = re.compile(r"^(?:☕-)?store[-\s]", re.IGNORECASE)
1788
- cloud_sites = {
1789
- str(n.get("name", ""))
1790
- for n in nodes
1791
- if pattern.match(str(n.get("name", "")))
1792
- and (n.get("node_type") or "").lower() == "cloud"
1793
- }
1794
- switch_sites = {
1795
- str(n.get("name", ""))
1796
- for n in nodes
1797
- if str(n.get("name", "")).startswith("SW-Store-")
1798
- }
1799
- return {
1800
- "count": max(len(cloud_sites), len(switch_sites)),
1801
- "cloud_sites": sorted(cloud_sites),
1802
- "switch_sites": sorted(switch_sites),
1803
- }
1804
-
1805
  if self.gns3_server and self.enable_gns3_build and lab_info:
 
1806
  try:
1807
  base_desc = (
1808
  intent.description
@@ -1819,63 +1881,54 @@ Be specific and practical. Use RFC1918 addressing. Consider scalability and secu
1819
  )
1820
  lab_info["build_result"] = build_resp
1821
  nodes = _nodes_from_resp(build_resp)
1822
- site_summary_resp = _count_branch_sites(nodes)
1823
- node_sites = {
1824
- s.lower() for s in (site_summary_resp.get("cloud_sites") or site_summary_resp.get("switch_sites") or [])
1825
- }
1826
- missing_sites = [s for s in site_names if s.lower() not in node_sites]
1827
- lab_info["missing_sites"] = missing_sites
1828
- if missing_sites:
1829
- desc_retry = base_desc + " Second attempt: ensure these sites exist: " + ", ".join(missing_sites)
1830
- retry_resp = build_network_from_description(
1831
- description=desc_retry,
1832
- project_name=lab_info.get("project_name") or "overgrowth",
1833
- auto_configure=True,
1834
- site_count=site_count,
1835
- )
1836
- lab_info["build_retry"] = retry_resp
1837
  except Exception as e:
1838
  lab_info["build_error"] = str(e)
1839
 
1840
  try:
1841
  topology = get_lab_topology(lab_info.get("project_name") or "overgrowth")
1842
- site_summary = _count_branch_sites(topology.get("nodes", []))
1843
- actual_count = site_summary["count"]
1844
- lab_info["actual_site_count"] = actual_count
 
 
 
1845
  lab_info["site_nodes"] = site_summary["cloud_sites"] or site_summary["switch_sites"]
1846
  lab_info["topology"] = {
1847
  "nodes": topology.get("nodes", []),
1848
  "links": topology.get("links", []),
1849
  }
 
 
1850
  results["actual_site_count"] = actual_count
1851
- if actual_count < site_count:
1852
- mismatch = {"requested": site_count, "actual": actual_count}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1853
  lab_info["site_count_mismatch"] = mismatch
1854
  results["site_count_mismatch"] = mismatch
1855
- if not lab_info.get("build_retry"):
1856
- retry_resp = build_network_from_description(
1857
- description=base_desc + " Retry to satisfy branch count.",
1858
- project_name=lab_info.get("project_name") or "overgrowth",
1859
- auto_configure=True,
1860
- site_count=site_count,
1861
- )
1862
- lab_info["build_retry"] = retry_resp
1863
- retry_topology = get_lab_topology(lab_info.get("project_name") or "overgrowth")
1864
- retry_summary = _count_branch_sites(retry_topology.get("nodes", []))
1865
- lab_info["actual_site_count"] = retry_summary["count"]
1866
- lab_info["site_nodes"] = retry_summary["cloud_sites"] or retry_summary["switch_sites"]
1867
- lab_info["topology"] = {
1868
- "nodes": retry_topology.get("nodes", []),
1869
- "links": retry_topology.get("links", []),
1870
- }
1871
- results["actual_site_count"] = retry_summary["count"]
1872
- if retry_summary["count"] >= site_count:
1873
- lab_info.pop("site_count_mismatch", None)
1874
- results.pop("site_count_mismatch", None)
1875
- else:
1876
- mismatch = {"requested": site_count, "actual": retry_summary["count"]}
1877
- lab_info["site_count_mismatch"] = mismatch
1878
- results["site_count_mismatch"] = mismatch
1879
  except Exception as e:
1880
  lab_info["topology_error"] = str(e)
1881
  results["lab"] = lab_info
 
29
  from agent.network_ops import create_gns3_project, build_network_from_description, get_lab_topology
30
 
31
  logger = logging.getLogger(__name__)
32
+ SITE_COUNT_MIN = 1
33
+ SITE_COUNT_MAX = 10
34
 
35
 
36
  @dataclass
 
251
  text_lower = text.lower()
252
  for word, val in words.items():
253
  if f"{word}-site" in text_lower or f"{word} site" in text_lower:
254
+ return max(SITE_COUNT_MIN, min(SITE_COUNT_MAX, val))
255
  m = re.search(r"(\d+)[-\s]?site", text_lower)
256
  if m:
257
  try:
258
+ return max(SITE_COUNT_MIN, min(SITE_COUNT_MAX, int(m.group(1))))
259
  except Exception:
260
  pass
261
  m = re.search(r"(\d+)\s*sites?", text_lower)
262
  if m:
263
  try:
264
+ return max(SITE_COUNT_MIN, min(SITE_COUNT_MAX, int(m.group(1))))
265
  except Exception:
266
  pass
267
  m = re.search(r"(\d+)\s*(locations?|branches?)", text_lower)
268
  if m:
269
  try:
270
+ return max(SITE_COUNT_MIN, min(SITE_COUNT_MAX, int(m.group(1))))
271
  except Exception:
272
  pass
273
  m = re.search(r"(\d+)\s*(stores?|shops?)", text_lower)
274
  if m:
275
  try:
276
+ return max(SITE_COUNT_MIN, min(SITE_COUNT_MAX, int(m.group(1))))
277
  except Exception:
278
  pass
279
  return 3
 
282
  # Match the naming used by the GNS3 builder (store clouds)
283
  return [f"☕-Store-{idx}" for idx in range(1, site_cnt + 1)]
284
 
285
+ def _count_branch_sites(
286
+ self,
287
+ nodes: List[Dict[str, Any]],
288
+ expected_site_count: Optional[int] = None
289
+ ) -> Dict[str, Any]:
290
+ """
291
+ Count branch/store sites using strict naming from the retail builder.
292
+ Only counts clouds named ☕-Store-N and switches named SW-Store-N.
293
+ """
294
+ import re
295
+
296
+ cloud_pat = re.compile(r"^☕-Store-(\d+)$")
297
+ sw_pat = re.compile(r"^SW-Store-(\d+)$")
298
+ cloud_sites: List[str] = []
299
+ switch_sites: List[str] = []
300
+ site_indices: set[int] = set()
301
+
302
+ for node in nodes or []:
303
+ name = str(node.get("name", "")).strip()
304
+ node_type = (node.get("node_type") or "").lower()
305
+ m_cloud = cloud_pat.match(name)
306
+ m_sw = sw_pat.match(name)
307
+ if m_cloud and node_type == "cloud":
308
+ idx = int(m_cloud.group(1))
309
+ site_indices.add(idx)
310
+ cloud_sites.append(name)
311
+ elif m_sw:
312
+ idx = int(m_sw.group(1))
313
+ site_indices.add(idx)
314
+ switch_sites.append(name)
315
+
316
+ site_count = len(site_indices)
317
+ missing: List[str] = []
318
+ if expected_site_count:
319
+ expected_names = [f"☕-Store-{i}" for i in range(1, expected_site_count + 1)]
320
+ missing = [name for name in expected_names if name not in cloud_sites]
321
+
322
+ return {
323
+ "count": site_count,
324
+ "indices": sorted(site_indices),
325
+ "cloud_sites": sorted(cloud_sites),
326
+ "switch_sites": sorted(switch_sites),
327
+ "missing": missing,
328
+ }
329
+
330
  def _ensure_wireguard(self):
331
  """
332
  If WG_CONFIG_B64 is provided, bring up wg0 using that config.
 
390
  logger.error(f"WireGuard setup error: {e}")
391
  self.wg_status = f"error: {e}"
392
 
393
+ def _extract_builder_summary(self, build_resp: Dict[str, Any]) -> Dict[str, Any]:
394
+ """
395
+ Extract builder summary (site counts, nodes) from the MCP response.
396
+ Handles both parsed dicts and JSON-encoded 'text' fields.
397
+ """
398
+ candidates: List[Dict[str, Any]] = []
399
+ if isinstance(build_resp, dict):
400
+ candidates.append(build_resp)
401
+ txt = build_resp.get("text")
402
+ if isinstance(txt, str):
403
+ try:
404
+ candidates.append(json.loads(txt))
405
+ except Exception:
406
+ pass
407
+ raw = build_resp.get("raw", {})
408
+ if isinstance(raw, dict):
409
+ content = raw.get("content") or []
410
+ if content and isinstance(content, list) and isinstance(content[0], dict):
411
+ txt2 = content[0].get("text")
412
+ if isinstance(txt2, str):
413
+ try:
414
+ candidates.append(json.loads(txt2))
415
+ except Exception:
416
+ pass
417
+
418
+ for cand in candidates:
419
+ if isinstance(cand, dict) and (
420
+ "site_count_built" in cand
421
+ or "site_count_requested" in cand
422
+ or "nodes" in cand
423
+ ):
424
+ return cand
425
+ return {}
426
+
427
  def _prepare_lab_info(self, intent: NetworkIntent, create_project: bool = False) -> Dict[str, Any]:
428
  """
429
  Return lab link info and optionally create a project via MCP to get the project_id.
 
1863
  return []
1864
  return []
1865
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1866
  if self.gns3_server and self.enable_gns3_build and lab_info:
1867
+ builder_site_count: Optional[int] = None
1868
  try:
1869
  base_desc = (
1870
  intent.description
 
1881
  )
1882
  lab_info["build_result"] = build_resp
1883
  nodes = _nodes_from_resp(build_resp)
1884
+ builder_summary = self._extract_builder_summary(build_resp)
1885
+ builder_site_count = builder_summary.get("site_count_built")
1886
+ lab_info["builder_site_count"] = builder_site_count
1887
+ results["builder_site_count"] = builder_site_count
1888
+ lab_info["builder_summary"] = builder_summary
1889
+ site_summary_resp = self._count_branch_sites(nodes, expected_site_count=site_count)
1890
+ lab_info["initial_site_count"] = site_summary_resp.get("count")
1891
+ lab_info["initial_missing_sites"] = site_summary_resp.get("missing", [])
 
 
 
 
 
 
 
1892
  except Exception as e:
1893
  lab_info["build_error"] = str(e)
1894
 
1895
  try:
1896
  topology = get_lab_topology(lab_info.get("project_name") or "overgrowth")
1897
+ site_summary = self._count_branch_sites(topology.get("nodes", []), expected_site_count=site_count)
1898
+ topology_count = site_summary["count"]
1899
+ missing_sites = site_summary.get("missing") or []
1900
+ lab_info["topology_site_count"] = topology_count
1901
+ results["topology_site_count"] = topology_count
1902
+ lab_info["missing_sites"] = missing_sites
1903
  lab_info["site_nodes"] = site_summary["cloud_sites"] or site_summary["switch_sites"]
1904
  lab_info["topology"] = {
1905
  "nodes": topology.get("nodes", []),
1906
  "links": topology.get("links", []),
1907
  }
1908
+ actual_count = builder_site_count if builder_site_count is not None else topology_count
1909
+ lab_info["actual_site_count"] = actual_count
1910
  results["actual_site_count"] = actual_count
1911
+
1912
+ mismatch = None
1913
+ if (
1914
+ builder_site_count is not None
1915
+ and builder_site_count != site_count
1916
+ ) or (topology_count is not None and topology_count != site_count):
1917
+ mismatch = {
1918
+ "requested": site_count,
1919
+ "builder": builder_site_count,
1920
+ "topology": topology_count,
1921
+ }
1922
+ elif builder_site_count is not None and topology_count is not None and builder_site_count != topology_count:
1923
+ mismatch = {
1924
+ "requested": site_count,
1925
+ "builder": builder_site_count,
1926
+ "topology": topology_count,
1927
+ }
1928
+
1929
+ if mismatch:
1930
  lab_info["site_count_mismatch"] = mismatch
1931
  results["site_count_mismatch"] = mismatch
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1932
  except Exception as e:
1933
  lab_info["topology_error"] = str(e)
1934
  results["lab"] = lab_info
app.py CHANGED
@@ -607,16 +607,23 @@ def build_ui():
607
  if isinstance(lab_info, dict):
608
  missing_sites = lab_info.get("missing_sites") or []
609
  requested_sites = lab_info.get("requested_site_count") or results.get("requested_site_count")
 
 
610
  actual_sites = lab_info.get("actual_site_count") or results.get("actual_site_count")
611
  mismatch = lab_info.get("site_count_mismatch") or results.get("site_count_mismatch")
612
- if requested_sites:
613
- status += f"- Requested site count: {requested_sites}\n"
 
 
 
 
614
  if actual_sites is not None:
615
  status += f"- Actual site count: {actual_sites}\n"
616
  if mismatch:
617
  status += (
618
  f"- Site count mismatch: requested {mismatch.get('requested')}, "
619
- f"actual {mismatch.get('actual')}\n"
 
620
  )
621
  build_err = lab_info.get("build_error") or (lab_info.get("build_result") or {}).get("error")
622
  if build_err:
 
607
  if isinstance(lab_info, dict):
608
  missing_sites = lab_info.get("missing_sites") or []
609
  requested_sites = lab_info.get("requested_site_count") or results.get("requested_site_count")
610
+ builder_sites = lab_info.get("builder_site_count") or results.get("builder_site_count")
611
+ topology_sites = lab_info.get("topology_site_count") or results.get("topology_site_count")
612
  actual_sites = lab_info.get("actual_site_count") or results.get("actual_site_count")
613
  mismatch = lab_info.get("site_count_mismatch") or results.get("site_count_mismatch")
614
+ if requested_sites is not None:
615
+ status += f"- Requested site count (parsed): {requested_sites}\n"
616
+ if builder_sites is not None:
617
+ status += f"- Builder site count (GNS3): {builder_sites}\n"
618
+ if topology_sites is not None:
619
+ status += f"- Topology site count (telemetry): {topology_sites}\n"
620
  if actual_sites is not None:
621
  status += f"- Actual site count: {actual_sites}\n"
622
  if mismatch:
623
  status += (
624
  f"- Site count mismatch: requested {mismatch.get('requested')}, "
625
+ f"builder {mismatch.get('builder')}, "
626
+ f"topology {mismatch.get('topology')}\n"
627
  )
628
  build_err = lab_info.get("build_error") or (lab_info.get("build_result") or {}).get("error")
629
  if build_err:
tests/test_site_counts.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Unit tests for site count parsing and topology counting helpers.
4
+ """
5
+
6
+ import unittest
7
+ from agent.pipeline_engine import OvergrowthPipeline
8
+
9
+
10
+ class SiteCountTests(unittest.TestCase):
11
+ def setUp(self):
12
+ # Keep NetBox disabled for fast, offline unit tests.
13
+ self.pipeline = OvergrowthPipeline(use_netbox=False)
14
+
15
+ def test_parse_site_count_variants(self):
16
+ self.assertEqual(
17
+ self.pipeline._parse_site_count("Design a 4-site retail network with WiFi"),
18
+ 4,
19
+ )
20
+ self.assertEqual(
21
+ self.pipeline._parse_site_count("We need a seven site deployment"), 7
22
+ )
23
+ self.assertEqual(
24
+ self.pipeline._parse_site_count("Build 9 stores across the region"), 9
25
+ )
26
+
27
+ def test_site_names_alignment(self):
28
+ self.assertEqual(
29
+ self.pipeline._site_names(3),
30
+ ["☕-Store-1", "☕-Store-2", "☕-Store-3"],
31
+ )
32
+
33
+ def test_count_branch_sites_exact(self):
34
+ nodes = [
35
+ {"name": "☕-Store-1", "node_type": "cloud"},
36
+ {"name": "☕-Store-2", "node_type": "cloud"},
37
+ {"name": "SW-Store-1", "node_type": "qemu"},
38
+ {"name": "SW-Store-2", "node_type": "qemu"},
39
+ {"name": "☁️-Internet-MPLS", "node_type": "cloud"},
40
+ {"name": "-HQ-Building", "node_type": "cloud"},
41
+ ]
42
+ summary = self.pipeline._count_branch_sites(nodes, expected_site_count=2)
43
+ self.assertEqual(summary["count"], 2)
44
+ self.assertEqual(summary["missing"], [])
45
+
46
+ def test_count_branch_sites_missing(self):
47
+ nodes = [
48
+ {"name": "☕-Store-1", "node_type": "cloud"},
49
+ {"name": "☕-Store-2", "node_type": "cloud"},
50
+ {"name": "☕-Store-3", "node_type": "cloud"},
51
+ {"name": "SW-Store-1", "node_type": "qemu"},
52
+ {"name": "SW-Store-2", "node_type": "qemu"},
53
+ {"name": "SW-Store-3", "node_type": "qemu"},
54
+ ]
55
+ summary = self.pipeline._count_branch_sites(nodes, expected_site_count=4)
56
+ self.assertEqual(summary["count"], 3)
57
+ self.assertEqual(summary["missing"], ["☕-Store-4"])
58
+
59
+
60
+ if __name__ == "__main__":
61
+ unittest.main()