hyperforge-nucliadb-agentic 1.0.0.post64__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.
- hyperforge_nucliadb_agentic/__init__.py +3 -0
- hyperforge_nucliadb_agentic/agent.py +1642 -0
- hyperforge_nucliadb_agentic/ask/__init__.py +5 -0
- hyperforge_nucliadb_agentic/ask/audit.py +439 -0
- hyperforge_nucliadb_agentic/ask/exceptions.py +50 -0
- hyperforge_nucliadb_agentic/ask/lifespan.py +21 -0
- hyperforge_nucliadb_agentic/ask/model.py +1299 -0
- hyperforge_nucliadb_agentic/ask/predict.py +431 -0
- hyperforge_nucliadb_agentic/ask/predict_models.py +78 -0
- hyperforge_nucliadb_agentic/ask/search/__init__.py +0 -0
- hyperforge_nucliadb_agentic/ask/search/ask.py +1182 -0
- hyperforge_nucliadb_agentic/ask/search/graph_strategy.py +1138 -0
- hyperforge_nucliadb_agentic/ask/search/highlight.py +93 -0
- hyperforge_nucliadb_agentic/ask/search/hydrator.py +29 -0
- hyperforge_nucliadb_agentic/ask/search/metrics.py +112 -0
- hyperforge_nucliadb_agentic/ask/search/parsers/__init__.py +0 -0
- hyperforge_nucliadb_agentic/ask/search/parsers/ask.py +70 -0
- hyperforge_nucliadb_agentic/ask/search/parsers/fetcher.py +192 -0
- hyperforge_nucliadb_agentic/ask/search/parsers/find.py +729 -0
- hyperforge_nucliadb_agentic/ask/search/prompt.py +1298 -0
- hyperforge_nucliadb_agentic/ask/search/rank_fusion.py +157 -0
- hyperforge_nucliadb_agentic/ask/search/rerankers.py +161 -0
- hyperforge_nucliadb_agentic/ask/search/retrieval.py +750 -0
- hyperforge_nucliadb_agentic/ask/search/rpc.py +192 -0
- hyperforge_nucliadb_agentic/ask/settings.py +9 -0
- hyperforge_nucliadb_agentic/ask/utils/ids.py +188 -0
- hyperforge_nucliadb_agentic/ask/utils/proto.py +6 -0
- hyperforge_nucliadb_agentic/ask/utils/responses.py +6 -0
- hyperforge_nucliadb_agentic/ask/utils/text_blocks.py +51 -0
- hyperforge_nucliadb_agentic/config.py +47 -0
- hyperforge_nucliadb_agentic/internal_driver.py +87 -0
- hyperforge_nucliadb_agentic/py.typed +0 -0
- hyperforge_nucliadb_agentic-1.0.0.post64.dist-info/METADATA +24 -0
- hyperforge_nucliadb_agentic-1.0.0.post64.dist-info/RECORD +35 -0
- hyperforge_nucliadb_agentic-1.0.0.post64.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
from nucliadb_models import retrieval as retrieval_models, search as search_models
|
|
2
|
+
from nucliadb_models.common import FieldTypeName, Paragraph
|
|
3
|
+
from nucliadb_models.filters import (
|
|
4
|
+
And,
|
|
5
|
+
DateCreated,
|
|
6
|
+
DateModified,
|
|
7
|
+
Entity,
|
|
8
|
+
Field,
|
|
9
|
+
FieldFilterExpression,
|
|
10
|
+
FieldMimetype,
|
|
11
|
+
FilterExpression,
|
|
12
|
+
Generated,
|
|
13
|
+
Keyword,
|
|
14
|
+
Kind,
|
|
15
|
+
Label,
|
|
16
|
+
Language,
|
|
17
|
+
Not,
|
|
18
|
+
Or,
|
|
19
|
+
OriginCollaborator,
|
|
20
|
+
OriginMetadata,
|
|
21
|
+
OriginPath,
|
|
22
|
+
OriginSource,
|
|
23
|
+
OriginTag,
|
|
24
|
+
ParagraphFilterExpression,
|
|
25
|
+
Resource,
|
|
26
|
+
ResourceMimetype,
|
|
27
|
+
Status,
|
|
28
|
+
)
|
|
29
|
+
from nucliadb_models.labels import translate_alias_to_system_label
|
|
30
|
+
from nucliadb_models.metadata import ResourceProcessingStatus
|
|
31
|
+
from nucliadb_models.retrieval import RetrievalRequest
|
|
32
|
+
from nucliadb_models.search import Filter, FindRequest
|
|
33
|
+
from nucliadb_protos import knowledgebox_pb2
|
|
34
|
+
from nucliadb_sdk import NucliaDBAsync
|
|
35
|
+
from pydantic import ValidationError
|
|
36
|
+
|
|
37
|
+
from hyperforge_nucliadb_agentic.ask import logger
|
|
38
|
+
from hyperforge_nucliadb_agentic.ask.exceptions import (
|
|
39
|
+
InternalParserError,
|
|
40
|
+
InvalidQueryError,
|
|
41
|
+
)
|
|
42
|
+
from hyperforge_nucliadb_agentic.ask.search.parsers.fetcher import (
|
|
43
|
+
Fetcher,
|
|
44
|
+
)
|
|
45
|
+
from hyperforge_nucliadb_agentic.ask.search.rerankers import (
|
|
46
|
+
NoopReranker,
|
|
47
|
+
PredictReranker,
|
|
48
|
+
Reranker,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Filters that end up as a facet
|
|
52
|
+
FacetFilter = (
|
|
53
|
+
OriginTag
|
|
54
|
+
| Label
|
|
55
|
+
| ResourceMimetype
|
|
56
|
+
| FieldMimetype
|
|
57
|
+
| Entity
|
|
58
|
+
| Language
|
|
59
|
+
| OriginMetadata
|
|
60
|
+
| OriginPath
|
|
61
|
+
| Generated
|
|
62
|
+
| Kind
|
|
63
|
+
| OriginCollaborator
|
|
64
|
+
| OriginSource
|
|
65
|
+
| Status
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
DEFAULT_GENERIC_SEMANTIC_THRESHOLD = 0.7
|
|
70
|
+
CLASSIFICATION_LABEL_PREFIX = "/l/"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def parse_find(
|
|
74
|
+
kbid: str, find_request: FindRequest, reader_sdk: NucliaDBAsync
|
|
75
|
+
) -> tuple[Fetcher, RetrievalRequest, Reranker]:
|
|
76
|
+
# This is a thin layer to convert a FindRequest into a RetrievalRequest +
|
|
77
|
+
# some bw/c stuff we need while refactoring and decoupling code
|
|
78
|
+
|
|
79
|
+
fetcher = Fetcher(
|
|
80
|
+
kbid,
|
|
81
|
+
query=find_request.query,
|
|
82
|
+
user_vector=find_request.vector,
|
|
83
|
+
vectorset=find_request.vectorset,
|
|
84
|
+
rephrase=find_request.rephrase,
|
|
85
|
+
rephrase_prompt=find_request.rephrase_prompt,
|
|
86
|
+
generative_model=find_request.generative_model,
|
|
87
|
+
query_image=find_request.query_image,
|
|
88
|
+
)
|
|
89
|
+
parser = FindParser(kbid, find_request, fetcher)
|
|
90
|
+
retrieval_request, reranker = await parser.parse(reader_sdk=reader_sdk)
|
|
91
|
+
return fetcher, retrieval_request, reranker
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class FindParser:
|
|
95
|
+
def __init__(self, kbid: str, item: FindRequest, fetcher: Fetcher):
|
|
96
|
+
self.kbid = kbid
|
|
97
|
+
self.item = item
|
|
98
|
+
self.fetcher = fetcher
|
|
99
|
+
|
|
100
|
+
# cached data while parsing
|
|
101
|
+
self._query: retrieval_models.RawQuery | None = None
|
|
102
|
+
|
|
103
|
+
async def parse(
|
|
104
|
+
self, reader_sdk: NucliaDBAsync
|
|
105
|
+
) -> tuple[RetrievalRequest, Reranker]:
|
|
106
|
+
self._validate_request()
|
|
107
|
+
|
|
108
|
+
top_k = self.item.top_k
|
|
109
|
+
|
|
110
|
+
# parse search types (features)
|
|
111
|
+
|
|
112
|
+
self._query = retrieval_models.RawQuery()
|
|
113
|
+
|
|
114
|
+
if search_models.FindOptions.KEYWORD in self.item.features:
|
|
115
|
+
self._query.keyword = await parse_keyword_query(
|
|
116
|
+
self.item, fetcher=self.fetcher
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if search_models.FindOptions.SEMANTIC in self.item.features:
|
|
120
|
+
self._query.semantic = await parse_semantic_query(
|
|
121
|
+
self.item, fetcher=self.fetcher
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if search_models.FindOptions.RELATIONS in self.item.features:
|
|
125
|
+
# skip, we'll do something about this later on
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
if search_models.FindOptions.GRAPH in self.item.features:
|
|
129
|
+
self._query.graph = await self._parse_graph_query()
|
|
130
|
+
|
|
131
|
+
filters = await self._parse_filters(reader_sdk=reader_sdk)
|
|
132
|
+
|
|
133
|
+
# rank fusion is just forwarded to /retrieve
|
|
134
|
+
rank_fusion = self.item.rank_fusion
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
reranker = self._parse_reranker()
|
|
138
|
+
except ValidationError as exc:
|
|
139
|
+
raise InternalParserError(f"Parsing error in reranker: {exc!s}") from exc
|
|
140
|
+
|
|
141
|
+
# As we'll call /retrieve, that has rank fusion integrated, we have to
|
|
142
|
+
# make sure we ask for enough results to rerank.
|
|
143
|
+
if isinstance(reranker, PredictReranker):
|
|
144
|
+
top_k = max(top_k, reranker.window)
|
|
145
|
+
|
|
146
|
+
retrieval = RetrievalRequest(
|
|
147
|
+
query=self._query,
|
|
148
|
+
top_k=top_k,
|
|
149
|
+
filters=filters,
|
|
150
|
+
rank_fusion=rank_fusion,
|
|
151
|
+
)
|
|
152
|
+
return retrieval, reranker
|
|
153
|
+
|
|
154
|
+
def _validate_request(self):
|
|
155
|
+
# synonyms are not compatible with vector/graph search
|
|
156
|
+
if (
|
|
157
|
+
self.item.with_synonyms
|
|
158
|
+
and self.item.query
|
|
159
|
+
and (
|
|
160
|
+
search_models.FindOptions.SEMANTIC in self.item.features
|
|
161
|
+
or search_models.FindOptions.RELATIONS in self.item.features
|
|
162
|
+
or search_models.FindOptions.GRAPH in self.item.features
|
|
163
|
+
)
|
|
164
|
+
):
|
|
165
|
+
raise InvalidQueryError(
|
|
166
|
+
"synonyms",
|
|
167
|
+
"Search with custom synonyms is only supported on paragraph and document search",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if (
|
|
171
|
+
search_models.FindOptions.SEMANTIC in self.item.features
|
|
172
|
+
and should_disable_vector_search(self.item)
|
|
173
|
+
):
|
|
174
|
+
self.item.features.remove(search_models.FindOptions.SEMANTIC)
|
|
175
|
+
|
|
176
|
+
if (
|
|
177
|
+
self.item.graph_query
|
|
178
|
+
and search_models.FindOptions.GRAPH not in self.item.features
|
|
179
|
+
):
|
|
180
|
+
raise InvalidQueryError(
|
|
181
|
+
"graph_query", "Using a graph query requires enabling graph feature"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
async def _parse_graph_query(self) -> retrieval_models.GraphQuery:
|
|
185
|
+
if self.item.graph_query is None:
|
|
186
|
+
raise InvalidQueryError(
|
|
187
|
+
"graph_query", "Graph query must be provided when using graph search"
|
|
188
|
+
)
|
|
189
|
+
return retrieval_models.GraphQuery(query=self.item.graph_query)
|
|
190
|
+
|
|
191
|
+
async def _parse_filters(
|
|
192
|
+
self, reader_sdk: NucliaDBAsync
|
|
193
|
+
) -> retrieval_models.Filters:
|
|
194
|
+
assert self._query is not None, "query must be parsed before filters"
|
|
195
|
+
|
|
196
|
+
# this is a conversion between /find filters to /retrieve filters. As
|
|
197
|
+
# /find keeps maintaining old filter style, we must convert from one to
|
|
198
|
+
# another
|
|
199
|
+
|
|
200
|
+
has_old_filters = (
|
|
201
|
+
len(self.item.filters) > 0
|
|
202
|
+
or len(self.item.resource_filters) > 0
|
|
203
|
+
or len(self.item.fields) > 0
|
|
204
|
+
or len(self.item.keyword_filters) > 0
|
|
205
|
+
or self.item.range_creation_start is not None
|
|
206
|
+
or self.item.range_creation_end is not None
|
|
207
|
+
or self.item.range_modification_start is not None
|
|
208
|
+
or self.item.range_modification_end is not None
|
|
209
|
+
)
|
|
210
|
+
if self.item.filter_expression is not None and has_old_filters:
|
|
211
|
+
raise InvalidQueryError(
|
|
212
|
+
"filter_expression", "Cannot mix old filters with filter_expression"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
filter_expression = None
|
|
216
|
+
|
|
217
|
+
if has_old_filters:
|
|
218
|
+
# convert old filters into a filter expression
|
|
219
|
+
|
|
220
|
+
operator = FilterExpression.Operator.AND
|
|
221
|
+
field_expression: list[FieldFilterExpression] = []
|
|
222
|
+
paragraph_expression: list[ParagraphFilterExpression] = []
|
|
223
|
+
|
|
224
|
+
if self.item.range_creation_start or self.item.range_creation_end:
|
|
225
|
+
field_expression.append(
|
|
226
|
+
DateCreated(
|
|
227
|
+
since=self.item.range_creation_start,
|
|
228
|
+
until=self.item.range_creation_end,
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
if self.item.range_modification_start or self.item.range_modification_end:
|
|
233
|
+
field_expression.append(
|
|
234
|
+
DateModified(
|
|
235
|
+
since=self.item.range_modification_start,
|
|
236
|
+
until=self.item.range_modification_end,
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
if self.item.filters:
|
|
241
|
+
classification_labels = await self.fetcher.get_classification_labels(
|
|
242
|
+
reader_sdk=reader_sdk
|
|
243
|
+
)
|
|
244
|
+
field_exprs, paragraph_expr = convert_labels_to_filter_expressions(
|
|
245
|
+
self.item.filters, classification_labels
|
|
246
|
+
)
|
|
247
|
+
if field_exprs:
|
|
248
|
+
field_expression.extend(field_exprs)
|
|
249
|
+
if paragraph_expr:
|
|
250
|
+
paragraph_expression.append(paragraph_expr)
|
|
251
|
+
|
|
252
|
+
if self.item.keyword_filters:
|
|
253
|
+
# keyword filters
|
|
254
|
+
for keyword_filter in self.item.keyword_filters:
|
|
255
|
+
if isinstance(keyword_filter, str):
|
|
256
|
+
field_expression.append(Keyword(word=keyword_filter))
|
|
257
|
+
else:
|
|
258
|
+
# model validates that one and only one of these match
|
|
259
|
+
if keyword_filter.all:
|
|
260
|
+
field_expression.append(
|
|
261
|
+
And(
|
|
262
|
+
operands=[
|
|
263
|
+
Keyword(word=word)
|
|
264
|
+
for word in keyword_filter.all
|
|
265
|
+
]
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
elif keyword_filter.any:
|
|
269
|
+
field_expression.append(
|
|
270
|
+
Or(
|
|
271
|
+
operands=[
|
|
272
|
+
Keyword(word=word)
|
|
273
|
+
for word in keyword_filter.any
|
|
274
|
+
]
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
elif keyword_filter.none:
|
|
278
|
+
field_expression.append(
|
|
279
|
+
Not(
|
|
280
|
+
operand=Or(
|
|
281
|
+
operands=[
|
|
282
|
+
Keyword(word=word)
|
|
283
|
+
for word in keyword_filter.none
|
|
284
|
+
]
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
elif keyword_filter.not_all:
|
|
289
|
+
field_expression.append(
|
|
290
|
+
Not(
|
|
291
|
+
operand=And(
|
|
292
|
+
operands=[
|
|
293
|
+
Keyword(word=word)
|
|
294
|
+
for word in keyword_filter.not_all
|
|
295
|
+
]
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
if self.item.fields:
|
|
301
|
+
operands: list[FieldFilterExpression] = []
|
|
302
|
+
for key in self.item.fields:
|
|
303
|
+
parts = key.split("/")
|
|
304
|
+
try:
|
|
305
|
+
field_type = FieldTypeName.from_abbreviation(parts[0])
|
|
306
|
+
except KeyError: # pragma: no cover
|
|
307
|
+
raise InvalidQueryError(
|
|
308
|
+
"fields",
|
|
309
|
+
f"field filter {key} has an invalid field type: {parts[0]}",
|
|
310
|
+
)
|
|
311
|
+
field_id = parts[1] if len(parts) > 1 else None
|
|
312
|
+
operands.append(Field(type=field_type, name=field_id))
|
|
313
|
+
|
|
314
|
+
if len(operands) == 1:
|
|
315
|
+
field_expression.append(operands[0])
|
|
316
|
+
elif len(operands) > 1:
|
|
317
|
+
field_expression.append(Or(operands=operands))
|
|
318
|
+
|
|
319
|
+
if self.item.resource_filters:
|
|
320
|
+
operands = []
|
|
321
|
+
for key in self.item.resource_filters:
|
|
322
|
+
parts = key.split("/")
|
|
323
|
+
if len(parts) == 1:
|
|
324
|
+
operands.append(Resource(id=parts[0]))
|
|
325
|
+
else:
|
|
326
|
+
rid = parts[0]
|
|
327
|
+
field_type = FieldTypeName.from_abbreviation(parts[1])
|
|
328
|
+
field_id = parts[2] if len(parts) > 2 else None
|
|
329
|
+
operands.append(
|
|
330
|
+
And(
|
|
331
|
+
operands=[
|
|
332
|
+
Resource(id=rid),
|
|
333
|
+
Field(type=field_type, name=field_id),
|
|
334
|
+
]
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
if len(operands) == 1:
|
|
339
|
+
field_expression.append(operands[0])
|
|
340
|
+
elif len(operands) > 1:
|
|
341
|
+
field_expression.append(Or(operands=operands))
|
|
342
|
+
|
|
343
|
+
field = None
|
|
344
|
+
if len(field_expression) == 1:
|
|
345
|
+
field = field_expression[0]
|
|
346
|
+
elif len(field_expression) > 1:
|
|
347
|
+
field = And(operands=field_expression)
|
|
348
|
+
|
|
349
|
+
paragraph = None
|
|
350
|
+
if len(paragraph_expression) == 1:
|
|
351
|
+
paragraph = paragraph_expression[0]
|
|
352
|
+
elif len(paragraph_expression) > 1:
|
|
353
|
+
paragraph = And(operands=paragraph_expression)
|
|
354
|
+
|
|
355
|
+
if field or paragraph:
|
|
356
|
+
filter_expression = FilterExpression(
|
|
357
|
+
field=field, paragraph=paragraph, operator=operator
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
if self.item.filter_expression is not None:
|
|
361
|
+
filter_expression = self.item.filter_expression
|
|
362
|
+
|
|
363
|
+
return retrieval_models.Filters(
|
|
364
|
+
filter_expression=filter_expression,
|
|
365
|
+
show_hidden=self.item.show_hidden,
|
|
366
|
+
security=self.item.security,
|
|
367
|
+
with_duplicates=self.item.with_duplicates,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
def _parse_reranker(self) -> Reranker:
|
|
371
|
+
reranker: Reranker
|
|
372
|
+
top_k = self.item.top_k
|
|
373
|
+
|
|
374
|
+
if isinstance(self.item.reranker, search_models.RerankerName):
|
|
375
|
+
if self.item.reranker == search_models.RerankerName.NOOP:
|
|
376
|
+
reranker = NoopReranker()
|
|
377
|
+
|
|
378
|
+
elif self.item.reranker == search_models.RerankerName.PREDICT_RERANKER:
|
|
379
|
+
# for predict rearnker, by default, we want a x2 factor with a
|
|
380
|
+
# top of 200 results
|
|
381
|
+
reranker = PredictReranker(window=min(top_k * 2, 200))
|
|
382
|
+
|
|
383
|
+
else:
|
|
384
|
+
raise InternalParserError(
|
|
385
|
+
f"Unknown reranker algorithm: {self.item.reranker}"
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
elif isinstance(self.item.reranker, search_models.PredictReranker):
|
|
389
|
+
user_window = self.item.reranker.window
|
|
390
|
+
reranker = PredictReranker(window=min(max(user_window or 0, top_k), 200))
|
|
391
|
+
|
|
392
|
+
else:
|
|
393
|
+
raise InternalParserError(f"Unknown reranker {self.item.reranker}")
|
|
394
|
+
|
|
395
|
+
return reranker
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
async def parse_keyword_query(
|
|
399
|
+
item: search_models.BaseSearchRequest,
|
|
400
|
+
*,
|
|
401
|
+
fetcher: Fetcher,
|
|
402
|
+
) -> retrieval_models.KeywordQuery:
|
|
403
|
+
query = item.query
|
|
404
|
+
|
|
405
|
+
# only when a query image is used, we use the rephrased query for keyword
|
|
406
|
+
# search
|
|
407
|
+
if item.query_image is not None:
|
|
408
|
+
rephrased_query = await fetcher.get_rephrased_query()
|
|
409
|
+
if rephrased_query is not None:
|
|
410
|
+
query = rephrased_query
|
|
411
|
+
|
|
412
|
+
min_score = parse_keyword_min_score(item.min_score)
|
|
413
|
+
|
|
414
|
+
return retrieval_models.KeywordQuery(
|
|
415
|
+
query=query,
|
|
416
|
+
# Synonym checks are done at the retrieval endpoint already
|
|
417
|
+
with_synonyms=item.with_synonyms,
|
|
418
|
+
min_score=min_score,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def should_disable_vector_search(request: search_models.BaseSearchRequest) -> bool:
|
|
423
|
+
if has_user_vectors(request):
|
|
424
|
+
return False
|
|
425
|
+
|
|
426
|
+
if is_exact_match_only_query(request):
|
|
427
|
+
return True
|
|
428
|
+
|
|
429
|
+
return is_empty_query(request)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def has_user_vectors(request: search_models.BaseSearchRequest) -> bool:
|
|
433
|
+
return request.vector is not None and len(request.vector) > 0
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def is_exact_match_only_query(request: search_models.BaseSearchRequest) -> bool:
|
|
437
|
+
"""
|
|
438
|
+
'"something"' -> True
|
|
439
|
+
'foo "something" else' -> False
|
|
440
|
+
"""
|
|
441
|
+
query = request.query.strip()
|
|
442
|
+
return len(query) > 0 and query.startswith('"') and query.endswith('"')
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def is_empty_query(request: search_models.BaseSearchRequest) -> bool:
|
|
446
|
+
return len(request.query) == 0
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def parse_keyword_min_score(
|
|
450
|
+
min_score: float | search_models.MinScore | None,
|
|
451
|
+
) -> float:
|
|
452
|
+
# Keep backward compatibility with the deprecated min_score payload
|
|
453
|
+
# parameter being a float (specifying semantic)
|
|
454
|
+
if min_score is None or isinstance(min_score, float):
|
|
455
|
+
return 0.0
|
|
456
|
+
else:
|
|
457
|
+
return min_score.bm25 # type: ignore
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
async def parse_semantic_query(
|
|
461
|
+
item: search_models.SearchRequest | search_models.FindRequest,
|
|
462
|
+
*,
|
|
463
|
+
fetcher: Fetcher,
|
|
464
|
+
) -> retrieval_models.SemanticQuery:
|
|
465
|
+
vectorset = await fetcher.get_vectorset()
|
|
466
|
+
query = await fetcher.get_query_vector()
|
|
467
|
+
|
|
468
|
+
min_score = await parse_semantic_min_score(item.min_score, fetcher=fetcher)
|
|
469
|
+
|
|
470
|
+
return retrieval_models.SemanticQuery(
|
|
471
|
+
query=query, vectorset=vectorset, min_score=min_score
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
async def parse_semantic_min_score(
|
|
476
|
+
min_score: float | search_models.MinScore | None,
|
|
477
|
+
*,
|
|
478
|
+
fetcher: Fetcher,
|
|
479
|
+
) -> float:
|
|
480
|
+
if min_score is None:
|
|
481
|
+
min_score = None
|
|
482
|
+
elif isinstance(min_score, float):
|
|
483
|
+
min_score = min_score
|
|
484
|
+
else:
|
|
485
|
+
min_score = min_score.semantic # type: ignore
|
|
486
|
+
if min_score is None:
|
|
487
|
+
# min score not defined by the user, we'll try to get the default
|
|
488
|
+
# from Predict API
|
|
489
|
+
min_score = await fetcher.get_semantic_min_score()
|
|
490
|
+
if min_score is None:
|
|
491
|
+
logger.warning(
|
|
492
|
+
"Semantic threshold not found in query information, using default",
|
|
493
|
+
extra={"kbid": fetcher.kbid},
|
|
494
|
+
)
|
|
495
|
+
min_score = DEFAULT_GENERIC_SEMANTIC_THRESHOLD
|
|
496
|
+
|
|
497
|
+
return min_score
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def convert_labels_to_filter_expressions(
|
|
501
|
+
label_filters: list[str] | list[Filter],
|
|
502
|
+
classification_labels: knowledgebox_pb2.Labels,
|
|
503
|
+
) -> tuple[list[FieldFilterExpression], ParagraphFilterExpression | None]:
|
|
504
|
+
field_expressions: list[FieldFilterExpression] = []
|
|
505
|
+
paragraph_expressions: list[ParagraphFilterExpression] = []
|
|
506
|
+
|
|
507
|
+
for label_filter in label_filters:
|
|
508
|
+
if isinstance(label_filter, str):
|
|
509
|
+
# translate_label
|
|
510
|
+
if len(label_filter) == 0:
|
|
511
|
+
raise InvalidQueryError("filters", "Invalid empty label")
|
|
512
|
+
if label_filter[0] != "/":
|
|
513
|
+
raise InvalidQueryError(
|
|
514
|
+
"filters",
|
|
515
|
+
f"Invalid label. It must start with a `/`: {label_filter}",
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
label = translate_label(label_filter)
|
|
519
|
+
facet_filter = filter_from_facet(label)
|
|
520
|
+
|
|
521
|
+
if is_paragraph_label(label, classification_labels):
|
|
522
|
+
paragraph_expressions.append(facet_filter) # type: ignore
|
|
523
|
+
else:
|
|
524
|
+
field_expressions.append(facet_filter) # type: ignore
|
|
525
|
+
|
|
526
|
+
else:
|
|
527
|
+
combinator: (
|
|
528
|
+
type[And[FieldFilterExpression]] | type[Or[FieldFilterExpression]]
|
|
529
|
+
)
|
|
530
|
+
if label_filter.all:
|
|
531
|
+
labels = label_filter.all
|
|
532
|
+
combinator, negate = And, False
|
|
533
|
+
elif label_filter.any:
|
|
534
|
+
labels = label_filter.any
|
|
535
|
+
combinator, negate = Or, False
|
|
536
|
+
elif label_filter.none:
|
|
537
|
+
labels = label_filter.none
|
|
538
|
+
combinator, negate = And, True
|
|
539
|
+
elif label_filter.not_all:
|
|
540
|
+
labels = label_filter.not_all
|
|
541
|
+
combinator, negate = Or, True
|
|
542
|
+
else:
|
|
543
|
+
# Empty filter, should not happen due to validation, but skip just in case
|
|
544
|
+
continue
|
|
545
|
+
|
|
546
|
+
# equivalent to split_labels
|
|
547
|
+
field = []
|
|
548
|
+
paragraph = []
|
|
549
|
+
for label in labels:
|
|
550
|
+
label = translate_label(label)
|
|
551
|
+
expr = filter_from_facet(label)
|
|
552
|
+
|
|
553
|
+
if negate:
|
|
554
|
+
expr = Not(operand=expr) # type: ignore
|
|
555
|
+
|
|
556
|
+
if is_paragraph_label(label, classification_labels):
|
|
557
|
+
paragraph.append(expr)
|
|
558
|
+
else:
|
|
559
|
+
field.append(expr)
|
|
560
|
+
|
|
561
|
+
if len(paragraph) > 0 and not (combinator == And and negate is False):
|
|
562
|
+
raise InvalidQueryError(
|
|
563
|
+
"filters",
|
|
564
|
+
"Paragraph labels can only be used with 'all' filter",
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
if len(field) == 1:
|
|
568
|
+
field_expressions.append(field[0]) # type: ignore
|
|
569
|
+
elif len(field) > 1:
|
|
570
|
+
field_expressions.append(combinator(operands=field))
|
|
571
|
+
|
|
572
|
+
if len(paragraph) == 1:
|
|
573
|
+
paragraph_expressions.append(paragraph[0]) # type: ignore
|
|
574
|
+
elif len(paragraph) > 1:
|
|
575
|
+
paragraph_expressions.append(combinator(operands=paragraph))
|
|
576
|
+
|
|
577
|
+
if len(paragraph_expressions) == 1:
|
|
578
|
+
paragraph_expression = paragraph_expressions[0]
|
|
579
|
+
elif len(paragraph_expressions) > 1:
|
|
580
|
+
paragraph_expression = And(operands=paragraph_expressions)
|
|
581
|
+
else:
|
|
582
|
+
paragraph_expression = None
|
|
583
|
+
|
|
584
|
+
return field_expressions, paragraph_expression
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def filter_from_facet(facet: str) -> FacetFilter:
|
|
588
|
+
expr: FacetFilter
|
|
589
|
+
|
|
590
|
+
if facet.startswith("/t/"):
|
|
591
|
+
value = facet.removeprefix("/t/")
|
|
592
|
+
expr = OriginTag(tag=value)
|
|
593
|
+
|
|
594
|
+
elif facet.startswith("/l/"):
|
|
595
|
+
value = facet.removeprefix("/l/")
|
|
596
|
+
parts = value.split("/", maxsplit=1)
|
|
597
|
+
if len(parts) == 1:
|
|
598
|
+
type = parts[0]
|
|
599
|
+
expr = Label(labelset=type)
|
|
600
|
+
else:
|
|
601
|
+
type, subtype = parts
|
|
602
|
+
expr = Label(labelset=type, label=subtype)
|
|
603
|
+
|
|
604
|
+
elif facet.startswith("/n/i/"):
|
|
605
|
+
value = facet.removeprefix("/n/i/")
|
|
606
|
+
parts = value.split("/", maxsplit=1)
|
|
607
|
+
if len(parts) == 1:
|
|
608
|
+
type = parts[0]
|
|
609
|
+
expr = ResourceMimetype(type=type)
|
|
610
|
+
else:
|
|
611
|
+
type, subtype = parts
|
|
612
|
+
expr = ResourceMimetype(type=type, subtype=subtype)
|
|
613
|
+
|
|
614
|
+
elif facet.startswith("/mt/"):
|
|
615
|
+
value = facet.removeprefix("/mt/")
|
|
616
|
+
parts = value.split("/", maxsplit=1)
|
|
617
|
+
if len(parts) == 1:
|
|
618
|
+
type = parts[0]
|
|
619
|
+
expr = FieldMimetype(type=type)
|
|
620
|
+
else:
|
|
621
|
+
type, subtype = parts
|
|
622
|
+
expr = FieldMimetype(type=type, subtype=subtype)
|
|
623
|
+
|
|
624
|
+
elif facet.startswith("/e/"):
|
|
625
|
+
value = facet.removeprefix("/e/")
|
|
626
|
+
parts = value.split("/", maxsplit=1)
|
|
627
|
+
if len(parts) == 1:
|
|
628
|
+
subtype = parts[0]
|
|
629
|
+
expr = Entity(subtype=subtype)
|
|
630
|
+
else:
|
|
631
|
+
subtype, value = parts
|
|
632
|
+
expr = Entity(subtype=subtype, value=value)
|
|
633
|
+
|
|
634
|
+
elif facet.startswith("/s/p"):
|
|
635
|
+
value = facet.removeprefix("/s/p/")
|
|
636
|
+
expr = Language(language=value, only_primary=True)
|
|
637
|
+
|
|
638
|
+
elif facet.startswith("/s/s"):
|
|
639
|
+
value = facet.removeprefix("/s/s/")
|
|
640
|
+
expr = Language(language=value, only_primary=False)
|
|
641
|
+
|
|
642
|
+
elif facet.startswith("/m/"):
|
|
643
|
+
value = facet.removeprefix("/m/")
|
|
644
|
+
parts = value.split("/", maxsplit=1)
|
|
645
|
+
if len(parts) == 1:
|
|
646
|
+
field = parts[0]
|
|
647
|
+
expr = OriginMetadata(field=field)
|
|
648
|
+
else:
|
|
649
|
+
field, value = parts
|
|
650
|
+
expr = OriginMetadata(field=field, value=value)
|
|
651
|
+
|
|
652
|
+
elif facet.startswith("/p/"):
|
|
653
|
+
value = facet.removeprefix("/p/")
|
|
654
|
+
expr = OriginPath(prefix=value)
|
|
655
|
+
|
|
656
|
+
elif facet.startswith("/g/da"):
|
|
657
|
+
value = facet.removeprefix("/g/da")
|
|
658
|
+
expr = expr = Generated(by="data-augmentation")
|
|
659
|
+
if value.removeprefix("/"):
|
|
660
|
+
expr.da_task = value.removeprefix("/")
|
|
661
|
+
|
|
662
|
+
elif facet.startswith("/k/"):
|
|
663
|
+
value = facet.removeprefix("/k/")
|
|
664
|
+
try:
|
|
665
|
+
kind = Paragraph.TypeParagraph(value.upper())
|
|
666
|
+
except ValueError:
|
|
667
|
+
raise InvalidQueryError("filters", f"invalid paragraph kind: {value}")
|
|
668
|
+
expr = Kind(kind=kind)
|
|
669
|
+
|
|
670
|
+
elif facet.startswith("/u/o/"):
|
|
671
|
+
value = facet.removeprefix("/u/o/")
|
|
672
|
+
expr = OriginCollaborator(collaborator=value)
|
|
673
|
+
|
|
674
|
+
elif facet.startswith("/u/s"):
|
|
675
|
+
value = facet.removeprefix("/u/s")
|
|
676
|
+
expr = OriginSource()
|
|
677
|
+
if value.removeprefix("/"):
|
|
678
|
+
expr.id = value.removeprefix("/")
|
|
679
|
+
|
|
680
|
+
elif facet.startswith("/n/s/"):
|
|
681
|
+
value = facet.removeprefix("/n/s/")
|
|
682
|
+
try:
|
|
683
|
+
status = ResourceProcessingStatus(value.upper())
|
|
684
|
+
except ValueError:
|
|
685
|
+
raise InvalidQueryError(
|
|
686
|
+
"filters", f"invalid resource processing status: {value}"
|
|
687
|
+
)
|
|
688
|
+
expr = Status(status=status)
|
|
689
|
+
|
|
690
|
+
else:
|
|
691
|
+
raise InvalidQueryError("filters", f"invalid filter: {facet}")
|
|
692
|
+
|
|
693
|
+
return expr
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def translate_label(literal: str) -> str:
|
|
697
|
+
if len(literal) == 0:
|
|
698
|
+
raise InvalidQueryError("filters", "Invalid empty label")
|
|
699
|
+
if literal[0] != "/":
|
|
700
|
+
raise InvalidQueryError(
|
|
701
|
+
"filters", f"Invalid label. It must start with a `/`: {literal}"
|
|
702
|
+
)
|
|
703
|
+
return translate_alias_to_system_label(literal)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def is_paragraph_label(
|
|
707
|
+
label: str, classification_labels: knowledgebox_pb2.Labels
|
|
708
|
+
) -> bool:
|
|
709
|
+
if len(label) == 0 or label[0] != "/":
|
|
710
|
+
return False
|
|
711
|
+
if not label.startswith(CLASSIFICATION_LABEL_PREFIX):
|
|
712
|
+
return False
|
|
713
|
+
# Classification labels should have the form /l/labelset/label
|
|
714
|
+
# REVIEW: there's no technical reason why this has to be like this (/l/labelset could be valid)
|
|
715
|
+
parts = label.split("/")
|
|
716
|
+
if len(parts) < 4:
|
|
717
|
+
return False
|
|
718
|
+
labelset_id = parts[2]
|
|
719
|
+
|
|
720
|
+
try:
|
|
721
|
+
labelset: knowledgebox_pb2.LabelSet | None = classification_labels.labelset.get(
|
|
722
|
+
labelset_id
|
|
723
|
+
)
|
|
724
|
+
if labelset is None:
|
|
725
|
+
return False
|
|
726
|
+
return knowledgebox_pb2.LabelSet.LabelSetKind.PARAGRAPHS in labelset.kind
|
|
727
|
+
except KeyError:
|
|
728
|
+
# labelset_id not found
|
|
729
|
+
return False
|