aiagents4pharma 1.30.0__py3-none-any.whl → 1.30.2__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 (44) hide show
  1. aiagents4pharma/talk2scholars/agents/main_agent.py +18 -10
  2. aiagents4pharma/talk2scholars/agents/paper_download_agent.py +5 -6
  3. aiagents4pharma/talk2scholars/agents/pdf_agent.py +4 -10
  4. aiagents4pharma/talk2scholars/agents/zotero_agent.py +9 -7
  5. aiagents4pharma/talk2scholars/configs/agents/talk2scholars/main_agent/default.yaml +18 -9
  6. aiagents4pharma/talk2scholars/configs/agents/talk2scholars/s2_agent/default.yaml +2 -2
  7. aiagents4pharma/talk2scholars/configs/agents/talk2scholars/zotero_agent/default.yaml +9 -15
  8. aiagents4pharma/talk2scholars/configs/app/frontend/default.yaml +1 -0
  9. aiagents4pharma/talk2scholars/configs/tools/multi_paper_recommendation/default.yaml +6 -1
  10. aiagents4pharma/talk2scholars/configs/tools/search/default.yaml +7 -1
  11. aiagents4pharma/talk2scholars/configs/tools/single_paper_recommendation/default.yaml +6 -1
  12. aiagents4pharma/talk2scholars/configs/tools/zotero_read/default.yaml +1 -1
  13. aiagents4pharma/talk2scholars/configs/tools/zotero_write/default.yaml +55 -0
  14. aiagents4pharma/talk2scholars/state/state_talk2scholars.py +7 -1
  15. aiagents4pharma/talk2scholars/tests/test_llm_main_integration.py +84 -53
  16. aiagents4pharma/talk2scholars/tests/test_main_agent.py +24 -0
  17. aiagents4pharma/talk2scholars/tests/test_question_and_answer_tool.py +79 -15
  18. aiagents4pharma/talk2scholars/tests/test_routing_logic.py +13 -10
  19. aiagents4pharma/talk2scholars/tests/test_s2_multi.py +27 -4
  20. aiagents4pharma/talk2scholars/tests/test_s2_search.py +19 -3
  21. aiagents4pharma/talk2scholars/tests/test_s2_single.py +27 -3
  22. aiagents4pharma/talk2scholars/tests/test_zotero_agent.py +3 -2
  23. aiagents4pharma/talk2scholars/tests/test_zotero_human_in_the_loop.py +273 -0
  24. aiagents4pharma/talk2scholars/tests/test_zotero_path.py +419 -1
  25. aiagents4pharma/talk2scholars/tests/test_zotero_read.py +25 -18
  26. aiagents4pharma/talk2scholars/tests/test_zotero_write.py +123 -588
  27. aiagents4pharma/talk2scholars/tools/paper_download/abstract_downloader.py +2 -0
  28. aiagents4pharma/talk2scholars/tools/paper_download/arxiv_downloader.py +11 -4
  29. aiagents4pharma/talk2scholars/tools/paper_download/download_arxiv_input.py +5 -1
  30. aiagents4pharma/talk2scholars/tools/pdf/question_and_answer.py +73 -26
  31. aiagents4pharma/talk2scholars/tools/s2/multi_paper_rec.py +46 -22
  32. aiagents4pharma/talk2scholars/tools/s2/query_results.py +1 -1
  33. aiagents4pharma/talk2scholars/tools/s2/search.py +40 -12
  34. aiagents4pharma/talk2scholars/tools/s2/single_paper_rec.py +42 -16
  35. aiagents4pharma/talk2scholars/tools/zotero/__init__.py +1 -0
  36. aiagents4pharma/talk2scholars/tools/zotero/utils/zotero_path.py +125 -0
  37. aiagents4pharma/talk2scholars/tools/zotero/zotero_read.py +35 -20
  38. aiagents4pharma/talk2scholars/tools/zotero/zotero_review.py +198 -0
  39. aiagents4pharma/talk2scholars/tools/zotero/zotero_write.py +86 -118
  40. {aiagents4pharma-1.30.0.dist-info → aiagents4pharma-1.30.2.dist-info}/METADATA +4 -3
  41. {aiagents4pharma-1.30.0.dist-info → aiagents4pharma-1.30.2.dist-info}/RECORD +44 -41
  42. {aiagents4pharma-1.30.0.dist-info → aiagents4pharma-1.30.2.dist-info}/WHEEL +1 -1
  43. {aiagents4pharma-1.30.0.dist-info → aiagents4pharma-1.30.2.dist-info/licenses}/LICENSE +0 -0
  44. {aiagents4pharma-1.30.0.dist-info → aiagents4pharma-1.30.2.dist-info}/top_level.txt +0 -0
