From 2ff0338da438495a281af96abaa5d8fe9e562460 Mon Sep 17 00:00:00 2001 From: BigRoge Date: Tue, 21 Jul 2026 16:52:49 +1000 Subject: [PATCH] Add offline match CLI demo --- data/samples/jobs_match_demo.json | 38 +++++++++++++ data/samples/resume_match_demo.json | 8 +++ src/nerajob/cli.py | 88 +++++++++++++++++++++++++++++ tests/test_offline_match_cli.py | 26 +++++++++ 4 files changed, 160 insertions(+) create mode 100644 data/samples/jobs_match_demo.json create mode 100644 data/samples/resume_match_demo.json diff --git a/data/samples/jobs_match_demo.json b/data/samples/jobs_match_demo.json new file mode 100644 index 0000000..9e1d4f9 --- /dev/null +++ b/data/samples/jobs_match_demo.json @@ -0,0 +1,38 @@ +[ + { + "id": "match-demo-1", + "source": "fixture", + "title": "Senior Python Backend Engineer", + "company": "Remote APIs Co", + "location": "Remote", + "url": "https://example.com/jobs/python-backend", + "description": "Build FastAPI services backed by PostgreSQL and Docker-based delivery.", + "tags": ["python", "fastapi", "postgresql", "docker"], + "salary": "$110k-$145k", + "remote": true + }, + { + "id": "match-demo-2", + "source": "fixture", + "title": "Frontend Product Engineer", + "company": "Web Studio", + "location": "Remote", + "url": "https://example.com/jobs/frontend-product", + "description": "Build customer-facing React and TypeScript interfaces.", + "tags": ["react", "typescript", "css"], + "salary": "$90k-$125k", + "remote": true + }, + { + "id": "match-demo-3", + "source": "fixture", + "title": "Data Platform Engineer", + "company": "Data Flow Labs", + "location": "Hybrid", + "url": "https://example.com/jobs/data-platform", + "description": "Operate data pipelines with Python, SQL, Airflow, and cloud infrastructure.", + "tags": ["python", "sql", "airflow", "cloud"], + "salary": "$120k-$150k", + "remote": false + } +] diff --git a/data/samples/resume_match_demo.json b/data/samples/resume_match_demo.json new file mode 100644 index 0000000..9af712a --- /dev/null +++ b/data/samples/resume_match_demo.json @@ -0,0 +1,8 @@ +{ + "full_name": "Jordan Python", + "email": "jordan@example.com", + "location": "Remote", + "headline": "Python Backend Engineer", + "summary": "Backend engineer focused on APIs, data services, and reliable delivery.", + "skills": ["Python", "FastAPI", "PostgreSQL", "Docker"] +} diff --git a/src/nerajob/cli.py b/src/nerajob/cli.py index 6692212..76024b4 100644 --- a/src/nerajob/cli.py +++ b/src/nerajob/cli.py @@ -467,6 +467,94 @@ def jobs_match( console.print(table) +@app.command("match") +def match_cmd( + top: int = typer.Option(10, "--top", "-k", min=1, max=50), + job_id: str | None = typer.Option(None, "--job-id", "-j"), + resume_file: Path | None = typer.Option( + None, + "--resume-file", + "-r", + exists=True, + readable=True, + help="Offline: profile JSON file (instead of stored profile)", + ), + jobs_file: Path | None = typer.Option( + None, + "--jobs-file", + "-f", + exists=True, + readable=True, + help="Offline: jobs JSON file (instead of stored jobs)", + ), + skill_weight: float = typer.Option( + DEFAULT_MATCH_WEIGHTS.skills, + "--skill-weight", + min=0.0, + help="Maximum score contribution from profile skill matches", + ), + title_weight: float = typer.Option( + DEFAULT_MATCH_WEIGHTS.title, + "--title-weight", + min=0.0, + help="Maximum score contribution from headline/title overlap", + ), + location_weight: float = typer.Option( + DEFAULT_MATCH_WEIGHTS.location, + "--location-weight", + min=0.0, + help="Maximum score contribution from location or remote fit", + ), +) -> None: + """Rank jobs against a profile, including offline resume/jobs files.""" + import json + + from nerajob.match import match_score, rank_jobs + from nerajob.models import Profile + + if not resume_file or not jobs_file: + console.print( + "[red]Offline files are required.[/red] Run: nerajob match --resume-file data/samples/resume_match_demo.json --jobs-file data/samples/jobs_match_demo.json" + ) + raise typer.Exit(code=1) + + profile_data = json.loads(resume_file.read_text(encoding="utf-8")) + profile = Profile(**profile_data) + jobs_data = json.loads(jobs_file.read_text(encoding="utf-8")) + jobs = [JobPosting(**j) for j in jobs_data] + console.print(f"[dim]Offline match: {len(jobs)} jobs x {len(profile.skills or [])} skills[/dim]") + + weights = MatchWeights( + skills=skill_weight, + title=title_weight, + location=location_weight, + ) + if job_id: + job = next((j for j in jobs if j.id == job_id), None) + if not job: + console.print(f"[red]Unknown job id:[/red] {job_id}") + raise typer.Exit(1) + console.print_json(data=match_score(profile, job, weights=weights)) + return + + ranked = rank_jobs(profile, jobs, top_k=top, weights=weights) + table = Table(title=f"Job matches (top {len(ranked)})") + table.add_column("Score") + table.add_column("Band") + table.add_column("Title") + table.add_column("Company") + table.add_column("Hits") + for row in ranked: + table.add_row( + str(row["score"]), + str(row["band"]), + str(row["title"])[:40], + str(row["company"])[:24], + ", ".join(row["skill_hits"][:5]), + ) + console.print(table) + + @app_app.command("list") def app_list() -> None: """List all applications with status, job_id, created_at.""" diff --git a/tests/test_offline_match_cli.py b/tests/test_offline_match_cli.py index 78089b2..78d9e80 100644 --- a/tests/test_offline_match_cli.py +++ b/tests/test_offline_match_cli.py @@ -68,6 +68,32 @@ def test_offline_match_with_files(tmp_path: Path) -> None: assert "TestCo" in result.stdout +def test_top_level_offline_match_with_sample_fixtures() -> None: + root = Path(__file__).parent.parent + result = sp.run( + [ + "python", + "-m", + "nerajob", + "match", + "--resume-file", + str(root / "data" / "samples" / "resume_match_demo.json"), + "--jobs-file", + str(root / "data" / "samples" / "jobs_match_demo.json"), + "--top", + "2", + ], + capture_output=True, + text=True, + cwd=root, + ) + assert result.returncode == 0 + assert "Offline match: 3 jobs x 4 skills" in result.stdout + assert "Senior Python" in result.stdout + assert "Backend Engineer" in result.stdout + assert "Frontend Product Engineer" not in result.stdout + + def test_offline_match_python_profile_vs_frontend_jobs(): profile = Profile( headline="Python Backend Engineer",