File size: 14,865 Bytes
b91193c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
"""Offline preprocessing orchestrator β€” Phase 2."""

from __future__ import annotations

import argparse
import json
import logging
import sys
import time
from collections.abc import Callable
from pathlib import Path

PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))

from app.artifacts_manifest import is_locked, write_manifest  # noqa: E402
from app.bm25_index import build_bm25_index, save_bm25  # noqa: E402
from app.candidates import count_candidates, load_candidates, load_candidates_list  # noqa: E402
from app.config import get_settings  # noqa: E402
from app.embeddings import encode_candidates, save_embeddings  # noqa: E402
from app.feature_store import (  # noqa: E402
    build_features_frame,
    load_silver_tiers,
    validate_features_frame,
    write_features_parquet,
)
from app.features import FEATURE_NAMES  # noqa: E402
from app.jd_requirements import load_or_build_jd_requirements  # noqa: E402
from app.gemini_client import gemini_jd_enabled, gemini_labels_enabled  # noqa: E402
from app.logging_setup import configure_logging  # noqa: E402
from app.progress import format_duration  # noqa: E402

logger = logging.getLogger(__name__)

STEPS = ("jd", "features", "embeddings", "bm25", "labels", "career_recall", "all")


def _candidate_total(candidates_path: Path, limit: int | None) -> int:
    if limit is not None:
        return limit
    return count_candidates(candidates_path)


def _run_career_recall(settings, candidates_path: Path, artifacts_dir: Path) -> None:
    from scripts.build_career_recall_scores import build_scores, TOP_K, MIN_SCORE
    import json

    from app.artifact_names import CAREER_SCORES

    out_path = artifacts_dir / CAREER_SCORES
    scores = build_scores(candidates_path, settings=settings)
    payload = {"scores": scores, "top_k": TOP_K, "min_score": MIN_SCORE}
    out_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
    logger.info("Wrote %d career recall scores to %s", len(scores), out_path)


def _features_stale(settings) -> bool:
    """True when JD was rebuilt after the feature parquet (skill/title features depend on JD)."""
    feat_path = settings.artifact_path("candidate_features.parquet")
    jd_path = settings.jd_requirements_path
    if not feat_path.exists() or not jd_path.exists():
        return False
    return jd_path.stat().st_mtime_ns > feat_path.stat().st_mtime_ns


def run_jd(settings, artifacts_dir: Path, *, force: bool = False, force_llm: bool = False) -> None:
    jd_path = settings.jd_requirements_path
    if jd_path.exists() and not force:
        if gemini_jd_enabled(settings):
            try:
                cached = json.loads(jd_path.read_text(encoding="utf-8"))
                if cached.get("source") != "gemini_pro":
                    logger.info(
                        "Cached JD source=%s but Gemini JD enabled β€” rebuilding with Gemini Pro",
                        cached.get("source", "?"),
                    )
                    force = True
                else:
                    logger.info("Using cached gemini_pro JD requirements at %s", jd_path)
                    return
            except (OSError, json.JSONDecodeError):
                force = True
        else:
            logger.info("Using cached JD requirements at %s", jd_path)
            return
    if not gemini_jd_enabled(settings):
        logger.info("Gemini JD parse disabled β€” building heuristic JD requirements")
    load_or_build_jd_requirements(
        settings.job_description_path,
        settings.jd_requirements_path,
        settings=settings,
        force=force,
    )


def run_features(settings, candidates_path: Path, *, limit: int | None = None, force: bool = False) -> None:
    out_path = settings.artifact_path("candidate_features.parquet")
    if out_path.exists() and not force:
        if _features_stale(settings):
            logger.info("JD requirements newer than features β€” rebuilding feature parquet")
        else:
            logger.info("Features exist at %s β€” skipping (use --force)", out_path)
            return

    total = _candidate_total(candidates_path, limit)
    logger.info("Feature extraction: %d candidates", total)

    jd = load_or_build_jd_requirements(
        settings.job_description_path,
        settings.jd_requirements_path,
        settings=settings,
    )
    silver = load_silver_tiers(settings.artifact_path("labels_silver.parquet"))
    gemini_path = settings.artifact_path("gemini_tiers.parquet")
    gemini: dict[str, int] = {}
    if gemini_path.exists():
        import polars as pl

        gdf = pl.read_parquet(gemini_path)
        gemini = dict(zip(gdf["candidate_id"].to_list(), gdf["gemini_tier"].to_list(), strict=False))

    candidates = load_candidates(candidates_path, limit=limit)
    frame = build_features_frame(
        candidates,
        jd,
        silver_tiers=silver,
        gemini_tiers=gemini,
        total_candidates=total,
    )
    validate_features_frame(frame)
    write_features_parquet(frame, out_path)
    logger.info("Feature columns: %d features + metadata", len(FEATURE_NAMES))


