aiagents4pharma 1.37.0__py3-none-any.whl → 1.38.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 (23) hide show
  1. aiagents4pharma/talk2scholars/configs/tools/zotero_read/default.yaml +1 -0
  2. aiagents4pharma/talk2scholars/state/state_talk2scholars.py +33 -7
  3. aiagents4pharma/talk2scholars/tests/test_question_and_answer_tool.py +59 -3
  4. aiagents4pharma/talk2scholars/tests/test_read_helper_utils.py +110 -0
  5. aiagents4pharma/talk2scholars/tests/test_s2_display.py +20 -1
  6. aiagents4pharma/talk2scholars/tests/test_s2_query.py +17 -0
  7. aiagents4pharma/talk2scholars/tests/test_state.py +25 -1
  8. aiagents4pharma/talk2scholars/tests/test_zotero_pdf_downloader_utils.py +46 -0
  9. aiagents4pharma/talk2scholars/tests/test_zotero_read.py +35 -40
  10. aiagents4pharma/talk2scholars/tools/pdf/question_and_answer.py +62 -40
  11. aiagents4pharma/talk2scholars/tools/s2/display_dataframe.py +6 -2
  12. aiagents4pharma/talk2scholars/tools/s2/multi_paper_rec.py +2 -1
  13. aiagents4pharma/talk2scholars/tools/s2/query_dataframe.py +7 -3
  14. aiagents4pharma/talk2scholars/tools/s2/search.py +2 -1
  15. aiagents4pharma/talk2scholars/tools/s2/single_paper_rec.py +2 -1
  16. aiagents4pharma/talk2scholars/tools/zotero/utils/read_helper.py +79 -136
  17. aiagents4pharma/talk2scholars/tools/zotero/utils/zotero_pdf_downloader.py +147 -0
  18. aiagents4pharma/talk2scholars/tools/zotero/zotero_read.py +42 -9
  19. {aiagents4pharma-1.37.0.dist-info → aiagents4pharma-1.38.0.dist-info}/METADATA +1 -1
  20. {aiagents4pharma-1.37.0.dist-info → aiagents4pharma-1.38.0.dist-info}/RECORD +23 -20
  21. {aiagents4pharma-1.37.0.dist-info → aiagents4pharma-1.38.0.dist-info}/WHEEL +1 -1
  22. {aiagents4pharma-1.37.0.dist-info → aiagents4pharma-1.38.0.dist-info}/licenses/LICENSE +0 -0
  23. {aiagents4pharma-1.37.0.dist-info → aiagents4pharma-1.38.0.dist-info}/top_level.txt +0 -0
@@ -2,6 +2,7 @@
2
2
  library_type: "user" # Type of library ('user' or 'group')
3
3
  default_limit: 2
4
4
  request_timeout: 10
5
+ chunk_size: 16384 # Size (in bytes) for streaming PDF download chunks
5
6
  user_id: ${oc.env:ZOTERO_USER_ID} # Load from environment variable
6
7
  api_key: ${oc.env:ZOTERO_API_KEY} # Load from environment variable
7
8
 
@@ -7,6 +7,7 @@ across agent interactions.
7
7
  """
8
8
 
9
9
  import logging
10
+ from collections.abc import Mapping
10
11
  from typing import Annotated, Any, Dict
11
12
 
12
13
  from langchain_core.embeddings import Embeddings
@@ -18,7 +19,24 @@ logging.basicConfig(level=logging.INFO)
18
19
  logger = logging.getLogger(__name__)
19
20
 
20
21
 
21
- def replace_dict(existing: Dict[str, Any], new: Dict[str, Any]) -> Dict[str, Any]:
22
+ def merge_dict(existing: Dict[str, Any], new: Dict[str, Any]) -> Dict[str, Any]:
23
+ """
24
+ Merges the existing dictionary with a new dictionary.
25
+
26
+ This function logs the state merge and ensures that the new values
27
+ are appended to the existing state without overwriting other entries.
28
+ Args:
29
+ existing (Dict[str, Any]): The current dictionary state.
30
+ new (Dict[str, Any]): The new dictionary state to merge.
31
+ Returns:
32
+ Dict[str, Any]: The merged dictionary state.
33
+ """
34
+ merged = dict(existing) if existing else {}
35
+ merged.update(new or {})
36
+ return merged
37
+
38
+
39
+ def replace_dict(existing: Dict[str, Any], new: Any) -> Any:
22
40
  """
