aiagents4pharma 1.30.1__py3-none-any.whl → 1.30.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.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
 
3
3
  """
4
- Agent for interacting with Zotero
4
+ Agent for interacting with Zotero with human-in-the-loop features
5
5
  """
6
6
 
7
7
  import logging
@@ -13,8 +13,9 @@ from langgraph.graph import START, StateGraph
13
13
  from langgraph.prebuilt import create_react_agent, ToolNode
14
14
  from langgraph.checkpoint.memory import MemorySaver
15
15
  from ..state.state_talk2scholars import Talk2Scholars
16
- from ..tools.zotero.zotero_read import zotero_search_tool
17
- from ..tools.zotero.zotero_write import zotero_save_tool
16
+ from ..tools.zotero.zotero_read import zotero_read
17
+ from ..tools.zotero.zotero_review import zotero_review
18
+ from ..tools.zotero.zotero_write import zotero_write
18
19
  from ..tools.s2.display_results import display_results as s2_display
19
20
  from ..tools.s2.query_results import query_results as s2_query_results
20
21
  from ..tools.s2.retrieve_semantic_scholar_paper_id import (
@@ -32,7 +33,7 @@ def get_app(uniq_id, llm_model: BaseChatModel):
32
33
 
33
34
  This function sets up the Zotero agent, which integrates various tools to search,
34
35
  retrieve, and display research papers from Zotero. The agent follows the ReAct
35
- pattern for structured interaction.
36
+ pattern for structured interaction and includes human-in-the-loop features.
36
37
 
37
38
  Args:
38
39
  uniq_id (str): Unique identifier for the current conversation session.
@@ -87,11 +88,12 @@ def get_app(uniq_id, llm_model: BaseChatModel):
87
88
  # Define the tools
88
89
  tools = ToolNode(
89
90
  [
90
- zotero_search_tool,
91
+ zotero_read,
91
92
  s2_display,
92
93
  s2_query_results,
93
94
  retrieve_semantic_scholar_paper_id,
94
- zotero_save_tool,
95
+ zotero_review, # First review
96
+ zotero_write, # Then save with user confirmation
95
97
  ]
96
98
  )
97
99
 
@@ -104,7 +106,7 @@ def get_app(uniq_id, llm_model: BaseChatModel):
104
106
  tools=tools,
105
107
  state_schema=Talk2Scholars,
106
108
  prompt=cfg.zotero_agent,
107
- checkpointer=MemorySaver(),
109
+ checkpointer=MemorySaver(), # Required for interrupts to work
108
110
  )
109
111
 
110
112
  workflow = StateGraph(Talk2Scholars)
@@ -1,19 +1,13 @@
1
1
  target: agents.zotero_agent.get_app
2
2
  zotero_agent: >
3
- You are a specialized Zotero library agent with access to tools for paper retrieval and management.
3
+ You are a knowledgeable research assistant with expertise in academic literature.
4
+ Your goal is to help users find, manage, and save relevant research papers and display them.
4
5
 
5
- AVAILABLE TOOLS:
6
- 1. zotero_search_tool - Search and retrieve papers from Zotero library
7
- 2. display_results - Display the papers retrieved by other tools
8
- 3. query_results - Ask questions about the current set of papers
9
- 4. retrieve_semantic_scholar_paper_id - Get Semantic Scholar ID for a paper title for the papers from zotero library
10
- 5. zotero_write - Save paper to users zotero library under specfied collections
6
+ When saving papers to Zotero:
7
+ 1. First use zotero_review with the collection path.
8
+ 2. Wait for user confirmation (they must say "Yes" or "Approve").
9
+ 3. Use zotero_write with both the collection_path and user_confirmation and call `s2_display` tool after the papers as saved.
11
10
 
