orchestrator-core 4.4.1__py3-none-any.whl → 4.5.0a2__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 (54) hide show
  1. orchestrator/__init__.py +26 -2
  2. orchestrator/agentic_app.py +84 -0
  3. orchestrator/api/api_v1/api.py +10 -0
  4. orchestrator/api/api_v1/endpoints/search.py +277 -0
  5. orchestrator/app.py +32 -0
  6. orchestrator/cli/index_llm.py +73 -0
  7. orchestrator/cli/main.py +22 -1
  8. orchestrator/cli/resize_embedding.py +135 -0
  9. orchestrator/cli/search_explore.py +208 -0
  10. orchestrator/cli/speedtest.py +151 -0
  11. orchestrator/db/models.py +37 -1
  12. orchestrator/llm_settings.py +51 -0
  13. orchestrator/migrations/versions/schema/2025-08-12_52b37b5b2714_search_index_model_for_llm_integration.py +95 -0
  14. orchestrator/schemas/search.py +117 -0
  15. orchestrator/search/__init__.py +12 -0
  16. orchestrator/search/agent/__init__.py +8 -0
  17. orchestrator/search/agent/agent.py +47 -0
  18. orchestrator/search/agent/prompts.py +87 -0
  19. orchestrator/search/agent/state.py +8 -0
  20. orchestrator/search/agent/tools.py +236 -0
  21. orchestrator/search/core/__init__.py +0 -0
  22. orchestrator/search/core/embedding.py +64 -0
  23. orchestrator/search/core/exceptions.py +22 -0
  24. orchestrator/search/core/types.py +281 -0
  25. orchestrator/search/core/validators.py +27 -0
  26. orchestrator/search/docs/index.md +37 -0
  27. orchestrator/search/docs/running_local_text_embedding_inference.md +45 -0
  28. orchestrator/search/filters/__init__.py +27 -0
  29. orchestrator/search/filters/base.py +275 -0
  30. orchestrator/search/filters/date_filters.py +75 -0
  31. orchestrator/search/filters/definitions.py +93 -0
  32. orchestrator/search/filters/ltree_filters.py +43 -0
  33. orchestrator/search/filters/numeric_filter.py +60 -0
  34. orchestrator/search/indexing/__init__.py +3 -0
  35. orchestrator/search/indexing/indexer.py +323 -0
  36. orchestrator/search/indexing/registry.py +88 -0
  37. orchestrator/search/indexing/tasks.py +53 -0
  38. orchestrator/search/indexing/traverse.py +322 -0
  39. orchestrator/search/retrieval/__init__.py +3 -0
  40. orchestrator/search/retrieval/builder.py +113 -0
  41. orchestrator/search/retrieval/engine.py +152 -0
  42. orchestrator/search/retrieval/pagination.py +83 -0
  43. orchestrator/search/retrieval/retriever.py +447 -0
  44. orchestrator/search/retrieval/utils.py +106 -0
  45. orchestrator/search/retrieval/validation.py +174 -0
  46. orchestrator/search/schemas/__init__.py +0 -0
  47. orchestrator/search/schemas/parameters.py +116 -0
  48. orchestrator/search/schemas/results.py +64 -0
  49. orchestrator/services/settings_env_variables.py +2 -2
  50. orchestrator/settings.py +1 -1
  51. {orchestrator_core-4.4.1.dist-info → orchestrator_core-4.5.0a2.dist-info}/METADATA +8 -3
  52. {orchestrator_core-4.4.1.dist-info → orchestrator_core-4.5.0a2.dist-info}/RECORD +54 -11
  53. {orchestrator_core-4.4.1.dist-info → orchestrator_core-4.5.0a2.dist-info}/WHEEL +0 -0
  54. {orchestrator_core-4.4.1.dist-info → orchestrator_core-4.5.0a2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,275 @@
1
+ from __future__ import annotations
2
+
3
+ from itertools import count
4
+ from typing import Any, ClassVar, Literal
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
7
+ from sqlalchemy import BinaryExpression, and_, cast, exists, literal, or_, select
8
+ from sqlalchemy.dialects.postgresql import BOOLEAN
9
+ from sqlalchemy.sql.elements import ColumnElement
10
+ from sqlalchemy_utils.types.ltree import Ltree
11
+
12
+ from orchestrator.db.models import AiSearchIndex
13
+ from orchestrator.search.core.types import BooleanOperator, FieldType, FilterOp, SQLAColumn, UIType
14
+
15
+ from .date_filters import DateFilter
16
+ from .ltree_filters import LtreeFilter
17
+ from .numeric_filter import NumericFilter
18
+
19
+
20
+ class EqualityFilter(BaseModel):
21
+ op: Literal[FilterOp.EQ, FilterOp.NEQ]
22
+ value: Any
23
+
24
+ def to_expression(self, column: SQLAColumn, path: str) -> BinaryExpression[bool] | ColumnElement[bool]:
25
+ if isinstance(self.value, bool):
26
+ colb = cast(column, BOOLEAN)
27
+ return colb.is_(self.value) if self.op == FilterOp.EQ else ~colb.is_(self.value)
28
+ sv = str(self.value)
29
+ return (column == sv) if self.op == FilterOp.EQ else (column != sv)
30
+
31
+
32
+ class StringFilter(BaseModel):
33
+ op: Literal[FilterOp.LIKE]
34
+ value: str
35
+
36
+ def to_expression(self, column: SQLAColumn, path: str) -> ColumnElement[bool]:
37
+ return column.like(self.value)
38
+
39
+ @model_validator(mode="after")
40
+ def validate_like_pattern(self) -> StringFilter:
41
+ """If the operation is 'like', the value must contain a wildcard."""
42
+ if self.op == FilterOp.LIKE:
43
+ if "%" not in self.value and "_" not in self.value:
44
+ raise ValueError("The value for a 'like' operation must contain a wildcard character ('%' or '_').")
45
+ return self
46
+
47
+
48
+ # Order matters! Ambiguous ops (like 'eq') are resolved by first matching filter
49
+ FilterCondition = (
50
+ DateFilter # DATETIME
51
+ | NumericFilter # INT/FLOAT
52
+ | StringFilter # STRING TODO: convert to hybrid search?
53
+ | LtreeFilter # Path
54
+ | EqualityFilter # BOOLEAN/UUID/BLOCK/RESOURCE_TYPE - most generic, try last
55
+ )
56
+
57
+
58
+ class PathFilter(BaseModel):
59
+
60
+ path: str = Field(description="The ltree path of the field to filter on, e.g., 'subscription.customer_id'.")
61
+ condition: FilterCondition = Field(description="The filter condition to apply.")
62
+
63
+ value_kind: UIType
64
+
65
+ model_config = ConfigDict(
66
+ json_schema_extra={
67
+ "examples": [
68
+ {"path": "subscription.status", "condition": {"op": "eq", "value": "active"}, "value_kind": "string"},
69
+ {
70
+ "path": "subscription.customer_id",
71
+ "condition": {"op": "neq", "value": "acme"},
72
+ "value_kind": "string",
73
+ },
74
+ {
75
+ "path": "subscription.start_date",
76
+ "condition": {"op": "gt", "value": "2025-01-01"},
77
+ "value_kind": "datetime",
78
+ },
79
+ {
80
+ "path": "subscription.end_date",
81
+ "condition": {
82
+ "op": "between",
83
+ "value": {"start": "2025-06-01", "end": "2025-07-01"},
84
+ },
85
+ "value_kind": "datetime",
86
+ },
87
+ {
88
+ "path": "subscription",
89
+ "condition": {"op": "has_component", "value": "node"},
90
+ "value_kind": "component",
91
+ },
92
+ ]
93
+ }
94
+ )
95
+
96
+ @model_validator(mode="before")
97
+ @classmethod
98
+ def _transfer_path_to_value_if_needed(cls, data: Any) -> Any:
99
+ """Transform for path-only filters.
100
+
101
+ If `op` is `has_component`, `not_has_component`, or `ends_with` and no `value` is
102
+ provided in the `condition`, this validator will automatically use the `path`
103
+ field as the `value` and set the `path` to a wildcard '*' for the query.
104
+ """
105
+ if isinstance(data, dict):
106
+ path = data.get("path")
107
+ condition = data.get("condition")
108
+
109
+ if path and isinstance(condition, dict):
110
+ op = condition.get("op")
111
+ value = condition.get("value")
112
+
113
+ path_only_ops = [FilterOp.HAS_COMPONENT, FilterOp.NOT_HAS_COMPONENT, FilterOp.ENDS_WITH]
114
+
115
+ if op in path_only_ops and value is None:
116
+ condition["value"] = path
117
+ data["path"] = "*"
118
+ return data
119
+
120
+ def to_expression(self, value_column: SQLAColumn, value_type_column: SQLAColumn) -> ColumnElement[bool]:
121
+ """Convert the path filter into a SQLAlchemy expression with type safety.
122
+
123
+ This method creates a type guard to ensure we only match compatible field types,
124
+ then delegates to the specific filter condition.
125
+
126
+ Parameters
127
+ ----------
128
+ value_column : ColumnElement
129
+ The SQLAlchemy column element representing the value to be filtered.
130
+ value_type_column : ColumnElement
131
+ The SQLAlchemy column element representing the field type.
132
+
133
+ Returns:
134
+ -------
135
+ ColumnElement[bool]
136
+ A SQLAlchemy boolean expression that can be used in a ``WHERE`` clause.
137
+ """
138
+ # Type guard - only match compatible field types
139
+ allowed_field_types = [ft.value for ft in FieldType if UIType.from_field_type(ft) == self.value_kind]
140
+ type_guard = value_type_column.in_(allowed_field_types) if allowed_field_types else literal(True)
141
+
142
+ return and_(type_guard, self.condition.to_expression(value_column, self.path))
143
+
144
+
145
+ class FilterTree(BaseModel):
146
+ op: BooleanOperator = Field(
147
+ description="Operator for grouping conditions in uppercase.", default=BooleanOperator.AND
148
+ )
149
+
150
+ children: list[FilterTree | PathFilter] = Field(min_length=1, description="Path filters or nested groups.")
151
+
152
+ MAX_DEPTH: ClassVar[int] = 5
153
+
154
+ model_config = ConfigDict(
155
+ json_schema_extra={
156
+ "description": (
157
+ "Boolean filter tree. Operators must be UPPERCASE: AND / OR.\n"
158
+ "Node shapes:\n"
159
+ " • Group: {'op':'AND'|'OR', 'children': [<PathFilter|FilterTree>, ...]}\n"
160
+ " • Leaf (PathFilter): {'path':'<ltree>', 'condition': {...}}\n"
161
+ "Rules:\n"
162
+ " • Do NOT put 'op' or 'children' inside a leaf 'condition'.\n"
163
+ f" • Max depth = {MAX_DEPTH}.\n"
164
+ ),
165
+ "examples": [
166
+ {
167
+ "description": "Simple filters",
168
+ "op": "AND",
169
+ "children": [
170
+ {"path": "subscription.status", "condition": {"op": "eq", "value": "active"}},
171
+ {"path": "subscription.start_date", "condition": {"op": "gt", "value": "2021-01-01"}},
172
+ ],
173
+ },
174
+ {
175
+ "description": "Complex filters with OR group",
176
+ "op": "AND",
177
+ "children": [
178
+ {"path": "subscription.start_date", "condition": {"op": "gte", "value": "2024-01-01"}},
179
+ {
180
+ "op": "OR",
181
+ "children": [
182
+ {"path": "subscription.product.name", "condition": {"op": "like", "value": "%fiber%"}},
183
+ {"path": "subscription.customer_id", "condition": {"op": "eq", "value": "Surf"}},
184
+ ],
185
+ },
186
+ ],
187
+ },
188
+ ],
189
+ }
190
+ )
191
+
192
+ @model_validator(mode="after")
193
+ def _validate_depth(self) -> FilterTree:
194
+ def depth(node: "FilterTree | PathFilter") -> int:
195
+ return 1 + max(depth(c) for c in node.children) if isinstance(node, FilterTree) else 1
196
+
197
+ if depth(self) > self.MAX_DEPTH:
198
+ raise ValueError(f"FilterTree nesting exceeds MAX_DEPTH={self.MAX_DEPTH}")
199
+ return self
200
+
201
+ @classmethod
202
+ def from_flat_and(cls, filters: list[PathFilter]) -> FilterTree | None:
203
+ """Wrap a flat list of PathFilter into an AND group (or None)."""
204
+ return None if not filters else cls(op=BooleanOperator.AND, children=list(filters))
205
+
206
+ def get_all_paths(self) -> set[str]:
207
+ """Collects all unique paths from the PathFilter leaves in the tree."""
208
+ return {leaf.path for leaf in self.get_all_leaves()}
209
+
210
+ def get_all_leaves(self) -> list[PathFilter]:
211
+ """Collect all PathFilter leaves in the tree."""
212
+ leaves: list[PathFilter] = []
213
+ for child in self.children:
214
+ if isinstance(child, PathFilter):
215
+ leaves.append(child)
216
+ else:
217
+ leaves.extend(child.get_all_leaves())
218
+ return leaves
219
+
220
+ def to_expression(
221
+ self,
222
+ entity_id_col: SQLAColumn,
223
+ *,
224
+ entity_type_value: str | None = None,
225
+ ) -> ColumnElement[bool]:
226
+ """Compile this tree into a SQLAlchemy boolean expression.
227
+
228
+ Parameters
229
+ ----------
230
+ entity_id_col : SQLAColumn
231
+ Column in the outer query representing the entity ID.
232
+ entity_type_value : str, optional
233
+ If provided, each subquery is additionally constrained to this entity type.
234
+
235
+ Returns:
236
+ -------
237
+ ColumnElement[bool]
238
+ A SQLAlchemy expression suitable for use in a WHERE clause.
239
+ """
240
+ alias_idx = count(1)
241
+
242
+ def leaf_exists(pf: PathFilter) -> ColumnElement[bool]:
243
+ from sqlalchemy.orm import aliased
244
+
245
+ alias = aliased(AiSearchIndex, name=f"flt_{next(alias_idx)}")
246
+
247
+ correlates = [alias.entity_id == entity_id_col]
248
+ if entity_type_value is not None:
249
+ correlates.append(alias.entity_type == entity_type_value)
250
+
251
+ if isinstance(pf.condition, LtreeFilter):
252
+ # row-level predicate is always positive
253
+ positive = pf.condition.to_expression(alias.path, pf.path)
254
+ subq = select(1).select_from(alias).where(and_(*correlates, positive))
255
+ if pf.condition.op == FilterOp.NOT_HAS_COMPONENT:
256
+ return ~exists(subq) # NOT at the entity level
257
+ return exists(subq)
258
+
259
+ # value leaf: path predicate + typed value compare
260
+ if "." not in pf.path:
261
+ path_pred = LtreeFilter(op=FilterOp.ENDS_WITH, value=pf.path).to_expression(alias.path, "")
262
+ else:
263
+ path_pred = alias.path == Ltree(pf.path)
264
+
265
+ value_pred = pf.to_expression(alias.value, alias.value_type)
266
+ subq = select(1).select_from(alias).where(and_(*correlates, path_pred, value_pred))
267
+ return exists(subq)
268
+
269
+ def compile_node(node: FilterTree | PathFilter) -> ColumnElement[bool]:
270
+ if isinstance(node, FilterTree):
271
+ compiled = [compile_node(ch) for ch in node.children]
272
+ return and_(*compiled) if node.op == BooleanOperator.AND else or_(*compiled)
273
+ return leaf_exists(node)
274
+
275
+ return compile_node(self)
@@ -0,0 +1,75 @@
1
+ from datetime import date, datetime
2
+ from typing import Annotated, Any, Literal
3
+
4
+ from dateutil.parser import parse as dt_parse
5
+ from pydantic import BaseModel, BeforeValidator, Field, model_validator
6
+ from sqlalchemy import TIMESTAMP, and_
7
+ from sqlalchemy import cast as sa_cast
8
+ from sqlalchemy.sql.elements import ColumnElement
9
+
10
+ from orchestrator.search.core.types import FilterOp, SQLAColumn
11
+
12
+
13
+ def _validate_date_string(v: Any) -> Any:
14
+ if not isinstance(v, str):
15
+ return v
16
+ try:
17
+ dt_parse(v)
18
+ return v
19
+ except Exception as exc:
20
+ raise ValueError("is not a valid date or datetime string") from exc
21
+
22
+
23
+ DateValue = datetime | date | str
24
+ ValidatedDateValue = Annotated[DateValue, BeforeValidator(_validate_date_string)]
25
+
26
+
27
+ class DateRange(BaseModel):
28
+
29
+ start: ValidatedDateValue
30
+ end: ValidatedDateValue
31
+
32
+ @model_validator(mode="after")
33
+ def _order(self) -> "DateRange":
34
+ to_datetime = dt_parse(str(self.end))
35
+ from_datetime = dt_parse(str(self.start))
36
+ if to_datetime <= from_datetime:
37
+ raise ValueError("'to' must be after 'from'")
38
+ return self
39
+
40
+
41
+ class DateValueFilter(BaseModel):
42
+ """A filter that operates on a single date value."""
43
+
44
+ op: Literal[FilterOp.EQ, FilterOp.NEQ, FilterOp.LT, FilterOp.LTE, FilterOp.GT, FilterOp.GTE]
45
+ value: ValidatedDateValue
46
+
47
+ def to_expression(self, column: SQLAColumn, path: str) -> ColumnElement[bool]:
48
+ date_column = sa_cast(column, TIMESTAMP(timezone=True))
49
+ match self.op:
50
+ case FilterOp.EQ:
51
+ return date_column == self.value
52
+ case FilterOp.NEQ:
53
+ return date_column != self.value
54
+ case FilterOp.LT:
55
+ return date_column < self.value
56
+ case FilterOp.LTE:
57
+ return date_column <= self.value
58
+ case FilterOp.GT:
59
+ return date_column > self.value
60
+ case FilterOp.GTE:
61
+ return date_column >= self.value
62
+
63
+
64
+ class DateRangeFilter(BaseModel):
65
+ """A filter that operates on a range of dates."""
66
+
67
+ op: Literal[FilterOp.BETWEEN]
68
+ value: DateRange
69
+
70
+ def to_expression(self, column: SQLAColumn, path: str) -> ColumnElement[bool]:
71
+ date_column = sa_cast(column, TIMESTAMP(timezone=True))
72
+ return and_(date_column >= self.value.start, date_column < self.value.end)
73
+
74
+
75
+ DateFilter = Annotated[DateValueFilter | DateRangeFilter, Field(discriminator="op")]
@@ -0,0 +1,93 @@
1
+ from orchestrator.search.core.types import FieldType, FilterOp, UIType
2
+ from orchestrator.search.schemas.results import TypeDefinition, ValueSchema
3
+
4
+
5
+ def operators_for(ft: FieldType) -> list[FilterOp]:
6
+ """Return the list of valid operators for a given FieldType."""
7
+ return list(value_schema_for(ft).keys())
8
+
9
+
10
+ def component_operators() -> dict[FilterOp, ValueSchema]:
11
+ """Return operators available for path components."""
12
+ return {
13
+ FilterOp.HAS_COMPONENT: ValueSchema(kind=UIType.COMPONENT),
14
+ FilterOp.NOT_HAS_COMPONENT: ValueSchema(kind=UIType.COMPONENT),
15
+ }
16
+
17
+
18
+ def value_schema_for(ft: FieldType) -> dict[FilterOp, ValueSchema]:
19
+ """Return the value schema map for a given FieldType."""
20
+ if ft in (FieldType.INTEGER, FieldType.FLOAT):
21
+ return {
22
+ FilterOp.EQ: ValueSchema(kind=UIType.NUMBER),
23
+ FilterOp.NEQ: ValueSchema(kind=UIType.NUMBER),
24
+ FilterOp.LT: ValueSchema(kind=UIType.NUMBER),
25
+ FilterOp.LTE: ValueSchema(kind=UIType.NUMBER),
26
+ FilterOp.GT: ValueSchema(kind=UIType.NUMBER),
27
+ FilterOp.GTE: ValueSchema(kind=UIType.NUMBER),
28
+ FilterOp.BETWEEN: ValueSchema(
29
+ kind="object",
30
+ fields={
31
+ "start": ValueSchema(kind=UIType.NUMBER),
32
+ "end": ValueSchema(kind=UIType.NUMBER),
33
+ },
34
+ ),
35
+ }
36
+
37
+ if ft == FieldType.BOOLEAN:
38
+ return {
39
+ FilterOp.EQ: ValueSchema(kind=UIType.BOOLEAN),
40
+ FilterOp.NEQ: ValueSchema(kind=UIType.BOOLEAN),
41
+ }
42
+
43
+ if ft == FieldType.DATETIME:
44
+ return {
45
+ FilterOp.EQ: ValueSchema(kind=UIType.DATETIME),
46
+ FilterOp.NEQ: ValueSchema(kind=UIType.DATETIME),
47
+ FilterOp.LT: ValueSchema(kind=UIType.DATETIME),
48
+ FilterOp.LTE: ValueSchema(kind=UIType.DATETIME),
49
+ FilterOp.GT: ValueSchema(kind=UIType.DATETIME),
50
+ FilterOp.GTE: ValueSchema(kind=UIType.DATETIME),
51
+ FilterOp.BETWEEN: ValueSchema(
52
+ kind="object",
53
+ fields={
54
+ "start": ValueSchema(kind=UIType.DATETIME),
55
+ "end": ValueSchema(kind=UIType.DATETIME),
56
+ },
57
+ ),
58
+ }
59
+
60
+ return {
61
+ FilterOp.EQ: ValueSchema(kind=UIType.STRING),
62
+ FilterOp.NEQ: ValueSchema(kind=UIType.STRING),
63
+ }
64
+
65
+
66
+ def generate_definitions() -> dict[UIType, TypeDefinition]:
67
+ """Generate the full definitions dictionary for all UI types."""
68
+ definitions: dict[UIType, TypeDefinition] = {}
69
+
70
+ for ui_type in UIType:
71
+ if ui_type == UIType.COMPONENT:
72
+ # Special case for component filtering
73
+ comp_ops = component_operators()
74
+ definitions[ui_type] = TypeDefinition(
75
+ operators=list(comp_ops.keys()),
76
+ valueSchema=comp_ops,
77
+ )
78
+ else:
79
+ # Regular field types
80
+ if ui_type == UIType.NUMBER:
81
+ rep_ft = FieldType.INTEGER
82
+ elif ui_type == UIType.DATETIME:
83
+ rep_ft = FieldType.DATETIME
84
+ elif ui_type == UIType.BOOLEAN:
85
+ rep_ft = FieldType.BOOLEAN
86
+ else:
87
+ rep_ft = FieldType.STRING
88
+
89
+ definitions[ui_type] = TypeDefinition(
90
+ operators=operators_for(rep_ft),
91
+ valueSchema=value_schema_for(rep_ft),
92
+ )
93
+ return definitions
@@ -0,0 +1,43 @@
1
+ from typing import Literal
2
+
3
+ from pydantic import BaseModel, Field
4
+ from sqlalchemy import TEXT, bindparam
5
+ from sqlalchemy.sql.elements import ColumnElement
6
+ from sqlalchemy_utils.types.ltree import Ltree
7
+
8
+ from orchestrator.search.core.types import FilterOp, SQLAColumn
9
+
10
+
11
+ class LtreeFilter(BaseModel):
12
+ """Filter for ltree path operations."""
13
+
14
+ op: Literal[
15
+ FilterOp.MATCHES_LQUERY,
16
+ FilterOp.IS_ANCESTOR,
17
+ FilterOp.IS_DESCENDANT,
18
+ FilterOp.PATH_MATCH,
19
+ FilterOp.HAS_COMPONENT,
20
+ FilterOp.NOT_HAS_COMPONENT,
21
+ FilterOp.ENDS_WITH,
22
+ ]
23
+ value: str = Field(description="The ltree path or lquery pattern to compare against.")
24
+
25
+ def to_expression(self, column: SQLAColumn, path: str) -> ColumnElement[bool]:
26
+ """Converts the filter condition into a SQLAlchemy expression."""
27
+ match self.op:
28
+ case FilterOp.IS_DESCENDANT:
29
+ ltree_value = Ltree(self.value)
30
+ return column.op("<@")(ltree_value)
31
+ case FilterOp.IS_ANCESTOR:
32
+ ltree_value = Ltree(self.value)
33
+ return column.op("@>")(ltree_value)
34
+ case FilterOp.MATCHES_LQUERY:
35
+ param = bindparam(None, self.value, type_=TEXT)
36
+ return column.op("~")(param)
37
+ case FilterOp.PATH_MATCH:
38
+ ltree_value = Ltree(path)
39
+ return column == ltree_value
40
+ case FilterOp.HAS_COMPONENT | FilterOp.NOT_HAS_COMPONENT:
41
+ return column.op("~")(bindparam(None, f"*.{self.value}.*", type_=TEXT))
42
+ case FilterOp.ENDS_WITH:
43
+ return column.op("~")(bindparam(None, f"*.{self.value}", type_=TEXT))
@@ -0,0 +1,60 @@
1
+ from typing import Annotated, Any, Literal
2
+
3
+ from pydantic import BaseModel, Field, model_validator
4
+ from sqlalchemy import DOUBLE_PRECISION, INTEGER, and_
5
+ from sqlalchemy import cast as sa_cast
6
+ from sqlalchemy.sql.elements import ColumnElement
7
+ from typing_extensions import Self
8
+
9
+ from orchestrator.search.core.types import FilterOp, SQLAColumn
10
+
11
+
12
+ class NumericRange(BaseModel):
13
+ start: int | float
14
+ end: int | float
15
+
16
+ @model_validator(mode="after")
17
+ def validate_order(self) -> Self:
18
+ if self.end <= self.start:
19
+ raise ValueError("'end' must be greater than 'start'")
20
+ return self
21
+
22
+
23
+ class NumericValueFilter(BaseModel):
24
+ """A filter for single numeric value comparisons (int or float)."""
25
+
26
+ op: Literal[FilterOp.EQ, FilterOp.NEQ, FilterOp.LT, FilterOp.LTE, FilterOp.GT, FilterOp.GTE]
27
+ value: int | float
28
+
29
+ def to_expression(self, column: SQLAColumn, path: str) -> ColumnElement[bool]:
30
+ cast_type = INTEGER if isinstance(self.value, int) else DOUBLE_PRECISION
31
+ numeric_column: ColumnElement[Any] = sa_cast(column, cast_type)
32
+ match self.op:
33
+
34
+ case FilterOp.EQ:
35
+ return numeric_column == self.value
36
+ case FilterOp.NEQ:
37
+ return numeric_column != self.value
38
+ case FilterOp.LT:
39
+ return numeric_column < self.value
40
+ case FilterOp.LTE:
41
+ return numeric_column <= self.value
42
+ case FilterOp.GT:
43
+ return numeric_column > self.value
44
+ case FilterOp.GTE:
45
+ return numeric_column >= self.value
46
+
47
+
48
+ class NumericRangeFilter(BaseModel):
49
+ """A filter for a range of numeric values (int or float)."""
50
+
51
+ op: Literal[FilterOp.BETWEEN]
52
+ value: NumericRange
53
+
54
+ def to_expression(self, column: SQLAColumn, path: str) -> ColumnElement[bool]:
55
+ cast_type = INTEGER if isinstance(self.value.start, int) else DOUBLE_PRECISION
56
+ numeric_column: ColumnElement[Any] = sa_cast(column, cast_type)
57
+ return and_(numeric_column >= self.value.start, numeric_column <= self.value.end)
58
+
59
+
60
+ NumericFilter = Annotated[NumericValueFilter | NumericRangeFilter, Field(discriminator="op")]
@@ -0,0 +1,3 @@
1
+ from .tasks import run_indexing_for_entity
2
+
3
+ __all__ = ["run_indexing_for_entity"]