@jsonresume/jobs 0.14.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api.js +7 -0
- package/src/formatters.js +12 -0
- package/src/tui/JobDetail.js +25 -3
- package/src/tui/JobList.js +3 -1
- package/src/tui/useAI.js +23 -1
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -59,6 +59,13 @@ export function createApiClient({ baseUrl, apiKey }) {
|
|
|
59
59
|
}),
|
|
60
60
|
deleteSearch: (id) => request(`/searches/${id}`, { method: 'DELETE' }),
|
|
61
61
|
|
|
62
|
+
// Enrichment
|
|
63
|
+
enrichJob: (id, enriched) =>
|
|
64
|
+
request(`/jobs/${id}`, {
|
|
65
|
+
method: 'PATCH',
|
|
66
|
+
body: JSON.stringify({ enriched }),
|
|
67
|
+
}),
|
|
68
|
+
|
|
62
69
|
// Dossiers
|
|
63
70
|
fetchDossier: (id) => request(`/jobs/${id}/dossier`),
|
|
64
71
|
saveDossier: (id, content) =>
|
package/src/formatters.js
CHANGED
|
@@ -39,6 +39,18 @@ export function truncate(str, len) {
|
|
|
39
39
|
return str.length > len ? str.slice(0, len - 1) + '…' : str;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
export function formatAge(postedAt) {
|
|
43
|
+
if (!postedAt) return '';
|
|
44
|
+
const days = Math.floor(
|
|
45
|
+
(Date.now() - new Date(postedAt).getTime()) / 86400000
|
|
46
|
+
);
|
|
47
|
+
if (days === 0) return 'today';
|
|
48
|
+
if (days === 1) return '1d ago';
|
|
49
|
+
if (days < 7) return `${days}d ago`;
|
|
50
|
+
if (days < 30) return `${Math.floor(days / 7)}w ago`;
|
|
51
|
+
return `${Math.floor(days / 30)}mo ago`;
|
|
52
|
+
}
|
|
53
|
+
|
|
42
54
|
export function stateLabel(state) {
|
|
43
55
|
const labels = {
|
|
44
56
|
interested: 'Interested',
|
package/src/tui/JobDetail.js
CHANGED
|
@@ -2,7 +2,12 @@ import { useState, useEffect } from 'react';
|
|
|
2
2
|
import { Box, Text, useInput } from 'ink';
|
|
3
3
|
import Spinner from 'ink-spinner';
|
|
4
4
|
import { h } from './h.js';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
stateIcon,
|
|
7
|
+
formatSalary,
|
|
8
|
+
formatLocation,
|
|
9
|
+
formatAge,
|
|
10
|
+
} from '../formatters.js';
|
|
6
11
|
|
|
7
12
|
export default function JobDetail({
|
|
8
13
|
job,
|
|
@@ -79,17 +84,25 @@ export default function JobDetail({
|
|
|
79
84
|
lines.push({ text: '' });
|
|
80
85
|
|
|
81
86
|
// Meta as key-value pairs
|
|
87
|
+
const age = formatAge(d.posted_at || job.posted_at);
|
|
88
|
+
const postedStr = d.posted_at
|
|
89
|
+
? `${d.posted_at.slice(0, 10)}${age ? ` (${age})` : ''}`
|
|
90
|
+
: '—';
|
|
91
|
+
|
|
82
92
|
const meta = [
|
|
83
93
|
['📍 Location', loc],
|
|
84
94
|
['💰 Salary', sal],
|
|
85
95
|
['📋 Type', d.type || '—'],
|
|
86
96
|
['📊 Experience', d.experience || '—'],
|
|
87
|
-
['📅 Posted',
|
|
97
|
+
['📅 Posted', postedStr],
|
|
88
98
|
[
|
|
89
99
|
'🎯 Match',
|
|
90
100
|
typeof d.similarity === 'number' ? `${d.similarity.toFixed(3)}` : '—',
|
|
91
101
|
],
|
|
92
102
|
];
|
|
103
|
+
if (d.decayed_similarity && d.decayed_similarity !== d.similarity) {
|
|
104
|
+
meta.push(['⏳ Recency', d.decayed_similarity.toFixed(3)]);
|
|
105
|
+
}
|
|
93
106
|
if (d.rerank_score) meta.push(['🧠 AI Score', `${d.rerank_score}/10`]);
|
|
94
107
|
if (d.combined_score) meta.push(['📈 Combined', d.combined_score.toFixed(3)]);
|
|
95
108
|
meta.push(['📌 Status', state ? `${stateIcon(state)} ${state}` : 'unmarked']);
|
|
@@ -104,11 +117,20 @@ export default function JobDetail({
|
|
|
104
117
|
lines.push({ text: '' });
|
|
105
118
|
}
|
|
106
119
|
|
|
107
|
-
// Skills
|
|
120
|
+
// Skills with match indicators
|
|
108
121
|
if (d.skills?.length) {
|
|
109
122
|
lines.push({ text: 'Skills', bold: true, color: 'yellow' });
|
|
110
123
|
const skillNames = d.skills.map((s) => s.name || s).join(' · ');
|
|
111
124
|
lines.push({ text: ` ${skillNames}` });
|
|
125
|
+
// Show keywords for each skill
|
|
126
|
+
for (const sk of d.skills) {
|
|
127
|
+
if (sk.keywords?.length) {
|
|
128
|
+
lines.push({
|
|
129
|
+
text: ` ${sk.name}: ${sk.keywords.join(', ')}`,
|
|
130
|
+
dimColor: true,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
112
134
|
lines.push({ text: '' });
|
|
113
135
|
}
|
|
114
136
|
|
package/src/tui/JobList.js
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
truncate,
|
|
7
7
|
formatSalary,
|
|
8
8
|
formatLocation,
|
|
9
|
+
formatAge,
|
|
9
10
|
} from '../formatters.js';
|
|
10
11
|
|
|
11
12
|
// Column gap between each column
|
|
@@ -138,6 +139,7 @@ function JobRow({
|
|
|
138
139
|
const sal = formatSalary(job.salary, job.salary_usd);
|
|
139
140
|
const score =
|
|
140
141
|
typeof job.similarity === 'number' ? job.similarity.toFixed(2) : '—';
|
|
142
|
+
const age = formatAge(job.posted_at);
|
|
141
143
|
const icon = stateIcon(job.state);
|
|
142
144
|
const dossierIcon =
|
|
143
145
|
dossierStatus === 'generating' ? '◌' : dossierStatus === 'done' ? '📋' : '';
|
|
@@ -227,7 +229,7 @@ function JobRow({
|
|
|
227
229
|
h(
|
|
228
230
|
Box,
|
|
229
231
|
{ width: locW, marginRight: GAP },
|
|
230
|
-
h(Text, props, truncate(loc, locW - 1))
|
|
232
|
+
h(Text, props, truncate(age ? `${loc} · ${age}` : loc, locW - 1))
|
|
231
233
|
),
|
|
232
234
|
h(Box, { width: 12, marginRight: GAP }, h(Text, props, truncate(sal, 11))),
|
|
233
235
|
h(
|
package/src/tui/useAI.js
CHANGED
|
@@ -262,7 +262,19 @@ Research everything you can and produce a complete dossier covering:
|
|
|
262
262
|
- Questions the candidate should ask
|
|
263
263
|
- Topics to research before interviewing
|
|
264
264
|
|
|
265
|
-
Be thorough, specific, and opinionated. Reference the candidate's actual experience when making recommendations
|
|
265
|
+
Be thorough, specific, and opinionated. Reference the candidate's actual experience when making recommendations.
|
|
266
|
+
|
|
267
|
+
### IMPORTANT: Structured Data Block
|
|
268
|
+
At the very end of your response, output a JSON code block tagged \`\`\`enrichment with corrected/discovered job metadata. Only include fields where you found better data than what's in the posting. This helps improve the job database:
|
|
269
|
+
\`\`\`enrichment
|
|
270
|
+
{
|
|
271
|
+
"salary": "$X - $Y" or null if unknown,
|
|
272
|
+
"remote": "Full" | "Hybrid" | "None" | null,
|
|
273
|
+
"location": { "city": "...", "countryCode": "XX", "region": "..." } or null,
|
|
274
|
+
"experience": "Junior" | "Mid" | "Senior" | "Staff" | "Lead" | null,
|
|
275
|
+
"type": "Full-time" | "Contract" | "Part-time" | null
|
|
276
|
+
}
|
|
277
|
+
\`\`\``;
|
|
266
278
|
|
|
267
279
|
try {
|
|
268
280
|
const { spawn } = await import('child_process');
|
|
@@ -371,6 +383,16 @@ Be thorough, specific, and opinionated. Reference the candidate's actual experie
|
|
|
371
383
|
try {
|
|
372
384
|
await api.saveDossier(job.id, finalResult);
|
|
373
385
|
} catch {}
|
|
386
|
+
// Extract and save enrichment data if present
|
|
387
|
+
try {
|
|
388
|
+
const enrichMatch = finalResult.match(
|
|
389
|
+
/```enrichment\s*\n([\s\S]*?)\n```/
|
|
390
|
+
);
|
|
391
|
+
if (enrichMatch) {
|
|
392
|
+
const enriched = JSON.parse(enrichMatch[1]);
|
|
393
|
+
await api.enrichJob(job.id, enriched);
|
|
394
|
+
}
|
|
395
|
+
} catch {}
|
|
374
396
|
}
|
|
375
397
|
} catch (err) {
|
|
376
398
|
if (err.message?.includes('exited with code')) {
|