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

47 KiB

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:

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

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

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):

    ("survey_at", "TEXT"),

c) Add "survey" to _STAGE_TS_COL:

_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:

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:

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):

# ── 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

/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_db.py -v

Expected: all DB tests PASS

Step 5: Commit

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:

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

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

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:

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

/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_llm_router.py -v

Expected: all PASS

Step 5: Commit

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:

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

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

_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:

_CLASSIFY_LABELS = [
    "interview_scheduled", "offer_received", "rejected",
    "positive_response", "survey_received", "neutral",
]

Step 4: Run tests

/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_imap_sync.py -v

Expected: all PASS

Step 5: Commit

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

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

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

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

chmod +x scripts/manage-vision.sh
bash scripts/manage-vision.sh status

Expected: "Not running."

Step 5: Commit

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):

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:

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:

_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):

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:

# ── 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:

@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:

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:

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

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 ─────:

    - streamlit-paste-button>=0.1.0

Step 2: Install into the active env

/devl/miniconda3/envs/job-seeker/bin/pip install streamlit-paste-button

Step 3: Commit

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

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

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

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

/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

git add -A
git commit -m "feat: survey assistant complete — vision service, survey stage, kanban consolidation"