nora-lib-impl 1.0.0__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.
- nora_lib/__init__.py +0 -0
- nora_lib/impl/__init__.py +0 -0
- nora_lib/impl/context/__init__.py +0 -0
- nora_lib/impl/context/agent_context.py +43 -0
- nora_lib/impl/context/context_service.py +33 -0
- nora_lib/impl/context/models.py +11 -0
- nora_lib/impl/interactions/__init__.py +0 -0
- nora_lib/impl/interactions/interactions_service.py +561 -0
- nora_lib/impl/interactions/models.py +414 -0
- nora_lib/impl/interactions/step_progress.py +69 -0
- nora_lib/impl/pubsub.py +140 -0
- nora_lib/impl/tasks/__init__.py +0 -0
- nora_lib/impl/tasks/state.py +156 -0
- nora_lib/progress/__init__.py +0 -0
- nora_lib/progress/models.py +47 -0
- nora_lib/progress/reporter.py +122 -0
- nora_lib/py.typed +0 -0
- nora_lib/serializers.py +10 -0
- nora_lib/tasks/__init__.py +0 -0
- nora_lib/tasks/models.py +25 -0
- nora_lib/tasks/state.py +71 -0
- nora_lib_impl-1.0.0.dist-info/METADATA +20 -0
- nora_lib_impl-1.0.0.dist-info/RECORD +25 -0
- nora_lib_impl-1.0.0.dist-info/WHEEL +5 -0
- nora_lib_impl-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Model for interactions to be sent to the interactions service.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Dict, List, Optional, Tuple
|
|
9
|
+
from uuid import UUID
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field, field_serializer, ConfigDict, model_validator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Surface(str, Enum):
|
|
15
|
+
SLACK = "Slack"
|
|
16
|
+
WEB = "NoraWebapp"
|
|
17
|
+
CORPUS_QA_DEMO = "CorpusQADemo"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ThreadStatus(str, Enum):
|
|
21
|
+
ACTIVE = "Active"
|
|
22
|
+
ARCHIVED = "Archived"
|
|
23
|
+
DELETED = "Deleted"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Annotation(BaseModel):
|
|
27
|
+
# Need this config to stringify numeric values in attributes.
|
|
28
|
+
# Otherwise, we'll get 'Input should be a valid string' error.
|
|
29
|
+
model_config = ConfigDict(coerce_numbers_to_str=True)
|
|
30
|
+
|
|
31
|
+
tag: str
|
|
32
|
+
span: Tuple[int, int]
|
|
33
|
+
attributes: Optional[Dict[str, str]] = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AnnotationBatch(BaseModel):
|
|
37
|
+
actor_id: UUID
|
|
38
|
+
message_id: str
|
|
39
|
+
annotations: List[Annotation]
|
|
40
|
+
|
|
41
|
+
@field_serializer("actor_id")
|
|
42
|
+
def serialize_actor_id(self, actor_id: UUID):
|
|
43
|
+
return str(actor_id)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Message(BaseModel):
|
|
47
|
+
message_id: str
|
|
48
|
+
actor_id: UUID
|
|
49
|
+
text: str
|
|
50
|
+
thread_id: str
|
|
51
|
+
channel_id: str
|
|
52
|
+
surface: Surface
|
|
53
|
+
ts: datetime
|
|
54
|
+
annotations: List[Annotation] = Field(default_factory=list)
|
|
55
|
+
|
|
56
|
+
@field_serializer("actor_id")
|
|
57
|
+
def serialize_actor_id(self, actor_id: UUID):
|
|
58
|
+
return str(actor_id)
|
|
59
|
+
|
|
60
|
+
@field_serializer("ts")
|
|
61
|
+
def serialize_ts(self, ts: datetime):
|
|
62
|
+
return ts.isoformat()
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def from_returned_message(message: "ReturnedMessage") -> "Message":
|
|
66
|
+
if message.message_id is None:
|
|
67
|
+
raise ValueError("Message ID is required")
|
|
68
|
+
if message.thread_id is None:
|
|
69
|
+
raise ValueError("Thread ID is required")
|
|
70
|
+
if message.channel_id is None:
|
|
71
|
+
raise ValueError("Channel ID is required")
|
|
72
|
+
if message.surface is None:
|
|
73
|
+
raise ValueError("Surface is required")
|
|
74
|
+
return Message(
|
|
75
|
+
message_id=message.message_id,
|
|
76
|
+
actor_id=message.actor_id,
|
|
77
|
+
text=message.text,
|
|
78
|
+
thread_id=message.thread_id,
|
|
79
|
+
channel_id=message.channel_id,
|
|
80
|
+
surface=message.surface,
|
|
81
|
+
ts=message.ts,
|
|
82
|
+
annotations=message.annotations,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Event(BaseModel):
|
|
87
|
+
"""event object to be sent to the interactions service; requires association with a message, thread or channel id"""
|
|
88
|
+
|
|
89
|
+
type: str
|
|
90
|
+
actor_id: UUID = Field(
|
|
91
|
+
description="identifies actor writing the event to the interaction service"
|
|
92
|
+
)
|
|
93
|
+
timestamp: datetime
|
|
94
|
+
text: Optional[str] = None
|
|
95
|
+
data: dict = Field(default_factory=dict)
|
|
96
|
+
message_id: Optional[str] = None
|
|
97
|
+
thread_id: Optional[str] = None
|
|
98
|
+
channel_id: Optional[str] = None
|
|
99
|
+
|
|
100
|
+
@field_serializer("actor_id")
|
|
101
|
+
def serialize_actor_id(self, actor_id: UUID):
|
|
102
|
+
return str(actor_id)
|
|
103
|
+
|
|
104
|
+
@field_serializer("timestamp")
|
|
105
|
+
def serialize_timestamp(self, timestamp: datetime):
|
|
106
|
+
return timestamp.isoformat()
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def from_returned_event(event: "ReturnedEvent") -> "Event":
|
|
110
|
+
if event.channel_id is None:
|
|
111
|
+
raise ValueError("Channel ID is required")
|
|
112
|
+
elif event.thread_id is None and event.message_id is not None:
|
|
113
|
+
raise ValueError("Thread ID is required if Message ID is present")
|
|
114
|
+
return Event(
|
|
115
|
+
type=event.type,
|
|
116
|
+
actor_id=event.actor_id,
|
|
117
|
+
timestamp=event.timestamp,
|
|
118
|
+
text=event.text,
|
|
119
|
+
data=event.data,
|
|
120
|
+
message_id=event.message_id,
|
|
121
|
+
thread_id=event.thread_id,
|
|
122
|
+
channel_id=event.channel_id,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class EventType(Enum):
|
|
127
|
+
"""Enumeration of event types"""
|
|
128
|
+
|
|
129
|
+
# NOTE: These names should correspond to the ones in graphql/src/schema/Event.ts
|
|
130
|
+
|
|
131
|
+
# Recording agent tool calls in the context
|
|
132
|
+
AGENT_EVENT_TYPE = "agent:message_context"
|
|
133
|
+
|
|
134
|
+
# State storage for handler extensions
|
|
135
|
+
HANDLER_EXTENSION_STATE = "handler_extension_state"
|
|
136
|
+
|
|
137
|
+
# Marks a thread forked from Slack to Web
|
|
138
|
+
THREAD_FORK = "thread_fork"
|
|
139
|
+
|
|
140
|
+
# Cost Reporting
|
|
141
|
+
STEP_COST = "step_cost"
|
|
142
|
+
STEP_PROGRESS = "step_progress"
|
|
143
|
+
|
|
144
|
+
ADHOC_DEBUG = "adhoc_debug"
|
|
145
|
+
|
|
146
|
+
S2_ANNOTATION = "s2_annotation"
|
|
147
|
+
|
|
148
|
+
# Locking handler responses to a thread
|
|
149
|
+
HANDLER_START = "handler_start"
|
|
150
|
+
HANDLER_END = "handler_end"
|
|
151
|
+
|
|
152
|
+
# User feedback events
|
|
153
|
+
REACTION_ADDED = "reaction_added"
|
|
154
|
+
REACTION_REMOVED = "reaction_removed"
|
|
155
|
+
USER_FEEDBACK = "user_feedback"
|
|
156
|
+
USER_FEEDBACK_THREAD = "user_feedback_thread"
|
|
157
|
+
REACTION_ADDED_THREAD = "reaction_added_thread"
|
|
158
|
+
REACTION_REMOVED_THREAD = "reaction_removed_thread"
|
|
159
|
+
|
|
160
|
+
# Table widget events
|
|
161
|
+
UI_INTERACTION = "ui_interaction"
|
|
162
|
+
UI_STATE = "ui_state"
|
|
163
|
+
WIDGET_TABLE = "widget_table"
|
|
164
|
+
WIDGET_TABLE_RELATED_PAPERS = "widget_table_related_papers"
|
|
165
|
+
WIDGET_TABLE_ADD_RELATED_PAPERS = "widget_table_add_related_papers"
|
|
166
|
+
|
|
167
|
+
# User's preference to use their queries in a public dataset for future AI research and development
|
|
168
|
+
DATA_CONTRIBUTION_CONSENT = "data_contribution_consent"
|
|
169
|
+
|
|
170
|
+
# Deprecated cost reporting
|
|
171
|
+
COST_REPORT = "cost_report"
|
|
172
|
+
|
|
173
|
+
ERROR = "error"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class ThreadForkEventData(BaseModel):
|
|
177
|
+
"""Event data for a thread fork event"""
|
|
178
|
+
|
|
179
|
+
previous_message_id: str
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class Thread(BaseModel):
|
|
183
|
+
thread_id: str
|
|
184
|
+
channel_id: str
|
|
185
|
+
surface: Surface
|
|
186
|
+
status: ThreadStatus
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class ReturnedEvent(BaseModel):
|
|
190
|
+
"""Event format returned by the interaction service"""
|
|
191
|
+
|
|
192
|
+
event_id: str
|
|
193
|
+
type: str
|
|
194
|
+
actor_id: UUID = Field(
|
|
195
|
+
description="identifies actor writing the event to the interaction service"
|
|
196
|
+
)
|
|
197
|
+
timestamp: datetime
|
|
198
|
+
text: Optional[str] = None
|
|
199
|
+
data: dict = Field(default_factory=dict)
|
|
200
|
+
message_id: Optional[str] = None
|
|
201
|
+
thread_id: Optional[str] = None
|
|
202
|
+
channel_id: Optional[str] = None
|
|
203
|
+
surface: Optional[Surface] = None
|
|
204
|
+
|
|
205
|
+
@field_serializer("actor_id")
|
|
206
|
+
def serialize_actor_id(self, actor_id: UUID):
|
|
207
|
+
return str(actor_id)
|
|
208
|
+
|
|
209
|
+
@field_serializer("timestamp")
|
|
210
|
+
def serialize_timestamp(self, timestamp: datetime):
|
|
211
|
+
return timestamp.isoformat()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class ReturnedMessage(BaseModel):
|
|
215
|
+
"""Message format returned by interaction service"""
|
|
216
|
+
|
|
217
|
+
actor_id: UUID
|
|
218
|
+
text: str
|
|
219
|
+
ts: datetime
|
|
220
|
+
message_id: Optional[str] = None
|
|
221
|
+
annotated_text: Optional[str] = None
|
|
222
|
+
events: List[Event] = Field(default_factory=list)
|
|
223
|
+
preceding_messages: List["ReturnedMessage"] = Field(default_factory=list)
|
|
224
|
+
thread_id: Optional[str] = None
|
|
225
|
+
channel_id: Optional[str] = None
|
|
226
|
+
surface: Optional[Surface] = None
|
|
227
|
+
annotations: List[Annotation] = Field(default_factory=list)
|
|
228
|
+
|
|
229
|
+
@classmethod
|
|
230
|
+
def from_event(cls, event: Event) -> "ReturnedMessage":
|
|
231
|
+
"""Convert an event to a message"""
|
|
232
|
+
return ReturnedMessage(
|
|
233
|
+
actor_id=event.actor_id,
|
|
234
|
+
text=json.dumps(event.data),
|
|
235
|
+
ts=event.timestamp,
|
|
236
|
+
message_id=event.message_id,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class AgentMessageData(BaseModel):
|
|
241
|
+
"""capture requests to and responses from tools within Events"""
|
|
242
|
+
|
|
243
|
+
message_data: dict # dict of agent/tool request/response format
|
|
244
|
+
data_sender_actor_id: Optional[str] = None # agent sending the data
|
|
245
|
+
virtual_thread_id: Optional[str] = None # tool-provided thread
|
|
246
|
+
tool_call_id: Optional[str] = None # llm-provided thread
|
|
247
|
+
tool_name: Optional[str] = None # llm identifier for tool
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class ReturnedAgentContextEvent(BaseModel):
|
|
251
|
+
"""Event format returned by interaction service for agent context events"""
|
|
252
|
+
|
|
253
|
+
actor_id: UUID # agent that saved this context
|
|
254
|
+
timestamp: datetime
|
|
255
|
+
data: AgentMessageData
|
|
256
|
+
type: str
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class ReturnedAgentContextMessage(BaseModel):
|
|
260
|
+
"""Message format returned by interaction service for search by thread"""
|
|
261
|
+
|
|
262
|
+
message_id: str
|
|
263
|
+
actor_id: UUID
|
|
264
|
+
text: str
|
|
265
|
+
ts: str
|
|
266
|
+
annotated_text: Optional[str] = None
|
|
267
|
+
events: List[ReturnedAgentContextEvent] = Field(default_factory=list)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class ThreadRelationsResponse(BaseModel):
|
|
271
|
+
"""Thread format returned by interaction service for thread relations in a search response"""
|
|
272
|
+
|
|
273
|
+
thread_id: str
|
|
274
|
+
events: List[Event] = Field(
|
|
275
|
+
default_factory=list
|
|
276
|
+
) # events associated only with the thread
|
|
277
|
+
messages: List[ReturnedMessage] = Field(
|
|
278
|
+
default_factory=list
|
|
279
|
+
) # includes events associated with each message
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class VirtualThread:
|
|
283
|
+
"""Virtuals threads are an event type used to sub-divide a thread into sb-conversations"""
|
|
284
|
+
|
|
285
|
+
# The type of event that represetns a virtual thread
|
|
286
|
+
EVENT_TYPE = "virtual_thread"
|
|
287
|
+
|
|
288
|
+
# Data field in the event that contains the ID of the virtual thread id
|
|
289
|
+
ID_FIELD = "virtual_thread_id"
|
|
290
|
+
|
|
291
|
+
# Data field in the event that contains the type of other events in the virtual thread
|
|
292
|
+
EVENT_TYPE_FIELD = "event_type"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class CostDetail(BaseModel):
|
|
296
|
+
"""
|
|
297
|
+
Base class to store details of cost to service a request by an agent.
|
|
298
|
+
If an agent has different cost details,
|
|
299
|
+
it should create another class inheriting this class and add those fields.
|
|
300
|
+
See LLMCost and LLMTokenBreakdown below for examples.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
model_config = ConfigDict(protected_namespaces=())
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class LLMCost(CostDetail):
|
|
308
|
+
"""LLM cost detail"""
|
|
309
|
+
|
|
310
|
+
token_count: int
|
|
311
|
+
model_name: str
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class LLMTokenBreakdown(CostDetail):
|
|
315
|
+
"""Token usage breakdown"""
|
|
316
|
+
|
|
317
|
+
prompt_tokens: int
|
|
318
|
+
completion_tokens: int
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class LangChainRun(CostDetail):
|
|
322
|
+
"""LangChain Run"""
|
|
323
|
+
|
|
324
|
+
# Subset of run fields which allow future lookup of run details.
|
|
325
|
+
run_id: UUID
|
|
326
|
+
run_name: Optional[str] = None
|
|
327
|
+
trace_id: Optional[UUID] = None
|
|
328
|
+
session_name: Optional[str] = None # Alias: project_name
|
|
329
|
+
session_id: Optional[UUID] = None # Alias: project_id
|
|
330
|
+
|
|
331
|
+
# Serialize the UUIDs as strings
|
|
332
|
+
@field_serializer("run_id")
|
|
333
|
+
def serialize_id(self, run_id: UUID):
|
|
334
|
+
return str(run_id)
|
|
335
|
+
|
|
336
|
+
@field_serializer("trace_id")
|
|
337
|
+
def serialize_trace_id(self, trace_id: UUID):
|
|
338
|
+
return str(trace_id)
|
|
339
|
+
|
|
340
|
+
@field_serializer("session_id")
|
|
341
|
+
def serialize_session_id(self, session_id: UUID):
|
|
342
|
+
return str(session_id)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class ServiceCost(BaseModel):
|
|
346
|
+
"""Cost of servicing a request by an agent"""
|
|
347
|
+
|
|
348
|
+
dollar_cost: float
|
|
349
|
+
service_provider: Optional[str] = Field(
|
|
350
|
+
default=None, description="For example, OpenAI/Anthropic/Modal/Cohere"
|
|
351
|
+
)
|
|
352
|
+
description: Optional[str] = Field(
|
|
353
|
+
default=None, description="Describe the function within the agent"
|
|
354
|
+
)
|
|
355
|
+
tool_name: Optional[str] = Field(default=None, description="For example, PaperQA")
|
|
356
|
+
task_id: Optional[str] = Field(
|
|
357
|
+
default=None,
|
|
358
|
+
description="Agent generated task_id used to track nora assigned tasks",
|
|
359
|
+
)
|
|
360
|
+
tool_call_id: Optional[str] = None
|
|
361
|
+
details: List[CostDetail] = []
|
|
362
|
+
env: Optional[str] = None
|
|
363
|
+
git_sha: Optional[str] = None
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class StepCost(BaseModel):
|
|
367
|
+
"""Wrapping service cost with event metadata so that it can be converted to an Event object."""
|
|
368
|
+
|
|
369
|
+
actor_id: UUID
|
|
370
|
+
message_id: Optional[str] = None
|
|
371
|
+
thread_id: Optional[str] = None
|
|
372
|
+
service_cost: ServiceCost
|
|
373
|
+
|
|
374
|
+
@field_serializer("actor_id")
|
|
375
|
+
def serialize_actor_id(self, actor_id: UUID):
|
|
376
|
+
return str(actor_id)
|
|
377
|
+
|
|
378
|
+
@model_validator(mode="after")
|
|
379
|
+
def check_at_least_one_id(cls, model):
|
|
380
|
+
if not (model.message_id or model.thread_id):
|
|
381
|
+
raise ValueError(
|
|
382
|
+
"At least one of 'message_id' or 'thread_id' must be provided."
|
|
383
|
+
)
|
|
384
|
+
return model
|
|
385
|
+
|
|
386
|
+
def to_event(self) -> Event:
|
|
387
|
+
return Event(
|
|
388
|
+
type=EventType.STEP_COST.value,
|
|
389
|
+
actor_id=self.actor_id,
|
|
390
|
+
timestamp=datetime.now(),
|
|
391
|
+
text=self.service_cost.description,
|
|
392
|
+
# This flag is needed to serialize subclass
|
|
393
|
+
# https://docs.pydantic.dev/latest/concepts/serialization/#serializeasany-annotation
|
|
394
|
+
data=self.service_cost.model_dump(serialize_as_any=True),
|
|
395
|
+
thread_id=self.thread_id,
|
|
396
|
+
message_id=self.message_id,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def thread_message_lookup_request(message_id: str, event_type: str) -> dict:
|
|
401
|
+
"""retrieve messages and events for the thread associated with a message"""
|
|
402
|
+
return {
|
|
403
|
+
"id": message_id,
|
|
404
|
+
"relations": {
|
|
405
|
+
"thread": {
|
|
406
|
+
"relations": {
|
|
407
|
+
"messages": {
|
|
408
|
+
"relations": {"events": {"filter": {"type": event_type}}},
|
|
409
|
+
"apply_annotations_from_actors": ["*"],
|
|
410
|
+
},
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from nora_lib.progress.models import StepProgress, RunState
|
|
5
|
+
from nora_lib.progress.reporter import (
|
|
6
|
+
StepProgressWriter,
|
|
7
|
+
StepProgressReporter as IStepProgressReporter,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
from nora_lib.impl.pubsub import PubsubService
|
|
11
|
+
from nora_lib.impl.interactions.interactions_service import InteractionsService
|
|
12
|
+
from nora_lib.impl.interactions.models import Event, EventType
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class StepProgressIStoreWriter(StepProgressWriter):
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
actor_id: UUID,
|
|
19
|
+
message_id: str,
|
|
20
|
+
thread_id: str,
|
|
21
|
+
interactions_service: InteractionsService,
|
|
22
|
+
pubsub_service: PubsubService,
|
|
23
|
+
):
|
|
24
|
+
self.actor_id = actor_id
|
|
25
|
+
self.message_id = message_id
|
|
26
|
+
self.thread_id = thread_id
|
|
27
|
+
self.interactions_service = interactions_service
|
|
28
|
+
self.pubsub_service = pubsub_service
|
|
29
|
+
|
|
30
|
+
def write(self, step_progress: StepProgress) -> None:
|
|
31
|
+
timestamp = datetime.now(timezone.utc)
|
|
32
|
+
event_id = self.interactions_service.save_event(
|
|
33
|
+
Event(
|
|
34
|
+
type=EventType.STEP_PROGRESS.value,
|
|
35
|
+
actor_id=self.actor_id,
|
|
36
|
+
timestamp=timestamp,
|
|
37
|
+
data=step_progress.model_dump(exclude_none=True),
|
|
38
|
+
message_id=self.message_id,
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
if step_progress.run_state == RunState.CREATED:
|
|
42
|
+
# Overwrite the created_at timestamp with the event timestamp from the DB
|
|
43
|
+
event = self.interactions_service.get_event(event_id)
|
|
44
|
+
step_progress.created_at = event.timestamp
|
|
45
|
+
|
|
46
|
+
self.pubsub_service.publish(
|
|
47
|
+
topic=f"step_progress:{self.thread_id}",
|
|
48
|
+
payload={"event_id": event_id, "timestamp": timestamp.isoformat()},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class StepProgressReporter(IStepProgressReporter):
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
actor_id: UUID,
|
|
56
|
+
message_id: str,
|
|
57
|
+
thread_id: str,
|
|
58
|
+
step_progress: StepProgress,
|
|
59
|
+
interactions_service: InteractionsService,
|
|
60
|
+
pubsub_service: PubsubService,
|
|
61
|
+
):
|
|
62
|
+
writer = StepProgressIStoreWriter(
|
|
63
|
+
actor_id=actor_id,
|
|
64
|
+
message_id=message_id,
|
|
65
|
+
thread_id=thread_id,
|
|
66
|
+
interactions_service=interactions_service,
|
|
67
|
+
pubsub_service=pubsub_service,
|
|
68
|
+
)
|
|
69
|
+
super().__init__(step_progress, writer)
|
nora_lib/impl/pubsub.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Dict, Any, Iterator, Optional
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import json
|
|
7
|
+
import requests
|
|
8
|
+
import signal
|
|
9
|
+
from time import sleep
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PubsubService:
|
|
15
|
+
"""Client for Nora pubsub backend"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, base_url: str, namespace: Optional[str] = None):
|
|
18
|
+
"""
|
|
19
|
+
:param base_url: pubsub API URL
|
|
20
|
+
:param namespace: Topic namespace
|
|
21
|
+
"""
|
|
22
|
+
self.base_url = base_url
|
|
23
|
+
self.namespace = namespace
|
|
24
|
+
|
|
25
|
+
def subscribe_webhook(self, topic: str, url: str):
|
|
26
|
+
"""
|
|
27
|
+
Add a webhook subscriber to a topic
|
|
28
|
+
The webhook will receive a POST request whenever any client calls publish() on the topic
|
|
29
|
+
"""
|
|
30
|
+
body = {"url": url}
|
|
31
|
+
requests.post(
|
|
32
|
+
f"{self.base_url}/subscribe/webhook/{self._fully_qualified_topic(topic)}",
|
|
33
|
+
json=body,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def unsubscribe_webhook(self, topic: str, url: str):
|
|
37
|
+
"""
|
|
38
|
+
Remove a webhook subscriber from a topic
|
|
39
|
+
"""
|
|
40
|
+
body = {"url": url}
|
|
41
|
+
requests.post(
|
|
42
|
+
f"{self.base_url}/unsubscribe/webhook/{self._fully_qualified_topic(topic)}",
|
|
43
|
+
json=body,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
@contextmanager
|
|
47
|
+
def subscribe_sse(self, topic: str) -> Iterator[str]:
|
|
48
|
+
"""
|
|
49
|
+
Subscribe to a topic using Server-Sent Events
|
|
50
|
+
Returns an iterator that yields message payloads as they are published
|
|
51
|
+
Will close the underlying HTTP connection when the context manager exits
|
|
52
|
+
|
|
53
|
+
Usage:
|
|
54
|
+
|
|
55
|
+
# This will run indefinitely
|
|
56
|
+
with pubsub_service.subscribe_sse("my_topic") as messages:
|
|
57
|
+
for message in messages:
|
|
58
|
+
handle(message)
|
|
59
|
+
|
|
60
|
+
# This will run in the background, exiting after one minute
|
|
61
|
+
with pubsub_service.subscribe_sse("my_topic") as messages:
|
|
62
|
+
def run():
|
|
63
|
+
for message in messages:
|
|
64
|
+
handle(message)
|
|
65
|
+
threading.Thread(target=run).start()
|
|
66
|
+
|
|
67
|
+
sleep(60)
|
|
68
|
+
"""
|
|
69
|
+
open_connections = []
|
|
70
|
+
running = True
|
|
71
|
+
|
|
72
|
+
def msgs():
|
|
73
|
+
delay = 1
|
|
74
|
+
while running:
|
|
75
|
+
try:
|
|
76
|
+
response = requests.get(
|
|
77
|
+
f"{self.base_url}/subscribe/sse/{self._fully_qualified_topic(topic)}",
|
|
78
|
+
stream=True,
|
|
79
|
+
)
|
|
80
|
+
open_connections.append(response)
|
|
81
|
+
delay = 1
|
|
82
|
+
for line in response.iter_lines():
|
|
83
|
+
if line and line.startswith(b"data:"):
|
|
84
|
+
payload = line[5:].decode("utf-8").strip()
|
|
85
|
+
if payload:
|
|
86
|
+
yield json.loads(payload)
|
|
87
|
+
except requests.exceptions.ConnectionError:
|
|
88
|
+
logging.warning(
|
|
89
|
+
"Unable to establish server connection at %s. Sleeping for %ss",
|
|
90
|
+
self.base_url,
|
|
91
|
+
delay,
|
|
92
|
+
)
|
|
93
|
+
try:
|
|
94
|
+
open_connections.pop().close()
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
# Possible misconfiguration. Back off exponentially
|
|
98
|
+
sleep(delay)
|
|
99
|
+
delay = min(delay * 2, 60)
|
|
100
|
+
except requests.exceptions.RequestException:
|
|
101
|
+
logging.warning("Server at %s closed SSE connection", self.base_url)
|
|
102
|
+
try:
|
|
103
|
+
open_connections.pop().close()
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
# Service may have redeployed. Try to reestablish connection quickly
|
|
107
|
+
sleep(1)
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
yield msgs()
|
|
111
|
+
finally:
|
|
112
|
+
running = False
|
|
113
|
+
for conn in open_connections:
|
|
114
|
+
try:
|
|
115
|
+
conn.close()
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
def publish(self, topic: str, payload: Dict[str, Any]):
|
|
120
|
+
"""
|
|
121
|
+
Publish a message to a topic
|
|
122
|
+
"""
|
|
123
|
+
ns_topic = self._fully_qualified_topic(topic)
|
|
124
|
+
event = PublishedEvent(topic=ns_topic, payload=payload)
|
|
125
|
+
requests.post(f"{self.base_url}/publish/{ns_topic}", json=event.model_dump())
|
|
126
|
+
|
|
127
|
+
def _fully_qualified_topic(self, topic: str) -> str:
|
|
128
|
+
return f"{self.namespace}:{topic}" if self.namespace else topic
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def from_env() -> "PubsubService":
|
|
132
|
+
return PubsubService(
|
|
133
|
+
base_url=os.getenv("PUBSUB_URL", "https://nora-pubsub.apps.allenai.org"),
|
|
134
|
+
namespace=os.getenv("PUBSUB_NAMESPACE", os.getenv("ENV", "prod")),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class PublishedEvent(BaseModel):
|
|
139
|
+
topic: str
|
|
140
|
+
payload: Dict[str, Any]
|
|
File without changes
|