File size: 39,851 Bytes
28c12aa 11c79e6 08c3203 f5e4c7f 28c12aa f5e4c7f 28c12aa 11c79e6 28c12aa 08c3203 11c79e6 28c12aa 99deeee 28c12aa 700e896 99deeee 26f86bb 11c79e6 66cf40d 08c3203 1cbb761 99deeee 08c3203 99deeee 700e896 6f26961 99deeee 11c79e6 66cf40d 28c12aa 11c79e6 28c12aa 11c79e6 28c12aa 11c79e6 99deeee 28c12aa 08c3203 28c12aa cd5f9d4 28c12aa cd5f9d4 28c12aa 08c3203 1cbb761 08c3203 6f26961 1cbb761 08c3203 1cbb761 9fb0e9b 95e1a87 bc9e92d 08c3203 11c79e6 6f26961 11c79e6 1cbb761 11c79e6 99deeee 11c79e6 99deeee 11c79e6 99deeee 11c79e6 1cbb761 11c79e6 1cbb761 11c79e6 95e1a87 ae7dc3e baeef08 a41b023 ae7dc3e 11c79e6 6f26961 11c79e6 99deeee ae7dc3e 99deeee 11c79e6 99deeee 26f86bb 99deeee 26f86bb 99deeee 26f86bb 99deeee 26f86bb 99deeee 26f86bb 99deeee 051be4e 99deeee 26f86bb 051be4e 99deeee 051be4e 99deeee 051be4e 26f86bb 99deeee 26f86bb 700e896 08c3203 11c79e6 700e896 26f86bb 99deeee 11c79e6 700e896 26f86bb 700e896 08c3203 26f86bb 08c3203 26f86bb 28c12aa 11c79e6 700e896 08c3203 11c79e6 26f86bb 11c79e6 26f86bb 08c3203 28c12aa 11c79e6 99deeee 11c79e6 28c12aa 11c79e6 28c12aa 99deeee 28c12aa 11c79e6 99deeee 11c79e6 28c12aa f85f907 99deeee f85f907 99deeee f85f907 99deeee f85f907 11c79e6 f85f907 08c3203 86788de 08c3203 11c79e6 99deeee 11c79e6 b24d6e4 cd5f9d4 28c12aa a41b023 b24d6e4 cd5f9d4 28c12aa 99deeee 28c12aa 0d4730e 28c12aa 99deeee 9f37f2b 99deeee 700e896 11c79e6 700e896 28c12aa 99deeee 28c12aa 0d4730e 28c12aa f85f907 99deeee 26f86bb 99deeee 26f86bb f85f907 99deeee f85f907 99deeee 11c79e6 f5e4c7f 28c12aa | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 | """Streamlit phase explorer (Deployment 2).
Runs the pipeline phases that exist today and renders EACH phase's INPUT and OUTPUT — for every phase the
filters/config that drive it, for the embedders the exact query the candidate text is compared against, plus
row-count, timing, score spread and a preview table — so you can watch the funnel stage by stage: preprocess
(load → validate → materialize → quality) then rank (hard_filter → potion → soft → composite → granite →
cross-encoder → ensemble → reasoning). A per-phase markdown/JSON report can be exported. HF Space entrypoint
(Docker, 8501).
Run locally: uv run streamlit run src/streamlit_app.py
"""
from __future__ import annotations
import json
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
import orjson
import polars as pl
import streamlit as st
from common.checkpoint import checkpoint
from common.io import atomic_write_bytes
from common.logging import get_logger
from common.paths import DEFAULT_CANDIDATES, REPO_ROOT, resolve_candidates
from common.runtime import configure_compute
from config import DebugSettings, Settings, load_settings
from jd import JDSpec, embed_query, extract_spec, read_subjective
from jd.parse import _OBJECTIVE_PROMPT, _SUBJECTIVE_PROMPT, _SYSTEM, extract_objective, extract_subjective
from jd.spec import resolve
from models.llm import index_gen_client
from preprocess import DATASET_AS_OF, load_candidates, preprocess_stages
from rank import add_explanation, bm25_prefilter, composite_narrow, ensemble, hard_filter, soft_filter, write_submission
from rank.composite import ( # reused so the explorer's filter detail matches the engine exactly
_BM25_FIELDS,
_drop_disallowed_countries,
field_scoped_bm25,
)
from rank.hard_filter import _toggle_masks # reused so per-toggle drop counts match the engine exactly
from retrieve import cross_encoder_rerank, granite_precision_score, potion_broad_retrieval
@dataclass
class PhaseOutput:
"""The captured result of running a phase — frame plus the inputs that drove it and the engine metrics."""
name: str
frame: pl.DataFrame
rows_in: int
seconds: float
inputs: dict[str, Any] | None = None # filters/config + embedding query that drove this phase
metrics: dict[str, Any] = field(default_factory=dict) # captured step.end metrics (drop counts, score_max…)
is_funnel: bool = True # False for the JD-parse phases (not part of the candidate funnel)
def _display_frame(frame: pl.DataFrame, n: int = 50) -> pl.DataFrame:
"""ALL columns, renderable: scalars kept as-is; nested list/struct columns stringified (truncated).
Streamlit can't render polars List/Struct columns directly (and pandas isn't a dependency), so nested
columns are converted to a short text repr on the head — letting every column show with values.
"""
head = frame.head(n)
projected: list[pl.Expr] = []
for name, dtype in head.schema.items():
if isinstance(dtype, (pl.List, pl.Struct)):
# list cells arrive as a polars Series → .to_list() for a clean python repr; struct cells are dicts.
projected.append(
pl.col(name).map_elements(
lambda value: str(value.to_list() if hasattr(value, "to_list") else value)[:200],
return_dtype=pl.String,
).alias(name)
)
else:
projected.append(pl.col(name))
return head.select(projected)
@st.cache_resource(show_spinner=False)
def _load_phase_input(path: str, limit: int) -> pl.DataFrame:
"""Load candidates (cached by path+limit so re-runs are instant).
``@st.cache_resource`` (not ``@st.cache_data``) — returns the SAME in-memory object on every cache hit
instead of a fresh copy, avoiding a real copy cost on a 100K-row/119-column frame. Safe because every
downstream stage (validate/materialize/quality/hard_filter/...) is a Polars transform that returns a NEW
frame rather than mutating its input in place, so the cached original is never altered by a caller.
"""
return load_candidates(path, None if limit == 0 else limit)
def _rank_stages(settings: Settings) -> list[tuple[str, Callable[[pl.DataFrame], pl.DataFrame]]]:
"""The rank-phase stages (frame→frame closures), reading the JD spec from the generated extraction."""
spec = extract_spec(settings)
ideal_summary = read_subjective(resolve(settings.jd.extraction_path)).get("ideal_summary", "")
query = embed_query(spec, ideal_summary) # ideal_summary + extracted skills/roles → the semantic query
return [
("hard_filter", lambda frame: hard_filter(frame, settings)),
("bm25_prefilter", lambda frame: bm25_prefilter(frame, spec, settings)),
("potion_broad", lambda frame: potion_broad_retrieval(frame, query, settings)),
("soft_filter", lambda frame: soft_filter(frame, spec, settings)),
("composite_narrow", lambda frame: composite_narrow(frame, spec, settings)),
("granite_precision", lambda frame: granite_precision_score(frame, query, settings)),
("cross_encoder", lambda frame: cross_encoder_rerank(frame, query, settings)),
("ensemble", lambda frame: ensemble(frame, settings)),
("explain", lambda frame: add_explanation(frame, spec, settings)),
# Final stage: write submission.csv (4-col contract) so EVERY pipeline run in the explorer refreshes the
# deliverable, not just the headless CLI. Returns the written submission frame (shown as this phase's output).
("submission", lambda frame: write_submission(frame, settings)),
]
def _phase_inputs(name: str, settings: Settings, spec: JDSpec, ideal_summary: str) -> dict[str, Any] | None:
"""Describe what DRIVES a phase: its filter/config block, JD-derived signals, and (embedders) the query.
Keyed by the phase name the explorer uses (``rank.<stage>`` / ``preprocess.<stage>``). The embedding
phases all compare candidate ``text`` against ``ideal_summary`` — surfaced here as ``embed_query``.
"""
years = {"years_min": spec.years_min, "years_max": spec.years_max}
if name == "rank.hard_filter":
return {"label": "Hard-filter toggles (candidate-only drops)", "config": settings.hard_filter.model_dump()}
if name == "rank.bm25_prefilter":
bm25_fields = {
f"{group} ({'+'.join(columns)})": [term for attr in attrs for term in (getattr(spec, attr, []) or [])]
for group, attrs, columns, _weight in _BM25_FIELDS
}
return {
"label": "BM25 pre-filter (location drop + cheap lexical cut, BEFORE potion/soft exist)",
"config": settings.bm25_prefilter.model_dump(),
"jd_signals": {"acceptable_countries (location DROP; [] = accept all)": spec.acceptable_countries,
"bm25_query_terms_by_field": bm25_fields},
}
if name == "rank.potion_broad":
return {
"label": "Broad retrieval (static potion embedder)", "config": settings.broad_retrieval.model_dump(),
"model": settings.models.embedder_broad, "embed_query": embed_query(spec, ideal_summary), "score_column": "potion_score",
}
if name == "rank.soft_filter":
return {
"label": "Soft-filter penalty weights", "config": settings.soft_filter.model_dump(),
"jd_signals": {"target_industries": spec.target_industries,
"exclude_companies": spec.exclude_companies, **years},
"score_column": "soft_score",
}
if name == "rank.composite_narrow":
# Field-scoped BM25: each group's query terms come from a distinct JD bag, matched vs a distinct text section.
bm25_fields = {
f"{group} ({'+'.join(columns)})": [term for attr in attrs for term in (getattr(spec, attr, []) or [])]
for group, attrs, columns, _weight in _BM25_FIELDS
}
return {
"label": "Composite narrow (location drop + field-scoped BM25 + z-blend)",
"config": settings.composite_narrow.model_dump(),
"jd_signals": {"acceptable_countries (location DROP; [] = accept all)": spec.acceptable_countries,
"bm25_query_terms_by_field": bm25_fields},
"score_column": "composite_score",
}
if name == "rank.granite_precision":
return {
"label": "Granite precision embedding (ONNX bi-encoder)", "config": settings.precision.model_dump(),
"model": settings.models.embedder_precision, "embed_query": embed_query(spec, ideal_summary), "score_column": "granite_score",
}
if name == "rank.cross_encoder":
return {
"label": "Cross-encoder rerank (query+doc together)", "config": settings.rerank.model_dump(),
"model": settings.models.reranker, "embed_query": embed_query(spec, ideal_summary), "score_column": "reranker_score",
}
if name == "rank.ensemble":
return {"label": "Ensemble fusion weights", "config": settings.ensemble.model_dump(), "score_column": "final_score"}
if name == "rank.explain":
r = settings.reasoning
panel: dict[str, Any] = {
"label": "Multi-band explainability (JD-grounded why per finalist + artifact)",
"config": r.model_dump(),
"tiers": {
f"T1 ranks 1-{r.tier1_top}": "Qwen3-1.7B polish (reused, only if mode=llm) · 4 sentences · no gap",
f"T2 ranks {r.tier1_top + 1}-{r.tier2_top}": "pure NLG · 3-4 sentences · no gap",
f"T3 ranks {r.tier2_top + 1}-{r.tier3_top}": "pure NLG · 3-4 sentences · ≤1 soft gap",
f"T4 ranks {r.tier3_top + 1}-100": "pure NLG (static) · 3-4 sentences · ≤1 soft gap",
},
"variant_selection": f"{r.variant_select} (top-{r.variant_topk})",
}
run_summary = REPO_ROOT / "data/reports/run_summary.json"
if run_summary.exists(): # surface the last run's aggregate (tier/signal/gap distributions)
panel["last_run"] = orjson.loads(run_summary.read_bytes())
return panel
if name == "preprocess.quality":
return {"label": "Quality pillar weights (candidate_quality blend)", "config": settings.quality.model_dump()}
return None
def _stage_metrics(
name: str, frame_in: pl.DataFrame, frame_out: pl.DataFrame, settings: Settings, spec: JDSpec
) -> dict[str, Any]:
"""Per-phase drop counts, computed by reusing the engine's own filter helpers (so they match exactly).
The hard filter reports each enabled toggle's independent drop count; the composite reports how many
rows the JD location filter removed and how many got a non-zero BM25 hit. Other phases add nothing here
(their score spread is shown separately from the output frame).
"""
if name == "rank.hard_filter":
masks = _toggle_masks(frame_in, settings.hard_filter)
counts = (
frame_in.select([mask.alias(toggle) for toggle, mask in masks.items()]).sum().row(0, named=True)
if masks else {}
)
return {**{toggle: int(count or 0) for toggle, count in counts.items()},
"dropped (any toggle)": frame_in.height - frame_out.height}
if name == "rank.bm25_prefilter":
location_kept = _drop_disallowed_countries(frame_in, spec)
return {"location_dropped": frame_in.height - location_kept.height}
if name == "rank.composite_narrow":
location_kept = _drop_disallowed_countries(frame_in, spec)
metrics: dict[str, Any] = {"location_dropped": frame_in.height - location_kept.height}
# Field-scoped lexical hits over the survivors (reuses the engine helper; ≤ top_n docs, so cheap).
_blended, hits, _per_field = field_scoped_bm25(frame_out, spec, settings.composite_narrow)
metrics["bm25_matched (survivors, ≥1 field)"] = int((hits > 0).sum())
return metrics
return {}
def _jd_kv_frame(data: dict[str, Any]) -> pl.DataFrame:
"""A 2-column field/value frame of an extracted JD dict — so a JSON phase renders like the others."""
if not data:
return pl.DataFrame({"field": [], "value": []}, schema={"field": pl.String, "value": pl.String})
return pl.DataFrame({"field": [str(k) for k in data], "value": [str(v) for v in data.values()]})
def _jd_phases(settings: Settings, regenerate: bool, on_stage_start: Callable[[str], None] | None = None):
"""YIELDS the two JD-parse LLM phases (objective keyword spec + subjective ideal_summary) as soon as EACH
completes — not batched at the end, so a slow objective call (e.g. weak CPU hardware) is visible on its
own instead of the UI going quiet until both are done. ``on_stage_start``, if given, fires right BEFORE
each (possibly slow) call so the caller can show "currently running: X" while it's still in flight.
``regenerate`` runs the live Qwen3 calls (each timed independently) and atomically rewrites
``jd_extraction.json``; otherwise the existing artifact is read (a near-zero file read, still shown as a
phase). Outputs are non-funnel — their row counts are field counts, not candidate counts.
``regenerate=False`` with NO existing artifact is escalated to a live parse anyway: reading a missing file
silently yields an EMPTY JDSpec, which drives the entire funnel with no filters and no query — the whole
run "succeeds" but ranks on nothing. That's the actual root cause behind repeated
``jd.extract.missing ... empty spec (defaults)`` warnings on a fresh deployment where the artifact was
never generated. "Don't force a re-parse when a good cache exists" must never mean "run with nothing."
"""
extraction_path = resolve(settings.jd.extraction_path)
jd_path = resolve(settings.jd.path)
jd_text = jd_path.read_text(encoding="utf-8") if jd_path.exists() else ""
model = settings.models.index_gen_llm_repo
obj_prompt = _OBJECTIVE_PROMPT.replace("{jd_text}", jd_text or "(JD source missing)")
sum_prompt = _SUBJECTIVE_PROMPT.replace("{jd_text}", jd_text or "(JD source missing)")
if not regenerate and not extraction_path.exists() and jd_text.strip():
get_logger("streamlit").warning(
"streamlit.jd_extraction_missing_forcing_live_parse", path=str(extraction_path),
)
regenerate = True
if regenerate and jd_text.strip():
client = index_gen_client(settings)
if on_stage_start:
on_stage_start("jd.objective_extract")
started = time.perf_counter()
objective = extract_objective(jd_text, client) or {}
obj_seconds = time.perf_counter() - started
source = "live Qwen3 parse"
yield PhaseOutput(
"jd.objective_extract", _jd_kv_frame(objective), 0, obj_seconds,
inputs={"label": f"JD objective extraction — {source}", "model": model, "llm_system": _SYSTEM,
"llm_prompt": obj_prompt},
metrics={"fields_extracted": len(objective)}, is_funnel=False,
)
if on_stage_start:
on_stage_start("jd.summary_extract")
started = time.perf_counter()
subjective = extract_subjective(jd_text, client) or {}
sum_seconds = time.perf_counter() - started
# Persist the measured parse time alongside the spec so cached runs can replay the ORIGINAL timing.
# ``_timing`` is inert to the engine (extract_spec/read_subjective only read the keys they know).
atomic_write_bytes(
extraction_path,
orjson.dumps(
{**objective, "subjective": subjective,
"_timing": {"objective_seconds": obj_seconds, "summary_seconds": sum_seconds}},
option=orjson.OPT_INDENT_2,
),
)
yield PhaseOutput(
"jd.summary_extract", _jd_kv_frame(subjective), 0, sum_seconds,
inputs={"label": f"JD summary extraction — {source}", "model": model, "llm_system": _SYSTEM,
"llm_prompt": sum_prompt},
metrics={"has_ideal_summary": bool(subjective.get("ideal_summary"))}, is_funnel=False,
)
else: # cached read — replay the original parse time recorded in the artifact (else the bare file-read time)
started = time.perf_counter()
raw = orjson.loads(extraction_path.read_bytes()) if extraction_path.exists() else {}
read_seconds = time.perf_counter() - started
raw = raw if isinstance(raw, dict) else {}
subjective = raw.pop("subjective", {}) or {}
timing = raw.pop("_timing", {}) or {}
objective = raw
obj_seconds = float(timing.get("objective_seconds", read_seconds))
sum_seconds = float(timing.get("summary_seconds", 0.0))
source = ("cached jd_extraction.json (original parse time)" if timing
else "cached jd_extraction.json (no recorded time — tick Regenerate to time the live parse)")
yield PhaseOutput(
"jd.objective_extract", _jd_kv_frame(objective), 0, obj_seconds,
inputs={"label": f"JD objective extraction — {source}", "model": model, "llm_system": _SYSTEM,
"llm_prompt": obj_prompt},
metrics={"fields_extracted": len(objective)}, is_funnel=False,
)
yield PhaseOutput(
"jd.summary_extract", _jd_kv_frame(subjective), 0, sum_seconds,
inputs={"label": f"JD summary extraction — {source}", "model": model, "llm_system": _SYSTEM,
"llm_prompt": sum_prompt},
metrics={"has_ideal_summary": bool(subjective.get("ideal_summary"))}, is_funnel=False,
)
def _run_pipeline(
path: str, limit: int, save_checkpoints: bool, regenerate_jd: bool,
on_stage_start: Callable[[str], None] | None = None,
):
"""YIELDS each phase's ``PhaseOutput`` as soon as it finishes — the JD-parse phases, then preprocess
sub-stages, then the rank stages. Never collects everything before returning: on slow hardware a single
phase (JD parse, granite, the LLM polish) can take minutes, and the caller should be able to render/inspect
each phase's own time the moment it's known, not wait for the whole funnel to finish first. ``on_stage_start``,
if given, fires right BEFORE each stage runs — so the caller can show "currently running: X" while a slow
stage is still in flight, instead of only finding out after it finally completes.
Stage sequences come from the same lists the engine folds (``preprocess_stages`` + ``_rank_stages``),
so the explorer mirrors the real pipeline. ``save_checkpoints`` also writes each phase to Parquet. Drop
counts are recomputed here via the engine's own filter helpers (``_toggle_masks`` /
``_drop_disallowed_countries``) so they're single-sourced rather than scraped from logs.
"""
settings = load_settings()
if save_checkpoints: # override the config toggle for this run
settings = settings.model_copy(
update={"debug": DebugSettings(checkpoints=True, checkpoint_dir=settings.debug.checkpoint_dir)}
)
yield from _jd_phases(settings, regenerate_jd, on_stage_start) # JD parse first (writes the artifact if regen)
spec = extract_spec(settings) # reads the (now fresh, if regen) artifact the JD phases just wrote
ideal_summary = read_subjective(resolve(settings.jd.extraction_path)).get("ideal_summary", "")
if on_stage_start:
on_stage_start("preprocess.load")
started = time.perf_counter()
current = _load_phase_input(path, limit)
yield PhaseOutput("preprocess.load", current, current.height, time.perf_counter() - started,
inputs=_phase_inputs("preprocess.load", settings, spec, ideal_summary))
checkpoint(0, "load", current, settings)
stages: list[tuple[str, Callable[[pl.DataFrame], pl.DataFrame]]] = [
*((f"preprocess.{s.name}", s.run) for s in preprocess_stages(DATASET_AS_OF)),
*((f"rank.{name}", fn) for name, fn in _rank_stages(settings)),
]
for index, (name, run) in enumerate(stages, start=1):
if on_stage_start:
on_stage_start(name)
rows_in = current.height
frame_in = current
stage_started = time.perf_counter()
current = run(current)
inputs = _phase_inputs(name, settings, spec, ideal_summary)
metrics = _stage_metrics(name, frame_in, current, settings, spec)
score_column = (inputs or {}).get("score_column") # surface the score maximum alongside the drop counts
if score_column and score_column in current.columns and current.height \
and current.schema[score_column].is_numeric():
metrics[f"{score_column}_max"] = round(float(current.get_column(score_column).max()), 4)
yield PhaseOutput(
name, current, rows_in, time.perf_counter() - stage_started, inputs=inputs, metrics=metrics,
)
checkpoint(index, name.split(".", 1)[-1], current, settings)
def _score_stats(frame: pl.DataFrame, column: str | None) -> dict[str, float] | None:
"""min / mean / max of a numeric score column, if it's present and numeric (else None)."""
if not column or column not in frame.columns or frame.height == 0:
return None
if not frame.schema[column].is_numeric():
return None
row = frame.select(
pl.col(column).min().alias("min"), pl.col(column).mean().alias("mean"), pl.col(column).max().alias("max")
).row(0, named=True)
return {key: round(float(value), 4) for key, value in row.items() if value is not None}
def _render_inputs(output: PhaseOutput) -> None:
"""Render the INPUTS panel: filters/config, JD signals, the embedding-compare query, and engine metrics."""
inputs = output.inputs
st.markdown("**Inputs — filters / config that drove this phase**")
if inputs is None:
st.caption("No tunable filters — this phase transforms every row (no config or query).")
else:
if inputs.get("config"):
st.caption(inputs.get("label", "config"))
st.json(inputs["config"])
if inputs.get("jd_signals"):
st.caption("JD-derived signals (from jd_extraction.json)")
st.json(inputs["jd_signals"])
if inputs.get("model"):
st.caption(f"model: `{inputs['model']}`")
if inputs.get("llm_system"):
st.caption("LLM system prompt (persona — pins Qwen3 to JSON, no thinking loop)")
st.code(inputs["llm_system"], language="text")
if inputs.get("llm_prompt"):
st.markdown("**LLM prompt** — the structured Role-Task-Context-Constraints-Output prompt sent:")
st.text_area(
"llm_prompt", inputs["llm_prompt"], height=260, disabled=True,
label_visibility="collapsed", key=f"p_{output.name}",
)
if "embed_query" in inputs:
st.markdown("**Embedding-compare query** — candidate `text` is scored against this:")
st.text_area(
"embed_query", inputs["embed_query"] or "(none — phase passes through at score 0)",
height=110, disabled=True, label_visibility="collapsed", key=f"q_{output.name}",
)
stats = _score_stats(output.frame, (inputs or {}).get("score_column"))
if stats:
st.caption(f"`{inputs['score_column']}` spread over the output")
cols = st.columns(len(stats))
for col, (label, value) in zip(cols, stats.items()):
col.metric(label, f"{value:.4f}")
if output.metrics:
st.caption("engine metrics (per-rule drop counts, score maxima)")
st.dataframe(
pl.DataFrame({"metric": list(output.metrics.keys()),
"value": [str(v) for v in output.metrics.values()]}),
width="stretch", hide_index=True,
)
def _render_phase(output: PhaseOutput) -> None:
"""Render one phase: metrics row, the INPUTS panel, then the OUTPUT preview, inside an expander."""
with st.expander(f"Phase — {output.name}", expanded=True):
left, mid, right = st.columns(3)
if output.is_funnel:
left.metric("rows out", f"{output.frame.height:,}", delta=output.frame.height - output.rows_in)
else: # JD-parse phase — its frame is the extracted key/value pairs, not candidate rows
left.metric("fields out", f"{output.frame.height:,}")
mid.metric("columns", output.frame.width)
right.metric("seconds", f"{output.seconds:.2f}")
in_col, out_col = st.columns([2, 3])
with in_col:
_render_inputs(output)
with out_col:
out_label = "rows after this phase" if output.is_funnel else "extracted JD fields (key / value)"
st.markdown(f"**Output — {out_label}**")
st.caption(f"all {output.frame.width} columns (first 50 rows; nested columns shown as text)")
st.dataframe(_display_frame(output.frame), width="stretch")
with st.popover("Column dtypes"):
schema = pl.DataFrame(
{"column": list(output.frame.columns),
"dtype": [str(dtype) for dtype in output.frame.dtypes]}
)
st.dataframe(schema, width="stretch", height=400)
def _render_timing_summary(outputs: list[PhaseOutput]) -> None:
"""Top-of-page overview: total wall-clock (incl. JD parse), the candidate funnel, and a per-phase table."""
total_seconds = sum(output.seconds for output in outputs) # includes the JD-parse phases
slowest = max(outputs, key=lambda output: output.seconds)
funnel = [output for output in outputs if output.is_funnel] or outputs # JD phases excluded from the funnel
left, mid, right = st.columns(3)
left.metric("Total time", f"{total_seconds:.1f}s")
mid.metric("Funnel", f"{funnel[0].frame.height:,} → {funnel[-1].frame.height:,}")
right.metric("Slowest phase", f"{slowest.name.split('.')[-1]} ({slowest.seconds:.1f}s)")
overview = pl.DataFrame({
"phase": [output.name for output in outputs],
"rows_in": [output.rows_in for output in outputs],
"rows_out": [output.frame.height for output in outputs],
"seconds": [round(output.seconds, 2) for output in outputs],
"% of total": [round(100 * output.seconds / total_seconds, 1) if total_seconds else 0.0 for output in outputs],
})
st.dataframe(overview, width="stretch")
def _render_jd_panel(spec: JDSpec, ideal_summary: str) -> None:
"""Show the JD spec that drives the funnel (loaded from the generated jd_extraction.json)."""
with st.expander("JD spec (from jd_extraction.json — drives filters + retrieval)", expanded=False):
st.json({
"job_titles": spec.job_titles, "must_have_skills": spec.must_have_skills,
"nice_to_have": spec.nice_to_have, "target_industries": spec.target_industries,
"years": f"{spec.years_min:.0f}–{spec.years_max:.0f}",
})
st.text_area("ideal_summary (potion embedding query)", ideal_summary or "(none)", height=120, disabled=True)
def _scalar_preview(frame: pl.DataFrame, n: int = 5) -> list[dict[str, Any]]:
"""First ``n`` rows over the scalar columns only (nested columns dropped) — for the exported report."""
scalar = [name for name, dtype in frame.schema.items() if not isinstance(dtype, (pl.List, pl.Struct))]
return frame.select(scalar).head(n).to_dicts() if scalar else []
def _report_payload(outputs: list[PhaseOutput], spec: JDSpec, ideal_summary: str) -> dict[str, Any]:
"""Assemble the exportable report — JD spec plus each phase's inputs, outputs, metrics and a small preview."""
return {
"jd_spec": {
"job_titles": spec.job_titles, "must_have_skills": spec.must_have_skills,
"nice_to_have": spec.nice_to_have, "target_industries": spec.target_industries,
"exclude_companies": spec.exclude_companies, "acceptable_countries": spec.acceptable_countries,
"years_min": spec.years_min, "years_max": spec.years_max,
},
"ideal_summary": ideal_summary,
"phases": [
{
"name": output.name, "rows_in": output.rows_in, "rows_out": output.frame.height,
"delta": output.frame.height - output.rows_in, "seconds": round(output.seconds, 3),
"columns": output.frame.width, "inputs": output.inputs, "metrics": output.metrics,
"score_stats": _score_stats(output.frame, (output.inputs or {}).get("score_column")),
"preview": _scalar_preview(output.frame),
}
for output in outputs
],
}
def _report_markdown(payload: dict[str, Any]) -> str:
"""Render the report payload as readable markdown (one section per phase: input → output)."""
lines = ["# Reranker — per-phase input/output report", "", "## JD spec", "",
"```json", json.dumps(payload["jd_spec"], indent=2, ensure_ascii=False), "```", "",
"**ideal_summary (embedding-compare query):**", "", f"> {payload['ideal_summary'] or '(none)'}", "",
"## Phases", ""]
for phase in payload["phases"]:
lines.append(f"### {phase['name']} ({phase['rows_in']:,} → {phase['rows_out']:,}, "
f"Δ{phase['delta']:+,}, {phase['seconds']:.2f}s)")
inputs = phase["inputs"]
if inputs:
if inputs.get("config"):
lines += ["", f"_{inputs.get('label', 'config')}:_", "",
"```json", json.dumps(inputs["config"], indent=2, ensure_ascii=False), "```"]
if inputs.get("jd_signals"):
lines += ["", "_JD signals:_", "",
"```json", json.dumps(inputs["jd_signals"], indent=2, ensure_ascii=False), "```"]
if inputs.get("model"):
lines += ["", f"_model:_ `{inputs['model']}`"]
if inputs.get("llm_system"):
lines += ["", "_LLM system prompt:_", "", "```text", inputs["llm_system"], "```"]
if inputs.get("llm_prompt"):
lines += ["", "_LLM prompt:_", "", "```text", inputs["llm_prompt"], "```"]
if "embed_query" in inputs:
lines += ["", "_embedding-compare query:_", "", f"> {inputs['embed_query'] or '(none)'}"]
else:
lines += ["", "_No tunable filters — transforms every row._"]
if phase["score_stats"]:
lines += ["", f"_score spread:_ `{json.dumps(phase['score_stats'])}`"]
if phase["metrics"]:
lines += ["", f"_engine metrics:_ `{json.dumps(phase['metrics'], ensure_ascii=False)}`"]
lines.append("")
return "\n".join(lines)
def _render_export(outputs: list[PhaseOutput], spec: JDSpec, ideal_summary: str, persist: bool) -> None:
"""Offer the report as markdown + JSON downloads; optionally also write it under data/reports/."""
payload = _report_payload(outputs, spec, ideal_summary)
markdown = _report_markdown(payload)
json_text = json.dumps(payload, indent=2, ensure_ascii=False, default=str)
st.subheader("Export — per-phase input/output report")
left, right = st.columns(2)
left.download_button("Download report.md", markdown, file_name="phase_report.md",
mime="text/markdown", width="stretch")
right.download_button("Download report.json", json_text, file_name="phase_report.json",
mime="application/json", width="stretch")
if persist:
reports_dir = REPO_ROOT / "data" / "reports"
reports_dir.mkdir(parents=True, exist_ok=True)
(reports_dir / "phase_report.md").write_text(markdown, encoding="utf-8")
(reports_dir / "phase_report.json").write_text(json_text, encoding="utf-8")
st.success(f"Report written to {reports_dir / 'phase_report.md'} and phase_report.json")
@st.cache_resource(show_spinner="Warming models into memory (one-time per server process)…")
def _warm_models(_settings: Settings) -> bool:
"""Eagerly load every model into the process's memory ONCE per server process — not once per session/rerun
(``_settings`` is underscore-prefixed so Streamlit doesn't try to hash the Pydantic object; the cache key is
just "has this ever run", which is exactly what we want here).
``rank.py --setup`` at Docker BUILD time only downloads weights to disk (a separate, short-lived process —
its warm ``@lru_cache`` is gone the moment that process exits). The actual Streamlit server starts a FRESH
process with an empty cache, so without this, the first real request pays the full cold-load cost (the LLM
alone takes ~7s) instead of it happening once at server startup, before any user is waiting on it.
"""
from retrieve.granite import _load_granite
from retrieve.potion import _load_potion
from retrieve.reranker import _load_reranker
log = get_logger("streamlit")
log.info("streamlit.warm_models_start")
started = time.perf_counter()
index_gen_client(_settings).ensure()
_load_potion(_settings.models.embedder_broad)
threads = _settings.compute.cpu_threads
_load_granite(_settings.models.embedder_precision, _settings.precision.onnx_file, _settings.precision.max_length, threads)
_load_reranker(_settings.models.reranker, _settings.rerank.onnx_file, _settings.rerank.max_length, threads)
log.info("streamlit.warm_models_complete", seconds=round(time.perf_counter() - started, 3))
return True
@st.cache_resource(show_spinner="Warming candidate data into memory (one-time per server process)…")
def _warm_candidates(_settings: Settings) -> bool:
"""Eagerly populate ``_load_phase_input``'s cache with the FULL candidate pool, ONCE per server process —
same pattern as ``_warm_models`` (``_settings`` underscore-prefixed so Streamlit doesn't hash the Pydantic
object; the cache key is just "has this run once").
Warms ``limit=0`` (the full ~100K pool) specifically. The sidebar's own default is ``limit=1000``, which
is already fast (reads only the first 1000 JSONL lines, never touches the rest of the 465MB file) and
doesn't need this. ``limit=0`` is the case that matches the real production reproduce run — even on a
genuinely cold container (no Parquet cache yet), this hides the ~13-20s parse inside server startup
instead of the user's first "Run pipeline" click, the same way ``_warm_models`` hides the model cold-load
cost. In a built image (``rank.py --setup`` already ran), ``load_candidates`` itself reads straight from
its own Parquet cache (~1-2s) — this call is then near-instant, just populating the SESSION-level cache
too so later reruns skip even that.
"""
log = get_logger("streamlit")
log.info("streamlit.warm_candidates_start")
started = time.perf_counter()
_load_phase_input(str(DEFAULT_CANDIDATES), 0)
log.info("streamlit.warm_candidates_complete", seconds=round(time.perf_counter() - started, 3))
return True
def main() -> None:
"""The Streamlit page."""
settings = load_settings() # safe to load before configure_compute() — config loading imports no compute libs
configure_compute(settings.compute.cpu_threads) # thread-pool budget (covers the lazy onnxruntime imports too)
st.set_page_config(page_title="Reranker — phase explorer", layout="wide") # MUST be the first st.* call
_warm_models(settings) # runs once per server process, on the FIRST page load — before "Run pipeline"
_warm_candidates(settings) # same — pre-parses the full candidate pool so a limit=0 run isn't cold either
st.title("Reranker — per-phase explorer")
st.caption(
"JD→top-100 candidate ranker (one online run, ≤5 min, CPU). Each pipeline phase below shows its "
"INPUT (filters/config, LLM prompt, or embedding-compare query) and OUTPUT — JD parse "
"(objective_extract→summary_extract) then preprocess (load→validate→materialize→quality) then rank "
"(hard_filter→potion→soft→composite→granite→cross_encoder→ensemble→reasoning). Total time includes the "
"JD-parse phases."
)
with st.sidebar:
st.header("Run controls")
candidates_path = st.text_input("Candidates file", str(DEFAULT_CANDIDATES))
limit = int(st.number_input("Max candidates (0 = all)", min_value=0, value=1000, step=500))
regenerate_jd = st.checkbox(
"Regenerate JD extraction (live LLM parse)", value=False,
help="re-run the JD parse (Qwen3) to write a fresh data/jd_extraction.json before ranking; "
"off = use the existing file",
)
save_checkpoints = st.checkbox(
"Save each phase to Parquet", value=False, help="writes data/reports/debug/NN_<phase>.parquet"
)
persist_report = st.checkbox(
"Also write report to data/reports/", value=False,
help="persist phase_report.md + .json on disk (downloads are always offered)",
)
run_clicked = st.button("Run pipeline", type="primary", width="stretch")
# On-disk snapshot before the run; the JD-parse phases below show the extraction actually used this run.
_render_jd_panel(
extract_spec(settings),
read_subjective(resolve(settings.jd.extraction_path)).get("ideal_summary", ""),
)
if not run_clicked:
st.info("Set options in the sidebar and click **Run pipeline**.")
return
candidates = resolve_candidates(candidates_path)
if not candidates.exists():
st.error(f"File not found: {candidates_path} (also tried under the repo root).")
return
run_started = time.perf_counter()
spinner_msg = "Parsing JD (live Qwen3) then running phases…" if regenerate_jd else "Running phases…"
st.subheader("Timing")
# Live-updating placeholders: rewritten after EVERY phase (not once at the end), and a "currently running"
# line that fires BEFORE a phase starts — so a slow phase (e.g. JD parse or LLM polish on weak CPU hardware)
# is visible while it's still in flight instead of leaving the page blank until the whole funnel finishes.
timing_placeholder = st.empty()
status_placeholder = st.empty()
outputs: list[PhaseOutput] = []
def _on_stage_start(name: str) -> None:
status_placeholder.info(f"▶ Running **{name}** — elapsed so far: {time.perf_counter() - run_started:.1f}s")
with st.spinner(spinner_msg):
for output in _run_pipeline(
str(candidates), limit, save_checkpoints, regenerate_jd, on_stage_start=_on_stage_start
):
outputs.append(output)
with timing_placeholder.container():
_render_timing_summary(outputs)
_render_phase(output)
status_placeholder.empty()
wall_seconds = time.perf_counter() - run_started
# Spec/summary as written by the JD phases this run (fresh if regenerated) — for the export header.
spec = extract_spec(settings)
ideal_summary = read_subjective(resolve(settings.jd.extraction_path)).get("ideal_summary", "")
funnel = [output for output in outputs if output.is_funnel]
st.caption(f"wall-clock (incl. model loads): {wall_seconds:.1f}s")
st.success(
f"{len(outputs)} phase(s) complete (incl. 2 JD-parse): "
f"{funnel[0].frame.height:,} → {funnel[-1].frame.height:,}."
)
_render_export(outputs, spec, ideal_summary, persist_report)
main()
|