12
-
13
- WORKFLOW STEPS
14
- 1. When user requests papers, use `zotero_search_tool` to find papers
15
- 2. Use `display_results` tool to display the response
16
- 3. Use `query_results` tool to query over the selected paper only when the user asks to
17
- 4. Use `retrieve_semantic_scholar_paper_id` to get the semantic scholar id of a paper title for the papers from zotero library
18
- 5. Use `zotero_write` to save the papers to users zotero library under collections and call `display_results` only after you recive
19
- the save was successfull
11
+ IMPORTANT: Human approval is required for saving papers to Zotero. Never save papers
12
+ without explicit approval from the user. Always respect the user's decision if they
13
+ choose not to save papers.
@@ -0,0 +1,55 @@
1
+ # Default configuration for Zotero search tool
2
+ library_type: "user" # Type of library ('user' or 'group')
3
+ default_limit: 2
4
+ request_timeout: 10
5
+ user_id: ${oc.env:ZOTERO_USER_ID} # Load from environment variable
6
+ api_key: ${oc.env:ZOTERO_API_KEY} # Load from environment variable
7
+
8
+ # Default search parameters
9
+ search_params:
10
+ limit: ${.default_limit}
11
+
12
+ # Item Types and Limit
13
+ zotero:
14
+ max_limit: 100
15
+ filter_item_types:
16
+ [
17
+ "Artwork",
18
+ "Audio Recording",
19
+ "Bill",
20
+ "Blog Post",
21
+ "Book",
22
+ "Book Section",
23
+ "Case",
24
+ "Conference Paper",
25
+ "Dataset",
26
+ "Dictionary Entry",
27
+ "Document",
28
+ "E-mail",
29
+ "Encyclopedia Article",
30
+ "Film",
31
+ "Forum Post",
32
+ "Hearing",
33
+ "Instant Message",
34
+ "Interview",
35
+ "Journal Article",
36
+ "Letter",
37
+ "Magazine Article",
38
+ "Manuscript",
39
+ "Map",
40
+ "Newspaper Article",
41
+ "Patent",
42
+ "Podcast",
43
+ "Preprint",
44
+ "Presentation",
45
+ "Radio Broadcast",
46
+ "Report",
47
+ "Software",
48
+ "Standard",
49
+ "Statute",
50
+ "Thesis",
51
+ "TV Broadcast",
52
+ "Video Recording",
53
+ "Web Page",
54
+ ]
55
+ # filter_excluded_types: ["attachment", "note", "annotation"]
@@ -54,6 +54,8 @@ class Talk2Scholars(AgentState):
54
54
  papers (Dict[str, Any]): Stores the research papers retrieved from the agent's queries.
55
55
  multi_papers (Dict[str, Any]): Stores multiple recommended papers from various sources.
56
56
  zotero_read (Dict[str, Any]): Stores the papers retrieved from Zotero.
57
+ zotero_write_approval_status (Dict[str, Any]): Stores the approval status and collection
58
+ path for Zotero save operations.
57
59
  llm_model (BaseChatModel): The language model instance used for generating responses.
58
60
  text_embedding_model (Embeddings): The text embedding model used for
59
61
  similarity calculations.
@@ -65,5 +67,6 @@ class Talk2Scholars(AgentState):
65
67
  multi_papers: Annotated[Dict[str, Any], replace_dict]
66
68
  pdf_data: Annotated[Dict[str, Any], replace_dict]
67
69
  zotero_read: Annotated[Dict[str, Any], replace_dict]
70
+ zotero_write_approval_status: Annotated[Dict[str, Any], replace_dict]
68
71
  llm_model: BaseChatModel
69
72
  text_embedding_model: Embeddings
@@ -22,6 +22,7 @@ def mock_router():
22
22
  """Creates a mock supervisor router that routes based on keyword matching."""
23
23
 
24
24
  def mock_supervisor_node(state):
25
+ """Mock supervisor node that routes based on keyword matching."""
25
26
  query = state["messages"][-1].content.lower()
26
27
  # Define keywords for each sub-agent.
