aiagents4pharma 1.30.1__py3-none-any.whl → 1.30.3__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.
Files changed (45) hide show
  1. aiagents4pharma/talk2scholars/__init__.py +2 -0
  2. aiagents4pharma/talk2scholars/agents/__init__.py +8 -0
  3. aiagents4pharma/talk2scholars/agents/zotero_agent.py +9 -7
  4. aiagents4pharma/talk2scholars/configs/__init__.py +2 -0
  5. aiagents4pharma/talk2scholars/configs/agents/__init__.py +2 -0
  6. aiagents4pharma/talk2scholars/configs/agents/talk2scholars/__init__.py +2 -0
  7. aiagents4pharma/talk2scholars/configs/agents/talk2scholars/zotero_agent/default.yaml +9 -15
  8. aiagents4pharma/talk2scholars/configs/app/__init__.py +2 -0
  9. aiagents4pharma/talk2scholars/configs/tools/__init__.py +9 -0
  10. aiagents4pharma/talk2scholars/configs/tools/zotero_write/default.yaml +55 -0
  11. aiagents4pharma/talk2scholars/state/__init__.py +4 -2
  12. aiagents4pharma/talk2scholars/state/state_talk2scholars.py +3 -0
  13. aiagents4pharma/talk2scholars/tests/test_routing_logic.py +1 -2
  14. aiagents4pharma/talk2scholars/tests/test_s2_multi.py +10 -8
  15. aiagents4pharma/talk2scholars/tests/test_s2_search.py +9 -5
  16. aiagents4pharma/talk2scholars/tests/test_s2_single.py +7 -7
  17. aiagents4pharma/talk2scholars/tests/test_zotero_agent.py +3 -2
  18. aiagents4pharma/talk2scholars/tests/test_zotero_human_in_the_loop.py +273 -0
  19. aiagents4pharma/talk2scholars/tests/test_zotero_path.py +433 -1
  20. aiagents4pharma/talk2scholars/tests/test_zotero_read.py +57 -43
  21. aiagents4pharma/talk2scholars/tests/test_zotero_write.py +123 -588
  22. aiagents4pharma/talk2scholars/tools/__init__.py +3 -0
  23. aiagents4pharma/talk2scholars/tools/pdf/__init__.py +4 -2
  24. aiagents4pharma/talk2scholars/tools/s2/__init__.py +9 -0
  25. aiagents4pharma/talk2scholars/tools/s2/multi_paper_rec.py +9 -135
  26. aiagents4pharma/talk2scholars/tools/s2/search.py +8 -114
  27. aiagents4pharma/talk2scholars/tools/s2/single_paper_rec.py +8 -126
  28. aiagents4pharma/talk2scholars/tools/s2/utils/__init__.py +7 -0
  29. aiagents4pharma/talk2scholars/tools/s2/utils/multi_helper.py +194 -0
  30. aiagents4pharma/talk2scholars/tools/s2/utils/search_helper.py +175 -0
  31. aiagents4pharma/talk2scholars/tools/s2/utils/single_helper.py +186 -0
  32. aiagents4pharma/talk2scholars/tools/zotero/__init__.py +3 -0
  33. aiagents4pharma/talk2scholars/tools/zotero/utils/__init__.py +5 -0
  34. aiagents4pharma/talk2scholars/tools/zotero/utils/read_helper.py +167 -0
  35. aiagents4pharma/talk2scholars/tools/zotero/utils/review_helper.py +78 -0
  36. aiagents4pharma/talk2scholars/tools/zotero/utils/write_helper.py +197 -0
  37. aiagents4pharma/talk2scholars/tools/zotero/utils/zotero_path.py +126 -1
  38. aiagents4pharma/talk2scholars/tools/zotero/zotero_read.py +10 -139
  39. aiagents4pharma/talk2scholars/tools/zotero/zotero_review.py +164 -0
  40. aiagents4pharma/talk2scholars/tools/zotero/zotero_write.py +40 -229
  41. {aiagents4pharma-1.30.1.dist-info → aiagents4pharma-1.30.3.dist-info}/METADATA +3 -2
  42. {aiagents4pharma-1.30.1.dist-info → aiagents4pharma-1.30.3.dist-info}/RECORD +45 -35
  43. {aiagents4pharma-1.30.1.dist-info → aiagents4pharma-1.30.3.dist-info}/WHEEL +1 -1
  44. {aiagents4pharma-1.30.1.dist-info → aiagents4pharma-1.30.3.dist-info/licenses}/LICENSE +0 -0
  45. {aiagents4pharma-1.30.1.dist-info → aiagents4pharma-1.30.3.dist-info}/top_level.txt +0 -0
