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.
- aiagents4pharma/talk2scholars/agents/main_agent.py +18 -10
- aiagents4pharma/talk2scholars/agents/paper_download_agent.py +5 -6
- aiagents4pharma/talk2scholars/agents/pdf_agent.py +4 -10
- aiagents4pharma/talk2scholars/agents/zotero_agent.py +9 -7
- aiagents4pharma/talk2scholars/configs/agents/talk2scholars/main_agent/default.yaml +18 -9
- aiagents4pharma/talk2scholars/configs/agents/talk2scholars/s2_agent/default.yaml +2 -2
- aiagents4pharma/talk2scholars/configs/agents/talk2scholars/zotero_agent/default.yaml +9 -15
- aiagents4pharma/talk2scholars/configs/app/frontend/default.yaml +1 -0
- aiagents4pharma/talk2scholars/configs/tools/multi_paper_recommendation/default.yaml +6 -1
- aiagents4pharma/talk2scholars/configs/tools/search/default.yaml +7 -1
- aiagents4pharma/talk2scholars/configs/tools/single_paper_recommendation/default.yaml +6 -1
- aiagents4pharma/talk2scholars/configs/tools/zotero_read/default.yaml +1 -1
- aiagents4pharma/talk2scholars/configs/tools/zotero_write/default.yaml +55 -0
- aiagents4pharma/talk2scholars/state/state_talk2scholars.py +7 -1
- aiagents4pharma/talk2scholars/tests/test_llm_main_integration.py +84 -53
- aiagents4pharma/talk2scholars/tests/test_main_agent.py +24 -0
- aiagents4pharma/talk2scholars/tests/test_question_and_answer_tool.py +79 -15
- aiagents4pharma/talk2scholars/tests/test_routing_logic.py +13 -10
- aiagents4pharma/talk2scholars/tests/test_s2_multi.py +27 -4
- aiagents4pharma/talk2scholars/tests/test_s2_search.py +19 -3
- aiagents4pharma/talk2scholars/tests/test_s2_single.py +27 -3
- aiagents4pharma/talk2scholars/tests/test_zotero_agent.py +3 -2
- aiagents4pharma/talk2scholars/tests/test_zotero_human_in_the_loop.py +273 -0
- aiagents4pharma/talk2scholars/tests/test_zotero_path.py +419 -1
- aiagents4pharma/talk2scholars/tests/test_zotero_read.py +25 -18
- aiagents4pharma/talk2scholars/tests/test_zotero_write.py +123 -588
- aiagents4pharma/talk2scholars/tools/paper_download/abstract_downloader.py +2 -0
- aiagents4pharma/talk2scholars/tools/paper_download/arxiv_downloader.py +11 -4
- aiagents4pharma/talk2scholars/tools/paper_download/download_arxiv_input.py +5 -1
- aiagents4pharma/talk2scholars/tools/pdf/question_and_answer.py +73 -26
- aiagents4pharma/talk2scholars/tools/s2/multi_paper_rec.py +46 -22
- aiagents4pharma/talk2scholars/tools/s2/query_results.py +1 -1
- aiagents4pharma/talk2scholars/tools/s2/search.py +40 -12
- aiagents4pharma/talk2scholars/tools/s2/single_paper_rec.py +42 -16
- aiagents4pharma/talk2scholars/tools/zotero/__init__.py +1 -0
- aiagents4pharma/talk2scholars/tools/zotero/utils/zotero_path.py +125 -0
- aiagents4pharma/talk2scholars/tools/zotero/zotero_read.py +35 -20
- aiagents4pharma/talk2scholars/tools/zotero/zotero_review.py +198 -0
- aiagents4pharma/talk2scholars/tools/zotero/zotero_write.py +86 -118
- {aiagents4pharma-1.30.0.dist-info → aiagents4pharma-1.30.2.dist-info}/METADATA +4 -3
- {aiagents4pharma-1.30.0.dist-info → aiagents4pharma-1.30.2.dist-info}/RECORD +44 -41
- {aiagents4pharma-1.30.0.dist-info → aiagents4pharma-1.30.2.dist-info}/WHEEL +1 -1
- {aiagents4pharma-1.30.0.dist-info → aiagents4pharma-1.30.2.dist-info/licenses}/LICENSE +0 -0
- {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
|
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
|
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
|
-
|
108
|
-
|
109
|
-
|
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
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
):
|
134
|
-
|
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(
|
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, #
|
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 += "
|
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
|
+
)
|