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,626 @@
1
+ """
2
+ Unit tests for Zotero write tool in zotero_write.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_write import (
10
+ zotero_save_tool,
11
+ )
12
+
13
+ # Dummy Hydra configuration for the Zotero write tool
14
+ dummy_zotero_write_config = SimpleNamespace(
15
+ user_id="dummy_user_write",
16
+ library_type="user",
17
+ api_key="dummy_api_key_write",
18
+ )
19
+ dummy_cfg = SimpleNamespace(
20
+ tools=SimpleNamespace(zotero_write=dummy_zotero_write_config)
21
+ )
22
+
23
+
24
+ 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)
82
+
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
+ @patch(
94
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_write.get_item_collections"
95
+ )
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",
119
+ }
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 = {}
132
+
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
+ @patch(
151
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_write.get_item_collections"
152
+ )
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
+ @patch(
193
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_write.get_item_collections"
194
+ )
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"}}
226
+ ]
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)
243
+
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
+ @patch(
248
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_write.get_item_collections"
249
+ )
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
+ @patch(
299
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_write.get_item_collections"
300
+ )
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"}}
332
+ ]
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",
378
+ }
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
389
+
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
+ @patch(
409
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_write.get_item_collections"
410
+ )
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
+ @patch(
468
+ "aiagents4pharma.talk2scholars.tools.zotero.zotero_write.get_item_collections"
469
+ )
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"}}
505
+ ]
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)