wtf-p 0.3.0 → 0.4.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.
@@ -0,0 +1,299 @@
1
+ const https = require('https');
2
+ const querystring = require('querystring');
3
+ const s2 = require('./semantic-scholar');
4
+ const scholar = require('./scholar-lookup');
5
+ const ranker = require('./citation-ranker');
6
+ const bibFormat = require('./bib-format');
7
+
8
+ /**
9
+ * WTF-P Citation Fetcher v0.4.0
10
+ *
11
+ * Orchestrates tiered search:
12
+ * 1. Semantic Scholar (Primary, Free)
13
+ * 2. SerpAPI (Optional, Seminal/Paid)
14
+ * 3. CrossRef (Fallback)
15
+ *
16
+ * Usage:
17
+ * node citation-fetcher.js "<query>" --intent=<intent> --year=<year>
18
+ */
19
+
20
+ // --- CrossRef Fallback ---
21
+ async function searchCrossRef(query, limit = 5) {
22
+ return new Promise((resolve, reject) => {
23
+ const params = {
24
+ query: query,
25
+ rows: limit,
26
+ sort: 'relevance',
27
+ select: 'DOI,title,author,issued,type,container-title,volume,issue,page,abstract'
28
+ };
29
+
30
+ const options = {
31
+ hostname: 'api.crossref.org',
32
+ path: `/works?${querystring.stringify(params)}`,
33
+ method: 'GET',
34
+ headers: {
35
+ 'User-Agent': 'WTF-P/0.4.0 (citation-expert)'
36
+ }
37
+ };
38
+
39
+ const req = https.request(options, (res) => {
40
+ let data = '';
41
+ res.on('data', c => data += c);
42
+ res.on('end', () => {
43
+ if (res.statusCode !== 200) return resolve([]);
44
+ try {
45
+ const json = JSON.parse(data);
46
+ const items = json.message.items || [];
47
+ resolve(items.map(mapCrossRefToPaper));
48
+ } catch (e) {
49
+ resolve([]);
50
+ }
51
+ });
52
+ });
53
+
54
+ req.on('error', () => resolve([]));
55
+ req.end();
56
+ });
57
+ }
58
+
59
+ function mapCrossRefToPaper(item) {
60
+ const title = item.title ? item.title[0] : 'Untitled';
61
+ const year = item.issued && item.issued['date-parts'] ? item.issued['date-parts'][0][0] : null;
62
+
63
+ return {
64
+ source: 'crossref',
65
+ title: title,
66
+ year: year,
67
+ doi: item.DOI,
68
+ venue: item['container-title'] ? item['container-title'][0] : null,
69
+ authors: (item.author || []).map(a => ({ name: `${a.family}, ${a.given}` })),
70
+ abstract: item.abstract ? item.abstract.replace(/<[^>]*>?/gm, '').trim() : null,
71
+ citationCount: 0, // CrossRef doesn't give citation counts easily
72
+ externalIds: { DOI: item.DOI }
73
+ };
74
+ }
75
+
76
+ // --- Mapper ---
77
+
78
+ function mapS2ToPaper(item) {
79
+ return {
80
+ source: 'semantic_scholar',
81
+ paperId: item.paperId,
82
+ title: item.title,
83
+ year: item.year,
84
+ venue: item.venue,
85
+ authors: item.authors || [],
86
+ citationCount: item.citationCount || 0,
87
+ abstract: item.abstract,
88
+ externalIds: item.externalIds || {},
89
+ doi: (item.externalIds && item.externalIds.DOI) || null,
90
+ openAccessPdf: item.openAccessPdf
91
+ };
92
+ }
93
+
94
+ function mapScholarToPaper(item) {
95
+ return {
96
+ source: 'google_scholar',
97
+ scholarClusterId: item.clusterId,
98
+ title: item.title,
99
+ year: item.year,
100
+ venue: item.venue,
101
+ authors: item.authors || [],
102
+ citationCount: item.citationCount || 0,
103
+ abstract: item.snippet, // Use snippet as abstract fallback
104
+ externalIds: {},
105
+ doi: null, // Scholar doesn't reliably give DOIs
106
+ openAccessPdf: item.link ? { url: item.link } : null
107
+ };
108
+ }
109
+
110
+ // --- Deduplication ---
111
+
112
+ function fingerprint(title, year) {
113
+ if (!title) return `unknown::${Math.random()}`;
114
+ const normalized = title.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim();
115
+ return `${normalized}::${year || '????'}`;
116
+ }
117
+
118
+ function deduplicatePapers(papers) {
119
+ const seen = new Map();
120
+
121
+ for (const paper of papers) {
122
+ // Priority: DOI > S2ID > ScholarID > Fingerprint
123
+ const key = paper.doi
124
+ || paper.paperId
125
+ || paper.scholarClusterId
126
+ || fingerprint(paper.title, paper.year);
127
+
128
+ if (!seen.has(key)) {
129
+ seen.set(key, paper);
130
+ } else {
131
+ // Merge: prefer richer metadata
132
+ // S2 usually has best metadata (abstracts, verified authors).
133
+ // Scholar has best citation counts.
134
+ // CrossRef has verified DOIs.
135
+
136
+ const existing = seen.get(key);
137
+ let merged = { ...existing };
138
+
139
+ // If new one is S2, take its metadata but keep higher citation count
140
+ if (paper.source === 'semantic_scholar') {
141
+ merged = { ...paper, citationCount: Math.max(existing.citationCount, paper.citationCount) };
142
+ if (existing.scholarClusterId) merged.scholarClusterId = existing.scholarClusterId;
143
+ }
144
+ // If new one is Scholar, just update citation count and maybe ID
145
+ else if (paper.source === 'google_scholar') {
146
+ merged.citationCount = Math.max(merged.citationCount, paper.citationCount);
147
+ merged.scholarClusterId = paper.clusterId;
148
+ }
149
+
150
+ seen.set(key, merged);
151
+ }
152
+ }
153
+
154
+ return Array.from(seen.values());
155
+ }
156
+
157
+ // --- Main Search Logic ---
158
+
159
+ async function search(query, options = {}) {
160
+ const limit = options.limit || 10;
161
+ const intent = options.intent || 'balanced';
162
+
163
+ let papers = [];
164
+ const errors = [];
165
+
166
+ const searchPromises = [];
167
+
168
+ // 1. S2 Search (Always)
169
+ searchPromises.push(
170
+ s2.search(query, { limit: limit * 2, year: options.year })
171
+ .then(res => res.map(mapS2ToPaper))
172
+ .catch(e => {
173
+ errors.push(`S2 Error: ${e.message}`);
174
+ return [];
175
+ })
176
+ );
177
+
178
+ // 2. Scholar Search (Conditional)
179
+ if ((intent === 'seminal' || options.useScholar) && scholar.isAvailable()) {
180
+ searchPromises.push(
181
+ scholar.search(query, { limit: limit, yearLow: options.year, yearHigh: options.year })
182
+ .then(res => res.map(mapScholarToPaper))
183
+ .catch(e => {
184
+ errors.push(`Scholar Error: ${e.message}`);
185
+ return [];
186
+ })
187
+ );
188
+ }
189
+
190
+ const results = await Promise.all(searchPromises);
191
+ results.forEach(r => papers.push(...r));
192
+
193
+ // 3. CrossRef Fallback (if few results)
194
+ if (papers.length < 5) {
195
+ try {
196
+ const crResults = await searchCrossRef(query, limit);
197
+ papers.push(...crResults);
198
+ } catch (e) {
199
+ errors.push(`CrossRef Error: ${e.message}`);
200
+ }
201
+ }
202
+
203
+ // 4. Deduplicate
204
+ const unique = deduplicatePapers(papers);
205
+
206
+ // 5. Rank
207
+ const ranked = ranker.rank(unique, intent);
208
+
209
+ // 6. Format to BibTeX
210
+ const formatted = ranked.slice(0, limit).map(p => {
211
+ // Generate key
212
+ const firstAuthor = p.authors && p.authors.length > 0
213
+ ? (p.authors[0].name ? p.authors[0].name.split(',')[0].trim().split(' ').pop().toLowerCase() : 'unknown')
214
+ : 'unknown';
215
+
216
+ // Handle S2 author format variants or string parsing
217
+ let familyName = 'unknown';
218
+ if (p.authors && p.authors.length > 0) {
219
+ const nameParts = p.authors[0].name.split(' ');
220
+ familyName = nameParts[nameParts.length - 1].toLowerCase().replace(/[^a-z]/g, '');
221
+ }
222
+
223
+ const shortTitle = p.title.split(/\s+/)[0].toLowerCase().replace(/[^a-z0-9]/g, '');
224
+ const key = `${familyName}${p.year || '????'}${shortTitle}`;
225
+
226
+ const bibData = {
227
+ key: key,
228
+ entryType: 'article',
229
+ author: p.authors.map(a => a.name).join(' and '),
230
+ title: p.title,
231
+ year: p.year ? p.year.toString() : null,
232
+ venue: p.venue,
233
+ booktitle: p.venue,
234
+ abstract: p.abstract,
235
+ doi: p.doi,
236
+ url: p.openAccessPdf ? p.openAccessPdf.url : (p.doi ? `https://doi.org/${p.doi}` : null),
237
+ google_scholar_id: p.scholarClusterId
238
+ };
239
+
240
+ const provenance = {
241
+ wtfp_status: p.doi ? 'official' : 'partial',
242
+ wtfp_source: p.source,
243
+ wtfp_citations: p.citationCount,
244
+ wtfp_velocity: p.wtfp_velocity,
245
+ wtfp_s2_id: p.paperId,
246
+ wtfp_scholar_id: p.scholarClusterId
247
+ };
248
+
249
+ return {
250
+ ...p,
251
+ bibtex: bibFormat.format(bibData, provenance)
252
+ };
253
+ });
254
+
255
+ return {
256
+ results: formatted,
257
+ metadata: {
258
+ query,
259
+ total: unique.length,
260
+ returned: formatted.length,
261
+ errors
262
+ }
263
+ };
264
+ }
265
+
266
+
267
+ // --- CLI Handling ---
268
+
269
+ if (require.main === module) {
270
+ const args = process.argv.slice(2);
271
+ const query = args.find(a => !a.startsWith('--'));
272
+
273
+ if (!query) {
274
+ console.error('Usage: node citation-fetcher.js "<query>" [--intent=seminal] [--year=2023]');
275
+ process.exit(1);
276
+ }
277
+
278
+ const intentArg = args.find(a => a.startsWith('--intent='));
279
+ const yearArg = args.find(a => a.startsWith('--year='));
280
+ const limitArg = args.find(a => a.startsWith('--limit='));
281
+
282
+ const options = {
283
+ intent: intentArg ? intentArg.split('=')[1] : 'balanced',
284
+ year: yearArg ? yearArg.split('=')[1] : null,
285
+ limit: limitArg ? parseInt(limitArg.split('=')[1]) : 10
286
+ };
287
+
288
+ search(query, options).then(result => {
289
+ // console.log(JSON.stringify(result, null, 2));
290
+ // For now, output just the bibtex entries as text for easy reading, or JSON?
291
+ // The previous fetcher output JSON. Let's stick to JSON array of results.
292
+ console.log(JSON.stringify(result.results, null, 2));
293
+ }).catch(e => {
294
+ console.error(e);
295
+ process.exit(1);
296
+ });
297
+ }
298
+
299
+ module.exports = { search };
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Citation Ranker
3
+ *
4
+ * Ranks papers based on multidimensional impact score:
5
+ * - Citation Count (Log scaled)
6
+ * - Velocity (Citations per month)
7
+ * - Recency (Decay over time)
8
+ * - Venue (Tiered scoring)
9
+ */
10
+
11
+ // Venue Tiers
12
+ const VENUE_TIERS = {
13
+ TIER_1: [
14
+ // AI/ML
15
+ "neurips", "nips", "icml", "iclr", "cvpr", "iccv", "eccv", "acl", "emnlp", "naacl",
16
+ "aaai", "ijcai", "kdd", "www", "sigir", "chi",
17
+ // Systems
18
+ "osdi", "sosp", "nsdi", "eurosys", "atc", "fast", "sigcomm", "mobicom",
19
+ // Journals
20
+ "nature", "science", "pnas", "cell", "lancet", "nejm", "jama",
21
+ "ieee transactions", "acm computing surveys", "jmlr", "journal of machine learning research"
22
+ ],
23
+ TIER_2: [
24
+ "coling", "wsdm", "cikm", "pakdd", "ijcnn", "icra", "iros",
25
+ "middleware", "cloud", "sc", "hpdc", "cluster"
26
+ ],
27
+ PREPRINT: [
28
+ "arxiv", "biorxiv", "medrxiv", "ssrn", "workshop"
29
+ ]
30
+ };
31
+
32
+ /**
33
+ * Score a venue name
34
+ * @param {string} venue
35
+ * @returns {number} 0.0 to 1.0
36
+ */
37
+ function scoreVenue(venue) {
38
+ if (!venue) return 0.2;
39
+ const v = venue.toLowerCase();
40
+
41
+ if (VENUE_TIERS.TIER_1.some(t => v.includes(t))) return 1.0;
42
+ if (VENUE_TIERS.TIER_2.some(t => v.includes(t))) return 0.7;
43
+ if (VENUE_TIERS.PREPRINT.some(t => v.includes(t))) return 0.3;
44
+
45
+ return 0.5; // Unknown venue default
46
+ }
47
+
48
+ /**
49
+ * Calculate citation velocity
50
+ * @param {number} citations
51
+ * @param {number} year
52
+ * @returns {number} Citations per month
53
+ */
54
+ function calculateVelocity(citations, year) {
55
+ if (!citations || !year) return 0;
56
+ const now = new Date();
57
+ const currentYear = now.getFullYear();
58
+ const currentMonth = now.getMonth(); // 0-11
59
+
60
+ // Calculate months since publication (assuming Jan 1st of year)
61
+ // If current year, use months passed. If past year, (diff * 12) + currentMonth.
62
+ let monthsSince = (currentYear - year) * 12 + currentMonth;
63
+
64
+ // Guard against divide by zero or negative (future dates)
65
+ monthsSince = Math.max(1, monthsSince);
66
+
67
+ return citations / monthsSince;
68
+ }
69
+
70
+ /**
71
+ * Calculate impact score for a paper
72
+ * @param {Object} paper
73
+ * @param {string} intent "seminal" | "recent" | "specific" | "balanced"
74
+ */
75
+ function calculateScore(paper, intent = "balanced") {
76
+ const now = new Date().getFullYear();
77
+ const year = paper.year || now;
78
+ const age = Math.max(0, now - year);
79
+ const citations = paper.citationCount || 0;
80
+ const velocity = calculateVelocity(citations, year);
81
+
82
+ // Normalize factors (approximate ranges)
83
+ // Citations: log10(100,000) = 5. So score is 0-1.
84
+ const citationScore = Math.min(1, Math.log10(citations + 1) / 5);
85
+
86
+ // Velocity: 100 cites/month is huge. Normalize to 0-1.
87
+ const velocityScore = Math.min(1, velocity / 100);
88
+
89
+ // Recency: Linear decay over 10 years. 0 if > 10 years old.
90
+ const recencyScore = Math.max(0, 1 - (age / 10));
91
+
92
+ const venueScore = scoreVenue(paper.venue);
93
+
94
+ // Weights based on intent
95
+ const weights = {
96
+ seminal: { citation: 0.6, velocity: 0.2, recency: 0.1, venue: 0.1 },
97
+ recent: { citation: 0.2, velocity: 0.3, recency: 0.4, venue: 0.1 },
98
+ specific: { citation: 0.1, velocity: 0.1, recency: 0.1, venue: 0.1 }, // Ranking less important for specific lookup
99
+ balanced: { citation: 0.4, velocity: 0.3, recency: 0.2, venue: 0.1 }
100
+ };
101
+
102
+ const w = weights[intent] || weights.balanced;
103
+
104
+ return (
105
+ citationScore * w.citation +
106
+ velocityScore * w.velocity +
107
+ recencyScore * w.recency +
108
+ venueScore * w.venue
109
+ );
110
+ }
111
+
112
+ /**
113
+ * Rank a list of papers
114
+ * @param {Array} papers
115
+ * @param {string} intent
116
+ * @returns {Array} Sorted papers with score attached
117
+ */
118
+ function rank(papers, intent = "balanced") {
119
+ const scored = papers.map(p => ({
120
+ ...p,
121
+ wtfp_score: calculateScore(p, intent),
122
+ wtfp_velocity: calculateVelocity(p.citationCount, p.year)
123
+ }));
124
+
125
+ return scored.sort((a, b) => b.wtfp_score - a.wtfp_score);
126
+ }
127
+
128
+ module.exports = {
129
+ rank,
130
+ calculateScore,
131
+ calculateVelocity,
132
+ scoreVenue
133
+ };
@@ -26,6 +26,24 @@ const MANIFEST = {
26
26
  dest: 'skills/wtfp',
27
27
  type: 'dir'
28
28
  },
