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.
@@ -0,0 +1,3 @@
1
+ """Local job hunt pipeline."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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)