def run_embeddings(
    settings,
    candidates_path: Path,
    *,
    limit: int | None = None,
    force: bool = False,
    device: str | None = None,
    batch_size: int | None = None,
) -> None:
    npy_path = settings.artifact_path("bge_embeddings.npy")
    index_path = settings.artifact_path("faiss.index")
    if npy_path.exists() and index_path.exists() and not force:
        logger.info("Embeddings exist β€” skipping (use --force)")
        return

    from app.embeddings import default_batch_size, resolve_embedding_device

    candidates = load_candidates_list(candidates_path, limit=limit)
    resolved = resolve_embedding_device(device)
    effective_batch = batch_size or default_batch_size(resolved)
    total = len(candidates)
    batches = (total + effective_batch - 1) // effective_batch if total else 0
    logger.info(
        "Embeddings: encoding %d candidates on %s (batch_size=%d, ~%d batches, tqdm bar below)",
        total,
        resolved,
        effective_batch,
        batches,
    )
    t0 = time.perf_counter()
    matrix, ids = encode_candidates(
        candidates,
        show_progress=total > 100,
        device=resolved,
        batch_size=batch_size,
    )
    logger.info("Embeddings: encode finished in %s", format_duration(time.perf_counter() - t0))
    t1 = time.perf_counter()
    save_embeddings(matrix, ids, npy_path=npy_path, index_path=index_path)
    logger.info("Embeddings: saved FAISS + npy in %s", format_duration(time.perf_counter() - t1))


def run_bm25(settings, candidates_path: Path, *, limit: int | None = None, force: bool = False) -> None:
    bm25_path = settings.artifact_path("bm25.pkl")
    if bm25_path.exists() and not force:
        logger.info("BM25 index exists β€” skipping (use --force)")
        return

    total = _candidate_total(candidates_path, limit)
    logger.info("BM25: building index for %d candidates", total)
    candidates = load_candidates_list(candidates_path, limit=limit)
    artifacts = build_bm25_index(candidates)
    save_bm25(artifacts, bm25_path)


def run_labels(
    settings,
    candidates_path: Path,
    artifacts_dir: Path,
    *,
    limit: int | None = None,
    force: bool = False,
    force_llm: bool = False,
) -> None:
    from scripts.label_archetypes import build_gemini_tiers

    out_path = settings.artifact_path("gemini_tiers.parquet")
    if out_path.exists() and not force and not force_llm:
        logger.info("Gemini tiers exist at %s β€” skipping (use --force-llm to re-label)", out_path)
        return

    total = _candidate_total(candidates_path, limit)
    if gemini_labels_enabled(settings):
        logger.info("Gemini archetype labels: %d candidates (API enabled)", total)
    else:
        logger.info("Silver-tier labels only: %d candidates (Gemini labels disabled)", total)

    jd = load_or_build_jd_requirements(
        settings.job_description_path,
        settings.jd_requirements_path,
        settings=settings,
    )
    build_gemini_tiers(
        candidates_path,
        out_path,
        settings=settings,
        jd=jd,
        limit=limit,
        force=force or force_llm,
        skip_api=not gemini_labels_enabled(settings) or (is_locked(artifacts_dir) and not force_llm),
    )


def _run_named_step(
    name: str,
    fn: Callable[[], None],
    *,
    index: int | None = None,
    total_steps: int | None = None,
) -> None:
    if index is not None and total_steps is not None:
        logger.info("=== Pipeline step %d/%d: %s ===", index, total_steps, name)
    else:
        logger.info("=== Step: %s ===", name)
    t0 = time.perf_counter()
    fn()
    logger.info("=== %s complete (elapsed %s) ===", name, format_duration(time.perf_counter() - t0))


