@inflexa-ai/harness 0.16.0 → 0.18.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/dist/agents/conversation-agent.d.ts +3 -0
- package/dist/agents/conversation-agent.js +5 -2
- package/dist/agents/report-builder.d.ts +2 -0
- package/dist/agents/report-builder.js +2 -0
- package/dist/agents/sandbox/shared.d.ts +3 -0
- package/dist/agents/sandbox/shared.js +5 -0
- package/dist/agents/sandbox/types.d.ts +1 -1
- package/dist/app/chat-turn.d.ts +2 -0
- package/dist/app/chat-turn.js +11 -2
- package/dist/app/spawn-report-session.d.ts +85 -0
- package/dist/app/spawn-report-session.js +83 -0
- package/dist/app/synthesize-run.d.ts +3 -0
- package/dist/app/synthesize-run.js +2 -1
- package/dist/citations/__fixtures__/resolver.d.ts +3 -0
- package/dist/citations/__fixtures__/resolver.js +9 -0
- package/dist/citations/aggregate.d.ts +3 -0
- package/dist/citations/aggregate.js +44 -0
- package/dist/citations/clients/arxiv.d.ts +6 -0
- package/dist/citations/clients/arxiv.js +34 -0
- package/dist/citations/clients/common.d.ts +11 -0
- package/dist/citations/clients/common.js +43 -0
- package/dist/citations/clients/crossref.d.ts +8 -0
- package/dist/citations/clients/crossref.js +98 -0
- package/dist/citations/clients/doi-registry.d.ts +3 -0
- package/dist/citations/clients/doi-registry.js +95 -0
- package/dist/citations/clients/pubmed.d.ts +6 -0
- package/dist/citations/clients/pubmed.js +83 -0
- package/dist/citations/clients/semantic-scholar.d.ts +6 -0
- package/dist/citations/clients/semantic-scholar.js +60 -0
- package/dist/citations/compare.d.ts +9 -0
- package/dist/citations/compare.js +72 -0
- package/dist/citations/match.d.ts +11 -0
- package/dist/citations/match.js +173 -0
- package/dist/citations/normalize.d.ts +31 -0
- package/dist/citations/normalize.js +187 -0
- package/dist/citations/plan.d.ts +2 -0
- package/dist/citations/plan.js +59 -0
- package/dist/citations/resolve.d.ts +28 -0
- package/dist/citations/resolve.js +238 -0
- package/dist/citations/types.d.ts +583 -0
- package/dist/citations/types.js +129 -0
- package/dist/contracts/chat-events.d.ts +9 -0
- package/dist/contracts/index.d.ts +6 -2
- package/dist/contracts/index.js +3 -1
- package/dist/contracts/organ-system.d.ts +49 -0
- package/dist/contracts/organ-system.js +67 -0
- package/dist/contracts/report-blocks.d.ts +564 -0
- package/dist/contracts/report-blocks.js +117 -0
- package/dist/contracts/report-reference.d.ts +459 -0
- package/dist/contracts/report-reference.js +256 -0
- package/dist/contracts/schemas/chat-events.d.ts +2 -0
- package/dist/contracts/schemas/chat-events.js +2 -0
- package/dist/contracts/schemas/chat-parts.d.ts +8 -8
- package/dist/contracts/severity.d.ts +15 -0
- package/dist/contracts/severity.js +10 -0
- package/dist/contracts/target-dossier.d.ts +8673 -12970
- package/dist/contracts/target-dossier.js +534 -591
- package/dist/data/safety-panel-schema.d.ts +18 -5
- package/dist/data/safety-panel-schema.js +4 -4
- package/dist/execution/artifact-metadata.js +1 -0
- package/dist/execution/report-runner.js +1 -3
- package/dist/execution/run-synthesis.d.ts +3 -0
- package/dist/execution/run-synthesis.js +3 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.js +22 -0
- package/dist/lib/async-utils.d.ts +16 -4
- package/dist/lib/async-utils.js +20 -15
- package/dist/lib/cache.d.ts +8 -0
- package/dist/lib/cache.js +35 -0
- package/dist/lib/host-concurrency.js +1 -0
- package/dist/lib/rate-limit.d.ts +35 -0
- package/dist/lib/rate-limit.js +121 -0
- package/dist/literature/sources/arxiv.d.ts +22 -0
- package/dist/literature/sources/arxiv.js +103 -0
- package/dist/literature/sources/http.d.ts +28 -0
- package/dist/literature/sources/http.js +97 -0
- package/dist/literature/sources/pubmed.d.ts +68 -0
- package/dist/literature/sources/pubmed.js +266 -0
- package/dist/literature/sources/semantic-scholar.d.ts +24 -0
- package/dist/literature/sources/semantic-scholar.js +71 -0
- package/dist/loop/__fixtures__/scripted-provider.js +23 -2
- package/dist/loop/run-agent.js +58 -10
- package/dist/loop/tool-detail.d.ts +40 -0
- package/dist/loop/tool-detail.js +35 -3
- package/dist/loop/types.d.ts +13 -0
- package/dist/memory/thread-history.d.ts +10 -0
- package/dist/memory/thread-history.js +12 -1
- package/dist/memory/thread-store.d.ts +142 -25
- package/dist/memory/thread-store.js +228 -40
- package/dist/prompts/literature-reviewer.d.ts +1 -1
- package/dist/prompts/literature-reviewer.js +2 -0
- package/dist/prompts/target-assessment/briefs/executive-recommendation.d.ts +1 -1
- package/dist/prompts/target-assessment/briefs/executive-recommendation.js +5 -5
- package/dist/prompts/target-assessment/briefs/liability-bullets.d.ts +1 -1
- package/dist/prompts/target-assessment/briefs/liability-bullets.js +1 -1
- package/dist/prompts/target-assessment/briefs/target-organ-liabilities.d.ts +1 -1
- package/dist/prompts/target-assessment/briefs/target-organ-liabilities.js +11 -3
- package/dist/prompts/target-assessment/investigation/adversarial-critique.d.ts +1 -0
- package/dist/prompts/target-assessment/investigation/adversarial-critique.js +67 -0
- package/dist/prompts/target-assessment/investigation/claim-reverification.d.ts +1 -0
- package/dist/prompts/target-assessment/investigation/claim-reverification.js +49 -0
- package/dist/prompts/target-assessment/investigation/index.d.ts +3 -0
- package/dist/prompts/target-assessment/investigation/index.js +3 -0
- package/dist/prompts/target-assessment/investigation/mechanism-proposal.d.ts +1 -0
- package/dist/prompts/target-assessment/investigation/mechanism-proposal.js +49 -0
- package/dist/prompts/target-assessment/tox-voice/index.d.ts +1 -1
- package/dist/prompts/target-assessment/tox-voice/vocabulary.d.ts +8 -5
- package/dist/prompts/target-assessment/tox-voice/vocabulary.js +9 -20
- package/dist/providers/__fixtures__/session.d.ts +5 -4
- package/dist/providers/__fixtures__/session.js +4 -4
- package/dist/report-model/block-walk.d.ts +56 -0
- package/dist/report-model/block-walk.js +89 -0
- package/dist/report-model/draft-finish.d.ts +60 -0
- package/dist/report-model/draft-finish.js +56 -0
- package/dist/report-model/draft-operations.d.ts +125 -0
- package/dist/report-model/draft-operations.js +341 -0
- package/dist/report-model/draft-read.d.ts +59 -0
- package/dist/report-model/draft-read.js +109 -0
- package/dist/report-model/draft.d.ts +51 -0
- package/dist/report-model/draft.js +32 -0
- package/dist/report-model/fixture-resolver.d.ts +8 -0
- package/dist/report-model/fixture-resolver.js +252 -0
- package/dist/report-model/mint-snapshot.d.ts +30 -0
- package/dist/report-model/mint-snapshot.js +40 -0
- package/dist/report-model/reference-resolver.d.ts +113 -0
- package/dist/report-model/reference-resolver.js +60 -0
- package/dist/report-model/structural-validation.d.ts +22 -0
- package/dist/report-model/structural-validation.js +68 -0
- package/dist/report-model/validate.d.ts +40 -0
- package/dist/report-model/validate.js +80 -0
- package/dist/report-render/chart.d.ts +33 -0
- package/dist/report-render/chart.js +538 -0
- package/dist/report-render/page.d.ts +127 -0
- package/dist/report-render/page.js +307 -0
- package/dist/report-render/references.d.ts +32 -0
- package/dist/report-render/references.js +44 -0
- package/dist/report-render/render.d.ts +25 -0
- package/dist/report-render/render.js +125 -0
- package/dist/report-render/script-json.d.ts +13 -0
- package/dist/report-render/script-json.js +15 -0
- package/dist/report-render/types.d.ts +39 -0
- package/dist/report-render/types.js +10 -0
- package/dist/report-render/views/chart-view.d.ts +11 -0
- package/dist/report-render/views/chart-view.js +23 -0
- package/dist/report-render/views/page-view.d.ts +20 -0
- package/dist/report-render/views/page-view.js +49 -0
- package/dist/report-render/views/prose.d.ts +28 -0
- package/dist/report-render/views/prose.js +75 -0
- package/dist/report-render/views/references-view.d.ts +20 -0
- package/dist/report-render/views/references-view.js +64 -0
- package/dist/report-render/views/values.d.ts +42 -0
- package/dist/report-render/views/values.js +43 -0
- package/dist/runtime/assemble.d.ts +50 -4
- package/dist/runtime/assemble.js +47 -3
- package/dist/runtime/boot.d.ts +3 -3
- package/dist/runtime/boot.js +22 -5
- package/dist/runtime/dbos.d.ts +4 -4
- package/dist/state/artifacts.d.ts +16 -0
- package/dist/state/artifacts.js +18 -0
- package/dist/state/init.js +77 -1
- package/dist/state/purge-analysis.js +1 -0
- package/dist/state/report-versions.d.ts +132 -0
- package/dist/state/report-versions.js +194 -0
- package/dist/state/schema.d.ts +2 -2
- package/dist/tasks/data-profile.js +1 -0
- package/dist/tools/bio/chembl.js +1 -0
- package/dist/tools/bio/comptox.js +1 -0
- package/dist/tools/bio/drug-gene-interactions.js +1 -0
- package/dist/tools/bio/gene-disease-evidence.js +1 -0
- package/dist/tools/bio/gene-preclinical-profile.d.ts +1 -1
- package/dist/tools/bio/gene-preclinical-profile.js +1 -0
- package/dist/tools/bio/keys.d.ts +4 -2
- package/dist/tools/bio/keys.js +3 -2
- package/dist/tools/bio/lookup-annotation.js +1 -0
- package/dist/tools/bio/opentargets.d.ts +3 -3
- package/dist/tools/bio/opentargets.js +4 -3
- package/dist/tools/bio/pubchem.js +1 -0
- package/dist/tools/bio/pubmed.js +4 -4
- package/dist/tools/bio/search-clinical-trials.js +1 -0
- package/dist/tools/bio/search-faers.js +1 -0
- package/dist/tools/bio/search-geo-datasets.js +1 -0
- package/dist/tools/bio/search-interactions.js +1 -0
- package/dist/tools/bio/target-safety.d.ts +4 -6
- package/dist/tools/bio/target-safety.js +7 -6
- package/dist/tools/define-tool.d.ts +19 -12
- package/dist/tools/define-tool.js +4 -2
- package/dist/tools/display/show-user.js +1 -0
- package/dist/tools/iterate-report.js +2 -0
- package/dist/tools/lib/chembl-client.d.ts +2 -0
- package/dist/tools/lib/chembl-client.js +1 -0
- package/dist/tools/lib/monarch-client.d.ts +42 -0
- package/dist/tools/lib/monarch-client.js +100 -0
- package/dist/tools/lib/ncbi-utils.d.ts +3 -58
- package/dist/tools/lib/ncbi-utils.js +2 -286
- package/dist/tools/lib/pubmed-client.d.ts +5 -12
- package/dist/tools/lib/pubmed-client.js +20 -79
- package/dist/tools/report/build-report.js +1 -0
- package/dist/tools/report/mint-preview-url.js +1 -0
- package/dist/tools/report/preview-snapshot.js +1 -0
- package/dist/tools/report/submit-report.js +1 -0
- package/dist/tools/report/version-fs.js +4 -0
- package/dist/tools/report-authoring/authoring-tools.d.ts +134 -0
- package/dist/tools/report-authoring/authoring-tools.js +352 -0
- package/dist/tools/research/context7-docs.js +2 -0
- package/dist/tools/research/generate-analogy-report.js +3 -2
- package/dist/tools/research/generate-plan.js +3 -0
- package/dist/tools/research/index.d.ts +1 -0
- package/dist/tools/research/index.js +1 -0
- package/dist/tools/research/inspect-data-profile.js +4 -0
- package/dist/tools/research/inspect-run.d.ts +1 -1
- package/dist/tools/research/literature-reviewer.d.ts +3 -0
- package/dist/tools/research/literature-reviewer.js +3 -0
- package/dist/tools/research/resolve-citation.d.ts +3 -0
- package/dist/tools/research/resolve-citation.js +15 -0
- package/dist/tools/research/search-arxiv.d.ts +3 -19
- package/dist/tools/research/search-arxiv.js +10 -105
- package/dist/tools/research/search-github-repos.js +1 -0
- package/dist/tools/research/search-semantic-scholar.d.ts +8 -33
- package/dist/tools/research/search-semantic-scholar.js +30 -88
- package/dist/tools/sandbox/list-available-packages.js +23 -0
- package/dist/tools/sandbox/list-available-refs.js +37 -2
- package/dist/tools/sandbox/report-blocker.js +1 -0
- package/dist/tools/sandbox/skills.js +2 -0
- package/dist/tools/workspace/file-stat.js +1 -0
- package/dist/tools/workspace/show-file.js +2 -0
- package/dist/tools/workspace/show-plan.js +1 -0
- package/dist/workflows/execute-analysis.d.ts +8 -1
- package/dist/workflows/execute-analysis.js +6 -5
- package/dist/workflows/execute-target-assessment.d.ts +13 -2
- package/dist/workflows/execute-target-assessment.js +78 -16
- package/dist/workflows/sandbox-step.d.ts +5 -0
- package/dist/workflows/sandbox-step.js +1 -0
- package/dist/workflows/target-assessment/assemblers/index.d.ts +1 -1
- package/dist/workflows/target-assessment/assemblers/index.js +1 -1
- package/dist/workflows/target-assessment/assemblers/literature.d.ts +9 -27
- package/dist/workflows/target-assessment/assemblers/literature.js +32 -48
- package/dist/workflows/target-assessment/assemblers/orchestrator.d.ts +17 -9
- package/dist/workflows/target-assessment/assemblers/orchestrator.js +195 -85
- package/dist/workflows/target-assessment/assemblers/safety.d.ts +70 -48
- package/dist/workflows/target-assessment/assemblers/safety.js +125 -42
- package/dist/workflows/target-assessment/collectors/index.d.ts +5 -2
- package/dist/workflows/target-assessment/collectors/index.js +48 -5
- package/dist/workflows/target-assessment/coverage.d.ts +43 -27
- package/dist/workflows/target-assessment/coverage.js +33 -29
- package/dist/workflows/target-assessment/fanout/index.js +1 -0
- package/dist/workflows/target-assessment/investigation/index.d.ts +97 -0
- package/dist/workflows/target-assessment/investigation/index.js +419 -0
- package/dist/workflows/target-assessment/lib/approval-precedents.d.ts +26 -5
- package/dist/workflows/target-assessment/lib/approval-precedents.js +95 -29
- package/dist/workflows/target-assessment/lib/citation-validator.js +1 -1
- package/dist/workflows/target-assessment/lib/clinical-consequence-annotator.d.ts +2 -2
- package/dist/workflows/target-assessment/lib/clinical-evidence-attribution.d.ts +2 -3
- package/dist/workflows/target-assessment/lib/clinical-evidence-attribution.js +0 -4
- package/dist/workflows/target-assessment/lib/compute-derived.d.ts +6 -5
- package/dist/workflows/target-assessment/lib/compute-derived.js +14 -5
- package/dist/workflows/target-assessment/lib/family-complex-supplement.d.ts +5 -6
- package/dist/workflows/target-assessment/lib/family-complex-supplement.js +2 -3
- package/dist/workflows/target-assessment/lib/fda-label-safety.d.ts +72 -0
- package/dist/workflows/target-assessment/lib/fda-label-safety.js +166 -0
- package/dist/workflows/target-assessment/lib/heterodimer-filter.d.ts +0 -9
- package/dist/workflows/target-assessment/lib/heterodimer-filter.js +0 -10
- package/dist/workflows/target-assessment/lib/hpo-organ-map.d.ts +34 -0
- package/dist/workflows/target-assessment/lib/hpo-organ-map.js +63 -0
- package/dist/workflows/target-assessment/lib/impc-organ-map.d.ts +19 -0
- package/dist/workflows/target-assessment/lib/impc-organ-map.js +39 -0
- package/dist/workflows/target-assessment/lib/meddra-organ-map.d.ts +24 -14
- package/dist/workflows/target-assessment/lib/meddra-organ-map.js +59 -47
- package/dist/workflows/target-assessment/lib/recommendation-sanitizer.js +1 -1
- package/dist/workflows/target-assessment/lib/regulatory-actions.d.ts +2 -3
- package/dist/workflows/target-assessment/lib/regulatory-actions.js +33 -4
- package/dist/workflows/target-assessment/lib/safety-corroboration.d.ts +44 -0
- package/dist/workflows/target-assessment/lib/safety-corroboration.js +273 -0
- package/dist/workflows/target-assessment/lib/target-identity-filter.d.ts +5 -7
- package/dist/workflows/target-assessment/lib/target-identity-filter.js +5 -7
- package/dist/workflows/target-assessment/metrics.d.ts +1 -6
- package/dist/workflows/target-assessment/metrics.js +1 -13
- package/dist/workflows/target-assessment/phase4-assemble.d.ts +2149 -809
- package/dist/workflows/target-assessment/phase4-assemble.js +19 -19
- package/dist/workflows/target-assessment/phase5-persist.d.ts +20 -21
- package/dist/workflows/target-assessment/phase5-persist.js +56 -173
- package/dist/workflows/target-assessment/schemas.d.ts +48 -3
- package/dist/workflows/target-assessment/schemas.js +19 -0
- package/dist/workflows/target-assessment/steps/fanout/polypharm-for-one-modulator-step.d.ts +1 -0
- package/dist/workflows/target-assessment/steps/fanout/polypharm-for-one-modulator-step.js +2 -0
- package/dist/workflows/target-assessment/steps/phase2-aggregate.d.ts +27 -63
- package/dist/workflows/target-assessment/steps/phase2-aggregate.js +0 -4
- package/dist/workflows/target-assessment/steps/phase3-aggregate.d.ts +28 -155
- package/dist/workflows/target-assessment/steps/phase3-aggregate.js +0 -6
- package/dist/workflows/target-assessment/steps/synthesis/diagnostics.d.ts +0 -6
- package/dist/workflows/target-assessment/steps/synthesis/diagnostics.js +0 -18
- package/dist/workflows/target-assessment/sub-workflows/per-modulator-polypharm.d.ts +1 -0
- package/dist/workflows/target-assessment/synthesis/index.d.ts +52 -5
- package/dist/workflows/target-assessment/synthesis/index.js +4 -1
- package/dist/workflows/target-assessment/synthesis-probes.d.ts +3 -3
- package/package.json +4 -1
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { CitationInputSchema } from "./types.js";
|
|
2
|
+
// Each registry defines its own identifier syntax, and each is matched here rather than parsed:
|
|
3
|
+
// a citation string is prose, so the identifier has to be found inside it, not read off a field.
|
|
4
|
+
// The DOI suffix is deliberately permissive — the registry allows nearly any character in it, so
|
|
5
|
+
// there is no delimiter to stop at, and `trimDoiPunctuation` walks back the sentence punctuation
|
|
6
|
+
// and brackets a greedy match swallows. PMID and arXiv anchor instead, having fixed shapes.
|
|
7
|
+
const DOI_PATTERN = /(?:https?:\/\/(?:dx\.)?doi\.org\/|doi\s*:\s*)?(10\.\d{4,9}\/[^\s<>"']+)/i;
|
|
8
|
+
const PMID_PATTERN = /^(?:pmid\s*:\s*)?(\d{1,9})$/i;
|
|
9
|
+
const ARXIV_PATTERN = /^(?:https?:\/\/arxiv\.org\/(?:abs|pdf)\/|arxiv\s*:\s*)?((?:\d{4}\.\d{4,5}|[a-z-]+(?:\.[a-z-]+)?\/\d{7})(?:v\d+)?)(?:\.pdf)?$/i;
|
|
10
|
+
function collapseWhitespace(value) {
|
|
11
|
+
return value.normalize("NFKC").replace(/\s+/g, " ").trim();
|
|
12
|
+
}
|
|
13
|
+
export function normalizeComparable(value) {
|
|
14
|
+
return collapseWhitespace(value)
|
|
15
|
+
.toLocaleLowerCase("en-US")
|
|
16
|
+
.replace(/[\p{P}\p{S}]+/gu, " ")
|
|
17
|
+
.replace(/\s+/g, " ")
|
|
18
|
+
.trim();
|
|
19
|
+
}
|
|
20
|
+
export function normalizeAuthor(value) {
|
|
21
|
+
return normalizeComparable(value)
|
|
22
|
+
.replace(/\b(?:jr|sr|ii|iii|iv)\b/g, "")
|
|
23
|
+
.replace(/\s+/g, " ")
|
|
24
|
+
.trim();
|
|
25
|
+
}
|
|
26
|
+
function authorEvidence(value) {
|
|
27
|
+
const tokens = normalizeAuthor(value).split(" ").filter(Boolean);
|
|
28
|
+
return { words: tokens.filter((token) => token.length > 1), initials: tokens.filter((token) => token.length === 1) };
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A token long enough to be a spelled-out name part rather than an unpunctuated
|
|
32
|
+
* block of initials. Two letters is the boundary: `Smith JA` and `Wu` are both
|
|
33
|
+
* live readings of the same shape, so a token that short is never treated as a
|
|
34
|
+
* name part that could contradict one.
|
|
35
|
+
*/
|
|
36
|
+
const SPELLED_OUT_MINIMUM = 3;
|
|
37
|
+
/**
|
|
38
|
+
* The given-name letters a name can account for. A short leftover may itself be
|
|
39
|
+
* an initial block, so it contributes every letter; a spelled-out one
|
|
40
|
+
* contributes only the initial it would abbreviate to.
|
|
41
|
+
*/
|
|
42
|
+
function givenLetters(leftover, initials) {
|
|
43
|
+
return [...initials, ...leftover.flatMap((word) => (word.length < SPELLED_OUT_MINIMUM ? [...word] : [word[0]]))].sort();
|
|
44
|
+
}
|
|
45
|
+
function containsLetters(available, required) {
|
|
46
|
+
const pool = [...available];
|
|
47
|
+
return required.every((letter) => {
|
|
48
|
+
const index = pool.indexOf(letter);
|
|
49
|
+
if (index < 0)
|
|
50
|
+
return false;
|
|
51
|
+
pool.splice(index, 1);
|
|
52
|
+
return true;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Whether two author strings can name the same person. Written surname-position
|
|
57
|
+
* free, because citation styles disagree about it (`Smith, Jane A.`,
|
|
58
|
+
* `Jane A. Smith`, `Smith JA` all reach us) and normalization has already
|
|
59
|
+
* discarded the comma that would settle it.
|
|
60
|
+
*
|
|
61
|
+
* Sharing a name part is necessary but never sufficient: two spelled-out names
|
|
62
|
+
* that each carry a part the other lacks are different people, and where both
|
|
63
|
+
* sides supply given-name evidence it has to agree. Surname-only agreement is
|
|
64
|
+
* accepted only when one side supplies no given name at all — the case where
|
|
65
|
+
* there is nothing to contradict.
|
|
66
|
+
*/
|
|
67
|
+
export function authorNamesMatch(left, right) {
|
|
68
|
+
const a = authorEvidence(left);
|
|
69
|
+
const b = authorEvidence(right);
|
|
70
|
+
if (!a.words.some((word) => b.words.includes(word)))
|
|
71
|
+
return false;
|
|
72
|
+
const leftoverA = a.words.filter((word) => !b.words.includes(word));
|
|
73
|
+
const leftoverB = b.words.filter((word) => !a.words.includes(word));
|
|
74
|
+
const spelledOut = (leftover) => leftover.some((word) => word.length >= SPELLED_OUT_MINIMUM);
|
|
75
|
+
if (spelledOut(leftoverA) && spelledOut(leftoverB))
|
|
76
|
+
return false;
|
|
77
|
+
const lettersA = givenLetters(leftoverA, a.initials);
|
|
78
|
+
const lettersB = givenLetters(leftoverB, b.initials);
|
|
79
|
+
if (lettersA.length === 0 || lettersB.length === 0)
|
|
80
|
+
return true;
|
|
81
|
+
return lettersA.length <= lettersB.length ? containsLetters(lettersB, lettersA) : containsLetters(lettersA, lettersB);
|
|
82
|
+
}
|
|
83
|
+
function trimDoiPunctuation(value) {
|
|
84
|
+
let result = value.replace(/[.,;]+$/g, "");
|
|
85
|
+
const pairs = [
|
|
86
|
+
["(", ")"],
|
|
87
|
+
["[", "]"],
|
|
88
|
+
["{", "}"],
|
|
89
|
+
];
|
|
90
|
+
for (const [open, close] of pairs) {
|
|
91
|
+
while (result.endsWith(close) && result.split(open).length <= result.split(close).length)
|
|
92
|
+
result = result.slice(0, -1);
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
export function extractDoi(value) {
|
|
97
|
+
const match = collapseWhitespace(value).match(DOI_PATTERN);
|
|
98
|
+
if (!match?.[1])
|
|
99
|
+
return undefined;
|
|
100
|
+
return trimDoiPunctuation(match[1]).toLocaleLowerCase("en-US");
|
|
101
|
+
}
|
|
102
|
+
export function extractPmid(value) {
|
|
103
|
+
return collapseWhitespace(value).match(PMID_PATTERN)?.[1];
|
|
104
|
+
}
|
|
105
|
+
export function extractArxivId(value) {
|
|
106
|
+
const match = collapseWhitespace(value).match(ARXIV_PATTERN)?.[1];
|
|
107
|
+
return match?.toLocaleLowerCase("en-US").replace(/v\d+$/i, "");
|
|
108
|
+
}
|
|
109
|
+
function classifyUnsupportedWork(value) {
|
|
110
|
+
const normalized = normalizeComparable(value);
|
|
111
|
+
if (/\bpersonal communication\b/.test(normalized))
|
|
112
|
+
return "personal_communication";
|
|
113
|
+
if (/\bunpublished (?:data|work|manuscript)\b/.test(normalized))
|
|
114
|
+
return "unpublished";
|
|
115
|
+
if (/\b(?:in press|forthcoming)\b/.test(normalized))
|
|
116
|
+
return "unregistered_in_press";
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
function normalizedSupplied(input) {
|
|
120
|
+
return {
|
|
121
|
+
...(input.title === undefined ? {} : { title: normalizeComparable(input.title) }),
|
|
122
|
+
...(input.authors === undefined ? {} : { authors: input.authors.map(normalizeAuthor).filter(Boolean) }),
|
|
123
|
+
...(input.year === undefined ? {} : { year: input.year }),
|
|
124
|
+
...(input.venue === undefined ? {} : { venue: normalizeComparable(input.venue) }),
|
|
125
|
+
...(input.volume === undefined ? {} : { volume: normalizeComparable(input.volume) }),
|
|
126
|
+
...(input.firstPage === undefined ? {} : { firstPage: normalizeComparable(input.firstPage) }),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
export function normalizeCitation(unparsed) {
|
|
130
|
+
const input = CitationInputSchema.parse(unparsed);
|
|
131
|
+
const citation = collapseWhitespace(input.citation);
|
|
132
|
+
const doi = extractDoi(citation);
|
|
133
|
+
const pmid = extractPmid(citation);
|
|
134
|
+
const arxiv = extractArxivId(citation);
|
|
135
|
+
const hint = input.kind ?? "auto";
|
|
136
|
+
let kind;
|
|
137
|
+
if (hint === "auto")
|
|
138
|
+
kind = doi ? "doi" : pmid ? "pmid" : arxiv ? "arxiv" : "free_text";
|
|
139
|
+
else
|
|
140
|
+
kind = hint;
|
|
141
|
+
if (kind === "doi" && doi === undefined)
|
|
142
|
+
throw new Error("citation kind is doi but no valid DOI was found");
|
|
143
|
+
if (kind === "pmid" && pmid === undefined)
|
|
144
|
+
throw new Error("citation kind is pmid but no valid PMID was found");
|
|
145
|
+
if (kind === "arxiv" && arxiv === undefined)
|
|
146
|
+
throw new Error("citation kind is arxiv but no valid arXiv id was found");
|
|
147
|
+
const unsupportedWorkKind = kind === "free_text" ? classifyUnsupportedWork(citation) : undefined;
|
|
148
|
+
// A pinned `free_text` kind asserts the citation carries no usable identifier — `auto`
|
|
149
|
+
// would have classified it otherwise. Publishing one anyway splits the pipeline against
|
|
150
|
+
// itself: the plan gives the citation bibliographic-only treatment while candidate
|
|
151
|
+
// scoring short-circuits to a perfect match on an identifier nothing verified.
|
|
152
|
+
const identified = kind !== "free_text";
|
|
153
|
+
return {
|
|
154
|
+
citation,
|
|
155
|
+
query: normalizeComparable(citation),
|
|
156
|
+
kind,
|
|
157
|
+
identifiers: {
|
|
158
|
+
...(doi === undefined || !identified ? {} : { doi }),
|
|
159
|
+
...(pmid === undefined || !identified ? {} : { pmid }),
|
|
160
|
+
...(arxiv === undefined || !identified ? {} : { arxiv }),
|
|
161
|
+
},
|
|
162
|
+
supplied: normalizedSupplied(input),
|
|
163
|
+
...(unsupportedWorkKind === undefined ? {} : { unsupportedWorkKind }),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Identity of the *lookups* an input provokes, not of the input itself.
|
|
168
|
+
*
|
|
169
|
+
* Two citations share this key when the authorities would be asked the same
|
|
170
|
+
* question, so they share one round of source outcomes; each caller's own
|
|
171
|
+
* supplied metadata is compared against those outcomes separately. An exact
|
|
172
|
+
* identifier is the whole question — every operation an identifier plan selects
|
|
173
|
+
* is driven by the identifier alone — so supplied metadata stays out of the key
|
|
174
|
+
* there. A free-text citation is dispatched from its metadata, and folds it in.
|
|
175
|
+
*/
|
|
176
|
+
export function citationLookupKey(input) {
|
|
177
|
+
const normalized = normalizeCitation(input);
|
|
178
|
+
return JSON.stringify(normalized.kind === "free_text"
|
|
179
|
+
? {
|
|
180
|
+
kind: normalized.kind,
|
|
181
|
+
identifiers: normalized.identifiers,
|
|
182
|
+
query: normalized.query,
|
|
183
|
+
supplied: normalized.supplied,
|
|
184
|
+
unsupportedWorkKind: normalized.unsupportedWorkKind ?? null,
|
|
185
|
+
}
|
|
186
|
+
: { kind: normalized.kind, identifiers: normalized.identifiers });
|
|
187
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const SOURCES = ["doi_registry", "crossref", "pubmed", "arxiv", "semantic_scholar"];
|
|
2
|
+
function item(source, applicable, operation, reason, candidateGeneration = false) {
|
|
3
|
+
return { source, applicable, operation: applicable ? operation : "none", candidateGeneration: applicable && candidateGeneration, reason };
|
|
4
|
+
}
|
|
5
|
+
function enoughForEcitMatch(input) {
|
|
6
|
+
if (!input.venue)
|
|
7
|
+
return false;
|
|
8
|
+
const disambiguators = [input.year, input.volume, input.firstPage, input.authors?.[0]].filter((value) => value !== undefined).length;
|
|
9
|
+
return disambiguators >= 2;
|
|
10
|
+
}
|
|
11
|
+
function indicatesArxiv(input, normalized) {
|
|
12
|
+
return /\b(?:arxiv|preprint)\b/i.test(input.citation) || /\barxiv\b/i.test(input.venue ?? "") || normalized.identifiers.arxiv !== undefined;
|
|
13
|
+
}
|
|
14
|
+
export function planCitationSources(input, normalized) {
|
|
15
|
+
if (normalized.unsupportedWorkKind !== undefined) {
|
|
16
|
+
return SOURCES.map((source) => item(source, false, "none", `unsupported work kind: ${normalized.unsupportedWorkKind}`));
|
|
17
|
+
}
|
|
18
|
+
if (normalized.kind === "doi") {
|
|
19
|
+
return [
|
|
20
|
+
item("doi_registry", true, "doi_exact", "exact DOI handle and registration-agency lookup"),
|
|
21
|
+
item("crossref", true, "crossref_doi_if_owned", "exact lookup only when DOI registry identifies Crossref"),
|
|
22
|
+
item("pubmed", true, "pubmed_doi", "bounded DOI lookup in PubMed", true),
|
|
23
|
+
item("arxiv", false, "none", "a DOI alone does not indicate an arXiv preprint"),
|
|
24
|
+
item("semantic_scholar", true, "semantic_scholar_identifier", "DOI identifier lookup", true),
|
|
25
|
+
];
|
|
26
|
+
}
|
|
27
|
+
if (normalized.kind === "pmid") {
|
|
28
|
+
return [
|
|
29
|
+
item("doi_registry", false, "none", "no DOI supplied"),
|
|
30
|
+
item("crossref", false, "none", "PMID-only input has no Crossref bibliographic query"),
|
|
31
|
+
item("pubmed", true, "pubmed_exact", "exact PMID retrieval"),
|
|
32
|
+
item("arxiv", false, "none", "PMID-only input does not indicate arXiv"),
|
|
33
|
+
item("semantic_scholar", true, "semantic_scholar_identifier", "PMID identifier lookup", true),
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
if (normalized.kind === "arxiv") {
|
|
37
|
+
return [
|
|
38
|
+
item("doi_registry", false, "none", "no DOI supplied"),
|
|
39
|
+
item("crossref", false, "none", "arXiv-only input has no Crossref bibliographic query"),
|
|
40
|
+
item("pubmed", false, "none", "arXiv-only input has no PubMed query"),
|
|
41
|
+
item("arxiv", true, "arxiv_exact", "exact arXiv identifier retrieval"),
|
|
42
|
+
item("semantic_scholar", true, "semantic_scholar_identifier", "arXiv identifier lookup", true),
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
const pubmedStructured = enoughForEcitMatch(input);
|
|
46
|
+
const pubmedTitle = !pubmedStructured && input.title !== undefined;
|
|
47
|
+
const arxiv = indicatesArxiv(input, normalized);
|
|
48
|
+
return [
|
|
49
|
+
item("doi_registry", false, "none", "no exact DOI supplied"),
|
|
50
|
+
item("crossref", true, "crossref_bibliographic", "raw or structured bibliographic match", true),
|
|
51
|
+
item("pubmed", pubmedStructured || pubmedTitle, pubmedStructured ? "pubmed_structured" : "pubmed_title", pubmedStructured
|
|
52
|
+
? "structured fields support constrained citation match"
|
|
53
|
+
: pubmedTitle
|
|
54
|
+
? "supplied title supports bounded candidate search"
|
|
55
|
+
: "insufficient structured PubMed signal", true),
|
|
56
|
+
item("arxiv", arxiv, "arxiv_bibliographic", arxiv ? "citation indicates an arXiv preprint" : "citation does not indicate arXiv", true),
|
|
57
|
+
item("semantic_scholar", true, "semantic_scholar_bibliographic", "bounded bibliographic candidate search", true),
|
|
58
|
+
];
|
|
59
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type CitationMatchConfig } from "./match.js";
|
|
2
|
+
import { type RateLimitConfig, type RateLimitRuntime } from "../lib/rate-limit.js";
|
|
3
|
+
import { type CitationResolver, type CitationSource, type CitationSourceClient } from "./types.js";
|
|
4
|
+
export interface CitationResolverSourceConfig extends Partial<RateLimitConfig> {
|
|
5
|
+
readonly enabled?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface CitationResolverConfig {
|
|
8
|
+
readonly maxBatchSize?: number;
|
|
9
|
+
readonly timeoutMs?: number;
|
|
10
|
+
readonly cache?: {
|
|
11
|
+
readonly maximum?: number;
|
|
12
|
+
readonly positiveTtlMs?: number;
|
|
13
|
+
readonly negativeTtlMs?: number;
|
|
14
|
+
};
|
|
15
|
+
readonly match?: Partial<CitationMatchConfig>;
|
|
16
|
+
readonly sources?: Partial<Record<CitationSource, CitationResolverSourceConfig>>;
|
|
17
|
+
readonly crossref?: {
|
|
18
|
+
readonly mailto?: string;
|
|
19
|
+
readonly userAgent?: string;
|
|
20
|
+
};
|
|
21
|
+
readonly ncbiApiKey?: string;
|
|
22
|
+
readonly semanticScholarApiKey?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface CitationResolverDependencies extends RateLimitRuntime {
|
|
25
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
26
|
+
readonly clients?: readonly CitationSourceClient[];
|
|
27
|
+
}
|
|
28
|
+
export declare function createCitationResolver(config?: CitationResolverConfig, deps?: CitationResolverDependencies): CitationResolver;
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { aggregateCitationResolution } from "./aggregate.js";
|
|
2
|
+
import { BoundedTtlCache } from "../lib/cache.js";
|
|
3
|
+
import { createArxivCitationClient } from "./clients/arxiv.js";
|
|
4
|
+
import { notApplicableOutcome, sourceOutcome } from "./clients/common.js";
|
|
5
|
+
import { createCrossrefClient } from "./clients/crossref.js";
|
|
6
|
+
import { createDoiRegistryClient } from "./clients/doi-registry.js";
|
|
7
|
+
import { createPubmedCitationClient } from "./clients/pubmed.js";
|
|
8
|
+
import { createSemanticScholarCitationClient } from "./clients/semantic-scholar.js";
|
|
9
|
+
import { DEFAULT_MATCH_CONFIG } from "./match.js";
|
|
10
|
+
import { citationLookupKey, normalizeCitation } from "./normalize.js";
|
|
11
|
+
import { planCitationSources } from "./plan.js";
|
|
12
|
+
import { createRateLimitSchedule } from "../lib/rate-limit.js";
|
|
13
|
+
import { CitationInputSchema, } from "./types.js";
|
|
14
|
+
const SOURCE_PLAN_VERSION = "citation-plan-v1";
|
|
15
|
+
const SOURCE_ORDER = ["doi_registry", "crossref", "pubmed", "arxiv", "semantic_scholar"];
|
|
16
|
+
const DEFAULT_RATE_LIMITS = {
|
|
17
|
+
doi_registry: { maxConcurrency: 2, requestsPerSecond: 5, maxRetries: 2, maxRetryDelayMs: 5_000 },
|
|
18
|
+
crossref: { maxConcurrency: 2, requestsPerSecond: 5, maxRetries: 2, maxRetryDelayMs: 5_000 },
|
|
19
|
+
pubmed: { maxConcurrency: 3, requestsPerSecond: 3, maxRetries: 2, maxRetryDelayMs: 5_000 },
|
|
20
|
+
arxiv: { maxConcurrency: 1, requestsPerSecond: 1, maxRetries: 2, maxRetryDelayMs: 10_000 },
|
|
21
|
+
semantic_scholar: { maxConcurrency: 1, requestsPerSecond: 1, maxRetries: 2, maxRetryDelayMs: 10_000 },
|
|
22
|
+
};
|
|
23
|
+
function mergedRateLimit(source, config) {
|
|
24
|
+
return { ...DEFAULT_RATE_LIMITS[source], ...(config.sources?.[source] ?? {}) };
|
|
25
|
+
}
|
|
26
|
+
function createDefaultClients(config, deps) {
|
|
27
|
+
const runtime = { ...(deps.now ? { now: deps.now } : {}), ...(deps.sleep ? { sleep: deps.sleep } : {}) };
|
|
28
|
+
const http = (source) => {
|
|
29
|
+
const limits = mergedRateLimit(source, config);
|
|
30
|
+
return {
|
|
31
|
+
...(deps.fetch === undefined ? {} : { fetch: deps.fetch }),
|
|
32
|
+
...(config.timeoutMs === undefined ? {} : { timeoutMs: config.timeoutMs }),
|
|
33
|
+
...(deps.sleep ? { sleep: deps.sleep } : {}),
|
|
34
|
+
schedule: createRateLimitSchedule(limits, runtime),
|
|
35
|
+
maxRetries: limits.maxRetries,
|
|
36
|
+
maxRetryDelayMs: limits.maxRetryDelayMs,
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
return [
|
|
40
|
+
createDoiRegistryClient(http("doi_registry")),
|
|
41
|
+
createCrossrefClient({
|
|
42
|
+
...http("crossref"),
|
|
43
|
+
...(config.crossref?.mailto === undefined ? {} : { mailto: config.crossref.mailto }),
|
|
44
|
+
...(config.crossref?.userAgent === undefined ? {} : { userAgent: config.crossref.userAgent }),
|
|
45
|
+
}),
|
|
46
|
+
createPubmedCitationClient({
|
|
47
|
+
...http("pubmed"),
|
|
48
|
+
...(config.ncbiApiKey === undefined ? {} : { apiKey: config.ncbiApiKey }),
|
|
49
|
+
}),
|
|
50
|
+
createArxivCitationClient(http("arxiv")),
|
|
51
|
+
createSemanticScholarCitationClient({
|
|
52
|
+
...http("semantic_scholar"),
|
|
53
|
+
...(config.semanticScholarApiKey === undefined ? {} : { apiKey: config.semanticScholarApiKey }),
|
|
54
|
+
}),
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
function cacheable(outcomes) {
|
|
58
|
+
if (outcomes.some((outcome) => outcome.status === "unavailable"))
|
|
59
|
+
return undefined;
|
|
60
|
+
return outcomes.some((outcome) => outcome.records.length > 0) ? "positive" : "negative";
|
|
61
|
+
}
|
|
62
|
+
function abortReason(signal) {
|
|
63
|
+
return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
64
|
+
}
|
|
65
|
+
function withCallerSignal(promise, signal) {
|
|
66
|
+
if (signal === undefined)
|
|
67
|
+
return promise;
|
|
68
|
+
if (signal.aborted)
|
|
69
|
+
return Promise.reject(abortReason(signal));
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
const onAbort = () => reject(abortReason(signal));
|
|
72
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
73
|
+
promise.then((value) => {
|
|
74
|
+
signal.removeEventListener("abort", onAbort);
|
|
75
|
+
resolve(value);
|
|
76
|
+
}, (error) => {
|
|
77
|
+
signal.removeEventListener("abort", onAbort);
|
|
78
|
+
reject(error);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Read the DOI registry's outcome as evidence about the registration agency.
|
|
84
|
+
*
|
|
85
|
+
* The registry runs first in `SOURCE_ORDER`, so every later source sees its
|
|
86
|
+
* result. Only a named agency is a finding; every other shape — the registry
|
|
87
|
+
* disabled, unreachable, or silent on the agency — leaves ownership unknown.
|
|
88
|
+
*/
|
|
89
|
+
function registrationAgencyEvidence(outcome) {
|
|
90
|
+
if (outcome === undefined)
|
|
91
|
+
return { status: "undetermined", detail: "the DOI registry was not consulted" };
|
|
92
|
+
const evidence = outcome.identifierEvidence;
|
|
93
|
+
if (evidence?.registrationAgency !== undefined)
|
|
94
|
+
return { status: "determined", agency: evidence.registrationAgency };
|
|
95
|
+
if (evidence?.exists === false)
|
|
96
|
+
return { status: "absent" };
|
|
97
|
+
return { status: "undetermined", detail: outcome.detail ?? `the DOI registry returned ${outcome.status}` };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Run one source over the requests routed to it, keeping its failures its own.
|
|
101
|
+
*
|
|
102
|
+
* A source that throws, or that answers a batch with the wrong number of
|
|
103
|
+
* outcomes, is an unavailable source — the same operational state as an HTTP
|
|
104
|
+
* failure — not a failed batch. Caller cancellation still propagates.
|
|
105
|
+
*/
|
|
106
|
+
async function runSource(client, requests, signal) {
|
|
107
|
+
const unavailable = (request, detail) => sourceOutcome(client.source, request.plan.operation, "unavailable", 0, [], detail);
|
|
108
|
+
const describe = (error) => (error instanceof Error ? error.message : String(error));
|
|
109
|
+
if (client.resolveMany === undefined) {
|
|
110
|
+
const settled = await Promise.allSettled(requests.map((request) => client.resolve(request, signal)));
|
|
111
|
+
if (signal?.aborted)
|
|
112
|
+
throw abortReason(signal);
|
|
113
|
+
return settled.map((outcome, index) => (outcome.status === "fulfilled" ? outcome.value : unavailable(requests[index], describe(outcome.reason))));
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const resolved = await client.resolveMany(requests, signal);
|
|
117
|
+
if (resolved.length === requests.length)
|
|
118
|
+
return [...resolved];
|
|
119
|
+
return requests.map((request) => unavailable(request, `${client.source} returned ${resolved.length} outcomes for ${requests.length} requests`));
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (signal?.aborted)
|
|
123
|
+
throw abortReason(signal);
|
|
124
|
+
return requests.map((request) => unavailable(request, describe(error)));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
export function createCitationResolver(config = {}, deps = {}) {
|
|
128
|
+
const clients = new Map((deps.clients ?? createDefaultClients(config, deps)).map((client) => [client.source, client]));
|
|
129
|
+
const now = deps.now ?? Date.now;
|
|
130
|
+
const cacheConfig = {
|
|
131
|
+
maximum: config.cache?.maximum ?? 500,
|
|
132
|
+
positiveTtlMs: config.cache?.positiveTtlMs ?? 15 * 60_000,
|
|
133
|
+
negativeTtlMs: config.cache?.negativeTtlMs ?? 2 * 60_000,
|
|
134
|
+
};
|
|
135
|
+
const cache = new BoundedTtlCache(cacheConfig.maximum, now);
|
|
136
|
+
const inFlight = new Map();
|
|
137
|
+
const matchConfig = { ...DEFAULT_MATCH_CONFIG, ...(config.match ?? {}) };
|
|
138
|
+
const maxBatchSize = config.maxBatchSize ?? 200;
|
|
139
|
+
const keyFor = (input) => `${SOURCE_PLAN_VERSION}:${citationLookupKey(input)}`;
|
|
140
|
+
async function resolveUncachedBatch(entries, signal) {
|
|
141
|
+
const outcomes = entries.map(() => new Map());
|
|
142
|
+
for (const source of SOURCE_ORDER) {
|
|
143
|
+
const sourceConfig = config.sources?.[source];
|
|
144
|
+
const client = clients.get(source);
|
|
145
|
+
const requests = [];
|
|
146
|
+
const requestIndices = [];
|
|
147
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
148
|
+
const entry = entries[index];
|
|
149
|
+
const plan = entry.plans.find((candidate) => candidate.source === source);
|
|
150
|
+
if (!plan.applicable || sourceConfig?.enabled === false || client === undefined) {
|
|
151
|
+
outcomes[index].set(source, sourceConfig?.enabled === false
|
|
152
|
+
? notApplicableOutcome({ ...plan, applicable: false, operation: "none", reason: "source disabled by resolver configuration" })
|
|
153
|
+
: client === undefined
|
|
154
|
+
? notApplicableOutcome({ ...plan, applicable: false, operation: "none", reason: "source client unavailable" })
|
|
155
|
+
: notApplicableOutcome(plan));
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
requests.push({
|
|
159
|
+
input: entry.input,
|
|
160
|
+
normalized: entry.normalized,
|
|
161
|
+
plan,
|
|
162
|
+
registrationAgency: registrationAgencyEvidence(outcomes[index].get("doi_registry")),
|
|
163
|
+
});
|
|
164
|
+
requestIndices.push(index);
|
|
165
|
+
}
|
|
166
|
+
if (requests.length === 0 || client === undefined)
|
|
167
|
+
continue;
|
|
168
|
+
const resolved = await runSource(client, requests, signal);
|
|
169
|
+
for (let requestIndex = 0; requestIndex < resolved.length; requestIndex += 1) {
|
|
170
|
+
outcomes[requestIndices[requestIndex]].set(source, resolved[requestIndex]);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return entries.map((entry, index) => SOURCE_ORDER.map((source) => outcomes[index].get(source) ?? notApplicableOutcome(entry.plans.find((plan) => plan.source === source))));
|
|
174
|
+
}
|
|
175
|
+
async function resolveMany(inputs, options = {}) {
|
|
176
|
+
if (inputs.length > maxBatchSize)
|
|
177
|
+
throw new Error(`citation batch size ${inputs.length} exceeds configured maximum ${maxBatchSize}`);
|
|
178
|
+
if (inputs.length === 0)
|
|
179
|
+
return [];
|
|
180
|
+
const parsed = inputs.map((input) => CitationInputSchema.parse(input));
|
|
181
|
+
const uniqueOrder = [];
|
|
182
|
+
const firstByKey = new Map();
|
|
183
|
+
for (const input of parsed) {
|
|
184
|
+
const key = keyFor(input);
|
|
185
|
+
if (!firstByKey.has(key)) {
|
|
186
|
+
uniqueOrder.push(key);
|
|
187
|
+
firstByKey.set(key, input);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const pending = new Map();
|
|
191
|
+
const uncached = [];
|
|
192
|
+
for (const key of uniqueOrder) {
|
|
193
|
+
const cached = cache.get(key);
|
|
194
|
+
if (cached !== undefined) {
|
|
195
|
+
pending.set(key, Promise.resolve(cached));
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const running = inFlight.get(key);
|
|
199
|
+
if (running !== undefined) {
|
|
200
|
+
pending.set(key, running);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const input = firstByKey.get(key);
|
|
204
|
+
const normalized = normalizeCitation(input);
|
|
205
|
+
uncached.push({ key, entry: { input, normalized, plans: planCitationSources(input, normalized) } });
|
|
206
|
+
}
|
|
207
|
+
if (uncached.length > 0) {
|
|
208
|
+
const batch = resolveUncachedBatch(uncached.map(({ entry }) => entry), options.signal);
|
|
209
|
+
for (let index = 0; index < uncached.length; index += 1) {
|
|
210
|
+
const { key } = uncached[index];
|
|
211
|
+
const one = batch.then((results) => results[index]);
|
|
212
|
+
const tracked = one.then((outcomes) => {
|
|
213
|
+
const kind = cacheable(outcomes);
|
|
214
|
+
if (kind !== undefined)
|
|
215
|
+
cache.set(key, outcomes, kind === "positive" ? cacheConfig.positiveTtlMs : cacheConfig.negativeTtlMs);
|
|
216
|
+
inFlight.delete(key);
|
|
217
|
+
return outcomes;
|
|
218
|
+
}, (error) => {
|
|
219
|
+
inFlight.delete(key);
|
|
220
|
+
throw error;
|
|
221
|
+
});
|
|
222
|
+
inFlight.set(key, tracked);
|
|
223
|
+
pending.set(key, tracked);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const outcomesByKey = new Map();
|
|
227
|
+
await Promise.all(uniqueOrder.map(async (key) => {
|
|
228
|
+
outcomesByKey.set(key, await withCallerSignal(pending.get(key), options.signal));
|
|
229
|
+
}));
|
|
230
|
+
// Aggregated per input, never per shared lookup: comparisons and the
|
|
231
|
+
// verdict they drive belong to the caller's own supplied metadata.
|
|
232
|
+
return parsed.map((input) => aggregateCitationResolution(input, normalizeCitation(input), outcomesByKey.get(keyFor(input)), matchConfig));
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
resolveOne: async (input, options = {}) => (await resolveMany([input], options))[0],
|
|
236
|
+
resolveMany,
|
|
237
|
+
};
|
|
238
|
+
}
|