Skip to content
Closed
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
43 changes: 37 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,11 +1,42 @@
# LLM Provider Configuration
# Options: "ollama" or "gemini"
# Selects the backend. When set, this is authoritative; when left unset the
# provider is inferred from the model name.
# Options:
# ollama -> local models via Ollama
# gemini -> Google Gemini HTTP API (needs GEMINI_API_KEY)
# claude -> Anthropic Claude HTTP API (needs ANTHROPIC_API_KEY)
# claude_cli -> Claude Code command-line tool (uses your `claude` login)
# gemini_cli -> Gemini command-line tool (uses your `gemini` login)
LLM_PROVIDER=ollama

# Default model to use
# For Ollama: "gemma3:4b", "qwen3:4b", "mistral:7b", etc.
# For Gemini: "gemini-2.5-pro", "gemini-2.5-flash", etc.
# Default model to use (must match the chosen provider)
# Ollama: "gemma3:4b", "qwen3:4b", "mistral:7b", etc.
# Gemini: "gemini-2.5-pro", "gemini-2.5-flash", etc.
# Claude: "claude-sonnet-4-6", "claude-opus-4-8", "claude-haiku-4-5"
# claude_cli: any model your CLI accepts, e.g. "sonnet", "opus", "haiku"
# gemini_cli: any model your CLI accepts, e.g. "gemini-2.5-pro"
DEFAULT_MODEL=gemma3:4b

# Google Gemini API Key (required if using Gemini provider)
GEMINI_API_KEY=your_gemini_api_key_here
# API keys for the HTTP API providers only. The claude_cli / gemini_cli
# providers do NOT need these. They use your CLI login and ignore these vars.
# Leave them commented out unless you use the `gemini` or `claude` providers;
# a placeholder/invalid value will cause auth errors for the API providers.
# GEMINI_API_KEY=your_gemini_api_key_here
# ANTHROPIC_API_KEY=your_anthropic_api_key_here

# GitHub token (optional but recommended). Raises the GitHub API limit used by
# the enrichment step from 60 to 5000 requests/hour. Create one at
# https://github.com/settings/tokens (no scopes needed for public data).

# Max seconds to wait for a GitHub rate-limit reset before skipping enrichment
# instead of blocking. Default: 60
# GITHUB_MAX_RATE_LIMIT_WAIT=60

# --- CLI provider options (only relevant for claude_cli / gemini_cli) ---
# Override the command/path if the binaries are not named "claude"/"gemini"
# or are not on your PATH.
# CLAUDE_CLI_COMMAND=claude
# GEMINI_CLI_COMMAND=gemini

# Timeout (seconds) for a single CLI invocation. Default: 300
# LLM_CLI_TIMEOUT=300
52 changes: 42 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@

## Overview

Hiring Agent parses a resume PDF to Markdown, extracts sectioned JSON using a local or hosted LLM, augments the data with GitHub profile and repository signals, then produces an objective evaluation with category scores, evidence, bonus points, and deductions. You can run fully local with Ollama or use Google Gemini.
Hiring Agent parses a resume PDF to Markdown, extracts sectioned JSON using a local or hosted LLM, augments the data with GitHub profile and repository signals, then produces an objective evaluation with category scores, evidence, bonus points, and deductions. You can run fully local with Ollama, use the Google Gemini or Anthropic Claude APIs, or drive the models through the **Claude Code** and **Gemini** command-line tools (no API key needed; your existing CLI login is reused).

---

Expand Down Expand Up @@ -85,11 +85,14 @@ Hiring Agent parses a resume PDF to Markdown, extracts sectioned JSON using a lo

The repository pins `.python-version` to 3.11.13.

- **One LLM backend** (either of them)
- **One LLM backend** (any of them)

