offerprinter 0.2.0__py3-none-any.whl

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,202 @@
1
+ """Data models for OfferPrinter.
2
+
3
+ These are deliberately thin, typed containers (pydantic) that carry data between
4
+ the layers — config → services → controller → output. Keeping them in one place
5
+ makes the flow easy to read and extend.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from enum import StrEnum
11
+
12
+ from pydantic import BaseModel, Field
13
+
14
+
15
+ class Provider(StrEnum):
16
+ """Supported LLM providers."""
17
+
18
+ ANTHROPIC = "anthropic"
19
+ OPENAI = "openai"
20
+ GEMINI = "gemini"
21
+ KIMI = "kimi"
22
+ OLLAMA = "ollama"
23
+
24
+
25
+ class Locale(StrEnum):
26
+ """Output spelling/idiom."""
27
+
28
+ UK = "UK"
29
+ US = "US"
30
+
31
+
32
+ class LLMConfig(BaseModel):
33
+ """Everything the LLM layer needs to make a call."""
34
+
35
+ provider: Provider = Provider.ANTHROPIC
36
+ model: str = "" # "" means "use the provider default"
37
+ api_key: str = ""
38
+ base_url: str = ""
39
+ temperature: float = 0.2
40
+ max_tokens: int = 4096
41
+ timeout: float = 120.0
42
+ max_retries: int = 3 # retries on 429 / 5xx / network blips
43
+ retry_backoff: float = 1.5 # seconds; doubles each attempt
44
+
45
+
46
+ class OutputConfig(BaseModel):
47
+ """Where and how results are written."""
48
+
49
+ locale: Locale = Locale.UK
50
+ dir: str = "./output"
51
+ formats: list[str] = Field(default_factory=lambda: ["md", "docx"])
52
+ #: Append each run to the local history in ~/.offerprinter/applications.json.
53
+ track: bool = True
54
+
55
+
56
+ class GenerationConfig(BaseModel):
57
+ """Which artifacts to produce, and how fast to produce them."""
58
+
59
+ tailored_cv: bool = True
60
+ cover_letter: bool = True
61
+ fit_memo: bool = True
62
+ ats_report: bool = True
63
+ interview_prep: bool = True
64
+
65
+ #: Score the application 0-100 and band it. One extra cheap call.
66
+ fit_score: bool = True
67
+ #: Generate all artifacts concurrently instead of one after another.
68
+ parallel: bool = True
69
+ #: Upper bound on concurrent LLM calls.
70
+ max_workers: int = 5
71
+
72
+ def enabled(self) -> list[str]:
73
+ """Return the keys of the artifacts that are switched on, in order."""
74
+ return [k for k in ARTIFACT_ORDER if getattr(self, k)]
75
+
76
+
77
+ #: Canonical artifact order — used for output ordering regardless of which
78
+ #: artifact finishes first when generating in parallel.
79
+ ARTIFACT_ORDER: list[str] = [
80
+ "tailored_cv",
81
+ "cover_letter",
82
+ "fit_memo",
83
+ "ats_report",
84
+ "interview_prep",
85
+ ]
86
+
87
+
88
+ class ResumeInput(BaseModel):
89
+ """The user's base CV, already extracted to plain text."""
90
+
91
+ text: str
92
+ source: str = "pasted" # a filename or "pasted"
93
+
94
+
95
+ class JobDescription(BaseModel):
96
+ """The target job, already extracted to plain text."""
97
+
98
+ text: str
99
+ source: str = "pasted" # a URL or "pasted"
100
+ company: str = "" # best-effort; filled in by the pipeline
101
+ role: str = "" # best-effort; filled in by the pipeline
102
+
103
+
104
+ class Artifact(BaseModel):
105
+ """One generated document."""
106
+
107
+ key: str # e.g. "tailored_cv"
108
+ title: str # human title, e.g. "Tailored CV"
109
+ filename: str # base filename without extension, e.g. "tailored-cv"
110
+ content: str # markdown body
111
+
112
+
113
+ class Usage(BaseModel):
114
+ """Token counts and estimated spend for one run."""
115
+
116
+ input_tokens: int = 0
117
+ output_tokens: int = 0
118
+ calls: int = 0
119
+ cost_usd: float = 0.0
120
+
121
+ @property
122
+ def total_tokens(self) -> int:
123
+ return self.input_tokens + self.output_tokens
124
+
125
+ def add(self, other: Usage) -> None:
126
+ self.input_tokens += other.input_tokens
127
+ self.output_tokens += other.output_tokens
128
+ self.calls += other.calls
129
+ self.cost_usd += other.cost_usd
130
+
131
+
132
+ #: Score bands, highest threshold first. (min_score, label, one-line verdict)
133
+ FIT_BANDS: list[tuple[int, str, str]] = [
134
+ (85, "Exceptional", "Apply today. You are what they wrote the advert for."),
135
+ (70, "Strong", "A genuinely competitive application. Send it."),
136
+ (55, "Credible", "Worth applying — lead hard with your strongest match."),
137
+ (40, "Stretch", "A reach. Apply if you want it, and address the gaps head-on."),
138
+ (0, "Long shot", "Big gaps. Consider a closer role, or close a gap first."),
139
+ ]
140
+
141
+
142
+ class FitScore(BaseModel):
143
+ """How well the candidate genuinely matches the role."""
144
+
145
+ score: int = 0 # 0-100
146
+ band: str = ""
147
+ verdict: str = ""
148
+ strengths: list[str] = Field(default_factory=list)
149
+ gaps: list[str] = Field(default_factory=list)
150
+
151
+ @classmethod
152
+ def band_for(cls, score: int) -> tuple[str, str]:
153
+ """Return (band, verdict) for a 0-100 score."""
154
+ for threshold, label, verdict in FIT_BANDS:
155
+ if score >= threshold:
156
+ return label, verdict
157
+ return FIT_BANDS[-1][1], FIT_BANDS[-1][2]
158
+
159
+ def as_markdown(self) -> str:
160
+ filled = round(self.score / 10)
161
+ bar = "█" * filled + "░" * (10 - filled)
162
+ lines = [
163
+ "# Fit Score",
164
+ "",
165
+ f"## {self.score}/100 — {self.band}",
166
+ "",
167
+ f"`{bar}`",
168
+ "",
169
+ f"*{self.verdict}*",
170
+ "",
171
+ ]
172
+ if self.strengths:
173
+ lines += ["## Where you genuinely match", ""]
174
+ lines += [f"- {s}" for s in self.strengths] + [""]
175
+ if self.gaps:
176
+ lines += ["## Real gaps (not filled in for you)", ""]
177
+ lines += [f"- {g}" for g in self.gaps] + [""]
178
+ lines += [
179
+ "> Scored from evidence actually present in your CV. Gaps are stated,",
180
+ "> never papered over. — OfferPrinter",
181
+ "",
182
+ ]
183
+ return "\n".join(lines)
184
+
185
+
186
+ class ApplicationPackage(BaseModel):
187
+ """The full result of one run."""
188
+
189
+ company: str
190
+ role: str
191
+ slug: str # <company>-<role> folder name
192
+ artifacts: list[Artifact] = Field(default_factory=list)
193
+ fit: FitScore | None = None
194
+ usage: Usage = Field(default_factory=Usage)
195
+
196
+ def get(self, key: str) -> Artifact | None:
197
+ return next((a for a in self.artifacts if a.key == key), None)
198
+
199
+ def sort_artifacts(self) -> None:
200
+ """Restore canonical order after parallel generation."""
201
+ order = {key: i for i, key in enumerate(ARTIFACT_ORDER)}
202
+ self.artifacts.sort(key=lambda a: order.get(a.key, len(order)))
@@ -0,0 +1,98 @@
1
+ """Token pricing, so every run can tell you what it actually cost.
2
+
3
+ The numbers below are **published list prices in USD per 1,000,000 tokens**, and
4
+ they are estimates for display only — providers change prices, and your account
5
+ may have different rates. Nothing here affects generation; it only turns token
6
+ counts into a "this run cost about £0.03" line.
7
+
8
+ Override any of them in `config.toml`:
9
+
10
+ [pricing]
11
+ "claude-haiku-4-5-20251001" = { input = 1.0, output = 5.0 }
12
+
13
+ Unknown models fall back to a match on the closest known prefix, and finally to
14
+ zero (in which case OfferPrinter reports tokens but no cost).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ # model prefix -> (USD per 1M input tokens, USD per 1M output tokens)
20
+ _PRICES: dict[str, tuple[float, float]] = {
21
+ # --- Anthropic -----------------------------------------------------------
22
+ "claude-haiku-4-5": (1.00, 5.00),
23
+ "claude-sonnet-4-5": (3.00, 15.00),
24
+ "claude-sonnet-4": (3.00, 15.00),
25
+ "claude-opus-4": (15.00, 75.00),
26
+ "claude-3-5-haiku": (0.80, 4.00),
27
+ "claude-3-5-sonnet": (3.00, 15.00),
28
+ # --- OpenAI --------------------------------------------------------------
29
+ "gpt-4o-mini": (0.15, 0.60),
30
+ "gpt-4o": (2.50, 10.00),
31
+ "gpt-4.1-mini": (0.40, 1.60),
32
+ "gpt-4.1": (2.00, 8.00),
33
+ "o4-mini": (1.10, 4.40),
34
+ # --- Google --------------------------------------------------------------
35
+ "gemini-1.5-flash": (0.075, 0.30),
36
+ "gemini-1.5-pro": (1.25, 5.00),
37
+ "gemini-2.0-flash": (0.10, 0.40),
38
+ "gemini-2.5-flash": (0.30, 2.50),
39
+ # --- Moonshot ------------------------------------------------------------
40
+ "moonshot-v1-8k": (1.68, 1.68),
41
+ "moonshot-v1-32k": (3.36, 3.36),
42
+ "moonshot-v1-128k": (8.40, 8.40),
43
+ "kimi-": (1.68, 1.68),
44
+ }
45
+
46
+ #: Models that run on your own hardware and therefore cost nothing per token.
47
+ _FREE_PREFIXES = ("llama", "qwen", "mistral", "phi", "gemma", "deepseek", "stub")
48
+
49
+ #: Rough USD -> GBP rate, only used for the friendly "≈ £0.03" hint.
50
+ USD_TO_GBP = 0.79
51
+
52
+
53
+ def rates_for(
54
+ model: str, overrides: dict[str, tuple[float, float]] | None = None
55
+ ) -> tuple[float, float]:
56
+ """Return (input_price, output_price) per 1M tokens for a model name."""
57
+ if not model:
58
+ return (0.0, 0.0)
59
+ name = model.lower()
60
+
61
+ if overrides:
62
+ if name in overrides:
63
+ return overrides[name]
64
+ for prefix, rate in overrides.items():
65
+ if name.startswith(prefix.lower()):
66
+ return rate
67
+
68
+ if name.startswith(_FREE_PREFIXES):
69
+ return (0.0, 0.0)
70
+
71
+ if name in _PRICES:
72
+ return _PRICES[name]
73
+ # Longest matching prefix wins, so "gpt-4o-mini" beats "gpt-4o".
74
+ matches = [p for p in _PRICES if name.startswith(p)]
75
+ if matches:
76
+ return _PRICES[max(matches, key=len)]
77
+ return (0.0, 0.0)
78
+
79
+
80
+ def estimate_cost(
81
+ model: str,
82
+ input_tokens: int,
83
+ output_tokens: int,
84
+ overrides: dict[str, tuple[float, float]] | None = None,
85
+ ) -> float:
86
+ """Estimated USD cost for a number of input/output tokens."""
87
+ in_rate, out_rate = rates_for(model, overrides)
88
+ return (input_tokens * in_rate + output_tokens * out_rate) / 1_000_000
89
+
90
+
91
+ def format_cost(usd: float) -> str:
92
+ """Human-friendly cost string, tiny amounts included."""
93
+ if usd <= 0:
94
+ return "free (local model)"
95
+ gbp = usd * USD_TO_GBP
96
+ if usd < 0.01:
97
+ return f"${usd:.4f} (≈ £{gbp:.4f})"
98
+ return f"${usd:.3f} (≈ £{gbp:.3f})"
@@ -0,0 +1,32 @@
1
+ """Prompt templates, kept in their own module so they are easy to audit and improve.
2
+
3
+ Every generation prompt inherits the same non-negotiable system rules (see
4
+ `SYSTEM_RULES`), the most important of which is: NEVER fabricate. If you want to
5
+ change how OfferPrinter writes, this is the only file you need to touch.
6
+ """
7
+
8
+ from offerprinter.prompts.templates import (
9
+ ATS_REPORT_PROMPT,
10
+ COVER_LETTER_PROMPT,
11
+ EXTRACT_META_PROMPT,
12
+ FIT_MEMO_PROMPT,
13
+ FIT_SCORE_PROMPT,
14
+ INTERVIEW_PREP_PROMPT,
15
+ ROAST_PROMPT,
16
+ SYSTEM_RULES,
17
+ TAILORED_CV_PROMPT,
18
+ build_system,
19
+ )
20
+
21
+ __all__ = [
22
+ "SYSTEM_RULES",
23
+ "build_system",
24
+ "EXTRACT_META_PROMPT",
25
+ "TAILORED_CV_PROMPT",
26
+ "COVER_LETTER_PROMPT",
27
+ "FIT_MEMO_PROMPT",
28
+ "ATS_REPORT_PROMPT",
29
+ "INTERVIEW_PREP_PROMPT",
30
+ "FIT_SCORE_PROMPT",
31
+ "ROAST_PROMPT",
32
+ ]
@@ -0,0 +1,339 @@
1
+ """All prompt templates for OfferPrinter.
2
+
3
+ Design notes:
4
+ - Each artifact has ONE user-prompt template. They all share `SYSTEM_RULES`.
5
+ - Templates use ``{cv}``, ``{jd}``, ``{company}``, ``{role}`` placeholders and are
6
+ filled with ``str.format`` by the generator service.
7
+ - The no-fabrication rule is repeated in every prompt on purpose. It is the
8
+ product's core promise and models follow instructions they see close to the task.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # Shared system rules — the trust guarantee.
15
+ # ---------------------------------------------------------------------------
16
+
17
+ SYSTEM_RULES = """You are OfferPrinter, an assistant that tailors a person's real \
18
+ job-application materials to a specific role. You write in {locale_name} English \
19
+ ({locale_note}).
20
+
21
+ ABSOLUTE, NON-NEGOTIABLE RULES:
22
+ 1. NEVER fabricate. Do not invent experience, skills, employers, job titles, \
23
+ dates, qualifications, certifications, publications, or metrics. You may ONLY \
24
+ reframe, reorder, reword, and surface facts that are actually present in the \
25
+ candidate's CV.
26
+ 2. If the role wants something the candidate does not have, DO NOT pretend they \
27
+ have it. Where the artifact calls for honesty about gaps (the fit memo, the ATS \
28
+ report), state the gap plainly. Elsewhere, simply omit what isn't there.
29
+ 3. Numbers and metrics must come verbatim from the CV. If the CV says "led a team \
30
+ of 4", never write "a team of 10". If no metric is given, do not invent one.
31
+ 4. Keep the candidate's real voice and seniority. Do not inflate titles.
32
+ 5. Output clean, ATS-friendly text: no tables, no columns, no images, no text \
33
+ boxes, no fancy characters. Use plain Markdown headings and simple bullet points.
34
+
35
+ You will be given the candidate's CV and a target job description. Follow the \
36
+ task instructions exactly and return ONLY the requested document in Markdown, \
37
+ with no preamble, no explanation, and no code fences."""
38
+
39
+ _LOCALE_NOTES = {
40
+ "UK": ("British", "e.g. 'organise', 'programme', 'CV', 'colour', DD/MM/YYYY dates"),
41
+ "US": ("American", "e.g. 'organize', 'program', 'resume', 'color', MM/DD/YYYY dates"),
42
+ }
43
+
44
+
45
+ def build_system(locale: str) -> str:
46
+ """Return the shared system prompt localised to UK or US English."""
47
+ name, note = _LOCALE_NOTES.get(locale, _LOCALE_NOTES["UK"])
48
+ return SYSTEM_RULES.format(locale_name=name, locale_note=note)
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Metadata extraction — pull company + role so we can name the output folder.
53
+ # ---------------------------------------------------------------------------
54
+
55
+ EXTRACT_META_PROMPT = """From the job description below, identify the hiring \
56
+ company's name and the job title.
57
+
58
+ Return ONLY a single line of the exact form:
59
+ COMPANY: <name> | ROLE: <title>
60
+
61
+ If the company name is genuinely not stated, use "Company". If the role is not \
62
+ stated, use "Role". Do not add anything else.
63
+
64
+ --- JOB DESCRIPTION ---
65
+ {jd}
66
+ --- END JOB DESCRIPTION ---"""
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # 1. Tailored CV
71
+ # ---------------------------------------------------------------------------
72
+
73
+ TAILORED_CV_PROMPT = """Produce a TAILORED CV for this candidate targeting the \
74
+ role of {role} at {company}.
75
+
76
+ What "tailored" means here:
77
+ - Reorder and reword the candidate's REAL experience so the most role-relevant \
78
+ things come first and are described in the language the job description uses.
79
+ - Foreground the skills, projects and achievements that match this job. \
80
+ De-emphasise (but don't delete truthful, relevant) the rest.
81
+ - Mirror the job's terminology where it truthfully applies to the candidate's \
82
+ experience (helps with ATS keyword matching) — but only where it is genuinely true.
83
+ - Keep every fact, employer, date and metric exactly as in the source CV.
84
+
85
+ Format (ATS-friendly, plain Markdown):
86
+ # <Candidate Name>
87
+ <contact line: email · phone · location · links — copy from the CV; omit any not present>
88
+
89
+ ## Professional Summary
90
+ A 2–4 sentence summary aimed squarely at this role, built only from real facts.
91
+
92
+ ## Core Skills
93
+ A simple comma or bullet list of the candidate's real skills most relevant to the role.
94
+
95
+ ## Experience
96
+ For each role (most relevant / recent first): title, employer, dates, then 3–6 \
97
+ bullet points rewritten to highlight impact relevant to {role}. Keep real metrics.
98
+
99
+ ## Education
100
+ As in the source CV.
101
+
102
+ ## (Optional) Certifications / Projects
103
+ Only if present in the source CV.
104
+
105
+ --- CANDIDATE CV ---
106
+ {cv}
107
+ --- END CV ---
108
+
109
+ --- TARGET JOB DESCRIPTION ---
110
+ {jd}
111
+ --- END JOB DESCRIPTION ---"""
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # 2. Cover letter
116
+ # ---------------------------------------------------------------------------
117
+
118
+ COVER_LETTER_PROMPT = """Write a TAILORED COVER LETTER for the candidate applying \
119
+ to {role} at {company}.
120
+
121
+ Requirements:
122
+ - Specific to THIS company and role. No generic filler, no clichés \
123
+ ("I am writing to express my interest..."), no flattery that could apply anywhere.
124
+ - Open with a concrete hook tied to the role or company and the candidate's most \
125
+ relevant real strength.
126
+ - 3–4 short paragraphs. In the body, connect the candidate's actual experience to \
127
+ 2–3 of the job's key needs, using real examples and metrics from the CV.
128
+ - Confident but honest. Do not claim experience the candidate lacks.
129
+ - Around 250–350 words. Sign off with the candidate's real name.
130
+
131
+ Format as plain Markdown. Use the candidate's real contact details at the top if \
132
+ present in the CV; otherwise start at the greeting. If you do not know the hiring \
133
+ manager's name, use "Dear Hiring Team,".
134
+
135
+ --- CANDIDATE CV ---
136
+ {cv}
137
+ --- END CV ---
138
+
139
+ --- TARGET JOB DESCRIPTION ---
140
+ {jd}
141
+ --- END JOB DESCRIPTION ---"""
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # 3. Fit memo
146
+ # ---------------------------------------------------------------------------
147
+
148
+ FIT_MEMO_PROMPT = """Write a one-page "WHY I FIT" FIT MEMO for the candidate and \
149
+ the role of {role} at {company}.
150
+
151
+ This document is where honesty about gaps is REQUIRED. Structure it exactly:
152
+
153
+ # Fit Memo — {role} at {company}
154
+
155
+ ## Snapshot
156
+ 2–3 sentences: the candidate's honest overall fit for this role.
157
+
158
+ ## Requirement-by-requirement
159
+ Identify the key requirements from the job description. For EACH one, write a row \
160
+ in this format:
161
+ - **<the requirement>** — <Strong match | Partial match | Gap>: <one sentence of \
162
+ evidence drawn ONLY from the CV, or a plain statement that the CV does not show it>.
163
+
164
+ Be scrupulously honest. If the CV shows no evidence for a requirement, mark it \
165
+ **Gap** and say so. Do not invent evidence to fill a gap.
166
+
167
+ ## Honest gaps & how to talk about them
168
+ List the real gaps and, for each, a truthful, non-defensive way the candidate \
169
+ could address it in an interview (transferable experience, willingness to learn, \
170
+ adjacent skills) — WITHOUT claiming they already have the missing skill.
171
+
172
+ ## Bottom line
173
+ One or two sentences on overall suitability.
174
+
175
+ --- CANDIDATE CV ---
176
+ {cv}
177
+ --- END CV ---
178
+
179
+ --- TARGET JOB DESCRIPTION ---
180
+ {jd}
181
+ --- END JOB DESCRIPTION ---"""
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # 4. ATS keyword report
186
+ # ---------------------------------------------------------------------------
187
+
188
+ ATS_REPORT_PROMPT = """Produce an ATS KEYWORD REPORT comparing the candidate's CV \
189
+ against the job description for {role} at {company}.
190
+
191
+ Steps:
192
+ 1. Extract the important keywords and key phrases an Applicant Tracking System \
193
+ would likely screen for from the job description (skills, tools, methods, \
194
+ qualifications, domain terms). Aim for 12–20.
195
+ 2. For each, decide whether the candidate's CV already covers it.
196
+
197
+ Format exactly:
198
+
199
+ # ATS Keyword Report — {role} at {company}
200
+
201
+ ## Covered keywords
202
+ A bullet list of the job's key terms the CV already contains (quote the CV phrase \
203
+ where helpful).
204
+
205
+ ## Missing keywords
206
+ A bullet list of key terms NOT found in the CV.
207
+
208
+ ## How to add the missing terms — truthfully
209
+ For each missing keyword that the candidate PLAUSIBLY has real experience with \
210
+ (based on the CV), suggest exactly where and how to add it truthfully (e.g. \
211
+ "You list 'built dashboards' — if you used Tableau, name it in that bullet"). \
212
+ For any missing keyword the candidate clearly does NOT have, say plainly: \
213
+ "Do not add — no evidence in your CV. This is a genuine gap." NEVER instruct the \
214
+ candidate to claim something untrue.
215
+
216
+ ## Coverage summary
217
+ "Covered X of Y key terms." with the two numbers.
218
+
219
+ --- CANDIDATE CV ---
220
+ {cv}
221
+ --- END CV ---
222
+
223
+ --- TARGET JOB DESCRIPTION ---
224
+ {jd}
225
+ --- END JOB DESCRIPTION ---"""
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # 5. Interview prep pack
230
+ # ---------------------------------------------------------------------------
231
+
232
+ INTERVIEW_PREP_PROMPT = """Produce an INTERVIEW PREP PACK for the candidate \
233
+ interviewing for {role} at {company}.
234
+
235
+ Format exactly:
236
+
237
+ # Interview Prep Pack — {role} at {company}
238
+
239
+ ## Likely questions
240
+ 8–10 questions this candidate is likely to be asked for THIS role (mix of \
241
+ behavioural, role-specific/technical, and motivation).
242
+
243
+ ## STAR answer scaffolds
244
+ Pick the 4–5 most important behavioural/experience questions. For each, give a \
245
+ STAR scaffold (Situation, Task, Action, Result) built ONLY from the candidate's \
246
+ real experience in the CV. Where a strong real example exists, use it. Where the \
247
+ CV lacks a directly relevant example, say so honestly and suggest the closest \
248
+ real experience to adapt — do not invent a scenario.
249
+
250
+ ## 5 smart questions to ask them
251
+ Five thoughtful, specific questions the candidate should ask the interviewer \
252
+ about the role, team, or company — showing genuine engagement, not generic.
253
+
254
+ --- CANDIDATE CV ---
255
+ {cv}
256
+ --- END CV ---
257
+
258
+ --- TARGET JOB DESCRIPTION ---
259
+ {jd}
260
+ --- END JOB DESCRIPTION ---"""
261
+
262
+
263
+ # ---------------------------------------------------------------------------
264
+ # 6. Fit score — a single number, so a run ends with a verdict, not a shrug.
265
+ # ---------------------------------------------------------------------------
266
+
267
+ FIT_SCORE_PROMPT = """Score how well this candidate genuinely matches the role of \
268
+ {role} at {company}.
269
+
270
+ Scoring guide (be strict and evidence-based — an inflated score is useless):
271
+ - 85-100: meets essentially every requirement with direct, demonstrated evidence.
272
+ - 70-84: meets most requirements including the critical ones; one or two soft gaps.
273
+ - 55-69: meets roughly half; credible but with real gaps on secondary requirements.
274
+ - 40-54: meets a minority; a genuine stretch with gaps on important requirements.
275
+ - 0-39: missing most requirements, including critical ones.
276
+
277
+ Judge ONLY on evidence actually present in the CV. Absence of evidence is a gap, \
278
+ not a maybe. Do not be generous to be kind — the candidate needs the truth to \
279
+ decide where to spend their evening.
280
+
281
+ Return ONLY this exact block, nothing else, no code fences, no commentary:
282
+
283
+ SCORE: <integer 0-100>
284
+ STRENGTHS: <requirement they genuinely meet> ; <another> ; <another>
285
+ GAPS: <requirement they genuinely do not meet> ; <another>
286
+
287
+ Give 2-4 strengths and 0-4 gaps, each a short phrase of at most 12 words, \
288
+ separated by semicolons. If there are genuinely no gaps, write "GAPS: none".
289
+
290
+ --- CANDIDATE CV ---
291
+ {cv}
292
+ --- END CV ---
293
+
294
+ --- TARGET JOB DESCRIPTION ---
295
+ {jd}
296
+ --- END JOB DESCRIPTION ---"""
297
+
298
+
299
+ # ---------------------------------------------------------------------------
300
+ # 7. Roast mode — optional, opt-in, and still never dishonest.
301
+ # ---------------------------------------------------------------------------
302
+
303
+ ROAST_PROMPT = """Roast this CV. The candidate has explicitly asked for blunt, \
304
+ funny, unsparing feedback — they opted in, so do not soften it into a compliment \
305
+ sandwich.
306
+
307
+ Rules that still apply (roasting is not licence to be wrong or cruel):
308
+ - Roast the WRITING, never the person, their background, or their circumstances.
309
+ - Every jab must be about something actually in the CV. Do not invent flaws.
310
+ - Punch at clichés, vagueness, buzzwords, unquantified claims, wall-of-text \
311
+ formatting, "responsible for" bullets, and anything that says nothing.
312
+ - Nothing about protected characteristics, age, nationality, or career breaks.
313
+ - End genuinely useful. The point is a better CV, not a worse mood.
314
+
315
+ Format exactly:
316
+
317
+ # 🔥 CV Roast
318
+
319
+ ## The verdict in one line
320
+ One brutal but fair sentence.
321
+
322
+ ## What made me wince
323
+ 4-6 bullets. Each: quote the offending phrase from the CV, then say why it is \
324
+ doing nothing for them. Be funny. Be specific.
325
+
326
+ ## Cliché count
327
+ List the buzzwords and dead phrases you found, with a count. Crown the worst one.
328
+
329
+ ## Credit where it's due
330
+ 2-3 bullets on what genuinely works. Do not invent praise — if something is good, \
331
+ say why it is good.
332
+
333
+ ## The five fixes that would actually change the outcome
334
+ A numbered list of 5 concrete, specific edits, in priority order. Name the exact \
335
+ bullet or section to change and what to change it to.
336
+
337
+ --- CANDIDATE CV ---
338
+ {cv}
339
+ --- END CV ---"""
@@ -0,0 +1,14 @@
1
+ """Service layer: parsing inputs, generating artifacts, and writing outputs."""
2
+
3
+ from offerprinter.services.cv_parser import extract_cv, extract_cv_from_bytes
4
+ from offerprinter.services.generator import Generator
5
+ from offerprinter.services.jd_fetcher import load_job_description
6
+ from offerprinter.services.writer import write_package
7
+
8
+ __all__ = [
9
+ "extract_cv",
10
+ "extract_cv_from_bytes",
11
+ "load_job_description",
12
+ "Generator",
13
+ "write_package",
14
+ ]