@@ -9,6 +9,7 @@ import logging
9
9
  # Configure logging
10
10
  logging.basicConfig(level=logging.INFO)
11
11
  logger = logging.getLogger(__name__)
12
+ # pylint: disable=broad-exception-caught
12
13
 
13
14
 
14
15
  def get_item_collections(zot):
@@ -34,6 +35,7 @@ def get_item_collections(zot):
34
35
 
35
36
  # Build full paths for collections
36
37
  def build_collection_path(col_key):
38
+ """build collection path from collection key"""
37
39
  path = []
38
40
  while col_key:
39
41
  path.insert(0, collection_map.get(col_key, "Unknown"))
@@ -61,3 +63,126 @@ def get_item_collections(zot):
61
63
  logger.info("Successfully mapped items to collection paths.")
62
64
 
63
65
  return item_to_collections
66
+
67
+
68
+ def find_or_create_collection(zot, path, create_missing=False):
69
+ """find collection or create if missing"""
70
+ logger.info(
71
+ "Finding collection for path: %s (create_missing=%s)", path, create_missing
72
+ )
73
+ # Normalize path: remove leading/trailing slashes and convert to lowercase
74
+ normalized = path.strip("/").lower()
75
+ path_parts = normalized.split("/") if normalized else []
76
+
77
+ if not path_parts:
78
+ logger.warning("Empty path provided")
79
+ return None
80
+
81
+ # Get all collections from Zotero
82
+ all_collections = zot.collections()
83
+ logger.info("Found %d collections in Zotero", len(all_collections))
84
+
85
+ # Determine target name (last part) and, if nested, find the parent's key
86
+ target_name = path_parts[-1]
87
+ parent_key = None
88
+ if len(path_parts) > 1:
89
+ parent_name = path_parts[-2]
90
+ # Look for a collection with name matching the parent (case-insensitive)
91
+ for col in all_collections:
92
+ if col["data"]["name"].lower() == parent_name:
93
+ parent_key = col["key"]
94
+ break
95
+
96
+ # Try to find an existing collection by direct match (ignoring hierarchy)
97
+ for col in all_collections:
98
+ if col["data"]["name"].lower() == target_name:
99
+ logger.info("Found direct match for %s: %s", target_name, col["key"])
100
+ return col["key"]
101
+
102
+ # No match found: create one if allowed
103
+ if create_missing:
104
+ payload = {"name": target_name}
105
+ if parent_key:
106
+ payload["parentCollection"] = parent_key
107
+ try:
108
+ result = zot.create_collection(payload)
109
+ # Interpret result based on structure
110
+ if "success" in result:
111
+ new_key = result["success"]["0"]
112
+ else:
113
+ new_key = result["successful"]["0"]["data"]["key"]
114
+ logger.info("Created collection %s with key %s", target_name, new_key)
115
+ return new_key
116
+ except Exception as e:
117
+ logger.error("Failed to create collection: %s", e)
118
+ return None
119
+ else:
120
+ logger.warning("No matching collection found for %s", target_name)
121
+ return None
122
+
123
+
124
+ def get_all_collection_paths(zot):
125
+ """
126
+ Get all available collection paths in Zotero.
127
+
128
+ Args:
129
+ zot (Zotero): An initialized Zotero client.
130
+
131
+ Returns:
132
+ list: List of all available collection paths
133
+ """
134
+ logger.info("Getting all collection paths")
135
+ collections = zot.collections()
136
+
137
+ # Create mappings: collection key → name and collection key → parent key
138
+ collection_map = {col["key"]: col["data"]["name"] for col in collections}
139
+ parent_map = {
140
+ col["key"]: col["data"].get("parentCollection") for col in collections
141
+ }
142
+
143
+ # Build full paths for collections
144
+ def build_collection_path(col_key):
145
+ path = []
146
+ while col_key:
147
+ path.insert(0, collection_map.get(col_key, "Unknown"))
148
+ col_key = parent_map.get(col_key)
149
+ return "/" + "/".join(path)
150
+
151
+ collection_paths = [build_collection_path(key) for key in collection_map]
152
+ logger.info("Found %d collection paths", len(collection_paths))
153
+ return collection_paths
154
+
155
+
156
+ def fetch_papers_for_save(state):
157
+ """
158
+ Retrieve papers from the state for saving to Zotero.
159
+
160
+ Args:
161
+ state (dict): The state containing previously fetched papers.
162
+
163
+ Returns:
164
+ dict: Dictionary of papers to save, or None if no papers found
165
+ """
166
+ logger.info("Fetching papers from state for saving")
167
+
168
+ # Retrieve last displayed papers from the agent state
169
+ last_displayed_key = state.get("last_displayed_papers", "")
170
+
171
+ if not last_displayed_key:
172
+ logger.warning("No last_displayed_papers key in state")
173
+ return None
174
+
175
+ if isinstance(last_displayed_key, str):
176
+ # If it's a string (key to another state object), get that object
177
+ fetched_papers = state.get(last_displayed_key, {})
178
+ logger.info("Using papers from '%s' state key", last_displayed_key)
179
+ else:
180
+ # If it's already the papers object
181
+ fetched_papers = last_displayed_key
182
+ logger.info("Using papers directly from last_displayed_papers")
183
+
184
+ if not fetched_papers:
185
+ logger.warning("No fetched papers found to save.")
186
+ return None
187
+
188
+ return fetched_papers
@@ -13,9 +13,7 @@ from langchain_core.tools import tool
13
13
  from langchain_core.tools.base import InjectedToolCallId
