divapply 0.4.2__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.
- divapply/__init__.py +3 -0
- divapply/__main__.py +5 -0
- divapply/apply/__init__.py +1 -0
- divapply/apply/chrome.py +360 -0
- divapply/apply/dashboard.py +203 -0
- divapply/apply/launcher.py +1404 -0
- divapply/apply/prompt.py +942 -0
- divapply/cli.py +765 -0
- divapply/config/coursework.seed.json +560 -0
- divapply/config/employers.yaml +31 -0
- divapply/config/searches.example.yaml +113 -0
- divapply/config/sites.yaml +107 -0
- divapply/config.py +445 -0
- divapply/database.py +635 -0
- divapply/discovery/__init__.py +0 -0
- divapply/discovery/jobspy.py +557 -0
- divapply/discovery/smartextract.py +1268 -0
- divapply/discovery/workday.py +575 -0
- divapply/enrichment/__init__.py +0 -0
- divapply/enrichment/detail.py +1003 -0
- divapply/llm.py +367 -0
- divapply/pipeline.py +551 -0
- divapply/scoring/__init__.py +1 -0
- divapply/scoring/cover_letter.py +337 -0
- divapply/scoring/pdf.py +789 -0
- divapply/scoring/scorer.py +230 -0
- divapply/scoring/tailor.py +800 -0
- divapply/scoring/ultimate.py +214 -0
- divapply/scoring/validator.py +374 -0
- divapply/social.py +1324 -0
- divapply/view.py +407 -0
- divapply/wizard/__init__.py +0 -0
- divapply/wizard/init.py +396 -0
- divapply-0.4.2.dist-info/METADATA +278 -0
- divapply-0.4.2.dist-info/RECORD +38 -0
- divapply-0.4.2.dist-info/WHEEL +4 -0
- divapply-0.4.2.dist-info/entry_points.txt +2 -0
- divapply-0.4.2.dist-info/licenses/LICENSE +661 -0
divapply/database.py
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
"""DivApply database layer: schema, migrations, stats, and connection helpers.
|
|
2
|
+
|
|
3
|
+
Single source of truth for the jobs table schema. All columns from every
|
|
4
|
+
pipeline stage are created up front so any stage can run independently
|
|
5
|
+
without migration ordering issues.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import sqlite3
|
|
10
|
+
import threading
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from divapply.config import DB_PATH, LEGACY_DB_PATH
|
|
15
|
+
|
|
16
|
+
# Thread-local connection storage — each thread gets its own connection
|
|
17
|
+
# (required for SQLite thread safety with parallel workers)
|
|
18
|
+
_local = threading.local()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _resolve_db_path(db_path: Path | str | None = None) -> Path:
|
|
22
|
+
"""Resolve the active database path, preferring existing legacy data."""
|
|
23
|
+
if db_path is not None:
|
|
24
|
+
return Path(db_path)
|
|
25
|
+
if DB_PATH.exists():
|
|
26
|
+
return DB_PATH
|
|
27
|
+
if LEGACY_DB_PATH.exists():
|
|
28
|
+
return LEGACY_DB_PATH
|
|
29
|
+
return DB_PATH
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_connection(db_path: Path | str | None = None) -> sqlite3.Connection:
|
|
33
|
+
"""Get a thread-local cached SQLite connection with WAL mode enabled.
|
|
34
|
+
|
|
35
|
+
Each thread gets its own connection (required for SQLite thread safety).
|
|
36
|
+
Connections are cached and reused within the same thread.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
db_path: Override the default DB_PATH. Useful for testing.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
sqlite3.Connection configured with WAL mode and row factory.
|
|
43
|
+
"""
|
|
44
|
+
resolved_path = _resolve_db_path(db_path)
|
|
45
|
+
path = str(resolved_path)
|
|
46
|
+
|
|
47
|
+
if not hasattr(_local, 'connections'):
|
|
48
|
+
_local.connections = {}
|
|
49
|
+
|
|
50
|
+
conn = _local.connections.get(path)
|
|
51
|
+
if conn is not None:
|
|
52
|
+
try:
|
|
53
|
+
conn.execute("SELECT 1")
|
|
54
|
+
return conn
|
|
55
|
+
except sqlite3.ProgrammingError:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
conn = sqlite3.connect(path, timeout=30)
|
|
59
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
60
|
+
conn.execute("PRAGMA busy_timeout=10000")
|
|
61
|
+
conn.row_factory = sqlite3.Row
|
|
62
|
+
_local.connections[path] = conn
|
|
63
|
+
return conn
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def close_connection(db_path: Path | str | None = None) -> None:
|
|
67
|
+
"""Close the cached connection for the current thread."""
|
|
68
|
+
path = str(_resolve_db_path(db_path))
|
|
69
|
+
if hasattr(_local, 'connections'):
|
|
70
|
+
conn = _local.connections.pop(path, None)
|
|
71
|
+
if conn is not None:
|
|
72
|
+
conn.close()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def init_db(db_path: Path | str | None = None) -> sqlite3.Connection:
|
|
76
|
+
"""Create the full jobs table with all columns from every pipeline stage.
|
|
77
|
+
|
|
78
|
+
This is idempotent -- safe to call on every startup. Uses CREATE TABLE IF NOT EXISTS
|
|
79
|
+
so it won't destroy existing data.
|
|
80
|
+
|
|
81
|
+
Schema columns by stage:
|
|
82
|
+
- Discovery: url, title, salary, description, location, site, strategy, discovered_at
|
|
83
|
+
- Enrichment: full_description, application_url, detail_scraped_at, detail_error
|
|
84
|
+
- Scoring: fit_score, score_reasoning, scored_at
|
|
85
|
+
- Tailoring: tailored_resume_path, tailored_at, tailor_attempts
|
|
86
|
+
- Cover: cover_letter_path, cover_letter_at, cover_attempts
|
|
87
|
+
- Apply: applied_at, apply_status, apply_error, apply_attempts,
|
|
88
|
+
agent_id, last_attempted_at, apply_duration_ms, apply_task_id,
|
|
89
|
+
verification_confidence
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
db_path: Override the default DB_PATH.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
sqlite3.Connection with the schema initialized.
|
|
96
|
+
"""
|
|
97
|
+
path = _resolve_db_path(db_path)
|
|
98
|
+
|
|
99
|
+
# Ensure parent directory exists
|
|
100
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
|
|
102
|
+
conn = get_connection(path)
|
|
103
|
+
conn.execute("""
|
|
104
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
105
|
+
-- Discovery stage (smart_extract / job_search)
|
|
106
|
+
url TEXT PRIMARY KEY,
|
|
107
|
+
title TEXT,
|
|
108
|
+
salary TEXT,
|
|
109
|
+
description TEXT,
|
|
110
|
+
location TEXT,
|
|
111
|
+
site TEXT,
|
|
112
|
+
strategy TEXT,
|
|
113
|
+
discovered_at TEXT,
|
|
114
|
+
|
|
115
|
+
-- Enrichment stage (detail_scraper)
|
|
116
|
+
full_description TEXT,
|
|
117
|
+
application_url TEXT,
|
|
118
|
+
detail_scraped_at TEXT,
|
|
119
|
+
detail_error TEXT,
|
|
120
|
+
|
|
121
|
+
-- Scoring stage (job_scorer)
|
|
122
|
+
fit_score INTEGER,
|
|
123
|
+
score_reasoning TEXT,
|
|
124
|
+
scored_at TEXT,
|
|
125
|
+
|
|
126
|
+
-- Tailoring stage (resume tailor)
|
|
127
|
+
tailored_resume_path TEXT,
|
|
128
|
+
tailored_at TEXT,
|
|
129
|
+
tailor_attempts INTEGER DEFAULT 0,
|
|
130
|
+
|
|
131
|
+
-- Cover letter stage
|
|
132
|
+
cover_letter_path TEXT,
|
|
133
|
+
cover_letter_at TEXT,
|
|
134
|
+
cover_attempts INTEGER DEFAULT 0,
|
|
135
|
+
|
|
136
|
+
-- Application stage
|
|
137
|
+
applied_at TEXT,
|
|
138
|
+
apply_status TEXT,
|
|
139
|
+
apply_error TEXT,
|
|
140
|
+
apply_attempts INTEGER DEFAULT 0,
|
|
141
|
+
agent_id TEXT,
|
|
142
|
+
last_attempted_at TEXT,
|
|
143
|
+
apply_duration_ms INTEGER,
|
|
144
|
+
apply_task_id TEXT,
|
|
145
|
+
verification_confidence TEXT
|
|
146
|
+
)
|
|
147
|
+
""")
|
|
148
|
+
conn.commit()
|
|
149
|
+
|
|
150
|
+
# Create auxiliary knowledge tables used by the profile layer.
|
|
151
|
+
ensure_coursework_table(conn)
|
|
152
|
+
seed_coursework_if_empty(conn)
|
|
153
|
+
|
|
154
|
+
# Run migrations for any columns added after initial schema
|
|
155
|
+
ensure_columns(conn)
|
|
156
|
+
|
|
157
|
+
return conn
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# Complete column registry: column_name -> SQL type with optional default.
|
|
161
|
+
# This is the single source of truth. Adding a column here is all that's needed
|
|
162
|
+
# for it to appear in both new databases and migrated ones.
|
|
163
|
+
_ALL_COLUMNS: dict[str, str] = {
|
|
164
|
+
# Discovery
|
|
165
|
+
"url": "TEXT PRIMARY KEY",
|
|
166
|
+
"title": "TEXT",
|
|
167
|
+
"salary": "TEXT",
|
|
168
|
+
"description": "TEXT",
|
|
169
|
+
"location": "TEXT",
|
|
170
|
+
"site": "TEXT",
|
|
171
|
+
"strategy": "TEXT",
|
|
172
|
+
"discovered_at": "TEXT",
|
|
173
|
+
# Enrichment
|
|
174
|
+
"full_description": "TEXT",
|
|
175
|
+
"application_url": "TEXT",
|
|
176
|
+
"detail_scraped_at": "TEXT",
|
|
177
|
+
"detail_error": "TEXT",
|
|
178
|
+
# Scoring
|
|
179
|
+
"fit_score": "INTEGER",
|
|
180
|
+
"score_reasoning": "TEXT",
|
|
181
|
+
"scored_at": "TEXT",
|
|
182
|
+
# Tailoring
|
|
183
|
+
"tailored_resume_path": "TEXT",
|
|
184
|
+
"tailored_at": "TEXT",
|
|
185
|
+
"tailor_attempts": "INTEGER DEFAULT 0",
|
|
186
|
+
# Cover letter
|
|
187
|
+
"cover_letter_path": "TEXT",
|
|
188
|
+
"cover_letter_at": "TEXT",
|
|
189
|
+
"cover_attempts": "INTEGER DEFAULT 0",
|
|
190
|
+
# Application
|
|
191
|
+
"applied_at": "TEXT",
|
|
192
|
+
"apply_status": "TEXT",
|
|
193
|
+
"apply_error": "TEXT",
|
|
194
|
+
"apply_attempts": "INTEGER DEFAULT 0",
|
|
195
|
+
"agent_id": "TEXT",
|
|
196
|
+
"last_attempted_at": "TEXT",
|
|
197
|
+
"apply_duration_ms": "INTEGER",
|
|
198
|
+
"apply_task_id": "TEXT",
|
|
199
|
+
"verification_confidence": "TEXT",
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def ensure_columns(conn: sqlite3.Connection | None = None) -> list[str]:
|
|
204
|
+
"""Add any missing columns to the jobs table (forward migration).
|
|
205
|
+
|
|
206
|
+
Reads the current table schema via PRAGMA table_info and compares against
|
|
207
|
+
the full column registry. Any missing columns are added with ALTER TABLE.
|
|
208
|
+
|
|
209
|
+
This makes it safe to upgrade the database from any previous version --
|
|
210
|
+
columns are only added, never removed or renamed.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
conn: Database connection. Uses get_connection() if None.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
List of column names that were added (empty if schema was already current).
|
|
217
|
+
"""
|
|
218
|
+
if conn is None:
|
|
219
|
+
conn = get_connection()
|
|
220
|
+
|
|
221
|
+
existing = {row[1] for row in conn.execute("PRAGMA table_info(jobs)").fetchall()}
|
|
222
|
+
added = []
|
|
223
|
+
|
|
224
|
+
for col, dtype in _ALL_COLUMNS.items():
|
|
225
|
+
if col not in existing:
|
|
226
|
+
# PRIMARY KEY columns can't be added via ALTER TABLE, but url
|
|
227
|
+
# is always created with the table itself so this is safe
|
|
228
|
+
if "PRIMARY KEY" in dtype:
|
|
229
|
+
continue
|
|
230
|
+
conn.execute(f"ALTER TABLE jobs ADD COLUMN {col} {dtype}")
|
|
231
|
+
conn.commit() # commit each column individually so partial crashes don't corrupt schema
|
|
232
|
+
added.append(col)
|
|
233
|
+
|
|
234
|
+
return added
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def ensure_coursework_table(conn: sqlite3.Connection | None = None) -> None:
|
|
238
|
+
"""Create the hidden coursework knowledge table if it does not exist."""
|
|
239
|
+
if conn is None:
|
|
240
|
+
conn = get_connection()
|
|
241
|
+
|
|
242
|
+
conn.execute("""
|
|
243
|
+
CREATE TABLE IF NOT EXISTS coursework (
|
|
244
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
245
|
+
school TEXT,
|
|
246
|
+
course_code TEXT,
|
|
247
|
+
course_title TEXT,
|
|
248
|
+
subject_area TEXT,
|
|
249
|
+
term TEXT,
|
|
250
|
+
status TEXT,
|
|
251
|
+
credits REAL,
|
|
252
|
+
grade TEXT,
|
|
253
|
+
source TEXT,
|
|
254
|
+
notes TEXT,
|
|
255
|
+
skills TEXT,
|
|
256
|
+
raw_text TEXT,
|
|
257
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
258
|
+
)
|
|
259
|
+
""")
|
|
260
|
+
conn.commit()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _infer_course_skills(entry: dict) -> list[str]:
|
|
264
|
+
"""Infer a compact skill tag list from coursework metadata."""
|
|
265
|
+
text = " ".join(
|
|
266
|
+
str(entry.get(key, "")).lower()
|
|
267
|
+
for key in ("school", "course_code", "course_title", "subject_area", "notes", "raw_text")
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
rules: list[tuple[tuple[str, ...], list[str]]] = [
|
|
271
|
+
(("accounting", "taxation", "finance", "financial"), ["accounting", "financial records", "reconciliation"]),
|
|
272
|
+
(("business communications", "business english", "composition", "technical writing", "writing"), ["professional writing", "documentation", "communication"]),
|
|
273
|
+
(("information systems", "computer", "information technology", "is 101"), ["information systems", "digital workflows", "business software"]),
|
|
274
|
+
(("marketing",), ["marketing", "customer awareness", "communication"]),
|
|
275
|
+
(("management", "operations", "entrepreneurship", "legal environment", "personal finance"), ["business operations", "management", "compliance", "planning"]),
|
|
276
|
+
(("public health", "health science", "health and wellness", "community health", "epidemiology", "disease", "public health administration", "community and environmental health", "health education", "substance abuse"), ["public health", "health education", "community services", "program support"]),
|
|
277
|
+
(("nutrition", "medical terminology", "anatomy", "physiology", "disability"), ["health literacy", "human services", "wellness"]),
|
|
278
|
+
(("biostat", "statistics"), ["data analysis", "statistics", "research literacy"]),
|
|
279
|
+
(("biology", "chemistry"), ["scientific reasoning", "lab methods", "quantitative reasoning"]),
|
|
280
|
+
(("psychology", "human diversity", "history", "culture", "arts", "music", "new testament"), ["interpersonal communication", "cultural awareness", "behavioral insight"]),
|
|
281
|
+
(("precalculus", "math"), ["quantitative reasoning", "problem solving"]),
|
|
282
|
+
(("swimming", "strength training"), ["physical fitness", "discipline", "wellness"]),
|
|
283
|
+
(("nevadafit", "welcome"), ["orientation", "college readiness", "self-management"]),
|
|
284
|
+
]
|
|
285
|
+
|
|
286
|
+
tags: list[str] = []
|
|
287
|
+
for needles, inferred in rules:
|
|
288
|
+
if any(needle in text for needle in needles):
|
|
289
|
+
tags.extend(inferred)
|
|
290
|
+
|
|
291
|
+
if not tags:
|
|
292
|
+
tags = ["academic knowledge", "general problem solving"]
|
|
293
|
+
|
|
294
|
+
seen: set[str] = set()
|
|
295
|
+
ordered: list[str] = []
|
|
296
|
+
for tag in tags:
|
|
297
|
+
if tag not in seen:
|
|
298
|
+
seen.add(tag)
|
|
299
|
+
ordered.append(tag)
|
|
300
|
+
return ordered[:6]
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _normalize_coursework_entry(entry: dict, now: str) -> dict:
|
|
304
|
+
"""Normalize one coursework row before persisting it."""
|
|
305
|
+
normalized = {
|
|
306
|
+
"school": entry.get("school"),
|
|
307
|
+
"course_code": entry.get("course_code"),
|
|
308
|
+
"course_title": entry.get("course_title"),
|
|
309
|
+
"subject_area": entry.get("subject_area"),
|
|
310
|
+
"term": entry.get("term"),
|
|
311
|
+
"status": entry.get("status") or entry.get("course_status"),
|
|
312
|
+
"credits": entry.get("credits"),
|
|
313
|
+
"grade": entry.get("grade"),
|
|
314
|
+
"source": entry.get("source"),
|
|
315
|
+
"notes": entry.get("notes"),
|
|
316
|
+
"skills": entry.get("skills"),
|
|
317
|
+
"raw_text": entry.get("raw_text"),
|
|
318
|
+
"created_at": entry.get("created_at") or now,
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if not normalized["skills"]:
|
|
322
|
+
normalized["skills"] = json.dumps(_infer_course_skills(normalized), ensure_ascii=True)
|
|
323
|
+
elif isinstance(normalized["skills"], list):
|
|
324
|
+
normalized["skills"] = json.dumps(normalized["skills"], ensure_ascii=True)
|
|
325
|
+
|
|
326
|
+
return normalized
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _seed_coursework_path() -> Path:
|
|
330
|
+
return Path(__file__).parent / "config" / "coursework.seed.json"
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def load_coursework_seed() -> list[dict]:
|
|
334
|
+
"""Load bundled coursework seed data from the package config directory."""
|
|
335
|
+
path = _seed_coursework_path()
|
|
336
|
+
if not path.exists():
|
|
337
|
+
return []
|
|
338
|
+
try:
|
|
339
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
340
|
+
except json.JSONDecodeError:
|
|
341
|
+
return []
|
|
342
|
+
if isinstance(data, dict):
|
|
343
|
+
data = data.get("coursework") or data.get("courses") or []
|
|
344
|
+
return [row for row in data if isinstance(row, dict)]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def seed_coursework_if_empty(conn: sqlite3.Connection | None = None) -> int:
|
|
348
|
+
"""Populate coursework knowledge from the bundled seed file if empty."""
|
|
349
|
+
if conn is None:
|
|
350
|
+
conn = get_connection()
|
|
351
|
+
|
|
352
|
+
ensure_coursework_table(conn)
|
|
353
|
+
count = conn.execute("SELECT COUNT(*) FROM coursework").fetchone()[0]
|
|
354
|
+
if count:
|
|
355
|
+
return 0
|
|
356
|
+
|
|
357
|
+
seed_rows = load_coursework_seed()
|
|
358
|
+
if not seed_rows:
|
|
359
|
+
return 0
|
|
360
|
+
|
|
361
|
+
return replace_coursework(seed_rows, conn=conn)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def replace_coursework(entries: list[dict], conn: sqlite3.Connection | None = None) -> int:
|
|
365
|
+
"""Replace all stored coursework rows with a new set of entries."""
|
|
366
|
+
if conn is None:
|
|
367
|
+
conn = get_connection()
|
|
368
|
+
|
|
369
|
+
ensure_coursework_table(conn)
|
|
370
|
+
conn.execute("DELETE FROM coursework")
|
|
371
|
+
|
|
372
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
373
|
+
inserted = 0
|
|
374
|
+
for entry in entries:
|
|
375
|
+
normalized = _normalize_coursework_entry(entry, now)
|
|
376
|
+
conn.execute(
|
|
377
|
+
"""
|
|
378
|
+
INSERT INTO coursework (
|
|
379
|
+
school, course_code, course_title, subject_area,
|
|
380
|
+
term, status, credits, grade, source, notes, skills, raw_text, created_at
|
|
381
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
382
|
+
""",
|
|
383
|
+
(
|
|
384
|
+
normalized.get("school"),
|
|
385
|
+
normalized.get("course_code"),
|
|
386
|
+
normalized.get("course_title"),
|
|
387
|
+
normalized.get("subject_area"),
|
|
388
|
+
normalized.get("term"),
|
|
389
|
+
normalized.get("status"),
|
|
390
|
+
normalized.get("credits"),
|
|
391
|
+
normalized.get("grade"),
|
|
392
|
+
normalized.get("source"),
|
|
393
|
+
normalized.get("notes"),
|
|
394
|
+
normalized.get("skills"),
|
|
395
|
+
normalized.get("raw_text"),
|
|
396
|
+
normalized.get("created_at"),
|
|
397
|
+
),
|
|
398
|
+
)
|
|
399
|
+
inserted += 1
|
|
400
|
+
|
|
401
|
+
conn.commit()
|
|
402
|
+
return inserted
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def get_coursework(conn: sqlite3.Connection | None = None) -> list[dict]:
|
|
406
|
+
"""Return stored coursework rows sorted newest-last within each school."""
|
|
407
|
+
if conn is None:
|
|
408
|
+
conn = get_connection()
|
|
409
|
+
|
|
410
|
+
ensure_coursework_table(conn)
|
|
411
|
+
rows = conn.execute(
|
|
412
|
+
"""
|
|
413
|
+
SELECT school, course_code, course_title, subject_area, term, status, credits,
|
|
414
|
+
grade, source, notes, skills, raw_text, created_at
|
|
415
|
+
FROM coursework
|
|
416
|
+
ORDER BY school, term, course_title
|
|
417
|
+
"""
|
|
418
|
+
).fetchall()
|
|
419
|
+
|
|
420
|
+
if not rows:
|
|
421
|
+
return []
|
|
422
|
+
|
|
423
|
+
columns = rows[0].keys()
|
|
424
|
+
return [dict(zip(columns, row)) for row in rows]
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def get_stats(conn: sqlite3.Connection | None = None) -> dict:
|
|
428
|
+
"""Return job counts by pipeline stage.
|
|
429
|
+
|
|
430
|
+
Provides a snapshot of how many jobs are at each stage, useful for
|
|
431
|
+
dashboard display and pipeline progress tracking.
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
conn: Database connection. Uses get_connection() if None.
|
|
435
|
+
|
|
436
|
+
Returns:
|
|
437
|
+
Dictionary with keys:
|
|
438
|
+
total, by_site, pending_detail, with_description,
|
|
439
|
+
scored, unscored, tailored, untailored_eligible,
|
|
440
|
+
with_cover_letter, applied, score_distribution
|
|
441
|
+
"""
|
|
442
|
+
if conn is None:
|
|
443
|
+
conn = get_connection()
|
|
444
|
+
|
|
445
|
+
stats: dict = {}
|
|
446
|
+
|
|
447
|
+
# Total jobs
|
|
448
|
+
stats["total"] = conn.execute("SELECT COUNT(*) FROM jobs").fetchone()[0]
|
|
449
|
+
|
|
450
|
+
# By site breakdown
|
|
451
|
+
rows = conn.execute(
|
|
452
|
+
"SELECT site, COUNT(*) as cnt FROM jobs GROUP BY site ORDER BY cnt DESC"
|
|
453
|
+
).fetchall()
|
|
454
|
+
stats["by_site"] = [(row[0], row[1]) for row in rows]
|
|
455
|
+
|
|
456
|
+
# Enrichment stage
|
|
457
|
+
stats["pending_detail"] = conn.execute(
|
|
458
|
+
"SELECT COUNT(*) FROM jobs WHERE detail_scraped_at IS NULL"
|
|
459
|
+
).fetchone()[0]
|
|
460
|
+
|
|
461
|
+
stats["with_description"] = conn.execute(
|
|
462
|
+
"SELECT COUNT(*) FROM jobs WHERE full_description IS NOT NULL"
|
|
463
|
+
).fetchone()[0]
|
|
464
|
+
|
|
465
|
+
stats["detail_errors"] = conn.execute(
|
|
466
|
+
"SELECT COUNT(*) FROM jobs WHERE detail_error IS NOT NULL"
|
|
467
|
+
).fetchone()[0]
|
|
468
|
+
|
|
469
|
+
# Scoring stage
|
|
470
|
+
stats["scored"] = conn.execute(
|
|
471
|
+
"SELECT COUNT(*) FROM jobs WHERE fit_score IS NOT NULL"
|
|
472
|
+
).fetchone()[0]
|
|
473
|
+
|
|
474
|
+
stats["unscored"] = conn.execute(
|
|
475
|
+
"SELECT COUNT(*) FROM jobs "
|
|
476
|
+
"WHERE full_description IS NOT NULL AND fit_score IS NULL"
|
|
477
|
+
).fetchone()[0]
|
|
478
|
+
|
|
479
|
+
# Score distribution
|
|
480
|
+
dist_rows = conn.execute(
|
|
481
|
+
"SELECT fit_score, COUNT(*) as cnt FROM jobs "
|
|
482
|
+
"WHERE fit_score IS NOT NULL "
|
|
483
|
+
"GROUP BY fit_score ORDER BY fit_score DESC"
|
|
484
|
+
).fetchall()
|
|
485
|
+
stats["score_distribution"] = [(row[0], row[1]) for row in dist_rows]
|
|
486
|
+
|
|
487
|
+
# Tailoring stage
|
|
488
|
+
stats["tailored"] = conn.execute(
|
|
489
|
+
"SELECT COUNT(*) FROM jobs WHERE tailored_resume_path IS NOT NULL"
|
|
490
|
+
).fetchone()[0]
|
|
491
|
+
|
|
492
|
+
stats["untailored_eligible"] = conn.execute(
|
|
493
|
+
"SELECT COUNT(*) FROM jobs "
|
|
494
|
+
"WHERE fit_score >= 7 AND full_description IS NOT NULL "
|
|
495
|
+
"AND tailored_resume_path IS NULL"
|
|
496
|
+
).fetchone()[0]
|
|
497
|
+
|
|
498
|
+
stats["tailor_exhausted"] = conn.execute(
|
|
499
|
+
"SELECT COUNT(*) FROM jobs "
|
|
500
|
+
"WHERE COALESCE(tailor_attempts, 0) >= 5 "
|
|
501
|
+
"AND tailored_resume_path IS NULL"
|
|
502
|
+
).fetchone()[0]
|
|
503
|
+
|
|
504
|
+
# Cover letter stage
|
|
505
|
+
stats["with_cover_letter"] = conn.execute(
|
|
506
|
+
"SELECT COUNT(*) FROM jobs WHERE cover_letter_path IS NOT NULL"
|
|
507
|
+
).fetchone()[0]
|
|
508
|
+
|
|
509
|
+
stats["cover_exhausted"] = conn.execute(
|
|
510
|
+
"SELECT COUNT(*) FROM jobs "
|
|
511
|
+
"WHERE COALESCE(cover_attempts, 0) >= 5 "
|
|
512
|
+
"AND (cover_letter_path IS NULL OR cover_letter_path = '')"
|
|
513
|
+
).fetchone()[0]
|
|
514
|
+
|
|
515
|
+
# Application stage
|
|
516
|
+
stats["applied"] = conn.execute(
|
|
517
|
+
"SELECT COUNT(*) FROM jobs WHERE applied_at IS NOT NULL"
|
|
518
|
+
).fetchone()[0]
|
|
519
|
+
|
|
520
|
+
stats["apply_errors"] = conn.execute(
|
|
521
|
+
"SELECT COUNT(*) FROM jobs WHERE apply_error IS NOT NULL"
|
|
522
|
+
).fetchone()[0]
|
|
523
|
+
|
|
524
|
+
stats["ready_to_apply"] = conn.execute(
|
|
525
|
+
"SELECT COUNT(*) FROM jobs "
|
|
526
|
+
"WHERE tailored_resume_path IS NOT NULL "
|
|
527
|
+
"AND applied_at IS NULL "
|
|
528
|
+
"AND application_url IS NOT NULL"
|
|
529
|
+
).fetchone()[0]
|
|
530
|
+
|
|
531
|
+
return stats
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def store_jobs(conn: sqlite3.Connection, jobs: list[dict],
|
|
535
|
+
site: str, strategy: str) -> tuple[int, int]:
|
|
536
|
+
"""Store discovered jobs, skipping duplicates by URL.
|
|
537
|
+
|
|
538
|
+
Args:
|
|
539
|
+
conn: Database connection.
|
|
540
|
+
jobs: List of job dicts with keys: url, title, salary, description, location.
|
|
541
|
+
site: Source site name (e.g. "RemoteOK", "Dice").
|
|
542
|
+
strategy: Extraction strategy used (e.g. "json_ld", "api_response", "css_selectors").
|
|
543
|
+
|
|
544
|
+
Returns:
|
|
545
|
+
Tuple of (new_count, duplicate_count).
|
|
546
|
+
"""
|
|
547
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
548
|
+
new = 0
|
|
549
|
+
existing = 0
|
|
550
|
+
|
|
551
|
+
for job in jobs:
|
|
552
|
+
url = job.get("url")
|
|
553
|
+
if not url:
|
|
554
|
+
continue
|
|
555
|
+
try:
|
|
556
|
+
conn.execute(
|
|
557
|
+
"INSERT INTO jobs (url, title, salary, description, location, site, strategy, discovered_at) "
|
|
558
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
559
|
+
(url, job.get("title"), job.get("salary"), job.get("description"),
|
|
560
|
+
job.get("location"), site, strategy, now),
|
|
561
|
+
)
|
|
562
|
+
new += 1
|
|
563
|
+
except sqlite3.IntegrityError:
|
|
564
|
+
existing += 1
|
|
565
|
+
|
|
566
|
+
conn.commit()
|
|
567
|
+
return new, existing
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def get_jobs_by_stage(conn: sqlite3.Connection | None = None,
|
|
571
|
+
stage: str = "discovered",
|
|
572
|
+
min_score: int | None = None,
|
|
573
|
+
limit: int = 100) -> list[dict]:
|
|
574
|
+
"""Fetch jobs filtered by pipeline stage.
|
|
575
|
+
|
|
576
|
+
Args:
|
|
577
|
+
conn: Database connection. Uses get_connection() if None.
|
|
578
|
+
stage: One of "discovered", "enriched", "scored", "tailored", "applied".
|
|
579
|
+
min_score: Minimum fit_score filter (only relevant for scored+ stages).
|
|
580
|
+
limit: Maximum number of rows to return.
|
|
581
|
+
|
|
582
|
+
Returns:
|
|
583
|
+
List of job dicts.
|
|
584
|
+
"""
|
|
585
|
+
if conn is None:
|
|
586
|
+
conn = get_connection()
|
|
587
|
+
|
|
588
|
+
conditions = {
|
|
589
|
+
"discovered": "1=1",
|
|
590
|
+
"pending_detail": "detail_scraped_at IS NULL",
|
|
591
|
+
"enriched": "full_description IS NOT NULL",
|
|
592
|
+
"pending_score": "full_description IS NOT NULL AND fit_score IS NULL",
|
|
593
|
+
"scored": "fit_score IS NOT NULL",
|
|
594
|
+
"pending_tailor": (
|
|
595
|
+
"fit_score >= ? AND full_description IS NOT NULL "
|
|
596
|
+
"AND tailored_resume_path IS NULL AND COALESCE(tailor_attempts, 0) < 5"
|
|
597
|
+
),
|
|
598
|
+
"tailored": "tailored_resume_path IS NOT NULL",
|
|
599
|
+
"pending_cover": (
|
|
600
|
+
"fit_score >= ? AND full_description IS NOT NULL "
|
|
601
|
+
"AND tailored_resume_path IS NOT NULL AND cover_letter_path IS NULL "
|
|
602
|
+
"AND COALESCE(cover_attempts, 0) < 5"
|
|
603
|
+
),
|
|
604
|
+
"pending_apply": (
|
|
605
|
+
"tailored_resume_path IS NOT NULL AND applied_at IS NULL "
|
|
606
|
+
"AND application_url IS NOT NULL"
|
|
607
|
+
),
|
|
608
|
+
"applied": "applied_at IS NOT NULL",
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
where = conditions.get(stage, "1=1")
|
|
612
|
+
params: list = []
|
|
613
|
+
|
|
614
|
+
if "?" in where and min_score is not None:
|
|
615
|
+
params.append(min_score)
|
|
616
|
+
elif "?" in where:
|
|
617
|
+
params.append(7) # default min_score
|
|
618
|
+
|
|
619
|
+
if min_score is not None and "fit_score" not in where and stage in ("scored", "tailored", "applied"):
|
|
620
|
+
where += " AND fit_score >= ?"
|
|
621
|
+
params.append(min_score)
|
|
622
|
+
|
|
623
|
+
query = f"SELECT * FROM jobs WHERE {where} ORDER BY CASE WHEN fit_score IS NULL THEN 1 ELSE 0 END, fit_score DESC, discovered_at DESC"
|
|
624
|
+
if limit > 0:
|
|
625
|
+
query += " LIMIT ?"
|
|
626
|
+
params.append(limit)
|
|
627
|
+
|
|
628
|
+
rows = conn.execute(query, params).fetchall()
|
|
629
|
+
|
|
630
|
+
# Convert sqlite3.Row objects to dicts
|
|
631
|
+
if rows:
|
|
632
|
+
columns = rows[0].keys()
|
|
633
|
+
return [dict(zip(columns, row)) for row in rows]
|
|
634
|
+
return []
|
|
635
|
+
|
|
File without changes
|