diff --git a/graphify/serve.py b/graphify/serve.py index 4c55c6758..a349f511b 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -136,11 +136,38 @@ def _score_nodes(G: nx.Graph, terms: list[str]) -> list[tuple[float, str]]: scored = [] norm_terms = [tok for t in terms for tok in _search_tokens(t)] idf = _compute_idf(G, norm_terms) + # Whole-query string for full-label matching (mirrors _find_node's `term`). + joined = " ".join(norm_terms) + # Weight the full-query bonus by the rarest constituent term so a specific + # multi-word label still outweighs common-token noise; floor at 1.0. + joined_w = max((idf.get(t, 1.0) for t in norm_terms), default=1.0) for nid, data in G.nodes(data=True): norm_label = data.get("norm_label") or _strip_diacritics(data.get("label") or "").lower() bare_label = norm_label.rstrip("()") + # Tokenized form of the label (punctuation stripped, same transform as the + # query). norm_label may still carry punctuation like ':' or '-', which a + # tokenized query can never equal; comparing token-joined forms on both + # sides makes "uoce: dehumidifier driver" match query "uoce dehumidifier + # driver". + label_tokens = " ".join(_search_tokens(data.get("label") or "")) source = (data.get("source_file") or "").lower() score = 0.0 + # Full-query tier: a multi-word query that equals (or prefixes) the whole + # label must dominate the per-token bag-of-words sums below, so `path`/ + # `query` resolve the same node `explain` does (via _find_node). Without + # this, no single token equals a multi-word label, the per-token exact + # tier never fires, and every node sharing the token set ties -> arbitrary + # node-id sort -> wrong/disconnected endpoint -> false "No path found". + if joined: + nid_lower = nid.lower() + if joined in (norm_label, bare_label, label_tokens, nid_lower): + score += _EXACT_MATCH_BONUS * 10 * joined_w + elif ( + norm_label.startswith(joined) + or bare_label.startswith(joined) + or label_tokens.startswith(joined) + ): + score += _PREFIX_MATCH_BONUS * 10 * joined_w for t in norm_terms: w = idf.get(t, 1.0) # Three-tier precedence: exact > prefix > substring (take the @@ -155,7 +182,10 @@ def _score_nodes(G: nx.Graph, terms: list[str]) -> list[tuple[float, str]]: score += _SOURCE_MATCH_BONUS * w if score > 0: scored.append((score, nid)) - return sorted(scored, reverse=True) + # Sort by score desc; break ties toward the shorter label so a concise exact + # match beats a longer superset that happens to share the same score. + scored.sort(key=lambda s: (-s[0], len(G.nodes[s[1]].get("label") or s[1]), s[1])) + return scored def _pick_seeds(scored: list[tuple[float, str]], max_k: int = 3, gap_ratio: float = 0.2) -> list[str]: diff --git a/tests/test_serve.py b/tests/test_serve.py index 5ab6271af..c98dece2b 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -87,6 +87,37 @@ def test_score_nodes_ignores_trailing_punctuation(): assert scored[0][1] == "n1" +def test_score_nodes_multiword_exact_label_outranks_superset(): + """A multi-word query equal to a whole label must resolve uniquely. + + Regression for the `graphify path` "No path found" bug: every node sharing + the query's token set scored identically (no single token equals a + multi-word label, so the per-token exact tier never fired), the tie broke by + arbitrary node-id sort, and a wrong/disconnected endpoint was chosen. The + full-query tier in _score_nodes must make the exact label win strictly. + """ + G = nx.Graph() + # Reproduce the real graph: norm_label keeps punctuation (strip_diacritics + + # lower, NOT tokenized), so the ':' survives. A tokenized query can never + # equal that, which is exactly why the first-cut fix was a no-op for + # punctuated labels. The exact node must still win via the label's tokenized + # form. + def _add(nid, label, src): + G.add_node(nid, label=label, norm_label=label.lower(), + source_file=src, community=0) + + _add("exact", "UOCE: Dehumidifier Driver", "uoce_dehumidifier.yaml") + _add("super", "UOCE: Dehumidifier Driver State Machine", "uoce_dehumidifier.yaml") + _add("decoy", "Dehumidifier Driver Helper", "uoce_dehumidifier.yaml") + + # CLI resolves endpoints as [t.lower() for t in label.split()]. + scored = _score_nodes(G, [t.lower() for t in "UOCE: Dehumidifier Driver".split()]) + + # Resolves uniquely to the exact label, strictly ahead of the superset. + assert scored[0][1] == "exact" + assert scored[0][0] > scored[1][0], "exact label must strictly outrank superset/token-bag matches" + + def test_find_node_ignores_trailing_punctuation(): G = _make_graph() assert _find_node(G, "extract?") == ["n1"]