29
+ {
30
+ id: 'agents',
31
+ src: path.join(ROOT, 'vendors', 'claude', 'agents', 'wtfp'),
32
+ dest: 'agents/wtfp',
33
+ type: 'dir'
34
+ },
35
+ {
36
+ id: 'mcp',
37
+ src: path.join(ROOT, 'vendors', 'claude', 'mcp'),
38
+ dest: 'mcp',
39
+ type: 'dir'
40
+ },
41
+ {
42
+ id: 'scripts',
43
+ src: path.join(ROOT, 'bin', 'lib'),
44
+ dest: 'bin',
45
+ type: 'dir'
46
+ },
29
47
  {
30
48
  id: 'plugin',
31
49
  src: path.join(ROOT, 'vendors', 'claude', '.claude-plugin'),
@@ -0,0 +1,188 @@
1
+ const https = require('https');
2
+ const querystring = require('querystring');
3
+
4
+ /**
5
+ * SerpAPI Google Scholar Wrapper
6
+ *
7
+ * Provides access to Google Scholar data via SerpAPI.
8
+ * Used for high-value "seminal" queries to get citation velocity and cluster IDs.
9
+ *
10
+ * Env:
11
+ * - SERPAPI_KEY: Required for operation.
12
+ */
13
+
14
+ const HOST = 'serpapi.com';
15
+ const PATH = '/search';
16
+
17
+ const usage = {
18
+ queries: 0,
19
+ // Default budget: 100 queries/month (~$5)
20
+ monthlyBudget: 100,
21
+
22
+ canQuery() {
23
+ return this.queries < this.monthlyBudget;
24
+ },
25
+
26
+ recordQuery() {
27
+ this.queries++;
28
+ if (this.queries % 10 === 0) {
29
+ // console.error(`[Scholar] API usage: ${this.queries}/${this.monthlyBudget}`);
30
+ }
31
+ }
32
+ };
33
+
34
+ function getApiKey() {
35
+ return process.env.SERPAPI_KEY;
36
+ }
37
+
38
+ function isAvailable() {
39
+ return !!getApiKey();
40
+ }
41
+
42
+ function request(params) {
43
+ return new Promise((resolve, reject) => {
44
+ const key = getApiKey();
45
+ if (!key) {
46
+ return reject(new Error('SERPAPI_KEY not set'));
47
+ }
48
+ if (!usage.canQuery()) {
49
+ return reject(new Error('Monthly budget exceeded'));
50
+ }
51
+
52
+ usage.recordQuery();
53
+
54
+ const q = querystring.stringify({
55
+ ...params,
56
+ api_key: key,
57
+ engine: 'google_scholar'
58
+ });
59
+
60
+ const options = {
61
+ hostname: HOST,
62
+ path: `${PATH}?${q}`,
63
+ method: 'GET'
64
+ };
65
+
66
+ const req = https.request(options, (res) => {
67
+ let data = '';
68
+ res.on('data', c => data += c);
69
+ res.on('end', () => {
70
+ if (res.statusCode !== 200) {
71
+ return reject(new Error(`SerpAPI Error: ${res.statusCode} ${data}`));
72
+ }
73
+ try {
74
+ resolve(JSON.parse(data));
75
+ } catch (e) {
76
+ reject(e);
77
+ }
78
+ });
79
+ });
80
+
81
+ req.on('error', reject);
82
+ req.end();
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Search Google Scholar
88
+ * @param {string} query
89
+ * @param {Object} options { yearLow, yearHigh, limit }
90
+ */
91
+ async function search(query, options = {}) {
92
+ const params = {
93
+ q: query,
94
+ num: options.limit || 10
95
+ };
96
+
97
+ if (options.yearLow) params.as_ylo = options.yearLow;
98
+ if (options.yearHigh) params.as_yhi = options.yearHigh;
99
+
100
+ const data = await request(params);
101
+ return (data.organic_results || []).map(mapResult);
102
+ }
103
+
104
+ /**
105
+ * Get paper by Cluster ID
106
+ * @param {string} clusterId
107
+ */
108
+ async function getByClusterId(clusterId) {
109
+ const data = await request({ cluster: clusterId });
110
+ // When searching by cluster, it returns list of versions.
111
+ // We usually want the first one or metadata about the cluster.
112
+ // SerpAPI "cluster" usually returns "organic_results" which are the versions.
113
+ const results = data.organic_results || [];
114
+ if (results.length > 0) return mapResult(results[0]);
115
+ return null;
116
+ }
117
+
118
+ /**
119
+ * Get citing papers
120
+ * @param {string} clusterId
121
+ * @param {number} limit
122
+ */
123
+ async function getCitingPapers(clusterId, limit = 10) {
124
+ const data = await request({
125
+ cites: clusterId,
126
+ num: limit
127
+ });
128
+ return (data.organic_results || []).map(mapResult);
129
+ }
130
+
131
+ function mapResult(item) {
132
+ // Extract cluster ID
133
+ let clusterId = null;
134
+ // Try to find it in links or specific fields if SerpAPI provides it directly
135
+ // SerpAPI usually puts it in inline_links -> cited_by -> serpapi_link (cites=ID)
136
+ // or versions -> serpapi_link (cluster=ID)
137
+
138
+ if (item.inline_links && item.inline_links.cited_by && item.inline_links.cited_by.serpapi_link) {
139
+ const match = item.inline_links.cited_by.serpapi_link.match(/cites=(\w+)/);
140
+ if (match) clusterId = match[1];
141
+ }
142
+
143
+ return {
144
+ source: 'google_scholar',
145
+ title: item.title,
146
+ clusterId: clusterId || item.cluster_id, // Sometimes provided directly
147
+ link: item.link,
148
+ snippet: item.snippet,
149
+ publication_info: item.publication_info,
150
+ citationCount: item.inline_links && item.inline_links.cited_by ? item.inline_links.cited_by.total : 0,
151
+ year: extractYear(item.publication_info ? item.publication_info.summary : ''),
152
+ authors: extractAuthors(item.publication_info ? item.publication_info.summary : ''),
153
+ venue: extractVenue(item.publication_info ? item.publication_info.summary : '')
154
+ };
155
+ }
156
+
157
+ function extractYear(summary) {
158
+ const match = summary.match(/\b(19|20)\d{2}\b/);
159
+ return match ? parseInt(match[0]) : null;
160
+ }
161
+
162
+ function extractAuthors(summary) {
163
+ // "A Author, B Author - Venue, 2020 - publisher"
164
+ if (!summary) return [];
165
+ const parts = summary.split('-');
166
+ if (parts.length > 0) {
167
+ return parts[0].split(',').map(s => ({ name: s.trim() }));
168
+ }
169
+ return [];
170
+ }
171
+
172
+ function extractVenue(summary) {
173
+ if (!summary) return null;
174
+ const parts = summary.split('-');
175
+ if (parts.length > 1) {
176
+ // "Venue, Year" usually
177
+ return parts[1].split(',')[0].trim();
178
+ }
179
+ return null;
180
+ }
181
+
182
+ module.exports = {
183
+ isAvailable,
184
+ search,
185
+ getByClusterId,
186
+ getCitingPapers,
187
+ usage
188
+ };