java-codebase-rag 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mcp_hints.py ADDED
@@ -0,0 +1,748 @@
1
+ """Pure MCP v2 road-sign hint generation (no graph I/O, no search, no LLM).
2
+
3
+ Locked v1 catalog: ``propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md`` Appendix A
4
+ (issue #161 producer/override-route amendments in that appendix).
5
+ v2 resolve + neighbors fuzzy-strategy catalog: ``propose/completed/HINTS-V2-PROPOSE.md`` Appendix A.
6
+ v3 empty-neighbors structural catalog: ``propose/completed/HINTS-V3-PROPOSE.md`` §3.1–3.3.
7
+ v4 success-path catalog: ``propose/completed/HINTS-V4-SUCCESS-PATH-PROPOSE.md``.
8
+ Priority cap: same propose §7.12 / ``plans/completed/PLAN-HINTS.md`` principles.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Literal
14
+
15
+ from java_ontology import EDGE_SCHEMA, FUZZY_STRATEGY_SET
16
+
17
+ # Normative schema description (propose §3.1) — imported by ``mcp_v2`` for Field(description=...).
18
+ MCP_HINTS_FIELD_DESCRIPTION = (
19
+ "Road-sign hints pointing to likely next calls. Each hint is a short string "
20
+ "referencing one MCP V2 tool call. Hints are advisory and may be safely ignored. "
21
+ "Maximum 5 hints per output. Describe-time type rollup hints may recommend "
22
+ "DECLARES.* and OVERRIDDEN_BY.* dot-keys for neighbors() on matching Symbol origins; "
23
+ "empty neighbors structural hints never use "
24
+ "dot-key edge labels. For neighbors with multiple origin ids, empty-result "
25
+ "structural hints describe the first origin only. On neighbors with "
26
+ "edge_types=['CALLS'] only, optional edge_filter projects the ordered CALLS stream "
27
+ "(min_confidence, strategies, callee_declaring_role axes); fail-loud with composed "
28
+ "dot-keys or additional stored labels. include_unresolved interleaves "
29
+ "UnresolvedCallSite rows (mutually exclusive with edge_filter). dedup_calls collapses "
30
+ "identical (origin, callee) CALLS rows."
31
+ )
32
+
33
+ # --- Appendix A verbatim templates (substitute {id}, {kind}, {limit}) ---
34
+
35
+ TPL_DESCRIBE_TYPE_CLIENTS_VIA_MEMBERS = (
36
+ "clients via members: neighbors(['{id}'],'out',['DECLARES.DECLARES_CLIENT'])"
37
+ )
38
+ TPL_DESCRIBE_TYPE_ROUTES_VIA_MEMBERS = (
39
+ "routes via members: neighbors(['{id}'],'out',['DECLARES.EXPOSES'])"
40
+ )
41
+ TPL_DESCRIBE_TYPE_PRODUCERS_VIA_MEMBERS = (
42
+ "producers via members: neighbors(['{id}'],'out',['DECLARES.DECLARES_PRODUCER'])"
43
+ )
44
+ TPL_DESCRIBE_METHOD_OVERRIDERS = "overriders: neighbors(['{id}'],'out',['OVERRIDDEN_BY'])"
45
+ TPL_DESCRIBE_METHOD_CLIENTS_IN_OVERRIDERS = (
46
+ "clients in overriders: neighbors(['{id}'],'out',['OVERRIDDEN_BY.DECLARES_CLIENT'])"
47
+ )
48
+ TPL_DESCRIBE_METHOD_PRODUCERS_IN_OVERRIDERS = (
49
+ "producers in overriders: neighbors(['{id}'],'out',['OVERRIDDEN_BY.DECLARES_PRODUCER'])"
50
+ )
51
+ TPL_DESCRIBE_METHOD_ROUTES_IN_OVERRIDERS = (
52
+ "routes in overriders: neighbors(['{id}'],'out',['OVERRIDDEN_BY.EXPOSES'])"
53
+ )
54
+ TPL_DESCRIBE_METHOD_OUTBOUND_CLIENT = "outbound client: neighbors(['{id}'],'out',['DECLARES_CLIENT'])"
55
+ TPL_DESCRIBE_METHOD_OUTBOUND_PRODUCER = "outbound producer: neighbors(['{id}'],'out',['DECLARES_PRODUCER'])"
56
+ TPL_DESCRIBE_METHOD_INBOUND_ROUTE = "inbound route: neighbors(['{id}'],'out',['EXPOSES'])"
57
+ TPL_DESCRIBE_METHOD_MANY_CALLS = "many CALLS — consider filtering by target microservice"
58
+ TPL_DESCRIBE_ROUTE_DECLARING = "declaring method: neighbors(['{id}'],'in',['EXPOSES'])"
59
+ TPL_DESCRIBE_CLIENT_DECLARING = "declaring method: neighbors(['{id}'],'in',['DECLARES_CLIENT'])"
60
+ TPL_DESCRIBE_PRODUCER_DECLARING = "declaring method: neighbors(['{id}'],'in',['DECLARES_PRODUCER'])"
61
+
62
+ TPL_FIND_EMPTY_RESOLVE = "no matches — try resolve(identifier, hint_kind='{kind}') for canonical lookup"
63
+ TPL_FIND_PAGE_FULL = "result page full at {limit} — narrow filter or paginate"
64
+ TPL_FIND_SUCCESS_HANDLER = "handler: neighbors(['{id}'],'in',['EXPOSES'])"
65
+ TPL_FIND_SUCCESS_HTTP_TARGETS = "HTTP targets: neighbors(['{id}'],'out',['HTTP_CALLS'])"
66
+ TPL_FIND_SUCCESS_ASYNC_TARGETS = "async targets: neighbors(['{id}'],'out',['ASYNC_CALLS'])"
67
+
68
+ _FIND_SUCCESS_MAX_CHARS = 120
69
+
70
+ TPL_NEIGHBORS_WRONG_SUBJECT_KIND = (
71
+ "0 results — '{edge}' connects {src_kind} → {dst_kind}; "
72
+ "this is a {subject_kind}. Try: {canonical_traversal}"
73
+ )
74
+
75
+ TPL_NEIGHBORS_WRONG_DIRECTION = (
76
+ "0 results — '{edge}' is {src_kind} → {dst_kind}; "
77
+ "you requested direction='{requested_dir}'. Try direction='{correct_dir}'."
78
+ )
79
+
80
+ TPL_NEIGHBORS_TYPE_LEVEL_REQUERY = (
81
+ "0 results — '{edge}' lives on methods, not on {subject_kind}. "
82
+ "Try: {canonical_traversal}"
83
+ )
84
+
85
+ TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED = (
86
+ "edges on '{edge}' are emitted by the brownfield resolver — "
87
+ "absence here may mean unresolved (no matching annotation/target), "
88
+ "not absent from the codebase"
89
+ )
90
+
91
+ TPL_SEARCH_WEAK = "results look weak — narrow the query or try find(role=…)"
92
+
93
+ # --- v2: resolve templates (propose/HINTS-V2-PROPOSE.md Appendix A) ---
94
+
95
+ TPL_RESOLVE_NONE_TRY_SEARCH = (
96
+ "no match — try search(query='{identifier}') for ranked fuzzy lookup"
97
+ )
98
+ TPL_RESOLVE_NONE_TRY_FIND_ROUTE = (
99
+ "no match — try find(kind='route', filter={{path_prefix: '{seed}'}})"
100
+ )
101
+ TPL_RESOLVE_NONE_TRY_FIND_CLIENT = (
102
+ "no match — try find(kind='client', filter={{target_service: '{seed}'}})"
103
+ )
104
+ TPL_RESOLVE_MANY_TIGHTEN = (
105
+ "{n} candidates — tighten identifier or pick a candidate by id"
106
+ )
107
+
108
+ _RESOLVE_HINT_MAX_CHARS = 120
109
+ _RESOLVE_WILDCARDS = ("*", "?")
110
+
111
+ TPL_NEIGHBORS_FUZZY_STRATEGY = (
112
+ "some edges resolved via brownfield/fallback strategy — check attrs.strategy on each row"
113
+ )
114
+
115
+ TPL_NEIGHBORS_CALLS_ROLE_FILTER_OTHER_FALLBACK = (
116
+ "0 CALLS matched callee_declaring_role filter but method has many callees — "
117
+ "targets may be OTHER (interface/JDK); try "
118
+ "edge_filter={{exclude_callee_declaring_roles: ['ENTITY','DTO']}} instead of role exact match"
119
+ )
120
+
121
+ TPL_NEIGHBORS_CALLS_NODEFILTER_ROLE_COLLISION = (
122
+ "NodeFilter.role filters the neighbor method's role (usually OTHER), not the callee's "
123
+ "declaring type — use edge_filter={{callee_declaring_role: 'SERVICE'}} (or REPOSITORY) "
124
+ "for CALLS stereotype projection"
125
+ )
126
+
127
+ _CALLS_HIGH_FANOUT_THRESHOLD = 10
128
+
129
+ TPL_NEIGHBORS_CALLS_HIGH_FANOUT = (
130
+ "{n} CALLS on this method; the noisy axes are callee_declaring_role "
131
+ "and per-call-site multiplicity. Try edge_filter={{callee_declaring_role: 'SERVICE'}} "
132
+ "for delegation hops, edge_filter={{exclude_callee_declaring_roles: ['ENTITY','DTO']}} "
133
+ "to drop accessor noise, edge_filter={{min_confidence: 0.5}} to trim low-confidence rows "
134
+ "(exclude_external is find_callers-only, not neighbors), or dedup_calls=True to collapse "
135
+ "identical callees."
136
+ )
137
+
138
+ TPL_NEIGHBORS_CALLS_HAS_UNRESOLVED = (
139
+ "{n} CALLS shown; this method also has {k} unresolved call sites "
140
+ "(see describe(method_id).unresolved_call_sites, or call neighbors with "
141
+ "include_unresolved=True for a source-ordered interleaved view — note "
142
+ "include_unresolved is mutually exclusive with edge_filter)."
143
+ )
144
+
145
+ # v4 neighbors success-path (propose/HINTS-V4-SUCCESS-PATH-PROPOSE.md); N1a/N1b alias describe templates.
146
+ TPL_NEIGHBORS_SUCCESS_HTTP_TARGETS = "HTTP targets: neighbors(client_ids,'out',['HTTP_CALLS'])"
147
+ TPL_NEIGHBORS_SUCCESS_ASYNC_TARGETS = "async targets: neighbors(producer_ids,'out',['ASYNC_CALLS'])"
148
+ TPL_NEIGHBORS_SUCCESS_CALLERS = "callers: neighbors(handler_ids,'in',['CALLS'])"
149
+ TPL_NEIGHBORS_SUCCESS_DECLARING_CLIENT = (
150
+ "declaring method: neighbors(client_ids,'in',['DECLARES_CLIENT'])"
151
+ )
152
+ TPL_NEIGHBORS_SUCCESS_DECLARING_PRODUCER = (
153
+ "declaring method: neighbors(producer_ids,'in',['DECLARES_PRODUCER'])"
154
+ )
155
+ TPL_NEIGHBORS_SUCCESS_HANDLER = "handler: neighbors(route_ids,'in',['EXPOSES'])"
156
+
157
+ _NEIGHBORS_SUCCESS_MAX_CHARS = 120
158
+ _EDGE_DECLARES_CLIENT = frozenset({"DECLARES_CLIENT", "DECLARES.DECLARES_CLIENT"})
159
+ _EDGE_DECLARES_PRODUCER = frozenset({"DECLARES_PRODUCER", "DECLARES.DECLARES_PRODUCER"})
160
+
161
+ # §7.12 priority: DECLARES.* type rollups > OVERRIDDEN_BY.* > leaf follow-ups > meta.
162
+ PRIORITY_DECLARES_TYPE_ROLLUP = 4
163
+ PRIORITY_OVERRIDDEN_AXIS = 3
164
+ PRIORITY_LEAF_FOLLOWUP = 2
165
+ PRIORITY_META = 1
166
+
167
+ _TYPE_SYMBOL_KINDS = frozenset({"class", "interface", "enum", "record", "annotation"})
168
+ _METHOD_SYMBOL_KINDS = frozenset({"method", "constructor"})
169
+
170
+ _COMPOSED_DOT_KEY_PREFIXES = ("DECLARES.", "OVERRIDDEN_BY.")
171
+ # Row 4 (brownfield absence): only when the subject is a resolver endpoint node, not a
172
+ # structurally valid Symbol query that happens to be empty (DECLARES_CLIENT, EXPOSES, …).
173
+ _BROWNFIELD_ABSENCE_SUBJECT_LABELS = frozenset({"Client", "Producer", "Route"})
174
+ _REQUIRED_TRAVERSAL_ROLE_KEYS = frozenset({"type_subject", "member_subject", "alien_subject"})
175
+
176
+ _IDENTIFIER_FILTER_FIELDS: dict[str, tuple[str, ...]] = {
177
+ "symbol": ("fqn_prefix",),
178
+ "route": ("path_prefix",),
179
+ "client": ("target_service", "target_path_prefix"),
180
+ }
181
+
182
+
183
+ def _out_count(edge_summary: dict[str, Any] | None, key: str) -> int:
184
+ if not edge_summary or key not in edge_summary:
185
+ return 0
186
+ cell = edge_summary[key]
187
+ if not isinstance(cell, dict):
188
+ return 0
189
+ return int(cell.get("out", 0) or 0)
190
+
191
+
192
+ def _symbol_declaration_kind(record: dict[str, Any]) -> str | None:
193
+ data = record.get("data")
194
+ if isinstance(data, dict):
195
+ k = data.get("kind")
196
+ if k is not None:
197
+ return str(k).strip() or None
198
+ return None
199
+
200
+
201
+ def _subject_node_label(subject_record: dict[str, Any]) -> str:
202
+ if "producer_kind" in subject_record:
203
+ return "Producer"
204
+ if "client_kind" in subject_record:
205
+ return "Client"
206
+ if "framework" in subject_record:
207
+ return "Route"
208
+ return "Symbol"
209
+
210
+
211
+ def _traversal_role_for_wrong_kind(subject_label: str, subject_record: dict[str, Any]) -> str:
212
+ if subject_label == "Symbol":
213
+ sk = str(subject_record.get("kind") or "")
214
+ if sk in _METHOD_SYMBOL_KINDS:
215
+ return "member_subject"
216
+ if sk in _TYPE_SYMBOL_KINDS:
217
+ return "alien_subject"
218
+ return "alien_subject"
219
+
220
+
221
+ def typical_traversal_for(
222
+ edge: str,
223
+ role_key: str,
224
+ *,
225
+ subject_id: str,
226
+ direction: str,
227
+ ) -> str:
228
+ template = EDGE_SCHEMA[edge].typical_traversals[role_key]
229
+ return template.format(id=subject_id, direction=direction, edge=edge)
230
+
231
+
232
+ def neighbors_empty_hints(
233
+ *,
234
+ subject_record: dict[str, Any],
235
+ requested_edge_types: list[str],
236
+ requested_direction: Literal["in", "out"],
237
+ ) -> list[tuple[int, str]]:
238
+ """Structural empty-neighbors hints from ``EDGE_SCHEMA`` (at most one row 1–3 per edge)."""
239
+ pairs: list[tuple[int, str]] = []
240
+ subject_label = _subject_node_label(subject_record)
241
+ subject_id = str(subject_record.get("id") or "")
242
+
243
+ for edge in requested_edge_types:
244
+ spec = EDGE_SCHEMA.get(edge)
245
+ if spec is None:
246
+ continue
247
+
248
+ if subject_label != spec.src and subject_label != spec.dst:
249
+ role = _traversal_role_for_wrong_kind(subject_label, subject_record)
250
+ trav = typical_traversal_for(
251
+ edge, role, subject_id=subject_id, direction=requested_direction,
252
+ )
253
+ pairs.append(
254
+ (
255
+ PRIORITY_META,
256
+ TPL_NEIGHBORS_WRONG_SUBJECT_KIND.format(
257
+ edge=edge,
258
+ src_kind=spec.src,
259
+ dst_kind=spec.dst,
260
+ subject_kind=subject_label,
261
+ canonical_traversal=trav,
262
+ ),
263
+ )
264
+ )
265
+ continue
266
+
267
+ wrong_direction = spec.src != spec.dst and (
268
+ (requested_direction == "out" and subject_label == spec.dst)
269
+ or (requested_direction == "in" and subject_label == spec.src)
270
+ )
271
+ if wrong_direction:
272
+ correct_dir = "in" if requested_direction == "out" else "out"
273
+ pairs.append(
274
+ (
275
+ PRIORITY_META,
276
+ TPL_NEIGHBORS_WRONG_DIRECTION.format(
277
+ edge=edge,
278
+ src_kind=spec.src,
279
+ dst_kind=spec.dst,
280
+ requested_dir=requested_direction,
281
+ correct_dir=correct_dir,
282
+ ),
283
+ )
284
+ )
285
+ continue
286
+
287
+ if (
288
+ subject_label == "Symbol"
289
+ and str(subject_record.get("kind") or "") in _TYPE_SYMBOL_KINDS
290
+ and spec.member_only
291
+ ):
292
+ trav = typical_traversal_for(
293
+ edge, "type_subject", subject_id=subject_id, direction=requested_direction,
294
+ )
295
+ pairs.append(
296
+ (
297
+ PRIORITY_META,
298
+ TPL_NEIGHBORS_TYPE_LEVEL_REQUERY.format(
299
+ edge=edge,
300
+ subject_kind=subject_label,
301
+ canonical_traversal=trav,
302
+ ),
303
+ )
304
+ )
305
+
306
+ if subject_label in _BROWNFIELD_ABSENCE_SUBJECT_LABELS:
307
+ for edge in requested_edge_types:
308
+ spec = EDGE_SCHEMA.get(edge)
309
+ if spec is not None and spec.brownfield_resolver_sourced:
310
+ pairs.append(
311
+ (
312
+ PRIORITY_META,
313
+ TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED.format(edge=edge),
314
+ )
315
+ )
316
+ break
317
+
318
+ return pairs
319
+
320
+
321
+ def _hint_contains_composed_dotkey(hint: str) -> bool:
322
+ return any(prefix in hint for prefix in _COMPOSED_DOT_KEY_PREFIXES)
323
+
324
+
325
+ def _filter_neighbors_dotkey_hints(pairs: list[tuple[int, str]]) -> list[tuple[int, str]]:
326
+ return [(pri, text) for pri, text in pairs if not _hint_contains_composed_dotkey(text)]
327
+
328
+
329
+ def _neighbors_success_subject_is_type(subject_record: dict[str, Any]) -> bool:
330
+ return (
331
+ _subject_node_label(subject_record) == "Symbol"
332
+ and str(subject_record.get("kind") or "") in _TYPE_SYMBOL_KINDS
333
+ )
334
+
335
+
336
+ def _neighbors_results_homogeneous(
337
+ results: list[dict[str, Any]],
338
+ *,
339
+ endpoint_kind: str | None = None,
340
+ symbol_kinds: frozenset[str] | None = None,
341
+ ) -> bool:
342
+ if not results:
343
+ return False
344
+ for row in results:
345
+ other = row.get("other")
346
+ if not isinstance(other, dict):
347
+ return False
348
+ ok = str(other.get("kind") or "")
349
+ if endpoint_kind is not None and ok != endpoint_kind:
350
+ return False
351
+ if symbol_kinds is not None:
352
+ if ok != "symbol":
353
+ return False
354
+ if str(other.get("symbol_kind") or "") not in symbol_kinds:
355
+ return False
356
+ return True
357
+
358
+
359
+ def _append_neighbors_success_hint(pairs: list[tuple[int, str]], text: str) -> None:
360
+ # v4 neighbors cap only (describe uses the same N1a/N1b templates without this gate).
361
+ if text and len(text) <= _NEIGHBORS_SUCCESS_MAX_CHARS:
362
+ pairs.append((PRIORITY_LEAF_FOLLOWUP, text))
363
+
364
+
365
+ def neighbors_calls_fanout_hints(payload: dict[str, Any]) -> list[tuple[int, str]]:
366
+ """High-fanout and unresolved-site nudges for CALLS-on-method neighbors (PR-3)."""
367
+ pairs: list[tuple[int, str]] = []
368
+ req_types = payload.get("requested_edge_types")
369
+ if not isinstance(req_types, list) or req_types != ["CALLS"]:
370
+ return pairs
371
+ if payload.get("include_unresolved"):
372
+ return pairs
373
+ page_n = len(list(payload.get("results") or []))
374
+ calls_n = int(payload.get("calls_row_count") or 0) or page_n
375
+ unresolved = int(payload.get("unresolved_count") or 0)
376
+ if not payload.get("edge_filter_provided") and calls_n >= _CALLS_HIGH_FANOUT_THRESHOLD:
377
+ pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_NEIGHBORS_CALLS_HIGH_FANOUT.format(n=calls_n)))
378
+ if unresolved > 0:
379
+ pairs.append(
380
+ (PRIORITY_LEAF_FOLLOWUP, TPL_NEIGHBORS_CALLS_HAS_UNRESOLVED.format(n=page_n, k=unresolved))
381
+ )
382
+ return pairs
383
+
384
+
385
+ def neighbors_calls_meta_hints(payload: dict[str, Any]) -> list[tuple[int, str]]:
386
+ """CALLS-specific hints: role-filter OTHER fallback (Decision 20) and NodeFilter.role trap (30)."""
387
+ pairs: list[tuple[int, str]] = []
388
+ req_types = payload.get("requested_edge_types")
389
+ if not isinstance(req_types, list) or req_types != ["CALLS"]:
390
+ return pairs
391
+ results = list(payload.get("results") or [])
392
+ edge_flt = payload.get("edge_filter") if isinstance(payload.get("edge_filter"), dict) else {}
393
+ node_flt = payload.get("node_filter") if isinstance(payload.get("node_filter"), dict) else {}
394
+ role_exact = edge_flt.get("callee_declaring_role")
395
+ if (
396
+ role_exact in ("SERVICE", "REPOSITORY")
397
+ and not results
398
+ and int(payload.get("unfiltered_calls_count") or 0) >= 5
399
+ ):
400
+ pairs.append((PRIORITY_META, TPL_NEIGHBORS_CALLS_ROLE_FILTER_OTHER_FALLBACK))
401
+ node_role = node_flt.get("role")
402
+ if node_role and results:
403
+ method_rows = [
404
+ r
405
+ for r in results
406
+ if str(((r.get("other") or {}) if isinstance(r.get("other"), dict) else {}).get("symbol_kind") or "")
407
+ == "method"
408
+ ]
409
+ if method_rows:
410
+ other_roles = [
411
+ str(
412
+ ((r.get("other") or {}) if isinstance(r.get("other"), dict) else {}).get("role")
413
+ or ""
414
+ )
415
+ for r in method_rows
416
+ ]
417
+ if other_roles and sum(1 for role in other_roles if role == "OTHER") >= max(
418
+ 1, (len(other_roles) * 3) // 4
419
+ ):
420
+ pairs.append((PRIORITY_META, TPL_NEIGHBORS_CALLS_NODEFILTER_ROLE_COLLISION))
421
+ return pairs
422
+
423
+
424
+ def neighbors_success_hints(payload: dict[str, Any]) -> list[tuple[int, str]]:
425
+ """v4 non-empty neighbors follow-ups (N1a–N7); no graph I/O."""
426
+ if not payload.get("success"):
427
+ return []
428
+ results = list(payload.get("results") or [])
429
+ if not results or int(payload.get("offset") or 0) != 0:
430
+ return []
431
+ req_types = payload.get("requested_edge_types")
432
+ if not isinstance(req_types, list) or len(req_types) != 1:
433
+ return []
434
+ edge = str(req_types[0]).strip()
435
+ if not edge:
436
+ return []
437
+ direction = payload.get("requested_direction")
438
+ if direction not in ("in", "out"):
439
+ return []
440
+
441
+ pairs: list[tuple[int, str]] = []
442
+ origin_id = str(payload.get("origin_id") or "")
443
+ if not origin_id:
444
+ origin_id = str(results[0].get("origin_id") or "")
445
+ subject_record = payload.get("subject_record")
446
+ is_type_subject = (
447
+ isinstance(subject_record, dict) and _neighbors_success_subject_is_type(subject_record)
448
+ )
449
+
450
+ if (
451
+ edge == "DECLARES"
452
+ and direction == "out"
453
+ and is_type_subject
454
+ and _neighbors_results_homogeneous(results, symbol_kinds=_METHOD_SYMBOL_KINDS)
455
+ ):
456
+ if origin_id:
457
+ _append_neighbors_success_hint(
458
+ pairs, TPL_DESCRIBE_TYPE_CLIENTS_VIA_MEMBERS.format(id=origin_id),
459
+ )
460
+ _append_neighbors_success_hint(
461
+ pairs, TPL_DESCRIBE_TYPE_ROUTES_VIA_MEMBERS.format(id=origin_id),
462
+ )
463
+
464
+ if edge in _EDGE_DECLARES_CLIENT and direction == "out":
465
+ if _neighbors_results_homogeneous(results, endpoint_kind="client"):
466
+ _append_neighbors_success_hint(pairs, TPL_NEIGHBORS_SUCCESS_HTTP_TARGETS)
467
+
468
+ if edge in _EDGE_DECLARES_PRODUCER and direction == "out":
469
+ if _neighbors_results_homogeneous(results, endpoint_kind="producer"):
470
+ _append_neighbors_success_hint(pairs, TPL_NEIGHBORS_SUCCESS_ASYNC_TARGETS)
471
+
472
+ if (
473
+ edge == "EXPOSES"
474
+ and direction == "in"
475
+ and _neighbors_results_homogeneous(results, symbol_kinds=_METHOD_SYMBOL_KINDS)
476
+ ):
477
+ _append_neighbors_success_hint(pairs, TPL_NEIGHBORS_SUCCESS_CALLERS)
478
+
479
+ if edge == "HTTP_CALLS" and direction == "in":
480
+ if _neighbors_results_homogeneous(results, endpoint_kind="client"):
481
+ _append_neighbors_success_hint(pairs, TPL_NEIGHBORS_SUCCESS_DECLARING_CLIENT)
482
+
483
+ if edge == "ASYNC_CALLS" and direction == "in":
484
+ if _neighbors_results_homogeneous(results, endpoint_kind="producer"):
485
+ _append_neighbors_success_hint(pairs, TPL_NEIGHBORS_SUCCESS_DECLARING_PRODUCER)
486
+
487
+ if edge == "DECLARES.EXPOSES" and direction == "out":
488
+ if _neighbors_results_homogeneous(results, endpoint_kind="route"):
489
+ _append_neighbors_success_hint(pairs, TPL_NEIGHBORS_SUCCESS_HANDLER)
490
+
491
+ return pairs
492
+
493
+
494
+ def _find_is_page_full(payload: dict[str, Any], results: list[dict[str, Any]]) -> bool:
495
+ lim = payload.get("limit")
496
+ return (
497
+ lim is not None
498
+ and len(results) >= int(lim)
499
+ and payload.get("has_more_results") is True
500
+ )
501
+
502
+
503
+ def _append_find_success_hint(pairs: list[tuple[int, str]], text: str) -> None:
504
+ if text and len(text) <= _FIND_SUCCESS_MAX_CHARS:
505
+ pairs.append((PRIORITY_LEAF_FOLLOWUP, text))
506
+
507
+
508
+ def find_success_hints(payload: dict[str, Any]) -> list[tuple[int, str]]:
509
+ """v4 non-empty find follow-ups (F1–F3); no graph I/O."""
510
+ if not payload.get("success"):
511
+ return []
512
+ results = list(payload.get("results") or [])
513
+ if not results or _find_is_page_full(payload, results):
514
+ return []
515
+ node_id = str(results[0].get("id") or "")
516
+ if not node_id:
517
+ return []
518
+ kind = str(payload.get("kind") or "")
519
+ pairs: list[tuple[int, str]] = []
520
+ if kind == "route":
521
+ _append_find_success_hint(pairs, TPL_FIND_SUCCESS_HANDLER.format(id=node_id))
522
+ elif kind == "client":
523
+ _append_find_success_hint(pairs, TPL_FIND_SUCCESS_HTTP_TARGETS.format(id=node_id))
524
+ elif kind == "producer":
525
+ _append_find_success_hint(pairs, TPL_FIND_SUCCESS_ASYNC_TARGETS.format(id=node_id))
526
+ return pairs
527
+
528
+
529
+ def _any_fuzzy_strategy(edges: list[dict[str, Any]]) -> bool:
530
+ for e in edges:
531
+ attrs = e.get("attrs") if isinstance(e.get("attrs"), dict) else {}
532
+ s = attrs.get("strategy") if isinstance(attrs, dict) else None
533
+ if isinstance(s, str) and s in FUZZY_STRATEGY_SET:
534
+ return True
535
+ return False
536
+
537
+
538
+ def _find_has_identifier_shaped_filter(kind: str, flt: dict[str, Any]) -> bool:
539
+ for name in _IDENTIFIER_FILTER_FIELDS.get(kind, ()):
540
+ val = flt.get(name)
541
+ if val is None:
542
+ continue
543
+ if isinstance(val, str) and val.strip():
544
+ return True
545
+ if not isinstance(val, str):
546
+ return True
547
+ return False
548
+
549
+
550
+ def finalize_hint_list(scored: list[tuple[int, str]]) -> list[str]:
551
+ """Dedupe identical rendered strings keeping the highest priority; cap to 5 (drop lowest).
552
+
553
+ Within the same priority tier, keep hints in emission order (first scored wins the cap).
554
+ """
555
+ best: dict[str, tuple[int, int]] = {}
556
+ for idx, (pri, text) in enumerate(scored):
557
+ if not text:
558
+ continue
559
+ prev = best.get(text)
560
+ if prev is None or pri > prev[0]:
561
+ best[text] = (pri, idx)
562
+ elif pri == prev[0]:
563
+ best[text] = (pri, min(prev[1], idx))
564
+ ordered = sorted(best.items(), key=lambda kv: (-kv[1][0], kv[1][1]))
565
+ return [text for text, _pri in ordered[:5]]
566
+
567
+
568
+ def generate_hints(
569
+ output_kind: Literal["search", "find", "describe", "neighbors", "resolve"],
570
+ payload: dict[str, Any],
571
+ ) -> list[str]:
572
+ """Return up to 5 road-sign hint strings for a success-only MCP v2 payload dict.
573
+
574
+ For ``search`` / ``find`` / ``describe`` / ``neighbors``, callers must pass
575
+ ``success: True``; this function returns ``[]`` when ``success`` is false or
576
+ missing. The ``resolve`` branch is **status-driven** (``status``,
577
+ ``resolved_identifier``, ``candidates``, optional seeds) and does not require
578
+ ``success`` in the payload; an explicit ``success: False`` still suppresses
579
+ hints (defense in depth).
580
+ """
581
+ pairs: list[tuple[int, str]] = []
582
+
583
+ if output_kind == "resolve":
584
+ if payload.get("success") is False:
585
+ return []
586
+ status = str(payload.get("status") or "")
587
+ if status == "one":
588
+ return []
589
+ if status == "many":
590
+ n = len(payload.get("candidates") or [])
591
+ if n > 1:
592
+ pairs.append((PRIORITY_META, TPL_RESOLVE_MANY_TIGHTEN.format(n=n)))
593
+ return finalize_hint_list(pairs)
594
+ if status == "none":
595
+ identifier = payload.get("resolved_identifier")
596
+ hint_kind = payload.get("hint_kind")
597
+ if not isinstance(identifier, str) or not identifier.strip():
598
+ return finalize_hint_list(pairs)
599
+ if any(w in identifier for w in _RESOLVE_WILDCARDS):
600
+ return finalize_hint_list(pairs)
601
+ rendered: str | None = None
602
+ if hint_kind == "route":
603
+ seed = payload.get("path_prefix_seed")
604
+ if isinstance(seed, str) and seed.strip():
605
+ rendered = TPL_RESOLVE_NONE_TRY_FIND_ROUTE.format(seed=seed)
606
+ elif hint_kind == "client":
607
+ seed = payload.get("target_service_seed")
608
+ if isinstance(seed, str) and seed.strip():
609
+ rendered = TPL_RESOLVE_NONE_TRY_FIND_CLIENT.format(seed=seed)
610
+ else:
611
+ rendered = TPL_RESOLVE_NONE_TRY_SEARCH.format(identifier=identifier)
612
+ if rendered is not None and len(rendered) <= _RESOLVE_HINT_MAX_CHARS:
613
+ pairs.append((PRIORITY_META, rendered))
614
+ return finalize_hint_list(pairs)
615
+ return []
616
+
617
+ if not payload.get("success"):
618
+ return []
619
+
620
+ if output_kind == "search":
621
+ results: list[dict[str, Any]] = list(payload.get("results") or [])
622
+ lim = payload.get("limit")
623
+ if lim is not None and len(results) == int(lim) and results:
624
+ scores = [float(r.get("score", 0.0) or 0.0) for r in results]
625
+ mx = max(scores)
626
+ mn = min(scores)
627
+ if mx > 0.0 and (mx - mn) < 0.1 * mx:
628
+ pairs.append((PRIORITY_META, TPL_SEARCH_WEAK))
629
+ return finalize_hint_list(pairs)
630
+
631
+ if output_kind == "find":
632
+ kind = str(payload.get("kind") or "")
633
+ results = list(payload.get("results") or [])
634
+ flt = payload.get("filter") if isinstance(payload.get("filter"), dict) else {}
635
+ lim = payload.get("limit")
636
+ if not results and _find_has_identifier_shaped_filter(kind, flt):
637
+ pairs.append((PRIORITY_META, TPL_FIND_EMPTY_RESOLVE.format(kind=kind)))
638
+ if _find_is_page_full(payload, results) and lim is not None:
639
+ pairs.append((PRIORITY_META, TPL_FIND_PAGE_FULL.format(limit=int(lim))))
640
+ pairs.extend(find_success_hints(payload))
641
+ return finalize_hint_list(pairs)
642
+
643
+ if output_kind == "neighbors":
644
+ results = list(payload.get("results") or [])
645
+ req_types = payload.get("requested_edge_types")
646
+ if not isinstance(req_types, list):
647
+ req_types = []
648
+ edge_labels = [str(x).strip() for x in req_types if str(x).strip()]
649
+ offset = int(payload.get("offset") or 0)
650
+ empty_pairs: list[tuple[int, str]] = []
651
+ success_pairs: list[tuple[int, str]] = []
652
+ meta_pairs: list[tuple[int, str]] = []
653
+ if not results and edge_labels and offset == 0:
654
+ subject_record = payload.get("subject_record")
655
+ requested_direction = payload.get("requested_direction")
656
+ if (
657
+ isinstance(subject_record, dict)
658
+ and subject_record
659
+ and requested_direction in ("in", "out")
660
+ ):
661
+ empty_pairs.extend(
662
+ neighbors_empty_hints(
663
+ subject_record=subject_record,
664
+ requested_edge_types=edge_labels,
665
+ requested_direction=requested_direction,
666
+ )
667
+ )
668
+ elif results and offset == 0:
669
+ success_pairs = neighbors_success_hints(payload)
670
+ meta_pairs.extend(neighbors_calls_meta_hints(payload))
671
+ meta_pairs.extend(neighbors_calls_fanout_hints(payload))
672
+ if results and _any_fuzzy_strategy(results):
673
+ meta_pairs.append((PRIORITY_META, TPL_NEIGHBORS_FUZZY_STRATEGY))
674
+ return finalize_hint_list(
675
+ _filter_neighbors_dotkey_hints(empty_pairs) + success_pairs + meta_pairs,
676
+ )
677
+
678
+ if output_kind == "describe":
679
+ rec = payload.get("record")
680
+ if not isinstance(rec, dict):
681
+ return []
682
+ node_id = str(rec.get("id") or "")
683
+ if not node_id:
684
+ return []
685
+ kind = str(rec.get("kind") or "")
686
+ es = rec.get("edge_summary")
687
+ edge_summary = es if isinstance(es, dict) else None
688
+
689
+ if kind == "route":
690
+ pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_ROUTE_DECLARING.format(id=node_id)))
691
+ return finalize_hint_list(pairs)
692
+ if kind == "client":
693
+ pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_CLIENT_DECLARING.format(id=node_id)))
694
+ return finalize_hint_list(pairs)
695
+ if kind == "producer":
696
+ pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_PRODUCER_DECLARING.format(id=node_id)))
697
+ return finalize_hint_list(pairs)
698
+
699
+ if kind != "symbol":
700
+ return finalize_hint_list(pairs)
701
+
702
+ decl_kind = _symbol_declaration_kind(rec)
703
+ is_type = decl_kind in _TYPE_SYMBOL_KINDS
704
+ is_method = decl_kind in _METHOD_SYMBOL_KINDS
705
+
706
+ if is_type:
707
+ if _out_count(edge_summary, "DECLARES.DECLARES_CLIENT") > 0:
708
+ pairs.append(
709
+ (PRIORITY_DECLARES_TYPE_ROLLUP, TPL_DESCRIBE_TYPE_CLIENTS_VIA_MEMBERS.format(id=node_id))
710
+ )
711
+ if _out_count(edge_summary, "DECLARES.EXPOSES") > 0:
712
+ pairs.append(
713
+ (PRIORITY_DECLARES_TYPE_ROLLUP, TPL_DESCRIBE_TYPE_ROUTES_VIA_MEMBERS.format(id=node_id))
714
+ )
715
+ if _out_count(edge_summary, "DECLARES.DECLARES_PRODUCER") > 0:
716
+ pairs.append(
717
+ (PRIORITY_DECLARES_TYPE_ROLLUP, TPL_DESCRIBE_TYPE_PRODUCERS_VIA_MEMBERS.format(id=node_id))
718
+ )
719
+ return finalize_hint_list(pairs)
720
+
721
+ if is_method:
722
+ if _out_count(edge_summary, "OVERRIDDEN_BY") > 0:
723
+ pairs.append((PRIORITY_OVERRIDDEN_AXIS, TPL_DESCRIBE_METHOD_OVERRIDERS.format(id=node_id)))
724
+ if _out_count(edge_summary, "OVERRIDDEN_BY.DECLARES_CLIENT") > 0:
725
+ pairs.append(
726
+ (PRIORITY_OVERRIDDEN_AXIS, TPL_DESCRIBE_METHOD_CLIENTS_IN_OVERRIDERS.format(id=node_id))
727
+ )
728
+ if _out_count(edge_summary, "OVERRIDDEN_BY.DECLARES_PRODUCER") > 0:
729
+ pairs.append(
730
+ (PRIORITY_OVERRIDDEN_AXIS, TPL_DESCRIBE_METHOD_PRODUCERS_IN_OVERRIDERS.format(id=node_id))
731
+ )
732
+ if _out_count(edge_summary, "OVERRIDDEN_BY.EXPOSES") > 0:
733
+ pairs.append(
734
+ (PRIORITY_OVERRIDDEN_AXIS, TPL_DESCRIBE_METHOD_ROUTES_IN_OVERRIDERS.format(id=node_id))
735
+ )
736
+ if _out_count(edge_summary, "DECLARES_CLIENT") > 0:
737
+ pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_METHOD_OUTBOUND_CLIENT.format(id=node_id)))
738
+ if _out_count(edge_summary, "DECLARES_PRODUCER") > 0:
739
+ pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_METHOD_OUTBOUND_PRODUCER.format(id=node_id)))
740
+ if _out_count(edge_summary, "EXPOSES") > 0:
741
+ pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_METHOD_INBOUND_ROUTE.format(id=node_id)))
742
+ if _out_count(edge_summary, "CALLS") >= 10:
743
+ pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_METHOD_MANY_CALLS))
744
+ return finalize_hint_list(pairs)
745
+
746
+ return finalize_hint_list(pairs)
747
+
748
+ return []