aiagents4pharma 1.28.0__py3-none-any.whl → 1.29.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.
Files changed (41) hide show
  1. aiagents4pharma/talk2scholars/agents/main_agent.py +35 -209
  2. aiagents4pharma/talk2scholars/agents/s2_agent.py +10 -6
  3. aiagents4pharma/talk2scholars/agents/zotero_agent.py +12 -6
  4. aiagents4pharma/talk2scholars/configs/agents/talk2scholars/main_agent/default.yaml +2 -48
  5. aiagents4pharma/talk2scholars/configs/agents/talk2scholars/s2_agent/default.yaml +5 -28
  6. aiagents4pharma/talk2scholars/configs/agents/talk2scholars/zotero_agent/default.yaml +5 -21
  7. aiagents4pharma/talk2scholars/configs/config.yaml +1 -0
  8. aiagents4pharma/talk2scholars/configs/tools/__init__.py +1 -0
  9. aiagents4pharma/talk2scholars/configs/tools/multi_paper_recommendation/default.yaml +1 -1
  10. aiagents4pharma/talk2scholars/configs/tools/search/default.yaml +1 -1
  11. aiagents4pharma/talk2scholars/configs/tools/single_paper_recommendation/default.yaml +1 -1
  12. aiagents4pharma/talk2scholars/configs/tools/zotero_read/default.yaml +42 -1
  13. aiagents4pharma/talk2scholars/configs/tools/zotero_write/__inti__.py +3 -0
  14. aiagents4pharma/talk2scholars/tests/test_main_agent.py +186 -111
  15. aiagents4pharma/talk2scholars/tests/test_s2_display.py +74 -0
  16. aiagents4pharma/talk2scholars/tests/test_s2_multi.py +282 -0
  17. aiagents4pharma/talk2scholars/tests/test_s2_query.py +78 -0
  18. aiagents4pharma/talk2scholars/tests/test_s2_retrieve.py +65 -0
  19. aiagents4pharma/talk2scholars/tests/test_s2_search.py +266 -0
  20. aiagents4pharma/talk2scholars/tests/test_s2_single.py +274 -0
  21. aiagents4pharma/talk2scholars/tests/test_zotero_path.py +57 -0
  22. aiagents4pharma/talk2scholars/tests/test_zotero_read.py +412 -0
  23. aiagents4pharma/talk2scholars/tests/test_zotero_write.py +626 -0
  24. aiagents4pharma/talk2scholars/tools/s2/multi_paper_rec.py +50 -34
  25. aiagents4pharma/talk2scholars/tools/s2/retrieve_semantic_scholar_paper_id.py +8 -8
  26. aiagents4pharma/talk2scholars/tools/s2/search.py +36 -23
  27. aiagents4pharma/talk2scholars/tools/s2/single_paper_rec.py +44 -38
  28. aiagents4pharma/talk2scholars/tools/zotero/__init__.py +2 -0
  29. aiagents4pharma/talk2scholars/tools/zotero/utils/__init__.py +5 -0
  30. aiagents4pharma/talk2scholars/tools/zotero/utils/zotero_path.py +63 -0
  31. aiagents4pharma/talk2scholars/tools/zotero/zotero_read.py +64 -19
  32. aiagents4pharma/talk2scholars/tools/zotero/zotero_write.py +247 -0
  33. {aiagents4pharma-1.28.0.dist-info → aiagents4pharma-1.29.0.dist-info}/METADATA +6 -5
  34. {aiagents4pharma-1.28.0.dist-info → aiagents4pharma-1.29.0.dist-info}/RECORD +37 -28
  35. aiagents4pharma/talk2scholars/tests/test_call_s2.py +0 -100
  36. aiagents4pharma/talk2scholars/tests/test_call_zotero.py +0 -94
  37. aiagents4pharma/talk2scholars/tests/test_s2_tools.py +0 -355
  38. aiagents4pharma/talk2scholars/tests/test_zotero_tool.py +0 -171
  39. {aiagents4pharma-1.28.0.dist-info → aiagents4pharma-1.29.0.dist-info}/LICENSE +0 -0
  40. {aiagents4pharma-1.28.0.dist-info → aiagents4pharma-1.29.0.dist-info}/WHEEL +0 -0
  41. {aiagents4pharma-1.28.0.dist-info → aiagents4pharma-1.29.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,57 @@
1
+ """
2
+ Unit tests for Zotero path utility in zotero_path.py.
3
+ """
4
+
5
+ import unittest
6
+ from unittest.mock import MagicMock
7
+ from aiagents4pharma.talk2scholars.tools.zotero.utils.zotero_path import (
8
+ get_item_collections,
9
+ )
10
+
11
+
12
+ class TestGetItemCollections(unittest.TestCase):
13
+ """Unit tests for the get_item_collections function."""
14
+
15
+ def test_basic_collections_mapping(self):
16
+ """test_basic_collections_mapping"""
17
+ # Define fake collections with one parent-child relationship and one independent collection.
18
+ fake_collections = [
19
+ {"key": "A", "data": {"name": "Parent", "parentCollection": None}},
20
+ {"key": "B", "data": {"name": "Child", "parentCollection": "A"}},
21
+ {"key": "C", "data": {"name": "Independent", "parentCollection": None}},
22
+ ]
23
+ # Define fake collection items for each collection:
24
+ # - Collection A returns one item with key "item1"
25
+ # - Collection B returns one item with key "item2"
26
+ # - Collection C returns two items: one duplicate ("item1") and one new ("item3")
27
+ fake_collection_items = {
28
+ "A": [{"data": {"key": "item1"}}],
29
+ "B": [{"data": {"key": "item2"}}],
30
+ "C": [{"data": {"key": "item1"}}, {"data": {"key": "item3"}}],
31
+ }
32
+ fake_zot = MagicMock()
33
+ fake_zot.collections.return_value = fake_collections
34
+
35
+ # When collection_items is called, return the appropriate list based on collection key.
36
+ def fake_collection_items_func(collection_key):
37
+ return fake_collection_items.get(collection_key, [])
38
+
39
+ fake_zot.collection_items.side_effect = fake_collection_items_func
40
+
41
+ # Expected full collection paths:
42
+ # - Collection A: "/Parent"
43
+ # - Collection B: "/Parent/Child" (child of A)
44
+ # - Collection C: "/Independent"
45
+ #
46
+ # Expected mapping for items:
47
+ # - "item1" appears in collections A and C → ["/Parent", "/Independent"]
48
+ # - "item2" appears in B → ["/Parent/Child"]
49
+ # - "item3" appears in C → ["/Independent"]
50
+ expected_mapping = {
51
+ "item1": ["/Parent", "/Independent"],
52
+ "item2": ["/Parent/Child"],
53
+ "item3": ["/Independent"],
54
+ }
55
+
56
+ result = get_item_collections(fake_zot)
57
+ self.assertEqual(result, expected_mapping)
@@ -0,0 +1,412 @@
1
+ """
2
+ Unit tests for Zotero search tool in zotero_read.py.
3
+ """
4
+
5
+ from types import SimpleNamespace
6
+ import unittest
7
+ from unittest.mock import patch, MagicMock
8
+ from langgraph.types import Command
9
+ from aiagents4pharma.talk2scholars.tools.zotero.zotero_read import (
10
+ zotero_search_tool,
11
+ )
12
+
13
+
14
+ # Dummy Hydra configuration to be used in tests
15
+ dummy_zotero_read_config = SimpleNamespace(
16
+ user_id="dummy_user",
17
+ library_type="user",
18
+ api_key="dummy_api_key",
19
+ zotero=SimpleNamespace(
20
+ max_limit=5,
21
+ filter_item_types=["journalArticle", "conferencePaper"],
22
+ filter_excluded_types=["attachment", "note"],
23
+ ),
24
+ )
25
+ dummy_cfg = SimpleNamespace(tools=SimpleNamespace(zotero_read=dummy_zotero_read_config))
26
+
27
+
28
+ class TestZoteroSearchTool(unittest.TestCase):
29
+ """test for Zotero search tool"""
30
+
31
+ @patch(
32
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_read.get_item_collections"
33
+ )
34
+ @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_read.zotero.Zotero")
35
+ @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_read.hydra.compose")
36
+ @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_read.hydra.initialize")
37
+ def test_valid_query(
38
+ self,
39
+ mock_hydra_init,
40
+ mock_hydra_compose,
41
+ mock_zotero_class,
42
+ mock_get_item_collections,
43
+ ):
44
+ """test valid query"""
45
+ # Setup Hydra mocks
46
+ mock_hydra_compose.return_value = dummy_cfg
47
+ mock_hydra_init.return_value.__enter__.return_value = None
48
+
49
+ # Create a fake Zotero client that returns two valid items
50
+ fake_zot = MagicMock()
51
+ fake_items = [
52
+ {
53
+ "data": {
54
+ "key": "paper1",
55
+ "title": "Paper 1",
56
+ "abstractNote": "Abstract 1",
57
+ "date": "2021",
58
+ "url": "http://example.com",
59
+ "itemType": "journalArticle",
60
+ }
61
+ },
62
+ {
63
+ "data": {
64
+ "key": "paper2",
65
+ "title": "Paper 2",
66
+ "abstractNote": "Abstract 2",
67
+ "date": "2022",
68
+ "url": "http://example2.com",
69
+ "itemType": "conferencePaper",
70
+ }
71
+ },
72
+ ]
73
+ fake_zot.items.return_value = fake_items
74
+ mock_zotero_class.return_value = fake_zot
75
+
76
+ # Fake mapping for collection paths
77
+ mock_get_item_collections.return_value = {
78
+ "paper1": ["/Test Collection"],
79
+ "paper2": ["/Test Collection"],
80
+ }
81
+
82
+ # Call the tool with a valid query using .run() with a dictionary input
83
+ tool_call_id = "test_id_1"
84
+ tool_input = {
85
+ "query": "test",
86
+ "only_articles": True,
87
+ "tool_call_id": tool_call_id,
88
+ "limit": 2,
89
+ }
90
+ result = zotero_search_tool.run(tool_input)
91
+
92
+ # Verify the Command update structure and contents
93
+ self.assertIsInstance(result, Command)
94
+ update = result.update
95
+ self.assertIn("zotero_read", update)
96
+ self.assertIn("last_displayed_papers", update)
97
+ self.assertIn("messages", update)
98
+
99
+ filtered_papers = update["zotero_read"]
100
+ self.assertIn("paper1", filtered_papers)
101
+ self.assertIn("paper2", filtered_papers)
102
+ message_content = update["messages"][0].content
103
+ self.assertIn("Query: test", message_content)
104
+ self.assertIn("Number of papers found: 2", message_content)
105
+
106
+ @patch(
107
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_read.get_item_collections"
108
+ )
109
+ @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_read.zotero.Zotero")
110
+ @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_read.hydra.compose")
111
+ @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_read.hydra.initialize")
112
+ def test_empty_query_fetch_all_items(
113
+ self,
114
+ mock_hydra_init,
115
+ mock_hydra_compose,
116
+ mock_zotero_class,
117
+ mock_get_item_collections,
118
+ ):
119
+ """test empty query fetches all items"""
120
+ mock_hydra_compose.return_value = dummy_cfg
121
+ mock_hydra_init.return_value.__enter__.return_value = None
122
+
123
+ fake_zot = MagicMock()
124
+ fake_items = [
125
+ {
126
+ "data": {
127
+ "key": "paper1",
128
+ "title": "Paper 1",
129
+ "abstractNote": "Abstract 1",
130
+ "date": "2021",
131
+ "url": "http://example.com",
132
+ "itemType": "journalArticle",
133
+ }
134
+ },
135
+ ]
136
+ fake_zot.items.return_value = fake_items
137
+ mock_zotero_class.return_value = fake_zot
138
+ mock_get_item_collections.return_value = {"paper1": ["/Test Collection"]}
139
+
140
+ tool_call_id = "test_id_2"
141
+ tool_input = {
142
+ "query": " ",
143
+ "only_articles": True,
144
+ "tool_call_id": tool_call_id,
145
+ "limit": 2,
146
+ }
147
+ result = zotero_search_tool.run(tool_input)
148
+
149
+ update = result.update
150
+ filtered_papers = update["zotero_read"]
151
+ self.assertIn("paper1", filtered_papers)
152
+ fake_zot.items.assert_called_with(
153
+ limit=dummy_cfg.tools.zotero_read.zotero.max_limit
154
+ )
155
+
156
+ @patch(
157
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_read.get_item_collections"
158
+ )
159
+ @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_read.zotero.Zotero")
160
+ @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_read.hydra.compose")
161
+ @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_read.hydra.initialize")
162
+ def test_no_items_returned(
163
+ self,
164
+ mock_hydra_init,
165
+ mock_hydra_compose,
166
+ mock_zotero_class,
167
+ mock_get_item_collections,
168
+ ):
169
+ """test no items returned from Zotero"""
170
+ mock_hydra_compose.return_value = dummy_cfg
171
+ mock_hydra_init.return_value.__enter__.return_value = None
172
+
173
+ fake_zot = MagicMock()
174
+ fake_zot.items.return_value = []
175
+ mock_zotero_class.return_value = fake_zot
176
+ mock_get_item_collections.return_value = {}
177
+
178
+ tool_call_id = "test_id_3"
179
+ tool_input = {
180
+ "query": "nonexistent",
181
+ "only_articles": True,
182
+ "tool_call_id": tool_call_id,
183
+ "limit": 2,
184
+ }
185
+ with self.assertRaises(RuntimeError) as context:
186
+ zotero_search_tool.run(tool_input)
187
+ self.assertIn("No items returned from Zotero", str(context.exception))
188
+
189
+ @patch(
190
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_read.get_item_collections"
191
+ )
192
+ @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_read.zotero.Zotero")
193
+ @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_read.hydra.compose")
194
+ @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_read.hydra.initialize")
195
+ def test_filtering_no_matching_papers(
196
+ self,
197
+ mock_hydra_init,
198
+ mock_hydra_compose,
199
+ mock_zotero_class,
200
+ mock_get_item_collections,
201
+ ):
202
+ """test no matching papers returned from Zotero"""
203
+ mock_hydra_compose.return_value = dummy_cfg
204
+ mock_hydra_init.return_value.__enter__.return_value = None
205
+
206
+ fake_zot = MagicMock()
207
+ fake_items = [
208
+ {
209
+ "data": {
210
+ "key": "paper1",
211
+ "title": "Paper 1",
212
+ "abstractNote": "Abstract 1",
213
+ "date": "2021",
214
+ "url": "http://example.com",
215
+ "itemType": "attachment",
216
+ }
217
+ },
218
+ {
219
+ "data": {
220
+ "key": "paper2",
221
+ "title": "Paper 2",
222
+ "abstractNote": "Abstract 2",
223
+ "date": "2022",
224
+ "url": "http://example2.com",
225
+ "itemType": "note",
226
+ }
227
+ },
228
+ ]
229
+ fake_zot.items.return_value = fake_items
230
+ mock_zotero_class.return_value = fake_zot
231
+ mock_get_item_collections.return_value = {
232
+ "paper1": ["/Test Collection"],
233
+ "paper2": ["/Test Collection"],
234
+ }
235
+
236
+ tool_call_id = "test_id_4"
237
+ tool_input = {
238
+ "query": "test",
239
+ "only_articles": True,
240
+ "tool_call_id": tool_call_id,
241
+ "limit": 2,
242
+ }
243
+ with self.assertRaises(RuntimeError) as context:
244
+ zotero_search_tool.run(tool_input)
245
+ self.assertIn("No matching papers returned from Zotero", str(context.exception))
246
+
247
+ @patch(
248
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_read.get_item_collections"
249
+ )
250
+ @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_read.zotero.Zotero")
251
+ @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_read.hydra.compose")
252
+ @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_read.hydra.initialize")
253
+ def test_items_api_exception(
254
+ self,
255
+ mock_hydra_init,
256
+ mock_hydra_compose,
257
+ mock_zotero_class,
258
+ mock_get_item_collections,
259
+ ):
260
+ """test items API exception"""
261
+ mock_hydra_compose.return_value = dummy_cfg
262
+ mock_hydra_init.return_value.__enter__.return_value = None
263
+ mock_get_item_collections.return_value = {}
264
+
265
+ fake_zot = MagicMock()
266
+ fake_zot.items.side_effect = Exception("API error")
267
+ mock_zotero_class.return_value = fake_zot
268
+
269
+ tool_call_id = "test_id_5"
270
+ tool_input = {
271
+ "query": "test",
272
+ "only_articles": True,
273
+ "tool_call_id": tool_call_id,
274
+ "limit": 2,
275
+ }
276
+ with self.assertRaises(RuntimeError) as context:
277
+ zotero_search_tool.run(tool_input)
278
+ self.assertIn("Failed to fetch items from Zotero", str(context.exception))
279
+
280
+ @patch(
281
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_read.get_item_collections"
282
+ )
283
+ @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_read.zotero.Zotero")
284
+ @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_read.hydra.compose")
285
+ @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_read.hydra.initialize")
286
+ def test_missing_key_in_item(
287
+ self,
288
+ mock_hydra_init,
289
+ mock_hydra_compose,
290
+ mock_zotero_class,
291
+ mock_get_item_collections,
292
+ ):
293
+ """
294
+ Test that an item with a valid 'data' structure but missing the 'key' field is skipped.
295
+ """
296
+ mock_hydra_compose.return_value = dummy_cfg
297
+ mock_hydra_init.return_value.__enter__.return_value = None
298
+
299
+ fake_zot = MagicMock()
300
+ fake_items = [
301
+ {
302
+ "data": {
303
+ "title": "No Key Paper",
304
+ "abstractNote": "Abstract",
305
+ "date": "2021",
306
+ "url": "http://example.com",
307
+ "itemType": "journalArticle",
308
+ }
309
+ }, # missing key triggers line 136
310
+ {
311
+ "data": {
312
+ "key": "paper_valid",
313
+ "title": "Valid Paper",
314
+ "abstractNote": "Valid Abstract",
315
+ "date": "2021",
316
+ "url": "http://example.com",
317
+ "itemType": "journalArticle",
318
+ }
319
+ },
320
+ ]
321
+ fake_zot.items.return_value = fake_items
322
+ mock_zotero_class.return_value = fake_zot
323
+ mock_get_item_collections.return_value = {"paper_valid": ["/Test Collection"]}
324
+
325
+ tool_call_id = "test_id_6"
326
+ tool_input = {
327
+ "query": "dummy",
328
+ "only_articles": True,
329
+ "tool_call_id": tool_call_id,
330
+ "limit": 2,
331
+ }
332
+ result = zotero_search_tool.run(tool_input)
333
+
334
+ update = result.update
335
+ filtered_papers = update["zotero_read"]
336
+ self.assertIn("paper_valid", filtered_papers)
337
+ self.assertEqual(len(filtered_papers), 1)
338
+
339
+ @patch(
340
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_read.get_item_collections"
341
+ )
342
+ @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_read.zotero.Zotero")
343
+ @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_read.hydra.compose")
344
+ @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_read.hydra.initialize")
345
+ def test_item_not_dict(
346
+ self,
347
+ mock_hydra_init,
348
+ mock_hydra_compose,
349
+ mock_zotero_class,
350
+ mock_get_item_collections,
351
+ ):
352
+ """
353
+ Test that if the items list contains an element that is not a dict, it is skipped.
354
+ """
355
+ mock_hydra_compose.return_value = dummy_cfg
356
+ mock_hydra_init.return_value.__enter__.return_value = None
357
+
358
+ fake_zot = MagicMock()
359
+ # Supply one item that is not a dict.
360
+ fake_items = ["this is not a dict"]
361
+ fake_zot.items.return_value = fake_items
362
+ mock_zotero_class.return_value = fake_zot
363
+ # Mapping doesn't matter here.
364
+ mock_get_item_collections.return_value = {}
365
+
366
+ tool_call_id = "test_id_7"
367
+ tool_input = {
368
+ "query": "dummy",
369
+ "only_articles": True,
370
+ "tool_call_id": tool_call_id,
371
+ "limit": 2,
372
+ }
373
+ with self.assertRaises(RuntimeError) as context:
374
+ zotero_search_tool.run(tool_input)
375
+ self.assertIn("No matching papers returned from Zotero", str(context.exception))
376
+
377
+ @patch(
378
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_read.get_item_collections"
379
+ )
380
+ @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_read.zotero.Zotero")
381
+ @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_read.hydra.compose")
382
+ @patch("aiagents4pharma.talk2scholars.tools.zotero.zotero_read.hydra.initialize")
383
+ def test_data_not_dict(
384
+ self,
385
+ mock_hydra_init,
386
+ mock_hydra_compose,
387
+ mock_zotero_class,
388
+ mock_get_item_collections,
389
+ ):
390
+ """
391
+ Test that if an item has a 'data' field that is not a dict, it is skipped.
392
+ """
393
+ mock_hydra_compose.return_value = dummy_cfg
394
+ mock_hydra_init.return_value.__enter__.return_value = None
395
+
396
+ fake_zot = MagicMock()
397
+ # Supply one item whose "data" field is not a dict.
398
+ fake_items = [{"data": "this is not a dict"}]
399
+ fake_zot.items.return_value = fake_items
400
+ mock_zotero_class.return_value = fake_zot
401
+ mock_get_item_collections.return_value = {}
402
+
403
+ tool_call_id = "test_id_8"
404
+ tool_input = {
405
+ "query": "dummy",
406
+ "only_articles": True,
407
+ "tool_call_id": tool_call_id,
408
+ "limit": 2,
409
+ }
410
+ with self.assertRaises(RuntimeError) as context:
411
+ zotero_search_tool.run(tool_input)
412
+ self.assertIn("No matching papers returned from Zotero", str(context.exception))