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
offerprinter/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""OfferPrinter — local-first, one-input job-application package generator.
|
|
2
|
+
|
|
3
|
+
Paste one job description and your CV; get a tailored CV, cover letter, fit memo,
|
|
4
|
+
ATS keyword report, and interview prep pack — without fabricating anything.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.2.0"
|
offerprinter/cli.py
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""OfferPrinter command-line interface.
|
|
3
|
+
|
|
4
|
+
Examples
|
|
5
|
+
--------
|
|
6
|
+
Print a package from a CV file and a job URL:
|
|
7
|
+
|
|
8
|
+
offerprinter --cv path/to/cv.pdf --jd "https://careers.example.com/123"
|
|
9
|
+
|
|
10
|
+
From a CV file and a job description saved in a file:
|
|
11
|
+
|
|
12
|
+
offerprinter --cv cv.docx --jd-file jd.txt
|
|
13
|
+
|
|
14
|
+
Print packages for a whole folder of job descriptions in one go:
|
|
15
|
+
|
|
16
|
+
offerprinter --cv cv.pdf --jd-dir ./jobs
|
|
17
|
+
|
|
18
|
+
Other things it does:
|
|
19
|
+
|
|
20
|
+
offerprinter roast --cv cv.pdf # blunt, funny critique of your CV
|
|
21
|
+
offerprinter list # every application you've printed
|
|
22
|
+
offerprinter stats # totals, spend, achievements
|
|
23
|
+
offerprinter status acme-analyst interview
|
|
24
|
+
|
|
25
|
+
Run `offerprinter --help` for the full list of options.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
import typer
|
|
33
|
+
from rich.console import Console
|
|
34
|
+
from rich.panel import Panel
|
|
35
|
+
from rich.table import Table
|
|
36
|
+
from rich.text import Text
|
|
37
|
+
|
|
38
|
+
from offerprinter import __version__
|
|
39
|
+
from offerprinter.config import Config, load_config
|
|
40
|
+
from offerprinter.controllers.pipeline import Pipeline
|
|
41
|
+
from offerprinter.models.schemas import Locale, Provider, ResumeInput
|
|
42
|
+
from offerprinter.pricing import estimate_cost, format_cost
|
|
43
|
+
from offerprinter.services.cv_parser import cv_from_text, extract_cv
|
|
44
|
+
from offerprinter.services.jd_fetcher import load_job_description
|
|
45
|
+
from offerprinter.services.tracker import (
|
|
46
|
+
STATUSES,
|
|
47
|
+
Tracker,
|
|
48
|
+
describe,
|
|
49
|
+
summarise,
|
|
50
|
+
)
|
|
51
|
+
from offerprinter.ui.printer import PrinterAnimation, render_fit_bar
|
|
52
|
+
|
|
53
|
+
app = typer.Typer(
|
|
54
|
+
add_completion=False,
|
|
55
|
+
help="Paste one job description and your CV; print a full, tailored, "
|
|
56
|
+
"no-fabrication application package.",
|
|
57
|
+
rich_markup_mode="rich",
|
|
58
|
+
)
|
|
59
|
+
console = Console()
|
|
60
|
+
|
|
61
|
+
#: Job-description file types picked up by --jd-dir.
|
|
62
|
+
_JD_SUFFIXES = {".txt", ".md", ".markdown", ".text"}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _version_cb(value: bool) -> None:
|
|
66
|
+
if value:
|
|
67
|
+
console.print(f"OfferPrinter {__version__}")
|
|
68
|
+
raise typer.Exit()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _load_cv(cv: Path | None, cv_text: str | None) -> ResumeInput:
|
|
72
|
+
try:
|
|
73
|
+
return cv_from_text(cv_text) if cv_text else extract_cv(cv) # type: ignore[arg-type]
|
|
74
|
+
except Exception as exc: # noqa: BLE001
|
|
75
|
+
console.print(f"[red]Could not read CV:[/red] {exc}")
|
|
76
|
+
raise typer.Exit(code=1) from exc
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _require_key(config: Config) -> None:
|
|
80
|
+
from offerprinter.llm.factory import _REGISTRY
|
|
81
|
+
|
|
82
|
+
if not _REGISTRY[config.llm.provider].requires_key:
|
|
83
|
+
return
|
|
84
|
+
if config.llm.api_key:
|
|
85
|
+
return
|
|
86
|
+
console.print(
|
|
87
|
+
Panel.fit(
|
|
88
|
+
f"No API key for provider [bold]{config.llm.provider.value}[/bold].\n"
|
|
89
|
+
"Set it in config.toml or via an environment variable "
|
|
90
|
+
"(see config.example.toml).\n\n"
|
|
91
|
+
"No key at all? Run it fully locally instead:\n"
|
|
92
|
+
" [cyan]ollama pull llama3.1 && offerprinter --provider ollama …[/cyan]",
|
|
93
|
+
title="Missing API key",
|
|
94
|
+
border_style="red",
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
raise typer.Exit(code=2)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _apply_overrides(
|
|
101
|
+
config: Config,
|
|
102
|
+
provider: Provider | None,
|
|
103
|
+
model: str | None,
|
|
104
|
+
locale: Locale | None,
|
|
105
|
+
output_dir: Path | None,
|
|
106
|
+
formats: str | None,
|
|
107
|
+
no_track: bool,
|
|
108
|
+
sequential: bool,
|
|
109
|
+
) -> None:
|
|
110
|
+
if provider:
|
|
111
|
+
config.llm.provider = provider
|
|
112
|
+
config.llm.model = model or "" # let the new provider pick its default
|
|
113
|
+
if model:
|
|
114
|
+
config.llm.model = model
|
|
115
|
+
if locale:
|
|
116
|
+
config.output.locale = locale
|
|
117
|
+
if output_dir:
|
|
118
|
+
config.output.dir = str(output_dir)
|
|
119
|
+
if formats:
|
|
120
|
+
config.output.formats = [
|
|
121
|
+
f.strip().lower().lstrip(".") for f in formats.split(",") if f.strip()
|
|
122
|
+
]
|
|
123
|
+
if no_track:
|
|
124
|
+
config.output.track = False
|
|
125
|
+
if sequential:
|
|
126
|
+
config.generation.parallel = False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _cost_line(config: Config, package) -> str: # noqa: ANN001 - local formatting helper
|
|
130
|
+
usage = package.usage
|
|
131
|
+
if not usage.calls:
|
|
132
|
+
return ""
|
|
133
|
+
return (
|
|
134
|
+
f"{usage.calls} calls · {usage.total_tokens:,} tokens "
|
|
135
|
+
f"({usage.input_tokens:,} in / {usage.output_tokens:,} out) · "
|
|
136
|
+
f"{format_cost(usage.cost_usd)}"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _print_summary(config: Config, package, out_folder: Path, achievements: list[str]) -> None: # noqa: ANN001
|
|
141
|
+
if package.fit:
|
|
142
|
+
fit = package.fit
|
|
143
|
+
header = Text()
|
|
144
|
+
header.append(f"{fit.score}/100 ", style="bold")
|
|
145
|
+
header.append(render_fit_bar(fit.score))
|
|
146
|
+
header.append(f" {fit.band}", style="bold")
|
|
147
|
+
console.print()
|
|
148
|
+
console.print(Panel(header, title="🎯 Fit score", border_style="cyan", expand=False))
|
|
149
|
+
console.print(f" [italic]{fit.verdict}[/italic]")
|
|
150
|
+
if fit.gaps:
|
|
151
|
+
console.print(f" [yellow]Real gaps:[/yellow] {', '.join(fit.gaps)}")
|
|
152
|
+
|
|
153
|
+
lines = [f"Package written to [bold]{out_folder}[/bold]"]
|
|
154
|
+
cost = _cost_line(config, package)
|
|
155
|
+
if cost:
|
|
156
|
+
lines.append(f"[grey62]{cost}[/grey62]")
|
|
157
|
+
lines.append("Every line is drawn from your real CV — review before sending.")
|
|
158
|
+
console.print(Panel.fit("\n".join(lines), title="✅ Done", border_style="green"))
|
|
159
|
+
|
|
160
|
+
for achievement in achievements:
|
|
161
|
+
console.print(f"[magenta]Achievement unlocked:[/magenta] {describe(achievement)}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _run_one(config: Config, resume: ResumeInput, jd_value: str, animate: bool) -> bool:
|
|
165
|
+
"""Print one package. Returns True on success."""
|
|
166
|
+
try:
|
|
167
|
+
job = load_job_description(jd_value, timeout=config.llm.timeout)
|
|
168
|
+
except Exception as exc: # noqa: BLE001
|
|
169
|
+
console.print(f"[red]Could not load job description:[/red] {exc}")
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
pipeline = Pipeline(config)
|
|
173
|
+
written: dict | None = None
|
|
174
|
+
package = None
|
|
175
|
+
achievements: list[str] = []
|
|
176
|
+
total = len(config.generation.enabled())
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
with PrinterAnimation(console, total=total, enabled=animate) as anim:
|
|
180
|
+
anim.status("reading JD")
|
|
181
|
+
for event in pipeline.stream(resume, job):
|
|
182
|
+
if event.kind == "meta":
|
|
183
|
+
anim.status("targeting")
|
|
184
|
+
console.print(f"🎯 Target role: [bold]{event.message}[/bold]")
|
|
185
|
+
elif event.kind == "artifact":
|
|
186
|
+
anim.sheet(event.message)
|
|
187
|
+
elif event.kind == "fit":
|
|
188
|
+
anim.status("scoring")
|
|
189
|
+
elif event.kind == "written":
|
|
190
|
+
written = event.written
|
|
191
|
+
package = event.package
|
|
192
|
+
elif event.kind == "done":
|
|
193
|
+
achievements = event.achievements or []
|
|
194
|
+
package = event.package
|
|
195
|
+
except Exception as exc: # noqa: BLE001
|
|
196
|
+
console.print(f"[red]Generation failed:[/red] {exc}")
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
out_folder = Path(config.output.dir) / _slug_from(written)
|
|
200
|
+
_print_summary(config, package, out_folder, achievements)
|
|
201
|
+
return True
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _slug_from(written: dict | None) -> str:
|
|
205
|
+
"""Best-effort recover the output folder name from written paths."""
|
|
206
|
+
if written:
|
|
207
|
+
for paths in written.values():
|
|
208
|
+
if paths:
|
|
209
|
+
return paths[0].parent.name
|
|
210
|
+
return ""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _dry_run(config: Config, resume: ResumeInput, jd_value: str) -> None:
|
|
214
|
+
"""Estimate tokens and cost without calling the API once."""
|
|
215
|
+
from offerprinter.llm.factory import _REGISTRY
|
|
216
|
+
from offerprinter.services.jd_fetcher import jd_from_text
|
|
217
|
+
|
|
218
|
+
job = jd_from_text(jd_value) if not jd_value.startswith("http") else None
|
|
219
|
+
jd_chars = len(job.text) if job else 4000 # unfetched URL: assume a typical JD
|
|
220
|
+
model = config.llm.model or _REGISTRY[config.llm.provider].default_model
|
|
221
|
+
|
|
222
|
+
# ~4 characters per token is the standard rough rule for English prose.
|
|
223
|
+
per_call_in = (len(resume.text) + jd_chars) // 4 + 400 # + the prompt itself
|
|
224
|
+
calls = len(config.generation.enabled()) + 1 + (1 if config.generation.fit_score else 0)
|
|
225
|
+
est_in = per_call_in * calls
|
|
226
|
+
est_out = 900 * calls # each artifact is roughly a page
|
|
227
|
+
|
|
228
|
+
cost = estimate_cost(model, est_in, est_out, config.pricing)
|
|
229
|
+
console.print(
|
|
230
|
+
Panel.fit(
|
|
231
|
+
f"Model: [bold]{model}[/bold]\n"
|
|
232
|
+
f"Calls: [bold]{calls}[/bold]\n"
|
|
233
|
+
f"Estimated tokens: ~{est_in + est_out:,} "
|
|
234
|
+
f"({est_in:,} in / {est_out:,} out)\n"
|
|
235
|
+
f"Estimated cost: [bold]{format_cost(cost)}[/bold]\n\n"
|
|
236
|
+
"[grey62]A rough forecast, not a quote. Nothing was sent anywhere.[/grey62]",
|
|
237
|
+
title="🔍 Dry run",
|
|
238
|
+
border_style="yellow",
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
# The default command: print a package.
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@app.callback(invoke_without_command=True)
|
|
249
|
+
def run(
|
|
250
|
+
ctx: typer.Context,
|
|
251
|
+
cv: Path | None = typer.Option(
|
|
252
|
+
None,
|
|
253
|
+
"--cv",
|
|
254
|
+
help="Path to your CV/resume (.pdf, .docx, .md, or .txt).",
|
|
255
|
+
exists=False,
|
|
256
|
+
),
|
|
257
|
+
cv_text: str | None = typer.Option(
|
|
258
|
+
None, "--cv-text", help="Paste your CV as raw text instead of a file."
|
|
259
|
+
),
|
|
260
|
+
jd: str | None = typer.Option(
|
|
261
|
+
None,
|
|
262
|
+
"--jd",
|
|
263
|
+
help="Job description as a URL (fetched + extracted) or pasted text.",
|
|
264
|
+
),
|
|
265
|
+
jd_file: Path | None = typer.Option(
|
|
266
|
+
None, "--jd-file", help="Path to a file containing the job description text."
|
|
267
|
+
),
|
|
268
|
+
jd_dir: Path | None = typer.Option(
|
|
269
|
+
None,
|
|
270
|
+
"--jd-dir",
|
|
271
|
+
help="Batch mode: a folder of .txt/.md job descriptions, one package each.",
|
|
272
|
+
),
|
|
273
|
+
provider: Provider | None = typer.Option(
|
|
274
|
+
None, "--provider", help="Override the LLM provider for this run.", case_sensitive=False
|
|
275
|
+
),
|
|
276
|
+
model: str | None = typer.Option(None, "--model", help="Override the model name for this run."),
|
|
277
|
+
locale: Locale | None = typer.Option(
|
|
278
|
+
None, "--locale", help="Output English variant: UK (default) or US.", case_sensitive=False
|
|
279
|
+
),
|
|
280
|
+
output_dir: Path | None = typer.Option(
|
|
281
|
+
None, "--output-dir", "-o", help="Where to write the package (default ./output)."
|
|
282
|
+
),
|
|
283
|
+
formats: str | None = typer.Option(
|
|
284
|
+
None, "--formats", help="Comma-separated output formats: md, docx, pdf."
|
|
285
|
+
),
|
|
286
|
+
config_file: Path | None = typer.Option(
|
|
287
|
+
None, "--config", help="Path to a config.toml (default: ./config.toml if present)."
|
|
288
|
+
),
|
|
289
|
+
roast: bool = typer.Option(
|
|
290
|
+
False, "--roast", help="Also print a blunt, funny critique of your CV."
|
|
291
|
+
),
|
|
292
|
+
dry_run: bool = typer.Option(
|
|
293
|
+
False, "--dry-run", help="Estimate tokens and cost without calling the API."
|
|
294
|
+
),
|
|
295
|
+
sequential: bool = typer.Option(
|
|
296
|
+
False, "--sequential", help="Generate artifacts one at a time instead of in parallel."
|
|
297
|
+
),
|
|
298
|
+
no_track: bool = typer.Option(
|
|
299
|
+
False, "--no-track", help="Don't record this run in your local history."
|
|
300
|
+
),
|
|
301
|
+
no_animation: bool = typer.Option(
|
|
302
|
+
False, "--no-animation", help="Plain status lines instead of the printer animation."
|
|
303
|
+
),
|
|
304
|
+
_version: bool | None = typer.Option(
|
|
305
|
+
None, "--version", callback=_version_cb, is_eager=True, help="Show version and exit."
|
|
306
|
+
),
|
|
307
|
+
) -> None:
|
|
308
|
+
"""Generate a tailored application package from a CV and a job description."""
|
|
309
|
+
if ctx.invoked_subcommand is not None:
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
# ---- resolve inputs ---------------------------------------------------
|
|
313
|
+
if not cv and not cv_text:
|
|
314
|
+
console.print("[red]Error:[/red] provide a CV with --cv or --cv-text.")
|
|
315
|
+
raise typer.Exit(code=2)
|
|
316
|
+
if not jd and not jd_file and not jd_dir:
|
|
317
|
+
console.print("[red]Error:[/red] provide a job with --jd, --jd-file, or --jd-dir.")
|
|
318
|
+
raise typer.Exit(code=2)
|
|
319
|
+
|
|
320
|
+
config = load_config(config_file)
|
|
321
|
+
_apply_overrides(config, provider, model, locale, output_dir, formats, no_track, sequential)
|
|
322
|
+
|
|
323
|
+
resume = _load_cv(cv, cv_text)
|
|
324
|
+
|
|
325
|
+
# ---- batch mode -------------------------------------------------------
|
|
326
|
+
if jd_dir:
|
|
327
|
+
jobs = sorted(p for p in jd_dir.iterdir() if p.suffix.lower() in _JD_SUFFIXES)
|
|
328
|
+
if not jobs:
|
|
329
|
+
console.print(f"[red]No .txt or .md job descriptions found in[/red] {jd_dir}")
|
|
330
|
+
raise typer.Exit(code=1)
|
|
331
|
+
_require_key(config)
|
|
332
|
+
console.print(
|
|
333
|
+
Panel.fit(
|
|
334
|
+
f"Batch mode: [bold]{len(jobs)}[/bold] job descriptions from {jd_dir}",
|
|
335
|
+
title="🖨 OfferPrinter",
|
|
336
|
+
border_style="cyan",
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
succeeded = 0
|
|
340
|
+
for index, path in enumerate(jobs, start=1):
|
|
341
|
+
console.rule(f"[cyan]{index}/{len(jobs)}[/cyan] {path.name}")
|
|
342
|
+
if _run_one(config, resume, path.read_text(encoding="utf-8"), not no_animation):
|
|
343
|
+
succeeded += 1
|
|
344
|
+
console.print()
|
|
345
|
+
console.print(f"[green]Batch complete:[/green] {succeeded}/{len(jobs)} packages printed.")
|
|
346
|
+
raise typer.Exit(code=0 if succeeded == len(jobs) else 1)
|
|
347
|
+
|
|
348
|
+
jd_value = jd if jd else jd_file.read_text(encoding="utf-8") # type: ignore[union-attr]
|
|
349
|
+
|
|
350
|
+
# ---- dry run ----------------------------------------------------------
|
|
351
|
+
if dry_run:
|
|
352
|
+
_dry_run(config, resume, jd_value)
|
|
353
|
+
raise typer.Exit()
|
|
354
|
+
|
|
355
|
+
_require_key(config)
|
|
356
|
+
|
|
357
|
+
console.print(
|
|
358
|
+
Panel.fit(
|
|
359
|
+
f"Provider: [bold]{config.llm.provider.value}[/bold] · "
|
|
360
|
+
f"Model: [bold]{config.llm.model or 'provider default'}[/bold] · "
|
|
361
|
+
f"Locale: [bold]{config.output.locale.value}[/bold] · "
|
|
362
|
+
f"Formats: [bold]{', '.join(config.output.formats)}[/bold]",
|
|
363
|
+
title="🖨 OfferPrinter",
|
|
364
|
+
border_style="cyan",
|
|
365
|
+
)
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
ok = _run_one(config, resume, jd_value, not no_animation)
|
|
369
|
+
|
|
370
|
+
if ok and roast:
|
|
371
|
+
_do_roast(config, resume)
|
|
372
|
+
|
|
373
|
+
raise typer.Exit(code=0 if ok else 1)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# ---------------------------------------------------------------------------
|
|
377
|
+
# Subcommands
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _do_roast(config: Config, resume: ResumeInput) -> None:
|
|
382
|
+
pipeline = Pipeline(config)
|
|
383
|
+
with console.status("[red]Sharpening knives…[/red]", spinner="dots"):
|
|
384
|
+
artifact = pipeline.generator.roast(resume)
|
|
385
|
+
console.print()
|
|
386
|
+
console.print(Panel(artifact.content, title="🔥 CV Roast", border_style="red"))
|
|
387
|
+
out = Path(config.output.dir) / "roast.md"
|
|
388
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
389
|
+
out.write_text(artifact.content.rstrip() + "\n", encoding="utf-8")
|
|
390
|
+
console.print(f"[grey62]Saved to {out}[/grey62]")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@app.command("roast")
|
|
394
|
+
def roast_command(
|
|
395
|
+
cv: Path | None = typer.Option(None, "--cv", help="Path to your CV/resume."),
|
|
396
|
+
cv_text: str | None = typer.Option(None, "--cv-text", help="Paste your CV as raw text."),
|
|
397
|
+
provider: Provider | None = typer.Option(None, "--provider", case_sensitive=False),
|
|
398
|
+
model: str | None = typer.Option(None, "--model"),
|
|
399
|
+
config_file: Path | None = typer.Option(None, "--config"),
|
|
400
|
+
) -> None:
|
|
401
|
+
"""Get a blunt, funny, unsparing critique of your CV. You asked for it.
|
|
402
|
+
|
|
403
|
+
It roasts the writing, never you — and every jab has to point at something
|
|
404
|
+
actually in the CV.
|
|
405
|
+
"""
|
|
406
|
+
if not cv and not cv_text:
|
|
407
|
+
console.print("[red]Error:[/red] provide a CV with --cv or --cv-text.")
|
|
408
|
+
raise typer.Exit(code=2)
|
|
409
|
+
config = load_config(config_file)
|
|
410
|
+
if provider:
|
|
411
|
+
config.llm.provider = provider
|
|
412
|
+
config.llm.model = model or ""
|
|
413
|
+
if model:
|
|
414
|
+
config.llm.model = model
|
|
415
|
+
_require_key(config)
|
|
416
|
+
_do_roast(config, _load_cv(cv, cv_text))
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@app.command("list")
|
|
420
|
+
def list_applications(
|
|
421
|
+
limit: int = typer.Option(20, "--limit", "-n", help="How many to show (newest first)."),
|
|
422
|
+
status: str | None = typer.Option(None, "--status", help=f"Filter: {', '.join(STATUSES)}."),
|
|
423
|
+
) -> None:
|
|
424
|
+
"""List every application package you have printed."""
|
|
425
|
+
records = Tracker().load()
|
|
426
|
+
if status:
|
|
427
|
+
records = [r for r in records if r.status == status]
|
|
428
|
+
if not records:
|
|
429
|
+
console.print(
|
|
430
|
+
"[grey62]No applications recorded yet. Print one and it'll show up here.[/grey62]"
|
|
431
|
+
)
|
|
432
|
+
return
|
|
433
|
+
|
|
434
|
+
table = Table(title="🖨 Applications printed", header_style="bold cyan", expand=False)
|
|
435
|
+
table.add_column("Date", style="grey62", no_wrap=True)
|
|
436
|
+
table.add_column("Role")
|
|
437
|
+
table.add_column("Company")
|
|
438
|
+
table.add_column("Fit", justify="right")
|
|
439
|
+
table.add_column("Status")
|
|
440
|
+
|
|
441
|
+
for record in sorted(records, key=lambda r: r.printed_at, reverse=True)[:limit]:
|
|
442
|
+
fit = "—" if record.fit_score is None else str(record.fit_score)
|
|
443
|
+
fit_style = (
|
|
444
|
+
"green"
|
|
445
|
+
if (record.fit_score or 0) >= 70
|
|
446
|
+
else "yellow"
|
|
447
|
+
if (record.fit_score or 0) >= 50
|
|
448
|
+
else "red"
|
|
449
|
+
)
|
|
450
|
+
table.add_row(
|
|
451
|
+
record.printed_date,
|
|
452
|
+
record.role,
|
|
453
|
+
record.company,
|
|
454
|
+
Text(fit, style=fit_style if record.fit_score is not None else "grey62"),
|
|
455
|
+
record.status,
|
|
456
|
+
)
|
|
457
|
+
console.print(table)
|
|
458
|
+
if len(records) > limit:
|
|
459
|
+
console.print(
|
|
460
|
+
f"[grey62]…and {len(records) - limit} more. Use --limit to see them.[/grey62]"
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
@app.command("stats")
|
|
465
|
+
def stats_command() -> None:
|
|
466
|
+
"""Totals, spend, average fit, and achievements unlocked."""
|
|
467
|
+
from offerprinter.services.tracker import ACHIEVEMENTS, unlocked
|
|
468
|
+
|
|
469
|
+
records = Tracker().load()
|
|
470
|
+
if not records:
|
|
471
|
+
console.print("[grey62]Nothing recorded yet. Print an application to start.[/grey62]")
|
|
472
|
+
return
|
|
473
|
+
|
|
474
|
+
stats = summarise(records)
|
|
475
|
+
lines = [
|
|
476
|
+
f"Applications printed [bold]{stats.total}[/bold]",
|
|
477
|
+
f"Different companies [bold]{stats.companies}[/bold]",
|
|
478
|
+
f"Average fit score [bold]{stats.average_fit}[/bold]",
|
|
479
|
+
f"Best fit [bold]{stats.best_fit}[/bold] ({stats.best_fit_role})",
|
|
480
|
+
f"Total tokens [bold]{stats.total_tokens:,}[/bold]",
|
|
481
|
+
f"Total spend [bold]{format_cost(stats.total_cost_usd)}[/bold]",
|
|
482
|
+
f"Active since [bold]{stats.first_printed}[/bold]",
|
|
483
|
+
]
|
|
484
|
+
console.print(Panel.fit("\n".join(lines), title="📊 Your job hunt", border_style="cyan"))
|
|
485
|
+
|
|
486
|
+
if stats.by_status:
|
|
487
|
+
breakdown = " ".join(f"{k}: [bold]{v}[/bold]" for k, v in sorted(stats.by_status.items()))
|
|
488
|
+
console.print(f" {breakdown}")
|
|
489
|
+
|
|
490
|
+
earned = unlocked(records)
|
|
491
|
+
console.print()
|
|
492
|
+
console.print("[bold]Achievements[/bold]")
|
|
493
|
+
for achievement_id in ACHIEVEMENTS:
|
|
494
|
+
mark = "[green]✓[/green]" if achievement_id in earned else "[grey37]·[/grey37]"
|
|
495
|
+
style = "" if achievement_id in earned else "grey37"
|
|
496
|
+
console.print(f" {mark} ", end="")
|
|
497
|
+
console.print(describe(achievement_id), style=style, highlight=False)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@app.command("status")
|
|
501
|
+
def status_command(
|
|
502
|
+
slug: str = typer.Argument(..., help="The application slug, e.g. acme-data-analyst."),
|
|
503
|
+
new_status: str = typer.Argument(..., help=f"One of: {', '.join(STATUSES)}."),
|
|
504
|
+
) -> None:
|
|
505
|
+
"""Update where an application has got to (applied, interview, offer…)."""
|
|
506
|
+
try:
|
|
507
|
+
record = Tracker().set_status(slug, new_status)
|
|
508
|
+
except ValueError as exc:
|
|
509
|
+
console.print(f"[red]{exc}[/red]")
|
|
510
|
+
raise typer.Exit(code=2) from exc
|
|
511
|
+
|
|
512
|
+
if record is None:
|
|
513
|
+
console.print(f"[red]No application found with slug[/red] {slug}")
|
|
514
|
+
console.print("[grey62]Run `offerprinter list` to see your slugs.[/grey62]")
|
|
515
|
+
raise typer.Exit(code=1)
|
|
516
|
+
|
|
517
|
+
console.print(f"[green]✓[/green] {record.role} at {record.company} → [bold]{new_status}[/bold]")
|
|
518
|
+
if new_status == "offer":
|
|
519
|
+
console.print("[magenta]🏆 An offer. That's the whole point. Congratulations.[/magenta]")
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
if __name__ == "__main__":
|
|
523
|
+
app()
|