|
| 1 | +# Display Overlay |
| 2 | + |
| 3 | +The `DisplayResolver` applies a sparse `binding.yaml` overlay to a list of `ScannedModule` objects, resolving surface-facing presentation fields — alias, description, guidance, tags, and documentation — into `metadata["display"]` for downstream CLI, MCP, and A2A consumers. |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +Scanners produce raw `ScannedModule` metadata from code. The display overlay layer sits between scanning and surface emission: it reads a `binding.yaml` (or a directory of `*.binding.yaml` files) and merges human-curated presentation overrides on top of the scanned values without modifying the underlying schema or target. |
| 8 | + |
| 9 | +This approach is intentionally **sparse**: a binding file only needs to contain the fields you want to override. Unset fields fall through to sensible defaults. |
| 10 | + |
| 11 | +## `DisplayResolver` |
| 12 | + |
| 13 | +**Class**: `apcore_toolkit.display.DisplayResolver` |
| 14 | +**Module**: `apcore_toolkit/display/resolver.py` |
| 15 | + |
| 16 | +### Method Signature |
| 17 | + |
| 18 | +```python |
| 19 | +from apcore_toolkit.display import DisplayResolver |
| 20 | + |
| 21 | +resolver = DisplayResolver() |
| 22 | +resolved_modules = resolver.resolve( |
| 23 | + modules, |
| 24 | + *, |
| 25 | + binding_path=None, # str | Path | None |
| 26 | + binding_data=None, # dict | None |
| 27 | +) |
| 28 | +``` |
| 29 | + |
| 30 | +**Parameters** |
| 31 | + |
| 32 | +| Parameter | Type | Description | |
| 33 | +|-----------|------|-------------| |
| 34 | +| `modules` | `list[ScannedModule]` | Modules to resolve. Returned list has the same length and order. | |
| 35 | +| `binding_path` | `str \| Path \| None` | Path to a single `.binding.yaml` file **or** a directory containing `*.binding.yaml` files. Ignored when `binding_data` is provided. | |
| 36 | +| `binding_data` | `dict \| None` | Pre-parsed binding dict. Takes precedence over `binding_path` when both are supplied. | |
| 37 | + |
| 38 | +**Returns**: `list[ScannedModule]` — the same modules with `metadata["display"]` populated. |
| 39 | + |
| 40 | +### Resolution Chain |
| 41 | + |
| 42 | +For each presentation field (`alias`, `description`, `guidance`, `tags`, `documentation`), the resolver walks the following chain and uses the **first non-absent value**: |
| 43 | + |
| 44 | +1. **Surface-specific override** — e.g., `modules.<id>.cli.alias` |
| 45 | +2. **`display` default** — e.g., `modules.<id>.display.alias` |
| 46 | +3. **Binding-level field** — top-level `alias`, `description`, etc. in the binding entry |
| 47 | +4. **Scanner value** — the value already present in the `ScannedModule` (e.g., `description`, `tags`) |
| 48 | + |
| 49 | +If none of the above is set, the field is omitted from `metadata["display"]`. |
| 50 | + |
| 51 | +### `suggested_alias` Fallback |
| 52 | + |
| 53 | +When a scanner runs with `simplify_ids=True`, it may emit `suggested_alias` inside `ScannedModule.metadata`. `DisplayResolver` uses this as an additional fallback for `alias` **before** falling back to `module_id`: |
| 54 | + |
| 55 | +``` |
| 56 | +surface override > display.alias > binding alias > suggested_alias > module_id |
| 57 | +``` |
| 58 | + |
| 59 | +No warning is emitted when `suggested_alias` is used as the alias source. |
| 60 | + |
| 61 | +## Output Structure |
| 62 | + |
| 63 | +After resolution, each module's `metadata["display"]` contains a flat dict with optional per-surface sub-dicts: |
| 64 | + |
| 65 | +```python |
| 66 | +module.metadata["display"] = { |
| 67 | + # Shared fields (resolved from the chain above) |
| 68 | + "alias": "get-user", |
| 69 | + "description": "Retrieve a user by their unique identifier.", |
| 70 | + "documentation": "Returns 404 if the user does not exist.", |
| 71 | + "guidance": "Prefer this over listing all users when the ID is known.", |
| 72 | + "tags": ["users", "read-only"], |
| 73 | + |
| 74 | + # Surface-specific overrides (only present when the binding sets them) |
| 75 | + "cli": { |
| 76 | + "alias": "get-user", |
| 77 | + "description": "Fetch a user record (CLI variant).", |
| 78 | + "guidance": "Pass --id as a positional argument.", |
| 79 | + "tags": ["users"], |
| 80 | + }, |
| 81 | + "mcp": { |
| 82 | + "alias": "get_user", |
| 83 | + "description": "Retrieve a user by ID (MCP tool).", |
| 84 | + "guidance": "Provide the numeric user ID.", |
| 85 | + "tags": ["users", "read-only"], |
| 86 | + "documentation": "See /docs/api/users for the full schema.", |
| 87 | + }, |
| 88 | + "a2a": { |
| 89 | + "alias": "get-user", |
| 90 | + "description": "Agent-to-agent: retrieve a user record.", |
| 91 | + "tags": ["users"], |
| 92 | + }, |
| 93 | +} |
| 94 | +``` |
| 95 | + |
| 96 | +The three surfaces are `cli`, `mcp`, and `a2a`. A surface sub-dict is only added to `metadata["display"]` when at least one surface-specific field is present in the binding. |
| 97 | + |
| 98 | +## Alias Constraints by Surface |
| 99 | + |
| 100 | +### MCP Alias |
| 101 | + |
| 102 | +MCP tool names must conform to `[a-zA-Z0-9_-]+` with a maximum of 64 characters. |
| 103 | + |
| 104 | +`DisplayResolver` applies automatic sanitization to the resolved MCP alias: |
| 105 | + |
| 106 | +1. Replace every character outside `[a-zA-Z0-9_-]` with `_`. |
| 107 | +2. If the sanitized result starts with a digit, prepend `_`. |
| 108 | +3. If the final sanitized alias exceeds 64 characters, raise `ValueError`. |
| 109 | + |
| 110 | +```python |
| 111 | +# "users.get user" → "users_get_user" (dot and space replaced) |
| 112 | +# "1get-user" → "_1get-user" (leading digit prefixed) |
| 113 | +# "a" * 65 → ValueError (exceeds 64-char limit) |
| 114 | +``` |
| 115 | + |
| 116 | +### CLI Alias |
| 117 | + |
| 118 | +CLI command names must match `^[a-z][a-z0-9_-]*$`. |
| 119 | + |
| 120 | +`DisplayResolver` does **not** silently sanitize CLI aliases. Instead: |
| 121 | + |
| 122 | +- If a CLI alias was set **explicitly** in the binding and does not match the pattern, a `WARNING` is logged and the resolver falls back to the `display.alias` value (or the next item in the resolution chain). |
| 123 | +- If the alias originates from the scanner value or `suggested_alias` (i.e., was never explicitly set in the binding), it is accepted without warning even if it contains uppercase letters or other non-conforming characters — the downstream CLI adapter is expected to normalise it. |
| 124 | + |
| 125 | +## Match-Count Logging |
| 126 | + |
| 127 | +After resolving all modules against a binding map, `DisplayResolver` logs: |
| 128 | + |
| 129 | +| Level | Condition | Message | |
| 130 | +|-------|-----------|---------| |
| 131 | +| `INFO` | Always (when a binding is loaded) | `"DisplayResolver: matched N of M modules"` | |
| 132 | +| `WARNING` | Binding loaded but zero modules matched | `"DisplayResolver: binding map loaded but no modules matched — check module IDs"` | |
| 133 | + |
| 134 | +No log is emitted when neither `binding_path` nor `binding_data` is provided (pass-through mode). |
| 135 | + |
| 136 | +## Binding File Format |
| 137 | + |
| 138 | +`binding_path` accepts: |
| 139 | + |
| 140 | +- A **single file** with extension `.binding.yaml` (or `.yaml`). |
| 141 | +- A **directory**, in which case all `*.binding.yaml` files are loaded and merged. Files are processed in lexicographic order; later files win on key collision. |
| 142 | + |
| 143 | +`binding_data` takes precedence over `binding_path` when both are supplied. |
| 144 | + |
| 145 | +### Sample `binding.yaml` |
| 146 | + |
| 147 | +The top-level key is the `module_id`. All fields are optional — include only what you want to override. |
| 148 | + |
| 149 | +```yaml |
| 150 | +# users.get_user.binding.yaml |
| 151 | + |
| 152 | +module_id: users.get_user |
| 153 | + |
| 154 | +# Binding-level fields (lowest override priority, above scanner value) |
| 155 | +alias: get-user |
| 156 | +description: Retrieve a user by their unique identifier. |
| 157 | +documentation: Returns 404 if the user does not exist. |
| 158 | +guidance: Prefer this over listing all users when the ID is known. |
| 159 | +tags: |
| 160 | + - users |
| 161 | + - read-only |
| 162 | + |
| 163 | +# display block: shared defaults, override the binding-level fields above |
| 164 | +display: |
| 165 | + alias: get-user |
| 166 | + description: Retrieve a user record. |
| 167 | + guidance: Prefer this over listing all users when the ID is known. |
| 168 | + tags: |
| 169 | + - users |
| 170 | + |
| 171 | +# Surface-specific overrides (highest priority) |
| 172 | +cli: |
| 173 | + alias: get-user |
| 174 | + description: Fetch a user record. |
| 175 | + guidance: Pass --id as a positional argument. |
| 176 | + tags: |
| 177 | + - users |
| 178 | + |
| 179 | +mcp: |
| 180 | + alias: get_user |
| 181 | + description: Retrieve a user by ID. |
| 182 | + guidance: Provide the numeric user ID in the `id` field. |
| 183 | + documentation: See /docs/api/users for the full schema. |
| 184 | + tags: |
| 185 | + - users |
| 186 | + - read-only |
| 187 | + |
| 188 | +a2a: |
| 189 | + alias: get-user |
| 190 | + description: Agent-to-agent user retrieval. |
| 191 | + tags: |
| 192 | + - users |
| 193 | +``` |
| 194 | +
|
| 195 | +## Code Example |
| 196 | +
|
| 197 | +```python |
| 198 | +from apcore_toolkit.display import DisplayResolver |
| 199 | + |
| 200 | +# modules is a list[ScannedModule] from any scanner |
| 201 | +resolver = DisplayResolver() |
| 202 | + |
| 203 | +# Option A: point to a directory of *.binding.yaml files |
| 204 | +resolved = resolver.resolve(modules, binding_path="./bindings") |
| 205 | + |
| 206 | +# Option B: point to a single file |
| 207 | +resolved = resolver.resolve(modules, binding_path="./bindings/users.get_user.binding.yaml") |
| 208 | + |
| 209 | +# Option C: supply pre-parsed data (e.g. from a config system) |
| 210 | +binding_data = { |
| 211 | + "users.get_user": { |
| 212 | + "display": {"alias": "get-user"}, |
| 213 | + "mcp": {"alias": "get_user"}, |
| 214 | + } |
| 215 | +} |
| 216 | +resolved = resolver.resolve(modules, binding_data=binding_data) |
| 217 | + |
| 218 | +# Inspect the result |
| 219 | +for m in resolved: |
| 220 | + display = m.metadata.get("display", {}) |
| 221 | + print(m.module_id, "→", display.get("alias"), display.get("mcp", {}).get("alias")) |
| 222 | +``` |
| 223 | +
|
| 224 | +## Integration with `simplify_ids` |
| 225 | + |
| 226 | +When the scanner is configured with `simplify_ids=True`, each `ScannedModule.metadata` may contain a `suggested_alias` key. `DisplayResolver` picks this up automatically as the alias fallback: |
| 227 | + |
| 228 | +```python |
| 229 | +from fastapi_apcore import get_scanner |
| 230 | +from apcore_toolkit.display import DisplayResolver |
| 231 | +
|
| 232 | +scanner = get_scanner(app, simplify_ids=True) |
| 233 | +modules = scanner.scan() |
| 234 | +
|
| 235 | +resolver = DisplayResolver() |
| 236 | +resolved = resolver.resolve(modules, binding_path="./bindings") |
| 237 | +
|
| 238 | +# Modules without a binding entry still get a clean alias from suggested_alias |
| 239 | +for m in resolved: |
| 240 | + print(m.metadata["display"].get("alias")) # e.g. "get_user" not "users__get_user" |
| 241 | +``` |
0 commit comments