@cyanheads/openalex-mcp-server 0.7.16 → 0.9.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.
- package/AGENTS.md +48 -33
- package/CLAUDE.md +48 -33
- package/Dockerfile +98 -33
- package/README.md +23 -16
- package/changelog/0.8.x/0.8.0.md +38 -0
- package/changelog/0.9.x/0.9.0.md +48 -0
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/prompts/definitions/literature-review.prompt.js +1 -1
- package/dist/mcp-server/prompts/definitions/research-landscape.prompt.js +1 -1
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts +28 -3
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.js +57 -10
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts +62 -5
- package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/citation-graph.tool.js +100 -52
- package/dist/mcp-server/tools/definitions/citation-graph.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/describe-fields.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/describe-fields.tool.js +34 -4
- package/dist/mcp-server/tools/definitions/describe-fields.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts +38 -2
- package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.js +176 -41
- package/dist/mcp-server/tools/definitions/resolve-name.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts +75 -4
- package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-entities.tool.js +169 -73
- package/dist/mcp-server/tools/definitions/search-entities.tool.js.map +1 -1
- package/dist/mcp-server/tools/render-budget.d.ts +4 -2
- package/dist/mcp-server/tools/render-budget.d.ts.map +1 -1
- package/dist/mcp-server/tools/render-budget.js +4 -2
- package/dist/mcp-server/tools/render-budget.js.map +1 -1
- package/dist/mcp-server/tools/render-entity-record.d.ts +12 -1
- package/dist/mcp-server/tools/render-entity-record.d.ts.map +1 -1
- package/dist/mcp-server/tools/render-entity-record.js +23 -8
- package/dist/mcp-server/tools/render-entity-record.js.map +1 -1
- package/dist/mcp-server/tools/response-budget.d.ts +160 -0
- package/dist/mcp-server/tools/response-budget.d.ts.map +1 -0
- package/dist/mcp-server/tools/response-budget.js +489 -0
- package/dist/mcp-server/tools/response-budget.js.map +1 -0
- package/dist/services/openalex/budget.d.ts +3 -1
- package/dist/services/openalex/budget.d.ts.map +1 -1
- package/dist/services/openalex/budget.js +3 -1
- package/dist/services/openalex/budget.js.map +1 -1
- package/dist/services/openalex/field-catalog.json +50 -1
- package/dist/services/openalex/filter-values.d.ts +46 -0
- package/dist/services/openalex/filter-values.d.ts.map +1 -0
- package/dist/services/openalex/filter-values.js +294 -0
- package/dist/services/openalex/filter-values.js.map +1 -0
- package/dist/services/openalex/iso-codes.json +1565 -0
- package/dist/services/openalex/openalex-service.d.ts +181 -16
- package/dist/services/openalex/openalex-service.d.ts.map +1 -1
- package/dist/services/openalex/openalex-service.js +593 -141
- package/dist/services/openalex/openalex-service.js.map +1 -1
- package/dist/services/openalex/provider-text.d.ts +13 -5
- package/dist/services/openalex/provider-text.d.ts.map +1 -1
- package/dist/services/openalex/provider-text.js +799 -51
- package/dist/services/openalex/provider-text.js.map +1 -1
- package/dist/services/openalex/types.d.ts +1 -1
- package/dist/services/openalex/types.js +1 -1
- package/package.json +16 -13
- package/server.json +10 -24
|
@@ -30,6 +30,10 @@ export declare const searchEntitiesTool: import("@cyanheads/mcp-ts-core").ToolDe
|
|
|
30
30
|
page: z.ZodOptional<z.ZodNumber>;
|
|
31
31
|
sample: z.ZodOptional<z.ZodNumber>;
|
|
32
32
|
seed: z.ZodOptional<z.ZodString>;
|
|
33
|
+
slice: z.ZodOptional<z.ZodObject<{
|
|
34
|
+
field: z.ZodString;
|
|
35
|
+
offset: z.ZodDefault<z.ZodNumber>;
|
|
36
|
+
}, z.core.$strip>>;
|
|
33
37
|
}, z.core.$strip>, z.ZodObject<{
|
|
34
38
|
meta: z.ZodObject<{
|
|
35
39
|
count: z.ZodNumber;
|
|
@@ -40,6 +44,26 @@ export declare const searchEntitiesTool: import("@cyanheads/mcp-ts-core").ToolDe
|
|
|
40
44
|
id: z.ZodString;
|
|
41
45
|
display_name: z.ZodNullable<z.ZodString>;
|
|
42
46
|
}, z.core.$loose>>;
|
|
47
|
+
omitted: z.ZodOptional<z.ZodObject<{
|
|
48
|
+
ids: z.ZodArray<z.ZodString>;
|
|
49
|
+
next: z.ZodObject<{
|
|
50
|
+
tool: z.ZodLiteral<"openalex_search_entities">;
|
|
51
|
+
arguments: z.ZodObject<{}, z.core.$loose>;
|
|
52
|
+
}, z.core.$strip>;
|
|
53
|
+
}, z.core.$strip>>;
|
|
54
|
+
windows: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
55
|
+
id: z.ZodString;
|
|
56
|
+
field: z.ZodString;
|
|
57
|
+
offset: z.ZodNumber;
|
|
58
|
+
shown: z.ZodNumber;
|
|
59
|
+
total: z.ZodNumber;
|
|
60
|
+
possibly_capped: z.ZodBoolean;
|
|
61
|
+
next: z.ZodNullable<z.ZodObject<{
|
|
62
|
+
tool: z.ZodLiteral<"openalex_search_entities">;
|
|
63
|
+
arguments: z.ZodObject<{}, z.core.$loose>;
|
|
64
|
+
}, z.core.$strip>>;
|
|
65
|
+
}, z.core.$strip>>>;
|
|
66
|
+
over_budget: z.ZodOptional<z.ZodBoolean>;
|
|
43
67
|
}, z.core.$strip>, readonly [{
|
|
44
68
|
readonly reason: "semantic_per_page_cap";
|
|
45
69
|
readonly code: JsonRpcErrorCode.ValidationError;
|
|
@@ -85,6 +109,28 @@ export declare const searchEntitiesTool: import("@cyanheads/mcp-ts-core").ToolDe
|
|
|
85
109
|
readonly code: JsonRpcErrorCode.ValidationError;
|
|
86
110
|
readonly when: "A search (no `id`) provided `seed` without `sample`.";
|
|
87
111
|
readonly recovery: "Pass `sample` to enable random sampling, or remove `seed`.";
|
|
112
|
+
}, {
|
|
113
|
+
readonly reason: "slice_without_id";
|
|
114
|
+
readonly code: JsonRpcErrorCode.ValidationError;
|
|
115
|
+
readonly when: "A search passed `slice` without `id` — slice pages one array of one record.";
|
|
116
|
+
readonly recovery: "Pass the record's `id` alongside `slice`, or drop `slice` to run the search; a `windows` entry's `next` call carries both.";
|
|
117
|
+
}, {
|
|
118
|
+
readonly reason: "slice_field_not_array";
|
|
119
|
+
readonly code: JsonRpcErrorCode.ValidationError;
|
|
120
|
+
readonly when: "The `slice` field is `*`, or is not an array on the record at `id` — absent, null, a scalar, or an object.";
|
|
121
|
+
readonly recovery: "Name a top-level array field of that record, such as authorships, author_list, referenced_works, or locations; openalex_describe_fields(entity_type, \"select\") lists the field names.";
|
|
122
|
+
}, {
|
|
123
|
+
readonly reason: "id_type_mismatch";
|
|
124
|
+
readonly code: JsonRpcErrorCode.ValidationError;
|
|
125
|
+
readonly when: "`id` is a native OpenAlex ID whose letter names another entity type than `entity_type`, or a concept ID (C…), a type this server does not serve.";
|
|
126
|
+
readonly recovery: "Pass the entity_type the ID letter names — W works, A authors, S sources, I institutions, T topics, P publishers, F funders. A concept ID (C…) has no entity_type here; search topics for the concept by name with openalex_resolve_name instead.";
|
|
127
|
+
readonly thrownBy: "service";
|
|
128
|
+
}, {
|
|
129
|
+
readonly reason: "id_dot_segment";
|
|
130
|
+
readonly code: JsonRpcErrorCode.ValidationError;
|
|
131
|
+
readonly when: "`id` holds a `.` or `..` path segment, written plainly or percent-encoded.";
|
|
132
|
+
readonly recovery: "Pass the identifier itself — an OpenAlex ID, DOI, ORCID, ROR, PMID, or ISSN — with no relative path segments, or drop `id` and search with `query` or `filters`.";
|
|
133
|
+
readonly thrownBy: "service";
|
|
88
134
|
}, {
|
|
89
135
|
readonly reason: "entity_not_found";
|
|
90
136
|
readonly code: JsonRpcErrorCode.NotFound;
|
|
@@ -103,7 +149,14 @@ export declare const searchEntitiesTool: import("@cyanheads/mcp-ts-core").ToolDe
|
|
|
103
149
|
readonly code: JsonRpcErrorCode.RateLimited;
|
|
104
150
|
readonly when: "The OpenAlex daily usage budget is spent (HTTP 429).";
|
|
105
151
|
readonly retryable: false;
|
|
106
|
-
readonly recovery: "
|
|
152
|
+
readonly recovery: "This server's daily OpenAlex budget is spent and refills at midnight UTC — retrying before then will not succeed.";
|
|
153
|
+
readonly thrownBy: "service";
|
|
154
|
+
}, {
|
|
155
|
+
readonly reason: "upstream_plan_required";
|
|
156
|
+
readonly code: JsonRpcErrorCode.Forbidden;
|
|
157
|
+
readonly when: "A filter is one OpenAlex serves only on a paid plan — created_date, updated_date, or a from_/to_ form of either (HTTP 429 \"Plan upgrade required\").";
|
|
158
|
+
readonly retryable: false;
|
|
159
|
+
readonly recovery: "Drop the plan-gated filter and rerun; retrying it unchanged will not succeed. To bound works by date, filter on publication_date, from_publication_date, or to_publication_date, which need no paid plan.";
|
|
107
160
|
readonly thrownBy: "service";
|
|
108
161
|
}, {
|
|
109
162
|
readonly reason: "upstream_timeout";
|
|
@@ -115,7 +168,7 @@ export declare const searchEntitiesTool: import("@cyanheads/mcp-ts-core").ToolDe
|
|
|
115
168
|
}, {
|
|
116
169
|
readonly reason: "upstream_unavailable";
|
|
117
170
|
readonly code: JsonRpcErrorCode.ServiceUnavailable;
|
|
118
|
-
readonly when: "OpenAlex was unreachable or unusable — HTTP 503, a connection failure,
|
|
171
|
+
readonly when: "OpenAlex was unreachable or unusable — HTTP 503, a connection failure, a body that was empty, HTML, or unparseable JSON, or a semantic search whose query embedding failed upstream (HTTP 400 `Failed to embed query:`, or semantic search not configured).";
|
|
119
172
|
readonly retryable: true;
|
|
120
173
|
readonly recovery: "Wait and retry; check https://openalex.org for service status if the outage persists.";
|
|
121
174
|
readonly thrownBy: "service";
|
|
@@ -134,8 +187,26 @@ export declare const searchEntitiesTool: import("@cyanheads/mcp-ts-core").ToolDe
|
|
|
134
187
|
}, {
|
|
135
188
|
readonly reason: "comma_in_filter_value";
|
|
136
189
|
readonly code: JsonRpcErrorCode.InvalidParams;
|
|
137
|
-
readonly when: "A filter value contains a comma, which collides with the OpenAlex filter separator.";
|
|
138
|
-
readonly recovery: "Use `|` for OR within a filter value (e.g. \"2020|2021\"), or
|
|
190
|
+
readonly when: "A filter value contains a comma, which collides with the OpenAlex filter separator, on a field that takes no phrase. Free-text search filters (any `.search` key, such as title.search or abstract.search.exact) and fields matched as whole text (display_name, awards.funder_display_name, awards.funder_award_id, geo.city, geo.region, raw_affiliation_strings, block_key, parsed_longest_name, biblio fields) are exempt: a comma there is sent quoted, as part of the phrase.";
|
|
191
|
+
readonly recovery: "Use `|` for OR within a filter value (e.g. \"2020|2021\"), or move a phrase that contains commas into a free-text search filter such as title.search or raw_affiliation_strings.search, or into the `query` parameter.";
|
|
192
|
+
readonly thrownBy: "service";
|
|
193
|
+
}, {
|
|
194
|
+
readonly reason: "invalid_country_code";
|
|
195
|
+
readonly code: JsonRpcErrorCode.ValidationError;
|
|
196
|
+
readonly when: "A country filter (a key ending in country_code, country_codes, or countries) holds a segment that cannot be a country code, such as a misspelled name, an unassigned number, \"unknown\", or two codes joined by a space (\"DE US\").";
|
|
197
|
+
readonly recovery: "Pass the country as an ISO 3166-1 alpha-2 code such as DE, US, or GB (an alpha-3 code, numeric code, or English name is converted), or null for records with no country.";
|
|
198
|
+
readonly thrownBy: "service";
|
|
199
|
+
}, {
|
|
200
|
+
readonly reason: "mixed_and_or_in_filter_value";
|
|
201
|
+
readonly code: JsonRpcErrorCode.ValidationError;
|
|
202
|
+
readonly when: "A filter value puts \"|\" (OR) beside an AND operator — a \"+\", or a space on a field where words mean AND (on a country field a space is always part of a country name, and inside a language name it is part of the name). OpenAlex splits the value on \"|\" first and drops every alternative that ANDs more than one value, so no single value can carry both.";
|
|
203
|
+
readonly recovery: "Keep \"|\" and the AND operator in separate filter keys: OR the alternatives in this key and put the AND in a second key that reaches the same values — on works, {\"authorships.institutions.country_code\": \"DE|FR\", \"institutions.country_code\": \"US\"} matches (DE or FR) and US. The filters object holds each key once, so where no second key fits, run one call per alternative instead.";
|
|
204
|
+
readonly thrownBy: "service";
|
|
205
|
+
}, {
|
|
206
|
+
readonly reason: "quote_in_filter_value";
|
|
207
|
+
readonly code: JsonRpcErrorCode.ValidationError;
|
|
208
|
+
readonly when: "A filter value sent to OpenAlex double-quoted holds a double quote inside its outer pair — a value the server quotes (a multi-word value on a field matched as whole text, or a value holding a comma on such a field or on a free-text search filter), or one the caller wrapped in double quotes on any filter but a free-text search.";
|
|
209
|
+
readonly recovery: "Remove the double quote from the value; a phrase is matched without quoting its own text.";
|
|
139
210
|
readonly thrownBy: "service";
|
|
140
211
|
}, {
|
|
141
212
|
readonly reason: "upstream_invalid_params";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"search-entities.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/search-entities.tool.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;
|
|
1
|
+
{"version":3,"file":"search-entities.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/search-entities.tool.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AA2FjE,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAqqB7B,CAAC"}
|
|
@@ -5,11 +5,14 @@
|
|
|
5
5
|
import { tool, z } from '@cyanheads/mcp-ts-core';
|
|
6
6
|
import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
|
|
7
7
|
import { renderBudgetTrailer } from '../render-budget.js';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import { fitRecordsToBudget, fitSliceToBudget, formatRecordPage, joinNotices, measurePageFrame, omittedOutputSchema, overBudgetOutputSchema, windowsOutputSchema, } from '../response-budget.js';
|
|
9
|
+
import { quoteCallerText } from '../../../services/openalex/filter-values.js';
|
|
10
|
+
import { getOpenAlexService, selectRecordKey } from '../../../services/openalex/openalex-service.js';
|
|
10
11
|
import { ENTITY_TYPES } from '../../../services/openalex/types.js';
|
|
11
12
|
const SEMANTIC_PER_PAGE_CAP = 50;
|
|
12
13
|
const SAMPLE_MAX = 100;
|
|
14
|
+
/** What `meta.count` counts, in the rendered header. */
|
|
15
|
+
const RESULT_NOUN = 'result(s)';
|
|
13
16
|
/**
|
|
14
17
|
* The parameters that shape a *list* query, each paired with its echo rendering. Ground truth
|
|
15
18
|
* is `OpenAlexService.search()`: its `id` branch requests `/{entity_type}/{id}` with `select`
|
|
@@ -23,7 +26,7 @@ const SAMPLE_MAX = 100;
|
|
|
23
26
|
function searchOnlyParams(input) {
|
|
24
27
|
const parts = [];
|
|
25
28
|
if (input.query)
|
|
26
|
-
parts.push({ name: 'query', rendered: `query
|
|
29
|
+
parts.push({ name: 'query', rendered: `query=${JSON.stringify(input.query)}` });
|
|
27
30
|
if (input.search_mode && input.search_mode !== 'keyword') {
|
|
28
31
|
parts.push({ name: 'search_mode', rendered: `search_mode=${input.search_mode}` });
|
|
29
32
|
}
|
|
@@ -31,23 +34,25 @@ function searchOnlyParams(input) {
|
|
|
31
34
|
parts.push({ name: 'filters', rendered: `filters=${JSON.stringify(input.filters)}` });
|
|
32
35
|
}
|
|
33
36
|
if (input.sort)
|
|
34
|
-
parts.push({ name: 'sort', rendered: `sort=${input.sort}` });
|
|
37
|
+
parts.push({ name: 'sort', rendered: `sort=${JSON.stringify(input.sort)}` });
|
|
35
38
|
if (input.sample !== undefined) {
|
|
36
39
|
parts.push({ name: 'sample', rendered: `sample=${input.sample}` });
|
|
37
40
|
}
|
|
38
|
-
if (input.seed !== undefined)
|
|
39
|
-
parts.push({ name: 'seed', rendered: `seed=${input.seed}` });
|
|
41
|
+
if (input.seed !== undefined) {
|
|
42
|
+
parts.push({ name: 'seed', rendered: `seed=${JSON.stringify(input.seed)}` });
|
|
43
|
+
}
|
|
40
44
|
return parts;
|
|
41
45
|
}
|
|
42
46
|
/**
|
|
43
47
|
* Echo the criteria that actually ran. An `id` lookup echoes `entity_type` and `id` only —
|
|
44
48
|
* echoing a filter the singleton path never applied reads as confirmation that it did, which
|
|
45
|
-
* is the one thing this field exists not to do.
|
|
49
|
+
* is the one thing this field exists not to do. Caller text is JSON-encoded, so a line break in
|
|
50
|
+
* it cannot start a heading or end the line the echo renders on.
|
|
46
51
|
*/
|
|
47
52
|
function buildSearchEcho(input) {
|
|
48
53
|
const parts = [`entity_type=${input.entity_type}`];
|
|
49
54
|
if (input.id)
|
|
50
|
-
return [...parts, `id=${input.id}`].join(' | ');
|
|
55
|
+
return [...parts, `id=${JSON.stringify(input.id)}`].join(' | ');
|
|
51
56
|
return [...parts, ...searchOnlyParams(input).map((part) => part.rendered)].join(' | ');
|
|
52
57
|
}
|
|
53
58
|
/**
|
|
@@ -63,7 +68,7 @@ function isContinuationPage(input) {
|
|
|
63
68
|
return input.cursor !== undefined || (input.page ?? 1) > 1;
|
|
64
69
|
}
|
|
65
70
|
export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
66
|
-
description: 'Search, filter, sort, or retrieve by ID. Covers all OpenAlex entity types (works, authors, sources, institutions, topics, keywords, publishers, funders). Pass `id` to retrieve a single entity. Otherwise, use `query` and/or `filters` for discovery. Supports keyword search with boolean operators, exact phrase matching, and AI semantic search. Use openalex_resolve_name to resolve names to IDs before filtering. Searches and ID lookups return a curated set of fields by default; pass `select` to override with specific fields, or `["*"]` for the full record.',
|
|
71
|
+
description: 'Search, filter, sort, or retrieve by ID. Covers all OpenAlex entity types (works, authors, sources, institutions, topics, keywords, publishers, funders). Pass `id` to retrieve a single entity. Otherwise, use `query` and/or `filters` for discovery. Supports keyword search with boolean operators, exact phrase matching, and AI semantic search. Use openalex_resolve_name to resolve names to IDs before filtering. Searches and ID lookups return a curated set of fields by default; pass `select` to override with specific fields, or `["*"]` for the full record. Responses cap at 64,000 bytes per surface unless the least a call can return is larger (`over_budget`); `omitted` and `windows` give the calls that continue a cut, and `slice` pages a long array.',
|
|
67
72
|
sourceUrl: 'https://github.com/cyanheads/openalex-mcp-server/blob/main/src/mcp-server/tools/definitions/search-entities.tool.ts',
|
|
68
73
|
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
|
|
69
74
|
errors: [
|
|
@@ -121,6 +126,32 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
121
126
|
when: 'A search (no `id`) provided `seed` without `sample`.',
|
|
122
127
|
recovery: 'Pass `sample` to enable random sampling, or remove `seed`.',
|
|
123
128
|
},
|
|
129
|
+
{
|
|
130
|
+
reason: 'slice_without_id',
|
|
131
|
+
code: JsonRpcErrorCode.ValidationError,
|
|
132
|
+
when: 'A search passed `slice` without `id` — slice pages one array of one record.',
|
|
133
|
+
recovery: "Pass the record's `id` alongside `slice`, or drop `slice` to run the search; a `windows` entry's `next` call carries both.",
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
reason: 'slice_field_not_array',
|
|
137
|
+
code: JsonRpcErrorCode.ValidationError,
|
|
138
|
+
when: 'The `slice` field is `*`, or is not an array on the record at `id` — absent, null, a scalar, or an object.',
|
|
139
|
+
recovery: 'Name a top-level array field of that record, such as authorships, author_list, referenced_works, or locations; openalex_describe_fields(entity_type, "select") lists the field names.',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
reason: 'id_type_mismatch',
|
|
143
|
+
code: JsonRpcErrorCode.ValidationError,
|
|
144
|
+
when: '`id` is a native OpenAlex ID whose letter names another entity type than `entity_type`, or a concept ID (C…), a type this server does not serve.',
|
|
145
|
+
recovery: 'Pass the entity_type the ID letter names — W works, A authors, S sources, I institutions, T topics, P publishers, F funders. A concept ID (C…) has no entity_type here; search topics for the concept by name with openalex_resolve_name instead.',
|
|
146
|
+
thrownBy: 'service',
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
reason: 'id_dot_segment',
|
|
150
|
+
code: JsonRpcErrorCode.ValidationError,
|
|
151
|
+
when: '`id` holds a `.` or `..` path segment, written plainly or percent-encoded.',
|
|
152
|
+
recovery: 'Pass the identifier itself — an OpenAlex ID, DOI, ORCID, ROR, PMID, or ISSN — with no relative path segments, or drop `id` and search with `query` or `filters`.',
|
|
153
|
+
thrownBy: 'service',
|
|
154
|
+
},
|
|
124
155
|
{
|
|
125
156
|
reason: 'entity_not_found',
|
|
126
157
|
code: JsonRpcErrorCode.NotFound,
|
|
@@ -141,7 +172,15 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
141
172
|
code: JsonRpcErrorCode.RateLimited,
|
|
142
173
|
when: 'The OpenAlex daily usage budget is spent (HTTP 429).',
|
|
143
174
|
retryable: false,
|
|
144
|
-
recovery: '
|
|
175
|
+
recovery: "This server's daily OpenAlex budget is spent and refills at midnight UTC — retrying before then will not succeed.",
|
|
176
|
+
thrownBy: 'service',
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
reason: 'upstream_plan_required',
|
|
180
|
+
code: JsonRpcErrorCode.Forbidden,
|
|
181
|
+
when: 'A filter is one OpenAlex serves only on a paid plan — created_date, updated_date, or a from_/to_ form of either (HTTP 429 "Plan upgrade required").',
|
|
182
|
+
retryable: false,
|
|
183
|
+
recovery: 'Drop the plan-gated filter and rerun; retrying it unchanged will not succeed. To bound works by date, filter on publication_date, from_publication_date, or to_publication_date, which need no paid plan.',
|
|
145
184
|
thrownBy: 'service',
|
|
146
185
|
},
|
|
147
186
|
{
|
|
@@ -155,7 +194,7 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
155
194
|
{
|
|
156
195
|
reason: 'upstream_unavailable',
|
|
157
196
|
code: JsonRpcErrorCode.ServiceUnavailable,
|
|
158
|
-
when: 'OpenAlex was unreachable or unusable — HTTP 503, a connection failure,
|
|
197
|
+
when: 'OpenAlex was unreachable or unusable — HTTP 503, a connection failure, a body that was empty, HTML, or unparseable JSON, or a semantic search whose query embedding failed upstream (HTTP 400 `Failed to embed query:`, or semantic search not configured).',
|
|
159
198
|
retryable: true,
|
|
160
199
|
recovery: 'Wait and retry; check https://openalex.org for service status if the outage persists.',
|
|
161
200
|
thrownBy: 'service',
|
|
@@ -177,8 +216,29 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
177
216
|
{
|
|
178
217
|
reason: 'comma_in_filter_value',
|
|
179
218
|
code: JsonRpcErrorCode.InvalidParams,
|
|
180
|
-
when: 'A filter value contains a comma, which collides with the OpenAlex filter separator.',
|
|
181
|
-
recovery: 'Use `|` for OR within a filter value (e.g. "2020|2021"), or
|
|
219
|
+
when: 'A filter value contains a comma, which collides with the OpenAlex filter separator, on a field that takes no phrase. Free-text search filters (any `.search` key, such as title.search or abstract.search.exact) and fields matched as whole text (display_name, awards.funder_display_name, awards.funder_award_id, geo.city, geo.region, raw_affiliation_strings, block_key, parsed_longest_name, biblio fields) are exempt: a comma there is sent quoted, as part of the phrase.',
|
|
220
|
+
recovery: 'Use `|` for OR within a filter value (e.g. "2020|2021"), or move a phrase that contains commas into a free-text search filter such as title.search or raw_affiliation_strings.search, or into the `query` parameter.',
|
|
221
|
+
thrownBy: 'service',
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
reason: 'invalid_country_code',
|
|
225
|
+
code: JsonRpcErrorCode.ValidationError,
|
|
226
|
+
when: 'A country filter (a key ending in country_code, country_codes, or countries) holds a segment that cannot be a country code, such as a misspelled name, an unassigned number, "unknown", or two codes joined by a space ("DE US").',
|
|
227
|
+
recovery: 'Pass the country as an ISO 3166-1 alpha-2 code such as DE, US, or GB (an alpha-3 code, numeric code, or English name is converted), or null for records with no country.',
|
|
228
|
+
thrownBy: 'service',
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
reason: 'mixed_and_or_in_filter_value',
|
|
232
|
+
code: JsonRpcErrorCode.ValidationError,
|
|
233
|
+
when: 'A filter value puts "|" (OR) beside an AND operator — a "+", or a space on a field where words mean AND (on a country field a space is always part of a country name, and inside a language name it is part of the name). OpenAlex splits the value on "|" first and drops every alternative that ANDs more than one value, so no single value can carry both.',
|
|
234
|
+
recovery: 'Keep "|" and the AND operator in separate filter keys: OR the alternatives in this key and put the AND in a second key that reaches the same values — on works, {"authorships.institutions.country_code": "DE|FR", "institutions.country_code": "US"} matches (DE or FR) and US. The filters object holds each key once, so where no second key fits, run one call per alternative instead.',
|
|
235
|
+
thrownBy: 'service',
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
reason: 'quote_in_filter_value',
|
|
239
|
+
code: JsonRpcErrorCode.ValidationError,
|
|
240
|
+
when: 'A filter value sent to OpenAlex double-quoted holds a double quote inside its outer pair — a value the server quotes (a multi-word value on a field matched as whole text, or a value holding a comma on such a field or on a free-text search filter), or one the caller wrapped in double quotes on any filter but a free-text search.',
|
|
241
|
+
recovery: 'Remove the double quote from the value; a phrase is matched without quoting its own text.',
|
|
182
242
|
thrownBy: 'service',
|
|
183
243
|
},
|
|
184
244
|
{
|
|
@@ -231,7 +291,7 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
231
291
|
.string()
|
|
232
292
|
.min(1)
|
|
233
293
|
.optional()
|
|
234
|
-
.describe('Retrieve a single entity by ID. Supports: OpenAlex ID ("W2741809807"), DOI ("10.1038/nature12373"), ORCID ("0000-0002-1825-0097"), ROR ("https://ror.org/00hx57361"), PMID ("12345678" or "https://pubmed.ncbi.nlm.nih.gov/12345678"), ISSN ("1234-5678"). Keywords are identified by slug rather than a native ID — pass either the slug ("groundwater") or the URL a search returns ("https://openalex.org/keywords/groundwater"). A PMCID is recognized too, bare ("PMC1234567") or as a PubMed Central URL, but OpenAlex indexes no PMCIDs, so it resolves nothing — pass the work\'s PMID or DOI instead. When provided, `query`, `search_mode`, `filters`, `sort`, `sample`, and `seed` are not applied — the returned record is the entity at that ID regardless of them, and the response `notice` names any you passed. `select` still applies: the curated per-entity-type default is returned unless you pass `select` (use `["*"]` for the complete record). To filter, drop `id` and search. Use openalex_resolve_name to find the ID if unknown.'),
|
|
294
|
+
.describe('Retrieve a single entity by ID. Supports: OpenAlex ID ("W2741809807", or its OpenAlex record-page or API URL such as "https://openalex.org/works/W2741809807" or "https://api.openalex.org/works/W2741809807"), whose letter must name `entity_type` — W works, A authors, S sources, I institutions, T topics, P publishers, F funders; DOI ("10.1038/nature12373"; under entity_type "funders", a funder\'s Crossref Funder Registry DOI such as "10.13039/100000001"), ORCID ("0000-0002-1825-0097"), ROR ("https://ror.org/00hx57361" — one ROR can name an institution, a funder, and a publisher, and `entity_type` picks which), PMID ("12345678" or "https://pubmed.ncbi.nlm.nih.gov/12345678"), ISSN ("1234-5678"). Keywords are identified by slug rather than a native ID — pass either the slug ("groundwater") or the URL a search returns ("https://openalex.org/keywords/groundwater"). A PMCID is recognized too, bare ("PMC1234567") or as a PubMed Central URL, but OpenAlex indexes no PMCIDs, so it resolves nothing — pass the work\'s PMID or DOI instead. When provided, `query`, `search_mode`, `filters`, `sort`, `sample`, and `seed` are not applied — the returned record is the entity at that ID regardless of them, and the response `notice` names any you passed. `select` still applies: the curated per-entity-type default is returned unless you pass `select` (use `["*"]` for the complete record). An array too long for the response budget comes back as a window (`windows`); page the rest with `slice`. To filter, drop `id` and search. Use openalex_resolve_name to find the ID if unknown.'),
|
|
235
295
|
query: z
|
|
236
296
|
.string()
|
|
237
297
|
.min(1)
|
|
@@ -244,7 +304,7 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
244
304
|
filters: z
|
|
245
305
|
.record(z.string(), z.string())
|
|
246
306
|
.optional()
|
|
247
|
-
.describe('Filter criteria as field:value pairs. AND across fields (multiple keys). OR within field: pipe-separate ("us|gb"). NOT: prefix "!" ("!us"). Range: "2020-2024". Comparison: ">100", "<50". AND within same field: "+"-separate. Two keys that resolve to the same upstream field (an alias and its canonical name, e.g. `year` and `publication_year`) are both applied and AND\'d, so they narrow rather than override each other. Use OpenAlex IDs (not names) for entity filters — resolve names first. Common keys: `openalex` (filter by entity ID, e.g. {"openalex": "W123|W456"}), `cites` (works citing a given work), `publication_year` (range "2020-2024"), `authorships.author.id`, `type`, `is_oa`.'),
|
|
307
|
+
.describe('Filter criteria as field:value pairs. AND across fields (multiple keys). OR within field: pipe-separate ("us|gb"). NOT: prefix "!" ("!us"). Range: "2020-2024". Comparison: ">100", "<50". AND within same field: "+"-separate ("us+gb"); inside a `.search` value, a DOI, a date (a timestamp\'s "+00:00" offset), a URL field such as landing_page_url, an exactly matched name (display_name, awards.funder_display_name — "C++", "Erasmus+"), a biblio field (issue "1+2"), or awards.funder_award_id, "+" is a literal character. A value cannot mix "|" with an AND operator — a "+", or a space between values on an ID, code, or `language` key (a space inside a language name is part of the name, and a country field never reads a space as AND) — OpenAlex splits it on "|" first and drops every alternative that ANDs more than one value, so such a value is rejected; put the AND in a second key instead ("institutions.country_code": "US" beside "authorships.institutions.country_code": "DE|FR" on works). On a field matched as whole text (display_name, awards.funder_display_name, awards.funder_award_id, geo.city, geo.region, raw_affiliation_strings, block_key, biblio fields), a space is part of the value: "New York", "!New York", and "New York|Boston" match as written, but quotes around one "|" alternative make that alternative match nothing. A comma is part of such a value too ("University of California, Berkeley" on raw_affiliation_strings): a value holding one is sent as one quoted phrase, its "+" a literal character. A value sent as a quoted phrase cannot contain a double quote of its own. A multi-word value there cannot be ANDed with another ("New York+Boston" matches nothing); to require two funders or institutions, AND their IDs (awards.funder_id, authorships.institutions.id). Country fields (keys ending in country_code, country_codes, or countries) take ISO 3166-1 alpha-2 codes ("DE"): an alpha-3 code ("DEU"), numeric code ("276"), or English name ("Germany", "South Korea") is converted, "UK" becomes "GB", and a value that cannot be a country code is rejected. A space there is part of a name, never a separator: "South Korea|Japan" ORs two countries, and "DE US" is rejected. `language` takes ISO 639-1 codes ("en"): an ISO 639-2 code ("eng") or English name ("English") is converted. Two keys that resolve to the same upstream field (an alias and its canonical name, e.g. `year` and `publication_year`) are both applied and AND\'d, so they narrow rather than override each other. Use OpenAlex IDs (not names) for entity filters — resolve names first. Common keys: `openalex` (filter by entity ID, e.g. {"openalex": "W123|W456"}), `cites` (works citing a given work), `publication_year` (range "2020-2024"), `authorships.author.id`, `type`, `is_oa`.'),
|
|
248
308
|
sort: z
|
|
249
309
|
.string()
|
|
250
310
|
.optional()
|
|
@@ -252,14 +312,14 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
252
312
|
select: z
|
|
253
313
|
.array(z.string())
|
|
254
314
|
.optional()
|
|
255
|
-
.describe('OpenAlex top-level field names to return. Always returned: `id`, `display_name` — additional fields you list are appended. A curated default per entity type applies to both searches and single-entity (`id`) lookups; pass field names to override it, or `["*"]` to retrieve the complete record (every field). Only top-level fields project, so a nested value is requested by its parent object: bibliometrics (`h_index`, `i10_index`, `2yr_mean_citedness`) live under `summary_stats` on authors, sources, institutions, publishers, and funders, and naming a leaf returns that object. Invalid field names produce an error identifying the rejected field. Example: ["doi", "
|
|
315
|
+
.describe('OpenAlex top-level field names to return. Always returned: `id`, `display_name` — additional fields you list are appended. A curated default per entity type applies to both searches and single-entity (`id`) lookups; pass field names to override it, or `["*"]` to retrieve the complete record (every field). Only top-level fields project, so a nested value is requested by its parent object: bibliometrics (`h_index`, `i10_index`, `2yr_mean_citedness`) live under `summary_stats` on authors, sources, institutions, publishers, and funders, and naming a leaf returns that object. On sources, `publisher` returns `host_organization_name`: the publisher of a journal, the hosting institution of a repository, and null on most sources. On works, `author_list` returns each author in authorship order as `{id, display_name, orcid}` (`orcid` null when absent), a compact alternative to `authorships`, which also carries institutions and affiliations; neither the default nor `["*"]` includes it. Invalid field names produce an error identifying the rejected field. Large projections may be cut to fit the response budget (`omitted`, `windows`). Not applied with `slice`. Example: ["doi", "author_list", "primary_topic"].'),
|
|
256
316
|
per_page: z
|
|
257
317
|
.number()
|
|
258
318
|
.int()
|
|
259
319
|
.min(1)
|
|
260
320
|
.max(100)
|
|
261
321
|
.default(25)
|
|
262
|
-
.describe('Results per page (1-100). Default 25. Semantic search caps at 50 — when search_mode="semantic", set per_page ≤ 50 (also subject to a 1 req/sec rate limit upstream). The cap applies to searches only; an `id` lookup returns its one record regardless of both.'),
|
|
322
|
+
.describe('Results per page (1-100). Default 25. Semantic search caps at 50 — when search_mode="semantic", set per_page ≤ 50 (also subject to a 1 req/sec rate limit upstream). The cap applies to searches only; an `id` lookup returns its one record regardless of both. A budget-cut page returns fewer (`omitted`).'),
|
|
263
323
|
cursor: z
|
|
264
324
|
.string()
|
|
265
325
|
.min(1)
|
|
@@ -282,6 +342,20 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
282
342
|
.string()
|
|
283
343
|
.optional()
|
|
284
344
|
.describe('Deterministic seed for `sample`. Same seed + same filters = same results — pass when reproducibility matters. Has no effect without `sample`, and a search that passes it alone is rejected.'),
|
|
345
|
+
slice: z
|
|
346
|
+
.object({
|
|
347
|
+
field: z
|
|
348
|
+
.string()
|
|
349
|
+
.describe('Top-level array field to page, such as "authorships", "author_list", or "referenced_works". Leave it empty to skip slicing.'),
|
|
350
|
+
offset: z
|
|
351
|
+
.number()
|
|
352
|
+
.int()
|
|
353
|
+
.min(0)
|
|
354
|
+
.default(0)
|
|
355
|
+
.describe('0-based index of the first element to return.'),
|
|
356
|
+
})
|
|
357
|
+
.optional()
|
|
358
|
+
.describe('Page one array of the record at `id`: returns `id`, `display_name`, and `field` from `offset` onward, as many elements as fit the response budget (at least one), with the window and its `next` call in `windows`. Requires `id`; replaces `select`. An offset at or past the end returns an empty window. Walks any partial array, including a possibly capped 100-entry `authorships` or `author_list`.'),
|
|
285
359
|
}),
|
|
286
360
|
output: z.object({
|
|
287
361
|
meta: z
|
|
@@ -291,25 +365,30 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
291
365
|
.describe('Total results matching the query/filters. Under search_mode "semantic" it is instead the size of the ranked candidate set — the most results `page` can reach — not an exhaustive match total.'),
|
|
292
366
|
per_page: z
|
|
293
367
|
.number()
|
|
294
|
-
.describe('Page size OpenAlex echoed for this request — the requested per_page, not the number of records returned. A short or exhausted page carries fewer records than this.'),
|
|
368
|
+
.describe('Page size OpenAlex echoed for this request — the requested per_page, not the number of records returned. A short or exhausted page carries fewer records than this, as does a page the response budget cut (`omitted`).'),
|
|
295
369
|
next_cursor: z
|
|
296
370
|
.string()
|
|
297
371
|
.nullable()
|
|
298
|
-
.describe('Cursor for next page. null if no more results.'),
|
|
372
|
+
.describe('Cursor for next page. null if no more results. Continues after the full upstream page, `omitted` records included.'),
|
|
299
373
|
})
|
|
300
374
|
.describe('Result metadata including pagination.'),
|
|
301
375
|
results: z
|
|
302
376
|
.array(z
|
|
303
377
|
.object({
|
|
304
|
-
id: z
|
|
378
|
+
id: z
|
|
379
|
+
.string()
|
|
380
|
+
.describe('OpenAlex ID URL (e.g., "https://openalex.org/W2741809807"). `windows` and `omitted` name records by the bare ID ("W2741809807"); a keyword keeps its URL.'),
|
|
305
381
|
display_name: z
|
|
306
382
|
.string()
|
|
307
383
|
.nullable()
|
|
308
384
|
.describe('Entity name or work title. null when OpenAlex holds no title for the record (paratext works and other untitled entries) — use `id` to identify it.'),
|
|
309
385
|
})
|
|
310
|
-
.
|
|
386
|
+
.loose()
|
|
311
387
|
.describe('A single OpenAlex entity record. `id` is always present and `display_name` is always returned (though it may be null); additional fields vary by entity_type and `select`.'))
|
|
312
|
-
.describe('OpenAlex entity objects. Text values are plain text — HTML entities decoded, HTML/JATS/MathML markup removed — and an abstract arrives reconstructed as `abstract
|
|
388
|
+
.describe('OpenAlex entity objects. Text values are plain text — HTML entities decoded, HTML/JATS/MathML markup removed — except identifier and URL fields such as `id`, `doi`, `ids`, and `*_url`, returned exactly as OpenAlex stores them; an abstract arrives reconstructed as `abstract`, and a selected `author_list` as one `{id, display_name, orcid}` entry per author. Additional fields depend on entity_type and select. Records cut by the response budget are in `omitted`; partial or possibly capped arrays are in `windows`.'),
|
|
389
|
+
omitted: omittedOutputSchema,
|
|
390
|
+
windows: windowsOutputSchema,
|
|
391
|
+
over_budget: overBudgetOutputSchema,
|
|
313
392
|
}),
|
|
314
393
|
// Agent-facing context for the success path — the query/filters as parsed, the
|
|
315
394
|
// total match count, and recovery guidance for empty results. Populated via
|
|
@@ -322,12 +401,12 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
322
401
|
notice: z
|
|
323
402
|
.string()
|
|
324
403
|
.optional()
|
|
325
|
-
.describe('Guidance notice. Set when a first call returns no results (echoes the criteria and suggests how to broaden), when a paginated call ran past its last page (says the traversal is finished instead of advising a broader query), when an `id` lookup was passed search criteria it does not apply (names them),
|
|
404
|
+
.describe('Guidance notice. Set when a first call returns no results (echoes the criteria and suggests how to broaden), when a paginated call ran past its last page (says the traversal is finished instead of advising a broader query), when an `id` lookup was passed search criteria it does not apply (names them), on every semantic search to disclose that `meta.count` is a candidate total rather than a match total, when the response budget cut records, windowed an array, or ran over, when a list record carries exactly 100 authorships or author_list entries (possibly capped), when a `slice` offset is past the end of its array, and when `slice` was passed with `select`, which it replaces. Absent otherwise.'),
|
|
326
405
|
budget: z
|
|
327
406
|
.object({
|
|
328
407
|
costUsd: z
|
|
329
408
|
.number()
|
|
330
|
-
.describe('USD this call spent. 0 for an `id` lookup — OpenAlex does not bill single-entity fetches, so batching known IDs beats paging a filtered list.'),
|
|
409
|
+
.describe('USD this call spent. 0 for an `id` lookup by an OpenAlex ID, DOI, ORCID, ROR, PMID, or ISSN — OpenAlex does not bill those single-entity fetches, so batching known IDs beats paging a filtered list. OpenAlex bills like one list request a keyword looked up by its slug, an `id` carrying characters past the identifier itself (`W2741809807#x` still returns W2741809807), and a funder looked up by its Funder Registry DOI.'),
|
|
331
410
|
remainingUsd: z.number().describe("USD left in today's OpenAlex budget after this call."),
|
|
332
411
|
resetsInSeconds: z
|
|
333
412
|
.number()
|
|
@@ -346,6 +425,15 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
346
425
|
budget: { render: renderBudgetTrailer },
|
|
347
426
|
},
|
|
348
427
|
async handler(input, ctx) {
|
|
428
|
+
// A form client sends an empty `slice.field` for an unset slice; an empty field is no slice.
|
|
429
|
+
const slice = input.slice?.field ? input.slice : undefined;
|
|
430
|
+
if (slice && !input.id) {
|
|
431
|
+
throw ctx.fail('slice_without_id', '`slice` pages one array of the record at `id`, and no `id` was supplied.', { slice });
|
|
432
|
+
}
|
|
433
|
+
// `*` would fetch the full record only to find no field of that name; refuse it up front.
|
|
434
|
+
if (slice?.field === '*') {
|
|
435
|
+
throw ctx.fail('slice_field_not_array', '`slice.field` is `*`, which names every field rather than one array.', { field: slice.field, id: input.id });
|
|
436
|
+
}
|
|
349
437
|
/**
|
|
350
438
|
* Reject list-query parameter combinations OpenAlex cannot serve. Every check here
|
|
351
439
|
* constrains a *list* query. `id` takes the singleton path in `OpenAlexService.search()`,
|
|
@@ -359,61 +447,44 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
359
447
|
// nothing to embed — the request silently degraded to a plain unfiltered listing whose
|
|
360
448
|
// echo still advertised `search_mode=semantic`.
|
|
361
449
|
if (input.search_mode === 'semantic' && !input.query) {
|
|
362
|
-
throw ctx.fail('semantic_without_query', 'Semantic search needs `query` text to embed — none was supplied.', {
|
|
450
|
+
throw ctx.fail('semantic_without_query', 'Semantic search needs `query` text to embed — none was supplied.', { searchMode: input.search_mode });
|
|
363
451
|
}
|
|
364
452
|
if (input.search_mode === 'semantic' && input.per_page > SEMANTIC_PER_PAGE_CAP) {
|
|
365
|
-
throw ctx.fail('semantic_per_page_cap', `Semantic search supports at most ${SEMANTIC_PER_PAGE_CAP} results per page. Reduce per_page or switch search_mode.`, {
|
|
366
|
-
...ctx.recoveryFor('semantic_per_page_cap'),
|
|
367
|
-
searchMode: input.search_mode,
|
|
368
|
-
perPage: input.per_page,
|
|
369
|
-
cap: SEMANTIC_PER_PAGE_CAP,
|
|
370
|
-
});
|
|
453
|
+
throw ctx.fail('semantic_per_page_cap', `Semantic search supports at most ${SEMANTIC_PER_PAGE_CAP} results per page. Reduce per_page or switch search_mode.`, { searchMode: input.search_mode, perPage: input.per_page, cap: SEMANTIC_PER_PAGE_CAP });
|
|
371
454
|
}
|
|
372
455
|
// OpenAlex answers `search.semantic` plus a cursor with a 400 ("Cursor pagination is not
|
|
373
456
|
// supported with semantic search"), so forwarding it spends a round trip to learn that.
|
|
374
457
|
if (input.search_mode === 'semantic' && input.cursor !== undefined) {
|
|
375
|
-
throw ctx.fail('semantic_with_cursor', 'Semantic search does not accept `cursor` — walk its candidates with `page` (1-based) instead.', {
|
|
376
|
-
...ctx.recoveryFor('semantic_with_cursor'),
|
|
377
|
-
searchMode: input.search_mode,
|
|
378
|
-
cursor: input.cursor,
|
|
379
|
-
});
|
|
458
|
+
throw ctx.fail('semantic_with_cursor', 'Semantic search does not accept `cursor` — walk its candidates with `page` (1-based) instead.', { searchMode: input.search_mode, cursor: input.cursor });
|
|
380
459
|
}
|
|
381
460
|
// The inverse: `page` is the semantic-only knob, and OpenAlex ignores it on a cursor
|
|
382
461
|
// traversal — silently returning page 1 again rather than the page that was asked for.
|
|
383
462
|
if (input.page !== undefined && input.search_mode !== 'semantic') {
|
|
384
|
-
throw ctx.fail('page_without_semantic', `\`page\` applies to semantic search only — search_mode is "${input.search_mode}", which paginates with \`cursor\`.`, {
|
|
385
|
-
...ctx.recoveryFor('page_without_semantic'),
|
|
386
|
-
searchMode: input.search_mode,
|
|
387
|
-
page: input.page,
|
|
388
|
-
});
|
|
463
|
+
throw ctx.fail('page_without_semantic', `\`page\` applies to semantic search only — search_mode is "${input.search_mode}", which paginates with \`cursor\`.`, { searchMode: input.search_mode, page: input.page });
|
|
389
464
|
}
|
|
390
465
|
if (input.sample !== undefined && input.cursor !== undefined) {
|
|
391
|
-
throw ctx.fail('sample_with_cursor', 'Sampling returns one page only — `sample` cannot be combined with `cursor` pagination.', {
|
|
466
|
+
throw ctx.fail('sample_with_cursor', 'Sampling returns one page only — `sample` cannot be combined with `cursor` pagination.', { sample: input.sample, cursor: input.cursor });
|
|
392
467
|
}
|
|
393
468
|
// Sampling pins `per_page` to `sample` and returns that one page, so there is no second
|
|
394
469
|
// page for `page` to address — the same exclusion `cursor` has above. Reachable under
|
|
395
470
|
// semantic mode alone; every other mode rejects `page` at the check before this one.
|
|
396
471
|
if (input.sample !== undefined && input.page !== undefined) {
|
|
397
|
-
throw ctx.fail('sample_with_page', 'Sampling returns one page only — `sample` cannot be combined with `page` pagination.', {
|
|
472
|
+
throw ctx.fail('sample_with_page', 'Sampling returns one page only — `sample` cannot be combined with `page` pagination.', { sample: input.sample, page: input.page });
|
|
398
473
|
}
|
|
399
474
|
// OpenAlex ignores `sample` and `seed` under `search.semantic` and returns the ranked page,
|
|
400
475
|
// so forwarding the pair would label a relevance ranking as a random sample. The population
|
|
401
476
|
// request would also carry `cursor=*` beside `search.semantic`, which upstream rejects.
|
|
402
477
|
// Checked after `sample_with_page`, which is reachable under semantic mode alone.
|
|
403
478
|
if (input.sample !== undefined && input.search_mode === 'semantic') {
|
|
404
|
-
throw ctx.fail('sample_with_semantic', 'Semantic search does not sample — OpenAlex returns the same ranked candidates under every seed.', {
|
|
405
|
-
...ctx.recoveryFor('sample_with_semantic'),
|
|
406
|
-
sample: input.sample,
|
|
407
|
-
searchMode: input.search_mode,
|
|
408
|
-
});
|
|
479
|
+
throw ctx.fail('sample_with_semantic', 'Semantic search does not sample — OpenAlex returns the same ranked candidates under every seed.', { sample: input.sample, searchMode: input.search_mode });
|
|
409
480
|
}
|
|
410
481
|
// OpenAlex answers any sort beside `sample` with a 400 ("sample does not work with sort").
|
|
411
482
|
// A blank or whitespace-only sort is never sent upstream, so it is no conflict.
|
|
412
483
|
if (input.sample !== undefined && input.sort?.trim()) {
|
|
413
|
-
throw ctx.fail('sample_with_sort', 'A random sample cannot be sorted — `sample` cannot be combined with `sort`.', {
|
|
484
|
+
throw ctx.fail('sample_with_sort', 'A random sample cannot be sorted — `sample` cannot be combined with `sort`.', { sample: input.sample, sort: input.sort });
|
|
414
485
|
}
|
|
415
486
|
if (input.seed !== undefined && input.sample === undefined) {
|
|
416
|
-
throw ctx.fail('seed_without_sample', '`seed` is only meaningful with `sample` — pass `sample` to enable random sampling.', {
|
|
487
|
+
throw ctx.fail('seed_without_sample', '`seed` is only meaningful with `sample` — pass `sample` to enable random sampling.', { seed: input.seed });
|
|
417
488
|
}
|
|
418
489
|
}
|
|
419
490
|
const service = getOpenAlexService();
|
|
@@ -424,7 +495,7 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
424
495
|
searchMode: input.search_mode,
|
|
425
496
|
filters: input.filters,
|
|
426
497
|
sort: input.sort,
|
|
427
|
-
select: input.select,
|
|
498
|
+
select: slice ? [slice.field] : input.select,
|
|
428
499
|
perPage: input.per_page,
|
|
429
500
|
cursor: input.cursor,
|
|
430
501
|
page: input.page,
|
|
@@ -460,31 +531,56 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
460
531
|
if (!input.id && input.search_mode === 'semantic') {
|
|
461
532
|
notices.push(`Semantic search ranks a candidate set rather than every match, so \`meta.count\` (${result.meta.count}) is the number of candidates \`page\` can reach, not the total number of matching records. Reach the rest of the candidates with \`page\`.`);
|
|
462
533
|
}
|
|
463
|
-
if (
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
},
|
|
471
|
-
results: result.results,
|
|
534
|
+
if (slice && input.select) {
|
|
535
|
+
notices.push('`slice` returns only `id`, `display_name`, and the sliced field, so `select` was not applied.');
|
|
536
|
+
}
|
|
537
|
+
const meta = {
|
|
538
|
+
count: result.meta.count,
|
|
539
|
+
per_page: result.meta.per_page,
|
|
540
|
+
next_cursor: result.meta.next_cursor,
|
|
472
541
|
};
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
if (
|
|
482
|
-
|
|
542
|
+
const measureFrame = (disclosure, budgetNotice) => measurePageFrame({ meta, results: [], ...disclosure }, RESULT_NOUN, {
|
|
543
|
+
echo,
|
|
544
|
+
totalCount: result.meta.count,
|
|
545
|
+
totalLabel: 'Total',
|
|
546
|
+
notice: joinNotices([...notices, budgetNotice]),
|
|
547
|
+
});
|
|
548
|
+
let page;
|
|
549
|
+
// A slice without an `id` was refused as `slice_without_id`, so `input.id` only narrows here.
|
|
550
|
+
if (slice && input.id) {
|
|
551
|
+
// The one-field projection lands under its record key — `authors` arrives as `authorships`,
|
|
552
|
+
// and `author_list` as itself.
|
|
553
|
+
const field = selectRecordKey(input.entity_type, slice.field);
|
|
554
|
+
const [record] = result.results;
|
|
555
|
+
const items = record?.[field];
|
|
556
|
+
if (!record || !Array.isArray(items)) {
|
|
557
|
+
throw ctx.fail('slice_field_not_array', `${quoteCallerText(slice.field)} is not an array on the record at ${quoteCallerText(input.id)}.`, { field: slice.field, id: input.id });
|
|
558
|
+
}
|
|
559
|
+
if (slice.offset > items.length) {
|
|
560
|
+
notices.push(`slice.offset ${slice.offset} is past the end of \`${field}\`, which holds ${items.length} element(s), so nothing follows.`);
|
|
561
|
+
}
|
|
562
|
+
page = fitSliceToBudget({
|
|
563
|
+
record,
|
|
564
|
+
field,
|
|
565
|
+
offset: slice.offset,
|
|
566
|
+
entityType: input.entity_type,
|
|
567
|
+
measureFrame,
|
|
568
|
+
});
|
|
483
569
|
}
|
|
484
|
-
|
|
485
|
-
|
|
570
|
+
else {
|
|
571
|
+
page = fitRecordsToBudget({
|
|
572
|
+
records: result.results,
|
|
573
|
+
entityType: input.entity_type,
|
|
574
|
+
path: input.id ? 'lookup' : 'list',
|
|
575
|
+
select: input.select,
|
|
576
|
+
measureFrame,
|
|
577
|
+
});
|
|
486
578
|
}
|
|
487
|
-
|
|
579
|
+
const notice = joinNotices([...notices, page.notice]);
|
|
580
|
+
if (notice)
|
|
581
|
+
ctx.enrich.notice(notice);
|
|
582
|
+
return { meta, results: page.results, ...page.disclosure };
|
|
488
583
|
},
|
|
584
|
+
format: (result) => formatRecordPage(result, RESULT_NOUN),
|
|
489
585
|
});
|
|
490
586
|
//# sourceMappingURL=search-entities.tool.js.map
|