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,210 @@
1
+ """A local record of every application you have printed.
2
+
3
+ Job hunting is a long, demoralising process with no scoreboard. This module is
4
+ the scoreboard. Every run appends a row to a plain JSON file in
5
+ ``~/.offerprinter/applications.json`` — no server, no account, no sync, just a
6
+ file you own and can delete, grep, or check into your own private repo.
7
+
8
+ It powers three things:
9
+ * ``offerprinter list`` — everything you have applied for
10
+ * ``offerprinter stats`` — totals, spend, average fit, outcomes
11
+ * achievements — the small, silly nudges that keep you going
12
+
13
+ Nothing here ever leaves your machine.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ from datetime import UTC, datetime
21
+ from pathlib import Path
22
+
23
+ from pydantic import BaseModel, Field
24
+
25
+ #: Where applications are recorded. Override with OFFERPRINTER_HOME.
26
+ DEFAULT_HOME = Path.home() / ".offerprinter"
27
+
28
+ #: Valid values for a record's `status` field, in pipeline order.
29
+ STATUSES = ("printed", "applied", "screening", "interview", "offer", "rejected")
30
+
31
+
32
+ class ApplicationRecord(BaseModel):
33
+ """One printed application package."""
34
+
35
+ slug: str
36
+ company: str
37
+ role: str
38
+ printed_at: str # ISO-8601 UTC
39
+ provider: str = ""
40
+ model: str = ""
41
+ fit_score: int | None = None
42
+ fit_band: str = ""
43
+ cost_usd: float = 0.0
44
+ total_tokens: int = 0
45
+ output_dir: str = ""
46
+ status: str = "printed"
47
+ notes: str = ""
48
+
49
+ @property
50
+ def printed_date(self) -> str:
51
+ return self.printed_at[:10]
52
+
53
+
54
+ class Tracker:
55
+ """Read/write access to the local application history."""
56
+
57
+ def __init__(self, home: Path | None = None) -> None:
58
+ self.home = Path(home or os.environ.get("OFFERPRINTER_HOME") or DEFAULT_HOME)
59
+ self.path = self.home / "applications.json"
60
+
61
+ # -- persistence --------------------------------------------------------
62
+
63
+ def load(self) -> list[ApplicationRecord]:
64
+ """Return every record, oldest first. A corrupt file is never fatal."""
65
+ if not self.path.is_file():
66
+ return []
67
+ try:
68
+ raw = json.loads(self.path.read_text(encoding="utf-8"))
69
+ except (json.JSONDecodeError, OSError):
70
+ return []
71
+ records = []
72
+ for item in raw if isinstance(raw, list) else []:
73
+ try:
74
+ records.append(ApplicationRecord(**item))
75
+ except (TypeError, ValueError):
76
+ continue # skip a bad row rather than losing the whole history
77
+ return records
78
+
79
+ def save(self, records: list[ApplicationRecord]) -> None:
80
+ self.home.mkdir(parents=True, exist_ok=True)
81
+ payload = [r.model_dump() for r in records]
82
+ self.path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
83
+
84
+ # -- mutations ----------------------------------------------------------
85
+
86
+ def record(self, record: ApplicationRecord) -> list[str]:
87
+ """Append a run and return any achievements newly unlocked by it."""
88
+ records = self.load()
89
+ before = unlocked(records)
90
+ records.append(record)
91
+ self.save(records)
92
+ return sorted(unlocked(records) - before)
93
+
94
+ def set_status(self, slug: str, status: str) -> ApplicationRecord | None:
95
+ """Update the most recent record for a slug. Returns it, or None."""
96
+ if status not in STATUSES:
97
+ raise ValueError(f"Unknown status '{status}'. Use one of: {', '.join(STATUSES)}")
98
+ records = self.load()
99
+ for record in reversed(records):
100
+ if record.slug == slug:
101
+ record.status = status
102
+ self.save(records)
103
+ return record
104
+ return None
105
+
106
+
107
+ class Stats(BaseModel):
108
+ """Aggregate view of the whole history."""
109
+
110
+ total: int = 0
111
+ companies: int = 0
112
+ average_fit: float = 0.0
113
+ best_fit: int = 0
114
+ best_fit_role: str = ""
115
+ total_cost_usd: float = 0.0
116
+ total_tokens: int = 0
117
+ by_status: dict[str, int] = Field(default_factory=dict)
118
+ first_printed: str = ""
119
+ last_printed: str = ""
120
+
121
+
122
+ def summarise(records: list[ApplicationRecord]) -> Stats:
123
+ """Roll a list of records up into headline numbers."""
124
+ if not records:
125
+ return Stats()
126
+
127
+ scored = [r for r in records if r.fit_score is not None]
128
+ best = max(scored, key=lambda r: r.fit_score or 0, default=None)
129
+ by_status: dict[str, int] = {}
130
+ for record in records:
131
+ by_status[record.status] = by_status.get(record.status, 0) + 1
132
+
133
+ return Stats(
134
+ total=len(records),
135
+ companies=len({r.company.lower() for r in records if r.company}),
136
+ average_fit=round(sum(r.fit_score or 0 for r in scored) / len(scored), 1)
137
+ if scored
138
+ else 0.0,
139
+ best_fit=best.fit_score if best and best.fit_score is not None else 0,
140
+ best_fit_role=f"{best.role} at {best.company}" if best else "",
141
+ total_cost_usd=round(sum(r.cost_usd for r in records), 4),
142
+ total_tokens=sum(r.total_tokens for r in records),
143
+ by_status=by_status,
144
+ first_printed=min(r.printed_at for r in records)[:10],
145
+ last_printed=max(r.printed_at for r in records)[:10],
146
+ )
147
+
148
+
149
+ # --- achievements ------------------------------------------------------------
150
+ #
151
+ # Deliberately small and a bit silly. Job hunting is a grind with almost no
152
+ # feedback loop; a line that says "that's ten applications this month" is a
153
+ # cheap way to make the grind visible.
154
+
155
+ #: id -> (emoji, title, description, predicate over the full record list)
156
+ ACHIEVEMENTS: dict[str, tuple[str, str, str]] = {
157
+ "first_print": ("🖨", "First Print", "You printed your first application package."),
158
+ "five_printed": ("🖐", "Warmed Up", "Five applications printed."),
159
+ "ten_printed": ("🔟", "Double Digits", "Ten applications printed."),
160
+ "fifty_printed": ("💯", "Machine", "Fifty applications printed."),
161
+ "strong_fit": ("🎯", "Bullseye", "Scored 85+ on a role — apply to that one today."),
162
+ "honest_stretch": ("🧗", "Honest Stretch", "Applied to a role you scored under 50 on. Brave."),
163
+ "five_companies": ("🌍", "Spread Bet", "Applications to five different companies."),
164
+ "thrifty": ("🪙", "Thrifty", "Ten packages printed for under one dollar total."),
165
+ "local_hero": ("🏠", "Local Hero", "Printed a package on a locally-hosted model."),
166
+ "interview": ("🤝", "In The Room", "Marked an application as reaching interview."),
167
+ "offer": ("🏆", "Offer Printed", "Marked an application as an offer. Congratulations."),
168
+ }
169
+
170
+
171
+ def unlocked(records: list[ApplicationRecord]) -> set[str]:
172
+ """Return the ids of every achievement earned by this history."""
173
+ if not records:
174
+ return set()
175
+
176
+ earned: set[str] = {"first_print"}
177
+ count = len(records)
178
+ if count >= 5:
179
+ earned.add("five_printed")
180
+ if count >= 10:
181
+ earned.add("ten_printed")
182
+ if count >= 50:
183
+ earned.add("fifty_printed")
184
+ if len({r.company.lower() for r in records if r.company}) >= 5:
185
+ earned.add("five_companies")
186
+ if any((r.fit_score or 0) >= 85 for r in records):
187
+ earned.add("strong_fit")
188
+ if any(r.fit_score is not None and r.fit_score < 50 for r in records):
189
+ earned.add("honest_stretch")
190
+ if count >= 10 and sum(r.cost_usd for r in records) < 1.0:
191
+ earned.add("thrifty")
192
+ if any(r.provider == "ollama" for r in records):
193
+ earned.add("local_hero")
194
+ statuses = {r.status for r in records}
195
+ if statuses & {"interview", "offer"}:
196
+ earned.add("interview")
197
+ if "offer" in statuses:
198
+ earned.add("offer")
199
+ return earned
200
+
201
+
202
+ def describe(achievement_id: str) -> str:
203
+ """One-line human description of an achievement, for the CLI."""
204
+ emoji, title, description = ACHIEVEMENTS.get(achievement_id, ("🎖", achievement_id, ""))
205
+ return f"{emoji} {title} — {description}"
206
+
207
+
208
+ def utc_now() -> str:
209
+ """Current time as an ISO-8601 UTC string (stable, sortable, timezone-safe)."""
210
+ return datetime.now(UTC).isoformat(timespec="seconds")
@@ -0,0 +1,142 @@
1
+ """Write a generated package to disk as .md, .docx and/or .pdf, plus a combined file.
2
+
3
+ Kept separate from generation so the format concerns live in one place. The
4
+ .docx writer does a lightweight Markdown-to-Word rendering (headings, bullets,
5
+ bold) — enough to produce clean, ATS-friendly documents without a heavy
6
+ dependency. The .pdf writer is our own (see `pdf_writer.py`), for the same
7
+ reason: recruiters ask for PDFs and ATS parsers want plain Helvetica text.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from pathlib import Path
14
+
15
+ from offerprinter.models.schemas import ApplicationPackage, Artifact
16
+ from offerprinter.services.pdf_writer import write_pdf
17
+
18
+ #: Output formats `write_package` knows how to produce.
19
+ SUPPORTED_FORMATS = ("md", "docx", "pdf")
20
+
21
+
22
+ def _write_md(artifact: Artifact, folder: Path) -> Path:
23
+ path = folder / f"{artifact.filename}.md"
24
+ path.write_text(artifact.content.rstrip() + "\n", encoding="utf-8")
25
+ return path
26
+
27
+
28
+ def _write_pdf(artifact: Artifact, folder: Path) -> Path:
29
+ return write_pdf(artifact.content, folder / f"{artifact.filename}.pdf")
30
+
31
+
32
+ _BOLD_RE = re.compile(r"\*\*(.+?)\*\*")
33
+
34
+
35
+ def _add_markdown_runs(paragraph, text: str) -> None:
36
+ """Add text to a python-docx paragraph, honouring **bold** spans."""
37
+ pos = 0
38
+ for m in _BOLD_RE.finditer(text):
39
+ if m.start() > pos:
40
+ paragraph.add_run(text[pos : m.start()])
41
+ run = paragraph.add_run(m.group(1))
42
+ run.bold = True
43
+ pos = m.end()
44
+ if pos < len(text):
45
+ paragraph.add_run(text[pos:])
46
+
47
+
48
+ def _write_docx(artifact: Artifact, folder: Path) -> Path:
49
+ from docx import Document
50
+
51
+ doc = Document()
52
+ for raw_line in artifact.content.splitlines():
53
+ line = raw_line.rstrip()
54
+ if not line:
55
+ doc.add_paragraph()
56
+ continue
57
+ if line.startswith("### "):
58
+ doc.add_heading(line[4:], level=3)
59
+ elif line.startswith("## "):
60
+ doc.add_heading(line[3:], level=2)
61
+ elif line.startswith("# "):
62
+ doc.add_heading(line[2:], level=1)
63
+ elif line.lstrip().startswith(("- ", "* ")):
64
+ stripped = line.lstrip()[2:]
65
+ p = doc.add_paragraph(style="List Bullet")
66
+ _add_markdown_runs(p, stripped)
67
+ elif re.match(r"^\d+\.\s", line.lstrip()):
68
+ stripped = re.sub(r"^\d+\.\s", "", line.lstrip())
69
+ p = doc.add_paragraph(style="List Number")
70
+ _add_markdown_runs(p, stripped)
71
+ else:
72
+ p = doc.add_paragraph()
73
+ _add_markdown_runs(p, line)
74
+
75
+ path = folder / f"{artifact.filename}.docx"
76
+ doc.save(str(path))
77
+ return path
78
+
79
+
80
+ def _combined_markdown(package: ApplicationPackage) -> str:
81
+ parts = [f"# Application Package — {package.role} at {package.company}\n"]
82
+ parts.append(
83
+ "> Generated by OfferPrinter. Every statement is drawn from your real CV — "
84
+ "nothing here is fabricated. Review before sending.\n"
85
+ )
86
+ if package.fit:
87
+ parts.append(f"\n**Fit score: {package.fit.score}/100 — {package.fit.band}.** ")
88
+ parts.append(f"{package.fit.verdict}\n")
89
+ for artifact in package.artifacts:
90
+ parts.append("\n---\n")
91
+ parts.append(artifact.content.rstrip())
92
+ return "\n".join(parts) + "\n"
93
+
94
+
95
+ def write_package(
96
+ package: ApplicationPackage, output_dir: str, formats: list[str]
97
+ ) -> dict[str, list[Path]]:
98
+ """Write every artifact in the requested formats. Returns paths written.
99
+
100
+ Returns a dict mapping artifact key (plus "_combined") to the list of files
101
+ written for it.
102
+ """
103
+ folder = Path(output_dir) / package.slug
104
+ folder.mkdir(parents=True, exist_ok=True)
105
+
106
+ def write_all(artifact: Artifact) -> list[Path]:
107
+ paths: list[Path] = []
108
+ if "md" in formats:
109
+ paths.append(_write_md(artifact, folder))
110
+ if "docx" in formats:
111
+ paths.append(_write_docx(artifact, folder))
112
+ if "pdf" in formats:
113
+ paths.append(_write_pdf(artifact, folder))
114
+ return paths
115
+
116
+ written: dict[str, list[Path]] = {}
117
+ for artifact in package.artifacts:
118
+ written[artifact.key] = write_all(artifact)
119
+
120
+ # The fit score is structured data rather than a generated document, so it
121
+ # gets rendered here rather than coming back from the model as Markdown.
122
+ if package.fit:
123
+ written["fit_score"] = write_all(
124
+ Artifact(
125
+ key="fit_score",
126
+ title="Fit Score",
127
+ filename="fit-score",
128
+ content=package.fit.as_markdown(),
129
+ )
130
+ )
131
+
132
+ # Combined package — a single file with everything.
133
+ written["_combined"] = write_all(
134
+ Artifact(
135
+ key="_combined",
136
+ title="Full Package",
137
+ filename="full-package",
138
+ content=_combined_markdown(package),
139
+ )
140
+ )
141
+
142
+ return written
@@ -0,0 +1,9 @@
1
+ """Terminal presentation helpers.
2
+
3
+ Kept apart from the pipeline so that nothing in the core depends on how (or
4
+ whether) progress is drawn. The CLI is the only consumer.
5
+ """
6
+
7
+ from offerprinter.ui.printer import PrinterAnimation, render_fit_bar
8
+
9
+ __all__ = ["PrinterAnimation", "render_fit_bar"]
@@ -0,0 +1,165 @@
1
+ """The printer animation — an ASCII dot-matrix printer feeding paper.
2
+
3
+ Purely cosmetic, and that is the point: a generation run is 30-60 seconds of
4
+ nothing, and watching a little printer chug through five documents is far nicer
5
+ than watching a spinner. It degrades safely — if the output is not a terminal
6
+ (piped, redirected, CI, or `--no-animation`), it prints plain status lines
7
+ instead and nothing is lost.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import itertools
13
+ import os
14
+ import sys
15
+ import threading
16
+ import time
17
+
18
+ from rich.console import Console, Group
19
+ from rich.live import Live
20
+ from rich.text import Text
21
+
22
+ #: The printer body. `{roll}` animates the paper feed rollers.
23
+ _PRINTER = r""" ╔═══════════════════════════════════╗
24
+ ║ ● OfferPrinter {status} ║
25
+ ╟───────────────────────────────────╢
26
+ ║ {roll} ║
27
+ ╚═══════════════════════════════════╝"""
28
+
29
+ #: Frames for the paper-feed rollers, cycled while a document is generating.
30
+ _ROLLERS = [
31
+ "▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚",
32
+ "▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞",
33
+ ]
34
+
35
+ #: How a sheet of paper looks as it emerges, one line at a time.
36
+ _SHEET_TOP = " ┌─────────────────────────────────┐"
37
+ _SHEET_ROW = " │ {line:<31} │"
38
+ _SHEET_BOTTOM = " └─────────────────────────────────┘"
39
+
40
+ _STATUS_WIDTH = 10
41
+
42
+
43
+ def _supports_animation(console: Console) -> bool:
44
+ """Animate only when there's a human watching a real terminal."""
45
+ if os.environ.get("OFFERPRINTER_NO_ANIM"):
46
+ return False
47
+ if os.environ.get("CI"):
48
+ return False
49
+ return console.is_terminal and not console.is_jupyter
50
+
51
+
52
+ class PrinterAnimation:
53
+ """Draws the printer while artifacts generate, feeding a sheet per artifact.
54
+
55
+ Use as a context manager:
56
+
57
+ with PrinterAnimation(console, total=5) as anim:
58
+ anim.status("Reading the job description")
59
+ anim.sheet("Tailored CV")
60
+ """
61
+
62
+ def __init__(self, console: Console, total: int, enabled: bool = True) -> None:
63
+ self.console = console
64
+ self.total = max(total, 1)
65
+ self.enabled = enabled and _supports_animation(console)
66
+ self.done = 0
67
+ self._status = "warming up"
68
+ self._sheets: list[str] = []
69
+ self._live: Live | None = None
70
+ self._stop = threading.Event()
71
+ self._thread: threading.Thread | None = None
72
+ self._frames = itertools.cycle(_ROLLERS)
73
+ self._lock = threading.Lock()
74
+
75
+ # -- rendering ----------------------------------------------------------
76
+
77
+ def _render(self) -> Group:
78
+ with self._lock:
79
+ status = self._status[:_STATUS_WIDTH].ljust(_STATUS_WIDTH)
80
+ sheets = list(self._sheets)
81
+
82
+ body = Text(
83
+ _PRINTER.format(status=status, roll=next(self._frames)),
84
+ style="bright_cyan",
85
+ )
86
+ lines: list[Text | str] = [body]
87
+ if sheets:
88
+ lines.append(Text(_SHEET_TOP, style="grey58"))
89
+ for name in sheets:
90
+ lines.append(Text(_SHEET_ROW.format(line=f"✓ {name}"), style="green"))
91
+ lines.append(Text(_SHEET_BOTTOM, style="grey58"))
92
+ return Group(*lines)
93
+
94
+ def _spin(self) -> None:
95
+ while not self._stop.wait(0.18):
96
+ if self._live is not None:
97
+ self._live.update(self._render())
98
+
99
+ # -- public API ---------------------------------------------------------
100
+
101
+ def status(self, message: str) -> None:
102
+ """Update the little status readout on the printer's front panel."""
103
+ with self._lock:
104
+ self._status = message
105
+ if not self.enabled:
106
+ self.console.print(f"[cyan]…[/cyan] {message}")
107
+ elif self._live is not None:
108
+ self._live.update(self._render())
109
+
110
+ def sheet(self, title: str) -> None:
111
+ """Feed one finished sheet out of the printer."""
112
+ self.done += 1
113
+ with self._lock:
114
+ self._sheets.append(title)
115
+ self._status = f"{self.done}/{self.total}"
116
+ if not self.enabled:
117
+ self.console.print(f" [green]✓[/green] {title}")
118
+ elif self._live is not None:
119
+ self._live.update(self._render())
120
+
121
+ def __enter__(self) -> PrinterAnimation:
122
+ if self.enabled:
123
+ self._live = Live(
124
+ self._render(),
125
+ console=self.console,
126
+ refresh_per_second=12,
127
+ transient=False,
128
+ )
129
+ self._live.__enter__()
130
+ self._thread = threading.Thread(target=self._spin, daemon=True)
131
+ self._thread.start()
132
+ return self
133
+
134
+ def __exit__(self, *exc_info: object) -> None:
135
+ self._stop.set()
136
+ if self._thread is not None:
137
+ self._thread.join(timeout=1.0)
138
+ if self._live is not None:
139
+ with self._lock:
140
+ self._status = "done".ljust(_STATUS_WIDTH)
141
+ self._live.update(self._render())
142
+ self._live.__exit__(*exc_info) # type: ignore[arg-type]
143
+ self._live = None
144
+
145
+
146
+ def render_fit_bar(score: int, width: int = 24) -> Text:
147
+ """A coloured 0-100 bar for the fit score, for the end-of-run summary."""
148
+ filled = round(score / 100 * width)
149
+ colour = "green" if score >= 70 else "yellow" if score >= 50 else "red"
150
+ bar = Text()
151
+ bar.append("█" * filled, style=colour)
152
+ bar.append("░" * (width - filled), style="grey37")
153
+ return bar
154
+
155
+
156
+ def type_out(console: Console, text: str, delay: float = 0.012, style: str = "") -> None:
157
+ """Print text a character at a time, for the one line that deserves it."""
158
+ if not _supports_animation(console) or delay <= 0:
159
+ console.print(text, style=style)
160
+ return
161
+ for char in text:
162
+ console.print(char, end="", style=style, highlight=False, markup=False)
163
+ sys.stdout.flush()
164
+ time.sleep(delay)
165
+ console.print()