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/config.py ADDED
@@ -0,0 +1,183 @@
1
+ """Configuration loading.
2
+
3
+ Precedence (highest wins):
4
+ 1. Environment variables
5
+ 2. config.toml (or a path passed explicitly)
6
+ 3. Built-in defaults
7
+
8
+ The only thing a user strictly has to provide is an API key. If the key is not
9
+ in the file or the generic OFFERPRINTER_API_KEY var, we fall back to the
10
+ standard per-provider env var (ANTHROPIC_API_KEY, OPENAI_API_KEY, ...).
11
+
12
+ The Ollama provider is the exception: it runs on your own machine and needs no
13
+ key at all.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from pathlib import Path
20
+
21
+ try: # Python 3.11+
22
+ import tomllib
23
+ except ModuleNotFoundError: # pragma: no cover - 3.10 fallback
24
+ import tomli as tomllib # type: ignore
25
+
26
+ from offerprinter.models.schemas import (
27
+ GenerationConfig,
28
+ LLMConfig,
29
+ Locale,
30
+ OutputConfig,
31
+ Provider,
32
+ )
33
+ from offerprinter.services.writer import SUPPORTED_FORMATS
34
+
35
+ # Standard per-provider environment variables to fall back to for the key.
36
+ _PROVIDER_KEY_ENV: dict[Provider, tuple[str, ...]] = {
37
+ Provider.ANTHROPIC: ("ANTHROPIC_API_KEY",),
38
+ Provider.OPENAI: ("OPENAI_API_KEY",),
39
+ Provider.GEMINI: ("GEMINI_API_KEY", "GOOGLE_API_KEY"),
40
+ Provider.KIMI: ("MOONSHOT_API_KEY", "KIMI_API_KEY"),
41
+ Provider.OLLAMA: ("OLLAMA_API_KEY",),
42
+ }
43
+
44
+
45
+ class Config:
46
+ """Fully-resolved runtime configuration."""
47
+
48
+ def __init__(
49
+ self,
50
+ llm: LLMConfig,
51
+ output: OutputConfig,
52
+ generation: GenerationConfig,
53
+ pricing: dict[str, tuple[float, float]] | None = None,
54
+ ) -> None:
55
+ self.llm = llm
56
+ self.output = output
57
+ self.generation = generation
58
+ #: model name/prefix -> (USD per 1M input, USD per 1M output)
59
+ self.pricing = pricing or {}
60
+
61
+
62
+ def _read_toml(path: Path | None) -> dict:
63
+ """Load a TOML file if it exists; otherwise return an empty dict."""
64
+ candidates: list[Path] = []
65
+ if path is not None:
66
+ candidates.append(path)
67
+ else:
68
+ # Look for config.toml in the current directory by default.
69
+ candidates.append(Path("config.toml"))
70
+
71
+ for candidate in candidates:
72
+ if candidate.is_file():
73
+ with candidate.open("rb") as fh:
74
+ return tomllib.load(fh)
75
+ return {}
76
+
77
+
78
+ def _first_env(*names: str) -> str | None:
79
+ for name in names:
80
+ val = os.environ.get(name)
81
+ if val:
82
+ return val
83
+ return None
84
+
85
+
86
+ def _env_bool(name: str, default: bool) -> bool:
87
+ raw = os.environ.get(name)
88
+ if raw is None:
89
+ return default
90
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
91
+
92
+
93
+ def _clean_formats(raw: object) -> list[str]:
94
+ """Keep only formats we can actually write, preserving the user's order."""
95
+ values = [str(f).strip().lower().lstrip(".") for f in raw] if isinstance(raw, list) else []
96
+ formats = [f for f in values if f in SUPPORTED_FORMATS]
97
+ return formats or ["md", "docx"]
98
+
99
+
100
+ def _parse_pricing(raw: object) -> dict[str, tuple[float, float]]:
101
+ """Parse a [pricing] table of {model = {input = x, output = y}}."""
102
+ if not isinstance(raw, dict):
103
+ return {}
104
+ parsed: dict[str, tuple[float, float]] = {}
105
+ for model, rates in raw.items():
106
+ if isinstance(rates, dict) and "input" in rates and "output" in rates:
107
+ try:
108
+ parsed[str(model).lower()] = (float(rates["input"]), float(rates["output"]))
109
+ except (TypeError, ValueError):
110
+ continue
111
+ return parsed
112
+
113
+
114
+ def load_config(config_path: str | os.PathLike[str] | None = None) -> Config:
115
+ """Resolve configuration from file + environment.
116
+
117
+ Args:
118
+ config_path: Optional explicit path to a config.toml.
119
+ """
120
+ data = _read_toml(Path(config_path) if config_path else None)
121
+ llm_raw = data.get("llm", {})
122
+ out_raw = data.get("output", {})
123
+ gen_raw = data.get("generation", {})
124
+
125
+ # ---- provider ----------------------------------------------------------
126
+ provider_str = _first_env("OFFERPRINTER_PROVIDER") or llm_raw.get("provider", "anthropic")
127
+ provider = Provider(provider_str.lower())
128
+
129
+ # ---- model -------------------------------------------------------------
130
+ model = _first_env("OFFERPRINTER_MODEL") or llm_raw.get("model", "") or ""
131
+
132
+ # ---- api key -----------------------------------------------------------
133
+ api_key = (
134
+ _first_env("OFFERPRINTER_API_KEY")
135
+ or (llm_raw.get("api_key") or "")
136
+ or (_first_env(*_PROVIDER_KEY_ENV[provider]) or "")
137
+ )
138
+
139
+ # ---- base url ----------------------------------------------------------
140
+ base_url = _first_env("OFFERPRINTER_BASE_URL") or llm_raw.get("base_url", "") or ""
141
+
142
+ llm = LLMConfig(
143
+ provider=provider,
144
+ model=model,
145
+ api_key=api_key,
146
+ base_url=base_url,
147
+ temperature=float(llm_raw.get("temperature", 0.2)),
148
+ max_tokens=int(llm_raw.get("max_tokens", 4096)),
149
+ timeout=float(llm_raw.get("timeout", 120)),
150
+ max_retries=int(llm_raw.get("max_retries", 3)),
151
+ retry_backoff=float(llm_raw.get("retry_backoff", 1.5)),
152
+ )
153
+
154
+ # ---- output ------------------------------------------------------------
155
+ locale_str = _first_env("OFFERPRINTER_LOCALE") or out_raw.get("locale", "UK")
156
+ env_formats = _first_env("OFFERPRINTER_FORMATS")
157
+ output = OutputConfig(
158
+ locale=Locale(locale_str.upper()),
159
+ dir=_first_env("OFFERPRINTER_OUTPUT_DIR") or out_raw.get("dir", "./output"),
160
+ formats=_clean_formats(
161
+ env_formats.split(",") if env_formats else out_raw.get("formats", ["md", "docx"])
162
+ ),
163
+ track=_env_bool("OFFERPRINTER_TRACK", bool(out_raw.get("track", True))),
164
+ )
165
+
166
+ # ---- generation --------------------------------------------------------
167
+ generation = GenerationConfig(
168
+ tailored_cv=bool(gen_raw.get("tailored_cv", True)),
169
+ cover_letter=bool(gen_raw.get("cover_letter", True)),
170
+ fit_memo=bool(gen_raw.get("fit_memo", True)),
171
+ ats_report=bool(gen_raw.get("ats_report", True)),
172
+ interview_prep=bool(gen_raw.get("interview_prep", True)),
173
+ fit_score=bool(gen_raw.get("fit_score", True)),
174
+ parallel=_env_bool("OFFERPRINTER_PARALLEL", bool(gen_raw.get("parallel", True))),
175
+ max_workers=int(gen_raw.get("max_workers", 5)),
176
+ )
177
+
178
+ return Config(
179
+ llm=llm,
180
+ output=output,
181
+ generation=generation,
182
+ pricing=_parse_pricing(data.get("pricing")),
183
+ )
@@ -0,0 +1,5 @@
1
+ """Controller layer: orchestrates services into the end-to-end pipeline."""
2
+
3
+ from offerprinter.controllers.pipeline import Pipeline, PipelineEvent
4
+
5
+ __all__ = ["Pipeline", "PipelineEvent"]
@@ -0,0 +1,135 @@
1
+ """The end-to-end pipeline controller.
2
+
3
+ One entry point — `Pipeline.run(...)` — takes a CV and a job description and
4
+ produces (and optionally writes) the full application package. It emits progress
5
+ events so both the CLI and the Streamlit UI can show live status with the same
6
+ code path.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterator
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from offerprinter.config import Config
16
+ from offerprinter.llm import build_provider
17
+ from offerprinter.models.schemas import (
18
+ ApplicationPackage,
19
+ Artifact,
20
+ FitScore,
21
+ JobDescription,
22
+ ResumeInput,
23
+ Usage,
24
+ )
25
+ from offerprinter.services.generator import Generator, _slugify
26
+ from offerprinter.services.tracker import ApplicationRecord, Tracker, utc_now
27
+ from offerprinter.services.writer import write_package
28
+
29
+
30
+ @dataclass
31
+ class PipelineEvent:
32
+ """A progress update emitted while the pipeline runs."""
33
+
34
+ kind: str # "meta" | "artifact" | "fit" | "written" | "done"
35
+ message: str
36
+ artifact: Artifact | None = None
37
+ package: ApplicationPackage | None = None
38
+ written: dict[str, list[Path]] | None = None
39
+ fit: FitScore | None = None
40
+ achievements: list[str] | None = None
41
+
42
+
43
+ class Pipeline:
44
+ """Runs the full generation flow for one (CV, JD) pair."""
45
+
46
+ def __init__(self, config: Config) -> None:
47
+ self.config = config
48
+ self.provider = build_provider(config.llm, config.pricing)
49
+ self.generator = Generator(
50
+ self.provider,
51
+ locale=config.output.locale,
52
+ generation=config.generation,
53
+ )
54
+
55
+ def stream(self, cv: ResumeInput, jd: JobDescription) -> Iterator[PipelineEvent]:
56
+ """Run the pipeline, yielding a PipelineEvent at each step."""
57
+ # 1. Identify company + role.
58
+ company = jd.company or ""
59
+ role = jd.role or ""
60
+ if not (company and role):
61
+ company, role = self.generator.extract_meta(jd)
62
+ jd.company, jd.role = company, role
63
+ slug = f"{_slugify(company)}-{_slugify(role)}"
64
+ yield PipelineEvent(kind="meta", message=f"{role} at {company}")
65
+
66
+ package = ApplicationPackage(company=company, role=role, slug=slug)
67
+
68
+ # 2. Generate each enabled artifact, streaming as we go. In parallel
69
+ # mode these arrive in completion order, so we re-sort afterwards.
70
+ for artifact in self.generator.iter_generate(cv, jd, company, role):
71
+ package.artifacts.append(artifact)
72
+ yield PipelineEvent(kind="artifact", message=artifact.title, artifact=artifact)
73
+ package.sort_artifacts()
74
+
75
+ # 3. Score the fit, if enabled.
76
+ if self.config.generation.fit_score:
77
+ fit = self.generator.score_fit(cv, jd, company, role)
78
+ package.fit = fit
79
+ yield PipelineEvent(kind="fit", message=f"{fit.score}/100 — {fit.band}", fit=fit)
80
+
81
+ # 4. Account for what the run cost.
82
+ package.usage = Usage(**self.provider.usage.model_dump())
83
+
84
+ # 5. Write to disk.
85
+ written = write_package(package, self.config.output.dir, self.config.output.formats)
86
+ out_dir = Path(self.config.output.dir) / slug
87
+ yield PipelineEvent(
88
+ kind="written",
89
+ message=str(out_dir),
90
+ package=package,
91
+ written=written,
92
+ )
93
+
94
+ # 6. Record it locally, so `offerprinter list` and `stats` can see it.
95
+ achievements = self._track(package, out_dir)
96
+
97
+ yield PipelineEvent(
98
+ kind="done",
99
+ message="Done",
100
+ package=package,
101
+ written=written,
102
+ achievements=achievements,
103
+ )
104
+
105
+ def _track(self, package: ApplicationPackage, out_dir: Path) -> list[str]:
106
+ """Append this run to the local history. Never fatal if it fails."""
107
+ if not self.config.output.track:
108
+ return []
109
+ try:
110
+ return Tracker().record(
111
+ ApplicationRecord(
112
+ slug=package.slug,
113
+ company=package.company,
114
+ role=package.role,
115
+ printed_at=utc_now(),
116
+ provider=self.config.llm.provider.value,
117
+ model=self.provider.model,
118
+ fit_score=package.fit.score if package.fit else None,
119
+ fit_band=package.fit.band if package.fit else "",
120
+ cost_usd=package.usage.cost_usd,
121
+ total_tokens=package.usage.total_tokens,
122
+ output_dir=str(out_dir),
123
+ )
124
+ )
125
+ except OSError:
126
+ return [] # a read-only home directory should never fail a run
127
+
128
+ def run(self, cv: ResumeInput, jd: JobDescription) -> ApplicationPackage:
129
+ """Convenience wrapper that runs the pipeline to completion."""
130
+ package: ApplicationPackage | None = None
131
+ for event in self.stream(cv, jd):
132
+ if event.package is not None:
133
+ package = event.package
134
+ assert package is not None # the "done" event always carries the package
135
+ return package
@@ -0,0 +1,11 @@
1
+ """Provider-agnostic LLM layer.
2
+
3
+ A single interface — `LLMProvider.complete(system, user)` — with concrete
4
+ implementations for Anthropic, OpenAI, Gemini and Moonshot Kimi. Use
5
+ `build_provider(llm_config)` to get the right one from config.
6
+ """
7
+
8
+ from offerprinter.llm.base import LLMError, LLMProvider
9
+ from offerprinter.llm.factory import build_provider
10
+
11
+ __all__ = ["LLMProvider", "LLMError", "build_provider"]
@@ -0,0 +1,41 @@
1
+ """Anthropic (Claude) provider — the default."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from offerprinter.llm.base import LLMError, LLMProvider
6
+
7
+
8
+ class AnthropicProvider(LLMProvider):
9
+ """Calls the Anthropic Messages API (https://api.anthropic.com/v1/messages)."""
10
+
11
+ default_model = "claude-haiku-4-5-20251001"
12
+
13
+ def complete(self, system: str, user: str) -> str:
14
+ key = self._require_key()
15
+ base = self.config.base_url or "https://api.anthropic.com"
16
+ url = f"{base.rstrip('/')}/v1/messages"
17
+ headers = {
18
+ "x-api-key": key,
19
+ "anthropic-version": "2023-06-01",
20
+ "content-type": "application/json",
21
+ }
22
+ payload = {
23
+ "model": self.model,
24
+ "max_tokens": self.config.max_tokens,
25
+ "temperature": self.config.temperature,
26
+ "system": system,
27
+ "messages": [{"role": "user", "content": user}],
28
+ }
29
+ data = self._post(url, headers, payload)
30
+
31
+ usage = data.get("usage") or {}
32
+ self._record_usage(
33
+ int(usage.get("input_tokens", 0)),
34
+ int(usage.get("output_tokens", 0)),
35
+ )
36
+
37
+ try:
38
+ blocks = data["content"]
39
+ return "".join(b.get("text", "") for b in blocks if b.get("type") == "text")
40
+ except (KeyError, TypeError) as exc:
41
+ raise LLMError(f"Unexpected Anthropic response shape: {data}") from exc
@@ -0,0 +1,125 @@
1
+ """The single interface every provider implements.
2
+
3
+ The whole rest of the app talks to LLMs through `LLMProvider.complete()`. Adding
4
+ a new provider means writing one subclass — nothing else in the codebase changes.
5
+
6
+ Two things are handled once, here, for every provider:
7
+
8
+ * **Retries** — a single 429 or a 503 should not kill a five-document run, so
9
+ `_post` retries with exponential backoff and honours `Retry-After`.
10
+ * **Token accounting** — providers report usage in their responses; we
11
+ accumulate it thread-safely so a run can report what it cost.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import abc
17
+ import random
18
+ import threading
19
+ import time
20
+
21
+ import httpx
22
+
23
+ from offerprinter.models.schemas import LLMConfig, Usage
24
+ from offerprinter.pricing import estimate_cost
25
+
26
+ #: Status codes worth trying again — rate limits, overload, and gateway blips.
27
+ RETRYABLE_STATUS = {408, 409, 425, 429, 500, 502, 503, 504}
28
+
29
+
30
+ class LLMError(RuntimeError):
31
+ """Raised when a provider call fails (network, auth, or API error)."""
32
+
33
+
34
+ class LLMProvider(abc.ABC):
35
+ """Abstract base class for a chat-completion provider."""
36
+
37
+ #: The model used when the user leaves `model` blank in config.
38
+ default_model: str = ""
39
+ #: Set False for providers that run locally and need no credentials.
40
+ requires_key: bool = True
41
+
42
+ def __init__(self, config: LLMConfig) -> None:
43
+ self.config = config
44
+ self.model = config.model or self.default_model
45
+ self.usage = Usage()
46
+ self.price_overrides: dict[str, tuple[float, float]] = {}
47
+ self._usage_lock = threading.Lock()
48
+
49
+ @property
50
+ def name(self) -> str:
51
+ return type(self).__name__.replace("Provider", "").lower()
52
+
53
+ @abc.abstractmethod
54
+ def complete(self, system: str, user: str) -> str:
55
+ """Return the model's text completion for a system + user prompt."""
56
+ raise NotImplementedError
57
+
58
+ # -- shared helpers -----------------------------------------------------
59
+
60
+ def _require_key(self) -> str:
61
+ if not self.requires_key:
62
+ return self.config.api_key or "not-required"
63
+ if not self.config.api_key:
64
+ raise LLMError(
65
+ f"No API key found for provider '{self.name}'. Set it in config.toml "
66
+ f"or via an environment variable (see config.example.toml)."
67
+ )
68
+ return self.config.api_key
69
+
70
+ def _record_usage(self, input_tokens: int, output_tokens: int) -> None:
71
+ """Accumulate token counts and estimated spend for this run."""
72
+ cost = estimate_cost(self.model, input_tokens, output_tokens, self.price_overrides)
73
+ with self._usage_lock:
74
+ self.usage.input_tokens += input_tokens
75
+ self.usage.output_tokens += output_tokens
76
+ self.usage.calls += 1
77
+ self.usage.cost_usd += cost
78
+
79
+ @staticmethod
80
+ def _retry_after(resp: httpx.Response) -> float | None:
81
+ """Seconds to wait per the server's Retry-After header, if sane."""
82
+ raw = resp.headers.get("retry-after")
83
+ if not raw:
84
+ return None
85
+ try:
86
+ wait = float(raw)
87
+ except ValueError:
88
+ return None
89
+ return wait if 0 <= wait <= 60 else None
90
+
91
+ def _post(self, url: str, headers: dict, json: dict) -> dict:
92
+ """POST JSON and return the parsed response, retrying transient failures."""
93
+ attempts = max(1, self.config.max_retries + 1)
94
+ last_error: str = ""
95
+
96
+ for attempt in range(attempts):
97
+ resp: httpx.Response | None = None
98
+ try:
99
+ with httpx.Client(timeout=self.config.timeout) as client:
100
+ resp = client.post(url, headers=headers, json=json)
101
+ except httpx.HTTPError as exc: # network-level failure
102
+ last_error = f"Network error calling {self.name}: {exc}"
103
+ else:
104
+ if resp.status_code < 400:
105
+ try:
106
+ return resp.json()
107
+ except ValueError as exc:
108
+ raise LLMError(f"{self.name} returned non-JSON response.") from exc
109
+
110
+ last_error = f"{self.name} API returned {resp.status_code}: {resp.text[:500]}"
111
+ if resp.status_code not in RETRYABLE_STATUS:
112
+ # Auth errors, bad requests: retrying cannot help.
113
+ raise LLMError(last_error)
114
+
115
+ if attempt == attempts - 1:
116
+ break
117
+
118
+ server_hint = self._retry_after(resp) if resp is not None else None
119
+ # Exponential backoff with jitter, so parallel artifacts don't all
120
+ # come back and hammer the API on the same tick.
121
+ backoff = self.config.retry_backoff * (2**attempt)
122
+ delay = server_hint if server_hint is not None else backoff
123
+ time.sleep(delay + random.uniform(0, 0.25))
124
+
125
+ raise LLMError(f"{last_error} (gave up after {attempts} attempts)")
@@ -0,0 +1,33 @@
1
+ """Map a provider name in config to the concrete provider implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from offerprinter.llm.anthropic_provider import AnthropicProvider
6
+ from offerprinter.llm.base import LLMProvider
7
+ from offerprinter.llm.gemini_provider import GeminiProvider
8
+ from offerprinter.llm.kimi_provider import KimiProvider
9
+ from offerprinter.llm.ollama_provider import OllamaProvider
10
+ from offerprinter.llm.openai_provider import OpenAIProvider
11
+ from offerprinter.models.schemas import LLMConfig, Provider
12
+
13
+ _REGISTRY: dict[Provider, type[LLMProvider]] = {
14
+ Provider.ANTHROPIC: AnthropicProvider,
15
+ Provider.OPENAI: OpenAIProvider,
16
+ Provider.GEMINI: GeminiProvider,
17
+ Provider.KIMI: KimiProvider,
18
+ Provider.OLLAMA: OllamaProvider,
19
+ }
20
+
21
+
22
+ def build_provider(
23
+ config: LLMConfig,
24
+ price_overrides: dict[str, tuple[float, float]] | None = None,
25
+ ) -> LLMProvider:
26
+ """Instantiate the provider named in `config`."""
27
+ cls = _REGISTRY.get(config.provider)
28
+ if cls is None: # pragma: no cover - guarded by the Provider enum
29
+ raise ValueError(f"Unknown provider: {config.provider}")
30
+ provider = cls(config)
31
+ if price_overrides:
32
+ provider.price_overrides = price_overrides
33
+ return provider
@@ -0,0 +1,38 @@
1
+ """Google Gemini provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from offerprinter.llm.base import LLMError, LLMProvider
6
+
7
+
8
+ class GeminiProvider(LLMProvider):
9
+ """Calls the Gemini generateContent API."""
10
+
11
+ default_model = "gemini-1.5-flash"
12
+
13
+ def complete(self, system: str, user: str) -> str:
14
+ key = self._require_key()
15
+ base = self.config.base_url or "https://generativelanguage.googleapis.com"
16
+ url = f"{base.rstrip('/')}/v1beta/models/{self.model}:generateContent"
17
+ headers = {"Content-Type": "application/json", "x-goog-api-key": key}
18
+ payload = {
19
+ "systemInstruction": {"parts": [{"text": system}]},
20
+ "contents": [{"role": "user", "parts": [{"text": user}]}],
21
+ "generationConfig": {
22
+ "temperature": self.config.temperature,
23
+ "maxOutputTokens": self.config.max_tokens,
24
+ },
25
+ }
26
+ data = self._post(url, headers, payload)
27
+
28
+ usage = data.get("usageMetadata") or {}
29
+ self._record_usage(
30
+ int(usage.get("promptTokenCount", 0)),
31
+ int(usage.get("candidatesTokenCount", 0)),
32
+ )
33
+
34
+ try:
35
+ parts = data["candidates"][0]["content"]["parts"]
36
+ return "".join(p.get("text", "") for p in parts)
37
+ except (KeyError, IndexError, TypeError) as exc:
38
+ raise LLMError(f"Unexpected Gemini response shape: {data}") from exc
@@ -0,0 +1,16 @@
1
+ """Moonshot Kimi provider.
2
+
3
+ Moonshot exposes an OpenAI-compatible Chat Completions API, so this is a thin
4
+ subclass of OpenAIProvider with a different default base URL and model.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from offerprinter.llm.openai_provider import OpenAIProvider
10
+
11
+
12
+ class KimiProvider(OpenAIProvider):
13
+ """Calls Moonshot's OpenAI-compatible endpoint."""
14
+
15
+ default_model = "moonshot-v1-8k"
16
+ default_base_url = "https://api.moonshot.cn"
@@ -0,0 +1,24 @@
1
+ """Ollama provider — run OfferPrinter entirely on your own machine.
2
+
3
+ Ollama exposes an OpenAI-compatible Chat Completions endpoint at
4
+ ``http://localhost:11434/v1``, so this is a thin subclass of OpenAIProvider with
5
+ a local default base URL, a local default model, and no API key requirement.
6
+
7
+ With this provider your CV never leaves your laptop at all — not even to an LLM
8
+ vendor. Get started with:
9
+
10
+ ollama pull llama3.1
11
+ offerprinter --provider ollama --cv cv.pdf --jd-file jd.txt
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from offerprinter.llm.openai_provider import OpenAIProvider
17
+
18
+
19
+ class OllamaProvider(OpenAIProvider):
20
+ """Calls a local Ollama server through its OpenAI-compatible API."""
21
+
22
+ default_model = "llama3.1"
23
+ default_base_url = "http://localhost:11434"
24
+ requires_key = False
@@ -0,0 +1,42 @@
1
+ """OpenAI provider (also the base for any OpenAI-compatible endpoint)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from offerprinter.llm.base import LLMError, LLMProvider
6
+
7
+
8
+ class OpenAIProvider(LLMProvider):
9
+ """Calls the OpenAI Chat Completions API."""
10
+
11
+ default_model = "gpt-4o-mini"
12
+ default_base_url = "https://api.openai.com"
13
+
14
+ def complete(self, system: str, user: str) -> str:
15
+ key = self._require_key()
16
+ base = self.config.base_url or self.default_base_url
17
+ url = f"{base.rstrip('/')}/v1/chat/completions"
18
+ headers = {
19
+ "Authorization": f"Bearer {key}",
20
+ "Content-Type": "application/json",
21
+ }
22
+ payload = {
23
+ "model": self.model,
24
+ "max_tokens": self.config.max_tokens,
25
+ "temperature": self.config.temperature,
26
+ "messages": [
27
+ {"role": "system", "content": system},
28
+ {"role": "user", "content": user},
29
+ ],
30
+ }
31
+ data = self._post(url, headers, payload)
32
+
33
+ usage = data.get("usage") or {}
34
+ self._record_usage(
35
+ int(usage.get("prompt_tokens", 0)),
36
+ int(usage.get("completion_tokens", 0)),
37
+ )
38
+
39
+ try:
40
+ return data["choices"][0]["message"]["content"]
41
+ except (KeyError, IndexError, TypeError) as exc:
42
+ raise LLMError(f"Unexpected OpenAI response shape: {data}") from exc
@@ -0,0 +1,21 @@
1
+ """Typed data models that flow through the OfferPrinter pipeline."""
2
+
3
+ from offerprinter.models.schemas import (
4
+ ApplicationPackage,
5
+ Artifact,
6
+ GenerationConfig,
7
+ JobDescription,
8
+ LLMConfig,
9
+ OutputConfig,
10
+ ResumeInput,
11
+ )
12
+
13
+ __all__ = [
14
+ "Artifact",
15
+ "ApplicationPackage",
16
+ "GenerationConfig",
17
+ "JobDescription",
18
+ "LLMConfig",
19
+ "OutputConfig",
20
+ "ResumeInput",
21
+ ]