diff --git a/README.md b/README.md index c40f4ee..81ea9b0 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,7 @@ Example: {"kind":"symbol","filter":{"microservice":"chat-core","symbol_kind":"interface"}} ``` -**MCP v2 response extras (`hints`, pagination echo):** On success, `search`, `find`, `describe`, `neighbors`, and `resolve` return a `hints` field (`list[str]`, capped at five unique strings) with short, templated suggestions for likely next tool calls; hints are advisory. `hints` is always empty when `success` is false. `resolve` additionally echoes `resolved_identifier` (post-validation trimmed identifier) on every `success=true` response; it is `null` when `success` is false. Resolve hints fire only on `status: none` or `status: many` (not on `status: one`). `search` and `find` additionally echo the request’s `limit` and `offset` on success; on failure those echoed fields are omitted (`null` in JSON). The find page-full hint fires only when another page may exist (handler over-fetches by one row; not exposed on the output model). `neighbors` echoes `requested_edge_types` (deduped edge labels from the request) on success for empty-result hints and diagnostics. See [`propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md`](./propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md) Appendix A for the locked v1 template catalog; see [`propose/HINTS-V2-PROPOSE.md`](./propose/HINTS-V2-PROPOSE.md) for v2 additions (`resolve` rules; neighbors fuzzy-strategy in a follow-up PR). +**MCP v2 response extras (`hints`, pagination echo):** On success, `search`, `find`, `describe`, `neighbors`, and `resolve` return a `hints` field (`list[str]`, capped at five unique strings) with short, templated suggestions for likely next tool calls; hints are advisory. `hints` is always empty when `success` is false. `resolve` additionally echoes `resolved_identifier` (post-validation trimmed identifier) on every `success=true` response; it is `null` when `success` is false. Resolve hints fire only on `status: none` or `status: many` (not on `status: one`). `search` and `find` additionally echo the request’s `limit` and `offset` on success; on failure those echoed fields are omitted (`null` in JSON). The find page-full hint fires only when another page may exist (handler over-fetches by one row; not exposed on the output model). `neighbors` echoes `requested_edge_types` (deduped edge labels from the request) on success for empty-result hints and diagnostics; when any result edge carries a brownfield/fallback `attrs.strategy` (see `FUZZY_STRATEGY_SET` in `java_ontology.py`), a single meta-tier fuzzy-strategy hint may also appear. See [`propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md`](./propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md) Appendix A for the locked v1 template catalog; see [`propose/HINTS-V2-PROPOSE.md`](./propose/HINTS-V2-PROPOSE.md) for v2 additions (`resolve` rules and neighbors fuzzy-strategy hint). --- diff --git a/java_ontology.py b/java_ontology.py index a05ec37..f3ed198 100644 --- a/java_ontology.py +++ b/java_ontology.py @@ -85,6 +85,16 @@ "client_target_path", )) +# Brownfield / fallback edge resolution strategies (hints v2 neighbors fuzzy signal). +FUZZY_STRATEGY_SET: frozenset[str] = frozenset({ + "layer_c_source", + "layer_b_fqn", + "phantom", + "chained_receiver", + "overload_ambiguous", + "implicit_super", +}) + ResolveReason = Literal[ "exact_id", "exact_fqn", @@ -107,5 +117,6 @@ "VALID_ASYNC_CALL_STRATEGIES", "VALID_HTTP_CALL_MATCHES", "VALID_RESOLVE_REASONS", + "FUZZY_STRATEGY_SET", "ResolveReason", ] diff --git a/mcp_hints.py b/mcp_hints.py index f03b7a3..4481f21 100644 --- a/mcp_hints.py +++ b/mcp_hints.py @@ -1,7 +1,7 @@ """Pure MCP v2 road-sign hint generation (no graph I/O, no search, no LLM). Locked v1 catalog: ``propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md`` Appendix A. -v2 resolve catalog: ``propose/HINTS-V2-PROPOSE.md`` Appendix A. +v2 resolve + neighbors fuzzy-strategy catalog: ``propose/HINTS-V2-PROPOSE.md`` Appendix A. Priority cap: same propose §7.12 / ``plans/completed/PLAN-HINTS.md`` principles. """ @@ -9,6 +9,8 @@ from typing import Any, Literal +from java_ontology import FUZZY_STRATEGY_SET + # Normative schema description (propose §3.1) — imported by ``mcp_v2`` for Field(description=...). MCP_HINTS_FIELD_DESCRIPTION = ( "Road-sign hints pointing to likely next calls. Each hint is a short string " @@ -65,6 +67,10 @@ _RESOLVE_HINT_MAX_CHARS = 120 _RESOLVE_WILDCARDS = ("*", "?") +TPL_NEIGHBORS_FUZZY_STRATEGY = ( + "some edges resolved via brownfield/fallback strategy — check attrs.strategy on each row" +) + # §7.12 priority: DECLARES.* type rollups > OVERRIDDEN_BY.* > leaf follow-ups > meta. PRIORITY_DECLARES_TYPE_ROLLUP = 4 PRIORITY_OVERRIDDEN_AXIS = 3 @@ -99,6 +105,15 @@ def _symbol_declaration_kind(record: dict[str, Any]) -> str | None: return None +def _any_fuzzy_strategy(edges: list[dict[str, Any]]) -> bool: + for e in edges: + attrs = e.get("attrs") if isinstance(e.get("attrs"), dict) else {} + s = attrs.get("strategy") if isinstance(attrs, dict) else None + if isinstance(s, str) and s in FUZZY_STRATEGY_SET: + return True + return False + + def _find_has_identifier_shaped_filter(kind: str, flt: dict[str, Any]) -> bool: for name in _IDENTIFIER_FILTER_FIELDS.get(kind, ()): val = flt.get(name) @@ -215,6 +230,8 @@ def generate_hints( n_types = len([x for x in req_types if str(x).strip()]) if not results and n_types > 0: pairs.append((PRIORITY_META, TPL_NEIGHBORS_EMPTY_KIND_CHECK)) + elif _any_fuzzy_strategy(results): + pairs.append((PRIORITY_META, TPL_NEIGHBORS_FUZZY_STRATEGY)) return finalize_hint_list(pairs) if output_kind == "describe": diff --git a/server.py b/server.py index 6ce89b1..ac3975e 100644 --- a/server.py +++ b/server.py @@ -448,7 +448,8 @@ async def describe( "Optional `filter` applies to each neighbor endpoint row; populated fields must be applicable to that " "neighbor's kind—mixed-kind result sets fail on the first inapplicable neighbor (strict frame). " "Wildcards in prefix fields are rejected. Unknown NodeFilter keys return success=false. " - "Successful responses echo `requested_edge_types` and may include `hints` (advisory next-step strings)." + "Successful responses echo `requested_edge_types` and may include `hints` (advisory next-step strings). " + "Each edge's `attrs.strategy` indicates resolution quality (brownfield/fallback vs primary paths)." ), ) async def neighbors( diff --git a/tests/test_mcp_hints.py b/tests/test_mcp_hints.py index 7e20984..2e2c004 100644 --- a/tests/test_mcp_hints.py +++ b/tests/test_mcp_hints.py @@ -15,6 +15,7 @@ finalize_hint_list, generate_hints, ) +from java_ontology import FUZZY_STRATEGY_SET from mcp_v2 import FindOutput, SearchOutput, describe_v2, find_v2, neighbors_v2, resolve_v2, search_v2 _TYPE_KINDS = frozenset({"class", "interface", "enum", "record", "annotation"}) @@ -253,6 +254,100 @@ def test_hints_neighbors_empty_with_edge_types_emits_kind_check(kuzu_graph) -> N assert mcp_hints.TPL_NEIGHBORS_EMPTY_KIND_CHECK in out.hints +def _neighbors_hint_payload( + results: list[dict[str, Any]], + *, + requested_edge_types: list[str] | None = None, +) -> dict[str, Any]: + return { + "success": True, + "results": results, + "requested_edge_types": requested_edge_types or ["DECLARES_CLIENT"], + } + + +def _edge_result(*, strategy: str | None = None, edge_type: str = "DECLARES_CLIENT") -> dict[str, Any]: + attrs: dict[str, Any] = {} + if strategy is not None: + attrs["strategy"] = strategy + return { + "origin_id": "sym:pkg.Type#m()", + "edge_type": edge_type, + "direction": "out", + "other": {"id": "client:svc:feign:t:GET:/p", "kind": "client"}, + "attrs": attrs, + } + + +def _method_id_with_fuzzy_calls(kuzu_graph) -> str: + rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (m:Symbol)-[e:CALLS]->() " + "WHERE e.strategy IN $strategies " + "RETURN m.id AS id LIMIT 1", + {"strategies": sorted(FUZZY_STRATEGY_SET)}, + ) + if not rows: + pytest.fail("no CALLS edge with fuzzy strategy in bank fixture") + return str(rows[0]["id"]) + + +def test_hints_neighbors_fuzzy_strategy_layer_c_source_emits() -> None: + payload = _neighbors_hint_payload([_edge_result(strategy="layer_c_source")]) + hints = generate_hints("neighbors", payload) + assert mcp_hints.TPL_NEIGHBORS_FUZZY_STRATEGY in hints + assert "attrs.strategy" in hints[0] + + +def test_hints_neighbors_fuzzy_strategy_annotation_absent() -> None: + payload = _neighbors_hint_payload([_edge_result(strategy="annotation")]) + assert generate_hints("neighbors", payload) == [] + + +def test_hints_neighbors_fuzzy_strategy_calls_phantom_emits() -> None: + payload = _neighbors_hint_payload( + [_edge_result(strategy="phantom", edge_type="CALLS")], + requested_edge_types=["CALLS"], + ) + hints = generate_hints("neighbors", payload) + assert mcp_hints.TPL_NEIGHBORS_FUZZY_STRATEGY in hints + + +def test_hints_neighbors_declares_no_strategy_attrs_empty() -> None: + payload = _neighbors_hint_payload( + [_edge_result(edge_type="DECLARES")], + requested_edge_types=["DECLARES"], + ) + assert generate_hints("neighbors", payload) == [] + + +def test_hints_neighbors_multi_origin_fuzzy_emits_once() -> None: + payload = _neighbors_hint_payload( + [ + _edge_result(strategy="phantom", edge_type="CALLS"), + _edge_result(strategy="annotation", edge_type="CALLS"), + ], + requested_edge_types=["CALLS"], + ) + hints = generate_hints("neighbors", payload) + assert hints.count(mcp_hints.TPL_NEIGHBORS_FUZZY_STRATEGY) == 1 + + +def test_hints_neighbors_layer_a_meta_no_fuzzy_hint() -> None: + payload = _neighbors_hint_payload([_edge_result(strategy="layer_a_meta")]) + assert generate_hints("neighbors", payload) == [] + + +def test_hints_neighbors_fuzzy_strategy_neighbors_v2_round_trip(kuzu_graph) -> None: + mid = _method_id_with_fuzzy_calls(kuzu_graph) + out = neighbors_v2(mid, direction="out", edge_types=["CALLS"], graph=kuzu_graph, limit=50) + assert out.success is True + assert out.results + strategies = [e.attrs.get("strategy") for e in out.results] + assert any(s in FUZZY_STRATEGY_SET for s in strategies if isinstance(s, str)) + assert mcp_hints.TPL_NEIGHBORS_FUZZY_STRATEGY in out.hints + assert "brownfield/fallback strategy" in out.hints[0] + + def test_hints_search_weak_structural_signal_emits(monkeypatch, kuzu_graph) -> None: rows = [ { @@ -735,6 +830,7 @@ def test_hints_pagination_none_skips_page_derived_hints() -> None: ), (mcp_hints.TPL_RESOLVE_NONE_TRY_FIND_CLIENT, {"seed": "smartcare-assign-chat"}), (mcp_hints.TPL_RESOLVE_MANY_TIGHTEN, {"n": 10}), + (mcp_hints.TPL_NEIGHBORS_FUZZY_STRATEGY, {}), ], ) def test_hints_template_rendered_length_leq_120(template: str, fmt: dict[str, Any]) -> None: