Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions PR_DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Pull Request: Add an optional web UI for the scoring pipeline

> Submit this **after** opening the feature-request issue below and commenting that you're working on it (per CONTRIBUTING.md). Fork `interviewstreet/hiring-agent`, branch off `master`, e.g. `feat/web-ui`.

---

## Suggested issue to open first

**Title:** Optional browser UI for running the scoring pipeline

**Body:**
The pipeline is currently CLI-only. For users who want to score a resume without the terminal, a thin local web UI would help: drag-and-drop the PDF, pick the model/key in a settings panel, and watch the same pipeline output stream live. It would wrap the existing `score.main()` without changing CLI behavior. Would a self-contained, optional Flask UI be welcome? Happy to implement.

---

## PR title

```
feat: add optional Flask web UI with live-streamed scoring output
```

## Summary

Adds an optional browser interface on top of the existing pipeline. It does not change the CLI, the scoring logic, the prompts, or any model behavior. The server simply calls the existing `score.main(pdf_path, api_key, model_name)` and streams its stdout/stderr to the page.

## Motivation

`python score.py <pdf>` is great for developers, but reviewers who just want to score a resume have to use the terminal and edit `.env` to switch models or keys. A small local UI lowers that barrier while reusing the exact same pipeline, so results are identical to the CLI.

## What's included

- `app.py` — a small Flask server:
- `GET /` serves the UI.
- `POST /api/upload` accepts a PDF plus model/API-key from the settings panel, starts the pipeline on a background thread.
- `GET /api/stream/<job_id>` streams pipeline output to the browser over Server-Sent Events, with heartbeats and a clean end-of-stream sentinel.
- Friendly handling for an invalid/expired Gemini key and for failed evaluations.
- `frontend/` — a no-framework UI (`index.html`, `app.js`, `style.css`): drag-and-drop PDF upload, a settings panel (model + API key, stored client-side), session history, and a live output console.
- `requirements.txt` — adds `flask` (currently imported by the UI but not declared).
- `Start-Hiring-Agent.bat` — a convenient one-click Windows batch script to activate the environment, start the server, and open the browser.

## What's deliberately unchanged

- No change to `score.py` scoring logic, `prompt.py`/`prompts/` templates, `github.py`, `evaluator.py`, or `models.py` behavior.
- The CLI (`python score.py <pdf>`) works exactly as before. The UI is opt-in.
- No new model providers or prompt formatting; provider-agnostic, per CONTRIBUTING.

## How to run

```bash
pip install -r requirements.txt
python app.py
# open http://127.0.0.1:5000, set your model + Gemini key in Settings, drop a PDF
```

## Testing / smoke checks

- PDF to Markdown, section extraction, GitHub enrichment, and evaluation all run through the unchanged `score.main()`; verified the UI output matches a CLI run on the same resume.
- Gemini run (`gemini-2.5-flash`): identical category scores via UI and CLI.
- Verified invalid-key and non-PDF upload paths return clear errors.
- Formatted with Black (`black .`).

## Notes for reviewers

- The server binds to `127.0.0.1` only and is meant for local use.
- Uploads go to a temp dir keyed by job id; nothing is committed.
- If a web UI is out of scope for this project, I'm happy to instead split out just the `requirements.txt` `flask` fix, or move the UI to a `contrib/` or `examples/` folder.

## Checklist

- [ ] Opened/linked a feature-request issue and claimed it
- [ ] Branched off `master` on my fork
- [ ] CLI behavior unchanged
- [ ] `flask` added to `requirements.txt`
- [ ] Formatted with Black
- [ ] Smoke-tested at least one full run (Gemini)
- [ ] Added a screenshot/GIF of the UI to this PR
20 changes: 20 additions & 0 deletions Start-Hiring-Agent.bat
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
@echo off
echo Starting Hiring Agent...

:: Activate the virtual environment
if exist ".venv\Scripts\activate.bat" (
call .venv\Scripts\activate.bat
) else (
echo Virtual environment not found. Make sure you ran 'python -m venv .venv' and installed requirements.
pause
exit /b
)

:: Wait a second for the server to be ready before opening the browser
timeout /t 2 /nobreak >nul
start http://127.0.0.1:5000

:: Run the local web server
python app.py

pause
185 changes: 185 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"""
Flask web server for the Hiring Agent frontend.
Provides file upload, settings management, and streams the scoring pipeline output in real-time.
"""

import os
import sys
import json
import uuid
import threading
import queue
import io
import contextlib
import tempfile
import shutil
import importlib
from pathlib import Path

from flask import Flask, request, jsonify, Response, send_from_directory, stream_with_context

app = Flask(__name__, static_folder="frontend", static_url_path="")

UPLOAD_DIR = os.path.join(tempfile.gettempdir(), "hiring_agent_uploads")
os.makedirs(UPLOAD_DIR, exist_ok=True)

ENV_FILE = os.path.join(os.path.dirname(__file__), ".env")

# All supported Gemini models (from prompt.py)
GEMINI_MODELS = [
"gemini-2.0-flash",
"gemini-2.0-flash-lite",
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemini-3.5-flash",
"gemini-3.1-flash-lite",
]

# Store active jobs: job_id -> {"queue": Queue, "status": str, "thread": Thread}
jobs = {}

class OutputCapture:
"""Captures both stdout and stderr into a queue for streaming."""

