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.
Files changed (35) hide show
  1. hyperforge_nucliadb_agentic/__init__.py +3 -0
  2. hyperforge_nucliadb_agentic/agent.py +1642 -0
  3. hyperforge_nucliadb_agentic/ask/__init__.py +5 -0
  4. hyperforge_nucliadb_agentic/ask/audit.py +439 -0
  5. hyperforge_nucliadb_agentic/ask/exceptions.py +50 -0
  6. hyperforge_nucliadb_agentic/ask/lifespan.py +21 -0
  7. hyperforge_nucliadb_agentic/ask/model.py +1299 -0
  8. hyperforge_nucliadb_agentic/ask/predict.py +431 -0
  9. hyperforge_nucliadb_agentic/ask/predict_models.py +78 -0
  10. hyperforge_nucliadb_agentic/ask/search/__init__.py +0 -0
  11. hyperforge_nucliadb_agentic/ask/search/ask.py +1182 -0
  12. hyperforge_nucliadb_agentic/ask/search/graph_strategy.py +1138 -0
  13. hyperforge_nucliadb_agentic/ask/search/highlight.py +93 -0
  14. hyperforge_nucliadb_agentic/ask/search/hydrator.py +29 -0
  15. hyperforge_nucliadb_agentic/ask/search/metrics.py +112 -0
  16. hyperforge_nucliadb_agentic/ask/search/parsers/__init__.py +0 -0
  17. hyperforge_nucliadb_agentic/ask/search/parsers/ask.py +70 -0
  18. hyperforge_nucliadb_agentic/ask/search/parsers/fetcher.py +192 -0
  19. hyperforge_nucliadb_agentic/ask/search/parsers/find.py +729 -0
  20. hyperforge_nucliadb_agentic/ask/search/prompt.py +1298 -0
  21. hyperforge_nucliadb_agentic/ask/search/rank_fusion.py +157 -0
  22. hyperforge_nucliadb_agentic/ask/search/rerankers.py +161 -0
  23. hyperforge_nucliadb_agentic/ask/search/retrieval.py +750 -0
  24. hyperforge_nucliadb_agentic/ask/search/rpc.py +192 -0
  25. hyperforge_nucliadb_agentic/ask/settings.py +9 -0
  26. hyperforge_nucliadb_agentic/ask/utils/ids.py +188 -0
  27. hyperforge_nucliadb_agentic/ask/utils/proto.py +6 -0
  28. hyperforge_nucliadb_agentic/ask/utils/responses.py +6 -0
  29. hyperforge_nucliadb_agentic/ask/utils/text_blocks.py +51 -0
  30. hyperforge_nucliadb_agentic/config.py +47 -0
  31. hyperforge_nucliadb_agentic/internal_driver.py +87 -0
  32. hyperforge_nucliadb_agentic/py.typed +0 -0
  33. hyperforge_nucliadb_agentic-1.0.0.post64.dist-info/METADATA +24 -0
  34. hyperforge_nucliadb_agentic-1.0.0.post64.dist-info/RECORD +35 -0
  35. 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