peregrine/docs/plans/2026-02-23-survey-assistant-plan.md
pyr0ball f11a38eb0b chore: seed Peregrine from personal job-seeker (pre-generalization)
App: Peregrine
Company: Circuit Forge LLC
Source: github.com/pyr0ball/job-seeker (personal fork, not linked)
2026-02-24 18:25:39 -08:00

1441 lines
47 KiB
Markdown

# Survey Assistant Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a real-time Survey Assistant page that helps users answer culture-fit surveys via text paste or screenshot, backed by a local moondream2 vision service, with a new `survey` pipeline stage and kanban consolidation.
**Architecture:** Six sequential tasks build foundation-up: DB → LLM Router → Email Classifier → Vision Service → Interviews Kanban → Survey Page. Each task is independently testable and committed before the next begins.
**Tech Stack:** SQLite (db.py migrations), FastAPI + moondream2 (vision service, separate conda env), streamlit-paste-button (clipboard paste), Python requests (vision service client in LLM router).
---
## Task 1: DB — survey_responses table + survey_at column + CRUD
**Files:**
- Modify: `scripts/db.py`
- Test: `tests/test_db.py`
### Step 1: Write the failing tests
Add to `tests/test_db.py`:
```python
def test_survey_responses_table_created(tmp_path):
"""init_db creates survey_responses 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='survey_responses'"
)
assert cur.fetchone() is not None
conn.close()
def test_survey_at_column_exists(tmp_path):
"""jobs table has survey_at column after init_db."""
from scripts.db import init_db
db_path = tmp_path / "test.db"
init_db(db_path)
import sqlite3
conn = sqlite3.connect(db_path)
cols = [row[1] for row in conn.execute("PRAGMA table_info(jobs)").fetchall()]
assert "survey_at" in cols
conn.close()
def test_insert_and_get_survey_response(tmp_path):
"""insert_survey_response inserts a row; get_survey_responses returns it."""
from scripts.db import init_db, insert_job, insert_survey_response, get_survey_responses
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-23",
})
row_id = insert_survey_response(
db_path, job_id=job_id, survey_name="Culture Fit",
source="text_paste", raw_input="Q1: A B C", mode="quick",
llm_output="1. B — collaborative", reported_score="82%",
)
assert isinstance(row_id, int)
responses = get_survey_responses(db_path, job_id=job_id)
assert len(responses) == 1
assert responses[0]["survey_name"] == "Culture Fit"
assert responses[0]["reported_score"] == "82%"
def test_get_interview_jobs_includes_survey(tmp_path):
"""get_interview_jobs returns survey-stage jobs."""
from scripts.db import init_db, insert_job, update_job_status, get_interview_jobs
db_path = tmp_path / "test.db"
init_db(db_path)
job_id = insert_job(db_path, {
"title": "CSM", "company": "Acme", "url": "https://ex.com/2",
"source": "linkedin", "location": "Remote", "is_remote": True,
"salary": "", "description": "", "date_found": "2026-02-23",
})
update_job_status(db_path, [job_id], "survey")
result = get_interview_jobs(db_path)
assert any(j["id"] == job_id for j in result.get("survey", []))
def test_advance_to_survey_sets_survey_at(tmp_path):
"""advance_to_stage('survey') sets survey_at timestamp."""
from scripts.db import init_db, insert_job, update_job_status, advance_to_stage, get_job_by_id
db_path = tmp_path / "test.db"
init_db(db_path)
job_id = insert_job(db_path, {
"title": "CSM", "company": "Acme", "url": "https://ex.com/3",
"source": "linkedin", "location": "Remote", "is_remote": True,
"salary": "", "description": "", "date_found": "2026-02-23",
})
update_job_status(db_path, [job_id], "applied")
advance_to_stage(db_path, job_id=job_id, stage="survey")
job = get_job_by_id(db_path, job_id=job_id)
assert job["status"] == "survey"
assert job["survey_at"] is not None
```
### Step 2: Run to verify they fail
```bash
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_db.py::test_survey_responses_table_created tests/test_db.py::test_survey_at_column_exists tests/test_db.py::test_insert_and_get_survey_response tests/test_db.py::test_get_interview_jobs_includes_survey tests/test_db.py::test_advance_to_survey_sets_survey_at -v
```
Expected: FAIL with `ImportError` or `AssertionError`
### Step 3: Implement
In `scripts/db.py`, make the following changes:
**a) Add `CREATE_SURVEY_RESPONSES` constant** after `CREATE_BACKGROUND_TASKS`:
```python
CREATE_SURVEY_RESPONSES = """
CREATE TABLE IF NOT EXISTS survey_responses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL REFERENCES jobs(id),
survey_name TEXT,
received_at DATETIME,
source TEXT,
raw_input TEXT,
image_path TEXT,
mode TEXT,
llm_output TEXT,
reported_score TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
"""
```
**b) Add `survey_at` to `_MIGRATIONS`** (after `hired_at`):
```python
("survey_at", "TEXT"),
```
**c) Add `"survey"` to `_STAGE_TS_COL`**:
```python
_STAGE_TS_COL = {
"phone_screen": "phone_screen_at",
"interviewing": "interviewing_at",
"offer": "offer_at",
"hired": "hired_at",
"survey": "survey_at",
}
```
**d) Add `survey_responses` to `init_db`**:
```python
def init_db(db_path: Path = DEFAULT_DB) -> None:
conn = sqlite3.connect(db_path)
conn.execute(CREATE_JOBS)
conn.execute(CREATE_JOB_CONTACTS)
conn.execute(CREATE_COMPANY_RESEARCH)
conn.execute(CREATE_BACKGROUND_TASKS)
conn.execute(CREATE_SURVEY_RESPONSES)
conn.commit()
conn.close()
_migrate_db(db_path)
```
**e) Add `survey` to `get_interview_jobs`**:
```python
def get_interview_jobs(db_path: Path = DEFAULT_DB) -> dict[str, list[dict]]:
stages = ["applied", "survey", "phone_screen", "interviewing", "offer", "hired", "rejected"]
... # rest unchanged
```
**f) Add CRUD helpers at the end of the file** (before the background task helpers section):
```python
# ── Survey response helpers ───────────────────────────────────────────────────
def insert_survey_response(
db_path: Path = DEFAULT_DB,
job_id: int = None,
survey_name: str = "",
received_at: str = "",
source: str = "text_paste",
raw_input: str = "",
image_path: str = "",
mode: str = "quick",
llm_output: str = "",
reported_score: str = "",
) -> int:
"""Insert a survey response row. Returns the new row id."""
conn = sqlite3.connect(db_path)
cur = conn.execute(
"""INSERT INTO survey_responses
(job_id, survey_name, received_at, source, raw_input,
image_path, mode, llm_output, reported_score)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(job_id, survey_name or None, received_at or None,
source, raw_input or None, image_path or None,
mode, llm_output, reported_score or None),
)
conn.commit()
row_id = cur.lastrowid
conn.close()
return row_id
def get_survey_responses(db_path: Path = DEFAULT_DB, job_id: int = None) -> list[dict]:
"""Return all survey responses for a job, newest first."""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT * FROM survey_responses WHERE job_id = ? ORDER BY created_at DESC",
(job_id,),
).fetchall()
conn.close()
return [dict(r) for r in rows]
```
### Step 4: Run tests to verify they pass
```bash
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_db.py -v
```
Expected: all DB tests PASS
### Step 5: Commit
```bash
git add scripts/db.py tests/test_db.py
git commit -m "feat: survey_responses table, survey_at column, CRUD helpers"
```
---
## Task 2: LLM Router — images= parameter + vision_service backend
**Files:**
- Modify: `scripts/llm_router.py`
- Modify: `config/llm.yaml`
- Modify: `config/llm.yaml.example` (same change)
- Test: `tests/test_llm_router.py`
### Step 1: Write the failing tests
Read `tests/test_llm_router.py` first to see existing test style, then add:
```python
def test_complete_skips_backend_without_image_support(tmp_path):
"""When images= is passed, backends without supports_images are skipped."""
import yaml
from scripts.llm_router import LLMRouter
cfg = {
"fallback_order": ["ollama", "vision_service"],
"backends": {
"ollama": {
"type": "openai_compat",
"base_url": "http://localhost:11434/v1",
"model": "llava",
"api_key": "ollama",
"enabled": True,
"supports_images": False,
},
"vision_service": {
"type": "vision_service",
"base_url": "http://localhost:8002",
"enabled": True,
"supports_images": True,
},
},
}
cfg_file = tmp_path / "llm.yaml"
cfg_file.write_text(yaml.dump(cfg))
from unittest.mock import patch, MagicMock
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {"text": "B — collaborative"}
with patch("scripts.llm_router.requests.get") as mock_get, \
patch("scripts.llm_router.requests.post") as mock_post:
# health check returns ok for vision_service
mock_get.return_value = MagicMock(status_code=200)
mock_post.return_value = mock_resp
router = LLMRouter(config_path=cfg_file)
result = router.complete("Which option?", images=["base64data"])
assert result == "B — collaborative"
# ollama should never have been called (no supports_images)
# vision_service POST /analyze should have been called
assert mock_post.called
def test_complete_without_images_skips_vision_service(tmp_path):
"""When images=None, vision_service backend is skipped."""
import yaml
from scripts.llm_router import LLMRouter
from unittest.mock import patch, MagicMock
cfg = {
"fallback_order": ["vision_service"],
"backends": {
"vision_service": {
"type": "vision_service",
"base_url": "http://localhost:8002",
"enabled": True,
"supports_images": True,
},
},
}
cfg_file = tmp_path / "llm.yaml"
cfg_file.write_text(yaml.dump(cfg))
router = LLMRouter(config_path=cfg_file)
with patch("scripts.llm_router.requests.post") as mock_post:
try:
router.complete("text only prompt")
except RuntimeError:
pass # all backends exhausted is expected
assert not mock_post.called
```
### Step 2: Run to verify they fail
```bash
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_llm_router.py::test_complete_skips_backend_without_image_support tests/test_llm_router.py::test_complete_without_images_skips_vision_service -v
```
Expected: FAIL
### Step 3: Implement
**a) Update `scripts/llm_router.py`** — change the `complete()` signature and add image routing logic:
```python
def complete(self, prompt: str, system: str | None = None,
model_override: str | None = None,
fallback_order: list[str] | None = None,
images: list[str] | None = None) -> str:
"""
Generate a completion. Tries each backend in fallback_order.
images: optional list of base64-encoded PNG/JPG strings. When provided,
backends without supports_images=true are skipped. vision_service backends
are only tried when images is provided.
"""
order = fallback_order if fallback_order is not None else self.config["fallback_order"]
for name in order:
backend = self.config["backends"][name]
if not backend.get("enabled", True):
print(f"[LLMRouter] {name}: disabled, skipping")
continue
supports_images = backend.get("supports_images", False)
is_vision_service = backend["type"] == "vision_service"
# vision_service only used when images provided
if is_vision_service and not images:
print(f"[LLMRouter] {name}: vision_service skipped (no images)")
continue
# non-vision backends skipped when images provided and they don't support it
if images and not supports_images and not is_vision_service:
print(f"[LLMRouter] {name}: no image support, skipping")
continue
if is_vision_service:
if not self._is_reachable(backend["base_url"]):
print(f"[LLMRouter] {name}: unreachable, skipping")
continue
try:
resp = requests.post(
backend["base_url"].rstrip("/") + "/analyze",
json={
"prompt": prompt,
"image_base64": images[0] if images else "",
},
timeout=60,
)
resp.raise_for_status()
print(f"[LLMRouter] Used backend: {name} (vision_service)")
return resp.json()["text"]
except Exception as e:
print(f"[LLMRouter] {name}: error — {e}, trying next")
continue
elif backend["type"] == "openai_compat":
if not self._is_reachable(backend["base_url"]):
print(f"[LLMRouter] {name}: unreachable, skipping")
continue
try:
client = OpenAI(
base_url=backend["base_url"],
api_key=backend.get("api_key") or "any",
)
raw_model = model_override or backend["model"]
model = self._resolve_model(client, raw_model)
messages = []
if system:
messages.append({"role": "system", "content": system})
if images and supports_images:
content = [{"type": "text", "text": prompt}]
for img in images:
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{img}"},
})
messages.append({"role": "user", "content": content})
else:
messages.append({"role": "user", "content": prompt})
resp = client.chat.completions.create(
model=model, messages=messages
)
print(f"[LLMRouter] Used backend: {name} ({model})")
return resp.choices[0].message.content
except Exception as e:
print(f"[LLMRouter] {name}: error — {e}, trying next")
continue
elif backend["type"] == "anthropic":
api_key = os.environ.get(backend["api_key_env"], "")
if not api_key:
print(f"[LLMRouter] {name}: {backend['api_key_env']} not set, skipping")
continue
try:
import anthropic as _anthropic
client = _anthropic.Anthropic(api_key=api_key)
if images and supports_images:
content = []
for img in images:
content.append({
"type": "image",
"source": {"type": "base64", "media_type": "image/png", "data": img},
})
content.append({"type": "text", "text": prompt})
else:
content = prompt
kwargs: dict = {
"model": backend["model"],
"max_tokens": 4096,
"messages": [{"role": "user", "content": content}],
}
if system:
kwargs["system"] = system
msg = client.messages.create(**kwargs)
print(f"[LLMRouter] Used backend: {name}")
return msg.content[0].text
except Exception as e:
print(f"[LLMRouter] {name}: error — {e}, trying next")
continue
raise RuntimeError("All LLM backends exhausted")
```
**b) Update `config/llm.yaml`** — add `vision_service` backend and `vision_fallback_order`, and add `supports_images` to relevant backends:
```yaml
backends:
anthropic:
api_key_env: ANTHROPIC_API_KEY
enabled: false
model: claude-sonnet-4-6
type: anthropic
supports_images: true
claude_code:
api_key: any
base_url: http://localhost:3009/v1
enabled: false
model: claude-code-terminal
type: openai_compat
supports_images: true
github_copilot:
api_key: any
base_url: http://localhost:3010/v1
enabled: false
model: gpt-4o
type: openai_compat
supports_images: false
ollama:
api_key: ollama
base_url: http://localhost:11434/v1
enabled: true
model: alex-cover-writer:latest
type: openai_compat
supports_images: false
ollama_research:
api_key: ollama
base_url: http://localhost:11434/v1
enabled: true
model: llama3.1:8b
type: openai_compat
supports_images: false
vllm:
api_key: ''
base_url: http://localhost:8000/v1
enabled: true
model: __auto__
type: openai_compat
supports_images: false
vision_service:
base_url: http://localhost:8002
enabled: false
type: vision_service
supports_images: true
fallback_order:
- ollama
- claude_code
- vllm
- github_copilot
- anthropic
research_fallback_order:
- claude_code
- vllm
- ollama_research
- github_copilot
- anthropic
vision_fallback_order:
- vision_service
- claude_code
- anthropic
# Note: 'ollama' (alex-cover-writer) intentionally excluded — research
# must never use the fine-tuned writer model.
```
Make the same `vision_service` backend and `supports_images` changes to `config/llm.yaml.example`.
### Step 4: Run tests
```bash
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_llm_router.py -v
```
Expected: all PASS
### Step 5: Commit
```bash
git add scripts/llm_router.py config/llm.yaml config/llm.yaml.example tests/test_llm_router.py
git commit -m "feat: LLM router images= parameter + vision_service backend type"
```
---
## Task 3: Email classifier — survey_received label
**Files:**
- Modify: `scripts/imap_sync.py`
- Test: `tests/test_imap_sync.py`
### Step 1: Write the failing test
Add to `tests/test_imap_sync.py`:
```python
def test_classify_labels_includes_survey_received():
"""_CLASSIFY_LABELS includes survey_received."""
from scripts.imap_sync import _CLASSIFY_LABELS
assert "survey_received" in _CLASSIFY_LABELS
def test_classify_stage_signal_returns_survey_received():
"""classify_stage_signal returns 'survey_received' when LLM outputs that label."""
from unittest.mock import patch
from scripts.imap_sync import classify_stage_signal
with patch("scripts.imap_sync._CLASSIFIER_ROUTER") as mock_router:
mock_router.complete.return_value = "survey_received"
result = classify_stage_signal("Complete our culture survey", "Please fill out this form")
assert result == "survey_received"
```
### Step 2: Run to verify they fail
```bash
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_imap_sync.py::test_classify_labels_includes_survey_received tests/test_imap_sync.py::test_classify_stage_signal_returns_survey_received -v
```
Expected: FAIL
### Step 3: Implement
In `scripts/imap_sync.py`, make two changes:
**a) Update `_CLASSIFY_SYSTEM`** — add survey_received to the category list:
```python
_CLASSIFY_SYSTEM = (
"You are an email classifier. Classify the recruitment email into exactly ONE of these categories:\n"
" interview_scheduled, offer_received, rejected, positive_response, survey_received, neutral\n\n"
"Rules:\n"
"- interview_scheduled: recruiter wants to book a call/interview\n"
"- offer_received: job offer is being extended\n"
"- rejected: explicitly not moving forward\n"
"- positive_response: interested/impressed but no interview booked yet\n"
"- survey_received: link or request to complete a survey, assessment, or questionnaire\n"
"- neutral: auto-confirmation, generic update, no clear signal\n\n"
"Respond with ONLY the category name. No explanation."
)
```
**b) Update `_CLASSIFY_LABELS`**:
```python
_CLASSIFY_LABELS = [
"interview_scheduled", "offer_received", "rejected",
"positive_response", "survey_received", "neutral",
]
```
### Step 4: Run tests
```bash
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_imap_sync.py -v
```
Expected: all PASS
### Step 5: Commit
```bash
git add scripts/imap_sync.py tests/test_imap_sync.py
git commit -m "feat: add survey_received classifier label to email sync"
```
---
## Task 4: Vision service — moondream2 FastAPI server
**Files:**
- Create: `scripts/vision_service/environment.yml`
- Create: `scripts/vision_service/main.py`
- Create: `scripts/manage-vision.sh`
No unit tests for this task — the service is tested manually via the health endpoint. The survey page (Task 6) will degrade gracefully if the service is absent.
### Step 1: Create `scripts/vision_service/environment.yml`
```yaml
name: job-seeker-vision
channels:
- conda-forge
- defaults
dependencies:
- python=3.11
- pip
- pip:
- torch>=2.0.0
- torchvision>=0.15.0
- transformers>=4.40.0
- accelerate>=0.26.0
- bitsandbytes>=0.43.0
- einops>=0.7.0
- Pillow>=10.0.0
- fastapi>=0.110.0
- "uvicorn[standard]>=0.27.0"
```
### Step 2: Create `scripts/vision_service/main.py`
```python
"""
Vision service — moondream2 inference for survey screenshot analysis.
Start: conda run -n job-seeker-vision uvicorn scripts.vision_service.main:app --port 8002
Or use: bash scripts/manage-vision.sh start
"""
import base64
import io
from typing import Optional
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI(title="Job Seeker Vision Service")
# Module-level model state — lazy loaded on first request
_model = None
_tokenizer = None
_device = "cpu"
_loading = False
def _load_model() -> None:
global _model, _tokenizer, _device, _loading
if _model is not None:
return
_loading = True
print("[vision] Loading moondream2…")
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
model_id = "vikhyatk/moondream2"
revision = "2025-01-09"
_device = "cuda" if torch.cuda.is_available() else "cpu"
if _device == "cuda":
from transformers import BitsAndBytesConfig
bnb = BitsAndBytesConfig(load_in_4bit=True)
_model = AutoModelForCausalLM.from_pretrained(
model_id, revision=revision,
quantization_config=bnb,
trust_remote_code=True,
device_map="auto",
)
else:
_model = AutoModelForCausalLM.from_pretrained(
model_id, revision=revision,
trust_remote_code=True,
)
_model.to(_device)
_tokenizer = AutoTokenizer.from_pretrained(model_id, revision=revision)
_loading = False
print(f"[vision] moondream2 ready on {_device}")
class AnalyzeRequest(BaseModel):
prompt: str
image_base64: str
class AnalyzeResponse(BaseModel):
text: str
@app.get("/health")
def health():
import torch
return {
"status": "loading" if _loading else "ok",
"model": "moondream2",
"gpu": torch.cuda.is_available(),
"loaded": _model is not None,
}
@app.post("/analyze", response_model=AnalyzeResponse)
def analyze(req: AnalyzeRequest):
from PIL import Image
import torch
_load_model()
try:
image_data = base64.b64decode(req.image_base64)
image = Image.open(io.BytesIO(image_data)).convert("RGB")
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid image: {e}")
with torch.no_grad():
enc_image = _model.encode_image(image)
answer = _model.answer_question(enc_image, req.prompt, _tokenizer)
return AnalyzeResponse(text=answer)
```
### Step 3: Create `scripts/manage-vision.sh`
```bash
#!/usr/bin/env bash
# scripts/manage-vision.sh — manage the moondream2 vision service
# Usage: bash scripts/manage-vision.sh start|stop|restart|status|logs
set -euo pipefail
CONDA_ENV="job-seeker-vision"
UVICORN_BIN="/devl/miniconda3/envs/${CONDA_ENV}/bin/uvicorn"
PID_FILE="/tmp/vision-service.pid"
LOG_FILE="/tmp/vision-service.log"
PORT=8002
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
is_running() {
if [[ -f "$PID_FILE" ]]; then
PID=$(cat "$PID_FILE")
if kill -0 "$PID" 2>/dev/null; then
return 0
fi
fi
return 1
}
start() {
if is_running; then
echo "Already running (PID $(cat "$PID_FILE"))."
return 0
fi
if [[ ! -f "$UVICORN_BIN" ]]; then
echo "ERROR: conda env '$CONDA_ENV' not found."
echo "Install with: conda env create -f scripts/vision_service/environment.yml"
exit 1
fi
echo "Starting vision service (moondream2) on port $PORT…"
cd "$REPO_ROOT"
PYTHONPATH="$REPO_ROOT" "$UVICORN_BIN" \
scripts.vision_service.main:app \
--host 0.0.0.0 \
--port "$PORT" \
> "$LOG_FILE" 2>&1 &
echo $! > "$PID_FILE"
sleep 2
if is_running; then
echo "Started (PID $(cat "$PID_FILE")). Logs: $LOG_FILE"
echo "Health: http://localhost:$PORT/health"
else
echo "Failed to start. Check logs: $LOG_FILE"
tail -20 "$LOG_FILE"
rm -f "$PID_FILE"
exit 1
fi
}
stop() {
if ! is_running; then
echo "Not running."
rm -f "$PID_FILE"
return 0
fi
PID=$(cat "$PID_FILE")
echo "Stopping PID $PID…"
kill "$PID" 2>/dev/null || true
sleep 2
if kill -0 "$PID" 2>/dev/null; then
kill -9 "$PID" 2>/dev/null || true
fi
rm -f "$PID_FILE"
echo "Stopped."
}
restart() { stop; sleep 1; start; }
status() {
if is_running; then
echo "Running (PID $(cat "$PID_FILE")) — http://localhost:$PORT"
curl -s "http://localhost:$PORT/health" | python3 -m json.tool 2>/dev/null || true
else
echo "Not running."
fi
}
logs() {
if [[ -f "$LOG_FILE" ]]; then
tail -50 "$LOG_FILE"
else
echo "No log file at $LOG_FILE"
fi
}
CMD="${1:-help}"
case "$CMD" in
start) start ;;
stop) stop ;;
restart) restart ;;
status) status ;;
logs) logs ;;
*)
echo "Usage: bash scripts/manage-vision.sh start|stop|restart|status|logs"
echo ""
echo " Manages the moondream2 vision service on port $PORT."
echo " First-time setup: conda env create -f scripts/vision_service/environment.yml"
;;
esac
```
### Step 4: Make the script executable and verify
```bash
chmod +x scripts/manage-vision.sh
bash scripts/manage-vision.sh status
```
Expected: "Not running."
### Step 5: Commit
```bash
git add scripts/vision_service/ scripts/manage-vision.sh
git commit -m "feat: moondream2 vision service + manage-vision.sh"
```
---
## Task 5: Interviews page — kanban consolidation + survey stage
**Files:**
- Modify: `app/pages/5_Interviews.py`
No unit tests for UI pages. Verify manually by running the app.
### Step 1: Read the current Interviews page
Read `app/pages/5_Interviews.py` in full before making any changes.
### Step 2: Implement all changes
The following changes are needed in `5_Interviews.py`:
**a) Update imports** — add `insert_survey_response`, `get_survey_responses` from db (needed for survey banner action):
```python
from scripts.db import (
DEFAULT_DB, init_db,
get_interview_jobs, advance_to_stage, reject_at_stage,
set_interview_date, add_contact, get_contacts,
get_research, get_task_for_job, get_job_by_id,
get_unread_stage_signals, dismiss_stage_signal,
)
```
(No new db imports needed here — advance_to_stage handles survey_at already.)
**b) Update `STAGE_LABELS`, `STAGE_NEXT`, `STAGE_NEXT_LABEL`**:
```python
STAGE_LABELS = {
"phone_screen": "📞 Phone Screen",
"interviewing": "🎯 Interviewing",
"offer": "📜 Offer / Hired",
}
STAGE_NEXT = {
"survey": "phone_screen",
"applied": "phone_screen",
"phone_screen": "interviewing",
"interviewing": "offer",
"offer": "hired",
}
STAGE_NEXT_LABEL = {
"survey": "📞 Phone Screen",
"applied": "📞 Phone Screen",
"phone_screen": "🎯 Interviewing",
"interviewing": "📜 Offer",
"offer": "🎉 Hired",
}
```
**c) Update `_render_card` — add `survey_received` to `_SIGNAL_TO_STAGE`**:
```python
_SIGNAL_TO_STAGE = {
"interview_scheduled": ("phone_screen", "📞 Phone Screen"),
"positive_response": ("phone_screen", "📞 Phone Screen"),
"offer_received": ("offer", "📜 Offer"),
"survey_received": ("survey", "📋 Survey"),
}
```
**d) Update stats bar** — add survey metric, remove hired as separate metric (now in offer column):
```python
c1, c2, c3, c4, c5, c6 = st.columns(6)
c1.metric("Applied", len(jobs_by_stage.get("applied", [])))
c2.metric("Survey", len(jobs_by_stage.get("survey", [])))
c3.metric("Phone Screen", len(jobs_by_stage.get("phone_screen", [])))
c4.metric("Interviewing", len(jobs_by_stage.get("interviewing", [])))
c5.metric("Offer/Hired", len(jobs_by_stage.get("offer", [])) + len(jobs_by_stage.get("hired", [])))
c6.metric("Rejected", len(jobs_by_stage.get("rejected", [])))
```
**e) Update pre-kanban section** — show both applied and survey jobs.
Replace the current "Applied queue" section with:
```python
# ── Pre-kanban: Applied + Survey ───────────────────────────────────────────────
applied_jobs = jobs_by_stage.get("applied", [])
survey_jobs = jobs_by_stage.get("survey", [])
pre_kanban = survey_jobs + applied_jobs # survey shown first
if pre_kanban:
st.subheader(f"📋 Pre-pipeline ({len(pre_kanban)})")
st.caption(
"Move a job to **Phone Screen** once you receive an outreach. "
"A company research brief will be auto-generated to help you prepare."
)
for job in pre_kanban:
_pre_kanban_row_fragment(job["id"])
st.divider()
```
**f) Add `_pre_kanban_row_fragment`** — handles both applied and survey jobs in one fragment:
```python
@st.fragment
def _pre_kanban_row_fragment(job_id: int) -> None:
job = get_job_by_id(DEFAULT_DB, job_id)
if job is None or job.get("status") not in ("applied", "survey"):
return
stage = job["status"]
contacts = get_contacts(DEFAULT_DB, job_id=job_id)
last_contact = contacts[-1] if contacts else None
with st.container(border=True):
left, mid, right = st.columns([3, 2, 2])
badge = " 📋 **Survey**" if stage == "survey" else ""
left.markdown(f"**{job.get('company')}** — {job.get('title', '')}{badge}")
left.caption(f"Applied: {_days_ago(job.get('applied_at'))}")
if last_contact:
mid.caption(f"Last contact: {_days_ago(last_contact.get('received_at'))}")
with right:
if st.button(
"→ 📞 Phone Screen", key=f"adv_pre_{job_id}",
use_container_width=True, type="primary",
):
advance_to_stage(DEFAULT_DB, job_id=job_id, stage="phone_screen")
submit_task(DEFAULT_DB, "company_research", job_id)
st.rerun(scope="app")
col_a, col_b = st.columns(2)
if stage == "applied" and col_a.button(
"📋 Survey", key=f"to_survey_{job_id}", use_container_width=True,
):
advance_to_stage(DEFAULT_DB, job_id=job_id, stage="survey")
st.rerun(scope="app")
if col_b.button("✗ Reject", key=f"rej_pre_{job_id}", use_container_width=True):
reject_at_stage(DEFAULT_DB, job_id=job_id, rejection_stage=stage)
st.rerun()
```
**g) Update kanban columns** — merge offer+hired into one column, 3 total:
```python
kanban_stages = ["phone_screen", "interviewing", "offer"]
cols = st.columns(len(kanban_stages))
for col, stage in zip(cols, kanban_stages):
with col:
stage_jobs = jobs_by_stage.get(stage, [])
hired_jobs = jobs_by_stage.get("hired", []) if stage == "offer" else []
all_col_jobs = stage_jobs + hired_jobs
st.markdown(f"### {STAGE_LABELS[stage]}")
st.caption(f"{len(all_col_jobs)} job{'s' if len(all_col_jobs) != 1 else ''}")
st.divider()
if not all_col_jobs:
st.caption("_Empty_")
else:
for job in stage_jobs:
_card_fragment(job["id"], stage)
for job in hired_jobs:
_hired_card_fragment(job["id"])
```
**h) Add `_hired_card_fragment`** for visually differentiated hired jobs:
```python
@st.fragment
def _hired_card_fragment(job_id: int) -> None:
job = get_job_by_id(DEFAULT_DB, job_id)
if job is None or job.get("status") != "hired":
return
with st.container(border=True):
st.markdown(f"✅ **{job.get('company', '?')}**")
st.caption(job.get("title", ""))
st.caption(f"Hired {_days_ago(job.get('hired_at'))}")
```
**i) Remove the old `_applied_row_fragment`** — replaced by `_pre_kanban_row_fragment`.
### Step 3: Verify visually
```bash
bash scripts/manage-ui.sh restart
```
Open http://localhost:8501 → Interviews page. Verify:
- Pre-kanban section shows applied and survey jobs
- Kanban has 3 columns (Phone Screen, Interviewing, Offer/Hired)
- Stats bar has Survey metric
### Step 4: Commit
```bash
git add app/pages/5_Interviews.py
git commit -m "feat: kanban consolidation — survey pre-section, merged offer/hired column"
```
---
## Task 6: Add streamlit-paste-button to environment.yml
**Files:**
- Modify: `environment.yml`
### Step 1: Add the dependency
In `environment.yml`, under `# ── Web UI ─────`:
```yaml
- streamlit-paste-button>=0.1.0
```
### Step 2: Install into the active env
```bash
/devl/miniconda3/envs/job-seeker/bin/pip install streamlit-paste-button
```
### Step 3: Commit
```bash
git add environment.yml
git commit -m "chore: add streamlit-paste-button dependency"
```
---
## Task 7: Survey Assistant page
**Files:**
- Create: `app/pages/7_Survey.py`
- Create: `data/survey_screenshots/.gitkeep`
- Modify: `.gitignore`
### Step 1: Create the screenshots directory and gitignore
```bash
mkdir -p data/survey_screenshots
touch data/survey_screenshots/.gitkeep
```
Add to `.gitignore`:
```
data/survey_screenshots/
```
(Keep `.gitkeep` but ignore the content. Actually, add `data/survey_screenshots/*` and `!data/survey_screenshots/.gitkeep`.)
### Step 2: Create `app/pages/7_Survey.py`
```python
# app/pages/7_Survey.py
"""
Survey Assistant — real-time help with culture-fit surveys.
Supports text paste and screenshot (via clipboard or file upload).
Quick mode: "pick B" + one-liner. Detailed mode: option-by-option breakdown.
"""
import base64
import io
import sys
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
import requests
import streamlit as st
from scripts.db import (
DEFAULT_DB, init_db,
get_interview_jobs, get_job_by_id,
insert_survey_response, get_survey_responses,
)
from scripts.llm_router import LLMRouter
st.title("📋 Survey Assistant")
init_db(DEFAULT_DB)
# ── Vision service health check ────────────────────────────────────────────────
def _vision_available() -> bool:
try:
r = requests.get("http://localhost:8002/health", timeout=2)
return r.status_code == 200
except Exception:
return False
vision_up = _vision_available()
# ── Job selector ───────────────────────────────────────────────────────────────
jobs_by_stage = get_interview_jobs(DEFAULT_DB)
survey_jobs = jobs_by_stage.get("survey", [])
other_jobs = (
jobs_by_stage.get("applied", []) +
jobs_by_stage.get("phone_screen", []) +
jobs_by_stage.get("interviewing", []) +
jobs_by_stage.get("offer", [])
)
all_jobs = survey_jobs + other_jobs
if not all_jobs:
st.info("No active jobs found. Add jobs in Job Review first.")
st.stop()
job_labels = {j["id"]: f"{j.get('company', '?')}{j.get('title', '')}" for j in all_jobs}
selected_job_id = st.selectbox(
"Job",
options=[j["id"] for j in all_jobs],
format_func=lambda jid: job_labels[jid],
index=0,
)
selected_job = get_job_by_id(DEFAULT_DB, selected_job_id)
# ── Layout ─────────────────────────────────────────────────────────────────────
left_col, right_col = st.columns([1, 1], gap="large")
with left_col:
survey_name = st.text_input(
"Survey name (optional)",
placeholder="e.g. Culture Fit Round 1",
key="survey_name",
)
mode = st.radio("Mode", ["Quick", "Detailed"], horizontal=True, key="survey_mode")
st.caption(
"**Quick** — best answer + one-liner per question | "
"**Detailed** — option-by-option breakdown"
)
# Input tabs
if vision_up:
tab_text, tab_screenshot = st.tabs(["📝 Paste Text", "🖼️ Screenshot"])
else:
tab_text = st.container()
st.info(
"📷 Screenshot input unavailable — vision service not running. \n"
"Start it with: `bash scripts/manage-vision.sh start`"
)
tab_screenshot = None
image_b64: str | None = None
raw_text: str = ""
with tab_text:
raw_text = st.text_area(
"Paste survey questions here",
height=280,
placeholder="Q1: Which describes your ideal work environment?\nA. Solo focused work\nB. Collaborative team\nC. Mix of both\nD. Depends on the task",
key="survey_text",
)
if tab_screenshot is not None:
with tab_screenshot:
st.caption("Paste from clipboard or upload a screenshot file.")
paste_col, upload_col = st.columns(2)
with paste_col:
try:
from streamlit_paste_button import paste_image_button
paste_result = paste_image_button("📋 Paste from clipboard", key="paste_btn")
if paste_result and paste_result.image_data:
buf = io.BytesIO()
paste_result.image_data.save(buf, format="PNG")
image_b64 = base64.b64encode(buf.getvalue()).decode()
st.image(paste_result.image_data, caption="Pasted image", use_container_width=True)
except ImportError:
st.warning("streamlit-paste-button not installed. Use file upload.")
with upload_col:
uploaded = st.file_uploader(
"Upload screenshot",
type=["png", "jpg", "jpeg"],
key="survey_upload",
label_visibility="collapsed",
)
if uploaded:
image_b64 = base64.b64encode(uploaded.read()).decode()
st.image(uploaded, caption="Uploaded image", use_container_width=True)
# Analyze button
has_input = bool(raw_text.strip()) or bool(image_b64)
if st.button("🔍 Analyze", type="primary", disabled=not has_input, use_container_width=True):
with st.spinner("Analyzing…"):
try:
router = LLMRouter()
if image_b64:
prompt = _build_image_prompt(mode)
output = router.complete(
prompt,
images=[image_b64],
fallback_order=router.config.get("vision_fallback_order"),
)
source = "screenshot"
else:
prompt = _build_text_prompt(raw_text, mode)
output = router.complete(
prompt,
system=_SURVEY_SYSTEM,
fallback_order=router.config.get("research_fallback_order"),
)
source = "text_paste"
st.session_state["survey_output"] = output
st.session_state["survey_source"] = source
st.session_state["survey_image_b64"] = image_b64
st.session_state["survey_raw_text"] = raw_text
except Exception as e:
st.error(f"Analysis failed: {e}")
with right_col:
output = st.session_state.get("survey_output")
if output:
st.markdown("### Analysis")
st.markdown(output)
st.divider()
with st.form("save_survey_form"):
reported_score = st.text_input(
"Reported score (optional)",
placeholder="e.g. 82% or 4.2/5",
key="reported_score_input",
)
if st.form_submit_button("💾 Save to Job"):
source = st.session_state.get("survey_source", "text_paste")
image_b64_saved = st.session_state.get("survey_image_b64")
raw_text_saved = st.session_state.get("survey_raw_text", "")
image_path = ""
if image_b64_saved:
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
save_dir = Path(__file__).parent.parent.parent / "data" / "survey_screenshots" / str(selected_job_id)
save_dir.mkdir(parents=True, exist_ok=True)
img_file = save_dir / f"{ts}.png"
img_file.write_bytes(base64.b64decode(image_b64_saved))
image_path = str(img_file)
insert_survey_response(
DEFAULT_DB,
job_id=selected_job_id,
survey_name=survey_name,
source=source,
raw_input=raw_text_saved,
image_path=image_path,
mode=mode.lower(),
llm_output=output,
reported_score=reported_score,
)
st.success("Saved!")
# Clear output so form resets
del st.session_state["survey_output"]
st.rerun()
else:
st.markdown("### Analysis")
st.caption("Results will appear here after analysis.")
# ── History ────────────────────────────────────────────────────────────────────
st.divider()
st.subheader("📂 Response History")
history = get_survey_responses(DEFAULT_DB, job_id=selected_job_id)
if not history:
st.caption("No saved responses for this job yet.")
else:
for resp in history:
label = resp.get("survey_name") or "Survey response"
ts = (resp.get("created_at") or "")[:16]
score = resp.get("reported_score")
score_str = f" · Score: {score}" if score else ""
with st.expander(f"{label} · {ts}{score_str}"):
st.caption(f"Mode: {resp.get('mode', '?')} · Source: {resp.get('source', '?')}")
if resp.get("raw_input"):
with st.expander("Original input"):
st.text(resp["raw_input"])
st.markdown(resp.get("llm_output", ""))
# ── LLM prompt builders ────────────────────────────────────────────────────────
_SURVEY_SYSTEM = (
"You are a job application advisor helping a candidate answer a culture-fit survey. "
"The candidate values collaborative teamwork, clear communication, growth, and impact. "
"Choose answers that present them in the best professional light."
)
def _build_text_prompt(text: str, mode: str) -> str:
if mode == "Quick":
return (
"Answer each survey question below. For each, give ONLY the letter of the best "
"option and a single-sentence reason. Format exactly as:\n"
"1. B — reason here\n2. A — reason here\n\n"
f"Survey:\n{text}"
)
return (
"Analyze each survey question below. For each question:\n"
"- Briefly evaluate each option (1 sentence each)\n"
"- State your recommendation with reasoning\n\n"
f"Survey:\n{text}"
)
def _build_image_prompt(mode: str) -> str:
if mode == "Quick":
return (
"This is a screenshot of a culture-fit survey. Read all questions and answer each "
"with the letter of the best option for a collaborative, growth-oriented candidate. "
"Format: '1. B — brief reason' on separate lines."
)
return (
"This is a screenshot of a culture-fit survey. For each question, evaluate each option "
"and recommend the best choice for a collaborative, growth-oriented candidate. "
"Include a brief breakdown per option and a clear recommendation."
)
```
### Step 3: Verify the page loads
```bash
bash scripts/manage-ui.sh restart
```
Open http://localhost:8501 → navigate to "Survey Assistant" in the sidebar. Verify:
- Job selector shows survey-stage jobs first
- Text paste tab works
- Screenshot tab shows "not running" message when vision service is down
- History section loads (empty is fine)
### Step 4: Commit
```bash
git add app/pages/7_Survey.py data/survey_screenshots/.gitkeep .gitignore
git commit -m "feat: Survey Assistant page with text paste, screenshot, and response history"
```
---
## Final: Full test run + wrap-up
### Step 1: Run all tests
```bash
/devl/miniconda3/envs/job-seeker/bin/pytest tests/ -v
```
Expected: all existing tests pass, new tests in test_db.py and test_imap_sync.py pass.
### Step 2: Smoke-test the full flow
1. Trigger an email sync — verify a survey_received signal can be classified
2. Open Interviews — confirm pre-kanban section shows applied/survey jobs, kanban has 3 columns
3. Manually promote a job to survey stage via the "📋 Survey" button
4. Open Survey Assistant — job appears in selector
5. Paste a sample survey question, click Analyze, save response
6. Verify response appears in history
### Step 3: Final commit
```bash
git add -A
git commit -m "feat: survey assistant complete — vision service, survey stage, kanban consolidation"
```