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.
- offerprinter/__init__.py +7 -0
- offerprinter/cli.py +523 -0
- offerprinter/config.py +183 -0
- offerprinter/controllers/__init__.py +5 -0
- offerprinter/controllers/pipeline.py +135 -0
- offerprinter/llm/__init__.py +11 -0
- offerprinter/llm/anthropic_provider.py +41 -0
- offerprinter/llm/base.py +125 -0
- offerprinter/llm/factory.py +33 -0
- offerprinter/llm/gemini_provider.py +38 -0
- offerprinter/llm/kimi_provider.py +16 -0
- offerprinter/llm/ollama_provider.py +24 -0
- offerprinter/llm/openai_provider.py +42 -0
- offerprinter/models/__init__.py +21 -0
- offerprinter/models/schemas.py +202 -0
- offerprinter/pricing.py +98 -0
- offerprinter/prompts/__init__.py +32 -0
- offerprinter/prompts/templates.py +339 -0
- offerprinter/services/__init__.py +14 -0
- offerprinter/services/cv_parser.py +84 -0
- offerprinter/services/generator.py +169 -0
- offerprinter/services/jd_fetcher.py +86 -0
- offerprinter/services/pdf_writer.py +329 -0
- offerprinter/services/tracker.py +210 -0
- offerprinter/services/writer.py +142 -0
- offerprinter/ui/__init__.py +9 -0
- offerprinter/ui/printer.py +165 -0
- offerprinter-0.2.0.dist-info/METADATA +542 -0
- offerprinter-0.2.0.dist-info/RECORD +32 -0
- offerprinter-0.2.0.dist-info/WHEEL +4 -0
- offerprinter-0.2.0.dist-info/entry_points.txt +3 -0
- offerprinter-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)))
|
offerprinter/pricing.py
ADDED
|
@@ -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
|
+
]
|