App: Peregrine Company: Circuit Forge LLC Source: github.com/pyr0ball/job-seeker (personal fork, not linked)
933 lines
32 KiB
Markdown
933 lines
32 KiB
Markdown
# Background Task Processing Implementation Plan
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||
|
||
**Goal:** Replace synchronous LLM calls in Apply and Interview Prep pages with background threads so cover letter and research generation survive page navigation.
|
||
|
||
**Architecture:** A new `background_tasks` SQLite table tracks task state. `scripts/task_runner.py` spawns daemon threads that call existing generator functions and write results via existing DB helpers. The Streamlit sidebar polls active tasks every 3s via `@st.fragment(run_every=3)`; individual pages show per-job status with the same pattern.
|
||
|
||
**Tech Stack:** Python `threading` (stdlib), SQLite, Streamlit `st.fragment` (≥1.33 — already installed)
|
||
|
||
---
|
||
|
||
## Task 1: Add background_tasks table and DB helpers
|
||
|
||
**Files:**
|
||
- Modify: `scripts/db.py`
|
||
- Test: `tests/test_db.py`
|
||
|
||
### Step 1: Write the failing tests
|
||
|
||
Add to `tests/test_db.py`:
|
||
|
||
```python
|
||
# ── background_tasks tests ────────────────────────────────────────────────────
|
||
|
||
def test_init_db_creates_background_tasks_table(tmp_path):
|
||
"""init_db creates a background_tasks table."""
|
||
from scripts.db import init_db
|
||
db_path = tmp_path / "test.db"
|
||
init_db(db_path)
|
||
import sqlite3
|
||
conn = sqlite3.connect(db_path)
|
||
cur = conn.execute(
|
||
"SELECT name FROM sqlite_master WHERE type='table' AND name='background_tasks'"
|
||
)
|
||
assert cur.fetchone() is not None
|
||
conn.close()
|
||
|
||
|
||
def test_insert_task_returns_id_and_true(tmp_path):
|
||
"""insert_task returns (task_id, True) for a new task."""
|
||
from scripts.db import init_db, insert_job, insert_task
|
||
db_path = tmp_path / "test.db"
|
||
init_db(db_path)
|
||
job_id = insert_job(db_path, {
|
||
"title": "CSM", "company": "Acme", "url": "https://ex.com/1",
|
||
"source": "linkedin", "location": "Remote", "is_remote": True,
|
||
"salary": "", "description": "", "date_found": "2026-02-20",
|
||
})
|
||
task_id, is_new = insert_task(db_path, "cover_letter", job_id)
|
||
assert isinstance(task_id, int) and task_id > 0
|
||
assert is_new is True
|
||
|
||
|
||
def test_insert_task_deduplicates_active_task(tmp_path):
|
||
"""insert_task returns (existing_id, False) if a queued/running task already exists."""
|
||
from scripts.db import init_db, insert_job, insert_task
|
||
db_path = tmp_path / "test.db"
|
||
init_db(db_path)
|
||
job_id = insert_job(db_path, {
|
||
"title": "CSM", "company": "Acme", "url": "https://ex.com/1",
|
||
"source": "linkedin", "location": "Remote", "is_remote": True,
|
||
"salary": "", "description": "", "date_found": "2026-02-20",
|
||
})
|
||
first_id, _ = insert_task(db_path, "cover_letter", job_id)
|
||
second_id, is_new = insert_task(db_path, "cover_letter", job_id)
|
||
assert second_id == first_id
|
||
assert is_new is False
|
||
|
||
|
||
def test_insert_task_allows_different_types_same_job(tmp_path):
|
||
"""insert_task allows cover_letter and company_research for the same job concurrently."""
|
||
from scripts.db import init_db, insert_job, insert_task
|
||
db_path = tmp_path / "test.db"
|
||
init_db(db_path)
|
||
job_id = insert_job(db_path, {
|
||
"title": "CSM", "company": "Acme", "url": "https://ex.com/1",
|
||
"source": "linkedin", "location": "Remote", "is_remote": True,
|
||
"salary": "", "description": "", "date_found": "2026-02-20",
|
||
})
|
||
_, cl_new = insert_task(db_path, "cover_letter", job_id)
|
||
_, res_new = insert_task(db_path, "company_research", job_id)
|
||
assert cl_new is True
|
||
assert res_new is True
|
||
|
||
|
||
def test_update_task_status_running(tmp_path):
|
||
"""update_task_status('running') sets started_at."""
|
||
from scripts.db import init_db, insert_job, insert_task, update_task_status
|
||
import sqlite3
|
||
db_path = tmp_path / "test.db"
|
||
init_db(db_path)
|
||
job_id = insert_job(db_path, {
|
||
"title": "CSM", "company": "Acme", "url": "https://ex.com/1",
|
||
"source": "linkedin", "location": "Remote", "is_remote": True,
|
||
"salary": "", "description": "", "date_found": "2026-02-20",
|
||
})
|
||
task_id, _ = insert_task(db_path, "cover_letter", job_id)
|
||
update_task_status(db_path, task_id, "running")
|
||
conn = sqlite3.connect(db_path)
|
||
row = conn.execute("SELECT status, started_at FROM background_tasks WHERE id=?", (task_id,)).fetchone()
|
||
conn.close()
|
||
assert row[0] == "running"
|
||
assert row[1] is not None
|
||
|
||
|
||
def test_update_task_status_completed(tmp_path):
|
||
"""update_task_status('completed') sets finished_at."""
|
||
from scripts.db import init_db, insert_job, insert_task, update_task_status
|
||
import sqlite3
|
||
db_path = tmp_path / "test.db"
|
||
init_db(db_path)
|
||
job_id = insert_job(db_path, {
|
||
"title": "CSM", "company": "Acme", "url": "https://ex.com/1",
|
||
"source": "linkedin", "location": "Remote", "is_remote": True,
|
||
"salary": "", "description": "", "date_found": "2026-02-20",
|
||
})
|
||
task_id, _ = insert_task(db_path, "cover_letter", job_id)
|
||
update_task_status(db_path, task_id, "completed")
|
||
conn = sqlite3.connect(db_path)
|
||
row = conn.execute("SELECT status, finished_at FROM background_tasks WHERE id=?", (task_id,)).fetchone()
|
||
conn.close()
|
||
assert row[0] == "completed"
|
||
assert row[1] is not None
|
||
|
||
|
||
def test_update_task_status_failed_stores_error(tmp_path):
|
||
"""update_task_status('failed') stores error message and sets finished_at."""
|
||
from scripts.db import init_db, insert_job, insert_task, update_task_status
|
||
import sqlite3
|
||
db_path = tmp_path / "test.db"
|
||
init_db(db_path)
|
||
job_id = insert_job(db_path, {
|
||
"title": "CSM", "company": "Acme", "url": "https://ex.com/1",
|
||
"source": "linkedin", "location": "Remote", "is_remote": True,
|
||
"salary": "", "description": "", "date_found": "2026-02-20",
|
||
})
|
||
task_id, _ = insert_task(db_path, "cover_letter", job_id)
|
||
update_task_status(db_path, task_id, "failed", error="LLM timeout")
|
||
conn = sqlite3.connect(db_path)
|
||
row = conn.execute("SELECT status, error, finished_at FROM background_tasks WHERE id=?", (task_id,)).fetchone()
|
||
conn.close()
|
||
assert row[0] == "failed"
|
||
assert row[1] == "LLM timeout"
|
||
assert row[2] is not None
|
||
|
||
|
||
def test_get_active_tasks_returns_only_active(tmp_path):
|
||
"""get_active_tasks returns only queued/running tasks with job info joined."""
|
||
from scripts.db import init_db, insert_job, insert_task, update_task_status, get_active_tasks
|
||
db_path = tmp_path / "test.db"
|
||
init_db(db_path)
|
||
job_id = insert_job(db_path, {
|
||
"title": "CSM", "company": "Acme", "url": "https://ex.com/1",
|
||
"source": "linkedin", "location": "Remote", "is_remote": True,
|
||
"salary": "", "description": "", "date_found": "2026-02-20",
|
||
})
|
||
active_id, _ = insert_task(db_path, "cover_letter", job_id)
|
||
done_id, _ = insert_task(db_path, "company_research", job_id)
|
||
update_task_status(db_path, done_id, "completed")
|
||
|
||
tasks = get_active_tasks(db_path)
|
||
assert len(tasks) == 1
|
||
assert tasks[0]["id"] == active_id
|
||
assert tasks[0]["company"] == "Acme"
|
||
assert tasks[0]["title"] == "CSM"
|
||
|
||
|
||
def test_get_task_for_job_returns_latest(tmp_path):
|
||
"""get_task_for_job returns the most recent task for the given type+job."""
|
||
from scripts.db import init_db, insert_job, insert_task, update_task_status, get_task_for_job
|
||
db_path = tmp_path / "test.db"
|
||
init_db(db_path)
|
||
job_id = insert_job(db_path, {
|
||
"title": "CSM", "company": "Acme", "url": "https://ex.com/1",
|
||
"source": "linkedin", "location": "Remote", "is_remote": True,
|
||
"salary": "", "description": "", "date_found": "2026-02-20",
|
||
})
|
||
first_id, _ = insert_task(db_path, "cover_letter", job_id)
|
||
update_task_status(db_path, first_id, "completed")
|
||
second_id, _ = insert_task(db_path, "cover_letter", job_id) # allowed since first is done
|
||
|
||
task = get_task_for_job(db_path, "cover_letter", job_id)
|
||
assert task is not None
|
||
assert task["id"] == second_id
|
||
|
||
|
||
def test_get_task_for_job_returns_none_when_absent(tmp_path):
|
||
"""get_task_for_job returns None when no task exists for that job+type."""
|
||
from scripts.db import init_db, insert_job, get_task_for_job
|
||
db_path = tmp_path / "test.db"
|
||
init_db(db_path)
|
||
job_id = insert_job(db_path, {
|
||
"title": "CSM", "company": "Acme", "url": "https://ex.com/1",
|
||
"source": "linkedin", "location": "Remote", "is_remote": True,
|
||
"salary": "", "description": "", "date_found": "2026-02-20",
|
||
})
|
||
assert get_task_for_job(db_path, "cover_letter", job_id) is None
|
||
```
|
||
|
||
### Step 2: Run tests to verify they fail
|
||
|
||
```bash
|
||
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_db.py -v -k "background_tasks or insert_task or update_task_status or get_active_tasks or get_task_for_job"
|
||
```
|
||
|
||
Expected: FAIL with `ImportError: cannot import name 'insert_task'`
|
||
|
||
### Step 3: Implement in scripts/db.py
|
||
|
||
Add the DDL constant after `CREATE_COMPANY_RESEARCH`:
|
||
|
||
```python
|
||
CREATE_BACKGROUND_TASKS = """
|
||
CREATE TABLE IF NOT EXISTS background_tasks (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
task_type TEXT NOT NULL,
|
||
job_id INTEGER NOT NULL,
|
||
status TEXT NOT NULL DEFAULT 'queued',
|
||
error TEXT,
|
||
created_at DATETIME DEFAULT (datetime('now')),
|
||
started_at DATETIME,
|
||
finished_at DATETIME
|
||
)
|
||
"""
|
||
```
|
||
|
||
Add `conn.execute(CREATE_BACKGROUND_TASKS)` inside `init_db()`, after the existing three `conn.execute()` calls:
|
||
|
||
```python
|
||
def init_db(db_path: Path = DEFAULT_DB) -> None:
|
||
"""Create tables if they don't exist, then run migrations."""
|
||
conn = sqlite3.connect(db_path)
|
||
conn.execute(CREATE_JOBS)
|
||
conn.execute(CREATE_JOB_CONTACTS)
|
||
conn.execute(CREATE_COMPANY_RESEARCH)
|
||
conn.execute(CREATE_BACKGROUND_TASKS) # ← add this line
|
||
conn.commit()
|
||
conn.close()
|
||
_migrate_db(db_path)
|
||
```
|
||
|
||
Add the four helper functions at the end of `scripts/db.py`:
|
||
|
||
```python
|
||
# ── Background task helpers ───────────────────────────────────────────────────
|
||
|
||
def insert_task(db_path: Path = DEFAULT_DB, task_type: str = "",
|
||
job_id: int = None) -> tuple[int, bool]:
|
||
"""Insert a new background task.
|
||
|
||
Returns (task_id, True) if inserted, or (existing_id, False) if a
|
||
queued/running task for the same (task_type, job_id) already exists.
|
||
"""
|
||
conn = sqlite3.connect(db_path)
|
||
existing = conn.execute(
|
||
"SELECT id FROM background_tasks WHERE task_type=? AND job_id=? AND status IN ('queued','running')",
|
||
(task_type, job_id),
|
||
).fetchone()
|
||
if existing:
|
||
conn.close()
|
||
return existing[0], False
|
||
cur = conn.execute(
|
||
"INSERT INTO background_tasks (task_type, job_id, status) VALUES (?, ?, 'queued')",
|
||
(task_type, job_id),
|
||
)
|
||
task_id = cur.lastrowid
|
||
conn.commit()
|
||
conn.close()
|
||
return task_id, True
|
||
|
||
|
||
def update_task_status(db_path: Path = DEFAULT_DB, task_id: int = None,
|
||
status: str = "", error: Optional[str] = None) -> None:
|
||
"""Update a task's status and set the appropriate timestamp."""
|
||
now = datetime.now().isoformat()[:16]
|
||
conn = sqlite3.connect(db_path)
|
||
if status == "running":
|
||
conn.execute(
|
||
"UPDATE background_tasks SET status=?, started_at=? WHERE id=?",
|
||
(status, now, task_id),
|
||
)
|
||
elif status in ("completed", "failed"):
|
||
conn.execute(
|
||
"UPDATE background_tasks SET status=?, finished_at=?, error=? WHERE id=?",
|
||
(status, now, error, task_id),
|
||
)
|
||
else:
|
||
conn.execute("UPDATE background_tasks SET status=? WHERE id=?", (status, task_id))
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
|
||
def get_active_tasks(db_path: Path = DEFAULT_DB) -> list[dict]:
|
||
"""Return all queued/running tasks with job title and company joined in."""
|
||
conn = sqlite3.connect(db_path)
|
||
conn.row_factory = sqlite3.Row
|
||
rows = conn.execute("""
|
||
SELECT bt.*, j.title, j.company
|
||
FROM background_tasks bt
|
||
LEFT JOIN jobs j ON j.id = bt.job_id
|
||
WHERE bt.status IN ('queued', 'running')
|
||
ORDER BY bt.created_at ASC
|
||
""").fetchall()
|
||
conn.close()
|
||
return [dict(r) for r in rows]
|
||
|
||
|
||
def get_task_for_job(db_path: Path = DEFAULT_DB, task_type: str = "",
|
||
job_id: int = None) -> Optional[dict]:
|
||
"""Return the most recent task row for a (task_type, job_id) pair, or None."""
|
||
conn = sqlite3.connect(db_path)
|
||
conn.row_factory = sqlite3.Row
|
||
row = conn.execute(
|
||
"""SELECT * FROM background_tasks
|
||
WHERE task_type=? AND job_id=?
|
||
ORDER BY id DESC LIMIT 1""",
|
||
(task_type, job_id),
|
||
).fetchone()
|
||
conn.close()
|
||
return dict(row) if row else None
|
||
```
|
||
|
||
### Step 4: Run tests to verify they pass
|
||
|
||
```bash
|
||
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_db.py -v -k "background_tasks or insert_task or update_task_status or get_active_tasks or get_task_for_job"
|
||
```
|
||
|
||
Expected: all new tests PASS, no regressions
|
||
|
||
### Step 5: Run full test suite
|
||
|
||
```bash
|
||
/devl/miniconda3/envs/job-seeker/bin/pytest tests/ -v
|
||
```
|
||
|
||
Expected: all tests PASS
|
||
|
||
### Step 6: Commit
|
||
|
||
```bash
|
||
git add scripts/db.py tests/test_db.py
|
||
git commit -m "feat: add background_tasks table and DB helpers"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Create scripts/task_runner.py
|
||
|
||
**Files:**
|
||
- Create: `scripts/task_runner.py`
|
||
- Test: `tests/test_task_runner.py`
|
||
|
||
### Step 1: Write the failing tests
|
||
|
||
Create `tests/test_task_runner.py`:
|
||
|
||
```python
|
||
import threading
|
||
import time
|
||
import pytest
|
||
from pathlib import Path
|
||
from unittest.mock import patch, MagicMock
|
||
import sqlite3
|
||
|
||
|
||
def _make_db(tmp_path):
|
||
from scripts.db import init_db, insert_job
|
||
db = tmp_path / "test.db"
|
||
init_db(db)
|
||
job_id = insert_job(db, {
|
||
"title": "CSM", "company": "Acme", "url": "https://ex.com/1",
|
||
"source": "linkedin", "location": "Remote", "is_remote": True,
|
||
"salary": "", "description": "Great role.", "date_found": "2026-02-20",
|
||
})
|
||
return db, job_id
|
||
|
||
|
||
def test_submit_task_returns_id_and_true(tmp_path):
|
||
"""submit_task returns (task_id, True) and spawns a thread."""
|
||
db, job_id = _make_db(tmp_path)
|
||
with patch("scripts.task_runner._run_task"): # don't actually call LLM
|
||
from scripts.task_runner import submit_task
|
||
task_id, is_new = submit_task(db, "cover_letter", job_id)
|
||
assert isinstance(task_id, int) and task_id > 0
|
||
assert is_new is True
|
||
|
||
|
||
def test_submit_task_deduplicates(tmp_path):
|
||
"""submit_task returns (existing_id, False) for a duplicate in-flight task."""
|
||
db, job_id = _make_db(tmp_path)
|
||
with patch("scripts.task_runner._run_task"):
|
||
from scripts.task_runner import submit_task
|
||
first_id, _ = submit_task(db, "cover_letter", job_id)
|
||
second_id, is_new = submit_task(db, "cover_letter", job_id)
|
||
assert second_id == first_id
|
||
assert is_new is False
|
||
|
||
|
||
def test_run_task_cover_letter_success(tmp_path):
|
||
"""_run_task marks running→completed and saves cover letter to DB."""
|
||
db, job_id = _make_db(tmp_path)
|
||
from scripts.db import insert_task, get_task_for_job, get_jobs_by_status
|
||
task_id, _ = insert_task(db, "cover_letter", job_id)
|
||
|
||
with patch("scripts.generate_cover_letter.generate", return_value="Dear Hiring Manager,\nGreat fit!"):
|
||
from scripts.task_runner import _run_task
|
||
_run_task(db, task_id, "cover_letter", job_id)
|
||
|
||
task = get_task_for_job(db, "cover_letter", job_id)
|
||
assert task["status"] == "completed"
|
||
assert task["error"] is None
|
||
|
||
conn = sqlite3.connect(db)
|
||
row = conn.execute("SELECT cover_letter FROM jobs WHERE id=?", (job_id,)).fetchone()
|
||
conn.close()
|
||
assert row[0] == "Dear Hiring Manager,\nGreat fit!"
|
||
|
||
|
||
def test_run_task_company_research_success(tmp_path):
|
||
"""_run_task marks running→completed and saves research to DB."""
|
||
db, job_id = _make_db(tmp_path)
|
||
from scripts.db import insert_task, get_task_for_job, get_research
|
||
|
||
task_id, _ = insert_task(db, "company_research", job_id)
|
||
fake_result = {
|
||
"raw_output": "raw", "company_brief": "brief",
|
||
"ceo_brief": "ceo", "talking_points": "points",
|
||
}
|
||
with patch("scripts.company_research.research_company", return_value=fake_result):
|
||
from scripts.task_runner import _run_task
|
||
_run_task(db, task_id, "company_research", job_id)
|
||
|
||
task = get_task_for_job(db, "company_research", job_id)
|
||
assert task["status"] == "completed"
|
||
|
||
research = get_research(db, job_id=job_id)
|
||
assert research["company_brief"] == "brief"
|
||
|
||
|
||
def test_run_task_marks_failed_on_exception(tmp_path):
|
||
"""_run_task marks status=failed and stores error when generator raises."""
|
||
db, job_id = _make_db(tmp_path)
|
||
from scripts.db import insert_task, get_task_for_job
|
||
task_id, _ = insert_task(db, "cover_letter", job_id)
|
||
|
||
with patch("scripts.generate_cover_letter.generate", side_effect=RuntimeError("LLM timeout")):
|
||
from scripts.task_runner import _run_task
|
||
_run_task(db, task_id, "cover_letter", job_id)
|
||
|
||
task = get_task_for_job(db, "cover_letter", job_id)
|
||
assert task["status"] == "failed"
|
||
assert "LLM timeout" in task["error"]
|
||
|
||
|
||
def test_submit_task_actually_completes(tmp_path):
|
||
"""Integration: submit_task spawns a thread that completes asynchronously."""
|
||
db, job_id = _make_db(tmp_path)
|
||
from scripts.db import get_task_for_job
|
||
|
||
with patch("scripts.generate_cover_letter.generate", return_value="Cover letter text"):
|
||
from scripts.task_runner import submit_task
|
||
task_id, _ = submit_task(db, "cover_letter", job_id)
|
||
# Wait for thread to complete (max 5s)
|
||
for _ in range(50):
|
||
task = get_task_for_job(db, "cover_letter", job_id)
|
||
if task and task["status"] in ("completed", "failed"):
|
||
break
|
||
time.sleep(0.1)
|
||
|
||
task = get_task_for_job(db, "cover_letter", job_id)
|
||
assert task["status"] == "completed"
|
||
```
|
||
|
||
### Step 2: Run tests to verify they fail
|
||
|
||
```bash
|
||
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_task_runner.py -v
|
||
```
|
||
|
||
Expected: FAIL with `ModuleNotFoundError: No module named 'scripts.task_runner'`
|
||
|
||
### Step 3: Implement scripts/task_runner.py
|
||
|
||
Create `scripts/task_runner.py`:
|
||
|
||
```python
|
||
# scripts/task_runner.py
|
||
"""
|
||
Background task runner for LLM generation tasks.
|
||
|
||
Submitting a task inserts a row in background_tasks and spawns a daemon thread.
|
||
The thread calls the appropriate generator, writes results to existing tables,
|
||
and marks the task completed or failed.
|
||
|
||
Deduplication: only one queued/running task per (task_type, job_id) is allowed.
|
||
Different task types for the same job run concurrently (e.g. cover letter + research).
|
||
"""
|
||
import sqlite3
|
||
import threading
|
||
from pathlib import Path
|
||
|
||
from scripts.db import (
|
||
DEFAULT_DB,
|
||
insert_task,
|
||
update_task_status,
|
||
update_cover_letter,
|
||
save_research,
|
||
)
|
||
|
||
|
||
def submit_task(db_path: Path = DEFAULT_DB, task_type: str = "",
|
||
job_id: int = None) -> tuple[int, bool]:
|
||
"""Submit a background LLM task.
|
||
|
||
Returns (task_id, True) if a new task was queued and a thread spawned.
|
||
Returns (existing_id, False) if an identical task is already in-flight.
|
||
"""
|
||
task_id, is_new = insert_task(db_path, task_type, job_id)
|
||
if is_new:
|
||
t = threading.Thread(
|
||
target=_run_task,
|
||
args=(db_path, task_id, task_type, job_id),
|
||
daemon=True,
|
||
)
|
||
t.start()
|
||
return task_id, is_new
|
||
|
||
|
||
def _run_task(db_path: Path, task_id: int, task_type: str, job_id: int) -> None:
|
||
"""Thread body: run the generator and persist the result."""
|
||
conn = sqlite3.connect(db_path)
|
||
conn.row_factory = sqlite3.Row
|
||
row = conn.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
|
||
conn.close()
|
||
if row is None:
|
||
update_task_status(db_path, task_id, "failed", error=f"Job {job_id} not found")
|
||
return
|
||
|
||
job = dict(row)
|
||
update_task_status(db_path, task_id, "running")
|
||
|
||
try:
|
||
if task_type == "cover_letter":
|
||
from scripts.generate_cover_letter import generate
|
||
result = generate(
|
||
job.get("title", ""),
|
||
job.get("company", ""),
|
||
job.get("description", ""),
|
||
)
|
||
update_cover_letter(db_path, job_id, result)
|
||
|
||
elif task_type == "company_research":
|
||
from scripts.company_research import research_company
|
||
result = research_company(job)
|
||
save_research(db_path, job_id=job_id, **result)
|
||
|
||
else:
|
||
raise ValueError(f"Unknown task_type: {task_type!r}")
|
||
|
||
update_task_status(db_path, task_id, "completed")
|
||
|
||
except Exception as exc:
|
||
update_task_status(db_path, task_id, "failed", error=str(exc))
|
||
```
|
||
|
||
### Step 4: Run tests to verify they pass
|
||
|
||
```bash
|
||
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_task_runner.py -v
|
||
```
|
||
|
||
Expected: all tests PASS
|
||
|
||
### Step 5: Run full test suite
|
||
|
||
```bash
|
||
/devl/miniconda3/envs/job-seeker/bin/pytest tests/ -v
|
||
```
|
||
|
||
Expected: all tests PASS
|
||
|
||
### Step 6: Commit
|
||
|
||
```bash
|
||
git add scripts/task_runner.py tests/test_task_runner.py
|
||
git commit -m "feat: add task_runner — background thread executor for LLM tasks"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Add sidebar task indicator to app/app.py
|
||
|
||
**Files:**
|
||
- Modify: `app/app.py`
|
||
|
||
No new tests needed — this is pure UI wiring.
|
||
|
||
### Step 1: Replace the contents of app/app.py
|
||
|
||
Current file is 33 lines. Replace entirely with:
|
||
|
||
```python
|
||
# app/app.py
|
||
"""
|
||
Streamlit entry point — uses st.navigation() to control the sidebar.
|
||
Main workflow pages are listed at the top; Settings is separated into
|
||
a "System" section so it doesn't crowd the navigation.
|
||
|
||
Run: streamlit run app/app.py
|
||
bash scripts/manage-ui.sh start
|
||
"""
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||
|
||
import streamlit as st
|
||
from scripts.db import DEFAULT_DB, init_db, get_active_tasks
|
||
|
||
st.set_page_config(
|
||
page_title="Job Seeker",
|
||
page_icon="💼",
|
||
layout="wide",
|
||
)
|
||
|
||
init_db(DEFAULT_DB)
|
||
|
||
# ── Background task sidebar indicator ─────────────────────────────────────────
|
||
@st.fragment(run_every=3)
|
||
def _task_sidebar() -> None:
|
||
tasks = get_active_tasks(DEFAULT_DB)
|
||
if not tasks:
|
||
return
|
||
with st.sidebar:
|
||
st.divider()
|
||
st.markdown(f"**⏳ {len(tasks)} task(s) running**")
|
||
for t in tasks:
|
||
icon = "⏳" if t["status"] == "running" else "🕐"
|
||
label = "Cover letter" if t["task_type"] == "cover_letter" else "Research"
|
||
st.caption(f"{icon} {label} — {t.get('company') or 'unknown'}")
|
||
|
||
_task_sidebar()
|
||
|
||
# ── Navigation ─────────────────────────────────────────────────────────────────
|
||
pages = {
|
||
"": [
|
||
st.Page("Home.py", title="Home", icon="🏠"),
|
||
st.Page("pages/1_Job_Review.py", title="Job Review", icon="📋"),
|
||
st.Page("pages/4_Apply.py", title="Apply Workspace", icon="🚀"),
|
||
st.Page("pages/5_Interviews.py", title="Interviews", icon="🎯"),
|
||
st.Page("pages/6_Interview_Prep.py", title="Interview Prep", icon="📞"),
|
||
],
|
||
"System": [
|
||
st.Page("pages/2_Settings.py", title="Settings", icon="⚙️"),
|
||
],
|
||
}
|
||
|
||
pg = st.navigation(pages)
|
||
pg.run()
|
||
```
|
||
|
||
### Step 2: Smoke-test by running the UI
|
||
|
||
```bash
|
||
bash /devl/job-seeker/scripts/manage-ui.sh restart
|
||
```
|
||
|
||
Navigate to http://localhost:8501 and confirm the app loads without error. The sidebar task indicator does not appear when no tasks are running (correct).
|
||
|
||
### Step 3: Commit
|
||
|
||
```bash
|
||
git add app/app.py
|
||
git commit -m "feat: sidebar background task indicator with 3s auto-refresh"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Update 4_Apply.py to use background generation
|
||
|
||
**Files:**
|
||
- Modify: `app/pages/4_Apply.py`
|
||
|
||
No new unit tests — covered by existing test suite for DB layer. Smoke-test in browser.
|
||
|
||
### Step 1: Add imports at the top of 4_Apply.py
|
||
|
||
After the existing imports block (after `from scripts.db import ...`), add:
|
||
|
||
```python
|
||
from scripts.db import get_task_for_job
|
||
from scripts.task_runner import submit_task
|
||
```
|
||
|
||
So the full import block becomes:
|
||
|
||
```python
|
||
from scripts.db import (
|
||
DEFAULT_DB, init_db, get_jobs_by_status,
|
||
update_cover_letter, mark_applied,
|
||
get_task_for_job,
|
||
)
|
||
from scripts.task_runner import submit_task
|
||
```
|
||
|
||
### Step 2: Replace the Generate button section
|
||
|
||
Find this block (around line 174–185):
|
||
|
||
```python
|
||
if st.button("✨ Generate / Regenerate", use_container_width=True):
|
||
with st.spinner("Generating via LLM…"):
|
||
try:
|
||
from scripts.generate_cover_letter import generate as _gen
|
||
st.session_state[_cl_key] = _gen(
|
||
job.get("title", ""),
|
||
job.get("company", ""),
|
||
job.get("description", ""),
|
||
)
|
||
st.rerun()
|
||
except Exception as e:
|
||
st.error(f"Generation failed: {e}")
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```python
|
||
_cl_task = get_task_for_job(DEFAULT_DB, "cover_letter", selected_id)
|
||
_cl_running = _cl_task and _cl_task["status"] in ("queued", "running")
|
||
|
||
if st.button("✨ Generate / Regenerate", use_container_width=True, disabled=bool(_cl_running)):
|
||
submit_task(DEFAULT_DB, "cover_letter", selected_id)
|
||
st.rerun()
|
||
|
||
if _cl_running:
|
||
@st.fragment(run_every=3)
|
||
def _cl_status_fragment():
|
||
t = get_task_for_job(DEFAULT_DB, "cover_letter", selected_id)
|
||
if t and t["status"] in ("queued", "running"):
|
||
lbl = "Queued…" if t["status"] == "queued" else "Generating via LLM…"
|
||
st.info(f"⏳ {lbl}")
|
||
else:
|
||
st.rerun() # full page rerun — reloads cover letter from DB
|
||
_cl_status_fragment()
|
||
elif _cl_task and _cl_task["status"] == "failed":
|
||
st.error(f"Generation failed: {_cl_task.get('error', 'unknown error')}")
|
||
```
|
||
|
||
Also update the session-state initialiser just below (line 171–172) so it loads from DB after background completion. The existing code already does this correctly:
|
||
|
||
```python
|
||
if _cl_key not in st.session_state:
|
||
st.session_state[_cl_key] = job.get("cover_letter") or ""
|
||
```
|
||
|
||
This is fine — `job` is fetched fresh on each full-page rerun, so when the background thread writes to `jobs.cover_letter`, the next full rerun picks it up.
|
||
|
||
### Step 3: Smoke-test in browser
|
||
|
||
1. Navigate to Apply Workspace
|
||
2. Select an approved job
|
||
3. Click "Generate / Regenerate"
|
||
4. Navigate away to Home
|
||
5. Navigate back to Apply Workspace for the same job
|
||
6. Observe: button is disabled and "⏳ Generating via LLM…" shows while running; cover letter appears when done
|
||
|
||
### Step 4: Commit
|
||
|
||
```bash
|
||
git add app/pages/4_Apply.py
|
||
git commit -m "feat: cover letter generation runs in background, survives navigation"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Update 6_Interview_Prep.py to use background research
|
||
|
||
**Files:**
|
||
- Modify: `app/pages/6_Interview_Prep.py`
|
||
|
||
### Step 1: Add imports at the top of 6_Interview_Prep.py
|
||
|
||
After the existing `from scripts.db import (...)` block, add:
|
||
|
||
```python
|
||
from scripts.db import get_task_for_job
|
||
from scripts.task_runner import submit_task
|
||
```
|
||
|
||
So the full import block becomes:
|
||
|
||
```python
|
||
from scripts.db import (
|
||
DEFAULT_DB, init_db,
|
||
get_interview_jobs, get_contacts, get_research,
|
||
save_research, get_task_for_job,
|
||
)
|
||
from scripts.task_runner import submit_task
|
||
```
|
||
|
||
### Step 2: Replace the "no research yet" generate button block
|
||
|
||
Find this block (around line 99–111):
|
||
|
||
```python
|
||
if not research:
|
||
st.warning("No research brief yet for this job.")
|
||
if st.button("🔬 Generate research brief", type="primary", use_container_width=True):
|
||
with st.spinner("Generating… this may take 30–60 seconds"):
|
||
try:
|
||
from scripts.company_research import research_company
|
||
result = research_company(job)
|
||
save_research(DEFAULT_DB, job_id=selected_id, **result)
|
||
st.success("Done!")
|
||
st.rerun()
|
||
except Exception as e:
|
||
st.error(f"Error: {e}")
|
||
st.stop()
|
||
else:
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```python
|
||
_res_task = get_task_for_job(DEFAULT_DB, "company_research", selected_id)
|
||
_res_running = _res_task and _res_task["status"] in ("queued", "running")
|
||
|
||
if not research:
|
||
if not _res_running:
|
||
st.warning("No research brief yet for this job.")
|
||
if _res_task and _res_task["status"] == "failed":
|
||
st.error(f"Last attempt failed: {_res_task.get('error', '')}")
|
||
if st.button("🔬 Generate research brief", type="primary", use_container_width=True):
|
||
submit_task(DEFAULT_DB, "company_research", selected_id)
|
||
st.rerun()
|
||
|
||
if _res_running:
|
||
@st.fragment(run_every=3)
|
||
def _res_status_initial():
|
||
t = get_task_for_job(DEFAULT_DB, "company_research", selected_id)
|
||
if t and t["status"] in ("queued", "running"):
|
||
lbl = "Queued…" if t["status"] == "queued" else "Generating… this may take 30–60 seconds"
|
||
st.info(f"⏳ {lbl}")
|
||
else:
|
||
st.rerun()
|
||
_res_status_initial()
|
||
|
||
st.stop()
|
||
else:
|
||
```
|
||
|
||
### Step 3: Replace the "refresh" button block
|
||
|
||
Find this block (around line 113–124):
|
||
|
||
```python
|
||
generated_at = research.get("generated_at", "")
|
||
col_ts, col_btn = st.columns([3, 1])
|
||
col_ts.caption(f"Research generated: {generated_at}")
|
||
if col_btn.button("🔄 Refresh", use_container_width=True):
|
||
with st.spinner("Refreshing…"):
|
||
try:
|
||
from scripts.company_research import research_company
|
||
result = research_company(job)
|
||
save_research(DEFAULT_DB, job_id=selected_id, **result)
|
||
st.rerun()
|
||
except Exception as e:
|
||
st.error(f"Error: {e}")
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```python
|
||
generated_at = research.get("generated_at", "")
|
||
col_ts, col_btn = st.columns([3, 1])
|
||
col_ts.caption(f"Research generated: {generated_at}")
|
||
if col_btn.button("🔄 Refresh", use_container_width=True, disabled=bool(_res_running)):
|
||
submit_task(DEFAULT_DB, "company_research", selected_id)
|
||
st.rerun()
|
||
|
||
if _res_running:
|
||
@st.fragment(run_every=3)
|
||
def _res_status_refresh():
|
||
t = get_task_for_job(DEFAULT_DB, "company_research", selected_id)
|
||
if t and t["status"] in ("queued", "running"):
|
||
lbl = "Queued…" if t["status"] == "queued" else "Refreshing research…"
|
||
st.info(f"⏳ {lbl}")
|
||
else:
|
||
st.rerun()
|
||
_res_status_refresh()
|
||
elif _res_task and _res_task["status"] == "failed":
|
||
st.error(f"Refresh failed: {_res_task.get('error', '')}")
|
||
```
|
||
|
||
### Step 4: Smoke-test in browser
|
||
|
||
1. Move a job to Phone Screen on the Interviews page
|
||
2. Navigate to Interview Prep, select that job
|
||
3. Click "Generate research brief"
|
||
4. Navigate away to Home
|
||
5. Navigate back — observe "⏳ Generating…" inline indicator
|
||
6. Wait for completion — research sections populate automatically
|
||
|
||
### Step 5: Run full test suite one final time
|
||
|
||
```bash
|
||
/devl/miniconda3/envs/job-seeker/bin/pytest tests/ -v
|
||
```
|
||
|
||
Expected: all tests PASS
|
||
|
||
### Step 6: Commit
|
||
|
||
```bash
|
||
git add app/pages/6_Interview_Prep.py
|
||
git commit -m "feat: company research generation runs in background, survives navigation"
|
||
```
|
||
|
||
---
|
||
|
||
## Summary of Changes
|
||
|
||
| File | Change |
|
||
|------|--------|
|
||
| `scripts/db.py` | Add `CREATE_BACKGROUND_TASKS`, `init_db` call, 4 new helpers |
|
||
| `scripts/task_runner.py` | New file — `submit_task` + `_run_task` thread body |
|
||
| `app/app.py` | Add `_task_sidebar` fragment with 3s auto-refresh |
|
||
| `app/pages/4_Apply.py` | Generate button → `submit_task`; inline status fragment |
|
||
| `app/pages/6_Interview_Prep.py` | Generate/Refresh buttons → `submit_task`; inline status fragments |
|
||
| `tests/test_db.py` | 9 new tests for background_tasks helpers |
|
||
| `tests/test_task_runner.py` | New file — 6 tests for task_runner |
|