lexmatch 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.
lexmatch/__init__.py ADDED
@@ -0,0 +1,100 @@
1
+ """BM25 question matching with phrase scoring, and no dependencies.
2
+
3
+ Built for the case where a vector database is more machinery than the problem
4
+ deserves: a few dozen to a few thousand short question/answer pairs, where the
5
+ answers are written by hand and the matching has to be predictable.
6
+
7
+ from lexmatch import Entry, Index
8
+
9
+ index = Index([
10
+ Entry("What do you do?", "I build backend services.",
11
+ aliases=["what is your job", "what is your role"]),
12
+ Entry("Are you open to remote work?", "Yes, and to relocation."),
13
+ ])
14
+
15
+ entry, score = index.answer("what is your role")
16
+ # -> the first entry, 1.0
17
+
18
+ Two things it does that plain BM25 does not:
19
+
20
+ **Phrase scoring.** BM25 cannot rank a query made entirely of stopwords —
21
+ "what do you do?" — because those words appear in every entry and carry no
22
+ IDF. Phrasing is scored separately so such questions still match.
23
+
24
+ **A refusal threshold.** Below it, `answer()` returns None. For a question
25
+ answering system this matters more than ranking: a confident wrong answer is
26
+ worse than no answer, and an off-topic question scores 0.2-0.4 on shared
27
+ filler words alone.
28
+
29
+ Scores are scaled to 0..1, so a threshold means the same thing whatever the
30
+ corpus size.
31
+ """
32
+
33
+ from lexmatch.composer import Composer
34
+ from lexmatch.constants import (
35
+ DEFAULT_THRESHOLD,
36
+ LENGTH_PENALTY,
37
+ MIN_CONTENT_OVERLAP,
38
+ TERM_FREQUENCY_SATURATION,
39
+ )
40
+ from lexmatch.contractions import Contractions
41
+ from lexmatch.exceptions import (
42
+ EntryError,
43
+ LexmatchError,
44
+ ParseError,
45
+ SmallTalkError,
46
+ SynonymError,
47
+ TuningError,
48
+ )
49
+ from lexmatch.index import Index
50
+ from lexmatch.models import (
51
+ Corrections,
52
+ Entry,
53
+ Outcome,
54
+ Reply,
55
+ Synonyms,
56
+ Tuning,
57
+ )
58
+ from lexmatch.morphology import Morphology
59
+ from lexmatch.negation import Negation
60
+ from lexmatch.parsers import CsvParser, JsonParser, MarkdownParser
61
+ from lexmatch.questions import QuestionSplitter
62
+ from lexmatch.smalltalk import SmallTalk
63
+ from lexmatch.stemming import Stemmer
64
+ from lexmatch.stopwords import STOPWORDS
65
+ from lexmatch.text import normalize, query_terms
66
+
67
+ __all__ = [
68
+ "DEFAULT_THRESHOLD",
69
+ "LENGTH_PENALTY",
70
+ "MIN_CONTENT_OVERLAP",
71
+ "STOPWORDS",
72
+ "TERM_FREQUENCY_SATURATION",
73
+ "Composer",
74
+ "Contractions",
75
+ "Corrections",
76
+ "CsvParser",
77
+ "Entry",
78
+ "EntryError",
79
+ "Index",
80
+ "JsonParser",
81
+ "LexmatchError",
82
+ "MarkdownParser",
83
+ "Morphology",
84
+ "Negation",
85
+ "Outcome",
86
+ "ParseError",
87
+ "QuestionSplitter",
88
+ "Reply",
89
+ "SmallTalk",
90
+ "SmallTalkError",
91
+ "Stemmer",
92
+ "SynonymError",
93
+ "Synonyms",
94
+ "Tuning",
95
+ "TuningError",
96
+ "__version__",
97
+ "normalize",
98
+ "query_terms",
99
+ ]
100
+ __version__ = "0.1.0"
lexmatch/composer.py ADDED
@@ -0,0 +1,190 @@
1
+ """Turning what was found into something a person would say."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ from lexmatch.models.entry import Entry
8
+ from lexmatch.models.outcome import Outcome
9
+ from lexmatch.models.reply import Reply
10
+
11
+ # Two parts join with "and" and no comma; three or more take commas and a
12
+ # final "and". Named so the comparison says which case it is.
13
+ _PAIR = 2
14
+
15
+
16
+ class Composer:
17
+ """Writes one reply out of several answers, or out of none.
18
+
19
+ `answers()` returns a list and `unanswered()` returns the questions it
20
+ missed, which leaves the caller writing the same joining logic every
21
+ time — and writing it badly, because the cases are not obvious until you
22
+ have hit them. Three answers run together with no connective read as a
23
+ dump; a missed question left unmentioned reads as having been understood;
24
+ an ambiguous question answered at all reads as a guess.
25
+
26
+ The wording is deliberately plain and replaceable. Every phrase is a
27
+ class attribute, so an application with a voice of its own overrides what
28
+ it needs and keeps the sequencing:
29
+
30
+ class Warmer(Composer):
31
+ NOTHING = "Hmm, I'm not sure about that one."
32
+ MISSED_ONE = "I don't know about {questions}, sorry!"
33
+
34
+ Nothing here invents content. Every sentence is either an entry's own
35
+ answer or one of these fixed phrases, so a composed reply can say nothing
36
+ the owner did not write.
37
+ """
38
+
39
+ #: Said when nothing matched at all.
40
+ NOTHING = "I don't have an answer for that."
41
+ #: Appended to `NOTHING` when there are questions worth offering.
42
+ TRY_INSTEAD = "You could try: {questions}"
43
+ #: Said when the question was understood but was not specific enough.
44
+ WHICH = "Which did you mean — {options}?"
45
+ #: Said when the question was asked in the negative.
46
+ NEGATIVE = "I want to be careful with that one — could you rephrase it?"
47
+ #: Appended when some of a compound message went unanswered.
48
+ MISSED_ONE = "I don't have an answer about {questions}, though."
49
+ MISSED_SEVERAL = "I don't have answers about {questions}, though."
50
+ #: Joins the final answer of several.
51
+ AND = "And "
52
+
53
+ @classmethod
54
+ def compose(
55
+ cls,
56
+ answers: Sequence[tuple[Entry, float]],
57
+ missed: Sequence[str] = (),
58
+ ) -> str:
59
+ """Write one reply covering every answer, and what was missed.
60
+
61
+ The order of `answers` is the order asked, which is the order a
62
+ person expects them back in.
63
+ """
64
+ if not answers:
65
+ return cls._nothing(missed)
66
+ sentences = [cls._sentence(entry.answer) for entry, _ in answers]
67
+ if len(sentences) > 1:
68
+ # A connective before the last one, so three answers read as a
69
+ # list rather than as three separate statements that happen to
70
+ # be adjacent.
71
+ sentences[-1] = cls.AND + cls._lower_first(sentences[-1])
72
+ if missed:
73
+ sentences.append(cls._missed(missed))
74
+ return " ".join(sentences)
75
+
76
+ @classmethod
77
+ def compose_reply(cls, reply: Reply) -> str:
78
+ """Write one reply from a single `Reply`, whatever its outcome."""
79
+ if reply.outcome is Outcome.ANSWERED and reply.entry:
80
+ return cls._sentence(reply.entry.answer)
81
+ if reply.outcome is Outcome.AMBIGUOUS:
82
+ return cls.WHICH.format(options=cls._options(reply.alternatives))
83
+ if reply.outcome is Outcome.NEGATED:
84
+ return cls.NEGATIVE
85
+ return cls._nothing(reply.suggestions)
86
+
87
+ @classmethod
88
+ def _nothing(cls, suggestions: Sequence[str]) -> str:
89
+ """Say there is no answer, and offer what there is."""
90
+ if not suggestions:
91
+ return cls.NOTHING
92
+ offered = ", ".join(question.strip().rstrip("?") for question in suggestions)
93
+ return f"{cls.NOTHING} {cls.TRY_INSTEAD.format(questions=offered)}"
94
+
95
+ #: Interrogative openings stripped when a missed question is quoted
96
+ #: back. "I don't have an answer about *what is* your favourite film"
97
+ #: reads as a transcript; "about your favourite film" reads as a reply.
98
+ OPENERS = (
99
+ "what is",
100
+ "what are",
101
+ "what do",
102
+ "whats",
103
+ "when is",
104
+ "when can",
105
+ "where is",
106
+ "who is",
107
+ "how is",
108
+ "how do",
109
+ "how much",
110
+ "why is",
111
+ "do you know",
112
+ "do you have",
113
+ "do you",
114
+ "does your",
115
+ "did you",
116
+ "are you",
117
+ "is your",
118
+ "can you",
119
+ "could you",
120
+ "will you",
121
+ "would you",
122
+ "tell me about",
123
+ )
124
+
125
+ @classmethod
126
+ def _missed(cls, missed: Sequence[str]) -> str:
127
+ """Name the parts of a message that found nothing.
128
+
129
+ Said rather than left out. Answering two of three questions and
130
+ saying nothing about the third reads as having understood it.
131
+ """
132
+ questions = cls._join([cls._as_topic(question) for question in missed])
133
+ template = cls.MISSED_ONE if len(missed) == 1 else cls.MISSED_SEVERAL
134
+ return template.format(questions=questions)
135
+
136
+ @classmethod
137
+ def _options(cls, entries: Sequence[Entry]) -> str:
138
+ """List the contenders for a clarifying question."""
139
+ return " or ".join(entry.question.rstrip("?") for entry in entries)
140
+
141
+ @classmethod
142
+ def _as_topic(cls, question: str) -> str:
143
+ """Turn a question back into the thing it was about.
144
+
145
+ Quoted whole, a missed question reads as a transcript of the input
146
+ rather than as part of a reply. Stripping the interrogative opening
147
+ is enough to make it read as a topic, and anything it does not
148
+ recognize is quoted as it came — a slightly stiff sentence being
149
+ better than a mangled one.
150
+ """
151
+ trimmed = question.strip().rstrip("?").strip()
152
+ lowered = trimmed.lower()
153
+ for opener in cls.OPENERS:
154
+ if lowered.startswith(f"{opener} "):
155
+ return trimmed[len(opener) + 1 :]
156
+ return trimmed
157
+
158
+ @staticmethod
159
+ def _join(parts: Sequence[str]) -> str:
160
+ """Join with commas and a final "and", as somebody would say it."""
161
+ if len(parts) == 1:
162
+ return parts[0]
163
+ if len(parts) == _PAIR:
164
+ return f"{parts[0]} and {parts[1]}"
165
+ return f"{', '.join(parts[:-1])} and {parts[-1]}"
166
+
167
+ @staticmethod
168
+ def _sentence(text: str) -> str:
169
+ """Give the text a full stop if it has no closing punctuation.
170
+
171
+ An owner's answer is their wording and is not otherwise touched —
172
+ but run together without punctuation, several answers read as one
173
+ long sentence that means something else.
174
+ """
175
+ stripped = text.strip()
176
+ if not stripped or stripped[-1] in ".!?…":
177
+ return stripped
178
+ return f"{stripped}."
179
+
180
+ @staticmethod
181
+ def _lower_first(text: str) -> str:
182
+ """Lower-case the first letter after a connective.
183
+
184
+ "And Yes, I have" reads wrong. Only the first character, and only
185
+ when the second is lower-case — so an initialism or a name keeps its
186
+ capital: "And NDAs are fine" stays as it is.
187
+ """
188
+ if len(text) > 1 and text[1].islower():
189
+ return text[0].lower() + text[1:]
190
+ return text
lexmatch/constants.py ADDED
@@ -0,0 +1,124 @@
1
+ """The tunable numbers, in one place.
2
+
3
+ These are what someone adjusts when matching is too eager or too reluctant.
4
+ Scattered through the modules that use them they are effectively
5
+ undiscoverable; collected here they can be read and changed as a set.
6
+
7
+ What is deliberately not here: the private regex `text.py` uses to split
8
+ words. That is implementation detail for one function, and moving it away
9
+ from that function would make both harder to read. A constant earns a place
10
+ in this file by being something a caller might want to change.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ # Below this, `answer()` returns None rather than a weak match.
16
+ #
17
+ # The number is not arbitrary: an off-topic question scores roughly 0.2 to 0.4
18
+ # on shared filler words alone, so the bar has to sit above that band. For
19
+ # question answering, refusing matters more than ranking — a confident wrong
20
+ # answer is worse for the reader than no answer at all.
21
+ DEFAULT_THRESHOLD = 0.45
22
+
23
+ # BM25 parameters, named for what they do rather than for the paper.
24
+ #
25
+ # Saturation is the paper's k1. Above it, repeating a term stops helping —
26
+ # a question mentioning "python" five times is not five times more about
27
+ # Python than one mentioning it once.
28
+ TERM_FREQUENCY_SATURATION = 1.4
29
+
30
+ # The paper's b. How much a long entry is discounted, because a long entry
31
+ # matches more queries by accident than a short one does.
32
+ LENGTH_PENALTY = 0.72
33
+
34
+ # Phrase scoring needs at least this much overlap of substantive words before
35
+ # a shared phrasing counts for anything. Without the guard, two questions
36
+ # beginning "what is" match on that alone.
37
+ MIN_CONTENT_OVERLAP = 1
38
+
39
+ # How alike a mistyped word and a real one have to be before the first is
40
+ # read as the second. Measured as difflib's ratio over the whole token, so
41
+ # 0.8 is roughly one edit in five characters.
42
+ #
43
+ # High on purpose: a wrong correction answers a question nobody asked, which
44
+ # is worse than refusing a question somebody did.
45
+ CORRECTION_SIMILARITY = 0.8
46
+
47
+ # Tokens shorter than this are never corrected. At three characters or fewer
48
+ # almost everything is within one edit of everything else, so "api" would
49
+ # "correct" to "and" and a query about an API would be answered as a
50
+ # conjunction.
51
+ CORRECTION_MINIMUM_LENGTH = 4
52
+
53
+ # How close the runner-up has to be before a question counts as ambiguous
54
+ # rather than answered. Measured as a difference in score.
55
+ #
56
+ # Both entries cleared the threshold, so the question was understood — it
57
+ # just was not specific enough to choose between them. Guessing is a
58
+ # confident wrong answer half the time, and the visitor cannot tell which
59
+ # half they got.
60
+ AMBIGUITY_MARGIN = 0.05
61
+
62
+ # The score a contender has to reach before a tie counts as ambiguity rather
63
+ # than as two weak guesses.
64
+ #
65
+ # A tie is only worth asking about when both candidates are confidently
66
+ # matched. Below the floor the tie is reported as unanswered with the
67
+ # contenders as suggestions, which is honest: several candidates at a middling
68
+ # score mean the question was not understood, and letting the asker choose
69
+ # beats a one-in-four guess.
70
+ #
71
+ # This started at 0.65, chosen when "what do you work on" tied four ways at
72
+ # 0.537 and asked "remote work, or visa sponsorship?" — which read as
73
+ # nonsense. That tie was an artifact of weighting every shared token equally:
74
+ # the four entries shared "work" plus filler and nothing else. Once phrase
75
+ # overlap was weighted by inverse document frequency the same query scored
76
+ # 0.921 against one entry and 0.000 against the rest, so the floor was no
77
+ # longer holding back anything it was built for — and at 0.65 it had started
78
+ # refusing *genuine* ambiguity instead, which measures 0.580 for two rate
79
+ # entries and 0.633 for two notice-period entries.
80
+ #
81
+ # 0.50 sits below both and above the noise. The knob stays because an index
82
+ # whose entries are near-duplicates may want it higher.
83
+ AMBIGUITY_FLOOR = 0.50
84
+
85
+ # How many alternatives to offer for a genuine ambiguity. Two is a question;
86
+ # five is a menu, and a menu is what `suggestions` is for.
87
+ AMBIGUITY_ALTERNATIVES = 3
88
+
89
+
90
+ # The tag `SmallTalk.entries` puts on what it builds, and the marker `Index`
91
+ # reads to know an entry is a social formula rather than a subject.
92
+ #
93
+ # Social phrasings are short, and the length penalty favours short entries, so
94
+ # a message that merely *contains* a pleasantry used to be answered by one.
95
+ # Measured over fifteen simulated visitors: "is the pay good" scored 1.000
96
+ # against the greeting entry, on "good" matching "good morning"; "do you care
97
+ # about the frontend at all" scored 0.957 against the farewell, on "care"
98
+ # matching "take care". Both are real questions with a social word in them.
99
+ #
100
+ # Renaming this is enough to opt a custom set of entries into the same
101
+ # treatment, or out of it.
102
+ SMALL_TALK_TAG = "smalltalk"
103
+
104
+
105
+ # How much a word the index has never seen costs the query's confidence.
106
+ #
107
+ # An unknown word has no inverse document frequency, so it contributes
108
+ # nothing to the score ceiling and is effectively invisible — which leaves a
109
+ # query satisfiable by matching only the words it happened to share. Measured
110
+ # against a twenty-five entry index, "what dashboards do you build" scored
111
+ # 1.000 against "How do you decide build versus buy?" with "dashboards"
112
+ # discarded before anything was scored.
113
+ #
114
+ # Charging for it is nonetheless **off by default**, because measuring it
115
+ # across 133 questions from thirty-seven simulated askers showed it costs
116
+ # more than it saves: at 1.0 the suite fell from 81.2% to 73.7%, because a
117
+ # real question with one unrecognized word has its confidence halved and
118
+ # drops below the threshold. It converts correct answers into refusals faster
119
+ # than it converts wrong answers into refusals.
120
+ #
121
+ # Raise it toward 1.0 for an index where a wrong answer is worse than no
122
+ # answer — a support bot whose topics are deliberately narrow, where most of
123
+ # what it is asked is out of scope and the refusal is the product.
124
+ UNKNOWN_WORD_COST = 0.0
@@ -0,0 +1,100 @@
1
+ """Reading a contraction as the words it stands for.
2
+
3
+ Normalizing drops the apostrophe, so "don't" arrives as `dont` — a token in
4
+ no stopword list and in no entry, which the weighting therefore treats as one
5
+ of the most discriminating words in the index. Measured against a
6
+ twenty-five entry index, `youv` (from "you've") scored 3.06, the same as the
7
+ rarest real term there.
8
+
9
+ Informal writing is full of them, and informal writing is most of what a
10
+ question box receives. So each one is replaced by the words it stands for,
11
+ and those words are ordinary stopwords:
12
+
13
+ dont -> do not
14
+ youve -> you have
15
+ whats -> what is
16
+
17
+ Expanding rather than listing the contracted forms as stopwords, because the
18
+ words are sometimes wanted: "what is your rate" needs its "what is" for
19
+ phrase scoring, and "I don't need sponsorship" needs its "not" for negation
20
+ to see.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from collections.abc import Mapping
26
+ from types import MappingProxyType
27
+ from typing import ClassVar
28
+
29
+
30
+ class Contractions:
31
+ """Expands contracted forms, with the apostrophe already gone.
32
+
33
+ Replace `FORMS` to add to them, or to drop one whose expansion is wrong
34
+ for a particular corpus.
35
+ """
36
+
37
+ #: Keyed on the apostrophe-stripped form, because that is what reaches
38
+ #: this after normalization — which also means "dont" and "don't" are
39
+ #: handled by one entry.
40
+ #:
41
+ #: Ambiguous forms are deliberately absent, because expanding them is
42
+ #: wrong as often as it is right: "ill" is a word, and so are "well",
43
+ #: "id", "wed", "shed" and "lets". "its", "were", "hes" and "shes" are
44
+ #: left alone for the same reason, and the first two are stopwords
45
+ #: already.
46
+ FORMS: ClassVar[Mapping[str, tuple[str, ...]]] = MappingProxyType(
47
+ {
48
+ "im": ("i", "am"),
49
+ "ive": ("i", "have"),
50
+ "youve": ("you", "have"),
51
+ "weve": ("we", "have"),
52
+ "theyve": ("they", "have"),
53
+ "youre": ("you", "are"),
54
+ "theyre": ("they", "are"),
55
+ "whats": ("what", "is"),
56
+ "thats": ("that", "is"),
57
+ "hows": ("how", "is"),
58
+ "wheres": ("where", "is"),
59
+ "theres": ("there", "is"),
60
+ "whos": ("who", "is"),
61
+ "whens": ("when", "is"),
62
+ "whys": ("why", "is"),
63
+ "youd": ("you", "would"),
64
+ "youll": ("you", "will"),
65
+ "theyll": ("they", "will"),
66
+ "dont": ("do", "not"),
67
+ "doesnt": ("does", "not"),
68
+ "didnt": ("did", "not"),
69
+ "cant": ("can", "not"),
70
+ "cannot": ("can", "not"),
71
+ "couldnt": ("could", "not"),
72
+ "wouldnt": ("would", "not"),
73
+ "shouldnt": ("should", "not"),
74
+ "wont": ("will", "not"),
75
+ "isnt": ("is", "not"),
76
+ "arent": ("are", "not"),
77
+ "wasnt": ("was", "not"),
78
+ "werent": ("were", "not"),
79
+ "havent": ("have", "not"),
80
+ "hasnt": ("has", "not"),
81
+ "hadnt": ("had", "not"),
82
+ "aint": ("is", "not"),
83
+ "gonna": ("going", "to"),
84
+ "wanna": ("want", "to"),
85
+ "gotta": ("got", "to"),
86
+ }
87
+ )
88
+
89
+ @classmethod
90
+ def expand(cls, words: list[str]) -> list[str]:
91
+ """Replace each contraction with its words, keeping the order."""
92
+ expanded: list[str] = []
93
+ for word in words:
94
+ expanded.extend(cls.FORMS.get(word, (word,)))
95
+ return expanded
96
+
97
+ @classmethod
98
+ def holds(cls, word: str) -> bool:
99
+ """Whether this word is a contraction this knows how to expand."""
100
+ return word in cls.FORMS
@@ -0,0 +1,19 @@
1
+ """Every error this library raises."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from lexmatch.exceptions.base import LexmatchError
6
+ from lexmatch.exceptions.entry import EntryError
7
+ from lexmatch.exceptions.parsing import ParseError
8
+ from lexmatch.exceptions.smalltalk import SmallTalkError
9
+ from lexmatch.exceptions.synonyms import SynonymError
10
+ from lexmatch.exceptions.tuning import TuningError
11
+
12
+ __all__ = [
13
+ "EntryError",
14
+ "LexmatchError",
15
+ "ParseError",
16
+ "SmallTalkError",
17
+ "SynonymError",
18
+ "TuningError",
19
+ ]
@@ -0,0 +1,11 @@
1
+ """The base every lexmatch error derives from."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class LexmatchError(Exception):
7
+ """Something this library was asked to do that it cannot.
8
+
9
+ One base so an application can catch everything from here with a single
10
+ `except`, whichever part of the library raised it.
11
+ """
@@ -0,0 +1,20 @@
1
+ """An entry that cannot answer anything."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from lexmatch.exceptions.base import LexmatchError
6
+
7
+
8
+ class EntryError(LexmatchError):
9
+ """An entry is missing the question or the answer it needs.
10
+
11
+ Raised when the entry is built rather than when a query fails to match
12
+ it, because the two are indistinguishable afterwards: an entry with a
13
+ blank question scores zero against everything, exactly as an entry on a
14
+ subject nobody asks about does. One is a bug in the question set and the
15
+ other is normal, and nothing downstream can tell them apart.
16
+
17
+ Question sets are generated as often as they are written — from a
18
+ spreadsheet export, a content system, a CV parser — and a blank row is
19
+ the ordinary failure of all three.
20
+ """
@@ -0,0 +1,14 @@
1
+ """A question set that could not be read."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from lexmatch.exceptions.base import LexmatchError
6
+
7
+
8
+ class ParseError(LexmatchError):
9
+ """A question set file is not shaped the way its format requires.
10
+
11
+ Carries where the problem is — a line number, a row number, a key —
12
+ because a question set is usually long and generated, and "something is
13
+ wrong with your file" costs whoever owns it an afternoon.
14
+ """
@@ -0,0 +1,17 @@
1
+ """Raised when small talk is configured for an intent that does not exist."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from lexmatch.exceptions.base import LexmatchError
6
+
7
+
8
+ class SmallTalkError(LexmatchError, KeyError):
9
+ """A small-talk intent this library does not know.
10
+
11
+ Raised rather than ignored. A typo in an intent name leaves that handler
12
+ silently absent, which looks exactly like one that is working right up
13
+ until a visitor says hello and gets a refusal.
14
+
15
+ Also a `KeyError`, since naming an intent is a lookup and that is what a
16
+ caller validating one will already be catching.
17
+ """
@@ -0,0 +1,16 @@
1
+ """Raised when a synonym map cannot work."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from lexmatch.exceptions.base import LexmatchError
6
+
7
+
8
+ class SynonymError(LexmatchError, ValueError):
9
+ """A synonym is declared in a way that could never match.
10
+
11
+ A multi-word key is the common case: expansion happens one token at a
12
+ time, so `{"work from home": "remote"}` would never fire. Raised rather
13
+ than ignored, because a synonym map that silently does nothing looks
14
+ exactly like one that is working — the queries it was meant to catch keep
15
+ scoring zero and the cause is invisible.
16
+ """
@@ -0,0 +1,18 @@
1
+ """Raised when scoring is configured in a way that cannot work."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from lexmatch.exceptions.base import LexmatchError
6
+
7
+
8
+ class TuningError(LexmatchError, ValueError):
9
+ """A tuning value is outside the range it has to be in.
10
+
11
+ Raised at construction rather than at scoring time. A threshold above 1
12
+ answers nothing and a saturation of 0 divides by zero, and both would
13
+ otherwise surface as an index that silently never matches — which reads
14
+ as "my questions are worded badly" rather than as a configuration error.
15
+
16
+ Also a `ValueError`, because that is what a caller validating input will
17
+ already be catching.
18
+ """