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,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()