Skip to content

Commit c2ba35a

Browse files
mpstatonclaude
andcommitted
milestone(jina-two-profiles): source-kind routing so academic sources keep their metadata — Fixes #78
The Jina parser was blog-only: it read OpenGraph keys and missed the Highwire/Dublin-Core/PRISM metadata that scholarly pages use, so their date + publisher fell through empty and the operator hand-filled them. Now jina.ts has two profiles and fuzzy routing: - detectProfile() reads which convention is present and returns a profile (structured | opengraph) + a source_kind (academic-paper | article | company-landing | web-page), recorded on the source. - extractBib() is a pure, profile-aware, forceProfile-overridable extractor; every field resolves across an ordered alias list (first hit wins), so a field missing under one convention still resolves under another. - fetchViaJina() accepts forceProfile — the foundation for a human re-route button (UI + re-extract capability tracked as a fast-follow). Authors stay an array; "Last, First" is never comma-split. Verified with a vitest suite whose academic fixture is the REAL metadata captured live from a Springer article — 11 tests + the existing corpus-files suite, all green; tsc --noEmit clean. Reaches augment.didi.sh on the next redeploy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UYTYu4MAFZ7iyr2VTo2kq
1 parent fdc9646 commit c2ba35a

2 files changed

Lines changed: 261 additions & 28 deletions

File tree

services/content-ingest/src/jina.ts

Lines changed: 144 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@
55

66
const JINA_BASE = 'https://r.jina.ai/';
77

8+
// Metadata comes back under different conventions depending on what KIND of
9+
// page it is — a scholarly article hides everything under Highwire/Dublin-Core/
10+
// PRISM keys (citation_*, dc.*, prism.*); a blog/news/company page uses
11+
// OpenGraph (og:*, article:*). So there are two PROFILES and fuzzy routing
12+
// picks one; forceProfile lets a human re-route when detection is wrong.
13+
export type ParserProfile = 'structured' | 'opengraph';
14+
export type SourceKind = 'academic-paper' | 'article' | 'company-landing' | 'web-page';
15+
816
export type JinaResult =
917
| {
1018
ok: true;
@@ -15,12 +23,15 @@ export type JinaResult =
1523
}
1624
| { ok: false; error: string; status?: number };
1725

