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.
- aiagents4pharma/talk2scholars/configs/tools/zotero_read/default.yaml +1 -0
- aiagents4pharma/talk2scholars/state/state_talk2scholars.py +33 -7
- aiagents4pharma/talk2scholars/tests/test_question_and_answer_tool.py +59 -3
- aiagents4pharma/talk2scholars/tests/test_read_helper_utils.py +110 -0
- aiagents4pharma/talk2scholars/tests/test_s2_display.py +20 -1
- aiagents4pharma/talk2scholars/tests/test_s2_query.py +17 -0
- aiagents4pharma/talk2scholars/tests/test_state.py +25 -1
- aiagents4pharma/talk2scholars/tests/test_zotero_pdf_downloader_utils.py +46 -0
- aiagents4pharma/talk2scholars/tests/test_zotero_read.py +35 -40
- aiagents4pharma/talk2scholars/tools/pdf/question_and_answer.py +62 -40
- aiagents4pharma/talk2scholars/tools/s2/display_dataframe.py +6 -2
- aiagents4pharma/talk2scholars/tools/s2/multi_paper_rec.py +2 -1
- aiagents4pharma/talk2scholars/tools/s2/query_dataframe.py +7 -3
- aiagents4pharma/talk2scholars/tools/s2/search.py +2 -1
- aiagents4pharma/talk2scholars/tools/s2/single_paper_rec.py +2 -1
- aiagents4pharma/talk2scholars/tools/zotero/utils/read_helper.py +79 -136
- aiagents4pharma/talk2scholars/tools/zotero/utils/zotero_pdf_downloader.py +147 -0
- aiagents4pharma/talk2scholars/tools/zotero/zotero_read.py +42 -9
- {aiagents4pharma-1.37.0.dist-info → aiagents4pharma-1.38.0.dist-info}/METADATA +1 -1
- {aiagents4pharma-1.37.0.dist-info → aiagents4pharma-1.38.0.dist-info}/RECORD +23 -20
- {aiagents4pharma-1.37.0.dist-info → aiagents4pharma-1.38.0.dist-info}/WHEEL +1 -1
- {aiagents4pharma-1.37.0.dist-info → aiagents4pharma-1.38.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
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
|
-
#
|
43
|
-
|
44
|
-
|
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
|
-
|
68
|
-
|
69
|
-
|
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
|
-
|
797
|
-
|
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(
|
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
|
6
|
+
from types import SimpleNamespace
|
7
|
+
from unittest.mock import MagicMock, patch
|
8
|
+
|
9
|
+
import requests
|
8
10
|
from langgraph.types import Command
|
9
|
-
|
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.
|
723
|
+
"aiagents4pharma.talk2scholars.tools.zotero.utils.zotero_pdf_downloader."
|
724
|
+
"requests.Session.get"
|
719
725
|
)
|
720
|
-
|
721
|
-
|
722
|
-
|
723
|
-
|
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
|
-
|
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
|
787
|
+
# Patch the module-level download_zotero_pdf to raise an exception
|
795
788
|
with patch(
|
796
|
-
"aiagents4pharma.talk2scholars.tools.zotero.utils.
|
797
|
-
"
|
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 =
|
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",
|