orchestrator-core 4.5.3__py3-none-any.whl → 4.6.0rc2__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 (47) hide show
  1. orchestrator/__init__.py +1 -1
  2. orchestrator/agentic_app.py +1 -21
  3. orchestrator/api/api_v1/api.py +5 -0
  4. orchestrator/api/api_v1/endpoints/agent.py +50 -0
  5. orchestrator/api/api_v1/endpoints/search.py +120 -201
  6. orchestrator/cli/database.py +3 -0
  7. orchestrator/cli/generate.py +11 -4
  8. orchestrator/cli/generator/generator/migration.py +7 -3
  9. orchestrator/cli/scheduler.py +15 -22
  10. orchestrator/cli/search/resize_embedding.py +28 -22
  11. orchestrator/cli/search/speedtest.py +4 -6
  12. orchestrator/db/__init__.py +6 -0
  13. orchestrator/db/models.py +75 -0
  14. orchestrator/migrations/helpers.py +46 -38
  15. orchestrator/schedules/scheduler.py +32 -15
  16. orchestrator/schedules/validate_products.py +1 -1
  17. orchestrator/schemas/search.py +8 -85
  18. orchestrator/search/agent/__init__.py +2 -2
  19. orchestrator/search/agent/agent.py +25 -29
  20. orchestrator/search/agent/json_patch.py +51 -0
  21. orchestrator/search/agent/prompts.py +35 -9
  22. orchestrator/search/agent/state.py +28 -2
  23. orchestrator/search/agent/tools.py +192 -53
  24. orchestrator/search/core/exceptions.py +6 -0
  25. orchestrator/search/core/types.py +1 -0
  26. orchestrator/search/export.py +199 -0
  27. orchestrator/search/indexing/indexer.py +13 -4
  28. orchestrator/search/indexing/registry.py +14 -1
  29. orchestrator/search/llm_migration.py +55 -0
  30. orchestrator/search/retrieval/__init__.py +3 -2
  31. orchestrator/search/retrieval/builder.py +5 -1
  32. orchestrator/search/retrieval/engine.py +66 -23
  33. orchestrator/search/retrieval/pagination.py +46 -56
  34. orchestrator/search/retrieval/query_state.py +61 -0
  35. orchestrator/search/retrieval/retrievers/base.py +26 -40
  36. orchestrator/search/retrieval/retrievers/fuzzy.py +10 -9
  37. orchestrator/search/retrieval/retrievers/hybrid.py +11 -8
  38. orchestrator/search/retrieval/retrievers/semantic.py +9 -8
  39. orchestrator/search/retrieval/retrievers/structured.py +6 -6
  40. orchestrator/search/schemas/parameters.py +17 -13
  41. orchestrator/search/schemas/results.py +4 -1
  42. orchestrator/settings.py +1 -0
  43. orchestrator/utils/auth.py +3 -2
  44. {orchestrator_core-4.5.3.dist-info → orchestrator_core-4.6.0rc2.dist-info}/METADATA +3 -3
  45. {orchestrator_core-4.5.3.dist-info → orchestrator_core-4.6.0rc2.dist-info}/RECORD +47 -43
  46. {orchestrator_core-4.5.3.dist-info → orchestrator_core-4.6.0rc2.dist-info}/WHEEL +0 -0
  47. {orchestrator_core-4.5.3.dist-info → orchestrator_core-4.6.0rc2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,51 @@
1
+ # Copyright 2019-2025 SURF, GÉANT.
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+
14
+ from typing import Any, Literal
15
+
16
+ from pydantic import BaseModel, Field
17
+
18
+
19
+ class JSONPatchOp(BaseModel):
20
+ """A JSON Patch operation (RFC 6902).
21
+
22
+ Docs reference: https://docs.ag-ui.com/concepts/state
23
+ """
24
+
25
+ op: Literal["add", "remove", "replace", "move", "copy", "test"] = Field(
26
+ description="The operation to perform: add, remove, replace, move, copy, or test"
27
+ )
28
+ path: str = Field(description="JSON Pointer (RFC 6901) to the target location")
29
+ value: Any | None = Field(
30
+ default=None,
31
+ description="The value to apply (for add, replace operations)",
32
+ )
33
+ from_: str | None = Field(
34
+ default=None,
35
+ alias="from",
36
+ description="Source path (for move, copy operations)",
37
+ )
38
+
39
+ @classmethod
40
+ def upsert(cls, path: str, value: Any, existed: bool) -> "JSONPatchOp":
41
+ """Create an add or replace operation depending on whether the path existed.
42
+
43
+ Args:
44
+ path: JSON Pointer path to the target location
45
+ value: The value to set
46
+ existed: True if the path already exists (use replace), False otherwise (use add)
47
+
48
+ Returns:
49
+ JSONPatchOp with 'replace' if existed is True, 'add' otherwise
50
+ """
51
+ return cls(op="replace" if existed else "add", path=path, value=value)
@@ -50,14 +50,15 @@ async def get_base_instructions() -> str:
50
50
 
51
51
  Follow these steps in strict order:
52
52
 
53
- 1. **Set Context**: Always begin by calling `set_search_parameters`.
53
+ 1. **Set Context**: If the user is asking for a NEW search, call `start_new_search`.
54
54
  2. **Analyze for Filters**: Based on the user's request, decide if specific filters are necessary.
55
55
  - **If filters ARE required**, follow these sub-steps:
56
56
  a. **Gather Intel**: Identify all needed field names, then call `discover_filter_paths` and `get_valid_operators` **once each** to get all required information.
57
57
  b. **Construct FilterTree**: Build the `FilterTree` object.
58
58
  c. **Set Filters**: Call `set_filter_tree`.
59
- 3. **Execute**: Call `execute_search`. This is done for both filtered and non-filtered searches.
60
- 4. **Report**: Answer the users' question directly and summarize when appropiate.
59
+ 3. **Execute**: Call `run_search`. This is done for both filtered and non-filtered searches.
60
+
61
+ After search execution, follow the dynamic instructions based on the current state.
61
62
 
62
63
  ---
63
64
  ### 4. Critical Rules
@@ -73,28 +74,53 @@ async def get_dynamic_instructions(ctx: RunContext[StateDeps[SearchState]]) -> s
73
74
  """Dynamically provides 'next step' coaching based on the current state."""
74
75
  state = ctx.deps.state
75
76
  param_state_str = json.dumps(state.parameters, indent=2, default=str) if state.parameters else "Not set."
77
+ results_count = state.results_data.total_count if state.results_data else 0
76
78
 
77
- next_step_guidance = ""
78
- if not state.parameters or not state.parameters.get("entity_type"):
79
+ if state.export_data:
80
+ next_step_guidance = (
81
+ "INSTRUCTION: Export has been prepared successfully. "
82
+ "Simply confirm to the user that the export is ready for download. "
83
+ "DO NOT include or mention the download URL - the UI will display it automatically."
84
+ )
85
+ elif not state.parameters or not state.parameters.get("entity_type"):
79
86
  next_step_guidance = (
80
- "INSTRUCTION: The search context is not set. Your next action is to call `set_search_parameters`."
87
+ "INSTRUCTION: The search context is not set. Your next action is to call `start_new_search`."
88
+ )
89
+ elif results_count > 0:
90
+ next_step_guidance = dedent(
91
+ f"""
92
+ INSTRUCTION: Search completed successfully.
93
+ Found {results_count} results containing only: entity_id, title, score.
94
+
95
+ Choose your next action based on what the user requested:
96
+ 1. **Broad/generic search** (e.g., 'show me subscriptions'): Confirm search completed and report count. Do nothing else.
97
+ 2. **Question answerable with entity_id/title/score**: Answer directly using the current results.
98
+ 3. **Question requiring other details**: Call `fetch_entity_details` first, then answer with the detailed data.
99
+ 4. **Export request** (phrases like 'export', 'download', 'save as CSV'): Call `prepare_export` directly.
100
+ """
81
101
  )
82
102
  else:
83
103
  next_step_guidance = (
84
104
  "INSTRUCTION: Context is set. Now, analyze the user's request. "
85
105
  "If specific filters ARE required, use the information-gathering tools to build a `FilterTree` and call `set_filter_tree`. "
86
- "If no specific filters are needed, you can proceed directly to `execute_search`."
106
+ "If no specific filters are needed, you can proceed directly to `run_search`."
87
107
  )
108
+
88
109
  return dedent(
89
110
  f"""
90
111
  ---
91
- ### Current State & Next Action
112
+ ## CURRENT STATE
92
113
 
93
114
  **Current Search Parameters:**
94
115
  ```json
95
116
  {param_state_str}
96
117
  ```
97
118
 
98
- **{next_step_guidance}**
119
+ **Current Results Count:** {results_count}
120
+
121
+ ---
122
+ ## NEXT ACTION REQUIRED
123
+
124
+ {next_step_guidance}
99
125
  """
100
126
  )
@@ -12,10 +12,36 @@
12
12
  # limitations under the License.
13
13
 
14
14
  from typing import Any
15
+ from uuid import UUID
15
16
 
16
- from pydantic import BaseModel, Field
17
+ from pydantic import BaseModel
18
+
19
+ from orchestrator.search.schemas.results import SearchResult
20
+
21
+
22
+ class ExportData(BaseModel):
23
+ """Export metadata for download."""
24
+
25
+ action: str = "export"
26
+ query_id: str
27
+ download_url: str
28
+ message: str
29
+
30
+
31
+ class SearchResultsData(BaseModel):
32
+ """Search results data for frontend display and agent context."""
33
+
34
+ action: str = "view_results"
35
+ query_id: str
36
+ results_url: str
37
+ total_count: int
38
+ message: str
39
+ results: list[SearchResult] = []
17
40
 
18
41
 
19
42
  class SearchState(BaseModel):
43
+ run_id: UUID | None = None
44
+ query_id: UUID | None = None
20
45
  parameters: dict[str, Any] | None = None
21
- results: list[dict[str, Any]] = Field(default_factory=list)
46
+ results_data: SearchResultsData | None = None
47
+ export_data: ExportData | None = None
@@ -11,11 +11,11 @@
11
11
  # See the License for the specific language governing permissions and
12
12
  # limitations under the License.
13
13
 
14
- from collections.abc import Awaitable, Callable
15
- from typing import Any, TypeVar
14
+ import json
15
+ from typing import Any
16
16
 
17
17
  import structlog
18
- from ag_ui.core import EventType, StateSnapshotEvent
18
+ from ag_ui.core import EventType, StateDeltaEvent, StateSnapshotEvent
19
19
  from pydantic_ai import RunContext
20
20
  from pydantic_ai.ag_ui import StateDeps
21
21
  from pydantic_ai.exceptions import ModelRetry
@@ -25,34 +25,22 @@ from pydantic_ai.toolsets import FunctionToolset
25
25
  from orchestrator.api.api_v1.endpoints.search import (
26
26
  get_definitions,
27
27
  list_paths,
28
- search_processes,
29
- search_products,
30
- search_subscriptions,
31
- search_workflows,
32
28
  )
33
- from orchestrator.schemas.search import SearchResultsSchema
29
+ from orchestrator.db import AgentRunTable, SearchQueryTable, db
30
+ from orchestrator.search.agent.json_patch import JSONPatchOp
31
+ from orchestrator.search.agent.state import ExportData, SearchResultsData, SearchState
34
32
  from orchestrator.search.core.types import ActionType, EntityType, FilterOp
33
+ from orchestrator.search.export import fetch_export_data
35
34
  from orchestrator.search.filters import FilterTree
35
+ from orchestrator.search.retrieval.engine import execute_search
36
36
  from orchestrator.search.retrieval.exceptions import FilterValidationError, PathNotFoundError
37
+ from orchestrator.search.retrieval.query_state import SearchQueryState
37
38
  from orchestrator.search.retrieval.validation import validate_filter_tree
38
- from orchestrator.search.schemas.parameters import PARAMETER_REGISTRY, BaseSearchParameters
39
-
40
- from .state import SearchState
39
+ from orchestrator.search.schemas.parameters import BaseSearchParameters
40
+ from orchestrator.settings import app_settings
41
41
 
42
42
  logger = structlog.get_logger(__name__)
43
43
 
44
-
45
- P = TypeVar("P", bound=BaseSearchParameters)
46
-
47
- SearchFn = Callable[[P], Awaitable[SearchResultsSchema[Any]]]
48
-
49
- SEARCH_FN_MAP: dict[EntityType, SearchFn] = {
50
- EntityType.SUBSCRIPTION: search_subscriptions,
51
- EntityType.WORKFLOW: search_workflows,
52
- EntityType.PRODUCT: search_products,
53
- EntityType.PROCESS: search_processes,
54
- }
55
-
56
44
  search_toolset: FunctionToolset[StateDeps[SearchState]] = FunctionToolset(max_retries=1)
57
45
 
58
46
 
@@ -65,32 +53,50 @@ def last_user_message(ctx: RunContext[StateDeps[SearchState]]) -> str | None:
65
53
  return None
66
54
 
67
55
 
56
+ def _set_parameters(
57
+ ctx: RunContext[StateDeps[SearchState]],
58
+ entity_type: EntityType,
59
+ action: str | ActionType,
60
+ query: str,
61
+ filters: Any | None,
62
+ ) -> None:
63
+ """Internal helper to set parameters."""
64
+ ctx.deps.state.parameters = {
65
+ "action": action,
66
+ "entity_type": entity_type,
67
+ "filters": filters,
68
+ "query": query,
69
+ }
70
+
71
+
68
72
  @search_toolset.tool
69
- async def set_search_parameters(
73
+ async def start_new_search(
70
74
  ctx: RunContext[StateDeps[SearchState]],
71
75
  entity_type: EntityType,
72
76
  action: str | ActionType = ActionType.SELECT,
73
77
  ) -> StateSnapshotEvent:
74
- """Sets the initial search context, like the entity type and the user's query.
78
+ """Starts a completely new search, clearing all previous state.
75
79
 
76
- This MUST be the first tool called to start any new search.
77
- Warning: Calling this tool will erase any existing filters and search results from the state.
80
+ This MUST be the first tool called when the user asks for a NEW search.
81
+ Warning: This will erase any existing filters, results, and search state.
78
82
  """
79
- params = ctx.deps.state.parameters or {}
80
- is_new_search = params.get("entity_type") != entity_type.value
81
- final_query = (last_user_message(ctx) or "") if is_new_search else params.get("query", "")
83
+ final_query = last_user_message(ctx) or ""
82
84
 
83
85
  logger.debug(
84
- "Setting search parameters",
86
+ "Starting new search",
85
87
  entity_type=entity_type.value,
86
88
  action=action,
87
- is_new_search=is_new_search,
88
89
  query=final_query,
89
90
  )
90
91
 
91
- ctx.deps.state.parameters = {"action": action, "entity_type": entity_type, "filters": None, "query": final_query}
92
- ctx.deps.state.results = []
93
- logger.debug("Search parameters set", parameters=ctx.deps.state.parameters)
92
+ # Clear all state
93
+ ctx.deps.state.results_data = None
94
+ ctx.deps.state.export_data = None
95
+
96
+ # Set fresh parameters with no filters
97
+ _set_parameters(ctx, entity_type, action, final_query, None)
98
+
99
+ logger.debug("New search started", parameters=ctx.deps.state.parameters)
94
100
 
95
101
  return StateSnapshotEvent(
96
102
  type=EventType.STATE_SNAPSHOT,
@@ -102,7 +108,7 @@ async def set_search_parameters(
102
108
  async def set_filter_tree(
103
109
  ctx: RunContext[StateDeps[SearchState]],
104
110
  filters: FilterTree | None,
105
- ) -> StateSnapshotEvent:
111
+ ) -> StateDeltaEvent:
106
112
  """Replace current filters atomically with a full FilterTree, or clear with None.
107
113
 
108
114
  Requirements:
@@ -111,7 +117,7 @@ async def set_filter_tree(
111
117
  - See the FilterTree schema examples for the exact shape.
112
118
  """
113
119
  if ctx.deps.state.parameters is None:
114
- raise ModelRetry("Search parameters are not initialized. Call set_search_parameters first.")
120
+ raise ModelRetry("Search parameters are not initialized. Call start_new_search first.")
115
121
 
116
122
  entity_type = EntityType(ctx.deps.state.parameters["entity_type"])
117
123
 
@@ -136,28 +142,33 @@ async def set_filter_tree(
136
142
  raise ModelRetry(f"Filter validation failed: {str(e)}. Please check your filter structure and try again.")
137
143
 
138
144
  filter_data = None if filters is None else filters.model_dump(mode="json", by_alias=True)
145
+ filters_existed = "filters" in ctx.deps.state.parameters
139
146
  ctx.deps.state.parameters["filters"] = filter_data
140
- return StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot=ctx.deps.state.model_dump())
147
+ return StateDeltaEvent(
148
+ type=EventType.STATE_DELTA,
149
+ delta=[
150
+ JSONPatchOp.upsert(
151
+ path="/parameters/filters",
152
+ value=filter_data,
153
+ existed=filters_existed,
154
+ )
155
+ ],
156
+ )
141
157
 
142
158
 
143
159
  @search_toolset.tool
144
- async def execute_search(
160
+ async def run_search(
145
161
  ctx: RunContext[StateDeps[SearchState]],
146
162
  limit: int = 10,
147
- ) -> StateSnapshotEvent:
148
- """Execute the search with the current parameters."""
163
+ ) -> StateDeltaEvent:
164
+ """Execute the search with the current parameters and save to database."""
149
165
  if not ctx.deps.state.parameters:
150
166
  raise ValueError("No search parameters set")
151
167
 
152
- entity_type = EntityType(ctx.deps.state.parameters["entity_type"])
153
- param_class = PARAMETER_REGISTRY.get(entity_type)
154
- if not param_class:
155
- raise ValueError(f"Unknown entity type: {entity_type}")
156
-
157
- params = param_class(**ctx.deps.state.parameters)
168
+ params = BaseSearchParameters.create(**ctx.deps.state.parameters)
158
169
  logger.debug(
159
170
  "Executing database search",
160
- search_entity_type=entity_type.value,
171
+ search_entity_type=params.entity_type.value,
161
172
  limit=limit,
162
173
  has_filters=params.filters is not None,
163
174
  query=params.query,
@@ -169,17 +180,61 @@ async def execute_search(
169
180
 
170
181
  params.limit = limit
171
182
 
172
- fn = SEARCH_FN_MAP[entity_type]
173
- search_results = await fn(params)
183
+ changes: list[JSONPatchOp] = []
184
+
185
+ if not ctx.deps.state.run_id:
186
+ agent_run = AgentRunTable(agent_type="search")
187
+
188
+ db.session.add(agent_run)
189
+ db.session.commit()
190
+ db.session.expire_all() # Release connection to prevent stacking while agent runs
191
+
192
+ ctx.deps.state.run_id = agent_run.run_id
193
+ logger.debug("Created new agent run", run_id=str(agent_run.run_id))
194
+ changes.append(JSONPatchOp(op="add", path="/run_id", value=str(ctx.deps.state.run_id)))
195
+
196
+ # Get query with embedding and save to DB
197
+ search_response = await execute_search(params, db.session)
198
+ query_embedding = search_response.query_embedding
199
+ query_state = SearchQueryState(parameters=params, query_embedding=query_embedding)
200
+ query_number = db.session.query(SearchQueryTable).filter_by(run_id=ctx.deps.state.run_id).count() + 1
201
+ search_query = SearchQueryTable.from_state(
202
+ state=query_state,
203
+ run_id=ctx.deps.state.run_id,
204
+ query_number=query_number,
205
+ )
206
+ db.session.add(search_query)
207
+ db.session.commit()
208
+ db.session.expire_all()
209
+
210
+ query_id_existed = ctx.deps.state.query_id is not None
211
+ ctx.deps.state.query_id = search_query.query_id
212
+ logger.debug("Saved search query", query_id=str(search_query.query_id), query_number=query_number)
213
+ changes.append(JSONPatchOp.upsert(path="/query_id", value=str(ctx.deps.state.query_id), existed=query_id_existed))
174
214
 
175
215
  logger.debug(
176
216
  "Search completed",
177
- total_results=len(search_results.data) if search_results.data else 0,
217
+ total_results=len(search_response.results),
178
218
  )
179
219
 
180
- ctx.deps.state.results = search_results.data
220
+ # Store results data for both frontend display and agent context
221
+ results_url = f"{app_settings.BASE_URL}/api/search/queries/{ctx.deps.state.query_id}"
222
+
223
+ results_data_existed = ctx.deps.state.results_data is not None
224
+ ctx.deps.state.results_data = SearchResultsData(
225
+ query_id=str(ctx.deps.state.query_id),
226
+ results_url=results_url,
227
+ total_count=len(search_response.results),
228
+ message=f"Found {len(search_response.results)} results.",
229
+ results=search_response.results, # Include actual results in state
230
+ )
231
+ changes.append(
232
+ JSONPatchOp.upsert(
233
+ path="/results_data", value=ctx.deps.state.results_data.model_dump(), existed=results_data_existed
234
+ )
235
+ )
181
236
 
182
- return StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot=ctx.deps.state.model_dump())
237
+ return StateDeltaEvent(type=EventType.STATE_DELTA, delta=changes)
183
238
 
184
239
 
185
240
  @search_toolset.tool
@@ -256,3 +311,87 @@ async def get_valid_operators() -> dict[str, list[FilterOp]]:
256
311
  if hasattr(type_def, "operators"):
257
312
  operator_map[key] = type_def.operators
258
313
  return operator_map
314
+
315
+
316
+ @search_toolset.tool
317
+ async def fetch_entity_details(
318
+ ctx: RunContext[StateDeps[SearchState]],
319
+ limit: int = 10,
320
+ ) -> str:
321
+ """Fetch detailed entity information to answer user questions.
322
+
323
+ Use this tool when you need detailed information about entities from the search results
324
+ to answer the user's question. This provides the same detailed data that would be
325
+ included in an export (e.g., subscription status, product details, workflow info, etc.).
326
+
327
+ Args:
328
+ ctx: Runtime context for agent (injected).
329
+ limit: Maximum number of entities to fetch details for (default 10).
330
+
331
+ Returns:
332
+ JSON string containing detailed entity information.
333
+
334
+ Raises:
335
+ ValueError: If no search results are available.
336
+ """
337
+ if not ctx.deps.state.results_data or not ctx.deps.state.results_data.results:
338
+ raise ValueError("No search results available. Run a search first before fetching entity details.")
339
+
340
+ if not ctx.deps.state.parameters:
341
+ raise ValueError("No search parameters found.")
342
+
343
+ entity_type = EntityType(ctx.deps.state.parameters["entity_type"])
344
+
345
+ entity_ids = [r.entity_id for r in ctx.deps.state.results_data.results[:limit]]
346
+
347
+ logger.debug(
348
+ "Fetching detailed entity data",
349
+ entity_type=entity_type.value,
350
+ entity_count=len(entity_ids),
351
+ )
352
+
353
+ detailed_data = fetch_export_data(entity_type, entity_ids)
354
+
355
+ return json.dumps(detailed_data, indent=2)
356
+
357
+
358
+ @search_toolset.tool
359
+ async def prepare_export(
360
+ ctx: RunContext[StateDeps[SearchState]],
361
+ ) -> StateSnapshotEvent:
362
+ """Prepares export URL using the last executed search query."""
363
+ if not ctx.deps.state.query_id or not ctx.deps.state.run_id:
364
+ raise ValueError("No search has been executed yet. Run a search first before exporting.")
365
+
366
+ if not ctx.deps.state.parameters:
367
+ raise ValueError("No search parameters found. Run a search first before exporting.")
368
+
369
+ # Validate that export is only available for SELECT actions
370
+ action = ctx.deps.state.parameters.get("action", ActionType.SELECT)
371
+ if action != ActionType.SELECT:
372
+ raise ValueError(
373
+ f"Export is only available for SELECT actions. Current action is '{action}'. "
374
+ "Please run a SELECT search first."
375
+ )
376
+
377
+ logger.debug(
378
+ "Prepared query for export",
379
+ query_id=str(ctx.deps.state.query_id),
380
+ )
381
+
382
+ download_url = f"{app_settings.BASE_URL}/api/search/queries/{ctx.deps.state.query_id}/export"
383
+
384
+ ctx.deps.state.export_data = ExportData(
385
+ query_id=str(ctx.deps.state.query_id),
386
+ download_url=download_url,
387
+ message="Export ready for download.",
388
+ )
389
+
390
+ logger.debug("Export data set in state", export_data=ctx.deps.state.export_data.model_dump())
391
+
392
+ # Should use StateDelta here? Use snapshot to workaround state persistence issue
393
+ # TODO: Fix root cause; state is empty on frontend when it should have data from run_search
394
+ return StateSnapshotEvent(
395
+ type=EventType.STATE_SNAPSHOT,
396
+ snapshot=ctx.deps.state.model_dump(),
397
+ )
@@ -34,3 +34,9 @@ class InvalidCursorError(SearchUtilsError):
34
34
  """Raised when cursor cannot be decoded."""
35
35
 
36
36
  pass
37
+
38
+
39
+ class QueryStateNotFoundError(SearchUtilsError):
40
+ """Raised when a query state cannot be found in the database."""
41
+
42
+ pass
@@ -289,6 +289,7 @@ class ExtractedField(NamedTuple):
289
289
  class IndexableRecord(TypedDict):
290
290
  entity_id: str
291
291
  entity_type: str
292
+ entity_title: str
292
293
  path: Ltree
293
294
  value: Any
294
295
  value_type: Any