def main() -> int:
    parser = argparse.ArgumentParser(description="Run offline preprocessing pipeline (Phase 2).")
    parser.add_argument(
        "--step",
        choices=STEPS,
        default="all",
        help="Pipeline step to run (default: all).",
    )
    parser.add_argument("--candidates", type=Path, default=None)
    parser.add_argument("--limit", type=int, default=None, help="Limit candidates for quick runs.")
    parser.add_argument("--force", action="store_true", help="Rebuild even if artifacts exist.")
    parser.add_argument(
        "--skip-llm",
        action="store_true",
        help="Disable all Gemini calls (same as --no-gemini-jd --no-gemini-labels).",
    )
    parser.add_argument(
        "--gemini-jd",
        action=argparse.BooleanOptionalAction,
        default=None,
        help="Enable/disable Gemini Pro JD parse (default: from REDROB_GEMINI_JD_PARSE).",
    )
    parser.add_argument(
        "--gemini-labels",
        action=argparse.BooleanOptionalAction,
        default=None,
        help="Enable/disable Gemini Flash 100K labels (default: from REDROB_GEMINI_LABELS).",
    )
    parser.add_argument(
        "--force-llm",
        action="store_true",
        help="Re-run Gemini archetype labels even when gemini_tiers.parquet exists.",
    )
    parser.add_argument(
        "--device",
        choices=("cuda", "cpu"),
        default=None,
        help="Embedding device override (default: auto-detect CUDA).",
    )
    parser.add_argument(
        "--batch-size",
        type=int,
        default=None,
        help="Embedding batch size override (default: 256 on CUDA, 64 on CPU).",
    )
    args = parser.parse_args()

    settings = get_settings()
    settings_updates: dict = {}
    if args.skip_llm:
        settings_updates["skip_gemini"] = True
    if args.gemini_jd is not None:
        settings_updates["gemini_jd_parse"] = args.gemini_jd
    if args.gemini_labels is not None:
        settings_updates["gemini_labels"] = args.gemini_labels
    if settings_updates:
        settings = settings.model_copy(update=settings_updates)
    configure_logging(settings.log_level)
    settings.ensure_artifacts_dir()
    artifacts_dir = settings.artifacts_dir.resolve()

    candidates_path = (args.candidates or settings.candidates_path).resolve()
    step = args.step
    pipeline_t0 = time.perf_counter()

    if step == "all":
        total_candidates = _candidate_total(candidates_path, args.limit)
        logger.info(
            "Preprocess pipeline: %d candidates | gemini_jd=%s | gemini_labels=%s | force=%s | force_llm=%s",
            total_candidates,
            gemini_jd_enabled(settings),
            gemini_labels_enabled(settings),
            args.force,
            args.force_llm,
        )
        planned: list[tuple[str, Callable[[], None]]] = [
            ("JD parse", lambda: run_jd(settings, artifacts_dir, force=args.force, force_llm=args.force_llm)),
            (
                "Gemini archetype labels",
                lambda: run_labels(
                    settings,
                    candidates_path,
                    artifacts_dir,
                    limit=args.limit,
                    force=args.force,
                    force_llm=args.force_llm,
                ),
            ),
            (
                "Feature extraction",
                lambda: run_features(settings, candidates_path, limit=args.limit, force=args.force),
            ),
            (
                "BGE embeddings + FAISS",
                lambda: run_embeddings(
                    settings,
                    candidates_path,
                    limit=args.limit,
                    force=args.force,
                    device=args.device,
                    batch_size=args.batch_size,
                ),
            ),
            ("BM25 index", lambda: run_bm25(settings, candidates_path, limit=args.limit, force=args.force)),
        ]
        for idx, (name, fn) in enumerate(planned, start=1):
            _run_named_step(name, fn, index=idx, total_steps=len(planned))
        if not args.force_llm:
            write_manifest(artifacts_dir, locked=is_locked(artifacts_dir))
        logger.info(
            "Preprocess pipeline complete β€” total elapsed %s",
            format_duration(time.perf_counter() - pipeline_t0),
        )
        return 0

    if step == "jd":
        _run_named_step("JD parse", lambda: run_jd(settings, artifacts_dir, force=args.force, force_llm=args.force_llm))
    elif step == "labels":
        _run_named_step(
            "Gemini archetype labels",
            lambda: run_labels(
                settings,
                candidates_path,
                artifacts_dir,
                limit=args.limit,
                force=args.force,
                force_llm=args.force_llm,
            ),
        )
    elif step == "features":
        _run_named_step(
            "Feature extraction",
            lambda: run_features(settings, candidates_path, limit=args.limit, force=args.force),
        )
    elif step == "embeddings":
        _run_named_step(
            "BGE embeddings + FAISS",
            lambda: run_embeddings(
                settings,
                candidates_path,
                limit=args.limit,
                force=args.force,
                device=args.device,
                batch_size=args.batch_size,
            ),
        )
    elif step == "bm25":
        _run_named_step(
            "BM25 index",
            lambda: run_bm25(settings, candidates_path, limit=args.limit, force=args.force),
        )
    elif step == "career_recall":
        _run_named_step(
            "Career recall scores",
            lambda: _run_career_recall(settings, candidates_path, artifacts_dir),
        )

    logger.info("Preprocessing step '%s' complete (elapsed %s).", step, format_duration(time.perf_counter() - pipeline_t0))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())