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.
@@ -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)
@@ -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