nora-lib 0.0.5.dev2__tar.gz → 0.0.6__tar.gz

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 (25) hide show
  1. {nora_lib-0.0.5.dev2 → nora_lib-0.0.6}/PKG-INFO +2 -1
  2. {nora_lib-0.0.5.dev2 → nora_lib-0.0.6}/nora_lib/interactions/interactions_service.py +74 -9
  3. {nora_lib-0.0.5.dev2 → nora_lib-0.0.6}/nora_lib/interactions/models.py +10 -8
  4. nora_lib-0.0.6/nora_lib/tasks/state.py +163 -0
  5. {nora_lib-0.0.5.dev2 → nora_lib-0.0.6}/nora_lib.egg-info/PKG-INFO +2 -1
  6. {nora_lib-0.0.5.dev2 → nora_lib-0.0.6}/nora_lib.egg-info/requires.txt +1 -0
  7. {nora_lib-0.0.5.dev2 → nora_lib-0.0.6}/setup.py +2 -2
  8. nora_lib-0.0.5.dev2/nora_lib/tasks/state.py +0 -53
  9. {nora_lib-0.0.5.dev2 → nora_lib-0.0.6}/README.md +0 -0
  10. {nora_lib-0.0.5.dev2 → nora_lib-0.0.6}/nora_lib/__init__.py +0 -0
  11. {nora_lib-0.0.5.dev2 → nora_lib-0.0.6}/nora_lib/context/__init__.py +0 -0
  12. {nora_lib-0.0.5.dev2 → nora_lib-0.0.6}/nora_lib/context/context_service.py +0 -0
  13. {nora_lib-0.0.5.dev2 → nora_lib-0.0.6}/nora_lib/context/models.py +0 -0
  14. {nora_lib-0.0.5.dev2 → nora_lib-0.0.6}/nora_lib/interactions/__init__.py +0 -0
  15. {nora_lib-0.0.5.dev2 → nora_lib-0.0.6}/nora_lib/py.typed +0 -0
  16. {nora_lib-0.0.5.dev2 → nora_lib-0.0.6}/nora_lib/tasks/__init__.py +0 -0
  17. {nora_lib-0.0.5.dev2 → nora_lib-0.0.6}/nora_lib/tasks/models.py +0 -0
  18. {nora_lib-0.0.5.dev2 → nora_lib-0.0.6}/nora_lib.egg-info/SOURCES.txt +0 -0
  19. {nora_lib-0.0.5.dev2 → nora_lib-0.0.6}/nora_lib.egg-info/dependency_links.txt +0 -0
  20. {nora_lib-0.0.5.dev2 → nora_lib-0.0.6}/nora_lib.egg-info/top_level.txt +0 -0
  21. {nora_lib-0.0.5.dev2 → nora_lib-0.0.6}/pyproject.toml +0 -0
  22. {nora_lib-0.0.5.dev2 → nora_lib-0.0.6}/setup.cfg +0 -0
  23. {nora_lib-0.0.5.dev2 → nora_lib-0.0.6}/tests/tasks/__init__.py +0 -0
  24. {nora_lib-0.0.5.dev2 → nora_lib-0.0.6}/tests/tasks/test_state.py +0 -0
  25. {nora_lib-0.0.5.dev2 → nora_lib-0.0.6}/tests/test_placeholder.py +0 -0
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: nora_lib
3
- Version: 0.0.5.dev2
3
+ Version: 0.0.6
4
4
  Summary: For making and coordinating agents and tools
5
5
  Home-page: https://github.com/allenai/nora_lib
6
6
  Requires-Python: >=3.9
7
7
  Requires-Dist: pydantic<3,>=2
8
8
  Requires-Dist: requests
9
+ Requires-Dist: boto3
9
10
  Provides-Extra: dev
10
11
  Requires-Dist: mypy; extra == "dev"
11
12
  Requires-Dist: pytest; extra == "dev"
@@ -2,6 +2,7 @@ from datetime import datetime
2
2
  import logging
3
3
  import requests
4
4
  from typing import List, Optional
5
+ import json
5
6
 
6
7
  from nora_lib.interactions.models import (
7
8
  AnnotationBatch,
@@ -31,7 +32,7 @@ class InteractionsService:
31
32
  message_url,
32
33
  json=message.model_dump(),
33
34
  headers=self.headers,
34
- timeout=int(self.timeout),
35
+ timeout=self.timeout,
35
36
  )
36
37
  response.raise_for_status()
37
38
 
