- rank.py: get_weights(profile) — when the profile reads non-tech (sales/finance/HR/ops/support/…),
tilt the weights to role(0.16)/industry(0.12)/semantic(0.12), drop skill(0.18). Tech weights
unchanged. (The competency-skill-backfill was tried + REVERTED — uniform per-role competencies
added no discrimination and Phase 2's real-cosine f_semantic already supplies the non-tech signal.)
- curate.py: non-tech instruction — role/responsibility fit is the spine; a missing skill list is NOT
a negative; a strong role+industry+responsibility+seniority match is a genuine ~80, not ~65.
- test_contracts.py: allowlist the new keyword-handled non-tech vocab (active boards keyword-fold;
numeric codes deferred since the active feed/recall paths don't use them).
Regression (120 pairs): MAE 18.4 → 16.1 (all from non-tech: sales 17.1→13.0, ops 16.3→11.7), recall
holds 0.92, tech unchanged. 31 tests pass.
The sift was feeding Opus a magnitude-blind, under-trusted, truncated shortlist — only 68% of the
genuinely-best jobs reached the curator. Fixes:
- embed.py: embed the FULL description (was desc[:600]) + role_category + industry; richer profile
text (current_role + experience summary + seniority). The best semantic signal, fed real content.
- sift.py: magnitude-preserving min-max fusion (was rank-position, which flattened cosine 0.95 vs
0.72) and embedding-LED weights (W_VIBE 0.6 / W_WHITEBOX 0.4). Embed runs FIRST so the white-box
f_semantic reuses the real cosine (threaded as job["_vibe_cosine"]) instead of token overlap.
- config.py: SIFT_TOP_K 18 → 28 (the gate was cutting good jobs before Opus).
- curate.py: richer Opus briefs — full responsibilities (was desc[:500]) + role_category/industry/
seniority/pay, so even the shortlist is fully described.
Regression (120 pairs): sift membership recall 0.68 → 0.92, score MAE 24.7 → 18.4. 31 tests pass.
Phase 0 — measurement gate:
- tests/test_contracts.py: BOARDS↔NORMALIZERS alignment, funnel-shape guards, overlap() None
contract, and the frontend↔backend EXACT-STRING vocab check (scout.ts options must resolve in
coerce.py, else silent keyword-fold).
- tests/run_regression.py + fixtures/regression_set.json (+ _seed_regression.py): a frozen 120-pair
set (tech + non-tech) Opus-judged for recruiter-fit targets. Two metrics: score MAE and SIFT TOP-K
MEMBERSHIP RECALL (do the best jobs reach Opus?). Baseline: MAE 24.7, recall 0.68 — i.e. ~32% of
the genuinely-best jobs are cut before the curator ever sees them (worse for non-tech).
Phase 1 — coverage (India non-tech):
- Enable Indeed (misceres) + enrich indeed_to_scoutjob with details.description + location_mode
(jobType is employment type, not skills → required_skills now []).
- Add WorkIndia (shahidirfan) — India blue/grey-collar non-tech: build_workindia_input +
workindia_to_scoutjob (real skills + description; per-job apply URL from job_id since source_url
is generic and would dedup-collapse the deck).
- Drop Wellfound from the stack (US-startup-heavy + 400s); kept registered-but-off.
- BOARDS_ENABLED = naukri,foundit,linkedin,indeed,workindia. Pool ~90 → ~140 jobs/search.
Regression scores unchanged (engine scoring untouched). 31 tests pass.
Emit a rotating progress message every ~5s while the Apify scrape + Opus curation run,
so the long (30-40s) silent gap can't idle the response relay and improves the wait UX.
- OpportunityState model + repo: persist applied / dismissed / restored status and the
generated resume-builder doc ids (never re-pay to re-tailor). annotate_and_filter drops
dismissed + annotates applied/docs on read; handlers for dismiss/restore/mark_applied/
save_apply_docs.
- Unique decks per run (spend only for NEW jobs, no cache, no re-fetch+dedup band-aid):
per-(user,query) search cursor → LinkedIn page++ and Naukri incremental+stateKey (async
run path in apify_client, since the actor's crawl exceeds the run-sync window); seen-net
excludes already-shown ids (covers Foundit, which can't paginate).
- normalize: every board offsite-first (apply_url prefers the employer/ATS redirect over the
board listing) + offsite_apply flag; real company logos (_logo_url across Naukri logoPath /
LinkedIn company.logo / etc.).
- suggest.py: stage-aware engine (broad -> narrow -> role) — LLM (claude-haiku-4-5)
generates bubbles per stage from the picks so far. Profile-grounded, generalizes
to any field (English professor -> Senior Instructional Designer, etc.); None on
no input -> frontend keeps seeded bubbles
- handle_suggest_bubbles action (run off the event loop) + SUGGEST_MODEL config
- bundled: LLM work (sift/curate/suggest) now via asyncio.to_thread so the Redis
response can publish (fixes the loader hang); get_feed handler -> get_scout_feed
(avoids the course-service action collision)
- A1: fire the hard filter (Apify is precise); embedding vibe-ranker (embed.py)
blends with the floor-free white-box (rank.py) to sift ~90 -> top 18
- A2: Opus curator (curate.py) reads the 18 -> honest <=count + Gemini-voiced
report cards; graceful fallback to the white-box + templated cards
- LLM via opencode.ai/zen gateway (llm.py): Opus chat + direct-OpenAI embeddings
- Run LLM work off the event loop (asyncio.to_thread) so the Redis response publishes
- C1: dedicated Postgres (app/db/) persists the per-user feed; get_scout_feed
replays it so matches survive navigation/refresh
- match contract + report-card fields (schema.py); skills.py; tests/