qtype 0.1.0__py3-none-any.whl → 0.1.2__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 (33) hide show
  1. qtype/application/facade.py +16 -17
  2. qtype/cli.py +5 -1
  3. qtype/commands/generate.py +1 -1
  4. qtype/commands/run.py +28 -5
  5. qtype/dsl/domain_types.py +24 -3
  6. qtype/dsl/model.py +56 -3
  7. qtype/interpreter/base/base_step_executor.py +8 -1
  8. qtype/interpreter/base/executor_context.py +18 -1
  9. qtype/interpreter/base/factory.py +33 -66
  10. qtype/interpreter/base/progress_tracker.py +35 -0
  11. qtype/interpreter/base/step_cache.py +3 -2
  12. qtype/interpreter/conversions.py +34 -19
  13. qtype/interpreter/converters.py +19 -13
  14. qtype/interpreter/executors/bedrock_reranker_executor.py +195 -0
  15. qtype/interpreter/executors/document_embedder_executor.py +36 -4
  16. qtype/interpreter/executors/document_search_executor.py +37 -46
  17. qtype/interpreter/executors/document_splitter_executor.py +1 -1
  18. qtype/interpreter/executors/field_extractor_executor.py +10 -5
  19. qtype/interpreter/executors/index_upsert_executor.py +115 -111
  20. qtype/interpreter/executors/invoke_embedding_executor.py +2 -2
  21. qtype/interpreter/executors/invoke_tool_executor.py +6 -1
  22. qtype/interpreter/flow.py +47 -32
  23. qtype/interpreter/rich_progress.py +225 -0
  24. qtype/interpreter/types.py +2 -0
  25. qtype/semantic/checker.py +79 -19
  26. qtype/semantic/model.py +43 -3
  27. qtype/semantic/resolver.py +4 -2
  28. {qtype-0.1.0.dist-info → qtype-0.1.2.dist-info}/METADATA +12 -11
  29. {qtype-0.1.0.dist-info → qtype-0.1.2.dist-info}/RECORD +33 -31
  30. {qtype-0.1.0.dist-info → qtype-0.1.2.dist-info}/WHEEL +0 -0
  31. {qtype-0.1.0.dist-info → qtype-0.1.2.dist-info}/entry_points.txt +0 -0
  32. {qtype-0.1.0.dist-info → qtype-0.1.2.dist-info}/licenses/LICENSE +0 -0
  33. {qtype-0.1.0.dist-info → qtype-0.1.2.dist-info}/top_level.txt +0 -0
@@ -3,9 +3,12 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ import uuid
6
7
  from typing import AsyncIterator
7
8
 
8
9
  from llama_index.core.schema import TextNode
10
+ from opensearchpy import AsyncOpenSearch
11
+ from pydantic import BaseModel
9
12
 
10
13
  from qtype.dsl.domain_types import RAGChunk, RAGDocument
11
14
  from qtype.interpreter.base.batch_step_executor import BatchedStepExecutor
@@ -39,21 +42,32 @@ class IndexUpsertExecutor(BatchedStepExecutor):
39
42
  self._vector_store, _ = to_llama_vector_store_and_retriever(
40
43
  self.step.index, self.context.secret_manager
41
44
  )
42
- self._opensearch_client = None
43
45
  self.index_type = "vector"
44
46
  elif isinstance(self.step.index, DocumentIndex):
45
47
  # Document index for text-based search
