stellenpilot 0.1.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.
- stellenpilot/__init__.py +3 -0
- stellenpilot/__main__.py +5 -0
- stellenpilot/cli.py +289 -0
- stellenpilot/config.py +136 -0
- stellenpilot/db.py +166 -0
- stellenpilot/demo.py +54 -0
- stellenpilot/examples/config.example.toml +106 -0
- stellenpilot/examples/profile.example.toml +74 -0
- stellenpilot/gmail_sync.py +110 -0
- stellenpilot/http.py +46 -0
- stellenpilot/importer.py +324 -0
- stellenpilot/llm.py +147 -0
- stellenpilot/manual.py +232 -0
- stellenpilot/notify.py +116 -0
- stellenpilot/pipeline.py +182 -0
- stellenpilot/schedule.py +164 -0
- stellenpilot/score.py +170 -0
- stellenpilot/server.py +430 -0
- stellenpilot/sources/__init__.py +20 -0
- stellenpilot/sources/adzuna.py +58 -0
- stellenpilot/sources/arbeitnow.py +61 -0
- stellenpilot/sources/arbeitsagentur.py +75 -0
- stellenpilot/sources/theirstack.py +75 -0
- stellenpilot/sources/watchlist.py +230 -0
- stellenpilot/tailor.py +420 -0
- stellenpilot/text.py +176 -0
- stellenpilot/web/index.html +1018 -0
- stellenpilot-0.1.0.dist-info/METADATA +204 -0
- stellenpilot-0.1.0.dist-info/RECORD +33 -0
- stellenpilot-0.1.0.dist-info/WHEEL +5 -0
- stellenpilot-0.1.0.dist-info/entry_points.txt +2 -0
- stellenpilot-0.1.0.dist-info/licenses/LICENSE +21 -0
- stellenpilot-0.1.0.dist-info/top_level.txt +1 -0
stellenpilot/__init__.py
ADDED
stellenpilot/__main__.py
ADDED
stellenpilot/cli.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Command line: stellenpilot init | doctor | run | serve | gmail | demo"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
from importlib import resources
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .config import Settings
|
|
13
|
+
|
|
14
|
+
ENV_TEMPLATE = """# Secrets for stellenpilot. This file is ignored by git. Never commit it.
|
|
15
|
+
# LLM (pick one, matching llm.provider in config.toml)
|
|
16
|
+
ANTHROPIC_API_KEY=
|
|
17
|
+
OPENAI_API_KEY=
|
|
18
|
+
# Optional job sources
|
|
19
|
+
ADZUNA_APP_ID=
|
|
20
|
+
ADZUNA_APP_KEY=
|
|
21
|
+
THEIRSTACK_API_KEY=
|
|
22
|
+
# Optional daily digest to yourself (see docs/digest.md)
|
|
23
|
+
SMTP_HOST=
|
|
24
|
+
SMTP_PORT=587
|
|
25
|
+
SMTP_USER=
|
|
26
|
+
SMTP_PASSWORD=
|
|
27
|
+
DIGEST_TO=
|
|
28
|
+
TELEGRAM_BOT_TOKEN=
|
|
29
|
+
TELEGRAM_CHAT_ID=
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _examples_dir() -> Path:
|
|
34
|
+
return Path(str(resources.files("stellenpilot").joinpath("examples")))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def cmd_init(home: Path) -> None:
|
|
38
|
+
ex = _examples_dir()
|
|
39
|
+
for src, dst in (("config.example.toml", "config.toml"), ("profile.example.toml", "profile.toml")):
|
|
40
|
+
target = home / dst
|
|
41
|
+
if target.exists():
|
|
42
|
+
print(f"keep {dst} (already exists)")
|
|
43
|
+
else:
|
|
44
|
+
shutil.copy(ex / src, target)
|
|
45
|
+
print(f"made {dst}")
|
|
46
|
+
env = home / ".env"
|
|
47
|
+
if not env.exists():
|
|
48
|
+
env.write_text(ENV_TEMPLATE, encoding="utf-8")
|
|
49
|
+
print("made .env (put your API keys here)")
|
|
50
|
+
print("\nNext: edit profile.toml with your real CV facts, check config.toml, then run `stellenpilot doctor`.")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cmd_doctor(settings: Settings) -> int:
|
|
54
|
+
from .sources import REGISTRY
|
|
55
|
+
ok = True
|
|
56
|
+
print(f"home: {settings.home}")
|
|
57
|
+
print(f"config.toml: {'found' if (settings.home / 'config.toml').exists() else 'missing, using defaults'}")
|
|
58
|
+
if not settings.profile:
|
|
59
|
+
print("profile.toml: MISSING (run stellenpilot init)"); ok = False
|
|
60
|
+
elif settings.profile.get("person", {}).get("name") == "Alex Example":
|
|
61
|
+
print("profile.toml: still the example profile. Replace it with your own facts.")
|
|
62
|
+
else:
|
|
63
|
+
print("profile.toml: ok")
|
|
64
|
+
for name, mod in REGISTRY.items():
|
|
65
|
+
on = settings["sources"].get(name)
|
|
66
|
+
need = [k for k in getattr(mod, "NEEDS", ()) if not settings.secret(k)]
|
|
67
|
+
covered = getattr(mod, "COUNTRIES", None)
|
|
68
|
+
country = settings["search"]["country"].lower()
|
|
69
|
+
state = "off" if not on else (f"ON but missing {', '.join(need)}" if need else
|
|
70
|
+
f"on, but it does not cover '{country}' (skipped)" if covered and country not in covered else "on")
|
|
71
|
+
if name == "watchlist":
|
|
72
|
+
state = f"{len(settings['watch'].get('companies') or [])} companies"
|
|
73
|
+
ok &= not (on and need and name != "watchlist")
|
|
74
|
+
print(f"source {name:15} {state}")
|
|
75
|
+
ok &= _check_llm(settings)
|
|
76
|
+
from .tailor import find_soffice
|
|
77
|
+
print(f"pdf export: {'LibreOffice found' if find_soffice() else 'LibreOffice not found, DOCX only'}")
|
|
78
|
+
print(f"gmail: {'on' if settings['gmail'].get('enabled') else 'off'}")
|
|
79
|
+
return 0 if ok else 1
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def llm_status(settings: Settings) -> tuple[bool, str]:
|
|
83
|
+
from .llm import LLM
|
|
84
|
+
prov = settings["llm"]["provider"]
|
|
85
|
+
try:
|
|
86
|
+
llm = LLM(settings)
|
|
87
|
+
except RuntimeError as e:
|
|
88
|
+
return False, f"{prov}: {e}"
|
|
89
|
+
if not llm.local:
|
|
90
|
+
return True, (f"{prov} ({llm.model})" if llm.enabled else "none: keyword scoring and template letters")
|
|
91
|
+
try:
|
|
92
|
+
models = llm.list_models()
|
|
93
|
+
except Exception as e:
|
|
94
|
+
start = "Start the Ollama app (or run: ollama serve)" if prov == "ollama" else "Start the model server"
|
|
95
|
+
return False, f"nothing answers at {llm.base_url}. {start}, then test again. ({e.__class__.__name__})"
|
|
96
|
+
have = any(m == llm.model or m.split(":")[0] == llm.model or m.endswith("/" + llm.model) for m in models)
|
|
97
|
+
if have or (len(models) == 1 and prov != "ollama"):
|
|
98
|
+
return True, f"{prov} at {llm.base_url}, model {llm.model} ready"
|
|
99
|
+
hint = f" Run: ollama pull {llm.model}" if prov == "ollama" else ""
|
|
100
|
+
return False, f"{prov} is running, but model '{llm.model}' is not installed. Available: {', '.join(models[:8]) or 'none'}.{hint}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _check_llm(settings: Settings) -> bool:
|
|
104
|
+
ok, msg = llm_status(settings)
|
|
105
|
+
print(f"llm: {msg}" if ok else f"llm: PROBLEM: {msg}")
|
|
106
|
+
return ok
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def setup_checks(settings: Settings) -> list[dict]:
|
|
110
|
+
"""The same checks as `stellenpilot doctor`, as data for the dashboard's setup guide."""
|
|
111
|
+
from .sources import REGISTRY
|
|
112
|
+
from .tailor import find_soffice
|
|
113
|
+
out = []
|
|
114
|
+
name = settings.profile.get("person", {}).get("name") if settings.profile else ""
|
|
115
|
+
out.append({"step": "cv", "ok": bool(name) and name != "Alex Example",
|
|
116
|
+
"text": f"CV saved for {name}" if name and name != "Alex Example" else "Your CV is not saved yet"})
|
|
117
|
+
out.append({"step": "search", "ok": bool(settings["search"]["keywords"]),
|
|
118
|
+
"text": f"Looking for {', '.join(settings['search']['keywords'][:3])} near {settings['search']['city']}"})
|
|
119
|
+
ok, msg = llm_status(settings)
|
|
120
|
+
out.append({"step": "ai", "ok": ok, "text": "AI model: " + msg})
|
|
121
|
+
on = [n for n, m in REGISTRY.items() if n != "watchlist" and settings["sources"].get(n)]
|
|
122
|
+
for n in on:
|
|
123
|
+
need = [k for k in getattr(REGISTRY[n], "NEEDS", ()) if not settings.secret(k)]
|
|
124
|
+
if need:
|
|
125
|
+
out.append({"step": "sources", "ok": False, "text": f"{REGISTRY[n].LABEL} is on but its key is missing"})
|
|
126
|
+
out.append({"step": "sources", "ok": bool(on), "text": f"{len(on)} job source{'s' if len(on) != 1 else ''} on" if on else "No job source is switched on"})
|
|
127
|
+
out.append({"step": "extra", "ok": True, "optional": True,
|
|
128
|
+
"text": "PDF export: LibreOffice found" if find_soffice() else "No LibreOffice, so you get Word files only (optional)"})
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def main(argv: list[str] | None = None) -> int:
|
|
133
|
+
ap = argparse.ArgumentParser(prog="stellenpilot", description="Stellenpilot: your local job search assistant")
|
|
134
|
+
ap.add_argument("--home", type=Path, default=Path.cwd(), help="folder with config.toml and profile.toml")
|
|
135
|
+
ap.add_argument("-v", "--verbose", action="store_true")
|
|
136
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
137
|
+
sub.add_parser("init", help="create config.toml, profile.toml and .env from the examples")
|
|
138
|
+
sub.add_parser("doctor", help="check your setup")
|
|
139
|
+
r = sub.add_parser("run", help="search, score, tailor and sync once")
|
|
140
|
+
r.add_argument("--no-tailor", action="store_true", help="skip CV and letter generation")
|
|
141
|
+
s = sub.add_parser("serve", help="open the local dashboard")
|
|
142
|
+
s.add_argument("--port", type=int, default=8765)
|
|
143
|
+
s.add_argument("--host", default=os.environ.get("JOBHUNT_HOST", "127.0.0.1"),
|
|
144
|
+
help="address to listen on; keep 127.0.0.1 unless you run inside Docker")
|
|
145
|
+
sub.add_parser("gmail", help="sync statuses from Gmail once")
|
|
146
|
+
sub.add_parser("demo", help="load fictional sample jobs to try the dashboard without any keys")
|
|
147
|
+
sub.add_parser("notify-test", help="send a test digest by email or Telegram")
|
|
148
|
+
sub.add_parser("telegram-id", help="show your Telegram chat id after you messaged your bot")
|
|
149
|
+
im = sub.add_parser("import", help="turn your existing CV (PDF, DOCX, TXT) into profile.imported.toml")
|
|
150
|
+
im.add_argument("cv", type=Path)
|
|
151
|
+
ad = sub.add_parser("add", help="add a job yourself, from a link or pasted text")
|
|
152
|
+
ad.add_argument("url", nargs="?", default="", help="link to the ad on a company careers page")
|
|
153
|
+
ad.add_argument("--text", help="ad text, or a path to a .txt file, or - to read from stdin")
|
|
154
|
+
ad.add_argument("--title", default="")
|
|
155
|
+
ad.add_argument("--company", default="")
|
|
156
|
+
ad.add_argument("--location", default="")
|
|
157
|
+
ad.add_argument("--tailor", action="store_true", help="also make the CV and cover letter now")
|
|
158
|
+
w = sub.add_parser("watch", help="find the watchlist entry for a company's careers page")
|
|
159
|
+
w.add_argument("url", help="careers page or job link, or an entry like greenhouse:trivago")
|
|
160
|
+
a = ap.parse_args(argv)
|
|
161
|
+
logging.basicConfig(level=logging.INFO if a.verbose else logging.WARNING, format="%(message)s")
|
|
162
|
+
home = a.home.resolve()
|
|
163
|
+
|
|
164
|
+
if a.cmd == "init":
|
|
165
|
+
cmd_init(home)
|
|
166
|
+
return 0
|
|
167
|
+
settings = Settings(home)
|
|
168
|
+
if a.cmd == "doctor":
|
|
169
|
+
return cmd_doctor(settings)
|
|
170
|
+
if a.cmd == "run":
|
|
171
|
+
from .pipeline import run
|
|
172
|
+
res = run(settings, tailor=not a.no_tailor)
|
|
173
|
+
print(f"{res['new']} new jobs, {res['shortlisted']} shortlisted, {res['docs']} files made, "
|
|
174
|
+
f"{len(res['gmail'])} Gmail updates")
|
|
175
|
+
for p in res["problems"]:
|
|
176
|
+
print(f" problem: {p}")
|
|
177
|
+
return 0
|
|
178
|
+
if a.cmd == "serve":
|
|
179
|
+
from .server import serve
|
|
180
|
+
serve(settings, a.port, a.host)
|
|
181
|
+
return 0
|
|
182
|
+
if a.cmd == "gmail":
|
|
183
|
+
from .db import DB
|
|
184
|
+
from .gmail_sync import sync
|
|
185
|
+
changes = sync(DB(settings.db_path), home, settings["gmail"].get("lookback_days", 3))
|
|
186
|
+
print(f"{len(changes)} status changes")
|
|
187
|
+
return 0
|
|
188
|
+
if a.cmd == "notify-test":
|
|
189
|
+
from .db import DB, today
|
|
190
|
+
from .notify import send_digest
|
|
191
|
+
if not (settings["notify"].get("email") or settings["notify"].get("telegram")):
|
|
192
|
+
print("Turn on email or telegram under [notify] in config.toml first.")
|
|
193
|
+
return 1
|
|
194
|
+
cfg = dict(settings["notify"], only_when_new=False)
|
|
195
|
+
settings.cfg["notify"] = cfg
|
|
196
|
+
problems = send_digest(settings, DB(settings.db_path), {"new": 0, "shortlisted": 0, "problems": ["(test message)"]}, today())
|
|
197
|
+
print("\n".join(problems) if problems else "Sent. Check your inbox or Telegram.")
|
|
198
|
+
return 1 if problems else 0
|
|
199
|
+
if a.cmd == "telegram-id":
|
|
200
|
+
from .notify import telegram_chat_ids
|
|
201
|
+
token = settings.secret("TELEGRAM_BOT_TOKEN")
|
|
202
|
+
if not token:
|
|
203
|
+
print("Put TELEGRAM_BOT_TOKEN in .env first (from @BotFather).")
|
|
204
|
+
return 1
|
|
205
|
+
ids = telegram_chat_ids(token)
|
|
206
|
+
if not ids:
|
|
207
|
+
print("No messages yet. Send any message (e.g. /start) to your bot in Telegram, then run this again.")
|
|
208
|
+
return 1
|
|
209
|
+
for cid, who in ids:
|
|
210
|
+
print(f"TELEGRAM_CHAT_ID={cid} ({who})")
|
|
211
|
+
return 0
|
|
212
|
+
if a.cmd == "import":
|
|
213
|
+
from .importer import import_cv
|
|
214
|
+
try:
|
|
215
|
+
out, notes = import_cv(a.cv, settings)
|
|
216
|
+
except FileNotFoundError as e:
|
|
217
|
+
print(f"Import failed: {e}")
|
|
218
|
+
return 1
|
|
219
|
+
except (RuntimeError, ValueError) as e:
|
|
220
|
+
print(f"Import failed: {e}")
|
|
221
|
+
return 1
|
|
222
|
+
except OSError as e: # network: the AI model server is not running or not reachable
|
|
223
|
+
print(f"Import failed: could not reach the AI model ({e}). Is it running? Check with: stellenpilot doctor")
|
|
224
|
+
return 1
|
|
225
|
+
except Exception as e: # never show a raw traceback for a bad file or odd model output
|
|
226
|
+
print(f"Import failed: {e.__class__.__name__}: {e}")
|
|
227
|
+
return 1
|
|
228
|
+
print(f"Wrote {out.name}. Review it, then rename it to profile.toml.")
|
|
229
|
+
for n in notes[:12]:
|
|
230
|
+
print(f" check: {n}")
|
|
231
|
+
if len(notes) > 12:
|
|
232
|
+
print(f" ... {len(notes) - 12} more notes in the file")
|
|
233
|
+
return 0
|
|
234
|
+
if a.cmd == "add":
|
|
235
|
+
from .db import DB
|
|
236
|
+
from .manual import AddError, add_job
|
|
237
|
+
text = a.text or ""
|
|
238
|
+
if text == "-":
|
|
239
|
+
text = sys.stdin.read()
|
|
240
|
+
elif text and len(text) < 260 and "\n" not in text and Path(text).is_file():
|
|
241
|
+
text = Path(text).read_text(encoding="utf-8", errors="replace")
|
|
242
|
+
try:
|
|
243
|
+
res = add_job(settings, DB(settings.db_path), tailor=a.tailor, url=a.url, text=text,
|
|
244
|
+
title=a.title, company=a.company, location=a.location)
|
|
245
|
+
except AddError as e:
|
|
246
|
+
print(f"Not added: {e}")
|
|
247
|
+
return 1
|
|
248
|
+
except Exception as e: # never a raw traceback for an odd page or a model hiccup
|
|
249
|
+
print(f"Not added: {e.__class__.__name__}: {e}")
|
|
250
|
+
return 1
|
|
251
|
+
j = res["job"]
|
|
252
|
+
if res["status"] == "exists":
|
|
253
|
+
print(f"Already on your board: {j['title']} at {j['company']} (match {j['match']})")
|
|
254
|
+
return 0
|
|
255
|
+
print(f"Added: {j['title']} at {j['company']}, match {j['match']} ({j['fit']}). {j['why']}")
|
|
256
|
+
if j.get("german_level"):
|
|
257
|
+
print(f" German asked for: {j['german_level']}")
|
|
258
|
+
if res.get("tailored"):
|
|
259
|
+
print(f" Documents: {res['tailored']['folder']}")
|
|
260
|
+
return 0
|
|
261
|
+
if a.cmd == "watch":
|
|
262
|
+
from .sources.watchlist import fetch_company, parse_careers_url, title_matches, title_rules
|
|
263
|
+
entry = a.url if ":" in a.url and "//" not in a.url else parse_careers_url(a.url)
|
|
264
|
+
if not entry:
|
|
265
|
+
print("Could not recognize that link. Supported: Greenhouse, Lever, Ashby, Personio, SmartRecruiters.\n"
|
|
266
|
+
"Tip: open a job on the company's careers page and use the link of its Apply page.")
|
|
267
|
+
return 1
|
|
268
|
+
try:
|
|
269
|
+
jobs = fetch_company(entry, settings["search"]["country"])
|
|
270
|
+
except Exception as e:
|
|
271
|
+
print(f"{entry}: could not read the feed ({e})")
|
|
272
|
+
return 1
|
|
273
|
+
rules = title_rules(settings)
|
|
274
|
+
hits = [j for j in jobs if title_matches(j["title"], rules)]
|
|
275
|
+
print(f"{entry}: {len(jobs)} open jobs, {len(hits)} match your keywords ({' or '.join(' + '.join(r) for r in rules)})")
|
|
276
|
+
for j in hits[:8]:
|
|
277
|
+
print(f" * {j['title']} ({j['location']})")
|
|
278
|
+
print(f'\nAdd this to [watch] companies in config.toml: "{entry}"')
|
|
279
|
+
return 0
|
|
280
|
+
if a.cmd == "demo":
|
|
281
|
+
from .demo import load_demo
|
|
282
|
+
n = load_demo(settings)
|
|
283
|
+
print(f"Loaded {n} fictional jobs into {settings.db_path.name}. Now run: stellenpilot serve")
|
|
284
|
+
return 0
|
|
285
|
+
return 1
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
if __name__ == "__main__":
|
|
289
|
+
sys.exit(main())
|
stellenpilot/config.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Load config.toml, profile.toml and secrets from the environment or a .env file."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import tomllib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
DEFAULTS = {
|
|
9
|
+
"search": {
|
|
10
|
+
"keywords": ["Werkstudent Data", "Junior Data Engineer"],
|
|
11
|
+
"city": "Berlin",
|
|
12
|
+
"radius_km": 50,
|
|
13
|
+
"nearby_cities": [],
|
|
14
|
+
"country": "de",
|
|
15
|
+
"include_remote": True,
|
|
16
|
+
"max_age_days": 3,
|
|
17
|
+
"exclude_title_words": ["senior", "lead", "head of", "principal"],
|
|
18
|
+
},
|
|
19
|
+
"language": {"skip_if_german_too_high": True, "my_german_level": "", "german_tolerance": 1},
|
|
20
|
+
"sources": {
|
|
21
|
+
"arbeitsagentur": True,
|
|
22
|
+
"arbeitnow": True,
|
|
23
|
+
"adzuna": False,
|
|
24
|
+
"theirstack": False,
|
|
25
|
+
"limits": {"adzuna_calls": 4, "theirstack_jobs": 6, "arbeitsagentur_results": 50},
|
|
26
|
+
"theirstack_options": {"location_ids": [], "english_only": True},
|
|
27
|
+
},
|
|
28
|
+
"llm": {"provider": "none", "model": "", "base_url": ""},
|
|
29
|
+
"tailor": {"min_match": 70, "max_per_run": 5, "output_dir": "output", "make_pdf": True, "only_english_ads": True, "letter_language": "auto"},
|
|
30
|
+
"gmail": {"enabled": False, "lookback_days": 3},
|
|
31
|
+
"watch": {"companies": [], "title_words": [], "max_age_days": 30},
|
|
32
|
+
"notify": {"email": False, "telegram": False, "only_when_new": False},
|
|
33
|
+
"schedule": {"enabled": False, "time": "08:45"},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _merge(base: dict, over: dict) -> dict:
|
|
38
|
+
out = dict(base)
|
|
39
|
+
for k, v in over.items():
|
|
40
|
+
out[k] = _merge(base[k], v) if isinstance(v, dict) and isinstance(base.get(k), dict) else v
|
|
41
|
+
return out
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_dotenv(path: Path) -> None:
|
|
45
|
+
"""Minimal .env reader: KEY=value lines, existing env vars win."""
|
|
46
|
+
if not path.exists():
|
|
47
|
+
return
|
|
48
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
49
|
+
line = line.strip()
|
|
50
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
51
|
+
continue
|
|
52
|
+
key, _, val = line.partition("=")
|
|
53
|
+
val = val.strip()
|
|
54
|
+
if val[:1] in ("'", '"') and val[:1] in val[1:]:
|
|
55
|
+
val = val[1:val.index(val[0], 1)]
|
|
56
|
+
else:
|
|
57
|
+
val = val.split(" #")[0].strip()
|
|
58
|
+
os.environ.setdefault(key.strip(), val)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Settings:
|
|
62
|
+
def __init__(self, home: Path):
|
|
63
|
+
self.home = home
|
|
64
|
+
load_dotenv(home / ".env")
|
|
65
|
+
cfg_path = home / "config.toml"
|
|
66
|
+
user = tomllib.loads(cfg_path.read_text(encoding="utf-8")) if cfg_path.exists() else {}
|
|
67
|
+
self.cfg = _merge(DEFAULTS, user)
|
|
68
|
+
prof_path = home / "profile.toml"
|
|
69
|
+
self.profile = tomllib.loads(prof_path.read_text(encoding="utf-8")) if prof_path.exists() else {}
|
|
70
|
+
self.db_path = home / "jobhunt.db"
|
|
71
|
+
|
|
72
|
+
def __getitem__(self, key):
|
|
73
|
+
return self.cfg[key]
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def secret(name: str) -> str | None:
|
|
77
|
+
val = os.environ.get(name, "").strip()
|
|
78
|
+
return val or None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------- saving from the dashboard
|
|
82
|
+
|
|
83
|
+
def _toml_value(v) -> str:
|
|
84
|
+
import json
|
|
85
|
+
if isinstance(v, bool):
|
|
86
|
+
return "true" if v else "false"
|
|
87
|
+
if isinstance(v, (int, float)):
|
|
88
|
+
return repr(v)
|
|
89
|
+
if isinstance(v, list):
|
|
90
|
+
return "[" + ", ".join(_toml_value(x) for x in v) + "]"
|
|
91
|
+
return json.dumps(str(v), ensure_ascii=False) # JSON strings are valid TOML basic strings
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def dump_toml(data: dict, prefix: str = "") -> str:
|
|
95
|
+
import json
|
|
96
|
+
key = lambda k: k if k.replace("_", "").replace("-", "").isalnum() else json.dumps(k, ensure_ascii=False)
|
|
97
|
+
lines = [f"{key(k)} = {_toml_value(v)}" for k, v in data.items() if not isinstance(v, dict)]
|
|
98
|
+
for k, v in data.items():
|
|
99
|
+
if isinstance(v, dict):
|
|
100
|
+
name = f"{prefix}.{key(k)}" if prefix else key(k)
|
|
101
|
+
lines += ["", f"[{name}]", dump_toml(v, name).strip("\n")]
|
|
102
|
+
return "\n".join(lines).strip("\n") + "\n"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def save_user_config(home: Path, updates: dict) -> dict:
|
|
106
|
+
"""Merge updates into config.toml, keep a backup, return the new merged config."""
|
|
107
|
+
path = home / "config.toml"
|
|
108
|
+
user = tomllib.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
|
109
|
+
user = _merge(user, updates)
|
|
110
|
+
body = ("# Saved from the dashboard. Every option is explained in config.example.toml in the project.\n\n"
|
|
111
|
+
+ dump_toml(user))
|
|
112
|
+
tomllib.loads(body) # never write a file that would not load
|
|
113
|
+
if path.exists():
|
|
114
|
+
path.replace(home / "config.backup.toml")
|
|
115
|
+
path.write_text(body, encoding="utf-8")
|
|
116
|
+
return _merge(DEFAULTS, user)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def save_secrets(home: Path, secrets: dict) -> None:
|
|
120
|
+
"""Set keys in .env, keeping every other line. Empty values are ignored (keep the old key)."""
|
|
121
|
+
secrets = {k: v.strip() for k, v in secrets.items() if v and v.strip()}
|
|
122
|
+
if not secrets:
|
|
123
|
+
return
|
|
124
|
+
for v in secrets.values():
|
|
125
|
+
if "\n" in v or "\r" in v:
|
|
126
|
+
raise ValueError("A key cannot contain a line break.")
|
|
127
|
+
path = home / ".env"
|
|
128
|
+
lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
|
|
129
|
+
left = dict(secrets)
|
|
130
|
+
for i, ln in enumerate(lines):
|
|
131
|
+
name = ln.split("=", 1)[0].strip()
|
|
132
|
+
if "=" in ln and not ln.lstrip().startswith("#") and name in left:
|
|
133
|
+
lines[i] = f"{name}={left.pop(name)}"
|
|
134
|
+
lines += [f"{k}={v}" for k, v in left.items()]
|
|
135
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
136
|
+
os.environ.update(secrets)
|
stellenpilot/db.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""SQLite storage for jobs and runs. One file, no server."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sqlite3
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
SCHEMA = """
|
|
10
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
11
|
+
id TEXT PRIMARY KEY,
|
|
12
|
+
source TEXT NOT NULL,
|
|
13
|
+
title TEXT NOT NULL,
|
|
14
|
+
company TEXT NOT NULL DEFAULT '',
|
|
15
|
+
location TEXT NOT NULL DEFAULT '',
|
|
16
|
+
remote INTEGER NOT NULL DEFAULT 0,
|
|
17
|
+
url TEXT NOT NULL DEFAULT '',
|
|
18
|
+
description TEXT NOT NULL DEFAULT '',
|
|
19
|
+
posted_at TEXT,
|
|
20
|
+
found_at TEXT NOT NULL,
|
|
21
|
+
job_type TEXT NOT NULL DEFAULT '',
|
|
22
|
+
lang TEXT NOT NULL DEFAULT '',
|
|
23
|
+
match INTEGER,
|
|
24
|
+
fit TEXT NOT NULL DEFAULT '',
|
|
25
|
+
why TEXT NOT NULL DEFAULT '',
|
|
26
|
+
gaps TEXT NOT NULL DEFAULT '',
|
|
27
|
+
gap_tags TEXT NOT NULL DEFAULT '[]',
|
|
28
|
+
status TEXT NOT NULL DEFAULT 'new',
|
|
29
|
+
status_source TEXT NOT NULL DEFAULT 'run',
|
|
30
|
+
status_updated_at TEXT,
|
|
31
|
+
status_note TEXT NOT NULL DEFAULT '',
|
|
32
|
+
folder TEXT NOT NULL DEFAULT '',
|
|
33
|
+
closed INTEGER NOT NULL DEFAULT 0,
|
|
34
|
+
closed_at TEXT,
|
|
35
|
+
interview_at TEXT,
|
|
36
|
+
prep TEXT NOT NULL DEFAULT '',
|
|
37
|
+
rating INTEGER,
|
|
38
|
+
notes TEXT NOT NULL DEFAULT '',
|
|
39
|
+
dedupe_key TEXT NOT NULL DEFAULT '',
|
|
40
|
+
german_level TEXT NOT NULL DEFAULT '',
|
|
41
|
+
summary_en TEXT NOT NULL DEFAULT ''
|
|
42
|
+
);
|
|
43
|
+
CREATE INDEX IF NOT EXISTS jobs_dedupe ON jobs(dedupe_key);
|
|
44
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
45
|
+
date TEXT PRIMARY KEY,
|
|
46
|
+
found INTEGER NOT NULL DEFAULT 0,
|
|
47
|
+
added INTEGER NOT NULL DEFAULT 0,
|
|
48
|
+
shortlisted INTEGER NOT NULL DEFAULT 0,
|
|
49
|
+
docs_made INTEGER NOT NULL DEFAULT 0,
|
|
50
|
+
gmail_updates INTEGER NOT NULL DEFAULT 0,
|
|
51
|
+
notes TEXT NOT NULL DEFAULT '',
|
|
52
|
+
stage TEXT NOT NULL DEFAULT 'done',
|
|
53
|
+
stage_at TEXT,
|
|
54
|
+
stage_note TEXT NOT NULL DEFAULT ''
|
|
55
|
+
);
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
STATUSES = ("new", "ready", "applied", "interview", "offer", "rejected", "skipped")
|
|
59
|
+
# Fields the user owns. The pipeline never overwrites them.
|
|
60
|
+
USER_FIELDS = ("rating", "notes")
|
|
61
|
+
EDITABLE = {"status", "status_note", "rating", "notes", "closed", "interview_at", "prep", "folder"}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def now() -> str:
|
|
65
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def today() -> str:
|
|
69
|
+
return datetime.now().date().isoformat()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class DB:
|
|
73
|
+
def __init__(self, path: Path | str):
|
|
74
|
+
self.conn = sqlite3.connect(str(path), check_same_thread=False, timeout=30)
|
|
75
|
+
self.conn.row_factory = sqlite3.Row
|
|
76
|
+
self.conn.executescript(SCHEMA)
|
|
77
|
+
self._migrate()
|
|
78
|
+
|
|
79
|
+
def _migrate(self) -> None:
|
|
80
|
+
"""Add columns introduced after a user's database was created."""
|
|
81
|
+
have = {r[1] for r in self.conn.execute("PRAGMA table_info(jobs)")}
|
|
82
|
+
for col in ("german_level", "summary_en"):
|
|
83
|
+
if col not in have:
|
|
84
|
+
self.conn.execute(f"ALTER TABLE jobs ADD COLUMN {col} TEXT NOT NULL DEFAULT ''")
|
|
85
|
+
self.conn.commit()
|
|
86
|
+
|
|
87
|
+
# ---------- jobs ----------
|
|
88
|
+
def job_ids(self) -> set[str]:
|
|
89
|
+
return {r[0] for r in self.conn.execute("SELECT id FROM jobs")}
|
|
90
|
+
|
|
91
|
+
def dedupe_keys(self) -> set[str]:
|
|
92
|
+
return {r[0] for r in self.conn.execute("SELECT dedupe_key FROM jobs") if r[0]}
|
|
93
|
+
|
|
94
|
+
def insert_job(self, job: dict) -> bool:
|
|
95
|
+
"""Insert a new job. Returns False if the id already exists."""
|
|
96
|
+
row = {
|
|
97
|
+
"found_at": today(), "status": "new", "status_source": "run",
|
|
98
|
+
"status_updated_at": now(), **job,
|
|
99
|
+
}
|
|
100
|
+
row["gap_tags"] = json.dumps(row.get("gap_tags") or [])
|
|
101
|
+
row["remote"] = int(bool(row.get("remote")))
|
|
102
|
+
cols = [c for c in row if c in self._columns()]
|
|
103
|
+
sql = f"INSERT OR IGNORE INTO jobs ({','.join(cols)}) VALUES ({','.join('?' * len(cols))})"
|
|
104
|
+
cur = self.conn.execute(sql, [row[c] for c in cols])
|
|
105
|
+
self.conn.commit()
|
|
106
|
+
return cur.rowcount == 1
|
|
107
|
+
|
|
108
|
+
def update_job(self, job_id: str, fields: dict, *, by_user: bool = False) -> None:
|
|
109
|
+
if not by_user:
|
|
110
|
+
fields = {k: v for k, v in fields.items() if k not in USER_FIELDS}
|
|
111
|
+
if "gap_tags" in fields:
|
|
112
|
+
fields["gap_tags"] = json.dumps(fields["gap_tags"])
|
|
113
|
+
cols = [c for c in fields if c in self._columns() and c != "id"]
|
|
114
|
+
if not cols:
|
|
115
|
+
return
|
|
116
|
+
sql = f"UPDATE jobs SET {', '.join(f'{c} = ?' for c in cols)} WHERE id = ?"
|
|
117
|
+
self.conn.execute(sql, [fields[c] for c in cols] + [job_id])
|
|
118
|
+
self.conn.commit()
|
|
119
|
+
|
|
120
|
+
def mark_ready(self, job_id: str, folder: str, note: str) -> None:
|
|
121
|
+
"""Store the folder; move to 'ready' only if the user has not changed the status meanwhile."""
|
|
122
|
+
self.conn.execute("UPDATE jobs SET folder = ? WHERE id = ?", (folder, job_id))
|
|
123
|
+
self.conn.execute("UPDATE jobs SET status = 'ready', status_source = 'run', status_updated_at = ?, "
|
|
124
|
+
"status_note = ? WHERE id = ? AND status = 'new'", (now(), note, job_id))
|
|
125
|
+
self.conn.commit()
|
|
126
|
+
|
|
127
|
+
def set_status(self, job_id: str, status: str, *, source: str, note: str = "") -> None:
|
|
128
|
+
if status not in STATUSES:
|
|
129
|
+
raise ValueError(f"unknown status {status}")
|
|
130
|
+
self.update_job(job_id, {"status": status, "status_source": source,
|
|
131
|
+
"status_updated_at": now(), "status_note": note}, by_user=True)
|
|
132
|
+
|
|
133
|
+
def jobs(self, where: str = "1=1", params: tuple = ()) -> list[dict]:
|
|
134
|
+
rows = self.conn.execute(f"SELECT * FROM jobs WHERE {where} ORDER BY match DESC NULLS LAST, found_at DESC", params)
|
|
135
|
+
out = []
|
|
136
|
+
for r in rows:
|
|
137
|
+
d = dict(r)
|
|
138
|
+
d["gap_tags"] = json.loads(d["gap_tags"] or "[]")
|
|
139
|
+
d["remote"] = bool(d["remote"])
|
|
140
|
+
d["closed"] = bool(d["closed"])
|
|
141
|
+
out.append(d)
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
def job(self, job_id: str) -> dict | None:
|
|
145
|
+
found = self.jobs("id = ?", (job_id,))
|
|
146
|
+
return found[0] if found else None
|
|
147
|
+
|
|
148
|
+
def _columns(self) -> set[str]:
|
|
149
|
+
if not hasattr(self, "_cols"):
|
|
150
|
+
self._cols = {r[1] for r in self.conn.execute("PRAGMA table_info(jobs)")}
|
|
151
|
+
return self._cols
|
|
152
|
+
|
|
153
|
+
# ---------- runs ----------
|
|
154
|
+
def run_update(self, date: str, **fields) -> None:
|
|
155
|
+
self.conn.execute("INSERT OR IGNORE INTO runs (date) VALUES (?)", (date,))
|
|
156
|
+
if fields:
|
|
157
|
+
cols = ", ".join(f"{k} = ?" for k in fields)
|
|
158
|
+
self.conn.execute(f"UPDATE runs SET {cols} WHERE date = ?", [*fields.values(), date])
|
|
159
|
+
self.conn.commit()
|
|
160
|
+
|
|
161
|
+
def stage(self, stage: str, note: str = "") -> None:
|
|
162
|
+
"""Live progress for the dashboard's office scene."""
|
|
163
|
+
self.run_update(today(), stage=stage, stage_at=now(), stage_note=note)
|
|
164
|
+
|
|
165
|
+
def runs(self) -> list[dict]:
|
|
166
|
+
return [dict(r) for r in self.conn.execute("SELECT * FROM runs ORDER BY date DESC")]
|
stellenpilot/demo.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Fictional sample data so anyone can try the dashboard without keys or network."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import date, datetime, timedelta, timezone
|
|
5
|
+
|
|
6
|
+
from .db import DB, today
|
|
7
|
+
from .text import dedupe_key
|
|
8
|
+
|
|
9
|
+
D = date.today()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _iso(days_ago: int) -> str:
|
|
13
|
+
return (D - timedelta(days=days_ago)).isoformat()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
SAMPLES = [
|
|
17
|
+
dict(id="demo-1", source="Bundesagentur für Arbeit", title="Working Student Data Engineering", company="Nordlicht Energy GmbH",
|
|
18
|
+
location="Essen, Nordrhein Westfalen", job_type="Werkstudent", match=86, fit="Strong", lang="English",
|
|
19
|
+
why="Python, SQL and AWS pipelines match your project work closely.", gaps="No Snowflake yet.",
|
|
20
|
+
gap_tags=["Snowflake"], status="ready", folder="", url="https://example.com/jobs/1"),
|
|
21
|
+
dict(id="demo-2", source="Arbeitnow", title="Werkstudent Data Analytics", company="Rhein Mobility AG",
|
|
22
|
+
location="Remote, Düsseldorf", remote=True, job_type="Werkstudent", match=78, fit="Strong", lang="English",
|
|
23
|
+
why="Remote. Power BI and SQL reporting are the core of the role.", gaps="Asks for dbt.",
|
|
24
|
+
gap_tags=["dbt"], status="new", url="https://example.com/jobs/2"),
|
|
25
|
+
dict(id="demo-3", source="Adzuna", title="Junior Data Engineer", company="Ruhr Logistics SE",
|
|
26
|
+
location="Dortmund", job_type="Full time", match=74, fit="Possible", lang="English",
|
|
27
|
+
why="Airflow and Docker experience fits their batch pipelines.", gaps="Kafka and Terraform are new to you.",
|
|
28
|
+
gap_tags=["Kafka", "Terraform"], status="applied", status_source="manual", url="https://example.com/jobs/3"),
|
|
29
|
+
dict(id="demo-4", source="TheirStack", title="Working Student BI", company="Kölner Retail Tech",
|
|
30
|
+
location="Köln", job_type="Werkstudent", match=69, fit="Possible", lang="English",
|
|
31
|
+
why="Dashboard work in Power BI.", gaps="Retail domain and dbt.", gap_tags=["dbt"],
|
|
32
|
+
status="interview", interview_at=(datetime.now(timezone.utc) + timedelta(days=4)).replace(hour=8, minute=0, microsecond=0).isoformat(),
|
|
33
|
+
prep="## About Kölner Retail Tech\nA fictional company used for the demo.\n## Likely questions\n- Walk us through a pipeline you built\n- How do you check data quality?\n## Questions to ask them\n- What does the BI stack look like?",
|
|
34
|
+
url="https://example.com/jobs/4"),
|
|
35
|
+
dict(id="demo-5", source="Bundesagentur für Arbeit", title="Werkstudent Datenmanagement", company="Westfalen Stadtwerke",
|
|
36
|
+
location="Bochum", job_type="Werkstudent", match=58, fit="Stretch", lang="German needed",
|
|
37
|
+
why="Data cleaning in SAP.", gaps="Asks for fluent German.", gap_tags=["German", "SAP"],
|
|
38
|
+
status="skipped", status_note="Ad asks for fluent German", url="https://example.com/jobs/5"),
|
|
39
|
+
dict(id="demo-6", source="Arbeitnow", title="Data Analyst Intern (paid)", company="Spree Insights",
|
|
40
|
+
location="Remote, Berlin", remote=True, job_type="", match=64, fit="Possible", lang="English",
|
|
41
|
+
why="Remote. SQL and Python analysis.", gaps="dbt and Looker.", gap_tags=["dbt", "Looker"],
|
|
42
|
+
status="rejected", status_source="gmail", status_note="Rejection email", url="https://example.com/jobs/6"),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_demo(settings) -> int:
|
|
47
|
+
db = DB(settings.db_path)
|
|
48
|
+
for i, job in enumerate(SAMPLES):
|
|
49
|
+
job = {**job, "found_at": _iso(i % 3), "posted_at": _iso(i % 3 + 1),
|
|
50
|
+
"dedupe_key": dedupe_key(job["company"], job["title"])}
|
|
51
|
+
db.insert_job(job)
|
|
52
|
+
db.run_update(today(), found=42, added=6, shortlisted=3, docs_made=3, gmail_updates=1,
|
|
53
|
+
notes="Demo data", stage="done")
|
|
54
|
+
return len(SAMPLES)
|