Files
matchmaking-v2/research/docs/ENGINE_BUILD_ANALYSIS.md
T
raulgupta b1e9cdd182 Engine v2: embeddings sift + Opus curator + Postgres feed persistence
- 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/
2026-06-19 15:51:16 +05:30

172 lines
9.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Engine Build Analysis — from 90 raw cards to a ranked, explained shortlist
> Written 2026-06-19. Companion to `ENGINE_DESIGN.md` (the locked design) + `SIGNAL_AUDIT_V2.md`
> (the signal inventory). This doc is the **build-readiness analysis**: what we have *today*, the gap,
> and the smallest engine that turns the live multi-board sweep into "only the recommended ones, with
> aligned stats." It translates the design into a phased, buildable plan against the real data surface.
---
## 1. The gap (current vs target)
**Today (Slice 1a):** fetch ~90 live jobs → `search.run_sweep` assigns a **placeholder** score
(`matchScore = max(55, 95-i)`, i.e. rank = fetch order) → **all 90** shown. Critically:
- `user_context` (the user's resume/skills/experience) is **fetched by the orchestrator but ignored**
by `handle_run_search` — so nothing is actually matched *to the user*.
- The card's "stats" are **fabricated in the frontend**: `scoutJobToMatchRole()` builds the QX
`breakdown` from `required_skills[:3]` + fixed deltas `[10,-7,-32]`, derives `growth` from that, sets
`reason = "Live from Naukri"`, and `proofReady = matchScore≥84`. **None of it reflects the user.**
**Target:** fetch ~90 → **engine** (filter + rank *against the user* + select) → **top ~20–30** →
each card carries a **real** match %, a real "why picked", a real skill breakdown, and a FIT/STRETCH tag.
Two asks map to two engine outputs:
- *"only show the ones recommended"* → **hard filter + score + top-N selection**.
- *"stats more aligned"* → **move the breakdown/why/growth from fabricated-frontend to real-backend**,
and have the card render what the engine emits.
---
## 2. The input surface we actually have NOW
### Job side — per fetched `ScoutJob` (already extracted by the normalizers)
`title` · `required_skills` (full list in `details.skills`) · `seniority_level` · `location_city` +
`location_mode` · `salary_lpa` / `payLabel` · `industry` · `role_category` · `employment_type` ·
`description` (clean text) · `note` (board) · `offsite_apply` · freshness (**all on-demand = fresh by
construction**; we already drop `isExpired`).
### User side
- **Configure prefs (`ScoutPrefs`) — always present:** `title`, `role[]`, `location[]`, `workMode[]`,
`experience`/seniority, `industry[]`, `years`, `targetComp`, `companyStage`, `availability`,
`dealBreakers[]`, `priorities[]` (rank), `stretch` (safe/balanced/reach).
- **`user_context` (orchestrator-assembled, currently ignored) — present when the user has a profile:**
`skills[]` (resume + live LinkedIn, deduped), `current_role`, `experience_history`, `education`,
`linkedin_headline/summary/experience`. Plus the **QScore quotient vector** (qscore-service).
**Coverage reality:** Configure prefs are guaranteed; `user_context` may be sparse/empty (the "No
resume" state). The engine **must** degrade gracefully (design §3.3 coverage-renormalization) — rank on
what's present, never fabricate, never collapse to a neutral 60 (the v1 bug).
---
## 3. The design (recap) vs what's buildable first
`ENGINE_DESIGN.md` §3 cascade: ① normalize → ② hard-filter → ③ white-box utility → ④ rank-fusion →
⑤ LLM rerank+explain → ⑥ output → (log → learned ranker).
The **full** vision needs ESCO taxonomy + depth-2 KG, contrastive skill embeddings, GPT-5.4 rerank, and
a learned pairwise ranker. **None of that is needed to ship the core value.** Stages ② + ③ + ⑥ —
deterministic, white-box, no LLM, no taxonomy service — already deliver *filter + rank + select +
explain* against the current data. That is the first engine; the rest are precision upgrades.
---
## 4. Phased build
### Phase 0 — Deterministic white-box engine (`app/engine/rank.py`), no LLM, no `user_context` required
The shippable core. Filters 90 → top-N, scores, emits real stats. Works on Configure prefs alone, gets
sharper when `user_context` lands (Phase 1).
**② Hard filter (deterministic gate — the design's point: embeddings can't do this):**
- **Location / work-mode fit** — port `_score_location_fit` + `_location_filter_blocks_match` from the
*old* `matchmaking/app/engine/core.py` (the audit says it did this better): remote→100 (0 if seeker
onsite-only); else city/country substring vs preferred locations; unknown→neutral 60; **gate** blocks
no-fit unless prefs are "Anywhere".
- **Seniority band** — drop jobs far outside the user's level (e.g. a 2-yr seeker vs a Lead role),
governed by `stretch` (reach allows +1 level).
- **Deal-breakers** — exclude on stated breakers where detectable (on-call, on-site, etc.).
- **Freshness** — already fresh; drop any `isExpired`/dead.
**③ White-box utility, coverage-renormalized** — `U = Σ_present c_f·w_f·φ_f / Σ_present c_f·w_f`:
| factor | prior w | φ (0–1) from |
|---|---|---|
| skill_match | .35 | overlap(user.skills, job.required_skills), synonym-normalized; required>preferred |
| experience | .25 | user `years`/seniority vs job `seniority_level` band |
| location | .15 | ported location-fit score |
| salary | .10 | job `salary_lpa` vs `targetComp` band — **present-only** (most are "Not disclosed" → weight redistributes) |
| industry/role | .05+.05 | job `industry`/`role_category` vs prefs |
| semantic | .10 | cheap title/description keyword overlap (NOT embeddings in v0) |
Sparse profile (no `skills`) → skill weight redistributes to location/experience/recency. Never penalize absence.
**⑤′ Selection (the "only recommended" lever)** — sort by U, cut by `stretch`:
`safe`→ U≥0.85 · `balanced`→ U≥0.70 (+ a few stretch) · `reach`→ U≥0.55. **Floor of ~8–10 so the deck
is never empty** (relax threshold if too few pass); **cap ~25–30** (never the full 90).
**FIT vs STRETCH (§3.5):** FIT = skill-gap≈0 (score current overlap, mild over-qual penalty);
STRETCH = one level up / adjacent, scored by **reachability** (fraction of required skills the user has
or is one hop from). Tag each card; the `stretch` pref sets how many STRETCH cards ride along.
**⑥ Aligned stats (emitted by the engine, rendered by the card):**
- `match` = round(U×100) — **real**, not fetch-order.
- `breakdown[]` = the real top dims (matched skills Strong / partial Solid / missing Light) — **from
user↔job skill overlap**, replacing the fabricated `[10,-7,-32]`.
- `growth` = the weakest *reachable* dim + the lift it buys.
- `reason` = a real templated "why" ("Matches 6/8 must-have skills · Senior fit · fresh from Naukri").
- `proofReady` = user is Strong on the job's key skill (real).
- `fit | stretch` tag.
### Phase 1 — Wire `user_context` (the accuracy unlock)
`handle_run_search` reads `params["user_context"]` (already delivered by the worker) → feeds
`skills/current_role/experience_history/education` + QScore quotient into ③. QScore/assessments are
**boosters** (present-only, coverage-penalized), never a penalty when absent. This is the user-arm the
whole product is about — Phase 0 ranks on prefs; Phase 1 ranks on *who the user actually is*.
### Phase 2 — LLM rerank + explanation (GPT-5.4 on top ~20)
Calibrated 0–1 fit + a genuine 1-line "why" + growth nudge; replaces the templated reason on the top
slice. Log soft-scores as cold-start supervision. **Only the top ~20, cached per (role, location)** —
never LLM all 90 (cost/latency).
### Phase 3 — Learned ranker
Accrue SAVE/DISMISS/APPLY labels (the `record_feedback` action) → pairwise/listwise ranker → shift
fusion weight off the expert priors.
---
## 5. The stats-alignment refactor (a contract change)
Right now the card's "intelligence" is **invented in `scoutJobToMatchRole`**. For stats to be honest,
the engine must own them and the ScoutJob must carry a `match` block:
```
match: {
score: number, // U×100
fit: "fit" | "stretch",
reason: string, // real why-picked
breakdown: [{name, score, level}], // real user↔job skill dims
growth?: {text, from, to},
proofReady: boolean,
}
```
The frontend `scoutJobToMatchRole` then **reads `job.match.*`** instead of fabricating it. Net: delete
the fake-breakdown logic; the card becomes a pure renderer of engine output. (Same modal/stats UI — it
just gets real numbers.)
---
## 6. Key decisions & risks
- **Skill normalization without ESCO (v0):** a **seed synonym map** (k8s→Kubernetes, React.js→React,
ML→Machine Learning, JS→JavaScript…) + India tech terms + lowercase/punctuation fold. Beats exact
match; upgrade to ESCO+KG+contrastive later. This is the single biggest accuracy lever (§3.2).
- **Sparse profiles are the norm, not the exception** — coverage-renormalization is load-bearing, not
a nicety. Test the "No resume" path first.
- **Never an empty deck** — selection has a floor; if few pass, lower the bar and label them honestly
("a few reaches").
- **Don't double-count QScore** (already digests interview/roleplay) — §2 of the audit.
- **LLM only on the top slice, cached** — the §3 "retrieve cheap, rerank expensive" discipline.
- **Freshness is free here** — on-demand fetch means every shown listing is seconds old (§6.1).
---
## 7. Recommendation (smallest first step)
Build **Phase 0 + Phase 1 together** as `app/engine/rank.py` — a deterministic, coverage-aware,
white-box ranker that consumes `user_context`, filters 90 → top ~25, and emits the real `match` block.
No LLM, no ESCO service, no new infra; it ships *both* asks ("only recommended" + "aligned stats").
Then add Phase 2 (LLM rerank/explain) once the deterministic core + the frontend `match`-contract are
in. Phase 3 (learned) follows the live save/apply loop.
**Open scoping questions for the build:** (a) wire `user_context` now or rank on Configure prefs first?
(b) how aggressive should default selection be (target deck size)? (c) include the GPT-5.4 rerank in v1
or ship deterministic-only first? (d) seed-synonym map now vs minimal exact+fold to start?