aiagents4pharma 1.30.1__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.
@@ -5,3 +5,4 @@ Import statements
5
5
  from . import zotero_read
6
6
  from . import zotero_write
7
7
  from . import utils
8
+ from . import zotero_review
@@ -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],
@@ -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
+ )
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
 
3
3
  """
4
- This tool is used to save fetched papers to Zotero library.
4
+ This tool is used to save fetched papers to Zotero library after human approval.
5
5
  """
6
6
 
7
7
  import logging
@@ -14,11 +14,13 @@ from langchain_core.tools.base import InjectedToolCallId
14
14
  from langgraph.types import Command
15
15
  from langgraph.prebuilt import InjectedState
16
16
  from pydantic import BaseModel, Field
17
- from aiagents4pharma.talk2scholars.tools.zotero.utils.zotero_path import (
18
- get_item_collections,
17
+ from .utils.zotero_path import (
18
+ find_or_create_collection,
19
+ fetch_papers_for_save,
19
20
  )
20
21
 
21
- # pylint: disable=R0914,R0912,R0915
22
+
23
+ # pylint: disable=R0914,R0911,R0912,R0915
22
24
 
23
25
  # Configure logging
24
26
  logging.basicConfig(level=logging.INFO)
@@ -30,29 +32,31 @@ class ZoteroSaveInput(BaseModel):
30
32
 
31
33
  tool_call_id: Annotated[str, InjectedToolCallId]
32
34
  collection_path: str = Field(
33
- default=None,
34
- description=(
35
- "The path where the paper should be saved in the Zotero library."
36
- "Example: '/machine/cern/mobile'"
37
- ),
35
+ description="The path where the paper should be saved in the Zotero library."
38
36
  )
39
37
  state: Annotated[dict, InjectedState]
40
38
 
41
39
 
42
40
  @tool(args_schema=ZoteroSaveInput, parse_docstring=True)
43
- def zotero_save_tool(
41
+ def zotero_write(
44
42
  tool_call_id: Annotated[str, InjectedToolCallId],
45
43
  collection_path: str,
46
44
  state: Annotated[dict, InjectedState],
47
45
  ) -> Command[Any]:
48
46
  """
49
47
  Use this tool to save previously fetched papers from Semantic Scholar
50
- to a specified Zotero collection.
48
+ to a specified Zotero collection after human approval.
49
+
50
+ This tool checks if the user has approved the save operation via the
51
+ zotero_review. If approved, it will save the papers to the
52
+ approved collection path.
51
53
 
52
54
  Args:
53
55
  tool_call_id (Annotated[str, InjectedToolCallId]): The tool call ID.
54
56
  collection_path (str): The Zotero collection path where papers should be saved.
55
57
  state (Annotated[dict, InjectedState]): The state containing previously fetched papers.
58
+ user_confirmation (str, optional): User confirmation message when interrupt is
59
+ not available.
56
60
 
57
61
  Returns:
58
62
  Command[Any]: The save results and related information.
@@ -71,115 +75,44 @@ def zotero_save_tool(
71
75
  # Initialize Zotero client
72
76
  zot = zotero.Zotero(cfg.user_id, cfg.library_type, cfg.api_key)
73
77
 
74
- # Retrieve last displayed papers from the agent state
75
- last_displayed_key = state.get("last_displayed_papers", {})
76
- if isinstance(last_displayed_key, str):
77
- # If it's a string (key to another state object), get that object
78
- fetched_papers = state.get(last_displayed_key, {})
79
- logger.info("Using papers from '%s' state key", last_displayed_key)
80
- else:
81
- # If it's already the papers object
82
- fetched_papers = last_displayed_key
83
- logger.info("Using papers directly from last_displayed_papers")
78
+ # Use our utility function to fetch papers from state
79
+ fetched_papers = fetch_papers_for_save(state)
84
80
 
85
81
  if not fetched_papers:
86
- logger.warning("No fetched papers found to save.")
87
- raise RuntimeError(
88
- "No fetched papers were found to save. Please retry the same query."
89
- )
90
-
91
- # First, check if zotero_read exists in state and has collection data
92
- zotero_read_data = state.get("zotero_read", {})
93
- logger.info("Retrieved zotero_read from state: %d items", len(zotero_read_data))
94
-
95
- # If zotero_read is empty, use get_item_collections as fallback
96
- if not zotero_read_data:
97
- logger.info(
98
- "zotero_read is empty, fetching paths dynamically using get_item_collections"
82
+ raise ValueError(
83
+ "No fetched papers were found to save. "
84
+ "Please retrieve papers using Zotero Read or Semantic Scholar first."
99
85
  )
100
- try:
101
- zotero_read_data = get_item_collections(zot)
102
- logger.info(
103
- "Successfully generated %d path mappings", len(zotero_read_data)
104
- )
105
- except Exception as e:
106
- logger.error("Error generating path mappings: %s", str(e))
107
- raise RuntimeError(
108
- "Failed to generate collection path mappings. Please retry the same query."
109
- ) from e
110
86
 
111
- # Get all collections to find the correct one
112
- collections = zot.collections()
113
- logger.info("Found %d collections", len(collections))
114
-
115
- # Normalize the requested collection path (remove trailing slash, lowercase for comparison)
87
+ # Normalize the requested collection path
116
88
  normalized_path = collection_path.rstrip("/").lower()
117
89
 
118
- # Find matching collection
119
- matched_collection_key = None
120
-
121
- # First, try to directly find the collection key in zotero_read data
122
- for key, paths in zotero_read_data.items():
123
- if isinstance(paths, list):
124
- for path in paths:
125
- if path.lower() == normalized_path:
126
- matched_collection_key = key
127
- logger.info(
128
- "Found direct match in zotero_read: %s -> %s", path, key
129
- )
130
- break
131
- elif isinstance(paths, str) and paths.lower() == normalized_path:
132
- matched_collection_key = key
133
- logger.info("Found direct match in zotero_read: %s -> %s", paths, key)
134
- break
135
-
136
- # If not found in zotero_read, try matching by collection name
137
- if not matched_collection_key:
138
- for col in collections:
139
- col_name = col["data"]["name"]
140
- if f"/{col_name}".lower() == normalized_path:
141
- matched_collection_key = col["key"]
142
- logger.info(
143
- "Found direct match by collection name: %s (key: %s)",
144
- col_name,
145
- col["key"],
146
- )
147
- break
90
+ # Use our utility function to find or optionally create the collection
91
+ # First try to find the exact collection
92
+ matched_collection_key = find_or_create_collection(
93
+ zot, normalized_path, create_missing=False # First try without creating
94
+ )
148
95
 
149
- # If still not found, try part-matching
150
96
  if not matched_collection_key:
151
- name_to_key = {col["data"]["name"].lower(): col["key"] for col in collections}
152
- collection_name = normalized_path.lstrip("/")
153
- if collection_name in name_to_key:
154
- matched_collection_key = name_to_key[collection_name]
155
- logger.info(
156
- "Found match by collection name: %s -> %s",
157
- collection_name,
158
- matched_collection_key,
159
- )
160
- else:
161
- path_parts = normalized_path.strip("/").split("/")
162
- for part in path_parts:
163
- if part in name_to_key:
164
- matched_collection_key = name_to_key[part]
165
- logger.info(
166
- "Found match by path component: %s -> %s",
167
- part,
168
- matched_collection_key,
97
+ # Get all collection names without hierarchy for clearer display
98
+ available_collections = zot.collections()
99
+ collection_names = [col["data"]["name"] for col in available_collections]
100
+ names_display = ", ".join(collection_names)
101
+
102
+ return Command(
103
+ update={
104
+ "messages": [
105
+ ToolMessage(
106
+ content=(
107
+ f"Error: The collection path '{collection_path}' does "
108
+ f"not exist in Zotero. "
109
+ f"Available collections are: {names_display}. "
110
+ f"Please try saving to one of these existing collections."
111
+ ),
112
+ tool_call_id=tool_call_id,
169
113
  )
170
- break
171
-
172
- # Do not fall back to a default collection: raise error if no match found
173
- if not matched_collection_key:
174
- logger.error(
175
- "Invalid collection path: %s. No matching collection found in Zotero.",
176
- collection_path,
177
- )
178
-
179
- available_paths = ", ".join(["/" + col["data"]["name"] for col in collections])
180
- raise RuntimeError(
181
- f"Error: The collection path '{collection_path}' does not exist in Zotero. "
182
- f"Available collections are: {available_paths}"
114
+ ],
115
+ }
183
116
  )
184
117
 
185
118
  # Format papers for Zotero and assign to the specified collection
@@ -244,6 +177,7 @@ def zotero_save_tool(
244
177
  raise RuntimeError(f"Error saving papers to Zotero: {str(e)}") from e
245
178
 
246
179
  # Get the collection name for better feedback
180
+ collections = zot.collections()
247
181
  collection_name = ""
248
182
  for col in collections:
249
183
  if col["key"] == matched_collection_key:
@@ -266,6 +200,7 @@ def zotero_save_tool(
266
200
  )
267
201
  content += "Here are a few of these articles:\n" + top_papers_info
268
202
 
203
+ # Clear the approval info so it's not reused
269
204
  return Command(
270
205
  update={
271
206
  "messages": [
@@ -275,5 +210,6 @@ def zotero_save_tool(
275
210
  artifact=fetched_papers,
276
211
  )
277
212
  ],
213
+ "zotero_write_approval_status": {}, # Clear approval info
278
214
  }
279
215
  )
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: aiagents4pharma
3
- Version: 1.30.1
3
+ Version: 1.30.2
4
4
  Summary: AI Agents for drug discovery, drug development, and other pharmaceutical R&D.
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -62,6 +62,7 @@ Requires-Dist: umap-learn==0.5.7
62
62
  Requires-Dist: plotly-express==0.4.1
63
63
  Requires-Dist: seaborn==0.13.2
64
64
  Requires-Dist: scanpy==1.11.0
65
+ Dynamic: license-file
65
66
 
66
67
  [![Talk2BioModels](https://github.com/VirtualPatientEngine/AIAgents4Pharma/actions/workflows/tests_talk2biomodels.yml/badge.svg)](https://github.com/VirtualPatientEngine/AIAgents4Pharma/actions/workflows/tests_talk2biomodels.yml)
67
68
  [![Talk2Cells](https://github.com/VirtualPatientEngine/AIAgents4Pharma/actions/workflows/tests_talk2cells.yml/badge.svg)](https://github.com/VirtualPatientEngine/AIAgents4Pharma/actions/workflows/tests_talk2cells.yml)