@@ -2,19 +2,14 @@
2
2
  Unit tests for Zotero write tool in zotero_write.py.
3
3
  """
4
4
 
5
- from types import SimpleNamespace
6
5
  import unittest
6
+ from types import SimpleNamespace
7
7
  from unittest.mock import patch, MagicMock
8
- from langgraph.types import Command
9
- from aiagents4pharma.talk2scholars.tools.zotero.zotero_write import (
10
- zotero_save_tool,
11
- )
12
8
 
13
- # Dummy Hydra configuration for the Zotero write tool
9
+ from aiagents4pharma.talk2scholars.tools.zotero.zotero_write import zotero_write
10
+
14
11
  dummy_zotero_write_config = SimpleNamespace(
15
- user_id="dummy_user_write",
16
- library_type="user",
17
- api_key="dummy_api_key_write",
12
+ user_id="dummy", library_type="user", api_key="dummy"
18
13
  )
19
14
  dummy_cfg = SimpleNamespace(
20
15
  tools=SimpleNamespace(zotero_write=dummy_zotero_write_config)
@@ -22,605 +17,145 @@ dummy_cfg = SimpleNamespace(
22
17
 
23
18
 
24
19
  class TestZoteroSaveTool(unittest.TestCase):
25
- """a test class for the Zotero save tool"""
26
-
27
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.hydra.initialize")
28
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.hydra.compose")
29
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.zotero.Zotero")
30
- @patch(
31
- "aiagents4pharma.talk2scholars.tools.zotero.zotero_write.get_item_collections"
32
- )
33
- def test_successful_save_direct_state(
34
- self,
35
- mock_get_item_collections,
36
- mock_zotero_class,
37
- mock_hydra_compose,
38
- mock_hydra_init,
39
- ):
40
- """
41
- Test successful saving when the fetched papers are directly provided in the state.
42
- """
43
- mock_hydra_compose.return_value = dummy_cfg
44
- mock_hydra_init.return_value.__enter__.return_value = None
45
-
46
- state = {
47
- "last_displayed_papers": {
48
- "paper1": {
49
- "Title": "Test Paper 1",
50
- "Abstract": "Abstract 1",
51
- "Date": "2021",
52
- "URL": "http://example.com",
53
- "Citations": "0",
54
- },
55
- "paper2": {
56
- "Title": "Test Paper 2",
57
- "Abstract": "Abstract 2",
58
- "Date": "2022",
59
- "URL": "http://example2.com",
60
- "Citations": "1",
61
- },
62
- },
63
- "zotero_read": {"paper1": ["/Test Collection"]},
64
- "query": "dummy query",
65
- }
66
-
67
- fake_zot = MagicMock()
68
- fake_zot.collections.return_value = [
69
- {"key": "col1", "data": {"name": "Test Collection"}}
70
- ]
71
- fake_zot.create_items.return_value = {"success": True}
72
- mock_zotero_class.return_value = fake_zot
73
- mock_get_item_collections.return_value = {}
74
-
75
- tool_call_id = "test_call_1"
76
- tool_input = {
77
- "tool_call_id": tool_call_id,
78
- "collection_path": "/Test Collection",
79
- "state": state,
80
- }
81
- result = zotero_save_tool.run(tool_input)
20
+ """Test class for Zotero save tool"""
21
+
22
+ def setUp(self):
23
+ """Patch Hydra and Zotero client globally"""
24
+ self.hydra_init = patch(
25
+ "aiagents4pharma.talk2scholars.tools.zotero.utils.write_helper.hydra.initialize"
26
+ ).start()
27
+ self.hydra_compose = patch(
28
+ "aiagents4pharma.talk2scholars.tools.zotero.utils.write_helper.hydra.compose",
29
+ return_value=dummy_cfg,
30
+ ).start()
31
+ self.zotero_class = patch(
32
+ "aiagents4pharma.talk2scholars.tools.zotero.utils.write_helper.zotero.Zotero"
33
+ ).start()
34
+
35
+ self.fake_zot = MagicMock()
36
+ self.zotero_class.return_value = self.fake_zot
37
+
38
+ def tearDown(self):
39
+ """Stop all patches"""
40
+ patch.stopall()
41
+
42
+ def make_state(self, papers=None, approved=True, path="/Test Collection"):
43
+ """Create a state dictionary with optional papers and approval info"""
44
+ state = {}
45
+ if approved:
46
+ state["zotero_write_approval_status"] = {
47
+ "approved": True,
48
+ "collection_path": path,
49
+ }
50
+ if papers is not None:
51
+ # When papers is provided as dict, use it directly.
52
+ state["last_displayed_papers"] = (
53
+ papers if isinstance(papers, dict) else "papers"
54
+ )
55
+ if isinstance(papers, dict):
56
+ state["papers"] = papers
57
+ return state
82
58
 
83
- self.assertIsInstance(result, Command)
84
- messages = result.update.get("messages", [])
85
- self.assertTrue(len(messages) > 0)
86
- content = messages[0].content
87
- self.assertIn("Save was successful", content)
88
- self.assertIn("Test Collection", content)
89
-
90
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.hydra.initialize")
91
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.hydra.compose")
92
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.zotero.Zotero")
93
59
  @patch(
94
- "aiagents4pharma.talk2scholars.tools.zotero.zotero_write.get_item_collections"
60
+ "aiagents4pharma.talk2scholars.tools.zotero.utils.write_helper.fetch_papers_for_save",
61
+ return_value=None,
95
62
  )
96
- def test_successful_save_state_key(
97
- self,
98
- mock_get_item_collections,
99
- mock_zotero_class,
100
- mock_hydra_compose,
101
- mock_hydra_init,
102
- ):
103
- """
104
- Test successful saving when the state's last_displayed_papers is a key referencing
105
- the actual fetched papers.
106
- """
107
- mock_hydra_compose.return_value = dummy_cfg
108
- mock_hydra_init.return_value.__enter__.return_value = None
109
-
110
- state = {
111
- "last_displayed_papers": "papers_key",
112
- "papers_key": {
113
- "paper1": {
114
- "Title": "Test Paper 1",
115
- "Abstract": "Abstract 1",
116
- "Date": "2021",
117
- "URL": "http://example.com",
118
- "Citations": "0",
63
+ def test_no_papers_after_approval(self, mock_fetch):
64
+ """Test when no fetched papers are found after approval"""
65
+ with self.assertRaises(ValueError) as cm:
66
+ zotero_write.run(
67
+ {
68
+ "tool_call_id": "id",
69
+ "collection_path": "/Test Collection",
70
+ "state": self.make_state({}, True),
119
71
  }
120
- },
121
- "zotero_read": {"paper1": ["/Test Collection"]},
122
- "query": "dummy query",
123
- }
124
-
125
- fake_zot = MagicMock()
126
- fake_zot.collections.return_value = [
127
- {"key": "col1", "data": {"name": "Test Collection"}}
128
- ]
129
- fake_zot.create_items.return_value = {"success": True}
130
- mock_zotero_class.return_value = fake_zot
131
- mock_get_item_collections.return_value = {}
72
+ )
73
+ self.assertIn("No fetched papers were found to save", str(cm.exception))
74
+ mock_fetch.assert_called_once()
132
75
 
133
- tool_call_id = "test_call_2"
134
- tool_input = {
135
- "tool_call_id": tool_call_id,
136
- "collection_path": "/Test Collection",
137
- "state": state,
138
- }
139
- result = zotero_save_tool.run(tool_input)
140
- self.assertIsInstance(result, Command)
141
- messages = result.update.get("messages", [])
142
- self.assertTrue(len(messages) > 0)
143
- content = messages[0].content
144
- self.assertIn("Save was successful", content)
145
- self.assertIn("Test Collection", content)
146
-
147
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.hydra.initialize")
148
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.hydra.compose")
149
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.zotero.Zotero")
150
76
  @patch(
151
- "aiagents4pharma.talk2scholars.tools.zotero.zotero_write.get_item_collections"
77
+ "aiagents4pharma.talk2scholars.tools.zotero.utils.write_helper.fetch_papers_for_save",
78
+ return_value={"p1": {"Title": "X"}},
152
79
  )
153
- def test_no_fetched_papers(
154
- self,
155
- mock_get_item_collections,
156
- mock_zotero_class,
157
- mock_hydra_compose,
158
- mock_hydra_init,
159
- ):
160
- """
161
- Test that a RuntimeError is raised when there are no fetched papers in the state.
162
- """
163
- mock_hydra_compose.return_value = dummy_cfg
164
- mock_get_item_collections.return_value = {}
165
- mock_hydra_init.return_value.__enter__.return_value = None
166
-
167
- state = {
168
- "last_displayed_papers": {},
169
- "zotero_read": {"paper1": ["/Test Collection"]},
170
- "query": "dummy query",
171
- }
172
-
173
- fake_zot = MagicMock()
174
- fake_zot.collections.return_value = [
175
- {"key": "col1", "data": {"name": "Test Collection"}}
176
- ]
177
- mock_zotero_class.return_value = fake_zot
178
-
179
- tool_call_id = "test_call_3"
180
- tool_input = {
181
- "tool_call_id": tool_call_id,
182
- "collection_path": "/Test Collection",
183
- "state": state,
184
- }
185
- with self.assertRaises(RuntimeError) as context:
186
- zotero_save_tool.run(tool_input)
187
- self.assertIn("No fetched papers were found to save", str(context.exception))
188
-
189
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.hydra.initialize")
190
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.hydra.compose")
191
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.zotero.Zotero")
192
80
  @patch(
193
- "aiagents4pharma.talk2scholars.tools.zotero.zotero_write.get_item_collections"
81
+ "aiagents4pharma.talk2scholars.tools.zotero.utils.write_helper.find_or_create_collection",
82
+ return_value=None,
194
83
  )
195
- def test_fallback_get_item_collections(
196
- self,
197
- mock_get_item_collections,
198
- mock_zotero_class,
199
- mock_hydra_compose,
200
- mock_hydra_init,
201
- ):
202
- """
203
- Test that if 'zotero_read' in the state is empty, the fallback
204
- using get_item_collections is used.
205
- """
206
- mock_hydra_compose.return_value = dummy_cfg
207
- mock_hydra_init.return_value.__enter__.return_value = None
208
-
209
- state = {
210
- "last_displayed_papers": {
211
- "paper1": {
212
- "Title": "Test Paper 1",
213
- "Abstract": "Abstract 1",
214
- "Date": "2021",
215
- "URL": "http://example.com",
216
- "Citations": "0",
217
- }
218
- },
219
- "zotero_read": {}, # empty mapping triggers fallback
220
- "query": "dummy query",
221
- }
222
-
223
- fake_zot = MagicMock()
224
- fake_zot.collections.return_value = [
225
- {"key": "col1", "data": {"name": "Test Collection"}}
84
+ def test_invalid_collection(self, mock_find, mock_fetch):
85
+ """Test when collection path is invalid"""
86
+ self.fake_zot.collections.return_value = [
87
+ {"key": "k1", "data": {"name": "Existing"}}
226
88
  ]
227
- fake_zot.create_items.return_value = {"success": True}
228
- mock_zotero_class.return_value = fake_zot
229
-
230
- # Simulate get_item_collections returning a mapping that includes a match.
231
- mock_get_item_collections.return_value = {"paper1": ["/Test Collection"]}
232
-
233
- tool_call_id = "test_call_4"
234
- tool_input = {
235
- "tool_call_id": tool_call_id,
236
- "collection_path": "/Test Collection",
237
- "state": state,
238
- }
239
- result = zotero_save_tool.run(tool_input)
240
- messages = result.update.get("messages", [])
241
- self.assertTrue(len(messages) > 0)
242
- self.assertIn("Save was successful", messages[0].content)
89
+ # Provide a valid papers dict so we don't hit the no-papers error.
90
+ state = self.make_state({"p1": {"Title": "X"}}, True)
91
+ result = zotero_write.run(
92
+ {
93
+ "tool_call_id": "id",
94
+ "collection_path": "/DoesNotExist",
95
+ "state": state,
96
+ }
97
+ )
98
+ # Remove outdated assertions and check for updated message content.
99
+ content = result.update["messages"][0].content
100
+ self.assertIn("does not exist in Zotero", content)
101
+ self.assertIn("/DoesNotExist", content)
102
+ self.assertIn("Existing", content)
103
+ mock_fetch.return_value = {"p1": {"Title": "X"}}
104
+ mock_find.return_value = None
243
105
 
244
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.hydra.initialize")
245
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.hydra.compose")
246
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.zotero.Zotero")
247
106
  @patch(
248
- "aiagents4pharma.talk2scholars.tools.zotero.zotero_write.get_item_collections"
107
+ "aiagents4pharma.talk2scholars.tools.zotero.utils.write_helper.fetch_papers_for_save",
108
+ return_value={"p1": {"Title": "X"}},
249
109
  )
250
- def test_invalid_collection_path(
251
- self,
252
- mock_get_item_collections,
253
- mock_zotero_class,
254
- mock_hydra_compose,
255
- mock_hydra_init,
256
- ):
257
- """
258
- Test that a RuntimeError is raised when the provided collection
259
- path does not match any collection.
260
- """
261
- mock_hydra_compose.return_value = dummy_cfg
262
- mock_hydra_init.return_value.__enter__.return_value = None
263
-
264
- state = {
265
- "last_displayed_papers": {
266
- "paper1": {
267
- "Title": "Test Paper 1",
268
- "Abstract": "Abstract 1",
269
- "Date": "2021",
270
- "URL": "http://example.com",
271
- "Citations": "0",
272
- }
273
- },
274
- "zotero_read": {}, # empty mapping; no match available
275
- "query": "dummy query",
276
- }
277
-
278
- fake_zot = MagicMock()
279
- fake_zot.collections.return_value = [
280
- {"key": "col1", "data": {"name": "Test Collection"}}
281
- ]
282
- mock_zotero_class.return_value = fake_zot
283
- mock_get_item_collections.return_value = {}
284
-
285
- tool_call_id = "test_call_5"
286
- tool_input = {
287
- "tool_call_id": tool_call_id,
288
- "collection_path": "/Nonexistent",
289
- "state": state,
290
- }
291
- with self.assertRaises(RuntimeError) as context:
292
- zotero_save_tool.run(tool_input)
293
- self.assertIn("does not exist in Zotero", str(context.exception))
294
-
295
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.hydra.initialize")
296
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.hydra.compose")
297
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.zotero.Zotero")
298
110
  @patch(
299
- "aiagents4pharma.talk2scholars.tools.zotero.zotero_write.get_item_collections"
111
+ "aiagents4pharma.talk2scholars.tools.zotero.utils.write_helper.find_or_create_collection",
112
+ return_value="colKey",
300
113
  )
301
- def test_save_failure(
302
- self,
303
- mock_get_item_collections,
304
- mock_zotero_class,
305
- mock_hydra_compose,
306
- mock_hydra_init,
307
- ):
308
- """
309
- Test that if the Zotero client raises an exception during
310
- create_items, a RuntimeError is raised.
311
- """
312
- mock_hydra_compose.return_value = dummy_cfg
313
- mock_hydra_init.return_value.__enter__.return_value = None
314
-
315
- state = {
316
- "last_displayed_papers": {
317
- "paper1": {
318
- "Title": "Test Paper 1",
319
- "Abstract": "Abstract 1",
320
- "Date": "2021",
321
- "URL": "http://example.com",
322
- "Citations": "0",
323
- }
324
- },
325
- "zotero_read": {"paper1": ["/Test Collection"]},
326
- "query": "dummy query",
327
- }
328
-
329
- fake_zot = MagicMock()
330
- fake_zot.collections.return_value = [
331
- {"key": "col1", "data": {"name": "Test Collection"}}
114
+ def test_save_failure(self, mock_find, mock_fetch):
115
+ """Test when Zotero save operation fails"""
116
+ self.fake_zot.collections.return_value = [
117
+ {"key": "colKey", "data": {"name": "Test Collection"}}
332
118
  ]
333
- fake_zot.create_items.side_effect = Exception("Creation error")
334
- mock_zotero_class.return_value = fake_zot
335
- mock_get_item_collections.return_value = {}
336
-
337
- tool_call_id = "test_call_6"
338
- tool_input = {
339
- "tool_call_id": tool_call_id,
340
- "collection_path": "/Test Collection",
341
- "state": state,
342
- }
343
- with self.assertRaises(RuntimeError) as context:
344
- zotero_save_tool.run(tool_input)
345
- self.assertIn("Error saving papers to Zotero", str(context.exception))
346
-
347
- # --- Additional tests to cover missing lines ---
348
-
349
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.hydra.initialize")
350
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.hydra.compose")
351
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.zotero.Zotero")
352
- @patch(
353
- "aiagents4pharma.talk2scholars.tools.zotero.zotero_write.get_item_collections"
354
- )
355
- def test_get_item_collections_exception(
356
- self,
357
- mock_get_item_collections,
358
- mock_zotero_class,
359
- mock_hydra_compose,
360
- mock_hydra_init,
361
- ):
362
- """
363
- Test that if get_item_collections raises an exception, the fallback branch
364
- raises a RuntimeError.
365
- """
366
- mock_hydra_compose.return_value = dummy_cfg
367
- mock_hydra_init.return_value.__enter__.return_value = None
368
-
369
- # Provide valid fetched papers so we bypass earlier error
370
- state = {
371
- "last_displayed_papers": {
372
- "paper1": {
373
- "Title": "Paper 1",
374
- "Abstract": "Abstract 1",
375
- "Date": "2021",
376
- "URL": "http://example.com",
377
- "Citations": "0",
119
+ self.fake_zot.create_items.side_effect = Exception("Creation error")
120
+ state = self.make_state({"p1": {"Title": "X"}}, True)
121
+ with self.assertRaises(RuntimeError) as cm:
122
+ zotero_write.run(
123
+ {
124
+ "tool_call_id": "id",
125
+ "collection_path": "/Test Collection",
126
+ "state": state,
378
127
  }
379
- },
380
- "zotero_read": {}, # empty so fallback is triggered
381
- "query": "dummy query",
382
- }
383
-
384
- fake_zot = MagicMock()
385
- fake_zot.collections.return_value = [
386
- {"key": "col1", "data": {"name": "Test Collection"}}
387
- ]
388
- mock_zotero_class.return_value = fake_zot
128
+ )
129
+ self.assertIn("Error saving papers to Zotero", str(cm.exception))
130
+ mock_fetch.return_value = {"p1": {"Title": "X"}}
131
+ mock_find.return_value = "colKey"
389
132
 
390
- # Simulate exception in get_item_collections
391
- mock_get_item_collections.side_effect = Exception("Mapping error")
392
-
393
- tool_call_id = "test_call_7"
394
- tool_input = {
395
- "tool_call_id": tool_call_id,
396
- "collection_path": "/Test Collection",
397
- "state": state,
398
- }
399
- with self.assertRaises(RuntimeError) as context:
400
- zotero_save_tool.run(tool_input)
401
- self.assertIn(
402
- "Failed to generate collection path mappings", str(context.exception)
403
- )
404
-
405
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.hydra.initialize")
406
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.hydra.compose")
407
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.zotero.Zotero")
408
133
  @patch(
409
- "aiagents4pharma.talk2scholars.tools.zotero.zotero_write.get_item_collections"
134
+ "aiagents4pharma.talk2scholars.tools.zotero.utils.write_helper.fetch_papers_for_save",
135
+ return_value={"p1": {"Title": "X"}},
410
136
  )
411
- def test_zotero_read_string_match(
412
- self,
413
- mock_get_item_collections,
414
- mock_zotero_class,
415
- mock_hydra_compose,
416
- mock_hydra_init,
417
- ):
418
- """
419
- Test that if an entry in zotero_read is a string that matches the normalized path,
420
- it is used as the collection key
421
- """
422
- mock_hydra_compose.return_value = dummy_cfg
423
- mock_hydra_init.return_value.__enter__.return_value = None
424
-
425
- state = {
426
- "last_displayed_papers": {
427
- "paper1": {
428
- "Title": "Paper 1",
429
- "Abstract": "Abstract 1",
430
- "Date": "2021",
431
- "URL": "http://example.com",
432
- "Citations": "0",
433
- }
434
- },
435
- # zotero_read entry is a string, not a list
436
- "zotero_read": {"match_key": "/test collection"},
437
- "query": "dummy query",
438
- }
439
-
440
- # Return a collection with key "match_key" to simulate a successful match.
441
- fake_zot = MagicMock()
442
- fake_zot.collections.return_value = [
443
- {"key": "match_key", "data": {"name": "Test Collection"}}
444
- ]
445
- fake_zot.create_items.return_value = {"success": True}
446
- mock_zotero_class.return_value = fake_zot
447
- # get_item_collections is not used in this branch.
448
- mock_get_item_collections.return_value = {}
449
-
450
- tool_call_id = "test_call_8"
451
- tool_input = {
452
- "tool_call_id": tool_call_id,
453
- "collection_path": "/test collection",
454
- "state": state,
455
- }
456
- result = zotero_save_tool.run(tool_input)
457
- messages = result.update.get("messages", [])
458
- self.assertTrue(len(messages) > 0)
459
- # Check for the correct substring in the returned message.
460
- self.assertIn(
461
- "Papers have been saved to Zotero collection", messages[0].content
462
- )
463
-
464
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.hydra.initialize")
465
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.hydra.compose")
466
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.zotero.Zotero")
467
137
  @patch(
468
- "aiagents4pharma.talk2scholars.tools.zotero.zotero_write.get_item_collections"
138
+ "aiagents4pharma.talk2scholars.tools.zotero.utils.write_helper.find_or_create_collection",
139
+ return_value="colKey",
469
140
  )
470
- def test_direct_match_by_collection_name(
471
- self,
472
- mock_get_item_collections,
473
- mock_zotero_class,
474
- mock_hydra_compose,
475
- mock_hydra_init,
476
- ):
477
- """
478
- Test that if zotero_read does not yield a match, the tool finds
479
- a direct match by collection name
480
- in the collections list
481
- """
482
- mock_hydra_compose.return_value = dummy_cfg
483
- mock_hydra_init.return_value.__enter__.return_value = None
484
-
485
- state = {
486
- "last_displayed_papers": {
487
- "paper1": {
488
- "Title": "Paper 1",
489
- "Abstract": "Abstract 1",
490
- "Date": "2021",
491
- "URL": "http://example.com",
492
- "Citations": "0",
493
- }
494
- },
495
- # Non-matching zotero_read data
496
- "zotero_read": {"dummy": ["/other"]},
497
- "query": "dummy query",
498
- }
499
-
500
- fake_zot = MagicMock()
501
- # Collection with name "Test Collection" should match because
502
- # f"/Test Collection".lower() equals normalized path.
503
- fake_zot.collections.return_value = [
504
- {"key": "col1", "data": {"name": "Test Collection"}}
141
+ def test_successful_save(self, mock_find, mock_fetch):
142
+ """Test when Zotero save operation is successful"""
143
+ self.fake_zot.collections.return_value = [
144
+ {"key": "colKey", "data": {"name": "Test Collection"}}
505
145
  ]
506
- fake_zot.create_items.return_value = {"success": True}
507
- mock_zotero_class.return_value = fake_zot
508
- mock_get_item_collections.return_value = {}
509
-
510
- tool_call_id = "test_call_9"
511
- tool_input = {
512
- "tool_call_id": tool_call_id,
513
- "collection_path": "/Test Collection",
514
- "state": state,
515
- }
516
- result = zotero_save_tool.run(tool_input)
517
- messages = result.update.get("messages", [])
518
- self.assertTrue(len(messages) > 0)
519
- self.assertIn("Test Collection", messages[0].content)
520
-
521
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.hydra.initialize")
522
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.hydra.compose")
523
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.zotero.Zotero")
524
- @patch(
525
- "aiagents4pharma.talk2scholars.tools.zotero.zotero_write.get_item_collections"
526
- )
527
- def test_match_by_stripped_name(
528
- self,
529
- mock_get_item_collections,
530
- mock_zotero_class,
531
- mock_hydra_compose,
532
- mock_hydra_init,
533
- ):
534
- """
535
- Test that if no direct match is found, a match is found by comparing
536
- the stripped collection path.
537
- """
538
- mock_hydra_compose.return_value = dummy_cfg
539
- mock_hydra_init.return_value.__enter__.return_value = None
540
-
541
- # Provide state with non-matching zotero_read
542
- state = {
543
- "last_displayed_papers": {
544
- "paper1": {
545
- "Title": "Paper 1",
546
- "Abstract": "Abstract 1",
547
- "Date": "2021",
548
- "URL": "http://example.com",
549
- "Citations": "0",
550
- }
551
- },
552
- "zotero_read": {"dummy": ["/other"]},
553
- "query": "dummy query",
554
- }
555
-
556
- fake_zot = MagicMock()
557
- # Set collection_path without leading slash so that direct matching fails,
558
- # but normalized_path.lstrip("/") yields "test", which matches the collection name.
559
- fake_zot.collections.return_value = [{"key": "colX", "data": {"name": "test"}}]
560
- fake_zot.create_items.return_value = {"success": True}
561
- mock_zotero_class.return_value = fake_zot
562
- mock_get_item_collections.return_value = {}
563
-
564
- tool_call_id = "test_call_10"
565
- tool_input = {
566
- "tool_call_id": tool_call_id,
567
- "collection_path": "test", # no leading slash
568
- "state": state,
569
- }
570
- result = zotero_save_tool.run(tool_input)
571
- messages = result.update.get("messages", [])
572
- self.assertTrue(len(messages) > 0)
573
- self.assertIn("test", messages[0].content)
574
-
575
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.hydra.initialize")
576
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.hydra.compose")
577
- @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_write.zotero.Zotero")
578
- @patch(
579
- "aiagents4pharma.talk2scholars.tools.zotero.zotero_write.get_item_collections"
580
- )
581
- def test_match_by_path_component(
582
- self,
583
- mock_get_item_collections,
584
- mock_zotero_class,
585
- mock_hydra_compose,
586
- mock_hydra_init,
587
- ):
588
- """
589
- Test that if no full-string match is found, the tool can match
590
- by one of the path components.
591
- """
592
- mock_hydra_compose.return_value = dummy_cfg
593
- mock_hydra_init.return_value.__enter__.return_value = None
594
-
595
- state = {
596
- "last_displayed_papers": {
597
- "paper1": {
598
- "Title": "Paper 1",
599
- "Abstract": "Abstract 1",
600
- "Date": "2021",
601
- "URL": "http://example.com",
602
- "Citations": "0",
603
- }
604
- },
605
- "zotero_read": {"dummy": ["/other"]},
606
- "query": "dummy query",
607
- }
608
-
609
- fake_zot = MagicMock()
610
- # Collection name "bar" should be found via a path component when
611
- # collection_path is "/foo/bar"
612
- fake_zot.collections.return_value = [{"key": "colBar", "data": {"name": "bar"}}]
613
- fake_zot.create_items.return_value = {"success": True}
614
- mock_zotero_class.return_value = fake_zot
615
- mock_get_item_collections.return_value = {}
616
-
617
- tool_call_id = "test_call_11"
618
- tool_input = {
619
- "tool_call_id": tool_call_id,
620
- "collection_path": "/foo/bar",
621
- "state": state,
622
- }
623
- result = zotero_save_tool.run(tool_input)
624
- messages = result.update.get("messages", [])
625
- self.assertTrue(len(messages) > 0)
626
- self.assertIn("bar", messages[0].content)
146
+ self.fake_zot.create_items.return_value = {
147
+ "successful": {"0": {"key": "item1"}}
148
+ }
149
+ mock_fetch.return_value = {"p1": {"Title": "X"}}
150
+ mock_find.return_value = "colKey"
151
+
152
+ result = zotero_write.run(
153
+ {
154
+ "tool_call_id": "id",
155
+ "collection_path": "/Test Collection",
156
+ "state": self.make_state({"p1": {"Title": "X"}}, True),
157
+ }
158
+ )
159
+ content = result.update["messages"][0].content
160
+ self.assertIn("Save was successful", content)
161
+ self.assertIn("Test Collection", content)