-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathscholar_worker.py
More file actions
396 lines (368 loc) · 18 KB
/
Copy pathscholar_worker.py
File metadata and controls
396 lines (368 loc) · 18 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
# /// script
# requires-python = ">=3.13"
# dependencies = [
# # 0.16.x emits the current table-listing wire schema the rebuilt signed vgi
# # community extension now expects; keep at >=0.16.0 (older releases use the
# # required_field_filter_paths / 31-field schema the current extension rejects).
# "vgi-python[http]>=0.16.0",
# "httpx>=0.27",
# ]
# ///
"""VGI worker exposing scholarly-literature search to DuckDB/SQL.
Assembles the table functions in ``vgi_scholar`` into a single ``scholar``
catalog and runs the worker over stdio (a DuckDB subprocess) or HTTP (serve.py).
Search runs against FREE, ToS-clean scholarly APIs behind one pluggable
provider surface: OpenAlex (default), arXiv, and Crossref. No API key is
required for any v1 provider. Results are normalized into a single unified
schema so a query never has to know which provider produced a row.
Usage:
uv run scholar_worker.py # serve over stdio (DuckDB subprocess)
python serve.py --port 8000 # serve over HTTP
INSTALL vgi FROM community; LOAD vgi;
ATTACH 'scholar' (TYPE vgi, LOCATION 'uv run scholar_worker.py');
SELECT title, authors, year, doi
FROM scholar.scholar_search('retrieval augmented generation', count := 5);
SELECT * FROM scholar.scholar_providers();
Be a good API citizen: set VGI_SCHOLAR_MAILTO to your contact e-mail so the
worker joins OpenAlex's and Crossref's faster "polite pool".
"""
from __future__ import annotations
import json
from vgi import Worker
from vgi.catalog import Catalog, Schema, Table
from vgi_scholar.tables import TABLE_FUNCTIONS, ScholarProvidersFunction
_CATALOG_DESCRIPTION_LLM = (
"Search scholarly / academic literature from SQL across free, ToS-clean providers "
"(OpenAlex by default, plus arXiv and Crossref) behind one pluggable surface, with "
"results normalized into a single unified schema (title, authors, abstract, doi, year, "
"published, venue, citations_count, url, source, extra). Use it to find papers by topic, "
"pull DOIs and citation counts, build literature reviews, or feed retrieval-augmented "
"generation (RAG) pipelines. No API key is required for any provider. "
"`scholar_search(query, provider := ..., count := ...)` returns matching works; "
"`scholar_providers()` lists the available providers."
)
_CATALOG_DESCRIPTION_MD = (
"# Scholarly Literature Search in SQL\n\n"
"**Search academic papers, preprints, and citations directly from DuckDB/SQL** "
"across [OpenAlex](https://docs.openalex.org), [arXiv](https://arxiv.org), and "
"[Crossref](https://www.crossref.org) — three free, ToS-clean scholarly APIs unified "
"behind one pluggable query surface, with no API key required.\n\n"
"The `scholar` catalog turns the world's open scholarly-metadata graph into ordinary "
"SQL tables. It is built for researchers, data engineers, librarians, and anyone wiring "
"up retrieval-augmented generation (RAG) pipelines who would rather write a query than "
"glue together three different REST clients. Every search returns the **same unified "
"result schema** — `title`, `authors`, `abstract`, `doi`, `year`, `published`, `venue`, "
"`citations_count`, `url`, `source`, and a JSON `extra` column — so a query never has to "
"know or care which provider produced a given row. Switch providers with a single named "
"argument and the rest of your SQL stays identical.\n\n"
"Under the hood the worker talks plain HTTP to each provider's public API: the "
"[OpenAlex API](https://docs.openalex.org) (the open catalog of ~250M scholarly works, "
"used by default), the [arXiv API](https://info.arxiv.org/help/api/index.html) for "
"physics, math, and CS preprints, and the [Crossref REST API]"
"(https://www.crossref.org/documentation/retrieve-metadata/rest-api/) for DOI-registered "
"publications. Results are fetched page by page and streamed back to DuckDB as Arrow, so "
"large result sets never have to be buffered in full. Pagination cursors round-trip "
"through the worker, retries back off on rate limits, and a provider error surfaces as a "
"clean SQL error rather than crashing the attach.\n\n"
"## Table functions\n\n"
"- `scholar_search(query, provider := 'openalex', count := 10, page_size := 0)` — full-text "
"search scholarly works and stream up to `count` rows in the unified schema. Pass "
"`provider := 'arxiv'` or `provider := 'crossref'` to target a different source.\n"
"- `scholar_providers()` — list every provider `scholar_search` can target (also queryable "
"as the table `scholar.main.scholar_providers`), with its keyless and default flags.\n\n"
"## Getting started\n\n"
"Install and load the signed `vgi` community extension, `ATTACH` this worker at a "
"`LOCATION` such as `uv run scholar_worker.py`, and then call the two table functions "
"directly. Ready-to-run, copy-paste queries live in this schema's example queries and in "
"each function's own examples, so they are executed and coverage-checked rather than "
"duplicated inline here.\n\n"
"Typical uses: find papers by topic, resolve and enrich DOIs, gather citation counts, "
"assemble a literature-review corpus, or feed a RAG index — all without leaving SQL. "
"**No API key is required** for any provider; set `VGI_SCHOLAR_MAILTO` to your contact "
"e-mail to join OpenAlex's and Crossref's faster \"polite pool\".\n\n"
"Powered by [OpenAlex](https://docs.openalex.org) "
"([source](https://github.com/ourresearch/openalex-guts)), "
"[arXiv](https://arxiv.org) "
"([API docs](https://info.arxiv.org/help/api/index.html)), and "
"[Crossref](https://www.crossref.org) "
"([REST API docs](https://github.com/CrossRef/rest-api-doc))."
)
_SCHEMA_DESCRIPTION_LLM = (
"## scholar.main\n\n"
"Scholarly-literature search functions returning a single **unified result schema**. "
"`scholar_search(query, provider := ..., count := ...)` searches OpenAlex / arXiv / Crossref "
"for works matching a free-text query and streams up to `count` rows (title, authors, abstract, "
"doi, year, published, venue, citations_count, url, source, extra). `scholar_providers()` lists "
"the providers available to `scholar_search`, one per row. Use this schema to find papers by "
"topic, resolve DOIs, gather citation counts, or build a corpus for retrieval-augmented "
"generation. No API key is required; set `VGI_SCHOLAR_MAILTO` for the polite pool."
)
_SCHEMA_DESCRIPTION_MD = (
"# scholar.main\n\n"
"Scholarly-literature search over **OpenAlex**, **arXiv**, and **Crossref**, normalized into a "
"single unified schema.\n\n"
"## Functions\n\n"
"- `scholar_search(query, provider := 'openalex', count := 10)` — search works and stream "
"unified-schema rows.\n"
"- `scholar_providers()` — list the providers `scholar_search` can target.\n\n"
"## Notes\n\n"
"No API key is required for any provider. Set `VGI_SCHOLAR_MAILTO` to join the OpenAlex / "
"Crossref polite pool. `scholar_search` requires outbound network access; `scholar_providers` "
"is pure metadata and always runs offline."
)
_CATALOG_TAGS = {
"vgi.title": "Scholarly Literature Search",
"vgi.keywords": json.dumps(
[
"scholarly search",
"academic literature",
"papers",
"publications",
"openalex",
"arxiv",
"crossref",
"doi",
"citations",
"preprints",
"literature review",
"rag",
"retrieval",
"research",
]
),
"vgi.doc_llm": _CATALOG_DESCRIPTION_LLM,
"vgi.doc_md": _CATALOG_DESCRIPTION_MD,
"vgi.author": "Query.Farm",
"vgi.copyright": "Copyright 2026 Query Farm LLC - https://query.farm",
"vgi.license": "MIT",
"vgi.support_contact": "https://github.com/Query-farm/vgi-scholar/issues",
"vgi.support_policy_url": "https://github.com/Query-farm/vgi-scholar/blob/main/README.md",
# Fixed analyst-task suite for `vgi-lint simulate` (VGI152/VGI520/VGI920). The
# provider-discovery tasks grade against `scholar_providers()`, which is pure,
# offline metadata (no network), so their references are deterministic. The
# `scholar_search` task instead grades a stable yes/no THRESHOLD predicate
# (does a common query return at least N works?) that returns a single BOOLEAN
# — robust against live-provider result variation, which an exact-compare
# reference on `scholar_search` rows could never be.
"vgi.agent_test_tasks": json.dumps(
[
{
"name": "list_providers",
"prompt": (
"Which scholarly-search providers can this worker query? Return a single column "
"listing only each provider's name, one provider per row."
),
"reference_sql": "SELECT provider FROM scholar.main.scholar_providers()",
"unordered": True,
"ignore_column_names": True,
},
{
"name": "default_provider",
"prompt": (
"When a scholarly search does not specify a provider, which provider is "
"used by default? Return just that provider's name."
),
"reference_sql": 'SELECT provider FROM scholar.main.scholar_providers() WHERE "default"',
"ignore_column_names": True,
},
{
"name": "keyless_provider_count",
"prompt": ("How many of the available scholarly-search providers can be used without an API key?"),
"reference_sql": (
"SELECT count(*) AS keyless FROM scholar.main.scholar_providers() WHERE NOT requires_key"
),
"ignore_column_names": True,
},
{
"name": "search_finds_works",
"prompt": (
'Search the scholarly literature for the topic "machine learning" and determine '
"whether at least three matching works are found. Answer with a single true/false value."
),
"reference_sql": (
"SELECT count(*) >= 3 AS found FROM scholar.main.scholar_search('machine learning', count := 5)"
),
"ignore_column_names": True,
},
]
),
}
# VGI515/VGI514: a JSON list of described {description, sql} examples (not a bare
# SQL string, and no trivial `SELECT *`) so each schema example carries prose and
# demonstrates a real projection/filter.
_SCHEMA_EXAMPLE_QUERIES = json.dumps(
[
{
"description": "List each provider with its keyless and default flags, alphabetically.",
"sql": ('SELECT provider, requires_key, "default" FROM scholar.main.scholar_providers ORDER BY provider'),
},
{
"description": "Find the default provider used when scholar_search omits provider.",
"sql": 'SELECT provider FROM scholar.main.scholar_providers WHERE "default"',
},
{
"description": "Fetch the top 5 OpenAlex (default provider) results for a topic.",
"sql": (
"SELECT title, authors, year FROM "
"scholar.main.scholar_search('retrieval augmented generation', count := 5)"
),
},
{
"description": "Search Crossref instead of the default provider and pull DOIs.",
"sql": (
"SELECT title, doi FROM "
"scholar.main.scholar_search('graph neural networks', provider := 'crossref', count := 10)"
),
},
]
)
_SCHEMA_TAGS = {
"vgi.title": "Scholar — main",
"vgi.keywords": json.dumps(
[
"scholar_search",
"scholar_providers",
"scholarly search",
"academic literature",
"openalex",
"arxiv",
"crossref",
"doi",
"citations",
"literature review",
"rag",
]
),
# VGI123 classifying tags use BARE keys (NOT vgi.-namespaced).
"domain": "research",
"category": "search",
"topic": "scholarly-literature",
"vgi.doc_llm": _SCHEMA_DESCRIPTION_LLM,
"vgi.doc_md": _SCHEMA_DESCRIPTION_MD,
"vgi.example_queries": _SCHEMA_EXAMPLE_QUERIES,
# Navigation registry (VGI408-413): an ordered list of categories that the
# schema's objects are grouped under. Each object carries a matching
# `vgi.category` naming one of these.
"vgi.categories": json.dumps(
[
{
"name": "Search",
"description": (
"Full-text scholarly search that streams matching works (title, authors, "
"DOI, year, citations, …) into one unified result schema."
),
},
{
"name": "Providers",
"description": (
"Discover the scholarly-search providers the worker can target, which is "
"the default, and whether each is keyless."
),
},
]
),
}
_PROVIDERS_TABLE_TAGS = {
"vgi.title": "Scholarly Providers",
"vgi.keywords": json.dumps(
[
"providers",
"list providers",
"scholarly providers",
"openalex",
"arxiv",
"crossref",
"default provider",
"capability",
"discovery",
"requires key",
]
),
"domain": "research",
"category": "search",
"topic": "scholarly-literature",
"vgi.category": "Providers",
"vgi.doc_llm": (
"## scholar_providers (table)\n\n"
"The fixed set of scholarly-search providers that `scholar_search` can target, one row per "
"provider. This is the table form of the `scholar_providers()` function: because it takes no "
"arguments and always returns the same rows, you can read it as a plain table — "
"`scholar.main.scholar_providers`, no parentheses — rather than calling it as a function. "
"Use it to discover the valid values for "
"`scholar_search`'s `provider :=` argument, to find the default provider, or to confirm a "
"provider is keyless before running a search.\n\n"
"Columns: `provider` (the name to pass as `provider := '...'`; unique per row and the table's "
"primary key), `requires_key` (true if the provider needs an API key — all current providers "
"are keyless, so false), and `default` (true for the single provider used when `scholar_search` "
"omits `provider`). Reading this table makes no network calls."
),
"vgi.doc_md": (
"# scholar_providers (table)\n\n"
"Every scholarly-search provider available to `scholar_search`, one row each. This is the "
"table form of the parameterless `scholar_providers()` function.\n\n"
"Read it with no parentheses, e.g. `scholar.main.scholar_providers`, to enumerate the "
'valid `provider :=` values, filter on the `"default"` flag to see which provider is '
"used when a search omits `provider`, or count the `requires_key` column to confirm a "
"provider is keyless. Runnable examples are attached as this table's example queries.\n\n"
"## Columns\n\n"
"- `provider` (`VARCHAR`, primary key) — name to pass as `provider := '...'`.\n"
"- `requires_key` (`BOOLEAN`) — whether the provider needs an API key (all v1 providers are keyless).\n"
"- `default` (`BOOLEAN`) — whether this is the default provider; exactly one row is true.\n\n"
"## Notes\n\n"
"Reading this table needs no network access, so it is a reliable capability/health probe."
),
"vgi.example_queries": json.dumps(
[
{
"description": "List each provider with its keyless and default flags, alphabetically.",
"sql": (
'SELECT provider, requires_key, "default" FROM scholar.main.scholar_providers ORDER BY provider'
),
},
{
"description": "Find the default provider used when scholar_search omits provider.",
"sql": 'SELECT provider FROM scholar.main.scholar_providers WHERE "default"',
},
{
"description": "Confirm all providers are keyless (no API key required).",
"sql": "SELECT count(*) AS keyless FROM scholar.main.scholar_providers WHERE NOT requires_key",
},
]
),
}
_PROVIDERS_TABLE = Table(
name="scholar_providers",
function=ScholarProvidersFunction,
comment="One row per scholarly-search provider scholar_search can target (provider name, keyless flag, default flag)",
not_null=("provider", "requires_key", "default"),
primary_key=(("provider",),),
tags=_PROVIDERS_TABLE_TAGS,
)
_SCHOLAR_CATALOG = Catalog(
name="scholar",
default_schema="main",
comment="Scholarly-literature search across OpenAlex, arXiv, and Crossref, unified for SQL and RAG",
tags=_CATALOG_TAGS,
source_url="https://github.com/Query-farm/vgi-scholar",
schemas=[
Schema(
name="main",
comment="Scholarly-search table functions: scholar_search (query works) and scholar_providers (list providers)",
tags=_SCHEMA_TAGS,
functions=list(TABLE_FUNCTIONS),
# scholar_providers takes no arguments and always returns the same
# provider rows, so also expose it as a regular table — callers can
# then run `SELECT * FROM scholar.main.scholar_providers` (no
# parentheses) in addition to the table-function call form.
tables=[_PROVIDERS_TABLE],
),
],
)
class ScholarWorker(Worker):
"""Worker process hosting the ``scholar`` catalog."""
catalog = _SCHOLAR_CATALOG
def main() -> None:
"""Run the scholar worker process (stdio or, via flags, HTTP)."""
ScholarWorker.main()
if __name__ == "__main__":
main()