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/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