@@ -42,7 +43,7 @@ class InteractionsService:
42
43
  event_url,
43
44
  json=event.model_dump(),
44
45
  headers=self.headers,
45
- timeout=int(self.timeout),
46
+ timeout=self.timeout,
46
47
  )
47
48
  response.raise_for_status()
48
49
 
@@ -53,7 +54,7 @@ class InteractionsService:
53
54
  annotation_url,
54
55
  json=annotation.model_dump(),
55
56
  headers=self.headers,
56
- timeout=int(self.timeout),
57
+ timeout=self.timeout,
57
58
  )
58
59
  response.raise_for_status()
59
60
 
@@ -68,7 +69,7 @@ class InteractionsService:
68
69
  message_url,
69
70
  json=request_body,
70
71
  headers=self.headers,
71
- timeout=int(self.timeout),
72
+ timeout=self.timeout,
72
73
  )
73
74
  response.raise_for_status()
74
75
  res_dict = response.json()["message"]
@@ -92,7 +93,7 @@ class InteractionsService:
92
93
  message_url,
93
94
  json=request_body,
94
95
  headers=self.headers,
95
- timeout=int(self.timeout),
96
+ timeout=self.timeout,
96
97
  )
97
98
  response.raise_for_status()
98
99
  return response.json()
@@ -130,7 +131,7 @@ class InteractionsService:
130
131
  e,
131
132
  )
132
133
 
133
- messages_with_events.sort(key=lambda x: datetime.fromisoformat(x.ts))
134
+ messages_with_events.sort(key=lambda x: x.ts)
134
135
  return messages_with_events
135
136
 
136
137
  def fetch_thread_messages_and_events_for_message(
@@ -143,7 +144,7 @@ class InteractionsService:
143
144
  message_url,
144
145
  json=request_body,
145
146
  headers=self.headers,
146
- timeout=int(self.timeout),
147
+ timeout=self.timeout,
147
148
  )
148
149
  response.raise_for_status()
149
150
  json_response = response.json()
@@ -176,11 +177,46 @@ class InteractionsService:
176
177
  thread_search_url,
177
178
  json=request_body,
178
179
  headers=self.headers,
179
- timeout=int(self.timeout),
180
+ timeout=self.timeout,
180
181
  )
181
182
  response.raise_for_status()
182
183
  return response.json()
183
184
 
185
+ def fetch_messages_and_events_for_forked_thread(
186
+ self, message_id: str, event_type: str
187
+ ) -> List[ReturnedMessage]:
188
+ """Build a history of messages for a given message including associated events.
189
+ This includes messages from pre-forked threads."""
190
+ returned_messages: List[ReturnedMessage] = []
191
+
192
+ messages_for_thread: ThreadRelationsResponse = (
193
+ self.fetch_thread_messages_and_events_for_message(message_id, [event_type])
194
+ )
195
+ if messages_for_thread.messages:
196
+ returned_messages.extend(messages_for_thread.messages)
197
+
198
+ # Lookup any thread_fork events (conversation across surfaces)
199
+ thread_fork_events = self.fetch_messages_and_events_for_thread(
200
+ messages_for_thread.thread_id, EventType.THREAD_FORK.value
201
+ )
202
+ for forked_thread_event in thread_fork_events.get("thread", {}).get(
203
+ "events", []
204
+ ):
205
+ event_data = ThreadForkEventData.model_validate(
206
+ forked_thread_event.get("data", {})
207
+ )
208
+ forked_thread: ThreadRelationsResponse = (
209
+ self.fetch_thread_messages_and_events_for_message(
210
+ event_data.previous_message_id, [event_type]
211
+ )
212
+ )
213
+ if forked_thread.messages:
214
+ returned_messages.extend(forked_thread.messages)
215
+
216
+ returned_messages.sort(key=lambda x: x.ts)
217
+
218
+ return returned_messages
219
+
184
220
  def fetch_events_for_message(
185
221
  self,
186
222
  message_id: str,
@@ -199,7 +235,7 @@ class InteractionsService:
199
235
  message_search_url,
200
236
  json=request_body,
201
237
  headers=self.headers,
202
- timeout=int(self.timeout),
238
+ timeout=self.timeout,
203
239
  )
204
240
  response.raise_for_status()
205
241
  return response.json()
@@ -237,3 +273,32 @@ class InteractionsService:
237
273
  }
238
274
  },
239
275
  }