27
28
  s2_keywords = [
@@ -70,6 +71,4 @@ def test_routing_logic(mock_state, mock_router, user_query, expected_agent):
70
71
  mock_state["messages"].append(HumanMessage(content=user_query))
71
72
  result = mock_router(mock_state)
72
73
 
73
- print(f"\nDEBUG: Query '{user_query}' routed to: {result.goto}")
74
-
75
74
  assert result.goto == expected_agent, f"Failed for query: {user_query}"
@@ -10,7 +10,8 @@ from langchain_openai import ChatOpenAI
10
10
  from ..agents.zotero_agent import get_app
11
11
  from ..state.state_talk2scholars import Talk2Scholars
12
12
 
13
- LLM_MODEL = ChatOpenAI(model='gpt-4o-mini', temperature=0)
13
+ LLM_MODEL = ChatOpenAI(model="gpt-4o-mini", temperature=0)
14
+
14
15
 
15
16
  @pytest.fixture(autouse=True)
16
17
  def mock_hydra_fixture():
@@ -39,7 +40,7 @@ def mock_tools_fixture():
39
40
  "retrieve_semantic_scholar_paper_id"
40
41
  ) as mock_s2_retrieve_id,
41
42
  mock.patch(
42
- "aiagents4pharma.talk2scholars.tools.zotero.zotero_read.zotero_search_tool"
43
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_read.zotero_read"
43
44
  ) as mock_zotero_query_results,
44
45
  ):
45
46
  mock_s2_display.return_value = {"result": "Mock Display Result"}
