diff --git a/mcp_hints.py b/mcp_hints.py index bd64d9b7..b3266e0b 100644 --- a/mcp_hints.py +++ b/mcp_hints.py @@ -34,6 +34,7 @@ MCP_HINTS_STRUCTURED_FIELD_DESCRIPTION = ( "Machine-parseable next-action objects alongside hints. Each element has " + "label (short semantic name, e.g. 'routes via members', 'implementors'), " "tool (MCP tool name), args (ready-to-use parameters), and actionable " "(True = direct call with complete args; False = advisory/partial — agent " "fills missing values or uses as guidance). Same trigger logic, priority, " @@ -48,6 +49,7 @@ class _StructuredHint(NamedTuple): args: dict[str, Any] actionable: bool priority: int + label: str = "" def finalize_structured_hints(scored: list[_StructuredHint]) -> list[_StructuredHint]: @@ -230,6 +232,43 @@ def _extract_other_ids(results: list[dict[str, Any]]) -> list[str]: _EDGE_DECLARES_CLIENT = frozenset({"DECLARES_CLIENT", "DECLARES.DECLARES_CLIENT"}) _EDGE_DECLARES_PRODUCER = frozenset({"DECLARES_PRODUCER", "DECLARES.DECLARES_PRODUCER"}) +# --- Labels for structured hints (semantic name of each hint) --- + +LABEL_CLIENTS_VIA_MEMBERS = "clients via members" +LABEL_ROUTES_VIA_MEMBERS = "routes via members" +LABEL_PRODUCERS_VIA_MEMBERS = "producers via members" +LABEL_OVERRIDERS = "overriders" +LABEL_CLIENTS_IN_OVERRIDERS = "clients in overriders" +LABEL_PRODUCERS_IN_OVERRIDERS = "producers in overriders" +LABEL_ROUTES_IN_OVERRIDERS = "routes in overriders" +LABEL_OUTBOUND_CLIENT = "outbound client" +LABEL_OUTBOUND_PRODUCER = "outbound producer" +LABEL_INBOUND_ROUTE = "inbound route" +LABEL_HIGH_FANOUT = "high fanout" +LABEL_DECLARING_METHOD = "declaring method" +LABEL_IMPLEMENTORS = "implementors" +LABEL_IMPLEMENTS = "implements" +LABEL_DEPENDENCIES = "dependencies" +LABEL_INJECTORS = "injectors" +LABEL_OUTBOUND_CALLS = "outbound calls" +LABEL_SUPER_DECLARATION = "super declaration" +LABEL_UNRESOLVED = "unresolved" +LABEL_TRY_RESOLVE = "try resolve" +LABEL_PAGE_FULL = "page full" +LABEL_HANDLER = "handler" +LABEL_HTTP_TARGETS = "HTTP targets" +LABEL_ASYNC_TARGETS = "async targets" +LABEL_WEAK_RESULTS = "weak results" +LABEL_TRY_SEARCH = "try search" +LABEL_TRY_FIND_ROUTE = "try find route" +LABEL_TRY_FIND_CLIENT = "try find client" +LABEL_TIGHTEN_IDENTIFIER = "tighten identifier" +LABEL_FUZZY_STRATEGY = "fuzzy strategy" +LABEL_CALLERS = "callers" +LABEL_WRONG_SUBJECT_KIND = "wrong subject kind" +LABEL_WRONG_DIRECTION = "wrong direction" +LABEL_TYPE_LEVEL_REQUERY = "type-level requery" + # §7.12 priority: DECLARES.* type rollups > OVERRIDDEN_BY.* > leaf follow-ups > meta. PRIORITY_DECLARES_TYPE_ROLLUP = 4 PRIORITY_OVERRIDDEN_AXIS = 3 @@ -687,6 +726,7 @@ def _neighbors_empty_structured_hints( {"ids": [subject_id], "direction": direction, "edge_types": edge_types}, False, PRIORITY_META, + LABEL_WRONG_SUBJECT_KIND, )) continue @@ -702,6 +742,7 @@ def _neighbors_empty_structured_hints( {"ids": [subject_id], "direction": correct_dir, "edge_types": [edge]}, False, PRIORITY_META, + LABEL_WRONG_DIRECTION, )) continue @@ -718,6 +759,7 @@ def _neighbors_empty_structured_hints( {"ids": [subject_id], "direction": requested_direction, "edge_types": [dot_key]}, False, PRIORITY_META, + LABEL_TYPE_LEVEL_REQUERY, )) else: template = spec.typical_traversals.get("type_subject", "") @@ -729,6 +771,7 @@ def _neighbors_empty_structured_hints( {"ids": [subject_id], "direction": direction, "edge_types": edge_types}, False, PRIORITY_META, + LABEL_TYPE_LEVEL_REQUERY, )) return out @@ -775,6 +818,7 @@ def _neighbors_success_structured_hints(payload: dict[str, Any]) -> list[_Struct {"ids": [origin_id], "direction": "out", "edge_types": ["DECLARES.DECLARES_CLIENT"]}, True, PRIORITY_LEAF_FOLLOWUP, + LABEL_CLIENTS_VIA_MEMBERS, )) if len(n1b) <= _NEIGHBORS_SUCCESS_MAX_CHARS: out.append(_StructuredHint( @@ -782,6 +826,7 @@ def _neighbors_success_structured_hints(payload: dict[str, Any]) -> list[_Struct {"ids": [origin_id], "direction": "out", "edge_types": ["DECLARES.EXPOSES"]}, True, PRIORITY_LEAF_FOLLOWUP, + LABEL_ROUTES_VIA_MEMBERS, )) # N2: DECLARES_CLIENT / DECLARES.DECLARES_CLIENT out → HTTP_CALLS @@ -793,6 +838,7 @@ def _neighbors_success_structured_hints(payload: dict[str, Any]) -> list[_Struct {"ids": other_ids, "direction": "out", "edge_types": ["HTTP_CALLS"]}, bool(other_ids), PRIORITY_LEAF_FOLLOWUP, + LABEL_HTTP_TARGETS, )) # N3: DECLARES_PRODUCER / DECLARES.DECLARES_PRODUCER out → ASYNC_CALLS @@ -804,6 +850,7 @@ def _neighbors_success_structured_hints(payload: dict[str, Any]) -> list[_Struct {"ids": other_ids, "direction": "out", "edge_types": ["ASYNC_CALLS"]}, bool(other_ids), PRIORITY_LEAF_FOLLOWUP, + LABEL_ASYNC_TARGETS, )) # N4: EXPOSES in → CALLS (callers) @@ -818,6 +865,7 @@ def _neighbors_success_structured_hints(payload: dict[str, Any]) -> list[_Struct {"ids": other_ids, "direction": "in", "edge_types": ["CALLS"]}, bool(other_ids), PRIORITY_LEAF_FOLLOWUP, + LABEL_CALLERS, )) # N5: HTTP_CALLS in → DECLARES_CLIENT @@ -829,6 +877,7 @@ def _neighbors_success_structured_hints(payload: dict[str, Any]) -> list[_Struct {"ids": other_ids, "direction": "in", "edge_types": ["DECLARES_CLIENT"]}, bool(other_ids), PRIORITY_LEAF_FOLLOWUP, + LABEL_DECLARING_METHOD, )) # N6: ASYNC_CALLS in → DECLARES_PRODUCER @@ -840,6 +889,7 @@ def _neighbors_success_structured_hints(payload: dict[str, Any]) -> list[_Struct {"ids": other_ids, "direction": "in", "edge_types": ["DECLARES_PRODUCER"]}, bool(other_ids), PRIORITY_LEAF_FOLLOWUP, + LABEL_DECLARING_METHOD, )) # N7: DECLARES.EXPOSES out → EXPOSES (handler) @@ -851,6 +901,7 @@ def _neighbors_success_structured_hints(payload: dict[str, Any]) -> list[_Struct {"ids": other_ids, "direction": "in", "edge_types": ["EXPOSES"]}, bool(other_ids), PRIORITY_LEAF_FOLLOWUP, + LABEL_HANDLER, )) return out @@ -886,6 +937,7 @@ def generate_hints( "resolve", {"identifier": str(payload.get("resolved_identifier") or ""), "hint_kind": str(payload.get("hint_kind") or "")}, False, PRIORITY_META, + LABEL_TIGHTEN_IDENTIFIER, )) return (finalize_hint_list(pairs), finalize_structured_hints(struct_pairs)) if status == "none": @@ -902,6 +954,7 @@ def generate_hints( rendered = TPL_RESOLVE_NONE_TRY_FIND_ROUTE.format(seed=seed) struct_pairs.append(_StructuredHint( "find", {"kind": "route", "filter": {"path_prefix": seed}}, True, PRIORITY_META, + LABEL_TRY_FIND_ROUTE, )) elif hint_kind == "client": seed = payload.get("target_service_seed") @@ -909,11 +962,13 @@ def generate_hints( rendered = TPL_RESOLVE_NONE_TRY_FIND_CLIENT.format(seed=seed) struct_pairs.append(_StructuredHint( "find", {"kind": "client", "filter": {"target_service": seed}}, True, PRIORITY_META, + LABEL_TRY_FIND_CLIENT, )) else: rendered = TPL_RESOLVE_NONE_TRY_SEARCH.format(identifier=identifier) struct_pairs.append(_StructuredHint( "search", {"query": identifier}, True, PRIORITY_META, + LABEL_TRY_SEARCH, )) if rendered is not None and len(rendered) <= _RESOLVE_HINT_MAX_CHARS: pairs.append((PRIORITY_META, rendered)) @@ -934,6 +989,7 @@ def generate_hints( pairs.append((PRIORITY_META, TPL_SEARCH_WEAK)) struct_pairs.append(_StructuredHint( "find", {"kind": "symbol", "filter": {"role": "SERVICE"}}, False, PRIORITY_META, + LABEL_WEAK_RESULTS, )) return (finalize_hint_list(pairs), finalize_structured_hints(struct_pairs)) @@ -952,11 +1008,13 @@ def generate_hints( break struct_pairs.append(_StructuredHint( "resolve", {"identifier": identifier, "hint_kind": kind}, True, PRIORITY_META, + LABEL_TRY_RESOLVE, )) if _find_is_page_full(payload, results) and lim is not None: pairs.append((PRIORITY_META, TPL_FIND_PAGE_FULL.format(limit=int(lim)))) struct_pairs.append(_StructuredHint( "find", {"kind": kind, "filter": flt, "limit": int(lim)}, False, PRIORITY_META, + LABEL_PAGE_FULL, )) pairs.extend(find_success_hints(payload)) if results and not _find_is_page_full(payload, results): @@ -966,16 +1024,19 @@ def generate_hints( struct_pairs.append(_StructuredHint( "neighbors", {"ids": [node_id], "direction": "in", "edge_types": ["EXPOSES"]}, True, PRIORITY_LEAF_FOLLOWUP, + LABEL_HANDLER, )) elif kind == "client": struct_pairs.append(_StructuredHint( "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["HTTP_CALLS"]}, True, PRIORITY_LEAF_FOLLOWUP, + LABEL_HTTP_TARGETS, )) elif kind == "producer": struct_pairs.append(_StructuredHint( "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["ASYNC_CALLS"]}, True, PRIORITY_LEAF_FOLLOWUP, + LABEL_ASYNC_TARGETS, )) return (finalize_hint_list(pairs), finalize_structured_hints(struct_pairs)) @@ -1030,10 +1091,11 @@ def generate_hints( "neighbors", {"ids": [origin_id], "direction": "out", "edge_types": ["CALLS"], "edge_filter": {}}, False, PRIORITY_LEAF_FOLLOWUP, + LABEL_HIGH_FANOUT, )) if results and _any_fuzzy_strategy(results): meta_pairs.append((PRIORITY_META, TPL_NEIGHBORS_FUZZY_STRATEGY)) - struct_meta.append(_StructuredHint("neighbors", {}, False, PRIORITY_META)) + struct_meta.append(_StructuredHint("neighbors", {}, False, PRIORITY_META, LABEL_FUZZY_STRATEGY)) return ( finalize_hint_list( _filter_neighbors_dotkey_hints(empty_pairs) + success_pairs + meta_pairs, @@ -1057,6 +1119,7 @@ def generate_hints( struct_pairs.append(_StructuredHint( "neighbors", {"ids": [node_id], "direction": "in", "edge_types": ["EXPOSES"]}, True, PRIORITY_LEAF_FOLLOWUP, + LABEL_DECLARING_METHOD, )) return (finalize_hint_list(pairs), finalize_structured_hints(struct_pairs)) if kind == "client": @@ -1064,12 +1127,14 @@ def generate_hints( struct_pairs.append(_StructuredHint( "neighbors", {"ids": [node_id], "direction": "in", "edge_types": ["DECLARES_CLIENT"]}, True, PRIORITY_LEAF_FOLLOWUP, + LABEL_DECLARING_METHOD, )) if _out_count(edge_summary, "HTTP_CALLS") > 0: pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_FIND_SUCCESS_HTTP_TARGETS.format(id=node_id))) struct_pairs.append(_StructuredHint( "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["HTTP_CALLS"]}, True, PRIORITY_LEAF_FOLLOWUP, + LABEL_HTTP_TARGETS, )) return (finalize_hint_list(pairs), finalize_structured_hints(struct_pairs)) if kind == "producer": @@ -1077,12 +1142,14 @@ def generate_hints( struct_pairs.append(_StructuredHint( "neighbors", {"ids": [node_id], "direction": "in", "edge_types": ["DECLARES_PRODUCER"]}, True, PRIORITY_LEAF_FOLLOWUP, + LABEL_DECLARING_METHOD, )) if _out_count(edge_summary, "ASYNC_CALLS") > 0: pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_FIND_SUCCESS_ASYNC_TARGETS.format(id=node_id))) struct_pairs.append(_StructuredHint( "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["ASYNC_CALLS"]}, True, PRIORITY_LEAF_FOLLOWUP, + LABEL_ASYNC_TARGETS, )) return (finalize_hint_list(pairs), finalize_structured_hints(struct_pairs)) @@ -1101,6 +1168,7 @@ def generate_hints( struct_pairs.append(_StructuredHint( "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["DECLARES.DECLARES_CLIENT"]}, True, PRIORITY_DECLARES_TYPE_ROLLUP, + LABEL_CLIENTS_VIA_MEMBERS, )) if _out_count(edge_summary, "DECLARES.EXPOSES") > 0: pairs.append( @@ -1109,6 +1177,7 @@ def generate_hints( struct_pairs.append(_StructuredHint( "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["DECLARES.EXPOSES"]}, True, PRIORITY_DECLARES_TYPE_ROLLUP, + LABEL_ROUTES_VIA_MEMBERS, )) if _out_count(edge_summary, "DECLARES.DECLARES_PRODUCER") > 0: pairs.append( @@ -1117,6 +1186,7 @@ def generate_hints( struct_pairs.append(_StructuredHint( "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["DECLARES.DECLARES_PRODUCER"]}, True, PRIORITY_DECLARES_TYPE_ROLLUP, + LABEL_PRODUCERS_VIA_MEMBERS, )) if not _type_rollup_would_emit(edge_summary): @@ -1125,24 +1195,28 @@ def generate_hints( struct_pairs.append(_StructuredHint( "neighbors", {"ids": [node_id], "direction": "in", "edge_types": ["IMPLEMENTS"]}, True, PRIORITY_LEAF_FOLLOWUP, + LABEL_IMPLEMENTORS, )) if decl_kind == "class" and _out_count(edge_summary, "IMPLEMENTS") > 0: pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_TYPE_IMPLEMENTS.format(id=node_id))) struct_pairs.append(_StructuredHint( "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["IMPLEMENTS"]}, True, PRIORITY_LEAF_FOLLOWUP, + LABEL_IMPLEMENTS, )) if decl_kind == "class" and _record_role(rec) == "SERVICE" and _out_count(edge_summary, "INJECTS") > 0: pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_TYPE_DEPENDENCIES.format(id=node_id))) struct_pairs.append(_StructuredHint( "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["INJECTS"]}, True, PRIORITY_LEAF_FOLLOWUP, + LABEL_DEPENDENCIES, )) if decl_kind in {"interface", "class"} and _in_count(edge_summary, "INJECTS") > 0: pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_TYPE_INJECTORS.format(id=node_id))) struct_pairs.append(_StructuredHint( "neighbors", {"ids": [node_id], "direction": "in", "edge_types": ["INJECTS"]}, True, PRIORITY_LEAF_FOLLOWUP, + LABEL_INJECTORS, )) return (finalize_hint_list(pairs), finalize_structured_hints(struct_pairs)) @@ -1153,6 +1227,7 @@ def generate_hints( struct_pairs.append(_StructuredHint( "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["OVERRIDDEN_BY"]}, True, PRIORITY_OVERRIDDEN_AXIS, + LABEL_OVERRIDERS, )) if _out_count(edge_summary, "OVERRIDDEN_BY.DECLARES_CLIENT") > 0: pairs.append( @@ -1161,6 +1236,7 @@ def generate_hints( struct_pairs.append(_StructuredHint( "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["OVERRIDDEN_BY.DECLARES_CLIENT"]}, True, PRIORITY_OVERRIDDEN_AXIS, + LABEL_CLIENTS_IN_OVERRIDERS, )) if _out_count(edge_summary, "OVERRIDDEN_BY.DECLARES_PRODUCER") > 0: pairs.append( @@ -1169,6 +1245,7 @@ def generate_hints( struct_pairs.append(_StructuredHint( "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["OVERRIDDEN_BY.DECLARES_PRODUCER"]}, True, PRIORITY_OVERRIDDEN_AXIS, + LABEL_PRODUCERS_IN_OVERRIDERS, )) if _out_count(edge_summary, "OVERRIDDEN_BY.EXPOSES") > 0: pairs.append( @@ -1177,24 +1254,28 @@ def generate_hints( struct_pairs.append(_StructuredHint( "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["OVERRIDDEN_BY.EXPOSES"]}, True, PRIORITY_OVERRIDDEN_AXIS, + LABEL_ROUTES_IN_OVERRIDERS, )) if _out_count(edge_summary, "DECLARES_CLIENT") > 0: pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_METHOD_OUTBOUND_CLIENT.format(id=node_id))) struct_pairs.append(_StructuredHint( "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["DECLARES_CLIENT"]}, True, PRIORITY_LEAF_FOLLOWUP, + LABEL_OUTBOUND_CLIENT, )) if _out_count(edge_summary, "DECLARES_PRODUCER") > 0: pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_METHOD_OUTBOUND_PRODUCER.format(id=node_id))) struct_pairs.append(_StructuredHint( "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["DECLARES_PRODUCER"]}, True, PRIORITY_LEAF_FOLLOWUP, + LABEL_OUTBOUND_PRODUCER, )) if _out_count(edge_summary, "EXPOSES") > 0: pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_METHOD_INBOUND_ROUTE.format(id=node_id))) struct_pairs.append(_StructuredHint( "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["EXPOSES"]}, True, PRIORITY_LEAF_FOLLOWUP, + LABEL_INBOUND_ROUTE, )) calls_out = _out_count(edge_summary, "CALLS") if 1 <= calls_out <= 9: @@ -1204,6 +1285,7 @@ def generate_hints( struct_pairs.append(_StructuredHint( "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["CALLS"]}, True, PRIORITY_LEAF_FOLLOWUP, + LABEL_OUTBOUND_CALLS, )) if _out_count(edge_summary, "OVERRIDES") > 0: if _out_count(edge_summary, "OVERRIDDEN_BY") == 0: @@ -1211,6 +1293,7 @@ def generate_hints( struct_pairs.append(_StructuredHint( "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["OVERRIDES"]}, True, PRIORITY_LEAF_FOLLOWUP, + LABEL_SUPER_DECLARATION, )) data = rec.get("data") unresolved = 0 @@ -1221,12 +1304,14 @@ def generate_hints( struct_pairs.append(_StructuredHint( "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["CALLS"], "include_unresolved": True}, True, PRIORITY_LEAF_FOLLOWUP, + LABEL_UNRESOLVED, )) if _out_count(edge_summary, "CALLS") >= 10: pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_METHOD_MANY_CALLS)) struct_pairs.append(_StructuredHint( "neighbors", {"ids": [node_id], "direction": "out", "edge_types": ["CALLS"]}, False, PRIORITY_LEAF_FOLLOWUP, + LABEL_HIGH_FANOUT, )) return (finalize_hint_list(pairs), finalize_structured_hints(struct_pairs)) diff --git a/mcp_v2.py b/mcp_v2.py index fd4b61bd..63979f18 100644 --- a/mcp_v2.py +++ b/mcp_v2.py @@ -177,6 +177,7 @@ def _role_axes_mutually_exclusive(self) -> EdgeFilter: class StructuredHint(BaseModel): + label: str = "" tool: Literal["search", "find", "describe", "neighbors", "resolve"] args: dict[str, Any] actionable: bool = True @@ -347,7 +348,7 @@ def _edgefilter_applicability_error(edge_types: list[str], ef: EdgeFilter) -> st def _to_structured_hints(raw: list[Any]) -> list[StructuredHint]: - return [StructuredHint(tool=h.tool, args=h.args, actionable=h.actionable) for h in raw] + return [StructuredHint(label=h.label, tool=h.tool, args=h.args, actionable=h.actionable) for h in raw] def _coerce_edge_filter( diff --git a/propose/HINTS-STRUCTURED-LABEL.md b/propose/HINTS-STRUCTURED-LABEL.md new file mode 100644 index 00000000..a9cf978f --- /dev/null +++ b/propose/HINTS-STRUCTURED-LABEL.md @@ -0,0 +1,44 @@ +# HINTS-STRUCTURED-LABEL — Add label field to StructuredHint + +Issue: [#216](https://github.com/HumanBean17/java-codebase-rag/issues/216) +Related: [#211](https://github.com/HumanBean17/java-codebase-rag/issues/211) (reason field) + +## Problem + +`hints_structured` captures `tool` + `args` but loses the semantic name embedded +in string hints. String hint `"routes via members: neighbors([...])"` carries the +label `"routes via members"` in its prefix — structured hints have no equivalent. +An LLM agent must infer hint purpose from args alone, which is ambiguous. + +## Proposal + +Add `label: str` to both `_StructuredHint` (internal NamedTuple) and +`StructuredHint` (public Pydantic model). Labels are 1–4 word semantic tags +extracted from the colon-prefix of existing `TPL_*` string templates. + +## Label values + +Labels are derived from template prefixes (text before first `:`) or assigned +a short descriptive tag for colon-less advisory templates. Full mapping in +issue #216. + +## Scope + +- `mcp_hints.py` — add `label` field to `_StructuredHint`, update all constructors +- `mcp_v2.py` — add `label` field to `StructuredHint`, update `_to_structured_hints` +- `tests/test_mcp_hints.py` — assert on `label` values + +## Out of scope + +- No changes to string `hints` generation or templates +- No changes to `reason` field (#211) — that is a separate effort +- No graph/index schema changes + +## Reindex + +None — output-only change. + +## Tests + +- Update existing structured hint tests to assert `label` is present and correct +- No new test files needed diff --git a/tests/test_mcp_hints.py b/tests/test_mcp_hints.py index 494bde43..21e93c00 100644 --- a/tests/test_mcp_hints.py +++ b/tests/test_mcp_hints.py @@ -1994,6 +1994,7 @@ def _assert_structured_hint( tool: str, args_subset: dict[str, Any] | None = None, actionable: bool = True, + label: str | None = None, ) -> _StructuredHint: """Find and return a structured hint matching tool, actionable, and args subset.""" for h in hints: @@ -2002,6 +2003,8 @@ def _assert_structured_hint( if args_subset is not None: if not all(h.args.get(k) == v for k, v in args_subset.items()): continue + if label is not None and h.label != label: + continue return h pytest.fail( f"no structured hint with tool={tool!r} actionable={actionable} " @@ -2631,6 +2634,8 @@ def test_structured_hints_parity_with_string_hints() -> None: assert len(struct) <= len(str_hints), ( f"parity violation for {output_kind}: {len(struct)} structured > {len(str_hints)} string" ) + for h in struct: + assert h.label, f"struct hint missing label: {h}" def test_structured_hint_round_trip(kuzu_graph) -> None: @@ -2641,6 +2646,7 @@ def test_structured_hint_round_trip(kuzu_graph) -> None: assert out.hints_structured h = out.hints_structured[0] assert h.tool == "neighbors" + assert h.label, "structured hint should have a non-empty label" # Actually call neighbors_v2 with the structured hint args nout = neighbors_v2( ids=h.args["ids"], @@ -2649,3 +2655,81 @@ def test_structured_hint_round_trip(kuzu_graph) -> None: graph=kuzu_graph, ) assert nout.success + + +def test_structured_hint_label_values() -> None: + """Verify label values match expected semantic names for key hint scenarios.""" + # describe type with clients via members + struct = _struct("describe", {"success": True, "record": { + "id": "sym:a", "kind": "symbol", "fqn": "T", "data": {"kind": "class"}, + "edge_summary": {"DECLARES.DECLARES_CLIENT": {"in": 0, "out": 3}}, + }}) + assert any(h.label == "clients via members" for h in struct) + + # describe type with routes via members + struct = _struct("describe", {"success": True, "record": { + "id": "sym:a", "kind": "symbol", "fqn": "T", "data": {"kind": "class"}, + "edge_summary": {"DECLARES.EXPOSES": {"in": 0, "out": 2}}, + }}) + assert any(h.label == "routes via members" for h in struct) + + # describe route → declaring method + struct = _struct("describe", {"success": True, "record": {"id": "route:a", "kind": "route", "fqn": "GET /"}}) + assert any(h.label == "declaring method" for h in struct) + + # resolve none → try search + struct = _struct("resolve", {"status": "none", "resolved_identifier": "com.foo.Bar", "hint_kind": "symbol"}) + assert any(h.label == "try search" for h in struct) + + # resolve none route → try find route + struct = _struct("resolve", {"status": "none", "resolved_identifier": "x", "hint_kind": "route", "path_prefix_seed": "/api"}) + assert any(h.label == "try find route" for h in struct) + + # resolve many → tighten identifier + struct = _struct("resolve", {"status": "many", "resolved_identifier": "x", "candidates": [{"id": "a"}, {"id": "b"}]}) + assert any(h.label == "tighten identifier" for h in struct) + + # find empty → try resolve + struct = _struct("find", {"success": True, "kind": "client", "results": [], "filter": {"target_service": "x"}, "offset": 0}) + assert any(h.label == "try resolve" for h in struct) + + # find page full + struct = _struct("find", {"success": True, "kind": "symbol", "results": [{"id": "a"}], "filter": {}, "limit": 1, "has_more_results": True, "offset": 0}) + assert any(h.label == "page full" for h in struct) + + # search weak + struct = _struct("search", {"success": True, "results": [ + {"score": 1.0}, {"score": 0.95}, + ], "limit": 2}) + assert any(h.label == "weak results" for h in struct) + + # describe method with overriders + struct = _struct("describe", {"success": True, "record": { + "id": "sym:a", "kind": "symbol", "fqn": "T#m()", "data": {"kind": "method"}, + "edge_summary": {"OVERRIDDEN_BY": {"in": 0, "out": 1}}, + }}) + assert any(h.label == "overriders" for h in struct) + + # describe method with outbound calls + struct = _struct("describe", {"success": True, "record": { + "id": "sym:a", "kind": "symbol", "fqn": "T#m()", "data": {"kind": "method", "role": "SERVICE"}, + "edge_summary": {"CALLS": {"in": 0, "out": 3}}, + }}) + assert any(h.label == "outbound calls" for h in struct) + + # describe method with many calls (≥10) → not actionable + struct = _struct("describe", {"success": True, "record": { + "id": "sym:a", "kind": "symbol", "fqn": "T#m()", "data": {"kind": "method", "role": "OTHER"}, + "edge_summary": {"CALLS": {"in": 0, "out": 12}}, + }}) + assert any(h.label == "high fanout" and not h.actionable for h in struct) + + # neighbors success callers (N4) + struct = _struct("neighbors", { + "success": True, + "results": [{"origin_id": "sym:T", "edge_type": "EXPOSES", "direction": "in", + "other": {"id": "sym:m", "kind": "symbol", "symbol_kind": "method"}, "attrs": {}}], + "requested_edge_types": ["EXPOSES"], "requested_direction": "in", "offset": 0, + "subject_record": {"id": "route:a", "kind": "route"}, "origin_id": "route:a", + }) + assert any(h.label == "callers" for h in struct)