Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions core/ingest/fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ const CROSSREF_FIXTURE = {
},
}

const CROSSREF_FIXTURE_WITH_ORCIDS = {
message: {
title: ['ORCID-Bearing Paper'],
author: [
{ given: 'Ada', family: 'Lovelace', ORCID: 'http://orcid.org/0000-0002-1825-0097' },
{ given: 'Alan', family: 'Turing' },
{ given: 'Grace', family: 'Hopper', ORCID: 'https://orcid.org/0000-0001-5109-3700/' },
],
published: { 'date-parts': [[2023, 1, 1]] },
'container-title': ['Journal of Reproducible Authorship'],
},
}

describe('fetchSource', () => {
let fetchMock: ReturnType<typeof vi.fn>

Expand Down Expand Up @@ -67,6 +80,7 @@ describe('fetchSource', () => {
venue: 'NeurIPS 2024',
abstract: 'This paper revisits attention mechanisms.',
})
expect(result.metadata?.authorOrcids).toBeUndefined()

const calledUrls = fetchMock.mock.calls.map((c) => String(c[0]))
expect(calledUrls.some((u) => u.includes('id_list=2401.12345'))).toBe(true)
Expand Down Expand Up @@ -103,13 +117,45 @@ describe('fetchSource', () => {
venue: 'Journal of Made-Up Science',
abstract: 'A survey of things.',
})
expect(result.metadata?.authorOrcids).toBeUndefined()

const calledUrls = fetchMock.mock.calls.map((c) => String(c[0]))
expect(
calledUrls.some((u) => u.includes('api.crossref.org/works/10.1038%2Fs41586-021-03819-2')),
).toBe(true)
})

