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 +100 -0
- lexmatch/composer.py +190 -0
- lexmatch/constants.py +124 -0
- lexmatch/contractions.py +100 -0
- lexmatch/exceptions/__init__.py +19 -0
- lexmatch/exceptions/base.py +11 -0
- lexmatch/exceptions/entry.py +20 -0
- lexmatch/exceptions/parsing.py +14 -0
- lexmatch/exceptions/smalltalk.py +17 -0
- lexmatch/exceptions/synonyms.py +16 -0
- lexmatch/exceptions/tuning.py +18 -0
- lexmatch/index.py +948 -0
- lexmatch/models/__init__.py +15 -0
- lexmatch/models/corrections.py +152 -0
- lexmatch/models/entry.py +73 -0
- lexmatch/models/outcome.py +46 -0
- lexmatch/models/reply.py +60 -0
- lexmatch/models/synonyms.py +137 -0
- lexmatch/models/tuning.py +229 -0
- lexmatch/morphology.py +116 -0
- lexmatch/negation.py +95 -0
- lexmatch/parsers/__init__.py +20 -0
- lexmatch/parsers/csv_parser.py +110 -0
- lexmatch/parsers/json_parser.py +127 -0
- lexmatch/parsers/markdown_parser.py +159 -0
- lexmatch/py.typed +0 -0
- lexmatch/questions.py +172 -0
- lexmatch/smalltalk.py +157 -0
- lexmatch/stemming.py +347 -0
- lexmatch/stopwords.py +188 -0
- lexmatch/text.py +14 -0
- lexmatch/utils/__init__.py +13 -0
- lexmatch/utils/text.py +97 -0
- lexmatch-0.1.0.dist-info/METADATA +132 -0
- lexmatch-0.1.0.dist-info/RECORD +37 -0
- lexmatch-0.1.0.dist-info/WHEEL +4 -0
- lexmatch-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
lexmatch/contractions.py
ADDED
|
@@ -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
|
+
"""
|