calibrcv 1.0.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.
- package/LICENSE +21 -0
- package/README.md +196 -0
- package/bin/calibrcv.js +193 -0
- package/package.json +58 -0
- package/src/config.js +31 -0
- package/src/constants/hbs-verbs.js +15 -0
- package/src/constants/latex-template.js +114 -0
- package/src/lib/ai-router.js +125 -0
- package/src/lib/ats-scorer.js +479 -0
- package/src/lib/job-scraper.js +105 -0
- package/src/lib/latex-compiler.js +125 -0
- package/src/lib/latex-escape.js +58 -0
- package/src/lib/page-loop.js +99 -0
- package/src/lib/pdf-extractor.js +49 -0
- package/src/pipeline/orchestrator.js +197 -0
- package/src/prompts/analyze.js +48 -0
- package/src/prompts/latex.js +44 -0
- package/src/prompts/synthesize.js +75 -0
- package/src/prompts/tailor.js +33 -0
- package/src/prompts/trim.js +34 -0
- package/src/providers/gemini.js +35 -0
- package/src/providers/groq.js +34 -0
- package/src/providers/ollama.js +71 -0
- package/src/providers/openrouter.js +46 -0
- package/src/ui/interview.js +65 -0
- package/src/ui/report.js +73 -0
- package/src/ui/spinner.js +60 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export function buildSynthesizePrompt(targetSector) {
|
|
2
|
+
return `You are the CalibrCV Content Synthesis Agent. You transform raw resume content and enrichment answers into a structured JSON resume object, ready for LaTeX conversion.
|
|
3
|
+
|
|
4
|
+
You operate under EIGHT INVIOLABLE LAWS. Violating any single law produces an invalid output.
|
|
5
|
+
|
|
6
|
+
THE CALIBRCV LAWS:
|
|
7
|
+
|
|
8
|
+
LAW 1: Every bullet in experience and projects MUST be strictly fewer than 100 characters including spaces. COUNT EVERY CHARACTER before outputting.
|
|
9
|
+
|
|
10
|
+
LAW 2: Every bullet MUST begin with one of these approved HBS verbs (or strong equivalents): Architected, Engineered, Synthesized, Constructed, Deployed, Formulated, Orchestrated, Executed, Modelled, Spearheaded, Designed, Developed, Delivered, Automated, Optimised, Launched, Scaled, Diagnosed, Evaluated, Built, Implemented, Piloted, Led, Directed, Managed, Analysed, Generated, Secured, Reduced, Increased, Projected, Streamlined, Transformed, Drove, Established, Created, Expanded, Accelerated, Pioneered, Championed, Coordinated, Negotiated, Resolved. FORBIDDEN OPENERS: "Was responsible for", "Helped", "Assisted", "Worked on", "Participated in".
|
|
11
|
+
|
|
12
|
+
LAW 3: Summary has 3-4 sentences. ZERO pronouns: no I, my, me, our, we, you. Dense, achievement-forward, sector-targeted to: ${targetSector}. Implicit third-person executive voice.
|
|
13
|
+
|
|
14
|
+
LAW 4: For student/intern roles: use "contributed to", "supported", "projected" for large outcomes. NEVER claim solo ownership above the candidate's seniority. NEVER invent metrics.
|
|
15
|
+
|
|
16
|
+
LAW 5: ZERO EM DASHES anywhere. Replace with semicolons, colons, commas, or restructure.
|
|
17
|
+
|
|
18
|
+
LAW 6: Exactly two skill rows: "Quantitative Stack" (technical tools/languages/frameworks) and "Analytic Domain" (methodological/domain competencies). Never a third row.
|
|
19
|
+
|
|
20
|
+
LAW 7: Experience entries: exactly 2-3 bullets each. Project entries: exactly 2 bullets each. No filler.
|
|
21
|
+
|
|
22
|
+
LAW 8: Abbreviated date format: "Jun. 2023" not "June 2023". All dates in this format.
|
|
23
|
+
|
|
24
|
+
PRE-OUTPUT SELF-CHECK:
|
|
25
|
+
[] Count characters in every bullet; is every single one under 100?
|
|
26
|
+
[] Does every bullet start with an HBS-approved action verb?
|
|
27
|
+
[] Does the summary contain zero pronouns?
|
|
28
|
+
[] Is there an em dash anywhere? If yes, replace it.
|
|
29
|
+
[] Are skills in exactly two rows?
|
|
30
|
+
[] Do experience entries have 2-3 bullets? Projects exactly 2?
|
|
31
|
+
[] Are all dates abbreviated?
|
|
32
|
+
|
|
33
|
+
Return ONLY valid JSON in this exact schema. No markdown. No explanation.
|
|
34
|
+
|
|
35
|
+
{
|
|
36
|
+
"contact": {
|
|
37
|
+
"name": "<full name>",
|
|
38
|
+
"phone": "<phone number>",
|
|
39
|
+
"email": "<email address>",
|
|
40
|
+
"linkedin": "<slug only, not the full URL>",
|
|
41
|
+
"location": "<City, Country>"
|
|
42
|
+
},
|
|
43
|
+
"summary": "<3-4 sentence paragraph. Zero pronouns. Executive voice.>",
|
|
44
|
+
"education": [
|
|
45
|
+
{
|
|
46
|
+
"institution": "<university name>",
|
|
47
|
+
"location": "<City, Country>",
|
|
48
|
+
"degree": "<full degree title>",
|
|
49
|
+
"dates": "<Mon. YYYY - Expected Mon. YYYY or Mon. YYYY>",
|
|
50
|
+
"bullets": ["<string under 100 chars>"]
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
"experience": [
|
|
54
|
+
{
|
|
55
|
+
"title": "<Job Title>",
|
|
56
|
+
"dates": "<Mon. YYYY - Mon. YYYY>",
|
|
57
|
+
"company": "<Company Name>",
|
|
58
|
+
"location": "<City, Country>",
|
|
59
|
+
"bullets": ["<under 100 chars>", "<under 100 chars>", "<optional 3rd>"]
|
|
60
|
+
}
|
|
61
|
+
],
|
|
62
|
+
"projects": [
|
|
63
|
+
{
|
|
64
|
+
"name": "<Project or Competition Name>",
|
|
65
|
+
"label": "<Winner (1st Place) & Team Lead | etc.>",
|
|
66
|
+
"date": "<Mon. YYYY>",
|
|
67
|
+
"bullets": ["<under 100 chars>", "<under 100 chars>"]
|
|
68
|
+
}
|
|
69
|
+
],
|
|
70
|
+
"skills": {
|
|
71
|
+
"quantitative_stack": "<comma-separated tools, languages, frameworks>",
|
|
72
|
+
"analytic_domain": "<comma-separated methods, domains, competencies>"
|
|
73
|
+
}
|
|
74
|
+
}`;
|
|
75
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function buildTailorPrompt(jobTitle, companyName) {
|
|
2
|
+
return `You are the CalibrCV Job Tailoring Agent. You take a master resume JSON and a target job description and produce a tailored version for the role of "${jobTitle}" at "${companyName}".
|
|
3
|
+
|
|
4
|
+
ALL EIGHT CALIBRCV LAWS REMAIN FULLY IN FORCE. Every tailored bullet must still be under 100 chars, start with an HBS verb, and follow all other constraints.
|
|
5
|
+
|
|
6
|
+
TAILORING STRATEGY (execute all 5 steps):
|
|
7
|
+
|
|
8
|
+
STEP 1 KEYWORD EXTRACTION:
|
|
9
|
+
From the job description, extract: hard skills, soft skills, industry terminology, seniority signals, company-specific language.
|
|
10
|
+
|
|
11
|
+
STEP 2 LANGUAGE MIRRORING:
|
|
12
|
+
Where the candidate's bullets describe the same activity using different words, rephrase to mirror the job description's exact vocabulary. Mirror language, not meaning.
|
|
13
|
+
|
|
14
|
+
STEP 3 RELEVANCE REORDERING:
|
|
15
|
+
Move the most JD-relevant bullets to the TOP of each experience entry. NEVER invent new experience.
|
|
16
|
+
|
|
17
|
+
STEP 4 SUMMARY RETARGETING:
|
|
18
|
+
Rewrite the professional summary to reference the target role domain, the most relevant skills, and the seniority level. Zero pronouns, 3-4 sentences, Harvard style.
|
|
19
|
+
|
|
20
|
+
STEP 5 SKILLS ALIGNMENT:
|
|
21
|
+
Add JD keywords to the skills section IF the candidate demonstrably has that skill. NEVER add skills the candidate does not have.
|
|
22
|
+
|
|
23
|
+
Return the same JSON schema as the Content Synthesis Agent. Return ONLY valid JSON. No markdown. No explanation.
|
|
24
|
+
|
|
25
|
+
{
|
|
26
|
+
"contact": { "name": "", "phone": "", "email": "", "linkedin": "", "location": "" },
|
|
27
|
+
"summary": "<3-4 sentences. Zero pronouns. Targeted to this JD.>",
|
|
28
|
+
"education": [{ "institution": "", "location": "", "degree": "", "dates": "", "bullets": [] }],
|
|
29
|
+
"experience": [{ "title": "", "dates": "", "company": "", "location": "", "bullets": [] }],
|
|
30
|
+
"projects": [{ "name": "", "label": "", "date": "", "bullets": [] }],
|
|
31
|
+
"skills": { "quantitative_stack": "", "analytic_domain": "" }
|
|
32
|
+
}`;
|
|
33
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export function buildTrimPrompt(targetSector, pageCount, iteration) {
|
|
2
|
+
return `You are the CalibrCV Trim Agent. The compiled PDF is currently ${pageCount} pages. You must trim it to exactly 1 page by making the minimum necessary changes. This is iteration ${iteration} of a maximum 6.
|
|
3
|
+
|
|
4
|
+
ALL EIGHT CALIBRCV LAWS REMAIN FULLY IN FORCE. Never violate them while trimming.
|
|
5
|
+
|
|
6
|
+
TRIM PRIORITY HIERARCHY (apply in order, stop as soon as 1 page is estimated):
|
|
7
|
+
|
|
8
|
+
LEVEL 1: Remove high school education bullets (if university is present)
|
|
9
|
+
Action: Delete all \\resumeItem entries under the secondary school \\resumeSubheading.
|
|
10
|
+
Keep the heading line intact.
|
|
11
|
+
|
|
12
|
+
LEVEL 2: Shorten the longest project bullets
|
|
13
|
+
Rephrase the longest 3-4 project bullets to be 10-15 chars shorter each.
|
|
14
|
+
Never remove a metric. Never remove a specific tool name.
|
|
15
|
+
|
|
16
|
+
LEVEL 3: Remove the oldest or least-relevant project entry
|
|
17
|
+
Remove the project that is BOTH oldest AND least relevant to sector: ${targetSector}.
|
|
18
|
+
Never remove the most recent project.
|
|
19
|
+
|
|
20
|
+
LEVEL 4: Remove the university Relevant Coursework bullet
|
|
21
|
+
Delete the \\resumeItem containing "Relevant Coursework" from Education.
|
|
22
|
+
|
|
23
|
+
LEVEL 5: Reduce experience bullets from 3 to 2 (oldest role first)
|
|
24
|
+
Remove the third bullet from the oldest experience entry.
|
|
25
|
+
|
|
26
|
+
LEVEL 6: Shorten summary from 4 sentences to 3
|
|
27
|
+
Remove the least impactful sentence.
|
|
28
|
+
|
|
29
|
+
RETURN ONLY: The complete modified LaTeX code.
|
|
30
|
+
- No explanation of what you changed
|
|
31
|
+
- No comments added to the LaTeX
|
|
32
|
+
- All CalibrCV Laws intact
|
|
33
|
+
- Must start with \\documentclass and end with \\end{document}`;
|
|
34
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { GoogleGenerativeAI } from '@google/generative-ai';
|
|
2
|
+
|
|
3
|
+
let genAI = null;
|
|
4
|
+
|
|
5
|
+
function getClient() {
|
|
6
|
+
if (!genAI) {
|
|
7
|
+
genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
|
|
8
|
+
}
|
|
9
|
+
return genAI;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Call Google Gemini (gemini-2.5-flash).
|
|
14
|
+
*/
|
|
15
|
+
export async function callGemini(systemPrompt, userMessage, options = {}) {
|
|
16
|
+
const model = getClient().getGenerativeModel({
|
|
17
|
+
model: 'gemini-2.5-flash',
|
|
18
|
+
systemInstruction: systemPrompt,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const attemptCall = async () => {
|
|
22
|
+
const result = await model.generateContent(userMessage);
|
|
23
|
+
return result.response.text();
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
return await attemptCall();
|
|
28
|
+
} catch (err) {
|
|
29
|
+
if (err.status === 429 || err.message?.includes('429') || err.message?.includes('RESOURCE_EXHAUSTED')) {
|
|
30
|
+
await new Promise(resolve => setTimeout(resolve, 10000));
|
|
31
|
+
return await attemptCall();
|
|
32
|
+
}
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import Groq from 'groq-sdk';
|
|
2
|
+
|
|
3
|
+
let groqClient = null;
|
|
4
|
+
|
|
5
|
+
function getClient() {
|
|
6
|
+
if (!groqClient) {
|
|
7
|
+
groqClient = new Groq({ apiKey: process.env.GROQ_API_KEY });
|
|
8
|
+
}
|
|
9
|
+
return groqClient;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Call Groq API (llama-3.3-70b-versatile).
|
|
14
|
+
*/
|
|
15
|
+
export async function callGroq(systemPrompt, userMessage, options = {}) {
|
|
16
|
+
const { responseFormat = 'text' } = options;
|
|
17
|
+
|
|
18
|
+
const requestParams = {
|
|
19
|
+
model: 'llama-3.3-70b-versatile',
|
|
20
|
+
messages: [
|
|
21
|
+
{ role: 'system', content: systemPrompt },
|
|
22
|
+
{ role: 'user', content: userMessage },
|
|
23
|
+
],
|
|
24
|
+
temperature: 0.3,
|
|
25
|
+
max_tokens: 4096,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
if (responseFormat === 'json') {
|
|
29
|
+
requestParams.response_format = { type: 'json_object' };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const response = await getClient().chat.completions.create(requestParams);
|
|
33
|
+
return response.choices?.[0]?.message?.content || '';
|
|
34
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ollama provider. Calls the local Ollama REST API.
|
|
3
|
+
* Default: http://localhost:11434, model llama3.1
|
|
4
|
+
* Uses streaming to avoid Node.js headers timeout on long generations.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const DEFAULT_HOST = 'http://localhost:11434';
|
|
8
|
+
const DEFAULT_MODEL = 'llama3.1';
|
|
9
|
+
|
|
10
|
+
export async function callOllama(systemPrompt, userMessage, options = {}) {
|
|
11
|
+
const { responseFormat = 'text' } = options;
|
|
12
|
+
const host = process.env.OLLAMA_HOST || DEFAULT_HOST;
|
|
13
|
+
const model = process.env.OLLAMA_MODEL || DEFAULT_MODEL;
|
|
14
|
+
|
|
15
|
+
const body = {
|
|
16
|
+
model,
|
|
17
|
+
messages: [
|
|
18
|
+
{ role: 'system', content: systemPrompt },
|
|
19
|
+
{ role: 'user', content: userMessage },
|
|
20
|
+
],
|
|
21
|
+
stream: true,
|
|
22
|
+
options: { temperature: 0.3 },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
if (responseFormat === 'json') {
|
|
26
|
+
body.format = 'json';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let response;
|
|
30
|
+
try {
|
|
31
|
+
response = await fetch(`${host}/api/chat`, {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: { 'Content-Type': 'application/json' },
|
|
34
|
+
body: JSON.stringify(body),
|
|
35
|
+
});
|
|
36
|
+
} catch (err) {
|
|
37
|
+
if (err.cause?.code === 'ECONNREFUSED' || err.message?.includes('ECONNREFUSED')) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Ollama is not running. Start it with: ollama serve\n` +
|
|
40
|
+
`Or set a cloud provider: --provider groq`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
const errorText = await response.text();
|
|
48
|
+
if (errorText.includes('model') && errorText.includes('not found')) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Ollama model "${model}" not found. Pull it with: ollama pull ${model}`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`Ollama API error ${response.status}: ${errorText}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let content = '';
|
|
57
|
+
const reader = response.body.getReader();
|
|
58
|
+
const decoder = new TextDecoder();
|
|
59
|
+
while (true) {
|
|
60
|
+
const { done, value } = await reader.read();
|
|
61
|
+
if (done) break;
|
|
62
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
63
|
+
for (const line of chunk.split('\n').filter(Boolean)) {
|
|
64
|
+
try {
|
|
65
|
+
const json = JSON.parse(line);
|
|
66
|
+
if (json.message?.content) content += json.message.content;
|
|
67
|
+
} catch (_) { /* skip malformed chunks */ }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return content;
|
|
71
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
|
2
|
+
const MODEL = 'meta-llama/llama-3.1-8b-instruct:free';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Call OpenRouter free tier (llama-3.1-8b-instruct:free).
|
|
6
|
+
*/
|
|
7
|
+
export async function callOpenRouter(systemPrompt, userMessage, options = {}) {
|
|
8
|
+
const { responseFormat = 'text' } = options;
|
|
9
|
+
|
|
10
|
+
const body = {
|
|
11
|
+
model: MODEL,
|
|
12
|
+
messages: [
|
|
13
|
+
{ role: 'system', content: systemPrompt },
|
|
14
|
+
{ role: 'user', content: userMessage },
|
|
15
|
+
],
|
|
16
|
+
temperature: 0.3,
|
|
17
|
+
max_tokens: 4096,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
if (responseFormat === 'json') {
|
|
21
|
+
body.response_format = { type: 'json_object' };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const response = await fetch(OPENROUTER_API_URL, {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: {
|
|
27
|
+
'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
|
28
|
+
'Content-Type': 'application/json',
|
|
29
|
+
'HTTP-Referer': 'https://github.com/Coflazo/calibrcv',
|
|
30
|
+
'X-Title': 'CalibrCV CLI',
|
|
31
|
+
},
|
|
32
|
+
body: JSON.stringify(body),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (response.status === 429) {
|
|
36
|
+
throw new Error('OpenRouter rate limited');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
const errorBody = await response.text();
|
|
41
|
+
throw new Error(`OpenRouter API error ${response.status}: ${errorBody}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const data = await response.json();
|
|
45
|
+
return data.choices?.[0]?.message?.content || '';
|
|
46
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { input } from '@inquirer/prompts';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Run the enrichment interview in the terminal.
|
|
6
|
+
* Prompts the user for each question, returns answers array.
|
|
7
|
+
*
|
|
8
|
+
* @param {Array} questions - Follow-up questions from analysis.
|
|
9
|
+
* @param {boolean} skip - If true, return empty answers.
|
|
10
|
+
* @returns {Promise<Array<{question: string, answer: string}>>}
|
|
11
|
+
*/
|
|
12
|
+
export async function runInterview(questions, skip = false) {
|
|
13
|
+
if (!questions || questions.length === 0) {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Normalize questions (API can return strings or objects)
|
|
18
|
+
const normalized = questions.map((q, i) => {
|
|
19
|
+
if (typeof q === 'string') {
|
|
20
|
+
return { id: `q${i + 1}`, question: q, context: '', example_answer: '' };
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
id: q.id || `q${i + 1}`,
|
|
24
|
+
question: q.question || q,
|
|
25
|
+
context: q.context || '',
|
|
26
|
+
example_answer: q.example_answer || '',
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (skip) {
|
|
31
|
+
return normalized.map(q => ({ question: q.question, answer: '' }));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
console.log('');
|
|
35
|
+
console.log(chalk.bold(' Enrichment Interview'));
|
|
36
|
+
console.log(chalk.dim(' Answer these questions to improve your resume. Press Enter to skip any.'));
|
|
37
|
+
console.log('');
|
|
38
|
+
|
|
39
|
+
const answers = [];
|
|
40
|
+
|
|
41
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
42
|
+
const q = normalized[i];
|
|
43
|
+
|
|
44
|
+
if (q.context) {
|
|
45
|
+
console.log(chalk.dim(` [${q.context}]`));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const hint = q.example_answer
|
|
49
|
+
? chalk.dim(` (e.g. "${q.example_answer}")`)
|
|
50
|
+
: '';
|
|
51
|
+
|
|
52
|
+
const answer = await input({
|
|
53
|
+
message: `${i + 1}/${normalized.length} ${q.question}${hint}`,
|
|
54
|
+
default: '',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
answers.push({
|
|
58
|
+
question: q.question,
|
|
59
|
+
answer: answer.trim(),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log('');
|
|
64
|
+
return answers;
|
|
65
|
+
}
|
package/src/ui/report.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Print the ATS score report to the terminal.
|
|
5
|
+
* @param {object} report - ATS score report from atsScorer.score()
|
|
6
|
+
*/
|
|
7
|
+
export function printReport(report) {
|
|
8
|
+
console.log('');
|
|
9
|
+
console.log(chalk.bold(' ATS Score Report'));
|
|
10
|
+
console.log(' ' + chalk.dim('─'.repeat(50)));
|
|
11
|
+
|
|
12
|
+
// Score + grade
|
|
13
|
+
const scoreColor = report.total >= 80 ? chalk.green
|
|
14
|
+
: report.total >= 60 ? chalk.yellow
|
|
15
|
+
: chalk.red;
|
|
16
|
+
|
|
17
|
+
const grade = report.letter_grade;
|
|
18
|
+
console.log('');
|
|
19
|
+
console.log(` ${scoreColor.bold(` ${report.total}`)} ${chalk.dim('/ 100')} ${chalk.bold(grade.grade)} ${chalk.dim(grade.label)}`);
|
|
20
|
+
console.log('');
|
|
21
|
+
|
|
22
|
+
// Category breakdown
|
|
23
|
+
console.log(chalk.bold(' Categories'));
|
|
24
|
+
const cats = report.categories;
|
|
25
|
+
for (const [key, cat] of Object.entries(cats)) {
|
|
26
|
+
const pct = Math.round((cat.score / cat.max) * 100);
|
|
27
|
+
const bar = renderBar(cat.score, cat.max, 20);
|
|
28
|
+
const score = `${String(cat.score).padStart(2)}/${cat.max}`;
|
|
29
|
+
console.log(` ${chalk.dim(score)} ${bar} ${cat.label}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Law violations
|
|
33
|
+
if (report.calibrcv_law_violations?.length > 0) {
|
|
34
|
+
console.log('');
|
|
35
|
+
console.log(chalk.bold(' Law Violations'));
|
|
36
|
+
for (const v of report.calibrcv_law_violations.slice(0, 5)) {
|
|
37
|
+
console.log(` ${chalk.yellow('!')} ${v}`);
|
|
38
|
+
}
|
|
39
|
+
if (report.calibrcv_law_violations.length > 5) {
|
|
40
|
+
console.log(chalk.dim(` ... and ${report.calibrcv_law_violations.length - 5} more`));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Recommendations
|
|
45
|
+
if (report.recommendations?.length > 0) {
|
|
46
|
+
console.log('');
|
|
47
|
+
console.log(chalk.bold(' Recommendations'));
|
|
48
|
+
report.recommendations.forEach((rec, i) => {
|
|
49
|
+
console.log(` ${chalk.dim(`${i + 1}.`)} ${rec}`);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Missing keywords
|
|
54
|
+
const keywords = cats.keywords;
|
|
55
|
+
if (keywords?.missing_keywords?.length > 0) {
|
|
56
|
+
console.log('');
|
|
57
|
+
console.log(chalk.bold(' Missing Keywords'));
|
|
58
|
+
console.log(` ${keywords.missing_keywords.map(k => chalk.dim(`[${k}]`)).join(' ')}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.log('');
|
|
62
|
+
console.log(' ' + chalk.dim('─'.repeat(50)));
|
|
63
|
+
console.log('');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function renderBar(value, max, width) {
|
|
67
|
+
const filled = Math.round((value / max) * width);
|
|
68
|
+
const empty = width - filled;
|
|
69
|
+
const color = (value / max) >= 0.8 ? chalk.green
|
|
70
|
+
: (value / max) >= 0.6 ? chalk.yellow
|
|
71
|
+
: chalk.red;
|
|
72
|
+
return color('█'.repeat(filled)) + chalk.dim('░'.repeat(empty));
|
|
73
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import ora from 'ora';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
|
|
4
|
+
const STAGE_MESSAGES = {
|
|
5
|
+
parsing: 'Extracting resume content',
|
|
6
|
+
analyzing: 'Analyzing your profile',
|
|
7
|
+
enriching: 'Waiting for your answers',
|
|
8
|
+
synthesizing: 'Rewriting with CalibrCV rules',
|
|
9
|
+
tailoring: 'Tailoring for the target role',
|
|
10
|
+
generating_latex: 'Engineering your layout',
|
|
11
|
+
compiling: 'Compiling to PDF',
|
|
12
|
+
trimming: 'Fitting to one page',
|
|
13
|
+
checking_pages: 'Verifying page length',
|
|
14
|
+
scoring: 'Calculating ATS compatibility',
|
|
15
|
+
complete: 'Done',
|
|
16
|
+
error: 'Error',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create a pipeline UI that renders progress via ora spinner.
|
|
21
|
+
*/
|
|
22
|
+
export function createPipelineUI() {
|
|
23
|
+
const spinner = ora({
|
|
24
|
+
text: 'Starting pipeline...',
|
|
25
|
+
color: 'yellow',
|
|
26
|
+
});
|
|
27
|
+
spinner.start();
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
onProgress({ state, message, progress }) {
|
|
31
|
+
if (state === 'complete') {
|
|
32
|
+
spinner.succeed(chalk.green('Your resume is ready.'));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (state === 'error') {
|
|
37
|
+
spinner.fail(chalk.red(message));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const label = STAGE_MESSAGES[state] || message;
|
|
42
|
+
const pct = progress ? chalk.dim(` [${progress}%]`) : '';
|
|
43
|
+
spinner.text = `${label}${pct}`;
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
pause() {
|
|
47
|
+
spinner.stop();
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
resume() {
|
|
51
|
+
spinner.start();
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
stop() {
|
|
55
|
+
if (spinner.isSpinning) {
|
|
56
|
+
spinner.stop();
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|