276
+
277
+ @staticmethod
278
+ def prod() -> "InteractionsService":
279
+ return InteractionsService(
280
+ base_url="https://s2ub.prod.s2.allenai.org/service/noraretrieval",
281
+ timeout=30,
282
+ token=InteractionsService._fetch_bearer_token(
283
+ "nora/prod/interaction-bearer-token"
284
+ ),
285
+ )
286
+
287
+ @staticmethod
288
+ def dev() -> "InteractionsService":
289
+ return InteractionsService(
290
+ base_url="https://s2ub.dev.s2.allenai.org/service/noraretrieval",
291
+ timeout=30,
292
+ token=InteractionsService._fetch_bearer_token(
293
+ "nora/dev/interaction-bearer-token"
294
+ ),
295
+ )
296
+
297
+ @staticmethod
298
+ def _fetch_bearer_token(secret_id: str) -> str:
299
+ import boto3
300
+
301
+ secrets_manager = boto3.client("secretsmanager", region_name="us-west-2")
302
+ return json.loads(
303
+ secrets_manager.get_secret_value(SecretId=secret_id)["SecretString"]
304
+ )["token"]
@@ -43,7 +43,7 @@ class Message(BaseModel):
43
43
  channel_id: str
44
44
  surface: Surface
45
45
  ts: datetime
46
- annotations: Optional[List[Annotation]] = None
46
+ annotations: List[Annotation] = Field(default_factory=list)
47
47
 
48
48
  @field_serializer("actor_id")
49
49
  def serialize_actor_id(self, actor_id: UUID):
@@ -58,7 +58,9 @@ class EventType(str, Enum):
58
58
  """Event types for the interactions service"""
59
59
 
60
60
  AGENT_CONTEXT = "agent:message_context"
61
+ S2_ANNOTATION = "s2_annotation"
61
62
  THREAD_FORK = "thread_fork"
63
+ UI_INTERACTION = "ui_interaction"
62
64
 
63
65
 
64
66
  class Event(BaseModel):
@@ -88,14 +90,14 @@ class ReturnedMessage(BaseModel):
88
90
  """Message format returned by interaction service"""
89
91
 
90
92
  message_id: str
91
- actor_id: str
93
+ actor_id: UUID
92
94
  text: str
93
- ts: str
95
+ ts: datetime
94
96
  annotated_text: Optional[str] = None
95
97
  events: List[Event] = Field(default_factory=list)
96
98
  thread_id: Optional[str] = None
97
99
  channel_id: Optional[str] = None
98
- annotations: Optional[List[Annotation]] = None
100
+ annotations: List[Annotation] = Field(default_factory=list)
99
101
 
100
102
 
101
103
  class AgentMessageData(BaseModel):
@@ -111,8 +113,8 @@ class AgentMessageData(BaseModel):
111
113
  class ReturnedAgentContextEvent(BaseModel):
112
114
  """Event format returned by interaction service for agent context events"""
113
115
 
114
- actor_id: str # agent that saved this context
115
- timestamp: str
116
+ actor_id: UUID # agent that saved this context
117
+ timestamp: datetime
116
118
  data: AgentMessageData
117
119
  type: str
118
120
 
@@ -121,11 +123,11 @@ class ReturnedAgentContextMessage(BaseModel):
121
123
  """Message format returned by interaction service for search by thread"""
122
124
 
123
125
  message_id: str
124
- actor_id: str
126
+ actor_id: UUID
125
127
  text: str
126
128
  ts: str
127
129
  annotated_text: Optional[str] = None
128
- events: Optional[List[ReturnedAgentContextEvent]] = None
130
+ events: List[ReturnedAgentContextEvent] = Field(default_factory=list)
129
131
 
130
132
 
131
133
  class ThreadForkEventData(BaseModel):
