fix: growth_velocity always 60% bug - handle JSON string and empty work_exp
Browse files
backend/src/ml/feature_builder.py
CHANGED
|
@@ -55,17 +55,29 @@ def _extract_seniority(title: str) -> int:
|
|
| 55 |
|
| 56 |
|
| 57 |
def compute_growth_velocity(work_experience: list[dict], is_funded: bool = False) -> float:
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
base = 0.6 if is_funded else 0.5
|
| 60 |
return base
|
| 61 |
|
| 62 |
-
entries = sorted(
|
| 63 |
seniority_levels = []
|
| 64 |
total_months = 0.0
|
| 65 |
|
| 66 |
for entry in entries:
|
| 67 |
-
if not isinstance(entry, dict):
|
| 68 |
-
continue
|
| 69 |
title = entry.get("title") or entry.get("role") or ""
|
| 70 |
seniority_levels.append(_extract_seniority(title))
|
| 71 |
total_months += _parse_duration_months(entry)
|
|
|
|
| 55 |
|
| 56 |
|
| 57 |
def compute_growth_velocity(work_experience: list[dict], is_funded: bool = False) -> float:
|
| 58 |
+
import json as _json
|
| 59 |
+
|
| 60 |
+
# Handle case where work_experience arrives as a JSON string (not yet parsed)
|
| 61 |
+
if isinstance(work_experience, str):
|
| 62 |
+
try:
|
| 63 |
+
work_experience = _json.loads(work_experience)
|
| 64 |
+
except Exception:
|
| 65 |
+
work_experience = []
|
| 66 |
+
|
| 67 |
+
# Filter to only valid dict entries that have a title/role
|
| 68 |
+
valid_entries = [e for e in (work_experience or []) if isinstance(e, dict) and (e.get("title") or e.get("role"))]
|
| 69 |
+
|
| 70 |
+
if len(valid_entries) < 2:
|
| 71 |
+
# Fallback: compute from YOE-like numeric if available,
|
| 72 |
+
# otherwise use funded signal
|
| 73 |
base = 0.6 if is_funded else 0.5
|
| 74 |
return base
|
| 75 |
|
| 76 |
+
entries = sorted(valid_entries, key=lambda x: x.get("start_date", "") or "")
|
| 77 |
seniority_levels = []
|
| 78 |
total_months = 0.0
|
| 79 |
|
| 80 |
for entry in entries:
|
|
|
|
|
|
|
| 81 |
title = entry.get("title") or entry.get("role") or ""
|
| 82 |
seniority_levels.append(_extract_seniority(title))
|
| 83 |
total_months += _parse_duration_months(entry)
|
frontend/src/app/pipeline/page.tsx
CHANGED
|
@@ -38,6 +38,8 @@ export default function PipelinePage() {
|
|
| 38 |
const [sessionName, setSessionName] = useState("");
|
| 39 |
const [jds, setJds] = useState<JDInput[]>([{ title: "", desc: "" }]);
|
| 40 |
const [file, setFile] = useState<File | null>(null);
|
|
|
|
|
|
|
| 41 |
|
| 42 |
// Architecture state
|
| 43 |
const [state, setState] = useState<PipelineState>(DEFAULT_STATE);
|
|
@@ -178,7 +180,7 @@ export default function PipelinePage() {
|
|
| 178 |
|
| 179 |
for (const jdId of pendingJds) {
|
| 180 |
try {
|
| 181 |
-
await api.triggerMatch(jdId, sessionId);
|
| 182 |
} catch (e: any) {
|
| 183 |
if (e.message === "202_ACCEPTED") {
|
| 184 |
stillPending.push(jdId);
|
|
@@ -318,6 +320,37 @@ export default function PipelinePage() {
|
|
| 318 |
</div>
|
| 319 |
</div>
|
| 320 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
<button onClick={startPipeline}
|
| 322 |
className="w-full py-4 rounded-xl bg-[var(--color-brand)] text-white font-bold tracking-wide shadow-lg shadow-[var(--color-brand-glow)] hover:brightness-110 transition-all active:scale-[0.98]">
|
| 323 |
START AUTOMATED PIPELINE
|
|
|
|
| 38 |
const [sessionName, setSessionName] = useState("");
|
| 39 |
const [jds, setJds] = useState<JDInput[]>([{ title: "", desc: "" }]);
|
| 40 |
const [file, setFile] = useState<File | null>(null);
|
| 41 |
+
const [stage1TopK, setStage1TopK] = useState(100);
|
| 42 |
+
const [stage2TopK, setStage2TopK] = useState(40);
|
| 43 |
|
| 44 |
// Architecture state
|
| 45 |
const [state, setState] = useState<PipelineState>(DEFAULT_STATE);
|
|
|
|
| 180 |
|
| 181 |
for (const jdId of pendingJds) {
|
| 182 |
try {
|
| 183 |
+
await api.triggerMatch(jdId, sessionId, stage1TopK, stage2TopK);
|
| 184 |
} catch (e: any) {
|
| 185 |
if (e.message === "202_ACCEPTED") {
|
| 186 |
stillPending.push(jdId);
|
|
|
|
| 320 |
</div>
|
| 321 |
</div>
|
| 322 |
|
| 323 |
+
<div className="mb-6 border-t border-[var(--color-border-strong)] pt-6">
|
| 324 |
+
<div className="text-xs font-bold text-[var(--color-muted)] mb-4 uppercase tracking-wider">⚡ Neural Reranking Depth</div>
|
| 325 |
+
<div className="grid grid-cols-2 gap-4">
|
| 326 |
+
<div className="bg-[var(--color-surface-2)] border border-[var(--color-border-strong)] rounded-xl p-4">
|
| 327 |
+
<div className="flex justify-between text-xs mb-2">
|
| 328 |
+
<span className="text-[var(--color-muted)] font-medium">Stage 1 — Vector Retrieval</span>
|
| 329 |
+
<span className="font-bold text-[var(--color-brand-light)]">{stage1TopK}</span>
|
| 330 |
+
</div>
|
| 331 |
+
<input type="range" min={10} max={500} step={10} value={stage1TopK}
|
| 332 |
+
className="w-full cursor-pointer"
|
| 333 |
+
onChange={e => { const v = parseInt(e.target.value); setStage1TopK(v); if (stage2TopK > v) setStage2TopK(v); }}
|
| 334 |
+
/>
|
| 335 |
+
<div className="flex justify-between text-[9px] text-[var(--color-dimmer)] mt-1"><span>10 (fast)</span><span>500 (thorough)</span></div>
|
| 336 |
+
</div>
|
| 337 |
+
<div className="bg-[var(--color-surface-2)] border border-[var(--color-border-strong)] rounded-xl p-4">
|
| 338 |
+
<div className="flex justify-between text-xs mb-2">
|
| 339 |
+
<span className="text-[var(--color-muted)] font-medium">Stage 2 — Neural Reranker</span>
|
| 340 |
+
<span className="font-bold text-[var(--color-brand-light)]">{Math.min(stage2TopK, stage1TopK)}</span>
|
| 341 |
+
</div>
|
| 342 |
+
<input type="range" min={5} max={Math.min(stage1TopK, 250)} step={5} value={Math.min(stage2TopK, stage1TopK)}
|
| 343 |
+
className="w-full cursor-pointer"
|
| 344 |
+
onChange={e => setStage2TopK(parseInt(e.target.value))}
|
| 345 |
+
/>
|
| 346 |
+
<div className="flex justify-between text-[9px] text-[var(--color-dimmer)] mt-1"><span>5 (fast)</span><span>{Math.min(stage1TopK, 250)} (deep)</span></div>
|
| 347 |
+
</div>
|
| 348 |
+
</div>
|
| 349 |
+
<p className="text-[9px] text-[var(--color-dimmer)] mt-2 leading-relaxed">
|
| 350 |
+
Stage 2 = cross-encoder neural reranking (CPU-heavy). On Hugging Face ~40 takes ~20s. ~250 may cause timeout.
|
| 351 |
+
</p>
|
| 352 |
+
</div>
|
| 353 |
+
|
| 354 |
<button onClick={startPipeline}
|
| 355 |
className="w-full py-4 rounded-xl bg-[var(--color-brand)] text-white font-bold tracking-wide shadow-lg shadow-[var(--color-brand-glow)] hover:brightness-110 transition-all active:scale-[0.98]">
|
| 356 |
START AUTOMATED PIPELINE
|
frontend/src/app/sessions/[id]/page.tsx
CHANGED
|
@@ -28,8 +28,6 @@ export default function SessionDetailPage({ params }: { params: Promise<{ id: st
|
|
| 28 |
const [weights, setWeights] = useState(DEFAULT_WEIGHTS);
|
| 29 |
const [reranking, setReranking] = useState(false);
|
| 30 |
const [loading, setLoading] = useState(true);
|
| 31 |
-
const [stage1TopK, setStage1TopK] = useState(100); // how many to retrieve from vector DB
|
| 32 |
-
const [stage2TopK, setStage2TopK] = useState(40); // how many to pass to neural reranker
|
| 33 |
|
| 34 |
const debounce = useCallback(<T extends unknown[]>(fn: (...args: T) => void, ms: number) => {
|
| 35 |
let t: ReturnType<typeof setTimeout>;
|
|
@@ -106,7 +104,7 @@ export default function SessionDetailPage({ params }: { params: Promise<{ id: st
|
|
| 106 |
|
| 107 |
const poll = async () => {
|
| 108 |
try {
|
| 109 |
-
const r = await api.triggerMatch(selectedJD, sessionId
|
| 110 |
setMatch(r);
|
| 111 |
setBaseResults(r.results);
|
| 112 |
setMatching(false);
|
|
@@ -233,41 +231,12 @@ export default function SessionDetailPage({ params }: { params: Promise<{ id: st
|
|
| 233 |
<p className="text-sm text-[var(--color-muted)] mt-1">Sifting through vector embeddings & generating insights.</p>
|
| 234 |
</div>
|
| 235 |
) : !match ? (
|
| 236 |
-
<div className="flex flex-col items-center justify-center h-full text-center py-
|
| 237 |
<div className="text-5xl mb-4">🎯</div>
|
| 238 |
<h3 className="text-xl font-bold mb-2">No Match Matrix Found</h3>
|
| 239 |
<p className="text-sm text-[var(--color-muted)] mb-6 max-w-sm">
|
| 240 |
You haven't run the candidates against "{currentJdObj?.title}" yet.
|
| 241 |
</p>
|
| 242 |
-
{/* Pipeline Depth Controls */}
|
| 243 |
-
<div className="bg-[var(--color-surface-2)] border border-[var(--color-border-strong)] rounded-xl p-5 w-full max-w-sm mb-6 text-left space-y-4">
|
| 244 |
-
<div className="text-[10px] uppercase font-bold text-[var(--color-muted)] tracking-wider">Pipeline Depth Settings</div>
|
| 245 |
-
<div>
|
| 246 |
-
<div className="flex justify-between text-xs mb-1">
|
| 247 |
-
<span className="text-[var(--color-muted)]">Stage 1 — Vector Retrieval</span>
|
| 248 |
-
<span className="font-bold text-[var(--color-brand-light)]">{stage1TopK} candidates</span>
|
| 249 |
-
</div>
|
| 250 |
-
<input type="range" min={10} max={500} step={10} value={stage1TopK}
|
| 251 |
-
className="w-full cursor-pointer"
|
| 252 |
-
onChange={e => { const v = parseInt(e.target.value); setStage1TopK(v); if (stage2TopK > v) setStage2TopK(v); }}
|
| 253 |
-
/>
|
| 254 |
-
<div className="flex justify-between text-[9px] text-[var(--color-dimmer)] mt-0.5"><span>10 (fastest)</span><span>500 (most thorough)</span></div>
|
| 255 |
-
</div>
|
| 256 |
-
<div>
|
| 257 |
-
<div className="flex justify-between text-xs mb-1">
|
| 258 |
-
<span className="text-[var(--color-muted)]">Stage 2 — Neural Reranker</span>
|
| 259 |
-
<span className="font-bold text-[var(--color-brand-light)]">{Math.min(stage2TopK, stage1TopK)} candidates</span>
|
| 260 |
-
</div>
|
| 261 |
-
<input type="range" min={5} max={Math.min(stage1TopK, 250)} step={5} value={Math.min(stage2TopK, stage1TopK)}
|
| 262 |
-
className="w-full cursor-pointer"
|
| 263 |
-
onChange={e => setStage2TopK(parseInt(e.target.value))}
|
| 264 |
-
/>
|
| 265 |
-
<div className="flex justify-between text-[9px] text-[var(--color-dimmer)] mt-0.5"><span>5 (fast)</span><span>{Math.min(stage1TopK, 250)} (deep)</span></div>
|
| 266 |
-
</div>
|
| 267 |
-
<p className="text-[9px] text-[var(--color-dimmer)] leading-relaxed">
|
| 268 |
-
⚡ Higher Stage 2 = better accuracy but slower. On Hugging Face CPU, ~40 takes ~20s. ~250 may timeout.
|
| 269 |
-
</p>
|
| 270 |
-
</div>
|
| 271 |
<button onClick={handleMatch} className="px-6 py-3 rounded-xl bg-[var(--color-brand)] text-white font-bold tracking-wide shadow-lg hover:brightness-110 active:scale-95 transition-all">
|
| 272 |
⚡ Execute Match Pipeline
|
| 273 |
</button>
|
|
|
|
| 28 |
const [weights, setWeights] = useState(DEFAULT_WEIGHTS);
|
| 29 |
const [reranking, setReranking] = useState(false);
|
| 30 |
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
|
| 31 |
|
| 32 |
const debounce = useCallback(<T extends unknown[]>(fn: (...args: T) => void, ms: number) => {
|
| 33 |
let t: ReturnType<typeof setTimeout>;
|
|
|
|
| 104 |
|
| 105 |
const poll = async () => {
|
| 106 |
try {
|
| 107 |
+
const r = await api.triggerMatch(selectedJD, sessionId);
|
| 108 |
setMatch(r);
|
| 109 |
setBaseResults(r.results);
|
| 110 |
setMatching(false);
|
|
|
|
| 231 |
<p className="text-sm text-[var(--color-muted)] mt-1">Sifting through vector embeddings & generating insights.</p>
|
| 232 |
</div>
|
| 233 |
) : !match ? (
|
| 234 |
+
<div className="flex flex-col items-center justify-center h-full text-center py-32 animate-fade-in">
|
| 235 |
<div className="text-5xl mb-4">🎯</div>
|
| 236 |
<h3 className="text-xl font-bold mb-2">No Match Matrix Found</h3>
|
| 237 |
<p className="text-sm text-[var(--color-muted)] mb-6 max-w-sm">
|
| 238 |
You haven't run the candidates against "{currentJdObj?.title}" yet.
|
| 239 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
<button onClick={handleMatch} className="px-6 py-3 rounded-xl bg-[var(--color-brand)] text-white font-bold tracking-wide shadow-lg hover:brightness-110 active:scale-95 transition-all">
|
| 241 |
⚡ Execute Match Pipeline
|
| 242 |
</button>
|