it('extracts and bare-normalizes ORCIDs from Crossref, positionally aligned with nulls for authors with none', async () => {
fetchMock.mockImplementation((url: string) => {
if (url.includes('api.crossref.org/works/')) {
return Promise.resolve(
new Response(JSON.stringify(CROSSREF_FIXTURE_WITH_ORCIDS), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
)
}
if (url.startsWith('https://doi.org/')) {
return Promise.resolve(new Response(new Uint8Array([1]), { status: 200 }))
}
throw new Error(`unexpected fetch: ${url}`)
})

const input: ClassifiedInput = {
kind: 'doi',
slug: 'doi-orcid-test',
value: '10.1/orcid-test',
}
const result = await fetchSource(input)

expect(result.metadata?.authors).toEqual(['Ada Lovelace', 'Alan Turing', 'Grace Hopper'])
expect(result.metadata?.authorOrcids).toEqual([
'0000-0002-1825-0097',
null,
'0000-0001-5109-3700',
])
})

it('fetches raw PDF bytes for a direct pdf_url with no metadata', async () => {
fetchMock.mockImplementation((url: string) => {
if (url === 'https://example.com/paper.pdf') {
Expand Down
22 changes: 20 additions & 2 deletions core/ingest/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ export interface RawMetadata {
year: number | undefined
venue: string | undefined
abstract: string | undefined
/** Bare ORCID ids (e.g. "0000-0002-1825-0097"), positionally aligned to
* `authors`; null for authors with no known ORCID. Crossref-only signal —
* undefined when the source (e.g. arXiv) never carries ORCIDs. */
authorOrcids?: (string | null)[]
}

export interface FetchedSource {
Expand Down Expand Up @@ -115,14 +119,24 @@ async function fetchArxivPdf(arxivId: string): Promise<Uint8Array> {
interface CrossrefWork {
message: {
title?: string[]
author?: Array<{ given?: string; family?: string; name?: string }>
author?: Array<{ given?: string; family?: string; name?: string; ORCID?: unknown }>
published?: { 'date-parts'?: number[][] }
'published-print'?: { 'date-parts'?: number[][] }
'container-title'?: string[]
abstract?: string
}
}

// Crossref's ORCID field is a URL like "http://orcid.org/0000-0002-1825-0097"
// or "https://orcid.org/0000-0002-1825-0097/" — normalize to the bare id.
// Defensive: any non-string or unrecognized shape yields null rather than
// throwing (ORCID is a best-effort signal, never load-bearing for ingest).
function normalizeOrcid(value: unknown): string | null {
if (typeof value !== 'string') return null
const stripped = value.trim().replace(/^https?:\/\/orcid\.org\//i, '').replace(/\/+$/, '')
return /^\d{4}-\d{4}-\d{4}-\d{3}[\dX]$/.test(stripped) ? stripped : null
}

async function fetchCrossrefMetadata(doi: string): Promise<RawMetadata> {
const res = await fetch(`${CROSSREF_API}/${encodeURIComponent(doi)}`)
if (!res.ok) {
Expand All @@ -136,9 +150,12 @@ async function fetchCrossrefMetadata(doi: string): Promise<RawMetadata> {
throw new Error(`Crossref API: missing title for ${doi}`)
}

const authors = (work.author ?? []).map((a) =>
const crossrefAuthors = work.author ?? []
const authors = crossrefAuthors.map((a) =>
a.name ?? [a.given, a.family].filter(Boolean).join(' '),
)
const authorOrcids = crossrefAuthors.map((a) => normalizeOrcid(a.ORCID))
const hasAnyOrcid = authorOrcids.some((id) => id !== null)

const dateParts =
work.published?.['date-parts']?.[0] ?? work['published-print']?.['date-parts']?.[0]
Expand All @@ -152,6 +169,7 @@ async function fetchCrossrefMetadata(doi: string): Promise<RawMetadata> {
year,
venue,
abstract: work.abstract ? normalizeWhitespace(stripJats(work.abstract)) : undefined,
authorOrcids: hasAnyOrcid ? authorOrcids : undefined,
}
}

Expand Down
106 changes: 105 additions & 1 deletion core/ingest/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'

import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'

import type { AcpClient, AcpPromptRequest, AcpSession, AcpUpdate } from '../acp/client.js'
import { getPaper, listPapers } from '../library/repo.js'
Expand Down Expand Up @@ -130,6 +130,110 @@ describe('ingest', () => {
}
})

// Shared Crossref fixture for the authorOrcids alignment tests below.
const CROSSREF_ORCID_BODY = JSON.stringify({
message: {
title: ['Crossref Title'],
author: [
{ given: 'Ada', family: 'Lovelace', ORCID: 'http://orcid.org/0000-0002-1825-0097' },
{ given: 'Alan', family: 'Turing' },
],
published: { 'date-parts': [[2022, 1, 1]] },
},
})

function stubCrossrefFetch(pdfBytes: Buffer) {
const fetchMock = vi.fn((url: string) => {
if (url.includes('api.crossref.org/works/')) {
return Promise.resolve(
new Response(CROSSREF_ORCID_BODY, {
status: 200,
headers: { 'content-type': 'application/json' },
}),
)
}
if (url.startsWith('https://doi.org/')) {
return Promise.resolve(new Response(new Uint8Array(pdfBytes), { status: 200 }))
}
throw new Error(`unexpected fetch: ${url}`)
})
vi.stubGlobal('fetch', fetchMock)
}

it('drops authorOrcids when agent-extracted authors diverge from the Crossref list they were aligned to', async () => {
tmpDir = mkdtempSync(join(tmpdir(), 'vellum-ingest-'))
const db = openDb({ path: ':memory:' })
// AGENT_JSON's authors (['Agent Author']) differ from the Crossref
// fixture's authors (['Ada Lovelace', 'Alan Turing']) — agent authors win
// per the extracted-first merge, so the Crossref-aligned ORCIDs would
// misalign if kept.
const client = new FakeClient([textUpdate(AGENT_JSON), doneUpdate])
stubCrossrefFetch(readFileSync(FIXTURE_PDF))

try {
const result = await ingest('10.1234/orcid-divergent-test', { db, dataDir: tmpDir, client })

expect(result.metadata.authors).toEqual(['Agent Author'])
expect(result.metadata.authorOrcids).toBeUndefined()

const row = getPaper(db, result.slug)
expect(row?.authorOrcids).toBeUndefined()
} finally {
db.close()
vi.unstubAllGlobals()
}
})

it('retains authorOrcids when agent-extracted authors are content-equal to the Crossref list', async () => {
tmpDir = mkdtempSync(join(tmpdir(), 'vellum-ingest-'))
const db = openDb({ path: ':memory:' })
const agentJsonMatchingCrossref = JSON.stringify({
sections: [{ title: 'Sample Paper Title', startOffset: 0, endOffset: 20 }],
metadata: {
title: 'Sample Paper Title (agent-refined)',
authors: ['Ada Lovelace', 'Alan Turing'],
year: 2026,
},
})
const client = new FakeClient([textUpdate(agentJsonMatchingCrossref), doneUpdate])
stubCrossrefFetch(readFileSync(FIXTURE_PDF))

try {
const result = await ingest('10.1234/orcid-aligned-test', { db, dataDir: tmpDir, client })

expect(result.metadata.authors).toEqual(['Ada Lovelace', 'Alan Turing'])
expect(result.metadata.authorOrcids).toEqual(['0000-0002-1825-0097', null])

const row = getPaper(db, result.slug)
expect(row?.authorOrcids).toEqual(['0000-0002-1825-0097', null])
} finally {
db.close()
vi.unstubAllGlobals()
}
})

it('retains authorOrcids when the agent extracts no authors (falls back to the Crossref-aligned list)', async () => {
tmpDir = mkdtempSync(join(tmpdir(), 'vellum-ingest-'))
const db = openDb({ path: ':memory:' })
// Agent returns nothing usable -> extractPaper degrades to {}, so
// metadata.authors falls back to fetched.metadata.authors verbatim.
const client = new FakeClient([textUpdate('no json here'), doneUpdate])
stubCrossrefFetch(readFileSync(FIXTURE_PDF))

try {
const result = await ingest('10.1234/orcid-fallback-test', { db, dataDir: tmpDir, client })

expect(result.metadata.authors).toEqual(['Ada Lovelace', 'Alan Turing'])
expect(result.metadata.authorOrcids).toEqual(['0000-0002-1825-0097', null])

const row = getPaper(db, result.slug)
expect(row?.authorOrcids).toEqual(['0000-0002-1825-0097', null])
} finally {
db.close()
vi.unstubAllGlobals()
}
})

it('re-ingesting the same input is idempotent: no duplicate row, files overwritten not duplicated', async () => {
tmpDir = mkdtempSync(join(tmpdir(), 'vellum-ingest-'))
const db = openDb({ path: ':memory:' })
Expand Down
25 changes: 24 additions & 1 deletion core/ingest/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ export interface IngestMetadata {
year?: number
venue?: string
abstract?: string
/** Bare ORCID ids positionally aligned to `authors`; fetch-only signal
* (Crossref), never agent-extracted. Undefined when the source has none. */
authorOrcids?: (string | null)[]
}

export interface IngestResult {
Expand Down Expand Up @@ -107,12 +110,31 @@ export async function ingest(raw: string, options: IngestOptions): Promise<Inges
// the raw fetch-time metadata (arXiv/Crossref) for whatever the agent
// didn't find, and finally to the classified input value so `title` is
// never empty even in a fully degraded extraction.
const finalAuthors = extracted.metadata.authors ?? fetched.metadata?.authors ?? []

// ORCIDs are a fetch-only signal (Crossref), positionally aligned to
// fetched.metadata.authors — NOT to whatever the agent-extracted authors
// array ends up being. If the agent's author list wins and diverges from
// Crossref's (reorder, different formatting, truncation, extra/missing
// author), keeping the Crossref-aligned ORCIDs would attach badges to the
// wrong author. Only trust authorOrcids when the persisted authors array
// is content-equal (same length + order + strings) to the Crossref list
// it was aligned to; otherwise drop it (undefined -> DB column NULL). No
// fuzzy re-matching — conservative "aligned or nothing".
const fetchedAuthors = fetched.metadata?.authors
const authorsMatchFetched =
fetchedAuthors !== undefined &&
finalAuthors.length === fetchedAuthors.length &&
finalAuthors.every((a, i) => a === fetchedAuthors[i])
const authorOrcids = authorsMatchFetched ? fetched.metadata?.authorOrcids : undefined

const metadata: IngestMetadata = {
title: extracted.metadata.title ?? fetched.metadata?.title ?? classified.value,
authors: extracted.metadata.authors ?? fetched.metadata?.authors ?? [],
authors: finalAuthors,
year: extracted.metadata.year ?? fetched.metadata?.year,
venue: extracted.metadata.venue ?? fetched.metadata?.venue,
abstract: extracted.metadata.abstract ?? fetched.metadata?.abstract,
authorOrcids,
}

const record: PaperRecord = {
Expand All @@ -129,6 +151,7 @@ export async function ingest(raw: string, options: IngestOptions): Promise<Inges
pdfPath,
sections: extracted.sections,
addedAt: new Date().toISOString(),
authorOrcids: metadata.authorOrcids,
}
upsertPaper(options.db, record)

Expand Down
51 changes: 51 additions & 0 deletions core/library/repo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,57 @@ describe('repo', () => {
}
})

describe('author_orcids [P2-04]', () => {
it('round-trips authorOrcids, including null entries for unknown authors', () => {
const db = openDb({ path: ':memory:' })
try {
upsertPaper(
db,
makeRecord({
slug: 'orcid-paper',
authors: ['Jane Doe', 'John Smith'],
authorOrcids: ['0000-0002-1825-0097', null],
}),
)

const row = getPaper(db, 'orcid-paper')
expect(row?.authors).toEqual(['Jane Doe', 'John Smith'])
expect(row?.authorOrcids).toEqual(['0000-0002-1825-0097', null])
} finally {
db.close()
}
})

it('a paper upserted without authorOrcids reads back with authorOrcids undefined, authors intact', () => {
const db = openDb({ path: ':memory:' })
try {
upsertPaper(db, makeRecord({ slug: 'no-orcids' }))

const row = getPaper(db, 'no-orcids')
expect(row?.authorOrcids).toBeUndefined()
expect(row?.authors).toEqual(['Jane Doe', 'John Smith'])
} finally {
db.close()
}
})

it('an old row (author_orcids column NULL) reads back with authorOrcids undefined', () => {
const db = openDb({ path: ':memory:' })
try {
// Simulate a pre-[P2-04] row inserted without touching the new column at all.
db.prepare(
`INSERT INTO papers (slug, title, authors, added_at) VALUES (?, ?, ?, ?)`,
).run('legacy', 'Legacy Paper', JSON.stringify(['Old Author']), '2026-01-01T00:00:00.000Z')

const row = getPaper(db, 'legacy')
expect(row?.authorOrcids).toBeUndefined()
expect(row?.authors).toEqual(['Old Author'])
} finally {
db.close()
}
})
})

it('listPapers orders most-recently-added first', () => {
const db = openDb({ path: ':memory:' })
try {
Expand Down
Loading
Loading