diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 969e8a501..7ee5b3421 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -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"}, diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index f20351f7f..fbef883c3 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -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)