Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion graphify/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]:
Expand Down
31 changes: 31 additions & 0 deletions tests/test_serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down