14
14
  from langgraph.types import Command
15
15
  from pydantic import BaseModel, Field
16
- from aiagents4pharma.talk2scholars.tools.zotero.utils.zotero_path import (
17
- get_item_collections,
18
- )
16
+ from .utils.zotero_path import get_item_collections
19
17
 
20
18
  # pylint: disable=R0914,R0912,R0915
21
19
 
@@ -41,7 +39,7 @@ class ZoteroSearchInput(BaseModel):
41
39
 
42
40
 
43
41
  @tool(args_schema=ZoteroSearchInput, parse_docstring=True)
44
- def zotero_search_tool(
42
+ def zotero_read(
45
43
  query: str,
46
44
  only_articles: bool,
47
45
  tool_call_id: Annotated[str, InjectedToolCallId],
@@ -104,9 +102,10 @@ def zotero_search_tool(
104
102
 
105
103
  # Define filter criteria
106
104
  filter_item_types = cfg.zotero.filter_item_types if only_articles else []
107
- filter_excluded_types = (
108
- cfg.zotero.filter_excluded_types
109
- ) # Exclude non-research items
105
+ logger.debug("Filtering item types: %s", filter_item_types)
106
+ # filter_excluded_types = (
107
+ # cfg.zotero.filter_excluded_types
108
+ # ) # Exclude non-research items
110
109
 
111
110
  # Filter and format papers
112
111
  filtered_papers = {}
@@ -119,19 +118,19 @@ def zotero_search_tool(
119
118
  if not isinstance(data, dict):
120
119
  continue
121
120
 
122
- item_type = data.get("itemType")
121
+ item_type = data.get("itemType", "N/A")
123
122
  logger.debug("Item type: %s", item_type)
124
123
 
125
124
  # Exclude attachments, notes, and other unwanted types
126
- if (
127
- not item_type
128
- or not isinstance(item_type, str)
129
- or item_type in filter_excluded_types # Skip attachments & notes
130
- or (
131
- only_articles and item_type not in filter_item_types
132
- ) # Skip non-research types
133
- ):
134
- continue
125
+ # if (
126
+ # not item_type
127
+ # or not isinstance(item_type, str)
128
+ # or item_type in filter_excluded_types # Skip attachments & notes
129
+ # or (
130
+ # only_articles and item_type not in filter_item_types
131
+ # ) # Skip non-research types
132
+ # ):
133
+ # continue
135
134
 
136
135
  key = data.get("key")
137
136
  if not key:
@@ -140,13 +139,29 @@ def zotero_search_tool(
140
139
  # Use the imported utility function's mapping to get collection paths
141
140
  collection_paths = item_to_collections.get(key, ["/Unknown"])
142
141
 
142
+ # Extract metadata safely
143
143
  filtered_papers[key] = {
144
144
  "Title": data.get("title", "N/A"),
145
145
  "Abstract": data.get("abstractNote", "N/A"),
146
- "Date": data.get("date", "N/A"),
146
+ "Publication Date": data.get(
147
+ "date", "N/A"
148
+ ), # Correct field for publication date
147
149
  "URL": data.get("url", "N/A"),
148
150
  "Type": item_type if isinstance(item_type, str) else "N/A",
149
- "Collections": collection_paths, # Now displays full paths
151
+ "Collections": collection_paths, # Displays full collection paths
152
+ "Citation Count": data.get("citationCount", "N/A"), # Shows citations
153
+ "Venue": data.get("venue", "N/A"), # Displays venue
154
+ "Publication Venue": data.get(
155
+ "publicationTitle", "N/A"
156
+ ), # Matches with Zotero Write
157
+ "Journal Name": data.get("journalAbbreviation", "N/A"), # Journal Name
158
+ # "Journal Volume": data.get("volume", "N/A"), # Journal Volume
159
+ # "Journal Pages": data.get("pages", "N/A"), # Journal Pages
160
+ "Authors": [
161
+ f"{creator.get('firstName', '')} {creator.get('lastName', '')}".strip()
162
+ for creator in data.get("creators", []) # Prevents NoneType error
163
+ if isinstance(creator, dict) and creator.get("creatorType") == "author"
164
+ ],
150
165
  }
151
166
 
152
167
  if not filtered_papers:
@@ -170,7 +185,7 @@ def zotero_search_tool(
170
185
  content += " And here is a summary of the retrieval results:\n"
171
186
  content += f"Number of papers found: {len(filtered_papers)}\n"
172
187
  content += f"Query: {query}\n"
173
- content += "Top papers:\n" + top_papers_info
188
+ content += "Here are a few of these papers:\n" + top_papers_info
174
189
 
175
190
  return Command(
176
191
  update={
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env python3
2
+
3
+ """
4
+ This tool implements human-in-the-loop review for Zotero write operations.
5
+ """
6
+
7
+ import logging
8
+ from typing import Annotated, Any, Optional, Literal
9
+ from langchain_core.tools import tool
10
+ from langchain_core.messages import ToolMessage, HumanMessage
11
+ from langchain_core.tools.base import InjectedToolCallId
12
+ from langgraph.prebuilt import InjectedState
13
+ from langgraph.types import Command, interrupt
14
+ from pydantic import BaseModel, Field
15
+ from .utils.zotero_path import fetch_papers_for_save
16
+
17
+ # pylint: disable=R0914,R0911,R0912,R0915
18
+ # Configure logging
19
+ logging.basicConfig(level=logging.INFO)
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class ZoteroReviewDecision(BaseModel):
24
+ """
25
+ Structured output schema for the human review decision.
26
+ - decision: "approve", "reject", or "custom"
27
+ - custom_path: Optional custom collection path if the decision is "custom"
28
+ """
29
+
30
+ decision: Literal["approve", "reject", "custom"]
31
+ custom_path: Optional[str] = None
32
+
33
+
34
+ class ZoteroReviewInput(BaseModel):
35
+ """Input schema for the Zotero review tool."""
36
+
37
+ tool_call_id: Annotated[str, InjectedToolCallId]
38
+ collection_path: str = Field(
39
+ description="The path where the paper should be saved in the Zotero library."
40
+ )
41
+ state: Annotated[dict, InjectedState]
42
+
43
+
44
+ @tool(args_schema=ZoteroReviewInput, parse_docstring=True)
45
+ def zotero_review(
46
+ tool_call_id: Annotated[str, InjectedToolCallId],
47
+ collection_path: str,
48
+ state: Annotated[dict, InjectedState],
49
+ ) -> Command[Any]:
50
+ """
51
+ Use this tool to get human review and approval before saving papers to Zotero.
52
+ This tool should be called before the zotero_write to ensure the user approves
53
+ the operation.
54
+
55
+ Args:
56
+ tool_call_id (str): The tool call ID.
57
+ collection_path (str): The Zotero collection path where papers should be saved.
58
+ state (dict): The state containing previously fetched papers.
59
+
60
+ Returns:
61
+ Command[Any]: The next action to take based on human input.
62
+ """
63
+ logger.info("Requesting human review for saving to collection: %s", collection_path)
64
+
65
+ # Use our utility function to fetch papers from state
66
+ fetched_papers = fetch_papers_for_save(state)
67
+
68
+ if not fetched_papers:
69
+ raise ValueError(
70
+ "No fetched papers were found to save. "
71
+ "Please retrieve papers using Zotero Read or Semantic Scholar first."
72
+ )
73
+
74
+ # Prepare papers summary for review
75
+ papers_summary = []
76
+ for paper_id, paper in list(fetched_papers.items())[
77
+ :5
78
+ ]: # Limit to 5 papers for readability
79
+ logger.info("Paper ID: %s", paper_id)
80
+ title = paper.get("Title", "N/A")
81
+ authors = ", ".join(
82
+ [author.split(" (ID: ")[0] for author in paper.get("Authors", [])[:2]]
83
+ )
84
+ if len(paper.get("Authors", [])) > 2:
85
+ authors += " et al."
86
+ papers_summary.append(f"- {title} by {authors}")
87
+
88
+ if len(fetched_papers) > 5:
89
+ papers_summary.append(f"... and {len(fetched_papers) - 5} more papers")
90
+
91
+ papers_preview = "\n".join(papers_summary)
92
+ total_papers = len(fetched_papers)
93
+
94
+ # Prepare review information
95
+ review_info = {
96
+ "action": "save_to_zotero",
97
+ "collection_path": collection_path,
98
+ "total_papers": total_papers,
99
+ "papers_preview": papers_preview,
100
+ "message": (
101
+ f"Would you like to save {total_papers} papers to Zotero "
102
+ f"collection '{collection_path}'? Please respond with a structured decision "
103
+ f"using one of the following options: 'approve', 'reject', or 'custom' "
104
+ f"(with a custom_path)."
105
+ ),
106
+ }
107
+
108
+ try:
109
+ # Interrupt the graph to get human approval
110
+ human_review = interrupt(review_info)
111
+ # Process human response using structured output via LLM
112
+ llm_model = state.get("llm_model")
113
+ if llm_model is None:
114
+ raise ValueError("LLM model is not available in the state.")
115
+ structured_llm = llm_model.with_structured_output(ZoteroReviewDecision)
116
+ # Convert the raw human response to a message for structured parsing
117
+ decision_response = structured_llm.invoke(
118
+ [HumanMessage(content=str(human_review))]
119
+ )
120
+
121
+ # Process the structured response
122
+ if decision_response.decision == "approve":
123
+ logger.info("User approved saving papers to Zotero")
124
+ return Command(
125
+ update={
126
+ "messages": [
127
+ ToolMessage(
128
+ content=(
129
+ f"Human approved saving {total_papers} papers to Zotero "
130
+ f"collection '{collection_path}'."
131
+ ),
132
+ tool_call_id=tool_call_id,
133
+ )
134
+ ],
135
+ "zotero_write_approval_status": {
136
+ "collection_path": collection_path,
137
+ "approved": True,
138
+ },
139
+ }
140
+ )
141
+ if decision_response.decision == "custom" and decision_response.custom_path:
142
+ logger.info(
143
+ "User approved with custom path: %s", decision_response.custom_path
144
+ )
145
+ return Command(
146
+ update={
147
+ "messages": [
148
+ ToolMessage(
149
+ content=(
150
+ f"Human approved saving papers to custom Zotero "
151
+ f"collection '{decision_response.custom_path}'."
152
+ ),
153
+ tool_call_id=tool_call_id,
154
+ )
155
+ ],
156
+ "zotero_write_approval_status": {
157
+ "collection_path": decision_response.custom_path,
158
+ "approved": True,
159
+ },
160
+ }
161
+ )
162
+ logger.info("User rejected saving papers to Zotero")
163
+ return Command(
164
+ update={
165
+ "messages": [
166
+ ToolMessage(
167
+ content="Human rejected saving papers to Zotero.",
168
+ tool_call_id=tool_call_id,
169
+ )
170
+ ],
171
+ "zotero_write_approval_status": {"approved": False},
172
+ }
173
+ )
174
+ # pylint: disable=broad-except
175
+ except Exception as e:
176
+ # If interrupt or structured output processing fails, fallback to explicit confirmation
177
+ logger.warning("Structured review processing failed: %s", e)
178
+ return Command(
179
+ update={
180
+ "messages": [
181
+ ToolMessage(
182
+ content=(
183
+ f"REVIEW REQUIRED: Would you like to save {total_papers} papers "
184
+ f"to Zotero collection '{collection_path}'?\n\n"
185
+ f"Papers to save:\n{papers_preview}\n\n"
186
+ "Please respond with 'Yes' to confirm or 'No' to cancel."
187
+ ),
188
+ tool_call_id=tool_call_id,
189
+ )
190
+ ],
191
+ "zotero_write_approval_status": {
192
+ "collection_path": collection_path,
193
+ "papers_reviewed": True,
194
+ "approved": False, # Not approved yet
195
+ "papers_count": total_papers,
196
+ },
197
+ }
198
+ )