@@ -0,0 +1,273 @@
1
+ """
2
+ Unit tests for Zotero human in the loop in zotero_review.py with structured output.
3
+ """
4
+
5
+ import unittest
6
+ from unittest.mock import patch, MagicMock
7
+ from aiagents4pharma.talk2scholars.tools.zotero.zotero_review import zotero_review
8
+
9
+
10
+ class TestZoteroReviewTool(unittest.TestCase):
11
+ """Test class for Zotero review tool with structured LLM output."""
12
+
13
+ def setUp(self):
14
+ self.tool_call_id = "tc"
15
+ self.collection_path = "/Col"
16
+ # Create a sample fetched papers dictionary with one paper.
17
+ self.sample_papers = {"p1": {"Title": "T1", "Authors": ["A1", "A2", "A3"]}}
18
+
19
+ @patch(
20
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_review.fetch_papers_for_save",
21
+ return_value=None,
22
+ )
23
+ def test_no_fetched_papers(self, mock_fetch):
24
+ """Test when no fetched papers are found."""
25
+ with self.assertRaises(ValueError) as context:
26
+ zotero_review.run(
27
+ {
28
+ "tool_call_id": self.tool_call_id,
29
+ "collection_path": self.collection_path,
30
+ "state": {},
31
+ }
32
+ )
33
+ self.assertIn("No fetched papers were found to save", str(context.exception))
34
+ mock_fetch.assert_called_once()
35
+
36
+ @patch(
37
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_review.fetch_papers_for_save",
38
+ return_value={"p1": {"Title": "T1", "Authors": ["A1", "A2"]}},
39
+ )
40
+ @patch(
41
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_review.interrupt",
42
+ return_value="dummy_response",
43
+ )
44
+ def test_missing_llm_model(self, mock_interrupt, mock_fetch):
45
+ """Test when LLM model is not available in state, expecting fallback confirmation."""
46
+ state = {"last_displayed_papers": self.sample_papers} # llm_model missing
47
+ result = zotero_review.run(
48
+ {
49
+ "tool_call_id": self.tool_call_id,
50
+ "collection_path": self.collection_path,
51
+ "state": state,
52
+ }
53
+ )
54
+ upd = result.update
55
+ # The fallback message should start with "REVIEW REQUIRED:"
56
+ self.assertTrue(upd["messages"][0].content.startswith("REVIEW REQUIRED:"))
57
+ # Check that the approval status is set as fallback values.
58
+ approval = upd["zotero_write_approval_status"]
59
+ self.assertEqual(approval["collection_path"], self.collection_path)
60
+ self.assertTrue(approval["papers_reviewed"])
61
+ self.assertFalse(approval["approved"])
62
+ self.assertEqual(approval["papers_count"], len(self.sample_papers))
63
+ mock_fetch.assert_called_once()
64
+ mock_interrupt.assert_called_once()
65
+
66
+ @patch(
67
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_review.fetch_papers_for_save",
68
+ return_value={"p1": {"Title": "T1", "Authors": ["A1", "A2"]}},
69
+ )
70
+ @patch(
71
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_review.interrupt",
72
+ return_value="dummy_response",
73
+ )
74
+ def test_human_approve(self, mock_interrupt, mock_fetch):
75
+ """Test when human approves saving papers using structured output."""
76
+ # Prepare a fake llm_model with structured output.
77
+ fake_structured_llm = MagicMock()
78
+ # Simulate invoke() returns an object with decision "approve"
79
+ fake_decision = MagicMock()
80
+ fake_decision.decision = "approve"
81
+ fake_structured_llm.invoke.return_value = fake_decision
82
+
83
+ fake_llm_model = MagicMock()
84
+ fake_llm_model.with_structured_output.return_value = fake_structured_llm
85
+
86
+ state = {
87
+ "last_displayed_papers": self.sample_papers,
88
+ "llm_model": fake_llm_model,
89
+ }
90
+
91
+ result = zotero_review.run(
92
+ {
93
+ "tool_call_id": self.tool_call_id,
94
+ "collection_path": self.collection_path,
95
+ "state": state,
96
+ }
97
+ )
98
+
99
+ upd = result.update
100
+ self.assertEqual(
101
+ upd["zotero_write_approval_status"],
102
+ {"collection_path": self.collection_path, "approved": True},
103
+ )
104
+ self.assertIn(
105
+ f"Human approved saving 1 papers to Zotero collection '{self.collection_path}'",
106
+ upd["messages"][0].content,
107
+ )
108
+ mock_fetch.assert_called_once()
109
+ mock_interrupt.assert_called_once()
110
+
111
+ @patch(
112
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_review.fetch_papers_for_save",
113
+ return_value={"p1": {"Title": "T1", "Authors": ["A1", "A2"]}},
114
+ )
115
+ @patch(
116
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_review.interrupt",
117
+ return_value="dummy_response",
118
+ )
119
+ def test_human_approve_custom(self, mock_interrupt, mock_fetch):
120
+ """Test when human approves with a custom collection path."""
121
+ fake_structured_llm = MagicMock()
122
+ fake_decision = MagicMock()
123
+ fake_decision.decision = "custom"
124
+ fake_decision.custom_path = "/Custom"
125
+ fake_structured_llm.invoke.return_value = fake_decision
126
+
127
+ fake_llm_model = MagicMock()
128
+ fake_llm_model.with_structured_output.return_value = fake_structured_llm
129
+
130
+ state = {
131
+ "last_displayed_papers": self.sample_papers,
132
+ "llm_model": fake_llm_model,
133
+ }
134
+
135
+ result = zotero_review.run(
136
+ {
137
+ "tool_call_id": self.tool_call_id,
138
+ "collection_path": self.collection_path,
139
+ "state": state,
140
+ }
141
+ )
142
+
143
+ upd = result.update
144
+ self.assertEqual(
145
+ upd["zotero_write_approval_status"],
146
+ {"collection_path": "/Custom", "approved": True},
147
+ )
148
+ self.assertIn(
149
+ "Human approved saving papers to custom Zotero collection '/Custom'",
150
+ upd["messages"][0].content,
151
+ )
152
+ mock_fetch.assert_called_once()
153
+ mock_interrupt.assert_called_once()
154
+
155
+ @patch(
156
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_review.fetch_papers_for_save",
157
+ return_value={"p1": {"Title": "T1", "Authors": ["A1", "A2"]}},
158
+ )
159
+ @patch(
160
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_review.interrupt",
161
+ return_value="dummy_response",
162
+ )
163
+ def test_human_reject(self, mock_interrupt, mock_fetch):
164
+ """Test when human rejects saving papers via structured output."""
165
+ fake_structured_llm = MagicMock()
166
+ fake_decision = MagicMock()
167
+ fake_decision.decision = "reject"
168
+ fake_structured_llm.invoke.return_value = fake_decision
169
+
170
+ fake_llm_model = MagicMock()
171
+ fake_llm_model.with_structured_output.return_value = fake_structured_llm
172
+
173
+ state = {
174
+ "last_displayed_papers": self.sample_papers,
175
+ "llm_model": fake_llm_model,
176
+ }
177
+
178
+ result = zotero_review.run(
179
+ {
180
+ "tool_call_id": self.tool_call_id,
181
+ "collection_path": self.collection_path,
182
+ "state": state,
183
+ }
184
+ )
185
+
186
+ upd = result.update
187
+ self.assertEqual(upd["zotero_write_approval_status"], {"approved": False})
188
+ self.assertIn(
189
+ "Human rejected saving papers to Zotero", upd["messages"][0].content
190
+ )
191
+ mock_fetch.assert_called_once()
192
+ mock_interrupt.assert_called_once()
193
+
194
+ @patch(
195
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_review.fetch_papers_for_save"
196
+ )
197
+ @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_review.interrupt")
198
+ def test_structured_processing_failure(self, mock_interrupt, mock_fetch):
199
+ """Test fallback when structured review processing fails."""
200
+ # Simulate valid fetched papers with multiple entries.
201
+ papers = {
202
+ f"p{i}": {"Title": f"Title{i}", "Authors": [f"A{i}"]} for i in range(1, 8)
203
+ }
204
+ mock_fetch.return_value = papers
205
+ mock_interrupt.return_value = "dummy_response"
206
+ # Provide a fake llm_model whose invoke() raises an exception.
207
+ fake_structured_llm = MagicMock()
208
+ fake_structured_llm.invoke.side_effect = Exception("structured error")
209
+ fake_llm_model = MagicMock()
210
+ fake_llm_model.with_structured_output.return_value = fake_structured_llm
211
+
212
+ state = {"last_displayed_papers": papers, "llm_model": fake_llm_model}
213
+
214
+ result = zotero_review.run(
215
+ {
216
+ "tool_call_id": self.tool_call_id,
217
+ "collection_path": "/MyCol",
218
+ "state": state,
219
+ }
220
+ )
221
+
222
+ upd = result.update
223
+ content = upd["messages"][0].content
224
+ # The fallback message should start with "REVIEW REQUIRED:".
225
+ self.assertTrue(content.startswith("REVIEW REQUIRED:"))
226
+ self.assertIn("Would you like to save 7 papers", content)
227
+ self.assertIn("... and 2 more papers", content)
228
+
229
+ approved = upd["zotero_write_approval_status"]
230
+ self.assertEqual(approved["collection_path"], "/MyCol")
231
+ self.assertTrue(approved["papers_reviewed"])
232
+ self.assertFalse(approved["approved"])
233
+ self.assertEqual(approved["papers_count"], 7)
234
+
235
+ @patch(
236
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_review.fetch_papers_for_save",
237
+ return_value={
238
+ "p1": {"Title": "Test Paper", "Authors": ["Alice", "Bob", "Charlie"]}
239
+ },
240
+ )
241
+ @patch(
242
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_review.interrupt",
243
+ return_value="dummy_response",
244
+ )
245
+ def test_authors_et_al_in_summary(self, mock_interrupt, mock_fetch):
246
+ """
247
+ Test that the papers summary includes 'et al.' when a paper has more than two authors.
248
+ This is achieved by forcing a fallback (structured processing failure) so that the fallback
249
+ message with the papers summary is generated.
250
+ """
251
+ # Create a fake llm_model whose structured output processing fails.
252
+ fake_structured_llm = MagicMock()
253
+ fake_structured_llm.invoke.side_effect = Exception("structured error")
254
+ fake_llm_model = MagicMock()
255
+ fake_llm_model.with_structured_output.return_value = fake_structured_llm
256
+
257
+ state = {
258
+ "last_displayed_papers": {
259
+ "p1": {"Title": "Test Paper", "Authors": ["Alice", "Bob", "Charlie"]}
260
+ },
261
+ "llm_model": fake_llm_model,
262
+ }
263
+ result = zotero_review.run(
264
+ {
265
+ "tool_call_id": self.tool_call_id,
266
+ "collection_path": self.collection_path,
267
+ "state": state,
268
+ }
269
+ )
270
+ fallback_message = result.update["messages"][0].content
271
+ self.assertIn("et al.", fallback_message)
272
+ mock_fetch.assert_called_once()
273
+ mock_interrupt.assert_called_once()