Merge pull request #1285 from ispyisail/fix/element-search-ranking

Fix symbol search offering coils for "NC contact" (#1178)
This commit is contained in:
ispyisail
2026-10-04 10:54:38 +13:00
committed by GitHub
2 changed files with 90 additions and 6 deletions
+50 -6
View File
@@ -2355,6 +2355,44 @@ def _fold(text: str) -> str:
if not unicodedata.combining(c))
# Spellings of "normally open" and "normally closed" in the collection's names
# and file names, folded to one word each: N/O, NF (French "normalement
# fermé"), the words written out.
_CONTACT_SYNONYMS = (
(re.compile(r"\bnormal(?:ly|ement)\s+(?:closed|fermee?s?)\b"), " nc "),
(re.compile(r"\bnormal(?:ly|ement)\s+(?:open|ouverte?s?)\b"), " no "),
(re.compile(r"\bn\s*/\s*([ocf])\b"), r" n\1 "),
)
def _search_words(text: str) -> list:
"""The words of text as a search sees them: folded, split on anything
that is not a letter or digit (so a path's '_' and '/' separate words),
with every spelling of NO and NC reduced to 'no' and 'nc'."""
t = _fold(text)
for pattern, repl in _CONTACT_SYNONYMS:
t = pattern.sub(repl, t)
return ["nc" if w == "nf" else w for w in re.findall(r"[^\W_]+", t)]
def _word_matches(word: str, words: frozenset) -> bool:
"""A query of one or two letters must be a whole word, so 'nc' does
not find 'remanence' and 'no' does not find 'normal'. Anything longer,
or with a digit, may be part of a word: 'schutz' finds
'Leitungsschutzschalter', '3p' finds '3pn'."""
if word in words:
return True
if len(word) <= 2 and word.isalpha():
return False
return any(word in w for w in words)
# Folders of drawings that are not schematic symbols, or of one maker's
# parts: offered after everything else, so "emergency stop" gives the
# push button before an assembly-plan drawing of one.
_SECONDARY_FOLDER = re.compile(r"(?:^|/)\d+_(?:graphics|manufacturers_articles|miscellaneous_unsorted)/")
def _collection_signature(root: Path):
"""Cheap change detector: file count and newest mtime, no parsing."""
count, newest = 0, 0.0
@@ -2408,7 +2446,7 @@ def _index_collection(root: Path) -> list:
"terminal_names": terminals, # index order, not file order
"terminal_order_ambiguous": ambiguous,
"width": d.get("width"), "height": d.get("height"),
"haystack": _fold(" ".join([*names.values(), rel, kind])),
"haystack": frozenset(_search_words(" ".join([*names.values(), rel, kind]))),
})
_ELEMENT_INDEX[key] = {"sig": sig, "items": items}
return items
@@ -2436,6 +2474,7 @@ def tool_element_search(directory: str, query: str = "", link_type: str | None =
raise ValueError("limit must be >= 1")
words = _fold(query).split()
search = _search_words(query)
matches = []
for it in _index_collection(root):
if link_type and it["link_type"] != link_type:
@@ -2446,17 +2485,19 @@ def tool_element_search(directory: str, query: str = "", link_type: str | None =
continue
if max_terminals is not None and it["terminals"] > max_terminals:
continue
if not all(w in it["haystack"] for w in words):
if not all(_word_matches(w, it["haystack"]) for w in search):
continue
matches.append(it)
# Whole-name hits before substring hits, then shorter names first: a
# search for "coil" should offer "Coil" before "Remanence coil, latching".
# Schematic symbols before drawings and makers' parts; then whole-name
# hits before substring hits, then shorter names first: a search for
# "coil" should offer "Coil" before "Remanence coil, latching".
def rank(it):
name = _fold(it["name"])
secondary = 1 if _SECONDARY_FOLDER.search(it["path"]) else 0
exact = 0 if (words and name == " ".join(words)) else 1
starts = 0 if (words and name.startswith(words[0])) else 1
return (exact, starts, len(it["name"]), it["path"])
return (secondary, exact, starts, len(it["name"]), it["path"])
matches.sort(key=rank)
shown = [{k: v for k, v in it.items() if k not in ("haystack", "names", "file")}
@@ -4780,7 +4821,10 @@ TOOLS = [
"directory": {"type": "string",
"description": "the collection root, e.g. a checkout's elements/ directory"},
"query": {"type": "string",
"description": "words to find; every word must match some name, the path or the kind"},
"description": "words to find; every word must match some name, the path "
"or the kind (one or two letters, such as NC, only as a "
"whole word). "
"NO/NC, N/O, NF and \"normally open/closed\" are the same"},
"link_type": {"type": "string", "enum": list(LINK_TYPES)},
"kind": {"type": "string", "description": "the element's type information, e.g. coil, protection"},
"min_terminals": {"type": "integer"},
+40
View File
@@ -1630,6 +1630,46 @@ class ElementSearch(unittest.TestCase):
self.assertEqual(m.tool_element_search(str(self.root), "red coil")["total_matches"], 1)
self.assertEqual(m.tool_element_search(str(self.root), "red fuse")["total_matches"], 0)
def test_a_two_letter_word_matches_only_a_whole_word(self):
"""#1178: "NC contact" offered a remanence coil, because "nc" is in
"remanence" and "contact" is in its folder's name."""
self.put("contacts/coil.elmt", {"en": "Remanence coil"})
self.put("contacts/nc.elmt", {"en": "Simple contact (NC)"})
self.put("contacts/klemme.elmt", {"de": "Reihenklemme 3pn"})
names = lambda q: [r["path"] for r in m.tool_element_search(str(self.root), q)["results"]]
self.assertEqual(names("NC contact"), ["common://contacts/nc.elmt"])
# longer words, and short ones with a digit, still match inside a word
self.assertEqual(names("klemme"), ["common://contacts/klemme.elmt"])
self.assertEqual(names("3p"), ["common://contacts/klemme.elmt"])
def test_every_spelling_of_no_and_nc_is_the_same(self):
self.put("a/nf.elmt", {"fr": "Contact simple (NF)"})
self.put("a/no.elmt", {"en": "Contact N/O"})
self.put("a/x_nc.elmt", {"en": "Contact"})
self.put("a/open.elmt", {"en": "Normally open contact"})
g = lambda q: sorted(r["path"][len("common://a/"):]
for r in m.tool_element_search(str(self.root), q)["results"])
for q in ("NC contact", "normally closed contact", "NF contact", "contact normalement fermé"):
with self.subTest(q=q):
self.assertEqual(g(q), ["nf.elmt", "x_nc.elmt"])
for q in ("NO contact", "normally open contact", "N/O contact", "contact normalement ouvert"):
with self.subTest(q=q):
self.assertEqual(g(q), ["no.elmt", "open.elmt"])
def test_schematic_symbols_come_before_drawings_and_makers_parts(self):
"""#1178: "emergency stop" offered a maker's part and two
assembly-plan drawings before the push button."""
self.put("10_electric/20_manufacturers_articles/idec/my_emg.elmt", {"en": "Emergency stop"})
self.put("10_electric/98_graphics/99_assembly_plan/au.elmt", {"en": "Emergency stop"})
self.put("10_electric/99_miscellaneous_unsorted/au.elmt", {"en": "Emergency stop"})
self.put("10_electric/10_allpole/20_push_buttons/au.elmt", {"en": "Emergency stop (NC)"})
self.put("cadtb/estop.elmt", {"en": "Emergency stop, own"})
r = m.tool_element_search(str(self.root), "emergency stop")["results"]
self.assertEqual([e["path"] for e in r[:2]],
["common://10_electric/10_allpole/20_push_buttons/au.elmt",
"common://cadtb/estop.elmt"])
self.assertEqual(len(r), 5)
def test_filters(self):
self.put("a/m.elmt", {"en": "Coil"}, link="master", kind="coil")
self.put("a/s.elmt", {"en": "Contact"}, link="slave", terminals=((0, 0, "n"),) * 1)