flonat-research 0.1.0

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 (285) hide show
  1. package/.claude/agents/domain-reviewer.md +336 -0
  2. package/.claude/agents/fixer.md +226 -0
  3. package/.claude/agents/paper-critic.md +370 -0
  4. package/.claude/agents/peer-reviewer.md +289 -0
  5. package/.claude/agents/proposal-reviewer.md +215 -0
  6. package/.claude/agents/referee2-reviewer.md +367 -0
  7. package/.claude/agents/references/journal-referee-profiles.md +354 -0
  8. package/.claude/agents/references/paper-critic/council-personas.md +77 -0
  9. package/.claude/agents/references/paper-critic/council-prompts.md +198 -0
  10. package/.claude/agents/references/peer-reviewer/report-template.md +199 -0
  11. package/.claude/agents/references/peer-reviewer/sa-prompts.md +260 -0
  12. package/.claude/agents/references/peer-reviewer/security-scan.md +188 -0
  13. package/.claude/agents/references/proposal-reviewer/report-template.md +144 -0
  14. package/.claude/agents/references/proposal-reviewer/sa-prompts.md +149 -0
  15. package/.claude/agents/references/referee-config.md +114 -0
  16. package/.claude/agents/references/referee2-reviewer/audit-checklists.md +287 -0
  17. package/.claude/agents/references/referee2-reviewer/report-template.md +334 -0
  18. package/.claude/rules/design-before-results.md +52 -0
  19. package/.claude/rules/ignore-agents-md.md +17 -0
  20. package/.claude/rules/ignore-gemini-md.md +17 -0
  21. package/.claude/rules/lean-claude-md.md +45 -0
  22. package/.claude/rules/learn-tags.md +99 -0
  23. package/.claude/rules/overleaf-separation.md +67 -0
  24. package/.claude/rules/plan-first.md +175 -0
  25. package/.claude/rules/read-docs-first.md +50 -0
  26. package/.claude/rules/scope-discipline.md +28 -0
  27. package/.claude/settings.json +125 -0
  28. package/.context/current-focus.md +33 -0
  29. package/.context/preferences/priorities.md +36 -0
  30. package/.context/preferences/task-naming.md +28 -0
  31. package/.context/profile.md +29 -0
  32. package/.context/projects/_index.md +41 -0
  33. package/.context/projects/papers/nudge-exp.md +22 -0
  34. package/.context/projects/papers/uncertainty.md +31 -0
  35. package/.context/resources/claude-scientific-writer-review.md +48 -0
  36. package/.context/resources/cunningham-multi-analyst-agents.md +104 -0
  37. package/.context/resources/cunningham-multilang-code-audit.md +62 -0
  38. package/.context/resources/google-ai-co-scientist-review.md +72 -0
  39. package/.context/resources/karpathy-llm-council-review.md +58 -0
  40. package/.context/resources/multi-coder-reliability-protocol.md +175 -0
  41. package/.context/resources/pedro-santanna-takeaways.md +96 -0
  42. package/.context/resources/venue-rankings/abs_ajg_2024.csv +1823 -0
  43. package/.context/resources/venue-rankings/abs_ajg_2024_econ.csv +356 -0
  44. package/.context/resources/venue-rankings/cabs_4_4star_theory.csv +40 -0
  45. package/.context/resources/venue-rankings/core_2026.csv +801 -0
  46. package/.context/resources/venue-rankings.md +147 -0
  47. package/.context/workflows/README.md +69 -0
  48. package/.context/workflows/daily-review.md +91 -0
  49. package/.context/workflows/meeting-actions.md +108 -0
  50. package/.context/workflows/replication-protocol.md +155 -0
  51. package/.context/workflows/weekly-review.md +113 -0
  52. package/.mcp-server-biblio/formatters.py +158 -0
  53. package/.mcp-server-biblio/pyproject.toml +11 -0
  54. package/.mcp-server-biblio/server.py +678 -0
  55. package/.mcp-server-biblio/sources/__init__.py +14 -0
  56. package/.mcp-server-biblio/sources/base.py +73 -0
  57. package/.mcp-server-biblio/sources/formatters.py +83 -0
  58. package/.mcp-server-biblio/sources/models.py +22 -0
  59. package/.mcp-server-biblio/sources/multi_source.py +243 -0
  60. package/.mcp-server-biblio/sources/openalex_source.py +183 -0
  61. package/.mcp-server-biblio/sources/scopus_source.py +309 -0
  62. package/.mcp-server-biblio/sources/wos_source.py +508 -0
  63. package/.mcp-server-biblio/uv.lock +896 -0
  64. package/.scripts/README.md +161 -0
  65. package/.scripts/ai_pattern_density.py +446 -0
  66. package/.scripts/conf +445 -0
  67. package/.scripts/config.py +122 -0
  68. package/.scripts/count_inventory.py +275 -0
  69. package/.scripts/daily_digest.py +288 -0
  70. package/.scripts/done +177 -0
  71. package/.scripts/extract_meeting_actions.py +223 -0
  72. package/.scripts/focus +176 -0
  73. package/.scripts/generate-codex-agents-md.py +217 -0
  74. package/.scripts/inbox +194 -0
  75. package/.scripts/notion_helpers.py +325 -0
  76. package/.scripts/openalex/query_helpers.py +306 -0
  77. package/.scripts/papers +227 -0
  78. package/.scripts/query +223 -0
  79. package/.scripts/session-history.py +201 -0
  80. package/.scripts/skill-health.py +516 -0
  81. package/.scripts/skill-log-miner.py +273 -0
  82. package/.scripts/sync-to-codex.sh +252 -0
  83. package/.scripts/task +213 -0
  84. package/.scripts/tasks +190 -0
  85. package/.scripts/week +206 -0
  86. package/CLAUDE.md +197 -0
  87. package/LICENSE +21 -0
  88. package/MEMORY.md +38 -0
  89. package/README.md +269 -0
  90. package/docs/agents.md +44 -0
  91. package/docs/bibliography-setup.md +55 -0
  92. package/docs/council-mode.md +36 -0
  93. package/docs/getting-started.md +245 -0
  94. package/docs/hooks.md +38 -0
  95. package/docs/mcp-servers.md +82 -0
  96. package/docs/notion-setup.md +109 -0
  97. package/docs/rules.md +33 -0
  98. package/docs/scripts.md +303 -0
  99. package/docs/setup-overview/setup-overview.pdf +0 -0
  100. package/docs/skills.md +70 -0
  101. package/docs/system.md +159 -0
  102. package/hooks/block-destructive-git.sh +66 -0
  103. package/hooks/context-monitor.py +114 -0
  104. package/hooks/postcompact-restore.py +157 -0
  105. package/hooks/precompact-autosave.py +181 -0
  106. package/hooks/promise-checker.sh +124 -0
  107. package/hooks/protect-source-files.sh +81 -0
  108. package/hooks/resume-context-loader.sh +53 -0
  109. package/hooks/startup-context-loader.sh +102 -0
  110. package/package.json +51 -0
  111. package/packages/cli-council/.github/workflows/claude-code-review.yml +44 -0
  112. package/packages/cli-council/.github/workflows/claude.yml +50 -0
  113. package/packages/cli-council/README.md +100 -0
  114. package/packages/cli-council/pyproject.toml +43 -0
  115. package/packages/cli-council/src/cli_council/__init__.py +19 -0
  116. package/packages/cli-council/src/cli_council/__main__.py +185 -0
  117. package/packages/cli-council/src/cli_council/backends/__init__.py +8 -0
  118. package/packages/cli-council/src/cli_council/backends/base.py +81 -0
  119. package/packages/cli-council/src/cli_council/backends/claude.py +25 -0
  120. package/packages/cli-council/src/cli_council/backends/codex.py +27 -0
  121. package/packages/cli-council/src/cli_council/backends/gemini.py +26 -0
  122. package/packages/cli-council/src/cli_council/checkpoint.py +212 -0
  123. package/packages/cli-council/src/cli_council/config.py +51 -0
  124. package/packages/cli-council/src/cli_council/council.py +391 -0
  125. package/packages/cli-council/src/cli_council/models.py +46 -0
  126. package/packages/llm-council/.github/workflows/claude-code-review.yml +44 -0
  127. package/packages/llm-council/.github/workflows/claude.yml +50 -0
  128. package/packages/llm-council/README.md +453 -0
  129. package/packages/llm-council/pyproject.toml +42 -0
  130. package/packages/llm-council/src/llm_council/__init__.py +23 -0
  131. package/packages/llm-council/src/llm_council/__main__.py +259 -0
  132. package/packages/llm-council/src/llm_council/checkpoint.py +193 -0
  133. package/packages/llm-council/src/llm_council/client.py +253 -0
  134. package/packages/llm-council/src/llm_council/config.py +232 -0
  135. package/packages/llm-council/src/llm_council/council.py +482 -0
  136. package/packages/llm-council/src/llm_council/models.py +46 -0
  137. package/packages/mcp-bibliography/MEMORY.md +31 -0
  138. package/packages/mcp-bibliography/_app.py +226 -0
  139. package/packages/mcp-bibliography/formatters.py +158 -0
  140. package/packages/mcp-bibliography/log/2026-03-13-2100.md +35 -0
  141. package/packages/mcp-bibliography/pyproject.toml +15 -0
  142. package/packages/mcp-bibliography/run.sh +20 -0
  143. package/packages/mcp-bibliography/scholarly_formatters.py +83 -0
  144. package/packages/mcp-bibliography/server.py +1857 -0
  145. package/packages/mcp-bibliography/tools/__init__.py +28 -0
  146. package/packages/mcp-bibliography/tools/_registry.py +19 -0
  147. package/packages/mcp-bibliography/tools/altmetric.py +107 -0
  148. package/packages/mcp-bibliography/tools/core.py +92 -0
  149. package/packages/mcp-bibliography/tools/dblp.py +52 -0
  150. package/packages/mcp-bibliography/tools/openalex.py +296 -0
  151. package/packages/mcp-bibliography/tools/opencitations.py +102 -0
  152. package/packages/mcp-bibliography/tools/openreview.py +179 -0
  153. package/packages/mcp-bibliography/tools/orcid.py +131 -0
  154. package/packages/mcp-bibliography/tools/scholarly.py +575 -0
  155. package/packages/mcp-bibliography/tools/unpaywall.py +63 -0
  156. package/packages/mcp-bibliography/tools/zenodo.py +123 -0
  157. package/packages/mcp-bibliography/uv.lock +711 -0
  158. package/scripts/setup.sh +143 -0
  159. package/skills/beamer-deck/SKILL.md +199 -0
  160. package/skills/beamer-deck/references/quality-rubric.md +54 -0
  161. package/skills/beamer-deck/references/review-prompts.md +106 -0
  162. package/skills/bib-validate/SKILL.md +261 -0
  163. package/skills/bib-validate/references/council-mode.md +34 -0
  164. package/skills/bib-validate/references/deep-verify.md +79 -0
  165. package/skills/bib-validate/references/fix-mode.md +36 -0
  166. package/skills/bib-validate/references/openalex-verification.md +45 -0
  167. package/skills/bib-validate/references/preprint-check.md +31 -0
  168. package/skills/bib-validate/references/ref-manager-crossref.md +41 -0
  169. package/skills/bib-validate/references/report-template.md +82 -0
  170. package/skills/code-archaeology/SKILL.md +141 -0
  171. package/skills/code-review/SKILL.md +265 -0
  172. package/skills/code-review/references/quality-rubric.md +67 -0
  173. package/skills/consolidate-memory/SKILL.md +208 -0
  174. package/skills/context-status/SKILL.md +126 -0
  175. package/skills/creation-guard/SKILL.md +230 -0
  176. package/skills/devils-advocate/SKILL.md +130 -0
  177. package/skills/devils-advocate/references/competing-hypotheses.md +83 -0
  178. package/skills/init-project/SKILL.md +115 -0
  179. package/skills/init-project-course/references/memory-and-settings.md +92 -0
  180. package/skills/init-project-course/references/organise-templates.md +94 -0
  181. package/skills/init-project-course/skill.md +147 -0
  182. package/skills/init-project-light/skill.md +139 -0
  183. package/skills/init-project-research/SKILL.md +368 -0
  184. package/skills/init-project-research/references/atlas-pipeline-sync.md +70 -0
  185. package/skills/init-project-research/references/atlas-schema.md +81 -0
  186. package/skills/init-project-research/references/confirmation-report.md +39 -0
  187. package/skills/init-project-research/references/domain-profile-template.md +104 -0
  188. package/skills/init-project-research/references/interview-round3.md +34 -0
  189. package/skills/init-project-research/references/literature-discovery.md +43 -0
  190. package/skills/init-project-research/references/scaffold-details.md +197 -0
  191. package/skills/init-project-research/templates/field-calibration.md +60 -0
  192. package/skills/init-project-research/templates/pipeline-manifest.md +63 -0
  193. package/skills/init-project-research/templates/run-all.sh +116 -0
  194. package/skills/init-project-research/templates/seed-files.md +337 -0
  195. package/skills/insights-deck/SKILL.md +151 -0
  196. package/skills/interview-me/SKILL.md +157 -0
  197. package/skills/latex/SKILL.md +141 -0
  198. package/skills/latex/references/latex-configs.md +183 -0
  199. package/skills/latex-autofix/SKILL.md +230 -0
  200. package/skills/latex-autofix/references/known-errors.md +183 -0
  201. package/skills/latex-autofix/references/quality-rubric.md +50 -0
  202. package/skills/latex-health-check/SKILL.md +161 -0
  203. package/skills/learn/SKILL.md +220 -0
  204. package/skills/learn/scripts/validate_skill.py +265 -0
  205. package/skills/lessons-learned/SKILL.md +201 -0
  206. package/skills/literature/SKILL.md +335 -0
  207. package/skills/literature/references/agent-templates.md +393 -0
  208. package/skills/literature/references/bibliometric-apis.md +44 -0
  209. package/skills/literature/references/cli-council-search.md +79 -0
  210. package/skills/literature/references/openalex-api-guide.md +371 -0
  211. package/skills/literature/references/openalex-common-queries.md +381 -0
  212. package/skills/literature/references/openalex-workflows.md +248 -0
  213. package/skills/literature/references/reference-manager-sync.md +36 -0
  214. package/skills/literature/references/scopus-api-guide.md +208 -0
  215. package/skills/literature/references/wos-api-guide.md +308 -0
  216. package/skills/multi-perspective/SKILL.md +311 -0
  217. package/skills/multi-perspective/references/computational-many-analysts.md +77 -0
  218. package/skills/pipeline-manifest/SKILL.md +226 -0
  219. package/skills/pre-submission-report/SKILL.md +153 -0
  220. package/skills/process-reviews/SKILL.md +244 -0
  221. package/skills/process-reviews/references/rr-routing.md +101 -0
  222. package/skills/project-deck/SKILL.md +87 -0
  223. package/skills/project-safety/SKILL.md +135 -0
  224. package/skills/proofread/SKILL.md +254 -0
  225. package/skills/proofread/references/quality-rubric.md +104 -0
  226. package/skills/python-env/SKILL.md +57 -0
  227. package/skills/quarto-deck/SKILL.md +226 -0
  228. package/skills/quarto-deck/references/markdown-format.md +143 -0
  229. package/skills/quarto-deck/references/quality-rubric.md +54 -0
  230. package/skills/save-context/SKILL.md +174 -0
  231. package/skills/session-log/SKILL.md +98 -0
  232. package/skills/shared/concept-validation-gate.md +161 -0
  233. package/skills/shared/council-protocol.md +265 -0
  234. package/skills/shared/distribution-diagnostics.md +164 -0
  235. package/skills/shared/engagement-stratified-sampling.md +218 -0
  236. package/skills/shared/escalation-protocol.md +74 -0
  237. package/skills/shared/external-audit-protocol.md +205 -0
  238. package/skills/shared/intercoder-reliability.md +256 -0
  239. package/skills/shared/mcp-degradation.md +81 -0
  240. package/skills/shared/method-probing-questions.md +163 -0
  241. package/skills/shared/multi-language-conventions.md +143 -0
  242. package/skills/shared/paid-api-safety.md +174 -0
  243. package/skills/shared/palettes.md +90 -0
  244. package/skills/shared/progressive-disclosure.md +92 -0
  245. package/skills/shared/project-documentation-content.md +443 -0
  246. package/skills/shared/project-documentation-format.md +281 -0
  247. package/skills/shared/project-documentation.md +100 -0
  248. package/skills/shared/publication-output.md +138 -0
  249. package/skills/shared/quality-scoring.md +70 -0
  250. package/skills/shared/reference-resolution.md +77 -0
  251. package/skills/shared/research-quality-rubric.md +165 -0
  252. package/skills/shared/rhetoric-principles.md +54 -0
  253. package/skills/shared/skill-design-patterns.md +272 -0
  254. package/skills/shared/skill-index.md +240 -0
  255. package/skills/shared/system-documentation.md +334 -0
  256. package/skills/shared/tikz-rules.md +402 -0
  257. package/skills/shared/validation-tiers.md +121 -0
  258. package/skills/shared/venue-guides/README.md +46 -0
  259. package/skills/shared/venue-guides/cell_press_style.md +483 -0
  260. package/skills/shared/venue-guides/conferences_formatting.md +564 -0
  261. package/skills/shared/venue-guides/cs_conference_style.md +463 -0
  262. package/skills/shared/venue-guides/examples/cell_summary_example.md +247 -0
  263. package/skills/shared/venue-guides/examples/medical_structured_abstract.md +313 -0
  264. package/skills/shared/venue-guides/examples/nature_abstract_examples.md +213 -0
  265. package/skills/shared/venue-guides/examples/neurips_introduction_example.md +245 -0
  266. package/skills/shared/venue-guides/journals_formatting.md +486 -0
  267. package/skills/shared/venue-guides/medical_journal_styles.md +535 -0
  268. package/skills/shared/venue-guides/ml_conference_style.md +556 -0
  269. package/skills/shared/venue-guides/nature_science_style.md +405 -0
  270. package/skills/shared/venue-guides/reviewer_expectations.md +417 -0
  271. package/skills/shared/venue-guides/venue_writing_styles.md +321 -0
  272. package/skills/split-pdf/SKILL.md +172 -0
  273. package/skills/split-pdf/methodology.md +48 -0
  274. package/skills/sync-notion/SKILL.md +93 -0
  275. package/skills/system-audit/SKILL.md +157 -0
  276. package/skills/system-audit/references/sub-agent-prompts.md +294 -0
  277. package/skills/task-management/SKILL.md +131 -0
  278. package/skills/update-focus/SKILL.md +204 -0
  279. package/skills/update-project-doc/SKILL.md +194 -0
  280. package/skills/validate-bib/SKILL.md +242 -0
  281. package/skills/validate-bib/references/council-mode.md +34 -0
  282. package/skills/validate-bib/references/deep-verify.md +71 -0
  283. package/skills/validate-bib/references/openalex-verification.md +45 -0
  284. package/skills/validate-bib/references/preprint-check.md +31 -0
  285. package/skills/validate-bib/references/report-template.md +62 -0
