fix(tools): rewrite the semantic layer description around what it can do - #109
fix(tools): rewrite the semantic layer description around what it can do#109josep-reyero wants to merge 3 commits into
Conversation
…s dimension Verified against the dbt Semantic Layer API directly: searching list_metrics for "country" or "region" returns zero metrics, because those are dimensions. That is what stranded a real client session — it searched metrics for a dimension concept, got an empty array with no explanation, and abandoned the tool. Also corrects a name this guidance had wrong: the organization HQ dimension is entity-prefixed (activity_project_id__organization_lf_region), not a bare organization_lf_region, so the previous wording would have produced an invalid dimension. Spell out what metrics and dimensions each are, and that list_metrics searches metric names and descriptions only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Josep Garcia-Reyero Sais <josepreyero@gmail.com>
The description was 3,662 characters. Descriptions are cut at 2,048 before the model sees them, so 44% of it was invisible — including the entity-prefix rule, the country/region routing tip and the tlf membership caveat. The visible half told the model memberships were an exception without ever saying what to do instead, which is why regional questions kept landing on query_lfx_lens. Rewriting it meant first establishing what the tool actually offers. Probing the live Semantic Layer showed the old framing understated it badly: it is not "pre-aggregated metrics" (MetricFlow compiles SQL per request), and it does not only return numbers — grouping by a name dimension returns a ranked list of named organizations or people, which is exactly the shape of the stakeholder question this work exists to answer. So the description now names the six domains it covers, states that country/region questions always belong here, and describes ranking, trending, multi-dimension breakdown and cross-domain combination. Syntax moved onto the parameter it governs — where carries the MetricFlow forms, group_by the entity__field rule and grain suffixes — because each jsonschema description is a separate field with its own budget, and the model reads it at the moment it fills that field. That redistribution is what made room; the description had been rationing characters against a limit the parameters do not share. describe is renamed help, since it is now a fallback for a failed query rather than a prerequisite. The old name still dispatches so a cached schema does not break. Verified end to end against the live stack rather than by compiling SQL alone: both motivating questions return real data, and the ranked-list example was wrong — it filtered lf_region on 'Asia', which compiles but matches nothing. The value is 'Asia Pacific'. compileSql validates dimension names, never values. The example is fixed and the region values are enumerated. Tests now guard the budget in bytes rather than characters: em-dashes cost three bytes each and the description ran ~30 bytes over its character count. Coverage extends to every parameter and to query_lfx_lens, which has far less headroom and is the likelier of the two to drift past the cut unnoticed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Josep Garcia-Reyero Sais <josepreyero@gmail.com>
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.
Tip: disable this comment in your organization's Code Review settings.
There was a problem hiding this comment.
Pull request overview
Reworks semantic-layer guidance to fit schema limits and improve query routing.
Changes:
- Rewrites tool and parameter descriptions.
- Renames
describetohelp, retaining compatibility. - Adds schema-budget and guidance tests.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
internal/tools/lens.go |
Updates semantic-layer guidance and help handling. |
internal/tools/lens_test.go |
Adds budget and schema regression coverage. |
| Metrics string `json:"metrics,omitempty" jsonschema:"Comma-separated metric names from list_metrics (required for get_dimensions and query). List several to combine them in one result: metrics from different domains are outer-joined on the dimensions they share, so a group present in only one domain still appears, with NULL for the other metric. You can only group such a query by dimensions the metrics have in common — get_dimensions with several metrics returns exactly that set. Many metrics are already filtered — current_* means active-only, total_contributors excludes bots — so do not repeat those conditions in where."` | ||
| Search string `json:"search,omitempty" jsonschema:"Filters results by name and description. For list_metrics use a topic word from COVERS ('contributor', 'membership', 'event', 'enrollment', 'maintainer', 'health'). For get_dimensions use the slice you are after, e.g. 'region', 'country', 'tier', 'name'."` | ||
| GroupBy string `json:"group_by,omitempty" jsonschema:"Comma-separated dimension qualified_names, copied verbatim from list_metrics or get_dimensions — they are entity__field and the entity prefix differs per metric, so never assemble one by hand. Group by a name dimension to turn a metric into a ranked list of organizations, people or projects. For a trend add metric_time__year, or __quarter, __month, __week, __day. The entities listed alongside a metric are join keys, not group-by values — grouping by one returns raw IDs, so use the matching name dimension instead."` | ||
| Where string `json:"where,omitempty" jsonschema:"MetricFlow filter expression; this clause does the actual data filtering. Categorical: {{ Dimension('country__lf_region') }} = 'Europe'. Time: {{ TimeDimension('asset_id__install_date', 'DAY') }} >= '2024-01-01'. Dates are yyyy-mm-dd. Use the qualified_name exactly as returned by list_metrics or get_dimensions. If you passed project_slug, include a project filter here too — find the matching project_slug or project_name dimension in the dimensions list."` |
| Where string `json:"where,omitempty" jsonschema:"Optional for query action. MetricFlow filter using {{ Dimension('qualified_name') }} = 'value' syntax. Include a project scope filter to scope results (find the correct project_slug or project_name dimension from list_metrics); may be omitted for global or cross-foundation queries. Example: {{ Dimension('registration_id__project_slug') }} = 'cncf'"` | ||
| OrderBy string `json:"order_by,omitempty" jsonschema:"Comma-separated sort fields, prefix with - for descending (for query)"` | ||
| Limit int `json:"limit,omitempty" jsonschema:"Max rows to return, max 500 (for query)"` | ||
| ProjectSlug string `json:"project_slug,omitempty" jsonschema:"Optional project slug from search_projects (e.g. 'cncf'). Omit it for global or cross-foundation questions — the normal case for country and region questions. When provided, the where clause must also carry a project filter and every project reference is validated against that foundation's subtree."` |
Testing the description against a live client turned up a constraint we had been designing around blind. Clients that defer tool schemas behind a search index — Claude Desktop does — re-serialise the schema and replace OPTIONAL parameter descriptions with a short generated summary. The 459-byte where description arrived as "Filter conditions." and order_by as "Sort order.". Temporarily marking limit required made its real text appear, which is what pinned the cause down. That invalidated the previous commit's main structural move. Syntax had been pushed onto the parameter it governs precisely because each jsonschema description is a separate field with its own budget — true on the wire, but those fields are the ones that get summarised away. Roughly 3,300 bytes of guidance was reaching this client as a paraphrase. Cramming it all back into one description does not fit, and cramming it into the action parameter would have been a workaround rather than a fix. Splitting is the fix: discovery and querying become two tools, so each gets its own 2048 budget, the MetricFlow syntax lives in a tool description rather than on an optional parameter, and metrics becomes genuinely required on the query tool instead of optional — so its multi-metric join rules survive on their own merits. explore_lfx_semantic_layer carries discovery, the covered domains and the routing boundary with query_lfx_lens. query_lfx_semantic_layer carries the query and states its own where/order_by/limit syntax, so a caller never has to call help first. The name and the gate are unchanged, so existing clients keep working; a caller on a cached schema that still sends action=query is told where querying moved rather than getting a bare unknown-action error. TestCriticalGuidanceSurvivesSchemaCompaction is the guard: it walks both tool descriptions plus their required parameters and fails if a token the model cannot guess — the filter syntax, the date format, the limit ceiling, the trend grain — is reachable only through an optional parameter. The optional descriptions stay full and accurate for clients that pass them through; they just are not the only copy. Verified against the live stack: both motivating regional questions return the same data through the new tool, discovery returns metrics with dimensions inline, and the action=query redirect fires. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Josep Garcia-Reyero Sais <josepreyero@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
internal/tools/lens.go:398
- This alias does not preserve compatibility for the cached schema that existed before this PR. That schema only knows
query_lfx_semantic_layer, so an oldaction=describerequest is sent tohandleQuerySemanticLayer, never to this new explore handler, and fails the new requiredmetricsschema/validation. The same applies to oldlist_metricsandget_dimensionscalls. Preserve legacy action dispatch on the old tool name (or explicitly treat the split as breaking) rather than claiming the cached schema remains usable.
// "describe" is the pre-rename name for help, kept so a caller working
// from a cached schema does not get an Unknown action error.
case "help", "describe":
return handleLensHelp(args.Target)
cmd/lfx-mcp-server/main.go:804
- The new tool cannot actually be enabled by its advertised configuration name: with
LFXMCP_TOOLS=explore_lfx_semantic_layer, this condition is false and neither semantic-layer tool is registered. Conversely, selecting onlyquery_lfx_semantic_layerexposes both tools. This breaks the established per-tool-tools/LFXMCP_TOOLSbehavior used by every registration above. Please either register the explore and query tools independently, or explicitly treat either configured name as enabling the pair.
if enabledTools["query_lfx_semantic_layer"] && canRead && isStaff {
tools.RegisterSemanticLayer(server)
|
Closing for now — the scope has outgrown this PR. Live testing surfaced two further problems (unknown filter literals silently returning zero rows, and no way to discover dimension values), so the work is being consolidated into a single change that covers description truncation, caller guidance, and the new discovery capability. A replacement PR will follow from a fresh branch. |
Supersedes #108, which was branched as
fix/regional-dimension-namesbefore the scope grew well past that. Includes both commits.The problem
query_lfx_semantic_layer's description was 3,662 characters. Descriptions are cut at 2,048 before the model ever sees them, so 44% was invisible — the entity-prefix rule, the country/region routing tip and thetlfmembership caveat all sat past the cut.The visible half told the model that memberships were an exception without ever saying what to do instead. That is why regional questions kept landing on
query_lfx_lens.What the tool actually does
Rewriting it meant first establishing what is on offer. Probing the live Semantic Layer showed the old framing understated it in two ways that mattered:
Also confirmed against the live API: cross-domain metrics combine in one query (joined on shared entities, no join syntax written by the caller), every metric carries
metric_timeat five grains, and 33 metrics ship built-in filters (current_*is active-only,total_contributorsexcludes bots) that a caller must not re-apply.What changed
Description now names the six covered domains, states that country/region questions always belong here, and describes ranking, trending, multi-dimension breakdown and cross-domain combination. 2,021 bytes.
Syntax moved onto the parameter it governs —
wherecarries the MetricFlow forms,group_bytheentity__fieldrule and grain suffixes,metricsthe outer-join semantics. Each jsonschema description is a separate field with its own budget, and the model reads it at the moment it fills that field. That redistribution is what made room; the description had been rationing characters against a limit the parameters do not share.describe→help, since it is now a fallback for a failed query rather than a prerequisite. The old name still dispatches so a cached schema does not break.Verification
Run end to end against the live stack, not by compiling SQL alone. Both motivating questions return real data:
Ranked named lists work (top contributing orgs in Asia Pacific: FOSSASIA, National University of Singapore, SK Group, Canva).
Running it caught a bug the SQL check could not. The ranked-list example filtered
lf_regionon'Asia'— which compiles fine and matches nothing. The value is'Asia Pacific'.compileSqlvalidates dimension names, never values. Fixed, and the region values are now enumerated in the example.Tests
Budget is guarded in bytes, not characters — em-dashes cost three bytes each and the description ran ~30 bytes over its character count. Coverage extends to every parameter and to
query_lfx_lens, which has far less headroom and is the likelier of the two to drift past the cut unnoticed.TestSemanticLayerDescriptionalso asserts the absence ofpre-aggregatedandreturns numbers, not records, so neither framing comes back.Depends on
Two lines lean on linuxfoundation/lfx-lens#27 (ranked suggestions on an unknown metric name). If that does not merge, those claims must be cut.
🤖 Generated with Claude Code