orchestrator-core 4.4.0rc1__py3-none-any.whl → 5.0.0a1__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 +1 -1
- orchestrator/api/api_v1/api.py +7 -0
- orchestrator/api/api_v1/endpoints/agent.py +62 -0
- orchestrator/api/api_v1/endpoints/processes.py +6 -12
- orchestrator/api/api_v1/endpoints/search.py +197 -0
- orchestrator/app.py +4 -0
- orchestrator/cli/index_llm.py +73 -0
- orchestrator/cli/main.py +8 -1
- orchestrator/cli/resize_embedding.py +136 -0
- orchestrator/cli/scheduler.py +29 -39
- orchestrator/cli/search_explore.py +203 -0
- orchestrator/db/models.py +37 -1
- orchestrator/graphql/schema.py +0 -5
- orchestrator/graphql/schemas/process.py +2 -2
- orchestrator/graphql/utils/create_resolver_error_handler.py +1 -1
- orchestrator/migrations/versions/schema/2025-08-12_52b37b5b2714_search_index_model_for_llm_integration.py +95 -0
- orchestrator/schedules/__init__.py +2 -1
- orchestrator/schedules/resume_workflows.py +2 -2
- orchestrator/schedules/scheduling.py +24 -64
- orchestrator/schedules/task_vacuum.py +2 -2
- orchestrator/schedules/validate_products.py +2 -8
- orchestrator/schedules/validate_subscriptions.py +2 -2
- orchestrator/schemas/search.py +101 -0
- orchestrator/search/__init__.py +0 -0
- orchestrator/search/agent/__init__.py +1 -0
- orchestrator/search/agent/prompts.py +62 -0
- orchestrator/search/agent/state.py +8 -0
- orchestrator/search/agent/tools.py +122 -0
- orchestrator/search/core/__init__.py +0 -0
- orchestrator/search/core/embedding.py +64 -0
- orchestrator/search/core/exceptions.py +16 -0
- orchestrator/search/core/types.py +162 -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 +236 -0
- orchestrator/search/filters/date_filters.py +75 -0
- orchestrator/search/filters/definitions.py +76 -0
- orchestrator/search/filters/ltree_filters.py +31 -0
- orchestrator/search/filters/numeric_filter.py +60 -0
- orchestrator/search/indexing/__init__.py +3 -0
- orchestrator/search/indexing/indexer.py +316 -0
- orchestrator/search/indexing/registry.py +88 -0
- orchestrator/search/indexing/tasks.py +53 -0
- orchestrator/search/indexing/traverse.py +209 -0
- orchestrator/search/retrieval/__init__.py +3 -0
- orchestrator/search/retrieval/builder.py +64 -0
- orchestrator/search/retrieval/engine.py +96 -0
- orchestrator/search/retrieval/ranker.py +202 -0
- orchestrator/search/retrieval/utils.py +88 -0
- orchestrator/search/retrieval/validation.py +174 -0
- orchestrator/search/schemas/__init__.py +0 -0
- orchestrator/search/schemas/parameters.py +114 -0
- orchestrator/search/schemas/results.py +47 -0
- orchestrator/services/processes.py +11 -16
- orchestrator/settings.py +29 -1
- orchestrator/workflow.py +1 -8
- {orchestrator_core-4.4.0rc1.dist-info → orchestrator_core-5.0.0a1.dist-info}/METADATA +6 -3
- {orchestrator_core-4.4.0rc1.dist-info → orchestrator_core-5.0.0a1.dist-info}/RECORD +62 -26
- orchestrator/graphql/resolvers/scheduled_tasks.py +0 -36
- orchestrator/graphql/schemas/scheduled_task.py +0 -8
- orchestrator/schedules/scheduler.py +0 -153
- {orchestrator_core-4.4.0rc1.dist-info → orchestrator_core-5.0.0a1.dist-info}/WHEEL +0 -0
- {orchestrator_core-4.4.0rc1.dist-info → orchestrator_core-5.0.0a1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,236 @@
|
|
|
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 and_, exists, or_, select
|
|
8
|
+
from sqlalchemy.sql.elements import ColumnElement
|
|
9
|
+
from sqlalchemy_utils.types.ltree import Ltree
|
|
10
|
+
|
|
11
|
+
from orchestrator.db.models import AiSearchIndex
|
|
12
|
+
from orchestrator.search.core.types import BooleanOperator, FilterOp, SQLAColumn
|
|
13
|
+
|
|
14
|
+
from .date_filters import DateFilter
|
|
15
|
+
from .ltree_filters import LtreeFilter
|
|
16
|
+
from .numeric_filter import NumericFilter
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EqualityFilter(BaseModel):
|
|
20
|
+
op: Literal[FilterOp.EQ, FilterOp.NEQ]
|
|
21
|
+
value: Any # bool, str (UUID), str (enum values)
|
|
22
|
+
|
|
23
|
+
def to_expression(self, column: SQLAColumn, path: str) -> ColumnElement[bool]:
|
|
24
|
+
str_value = str(self.value)
|
|
25
|
+
match self.op:
|
|
26
|
+
case FilterOp.EQ:
|
|
27
|
+
return column == str_value
|
|
28
|
+
case FilterOp.NEQ:
|
|
29
|
+
return column != str_value
|
|
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
|
+
FilterCondition = (
|
|
49
|
+
DateFilter # DATETIME
|
|
50
|
+
| NumericFilter # INT/FLOAT
|
|
51
|
+
| EqualityFilter # BOOLEAN/UUID/BLOCK/RESOURCE_TYPE
|
|
52
|
+
| StringFilter # STRING TODO: convert to hybrid search
|
|
53
|
+
| LtreeFilter # Path
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PathFilter(BaseModel):
|
|
58
|
+
|
|
59
|
+
path: str = Field(description="The ltree path of the field to filter on, e.g., 'subscription.customer_id'.")
|
|
60
|
+
condition: FilterCondition = Field(description="The filter condition to apply.")
|
|
61
|
+
|
|
62
|
+
model_config = ConfigDict(
|
|
63
|
+
json_schema_extra={
|
|
64
|
+
"examples": [
|
|
65
|
+
{
|
|
66
|
+
"path": "subscription.status",
|
|
67
|
+
"condition": {"op": "eq", "value": "active"},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"path": "subscription.customer_id",
|
|
71
|
+
"condition": {"op": "ne", "value": "acme"},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"path": "subscription.start_date",
|
|
75
|
+
"condition": {"op": "gt", "value": "2025-01-01"},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"path": "subscription.end_date",
|
|
79
|
+
"condition": {
|
|
80
|
+
"op": "between",
|
|
81
|
+
"value": {"from": "2025-06-01", "to": "2025-07-01"},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"path": "subscription.*.name",
|
|
86
|
+
"condition": {"op": "matches_lquery", "value": "*.foo_*"},
|
|
87
|
+
},
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def to_expression(self, value_column: SQLAColumn) -> ColumnElement[bool]:
|
|
93
|
+
"""Convert the path filter into a SQLAlchemy expression.
|
|
94
|
+
|
|
95
|
+
This method delegates to the specific filter condition's ``to_expression``
|
|
96
|
+
implementation, passing along the column and path for context.
|
|
97
|
+
|
|
98
|
+
Parameters
|
|
99
|
+
----------
|
|
100
|
+
value_column : ColumnElement
|
|
101
|
+
The SQLAlchemy column element representing the value to be filtered.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
-------
|
|
105
|
+
ColumnElement[bool]
|
|
106
|
+
A SQLAlchemy boolean expression that can be used in a ``WHERE`` clause.
|
|
107
|
+
"""
|
|
108
|
+
return self.condition.to_expression(value_column, self.path)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class FilterTree(BaseModel):
|
|
112
|
+
model_config = ConfigDict(
|
|
113
|
+
json_schema_extra={
|
|
114
|
+
"description": (
|
|
115
|
+
"Boolean filter tree. Operators must be UPPERCASE: AND / OR.\n"
|
|
116
|
+
"Node shapes:\n"
|
|
117
|
+
" • Group: {'op':'AND'|'OR', 'children': [<PathFilter|FilterTree>, ...]}\n"
|
|
118
|
+
" • Leaf (PathFilter): {'path':'<ltree>', 'condition': {...}}\n"
|
|
119
|
+
"Rules:\n"
|
|
120
|
+
" • Do NOT put 'op' or 'children' inside a leaf 'condition'.\n"
|
|
121
|
+
" • Max depth = 5.\n"
|
|
122
|
+
" • Use from_flat_and() for a flat list of leaves."
|
|
123
|
+
),
|
|
124
|
+
"examples": [
|
|
125
|
+
{
|
|
126
|
+
"op": "AND",
|
|
127
|
+
"children": [
|
|
128
|
+
{"path": "subscription.status", "condition": {"op": "eq", "value": "active"}},
|
|
129
|
+
{"path": "subscription.start_date", "condition": {"op": "gt", "value": "2021-01-01"}},
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"op": "AND",
|
|
134
|
+
"children": [
|
|
135
|
+
{"path": "subscription.start_date", "condition": {"op": "gte", "value": "2024-01-01"}},
|
|
136
|
+
{
|
|
137
|
+
"op": "OR",
|
|
138
|
+
"children": [
|
|
139
|
+
{"path": "subscription.product_name", "condition": {"op": "like", "value": "%fiber%"}},
|
|
140
|
+
{"path": "subscription.customer_id", "condition": {"op": "eq", "value": "Surf"}},
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
op: BooleanOperator = Field(
|
|
150
|
+
description="Operator for grouping conditions in uppercase.", default=BooleanOperator.AND
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
children: list[FilterTree | PathFilter] = Field(min_length=1, description="Path filters or nested groups.")
|
|
154
|
+
|
|
155
|
+
MAX_DEPTH: ClassVar[int] = 5
|
|
156
|
+
|
|
157
|
+
@model_validator(mode="after")
|
|
158
|
+
def _validate_depth(self) -> FilterTree:
|
|
159
|
+
def depth(node: "FilterTree | PathFilter") -> int:
|
|
160
|
+
return 1 + max(depth(c) for c in node.children) if isinstance(node, FilterTree) else 1
|
|
161
|
+
|
|
162
|
+
if depth(self) > self.MAX_DEPTH:
|
|
163
|
+
raise ValueError(f"FilterTree nesting exceeds MAX_DEPTH={self.MAX_DEPTH}")
|
|
164
|
+
return self
|
|
165
|
+
|
|
166
|
+
@classmethod
|
|
167
|
+
def from_flat_and(cls, filters: list[PathFilter]) -> FilterTree | None:
|
|
168
|
+
"""Wrap a flat list of PathFilter into an AND group (or None)."""
|
|
169
|
+
return None if not filters else cls(op=BooleanOperator.AND, children=list(filters))
|
|
170
|
+
|
|
171
|
+
def get_all_paths(self) -> set[str]:
|
|
172
|
+
"""Collects all unique paths from the PathFilter leaves in the tree."""
|
|
173
|
+
return {leaf.path for leaf in self.get_all_leaves()}
|
|
174
|
+
|
|
175
|
+
def get_all_leaves(self) -> list[PathFilter]:
|
|
176
|
+
"""Collect all PathFilter leaves in the tree."""
|
|
177
|
+
leaves: list[PathFilter] = []
|
|
178
|
+
for child in self.children:
|
|
179
|
+
if isinstance(child, PathFilter):
|
|
180
|
+
leaves.append(child)
|
|
181
|
+
else:
|
|
182
|
+
leaves.extend(child.get_all_leaves())
|
|
183
|
+
return leaves
|
|
184
|
+
|
|
185
|
+
def to_expression(
|
|
186
|
+
self,
|
|
187
|
+
entity_id_col: SQLAColumn,
|
|
188
|
+
*,
|
|
189
|
+
entity_type_value: str | None = None,
|
|
190
|
+
) -> ColumnElement[bool]:
|
|
191
|
+
"""Compile this tree into a SQLAlchemy boolean expression.
|
|
192
|
+
|
|
193
|
+
Parameters
|
|
194
|
+
----------
|
|
195
|
+
entity_id_col : SQLAColumn
|
|
196
|
+
Column in the outer query representing the entity ID.
|
|
197
|
+
entity_type_value : str, optional
|
|
198
|
+
If provided, each subquery is additionally constrained to this entity type.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
-------
|
|
202
|
+
ColumnElement[bool]
|
|
203
|
+
A SQLAlchemy expression suitable for use in a WHERE clause.
|
|
204
|
+
"""
|
|
205
|
+
alias_idx = count(1)
|
|
206
|
+
|
|
207
|
+
def leaf_exists(pf: PathFilter) -> ColumnElement[bool]:
|
|
208
|
+
from sqlalchemy.orm import aliased
|
|
209
|
+
|
|
210
|
+
alias = aliased(AiSearchIndex, name=f"flt_{next(alias_idx)}")
|
|
211
|
+
|
|
212
|
+
correlates = [alias.entity_id == entity_id_col]
|
|
213
|
+
if entity_type_value is not None:
|
|
214
|
+
correlates.append(alias.entity_type == entity_type_value)
|
|
215
|
+
|
|
216
|
+
if isinstance(pf.condition, LtreeFilter):
|
|
217
|
+
# Path-only condition acts on path column
|
|
218
|
+
pred = pf.condition.to_expression(alias.path, pf.path)
|
|
219
|
+
where_clause = and_(*correlates, pred)
|
|
220
|
+
else:
|
|
221
|
+
where_clause = and_(
|
|
222
|
+
*correlates,
|
|
223
|
+
alias.path == Ltree(pf.path),
|
|
224
|
+
pf.condition.to_expression(alias.value, pf.path),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
subq = select(1).select_from(alias).where(where_clause)
|
|
228
|
+
return exists(subq)
|
|
229
|
+
|
|
230
|
+
def compile_node(node: FilterTree | PathFilter) -> ColumnElement[bool]:
|
|
231
|
+
if isinstance(node, FilterTree):
|
|
232
|
+
compiled = [compile_node(ch) for ch in node.children]
|
|
233
|
+
return and_(*compiled) if node.op == BooleanOperator.AND else or_(*compiled)
|
|
234
|
+
return leaf_exists(node)
|
|
235
|
+
|
|
236
|
+
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,76 @@
|
|
|
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 value_schema_for(ft: FieldType) -> dict[FilterOp, ValueSchema]:
|
|
11
|
+
"""Return the value schema map for a given FieldType."""
|
|
12
|
+
if ft in (FieldType.INTEGER, FieldType.FLOAT):
|
|
13
|
+
return {
|
|
14
|
+
FilterOp.EQ: ValueSchema(kind=UIType.NUMBER),
|
|
15
|
+
FilterOp.NEQ: ValueSchema(kind=UIType.NUMBER),
|
|
16
|
+
FilterOp.LT: ValueSchema(kind=UIType.NUMBER),
|
|
17
|
+
FilterOp.LTE: ValueSchema(kind=UIType.NUMBER),
|
|
18
|
+
FilterOp.GT: ValueSchema(kind=UIType.NUMBER),
|
|
19
|
+
FilterOp.GTE: ValueSchema(kind=UIType.NUMBER),
|
|
20
|
+
FilterOp.BETWEEN: ValueSchema(
|
|
21
|
+
kind="object",
|
|
22
|
+
fields={
|
|
23
|
+
"start": ValueSchema(kind=UIType.NUMBER),
|
|
24
|
+
"end": ValueSchema(kind=UIType.NUMBER),
|
|
25
|
+
},
|
|
26
|
+
),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if ft == FieldType.BOOLEAN:
|
|
30
|
+
return {
|
|
31
|
+
FilterOp.EQ: ValueSchema(kind=UIType.BOOLEAN),
|
|
32
|
+
FilterOp.NEQ: ValueSchema(kind=UIType.BOOLEAN),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if ft == FieldType.DATETIME:
|
|
36
|
+
return {
|
|
37
|
+
FilterOp.EQ: ValueSchema(kind=UIType.DATETIME),
|
|
38
|
+
FilterOp.NEQ: ValueSchema(kind=UIType.DATETIME),
|
|
39
|
+
FilterOp.LT: ValueSchema(kind=UIType.DATETIME),
|
|
40
|
+
FilterOp.LTE: ValueSchema(kind=UIType.DATETIME),
|
|
41
|
+
FilterOp.GT: ValueSchema(kind=UIType.DATETIME),
|
|
42
|
+
FilterOp.GTE: ValueSchema(kind=UIType.DATETIME),
|
|
43
|
+
FilterOp.BETWEEN: ValueSchema(
|
|
44
|
+
kind="object",
|
|
45
|
+
fields={
|
|
46
|
+
"start": ValueSchema(kind=UIType.DATETIME),
|
|
47
|
+
"end": ValueSchema(kind=UIType.DATETIME),
|
|
48
|
+
},
|
|
49
|
+
),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
FilterOp.EQ: ValueSchema(kind=UIType.STRING),
|
|
54
|
+
FilterOp.NEQ: ValueSchema(kind=UIType.STRING),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def generate_definitions() -> dict[UIType, TypeDefinition]:
|
|
59
|
+
"""Generate the full definitions dictionary for all UI types."""
|
|
60
|
+
definitions = {}
|
|
61
|
+
|
|
62
|
+
for ui_type in UIType:
|
|
63
|
+
if ui_type == UIType.NUMBER:
|
|
64
|
+
rep_ft = FieldType.INTEGER
|
|
65
|
+
elif ui_type == UIType.DATETIME:
|
|
66
|
+
rep_ft = FieldType.DATETIME
|
|
67
|
+
elif ui_type == UIType.BOOLEAN:
|
|
68
|
+
rep_ft = FieldType.BOOLEAN
|
|
69
|
+
else:
|
|
70
|
+
rep_ft = FieldType.STRING
|
|
71
|
+
|
|
72
|
+
definitions[ui_type] = TypeDefinition(
|
|
73
|
+
operators=operators_for(rep_ft),
|
|
74
|
+
valueSchema=value_schema_for(rep_ft),
|
|
75
|
+
)
|
|
76
|
+
return definitions
|
|
@@ -0,0 +1,31 @@
|
|
|
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[FilterOp.MATCHES_LQUERY, FilterOp.IS_ANCESTOR, FilterOp.IS_DESCENDANT, FilterOp.PATH_MATCH]
|
|
15
|
+
value: str = Field(description="The ltree path or lquery pattern to compare against.")
|
|
16
|
+
|
|
17
|
+
def to_expression(self, column: SQLAColumn, path: str) -> ColumnElement[bool]:
|
|
18
|
+
"""Converts the filter condition into a SQLAlchemy expression."""
|
|
19
|
+
match self.op:
|
|
20
|
+
case FilterOp.IS_DESCENDANT:
|
|
21
|
+
ltree_value = Ltree(self.value)
|
|
22
|
+
return column.op("<@")(ltree_value)
|
|
23
|
+
case FilterOp.IS_ANCESTOR:
|
|
24
|
+
ltree_value = Ltree(self.value)
|
|
25
|
+
return column.op("@>")(ltree_value)
|
|
26
|
+
case FilterOp.MATCHES_LQUERY:
|
|
27
|
+
param = bindparam("lquery_pattern", self.value, type_=TEXT)
|
|
28
|
+
return column.op("~")(param)
|
|
29
|
+
case FilterOp.PATH_MATCH:
|
|
30
|
+
ltree_value = Ltree(path)
|
|
31
|
+
return column == ltree_value
|
|
@@ -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")]
|