reranker / src /streamlit_app.py
Hemprasad Badgujar
Fix Docker build OOM; fix missing bm25_prefilter in Streamlit; update docs
6f26961
Raw History Blame Contribute Delete
39.9 kB
"""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()