- **Ollama** for local models
Install from the [official site](https://ollama.com/), then run `ollama serve`.
- **Google Gemini** if you have an API key, get it from [here](https://aistudio.google.com/api-keys).
- **Google Gemini API** if you have an API key, get it from [here](https://aistudio.google.com/api-keys).
- **Anthropic Claude API** if you have an API key, get it from the [Anthropic Console](https://console.anthropic.com/).
- **Claude Code CLI**: install with `npm install -g @anthropic-ai/claude-code`, then run `claude` once to log in. Uses your existing subscription, so no API key is required.
- **Gemini CLI**: install with `npm install -g @google/gemini-cli`, then run `gemini` once to log in. Uses your Google login, so no API key is required.

### Quick setup with pip

Expand Down Expand Up @@ -136,12 +139,18 @@ $ cp .env.example .env

**Environment variables**

| Variable | Values | Description |
| ---------------- | ------------------------------------------- | ---------------------------------------------------------------------- |
| `LLM_PROVIDER` | `ollama` or `gemini` | Chooses provider. Defaults to Ollama. |
| `DEFAULT_MODEL` | for example `gemma3:4b` or `gemini-2.5-pro` | Model name passed to the provider. |
| `GEMINI_API_KEY` | string | Required when `LLM_PROVIDER=gemini`. |
| `GITHUB_TOKEN` | optional | Inherits from your shell environment, improves GitHub API rate limits. |
| Variable | Values | Description |
| -------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `LLM_PROVIDER` | `ollama`, `gemini`, `claude`, `claude_cli`, `gemini_cli` | Chooses the backend. Authoritative when set; otherwise the provider is inferred from the model name. Defaults to Ollama. |
| `DEFAULT_MODEL` | for example `gemma3:4b`, `gemini-2.5-pro`, or `claude-sonnet-4-6` | Model name passed to the provider. Must match the chosen provider. |
| `GEMINI_API_KEY` | string | Required when `LLM_PROVIDER=gemini`. |
| `ANTHROPIC_API_KEY` | string | Required when `LLM_PROVIDER=claude`. |
| `CLAUDE_CLI_COMMAND` | optional, default `claude` | Command/path for the Claude Code CLI (used by `claude_cli`). |
| `GEMINI_CLI_COMMAND` | optional, default `gemini` | Command/path for the Gemini CLI (used by `gemini_cli`). |
| `LLM_CLI_TIMEOUT` | optional, default `300` | Per-call timeout in seconds for the CLI providers. |
| `GITHUB_TOKEN` | optional | Inherits from your shell environment, improves GitHub API rate limits. |

> If a selected provider's prerequisite is missing (an unset API key or a CLI that is not installed/logged in), the agent logs a warning and falls back to Ollama.

Provider mapping lives in `prompt.py` and `models.py`. The `config.py` file has a single flag:

Expand Down Expand Up @@ -259,13 +268,36 @@ What happens:
- Set `DEFAULT_MODEL` to any pulled model, for example `gemma3:4b`
- The provider wrapper in `models.OllamaProvider` calls `ollama.chat`

### Gemini
### Gemini (API)

- Set `LLM_PROVIDER=gemini`
- Set `DEFAULT_MODEL` to a supported Gemini model, for example `gemini-2.0-flash`
- Provide `GEMINI_API_KEY`
- The wrapper in `models.GeminiProvider` adapts responses to a unified format

### Claude (API)

- Set `LLM_PROVIDER=claude`
- Set `DEFAULT_MODEL` to a Claude model, for example `claude-sonnet-4-6`
- Provide `ANTHROPIC_API_KEY`
- The wrapper in `models.AnthropicProvider` calls the Anthropic Messages API and adapts the response to the unified format

### Claude Code CLI

- Set `LLM_PROVIDER=claude_cli`
- Set `DEFAULT_MODEL` to any model your CLI accepts, for example `sonnet` or `claude-sonnet-4-6`
- Make sure the `claude` command is installed and logged in (`claude` once interactively). No API key is needed.
- `models.ClaudeCLIProvider` runs `claude -p --output-format json` as a subprocess, piping the prompt over stdin and parsing the JSON result.

### Gemini CLI

- Set `LLM_PROVIDER=gemini_cli`
- Set `DEFAULT_MODEL` to any model your CLI accepts, for example `gemini-2.5-pro`
- Make sure the `gemini` command is installed and logged in (`gemini` once interactively). No API key is needed.
- `models.GeminiCLIProvider` runs the `gemini` CLI non-interactively, piping the prompt over stdin and capturing stdout.

> **Windows note:** the npm-installed `claude`/`gemini` commands are `.cmd` shims. The providers resolve them via `PATH` and invoke them through `cmd /c` automatically, so no extra configuration is required.

---

## Contributing
Expand Down
74 changes: 50 additions & 24 deletions github.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,48 +43,66 @@ def _fetch_github_api(api_url, params=None):

response = requests.get(api_url, params, timeout=10, headers=headers)
status_code = response.status_code

# Check GitHub rate limit headers
rate_limit_remaining = response.headers.get("X-RateLimit-Remaining")
rate_limit_limit = response.headers.get("X-RateLimit-Limit")
rate_limit_reset = response.headers.get("X-RateLimit-Reset")
logger.info(f"{rate_limit_remaining}/{rate_limit_limit}. Reset at {rate_limit_reset}")

logger.info(
f"{rate_limit_remaining}/{rate_limit_limit}. Reset at {rate_limit_reset}"
)

if rate_limit_remaining is not None and rate_limit_limit is not None:
remaining = int(rate_limit_remaining)
limit = int(rate_limit_limit)

# Log rate limit information and handle proactively

# Handle a nearly-exhausted rate limit. Rather than blocking for a long
# time, only wait when the reset is close; otherwise warn and proceed.
if remaining < 10 and rate_limit_reset:
reset_timestamp = int(rate_limit_reset)
current_timestamp = int(time.time())
wait_seconds = max(0, reset_timestamp - current_timestamp) + 5 # Add 5 second buffer
wait_seconds = max(0, reset_timestamp - current_timestamp) + 5 # 5s buffer
reset_time = datetime.datetime.fromtimestamp(reset_timestamp)

# Cap maximum wait time at 1 hour
max_wait = 3600
if wait_seconds > max_wait:
print(f"⚠️ Rate limit reset time is too far in the future ({wait_seconds}s). Capping wait to {max_wait}s")
wait_seconds = max_wait

logger.error(f"⚠️ GitHub API rate limit low: {remaining}/{limit} requests remaining. Resets at {reset_time}")
print(f"💡 Tip: Set GITHUB_TOKEN environment variable to increase rate limits (60/hour → 5000/hour)")

if wait_seconds > 0:
logger.info(f"⏳ Proactively sleeping for {wait_seconds} seconds until rate limit resets...")

logger.warning(
f"⚠️ GitHub API rate limit nearly exhausted: {remaining}/{limit} remaining "
f"(resets at {reset_time})."
)
if not os.environ.get("GITHUB_TOKEN"):
logger.warning(
"💡 No GITHUB_TOKEN set: limited to 60 requests/hour. Set GITHUB_TOKEN "
"to raise this to 5000/hour and avoid rate-limit waits."
)

try:
max_wait = int(os.environ.get("GITHUB_MAX_RATE_LIMIT_WAIT", "60"))
except ValueError:
max_wait = 60

if 0 < wait_seconds <= max_wait:
logger.info(
f"⏳ Waiting {wait_seconds}s for the GitHub rate limit to reset..."
)
time.sleep(wait_seconds)
print(f"✅ Rate limit should be reset now. Continuing...")
logger.info("✅ GitHub rate limit reset. Continuing...")
elif wait_seconds > max_wait:
logger.warning(
f"⏭️ Rate limit resets in {wait_seconds}s, over the {max_wait}s cap "
f"(GITHUB_MAX_RATE_LIMIT_WAIT). Skipping the wait. GitHub enrichment "
f"may be incomplete. Set GITHUB_TOKEN to avoid this."
)
elif remaining < 100:
logger.info(f"ℹ️ GitHub API rate limit: {remaining}/{limit} requests remaining")

logger.info(
f"ℹ️ GitHub API rate limit: {remaining}/{limit} requests remaining"
)

data = response.json() if response.status_code == 200 else {}

if DEVELOPMENT_MODE and status_code == 200:
try:
os.makedirs("cache", exist_ok=True)
Path(cache_filename).write_text(
json.dumps(data, indent=2, ensure_ascii=False),
encoding='utf-8'
json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
)
except Exception as e:
logger.error(f"Error caching GitHub data to {cache_filename}: {e}")
Expand Down Expand Up @@ -437,7 +455,15 @@ def generate_projects_json(projects: List[Dict]) -> List[Dict]:


def fetch_and_display_github_info(github_url: str) -> Dict:
logger.info(f"{github_url}")
logger.info(f"🔍 Fetching GitHub data for: {github_url}")
if os.environ.get("GITHUB_TOKEN"):
logger.info("🔑 GITHUB_TOKEN detected. GitHub API limit is 5000 requests/hour.")
else:
logger.warning(
"🔓 No GITHUB_TOKEN set. GitHub API is limited to 60 requests/hour, which is "
"easily exhausted (each repo costs an extra call for contributors). "
"Set GITHUB_TOKEN to raise it to 5000/hour."
)
github_profile = fetch_github_profile(github_url)
if not github_profile:
print("\n❌ Failed to fetch GitHub profile details.")
Expand Down
88 changes: 72 additions & 16 deletions llm_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,23 @@

import logging
from typing import Any, Dict, Optional
from models import ModelProvider, OllamaProvider, GeminiProvider
from prompt import MODEL_PROVIDER_MAPPING, GEMINI_API_KEY
from models import (
ModelProvider,
OllamaProvider,
GeminiProvider,
AnthropicProvider,
ClaudeCLIProvider,
GeminiCLIProvider,
)
from prompt import (
MODEL_PROVIDER_MAPPING,
PROVIDER,
PROVIDER_EXPLICITLY_SET,
GEMINI_API_KEY,
ANTHROPIC_API_KEY,
CLAUDE_CLI_COMMAND,
GEMINI_CLI_COMMAND,
)

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -37,26 +52,67 @@ def extract_json_from_response(response_text: str) -> str:
return response_text


def _resolve_provider_type(model_name: str) -> ModelProvider:
"""Decide which provider to use for a given model.

An explicit ``LLM_PROVIDER`` environment variable is authoritative. When it
is not set, the provider is inferred from ``MODEL_PROVIDER_MAPPING`` (the
original, backwards-compatible behaviour).
"""
if PROVIDER_EXPLICITLY_SET:
try:
return ModelProvider(PROVIDER)
except ValueError:
logger.warning(
f"⚠️ Unknown LLM_PROVIDER '{PROVIDER}'. Inferring provider from the model name."
)
return MODEL_PROVIDER_MAPPING.get(model_name, ModelProvider.OLLAMA)


def initialize_llm_provider(model_name: str) -> Any:
"""
Initialize the appropriate LLM provider based on the model name.
Initialize the appropriate LLM provider for the given model.

Supports Ollama, the Google Gemini API, the Anthropic Claude API, and the
Claude Code / Gemini command-line tools. If a provider's prerequisite
(an API key or an installed CLI) is missing, it falls back to Ollama.

Args:
model_name: The name of the model to use

Returns:
An initialized LLM provider (either OllamaProvider or GeminiProvider)
An initialized LLM provider.
"""
# Default to Ollama provider
provider = OllamaProvider()
# If using Gemini and API key is available, use Gemini provider
model_provider = MODEL_PROVIDER_MAPPING.get(model_name, ModelProvider.OLLAMA)
if model_provider == ModelProvider.GEMINI:
if not GEMINI_API_KEY:
logger.warning("⚠️ Gemini API key not found. Falling back to Ollama.")
else:
provider_type = _resolve_provider_type(model_name)

try:
if provider_type == ModelProvider.GEMINI:
if not GEMINI_API_KEY:
raise RuntimeError("GEMINI_API_KEY is not set")
logger.info(f"🔄 Using Google Gemini API provider with model {model_name}")
provider = GeminiProvider(api_key=GEMINI_API_KEY)
else:
logger.info(f"🔄 Using Ollama provider with model {model_name}")
return provider
return GeminiProvider(api_key=GEMINI_API_KEY)

if provider_type == ModelProvider.CLAUDE:
if not ANTHROPIC_API_KEY:
raise RuntimeError("ANTHROPIC_API_KEY is not set")
logger.info(
f"🔄 Using Anthropic Claude API provider with model {model_name}"
)
return AnthropicProvider(api_key=ANTHROPIC_API_KEY)

if provider_type == ModelProvider.CLAUDE_CLI:
logger.info(f"🔄 Using Claude CLI provider with model {model_name}")
return ClaudeCLIProvider(command=CLAUDE_CLI_COMMAND)

if provider_type == ModelProvider.GEMINI_CLI:
logger.info(f"🔄 Using Gemini CLI provider with model {model_name}")
return GeminiCLIProvider(command=GEMINI_CLI_COMMAND)
except Exception as e:
logger.warning(
f"⚠️ Could not initialize the '{provider_type.value}' provider ({e}). "
f"Falling back to Ollama."
)
return OllamaProvider()

logger.info(f"🔄 Using Ollama provider with model {model_name}")
return OllamaProvider()
Loading