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.
- orchestrator/__init__.py +26 -2
- orchestrator/agentic_app.py +84 -0
- orchestrator/api/api_v1/api.py +10 -0
- orchestrator/api/api_v1/endpoints/search.py +277 -0
- orchestrator/app.py +32 -0
- orchestrator/cli/index_llm.py +73 -0
- orchestrator/cli/main.py +22 -1
- orchestrator/cli/resize_embedding.py +135 -0
- orchestrator/cli/search_explore.py +208 -0
- orchestrator/cli/speedtest.py +151 -0
- orchestrator/db/models.py +37 -1
- orchestrator/llm_settings.py +51 -0
- orchestrator/migrations/versions/schema/2025-08-12_52b37b5b2714_search_index_model_for_llm_integration.py +95 -0
- orchestrator/schemas/search.py +117 -0
- orchestrator/search/__init__.py +12 -0
- orchestrator/search/agent/__init__.py +8 -0
- orchestrator/search/agent/agent.py +47 -0
- orchestrator/search/agent/prompts.py +87 -0
- orchestrator/search/agent/state.py +8 -0
- orchestrator/search/agent/tools.py +236 -0
- orchestrator/search/core/__init__.py +0 -0
- orchestrator/search/core/embedding.py +64 -0
- orchestrator/search/core/exceptions.py +22 -0
- orchestrator/search/core/types.py +281 -0
- orchestrator/search/core/validators.py +27 -0
- orchestrator/search/docs/index.md +37 -0
- orchestrator/search/docs/running_local_text_embedding_inference.md +45 -0
- orchestrator/search/filters/__init__.py +27 -0
- orchestrator/search/filters/base.py +275 -0
- orchestrator/search/filters/date_filters.py +75 -0
- orchestrator/search/filters/definitions.py +93 -0
- orchestrator/search/filters/ltree_filters.py +43 -0
- orchestrator/search/filters/numeric_filter.py +60 -0
- orchestrator/search/indexing/__init__.py +3 -0
- orchestrator/search/indexing/indexer.py +323 -0
- orchestrator/search/indexing/registry.py +88 -0
- orchestrator/search/indexing/tasks.py +53 -0
- orchestrator/search/indexing/traverse.py +322 -0
- orchestrator/search/retrieval/__init__.py +3 -0
- orchestrator/search/retrieval/builder.py +113 -0
- orchestrator/search/retrieval/engine.py +152 -0
- orchestrator/search/retrieval/pagination.py +83 -0
- orchestrator/search/retrieval/retriever.py +447 -0
- orchestrator/search/retrieval/utils.py +106 -0
- orchestrator/search/retrieval/validation.py +174 -0
- orchestrator/search/schemas/__init__.py +0 -0
- orchestrator/search/schemas/parameters.py +116 -0
- orchestrator/search/schemas/results.py +64 -0
- orchestrator/services/settings_env_variables.py +2 -2
- orchestrator/settings.py +1 -1
- {orchestrator_core-4.4.1.dist-info → orchestrator_core-4.5.0a2.dist-info}/METADATA +8 -3
- {orchestrator_core-4.4.1.dist-info → orchestrator_core-4.5.0a2.dist-info}/RECORD +54 -11
- {orchestrator_core-4.4.1.dist-info → orchestrator_core-4.5.0a2.dist-info}/WHEEL +0 -0
- {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")]
|