23
41
  Replaces the existing dictionary with a new dictionary.
24
42
 
@@ -39,9 +57,13 @@ def replace_dict(existing: Dict[str, Any], new: Dict[str, Any]) -> Dict[str, Any
39
57
  >>> print(updated_state)
40
58
  {"papers": {"id2": "Paper 2"}}
41
59
  """
42
- # No-op operation to use the 'existing' variable
43
- _ = len(existing)
44
- return new
60
+ # If new is not a mapping, just replace existing value outright
61
+ if not isinstance(new, Mapping):
62
+ return new
63
+ # In-place replace: clear existing mapping and update with new entries
64
+ existing.clear()
65
+ existing.update(new)
66
+ return existing
45
67
 
46
68
 
47
69
  class Talk2Scholars(AgentState):
@@ -63,10 +85,14 @@ class Talk2Scholars(AgentState):
63
85
  """
64
86
 
65
87
  # Agent state fields
88
+ # Key controlling UI display: always replace to reference latest output
89
+ # Stores the most recently displayed papers metadata
66
90
  last_displayed_papers: Annotated[Dict[str, Any], replace_dict]
67
- papers: Annotated[Dict[str, Any], replace_dict]
68
- multi_papers: Annotated[Dict[str, Any], replace_dict]
69
- article_data: Annotated[Dict[str, Any], replace_dict]
91
+ # Accumulative keys: merge new entries into existing state
92
+ papers: Annotated[Dict[str, Any], merge_dict]
93
+ multi_papers: Annotated[Dict[str, Any], merge_dict]
94
+ article_data: Annotated[Dict[str, Any], merge_dict]
95
+ # Approval status: always replace to reflect latest operation
70
96
  zotero_write_approval_status: Annotated[Dict[str, Any], replace_dict]
71
97
  llm_model: BaseChatModel
72
98
  text_embedding_model: Embeddings
@@ -3,11 +3,14 @@ Unit tests for question_and_answer tool functionality.
3
3
  """
4
4
 
5
5
  import unittest
6
+ from types import SimpleNamespace
6
7
  from unittest.mock import MagicMock, patch
7
8
 
8
9
  from langchain_core.documents import Document
9
10
  from langchain_core.embeddings import Embeddings
11
+ from langchain_core.messages import ToolMessage
10
12
 
13
+ import aiagents4pharma.talk2scholars.tools.pdf.question_and_answer as qa_module
11
14
  from aiagents4pharma.talk2scholars.tools.pdf.question_and_answer import (
12
15
  Vectorstore,
13
16
  generate_answer,
@@ -145,8 +148,9 @@ class TestQuestionAndAnswerTool(unittest.TestCase):
145
148
 
146
149
  vector_store = Vectorstore(embedding_model=mock_embedding_model)
147
150
  vector_store.vector_store = True
151
+ # Add a document chunk with required metadata including chunk_id
148
152
  vector_store.documents["test_doc"] = Document(
149
- page_content="Test content", metadata={"paper_id": "test_paper"}
153
+ page_content="Test content", metadata={"paper_id": "test_paper", "chunk_id": 0}
150
154
  )
151
155
 
152
156
  results = vector_store.retrieve_relevant_chunks(query="test query")
@@ -793,8 +797,9 @@ class TestMissingState(unittest.TestCase):
793
797
 
794
798
  vector_store = Vectorstore(embedding_model=mock_embedding_model)
795
799
  vector_store.vector_store = True
796
- doc1 = Document(page_content="Doc 1", metadata={"paper_id": "paper1"})
797
- doc2 = Document(page_content="Doc 2", metadata={"paper_id": "paper2"})
800
+ # Add document chunks with necessary metadata including chunk_ids
801
+ doc1 = Document(page_content="Doc 1", metadata={"paper_id": "paper1", "chunk_id": 0})
802
+ doc2 = Document(page_content="Doc 2", metadata={"paper_id": "paper2", "chunk_id": 1})
798
803
  vector_store.documents = {"doc1": doc1, "doc2": doc2}
799
804
 
800
805
  results = vector_store.retrieve_relevant_chunks(
@@ -820,3 +825,54 @@ class TestMissingState(unittest.TestCase):
820
825
  query="test", paper_ids=["nonexistent_id"]
821
826
  )
822
827
  assert results == []
828
+
829
+ @patch(
830
+ "aiagents4pharma.talk2scholars.tools.pdf.question_and_answer.load_hydra_config"
831
+ )
832
+ @patch(
833
+ "aiagents4pharma.talk2scholars.tools.pdf.question_and_answer.generate_answer"
834
+ )
835
+ def test_prebuilt_vector_store_branch(self, mock_generate, mock_load_config):
836
+ """Test question_and_answer tool with a shared pre-built vector store branch."""
837
+ # Mock configuration for tool-level thresholds
838
+ config = SimpleNamespace(top_k_papers=1, top_k_chunks=1)
839
+ mock_load_config.return_value = config
840
+ # Mock generate_answer to return a simple response
841
+ mock_generate.return_value = {"output_text": "Answer", "papers_used": ["p1"]}
842
+
843
+ # Prepare a dummy pre-built vector store
844
+ dummy_vs = SimpleNamespace(
845
+ loaded_papers=set(),
846
+ vector_store=True,
847
+ retrieve_relevant_chunks=lambda *_args, **_kwargs: [
848
+ Document(page_content="chunk", metadata={"paper_id": "p1"})
849
+ ],
850
+ )
851
+ # Override the module-level prebuilt_vector_store
852
+ qa_module.prebuilt_vector_store = dummy_vs
853
+
854
+ # Prepare state with required models and article_data
855
+ state = {
856
+ "text_embedding_model": MagicMock(),
857
+ "llm_model": MagicMock(),
858
+ "article_data": {"p1": {"source": "upload"}},
859
+ }
860
+
861
+ # Invoke the tool-level function via .run with appropriate input schema
862
+ input_data = {
863
+ "question": "What?",
864
+ "paper_ids": None,
865
+ "use_all_papers": False,
866
+ "tool_call_id": "testid",
867
+ "state": state,
868
+ }
869
+ result = qa_module.question_and_answer.run(input_data)
870
+
871
+ # Ensure the prebuilt branch was used and a Command is returned
872
+ self.assertTrue(hasattr(result, "update"))
873
+ messages = result.update.get("messages", [])
874
+ self.assertEqual(len(messages), 1)
875
+ self.assertIsInstance(messages[0], ToolMessage)
876
+
877
+ # Clean up global override
878
+ qa_module.prebuilt_vector_store = None
@@ -0,0 +1,110 @@
1
+ """
2
+ Unit tests for Zotero read helper download branches.
3
+ """
4
+
5
+ import unittest
6
+ from types import SimpleNamespace
7
+ from unittest.mock import MagicMock, patch
8
+
9
+ from aiagents4pharma.talk2scholars.tools.zotero.utils.read_helper import (
10
+ ZoteroSearchData,
11
+ )
12
+
13
+ # Dummy Hydra configuration for tests
14
+ dummy_zotero_read_config = SimpleNamespace(
15
+ user_id="dummy_user",
16
+ library_type="user",
17
+ api_key="dummy_api_key",
18
+ zotero=SimpleNamespace(
19
+ max_limit=5,
20
+ filter_item_types=["journalArticle", "conferencePaper"],
21
+ filter_excluded_types=["attachment", "note"],
22
+ ),
23
+ )
24
+ dummy_cfg = SimpleNamespace(tools=SimpleNamespace(zotero_read=dummy_zotero_read_config))
25
+
26
+
27
+ class TestReadHelperDownloadsFalse(unittest.TestCase):
28
+ """Tests for read_helper download_pdfs=False branches."""
29
+
30
+ @patch(
31
+ "aiagents4pharma.talk2scholars.tools.zotero.utils.zotero_path.get_item_collections"
32
+ )
33
+ @patch("aiagents4pharma.talk2scholars.tools.zotero.utils.read_helper.zotero.Zotero")
34
+ @patch("aiagents4pharma.talk2scholars.tools.zotero.utils.read_helper.hydra.compose")
35
+ @patch(
36
+ "aiagents4pharma.talk2scholars.tools.zotero.utils.read_helper.hydra.initialize"
37
+ )
38
+ def test_download_pdfs_false_branches(
39
+ self,
40
+ mock_hydra_init,
41
+ mock_hydra_compose,
42
+ mock_zotero_class,
43
+ mock_get_item_collections,
44
+ ):
45
+ """Ensure attachment_key and filename are set when download_pdfs=False."""
46
+ # Setup Hydra mocks
47
+ mock_hydra_compose.return_value = dummy_cfg
48
+ mock_hydra_init.return_value.__enter__.return_value = None
49
+
50
+ # Fake Zotero items: one paper with child PDF, one orphaned PDF
51
+ fake_zot = MagicMock()
52
+ fake_items = [
53
+ {
54
+ "data": {
55
+ "key": "paper1",
56
+ "title": "P1",
57
+ "abstractNote": "A1",
58
+ "date": "2021",
59
+ "url": "u1",
60
+ "itemType": "journalArticle",
61
+ }
62
+ },
63
+ {
64
+ "data": {
65
+ "key": "attach2",
66
+ "itemType": "attachment",
67
+ "contentType": "application/pdf",
68
+ "filename": "file2.pdf",
69
+ }
70
+ },
71
+ ]
72
+ fake_zot.items.return_value = fake_items
73
+ # children for paper1
74
+ fake_child = {
75
+ "data": {
76
+ "key": "attach1",
77
+ "filename": "file1.pdf",
78
+ "contentType": "application/pdf",
79
+ }
80
+ }
81
+
82
+ def children_side_effect(key):
83
+ return [fake_child] if key == "paper1" else []
84
+
85
+ fake_zot.children.side_effect = children_side_effect
86
+ mock_zotero_class.return_value = fake_zot
87
+ mock_get_item_collections.return_value = {"paper1": ["/C1"], "attach2": ["/C2"]}
88
+
89
+ # Instantiate with download_pdfs=False
90
+ search = ZoteroSearchData(
91
+ query="test",
92
+ only_articles=False,
93
+ limit=2,
94
+ tool_call_id="id",
95
+ download_pdfs=False,
96
+ )
97
+ search.process_search()
98
+ data = search.get_search_results()["article_data"]
99
+
100
+ # Regular paper1 should have attachment_key and filename, no pdf_url
101
+ self.assertIn("paper1", data)
102
+ self.assertEqual(data["paper1"]["attachment_key"], "attach1")
103
+ self.assertEqual(data["paper1"]["filename"], "file1.pdf")
104
+ self.assertNotIn("pdf_url", data["paper1"])
105
+
106
+ # Orphan attach2 should have attachment_key and filename, no pdf_url
107
+ self.assertIn("attach2", data)
108
+ self.assertEqual(data["attach2"]["attachment_key"], "attach2")
109
+ self.assertEqual(data["attach2"]["filename"], "file2.pdf")
110
+ self.assertNotIn("pdf_url", data["attach2"])
@@ -52,7 +52,9 @@ class TestS2Tools:
52
52
  raised_error,
53
53
  match="No papers found. A search/rec needs to be performed first.",
54
54
  ):
55
- display_dataframe.invoke({"state": initial_state, "tool_call_id": "test123"})
55
+ display_dataframe.invoke(
56
+ {"state": initial_state, "tool_call_id": "test123"}
57
+ )
56
58
 
57
59
  def test_display_dataframe_shows_papers(self, initial_state):
58
60
  """Verifies display_dataframe tool correctly returns papers from state"""
@@ -72,3 +74,20 @@ class TestS2Tools:
72
74
  "1 papers found. Papers are attached as an artifact."
73
75
  in result.update["messages"][0].content
74
76
  )
77
+
78
+ def test_display_dataframe_direct_mapping(self, initial_state):
79
+ """Verifies display_dataframe handles direct dict mapping in last_displayed_papers."""
80
+ # Prepare state with direct mapping of papers
81
+ state = initial_state.copy()
82
+ state["last_displayed_papers"] = MOCK_STATE_PAPER
83
+ # Invoke display tool
84
+ result = display_dataframe.invoke({"state": state, "tool_call_id": "test123"})
85
+ assert isinstance(result, Command)
86
+ update = result.update
87
+ # Artifact should be the direct mapping
88
+ messages = update.get("messages", [])
89
+ assert len(messages) == 1
90
+ artifact = messages[0].artifact
91
+ assert artifact == MOCK_STATE_PAPER
92
+ # Content count should match mapping length
93
+ assert "1 papers found" in messages[0].content
@@ -76,3 +76,20 @@ class TestS2Tools:
76
76
 
77
77
  assert isinstance(result, str) # Ensure output is a string
78
78
  assert result == "Mocked response" # Validate the expected response
79
+
80
+ @patch(
81
+ "aiagents4pharma.talk2scholars.tools.s2.query_dataframe.create_pandas_dataframe_agent"
82
+ )
83
+ def test_query_dataframe_direct_mapping(self, mock_create_agent, initial_state):
84
+ """Tests query_dataframe when last_displayed_papers is a direct dict mapping."""
85
+ # Prepare state with direct mapping
86
+ state = initial_state.copy()
87
+ state["last_displayed_papers"] = MOCK_STATE_PAPER
88
+ # Mock the dataframe agent
89
+ mock_agent = MagicMock()
90
+ mock_agent.invoke.return_value = {"output": "Direct mapping response"}
91
+ mock_create_agent.return_value = mock_agent
92
+ # Invoke tool
93
+ result = query_dataframe.invoke({"question": "Filter papers", "state": state})
94
+ assert isinstance(result, str)
95
+ assert result == "Direct mapping response"
@@ -2,7 +2,7 @@
2
2
  Tests for state management functionality.
3
3
  """
4
4
 
5
- from ..state.state_talk2scholars import replace_dict
5
+ from ..state.state_talk2scholars import merge_dict, replace_dict
6
6
 
7
7
 
8
8
  def test_state_replace_dict():
@@ -12,3 +12,27 @@ def test_state_replace_dict():
12
12
  result = replace_dict(existing, new)
13
13
  assert result == new
14
14
  assert isinstance(result, dict)
15
+
16
+
17
+ def test_state_merge_dict():
18
+ """Verifies state dictionary merging works correctly"""
19
+ existing = {"a": 1, "b": 2}
20
+ new = {"b": 3, "c": 4}
21
+ result = merge_dict(existing, new)
22
+ # result should contain merged keys, with new values overriding existing ones
23
+ assert result == {"a": 1, "b": 3, "c": 4}
24
+ assert isinstance(result, dict)
25
+ # original existing dict should be unchanged
26
+ assert existing == {"a": 1, "b": 2}
27
+
28
+
29
+ def test_replace_dict_non_mapping():
30
+ """Verifies replace_dict returns non-mapping values directly"""
31
+
32
+ existing = {"key": "value"}
33
+ # When new is not a dict, replace_dict should return new value unchanged
34
+ new_value = "not_a_dict"
35
+ result = replace_dict(existing, new_value)
36
+ assert result == new_value
37
+ # existing should remain unmodified when returning new directly
38
+ assert existing == {"key": "value"}
@@ -0,0 +1,46 @@
1
+ """
2
+ Unit tests for Zotero PDF downloader utilities.
3
+ """
4
+
5
+ import os
6
+ import unittest
7
+ from unittest.mock import MagicMock, patch
8
+
9
+ import requests
10
+
11
+ from aiagents4pharma.talk2scholars.tools.zotero.utils.zotero_pdf_downloader import (
12
+ download_pdfs_in_parallel,
13
+ download_zotero_pdf,
14
+ )
15
+
16
+
17
+ class TestZoteroPDFDownloaderUtils(unittest.TestCase):
18
+ """Tests for zotero_pdf_downloader module."""
19
+
20
+ @patch("requests.Session.get")
21
+ def test_download_zotero_pdf_default_filename(self, mock_get):
22
+ """Test download_zotero_pdf returns default filename when header has no filename."""
23
+ # Mock response without Content-Disposition filename
24
+ mock_response = MagicMock()
25
+ mock_response.raise_for_status = lambda: None
26
+ mock_response.iter_content = lambda chunk_size: [b"fakepdf"]
27
+ mock_response.headers = {}
28
+ mock_get.return_value = mock_response
29
+
30
+ session = requests.Session()
31
+ result = download_zotero_pdf(session, "user123", "apikey", "attach123")
32
+ # Should return a tuple (file_path, filename)
33
+ self.assertIsNotNone(result)
34
+ file_path, filename = result
35
+ # File should exist
36
+ self.assertTrue(os.path.isfile(file_path))
37
+ # Filename should default to 'downloaded.pdf'
38
+ self.assertEqual(filename, "downloaded.pdf")
39
+ # Clean up temp file
40
+ os.remove(file_path)
41
+
42
+ def test_download_pdfs_in_parallel_empty(self):
43
+ """Test that download_pdfs_in_parallel returns empty dict on empty input."""
44
+ session = requests.Session()
45
+ result = download_pdfs_in_parallel(session, "user123", "apikey", {})
46
+ self.assertEqual(result, {})
@@ -2,14 +2,20 @@
2
2
  Unit tests for Zotero search tool in zotero_read.py.
3
3
  """
4
4
 
5
- from types import SimpleNamespace
6
5
  import unittest
7
- from unittest.mock import patch, MagicMock
6
+ from types import SimpleNamespace
7
+ from unittest.mock import MagicMock, patch
8
+
9
+ import requests
8
10
  from langgraph.types import Command
9
- from aiagents4pharma.talk2scholars.tools.zotero.zotero_read import zotero_read
11
+
10
12
  from aiagents4pharma.talk2scholars.tools.zotero.utils.read_helper import (
11
13
  ZoteroSearchData,
12
14
  )
15
+ from aiagents4pharma.talk2scholars.tools.zotero.utils.zotero_pdf_downloader import (
16
+ download_zotero_pdf,
17
+ )
18
+ from aiagents4pharma.talk2scholars.tools.zotero.zotero_read import zotero_read
13
19
 
14
20
  # pylint: disable=protected-access
15
21
  # pylint: disable=protected-access, too-many-arguments, too-many-positional-arguments
@@ -22,7 +28,6 @@ dummy_zotero_read_config = SimpleNamespace(
22
28
  zotero=SimpleNamespace(
23
29
  max_limit=5,
24
30
  filter_item_types=["journalArticle", "conferencePaper"],
25
- filter_excluded_types=["attachment", "note"],
26
31
  ),
27
32
  )
28
33
  dummy_cfg = SimpleNamespace(tools=SimpleNamespace(zotero_read=dummy_zotero_read_config))
@@ -204,8 +209,7 @@ class TestZoteroSearchTool(unittest.TestCase):
204
209
  "aiagents4pharma.talk2scholars.tools.zotero.utils.read_helper.hydra.initialize"
205
210
  )
206
211
  @patch(
207
- "aiagents4pharma.talk2scholars.tools.zotero.utils.read_helper."
208
- "ZoteroSearchData._download_pdfs_in_parallel"
212
+ "aiagents4pharma.talk2scholars.tools.zotero.utils.read_helper.download_pdfs_in_parallel"
209
213
  )
210
214
  def test_filtering_no_matching_papers(
211
215
  self,
@@ -260,6 +264,7 @@ class TestZoteroSearchTool(unittest.TestCase):
260
264
  "only_articles": False,
261
265
  "tool_call_id": "test_id_4",
262
266
  "limit": 2,
267
+ "download_pdfs": True,
263
268
  }
264
269
 
265
270
  result = zotero_read.run(tool_input)
@@ -514,6 +519,7 @@ class TestZoteroSearchTool(unittest.TestCase):
514
519
  "only_articles": True,
515
520
  "tool_call_id": "test_pdf_success",
516
521
  "limit": 1,
522
+ "download_pdfs": True,
517
523
  }
518
524
 
519
525
  result = zotero_read.run(tool_input)
@@ -713,39 +719,26 @@ class TestZoteroSearchTool(unittest.TestCase):
713
719
  self.assertNotIn("filename", filtered_papers["paper1"])
714
720
  self.assertNotIn("attachment_key", filtered_papers["paper1"])
715
721
 
716
- @patch("aiagents4pharma.talk2scholars.tools.zotero.utils.read_helper.requests.get")
717
722
  @patch(
718
- "aiagents4pharma.talk2scholars.tools.zotero.utils.zotero_path.get_item_collections"
723
+ "aiagents4pharma.talk2scholars.tools.zotero.utils.zotero_pdf_downloader."
724
+ "requests.Session.get"
719
725
  )
720
- @patch("aiagents4pharma.talk2scholars.tools.zotero.utils.read_helper.zotero.Zotero")
721
- @patch("aiagents4pharma.talk2scholars.tools.zotero.utils.read_helper.hydra.compose")
722
- @patch(
723
- "aiagents4pharma.talk2scholars.tools.zotero.utils.read_helper.hydra.initialize"
724
- )
725
- def test_download_zotero_pdf_exception(
726
- self,
727
- mock_hydra_init,
728
- mock_hydra_compose,
729
- mock_zotero_class,
730
- mock_get_item_collections,
731
- mock_requests_get,
732
- ):
733
- """Test that _download_zotero_pdf returns None and logs error on request exception."""
734
- # Setup mocks for config and Zotero client
735
- mock_hydra_compose.return_value = dummy_cfg
736
- mock_hydra_init.return_value.__enter__.return_value = None
737
- mock_zotero_class.return_value = MagicMock()
738
- mock_get_item_collections.return_value = {}
739
-
740
- # Simulate a request exception during PDF download
741
- mock_requests_get.side_effect = Exception("Simulated download failure")
742
-
743
- zotero_search = ZoteroSearchData(
744
- query="test", only_articles=False, limit=1, tool_call_id="test123"
726
+ def test_download_zotero_pdf_exception(self, mock_session_get):
727
+ """Test that download_zotero_pdf returns None and logs error on request exception."""
728
+ # Simulate a session.get exception during PDF download
729
+ mock_session_get.side_effect = requests.exceptions.RequestException(
730
+ "Simulated download failure"
745
731
  )
746
-
747
- result = zotero_search._download_zotero_pdf("FAKE_ATTACHMENT_KEY")
748
-
732
+ # Create a session for testing
733
+ session = requests.Session()
734
+ # Call the module-level download function
735
+ result = download_zotero_pdf(
736
+ session,
737
+ dummy_cfg.tools.zotero_read.user_id,
738
+ dummy_cfg.tools.zotero_read.api_key,
739
+ "FAKE_ATTACHMENT_KEY",
740
+ )
741
+ # Should return None on failure
749
742
  self.assertIsNone(result)
750
743
 
751
744
  @patch(
@@ -791,12 +784,14 @@ class TestZoteroSearchTool(unittest.TestCase):
791
784
  mock_zotero_class.return_value = fake_zot
792
785
  mock_get_item_collections.return_value = {"paper1": ["/Fake Collection"]}
793
786
 
794
- # Patch just the internal _download_zotero_pdf to raise an exception
787
+ # Patch the module-level download_zotero_pdf to raise an exception
795
788
  with patch(
796
- "aiagents4pharma.talk2scholars.tools.zotero.utils.read_helper."
797
- "ZoteroSearchData._download_zotero_pdf"
789
+ "aiagents4pharma.talk2scholars.tools.zotero.utils.zotero_pdf_downloader."
790
+ "download_zotero_pdf"
798
791
  ) as mock_download_pdf:
799
- mock_download_pdf.side_effect = Exception("Simulated download error")
792
+ mock_download_pdf.side_effect = requests.exceptions.RequestException(
793
+ "Simulated download error"
794
+ )
800
795
 
801
796
  search = ZoteroSearchData(
802
797
  query="failure test",