Skip to content

Commit 1173fcc

Browse files
authored
Merge pull request #10 from Dynamite2003/bugfix/search
bugfix: fix searching errors and revise pdf selection; add new recommendations and revise recommendation logic
2 parents b118363 + 3b1752c commit 1173fcc

13 files changed

Lines changed: 1708 additions & 466 deletions

File tree

backend/app/api/v1/arxiv.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,9 @@
6161
# multi-batch candidate expansion tuning (keep modest to reduce arXiv timeouts)
6262
DEFAULT_BATCH_SIZE = 20
6363
MAX_BATCH_SIZE = 50
64-
CANDIDATE_MULTIPLIER = 2
6564
MAX_TOTAL_CANDIDATES = 150
66-
65+
CANDIDATE_MULTIPLIER = 4
66+
CANDIDATE_MULTIPLIER = 2
6767

6868
def fetch_arxiv_batch(
6969
query: str,

backend/app/core/config.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,27 @@ class Settings(BaseSettings):
2929

3030
# paper search APIs
3131
SEMANTIC_SCHOLAR_API_KEY: str = ""
32+
SEMANTIC_SCHOLAR_CA_BUNDLE: str | None = None
33+
SEMANTIC_SCHOLAR_VERIFY_SSL: bool = True
34+
SEMANTIC_SCHOLAR_TRUST_ENV: bool = True
35+
SEMANTIC_SCHOLAR_TIMEOUT: int = 30
36+
SEMANTIC_SCHOLAR_USER_AGENT: str = "InsightReading/1.0 (mailto:tech@insightreading.local)"
3237
ARXIV_API_BASE: str = "http://export.arxiv.org/api/query"
3338
ARXIV_CA_BUNDLE: str | None = None
3439
ARXIV_VERIFY_SSL: bool = True
3540
ARXIV_TRUST_ENV: bool = True
36-
ARXIV_TIMEOUT: int = 12
41+
ARXIV_TIMEOUT: int = 30
42+
ARXIV_MAX_RETRIES: int = 3
43+
ARXIV_RETRY_BACKOFF_SECONDS: float = 1.5
44+
ARXIV_USER_AGENT: str = "InsightReading/1.0 (mailto:tech@insightreading.local)"
3745
ARXIV_TIMEOUT_FALLBACK: int = 25
46+
OPENALEX_BASE_URL: str = "https://api.openalex.org/works"
47+
OPENALEX_TIMEOUT: int = 30
48+
OPENALEX_PER_PAGE: int = 25
49+
OPENALEX_MAX_PER_PAGE: int = 100
50+
OPENALEX_TRUST_ENV: bool = True
51+
OPENALEX_MAILTO: str = "insightreading@example.com"
52+
3853

3954
DEEPSEEK_API_KEY: str = "sk-7b3e938c92fb4a5ca601a35becdee4e2"
4055
DEEPSEEK_MODEL: str = "deepseek-chat"

backend/app/schemas/academic.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,14 @@ class Paper(BaseModel):
3232
tldr: Optional[str] = None
3333
published_date: Optional[str] = None
3434
external_ids: Optional[dict] = None
35+
score: Optional[float] = None
3536

3637
class PaperSearchResponse(BaseModel):
3738
papers: List[Paper]
3839
total: int
3940
query_interpretation: Optional[dict] = None
41+
sources_used: Optional[List[str]] = None
42+
source_errors: Optional[dict[str, str]] = None
4043

4144

4245
class QueryInterpretation(BaseModel):
@@ -91,4 +94,4 @@ class IntelligentSearchResponse(BaseModel):
9194
conversation: List[ChatMessage] = Field(default_factory=list)
9295
search_performed: bool = False
9396
search_reason: Optional[str] = None
94-
conversation_id: Optional[int] = None # 关联的对话ID
97+
conversation_id: Optional[int] = None # 关联的对话ID

backend/app/services/academic/providers/arxiv.py

Lines changed: 143 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,26 @@
44
查询示例: http://export.arxiv.org/api/query?search_query=all:electron&start=0&max_results=10
55
"""
66

7+
import asyncio
8+
import logging
9+
import re
710
import aiohttp
11+
import ssl
812
import xml.etree.ElementTree as ET
9-
from typing import List, Dict, Optional
1013
from datetime import datetime
14+
from pathlib import Path
15+
from typing import Dict, List, Optional, Tuple
1116
from urllib.parse import urlencode
17+
1218
from app.core.config import settings
1319

20+
logger = logging.getLogger(__name__)
1421

1522
class ArxivProvider:
1623
"""arXiv API 提供商"""
1724

1825
BASE_URL = settings.ARXIV_API_BASE
26+
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
1927

2028
# arXiv 命名空间
2129
NAMESPACES = {
@@ -68,14 +76,8 @@ async def search(
6876
}
6977

7078
url = f"{self.BASE_URL}?{urlencode(params)}"
71-
72-
async with aiohttp.ClientSession() as session:
73-
async with session.get(url) as response:
74-
if response.status != 200:
75-
raise Exception(f"arXiv API 错误: {response.status}")
76-
77-
xml_content = await response.text()
78-
return self._parse_xml_response(xml_content)
79+
xml_content = await self._fetch_feed(url)
80+
return self._parse_xml_response(xml_content)
7981

8082
async def get_by_id(self, arxiv_id: str) -> Optional[Dict]:
8183
"""
@@ -96,14 +98,15 @@ async def get_by_id(self, arxiv_id: str) -> Optional[Dict]:
9698

9799
url = f"{self.BASE_URL}?{urlencode(params)}"
98100

99-
async with aiohttp.ClientSession() as session:
100-
async with session.get(url) as response:
101-
if response.status != 200:
102-
return None
103-
104-
xml_content = await response.text()
105-
results = self._parse_xml_response(xml_content)
106-
return results[0] if results else None
101+
try:
102+
xml_content = await self._fetch_feed(url)
103+
except Exception as exc:
104+
if "404" in str(exc):
105+
return None
106+
raise
107+
108+
results = self._parse_xml_response(xml_content)
109+
return results[0] if results else None
107110

108111
async def search_by_author(
109112
self,
@@ -140,13 +143,132 @@ def _build_query(self, query: str, category: Optional[str] = None) -> str:
140143
141144
布尔运算符: AND, OR, ANDNOT
142145
"""
143-
# 默认在标题和摘要中搜索
144-
base_query = f'all:{query}'
145-
146+
terms = self._tokenize_terms(query)
147+
if not terms:
148+
base_query = 'all:*'
149+
else:
150+
formatted_terms: List[str] = []
151+
for term, is_phrase in terms:
152+
formatted = self._format_term(term, is_phrase)
153+
if formatted:
154+
formatted_terms.append(formatted)
155+
base_query = '+AND+'.join(formatted_terms) if formatted_terms else 'all:*'
156+
146157
if category:
147158
base_query = f'({base_query})+AND+cat:{category}'
148159

149160
return base_query
161+
162+
def _tokenize_terms(self, query: str) -> List[Tuple[str, bool]]:
163+
tokens = re.findall(r'"[^"]+"|\S+', query or '')
164+
parsed: List[Tuple[str, bool]] = []
165+
for token in tokens:
166+
stripped = token.strip()
167+
if not stripped:
168+
continue
169+
if stripped.startswith('"') and stripped.endswith('"') and len(stripped) >= 2:
170+
parsed.append((stripped[1:-1].strip(), True))
171+
else:
172+
parsed.append((stripped, False))
173+
return parsed
174+
175+
def _format_term(self, term: str, is_phrase: bool) -> Optional[str]:
176+
sanitized = term.replace('"', '').strip()
177+
if not sanitized:
178+
return None
179+
if is_phrase or ' ' in sanitized:
180+
return f'all:"{sanitized}"'
181+
return f'all:{sanitized}'
182+
183+
def _build_session_kwargs(self) -> Dict:
184+
"""
185+
Construct aiohttp session kwargs honoring SSL-related settings.
186+
"""
187+
connector = self._build_connector()
188+
kwargs: Dict = {
189+
"trust_env": settings.ARXIV_TRUST_ENV,
190+
"timeout": aiohttp.ClientTimeout(total=settings.ARXIV_TIMEOUT),
191+
}
192+
if connector is not None:
193+
kwargs["connector"] = connector
194+
return kwargs
195+
196+
def _build_connector(self) -> Optional[aiohttp.TCPConnector]:
197+
"""
198+
Build a TCPConnector that respects custom CA bundles or disables SSL validation.
199+
"""
200+
if not settings.ARXIV_VERIFY_SSL:
201+
return aiohttp.TCPConnector(ssl=False)
202+
203+
if settings.ARXIV_CA_BUNDLE:
204+
bundle_path = Path(settings.ARXIV_CA_BUNDLE)
205+
if bundle_path.is_file():
206+
ssl_context = ssl.create_default_context(cafile=str(bundle_path))
207+
return aiohttp.TCPConnector(ssl=ssl_context)
208+
logger.warning(
209+
"Configured ARXIV_CA_BUNDLE does not exist: %s. Falling back to default cert store.",
210+
settings.ARXIV_CA_BUNDLE,
211+
)
212+
213+
return None
214+
215+
def _build_headers(self) -> Dict[str, str]:
216+
return {
217+
"User-Agent": settings.ARXIV_USER_AGENT,
218+
}
219+
220+
async def _fetch_feed(self, url: str) -> str:
221+
"""
222+
执行带有重试的 arXiv 请求,处理速率限制和暂时性错误。
223+
"""
224+
session_kwargs = self._build_session_kwargs()
225+
headers = self._build_headers()
226+
retries = max(1, settings.ARXIV_MAX_RETRIES)
227+
backoff = max(0.5, settings.ARXIV_RETRY_BACKOFF_SECONDS)
228+
229+
async with aiohttp.ClientSession(**session_kwargs) as session:
230+
for attempt in range(retries):
231+
try:
232+
async with session.get(url, headers=headers) as response:
233+
if response.status == 200:
234+
return await response.text()
235+
236+
if (
237+
response.status in self.RETRYABLE_STATUS
238+
and attempt < retries - 1
239+
):
240+
await self._respect_retry_after(response, attempt, backoff)
241+
continue
242+
243+
raise Exception(f"arXiv API 错误: {response.status}")
244+
245+
except asyncio.TimeoutError as exc:
246+
if attempt >= retries - 1:
247+
raise Exception("arXiv 请求超时") from exc
248+
await asyncio.sleep(backoff * (2 ** attempt))
249+
250+
except aiohttp.ClientError as exc:
251+
if attempt >= retries - 1:
252+
raise Exception(f"arXiv 网络错误: {exc}") from exc
253+
await asyncio.sleep(backoff * (2 ** attempt))
254+
255+
raise Exception("arXiv API 错误: 请求失败")
256+
257+
async def _respect_retry_after(
258+
self,
259+
response: aiohttp.ClientResponse,
260+
attempt: int,
261+
backoff: float,
262+
) -> None:
263+
retry_after = response.headers.get("Retry-After")
264+
if retry_after:
265+
try:
266+
delay = float(retry_after)
267+
except ValueError:
268+
delay = backoff * (2 ** attempt)
269+
else:
270+
delay = backoff * (2 ** attempt)
271+
await asyncio.sleep(delay)
150272

151273
def _parse_xml_response(self, xml_content: str) -> List[Dict]:
152274
"""
@@ -352,4 +474,4 @@ async def get_arxiv_paper(arxiv_id: str) -> Optional[Dict]:
352474
>>> paper = await get_arxiv_paper("2103.00020")
353475
"""
354476
provider = ArxivProvider()
355-
return await provider.get_by_id(arxiv_id)
477+
return await provider.get_by_id(arxiv_id)

0 commit comments

Comments
 (0)