Boards (signal-weighted, ~150/sweep): - Add TimesJobs (shahidirfan) — richest board: skills+salary+experience+offsite. Build/normalize + register; legacy city alias (New Delhi→Delhi, Bengaluru→Bangalore, Gurugram→Gurgaon) — verified live it returns 0 for "New Delhi", 35 for "Delhi". - Swap LinkedIn harvestapi → curious_coder (structured industry/jobFunction/applicants); harvestapi kept registered as linkedin_v1 fallback. - Drop Indeed from enabled (0% skills, redundant). Budgets: Foundit 40, TimesJobs 35, Naukri 30, LinkedIn 30, WorkIndia 15. Pool — cursorless freshness + safety: - REMOVE pagination/cursors: curious_coder only honors start=0 (start>0 → empty), proven; the rest are date feeds. Freshness now = boards' date-sort + the per-user seen-net + pool_save id-dedup. - Exhaustion guard: a refill adding < POOL_MIN_NEW_PER_REFILL new jobs flags the (user,query) exhausted → the gate relaxes the floor instead of block-fetching dupes (makes the 100/90 floor safe on niche queries). - Storage cap: pool_save trims beyond POOL_MAX_PER_QUERY freshest (kills DB swell). 72h TTL verified. - Thresholds 100/90 (deep pool, pay-for-volume). Scoring / cards: - Evidence-based fallback prose: when Haiku's cards stage gives nothing, the card uses Opus's REAL dimension notes (not a generic "Strong on X") + coverage logging + generic salvage parser. - "Skills & requirements" → "Skills fit" (consistent dimension labels). Bug fixes: - mark_seen dedups ids (was CardinalityViolationError on a duplicate id in one batch). Tests: deep stack contracts + committed board-sample fixture (no cache-pollution flakiness) + offline e2e + opt-in live e2e. 64 pass.
113 lines
6.4 KiB
Python
113 lines
6.4 KiB
Python
"""E2E integration — the full curate machinery (deterministic, offline) + an opt-in LIVE stack run.
|
|
|
|
The offline test mocks the gateway so the WHOLE two-stage curate path runs in CI: Opus-score JSON →
|
|
rubric.aggregate → calibration → floor filter → best-first sort → Haiku prose merge. It asserts the
|
|
contract that matters: an aligned job reaches the 90s, a weak job is DROPPED at the floor, and every
|
|
surviving match decomposes to real evidence.
|
|
|
|
The live test (RUN_LIVE_E2E=1) runs the real new stack + Opus/Haiku — documents the manual verification
|
|
(sweep → ~133 jobs incl. TimesJobs full-signal → scores reach 96).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
|
|
import pytest
|
|
|
|
from app.engine import curate as C
|
|
from app.config import get_settings
|
|
|
|
|
|
# ── deterministic offline e2e (mocks the gateway) ────────────────────────────
|
|
class _Msg:
|
|
def __init__(self, content): self.content = content
|
|
class _Choice:
|
|
def __init__(self, content): self.message = _Msg(content); self.finish_reason = "stop"
|
|
class _Resp:
|
|
def __init__(self, content):
|
|
self.choices = [_Choice(content)]
|
|
self.usage = type("U", (), {"prompt_tokens": 50, "completion_tokens": 50})()
|
|
class _Completions:
|
|
def __init__(self, score_model, score_json, cards_json):
|
|
self._sm, self._s, self._c = score_model, score_json, cards_json
|
|
def create(self, *, model, **kw):
|
|
return _Resp(self._s if model == self._sm else self._c)
|
|
class _Chat:
|
|
def __init__(self, *a): self.completions = _Completions(*a)
|
|
class _FakeClient:
|
|
def __init__(self, score_model, score_json, cards_json): self.chat = _Chat(score_model, score_json, cards_json)
|
|
|
|
|
|
def test_curate_e2e_scores_calibrate_floor_and_decompose(monkeypatch):
|
|
s = get_settings()
|
|
sifted = [
|
|
{"id": "j1", "title": "Senior Sales Manager", "organization": "Acme", "details": {"description": "lead B2B sales"}},
|
|
{"id": "j2", "title": "Sales Manager", "organization": "Beta", "details": {"description": "manage accounts"}},
|
|
{"id": "j3", "title": "Junior Analyst", "organization": "Gamma", "details": {"description": "data entry"}},
|
|
]
|
|
# Opus rates dimensions; j1 = strongly aligned (→ 90s), j2 = good, j3 = weak (raw < floor → dropped).
|
|
score_json = json.dumps({"kept": [
|
|
{"id": "j1", "fit": "fit", "dimensions": {"role": {"score": 100, "note": "same function"},
|
|
"skills": {"score": 92, "note": "B2B + CRM"}, "seniority": {"score": 95, "note": "right level"},
|
|
"location": {"score": 100, "note": "target city"}, "industry": {"score": 90, "note": "fintech"},
|
|
"experience": {"score": 90, "note": "9 yrs"}}},
|
|
{"id": "j2", "fit": "fit", "dimensions": {"role": {"score": 90}, "skills": {"score": 75},
|
|
"seniority": {"score": 80}, "location": {"score": 100}, "experience": {"score": 75}}},
|
|
{"id": "j3", "fit": "stretch", "dimensions": {"role": {"score": 20}, "skills": {"score": 25},
|
|
"seniority": {"score": 30}, "location": {"score": 100}, "experience": {"score": 20}}},
|
|
]})
|
|
cards_json = json.dumps({"cards": [
|
|
{"id": "j1", "archetype": "The Perfect Fit", "one_line": "Strong on role and seniority.",
|
|
"coach_note": "Lead with revenue numbers.", "growth": {"text": "x", "from": 90, "to": 96}},
|
|
{"id": "j2", "archetype": "The Solid Step", "one_line": "Good role match, lighter on skills."},
|
|
]})
|
|
monkeypatch.setattr(C, "curate_enabled", lambda: True)
|
|
monkeypatch.setattr(C, "gateway_client", lambda: _FakeClient(s.CURATE_MODEL, score_json, cards_json))
|
|
|
|
out = C.curate({"title": "Sales Manager"}, {"skills": ["B2B Sales"]}, sifted)
|
|
assert out is not None
|
|
ids = [o["id"] for o in out]
|
|
assert "j3" not in ids, "weak job (raw < floor) must be DROPPED — floor honesty"
|
|
assert ids == ["j1", "j2"], "best-first by the COMPUTED score"
|
|
j1 = out[0]
|
|
assert j1["matchScore"] >= 90, f"aligned job should reach the 90s, got {j1['matchScore']}"
|
|
assert all(o["matchScore"] >= s.MATCH_FLOOR for o in out), "no sub-floor match surfaces"
|
|
# decomposes to real evidence
|
|
bd = {d["name"]: d["score"] for d in j1["match"]["breakdown"]}
|
|
assert len(bd) >= 5 and j1["match"]["archetype"] == "The Perfect Fit"
|
|
assert j1["match"]["coach_note"] == "Lead with revenue numbers."
|
|
|
|
|
|
def test_curate_floor_only_returns_empty_not_unfiltered_fallback(monkeypatch):
|
|
# Opus succeeds but keeps nothing ≥ floor → honest empty deck, NEVER the unfiltered white-box
|
|
s = get_settings()
|
|
sifted = [{"id": "j1", "title": "X", "organization": "Y", "details": {}}]
|
|
score_json = json.dumps({"kept": [{"id": "j1", "fit": "stretch",
|
|
"dimensions": {"role": {"score": 10}, "skills": {"score": 10}, "seniority": {"score": 10},
|
|
"location": {"score": 10}, "experience": {"score": 10}}}]})
|
|
monkeypatch.setattr(C, "curate_enabled", lambda: True)
|
|
monkeypatch.setattr(C, "gateway_client", lambda: _FakeClient(s.CURATE_MODEL, score_json, "{}"))
|
|
out = C.curate({"title": "X"}, None, sifted)
|
|
assert out == [], "all-below-floor → empty, not a fallback that could leak sub-floor matches"
|
|
|
|
|
|
# ── opt-in LIVE e2e (real stack + Opus) ──────────────────────────────────────
|
|
@pytest.mark.skipif(os.environ.get("RUN_LIVE_E2E") != "1", reason="live stack run — set RUN_LIVE_E2E=1")
|
|
def test_live_stack_reaches_90s_with_full_signal():
|
|
import asyncio
|
|
from app.engine import search, sift as _sift
|
|
prefs = {"title": "Sales Manager", "location": ["Mumbai · India"], "role": ["Sales"],
|
|
"industry": ["Fintech"], "experience": ["Senior"],
|
|
"user_context": {"id": "live", "skills": ["B2B Sales", "Account Management", "CRM"],
|
|
"current_role": "Senior Sales Manager", "years_experience": 9}}
|
|
ctx = prefs["user_context"]
|
|
sweep = asyncio.run(search.run_sweep({**prefs, "_fresh": True}, fresh=True))
|
|
assert len(sweep["sources"]) >= 4, "most of the stack should return"
|
|
assert len(sweep["opportunities"]) >= 80, "deck should be deep"
|
|
top, _ = _sift.sift(prefs, ctx, [dict(j) for j in sweep["opportunities"]])
|
|
cur = C.curate(prefs, ctx, top) or []
|
|
scores = [o["matchScore"] for o in cur]
|
|
assert scores and max(scores) >= 90, f"aligned profile should reach 90s, got max {max(scores) if scores else None}"
|
|
assert min(scores) >= get_settings().MATCH_FLOOR, "floor honored"
|