mcp-databnf 1.0.1 → 1.1.1
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/CHANGELOG.md +159 -0
- package/README.md +84 -22
- package/dist/bnf/client.d.ts +73 -0
- package/dist/bnf/client.js +207 -38
- package/dist/bnf/client.js.map +1 -1
- package/dist/index.js +679 -154
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
7
7
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
8
|
|
|
9
9
|
// src/version.ts
|
|
10
|
-
var PKG_VERSION = "1.
|
|
10
|
+
var PKG_VERSION = "1.1.1";
|
|
11
11
|
var REPO_URL = "https://github.com/smeet666/mcp-databnf";
|
|
12
12
|
|
|
13
13
|
// src/config.ts
|
|
@@ -395,8 +395,36 @@ function escapeLiteral(value) {
|
|
|
395
395
|
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "");
|
|
396
396
|
}
|
|
397
397
|
var literal = (value) => `"${escapeLiteral(value)}"`;
|
|
398
|
+
var INVISIBLE = /[\u0000-\u0008\u000e-\u001f\u007f-\u009f\u00ad\u061c\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/gu;
|
|
399
|
+
function withoutInvisibles(input) {
|
|
400
|
+
return input.replace(INVISIBLE, "");
|
|
401
|
+
}
|
|
402
|
+
function invisibleCharacters(input) {
|
|
403
|
+
const found = [];
|
|
404
|
+
for (const character of input.match(INVISIBLE) ?? []) {
|
|
405
|
+
if (!found.includes(character)) found.push(character);
|
|
406
|
+
}
|
|
407
|
+
return found;
|
|
408
|
+
}
|
|
409
|
+
function charactersSetAside(input) {
|
|
410
|
+
const found = [];
|
|
411
|
+
for (const character of withoutInvisibles(input).normalize("NFC")) {
|
|
412
|
+
if (/[\p{L}\p{N}'\-\s]/u.test(character) || /[‘’ʼ]/u.test(character)) continue;
|
|
413
|
+
if (!found.includes(character)) found.push(character);
|
|
414
|
+
}
|
|
415
|
+
return found;
|
|
416
|
+
}
|
|
417
|
+
function readSearchText(input) {
|
|
418
|
+
const words = toSearchWords(input);
|
|
419
|
+
return {
|
|
420
|
+
words,
|
|
421
|
+
terms: toIndexTerms(words),
|
|
422
|
+
invisible: invisibleCharacters(input),
|
|
423
|
+
setAside: charactersSetAside(input)
|
|
424
|
+
};
|
|
425
|
+
}
|
|
398
426
|
function toSearchWords(input) {
|
|
399
|
-
const cleaned = input.normalize("NFC").replace(/[‘’ʼ]/gu, "'").replace(/[^\p{L}\p{N}'-]+/gu, " ");
|
|
427
|
+
const cleaned = withoutInvisibles(input).normalize("NFC").replace(/[‘’ʼ]/gu, "'").replace(/[^\p{L}\p{N}'-]+/gu, " ");
|
|
400
428
|
const words = [];
|
|
401
429
|
for (const raw of cleaned.split(" ")) {
|
|
402
430
|
const word = raw.replace(/^['-]+/u, "").replace(/['-]+$/u, "");
|
|
@@ -406,6 +434,15 @@ function toSearchWords(input) {
|
|
|
406
434
|
}
|
|
407
435
|
return words;
|
|
408
436
|
}
|
|
437
|
+
function toIndexTerms(words) {
|
|
438
|
+
const terms = [];
|
|
439
|
+
for (const word of words) {
|
|
440
|
+
for (const piece of word.split(/['-]+/u)) {
|
|
441
|
+
if (piece !== "" && !terms.includes(piece)) terms.push(piece);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return terms;
|
|
445
|
+
}
|
|
409
446
|
function containsAllWords(words) {
|
|
410
447
|
if (words.length === 0) {
|
|
411
448
|
throw invalidInput(
|
|
@@ -477,31 +514,47 @@ var TEXT_WINDOW = 400;
|
|
|
477
514
|
var prefixed = (body) => `${PREFIX_BLOCK}
|
|
478
515
|
${body.trim()}
|
|
479
516
|
`;
|
|
517
|
+
function windowOccupancy(predicate, words) {
|
|
518
|
+
return `
|
|
519
|
+
{
|
|
520
|
+
SELECT (COUNT(*) AS ?windowRows) WHERE {
|
|
521
|
+
SELECT DISTINCT ?windowEntity ?windowText WHERE {
|
|
522
|
+
?windowEntity ${predicate} ?windowText .
|
|
523
|
+
?windowText bif:contains ${containsAllWords(words)} .
|
|
524
|
+
}
|
|
525
|
+
LIMIT ${TEXT_WINDOW}
|
|
526
|
+
}
|
|
527
|
+
}`;
|
|
528
|
+
}
|
|
480
529
|
function searchAuthorsQuery(words, limit, offset) {
|
|
481
530
|
const take = boundedInteger(limit, 1, 50, "limit") + 1;
|
|
482
531
|
const skip = boundedInteger(offset, 0, 5e3, "offset");
|
|
483
532
|
return prefixed(`
|
|
484
|
-
SELECT ?person ?name ?label ?birthYear ?deathYear ?role WHERE {
|
|
533
|
+
SELECT ?person ?name ?label ?birthYear ?deathYear ?role ?windowRows WHERE {
|
|
485
534
|
{
|
|
486
|
-
|
|
487
|
-
{
|
|
488
|
-
|
|
489
|
-
?person
|
|
490
|
-
|
|
535
|
+
{
|
|
536
|
+
SELECT DISTINCT ?person WHERE {
|
|
537
|
+
{
|
|
538
|
+
SELECT DISTINCT ?person ?name WHERE {
|
|
539
|
+
?person foaf:name ?name .
|
|
540
|
+
?name bif:contains ${containsAllWords(words)} .
|
|
541
|
+
}
|
|
542
|
+
LIMIT ${TEXT_WINDOW}
|
|
491
543
|
}
|
|
492
|
-
|
|
544
|
+
?person a foaf:Person .
|
|
493
545
|
}
|
|
494
|
-
?person
|
|
546
|
+
ORDER BY ?person
|
|
547
|
+
LIMIT ${take} OFFSET ${skip}
|
|
495
548
|
}
|
|
496
|
-
|
|
497
|
-
|
|
549
|
+
OPTIONAL { ?person foaf:name ?name }
|
|
550
|
+
OPTIONAL { ?record foaf:focus ?person ; skos:prefLabel ?label }
|
|
551
|
+
OPTIONAL { ?person bnf:firstYear ?birthYear }
|
|
552
|
+
OPTIONAL { ?person bnf:lastYear ?deathYear }
|
|
553
|
+
OPTIONAL { ?person rdag2:biographicalInformation ?role }
|
|
498
554
|
}
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
OPTIONAL { ?person bnf:lastYear ?deathYear }
|
|
503
|
-
OPTIONAL { ?person rdag2:biographicalInformation ?role }
|
|
504
|
-
}`);
|
|
555
|
+
UNION${windowOccupancy("foaf:name", words)}
|
|
556
|
+
}
|
|
557
|
+
ORDER BY ?person`);
|
|
505
558
|
}
|
|
506
559
|
var MOST_TRIPLES = 2e3;
|
|
507
560
|
function authorQuery(id) {
|
|
@@ -524,29 +577,54 @@ function searchWorksQuery(words, limit, offset) {
|
|
|
524
577
|
const take = boundedInteger(limit, 1, 50, "limit") + 1;
|
|
525
578
|
const skip = boundedInteger(offset, 0, 5e3, "offset");
|
|
526
579
|
return prefixed(`
|
|
527
|
-
SELECT ?work ?title ?date ?status ?creator ?creatorName WHERE {
|
|
580
|
+
SELECT ?work ?title ?date ?status ?creator ?creatorName ?windowRows WHERE {
|
|
528
581
|
{
|
|
529
|
-
|
|
530
|
-
{
|
|
531
|
-
|
|
532
|
-
?work
|
|
533
|
-
|
|
582
|
+
{
|
|
583
|
+
SELECT DISTINCT ?work WHERE {
|
|
584
|
+
{
|
|
585
|
+
SELECT DISTINCT ?work ?title WHERE {
|
|
586
|
+
?work dcterms:title ?title .
|
|
587
|
+
?title bif:contains ${containsAllWords(words)} .
|
|
588
|
+
}
|
|
589
|
+
LIMIT ${TEXT_WINDOW}
|
|
534
590
|
}
|
|
535
|
-
|
|
591
|
+
?work a rdafrbr:Work .
|
|
536
592
|
}
|
|
537
|
-
?work
|
|
593
|
+
ORDER BY ?work
|
|
594
|
+
LIMIT ${take} OFFSET ${skip}
|
|
595
|
+
}
|
|
596
|
+
OPTIONAL { ?work dcterms:title ?title }
|
|
597
|
+
OPTIONAL { ?work dcterms:date ?date }
|
|
598
|
+
OPTIONAL { ?work rdae:statusOfIdentification ?status }
|
|
599
|
+
OPTIONAL {
|
|
600
|
+
?work dcterms:creator ?creator .
|
|
601
|
+
OPTIONAL { ?creator foaf:name ?creatorName }
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
UNION${windowOccupancy("dcterms:title", words)}
|
|
605
|
+
}
|
|
606
|
+
ORDER BY ?work`);
|
|
607
|
+
}
|
|
608
|
+
function worksByCreatorQuery(id, limit, offset) {
|
|
609
|
+
const person = iriOf(id);
|
|
610
|
+
const take = boundedInteger(limit, 1, 50, "limit") + 1;
|
|
611
|
+
const skip = boundedInteger(offset, 0, 5e3, "offset");
|
|
612
|
+
return prefixed(`
|
|
613
|
+
SELECT ?work ?title ?date ?year ?status ?form WHERE {
|
|
614
|
+
{
|
|
615
|
+
SELECT DISTINCT ?work WHERE {
|
|
616
|
+
?work dcterms:creator ${person} .
|
|
538
617
|
}
|
|
539
618
|
ORDER BY ?work
|
|
540
619
|
LIMIT ${take} OFFSET ${skip}
|
|
541
620
|
}
|
|
542
621
|
OPTIONAL { ?work dcterms:title ?title }
|
|
543
622
|
OPTIONAL { ?work dcterms:date ?date }
|
|
623
|
+
OPTIONAL { ?work bnf:firstYear ?year }
|
|
544
624
|
OPTIONAL { ?work rdae:statusOfIdentification ?status }
|
|
545
|
-
OPTIONAL {
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
}
|
|
549
|
-
}`);
|
|
625
|
+
OPTIONAL { ?work rdae:formOfWork ?form }
|
|
626
|
+
}
|
|
627
|
+
ORDER BY ?work`);
|
|
550
628
|
}
|
|
551
629
|
function workQuery(id) {
|
|
552
630
|
const work = iriOf(id);
|
|
@@ -584,7 +662,8 @@ SELECT ?edition ?title ?date ?year ?publisher ?place ?editionStatement
|
|
|
584
662
|
OPTIONAL { ?edition rdfs:seeAlso ?catalogue }
|
|
585
663
|
OPTIONAL { ?edition rdarel:electronicReproduction ?reproduction }
|
|
586
664
|
OPTIONAL { ?edition bnf:OCR ?ocr }
|
|
587
|
-
}
|
|
665
|
+
}
|
|
666
|
+
ORDER BY ?edition`);
|
|
588
667
|
}
|
|
589
668
|
function digitisedForWorkQuery(id, limit) {
|
|
590
669
|
const work = iriOf(id);
|
|
@@ -721,12 +800,28 @@ function toDigitisedLink(address, role, fromId, fromTitle) {
|
|
|
721
800
|
if (!segment) return null;
|
|
722
801
|
const ark = segment.split(".")[0] ?? segment;
|
|
723
802
|
if (ark === "") return null;
|
|
724
|
-
return { ark, url: address, role, fromId, fromTitle };
|
|
803
|
+
return { ark, url: address, rendering: renderingOf(address, ark), role, fromId, fromTitle };
|
|
804
|
+
}
|
|
805
|
+
function renderingOf(address, ark) {
|
|
806
|
+
const path = address.split(/[?#]/)[0] ?? address;
|
|
807
|
+
const after = path.slice(path.indexOf(`ark:/12148/${ark}`) + `ark:/12148/${ark}`.length);
|
|
808
|
+
const rendering = after.replace(/^[./]+/, "").replace(/\/+$/, "");
|
|
809
|
+
return rendering === "" ? null : rendering;
|
|
725
810
|
}
|
|
726
811
|
function page(rows, limit, skipped) {
|
|
727
812
|
const more = rows.length > limit;
|
|
728
813
|
return skipped > 0 ? { rows: rows.slice(0, limit), hasMore: more, skipped } : { rows: rows.slice(0, limit), hasMore: more };
|
|
729
814
|
}
|
|
815
|
+
function readWindowOccupancy(results) {
|
|
816
|
+
for (const row of results.results.bindings) {
|
|
817
|
+
const raw = text(row.windowRows);
|
|
818
|
+
if (raw === null) continue;
|
|
819
|
+
const count = Number(raw);
|
|
820
|
+
if (Number.isInteger(count)) return count >= TEXT_WINDOW;
|
|
821
|
+
}
|
|
822
|
+
return null;
|
|
823
|
+
}
|
|
824
|
+
var isOccupancyRow = (row, entity) => row.windowRows !== void 0 && row[entity] === void 0;
|
|
730
825
|
function byPredicate(results) {
|
|
731
826
|
const index = /* @__PURE__ */ new Map();
|
|
732
827
|
for (const row of results.results.bindings) {
|
|
@@ -781,6 +876,7 @@ function toAuthorSummaries(results, limit) {
|
|
|
781
876
|
const seen = /* @__PURE__ */ new Set();
|
|
782
877
|
let skipped = 0;
|
|
783
878
|
for (const row of results.results.bindings) {
|
|
879
|
+
if (isOccupancyRow(row, "person")) continue;
|
|
784
880
|
const iri = text(row.person);
|
|
785
881
|
const id = iri === null ? null : idFromIri(iri);
|
|
786
882
|
if (iri === null || id === null) {
|
|
@@ -799,7 +895,7 @@ function toAuthorSummaries(results, limit) {
|
|
|
799
895
|
sourceUrl: publicPageFor(iri)
|
|
800
896
|
});
|
|
801
897
|
}
|
|
802
|
-
return page(rows, limit, skipped);
|
|
898
|
+
return { ...page(rows, limit, skipped), indexWindowFull: readWindowOccupancy(results) };
|
|
803
899
|
}
|
|
804
900
|
function toAuthorDetail(results, id, pageUrl) {
|
|
805
901
|
const index = byPredicate(results);
|
|
@@ -863,6 +959,7 @@ function toWorkSummaries(results, limit) {
|
|
|
863
959
|
const byId = /* @__PURE__ */ new Map();
|
|
864
960
|
let skipped = 0;
|
|
865
961
|
for (const row of results.results.bindings) {
|
|
962
|
+
if (isOccupancyRow(row, "work")) continue;
|
|
866
963
|
const iri = text(row.work);
|
|
867
964
|
const id = iri === null ? null : idFromIri(iri);
|
|
868
965
|
if (iri === null || id === null) {
|
|
@@ -888,6 +985,46 @@ function toWorkSummaries(results, limit) {
|
|
|
888
985
|
work.creators.push({ id: creatorId, name: text(row.creatorName) });
|
|
889
986
|
}
|
|
890
987
|
}
|
|
988
|
+
return {
|
|
989
|
+
...page(
|
|
990
|
+
order.map((id) => byId.get(id)),
|
|
991
|
+
limit,
|
|
992
|
+
skipped
|
|
993
|
+
),
|
|
994
|
+
indexWindowFull: readWindowOccupancy(results)
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
function toAuthoredWorks(results, limit) {
|
|
998
|
+
const order = [];
|
|
999
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1000
|
+
let skipped = 0;
|
|
1001
|
+
for (const row of results.results.bindings) {
|
|
1002
|
+
const iri = text(row.work);
|
|
1003
|
+
const id = iri === null ? null : idFromIri(iri);
|
|
1004
|
+
if (iri === null || id === null) {
|
|
1005
|
+
skipped += 1;
|
|
1006
|
+
continue;
|
|
1007
|
+
}
|
|
1008
|
+
let work = byId.get(id);
|
|
1009
|
+
if (!work) {
|
|
1010
|
+
work = {
|
|
1011
|
+
id,
|
|
1012
|
+
title: text(row.title),
|
|
1013
|
+
date: text(row.date),
|
|
1014
|
+
year: integer(row.year),
|
|
1015
|
+
forms: [],
|
|
1016
|
+
status: readStatus(text(row.status), iri),
|
|
1017
|
+
sourceUrl: publicPageFor(iri)
|
|
1018
|
+
};
|
|
1019
|
+
byId.set(id, work);
|
|
1020
|
+
order.push(id);
|
|
1021
|
+
}
|
|
1022
|
+
const form = text(row.form);
|
|
1023
|
+
if (form !== null && form.startsWith("http")) {
|
|
1024
|
+
const term = vocabularyTerm(form);
|
|
1025
|
+
if (!work.forms.includes(term)) work.forms.push(term);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
891
1028
|
return page(
|
|
892
1029
|
order.map((id) => byId.get(id)),
|
|
893
1030
|
limit,
|
|
@@ -1187,6 +1324,25 @@ var BnfClient = class {
|
|
|
1187
1324
|
words(input) {
|
|
1188
1325
|
return toSearchWords(input);
|
|
1189
1326
|
}
|
|
1327
|
+
/**
|
|
1328
|
+
* The terms the index requires, which is what a row's presence rests on.
|
|
1329
|
+
*
|
|
1330
|
+
* The index splits inside a word at an apostrophe and at a hyphen, so a word
|
|
1331
|
+
* a caller wrote as one can reach it as two, each required on its own.
|
|
1332
|
+
*/
|
|
1333
|
+
indexTerms(words) {
|
|
1334
|
+
return toIndexTerms(words);
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* How a piece of caller text became the terms the search was run with.
|
|
1338
|
+
*
|
|
1339
|
+
* A tool reports the reading back, so the same reading has to be the one the
|
|
1340
|
+
* query was built from: the words, the terms the index makes of them, and
|
|
1341
|
+
* the characters that reached neither.
|
|
1342
|
+
*/
|
|
1343
|
+
searchReading(input) {
|
|
1344
|
+
return readSearchText(input);
|
|
1345
|
+
}
|
|
1190
1346
|
searchAuthors(name, limit, offset) {
|
|
1191
1347
|
const words = toSearchWords(name);
|
|
1192
1348
|
const query = searchAuthorsQuery(words, limit, offset);
|
|
@@ -1203,7 +1359,7 @@ var BnfClient = class {
|
|
|
1203
1359
|
(results) => toAuthorDetail(results, id.id, id.pageUrl)
|
|
1204
1360
|
);
|
|
1205
1361
|
if (!result.data.types.includes(PERSON_CLASS)) {
|
|
1206
|
-
await this.refuse(id,
|
|
1362
|
+
await this.refuse(id, "a person", "get_work reads a work.");
|
|
1207
1363
|
}
|
|
1208
1364
|
return result;
|
|
1209
1365
|
}
|
|
@@ -1214,8 +1370,8 @@ var BnfClient = class {
|
|
|
1214
1370
|
* something else are two different answers, and only one of them means the
|
|
1215
1371
|
* caller should go looking elsewhere.
|
|
1216
1372
|
*/
|
|
1217
|
-
async refuse(id,
|
|
1218
|
-
const known =
|
|
1373
|
+
async refuse(id, wanted, hint) {
|
|
1374
|
+
const known = (await this.read(`types:${id.id}`, kindQuery(id), toTypes)).data;
|
|
1219
1375
|
if (known.length === 0) {
|
|
1220
1376
|
throw notFound(`data.bnf.fr describes nothing at "${id.id}".`, {
|
|
1221
1377
|
hint: "Find the identifier with search_authors or search_works rather than writing one.",
|
|
@@ -1245,13 +1401,26 @@ var BnfClient = class {
|
|
|
1245
1401
|
if (!result.data.types.some((type) => WORK_CLASSES.has(type))) {
|
|
1246
1402
|
await this.refuse(
|
|
1247
1403
|
id,
|
|
1248
|
-
result.data.types,
|
|
1249
1404
|
"a work",
|
|
1250
1405
|
"get_author reads a person, and list_editions reads the editions of a work."
|
|
1251
1406
|
);
|
|
1252
1407
|
}
|
|
1253
1408
|
return result;
|
|
1254
1409
|
}
|
|
1410
|
+
/**
|
|
1411
|
+
* The works one person is named the creator of.
|
|
1412
|
+
*
|
|
1413
|
+
* The record is not checked here. A person the catalogue credits with nothing
|
|
1414
|
+
* and an address that names no person both answer with no rows, and telling
|
|
1415
|
+
* them apart costs a second query that only an empty answer needs.
|
|
1416
|
+
*/
|
|
1417
|
+
worksByAuthor(id, limit, offset) {
|
|
1418
|
+
return this.read(
|
|
1419
|
+
`author-works:${id.id}:${limit}:${offset}`,
|
|
1420
|
+
worksByCreatorQuery(id, limit, offset),
|
|
1421
|
+
(results) => toAuthoredWorks(results, limit)
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1255
1424
|
listEditions(id, limit, offset) {
|
|
1256
1425
|
const query = editionsQuery(id, limit, offset);
|
|
1257
1426
|
return this.read(
|
|
@@ -1354,7 +1523,12 @@ var retrievedAtSchema = z2.string().describe(
|
|
|
1354
1523
|
);
|
|
1355
1524
|
var digitisedLinkSchema = z2.object({
|
|
1356
1525
|
ark: z2.string().describe("The Gallica ARK identifying the document."),
|
|
1357
|
-
url: z2.string().describe(
|
|
1526
|
+
url: z2.string().describe(
|
|
1527
|
+
"The address the catalogue publishes, for a person to open. Read it beside 'rendering': an address asking for a rendering opens that view of the document rather than the document. This server does not read either."
|
|
1528
|
+
),
|
|
1529
|
+
rendering: z2.string().nullable().describe(
|
|
1530
|
+
"The view the address asks Gallica for, read off what follows the ARK name in it, such as 'thumbnail' for a small image or 'item' for one leaf. Null when the address names the document itself. This says what was asked for, not what comes back."
|
|
1531
|
+
),
|
|
1358
1532
|
role: z2.enum(["reproduction", "ocr", "depiction"]).describe(
|
|
1359
1533
|
"'reproduction' is the edition itself digitised. 'ocr' is the text a machine read off it. 'depiction' is an image illustrating the record, which can be a portrait or a page mentioning the subject rather than the work."
|
|
1360
1534
|
),
|
|
@@ -1394,7 +1568,33 @@ function truncate(text2, maxChars) {
|
|
|
1394
1568
|
if (text2.length <= maxChars) return text2;
|
|
1395
1569
|
return `${text2.slice(0, Math.max(0, maxChars - 1)).trimEnd()}\u2026`;
|
|
1396
1570
|
}
|
|
1397
|
-
var
|
|
1571
|
+
var WINDOW_FULL_CAVEAT = "The window this search reads off the index came back full, so records carrying these words sit past what was read and this list is a part of the matches. Which records fill a full window is settled by the index as it answers, so another reading of the same search can bring back other rows. Requiring one more word narrows the reading and reaches what a full window leaves out.";
|
|
1572
|
+
var WINDOW_FULL_AND_NO_MORE = "'has_more' is false because the rows of this window stop here, and the window came back full: it says where the reading stopped rather than that the catalogue holds nothing further under these words.";
|
|
1573
|
+
var FORMS_CAVEAT = "The codes under 'forms' are the terms of the BnF's work-form vocabulary a record points at, passed on as they stand: the vocabulary publishes no label for them here, so some read as words, such as 'roman' or 'poesi', and others, such as 'te', say nothing on their own. A work carrying no code is a work whose form the catalogue does not state.";
|
|
1574
|
+
var FORMS_FILTER_CAVEAT = "A form is stated on some works and left unsaid on others, so keeping the rows that carry one code finds the works declaring it and never all the works of that form.";
|
|
1575
|
+
var SEVERAL_RECORDS_CAVEAT = "The BnF keeps more than one authority record for some people, and a record can carry a name and nothing else while a second one carries the dates, the occupation and the works. Everything here is read off this record, so search_authors under the name will show the other records the file holds and let you compare them.";
|
|
1576
|
+
function readingNotes(reading, field) {
|
|
1577
|
+
const notes = [];
|
|
1578
|
+
const quoted = (values) => values.map((value) => `"${value}"`).join(", ");
|
|
1579
|
+
if (reading.invisible.length > 0) {
|
|
1580
|
+
notes.push(
|
|
1581
|
+
`The text held ${reading.invisible.length} character(s) that mark nothing on a screen, such as a control character or a zero-width space. They were removed before the words were cut, so a word one of them sat inside was searched for whole rather than as two terms the index would each require.`
|
|
1582
|
+
);
|
|
1583
|
+
}
|
|
1584
|
+
if (reading.setAside.length > 0) {
|
|
1585
|
+
notes.push(
|
|
1586
|
+
`Only letters and digits reach the index: ${quoted(reading.setAside)} in the text ${reading.setAside.length === 1 ? "was" : "were"} set aside, and what was searched for is ${quoted(reading.terms)}.`
|
|
1587
|
+
);
|
|
1588
|
+
}
|
|
1589
|
+
const single = reading.terms.filter((term) => [...term].length === 1);
|
|
1590
|
+
if (single.length > 0) {
|
|
1591
|
+
notes.push(
|
|
1592
|
+
`${quoted(single)} ${single.length === 1 ? "is a term of one character" : "are terms of one character"} and ${single.length === 1 ? "is" : "are"} required like any other: a record has to carry ${single.length === 1 ? "it" : "them"} in its ${field} for a row to be a match. Leaving ${single.length === 1 ? "it" : "them"} out widens the search.`
|
|
1593
|
+
);
|
|
1594
|
+
}
|
|
1595
|
+
return notes;
|
|
1596
|
+
}
|
|
1597
|
+
var NO_RANKING = "The full-text index answers whether a record matches, and it does not score how well. Rows are ordered by the address of the record, which measures nothing, so the row a person would call the obvious answer can sit anywhere in the list, or on a later page. That order is the one the pages are cut along, so reading page after page reaches every match once.";
|
|
1398
1598
|
async function classifyEmptyPage(page2, firstPageHasRow) {
|
|
1399
1599
|
if (page2 === 1) return "absent";
|
|
1400
1600
|
try {
|
|
@@ -1403,18 +1603,42 @@ async function classifyEmptyPage(page2, firstPageHasRow) {
|
|
|
1403
1603
|
return "undetermined";
|
|
1404
1604
|
}
|
|
1405
1605
|
}
|
|
1406
|
-
var GALLICA_CAVEAT = "These are links for a person to open. This server reads the BnF catalogue and never requests gallica.bnf.fr, so it cannot say whether a document opens, what it contains, or on what terms it may be reused.";
|
|
1407
|
-
var PROVISIONAL_CAVEAT = "A provisional record is one the BnF created to hold a title it has catalogued and not yet established as a work of its own. Its identifier can change, and it can be merged into an established record.";
|
|
1408
|
-
|
|
1409
|
-
// src/tools/findDigitised.ts
|
|
1410
1606
|
var PERSON_TYPE = "http://xmlns.com/foaf/0.1/Person";
|
|
1411
1607
|
var WORK_TYPES = [
|
|
1412
1608
|
"http://rdvocab.info/uri/schema/FRBRentitiesRDA/Work",
|
|
1413
1609
|
"http://rdaregistry.info/Elements/c/#C10001"
|
|
1414
1610
|
];
|
|
1611
|
+
function recordKindOf(types) {
|
|
1612
|
+
if (types.includes(PERSON_TYPE)) return "person";
|
|
1613
|
+
if (types.some((type) => WORK_TYPES.includes(type))) return "work";
|
|
1614
|
+
return null;
|
|
1615
|
+
}
|
|
1616
|
+
function headingYears(label) {
|
|
1617
|
+
const bracketed = /\(([^()]*)\)\s*$/.exec(label);
|
|
1618
|
+
const [birth, death] = (bracketed?.[1] ?? "").split("-");
|
|
1619
|
+
const year = (part) => part !== void 0 && /^\d{4}$/.test(part.trim()) ? Number(part.trim()) : null;
|
|
1620
|
+
return { birth: year(birth), death: year(death) };
|
|
1621
|
+
}
|
|
1622
|
+
function headingYearConflicts(label, birthYear, deathYear) {
|
|
1623
|
+
if (label === null) return [];
|
|
1624
|
+
const stated = headingYears(label);
|
|
1625
|
+
const conflicts = [];
|
|
1626
|
+
if (stated.birth !== null && birthYear !== null && stated.birth !== birthYear) {
|
|
1627
|
+
conflicts.push(`${stated.birth} against 'birth_year' ${birthYear}`);
|
|
1628
|
+
}
|
|
1629
|
+
if (stated.death !== null && deathYear !== null && stated.death !== deathYear) {
|
|
1630
|
+
conflicts.push(`${stated.death} against 'death_year' ${deathYear}`);
|
|
1631
|
+
}
|
|
1632
|
+
return conflicts;
|
|
1633
|
+
}
|
|
1634
|
+
var GALLICA_CAVEAT = "These are links for a person to open. This server reads the BnF catalogue and never requests gallica.bnf.fr, so it cannot say whether a document opens, what it contains, or on what terms it may be reused.";
|
|
1635
|
+
var ALIGNMENTS_CAVEAT = "The addresses under 'same_as' are the BnF's own alignments to other files, passed on exactly as the catalogue publishes them, character for character. This server requests data.bnf.fr alone and does not open them, so it cannot say whether one still answers or what stands at the other end.";
|
|
1636
|
+
var PROVISIONAL_CAVEAT = "A provisional record is one the BnF created to hold a title it has catalogued and not yet established as a work of its own. Its identifier can change, and it can be merged into an established record.";
|
|
1637
|
+
|
|
1638
|
+
// src/tools/findDigitised.ts
|
|
1415
1639
|
var findDigitisedDescription = [
|
|
1416
1640
|
"Gather the digitised documents the Biblioth\xE8que nationale de France catalogue attaches to one person or one work, and return them as links.",
|
|
1417
|
-
"Give the identifier of either; the tool
|
|
1641
|
+
"Give the identifier of either; the tool asks the catalogue what kind of record it is and follows the right path. For a work it walks the editions; for a person it takes the images on the record and the digitised editions of the works they are credited with. The 'kind' returned is what the catalogue types the record as.",
|
|
1418
1642
|
"Every result is a link for someone to open. This server reads the BnF catalogue and never requests gallica.bnf.fr, so it reports nothing about what is at the other end: not whether the document opens, not what it contains, not on what terms it may be reused.",
|
|
1419
1643
|
"It returns links and nothing else: no publisher, no date, no ISBN. Use list_editions when which edition a copy belongs to matters.",
|
|
1420
1644
|
"A 'depiction' illustrates a record and can be a page that merely mentions the subject. A 'reproduction' is an edition digitised. An 'ocr' link names a machine-read text of a document, which this server does not fetch."
|
|
@@ -1424,7 +1648,7 @@ var findDigitisedInput = strictInput({
|
|
|
1424
1648
|
"A person or work identifier, such as 'cb119219976' or 'cb11970626n'. The argument is named 'id' rather than 'work_id' or 'author_id' because it takes either."
|
|
1425
1649
|
),
|
|
1426
1650
|
kind: z3.enum(["auto", "person", "work"]).default("auto").describe(
|
|
1427
|
-
"
|
|
1651
|
+
"What you expect the identifier to name. The catalogue is asked either way, and a kind it contradicts is refused rather than followed. 'auto' states no expectation."
|
|
1428
1652
|
),
|
|
1429
1653
|
limit: z3.number().int().min(1).max(200).default(40)
|
|
1430
1654
|
});
|
|
@@ -1432,11 +1656,13 @@ var findDigitisedOutput = z3.object({
|
|
|
1432
1656
|
id: z3.string(),
|
|
1433
1657
|
kind: z3.enum(["person", "work"]).describe("What the catalogue types this record as."),
|
|
1434
1658
|
links: z3.array(digitisedLinkSchema),
|
|
1435
|
-
|
|
1659
|
+
links_returned_by_role: z3.object({
|
|
1436
1660
|
reproduction: z3.number().int(),
|
|
1437
1661
|
ocr: z3.number().int(),
|
|
1438
1662
|
depiction: z3.number().int()
|
|
1439
|
-
}).describe(
|
|
1663
|
+
}).describe(
|
|
1664
|
+
"How many of the links in this answer carry each role. These count the links returned here, which is neither a count of documents nor a count of what the catalogue attaches: several links can name one document, and 'has_more' says when links were left out."
|
|
1665
|
+
),
|
|
1440
1666
|
has_more: z3.boolean(),
|
|
1441
1667
|
retrieved_at: retrievedAtSchema,
|
|
1442
1668
|
notes: z3.array(z3.string())
|
|
@@ -1444,43 +1670,48 @@ var findDigitisedOutput = z3.object({
|
|
|
1444
1670
|
async function runFindDigitised(client, args) {
|
|
1445
1671
|
try {
|
|
1446
1672
|
const id = client.identify(args.id);
|
|
1447
|
-
let kind;
|
|
1448
1673
|
const notes = [];
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1674
|
+
let kind;
|
|
1675
|
+
if (id.kind === "temp-work") {
|
|
1676
|
+
kind = "work";
|
|
1677
|
+
} else {
|
|
1678
|
+
const types = await client.types(id);
|
|
1679
|
+
if (types.data.length === 0) {
|
|
1680
|
+
return toToolError(
|
|
1681
|
+
notFound(`data.bnf.fr describes nothing at "${id.id}".`, {
|
|
1682
|
+
hint: "Find the identifier with search_authors or search_works rather than writing one.",
|
|
1683
|
+
url: id.pageUrl
|
|
1684
|
+
})
|
|
1685
|
+
);
|
|
1686
|
+
}
|
|
1687
|
+
const found = recordKindOf(types.data);
|
|
1688
|
+
if (found === null) {
|
|
1689
|
+
return toToolError(
|
|
1690
|
+
notFound(
|
|
1691
|
+
`"${id.id}" is described by data.bnf.fr, and it is neither a person nor a work: it is typed ${types.data.join(", ")}.`,
|
|
1692
|
+
{
|
|
1693
|
+
hint: "This tool follows a person or a work. For one edition, list_editions carries the digitised copies alongside the imprint.",
|
|
1458
1694
|
url: id.pageUrl
|
|
1459
|
-
}
|
|
1460
|
-
)
|
|
1461
|
-
|
|
1462
|
-
if (types.data.includes(PERSON_TYPE)) kind = "person";
|
|
1463
|
-
else if (types.data.some((type) => WORK_TYPES.includes(type))) kind = "work";
|
|
1464
|
-
else {
|
|
1465
|
-
return toToolError(
|
|
1466
|
-
notFound(
|
|
1467
|
-
`"${id.id}" is described by data.bnf.fr, and it is neither a person nor a work: it is typed ${types.data.join(", ")}.`,
|
|
1468
|
-
{
|
|
1469
|
-
hint: "This tool follows a person or a work. For one edition, list_editions carries the digitised copies alongside the imprint.",
|
|
1470
|
-
url: id.pageUrl
|
|
1471
|
-
}
|
|
1472
|
-
)
|
|
1473
|
-
);
|
|
1474
|
-
}
|
|
1695
|
+
}
|
|
1696
|
+
)
|
|
1697
|
+
);
|
|
1475
1698
|
}
|
|
1476
|
-
|
|
1477
|
-
|
|
1699
|
+
kind = found;
|
|
1700
|
+
}
|
|
1701
|
+
if (args.kind !== "auto" && args.kind !== kind) {
|
|
1702
|
+
return toToolError(
|
|
1703
|
+
invalidInput(
|
|
1704
|
+
`The call states that "${id.id}" is a ${args.kind}, and data.bnf.fr describes it as a ${kind}.`,
|
|
1705
|
+
`Leave 'kind' out, or pass '${kind}'. Following the ${args.kind} path over a ${kind} record answers with part of the record or with none of it, and nothing in that answer would say so.`
|
|
1706
|
+
)
|
|
1707
|
+
);
|
|
1478
1708
|
}
|
|
1479
1709
|
const { data, cached, retrievedAt } = kind === "person" ? await client.digitisedForPerson(id, args.limit) : await client.digitisedForWork(id, args.limit);
|
|
1480
1710
|
if (cached) notes.push("Served from this server's short-lived in-memory cache.");
|
|
1481
1711
|
const links = data.rows.map((link) => ({
|
|
1482
1712
|
ark: link.ark,
|
|
1483
1713
|
url: link.url,
|
|
1714
|
+
rendering: link.rendering,
|
|
1484
1715
|
role: link.role,
|
|
1485
1716
|
from_id: link.fromId,
|
|
1486
1717
|
from_title: link.fromTitle
|
|
@@ -1490,12 +1721,18 @@ async function runFindDigitised(client, args) {
|
|
|
1490
1721
|
ocr: countRole(data.rows, "ocr"),
|
|
1491
1722
|
depiction: countRole(data.rows, "depiction")
|
|
1492
1723
|
};
|
|
1724
|
+
const rendered = links.filter((link) => link.rendering !== null);
|
|
1493
1725
|
if (links.length > 0) notes.push(GALLICA_CAVEAT);
|
|
1494
1726
|
if (counts.depiction > 0) {
|
|
1495
1727
|
notes.push(
|
|
1496
1728
|
`${counts.depiction === 1 ? "One of these is an image" : `${counts.depiction} of these are images`} the catalogue uses to illustrate the record. Such an image can be a portrait, a title page, or a newspaper page that mentions the subject in passing, and the catalogue does not say which.`
|
|
1497
1729
|
);
|
|
1498
1730
|
}
|
|
1731
|
+
if (rendered.length > 0) {
|
|
1732
|
+
notes.push(
|
|
1733
|
+
`${rendered.length === 1 ? "One of these addresses opens" : `${rendered.length} of these addresses open`} a rendering of a document rather than the document: what follows the ARK name in the address, such as 'thumbnail', is the view being asked for, and 'ark' names the document that view was taken from. Read 'rendering' before quoting a link as the document itself.`
|
|
1734
|
+
);
|
|
1735
|
+
}
|
|
1499
1736
|
if (counts.ocr > 0) {
|
|
1500
1737
|
notes.push(
|
|
1501
1738
|
`${counts.ocr === 1 ? "One of these points" : `${counts.ocr} of these point`} at a text a machine read off a scanned document. This server names that text and does not read it.`
|
|
@@ -1505,6 +1742,9 @@ async function runFindDigitised(client, args) {
|
|
|
1505
1742
|
notes.push(
|
|
1506
1743
|
args.limit >= 200 ? "The catalogue points at more documents than this tool will return in one answer, and it does not page: 200 is the ceiling. Read the editions with list_editions to reach the rest, one page at a time." : `More links exist than were returned. Raise 'limit' to see more of them, up to 200. This tool does not page, so a higher limit re-reads the links already shown rather than continuing after them.`
|
|
1507
1744
|
);
|
|
1745
|
+
notes.push(
|
|
1746
|
+
"The figures under 'links_returned_by_role' count the links in this answer, so they fall short of what the catalogue attaches by however much was left out."
|
|
1747
|
+
);
|
|
1508
1748
|
}
|
|
1509
1749
|
if (links.length === 0) {
|
|
1510
1750
|
notes.push(
|
|
@@ -1521,7 +1761,7 @@ ${links.map((link, index) => {
|
|
|
1521
1761
|
id: id.id,
|
|
1522
1762
|
kind,
|
|
1523
1763
|
links,
|
|
1524
|
-
counts,
|
|
1764
|
+
links_returned_by_role: counts,
|
|
1525
1765
|
has_more: data.hasMore,
|
|
1526
1766
|
retrieved_at: retrievedAt,
|
|
1527
1767
|
notes
|
|
@@ -1541,7 +1781,7 @@ var getAuthorDescription = [
|
|
|
1541
1781
|
"Read one person's record in the Biblioth\xE8que nationale de France authority file, by the identifier search_authors returns.",
|
|
1542
1782
|
"It carries the dates and places of birth and death, the occupation the record names, the language and country it associates with the person, the field of activity with its Dewey class, and the addresses for the same person in VIAF, IdRef, DBpedia, Wikidata and ISNI.",
|
|
1543
1783
|
"It carries no biography. The field the BnF calls biographical information is a job title on most records and a single word on many, so quote it as what the catalogue states and do not build a life story out of it.",
|
|
1544
|
-
"It
|
|
1784
|
+
"It lists no work. list_works walks from this record to the works the catalogue names the person the creator of, and states what that link does and does not reach.",
|
|
1545
1785
|
"'depictions' are images data.bnf.fr attaches to the person. They are links for a person to open; this server does not read them, and an image can be a page that mentions the person rather than a portrait of them."
|
|
1546
1786
|
].join(" ");
|
|
1547
1787
|
var getAuthorInput = strictInput({
|
|
@@ -1575,7 +1815,9 @@ var getAuthorOutput = z4.object({
|
|
|
1575
1815
|
fields: z4.array(z4.string()).describe("Fields of activity in the words of the record."),
|
|
1576
1816
|
dewey_classes: z4.array(z4.string()).describe("Dewey classes the fields point at, such as '800' for literature."),
|
|
1577
1817
|
same_as: z4.record(z4.string(), z4.array(z4.string())).describe("Addresses for the same person in other files, grouped by file."),
|
|
1578
|
-
catalogue_url: z4.string().nullable().describe(
|
|
1818
|
+
catalogue_url: z4.string().nullable().describe(
|
|
1819
|
+
"The address of this record in the BnF general catalogue, as the record itself points at it. Null when the record states none, which is a silence in the record rather than a record the general catalogue lacks: no address is built in its place."
|
|
1820
|
+
),
|
|
1579
1821
|
record_created: z4.string().nullable().describe("When the authority record was created."),
|
|
1580
1822
|
record_modified: z4.string().nullable().describe("When it was last changed."),
|
|
1581
1823
|
source_url: z4.string()
|
|
@@ -1598,17 +1840,36 @@ async function runGetAuthor(client, args) {
|
|
|
1598
1840
|
`The BnF states "${data.biographicalInformation}" as this person's biographical information. That field holds an occupation rather than a life, and this is all the catalogue says.`
|
|
1599
1841
|
);
|
|
1600
1842
|
}
|
|
1843
|
+
if (data.biographicalInformation !== null && data.biographicalInformation === data.occupation) {
|
|
1844
|
+
notes.push(
|
|
1845
|
+
"This record states the same text under 'biographical_information' and 'occupation', character for character. It is one statement written in two fields rather than two the record makes separately."
|
|
1846
|
+
);
|
|
1847
|
+
}
|
|
1848
|
+
const headings = [data.label, ...data.otherNames.map((other) => other.label)];
|
|
1849
|
+
const conflicts = [
|
|
1850
|
+
...new Set(
|
|
1851
|
+
headings.flatMap(
|
|
1852
|
+
(heading) => headingYearConflicts(heading, data.birthYear, data.deathYear)
|
|
1853
|
+
)
|
|
1854
|
+
)
|
|
1855
|
+
];
|
|
1856
|
+
if (conflicts.length > 0) {
|
|
1857
|
+
notes.push(
|
|
1858
|
+
`This record states its dates twice and the two disagree: the heading says ${conflicts.join(", and ")}. Both are passed on as the BnF publishes them, and the record says nothing about which is right, so read source_url before citing either.`
|
|
1859
|
+
);
|
|
1860
|
+
}
|
|
1601
1861
|
if (data.deathDate === null && data.deathYear === null) {
|
|
1862
|
+
const dated = data.birthDate !== null || data.birthYear !== null;
|
|
1602
1863
|
notes.push(
|
|
1603
|
-
"The record states no date of death. That can mean the person is living, or that the BnF has not recorded one; the catalogue does not distinguish the two."
|
|
1864
|
+
dated ? "The record states no date of death. That can mean the person is living, or that the BnF has not recorded one; the catalogue does not distinguish the two." : `This record states no date of birth and no date of death, so it dates the person nowhere and says nothing about whether they are living. ${SEVERAL_RECORDS_CAVEAT}`
|
|
1604
1865
|
);
|
|
1605
1866
|
}
|
|
1606
1867
|
if (Object.keys(data.sameAs).length > 0) {
|
|
1607
1868
|
notes.push(
|
|
1608
|
-
|
|
1869
|
+
`${ALIGNMENTS_CAVEAT} Following one is how to reach a biography, which this catalogue does not hold.`
|
|
1609
1870
|
);
|
|
1610
1871
|
}
|
|
1611
|
-
if (data.depictions.length > 0) notes.push(GALLICA_CAVEAT);
|
|
1872
|
+
if (args.include_depictions && data.depictions.length > 0) notes.push(GALLICA_CAVEAT);
|
|
1612
1873
|
const structured = {
|
|
1613
1874
|
author: {
|
|
1614
1875
|
id: data.id,
|
|
@@ -1643,6 +1904,7 @@ async function runGetAuthor(client, args) {
|
|
|
1643
1904
|
structured.depictions = data.depictions.map((link) => ({
|
|
1644
1905
|
ark: link.ark,
|
|
1645
1906
|
url: link.url,
|
|
1907
|
+
rendering: link.rendering,
|
|
1646
1908
|
role: link.role,
|
|
1647
1909
|
from_id: link.fromId,
|
|
1648
1910
|
from_title: link.fromTitle
|
|
@@ -1680,6 +1942,7 @@ import { z as z5 } from "zod";
|
|
|
1680
1942
|
var getWorkDescription = [
|
|
1681
1943
|
"Read one work's record in the Biblioth\xE8que nationale de France catalogue, by the identifier search_works returns.",
|
|
1682
1944
|
"It carries the title, everyone the record credits, the date the BnF gives the work, its language, its form and its subject with the Dewey class.",
|
|
1945
|
+
"'forms' holds the work-form vocabulary's own terms, which carry no label in this dataset, so some read as words and some do not. A record stating none has a form the catalogue does not state.",
|
|
1683
1946
|
"'status' says whether the record is established or provisional, and 'status_statement' repeats what the catalogue itself states. A provisional identifier can change, so cite an established one where there is a choice.",
|
|
1684
1947
|
"'expression_count' counts the expressions the record links, which is not a count of published editions: use list_editions for those."
|
|
1685
1948
|
].join(" ");
|
|
@@ -1700,7 +1963,9 @@ var getWorkOutput = z5.object({
|
|
|
1700
1963
|
first_year: z5.number().int().nullable(),
|
|
1701
1964
|
creators: z5.array(z5.object({ id: z5.string(), name: z5.string().nullable() })),
|
|
1702
1965
|
languages: z5.array(z5.string()).describe("ISO 639-2 codes."),
|
|
1703
|
-
forms: z5.array(z5.string()).describe(
|
|
1966
|
+
forms: z5.array(z5.string()).describe(
|
|
1967
|
+
"Terms of the BnF work-form vocabulary, as the terms themselves. The vocabulary publishes no label for them here, so they are not translated: some read as words, such as 'poesi', and some do not, such as 'te'. An empty list is a form the record does not state."
|
|
1968
|
+
),
|
|
1704
1969
|
subjects: z5.array(z5.string()).describe("Subjects in the words of the record."),
|
|
1705
1970
|
dewey_classes: z5.array(z5.string()),
|
|
1706
1971
|
status: z5.enum(["established", "provisional"]),
|
|
@@ -1709,7 +1974,9 @@ var getWorkOutput = z5.object({
|
|
|
1709
1974
|
"Expressions the record links: translations, adaptations, recordings. This is not a count of published editions. Null when the record was longer than one query reads, since the number would then be the ceiling rather than the count."
|
|
1710
1975
|
),
|
|
1711
1976
|
same_as: z5.record(z5.string(), z5.array(z5.string())),
|
|
1712
|
-
catalogue_url: z5.string().nullable()
|
|
1977
|
+
catalogue_url: z5.string().nullable().describe(
|
|
1978
|
+
"The address of this record in the BnF general catalogue, as the record itself points at it. Null when the record states none, which is the ordinary case on work records: it is a silence in the record rather than a record the general catalogue lacks, and no address is built in its place."
|
|
1979
|
+
),
|
|
1713
1980
|
source_url: z5.string()
|
|
1714
1981
|
}),
|
|
1715
1982
|
depictions: z5.array(digitisedLinkSchema).optional(),
|
|
@@ -1744,12 +2011,14 @@ async function runGetWork(client, args) {
|
|
|
1744
2011
|
`The record links ${data.expressionCount} expression(s): translations, adaptations and recordings of this work. Published editions are a separate count, and list_editions is what reads them.`
|
|
1745
2012
|
);
|
|
1746
2013
|
}
|
|
2014
|
+
if (data.forms.length > 0) notes.push(FORMS_CAVEAT);
|
|
1747
2015
|
if (data.creators.length === 0) {
|
|
1748
2016
|
notes.push(
|
|
1749
2017
|
"The record credits nobody with this work. That is normal for anonymous and traditional works, and it also happens on records a cataloguer has not finished."
|
|
1750
2018
|
);
|
|
1751
2019
|
}
|
|
1752
|
-
if (data.
|
|
2020
|
+
if (Object.keys(data.sameAs).length > 0) notes.push(ALIGNMENTS_CAVEAT);
|
|
2021
|
+
if (args.include_depictions && data.depictions.length > 0) notes.push(GALLICA_CAVEAT);
|
|
1753
2022
|
const structured = {
|
|
1754
2023
|
work: {
|
|
1755
2024
|
id: data.id,
|
|
@@ -1777,6 +2046,7 @@ async function runGetWork(client, args) {
|
|
|
1777
2046
|
structured.depictions = data.depictions.map((link) => ({
|
|
1778
2047
|
ark: link.ark,
|
|
1779
2048
|
url: link.url,
|
|
2049
|
+
rendering: link.rendering,
|
|
1780
2050
|
role: link.role,
|
|
1781
2051
|
from_id: link.fromId,
|
|
1782
2052
|
from_title: link.fromTitle
|
|
@@ -1803,7 +2073,7 @@ var listEditionsDescription = [
|
|
|
1803
2073
|
"List the published editions of one work in the Biblioth\xE8que nationale de France catalogue, by the identifier search_works or get_work returns.",
|
|
1804
2074
|
"Each row carries the publisher, the place, the year, the edition statement, the extent in the words of the record, the ISBN when there is one, and the record in the BnF general catalogue.",
|
|
1805
2075
|
"An edition that has been digitised carries a link under 'digitised'. That link says a copy exists at that address; this server never opens it, so it cannot say whether the document is readable, complete, or free to reuse.",
|
|
1806
|
-
"Rows are
|
|
2076
|
+
"Rows are ordered by the address the catalogue gives each edition, which is neither by date nor by importance."
|
|
1807
2077
|
].join(" ");
|
|
1808
2078
|
var listEditionsInput = strictInput({
|
|
1809
2079
|
work_id: z6.string().min(1).max(200).describe("The work identifier, such as 'cb11970626n'."),
|
|
@@ -1815,8 +2085,12 @@ var editionRowSchema = z6.object({
|
|
|
1815
2085
|
title: z6.string().nullable().describe("The title as this edition carries it."),
|
|
1816
2086
|
date: z6.string().nullable().describe("The date as published, which can be a phrase such as '[s.d.]' for an undated one."),
|
|
1817
2087
|
year: z6.number().int().nullable().describe("The year, when the record states one as a number."),
|
|
1818
|
-
publisher: z6.string().nullable()
|
|
1819
|
-
|
|
2088
|
+
publisher: z6.string().nullable().describe(
|
|
2089
|
+
"The publisher as the record writes it, square brackets included. A bracketed part is the cataloguer speaking rather than the item: '[s.n.]' is a publisher the item does not name, and a role such as '[\xE9d., distrib.]' is what the named house did rather than part of its name."
|
|
2090
|
+
),
|
|
2091
|
+
place: z6.string().nullable().describe(
|
|
2092
|
+
"The place of publication as the record writes it, square brackets included. '[Paris]' is a place the cataloguer supplied because the item does not print one, and '[S.l.]' is an item stating no place at all."
|
|
2093
|
+
),
|
|
1820
2094
|
edition_statement: z6.string().nullable().describe("Such as a numbered or revised edition."),
|
|
1821
2095
|
extent: z6.string().nullable().describe("The extent in the words of the record: pages, volumes, discs."),
|
|
1822
2096
|
isbn: z6.string().nullable(),
|
|
@@ -1858,6 +2132,7 @@ async function runListEditions(client, args) {
|
|
|
1858
2132
|
digitised: row.digitised.map((link) => ({
|
|
1859
2133
|
ark: link.ark,
|
|
1860
2134
|
url: link.url,
|
|
2135
|
+
rendering: link.rendering,
|
|
1861
2136
|
role: link.role,
|
|
1862
2137
|
from_id: link.fromId,
|
|
1863
2138
|
from_title: link.fromTitle
|
|
@@ -1866,6 +2141,14 @@ async function runListEditions(client, args) {
|
|
|
1866
2141
|
}));
|
|
1867
2142
|
const digitisedCount = editions.filter((edition) => edition.digitised.length > 0).length;
|
|
1868
2143
|
if (digitisedCount > 0) notes.push(GALLICA_CAVEAT);
|
|
2144
|
+
const bracketed = editions.some(
|
|
2145
|
+
(edition) => (edition.publisher?.includes("[") ?? false) || (edition.place?.includes("[") ?? false)
|
|
2146
|
+
);
|
|
2147
|
+
if (bracketed) {
|
|
2148
|
+
notes.push(
|
|
2149
|
+
"Square brackets in 'place' and 'publisher' are the cataloguer's own, kept as the record writes them: '[S.l.]' and '[s.n.]' are an item naming no place and no publisher, a bracketed name is one supplied from outside the item, and a bracketed role such as '[\xE9d., distrib.]' says what a named house did. Drop them before quoting a value as what the item prints."
|
|
2150
|
+
);
|
|
2151
|
+
}
|
|
1869
2152
|
if (data.hasMore) {
|
|
1870
2153
|
notes.push(`More editions exist beyond this page. Ask for page ${args.page + 1}.`);
|
|
1871
2154
|
}
|
|
@@ -1874,13 +2157,14 @@ async function runListEditions(client, args) {
|
|
|
1874
2157
|
`Page ${args.page} holds no row, which means the list ended earlier rather than that the work has no editions. Ask for page 1 to see it from the start.`
|
|
1875
2158
|
);
|
|
1876
2159
|
} else if (editions.length === 0) {
|
|
2160
|
+
await assertWork(client, id);
|
|
1877
2161
|
notes.push(
|
|
1878
2162
|
`The catalogue links no published edition to "${id.id}". A work record can exist with none: it happens on provisional records, and on works the BnF describes through an anthology rather than through an edition of their own. Check get_work to see what kind of record this is.`
|
|
1879
2163
|
);
|
|
1880
2164
|
}
|
|
1881
2165
|
if (editions.length > 0) {
|
|
1882
2166
|
notes.push(
|
|
1883
|
-
"Rows are
|
|
2167
|
+
"Rows are ordered by the address the catalogue gives each edition. It is neither chronological nor an order of importance, so a first edition can appear anywhere in the list. That order is the one the pages are cut along, so reading page after page reaches every edition once."
|
|
1884
2168
|
);
|
|
1885
2169
|
}
|
|
1886
2170
|
const body = editions.length === 0 ? args.page > 1 ? `Page ${args.page} of the editions of "${id.id}" holds no row.` : `The BnF catalogue links no edition to the work "${id.id}".` : `${editions.length} edition(s) of "${id.id}":
|
|
@@ -1914,57 +2198,237 @@ ${editions.map((edition, index) => {
|
|
|
1914
2198
|
return toToolError(error);
|
|
1915
2199
|
}
|
|
1916
2200
|
}
|
|
2201
|
+
async function assertWork(client, id) {
|
|
2202
|
+
if (id.kind === "temp-work") return;
|
|
2203
|
+
const types = await client.types(id);
|
|
2204
|
+
if (types.data.length === 0) {
|
|
2205
|
+
throw notFound(`data.bnf.fr describes nothing at "${id.id}".`, {
|
|
2206
|
+
hint: "Find the identifier with search_works, or with list_works for the works of one person.",
|
|
2207
|
+
url: id.pageUrl
|
|
2208
|
+
});
|
|
2209
|
+
}
|
|
2210
|
+
if (recordKindOf(types.data) !== "work") {
|
|
2211
|
+
throw notFound(
|
|
2212
|
+
`"${id.id}" is described by data.bnf.fr, and it is not a work: it is typed ${types.data.join(", ")}.`,
|
|
2213
|
+
{
|
|
2214
|
+
hint: "This tool reads the editions of a work. For a person, list_works reaches the works they are credited with and get_author reads their record.",
|
|
2215
|
+
url: id.pageUrl
|
|
2216
|
+
}
|
|
2217
|
+
);
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
1917
2220
|
|
|
1918
|
-
// src/tools/
|
|
2221
|
+
// src/tools/listWorks.ts
|
|
1919
2222
|
import { z as z7 } from "zod";
|
|
2223
|
+
var PERSON_TYPE2 = "http://xmlns.com/foaf/0.1/Person";
|
|
2224
|
+
var SCOPE = "A work is on this list because the BnF catalogue names this person as the creator of it. That is one link in the catalogue and not a bibliography: the catalogue credits a person on a record in other ways, and the BnF holds printed editions whose work it has never established as a record of its own.";
|
|
2225
|
+
var DATE_CAVEAT = "'date' and 'year' are the date of the work as the record gives it, which dates the work rather than any printing of it. A provisional record is made from a title the BnF has catalogued, so its date can be that of the printing it was made from. For the year a copy was printed, read list_editions.";
|
|
2226
|
+
var ORDER_CAVEAT = "Rows are ordered by the address the catalogue gives each work, which is neither chronological nor an order of importance, so a first work can appear anywhere in the list. That order is the one the pages are cut along, so reading page after page reaches every work once.";
|
|
2227
|
+
var listWorksDescription = [
|
|
2228
|
+
"List the works the Biblioth\xE8que nationale de France catalogue names one person the creator of, by the identifier search_authors returns. This is the tool for 'what did this person write'.",
|
|
2229
|
+
"Each row carries the title, the date the record gives the work, the form codes the catalogue points at, whether the record is established or provisional, and the identifier get_work, list_editions and find_digitised take.",
|
|
2230
|
+
"It is not a bibliography. It reports one link the catalogue holds, and the catalogue credits a person on a record in other ways and holds editions whose work it has never established, so a work missing here is not a work the person did not write.",
|
|
2231
|
+
"The form codes are the work-form vocabulary's own terms and carry no label in this dataset, so some read as words and some do not. A work stating no code has a form the catalogue does not state, so keeping the rows carrying one code finds the works that declare it and never all the works of that form.",
|
|
2232
|
+
"Rows are ordered by the address the catalogue gives each work, which is neither chronological nor an order of importance. No total is reported, because the catalogue counts nothing here and a count would read as a measure of what the person wrote."
|
|
2233
|
+
].join(" ");
|
|
2234
|
+
var listWorksInput = strictInput({
|
|
2235
|
+
author_id: z7.string().min(1).max(200).describe(
|
|
2236
|
+
"The person identifier from search_authors, such as 'cb119219976'. A full data.bnf.fr address is accepted too."
|
|
2237
|
+
),
|
|
2238
|
+
limit: z7.number().int().min(1).max(50).default(10),
|
|
2239
|
+
page: z7.number().int().min(1).max(100).default(1)
|
|
2240
|
+
});
|
|
2241
|
+
var authoredWorkRowSchema = z7.object({
|
|
2242
|
+
id: z7.string().describe("Pass this to get_work, list_editions or find_digitised."),
|
|
2243
|
+
title: z7.string().nullable(),
|
|
2244
|
+
date: z7.string().nullable().describe(
|
|
2245
|
+
"The date the record gives the work, as published. It dates the work rather than an edition of it."
|
|
2246
|
+
),
|
|
2247
|
+
year: z7.number().int().nullable().describe("The same date as a number, when the record has one."),
|
|
2248
|
+
forms: z7.array(z7.string()).describe(
|
|
2249
|
+
"Terms of the BnF work-form vocabulary, as the terms themselves. The vocabulary publishes no label for them here, so they are not translated: some read as words, such as 'roman', and some do not, such as 'te'. An empty list is a form the record does not state, not a work without one."
|
|
2250
|
+
),
|
|
2251
|
+
status: z7.enum(["established", "provisional"]).describe(
|
|
2252
|
+
"'established' is a work the BnF has settled as a record of its own. 'provisional' is a title it holds while a cataloguer settles it, whose identifier can change."
|
|
2253
|
+
),
|
|
2254
|
+
source_url: z7.string()
|
|
2255
|
+
});
|
|
2256
|
+
var listWorksOutput = z7.object({
|
|
2257
|
+
author_id: z7.string(),
|
|
2258
|
+
works: z7.array(authoredWorkRowSchema),
|
|
2259
|
+
page: z7.number().int(),
|
|
2260
|
+
has_more: z7.boolean(),
|
|
2261
|
+
retrieved_at: retrievedAtSchema,
|
|
2262
|
+
notes: z7.array(z7.string())
|
|
2263
|
+
});
|
|
2264
|
+
function emptyPageNote(emptiness, page2, authorId) {
|
|
2265
|
+
if (emptiness === "past_the_end") {
|
|
2266
|
+
return `Page ${page2} holds no row because it sits past the last row of this listing. The catalogue does link works to "${authorId}", and they are on earlier pages: call again with page=1, or with a lower page number, to read them.`;
|
|
2267
|
+
}
|
|
2268
|
+
if (emptiness === "undetermined") {
|
|
2269
|
+
return `Page ${page2} holds no row, and the first page of the same listing could not be read to find out why. This is either the catalogue linking no work to "${authorId}", or a page sitting past the last row of a listing that does hold some. Call again with page=1: rows there mean the second.`;
|
|
2270
|
+
}
|
|
2271
|
+
return `The BnF catalogue links no work to "${authorId}" as its creator. The record exists and this link is empty on it, which is a statement about the catalogue rather than about what the person wrote. ${SEVERAL_RECORDS_CAVEAT}`;
|
|
2272
|
+
}
|
|
2273
|
+
function emptyPageBody(emptiness, page2, authorId) {
|
|
2274
|
+
if (emptiness === "past_the_end") {
|
|
2275
|
+
return `Page ${page2} of the works linked to "${authorId}" holds no row: it sits past the last row. Call again with page=1.`;
|
|
2276
|
+
}
|
|
2277
|
+
if (emptiness === "undetermined") {
|
|
2278
|
+
return `Page ${page2} of the works linked to "${authorId}" holds no row, and whether the rows stop before it could not be read. Call again with page=1.`;
|
|
2279
|
+
}
|
|
2280
|
+
return `The BnF catalogue links no work to "${authorId}" as its creator.`;
|
|
2281
|
+
}
|
|
2282
|
+
async function runListWorks(client, args) {
|
|
2283
|
+
try {
|
|
2284
|
+
const id = client.identify(args.author_id);
|
|
2285
|
+
const offset = (args.page - 1) * args.limit;
|
|
2286
|
+
const { data, cached, retrievedAt } = await client.worksByAuthor(id, args.limit, offset);
|
|
2287
|
+
const notes = [];
|
|
2288
|
+
if (cached) notes.push("Served from this server's short-lived in-memory cache.");
|
|
2289
|
+
const works = data.rows.map((row) => ({
|
|
2290
|
+
id: row.id,
|
|
2291
|
+
title: row.title,
|
|
2292
|
+
date: row.date,
|
|
2293
|
+
year: row.year,
|
|
2294
|
+
forms: row.forms,
|
|
2295
|
+
status: row.status,
|
|
2296
|
+
source_url: row.sourceUrl
|
|
2297
|
+
}));
|
|
2298
|
+
notes.push(SCOPE);
|
|
2299
|
+
if (data.skipped !== void 0 && data.skipped > 0) {
|
|
2300
|
+
notes.push(
|
|
2301
|
+
`${data.skipped} row(s) the endpoint sent could not be read, because each named a record at an address this client cannot address. The page is short by that much rather than the catalogue holding less.`
|
|
2302
|
+
);
|
|
2303
|
+
}
|
|
2304
|
+
let emptiness = "absent";
|
|
2305
|
+
if (works.length === 0) {
|
|
2306
|
+
emptiness = await classifyEmptyPage(args.page, async () => {
|
|
2307
|
+
const first = await client.worksByAuthor(id, 1, 0);
|
|
2308
|
+
return first.data.rows.length > 0;
|
|
2309
|
+
});
|
|
2310
|
+
if (emptiness === "absent") await assertPerson(client, id);
|
|
2311
|
+
notes.push(emptyPageNote(emptiness, args.page, id.id));
|
|
2312
|
+
} else {
|
|
2313
|
+
if (data.hasMore) {
|
|
2314
|
+
notes.push(
|
|
2315
|
+
`More works exist beyond this page. Ask for page ${args.page + 1}. This server reports no total: a count of the works one link reaches would read as a count of what the person wrote, and the catalogue does not carry that.`
|
|
2316
|
+
);
|
|
2317
|
+
}
|
|
2318
|
+
notes.push(`${FORMS_CAVEAT} ${FORMS_FILTER_CAVEAT}`);
|
|
2319
|
+
if (works.some((work) => work.status === "provisional")) notes.push(PROVISIONAL_CAVEAT);
|
|
2320
|
+
notes.push(ORDER_CAVEAT, DATE_CAVEAT);
|
|
2321
|
+
}
|
|
2322
|
+
const body = works.length === 0 ? emptyPageBody(emptiness, args.page, id.id) : `${works.length} work(s) the catalogue links to "${id.id}" as their creator:
|
|
2323
|
+
${works.map((work, index) => {
|
|
2324
|
+
const when = work.date ? ` (${work.date})` : "";
|
|
2325
|
+
const forms = work.forms.length > 0 ? ` \xB7 form: ${work.forms.join(", ")}` : "";
|
|
2326
|
+
const status = work.status === "provisional" ? " \xB7 provisional record" : "";
|
|
2327
|
+
return `${index + 1}. ${work.title ?? work.id}${when}${forms}${status} \xB7 id: ${work.id}
|
|
2328
|
+
${work.source_url}`;
|
|
2329
|
+
}).join("\n")}`;
|
|
2330
|
+
return ok(
|
|
2331
|
+
{
|
|
2332
|
+
author_id: id.id,
|
|
2333
|
+
works,
|
|
2334
|
+
page: args.page,
|
|
2335
|
+
has_more: data.hasMore,
|
|
2336
|
+
retrieved_at: retrievedAt,
|
|
2337
|
+
notes
|
|
2338
|
+
},
|
|
2339
|
+
body,
|
|
2340
|
+
{ retrievedAt, notes, sourceUrl: id.pageUrl }
|
|
2341
|
+
);
|
|
2342
|
+
} catch (error) {
|
|
2343
|
+
return toToolError(error);
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
async function assertPerson(client, id) {
|
|
2347
|
+
const types = await client.types(id);
|
|
2348
|
+
if (types.data.length === 0) {
|
|
2349
|
+
throw notFound(`data.bnf.fr describes nothing at "${id.id}".`, {
|
|
2350
|
+
hint: "Find the identifier with search_authors rather than writing one.",
|
|
2351
|
+
url: id.pageUrl
|
|
2352
|
+
});
|
|
2353
|
+
}
|
|
2354
|
+
if (!types.data.includes(PERSON_TYPE2)) {
|
|
2355
|
+
throw notFound(
|
|
2356
|
+
`"${id.id}" is described by data.bnf.fr, and it is not a person: it is typed ${types.data.join(", ")}.`,
|
|
2357
|
+
{
|
|
2358
|
+
hint: "This tool walks from a person to the works they are credited with. For a work, list_editions reads its editions and get_work reads the record.",
|
|
2359
|
+
url: id.pageUrl
|
|
2360
|
+
}
|
|
2361
|
+
);
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
// src/tools/searchAuthors.ts
|
|
2366
|
+
import { z as z8 } from "zod";
|
|
1920
2367
|
var searchAuthorsDescription = [
|
|
1921
2368
|
"Find a person in the Biblioth\xE8que nationale de France authority file by name, and get the identifier the other tools take.",
|
|
1922
2369
|
"This matches the name the BnF records, so it takes a surname, a full name or both names in either order. It does not read biographies, so it cannot find a person from what they wrote or what they did.",
|
|
2370
|
+
"It reads the person records and nothing else: an organisation, a conference or a place is outside it, and an answer holding no row says nothing about those.",
|
|
2371
|
+
"The match is letter for letter, so a name written with other accents or under another transliteration is a different search: try the spellings a library would use before concluding the BnF holds nobody of that name.",
|
|
1923
2372
|
"Several rows can carry one name: the BnF keeps more than one authority record for some people, and many people share a name. Read 'birth_year', 'death_year' and 'role' to tell them apart, and show the caller the choice rather than picking one.",
|
|
1924
|
-
"Rows come back
|
|
2373
|
+
"Rows come back ordered by the address of the record, which is not an order of relevance. That order is the one the pages are cut along, so paging through a search reaches every match once.",
|
|
2374
|
+
"One search reads a fixed window of the index. 'index_window_full' says whether that window came back full: when it did, names sit past what was read, 'has_more' being false says where the reading stopped, and another reading of the same search can bring back other rows. Narrow the name to reach further."
|
|
1925
2375
|
].join(" ");
|
|
1926
2376
|
var searchAuthorsInput = strictInput({
|
|
1927
|
-
name:
|
|
1928
|
-
limit:
|
|
1929
|
-
page:
|
|
2377
|
+
name: z8.string().min(1).max(200).describe("The person's name, such as 'Rimbaud' or 'Arthur Rimbaud'."),
|
|
2378
|
+
limit: z8.number().int().min(1).max(50).default(10),
|
|
2379
|
+
page: z8.number().int().min(1).max(100).default(1)
|
|
1930
2380
|
});
|
|
1931
|
-
var authorRowSchema =
|
|
1932
|
-
id:
|
|
1933
|
-
name:
|
|
1934
|
-
label:
|
|
1935
|
-
birth_year:
|
|
1936
|
-
death_year:
|
|
1937
|
-
role:
|
|
2381
|
+
var authorRowSchema = z8.object({
|
|
2382
|
+
id: z8.string().describe("Pass this to get_author, or to find_digitised."),
|
|
2383
|
+
name: z8.string().nullable().describe("The name as data.bnf.fr writes it."),
|
|
2384
|
+
label: z8.string().nullable().describe("The authority heading, which usually carries the dates in brackets."),
|
|
2385
|
+
birth_year: z8.number().int().nullable(),
|
|
2386
|
+
death_year: z8.number().int().nullable().describe("Null when the record states none."),
|
|
2387
|
+
role: z8.string().nullable().describe(
|
|
1938
2388
|
"What the record says the person did. This field holds a job title far more often than a sentence, and is frequently a single word."
|
|
1939
2389
|
),
|
|
1940
|
-
source_url:
|
|
2390
|
+
source_url: z8.string().describe("The record's page. Show this when citing it.")
|
|
1941
2391
|
});
|
|
1942
|
-
var searchAuthorsOutput =
|
|
1943
|
-
name:
|
|
1944
|
-
words_searched:
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
2392
|
+
var searchAuthorsOutput = z8.object({
|
|
2393
|
+
name: z8.string().describe("The name asked for."),
|
|
2394
|
+
words_searched: z8.array(z8.string()).describe(
|
|
2395
|
+
"The terms the index required, each of which has to appear in the name. A word is cut at an apostrophe and at a hyphen before the index sees it, so a name written as one word can appear here as two, and a record carrying the pieces apart is a match."
|
|
2396
|
+
),
|
|
2397
|
+
authors: z8.array(authorRowSchema),
|
|
2398
|
+
page: z8.number().int(),
|
|
2399
|
+
has_more: z8.boolean().describe(
|
|
2400
|
+
"Whether the endpoint held at least one further row beyond this page, within the window the search read off the index. Read it alongside 'index_window_full': false on a full window says where the reading stopped rather than that the file holds nobody else."
|
|
2401
|
+
),
|
|
2402
|
+
index_window_full: z8.boolean().nullable().describe(
|
|
2403
|
+
"Whether the fixed window this search reads off the index came back full. True means records carrying these words sit past what was read, so the rows here are a part of the matches and another reading can bring back other rows. False means the window held everything that matched. Null when the endpoint stated no occupancy, which claims nothing either way."
|
|
2404
|
+
),
|
|
1948
2405
|
retrieved_at: retrievedAtSchema,
|
|
1949
|
-
notes:
|
|
2406
|
+
notes: z8.array(z8.string())
|
|
1950
2407
|
});
|
|
2408
|
+
var SPELLING_CAVEAT = "The index matches the words letter for letter, so a name written with other accents, or under another transliteration, is a different search reaching different records. A short answer is no evidence that the file holds nobody else of that name: ask again with the accents dropped, with them restored, and under the transliterations a library uses.";
|
|
1951
2409
|
function lifespan(birthYear, deathYear) {
|
|
1952
2410
|
if (birthYear !== null && deathYear !== null) return ` (${birthYear}\u2013${deathYear})`;
|
|
1953
2411
|
if (birthYear !== null) return ` (born ${birthYear})`;
|
|
1954
2412
|
if (deathYear !== null) return ` (died ${deathYear})`;
|
|
1955
2413
|
return "";
|
|
1956
2414
|
}
|
|
1957
|
-
function
|
|
2415
|
+
function emptyPageNote2(emptiness, page2, words, windowFull) {
|
|
1958
2416
|
const quoted = words.map((word) => `"${word}"`).join(", ");
|
|
2417
|
+
if (windowFull) {
|
|
2418
|
+
return `The window this search reads off the index came back full, and no person record survived the reading: every row of it named something the authority file types as another kind of heading. Whether a person carries ${quoted} in a name is unanswered here rather than answered no. Narrow the search, by adding a word or by writing a fuller form of the name, so that the window reaches further into the index.`;
|
|
2419
|
+
}
|
|
1959
2420
|
if (emptiness === "past_the_end") {
|
|
1960
2421
|
return `Page ${page2} holds no row because it sits past the last row of this search. Records do carry ${quoted} in a name, and they are on earlier pages: call again with page=1, or with a lower page number, to read them.`;
|
|
1961
2422
|
}
|
|
1962
2423
|
if (emptiness === "undetermined") {
|
|
1963
2424
|
return `Page ${page2} holds no row, and reading the first page of the same search to find out why did not answer. This is either a name carrying ${quoted} matching no record, or a page sitting past the last row of a search that does match. Call again with page=1: rows there mean the second.`;
|
|
1964
2425
|
}
|
|
1965
|
-
return `No record in the BnF authority file carries every one of these words in a name: ${quoted}.
|
|
2426
|
+
return `No person record in the BnF authority file carries every one of these words in a name: ${quoted}. This search reads the person records and nothing else, so an organisation, a conference, a place or a subject heading is outside what was looked at, whether or not the BnF holds a heading for one. It reads names, so a person known by a pen name is found under that name rather than their own.`;
|
|
1966
2427
|
}
|
|
1967
|
-
function
|
|
2428
|
+
function emptyPageBody2(emptiness, page2, name, windowFull) {
|
|
2429
|
+
if (windowFull) {
|
|
2430
|
+
return `No person record survived the reading for "${name}", out of a window of the index that came back full. Narrow the search.`;
|
|
2431
|
+
}
|
|
1968
2432
|
if (emptiness === "past_the_end") {
|
|
1969
2433
|
return `Page ${page2} of the search for "${name}" holds no row: it sits past the last row. Call again with page=1.`;
|
|
1970
2434
|
}
|
|
@@ -1975,7 +2439,8 @@ function emptyPageBody(emptiness, page2, name) {
|
|
|
1975
2439
|
}
|
|
1976
2440
|
async function runSearchAuthors(client, args) {
|
|
1977
2441
|
try {
|
|
1978
|
-
const
|
|
2442
|
+
const reading = client.searchReading(args.name);
|
|
2443
|
+
const words = reading.words;
|
|
1979
2444
|
if (words.length === 0) {
|
|
1980
2445
|
return toToolError(
|
|
1981
2446
|
invalidInput(
|
|
@@ -1994,8 +2459,21 @@ async function runSearchAuthors(client, args) {
|
|
|
1994
2459
|
);
|
|
1995
2460
|
}
|
|
1996
2461
|
const { data, cached, retrievedAt } = await client.searchAuthors(args.name, args.limit, offset);
|
|
2462
|
+
const terms = reading.terms;
|
|
2463
|
+
const windowFull = data.indexWindowFull ?? null;
|
|
1997
2464
|
const notes = [];
|
|
1998
2465
|
if (cached) notes.push("Served from this server's short-lived in-memory cache.");
|
|
2466
|
+
if (windowFull) {
|
|
2467
|
+
notes.push(WINDOW_FULL_CAVEAT);
|
|
2468
|
+
if (!data.hasMore) notes.push(WINDOW_FULL_AND_NO_MORE);
|
|
2469
|
+
}
|
|
2470
|
+
const split = words.filter((word) => /['-]/u.test(word));
|
|
2471
|
+
if (split.length > 0) {
|
|
2472
|
+
notes.push(
|
|
2473
|
+
`${split.map((word) => `"${word}"`).join(", ")} reached the index as separate terms: it cuts a word at an apostrophe and at a hyphen, and requires each piece to appear in the name rather than in that arrangement, so a record writing the pieces apart is a match.`
|
|
2474
|
+
);
|
|
2475
|
+
}
|
|
2476
|
+
notes.push(...readingNotes(reading, "name"));
|
|
1999
2477
|
const authors = data.rows.map((row) => ({
|
|
2000
2478
|
id: row.id,
|
|
2001
2479
|
name: row.name,
|
|
@@ -2006,6 +2484,16 @@ async function runSearchAuthors(client, args) {
|
|
|
2006
2484
|
source_url: row.sourceUrl
|
|
2007
2485
|
}));
|
|
2008
2486
|
if (authors.length > 0) notes.push(NO_RANKING);
|
|
2487
|
+
notes.push(SPELLING_CAVEAT);
|
|
2488
|
+
const disputed = authors.map((author) => ({
|
|
2489
|
+
label: author.label ?? author.name,
|
|
2490
|
+
conflicts: headingYearConflicts(author.label, author.birth_year, author.death_year)
|
|
2491
|
+
})).filter((row) => row.conflicts.length > 0);
|
|
2492
|
+
if (disputed.length > 0) {
|
|
2493
|
+
notes.push(
|
|
2494
|
+
`${disputed.map((row) => `"${row.label}" states ${row.conflicts.join(", and ")}`).join("; ")}. A record stating its dates twice can disagree with itself, and the BnF publishes both. These fields are what tells two people of one name apart, so open source_url before telling them apart by a date the record disputes.`
|
|
2495
|
+
);
|
|
2496
|
+
}
|
|
2009
2497
|
const repeated = /* @__PURE__ */ new Map();
|
|
2010
2498
|
for (const author of authors) {
|
|
2011
2499
|
const key = (author.name ?? "").toLocaleLowerCase("fr");
|
|
@@ -2024,18 +2512,13 @@ async function runSearchAuthors(client, args) {
|
|
|
2024
2512
|
}
|
|
2025
2513
|
let emptiness = "absent";
|
|
2026
2514
|
if (authors.length === 0) {
|
|
2027
|
-
emptiness = await classifyEmptyPage(args.page, async () => {
|
|
2515
|
+
emptiness = windowFull ? "absent" : await classifyEmptyPage(args.page, async () => {
|
|
2028
2516
|
const first = await client.searchAuthors(args.name, 1, 0);
|
|
2029
2517
|
return first.data.rows.length > 0;
|
|
2030
2518
|
});
|
|
2031
|
-
notes.push(
|
|
2519
|
+
notes.push(emptyPageNote2(emptiness, args.page, terms, windowFull === true));
|
|
2032
2520
|
}
|
|
2033
|
-
|
|
2034
|
-
notes.push(
|
|
2035
|
-
`The index was asked for ${words.map((word) => `"${word}"`).join(", ")}; anything in the name that was not a word was set aside.`
|
|
2036
|
-
);
|
|
2037
|
-
}
|
|
2038
|
-
const body = authors.length === 0 ? emptyPageBody(emptiness, args.page, args.name) : `${authors.length} record(s) for "${args.name}":
|
|
2521
|
+
const body = authors.length === 0 ? emptyPageBody2(emptiness, args.page, args.name, windowFull === true) : `${authors.length} record(s) for "${args.name}":
|
|
2039
2522
|
${authors.map((author, index) => {
|
|
2040
2523
|
const life = lifespan(author.birth_year, author.death_year);
|
|
2041
2524
|
const role = author.role ? ` \xB7 ${author.role}` : "";
|
|
@@ -2045,10 +2528,11 @@ ${authors.map((author, index) => {
|
|
|
2045
2528
|
return ok(
|
|
2046
2529
|
{
|
|
2047
2530
|
name: args.name,
|
|
2048
|
-
words_searched:
|
|
2531
|
+
words_searched: terms,
|
|
2049
2532
|
authors,
|
|
2050
2533
|
page: args.page,
|
|
2051
2534
|
has_more: data.hasMore,
|
|
2535
|
+
index_window_full: windowFull,
|
|
2052
2536
|
retrieved_at: retrievedAt,
|
|
2053
2537
|
notes
|
|
2054
2538
|
},
|
|
@@ -2061,40 +2545,51 @@ ${authors.map((author, index) => {
|
|
|
2061
2545
|
}
|
|
2062
2546
|
|
|
2063
2547
|
// src/tools/searchWorks.ts
|
|
2064
|
-
import { z as
|
|
2548
|
+
import { z as z9 } from "zod";
|
|
2065
2549
|
var searchWorksDescription = [
|
|
2066
2550
|
"Find a work in the Biblioth\xE8que nationale de France catalogue by words in its title, and get the identifier get_work and list_editions take.",
|
|
2067
|
-
"A row matches when its title carries every word given. The index returns no measure of how well a row matches, so the rows are
|
|
2551
|
+
"A row matches when its title carries every word given. The index returns no measure of how well a row matches, so the rows are ordered by the address of the record and the work a person would call the obvious answer can sit anywhere in the list or on a later page.",
|
|
2068
2552
|
"A study of a book carries the book's title, so a search for a famous title returns the criticism alongside the work. Read 'creators' to tell them apart.",
|
|
2069
|
-
"This reads titles and nothing else.
|
|
2070
|
-
"'status' says whether the BnF has established the work as a record of its own or holds it provisionally under a title it has catalogued."
|
|
2553
|
+
"This reads titles and nothing else. Searching a person's name returns the works written about them, whose creators are their critics; list_works is what walks from a person to the works the catalogue names them the creator of.",
|
|
2554
|
+
"'status' says whether the BnF has established the work as a record of its own or holds it provisionally under a title it has catalogued.",
|
|
2555
|
+
"One search reads a fixed window of the index, and that window is read before the works are told apart from everything else the index holds, so even a short list can rest on a full one. 'index_window_full' says which: when it is true, titles sit past what was read, 'has_more' being false says where the reading stopped, and another reading of the same search can bring back other rows. Add a word to the title to reach further."
|
|
2071
2556
|
].join(" ");
|
|
2072
2557
|
var searchWorksInput = strictInput({
|
|
2073
|
-
title:
|
|
2074
|
-
limit:
|
|
2075
|
-
page:
|
|
2558
|
+
title: z9.string().min(1).max(200).describe("Words from the title, such as 'saison enfer'. Every word given has to appear."),
|
|
2559
|
+
limit: z9.number().int().min(1).max(50).default(10),
|
|
2560
|
+
page: z9.number().int().min(1).max(100).default(1)
|
|
2076
2561
|
});
|
|
2077
|
-
var workRowSchema =
|
|
2078
|
-
id:
|
|
2079
|
-
title:
|
|
2080
|
-
date:
|
|
2081
|
-
creators:
|
|
2082
|
-
status:
|
|
2562
|
+
var workRowSchema = z9.object({
|
|
2563
|
+
id: z9.string().describe("Pass this to get_work, list_editions or find_digitised."),
|
|
2564
|
+
title: z9.string().nullable(),
|
|
2565
|
+
date: z9.string().nullable().describe("The year the record gives the work, as published."),
|
|
2566
|
+
creators: z9.array(z9.object({ id: z9.string(), name: z9.string().nullable() })).describe("Everyone the record credits. Empty when the record credits nobody."),
|
|
2567
|
+
status: z9.enum(["established", "provisional"]).describe(
|
|
2083
2568
|
"'established' is a work the BnF has settled as a record of its own. 'provisional' is a title it holds while a cataloguer settles it, whose identifier can change."
|
|
2084
2569
|
),
|
|
2085
|
-
source_url:
|
|
2570
|
+
source_url: z9.string()
|
|
2086
2571
|
});
|
|
2087
|
-
var searchWorksOutput =
|
|
2088
|
-
title:
|
|
2089
|
-
words_searched:
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2572
|
+
var searchWorksOutput = z9.object({
|
|
2573
|
+
title: z9.string().describe("The words asked for."),
|
|
2574
|
+
words_searched: z9.array(z9.string()).describe(
|
|
2575
|
+
"The terms the index required, each of which has to appear in the title. A word is cut at an apostrophe and at a hyphen before the index sees it, so a word given as one can appear here as two, and a record carrying the pieces apart is a match."
|
|
2576
|
+
),
|
|
2577
|
+
works: z9.array(workRowSchema),
|
|
2578
|
+
page: z9.number().int(),
|
|
2579
|
+
has_more: z9.boolean().describe(
|
|
2580
|
+
"Whether the endpoint held at least one further row beyond this page, within the window the search read off the index. Read it alongside 'index_window_full': false on a full window says where the reading stopped rather than that the catalogue holds nothing else."
|
|
2581
|
+
),
|
|
2582
|
+
index_window_full: z9.boolean().nullable().describe(
|
|
2583
|
+
"Whether the fixed window this search reads off the index came back full. The window is read before the works are separated from the rest of the index, so a short list can rest on a full window. True means titles carrying these words sit past what was read, and another reading can bring back other rows. False means the window held everything that matched. Null when the endpoint stated no occupancy."
|
|
2584
|
+
),
|
|
2093
2585
|
retrieved_at: retrievedAtSchema,
|
|
2094
|
-
notes:
|
|
2586
|
+
notes: z9.array(z9.string())
|
|
2095
2587
|
});
|
|
2096
|
-
function
|
|
2588
|
+
function emptyPageNote3(emptiness, page2, words, windowFull) {
|
|
2097
2589
|
const quoted = words.map((word) => `"${word}"`).join(", ");
|
|
2590
|
+
if (windowFull) {
|
|
2591
|
+
return `The window this search reads off the index came back full, and no work record survived the reading: every row of it named something the catalogue types as another kind of record. Whether a work carries ${quoted} in its title is unanswered here rather than answered no. Add a word to the title so that the window reaches further into the index.`;
|
|
2592
|
+
}
|
|
2098
2593
|
if (emptiness === "past_the_end") {
|
|
2099
2594
|
return `Page ${page2} holds no row because it sits past the last row of this search. Works do carry ${quoted} in their title, and they are on earlier pages: call again with page=1, or with a lower page number, to read them.`;
|
|
2100
2595
|
}
|
|
@@ -2103,7 +2598,10 @@ function emptyPageNote2(emptiness, page2, words) {
|
|
|
2103
2598
|
}
|
|
2104
2599
|
return `No work in the BnF catalogue has a title carrying every one of these words: ${quoted}. Every word given has to appear, so dropping one widens the search.`;
|
|
2105
2600
|
}
|
|
2106
|
-
function
|
|
2601
|
+
function emptyPageBody3(emptiness, page2, title, windowFull) {
|
|
2602
|
+
if (windowFull) {
|
|
2603
|
+
return `No work record survived the reading for "${title}", out of a window of the index that came back full. Add a word to the title.`;
|
|
2604
|
+
}
|
|
2107
2605
|
if (emptiness === "past_the_end") {
|
|
2108
2606
|
return `Page ${page2} of the search for "${title}" holds no row: it sits past the last row. Call again with page=1.`;
|
|
2109
2607
|
}
|
|
@@ -2114,7 +2612,8 @@ function emptyPageBody2(emptiness, page2, title) {
|
|
|
2114
2612
|
}
|
|
2115
2613
|
async function runSearchWorks(client, args) {
|
|
2116
2614
|
try {
|
|
2117
|
-
const
|
|
2615
|
+
const reading = client.searchReading(args.title);
|
|
2616
|
+
const words = reading.words;
|
|
2118
2617
|
if (words.length === 0) {
|
|
2119
2618
|
return toToolError(
|
|
2120
2619
|
invalidInput(
|
|
@@ -2133,8 +2632,21 @@ async function runSearchWorks(client, args) {
|
|
|
2133
2632
|
);
|
|
2134
2633
|
}
|
|
2135
2634
|
const { data, cached, retrievedAt } = await client.searchWorks(args.title, args.limit, offset);
|
|
2635
|
+
const terms = reading.terms;
|
|
2636
|
+
const windowFull = data.indexWindowFull ?? null;
|
|
2136
2637
|
const notes = [];
|
|
2137
2638
|
if (cached) notes.push("Served from this server's short-lived in-memory cache.");
|
|
2639
|
+
if (windowFull) {
|
|
2640
|
+
notes.push(WINDOW_FULL_CAVEAT);
|
|
2641
|
+
if (!data.hasMore) notes.push(WINDOW_FULL_AND_NO_MORE);
|
|
2642
|
+
}
|
|
2643
|
+
const split = words.filter((word) => /['-]/u.test(word));
|
|
2644
|
+
if (split.length > 0) {
|
|
2645
|
+
notes.push(
|
|
2646
|
+
`${split.map((word) => `"${word}"`).join(", ")} reached the index as separate terms: it cuts a word at an apostrophe and at a hyphen, and requires each piece to appear in the title rather than in that arrangement, so a record writing the pieces apart is a match.`
|
|
2647
|
+
);
|
|
2648
|
+
}
|
|
2649
|
+
notes.push(...readingNotes(reading, "title"));
|
|
2138
2650
|
const works = data.rows.map((row) => ({
|
|
2139
2651
|
id: row.id,
|
|
2140
2652
|
title: row.title,
|
|
@@ -2145,9 +2657,9 @@ async function runSearchWorks(client, args) {
|
|
|
2145
2657
|
}));
|
|
2146
2658
|
if (works.length > 0) notes.push(NO_RANKING);
|
|
2147
2659
|
if (works.some((work) => work.status === "provisional")) notes.push(PROVISIONAL_CAVEAT);
|
|
2148
|
-
if (
|
|
2660
|
+
if (terms.length > 2) {
|
|
2149
2661
|
notes.push(
|
|
2150
|
-
`All ${
|
|
2662
|
+
`All ${terms.length} terms were required: ${terms.map((term) => `"${term}"`).join(", ")}. A record catalogued under a shorter form of the title carries fewer of them and falls out of the answer, so dropping a word widens the search.`
|
|
2151
2663
|
);
|
|
2152
2664
|
}
|
|
2153
2665
|
if (works.length > 0 && works.every((work) => work.status === "provisional")) {
|
|
@@ -2162,13 +2674,13 @@ async function runSearchWorks(client, args) {
|
|
|
2162
2674
|
}
|
|
2163
2675
|
let emptiness = "absent";
|
|
2164
2676
|
if (works.length === 0) {
|
|
2165
|
-
emptiness = await classifyEmptyPage(args.page, async () => {
|
|
2677
|
+
emptiness = windowFull ? "absent" : await classifyEmptyPage(args.page, async () => {
|
|
2166
2678
|
const first = await client.searchWorks(args.title, 1, 0);
|
|
2167
2679
|
return first.data.rows.length > 0;
|
|
2168
2680
|
});
|
|
2169
|
-
notes.push(
|
|
2681
|
+
notes.push(emptyPageNote3(emptiness, args.page, terms, windowFull === true));
|
|
2170
2682
|
}
|
|
2171
|
-
const body = works.length === 0 ?
|
|
2683
|
+
const body = works.length === 0 ? emptyPageBody3(emptiness, args.page, args.title, windowFull === true) : `${works.length} work(s) whose title carries ${terms.map((term) => `"${term}"`).join(" and ")}:
|
|
2172
2684
|
${works.map((work, index) => {
|
|
2173
2685
|
const by = work.creators.length > 0 ? ` \xB7 ${work.creators.map((creator) => creator.name ?? creator.id).join(", ")}` : "";
|
|
2174
2686
|
const when = work.date ? ` (${work.date})` : "";
|
|
@@ -2179,10 +2691,11 @@ ${works.map((work, index) => {
|
|
|
2179
2691
|
return ok(
|
|
2180
2692
|
{
|
|
2181
2693
|
title: args.title,
|
|
2182
|
-
words_searched:
|
|
2694
|
+
words_searched: terms,
|
|
2183
2695
|
works,
|
|
2184
2696
|
page: args.page,
|
|
2185
2697
|
has_more: data.hasMore,
|
|
2698
|
+
index_window_full: windowFull,
|
|
2186
2699
|
retrieved_at: retrievedAt,
|
|
2187
2700
|
notes
|
|
2188
2701
|
},
|
|
@@ -2203,9 +2716,10 @@ var READ_ONLY = {
|
|
|
2203
2716
|
};
|
|
2204
2717
|
var INSTRUCTIONS = [
|
|
2205
2718
|
"Tools for data.bnf.fr, the open catalogue of the Biblioth\xE8que nationale de France. No API key and no account are needed.",
|
|
2206
|
-
"The usual path is search_authors or search_works to get an identifier, then get_author, get_work or list_editions to read the record it names.",
|
|
2719
|
+
"The usual path is search_authors or search_works to get an identifier, then get_author, get_work or list_editions to read the record it names. What a person wrote is list_works, which walks from a person to the works the catalogue names them the creator of; a title search cannot answer that, because it reads titles and a person's name in a title belongs to the books written about them.",
|
|
2720
|
+
"A list of a person's works is one link the catalogue holds and not a bibliography: the catalogue credits a person on a record in other ways, and the BnF holds editions whose work it has never established as a record of its own, so a work missing from the list is not a work the person did not write. The catalogue states a work's form as a code from a vocabulary it publishes no label for, and it states no form at all on many works, so keeping the rows carrying one code finds the works that declare it and never all the works of that form.",
|
|
2207
2721
|
"The BnF publishes these metadata on one condition: name the source and state the date they were retrieved. Every answer carries 'retrieved_at' for that reason, and repeating both alongside what you show is what the licence asks for.",
|
|
2208
|
-
"Both searches read a full-text index that answers whether a title or a name matches and never scores how well, so rows come back
|
|
2722
|
+
"Both searches read a full-text index that answers whether a title or a name matches and never scores how well, so rows come back ordered by the address of the record. Never present the first row as the best answer, and never describe a count as a ranking. Every listing here is paged along that same order, so a page and the page after it hold different records and nothing between them is skipped.",
|
|
2209
2723
|
"One person can hold several authority records, and search_authors returns all of them: show the caller the choice rather than picking one.",
|
|
2210
2724
|
"A work record is either established, with an ARK of its own, or provisional, addressed under 'temp-work' while a cataloguer settles it. A provisional identifier can change, so prefer an established one when citing.",
|
|
2211
2725
|
"The catalogue describes what the BnF holds; it does not hold the texts. A record can point at a digitised copy on Gallica, and those links are returned as addresses for a person to open. This server never requests gallica.bnf.fr, because the BnF places its metadata and its digitised contents under two different regimes, so it can say a document exists at a link and nothing about what is there.",
|
|
@@ -2279,6 +2793,17 @@ function createServer(options = {}) {
|
|
|
2279
2793
|
},
|
|
2280
2794
|
async (args) => runListEditions(client, args)
|
|
2281
2795
|
);
|
|
2796
|
+
server.registerTool(
|
|
2797
|
+
"list_works",
|
|
2798
|
+
{
|
|
2799
|
+
title: "List the works a person is credited with",
|
|
2800
|
+
description: listWorksDescription,
|
|
2801
|
+
inputSchema: listWorksInput,
|
|
2802
|
+
outputSchema: listWorksOutput,
|
|
2803
|
+
annotations: READ_ONLY
|
|
2804
|
+
},
|
|
2805
|
+
async (args) => runListWorks(client, args)
|
|
2806
|
+
);
|
|
2282
2807
|
server.registerTool(
|
|
2283
2808
|
"find_digitised",
|
|
2284
2809
|
{
|