@jsonresume/jobs 0.14.1 → 0.14.2

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/src/tui/useAI.js CHANGED
@@ -1,50 +1,15 @@
1
1
  import { useState, useCallback, useRef } from 'react';
2
2
  import { writeFileSync } from 'fs';
3
- import { generateText } from 'ai';
4
- import { openai } from '@ai-sdk/openai';
5
-
6
- function buildResumeText(resume) {
7
- return [
8
- resume?.basics?.name,
9
- resume?.basics?.label,
10
- resume?.basics?.summary,
11
- ...(resume?.skills || []).map(
12
- (s) => `${s.name}: ${(s.keywords || []).join(', ')}`
13
- ),
14
- ...(resume?.work || [])
15
- .slice(0, 5)
16
- .map(
17
- (w) =>
18
- `${w.position} at ${w.name}${
19
- w.startDate ? ` (${w.startDate})` : ''
20
- }: ${w.summary || ''}`
21
- ),
22
- ...(resume?.projects || [])
23
- .slice(0, 3)
24
- .map((p) => `Project: ${p.name} — ${p.description || ''}`),
25
- ]
26
- .filter(Boolean)
27
- .join('\n');
28
- }
29
-
30
- function buildJobText(job, rawContent) {
31
- return [
32
- `Title: ${job.title}`,
33
- `Company: ${job.company}`,
34
- `Salary: ${job.salary || 'Not listed'}`,
35
- `Remote: ${job.remote || 'Not specified'}`,
36
- job.experience ? `Experience: ${job.experience}` : '',
37
- job.type ? `Type: ${job.type}` : '',
38
- job.url ? `HN Post: ${job.url}` : '',
39
- `Description: ${job.description || ''}`,
40
- `Skills: ${(job.skills || []).map((s) => s.name || s).join(', ')}`,
41
- ...(job.qualifications || []).map((q) => `Qualification: ${q}`),
42
- ...(job.responsibilities || []).map((r) => `Responsibility: ${r}`),
43
- rawContent ? `\nFull original posting:\n${rawContent}` : '',
44
- ]
45
- .filter(Boolean)
46
- .join('\n');
47
- }
3
+ import {
4
+ buildResumeText,
5
+ buildJobText,
6
+ buildDossierPrompt,
7
+ extractEnrichment,
8
+ dossierFilename,
9
+ } from './aiHelpers.js';
10
+ import { resolveClaudePath, runClaudeDossier } from './claudeDossier.js';
11
+ import { runJobSummary, runBatchReview } from './gptReview.js';
12
+ import { dossierStatus, seedDossierFlags } from './dossierCache.js';
48
13
 