@@ -0,0 +1,163 @@
1
+ """
2
+ Handles IO for asynchronous task-related state.
3
+
4
+ A StateManager instance must be initialized with
5
+ a concrete subclass of `AsyncTaskState`, as implemented
6
+ by dependent projects.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ from uuid import UUID
12
+ from datetime import datetime, timezone
13
+ from typing import Generic, Optional, Type, Any
14
+ from abc import ABC, abstractmethod
15
+
16
+ from nora_lib.tasks.models import AsyncTaskState, R, TASK_STATUSES
17
+ from nora_lib.interactions.interactions_service import InteractionsService
18
+ from nora_lib.interactions.models import Event
19
+
20
+
21
+ class NoSuchTaskException(Exception):
22
+ def __init__(self, task_id: str):
23
+ self._task_id = task_id
24
+
25
+ def __str__(self):
26
+ return f"No record found for task {self._task_id}"
27
+
28
+
29
+ class IStateManager(ABC, Generic[R]):
30
+ @abstractmethod
31
+ def read_state(self, task_id: str) -> AsyncTaskState[R]:
32
+ pass
33
+
34
+ @abstractmethod
35
+ def write_state(self, state: AsyncTaskState[R]) -> None:
36
+ pass
37
+
38
+ def update_status(self, task_id: str, new_status: str) -> None:
39
+ state = self.read_state(task_id)
40
+ state.task_status = new_status
41
+ self.write_state(state)
42
+
43
+ def save_result(self, task_id: str, task_result: R) -> None:
44
+ state = self.read_state(task_id)
45
+ state.task_status = TASK_STATUSES["COMPLETED"]
46
+ state.task_result = task_result
47
+ self.write_state(state)
48
+
49
+
50
+ class StateManager(IStateManager[R]):
51
+ """
52
+ Stores task state on local disk
53
+ """
54
+
55
+ def __init__(self, task_state_class: Type[AsyncTaskState[R]], state_dir) -> None:
56
+ self._task_state_class = task_state_class
57
+ self._state_dir = state_dir
58
+
59
+ def read_state(self, task_id: str) -> AsyncTaskState[R]:
60
+ task_state_path = os.path.join(self._state_dir, f"{task_id}.json")
61
+ if not os.path.isfile(task_state_path):
62
+ raise NoSuchTaskException(task_id)
63
+
64
+ with open(task_state_path, "r") as f:
65
+ return self._task_state_class(**json.loads(f.read()))
66
+
67
+ def write_state(self, state: AsyncTaskState[R]) -> None:
68
+ task_state_path = os.path.join(self._state_dir, f"{state.task_id}.json")
69
+ with open(task_state_path, "w") as f:
70
+ json.dump(state.model_dump(), f)
71
+
72
+
73
+ class TaskStateFetchException(Exception):
74
+ def __init__(self, message: str):
75
+ super().__init__(message)
76
+
77
+
78
+ class RemoteStateManagerFactory:
79
+ """
80
+ Stores task state in the interaction store
81
+ """
82
+
83
+ def __init__(
84
+ self, agent_name: str, actor_id: UUID, interactions_service: InteractionsService
85
+ ):
86
+ """
87
+ :param agent_name: Used to form the event type that will hold the task state in the interactions store
88
+ :param actor_id: Associated with the events written to the interactions store
89
+ :param interactions_service:
90
+ """
91
+ self.agent_name = agent_name
92
+ self.actor_id = actor_id
93
+ self.interactions_service = interactions_service
94
+
95
+ def for_message(self, message_id: str) -> IStateManager[R]:
96
+ return RemoteStateManager(
97
+ self.agent_name, self.actor_id, self.interactions_service, message_id
98
+ )
99
+
100
+
101
+ class RemoteStateManager(IStateManager[R]):
102
+ """
103
+ Stores task state in the interaction store
104
+ """
105
+
106
+ _TASK_STATE_EVENT_TYPE = "agent:{}:task_state"
107
+
108
+ def __init__(
109
+ self,
110
+ agent_name: str,
111
+ actor_id: UUID,
112
+ interactions_service: InteractionsService,
113
+ message_id: str,
114
+ ):
115
+ """
116
+ :param agent_name: Agent that saved the task
117
+ :param actor_id: ID for the agent (ignored when reading)
118
+ :param message_id: The message that initiated the request for task status
119
+ """
120
+ self.agent_name = agent_name
121
+ self.actor_id = actor_id
122
+ self.message_id = message_id
123
+ self.interactions_service = interactions_service
124
+
125
+ def read_state(self, task_id: str) -> AsyncTaskState[R]:
126
+ event_type = RemoteStateManager._TASK_STATE_EVENT_TYPE.format(self.agent_name)
127
+ response = (
128
+ self.interactions_service.fetch_thread_messages_and_events_for_message(
129
+ self.message_id, [event_type]
130
+ )
131
+ )
132
+ latest_state: Optional[AsyncTaskState[R]] = None
133
+ latest_timestamp = None
134
+ for msg in response.messages or []:
135
+ for event in msg.events or []:
136
+ try:
137
+ state = AsyncTaskState[Any].model_validate(event.data)
138
+ except Exception as e:
139
+ # Event json blob has unexpected format
140
+ raise TaskStateFetchException(
141
+ f"Event of type {event_type} for message {self.message_id} does not deserialize to AsyncTaskState: {e}"
142
+ )
143
+ if state.task_id != task_id:
144
+ continue
145
+ if latest_state is None or (
146
+ latest_timestamp and event.timestamp > latest_timestamp
147
+ ):
148
+ latest_state = state
149
+ latest_timestamp = event.timestamp
150
+
151
+ if not latest_state:
152
+ raise NoSuchTaskException(task_id)
153
+ return latest_state
154
+
155
+ def write_state(self, state: AsyncTaskState[R]) -> None:
156
+ event = Event(
157
+ type=RemoteStateManager._TASK_STATE_EVENT_TYPE.format(self.agent_name),
158
+ actor_id=self.actor_id,
159
+ timestamp=datetime.now(tz=timezone.utc),
160
+ message_id=self.message_id,
161
+ data=state.model_dump(),
162
+ )
163
+ self.interactions_service.save_event(event)
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: nora_lib
3
- Version: 0.0.5.dev2
3
+ Version: 0.0.6
4
4
  Summary: For making and coordinating agents and tools
5
5
  Home-page: https://github.com/allenai/nora_lib
6
6
  Requires-Python: >=3.9
7
7
  Requires-Dist: pydantic<3,>=2
8
8
  Requires-Dist: requests
9
+ Requires-Dist: boto3
9
10
  Provides-Extra: dev
10
11
  Requires-Dist: mypy; extra == "dev"
11
12
  Requires-Dist: pytest; extra == "dev"
@@ -1,5 +1,6 @@
1
1
  pydantic<3,>=2
2
2
  requests
3
+ boto3
3
4
 
4
5
  [dev]
5
6
  mypy
@@ -1,13 +1,13 @@
1
1
  import setuptools
2
2
 
3
- runtime_requirements = ["pydantic>=2,<3", "requests"]
3
+ runtime_requirements = ["pydantic>=2,<3", "requests", "boto3"]
4
4
 
5
5
  # For running tests, linting, etc
6
6
  dev_requirements = ["mypy", "pytest", "black", "types-requests"]
7
7
 
8
8
  setuptools.setup(
9
9
  name="nora_lib",
10
- version="0.0.5.dev2",
10
+ version="0.0.6",
11
11
  description="For making and coordinating agents and tools",
12
12
  url="https://github.com/allenai/nora_lib",
13
13
  packages=setuptools.find_packages(exclude=(["tests"])),
@@ -1,53 +0,0 @@
1
- """
2
- Handles IO for asynchronous task-related state.
3
- Currently just reads/writes from local disk,
4
- the best and most robust mechanism.
5
-
6
- A StateManager instance must be initialized with
7
- a concrete subclass of `AsyncTaskState`, as implemented
8
- by dependent projects.
9
- """
10
-
11
- import json
12
- import os
13
- from typing import Generic, Optional, Type
14
-
15
- from nora_lib.tasks.models import AsyncTaskState, R, TASK_STATUSES
16
-
17
-
18
- class NoSuchTaskException(Exception):
19
- def __init__(self, task_id: str):
20
- self._task_id = task_id
21
-
22
- def __str__(self):
23
- return f"No record found for task {self._task_id}"
24
-
25
-
26
- class StateManager(Generic[R]):
27
- def __init__(self, task_state_class: Type[AsyncTaskState[R]], state_dir) -> None:
28
- self._task_state_class = task_state_class
29
- self._state_dir = state_dir
30
-
31
- def read_state(self, task_id: str) -> AsyncTaskState[R]:
32
- task_state_path = os.path.join(self._state_dir, f"{task_id}.json")
33
- if not os.path.isfile(task_state_path):
34
- raise NoSuchTaskException(task_id)
35
-
36
- with open(task_state_path, "r") as f:
37
- return self._task_state_class(**json.loads(f.read()))
38
-
39
- def write_state(self, state: AsyncTaskState[R]) -> None:
40
- task_state_path = os.path.join(self._state_dir, f"{state.task_id}.json")
41
- with open(task_state_path, "w") as f:
42
- json.dump(state.model_dump(), f)
43
-
44
- def update_status(self, task_id: str, new_status: str) -> None:
45
- state = self.read_state(task_id)
46
- state.task_status = new_status
47
- self.write_state(state)
48
-
49
- def save_result(self, task_id: str, task_result: R) -> None:
50
- state = self.read_state(task_id)
51
- state.task_status = TASK_STATUSES["COMPLETED"]
52
- state.task_result = task_result
53
- self.write_state(state)
File without changes
File without changes
File without changes