Closes items 4-7 of cucx-docs's prompt-suggestions roadmap (see axl/agent-threads/cucx-prompt-suggestions/ for the source thread). did_block_overlap(block_pattern) — new prompt. LLM-orchestrated audit that finds carveout patterns inside a DID block and surfaces silent routing exceptions (e.g., 9498/9499 carved out of the 20878594XX block to route to a different fax server). Composes the existing route_patterns(filter=) tool with post-processing rather than introducing a new tool — cucx-docs's #3 was originally pitched as a tool, but the audit-narrative output is more naturally a prompt. partition_summary(partition_name=None) — new prompt. "What is this partition for?" orientation report composing route_partitions, route_patterns, route_calling_search_spaces, and the new route_patterns_targeting. No new SQL — this is pure orchestration. Useful when walking into an unfamiliar cluster and seeing a partition name like RTC-MGW-Inbound and needing to figure out its role before touching anything. cucm_sql_help — deepened with five schema-landmark sections that cost real audit sessions 3-5 query attempts each to discover. Topics: numplan↔device M:N via devicenumplanmap; non-existence of sipdestination as a table; routelist (singular) ≠ numplan→RL; LEFT-JOIN convention for type-decoder enum tables; CDR/CMR timestamp localization (cluster-TZ-conditional). Also updated the docs-search reference from "cisco-docs MCP" to "mcdewey MCP" to match yesterday's rename. cucm-schema-cheatsheet docs — appended a "Schema gotchas (from real audit sessions)" section mirroring the cucm_sql_help content. Two locations because they serve different consumers: the prompt is read by an LLM at query time, the docs page is read by a human reviewing the cluster offline. Tests: registration sentinel updated to include the two new prompts (catches the case where a new module is added without a server.py shim — the prompt would otherwise be invisible to the LLM). Full suite still 238 passing. Q3 verification (CDR timestamp empirical) still pending — cluster TLS intermittent this session. The schema-landmark text is conditional on cluster TZ per cucx-docs's caveat, so even an unverified ship is defensible.
294 lines
10 KiB
Python
294 lines
10 KiB
Python
"""Tests for the extracted prompts package.
|
|
|
|
The render() functions are pure (they take docs as a parameter, no module
|
|
globals), so they're trivially unit-testable. We verify each one renders
|
|
without raising both with and without a docs index.
|
|
"""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from mcaxl import prompts
|
|
from mcaxl.docs_loader import DocsIndex
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_docs(tmp_path: Path) -> DocsIndex:
|
|
chunks = [
|
|
{
|
|
"id": "cucm::v15::admin::Route-Plan::0",
|
|
"text": "Route plan defines call routing.",
|
|
"heading_path": ["Route Plan"],
|
|
"source_path": str(tmp_path / "fake.md"),
|
|
"product": "cucm",
|
|
"version": "v15",
|
|
"doc": "system-config-guide",
|
|
},
|
|
{
|
|
"id": "cucm::v15::admin::SIP-Trunk::0",
|
|
"text": "SIP trunks connect CUCM to other call control entities.",
|
|
"heading_path": ["SIP Trunk Configuration"],
|
|
"source_path": str(tmp_path / "fake.md"),
|
|
"product": "cucm",
|
|
"version": "v15",
|
|
"doc": "system-config-guide",
|
|
},
|
|
]
|
|
(tmp_path / "chunks.jsonl").write_text(
|
|
"\n".join(json.dumps(c) for c in chunks)
|
|
)
|
|
(tmp_path / "index_meta.json").write_text(
|
|
json.dumps({"model_name": "test", "embedding_dim": 384, "products": ["cucm"]})
|
|
)
|
|
idx = DocsIndex.load(tmp_path)
|
|
assert idx is not None
|
|
return idx
|
|
|
|
|
|
# ---- route_plan_overview ----------------------------------------------------
|
|
|
|
def test_route_plan_overview_renders_with_docs(fake_docs):
|
|
text = prompts.route_plan_overview.render(fake_docs)
|
|
assert "Route Plan Overview" in text
|
|
assert "axl_version" in text
|
|
assert "route_partitions" in text
|
|
|
|
|
|
def test_route_plan_overview_renders_without_docs():
|
|
# Graceful degradation — no docs index should still produce a usable prompt
|
|
text = prompts.route_plan_overview.render(None)
|
|
assert "Route Plan Overview" in text
|
|
assert "cisco-docs index is not loaded" in text
|
|
|
|
|
|
# ---- investigate_pattern ---------------------------------------------------
|
|
|
|
def test_investigate_pattern_includes_pattern_in_output(fake_docs):
|
|
text = prompts.investigate_pattern.render(fake_docs, "10.911", "CER911-PT")
|
|
assert "10.911" in text
|
|
assert "CER911-PT" in text
|
|
|
|
|
|
def test_investigate_pattern_no_partition(fake_docs):
|
|
text = prompts.investigate_pattern.render(fake_docs, "9.@")
|
|
assert "9.@" in text
|
|
# The partition clause should not appear when no partition is given
|
|
assert "in partition" not in text
|
|
|
|
|
|
# ---- audit_routing ----------------------------------------------------------
|
|
|
|
def test_audit_routing_default_focus(fake_docs):
|
|
text = prompts.audit_routing.render(fake_docs)
|
|
assert "focus: `full`" in text
|
|
|
|
|
|
def test_audit_routing_custom_focus(fake_docs):
|
|
text = prompts.audit_routing.render(fake_docs, "translations")
|
|
assert "focus: `translations`" in text
|
|
|
|
|
|
# ---- cucm_sql_help ----------------------------------------------------------
|
|
|
|
def test_cucm_sql_help_includes_question(fake_docs):
|
|
q = "How do I find phones with no associated user?"
|
|
text = prompts.cucm_sql_help.render(fake_docs, q)
|
|
assert q in text
|
|
assert "axl_describe_table" in text
|
|
|
|
|
|
def test_cucm_sql_help_handles_empty_question(fake_docs):
|
|
# No substantive keywords — must still render something useful
|
|
text = prompts.cucm_sql_help.render(fake_docs, "x y z")
|
|
assert "axl_list_tables" in text
|
|
|
|
|
|
# ---- sip_trunk_report (NEW) ------------------------------------------------
|
|
|
|
def test_sip_trunk_report_includes_query_1(fake_docs):
|
|
text = prompts.sip_trunk_report.render(fake_docs)
|
|
# Query 1 essentials: device JOIN sipdevice, the trunk_name column,
|
|
# and the WHERE filter on Trunk class
|
|
assert "FROM device d" in text
|
|
assert "JOIN sipdevice sd" in text
|
|
assert "tc.name = 'Trunk'" in text
|
|
|
|
|
|
def test_sip_trunk_report_includes_query_2_and_tools(fake_docs):
|
|
text = prompts.sip_trunk_report.render(fake_docs)
|
|
assert "siptrunkdestination" in text
|
|
# And references the existing MCP tool for route group traversal
|
|
assert "route_lists_and_groups()" in text
|
|
|
|
|
|
def test_sip_trunk_report_with_name_filter_injects_safe_like(fake_docs):
|
|
text = prompts.sip_trunk_report.render(fake_docs, "PSTN")
|
|
# The filter should appear as a LIKE clause and the scope note should
|
|
# reflect the narrowing
|
|
assert "%PSTN%" in text
|
|
assert "narrowed to trunks" in text
|
|
|
|
|
|
def test_sip_trunk_report_name_filter_escaped(fake_docs):
|
|
# SQL injection defense: a single-quote in the name_filter must be
|
|
# doubled before going into the LIKE pattern
|
|
text = prompts.sip_trunk_report.render(fake_docs, "O'Reilly")
|
|
assert "O''Reilly" in text
|
|
|
|
|
|
def test_sip_trunk_report_renders_without_docs():
|
|
text = prompts.sip_trunk_report.render(None)
|
|
assert "SIP Trunk Report" in text
|
|
assert "cisco-docs index is not loaded" in text
|
|
|
|
|
|
# ---- registration smoke test -----------------------------------------------
|
|
|
|
def test_all_prompts_registered_in_server():
|
|
"""Confirm each prompt module's render() is wired through a FastMCP shim
|
|
in server.py. Catches the case where a new module is added but the
|
|
shim wasn't (the prompt would be invisible to the LLM)."""
|
|
import asyncio
|
|
from mcaxl import server
|
|
|
|
async def _list():
|
|
registered = await server.mcp.list_prompts()
|
|
return {p.name for p in registered}
|
|
|
|
names = asyncio.run(_list())
|
|
assert names == {
|
|
"route_plan_overview",
|
|
"investigate_pattern",
|
|
"audit_routing",
|
|
"cucm_sql_help",
|
|
"sip_trunk_report",
|
|
"phone_inventory_report",
|
|
"user_audit",
|
|
"inbound_did_audit",
|
|
"hunt_pilot_audit",
|
|
"whoami",
|
|
"did_block_overlap",
|
|
"partition_summary",
|
|
}, f"unexpected prompt set: {names}"
|
|
|
|
|
|
# ---- new prompts: render-with-and-without-docs smoke tests -----------------
|
|
|
|
def test_phone_inventory_report_renders(fake_docs):
|
|
text = prompts.phone_inventory_report.render(fake_docs)
|
|
assert "Phone Inventory" in text
|
|
# Embedded SQL should query the `device` table with tkclass='Phone'
|
|
assert "tc.name = 'Phone'" in text
|
|
assert "all phones" in text
|
|
|
|
|
|
def test_phone_inventory_report_with_filter(fake_docs):
|
|
text = prompts.phone_inventory_report.render(fake_docs, "Lobby")
|
|
assert "%Lobby%" in text
|
|
assert "narrowed" in text
|
|
|
|
|
|
def test_phone_inventory_report_filter_escaped(fake_docs):
|
|
text = prompts.phone_inventory_report.render(fake_docs, "O'Hara")
|
|
assert "O''Hara" in text # SQL injection defense
|
|
|
|
|
|
def test_user_audit_default_focus(fake_docs):
|
|
text = prompts.user_audit.render(fake_docs)
|
|
assert "focus: `full`" in text
|
|
assert "applicationuser" in text
|
|
assert "enduser" in text
|
|
|
|
|
|
def test_user_audit_admin_focus(fake_docs):
|
|
text = prompts.user_audit.render(fake_docs, "admin")
|
|
assert "focus: `admin`" in text
|
|
|
|
|
|
def test_user_audit_unknown_focus_falls_back_to_full(fake_docs):
|
|
text = prompts.user_audit.render(fake_docs, "bogus_value")
|
|
assert "focus: `full`" in text
|
|
|
|
|
|
def test_inbound_did_audit_renders(fake_docs):
|
|
text = prompts.inbound_did_audit.render(fake_docs)
|
|
assert "Inbound DID Audit" in text
|
|
assert "XFORM-Inbound-DNIS" in text
|
|
assert "PSTN-Screen-PT" in text
|
|
|
|
|
|
def test_hunt_pilot_audit_uses_correct_column(fake_docs):
|
|
"""Hamilton bonus finding: huntpilotqueue joins via fknumplan_pilot,
|
|
NOT fknumplan. The prompt's embedded SQL must use the correct column
|
|
or every audit run will silently fail one category."""
|
|
text = prompts.hunt_pilot_audit.render(fake_docs)
|
|
assert "fknumplan_pilot" in text, (
|
|
"hunt_pilot_audit must use the verified column name `fknumplan_pilot`; "
|
|
"`fknumplan` was the silent-failure version we fixed in Hamilton review"
|
|
)
|
|
assert "tkpatternusage = 7" in text # Hunt Pilot type code
|
|
|
|
|
|
def test_all_new_prompts_render_without_docs():
|
|
"""Graceful degradation: each prompt produces usable output even when
|
|
the docs index isn't loaded."""
|
|
for name, fn, args in [
|
|
("phone_inventory_report", prompts.phone_inventory_report.render, ()),
|
|
("user_audit", prompts.user_audit.render, ()),
|
|
("inbound_did_audit", prompts.inbound_did_audit.render, ()),
|
|
("hunt_pilot_audit", prompts.hunt_pilot_audit.render, ()),
|
|
("whoami", prompts.whoami.render, ()),
|
|
]:
|
|
text = fn(None, *args)
|
|
assert "cisco-docs index is not loaded" in text, (
|
|
f"{name} failed graceful degradation"
|
|
)
|
|
|
|
|
|
# ---- whoami specifics ------------------------------------------------------
|
|
|
|
def test_whoami_uses_correct_table_names_for_cucm_15(fake_docs):
|
|
"""The query principle came in with `enduserauthgroupmap` and
|
|
`dirgrouprolemap`, but those tables don't exist on CUCM 15. The
|
|
prompt MUST embed the verified names: `enduserdirgroupmap` and
|
|
`functionroledirgroupmap`. If a future contributor reverts to the
|
|
older names, this test fires red."""
|
|
text = prompts.whoami.render(fake_docs, "TestUser")
|
|
assert "enduserdirgroupmap" in text
|
|
assert "functionroledirgroupmap" in text
|
|
# And the older deprecated names should NOT appear in the SQL we ship
|
|
# (they're fine in the prose section that explains why the names changed)
|
|
sql_section = text.split("## Schema knowledge")[0] + text.split("## Step")[1]
|
|
for old_name in ("enduserauthgroupmap", "dirgrouprolemap"):
|
|
assert old_name not in sql_section, (
|
|
f"deprecated table name {old_name} appeared in the SQL section"
|
|
)
|
|
|
|
|
|
def test_whoami_explicit_userid_in_query(fake_docs):
|
|
text = prompts.whoami.render(fake_docs, "SomeAccount")
|
|
assert "SomeAccount" in text
|
|
assert "explicit userid" in text
|
|
|
|
|
|
def test_whoami_default_uses_axl_user_env(fake_docs, monkeypatch):
|
|
monkeypatch.setenv("AXL_USER", "EnvAccount")
|
|
text = prompts.whoami.render(fake_docs)
|
|
assert "EnvAccount" in text
|
|
assert "AXL service account" in text
|
|
|
|
|
|
def test_whoami_no_userid_no_env(fake_docs, monkeypatch):
|
|
monkeypatch.delenv("AXL_USER", raising=False)
|
|
text = prompts.whoami.render(fake_docs)
|
|
assert "no userid supplied" in text
|
|
# Tells the LLM what to do — set the param or the env var
|
|
assert "set userid parameter" in text or "AXL_USER env var" in text
|
|
|
|
|
|
def test_whoami_userid_escaped_for_sql_safety(fake_docs):
|
|
text = prompts.whoami.render(fake_docs, "O'Brien")
|
|
assert "O''Brien" in text # single quote doubled per Informix convention
|