49
14
  export function useAI(resume) {
50
15
  const [text, setText] = useState('');
@@ -73,33 +38,7 @@ export function useAI(resume) {
73
38
  setError(null);
74
39
  setText('');
75
40
  try {
76
- const resumeText = [
77
- resume?.basics?.label,
78
- resume?.basics?.summary,
79
- ...(resume?.skills || []).map(
80
- (s) => `${s.name}: ${(s.keywords || []).join(', ')}`
81
- ),
82
- ]
83
- .filter(Boolean)
84
- .join('\n');
85
-
86
- const jobText = [
87
- `Title: ${job.title}`,
88
- `Company: ${job.company}`,
89
- `Salary: ${job.salary || 'Not listed'}`,
90
- `Remote: ${job.remote || 'Not specified'}`,
91
- `Description: ${job.description || ''}`,
92
- `Skills: ${(job.skills || []).map((s) => s.name).join(', ')}`,
93
- ].join('\n');
94
-
95
- const { text: result } = await generateText({
96
- model: openai('gpt-4o-mini'),
97
- system:
98
- 'You are a career advisor. Given a job posting and resume, provide a concise fit analysis in 4-5 bullet points. Cover: why it fits, skill gaps, salary assessment, and remote compatibility. Be direct and opinionated.',
99
- prompt: `Resume:\n${resumeText}\n\nJob:\n${jobText}`,
100
- maxTokens: 400,
101
- });
102
- setText(result);
41
+ setText(await runJobSummary(resume, job));
103
42
  } catch (err) {
104
43
  setError(err.message);
105
44
  } finally {
@@ -160,10 +99,9 @@ export function useAI(resume) {
160
99
  }
161
100
 
162
101
  // Check if claude CLI is available
163
- const { execSync } = await import('child_process');
164
102
  let claudePath;
165
103
  try {
166
- claudePath = execSync('which claude', { encoding: 'utf-8' }).trim();
104
+ claudePath = await resolveClaudePath();
167
105
  } catch {
168
106
  setError(
169
107
  'Claude Code CLI not found. Install it: npm install -g @anthropic-ai/claude-code'
@@ -201,181 +139,20 @@ export function useAI(resume) {
201
139
 
202
140
  const resumeText = buildResumeText(currentResume);
203
141
  const jobText = buildJobText(job, rawContent);
204
-
205
- const prompt = `You are a job search research assistant. A candidate is considering applying to this role. Do thorough research and produce a comprehensive dossier.
206
-
207
- ## Candidate Resume
208
- ${resumeText}
209
-
210
- ## Job Posting
211
- ${jobText}
212
-
213
- ## Your Task
214
-
215
- Research everything you can and produce a complete dossier covering:
216
-
217
- ### 1. Compensation Context
218
- - Market rate for this role/level/location
219
- - How the listed salary compares
220
- - Negotiation leverage points
221
-
222
- ### 2. AI Engineering Culture
223
- - Does the company use or encourage AI coding tools (Copilot, Claude Code, Cursor, agentic workflows)?
224
- - Any public statements, blog posts, or job descriptions mentioning AI-assisted development?
225
- - Is the engineering culture likely to embrace engineers who ship faster using AI tools, or is there resistance?
226
- - Look for signals: do they mention "AI-native", "agentic", "LLM-augmented" workflows, or similar?
227
- - Are there concerns about AI tool usage (IP policies, code review friction, etc.)?
228
- - Rating: AI-Forward / AI-Friendly / Neutral / Unknown / AI-Resistant
229
-
230
- ### 3. Company Deep Dive
231
- - What the company does, their products/services
232
- - Funding stage, size, recent news
233
- - Tech stack and engineering culture
234
- - Leadership team
235
- - Employee sentiment and Glassdoor/Blind highlights
236
- - Any red flags or concerns
237
-
238
- ### 4. Role Analysis
239
- - What you'd actually be doing day-to-day
240
- - Team context — who you'd work with
241
- - Growth trajectory for this role
242
- - How this role fits into the company's current priorities
243
-
244
- ### 5. Fit Assessment
245
- - Matching skills and experience (be specific, reference the candidate's actual background)
246
- - Skill gaps to acknowledge or address
247
- - Adjacent experience that transfers
248
- - Overall fit rating: Strong / Good / Stretch
249
-
250
- ### 6. Cover Letter Talking Points
251
- - 5-7 specific bullet points to mention, each referencing the candidate's real experience
252
- - What angle to take (technical depth? leadership? domain expertise?)
253
- - What to emphasize vs. downplay
254
-
255
- ### 7. Contact Info
256
- - Email addresses from the posting
257
- - Who posted (HN username from the URL if available)
258
- - Best way to reach out
259
-
260
- ### 8. Interview Prep
261
- - Questions they'll likely ask for this role
262
- - Questions the candidate should ask
263
- - Topics to research before interviewing
264
-
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
- \`\`\``;
142
+ const prompt = buildDossierPrompt(resumeText, jobText);
278
143
 
279
144
  try {
280
- const { spawn } = await import('child_process');
281
- // Remove CLAUDECODE env var to allow nested claude sessions
282
- const env = { ...process.env };
283
- delete env.CLAUDECODE;
284
- delete env.CLAUDE_CODE_ENTRYPOINT;
285
- delete env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
286
-
287
- const child = spawn(
145
+ const finalResult = await runClaudeDossier({
288
146
  claudePath,
289
- [
290
- '--print',
291
- '--output-format',
292
- 'stream-json',
293
- '--verbose',
294
- '--allowedTools',
295
- 'WebSearch',
296
- 'WebFetch',
297
- '--',
298
- prompt,
299
- ],
300
- { stdio: ['ignore', 'pipe', 'pipe'], env }
301
- );
302
- childRef.current = child;
303
-
304
- let finalResult = '';
305
- let buffer = '';
306
- let statusLine = '';
307
-
308
- function processLine(line) {
309
- if (!line.trim()) return;
310
- try {
311
- const event = JSON.parse(line);
312
-
313
- if (event.type === 'assistant') {
314
- // Extract text from assistant message content
315
- const content = event.message?.content || [];
316
- for (const block of content) {
317
- if (block.type === 'text' && block.text) {
318
- finalResult = block.text;
319
- updateText(
320
- statusLine ? `${statusLine}\n\n${finalResult}` : finalResult
321
- );
322
- } else if (block.type === 'tool_use') {
323
- // Show what Claude is doing (e.g. WebSearch, WebFetch)
324
- const name = block.name || 'tool';
325
- const input = block.input || {};
326
- if (name === 'WebSearch' || name === 'WebFetch') {
327
- statusLine = `🔍 ${name}: ${
328
- input.query || input.url || ''
329
- }`;
330
- } else {
331
- statusLine = `⚙ Using ${name}…`;
332
- }
333
- updateText(
334
- finalResult ? `${statusLine}\n\n${finalResult}` : statusLine
335
- );
336
- }
337
- }
338
- }
339
-
340
- if (event.type === 'result' && event.result) {
341
- finalResult = event.result;
342
- updateText(finalResult);
343
- }
344
- } catch {
345
- // Not valid JSON, skip
346
- }
347
- }
348
-
349
- child.stdout.on('data', (chunk) => {
350
- buffer += chunk.toString();
351
- // Process complete JSON lines
352
- const lines = buffer.split('\n');
353
- buffer = lines.pop() || '';
354
- for (const line of lines) {
355
- processLine(line);
356
- }
357
- });
358
-
359
- child.stderr.on('data', () => {});
360
-
361
- await new Promise((resolve, reject) => {
362
- child.on('close', (code) => {
147
+ prompt,
148
+ onText: updateText,
149
+ onChild: (child) => {
150
+ childRef.current = child;
151
+ },
152
+ onEnd: (child) => {
363
153
  // Only clear childRef if it's still our process
364
154
  if (childRef.current === child) childRef.current = null;
365
- // Process any remaining buffer
366
- if (buffer.trim()) processLine(buffer);
367
- if (code === 0) {
368
- resolve();
369
- } else if (code !== null) {
370
- reject(new Error(`Claude exited with code ${code}`));
371
- } else {
372
- resolve(); // killed
373
- }
374
- });
375
- child.on('error', (err) => {
376
- if (childRef.current === child) childRef.current = null;
377
- reject(err);
378
- });
155
+ },
379
156
  });
380
157
 
381
158
  // Save to server if we got output
@@ -384,15 +161,12 @@ At the very end of your response, output a JSON code block tagged \`\`\`enrichme
384
161
  await api.saveDossier(job.id, finalResult);
385
162
  } catch {}
386
163
  // 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]);
164
+ const enriched = extractEnrichment(finalResult);
165
+ if (enriched) {
166
+ try {
393
167
  await api.enrichJob(job.id, enriched);
394
- }
395
- } catch {}
168
+ } catch {}
169
+ }
396
170
  }
397
171
  } catch (err) {
398
172
  if (err.message?.includes('exited with code')) {
@@ -429,28 +203,7 @@ At the very end of your response, output a JSON code block tagged \`\`\`enrichme
429
203
  setError(null);
430
204
  setText('');
431
205
  try {
432
- const resumeText = [resume?.basics?.label, resume?.basics?.summary]
433
- .filter(Boolean)
434
- .join('\n');
435
-
436
- const jobsList = jobs
437
- .slice(0, 15)
438
- .map(
439
- (j, i) =>
440
- `${i + 1}. [${j.similarity}] ${j.title} at ${j.company} | ${
441
- j.salary || 'no salary'
442
- } | ${j.remote || 'no remote info'}`
443
- )
444
- .join('\n');
445
-
446
- const { text: result } = await generateText({
447
- model: openai('gpt-4o-mini'),
448
- system:
449
- 'You are a career advisor. Rank these jobs by fit for the candidate. For each, give a 1-line verdict. Be direct.',
450
- prompt: `Resume:\n${resumeText}\n\nJobs:\n${jobsList}`,
451
- maxTokens: 600,
452
- });
453
- setText(result);
206
+ setText(await runBatchReview(resume, jobs));
454
207
  } catch (err) {
455
208
  setError(err.message);
456
209
  } finally {
@@ -477,42 +230,22 @@ At the very end of your response, output a JSON code block tagged \`\`\`enrichme
477
230
  const content =
478
231
  textRef.current || dossierCache.current.get(job?.id)?.text || '';
479
232
  if (!content) return null;
480
- const company = (job?.company || 'unknown')
481
- .replace(/[^a-zA-Z0-9]+/g, '-')
482
- .replace(/-+/g, '-')
483
- .replace(/^-|-$/g, '')
484
- .toLowerCase()
485
- .slice(0, 50);
486
- const filename = `dossier-${company}.md`;
233
+ const filename = dossierFilename(job);
487
234
  writeFileSync(filename, content, 'utf-8');
488
235
  return filename;
489
236
  }, []);
490
237
 
491
238
  // Seed cache with server-side dossier flags from job list
492
- const seedDossierFlags = useCallback((jobs) => {
493
- let changed = false;
494
- for (const job of jobs) {
495
- if (job.has_dossier && !dossierCache.current.has(job.id)) {
496
- dossierCache.current.set(job.id, {
497
- text: '',
498
- done: true,
499
- loading: false,
500
- });
501
- changed = true;
502
- }
503
- }
504
- if (changed) bumpTick();
239
+ const seedFlags = useCallback((jobs) => {
240
+ if (seedDossierFlags(dossierCache.current, jobs)) bumpTick();
505
241
  }, []);
506
242
 
507
243
  // Expose dossier status for job list icons
508
244
  // Returns: 'generating' | 'done' | null
509
- const getDossierStatus = useCallback((jobId) => {
510
- const entry = dossierCache.current.get(jobId);
511
- if (!entry) return null;
512
- if (entry.loading) return 'generating';
513
- if (entry.done) return 'done';
514
- return null;
515
- }, []);
245
+ const getDossierStatus = useCallback(
246
+ (jobId) => dossierStatus(dossierCache.current, jobId),
247
+ []
248
+ );
516
249
 
517
250
  const regenerateDossier = useCallback(
518
251
  (job, api) => {
@@ -535,7 +268,7 @@ At the very end of your response, output a JSON code block tagged \`\`\`enrichme
535
268
  mode,
536
269
  hasActiveProcess: Boolean(childRef.current),
537
270
  getDossierStatus,
538
- seedDossierFlags,
271
+ seedDossierFlags: seedFlags,
539
272
  summarizeJob,
540
273
  dossier,
541
274
  regenerateDossier,
@@ -1,5 +1,6 @@
1
1
  import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
2
2
  import { getCached, setCache, updateCachedJob } from '../cache.js';
3
+ import { normalizeLocation } from '../formatters.js';
3
4
 
4
5
  export function useJobs(api, activeFilters, tab, searchId, getDossierStatus) {
5
6
  const [allJobs, setAllJobs] = useState([]);
@@ -10,7 +11,7 @@ export function useJobs(api, activeFilters, tab, searchId, getDossierStatus) {
10
11
 
11
12
  // Build API params from active filters array
12
13
  const params = useMemo(() => {
13
- const p = { top: 100, days: 30 };
14
+ const p = { top: 200, days: 90 };
14
15
  if (searchId) p.searchId = searchId;
15
16
  if (api.mode) p.mode = api.mode;
16
17
  for (const f of activeFilters || []) {
@@ -119,9 +120,11 @@ export function useJobs(api, activeFilters, tab, searchId, getDossierStatus) {
119
120
 
120
121
  for (const f of activeFilters || []) {
121
122
  if (f.type === 'remote') {
122
- filtered = filtered.filter(
123
- (j) => j.remote === 'Full' || /remote/i.test(j.location || '')
124
- );
123
+ // location may be an object {city,region,countryCode} (canonical wire
124
+ // shape) or a historic string; normalizeLocation handles both and also
125
+ // folds in the separate `remote` field. The old `/remote/i.test(...)`
126
+ // silently failed on object locations (it tested "[object Object]").
127
+ filtered = filtered.filter((j) => normalizeLocation(j).remote);
125
128
  }
126
129
  if (f.type === 'globalRemote') {
127
130
  filtered = filtered.filter((j) => j.global_remote === true);