@@ -0,0 +1,575 @@
1
+ """Cross-source scholarly tools (9+ tools, core always available)."""
2
+
3
+ import asyncio
4
+ import re
5
+ import time
6
+ import unicodedata
7
+
8
+ from _app import (
9
+ _crossref_source,
10
+ _multi_source,
11
+ _orcid_client,
12
+ _s2_source,
13
+ _source_info,
14
+ _scopus_key,
15
+ _scopus_source,
16
+ _wos_key,
17
+ _wos_source,
18
+ generate_bibtex_key,
19
+ log,
20
+ MultiSource,
21
+ format_papers_table,
22
+ format_verification_table,
23
+ format_source_status,
24
+ )
25
+ from mcp.types import Tool, TextContent
26
+ from tools._registry import register
27
+
28
+
29
+ # ---------- Search cascade helpers ----------
30
+
31
+ CASCADE_TIMEOUT = 60 # seconds total budget for all strategies
32
+
33
+ _STOPWORDS = frozenset({
34
+ "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for",
35
+ "of", "with", "by", "from", "is", "are", "was", "were", "be", "been",
36
+ "being", "have", "has", "had", "do", "does", "did", "will", "would",
37
+ "could", "should", "may", "might", "shall", "can", "not", "no", "nor",
38
+ "so", "if", "then", "than", "that", "this", "these", "those", "it",
39
+ "its", "as", "into", "through", "during", "before", "after", "above",
40
+ "below", "between", "under", "over", "about", "such", "each", "which",
41
+ "their", "we", "our", "how", "what", "when", "where", "who", "whom",
42
+ })
43
+
44
+
45
+ def _ascii_normalize(text: str) -> str:
46
+ """Strip diacritics and normalize to ASCII."""
47
+ nfkd = unicodedata.normalize("NFKD", text)
48
+ return nfkd.encode("ascii", "ignore").decode("ascii")
49
+
50
+
51
+ def _generate_variants(query: str) -> list[str]:
52
+ """Generate query variants for cascade search. Returns unique non-empty variants."""
53
+ variants = []
54
+ seen = {query.strip().lower()}
55
+
56
+ # Variant 1: ASCII-normalized (removes diacritics)
57
+ ascii_q = _ascii_normalize(query).strip()
58
+ if ascii_q.lower() not in seen and ascii_q:
59
+ variants.append(ascii_q)
60
+ seen.add(ascii_q.lower())
61
+
62
+ # Variant 2: dashes/hyphens to spaces
63
+ dashed = query.replace("-", " ").replace("–", " ").replace("—", " ")
64
+ dashed = re.sub(r"\s+", " ", dashed).strip()
65
+ if dashed.lower() not in seen and dashed:
66
+ variants.append(dashed)
67
+ seen.add(dashed.lower())
68
+
69
+ # Variant 3: remove quotes and special characters
70
+ cleaned = re.sub(r'["""\'`():\[\]{}]', " ", query)
71
+ cleaned = re.sub(r"\s+", " ", cleaned).strip()
72
+ if cleaned.lower() not in seen and cleaned:
73
+ variants.append(cleaned)
74
+ seen.add(cleaned.lower())
75
+
76
+ return variants
77
+
78
+
79
+ def _simplify_query(query: str) -> str | None:
80
+ """Extract significant keywords from query (drop stopwords, keep first 5)."""
81
+ words = re.findall(r"[a-zA-Z0-9]+", _ascii_normalize(query).lower())
82
+ keywords = [w for w in words if w not in _STOPWORDS and len(w) > 2]
83
+ if len(keywords) < 2:
84
+ return None
85
+ return " ".join(keywords[:5])
86
+
87
+
88
+ def _collect_dois(papers) -> set[str]:
89
+ """Extract DOI set from papers for deduplication."""
90
+ return {p.doi.lower() for p in papers if p.doi}
91
+
92
+
93
+ def _dedup_papers(all_papers: list, seen_dois: set) -> list:
94
+ """Remove papers whose DOI is already in seen_dois. Updates seen_dois in place."""
95
+ unique = []
96
+ for p in all_papers:
97
+ doi_key = p.doi.lower() if p.doi else None
98
+ if doi_key and doi_key in seen_dois:
99
+ continue
100
+ if doi_key:
101
+ seen_dois.add(doi_key)
102
+ unique.append(p)
103
+ return unique
104
+
105
+
106
+ # ---------- Handlers ----------
107
+
108
+
109
+ async def _handle_scholarly_search(args: dict) -> list[TextContent]:
110
+ query = args["query"]
111
+ limit = min(args.get("limit", 25), 50)
112
+ year_from = args.get("year_from")
113
+ year_to = args.get("year_to")
114
+ sort_by = args.get("sort_by", "relevance")
115
+
116
+ start_time = time.monotonic()
117
+ search_kwargs = dict(year_from=year_from, year_to=year_to, sort_by=sort_by, limit=limit)
118
+ strategies_used = []
119
+ all_diag_succeeded = set()
120
+ all_diag_failed = set()
121
+
122
+ if isinstance(_multi_source, MultiSource):
123
+ _multi_source.reset_diagnostics()
124
+
125
+ # --- Strategy 1: Original query ---
126
+ papers = await _multi_source.search_works(query, **search_kwargs)
127
+ strategies_used.append("original")
128
+ seen_dois = _collect_dois(papers)
129
+
130
+ if isinstance(_multi_source, MultiSource):
131
+ diag = _multi_source.consume_diagnostics()
132
+ if diag:
133
+ all_diag_succeeded.update(diag.get("succeeded", []))
134
+ all_diag_failed.update(diag.get("failed", []))
135
+
136
+ # --- Strategy 2: Query variants (ASCII, dashes, cleaned) ---
137
+ if len(papers) < 3:
138
+ variants = _generate_variants(query)
139
+ for variant in variants:
140
+ elapsed = time.monotonic() - start_time
141
+ if elapsed > CASCADE_TIMEOUT:
142
+ log(f"Cascade timeout after {elapsed:.1f}s, stopping variants")
143
+ break
144
+ if len(papers) >= limit:
145
+ break
146
+
147
+ log(f"Cascade: trying variant '{variant}'")
148
+ if isinstance(_multi_source, MultiSource):
149
+ _multi_source.reset_diagnostics()
150
+
151
+ try:
152
+ variant_papers = await _multi_source.search_works(variant, **search_kwargs)
153
+ new_papers = _dedup_papers(variant_papers, seen_dois)
154
+ if new_papers:
155
+ papers.extend(new_papers)
156
+ strategies_used.append("variant")
157
+ log(f"Cascade variant added {len(new_papers)} new papers")
158
+ except Exception as e:
159
+ log(f"Cascade variant failed: {e}")
160
+
161
+ if isinstance(_multi_source, MultiSource):
162
+ diag = _multi_source.consume_diagnostics()
163
+ if diag:
164
+ all_diag_succeeded.update(diag.get("succeeded", []))
165
+ all_diag_failed.update(diag.get("failed", []))
166
+
167
+ # --- Strategy 3: Simplified query (keywords only) ---
168
+ if len(papers) < 3:
169
+ elapsed = time.monotonic() - start_time
170
+ simplified = _simplify_query(query)
171
+ if simplified and elapsed < CASCADE_TIMEOUT and simplified.lower() != query.strip().lower():
172
+ log(f"Cascade: trying simplified '{simplified}'")
173
+ if isinstance(_multi_source, MultiSource):
174
+ _multi_source.reset_diagnostics()
175
+
176
+ try:
177
+ simp_papers = await _multi_source.search_works(simplified, **search_kwargs)
178
+ new_papers = _dedup_papers(simp_papers, seen_dois)
179
+ if new_papers:
180
+ papers.extend(new_papers)
181
+ strategies_used.append("simplified")
182
+ log(f"Cascade simplified added {len(new_papers)} new papers")
183
+ except Exception as e:
184
+ log(f"Cascade simplified failed: {e}")
185
+
186
+ if isinstance(_multi_source, MultiSource):
187
+ diag = _multi_source.consume_diagnostics()
188
+ if diag:
189
+ all_diag_succeeded.update(diag.get("succeeded", []))
190
+ all_diag_failed.update(diag.get("failed", []))
191
+
192
+ # --- Strategy 4: Individual source fallback (S2 directly) ---
193
+ if len(papers) < 3:
194
+ elapsed = time.monotonic() - start_time
195
+ if elapsed < CASCADE_TIMEOUT:
196
+ log("Cascade: trying S2 direct search")
197
+ try:
198
+ s2_results = await _s2_source.search_works(query, limit=limit)
199
+ new_papers = _dedup_papers(s2_results, seen_dois)
200
+ if new_papers:
201
+ papers.extend(new_papers)
202
+ strategies_used.append("s2_direct")
203
+ log(f"Cascade S2 direct added {len(new_papers)} new papers")
204
+ except Exception as e:
205
+ log(f"Cascade S2 direct failed: {e}")
206
+
207
+ # Trim to limit
208
+ papers = papers[:limit]
209
+ elapsed = time.monotonic() - start_time
210
+
211
+ # --- Format output ---
212
+ text = format_papers_table(papers, title=f"Scholarly Search: {query}")
213
+
214
+ # Source diagnostics
215
+ if isinstance(_multi_source, MultiSource):
216
+ source_parts = []
217
+ if all_diag_succeeded:
218
+ source_parts.append(f"Sources: {', '.join(sorted(all_diag_succeeded))}")
219
+ if all_diag_failed:
220
+ source_parts.append(f"Failed: {', '.join(sorted(all_diag_failed))}")
221
+ source_parts.append(f"{len(papers)} results after dedup")
222
+ text += f"\n\n*{' | '.join(source_parts)}*"
223
+ else:
224
+ text += f"\n\n*Source: OpenAlex | {len(papers)} results*"
225
+
226
+ # Cascade notes
227
+ if len(strategies_used) > 1:
228
+ text += f"\n*Cascade: {len(strategies_used)} strategies used ({', '.join(strategies_used)}) in {elapsed:.1f}s*"
229
+
230
+ return [TextContent(type="text", text=text)]
231
+
232
+
233
+ async def _handle_scholarly_verify_dois(args: dict) -> list[TextContent]:
234
+ dois = args["dois"]
235
+ if len(dois) > 50:
236
+ return [TextContent(type="text", text="**Error:** Maximum 50 DOIs per request.")]
237
+
238
+ results = await _multi_source.batch_verify_dois(dois)
239
+ text = format_verification_table(results)
240
+
241
+ active_names = [s["name"] for s in _source_info if s["active"]]
242
+ text += f"\n\n*Checked against: {', '.join(active_names)}*"
243
+
244
+ return [TextContent(type="text", text=text)]
245
+
246
+
247
+ async def _handle_scholarly_similar_works(args: dict) -> list[TextContent]:
248
+ text_query = args["text"]
249
+ limit = min(args.get("limit", 20), 50)
250
+
251
+ papers = await _multi_source.find_similar_works(text_query, limit=limit)
252
+ preview = text_query[:80] + "..." if len(text_query) > 80 else text_query
253
+ text = format_papers_table(papers, title=f"Similar to: {preview}")
254
+ text += f"\n\n*{len(papers)} results*"
255
+
256
+ return [TextContent(type="text", text=text)]
257
+
258
+
259
+ async def _handle_scholarly_source_status(args: dict) -> list[TextContent]:
260
+ text = format_source_status(_source_info)
261
+ active_count = sum(1 for s in _source_info if s["active"])
262
+ text += f"\n\n*{active_count}/{len(_source_info)} sources active*"
263
+ return [TextContent(type="text", text=text)]
264
+
265
+
266
+ async def _handle_scholarly_citations(args: dict) -> list[TextContent]:
267
+ paper_id = args["paper_id"]
268
+ limit = min(args.get("limit", 50), 1000)
269
+
270
+ papers = await _s2_source.get_paper_citations(paper_id, limit=limit)
271
+ text = format_papers_table(papers, title=f"Papers citing: {paper_id}")
272
+ text += f"\n\n*{len(papers)} citing papers (via Semantic Scholar Graph API)*"
273
+ return [TextContent(type="text", text=text)]
274
+
275
+
276
+ async def _handle_scholarly_references(args: dict) -> list[TextContent]:
277
+ paper_id = args["paper_id"]
278
+ limit = min(args.get("limit", 50), 1000)
279
+
280
+ papers = await _s2_source.get_paper_references(paper_id, limit=limit)
281
+ text = format_papers_table(papers, title=f"References of: {paper_id}")
282
+ text += f"\n\n*{len(papers)} references (via Semantic Scholar Graph API)*"
283
+ return [TextContent(type="text", text=text)]
284
+
285
+
286
+ async def _handle_scholarly_paper_detail(args: dict) -> list[TextContent]:
287
+ paper_id = args["paper_id"]
288
+
289
+ paper = await _s2_source.get_paper_detail(paper_id)
290
+ if not paper:
291
+ return [TextContent(type="text", text=f"Paper not found: {paper_id}")]
292
+
293
+ # Generate standardised BibTeX key
294
+ bib_key = generate_bibtex_key(paper.authors, paper.publication_year, paper.title)
295
+
296
+ lines = [f"## {paper.title}\n"]
297
+ lines.append(f"**Suggested BibTeX key:** `{bib_key}`")
298
+ lines.append(f"**Authors:** {', '.join(paper.authors)}")
299
+ lines.append(f"**Year:** {paper.publication_year}")
300
+ lines.append(f"**Citations:** {paper.cited_by_count:,}")
301
+ if paper.source_name:
302
+ lines.append(f"**Venue:** {paper.source_name}")
303
+ if paper.doi:
304
+ lines.append(f"**DOI:** {paper.doi}")
305
+ if paper.open_access_url:
306
+ lines.append(f"**Open Access PDF:** {paper.open_access_url}")
307
+ if paper.keywords:
308
+ lines.append(f"**Fields:** {', '.join(paper.keywords)}")
309
+
310
+ # ORCID enrichment: look up author ORCIDs when client available
311
+ if _orcid_client and paper.authors:
312
+ orcid_lines = []
313
+ for author_name in paper.authors[:10]: # cap at 10 to avoid rate-limit issues
314
+ try:
315
+ results = await asyncio.get_event_loop().run_in_executor(
316
+ None, lambda name=author_name: _orcid_client.search(name=name, limit=1)
317
+ )
318
+ if results:
319
+ orcid_lines.append(f" - {author_name}: `{results[0].orcid_id}`")
320
+ except Exception:
321
+ pass # best-effort — skip on any error
322
+ if orcid_lines:
323
+ lines.append("\n**Author ORCIDs:**")
324
+ lines.extend(orcid_lines)
325
+
326
+ if paper.tldr:
327
+ lines.append(f"\n**TLDR:** {paper.tldr}")
328
+ if paper.abstract:
329
+ lines.append(f"\n**Abstract:** {paper.abstract}")
330
+ if paper.bibtex:
331
+ lines.append(f"\n**BibTeX:**\n```bibtex\n{paper.bibtex}\n```")
332
+
333
+ lines.append(f"\n*Source: Semantic Scholar ({paper.source_id})*")
334
+ return [TextContent(type="text", text="\n".join(lines))]
335
+
336
+
337
+ async def _handle_scholarly_author_papers(args: dict) -> list[TextContent]:
338
+ author_name = args["author_name"]
339
+ limit = min(args.get("limit", 50), 100)
340
+
341
+ authors = await _s2_source.search_author(author_name, limit=5)
342
+ if not authors:
343
+ return [TextContent(type="text", text=f"Author not found: {author_name}")]
344
+
345
+ author = authors[0]
346
+ author_id = author.get("authorId", "")
347
+ display_name = author.get("name", author_name)
348
+
349
+ papers = await _s2_source.get_author_papers(author_id, limit=limit)
350
+ text = format_papers_table(papers, title=f"Papers by {display_name}")
351
+
352
+ if len(authors) > 1:
353
+ text += "\n\n**Other matching authors:**\n"
354
+ for a in authors[1:5]:
355
+ text += f"- {a.get('name', '?')} (S2 ID: {a.get('authorId', '?')})\n"
356
+
357
+ text += f"\n*{len(papers)} papers (via Semantic Scholar Graph API)*"
358
+ return [TextContent(type="text", text=text)]
359
+
360
+
361
+ async def _handle_scholarly_search_scopus(args: dict) -> list[TextContent]:
362
+ query = args["query"]
363
+ limit = min(args.get("limit", 25), 50)
364
+ year_from = args.get("year_from")
365
+ year_to = args.get("year_to")
366
+
367
+ papers = await _scopus_source.search_works(
368
+ query, year_from=year_from, year_to=year_to, limit=limit,
369
+ )
370
+ text = format_papers_table(papers, title=f"Scopus: {query}")
371
+ text += f"\n\n*{len(papers)} results from Scopus*"
372
+
373
+ return [TextContent(type="text", text=text)]
374
+
375
+
376
+ async def _handle_scholarly_search_wos(args: dict) -> list[TextContent]:
377
+ query = args["query"]
378
+ limit = min(args.get("limit", 25), 50)
379
+ year_from = args.get("year_from")
380
+ year_to = args.get("year_to")
381
+
382
+ papers = await _wos_source.search_works(
383
+ query, year_from=year_from, year_to=year_to, limit=limit,
384
+ )
385
+ text = format_papers_table(papers, title=f"Web of Science: {query}")
386
+ text += f"\n\n*{len(papers)} results from WoS*"
387
+
388
+ return [TextContent(type="text", text=text)]
389
+
390
+
391
+ # ---------- Tool definitions + registration ----------
392
+
393
+ _TOOLS = [
394
+ (
395
+ Tool(
396
+ name="scholarly_search",
397
+ description=(
398
+ "Search for scholarly papers across ALL enabled sources (OpenAlex, Scopus, WoS) "
399
+ "with automatic DOI-based deduplication. Returns merged results with the best "
400
+ "metadata from each source."
401
+ ),
402
+ inputSchema={
403
+ "type": "object",
404
+ "properties": {
405
+ "query": {"type": "string", "description": "Search query (topic, keywords, title fragment)"},
406
+ "year_from": {"type": "integer", "description": "Start year filter (inclusive)"},
407
+ "year_to": {"type": "integer", "description": "End year filter (inclusive)"},
408
+ "sort_by": {"type": "string", "description": "Sort: 'relevance' (default), 'cited_by_count', 'publication_year'"},
409
+ "limit": {"type": "integer", "description": "Max results (default 25, max 50)"},
410
+ },
411
+ "required": ["query"],
412
+ },
413
+ ),
414
+ _handle_scholarly_search,
415
+ ),
416
+ (
417
+ Tool(
418
+ name="scholarly_verify_dois",
419
+ description=(
420
+ "Batch-verify DOIs across all enabled sources. For each DOI, checks if it exists "
421
+ "in Crossref (authoritative), OpenAlex, Semantic Scholar, Scopus, and/or WoS. "
422
+ "Returns verification status: VERIFIED (2+ sources), SINGLE_SOURCE (1 source), "
423
+ "or NOT_FOUND. The killer tool for /literature Phase 4."
424
+ ),
425
+ inputSchema={
426
+ "type": "object",
427
+ "properties": {
428
+ "dois": {
429
+ "type": "array",
430
+ "items": {"type": "string"},
431
+ "description": "List of DOIs to verify (up to 50). With or without https://doi.org/ prefix.",
432
+ },
433
+ },
434
+ "required": ["dois"],
435
+ },
436
+ ),
437
+ _handle_scholarly_verify_dois,
438
+ ),
439
+ (
440
+ Tool(
441
+ name="scholarly_similar_works",
442
+ description="Find papers similar to a given text (title or abstract) across all enabled sources. Results are deduplicated by DOI.",
443
+ inputSchema={
444
+ "type": "object",
445
+ "properties": {
446
+ "text": {"type": "string", "description": "Text to find similar papers for (title, abstract, or topic description)"},
447
+ "limit": {"type": "integer", "description": "Max results (default 20, max 50)"},
448
+ },
449
+ "required": ["text"],
450
+ },
451
+ ),
452
+ _handle_scholarly_similar_works,
453
+ ),
454
+ (
455
+ Tool(
456
+ name="scholarly_source_status",
457
+ description="Show which scholarly data sources are configured and active. Reports OpenAlex (always), Scopus (if SCOPUS_API_KEY set), WoS (if WOS_API_KEY set).",
458
+ inputSchema={"type": "object", "properties": {}},
459
+ ),
460
+ _handle_scholarly_source_status,
461
+ ),
462
+ (
463
+ Tool(
464
+ name="scholarly_citations",
465
+ description=(
466
+ "Get papers that CITE a given paper (forward citation tracking). "
467
+ "Powered by Semantic Scholar Graph API. Accepts DOI, arXiv ID, or S2 paper ID. "
468
+ "Use for snowball searches, impact analysis, and finding follow-up work."
469
+ ),
470
+ inputSchema={
471
+ "type": "object",
472
+ "properties": {
473
+ "paper_id": {"type": "string", "description": "Paper identifier: DOI (with DOI: prefix, e.g. 'DOI:10.1234/example'), arXiv ID (ARXIV:2106.15928), or S2 paper ID"},
474
+ "limit": {"type": "integer", "description": "Max results (default 50, max 1000)"},
475
+ },
476
+ "required": ["paper_id"],
477
+ },
478
+ ),
479
+ _handle_scholarly_citations,
480
+ ),
481
+ (
482
+ Tool(
483
+ name="scholarly_references",
484
+ description=(
485
+ "Get papers REFERENCED BY a given paper (backward citation / bibliography). "
486
+ "Powered by Semantic Scholar Graph API. Accepts DOI, arXiv ID, or S2 paper ID. "
487
+ "Use for snowball searches, finding foundational works, and tracing intellectual lineage."
488
+ ),
489
+ inputSchema={
490
+ "type": "object",
491
+ "properties": {
492
+ "paper_id": {"type": "string", "description": "Paper identifier: DOI (with DOI: prefix, e.g. 'DOI:10.1234/example'), arXiv ID (ARXIV:2106.15928), or S2 paper ID"},
493
+ "limit": {"type": "integer", "description": "Max results (default 50, max 1000)"},
494
+ },
495
+ "required": ["paper_id"],
496
+ },
497
+ ),
498
+ _handle_scholarly_references,
499
+ ),
500
+ (
501
+ Tool(
502
+ name="scholarly_paper_detail",
503
+ description=(
504
+ "Get full metadata for a single paper including TLDR (AI summary), "
505
+ "BibTeX citation, open access PDF link, abstract, and citation count. "
506
+ "Powered by Semantic Scholar. Accepts DOI, arXiv ID, or S2 paper ID."
507
+ ),
508
+ inputSchema={
509
+ "type": "object",
510
+ "properties": {
511
+ "paper_id": {"type": "string", "description": "Paper identifier: DOI (with DOI: prefix), arXiv ID (ARXIV:xxx), or S2 paper ID"},
512
+ },
513
+ "required": ["paper_id"],
514
+ },
515
+ ),
516
+ _handle_scholarly_paper_detail,
517
+ ),
518
+ (
519
+ Tool(
520
+ name="scholarly_author_papers",
521
+ description="Find all papers by an author. First searches for the author by name, then retrieves their publications. Powered by Semantic Scholar Graph API.",
522
+ inputSchema={
523
+ "type": "object",
524
+ "properties": {
525
+ "author_name": {"type": "string", "description": "Author name to search for"},
526
+ "limit": {"type": "integer", "description": "Max papers to return (default 50, max 100)"},
527
+ },
528
+ "required": ["author_name"],
529
+ },
530
+ ),
531
+ _handle_scholarly_author_papers,
532
+ ),
533
+ ]
534
+
535
+ for tool, handler in _TOOLS:
536
+ register(tool, handler)
537
+
538
+ # Conditional source-specific tools
539
+ if _scopus_key:
540
+ register(
541
+ Tool(
542
+ name="scholarly_search_scopus",
543
+ description="Search Scopus directly using Scopus query syntax (TITLE-ABS-KEY). Useful for ASJC subject codes and Scopus-specific features.",
544
+ inputSchema={
545
+ "type": "object",
546
+ "properties": {
547
+ "query": {"type": "string", "description": "Search query for Scopus (TITLE-ABS-KEY syntax)"},
548
+ "year_from": {"type": "integer", "description": "Start year"},
549
+ "year_to": {"type": "integer", "description": "End year"},
550
+ "limit": {"type": "integer", "description": "Max results (default 25)"},
551
+ },
552
+ "required": ["query"],
553
+ },
554
+ ),
555
+ _handle_scholarly_search_scopus,
556
+ )
557
+
558
+ if _wos_key:
559
+ register(
560
+ Tool(
561
+ name="scholarly_search_wos",
562
+ description="Search Web of Science directly using WoS query syntax (TS=). Useful for WoS-specific features and citation tracking.",
563
+ inputSchema={
564
+ "type": "object",
565
+ "properties": {
566
+ "query": {"type": "string", "description": "Search query for WoS (TS= syntax)"},
567
+ "year_from": {"type": "integer", "description": "Start year"},
568
+ "year_to": {"type": "integer", "description": "End year"},
569
+ "limit": {"type": "integer", "description": "Max results (default 25)"},
570
+ },
571
+ "required": ["query"],
572
+ },
573
+ ),
574
+ _handle_scholarly_search_wos,
575
+ )
@@ -0,0 +1,63 @@
1
+ """Unpaywall tools (1 tool, always available)."""
2
+
3
+ from mcp.types import Tool, TextContent
4
+
5
+ from _app import _unpaywall_client
6
+ from tools._registry import register
7
+
8
+
9
+ # ---------- Handler ----------
10
+
11
+
12
+ async def _handle_unpaywall(args: dict) -> list[TextContent]:
13
+ doi = args["doi"]
14
+
15
+ result = await _unpaywall_client.lookup(doi)
16
+
17
+ if not result:
18
+ return [TextContent(type="text", text=f"DOI not found in Unpaywall: {doi}")]
19
+
20
+ lines = [f"## Unpaywall: {doi}\n"]
21
+
22
+ if result.title:
23
+ lines.append(f"**Title:** {result.title}")
24
+ if result.journal:
25
+ lines.append(f"**Journal:** {result.journal}")
26
+ if result.publisher:
27
+ lines.append(f"**Publisher:** {result.publisher}")
28
+
29
+ oa_emoji = "Yes" if result.is_oa else "No"
30
+ lines.append(f"**Open Access:** {oa_emoji}")
31
+ if result.oa_status:
32
+ lines.append(f"**OA Status:** {result.oa_status}")
33
+
34
+ if result.pdf_url:
35
+ lines.append(f"\n**PDF:** [{result.pdf_url}]({result.pdf_url})")
36
+ elif result.best_oa_url:
37
+ lines.append(f"\n**Best OA Link:** [{result.best_oa_url}]({result.best_oa_url})")
38
+ else:
39
+ lines.append("\n*No open access version found.*")
40
+
41
+ return [TextContent(type="text", text="\n".join(lines))]
42
+
43
+
44
+ # ---------- Registration ----------
45
+
46
+ register(
47
+ Tool(
48
+ name="unpaywall_find_pdf",
49
+ description=(
50
+ "Find an open access PDF for a DOI via Unpaywall. Returns the best available "
51
+ "OA link, PDF URL, OA status (gold/green/hybrid/bronze/closed), journal, and publisher. "
52
+ "Use after finding a paper to check if a free PDF is available."
53
+ ),
54
+ inputSchema={
55
+ "type": "object",
56
+ "properties": {
57
+ "doi": {"type": "string", "description": "DOI to find OA PDF for (with or without prefix)"},
58
+ },
59
+ "required": ["doi"],
60
+ },
61
+ ),
62
+ _handle_unpaywall,
63
+ )