@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/LICENSE +21 -0
- package/bin/cli.js +2 -2
- package/package.json +9 -2
- package/src/formatters.js +54 -2
- package/src/formatters.test.js +107 -0
- package/src/tui/App.js +108 -265
- package/src/tui/SplitPane.js +58 -0
- package/src/tui/aiHelpers.js +129 -0
- package/src/tui/aiHelpers.test.js +200 -0
- package/src/tui/appKeyHandler.js +89 -0
- package/src/tui/appKeyHandler.test.js +144 -0
- package/src/tui/claudeDossier.js +97 -0
- package/src/tui/claudeDossier.test.js +40 -0
- package/src/tui/dossierCache.js +26 -0
- package/src/tui/dossierCache.test.js +40 -0
- package/src/tui/dossierPrompt.js +80 -0
- package/src/tui/dossierStream.js +48 -0
- package/src/tui/dossierStream.test.js +81 -0
- package/src/tui/gptReview.js +47 -0
- package/src/tui/jobFilters.js +69 -0
- package/src/tui/jobFilters.test.js +80 -0
- package/src/tui/useAI.js +35 -302
- package/src/tui/useJobs.js +7 -4
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawn the Claude Code CLI to generate a research dossier and stream its
|
|
3
|
+
* output. Isolated from React so the spawn/stream/lifecycle logic can be
|
|
4
|
+
* reasoned about (and replaced) independently of the hook state.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createDossierStream } from './dossierStream.js';
|
|
8
|
+
|
|
9
|
+
// Locate the `claude` binary. Throws if not installed.
|
|
10
|
+
export async function resolveClaudePath() {
|
|
11
|
+
const { execSync } = await import('child_process');
|
|
12
|
+
return execSync('which claude', { encoding: 'utf-8' }).trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Env for nested claude sessions — drop the markers that block nesting.
|
|
16
|
+
export function nestedClaudeEnv(baseEnv = process.env) {
|
|
17
|
+
const env = { ...baseEnv };
|
|
18
|
+
delete env.CLAUDECODE;
|
|
19
|
+
delete env.CLAUDE_CODE_ENTRYPOINT;
|
|
20
|
+
delete env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
|
|
21
|
+
return env;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const CLAUDE_ARGS_PREFIX = [
|
|
25
|
+
'--print',
|
|
26
|
+
'--output-format',
|
|
27
|
+
'stream-json',
|
|
28
|
+
'--verbose',
|
|
29
|
+
'--allowedTools',
|
|
30
|
+
'WebSearch',
|
|
31
|
+
'WebFetch',
|
|
32
|
+
'--',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Spawn claude with the prompt, stream parsed text through onText, and resolve
|
|
37
|
+
* with the final result string once the process closes.
|
|
38
|
+
*
|
|
39
|
+
* @param {object} opts
|
|
40
|
+
* @param {string} opts.claudePath resolved path to the claude binary
|
|
41
|
+
* @param {string} opts.prompt dossier prompt
|
|
42
|
+
* @param {(text:string)=>void} opts.onText called with composed visible text
|
|
43
|
+
* @param {(child:any)=>void} opts.onChild receives the spawned child process
|
|
44
|
+
* @param {(child:any)=>void} [opts.onEnd] called when the process closes/errors
|
|
45
|
+
* @returns {Promise<string>} final result text
|
|
46
|
+
*/
|
|
47
|
+
export async function runClaudeDossier({
|
|
48
|
+
claudePath,
|
|
49
|
+
prompt,
|
|
50
|
+
onText,
|
|
51
|
+
onChild,
|
|
52
|
+
onEnd,
|
|
53
|
+
}) {
|
|
54
|
+
const { spawn } = await import('child_process');
|
|
55
|
+
const env = nestedClaudeEnv();
|
|
56
|
+
|
|
57
|
+
const child = spawn(claudePath, [...CLAUDE_ARGS_PREFIX, prompt], {
|
|
58
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
59
|
+
env,
|
|
60
|
+
});
|
|
61
|
+
onChild?.(child);
|
|
62
|
+
|
|
63
|
+
const stream = createDossierStream(onText);
|
|
64
|
+
let buffer = '';
|
|
65
|
+
|
|
66
|
+
child.stdout.on('data', (chunk) => {
|
|
67
|
+
buffer += chunk.toString();
|
|
68
|
+
const lines = buffer.split('\n');
|
|
69
|
+
buffer = lines.pop() || '';
|
|
70
|
+
for (const line of lines) {
|
|
71
|
+
stream.processLine(line);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
child.stderr.on('data', () => {});
|
|
76
|
+
|
|
77
|
+
await new Promise((resolve, reject) => {
|
|
78
|
+
child.on('close', (code) => {
|
|
79
|
+
onEnd?.(child);
|
|
80
|
+
// Process any remaining buffered partial line.
|
|
81
|
+
if (buffer.trim()) stream.processLine(buffer);
|
|
82
|
+
if (code === 0) {
|
|
83
|
+
resolve();
|
|
84
|
+
} else if (code !== null) {
|
|
85
|
+
reject(new Error(`Claude exited with code ${code}`));
|
|
86
|
+
} else {
|
|
87
|
+
resolve(); // killed
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
child.on('error', (err) => {
|
|
91
|
+
onEnd?.(child);
|
|
92
|
+
reject(err);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return stream.getResult();
|
|
97
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { nestedClaudeEnv, CLAUDE_ARGS_PREFIX } from './claudeDossier.js';
|
|
3
|
+
|
|
4
|
+
describe('nestedClaudeEnv', () => {
|
|
5
|
+
it('drops claude-nesting markers but keeps other vars', () => {
|
|
6
|
+
const env = nestedClaudeEnv({
|
|
7
|
+
PATH: '/usr/bin',
|
|
8
|
+
CLAUDECODE: '1',
|
|
9
|
+
CLAUDE_CODE_ENTRYPOINT: 'cli',
|
|
10
|
+
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: 'x',
|
|
11
|
+
OPENAI_API_KEY: 'sk',
|
|
12
|
+
});
|
|
13
|
+
expect(env.PATH).toBe('/usr/bin');
|
|
14
|
+
expect(env.OPENAI_API_KEY).toBe('sk');
|
|
15
|
+
expect(env.CLAUDECODE).toBeUndefined();
|
|
16
|
+
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined();
|
|
17
|
+
expect(env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBeUndefined();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('does not mutate the source env', () => {
|
|
21
|
+
const src = { CLAUDECODE: '1' };
|
|
22
|
+
nestedClaudeEnv(src);
|
|
23
|
+
expect(src.CLAUDECODE).toBe('1');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('CLAUDE_ARGS_PREFIX', () => {
|
|
28
|
+
it('requests stream-json with web tools and ends with the arg separator', () => {
|
|
29
|
+
expect(CLAUDE_ARGS_PREFIX).toEqual([
|
|
30
|
+
'--print',
|
|
31
|
+
'--output-format',
|
|
32
|
+
'stream-json',
|
|
33
|
+
'--verbose',
|
|
34
|
+
'--allowedTools',
|
|
35
|
+
'WebSearch',
|
|
36
|
+
'WebFetch',
|
|
37
|
+
'--',
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure operations over the in-memory dossier cache Map.
|
|
3
|
+
* Map shape: jobId → { text, done, loading }.
|
|
4
|
+
* Kept separate from the hook so the status/seed logic is unit-testable.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Map an entry to a job-list icon status.
|
|
8
|
+
export function dossierStatus(cache, jobId) {
|
|
9
|
+
const entry = cache.get(jobId);
|
|
10
|
+
if (!entry) return null;
|
|
11
|
+
if (entry.loading) return 'generating';
|
|
12
|
+
if (entry.done) return 'done';
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Seed cache from server-side has_dossier flags. Returns true if anything changed.
|
|
17
|
+
export function seedDossierFlags(cache, jobs) {
|
|
18
|
+
let changed = false;
|
|
19
|
+
for (const job of jobs) {
|
|
20
|
+
if (job.has_dossier && !cache.has(job.id)) {
|
|
21
|
+
cache.set(job.id, { text: '', done: true, loading: false });
|
|
22
|
+
changed = true;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return changed;
|
|
26
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { dossierStatus, seedDossierFlags } from './dossierCache.js';
|
|
3
|
+
|
|
4
|
+
describe('dossierStatus', () => {
|
|
5
|
+
it('returns null for unknown jobs', () => {
|
|
6
|
+
expect(dossierStatus(new Map(), 'x')).toBeNull();
|
|
7
|
+
});
|
|
8
|
+
it('returns generating while loading', () => {
|
|
9
|
+
const m = new Map([['1', { loading: true, done: false }]]);
|
|
10
|
+
expect(dossierStatus(m, '1')).toBe('generating');
|
|
11
|
+
});
|
|
12
|
+
it('returns done when finished', () => {
|
|
13
|
+
const m = new Map([['1', { loading: false, done: true }]]);
|
|
14
|
+
expect(dossierStatus(m, '1')).toBe('done');
|
|
15
|
+
});
|
|
16
|
+
it('returns null for a started-but-not-loading-or-done entry', () => {
|
|
17
|
+
const m = new Map([['1', { loading: false, done: false }]]);
|
|
18
|
+
expect(dossierStatus(m, '1')).toBeNull();
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('seedDossierFlags', () => {
|
|
23
|
+
it('adds done entries for jobs flagged has_dossier', () => {
|
|
24
|
+
const m = new Map();
|
|
25
|
+
const changed = seedDossierFlags(m, [
|
|
26
|
+
{ id: '1', has_dossier: true },
|
|
27
|
+
{ id: '2', has_dossier: false },
|
|
28
|
+
]);
|
|
29
|
+
expect(changed).toBe(true);
|
|
30
|
+
expect(m.get('1')).toEqual({ text: '', done: true, loading: false });
|
|
31
|
+
expect(m.has('2')).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('does not overwrite existing entries and reports no change', () => {
|
|
35
|
+
const m = new Map([['1', { text: 'keep', done: true, loading: false }]]);
|
|
36
|
+
const changed = seedDossierFlags(m, [{ id: '1', has_dossier: true }]);
|
|
37
|
+
expect(changed).toBe(false);
|
|
38
|
+
expect(m.get('1').text).toBe('keep');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The full research-dossier prompt sent to the Claude CLI.
|
|
3
|
+
* Kept in its own module since it is a large, self-contained instruction blob.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function buildDossierPrompt(resumeText, jobText) {
|
|
7
|
+
return `You are a job search research assistant. A candidate is considering applying to this role. Do thorough research and produce a comprehensive dossier.
|
|
8
|
+
|
|
9
|
+
## Candidate Resume
|
|
10
|
+
${resumeText}
|
|
11
|
+
|
|
12
|
+
## Job Posting
|
|
13
|
+
${jobText}
|
|
14
|
+
|
|
15
|
+
## Your Task
|
|
16
|
+
|
|
17
|
+
Research everything you can and produce a complete dossier covering:
|
|
18
|
+
|
|
19
|
+
### 1. Compensation Context
|
|
20
|
+
- Market rate for this role/level/location
|
|
21
|
+
- How the listed salary compares
|
|
22
|
+
- Negotiation leverage points
|
|
23
|
+
|
|
24
|
+
### 2. AI Engineering Culture
|
|
25
|
+
- Does the company use or encourage AI coding tools (Copilot, Claude Code, Cursor, agentic workflows)?
|
|
26
|
+
- Any public statements, blog posts, or job descriptions mentioning AI-assisted development?
|
|
27
|
+
- Is the engineering culture likely to embrace engineers who ship faster using AI tools, or is there resistance?
|
|
28
|
+
- Look for signals: do they mention "AI-native", "agentic", "LLM-augmented" workflows, or similar?
|
|
29
|
+
- Are there concerns about AI tool usage (IP policies, code review friction, etc.)?
|
|
30
|
+
- Rating: AI-Forward / AI-Friendly / Neutral / Unknown / AI-Resistant
|
|
31
|
+
|
|
32
|
+
### 3. Company Deep Dive
|
|
33
|
+
- What the company does, their products/services
|
|
34
|
+
- Funding stage, size, recent news
|
|
35
|
+
- Tech stack and engineering culture
|
|
36
|
+
- Leadership team
|
|
37
|
+
- Employee sentiment and Glassdoor/Blind highlights
|
|
38
|
+
- Any red flags or concerns
|
|
39
|
+
|
|
40
|
+
### 4. Role Analysis
|
|
41
|
+
- What you'd actually be doing day-to-day
|
|
42
|
+
- Team context — who you'd work with
|
|
43
|
+
- Growth trajectory for this role
|
|
44
|
+
- How this role fits into the company's current priorities
|
|
45
|
+
|
|
46
|
+
### 5. Fit Assessment
|
|
47
|
+
- Matching skills and experience (be specific, reference the candidate's actual background)
|
|
48
|
+
- Skill gaps to acknowledge or address
|
|
49
|
+
- Adjacent experience that transfers
|
|
50
|
+
- Overall fit rating: Strong / Good / Stretch
|
|
51
|
+
|
|
52
|
+
### 6. Cover Letter Talking Points
|
|
53
|
+
- 5-7 specific bullet points to mention, each referencing the candidate's real experience
|
|
54
|
+
- What angle to take (technical depth? leadership? domain expertise?)
|
|
55
|
+
- What to emphasize vs. downplay
|
|
56
|
+
|
|
57
|
+
### 7. Contact Info
|
|
58
|
+
- Email addresses from the posting
|
|
59
|
+
- Who posted (HN username from the URL if available)
|
|
60
|
+
- Best way to reach out
|
|
61
|
+
|
|
62
|
+
### 8. Interview Prep
|
|
63
|
+
- Questions they'll likely ask for this role
|
|
64
|
+
- Questions the candidate should ask
|
|
65
|
+
- Topics to research before interviewing
|
|
66
|
+
|
|
67
|
+
Be thorough, specific, and opinionated. Reference the candidate's actual experience when making recommendations.
|
|
68
|
+
|
|
69
|
+
### IMPORTANT: Structured Data Block
|
|
70
|
+
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:
|
|
71
|
+
\`\`\`enrichment
|
|
72
|
+
{
|
|
73
|
+
"salary": "$X - $Y" or null if unknown,
|
|
74
|
+
"remote": "Full" | "Hybrid" | "None" | null,
|
|
75
|
+
"location": { "city": "...", "countryCode": "XX", "region": "..." } or null,
|
|
76
|
+
"experience": "Junior" | "Mid" | "Senior" | "Staff" | "Lead" | null,
|
|
77
|
+
"type": "Full-time" | "Contract" | "Part-time" | null
|
|
78
|
+
}
|
|
79
|
+
\`\`\``;
|
|
80
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for the Claude CLI `--output-format stream-json` output.
|
|
3
|
+
*
|
|
4
|
+
* Each call to processLine() consumes one NDJSON line, updates internal state
|
|
5
|
+
* (the latest assistant text plus a tool-activity status line) and invokes
|
|
6
|
+
* onText with the composed visible text. getResult() returns the final text.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { statusLineForTool, composeDossierText } from './aiHelpers.js';
|
|
10
|
+
|
|
11
|
+
export function createDossierStream(onText) {
|
|
12
|
+
let finalResult = '';
|
|
13
|
+
let statusLine = '';
|
|
14
|
+
|
|
15
|
+
function processLine(line) {
|
|
16
|
+
if (!line.trim()) return;
|
|
17
|
+
let event;
|
|
18
|
+
try {
|
|
19
|
+
event = JSON.parse(line);
|
|
20
|
+
} catch {
|
|
21
|
+
// Not valid JSON, skip
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (event.type === 'assistant') {
|
|
26
|
+
const content = event.message?.content || [];
|
|
27
|
+
for (const block of content) {
|
|
28
|
+
if (block.type === 'text' && block.text) {
|
|
29
|
+
finalResult = block.text;
|
|
30
|
+
onText(composeDossierText(statusLine, finalResult));
|
|
31
|
+
} else if (block.type === 'tool_use') {
|
|
32
|
+
statusLine = statusLineForTool(block);
|
|
33
|
+
onText(composeDossierText(statusLine, finalResult));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (event.type === 'result' && event.result) {
|
|
39
|
+
finalResult = event.result;
|
|
40
|
+
onText(finalResult);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
processLine,
|
|
46
|
+
getResult: () => finalResult,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { createDossierStream } from './dossierStream.js';
|
|
3
|
+
|
|
4
|
+
function lines(...objs) {
|
|
5
|
+
return objs.map((o) => JSON.stringify(o));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe('createDossierStream', () => {
|
|
9
|
+
it('ignores blank and non-JSON lines', () => {
|
|
10
|
+
const onText = vi.fn();
|
|
11
|
+
const s = createDossierStream(onText);
|
|
12
|
+
s.processLine('');
|
|
13
|
+
s.processLine(' ');
|
|
14
|
+
s.processLine('not json {');
|
|
15
|
+
expect(onText).not.toHaveBeenCalled();
|
|
16
|
+
expect(s.getResult()).toBe('');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('captures assistant text blocks as the running result', () => {
|
|
20
|
+
const onText = vi.fn();
|
|
21
|
+
const s = createDossierStream(onText);
|
|
22
|
+
const [line] = lines({
|
|
23
|
+
type: 'assistant',
|
|
24
|
+
message: { content: [{ type: 'text', text: 'Hello world' }] },
|
|
25
|
+
});
|
|
26
|
+
s.processLine(line);
|
|
27
|
+
expect(onText).toHaveBeenLastCalledWith('Hello world');
|
|
28
|
+
expect(s.getResult()).toBe('Hello world');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('prefixes a tool status line before existing result text', () => {
|
|
32
|
+
const onText = vi.fn();
|
|
33
|
+
const s = createDossierStream(onText);
|
|
34
|
+
const [textLine, toolLine] = lines(
|
|
35
|
+
{
|
|
36
|
+
type: 'assistant',
|
|
37
|
+
message: { content: [{ type: 'text', text: 'Body' }] },
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
type: 'assistant',
|
|
41
|
+
message: {
|
|
42
|
+
content: [
|
|
43
|
+
{ type: 'tool_use', name: 'WebSearch', input: { query: 'q' } },
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
s.processLine(textLine);
|
|
49
|
+
s.processLine(toolLine);
|
|
50
|
+
expect(onText).toHaveBeenLastCalledWith('🔍 WebSearch: q\n\nBody');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('shows the status line alone when no result text yet', () => {
|
|
54
|
+
const onText = vi.fn();
|
|
55
|
+
const s = createDossierStream(onText);
|
|
56
|
+
const [toolLine] = lines({
|
|
57
|
+
type: 'assistant',
|
|
58
|
+
message: {
|
|
59
|
+
content: [{ type: 'tool_use', name: 'WebFetch', input: { url: 'u' } }],
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
s.processLine(toolLine);
|
|
63
|
+
expect(onText).toHaveBeenLastCalledWith('🔍 WebFetch: u');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('overrides result with the final result event', () => {
|
|
67
|
+
const onText = vi.fn();
|
|
68
|
+
const s = createDossierStream(onText);
|
|
69
|
+
const [textLine, resultLine] = lines(
|
|
70
|
+
{
|
|
71
|
+
type: 'assistant',
|
|
72
|
+
message: { content: [{ type: 'text', text: 'partial' }] },
|
|
73
|
+
},
|
|
74
|
+
{ type: 'result', result: 'FINAL ANSWER' }
|
|
75
|
+
);
|
|
76
|
+
s.processLine(textLine);
|
|
77
|
+
s.processLine(resultLine);
|
|
78
|
+
expect(s.getResult()).toBe('FINAL ANSWER');
|
|
79
|
+
expect(onText).toHaveBeenLastCalledWith('FINAL ANSWER');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GPT-backed review actions (job fit summary + batch ranking).
|
|
3
|
+
* Pure async functions: build the prompt, call the model, return the text.
|
|
4
|
+
* The hook layers loading/error state on top.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { generateText } from 'ai';
|
|
8
|
+
import { openai } from '@ai-sdk/openai';
|
|
9
|
+
import {
|
|
10
|
+
buildSummaryResumeText,
|
|
11
|
+
buildSummaryJobText,
|
|
12
|
+
buildBatchJobsList,
|
|
13
|
+
} from './aiHelpers.js';
|
|
14
|
+
|
|
15
|
+
const SUMMARY_SYSTEM =
|
|
16
|
+
'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.';
|
|
17
|
+
|
|
18
|
+
const BATCH_SYSTEM =
|
|
19
|
+
'You are a career advisor. Rank these jobs by fit for the candidate. For each, give a 1-line verdict. Be direct.';
|
|
20
|
+
|
|
21
|
+
// Single-job fit analysis.
|
|
22
|
+
export async function runJobSummary(resume, job) {
|
|
23
|
+
const resumeText = buildSummaryResumeText(resume);
|
|
24
|
+
const jobText = buildSummaryJobText(job);
|
|
25
|
+
const { text } = await generateText({
|
|
26
|
+
model: openai('gpt-4o-mini'),
|
|
27
|
+
system: SUMMARY_SYSTEM,
|
|
28
|
+
prompt: `Resume:\n${resumeText}\n\nJob:\n${jobText}`,
|
|
29
|
+
maxTokens: 400,
|
|
30
|
+
});
|
|
31
|
+
return text;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Ranked verdicts across a batch of jobs.
|
|
35
|
+
export async function runBatchReview(resume, jobs) {
|
|
36
|
+
const resumeText = [resume?.basics?.label, resume?.basics?.summary]
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.join('\n');
|
|
39
|
+
const jobsList = buildBatchJobsList(jobs);
|
|
40
|
+
const { text } = await generateText({
|
|
41
|
+
model: openai('gpt-4o-mini'),
|
|
42
|
+
system: BATCH_SYSTEM,
|
|
43
|
+
prompt: `Resume:\n${resumeText}\n\nJobs:\n${jobsList}`,
|
|
44
|
+
maxTokens: 600,
|
|
45
|
+
});
|
|
46
|
+
return text;
|
|
47
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure tab/count/filter logic for the TUI App.
|
|
3
|
+
* Extracted so the categorization rules can be unit-tested without Ink.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const TABS = [
|
|
7
|
+
'all',
|
|
8
|
+
'new',
|
|
9
|
+
'reviewed',
|
|
10
|
+
'interested',
|
|
11
|
+
'applied',
|
|
12
|
+
'maybe',
|
|
13
|
+
'passed',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export const TAB_LABELS = {
|
|
17
|
+
all: 'All',
|
|
18
|
+
new: 'New',
|
|
19
|
+
reviewed: 'Reviewed',
|
|
20
|
+
interested: 'Interested',
|
|
21
|
+
applied: 'Applied',
|
|
22
|
+
maybe: 'Maybe',
|
|
23
|
+
passed: 'Passed',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Next/previous tab id with wraparound.
|
|
27
|
+
export function nextTab(tab, dir = 1) {
|
|
28
|
+
const idx = TABS.indexOf(tab);
|
|
29
|
+
return TABS[(idx + dir + TABS.length) % TABS.length];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Free-text filter over the visible job fields.
|
|
33
|
+
export function filterJobsByQuery(jobs, query) {
|
|
34
|
+
if (!query) return jobs;
|
|
35
|
+
const q = query.toLowerCase();
|
|
36
|
+
return jobs.filter((j) => {
|
|
37
|
+
const fields = [
|
|
38
|
+
j.title,
|
|
39
|
+
j.company,
|
|
40
|
+
j.description,
|
|
41
|
+
j.remote,
|
|
42
|
+
j.location?.city,
|
|
43
|
+
j.location?.countryCode,
|
|
44
|
+
...(j.skills || []).map((s) => s.name || s),
|
|
45
|
+
];
|
|
46
|
+
return fields.some((f) => f && String(f).toLowerCase().includes(q));
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Per-tab counts for the header. getDossierStatus(jobId) → 'done'|'generating'|null
|
|
51
|
+
export function computeCounts(allJobs, getDossierStatus) {
|
|
52
|
+
return {
|
|
53
|
+
all: allJobs.length,
|
|
54
|
+
new: allJobs.filter(
|
|
55
|
+
(j) =>
|
|
56
|
+
!j.state &&
|
|
57
|
+
!j.has_dossier &&
|
|
58
|
+
getDossierStatus(j.id) !== 'done' &&
|
|
59
|
+
getDossierStatus(j.id) !== 'generating'
|
|
60
|
+
).length,
|
|
61
|
+
reviewed: allJobs.filter(
|
|
62
|
+
(j) => (j.has_dossier || getDossierStatus(j.id) === 'done') && !j.state
|
|
63
|
+
).length,
|
|
64
|
+
interested: allJobs.filter((j) => j.state === 'interested').length,
|
|
65
|
+
applied: allJobs.filter((j) => j.state === 'applied').length,
|
|
66
|
+
maybe: allJobs.filter((j) => j.state === 'maybe').length,
|
|
67
|
+
passed: allJobs.filter((j) => j.state === 'not_interested').length,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
TABS,
|
|
4
|
+
nextTab,
|
|
5
|
+
filterJobsByQuery,
|
|
6
|
+
computeCounts,
|
|
7
|
+
} from './jobFilters.js';
|
|
8
|
+
|
|
9
|
+
describe('nextTab', () => {
|
|
10
|
+
it('cycles forward with wraparound', () => {
|
|
11
|
+
expect(nextTab('all', 1)).toBe('new');
|
|
12
|
+
expect(nextTab(TABS[TABS.length - 1], 1)).toBe('all');
|
|
13
|
+
});
|
|
14
|
+
it('cycles backward with wraparound', () => {
|
|
15
|
+
expect(nextTab('all', -1)).toBe(TABS[TABS.length - 1]);
|
|
16
|
+
expect(nextTab('new', -1)).toBe('all');
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('filterJobsByQuery', () => {
|
|
21
|
+
const jobs = [
|
|
22
|
+
{ id: 1, title: 'React Dev', company: 'Acme', skills: [{ name: 'React' }] },
|
|
23
|
+
{ id: 2, title: 'Go Dev', company: 'Globex', skills: ['Golang'] },
|
|
24
|
+
{
|
|
25
|
+
id: 3,
|
|
26
|
+
title: 'Designer',
|
|
27
|
+
company: 'Initech',
|
|
28
|
+
location: { city: 'Berlin', countryCode: 'DE' },
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
it('returns all jobs when query is empty', () => {
|
|
33
|
+
expect(filterJobsByQuery(jobs, '')).toBe(jobs);
|
|
34
|
+
});
|
|
35
|
+
it('matches title case-insensitively', () => {
|
|
36
|
+
expect(filterJobsByQuery(jobs, 'react').map((j) => j.id)).toEqual([1]);
|
|
37
|
+
});
|
|
38
|
+
it('matches object skills by name and string skills directly', () => {
|
|
39
|
+
expect(filterJobsByQuery(jobs, 'golang').map((j) => j.id)).toEqual([2]);
|
|
40
|
+
expect(filterJobsByQuery(jobs, 'React').map((j) => j.id)).toEqual([1]);
|
|
41
|
+
});
|
|
42
|
+
it('matches nested location fields (city and countryCode)', () => {
|
|
43
|
+
expect(filterJobsByQuery(jobs, 'berlin').map((j) => j.id)).toEqual([3]);
|
|
44
|
+
// countryCode 'DE' — use a non-substring-of-titles probe to isolate it
|
|
45
|
+
const onlyHasCountry = [{ id: 7, location: { countryCode: 'NL' } }];
|
|
46
|
+
expect(filterJobsByQuery(onlyHasCountry, 'nl').map((j) => j.id)).toEqual([
|
|
47
|
+
7,
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('computeCounts', () => {
|
|
53
|
+
const none = () => null;
|
|
54
|
+
it('counts all, states, new and reviewed', () => {
|
|
55
|
+
const allJobs = [
|
|
56
|
+
{ id: 1 }, // new
|
|
57
|
+
{ id: 2, state: 'interested' },
|
|
58
|
+
{ id: 3, state: 'applied' },
|
|
59
|
+
{ id: 4, state: 'maybe' },
|
|
60
|
+
{ id: 5, state: 'not_interested' },
|
|
61
|
+
{ id: 6, has_dossier: true }, // reviewed (no state)
|
|
62
|
+
];
|
|
63
|
+
const c = computeCounts(allJobs, none);
|
|
64
|
+
expect(c.all).toBe(6);
|
|
65
|
+
expect(c.new).toBe(1);
|
|
66
|
+
expect(c.reviewed).toBe(1);
|
|
67
|
+
expect(c.interested).toBe(1);
|
|
68
|
+
expect(c.applied).toBe(1);
|
|
69
|
+
expect(c.maybe).toBe(1);
|
|
70
|
+
expect(c.passed).toBe(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('treats a generating/done dossier status as not-new', () => {
|
|
74
|
+
const allJobs = [{ id: 1 }, { id: 2 }];
|
|
75
|
+
const status = (id) => (id === 1 ? 'generating' : 'done');
|
|
76
|
+
const c = computeCounts(allJobs, status);
|
|
77
|
+
expect(c.new).toBe(0);
|
|
78
|
+
expect(c.reviewed).toBe(1); // id 2 done + no state
|
|
79
|
+
});
|
|
80
|
+
});
|