46
- self._opensearch_client = to_opensearch_client(
48
+ self._opensearch_client: AsyncOpenSearch = to_opensearch_client(
47
49
  self.step.index, self.context.secret_manager
48
50
  )
49
51
  self._vector_store = None
50
52
  self.index_type = "document"
51
53
  self.index_name = self.step.index.name
54
+ self._document_index: DocumentIndex = self.step.index
52
55
  else:
53
56
  raise ValueError(
54
57
  f"Unsupported index type: {type(self.step.index)}"
55
58
  )
56
59
 
60
+ async def finalize(self) -> AsyncIterator[FlowMessage]:
61
+ """Clean up resources after all messages are processed."""
62
+ if hasattr(self, "_opensearch_client") and self._opensearch_client:
63
+ try:
64
+ await self._opensearch_client.close()
65
+ except Exception:
66
+ pass
67
+ # Make this an async generator
68
+ return
69
+ yield # type: ignore[unreachable]
70
+
57
71
  async def process_batch(
58
72
  self, batch: list[FlowMessage]
59
73
  ) -> AsyncIterator[FlowMessage]:
@@ -65,61 +79,18 @@ class IndexUpsertExecutor(BatchedStepExecutor):
65
79
  Yields:
66
80
  FlowMessages: Success messages after upserting to the index
67
81
  """
68
- logger.info(
82
+ logger.debug(
69
83
  f"Executing IndexUpsert step: {self.step.id} with batch size: {len(batch)}"
70
84
  )
85
+ if len(batch) == 0:
86
+ return
71
87
 
72
88
  try:
73
- # Get the input variable (exactly one as validated by checker)
74
- if not self.step.inputs:
75
- raise ValueError("IndexUpsert step requires exactly one input")
76
-
77
- input_var = self.step.inputs[0]
78
-
79
- # Collect all RAGChunks or RAGDocuments from the batch
80
- items_to_upsert = []
81
- for message in batch:
82
- input_data = message.variables.get(input_var.id)
83
-
84
- if input_data is None:
85
- logger.warning(
86
- f"No data found for input: {input_var.id} in message"
87
- )
88
- continue
89
-
90
- if not isinstance(input_data, (RAGChunk, RAGDocument)):
91
- raise ValueError(
92
- f"IndexUpsert only supports RAGChunk or RAGDocument "
93
- f"inputs. Got: {type(input_data)}"
94
- )
95
-
96
- items_to_upsert.append(input_data)
97
-
98
- # Upsert to appropriate index type
99
- if items_to_upsert:
100
- if self.index_type == "vector":
101
- await self._upsert_to_vector_store(items_to_upsert)
102
- else: # document index
103
- await self._upsert_to_document_index(items_to_upsert)
104
-
105
- logger.info(
106
- f"Successfully upserted {len(items_to_upsert)} items "
107
- f"to {self.index_type} index in batch"
108
- )
109
-
110
- # Emit status update
111
- index_type_display = (
112
- "vector index"
113
- if self.index_type == "vector"
114
- else "document index"
115
- )
116
- await self.stream_emitter.status(
117
- f"Upserted {len(items_to_upsert)} items to "
118
- f"{index_type_display}"
119
- )
120
-
121
- # Yield all input messages back (IndexUpsert typically doesn't have outputs)
122
- for message in batch:
89
+ if self.index_type == "vector":
90
+ result_iter = self._upsert_to_vector_store(batch)
91
+ else:
92
+ result_iter = self._upsert_to_document_index(batch)
93
+ async for message in result_iter:
123
94
  yield message
124
95
 
125
96
  except Exception as e:
@@ -133,13 +104,27 @@ class IndexUpsertExecutor(BatchedStepExecutor):
133
104
  yield message
134
105
 
135
106
  async def _upsert_to_vector_store(
136
- self, items: list[RAGChunk | RAGDocument]
137
- ) -> None:
107
+ self, batch: list[FlowMessage]
108
+ ) -> AsyncIterator[FlowMessage]:
138
109
  """Upsert items to vector store.
139
110
 
140
111
  Args:
141
112
  items: List of RAGChunk or RAGDocument objects
142
113
  """
114
+ # safe since semantic validation checks input length
115
+ input_var = self.step.inputs[0]
116
+
117
+ # Collect all RAGChunks or RAGDocuments from the batch inputs
118
+ items = []
119
+ for message in batch:
120
+ input_data = message.variables.get(input_var.id)
121
+ if not isinstance(input_data, (RAGChunk, RAGDocument)):
122
+ raise ValueError(
123
+ f"IndexUpsert only supports RAGChunk or RAGDocument "
124
+ f"inputs. Got: {type(input_data)}"
125
+ )
126
+ items.append(input_data)
127
+
143
128
  # Convert to LlamaIndex TextNode objects
144
129
  nodes = []
145
130
  for item in items:
@@ -162,67 +147,86 @@ class IndexUpsertExecutor(BatchedStepExecutor):
162
147
 
163
148
  # Batch upsert all nodes to the vector store
164
149
  await self._vector_store.async_add(nodes)
150
+ num_inserted = len(items)
151
+
152
+ # Emit status update
153
+ await self.stream_emitter.status(
154
+ f"Upserted {num_inserted} items to index {self.step.index.name}"
155
+ )
156
+ for message in batch:
157
+ yield message
165
158
 
166
159
  async def _upsert_to_document_index(
167
- self, items: list[RAGChunk | RAGDocument]
168
- ) -> None:
160
+ self, batch: list[FlowMessage]
161
+ ) -> AsyncIterator[FlowMessage]:
169
162
  """Upsert items to document index using bulk API.
170
163
 
171
164
  Args:
172
- items: List of RAGChunk or RAGDocument objects
165
+ batch: List of FlowMessages containing documents to upsert
173
166
  """
174
- # Build bulk request body
167
+
175
168
  bulk_body = []
176
- for item in items:
177
- if isinstance(item, RAGChunk):
178
- # Add index action
179
- bulk_body.append(
180
- {
181
- "index": {
182
- "_index": self.index_name,
183
- "_id": item.chunk_id,
184
- }
185
- }
186
- )
187
- # Add document content
188
- doc = {
189
- "text": str(item.content),
190
- "metadata": item.metadata,
191
- }
192
- # Include embedding if available
193
- if item.vector:
194
- doc["embedding"] = item.vector
195
- bulk_body.append(doc)
196
- else: # RAGDocument
197
- # Add index action
198
- bulk_body.append(
199
- {
200
- "index": {
201
- "_index": self.index_name,
202
- "_id": item.file_id,
203
- }
204
- }
205
- )
206
- # Add document content
207
- doc = {
208
- "text": str(item.content),
209
- "metadata": item.metadata,
210
- "file_name": item.file_name,
211
- }
212
- if item.uri:
213
- doc["uri"] = item.uri
214
- bulk_body.append(doc)
215
-
216
- # Execute bulk request
217
- response = self._opensearch_client.bulk(body=bulk_body)
218
-
219
- # Check for errors
220
- if response.get("errors"):
221
- error_items = [
222
- item
223
- for item in response["items"]
224
- if "error" in item.get("index", {})
225
- ]
226
- logger.warning(
227
- f"Bulk upsert had {len(error_items)} errors: {error_items}"
169
+ message_by_id: dict[str, FlowMessage] = {}
170
+
171
+ for message in batch:
172
+ # Collect all input variables into a single document dict
173
+ doc_dict = {}
174
+ for input_var in self.step.inputs:
175
+ value = message.variables.get(input_var.id)
176
+
177
+ # Convert to dict if it's a Pydantic model
178
+ if isinstance(value, BaseModel):
179
+ value = value.model_dump()
180
+
181
+ # Merge into document dict
182
+ if isinstance(value, dict):
183
+ doc_dict.update(value)
184
+ else:
185
+ # Primitive types - use variable name as field name
186
+ doc_dict[input_var.id] = value
187
+
188
+ # Determine the document id field
189
+ id_field = None
190
+ if self._document_index.id_field is not None:
191
+ id_field = self._document_index.id_field
192
+ if id_field not in doc_dict:
193
+ raise ValueError(
194
+ f"Specified id_field '{id_field}' not found in inputs"
195
+ )
196
+ else:
197
+ # Auto-detect with fallback
198
+ for field in ["_id", "id", "doc_id", "document_id"]:
199
+ if field in doc_dict:
200
+ id_field = field
201
+ break
202
+ if id_field is not None:
203
+ doc_id = str(doc_dict[id_field])
204
+ else:
205
+ # Generate a UUID if no id field found
206
+ doc_id = str(uuid.uuid4())
207
+
208
+ # Add bulk action and document
209
+ bulk_body.append(
210
+ {"index": {"_index": self.index_name, "_id": doc_id}}
228
211
  )
212
+ bulk_body.append(doc_dict)
213
+ message_by_id[doc_id] = message
214
+
215
+ # Execute bulk request asynchronously
216
+ response = await self._opensearch_client.bulk(body=bulk_body)
217
+
218
+ num_inserted = 0
219
+ for item in response["items"]:
220
+ doc_id = item["index"]["_id"]
221
+ message = message_by_id[doc_id]
222
+ if "error" in item.get("index", {}):
223
+ message.set_error(
224
+ self.step.id,
225
+ Exception(item["index"]["error"]),
226
+ )
227
+ else:
228
+ num_inserted += 1
229
+ yield message
230
+ await self.stream_emitter.status(
231
+ f"Upserted {num_inserted} items to index {self.step.index.name}, {len(batch) - num_inserted} errors occurred."
232
+ )
@@ -58,13 +58,13 @@ class InvokeEmbeddingExecutor(StepExecutor):
58
58
  if input_type == PrimitiveTypeEnum.text:
59
59
  if not isinstance(input_value, str):
60
60
  input_value = str(input_value)
61
- vector = self.embedding_model.get_text_embedding(
61
+ vector = await self.embedding_model.aget_text_embedding(
62
62
  text=input_value
63
63
  )
64
64
  content = input_value
65
65
  elif input_type == PrimitiveTypeEnum.image:
66
66
  # For image embeddings
67
- vector = self.embedding_model.get_image_embedding(
67
+ vector = await self.embedding_model.aget_image_embedding(
68
68
  image_path=input_value
69
69
  )
70
70
  content = input_value
@@ -1,6 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import importlib
5
+ import inspect
4
6
  import logging
5
7
  import time
6
8
  import uuid
@@ -86,7 +88,10 @@ class ToolExecutionMixin:
86
88
  )
87
89
  )
88
90
 
89
- result = function(**inputs)
91
+ if inspect.iscoroutinefunction(function):
92
+ result = await function(**inputs)
93
+ else:
94
+ result = await asyncio.to_thread(function, **inputs)
90
95
  await tool_ctx.complete(result)
91
96
  return result
92
97
 
qtype/interpreter/flow.py CHANGED
@@ -1,6 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  import logging
5
+ from collections.abc import AsyncIterator
4
6
 
5
7
  from openinference.semconv.trace import (
6
8
  OpenInferenceSpanKindValues,
@@ -12,6 +14,7 @@ from opentelemetry.trace import Status, StatusCode
12
14
 
13
15
  from qtype.interpreter.base import factory
14
16
  from qtype.interpreter.base.executor_context import ExecutorContext
17
+ from qtype.interpreter.rich_progress import RichProgressCallback
15
18
  from qtype.interpreter.types import FlowMessage
16
19
  from qtype.semantic.model import Flow
17
20
 
@@ -19,7 +22,10 @@ logger = logging.getLogger(__name__)
19
22
 
20
23
 
21
24
  async def run_flow(
22
- flow: Flow, initial: list[FlowMessage] | FlowMessage, **kwargs
25
+ flow: Flow,
26
+ initial: list[FlowMessage] | AsyncIterator[FlowMessage] | FlowMessage,
27
+ show_progress: bool = False,
28
+ **kwargs,
23
29
  ) -> list[FlowMessage]:
24
30
  """
25
31
  Main entrypoint for executing a flow.
@@ -38,11 +44,16 @@ async def run_flow(
38
44
 
39
45
  # Extract or create ExecutorContext
40
46
  exec_context = kwargs.pop("context", None)
47
+ progress_callback = RichProgressCallback() if show_progress else None
41
48
  if exec_context is None:
42
49
  exec_context = ExecutorContext(
43
50
  secret_manager=NoOpSecretManager(),
44
51
  tracer=trace.get_tracer(__name__),
52
+ on_progress=progress_callback,
45
53
  )
54
+ else:
55
+ if exec_context.on_progress is None and show_progress:
56
+ exec_context.on_progress = progress_callback
46
57
 
47
58
  # Use tracer from context
48
59
  tracer = exec_context.tracer or trace.get_tracer(__name__)
@@ -68,38 +79,38 @@ async def run_flow(
68
79
  # 1. Get the execution plan is just the steps in order
69
80
  execution_plan = flow.steps
70
81
 
71
- # 2. Initialize the stream
72
- if not isinstance(initial, list):
82
+ # 2. Convert the initial input to an iterable of some kind. Record telemetry if possible.
83
+ if isinstance(initial, FlowMessage):
84
+ span.set_attribute("flow.input_count", 1)
85
+ input_vars = {k: v for k, v in initial.variables.items()}
86
+ span.set_attribute(
87
+ SpanAttributes.INPUT_VALUE,
88
+ json.dumps(input_vars, default=str),
89
+ )
90
+ span.set_attribute(
91
+ SpanAttributes.INPUT_MIME_TYPE, "application/json"
92
+ )
73
93
  initial = [initial]
74
94
 
75
- span.set_attribute("flow.input_count", len(initial))
76
-
77
- # Record input variables for observability
78
- if initial:
79
- import json
80
-
81
- try:
82
- input_vars = {
83
- k: v for msg in initial for k, v in msg.variables.items()
84
- }
85
- span.set_attribute(
86
- SpanAttributes.INPUT_VALUE,
87
- json.dumps(input_vars, default=str),
88
- )
89
- span.set_attribute(
90
- SpanAttributes.INPUT_MIME_TYPE, "application/json"
91
- )
92
- except Exception:
93
- # If serialization fails, skip it
94
- pass
95
+ if isinstance(initial, list):
96
+ span.set_attribute("flow.input_count", len(initial))
95
97
 
96
- async def initial_stream():
97
- for message in initial:
98
- yield message
98
+ # convert to async iterator
99
+ async def list_stream():
100
+ for message in initial:
101
+ yield message
99
102
 
100
- current_stream = initial_stream()
103
+ current_stream = list_stream()
104
+ elif isinstance(initial, AsyncIterator):
105
+ # We can't know the count ahead of time
106
+ current_stream = initial
107
+ else:
108
+ raise ValueError(
109
+ "Initial input must be a FlowMessage, list of FlowMessages, "
110
+ "or AsyncIterator of FlowMessages"
111
+ )
101
112
 
102
- # 3. Chain executors together in the main loop
113
+ # 4. Chain executors together in the main loop
103
114
  for step in execution_plan:
104
115
  executor = factory.create_executor(step, exec_context, **kwargs)
105
116
  output_stream = executor.execute(
@@ -107,18 +118,19 @@ async def run_flow(
107
118
  )
108
119
  current_stream = output_stream
109
120
 
110
- # 4. Collect the final results from the last stream
121
+ # 5. Collect the final results from the last stream
111
122
  final_results = [state async for state in current_stream]
112
123
 
124
+ # Close the progress bars if any
125
+ if progress_callback is not None:
126
+ progress_callback.close()
113
127
  # Record flow completion metrics
114
128
  span.set_attribute("flow.output_count", len(final_results))
115
129
  error_count = sum(1 for msg in final_results if msg.is_failed())
116
130
  span.set_attribute("flow.error_count", error_count)
117
131
 
118
132
  # Record output variables for observability
119
- if final_results:
120
- import json
121
-
133
+ if len(final_results) == 1 and span.is_recording():
122
134
  try:
123
135
  output_vars = {
124
136
  k: v
@@ -155,6 +167,9 @@ async def run_flow(
155
167
  span.set_status(Status(StatusCode.ERROR, f"Flow failed: {e}"))
156
168
  raise
157
169
  finally:
170
+ # Clean up context resources if we created it
171
+ if kwargs.get("context") is None:
172
+ exec_context.cleanup()
158
173
  # Detach the context and end the span
159
174
  # Only detach if we successfully attached (span was recording)
160
175
  if token is not None: