-
Notifications
You must be signed in to change notification settings - Fork 227
Expand file tree
/
Copy pathwindow.py
More file actions
351 lines (291 loc) · 13 KB
/
Copy pathwindow.py
File metadata and controls
351 lines (291 loc) · 13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
# Copyright 2025 DeepMind Technologies Limited. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Windowing processors for working with long or realtime streams.
When dealing with long streams (e.g. video file) or doing contigous inference on
realtime streams one may need to slice them in-to prompts to be sent to an LLM.
This module contains utilities for that.
Also see genai_processors.core.realtime.LiveProcessor for bidirectional
"Live" chat implementation.
"""
import asyncio
import collections
from collections.abc import AsyncIterable, Awaitable
import contextlib
import time
from typing import Callable
from genai_processors import content_api
from genai_processors import processor
from genai_processors import streams
ProcessorPart = content_api.ProcessorPart
ProcessorPartTypes = content_api.ProcessorPartTypes
Processor = processor.Processor
class RollingPrompt:
"""Rolling prompt (aka iterator of prompts) for conversation processors.
NOTE: This is a low level utility. Prefer using realtime.LiveProcessor or
window.Window which provide higher level abstractions.
This class acts as a buffer and organizer for a continuous, "infinite" stream
of multimodal parts (e.g. from a real-time camera feed). It transforms this
stream into finite, time-segmented prompts suitable for turn-based models.
Adding Parts:
add_part(part): Immediately appends a multimodal part to the current prompt.
stash_part(part): Temporarily stores a part to be appended later. This is
useful when it's not the client's "turn" to send parts (e.g. while a
model is generating a response) and you want to delay its inclusion in the
active prompt.
apply_stash(): Appends all currently stashed parts to the active prompt.
Accessing prompts:
pending() -> `AsyncIterable[ProcessorPart]`: Returns an asynchronous
iterator representing the current prompt state. This iterator first yields
the compressed conversation history, then *continues* to stream any new
parts added via `add_part` or `apply_stash`. This streaming behavior
allows models to begin processing the current turn *before it is fully
completed*, significantly minimizing Time To First Token (TTFT).
finalize_pending(): This method should be called when it is time for the
underlying model to make the turn.
* The `AsyncIterable` previously returned by `pending()` will cease
yielding parts after all current parts have been delivered, signaling the
model to generate its response.
* The history compression is applied to accumulated conversation (which
does not include the ongoing model response, as it has not arrived yet).
* A new `pending` iterator is then implicitly created for the *next* turn.
* The compressed history is written to this new iterator, and any parts
added subsequently will be directed to this new turn's pendig prompt.
Consider a scenario where a model is actively generating a response, but the
real-time stream (e.g. a video feed) continues to produce new parts (e.g.
image frames). Directly using `add_part` for these new frames would
incorrectly place them *within* or before the ongoing model response, falsely
suggesting the model considered these new images during its computation for
the *current* response.
To address this, such parts coming from the application should be added using
`stash_part` and appended to the prompt after the model turn is completed
using `apply_stash`.
"""
def __init__(
self,
*,
duration_prompt_sec: float | None = None,
compress_history: (
Callable[[collections.deque[ProcessorPart]], Awaitable[None]] | None
) = None,
):
"""Initializes the rolling prompt.
Args:
duration_prompt_sec: the length of the prompt in terms of the time when
the parts were added: we consider any part added after now -
duration_prompt_sec. Set to None to keep all incoming parts. Set to 10
minutes by default.
compress_history: A callable that takes a deque of ProcessorPart and
modifies it in place to compress the history.
"""
# Current prompt as a queue.
self._pending = asyncio.Queue[ProcessorPart | None]()
# Main prompt content used to build the next prompt and the prompt queues.
self._conversation_history = collections.deque(maxlen=10_000)
# stashed parts to be added to the prompt later.
self._stash: list[ProcessorPart] = []
async def _noop_compress_history(
_: collections.deque[ProcessorPart],
) -> None:
pass
self._compress_history = compress_history or _noop_compress_history
if duration_prompt_sec:
if compress_history:
raise ValueError(
'compress_history and duration_prompt_sec can not be set'
' simultaneously.'
)
self._compress_history = drop_old_parts(duration_prompt_sec)
def add_part(
self,
part: ProcessorPart,
) -> None:
"""Adds a part to the current prompt."""
part.metadata.setdefault('capture_time', time.perf_counter())
self._conversation_history.append(part)
self._pending.put_nowait(part)
def stash_part(self, part: ProcessorPart):
"""Stashes the part to be appended to the prompt later using `apply_stash`.
If it is not our turn to send parts (e.g. model is currently generating),
we can stash them and append to the prompt when we get the turn (after the
model generation is done).
Args:
part: The part to stash.
"""
self._stash.append(part)
def apply_stash(self):
"""Append all parts from the stash to the prompt."""
for part in self._stash:
self.add_part(part)
self._stash = []
def pending(self) -> content_api.ContentStream:
"""Returns the current pending prompt.
Note that the same AsyncIterable is returned unless `finalize_pending` is
called which creates a new pending prompt. So consuming Parts from it will
affect all callers of `pending`.
"""
return content_api.ContentStream(
parts_generator=streams.dequeue(self._pending)
)
async def finalize_pending(self) -> None:
"""Close the current pending prompt and starts a new one.
* The iterator previously returned from `pending` will stop after all Parts
added so far.
* Then history compression is applied.
* At last, a new `pending` iterator is created and the compressed history
is written to it.
* Any parts added afterwards will go to the new iterator.
"""
# Close the current queue.
self._pending.put_nowait(None)
# And create a new one.
self._pending = asyncio.Queue[ProcessorPart | None]()
await self._compress_history(self._conversation_history)
for part in self._conversation_history:
self._pending.put_nowait(part)
def drop_old_parts(
age_sec: float,
) -> Callable[[collections.deque[ProcessorPart]], Awaitable[None]]:
"""A history compression policy that drops parts older than `age_sec`."""
async def policy(history: collections.deque[ProcessorPart]):
time_cut_point = time.perf_counter() - age_sec
while history and history[0].metadata['capture_time'] < time_cut_point:
history.popleft()
return policy
def keep_last_n_turns(
turns_to_keep: int,
) -> Callable[[collections.deque[ProcessorPart]], Awaitable[None]]:
"""Returns a history compression policy that keeps turns_to_keep last turns.
Args:
turns_to_keep: How many turns to keep, including the turn that comes after
the policy is applied.
"""
async def policy(history: collections.deque[ProcessorPart]):
turns = 0
for part in history:
if content_api.is_end_of_turn(part):
turns += 1
turns_to_drop = turns + 1 - turns_to_keep
while turns_to_drop > 0:
if content_api.is_end_of_turn(history[0]):
turns_to_drop -= 1
history.popleft()
return policy
class Window(Processor):
"""Invokes the given processor on a sliding window across incoming content.
Accumulates incoming content. When `content_api.is_end_of_turn(part)` is
encountered, `window_processor` is invoked with the accumulated content.
NOTE: You can put a processor before the Window which will mark Parts with
`part.metadata['turn_complete'] = true` or filter-out unwanted parts. For
example when detecting events in a video stream you may leave only image parts
and mark each as turn_complete.
The output of `window_processor` is propagated to the Window output, but
unlike `LiveProcessor` it is not kept in the prompt of `window_processor` and
is not visible to the consecutive `window_processor` invocations.
Many instances of `window_processor` may be run concurrently, but their output
will be yielded in order.
After `window_processor` is invoked, the accumulated prompt is compressed with
`compress_history`. E.g. one can keep the last N turns or last M seconds or
use an LLM to summarize the context. Note that new Parts added after the last
turn won't be compressed. This allows handling each window in a streaming
fashion.
NOTE: If more sophisticated compression is needed (e.g. you want to leave
image frames with increasing backoff: now, -1s, -5s, -15s, -30s) this can be
done on per-window basis inside of `window_processor`. Similarly one can use
Preamble/Postamble processors or set a system prompt to inject additional
instructions in the `window_processor`.
"""
def __init__(
self,
window_processor: Processor,
compress_history: (
Callable[[collections.deque[ProcessorPart]], Awaitable[None]] | None
) = None,
max_concurrency: int = 0,
stride: int = 1,
):
"""Initializes the window processor.
Args:
window_processor: The processor to invoke on the window.
compress_history: A callable that takes a deque of ProcessorPart and
modifies it in place to compress the history.
max_concurrency: The maximum number of concurrent window_processor
invocations. If 0 or less, concurrency is unlimited.
stride: Only process every `stride` window, skipping the rest. Must be >=
1.
"""
if stride < 1:
raise ValueError('stride must be >= 1')
self._window_processor = window_processor
self._compress_history = compress_history
self._stride = stride
if max_concurrency > 0:
self._semaphore = asyncio.Semaphore(max_concurrency)
else:
self._semaphore = contextlib.nullcontext()
async def _consume_content(
self,
content: AsyncIterable[ProcessorPart],
window_results_queue: asyncio.Queue[
asyncio.Queue[ProcessorPart | None] | None
],
rolling_prompt: RollingPrompt,
) -> None:
"""The main loop that creates and manages window processing tasks."""
window_index = 0
async def _create_window_task():
prompt_for_window = rolling_prompt.pending()
single_window_output_queue = asyncio.Queue[ProcessorPart | None]()
window_results_queue.put_nowait(single_window_output_queue)
async def run_window_processor():
async with self._semaphore:
try:
async for part in self._window_processor(prompt_for_window):
await single_window_output_queue.put(part)
finally:
await single_window_output_queue.put(
content_api.ProcessorPart.end_of_turn()
)
await single_window_output_queue.put(None)
processor.create_task(run_window_processor())
await _create_window_task()
window_index += 1
async for part in content:
rolling_prompt.add_part(part)
if content_api.is_end_of_turn(part):
await rolling_prompt.finalize_pending()
if window_index % self._stride == 0:
await _create_window_task()
window_index += 1
await rolling_prompt.finalize_pending()
await window_results_queue.put(None) # Signal end of all windows.
async def call(
self, content: processor.ProcessorStream
) -> AsyncIterable[ProcessorPartTypes]:
"""The main entry point for the processor."""
rolling_prompt = RollingPrompt(compress_history=self._compress_history)
window_results_queue = asyncio.Queue[
asyncio.Queue[ProcessorPart | None] | None
]()
consume_content_task = processor.create_task(
self._consume_content(
content,
window_results_queue,
rolling_prompt,
)
)
while single_window_output_queue := await window_results_queue.get():
while part := await single_window_output_queue.get():
yield part
await consume_content_task