From 0e4fce44c4126c2767e073115aed47f824566e4f Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Wed, 15 Apr 2026 08:34:12 -0700 Subject: [PATCH] feat: shadow listing detector, hired feedback widget, contacts manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shadow listing detector (#95): - Capture date_posted from JobSpy in discover.py + insert_job() - Add date_posted migration to _MIGRATIONS - _shadow_score() heuristic: 'shadow' (≥30 days stale), 'stale' (≥14 days) - list_jobs() computes shadow_score per listing - JobCard.vue: 'Ghost post' and 'Stale' badges with tooltip Post-hire feedback widget (#91): - Add hired_feedback migration to _MIGRATIONS - POST /api/jobs/:id/hired-feedback endpoint - InterviewCard.vue: optional widget on hired cards with factor checkboxes + freetext; dismissible; shows saved state - PipelineJob interface extended with hired_feedback field Contacts manager (#73): - GET /api/contacts endpoint with job join, direction/search filters - New ContactsView.vue: searchable table, inbound/outbound filter, signal chip column, job link - Route /contacts added; Contacts nav link (UsersIcon) in AppNav Also: add git to Dockerfile apt-get for circuitforge-core editable install --- Dockerfile | 2 +- dev-api.py | 89 ++++++- scripts/db.py | 7 +- scripts/discover.py | 5 + web/src/components/AppNav.vue | 2 + web/src/components/InterviewCard.vue | 139 +++++++++++ web/src/components/JobCard.vue | 36 ++- web/src/router/index.ts | 1 + web/src/stores/interviews.ts | 1 + web/src/stores/review.ts | 28 +-- web/src/views/ContactsView.vue | 333 +++++++++++++++++++++++++++ 11 files changed, 622 insertions(+), 21 deletions(-) create mode 100644 web/src/views/ContactsView.vue diff --git a/Dockerfile b/Dockerfile index ccbe921..b2cca30 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ WORKDIR /app # System deps for companyScraper (beautifulsoup4, fake-useragent, lxml) and PDF gen # libsqlcipher-dev: required to build pysqlcipher3 (SQLCipher AES-256 encryption for cloud mode) RUN apt-get update && apt-get install -y --no-install-recommends \ - gcc libffi-dev curl libsqlcipher-dev \ + gcc libffi-dev curl libsqlcipher-dev git \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . diff --git a/dev-api.py b/dev-api.py index d9ae737..7893a57 100644 --- a/dev-api.py +++ b/dev-api.py @@ -16,7 +16,7 @@ import subprocess import sys import threading from contextvars import ContextVar -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from typing import Optional, List from urllib.parse import urlparse @@ -230,6 +230,28 @@ def _row_to_job(row) -> dict: return d +def _shadow_score(date_posted: str | None, date_found: str | None) -> str | None: + """Return 'shadow', 'stale', or None based on posting age when discovered. + + A job posted >30 days before discovery is a shadow candidate (posted to + satisfy legal/HR requirements, likely already filled). 14-30 days is stale. + Returns None when dates are unavailable. + """ + if not date_posted or not date_found: + return None + try: + posted = datetime.fromisoformat(date_posted.replace("Z", "+00:00")).replace(tzinfo=timezone.utc) + found = datetime.fromisoformat(date_found.replace("Z", "+00:00")).replace(tzinfo=timezone.utc) + days = (found - posted).days + if days >= 30: + return "shadow" + if days >= 14: + return "stale" + except (ValueError, TypeError): + pass + return None + + # ── GET /api/jobs ───────────────────────────────────────────────────────────── @app.get("/api/jobs") @@ -237,7 +259,7 @@ def list_jobs(status: str = "pending", limit: int = 50, fields: str = ""): db = _get_db() rows = db.execute( "SELECT id, title, company, url, source, location, is_remote, salary, " - "description, match_score, keyword_gaps, date_found, status, cover_letter " + "description, match_score, keyword_gaps, date_found, date_posted, status, cover_letter " "FROM jobs WHERE status = ? ORDER BY match_score DESC NULLS LAST LIMIT ?", (status, limit), ).fetchall() @@ -246,7 +268,7 @@ def list_jobs(status: str = "pending", limit: int = 50, fields: str = ""): for r in rows: d = _row_to_job(r) d["has_cover_letter"] = bool(d.get("cover_letter")) - # Don't send full cover_letter text in the list view + d["shadow_score"] = _shadow_score(d.get("date_posted"), d.get("date_found")) d.pop("cover_letter", None) result.append(d) return result @@ -1490,6 +1512,65 @@ def suggest_qa_answer(job_id: int, payload: QASuggestPayload): raise HTTPException(500, f"LLM generation failed: {e}") +# ── POST /api/jobs/:id/hired-feedback ───────────────────────────────────────── + +class HiredFeedbackPayload(BaseModel): + what_helped: str = "" + factors: list[str] = [] + +@app.post("/api/jobs/{job_id}/hired-feedback") +def save_hired_feedback(job_id: int, payload: HiredFeedbackPayload): + db = _get_db() + row = db.execute("SELECT status FROM jobs WHERE id = ?", (job_id,)).fetchone() + if not row: + raise HTTPException(404, "Job not found") + if row["status"] != "hired": + raise HTTPException(400, "Feedback only accepted for hired jobs") + db.execute( + "UPDATE jobs SET hired_feedback = ? WHERE id = ?", + (json.dumps({"what_helped": payload.what_helped, "factors": payload.factors}), job_id), + ) + db.commit() + db.close() + return {"ok": True} + + +# ── GET /api/contacts ────────────────────────────────────────────────────────── + +@app.get("/api/contacts") +def list_contacts(job_id: Optional[int] = None, direction: Optional[str] = None, + search: Optional[str] = None, limit: int = 100, offset: int = 0): + db = _get_db() + query = """ + SELECT jc.id, jc.job_id, jc.direction, jc.subject, jc.from_addr, jc.to_addr, + jc.received_at, jc.stage_signal, + j.title AS job_title, j.company AS job_company + FROM job_contacts jc + LEFT JOIN jobs j ON j.id = jc.job_id + WHERE 1=1 + """ + params: list = [] + if job_id is not None: + query += " AND jc.job_id = ?" + params.append(job_id) + if direction: + query += " AND jc.direction = ?" + params.append(direction) + if search: + query += " AND (jc.from_addr LIKE ? OR jc.to_addr LIKE ? OR jc.subject LIKE ?)" + like = f"%{search}%" + params += [like, like, like] + query += " ORDER BY jc.received_at DESC LIMIT ? OFFSET ?" + params += [limit, offset] + rows = db.execute(query, params).fetchall() + total = db.execute( + "SELECT COUNT(*) FROM job_contacts" + (" WHERE job_id = ?" if job_id else ""), + ([job_id] if job_id else []), + ).fetchone()[0] + db.close() + return {"total": total, "contacts": [dict(r) for r in rows]} + + # ── GET /api/interviews ──────────────────────────────────────────────────────── PIPELINE_STATUSES = { @@ -1509,7 +1590,7 @@ def list_interviews(): f"SELECT id, title, company, url, location, is_remote, salary, " f"match_score, keyword_gaps, status, " f"interview_date, rejection_stage, " - f"applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, survey_at " + f"applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, survey_at, hired_feedback " f"FROM jobs WHERE status IN ({placeholders}) " f"ORDER BY match_score DESC NULLS LAST", list(PIPELINE_STATUSES), diff --git a/scripts/db.py b/scripts/db.py index 59a069e..a6c0a2a 100644 --- a/scripts/db.py +++ b/scripts/db.py @@ -143,6 +143,8 @@ _MIGRATIONS = [ ("calendar_event_id", "TEXT"), ("optimized_resume", "TEXT"), # ATS-rewritten resume text (paid tier) ("ats_gap_report", "TEXT"), # JSON gap report (free tier) + ("date_posted", "TEXT"), # Original posting date from job board (shadow listing detection) + ("hired_feedback", "TEXT"), # JSON: optional post-hire "what helped" response ] @@ -202,8 +204,8 @@ def insert_job(db_path: Path = DEFAULT_DB, job: dict = None) -> Optional[int]: try: cursor = conn.execute( """INSERT INTO jobs - (title, company, url, source, location, is_remote, salary, description, date_found) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (title, company, url, source, location, is_remote, salary, description, date_found, date_posted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( job.get("title", ""), job.get("company", ""), @@ -214,6 +216,7 @@ def insert_job(db_path: Path = DEFAULT_DB, job: dict = None) -> Optional[int]: job.get("salary", ""), job.get("description", ""), job.get("date_found", ""), + job.get("date_posted", "") or "", ), ) conn.commit() diff --git a/scripts/discover.py b/scripts/discover.py index d87212e..1b43c8b 100644 --- a/scripts/discover.py +++ b/scripts/discover.py @@ -307,6 +307,10 @@ def run_discovery(db_path: Path = DEFAULT_DB, notion_push: bool = False, config_ elif job_dict.get("salary_source") and str(job_dict["salary_source"]) not in ("nan", "None", ""): salary_str = str(job_dict["salary_source"]) + _dp = job_dict.get("date_posted") + date_posted_str = ( + _dp.isoformat() if hasattr(_dp, "isoformat") else str(_dp) + ) if _dp and str(_dp) not in ("nan", "None", "") else "" row = { "url": url, "title": _s(job_dict.get("title")), @@ -316,6 +320,7 @@ def run_discovery(db_path: Path = DEFAULT_DB, notion_push: bool = False, config_ "is_remote": bool(job_dict.get("is_remote", False)), "salary": salary_str, "description": _s(job_dict.get("description")), + "date_posted": date_posted_str, "_exclude_kw": exclude_kw, } if _insert_if_new(row, _s(job_dict.get("site"))): diff --git a/web/src/components/AppNav.vue b/web/src/components/AppNav.vue index 5505717..e43dc87 100644 --- a/web/src/components/AppNav.vue +++ b/web/src/components/AppNav.vue @@ -96,6 +96,7 @@ import { NewspaperIcon, Cog6ToothIcon, DocumentTextIcon, + UsersIcon, } from '@heroicons/vue/24/outline' import { useDigestStore } from '../stores/digest' @@ -155,6 +156,7 @@ const navLinks = computed(() => [ { to: '/apply', icon: PencilSquareIcon, label: 'Apply' }, { to: '/resumes', icon: DocumentTextIcon, label: 'Resumes' }, { to: '/interviews', icon: CalendarDaysIcon, label: 'Interviews' }, + { to: '/contacts', icon: UsersIcon, label: 'Contacts' }, { to: '/digest', icon: NewspaperIcon, label: 'Digest', badge: digestStore.entries.length || undefined }, { to: '/prep', icon: LightBulbIcon, label: 'Interview Prep' }, diff --git a/web/src/components/InterviewCard.vue b/web/src/components/InterviewCard.vue index 3ecb1c2..9566f0c 100644 --- a/web/src/components/InterviewCard.vue +++ b/web/src/components/InterviewCard.vue @@ -191,6 +191,37 @@ const columnColor = computed(() => { } return map[props.job.status] ?? 'var(--color-border)' }) + +// ── Hired feedback ───────────────────────────────────────────────────────────── +const FEEDBACK_FACTORS = [ + 'Resume match', + 'Cover letter', + 'Interview prep', + 'Company research', + 'Network / referral', + 'Salary negotiation', +] as const + +const feedbackDismissed = ref(false) +const feedbackSaved = ref(!!props.job.hired_feedback) +const feedbackText = ref('') +const feedbackFactors = ref([]) +const feedbackSaving = ref(false) + +const showFeedbackWidget = computed(() => + props.job.status === 'hired' && !feedbackDismissed.value && !feedbackSaved.value +) + +async function saveFeedback() { + feedbackSaving.value = true + const { error } = await useApiFetch(`/api/jobs/${props.job.id}/hired-feedback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ what_helped: feedbackText.value, factors: feedbackFactors.value }), + }) + feedbackSaving.value = false + if (!error) feedbackSaved.value = true +} + + +
+
+ What helped you land this role? + +
+
+ +
+