research_lookup.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566
  1. #!/usr/bin/env python3
  2. """
  3. Research Information Lookup Tool
  4. Routes research queries to the best backend:
  5. - Parallel Chat API (core model): Default for all general research queries
  6. - Perplexity sonar-pro-search (via OpenRouter): Academic-specific paper searches
  7. Environment variables:
  8. PARALLEL_API_KEY - Required for Parallel Chat API (primary backend)
  9. OPENROUTER_API_KEY - Required for Perplexity academic searches (fallback)
  10. """
  11. import os
  12. import sys
  13. import json
  14. import re
  15. import time
  16. import requests
  17. from datetime import datetime
  18. from typing import Any, Dict, List, Optional
  19. class ResearchLookup:
  20. """Research information lookup with intelligent backend routing.
  21. Routes queries to the Parallel Chat API (default) or Perplexity
  22. sonar-pro-search (academic paper searches only).
  23. """
  24. ACADEMIC_KEYWORDS = [
  25. "find papers", "find paper", "find articles", "find article",
  26. "cite ", "citation", "citations for",
  27. "doi ", "doi:", "pubmed", "pmid",
  28. "journal article", "peer-reviewed",
  29. "systematic review", "meta-analysis",
  30. "literature search", "literature on",
  31. "academic papers", "academic paper",
  32. "research papers on", "research paper on",
  33. "published studies", "published study",
  34. "scholarly", "scholar",
  35. "arxiv", "preprint",
  36. "foundational papers", "seminal papers", "landmark papers",
  37. "highly cited", "most cited",
  38. ]
  39. PARALLEL_SYSTEM_PROMPT = (
  40. "You are a deep research analyst. Provide a comprehensive, well-cited "
  41. "research report on the user's topic. Include:\n"
  42. "- Key findings with specific data, statistics, and quantitative evidence\n"
  43. "- Detailed analysis organized by themes\n"
  44. "- Multiple authoritative sources cited inline\n"
  45. "- Methodologies and implications where relevant\n"
  46. "- Future outlook and research gaps\n"
  47. "Use markdown formatting with clear section headers. "
  48. "Prioritize authoritative and recent sources."
  49. )
  50. CHAT_BASE_URL = "https://api.parallel.ai"
  51. def __init__(self, force_backend: Optional[str] = None):
  52. """Initialize the research lookup tool.
  53. Args:
  54. force_backend: Force a specific backend ('parallel' or 'perplexity').
  55. If None, backend is auto-selected based on query content.
  56. """
  57. self.force_backend = force_backend
  58. self.parallel_available = bool(os.getenv("PARALLEL_API_KEY"))
  59. self.perplexity_available = bool(os.getenv("OPENROUTER_API_KEY"))
  60. if not self.parallel_available and not self.perplexity_available:
  61. raise ValueError(
  62. "No API keys found. Set at least one of:\n"
  63. " PARALLEL_API_KEY (for Parallel Chat API - primary)\n"
  64. " OPENROUTER_API_KEY (for Perplexity academic search - fallback)"
  65. )
  66. def _select_backend(self, query: str) -> str:
  67. """Select the best backend for a query."""
  68. if self.force_backend:
  69. if self.force_backend == "perplexity" and self.perplexity_available:
  70. return "perplexity"
  71. if self.force_backend == "parallel" and self.parallel_available:
  72. return "parallel"
  73. query_lower = query.lower()
  74. is_academic = any(kw in query_lower for kw in self.ACADEMIC_KEYWORDS)
  75. if is_academic and self.perplexity_available:
  76. return "perplexity"
  77. if self.parallel_available:
  78. return "parallel"
  79. if self.perplexity_available:
  80. return "perplexity"
  81. raise ValueError("No backend available. Check API keys.")
  82. # ------------------------------------------------------------------
  83. # Parallel Chat API backend
  84. # ------------------------------------------------------------------
  85. def _get_chat_client(self):
  86. """Lazy-load and cache the OpenAI client for Parallel Chat API."""
  87. if not hasattr(self, "_chat_client"):
  88. try:
  89. from openai import OpenAI
  90. except ImportError:
  91. raise ImportError(
  92. "The 'openai' package is required for Parallel Chat API.\n"
  93. "Install it with: pip install openai"
  94. )
  95. self._chat_client = OpenAI(
  96. api_key=os.getenv("PARALLEL_API_KEY"),
  97. base_url=self.CHAT_BASE_URL,
  98. )
  99. return self._chat_client
  100. def _parallel_lookup(self, query: str) -> Dict[str, Any]:
  101. """Run research via the Parallel Chat API (core model)."""
  102. timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  103. model = "core"
  104. try:
  105. client = self._get_chat_client()
  106. print(f"[Research] Parallel Chat API (model={model})...", file=sys.stderr)
  107. response = client.chat.completions.create(
  108. model=model,
  109. messages=[
  110. {"role": "system", "content": self.PARALLEL_SYSTEM_PROMPT},
  111. {"role": "user", "content": query},
  112. ],
  113. stream=False,
  114. )
  115. content = ""
  116. if response.choices and len(response.choices) > 0:
  117. content = response.choices[0].message.content or ""
  118. api_citations = self._extract_basis_citations(response)
  119. text_citations = self._extract_citations_from_text(content)
  120. return {
  121. "success": True,
  122. "query": query,
  123. "response": content,
  124. "citations": api_citations + text_citations,
  125. "sources": api_citations,
  126. "timestamp": timestamp,
  127. "backend": "parallel",
  128. "model": f"parallel-chat/{model}",
  129. }
  130. except Exception as e:
  131. return {
  132. "success": False,
  133. "query": query,
  134. "error": str(e),
  135. "timestamp": timestamp,
  136. "backend": "parallel",
  137. "model": f"parallel-chat/{model}",
  138. }
  139. def _extract_basis_citations(self, response) -> List[Dict[str, str]]:
  140. """Extract citation sources from the Chat API research basis."""
  141. citations = []
  142. basis = getattr(response, "basis", None)
  143. if not basis:
  144. return citations
  145. seen_urls = set()
  146. if isinstance(basis, list):
  147. for item in basis:
  148. cits = (
  149. item.get("citations", []) if isinstance(item, dict)
  150. else getattr(item, "citations", None) or []
  151. )
  152. for cit in cits:
  153. url = cit.get("url", "") if isinstance(cit, dict) else getattr(cit, "url", "")
  154. if url and url not in seen_urls:
  155. seen_urls.add(url)
  156. title = cit.get("title", "") if isinstance(cit, dict) else getattr(cit, "title", "")
  157. excerpts = cit.get("excerpts", []) if isinstance(cit, dict) else getattr(cit, "excerpts", [])
  158. citations.append({
  159. "type": "source",
  160. "url": url,
  161. "title": title,
  162. "excerpts": excerpts,
  163. })
  164. return citations
  165. # ------------------------------------------------------------------
  166. # Perplexity academic search backend
  167. # ------------------------------------------------------------------
  168. def _perplexity_lookup(self, query: str) -> Dict[str, Any]:
  169. """Run academic search via Perplexity sonar-pro-search through OpenRouter."""
  170. timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  171. api_key = os.getenv("OPENROUTER_API_KEY")
  172. model = "perplexity/sonar-pro-search"
  173. headers = {
  174. "Authorization": f"Bearer {api_key}",
  175. "Content-Type": "application/json",
  176. "HTTP-Referer": "https://scientific-writer.local",
  177. "X-Title": "Scientific Writer Research Tool",
  178. }
  179. research_prompt = self._format_academic_prompt(query)
  180. messages = [
  181. {
  182. "role": "system",
  183. "content": (
  184. "You are an academic research assistant specializing in finding "
  185. "HIGH-IMPACT, INFLUENTIAL research.\n\n"
  186. "QUALITY PRIORITIZATION (CRITICAL):\n"
  187. "- ALWAYS prefer highly-cited papers over obscure publications\n"
  188. "- ALWAYS prioritize Tier-1 venues: Nature, Science, Cell, NEJM, Lancet, JAMA, PNAS\n"
  189. "- ALWAYS prefer papers from established researchers\n"
  190. "- Include citation counts when known (e.g., 'cited 500+ times')\n"
  191. "- Quality matters more than quantity\n\n"
  192. "VENUE HIERARCHY:\n"
  193. "1. Nature/Science/Cell family, NEJM, Lancet, JAMA (highest)\n"
  194. "2. High-impact specialized journals (IF>10), top conferences (NeurIPS, ICML, ICLR)\n"
  195. "3. Respected field-specific journals (IF 5-10)\n"
  196. "4. Other peer-reviewed sources (only if no better option)\n\n"
  197. "Focus exclusively on scholarly sources. Prioritize recent literature (2020-2026) "
  198. "and provide complete citations with DOIs."
  199. ),
  200. },
  201. {"role": "user", "content": research_prompt},
  202. ]
  203. data = {
  204. "model": model,
  205. "messages": messages,
  206. "max_tokens": 8000,
  207. "temperature": 0.1,
  208. "search_mode": "academic",
  209. "search_context_size": "high",
  210. }
  211. try:
  212. response = requests.post(
  213. "https://openrouter.ai/api/v1/chat/completions",
  214. headers=headers,
  215. json=data,
  216. timeout=90,
  217. )
  218. response.raise_for_status()
  219. resp_json = response.json()
  220. if "choices" in resp_json and len(resp_json["choices"]) > 0:
  221. choice = resp_json["choices"][0]
  222. if "message" in choice and "content" in choice["message"]:
  223. content = choice["message"]["content"]
  224. api_citations = self._extract_api_citations(resp_json, choice)
  225. text_citations = self._extract_citations_from_text(content)
  226. citations = api_citations + text_citations
  227. return {
  228. "success": True,
  229. "query": query,
  230. "response": content,
  231. "citations": citations,
  232. "sources": api_citations,
  233. "timestamp": timestamp,
  234. "backend": "perplexity",
  235. "model": model,
  236. "usage": resp_json.get("usage", {}),
  237. }
  238. else:
  239. raise Exception("Invalid response format from API")
  240. else:
  241. raise Exception("No response choices received from API")
  242. except Exception as e:
  243. return {
  244. "success": False,
  245. "query": query,
  246. "error": str(e),
  247. "timestamp": timestamp,
  248. "backend": "perplexity",
  249. "model": model,
  250. }
  251. # ------------------------------------------------------------------
  252. # Shared utilities
  253. # ------------------------------------------------------------------
  254. def _format_academic_prompt(self, query: str) -> str:
  255. """Format a query for academic research results via Perplexity."""
  256. return f"""You are an expert research assistant. Please provide comprehensive, accurate research information for the following query: "{query}"
  257. IMPORTANT INSTRUCTIONS:
  258. 1. Focus on ACADEMIC and SCIENTIFIC sources (peer-reviewed papers, reputable journals, institutional research)
  259. 2. Include RECENT information (prioritize 2020-2026 publications)
  260. 3. Provide COMPLETE citations with authors, title, journal/conference, year, and DOI when available
  261. 4. Structure your response with clear sections and proper attribution
  262. 5. Be comprehensive but concise - aim for 800-1200 words
  263. 6. Include key findings, methodologies, and implications when relevant
  264. 7. Note any controversies, limitations, or conflicting evidence
  265. PAPER QUALITY PRIORITIZATION (CRITICAL):
  266. 8. ALWAYS prioritize HIGHLY-CITED papers over obscure publications
  267. 9. ALWAYS prioritize papers from TOP-TIER VENUES (Nature, Science, Cell, NEJM, Lancet, JAMA, PNAS)
  268. 10. PREFER papers from ESTABLISHED, REPUTABLE AUTHORS
  269. 11. For EACH citation include when available: citation count, venue tier, author credentials
  270. 12. PRIORITIZE papers that DIRECTLY address the research question
  271. RESPONSE FORMAT:
  272. - Start with a brief summary (2-3 sentences)
  273. - Present key findings and studies in organized sections
  274. - Rank papers by impact: most influential/cited first
  275. - End with future directions or research gaps if applicable
  276. - Include 5-8 high-quality citations
  277. Remember: Quality over quantity. Prioritize influential, highly-cited papers from prestigious venues."""
  278. def _extract_api_citations(self, response: Dict[str, Any], choice: Dict[str, Any]) -> List[Dict[str, str]]:
  279. """Extract citations from Perplexity API response fields."""
  280. citations = []
  281. search_results = (
  282. response.get("search_results")
  283. or choice.get("search_results")
  284. or choice.get("message", {}).get("search_results")
  285. or []
  286. )
  287. for result in search_results:
  288. citation = {
  289. "type": "source",
  290. "title": result.get("title", ""),
  291. "url": result.get("url", ""),
  292. "date": result.get("date", ""),
  293. }
  294. if result.get("snippet"):
  295. citation["snippet"] = result["snippet"]
  296. citations.append(citation)
  297. legacy_citations = (
  298. response.get("citations")
  299. or choice.get("citations")
  300. or choice.get("message", {}).get("citations")
  301. or []
  302. )
  303. for url in legacy_citations:
  304. if isinstance(url, str):
  305. citations.append({"type": "source", "url": url, "title": "", "date": ""})
  306. elif isinstance(url, dict):
  307. citations.append({
  308. "type": "source",
  309. "url": url.get("url", ""),
  310. "title": url.get("title", ""),
  311. "date": url.get("date", ""),
  312. })
  313. return citations
  314. def _extract_citations_from_text(self, text: str) -> List[Dict[str, str]]:
  315. """Extract DOIs and academic URLs from response text as fallback."""
  316. citations = []
  317. doi_pattern = r'(?:doi[:\s]*|https?://(?:dx\.)?doi\.org/)(10\.[0-9]{4,}/[^\s\)\]\,\[\<\>]+)'
  318. doi_matches = re.findall(doi_pattern, text, re.IGNORECASE)
  319. seen_dois = set()
  320. for doi in doi_matches:
  321. doi_clean = doi.strip().rstrip(".,;:)]")
  322. if doi_clean and doi_clean not in seen_dois:
  323. seen_dois.add(doi_clean)
  324. citations.append({
  325. "type": "doi",
  326. "doi": doi_clean,
  327. "url": f"https://doi.org/{doi_clean}",
  328. })
  329. url_pattern = (
  330. r'https?://[^\s\)\]\,\<\>\"\']+(?:arxiv\.org|pubmed|ncbi\.nlm\.nih\.gov|'
  331. r'nature\.com|science\.org|wiley\.com|springer\.com|ieee\.org|acm\.org)'
  332. r'[^\s\)\]\,\<\>\"\']*'
  333. )
  334. url_matches = re.findall(url_pattern, text, re.IGNORECASE)
  335. seen_urls = set()
  336. for url in url_matches:
  337. url_clean = url.rstrip(".")
  338. if url_clean not in seen_urls:
  339. seen_urls.add(url_clean)
  340. citations.append({"type": "url", "url": url_clean})
  341. return citations
  342. # ------------------------------------------------------------------
  343. # Public API
  344. # ------------------------------------------------------------------
  345. def lookup(self, query: str) -> Dict[str, Any]:
  346. """Perform a research lookup, routing to the best backend.
  347. Parallel Chat API is used by default. Perplexity sonar-pro-search
  348. is used only for academic-specific queries (paper searches, DOI lookups).
  349. """
  350. backend = self._select_backend(query)
  351. print(f"[Research] Backend: {backend} | Query: {query[:80]}...", file=sys.stderr)
  352. if backend == "parallel":
  353. return self._parallel_lookup(query)
  354. else:
  355. return self._perplexity_lookup(query)
  356. def batch_lookup(self, queries: List[str], delay: float = 1.0) -> List[Dict[str, Any]]:
  357. """Perform multiple research lookups with delay between requests."""
  358. results = []
  359. for i, query in enumerate(queries):
  360. if i > 0 and delay > 0:
  361. time.sleep(delay)
  362. result = self.lookup(query)
  363. results.append(result)
  364. print(f"[Research] Completed query {i+1}/{len(queries)}: {query[:50]}...", file=sys.stderr)
  365. return results
  366. # ---------------------------------------------------------------------------
  367. # CLI
  368. # ---------------------------------------------------------------------------
  369. def main():
  370. """Command-line interface for the research lookup tool."""
  371. import argparse
  372. parser = argparse.ArgumentParser(
  373. description="Research Information Lookup Tool (Parallel Chat API + Perplexity)",
  374. formatter_class=argparse.RawDescriptionHelpFormatter,
  375. epilog="""
  376. Examples:
  377. # General research (uses Parallel Chat API, core model)
  378. python research_lookup.py "latest advances in quantum computing 2025"
  379. # Academic paper search (auto-routes to Perplexity)
  380. python research_lookup.py "find papers on CRISPR gene editing clinical trials"
  381. # Force a specific backend
  382. python research_lookup.py "topic" --force-backend parallel
  383. python research_lookup.py "topic" --force-backend perplexity
  384. # Save output to file
  385. python research_lookup.py "topic" -o results.txt
  386. # JSON output
  387. python research_lookup.py "topic" --json -o results.json
  388. """,
  389. )
  390. parser.add_argument("query", nargs="?", help="Research query to look up")
  391. parser.add_argument("--batch", nargs="+", help="Run multiple queries")
  392. parser.add_argument(
  393. "--force-backend",
  394. choices=["parallel", "perplexity"],
  395. help="Force a specific backend (default: auto-select)",
  396. )
  397. parser.add_argument("-o", "--output", help="Write output to file")
  398. parser.add_argument("--json", action="store_true", help="Output as JSON")
  399. args = parser.parse_args()
  400. output_file = None
  401. if args.output:
  402. output_file = open(args.output, "w", encoding="utf-8")
  403. def write_output(text):
  404. if output_file:
  405. output_file.write(text + "\n")
  406. else:
  407. print(text)
  408. has_parallel = bool(os.getenv("PARALLEL_API_KEY"))
  409. has_perplexity = bool(os.getenv("OPENROUTER_API_KEY"))
  410. if not has_parallel and not has_perplexity:
  411. print("Error: No API keys found. Set at least one:", file=sys.stderr)
  412. print(" export PARALLEL_API_KEY='...' (primary - Parallel Chat API)", file=sys.stderr)
  413. print(" export OPENROUTER_API_KEY='...' (fallback - Perplexity academic)", file=sys.stderr)
  414. if output_file:
  415. output_file.close()
  416. return 1
  417. if not args.query and not args.batch:
  418. parser.print_help()
  419. if output_file:
  420. output_file.close()
  421. return 1
  422. try:
  423. research = ResearchLookup(force_backend=args.force_backend)
  424. if args.batch:
  425. print(f"Running batch research for {len(args.batch)} queries...", file=sys.stderr)
  426. results = research.batch_lookup(args.batch)
  427. else:
  428. print(f"Researching: {args.query}", file=sys.stderr)
  429. results = [research.lookup(args.query)]
  430. if args.json:
  431. write_output(json.dumps(results, indent=2, ensure_ascii=False, default=str))
  432. if output_file:
  433. output_file.close()
  434. return 0
  435. for i, result in enumerate(results):
  436. if result["success"]:
  437. write_output(f"\n{'='*80}")
  438. write_output(f"Query {i+1}: {result['query']}")
  439. write_output(f"Timestamp: {result['timestamp']}")
  440. write_output(f"Backend: {result.get('backend', 'unknown')} | Model: {result.get('model', 'unknown')}")
  441. write_output(f"{'='*80}")
  442. write_output(result["response"])
  443. sources = result.get("sources", [])
  444. if sources:
  445. write_output(f"\nSources ({len(sources)}):")
  446. for j, source in enumerate(sources):
  447. title = source.get("title", "Untitled")
  448. url = source.get("url", "")
  449. date = source.get("date", "")
  450. date_str = f" ({date})" if date else ""
  451. write_output(f" [{j+1}] {title}{date_str}")
  452. if url:
  453. write_output(f" {url}")
  454. citations = result.get("citations", [])
  455. text_citations = [c for c in citations if c.get("type") in ("doi", "url")]
  456. if text_citations:
  457. write_output(f"\nAdditional References ({len(text_citations)}):")
  458. for j, citation in enumerate(text_citations):
  459. if citation.get("type") == "doi":
  460. write_output(f" [{j+1}] DOI: {citation.get('doi', '')} - {citation.get('url', '')}")
  461. elif citation.get("type") == "url":
  462. write_output(f" [{j+1}] {citation.get('url', '')}")
  463. if result.get("usage"):
  464. write_output(f"\nUsage: {result['usage']}")
  465. else:
  466. write_output(f"\nError in query {i+1}: {result['error']}")
  467. if output_file:
  468. output_file.close()
  469. return 0
  470. except Exception as e:
  471. print(f"Error: {e}", file=sys.stderr)
  472. if output_file:
  473. output_file.close()
  474. return 1
  475. if __name__ == "__main__":
  476. sys.exit(main())