prompts + docs: did_block_overlap, partition_summary, schema landmarks

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.
This commit is contained in:
Ryan Malloy 2026-05-05 17:52:44 -06:00
parent 9427e3d4df
commit a07f8c7291
7 changed files with 464 additions and 2 deletions

View File

@ -187,8 +187,92 @@ Examples: `sipdevice.isanonymous`, `sipdevice.acceptinboundrdnis`,
bucket have `fkroutepartition = NULL`. The default-CSS rules apply bucket have `fkroutepartition = NULL`. The default-CSS rules apply
to them. to them.
## Schema gotchas (from real audit sessions)
Each landmark below cost a multi-attempt schema-discovery walk in
actual CUCM 15 audits. They're listed here so the next operator skips
that walk. `axl_sql` also appends a corrective hint at the error layer
for the first two — the docs version is for when you're composing a
query *before* it errors.
### `numplan``device` is M:N, not a direct foreign key
`numplan` does **not** have an `fkdevice` column. The link goes through
`devicenumplanmap`:
```sql
JOIN devicenumplanmap m ON m.fknumplan = numplan.pkid
JOIN device d ON m.fkdevice = d.pkid
```
A natural-sounding `WHERE numplan.fkdevice = ?` errors with
`Column (fkdevice) not found`. `axl_sql` appends a hint pointing at
the join table; this docs entry is the same fact in the pre-write
phase. The `route_patterns_targeting(device_name=...)` tool wraps
this join shape if you just want the inverse query.
### `sipdestination` is not a table
Reasonable-sounding name; doesn't exist as an Informix table. SIP
trunk destinations live on `device` joined with `sipdestinationgroup`
and `sipprofile`. Run:
```sql
-- See actual SIP-related tables on this cluster:
axl_list_tables(pattern='sip%')
```
`axl_sql` appends a hint when a query references `sipdestination`
and hits a "table not in database" error.
### `routelist` (singular) is the RL→RG join, not numplan→RL
The naming is confusable. `routelist` holds the
`(fkdevice, fkroutegroup, selectionorder)` rows that compose a Route
List from its Route Groups. It is **not** the link from a route
pattern (`numplan`) to a route list — that link is
`numplan.fkdestination`, which can point at a Route List's device
pkid, an external phone, etc.
### Always `LEFT JOIN` the enum-decoder type tables
`typeclass`, `typemodel`, `typepatternusage`, `typecountry`,
`typecallingsearchspaceuse`, etc. hold integer enum → name mappings.
**Always `LEFT JOIN`, not `INNER JOIN`** — if Cisco adds a new enum
value in a future release, an inner join silently drops rows for
that new value, while a left join returns the row with NULL in the
enum-name column. NULL is something an auditor notices and
investigates; a missing row is a silent gap.
### CDR/CMR timestamps are local-time-as-UTC-seconds (cluster-TZ-conditional)
When the cluster's TZ is anything other than UTC, the CDR Repository
writes wall-clock-as-epoch into `dateTimeOrigination`,
`dateTimeConnect`, `dateTimeDisconnect`, etc. Decoding via
`TO_TIMESTAMP()` returns the correct *value* (e.g. `06:53:31`) but
mislabels it as UTC. **Don't double-convert with `AT TIME ZONE`**
the timestamps are already in local time.
This is conditional on the cluster's TZ setting. A UTC-configured
cluster emits genuine UTC. Verify before assuming:
```sql
SELECT FIRST 5 ds.name, tz.name AS timezone
FROM datetimesetting ds
LEFT JOIN typetimezone tz ON tz.enum = ds.tktimezone
```
If the result shows anything non-UTC (e.g. `America/Denver`), CDR
timestamps from that cluster are local-as-epoch and tooling that
treats them as UTC will display correct values with a wrong label —
or, worse, double-convert and produce wrong values.
[`mcsiphon`](https://pypi.org/project/mcsiphon/) (operational
diagnostics MCP server) suffixes its CDR timestamp fields with
`_local` to make the convention visible to downstream tooling.
## See also ## See also
- [Tools reference](/reference/tools/) — the helpers that abstract over the gotchas above - [Tools reference](/reference/tools/) — the helpers that abstract over the gotchas above
- [SIP trunk report](/how-to/sip-trunk-report/) — worked example with all the joins - [SIP trunk report](/how-to/sip-trunk-report/) — worked example with all the joins
- Cisco's official CUCM data dictionary — search via `cisco-docs` MCP for "data dictionary" - Cisco's official CUCM data dictionary — search via [`mcdewey`](https://pypi.org/project/mcdewey/) MCP for "data dictionary"

View File

@ -20,9 +20,11 @@ shim is where the parameter contract lives.
from . import ( from . import (
audit_routing, audit_routing,
cucm_sql_help, cucm_sql_help,
did_block_overlap,
hunt_pilot_audit, hunt_pilot_audit,
inbound_did_audit, inbound_did_audit,
investigate_pattern, investigate_pattern,
partition_summary,
phone_inventory_report, phone_inventory_report,
route_plan_overview, route_plan_overview,
sip_trunk_report, sip_trunk_report,
@ -33,9 +35,11 @@ from . import (
__all__ = [ __all__ = [
"audit_routing", "audit_routing",
"cucm_sql_help", "cucm_sql_help",
"did_block_overlap",
"hunt_pilot_audit", "hunt_pilot_audit",
"inbound_did_audit", "inbound_did_audit",
"investigate_pattern", "investigate_pattern",
"partition_summary",
"phone_inventory_report", "phone_inventory_report",
"route_plan_overview", "route_plan_overview",
"sip_trunk_report", "sip_trunk_report",

View File

@ -38,12 +38,74 @@ The user asks: **{question}**
2. Run `axl_describe_table(<table_name>)` for the candidate tables to see 2. Run `axl_describe_table(<table_name>)` for the candidate tables to see
exact column names and types. exact column names and types.
3. If the schema chunks below already answer the question, draft the SQL 3. If the schema chunks below already answer the question, draft the SQL
directly. If not, also invoke the `cisco-docs` MCP server's `search_docs` directly. If not, also invoke the `mcdewey` MCP server's `search_docs`
tool with a relevant query (e.g., search_docs("data dictionary table for X")). tool with a relevant query (e.g., search_docs("data dictionary table for X")).
4. Compose the SELECT, run it via `axl_sql(query=...)`. 4. Compose the SELECT, run it via `axl_sql(query=...)`.
5. Summarize the result for the user counts, anomalies, and what you'd 5. Summarize the result for the user counts, anomalies, and what you'd
recommend doing about them. recommend doing about them.
## Schema landmarks worth knowing before you start
A handful of CUCM Informix schema details that cost real audit sessions
3-5 query attempts each before the right path was found. If your
question touches any of these areas, jump straight to the right shape
instead of rediscovering it.
### `numplan` ↔ `device` is **M:N**, not a direct foreign key
`numplan` does **not** have an `fkdevice` column. The link goes through
`devicenumplanmap`:
```sql
JOIN devicenumplanmap m ON m.fknumplan = numplan.pkid
JOIN device d ON m.fkdevice = d.pkid
```
A natural-sounding `WHERE numplan.fkdevice = ?` will fail with
"Column (fkdevice) not found" and `axl_sql` now appends a corrective
hint to that error, but you'll save the round-trip if you start with
the join above.
### `routelist` (singular) is the RL→RG join, not numplan→RL
The naming is confusable. `routelist` holds the `(fkdevice, fkroutegroup,
selectionorder)` rows that compose a Route List from its Route Groups.
It is **not** the link from a route pattern (`numplan`) to a route list.
That link is `numplan.fkdestination` (which can point at a Route List's
device pkid, an external phone, etc.).
### `sipdestination` does not exist
Reasonable-sounding name, surfaces in some Cisco docs, but it isn't an
Informix table. SIP trunk destinations live on `device` plus the
`sipdestinationgroup` and `sipprofile` join tables. Run
`axl_list_tables(pattern='sip%')` to see the actual tables, and
`axl_sql` will append a corrective hint if you try to FROM it.
### Always `LEFT JOIN` the type-decoder enum tables
`typeclass`, `typemodel`, `typepatternusage`, `typecountry`, `typecallingsearchspaceuse`, etc.
hold integer enum name mappings. **Always use `LEFT JOIN`, not `INNER
JOIN`** if Cisco adds a new enum value in a future release, an inner
join silently drops the row, while a left join still returns the row
with NULL in the enum-name column. The latter is something an auditor
sees and acts on; the former is a silent gap.
### CDR/CMR timestamps are stored as local-time-as-UTC-seconds (not actual UTC)
When the cluster's TZ is set to anything other than UTC, the CDR
Repository service writes wall-clock-as-epoch into `dateTimeOrigination`,
`dateTimeConnect`, `dateTimeDisconnect`, etc. So decoding via
`TO_TIMESTAMP()` returns the correct *value* (e.g. `06:53:31`) but
mislabels it as UTC. Don't double-convert with `AT TIME ZONE` — the
timestamps are already in local time.
A UTC-configured cluster (rare in production) emits genuine UTC
timestamps; this is conditional on the cluster's TZ setting, not a
universal truth. If your question involves CDR analysis or time windows,
verify the cluster TZ first via `axl_describe_table('datetimesetting')`
+ a query, or treat all CDR times as local with `_local` suffixes.
## Possibly relevant schema chunks ## Possibly relevant schema chunks
{schema_block} {schema_block}

View File

@ -0,0 +1,136 @@
"""DID block overlap audit — find carveout patterns inside larger blocks.
Surfaces the kind of finding that's easy to miss visually but material
for any DID-block audit: a more-specific pattern carved out of a larger
block (e.g., `9498` and `9499` carved out of the RightFax `20878594XX`
100-DID block, routing those 2 DIDs to ZetaFax instead).
Per CUCM's longest-match rule, the more-specific patterns win. Knowing
where carveouts exist tells the operator about routing exceptions that
documentation often omits.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from ._common import render_schema_block
if TYPE_CHECKING:
from ..docs_loader import DocsIndex
_KEYWORDS = [
"route pattern", "longest match", "translation pattern",
"wildcard", "DID block", "called party",
]
def render(docs: "DocsIndex | None", block_pattern: str) -> str:
"""Audit a DID block for carveout patterns that route differently.
Args:
block_pattern: the nominal block, e.g. `"20878594XX"`,
`"+1208524XXXX"`. Wildcard syntax is CUCM's (X for any digit,
[a-b] for ranges, sets, etc.).
"""
schema_block = render_schema_block(
docs, _KEYWORDS, max_chunks=3, max_chars_per_chunk=800
)
return f"""# DID Block Overlap Audit — `{block_pattern}`
Find every numplan entry that matches *anywhere* within the nominal
block `{block_pattern}`, grouped by destination device, with carveouts
flagged. Per CUCM's longest-match rule, more-specific patterns win —
which means a carveout silently overrides the block for those digits.
## Step 1 — Discover all numplan entries that fall in or near the block
```sql
SELECT
np.dnorpattern AS pattern,
rp.name AS partition,
np.description,
tpu.name AS pattern_type,
d.name AS destination_device,
tc.name AS destination_class
FROM numplan np
LEFT OUTER JOIN routepartition rp ON np.fkroutepartition = rp.pkid
LEFT OUTER JOIN typepatternusage tpu ON np.tkpatternusage = tpu.enum
LEFT OUTER JOIN devicenumplanmap m ON m.fknumplan = np.pkid
LEFT OUTER JOIN device d ON d.pkid = m.fkdevice
LEFT OUTER JOIN typeclass tc ON tc.enum = d.tkclass
WHERE np.dnorpattern LIKE '<prefix>%'
ORDER BY np.dnorpattern;
```
Replace `<prefix>` with the literal-prefix portion of `{block_pattern}`
(everything up to the first wildcard character). Example: for
`20878594XX`, the prefix is `20878594` so `LIKE '20878594%'` catches
both the block and any carveouts.
## Step 2 — Categorize each match against the block
Walk the result rows and assign each pattern to one of:
- **Block itself**: pattern equals `{block_pattern}`. The "default"
routing for the block.
- **Carveout** (more-specific): pattern is fully inside the block but
contains fewer wildcards. Example: block `20878594XX`, carveout
`2087859498` (no wildcards) or `208785949[8-9]` (range narrower than
the block's `XX`).
- **Sibling block**: pattern shares the prefix but doesn't fall fully
inside the named block. Example: `2087859410` when the block is
`20878594XX` wait, that IS inside; counter-example: `208785930X`
when the block is `20878594XX` shares `2087859` but not `20878594`.
- **Adjacent / unrelated**: shorter pattern, longer pattern, or one
whose prefix doesn't actually fall inside the block.
## Step 3 — Group by destination device
For each pattern in the block-itself + carveout categories, list its
destination device. The audit-grade output is:
```
Block: 20878594XX RightFax-Trunk (100 DIDs nominally)
Carveouts that route differently:
2087859498 ZetaFax-Trunk [single DID exception]
2087859499 ZetaFax-Trunk [single DID exception]
Effective routing:
98 DIDs RightFax-Trunk
2 DIDs ZetaFax-Trunk
```
## Findings to surface
- **Undocumented carveouts**: any carveout whose `description` doesn't
explain why it diverges from the block. High-severity if the
destination differs (silent routing exception); low if same-destination
(probably accidental duplication).
- **Same-destination carveouts**: patterns that route to the same device
as the block. Often harmless duplication that adds noise to the route
plan but no operational impact. Consolidation candidates.
- **Mixed-partition matches**: same pattern in multiple partitions can
produce different routing depending on the calling CSS. Flag if found
inside the audited block.
- **Disabled carveouts** (`np.blockenable = 't'`): a carveout that's
explicitly blocked is unusual likely a temporary measure that wasn't
removed. Worth surfacing.
## Suggested follow-up calls
- `route_patterns_targeting('<destination>')` for each unique destination
device surfaced confirms what *else* targets the same device beyond
this block.
- `route_inspect_pattern('<carveout pattern>')` to see the full route
trace for any carveout flagged as suspicious.
## Reference: longest-match semantics
""" + schema_block + """
Produce a structured report grouped by destination device, with the
"effective routing" summary at the top. Don't enumerate every digit —
summarize counts ("98 DIDs → A, 2 DIDs → B") and list only the carveout
patterns explicitly. Flag anything that looks like a silent override."""

View File

@ -0,0 +1,144 @@
"""Partition orientation report — what is this partition actually for?
Composes existing tools (route_partitions, route_patterns) into a
"what does this partition do" narrative pattern count, longest patterns,
distinct destination devices, internal vs external ratio. Useful when
you walk into a cluster and see a partition named `RTC-MGW-Inbound` or
similar and need to figure out its role before touching anything.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from ._common import render_schema_block
if TYPE_CHECKING:
from ..docs_loader import DocsIndex
_KEYWORDS = [
"route partition", "calling search space", "called party",
"route pattern", "translation pattern", "directory number",
]
def render(docs: "DocsIndex | None", partition_name: str | None = None) -> str:
"""Compose a partition's role profile from existing routing data.
Args:
partition_name: the partition to profile. If None, the prompt
instructs the LLM to first list all partitions and pick the
most-populous to demonstrate the shape.
"""
schema_block = render_schema_block(
docs, _KEYWORDS, max_chunks=3, max_chars_per_chunk=700
)
target = partition_name or "<largest partition>"
discovery_step = (
""
if partition_name
else """## Step 0 — Pick a partition (no name supplied)
Run `route_partitions()` and pick the most-populous partition (highest
`pattern_count`) to demonstrate the profile shape. In production use,
the partition name should be supplied explicitly.
"""
)
return f"""# Partition Orientation: `{target}`
Compose a "what is this partition for?" report from the routing data
already exposed by `route_partitions`, `route_patterns`, and
`route_calling_search_spaces`. No new SQL needed this is an
LLM-orchestrated narrative across existing tools.
{discovery_step}## Step 1 — High-level profile via `route_partitions`
Call `route_partitions()` and find the row for `{target}`. Note:
- `pattern_count` total numplan entries in the partition
- `css_count` how many Calling Search Spaces include this partition
(signal of "blast radius" if you change the partition's contents)
If `css_count` is 0, the partition is **orphaned** no calling device
can reach its patterns. High-severity audit finding (or vestigial cleanup
candidate). Surface it explicitly.
## Step 2 — Pattern inventory via `route_patterns(partition='{target}')`
Run `route_patterns(partition='{target}', limit=500)`. Categorize the
result by `kind`:
- **Directory numbers** (`tkpatternusage = 2`): user-assigned DNs.
Counted; if zero, partition holds no extensions.
- **Translation patterns** (`tkpatternusage = 3`): rewrite rules. List
the 5 longest patterns + 5 shortest.
- **Route patterns** (`tkpatternusage = 5`): outbound routing. Top
destinations by pattern count.
- **Hunt pilots** / **CTI route points** / etc.: count each.
Compute:
- **Internal vs external ratio**: count of patterns that look like 4-5
digit extensions vs 7+ digit external numbers.
- **Wildcard density**: count of patterns containing `X`, `!`, `@`,
`[]` vs literal-only patterns.
- **Longest pattern**: indicates the partition's "deepest" rule.
## Step 3 — CSS membership via `route_calling_search_spaces`
Call `route_calling_search_spaces()` (no name filter) and find every
CSS whose ordered partition list contains `{target}`. For each:
- Position in the CSS (sortorder) `0` means first-priority lookup
- Other partitions in the CSS context for what the partition gets
paired with
A partition that's always at position 0 of its CSSes behaves as the
primary lookup; a partition always at the bottom is a fallback or
catch-all.
## Step 4 — Destination devices via `route_patterns_targeting`
For the route-pattern subset (Step 2), if there are 10 distinct
destination devices, call `route_patterns_targeting(<each device>)`
to see what *else* targets each. This catches the case where two
partitions both route to the same trunk relevant for failover
analysis.
## Step 5 — Synthesize the role
Produce a 1-paragraph "what this partition is for" summary, then a
structured report:
```
Partition: <name>
Role inference: <e.g., "Internal extension home", "PSTN inbound
transformations", "Fax DID block">
Pattern count: N (M directory numbers, K route patterns, ...)
CSS membership: in N CSSes; primary in K, fallback in M
Internal/external mix: X% internal-shape, Y% external-shape
Wildcard density: N/M patterns use wildcards
Longest pattern: <pattern> (length=N)
Top 3 destinations: <list, if route patterns present>
```
## Findings to call out
- **Orphaned partition** (CSS count = 0): not reachable from any device.
- **Single-DN partition**: holds 1 pattern only. Often vestigial; rarely
intentional.
- **Mixed-purpose**: contains a mix of internal extensions AND external
route patterns. Usually a sign of legacy migration; flag for review.
- **Position-inconsistent**: partition appears at sortorder 0 in some
CSSes and sortorder >5 in others. May indicate intentional priority
inversion or accidental misconfiguration surface for operator review.
## Reference: partition + CSS semantics
""" + schema_block + """
Produce the structured report and the role-inference paragraph.
Don't dump every pattern — categorize, count, and call out exceptions."""

View File

@ -546,6 +546,36 @@ def hunt_pilot_audit() -> str:
return _prompts.hunt_pilot_audit.render(_docs) return _prompts.hunt_pilot_audit.render(_docs)
@mcp.prompt
def did_block_overlap(block_pattern: str) -> str:
"""Find carveout patterns inside a DID block — more-specific patterns
that win CUCM's longest-match rule and route differently than the
nominal block. Surfaces silent routing exceptions like "9498 and 9499
carved out of the 20878594XX block to route to a different fax server".
Args:
block_pattern: the nominal block in CUCM pattern syntax, e.g.
``20878594XX`` or ``+1208524XXXX``.
"""
return _prompts.did_block_overlap.render(_docs, block_pattern)
@mcp.prompt
def partition_summary(partition_name: str | None = None) -> str:
"""Compose a "what is this partition for?" report from existing
routing data pattern count, longest patterns, distinct destination
devices, internal vs external ratio, CSS membership. Useful for
walking into a cluster and orienting on an unfamiliar partition
before touching anything.
Args:
partition_name: partition to profile. If omitted, the prompt
instructs the LLM to first list partitions and pick the
most-populous one to demonstrate the shape.
"""
return _prompts.partition_summary.render(_docs, partition_name)
@mcp.prompt @mcp.prompt
def whoami(userid: str | None = None) -> str: def whoami(userid: str | None = None) -> str:
"""Look up the role chain for a single user (defaults to the AXL """Look up the role chain for a single user (defaults to the AXL

View File

@ -169,6 +169,8 @@ def test_all_prompts_registered_in_server():
"inbound_did_audit", "inbound_did_audit",
"hunt_pilot_audit", "hunt_pilot_audit",
"whoami", "whoami",
"did_block_overlap",
"partition_summary",
}, f"unexpected prompt set: {names}" }, f"unexpected prompt set: {names}"