def __init__(self, output_queue, original_stream, stream_name="stdout"):
self.queue = output_queue
self.original = original_stream
self.stream_name = stream_name

def write(self, text):
if text:
self.queue.put(text)
try:
self.original.write(text)
except (UnicodeEncodeError, UnicodeDecodeError):
# Windows console (cp1252) can't render emoji — skip it there,
# the browser stream still gets the full text via the queue.
pass

def flush(self):
self.original.flush()


def run_scoring_pipeline(pdf_path, output_queue, api_key, model_name):
"""Run the scoring pipeline in a thread, capturing all output."""
old_stdout = sys.stdout
old_stderr = sys.stderr

sys.stdout = OutputCapture(output_queue, old_stdout, "stdout")
sys.stderr = OutputCapture(output_queue, old_stderr, "stderr")

try:
from score import main as score_main
score = score_main(pdf_path, api_key=api_key, model_name=model_name)
if score is None:
output_queue.put("\n❌ Evaluation failed to produce a valid report. (Check rate limits or file quality)\n")
output_queue.put("__STATUS__:FAILED")
else:
output_queue.put("\n✅ Evaluation complete.\n")
output_queue.put("__STATUS__:SUCCESS")
except Exception as e:
error_msg = str(e)
if "API_KEY_INVALID" in error_msg or "API key not valid" in error_msg or "400 API key" in error_msg:
output_queue.put(f"\n❌ Error: Your Gemini API Key is invalid or expired. Please update it in Settings.\n")
else:
output_queue.put(f"\n❌ Error: {error_msg}\n")
output_queue.put("__STATUS__:FAILED")
finally:
sys.stdout = old_stdout
sys.stderr = old_stderr
output_queue.put(None) # Sentinel: signals end of stream


@app.route("/")
def index():
return send_from_directory("frontend", "index.html")


# --- Upload & Streaming ---

@app.route("/api/upload", methods=["POST"])
def upload_resume():
"""Handle resume PDF upload and start scoring."""
if "resume" not in request.files:
return jsonify({"error": "No file uploaded"}), 400

file = request.files["resume"]
if file.filename == "":
return jsonify({"error": "No file selected"}), 400

if not file.filename.lower().endswith(".pdf"):
return jsonify({"error": "Only PDF files are accepted"}), 400

# Save file
job_id = str(uuid.uuid4())
save_dir = os.path.join(UPLOAD_DIR, job_id)
os.makedirs(save_dir, exist_ok=True)
pdf_path = os.path.join(save_dir, file.filename)
file.save(pdf_path)

# Get local settings from frontend
api_key = request.form.get("api_key", "").strip()
model_name = request.form.get("model", "gemini-2.5-flash").strip()

if not api_key:
return jsonify({"error": "No API key provided"}), 400

# Create output queue
output_queue = queue.Queue()

# Start scoring in background thread
thread = threading.Thread(
target=run_scoring_pipeline,
args=(pdf_path, output_queue, api_key, model_name),
daemon=True,
)
jobs[job_id] = {
"queue": output_queue,
"status": "running",
"thread": thread,
"pdf_path": pdf_path,
}
thread.start()

return jsonify({"job_id": job_id, "filename": file.filename})


@app.route("/api/stream/<job_id>")
def stream_output(job_id):
"""Stream scoring output as Server-Sent Events."""
if job_id not in jobs:
return jsonify({"error": "Job not found"}), 404

def generate():
q = jobs[job_id]["queue"]
success_flag = False
while True:
try:
msg = q.get(timeout=120)
if msg is None:
# End sentinel
yield f"data: {json.dumps({'type': 'done', 'success': success_flag})}\n\n"
break

if isinstance(msg, str) and msg.startswith('__STATUS__:'):
success_flag = (msg == '__STATUS__:SUCCESS')
continue

yield f"data: {json.dumps({'type': 'output', 'text': msg})}\n\n"
except queue.Empty:
yield f"data: {json.dumps({'type': 'heartbeat'})}\n\n"

return Response(
stream_with_context(generate()),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)


if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000, debug=False, threaded=True)
6 changes: 3 additions & 3 deletions evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,28 @@
DEFAULT_MODEL,
MODEL_PARAMETERS,
MODEL_PROVIDER_MAPPING,
GEMINI_API_KEY,
)
from prompts.template_manager import TemplateManager

logger = logging.getLogger(__name__)


class ResumeEvaluator:
def __init__(self, model_name: str = DEFAULT_MODEL, model_params: dict = None):
def __init__(self, model_name: str = DEFAULT_MODEL, model_params: dict = None, api_key: str = None):
if not model_name:
raise ValueError("Model name cannot be empty")

self.model_name = model_name
self.model_params = model_params or MODEL_PARAMETERS.get(
model_name, {"temperature": 0.5, "top_p": 0.9}
)
self.api_key = api_key
self.template_manager = TemplateManager()
self._initialize_llm_provider()

def _initialize_llm_provider(self):
"""Initialize the appropriate LLM provider based on the model."""
self.provider = initialize_llm_provider(self.model_name)
self.provider = initialize_llm_provider(self.model_name, api_key=self.api_key)

def _load_evaluation_prompt(self, resume_text: str) -> str:
criteria_template = self.template_manager.render_template(
Expand Down
Loading