18-
export async function fetchViaJina(url: string, opts: { noCache?: boolean } = {}): Promise<JinaResult> {
26+
export async function fetchViaJina(
27+
url: string,
28+
opts: { noCache?: boolean; forceProfile?: ParserProfile } = {},
29+
): Promise<JinaResult> {
1930
const RETRIES = 3;
2031
let backoffMs = 2000;
2132
let lastErr: { ok: false; error: string; status?: number } | null = null;
2233
for (let attempt = 0; attempt < RETRIES; attempt += 1) {
23-
const result = await jinaFetchOnce(url, opts.noCache);
34+
const result = await jinaFetchOnce(url, opts.noCache, opts.forceProfile);
2435
if (result.ok) return result;
2536
if (result.status !== 429) return result;
2637
lastErr = result;
@@ -32,7 +43,7 @@ export async function fetchViaJina(url: string, opts: { noCache?: boolean } = {}
3243
return lastErr ?? { ok: false, error: 'jina fetch failed after retries' };
3344
}
3445

35-
async function jinaFetchOnce(url: string, noCache = false): Promise<JinaResult> {
46+
async function jinaFetchOnce(url: string, noCache = false, forceProfile?: ParserProfile): Promise<JinaResult> {
3647
const fetched_at = new Date().toISOString();
3748
const apiKey = process.env.JINA_API_KEY;
3849
const headers: Record<string, string> = { Accept: 'application/json' };
@@ -60,37 +71,28 @@ async function jinaFetchOnce(url: string, noCache = false): Promise<JinaResult>
6071
parsed = null;
6172
}
6273

63-
// JSON path (the normal case) — rich metadata available.
74+
// JSON path (the normal case) — rich metadata available, routed by profile.
6475
if (parsed?.data) {
6576
const data = parsed.data;
66-
const meta = (data.metadata && typeof data.metadata === 'object' ? data.metadata : {}) as Record<string, unknown>;
6777
const markdown = typeof data.content === 'string' ? data.content : '';
6878
if (!markdown.trim()) return { ok: false, error: 'Jina returned empty content' };
69-
const title = firstStr(data.title) ?? extractTitle(markdown, url);
70-
const extra: Record<string, unknown> = { jina_status: res.status, content_length_bytes: markdown.length };
71-
72-
const publishedRaw = firstStr(data.publishedTime, meta['article:published_time'], meta['article:modified_time'], meta.date);
73-
if (publishedRaw) {
74-
const iso = normalizeToISO(publishedRaw);
75-
if (iso) extra.published_at = iso;
76-
}
77-
const authors = normalizeAuthors(meta.author, meta['article:author'], meta['dc.creator'], data.author);
78-
if (authors.length) extra.authors = authors;
79-
const publisher = firstStr(meta['og:site_name'], data.publisher) ?? hostnameOf(url);
80-
if (publisher) extra.publisher = publisher;
81-
const description = firstStr(data.description, meta.description);
82-
if (description) extra.description = description;
83-
const language = firstStr(meta.lang, meta.language, data.lang);
84-
if (language) extra.language = language;
85-
79+
const { title, extra } = extractBib(data, url, { forceProfile });
80+
extra.jina_status = res.status;
81+
extra.content_length_bytes = markdown.length;
8682
return { ok: true, markdown, title, fetched_at, extra };
8783
}
8884

8985
// Fallback: non-JSON body (some upstreams / error shapes) — parse the legacy
90-
// key:value preamble so we still degrade gracefully.
86+
// key:value preamble so we still degrade gracefully. Always the opengraph
87+
// profile, kind web-page (no structured metadata is available this way).
9188
const markdown = body;
9289
const title = extractTitle(markdown, url);
93-
const extra: Record<string, unknown> = { jina_status: res.status, content_length_bytes: markdown.length };
90+
const extra: Record<string, unknown> = {
91+
jina_status: res.status,
92+
content_length_bytes: markdown.length,
93+
source_kind: 'web-page' as SourceKind,
94+
parser_profile: 'opengraph' as ParserProfile,
95+
};
9496
const preamble = parsePreamble(markdown);
9597
const publishedTime = preamble['Published Time'] ?? preamble['published_time'];
9698
if (publishedTime) {
@@ -105,6 +107,119 @@ async function jinaFetchOnce(url: string, noCache = false): Promise<JinaResult>
105107
return { ok: true, markdown, title, fetched_at, extra };
106108
}
107109

110+
// ── Profile routing ────────────────────────────────────────────────────────
111+
112+
function metaOf(data: Record<string, unknown>): Record<string, unknown> {
113+
return (data.metadata && typeof data.metadata === 'object' ? data.metadata : {}) as Record<string, unknown>;
114+
}
115+
116+
function present(meta: Record<string, unknown>, key: string): boolean {
117+
const v = meta[key];
118+
if (v == null || v === '') return false;
119+
if (Array.isArray(v)) return v.length > 0;
120+
return true;
121+
}
122+
123+
// Fuzzy-match the source kind from which metadata convention is present, and
124+
// map it to a parser profile. Academic tells (a DOI or citation_* journal
125+
// keys) are the strongest signal; then OpenGraph article/website; else a
126+
// plain web page.
127+
export function detectProfile(data: Record<string, unknown>): { profile: ParserProfile; kind: SourceKind } {
128+
const meta = metaOf(data);
129+
const ogType = firstStr(meta['og:type']);
130+
if (present(meta, 'citation_title') || present(meta, 'citation_doi') || present(meta, 'DOI') || present(meta, 'citation_journal_title')) {
131+
return { profile: 'structured', kind: 'academic-paper' };
132+
}
133+
if (ogType === 'article' || present(meta, 'article:published_time') || present(meta, 'article:author')) {
134+
return { profile: 'opengraph', kind: 'article' };
135+
}
136+
if (ogType === 'website' || (present(meta, 'og:site_name') && !present(meta, 'article:published_time'))) {
137+
return { profile: 'opengraph', kind: 'company-landing' };
138+
}
139+
return { profile: 'opengraph', kind: 'web-page' };
140+
}
141+
142+
// Pure, testable, profile-aware bibliographic extraction. Each field resolves
143+
// across an ordered alias list (first hit wins), so a field missing under one
144+
// convention still resolves under another. forceProfile overrides routing (the
145+
// manual re-route path).
146+
export function extractBib(
147+
data: Record<string, unknown>,
148+
url: string,
149+
opts: { forceProfile?: ParserProfile } = {},
150+
): { title: string; extra: Record<string, unknown> } {
151+
const meta = metaOf(data);
152+
const routed = detectProfile(data);
153+
const profile = opts.forceProfile ?? routed.profile;
154+
const kind: SourceKind = opts.forceProfile
155+
? (opts.forceProfile === 'structured' ? 'academic-paper' : routed.kind)
156+
: routed.kind;
157+
158+
const markdown = typeof data.content === 'string' ? data.content : '';
159+
160+
// Per-profile candidate lists. `data.*` are Jina's top-level convenience
161+
// fields (often undefined); the rest are pass-through meta tags.
162+
const titleCands =
163+
profile === 'structured'
164+
? [data.title, meta['citation_title'], meta['dc.title'], meta['og:title']]
165+
: [data.title, meta['og:title'], meta['twitter:title'], meta['dc.title']];
166+
167+
const authorCands =
168+
profile === 'structured'
169+
? [meta['citation_author'], meta['dc.creator'], data.author]
170+
: [meta['author'], meta['article:author'], data.author, meta['dc.creator']];
171+
172+
const dateCands =
173+
profile === 'structured'
174+
? [meta['dc.date'], meta['prism.publicationDate'], meta['citation_online_date'], meta['citation_publication_date'], meta['citation_cover_date'], data.publishedTime]
175+
: [data.publishedTime, meta['article:published_time'], meta['article:modified_time'], meta['date'], meta['dc.date']];
176+
177+
const publisherCands =
178+
profile === 'structured'
179+
? [meta['citation_publisher'], meta['dc.publisher'], meta['prism.publicationName'], meta['citation_journal_title'], meta['og:site_name']]
180+
: [meta['og:site_name'], data.publisher, meta['dc.publisher']];
181+
182+
const descCands =
183+
profile === 'structured'
184+
? [meta['dc.description'], data.description, meta['description'], meta['og:description']]
185+
: [data.description, meta['og:description'], meta['description'], meta['twitter:description']];
186+
187+
const langCands =
188+
profile === 'structured'
189+
? [meta['citation_language'], meta['dc.language'], meta['lang'], meta['language']]
190+
: [meta['lang'], meta['language'], data.lang];
191+
192+
const title = firstStr(...titleCands) ?? extractTitle(markdown, url);
193+
194+
const extra: Record<string, unknown> = { source_kind: kind, parser_profile: profile };
195+
196+
const publishedRaw = firstStr(...dateCands);
197+
if (publishedRaw) {
198+
const iso = normalizeToISO(publishedRaw);
199+
if (iso) extra.published_at = iso;
200+
}
201+
const authors = normalizeAuthors(...authorCands);
202+
if (authors.length) extra.authors = authors;
203+
const publisher = firstStr(...publisherCands) ?? hostnameOf(url);
204+
if (publisher) extra.publisher = publisher;
205+
const description = firstStr(...descCands);
206+
if (description) extra.description = description;
207+
const language = firstStr(...langCands);
208+
if (language) extra.language = language;
209+
210+
// Bonus identifiers worth carrying when the structured profile has them.
211+
if (profile === 'structured') {
212+
const doi = firstStr(meta['citation_doi'], meta['DOI'], meta['prism.doi']);
213+
if (doi) extra.doi = doi.replace(/^doi:/i, '');
214+
const journal = firstStr(meta['citation_journal_title'], meta['prism.publicationName']);
215+
if (journal) extra.journal = journal;
216+
}
217+
218+
return { title, extra };
219+
}
220+
221+
// ── helpers ──────────────────────────────────────────────────────────────
222+
108223
function firstStr(...vals: unknown[]): string | undefined {
109224
for (const v of vals) {
110225
if (typeof v === 'string' && v.trim()) return v.trim();
@@ -114,7 +229,8 @@ function firstStr(...vals: unknown[]): string | undefined {
114229

115230
// Jina returns author as a STRING for one author but an ARRAY for several.
116231
// Normalize to a string[] (one author → one-element array), taking the first
117-
// key that yields anything and stripping a leading "By ".
232+
// key that yields anything and stripping a leading "By ". Never comma-splits a
233+
// single string — "Pal, Soumen" (Last, First) is one author, not two.
118234
function normalizeAuthors(...vals: unknown[]): string[] {
119235
for (const v of vals) {
120236
let list: string[] = [];
@@ -156,9 +272,9 @@ function parsePreamble(markdown: string): Record<string, string> {
156272
}
157273

158274
// Jina passes through whatever the upstream meta tag carried. Coerce
159-
// the common cases (ISO already, RFC 2822, "YYYY-MM-DD") to ISO 8601.
160-
// Returns null when Date parsing yields NaN — better to drop than to
161-
// stamp garbage into the frontmatter.
275+
// the common cases (ISO already, RFC 2822, "YYYY-MM-DD", "YYYY/MM/DD") to
276+
// ISO 8601. Returns null when Date parsing yields NaN — better to drop than
277+
// to stamp garbage into the frontmatter.
162278
function normalizeToISO(raw: string): string | null {
163279
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(raw)) {
164280
const d = new Date(raw);
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Jina metadata extraction — two profiles + fuzzy routing.
2+
// Issue: context-v/issues/Jina-Metadata-Parser-Is-Blog-Only-Needs-Two-Profiles-And-Routing.md
3+
//
4+
// Pure extraction, no network: feeds representative Jina `data` objects (the
5+
// academic fixture is the REAL metadata block captured live from a Springer
6+
// article) through detectProfile + extractBib and asserts the routing and the
7+
// resolved bibliographic fields.
8+
9+
import { describe, expect, test } from 'vitest';
10+
import { detectProfile, extractBib } from '../src/jina';
11+
12+
// Real metadata captured live from r.jina.ai for
13+
// https://link.springer.com/article/10.1007/s12033-023-00765-4
14+
const academic = {
15+
title: 'Quantum Computing in the Next-Generation Computational Biology Landscape: From Protein Folding to Molecular Dynamics',
16+
content: '# Quantum Computing …\n\nbody text',
17+
// Jina's top-level convenience fields come back undefined for scholarly pages:
18+
publishedTime: undefined,
19+
author: undefined,
20+
metadata: {
21+
citation_title: 'Quantum Computing in the Next-Generation Computational Biology Landscape: From Protein Folding to Molecular Dynamics',
22+
citation_author: ['Pal, Soumen', 'Bhattacharya, Manojit', 'Lee, Sang-Soo', 'Chakraborty, Chiranjib'],
23+
'dc.creator': ['Pal, Soumen', 'Bhattacharya, Manojit', 'Lee, Sang-Soo', 'Chakraborty, Chiranjib'],
24+
citation_publisher: 'Springer US',
25+
'dc.publisher': 'Springer',
26+
'dc.date': '2023-05-27',
27+
'prism.publicationDate': '2023-05-27',
28+
citation_publication_date: '2024/02',
29+
citation_journal_title: 'Molecular Biotechnology',
30+
citation_doi: '10.1007/s12033-023-00765-4',
31+
DOI: '10.1007/s12033-023-00765-4',
32+
'og:site_name': 'SpringerLink',
33+
citation_language: 'en',
34+
},
35+
};
36+
37+
const blog = {
38+
title: 'Why We Rebuilt Our Pipeline',
39+
content: '# Why We Rebuilt Our Pipeline\n\nbody',
40+
metadata: {
41+
'og:type': 'article',
42+
'article:published_time': '2025-01-15T10:00:00Z',
43+
'article:author': 'Jane Doe',
44+
'og:site_name': 'Cool Engineering Blog',
45+
'og:description': 'A story about our rebuild.',
46+
},
47+
};
48+
49+
const company = {
50+
title: 'Acme — Industrial Robotics',
51+
content: '# Acme\n\nWe build robots.',
52+
metadata: {
53+
'og:type': 'website',
54+
'og:site_name': 'Acme Inc',
55+
'og:description': 'Industrial robotics for the modern factory.',
56+
},
57+
};
58+
59+
const URL_ACADEMIC = 'https://link.springer.com/article/10.1007/s12033-023-00765-4';
60+
61+
describe('detectProfile — routes by source kind', () => {
62+
test('academic article → structured / academic-paper', () => {
63+
expect(detectProfile(academic)).toEqual({ profile: 'structured', kind: 'academic-paper' });
64+
});
65+
test('blog post → opengraph / article', () => {
66+
expect(detectProfile(blog)).toEqual({ profile: 'opengraph', kind: 'article' });
67+
});
68+
test('company landing → opengraph / company-landing', () => {
69+
expect(detectProfile(company)).toEqual({ profile: 'opengraph', kind: 'company-landing' });
70+
});
71+
});
72+
73+
describe('extractBib — structured profile (academic)', () => {
74+
const { title, extra } = extractBib(academic, URL_ACADEMIC);
75+
test('title from citation_title', () => {
76+
expect(title).toContain('Quantum Computing in the Next-Generation');
77+
});
78+
test('all four authors, as an array, un-split', () => {
79+
expect(extra.authors).toEqual(['Pal, Soumen', 'Bhattacharya, Manojit', 'Lee, Sang-Soo', 'Chakraborty, Chiranjib']);
80+
});
81+
test('publisher prefers citation_publisher over og:site_name', () => {
82+
expect(extra.publisher).toBe('Springer US');
83+
});
84+
test('date resolves from dc.date (the field the old parser missed)', () => {
85+
expect(String(extra.published_at)).toMatch(/^2023-05-27/);
86+
});
87+
test('carries kind, profile, doi, journal', () => {
88+
expect(extra.source_kind).toBe('academic-paper');
89+
expect(extra.parser_profile).toBe('structured');
90+
expect(extra.doi).toBe('10.1007/s12033-023-00765-4');
91+
expect(extra.journal).toBe('Molecular Biotechnology');
92+
});
93+
});
94+
95+
describe('extractBib — opengraph profile', () => {
96+
test('blog: author + date + publisher', () => {
97+
const { extra } = extractBib(blog, 'https://blog.example.com/rebuild');
98+
expect(extra.authors).toEqual(['Jane Doe']);
99+
expect(String(extra.published_at)).toMatch(/^2025-01-15/);
100+
expect(extra.publisher).toBe('Cool Engineering Blog');
101+
expect(extra.source_kind).toBe('article');
102+
});
103+
test('company landing: publisher + description, NO date (none exists)', () => {
104+
const { extra } = extractBib(company, 'https://acme.example.com');
105+
expect(extra.publisher).toBe('Acme Inc');
106+
expect(extra.description).toBe('Industrial robotics for the modern factory.');
107+
expect(extra.published_at).toBeUndefined();
108+
expect(extra.source_kind).toBe('company-landing');
109+
});
110+
});
111+
112+
describe('forceProfile — the manual re-route override', () => {
113+
test('forcing structured overrides the routed opengraph profile', () => {
114+
const { extra } = extractBib(blog, 'https://blog.example.com/rebuild', { forceProfile: 'structured' });
115+
expect(extra.parser_profile).toBe('structured');
116+
});
117+
});

0 commit comments

Comments
 (0)