diff --git a/.env.example b/.env.example index cae00ffb..06861cdc 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 0396ef45..6d9239d9 100644 --- a/README.md +++ b/README.md @@ -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). --- @@ -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 @@ -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: @@ -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 diff --git a/github.py b/github.py index 1c52bc6f..7ecc8423 100644 --- a/github.py +++ b/github.py @@ -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}") @@ -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.") diff --git a/llm_utils.py b/llm_utils.py index 7e1d96d3..f5bc1ae1 100644 --- a/llm_utils.py +++ b/llm_utils.py @@ -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__) @@ -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() diff --git a/models.py b/models.py index e83779e8..44a1f4a6 100644 --- a/models.py +++ b/models.py @@ -1,13 +1,31 @@ +import os +import json +import time +import shutil +import logging +import subprocess from typing import List, Optional, Dict, Tuple, Any, Protocol, runtime_checkable from pydantic import BaseModel, Field, field_validator from enum import Enum +logger = logging.getLogger(__name__) + +# Timeout (in seconds) applied to CLI based providers. Override with the +# LLM_CLI_TIMEOUT environment variable. +try: + CLI_TIMEOUT = int(os.getenv("LLM_CLI_TIMEOUT", "300")) +except ValueError: + CLI_TIMEOUT = 300 + class ModelProvider(Enum): """Enum for supported model providers.""" OLLAMA = "ollama" - GEMINI = "gemini" + GEMINI = "gemini" # Google Gemini HTTP API + CLAUDE = "claude" # Anthropic Claude HTTP API + CLAUDE_CLI = "claude_cli" # Claude Code command-line tool + GEMINI_CLI = "gemini_cli" # Gemini command-line tool @runtime_checkable @@ -19,7 +37,7 @@ def chat( model: str, messages: List[Dict[str, str]], options: Dict[str, Any] = None, - **kwargs + **kwargs, ) -> Dict[str, Any]: """Send a chat request to the LLM provider.""" ... @@ -281,7 +299,7 @@ def chat( model: str, messages: List[Dict[str, str]], options: Dict[str, Any] = None, - **kwargs + **kwargs, ) -> Dict[str, Any]: """Send a chat request to Ollama.""" @@ -324,7 +342,7 @@ def chat( model: str, messages: List[Dict[str, str]], options: Dict[str, Any] = None, - **kwargs + **kwargs, ) -> Dict[str, Any]: """Send a chat request to Google Gemini API.""" # Map options to Gemini parameters @@ -351,3 +369,307 @@ def chat( # Convert Gemini response to Ollama-like format for compatibility return {"message": {"role": "assistant", "content": response.text}} + + +def _split_system_and_user(messages: List[Dict[str, str]]) -> Tuple[str, str]: + """Split chat messages into a combined system string and a user-prompt string. + + All call sites in this project send exactly one system + one user message, + so this is a single-turn helper: any non-system message is treated as + user/prompt text. It is intentionally not meant for multi-turn histories + that include assistant turns (those would be folded into the prompt); the + CLI backends are one-shot and cannot consume a multi-message conversation + anyway. + """ + system_parts: List[str] = [] + user_parts: List[str] = [] + for msg in messages or []: + content = msg.get("content") or "" + if not content: + continue + if msg.get("role") == "system": + system_parts.append(content) + else: + user_parts.append(content) + return "\n\n".join(system_parts), "\n\n".join(user_parts) + + +def _augment_prompt_with_schema( + prompt: str, json_schema: Optional[Dict[str, Any]] +) -> str: + """Append a strict JSON-only instruction describing the expected schema. + + Ollama enforces the ``format`` schema natively; the API/CLI providers below + instead steer the model via the prompt (the same approach the Gemini API + provider relies on) so that ``extract_json_from_response`` can parse it. + """ + if not json_schema: + return prompt + return ( + prompt + "\n\nIMPORTANT: Respond with ONLY a single valid JSON object that " + "conforms to the following JSON Schema. Do not include any prose, " + "explanations, comments, or Markdown code fences.\n\nJSON Schema:\n" + + json.dumps(json_schema) + ) + + +def _resolve_cli_command(command: str) -> List[str]: + """Resolve a CLI command name to an argv prefix runnable via subprocess. + + Handles the Windows case where npm-installed CLIs are ``.cmd``/``.bat`` + shims that ``CreateProcess`` cannot launch directly (they must go through + ``cmd /c``). + """ + resolved = shutil.which(command) + if resolved is None: + raise FileNotFoundError( + f"Could not find the '{command}' CLI on PATH. Install it (and log in), " + f"or point the matching *_CLI_COMMAND environment variable at its full path." + ) + if os.name == "nt" and resolved.lower().endswith((".cmd", ".bat")): + return ["cmd", "/c", resolved] + return [resolved] + + +def _cli_subprocess_env( + strip_keys: Optional[Tuple[str, ...]], +) -> Optional[Dict[str, str]]: + """Copy the current environment minus ``strip_keys`` (or None to inherit). + + Used to keep API-key env vars away from the CLI subprocesses so they + authenticate with their own interactive (subscription) login. + """ + if not strip_keys: + return None + return {k: v for k, v in os.environ.items() if k not in strip_keys} + + +def _run_cli( + cmd: List[str], + prompt: str, + timeout: int, + label: str, + attempts: int = 2, + env: Optional[Dict[str, str]] = None, +) -> str: + """Run a CLI provider command, feeding ``prompt`` over stdin, return stdout. + + Retries a few times on failure since CLI backends are prone to transient + "overloaded"/rate-limit errors. + """ + last_detail = "no output" + for attempt in range(1, attempts + 1): + try: + result = subprocess.run( + cmd, + input=prompt, + capture_output=True, + text=True, + encoding="utf-8", + timeout=timeout, + env=env, + ) + except FileNotFoundError as e: + # Missing binary is not transient, so fail fast. + raise RuntimeError(f"{label} CLI could not be executed: {e}") from e + except subprocess.TimeoutExpired: + last_detail = f"timed out after {timeout} seconds" + else: + if result.returncode == 0: + return result.stdout or "" + # `claude --output-format json` reports failures on stdout (a JSON + # envelope with is_error/result), so surface both streams. + stderr = (result.stderr or "").strip() + stdout = (result.stdout or "").strip() + last_detail = (stderr or stdout or "no output")[:1500] + + if attempt < attempts: + logger.warning( + f"āš ļø {label} CLI attempt {attempt}/{attempts} failed: " + f"{last_detail[:200]}; retrying..." + ) + time.sleep(2 * attempt) + + raise RuntimeError(f"{label} CLI failed after {attempts} attempt(s): {last_detail}") + + +def _parse_claude_cli_output(stdout: str) -> str: + """Extract the assistant text from `claude -p --output-format json` output. + + Falls back to the raw text if the output is not the expected JSON envelope. + """ + text = stdout.strip() + if not text: + return text + try: + data = json.loads(text) + except json.JSONDecodeError: + return text # plain text output (e.g. --output-format text) + if isinstance(data, dict): + if data.get("is_error"): + detail = data.get("result") or data.get("error") or data + raise RuntimeError(f"Claude CLI reported an error: {detail}") + result = data.get("result") + if isinstance(result, str): + return result + return text + + +class AnthropicProvider: + """Anthropic Claude HTTP API provider implementation.""" + + def __init__(self, api_key: str): + import anthropic + + self.client = anthropic.Anthropic(api_key=api_key) + + def chat( + self, + model: str, + messages: List[Dict[str, str]], + options: Dict[str, Any] = None, + **kwargs, + ) -> Dict[str, Any]: + """Send a chat request to the Anthropic Messages API.""" + system_text, user_text = _split_system_and_user(messages) + user_text = _augment_prompt_with_schema(user_text, kwargs.get("format")) + + options = options or {} + params: Dict[str, Any] = { + "model": model, + # max_tokens is required by the Anthropic API. + "max_tokens": int(options.get("max_tokens", 8192)), + "messages": [{"role": "user", "content": user_text}], + } + if system_text: + params["system"] = system_text + if "temperature" in options: + params["temperature"] = options["temperature"] + if "top_p" in options: + params["top_p"] = options["top_p"] + + response = self.client.messages.create(**params) + + # Concatenate all text blocks from the response. + content = "".join( + block.text + for block in response.content + if getattr(block, "type", "") == "text" + ) + + # Convert to Ollama-like format for compatibility. + return {"message": {"role": "assistant", "content": content}} + + +# A short, non-agentic system prompt used to REPLACE Claude Code's default +# coding-agent system prompt when driving it as a plain LLM. This avoids +# re-sending the large (~20k token) agent prompt on every call and stops the +# model from behaving like a coding agent (which can fail in headless mode). +_CLI_DEFAULT_SYSTEM_PROMPT = ( + "You are a precise data-extraction and evaluation engine. Follow the user's " + "instructions exactly and respond with only what they ask for." +) + +# API-key env vars that make the CLIs use external-API-key auth. The *_cli +# providers strip these so the CLI authenticates with its own interactive +# (subscription) login; otherwise a stray or placeholder key in the +# environment (e.g. copied from .env.example) causes 401 "Invalid API key". +_CLAUDE_CLI_AUTH_ENV_VARS = ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN") +_GEMINI_CLI_AUTH_ENV_VARS = ("GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GENAI_API_KEY") + + +class ClaudeCLIProvider: + """Claude Code CLI provider (invokes the local `claude` binary). + + Runs ``claude -p`` as a lean, non-agentic one-shot: it replaces the default + coding-agent system prompt (``--system-prompt``) and disables all tools + (``--tools ""``) so it behaves like a plain text-in/JSON-out model. By + default it authenticates with your ``claude`` login and ignores any + ``ANTHROPIC_API_KEY`` in the environment (set ``use_cli_auth=False`` to let + the CLI pick up the API key instead). + """ + + def __init__( + self, + command: str = "claude", + timeout: int = CLI_TIMEOUT, + disable_tools: bool = True, + system_prompt: str = _CLI_DEFAULT_SYSTEM_PROMPT, + use_cli_auth: bool = True, + ): + self._argv = _resolve_cli_command(command) + self.timeout = timeout + self.disable_tools = disable_tools + self.system_prompt = system_prompt + self.use_cli_auth = use_cli_auth + + def chat( + self, + model: str, + messages: List[Dict[str, str]], + options: Dict[str, Any] = None, + **kwargs, + ) -> Dict[str, Any]: + """Send a chat request through the Claude Code CLI in print mode.""" + system_text, user_text = _split_system_and_user(messages) + user_text = _augment_prompt_with_schema(user_text, kwargs.get("format")) + # Keep the (large, possibly special-charactered) task instructions on + # stdin so nothing fragile ends up on the command line. + prompt = f"{system_text}\n\n{user_text}" if system_text else user_text + + cmd = self._argv + ["-p", "--output-format", "json"] + if self.system_prompt: + cmd += ["--system-prompt", self.system_prompt] + if self.disable_tools: + cmd += ["--tools", ""] + if model: + cmd += ["--model", model] + + env = _cli_subprocess_env( + _CLAUDE_CLI_AUTH_ENV_VARS if self.use_cli_auth else None + ) + stdout = _run_cli(cmd, prompt, self.timeout, "Claude", env=env) + content = _parse_claude_cli_output(stdout) + return {"message": {"role": "assistant", "content": content}} + + +class GeminiCLIProvider: + """Gemini CLI provider (invokes the local `gemini` binary). + + By default it authenticates with your ``gemini`` login and ignores any + ``GEMINI_API_KEY`` in the environment (set ``use_cli_auth=False`` to let the + CLI pick up the API key instead). + """ + + def __init__( + self, + command: str = "gemini", + timeout: int = CLI_TIMEOUT, + use_cli_auth: bool = True, + ): + self._argv = _resolve_cli_command(command) + self.timeout = timeout + self.use_cli_auth = use_cli_auth + + def chat( + self, + model: str, + messages: List[Dict[str, str]], + options: Dict[str, Any] = None, + **kwargs, + ) -> Dict[str, Any]: + """Send a chat request through the Gemini CLI in non-interactive mode.""" + system_text, user_text = _split_system_and_user(messages) + user_text = _augment_prompt_with_schema(user_text, kwargs.get("format")) + # The Gemini CLI has no dedicated system-prompt flag, so prepend it. + prompt = f"{system_text}\n\n{user_text}" if system_text else user_text + + cmd = list(self._argv) + if model: + cmd += ["-m", model] + + env = _cli_subprocess_env( + _GEMINI_CLI_AUTH_ENV_VARS if self.use_cli_auth else None + ) + stdout = _run_cli(cmd, prompt, self.timeout, "Gemini", env=env) + return {"message": {"role": "assistant", "content": stdout.strip()}} diff --git a/pdf.py b/pdf.py index 296db476..92541aa8 100644 --- a/pdf.py +++ b/pdf.py @@ -270,6 +270,11 @@ def _extract_all_sections_separately( start_time = time.time() sections = ["basics", "work", "education", "skills", "projects", "awards"] + total_sections = len(sections) + logger.info( + f"šŸ“„ Extracting {total_sections} resume sections with model " + f"'{DEFAULT_MODEL}': {', '.join(sections)}" + ) complete_resume = { "basics": None, @@ -287,14 +292,24 @@ def _extract_all_sections_separately( "meta": None, } - for section_name in sections: + for index, section_name in enumerate(sections, 1): + logger.info( + f"[{index}/{total_sections}] šŸ”„ Extracting '{section_name}' section..." + ) + section_start = time.time() section_data = self._extract_section_data(text_content, section_name) + section_elapsed = time.time() - section_start if section_data: complete_resume.update(section_data) - logger.debug(f"āœ… Successfully extracted {section_name} section") + logger.info( + f"[{index}/{total_sections}] āœ… Extracted '{section_name}' in {section_elapsed:.1f}s" + ) else: - logger.error(f"āš ļø Failed to extract {section_name} section") + logger.error( + f"[{index}/{total_sections}] āš ļø Failed to extract '{section_name}' " + f"section after {section_elapsed:.1f}s" + ) try: if complete_resume.get("basics") and isinstance( diff --git a/prompt.py b/prompt.py index 108e970c..05b9e0b8 100644 --- a/prompt.py +++ b/prompt.py @@ -6,9 +6,12 @@ """ import os +import logging from dotenv import load_dotenv from models import ModelProvider +logger = logging.getLogger(__name__) + # Load environment variables load_dotenv() @@ -16,13 +19,27 @@ DEFAULT_MODEL_NAME = "gemma3:4b" DEFAULT_PROVIDER = ModelProvider.OLLAMA -# Get model and provider from environment or use defaults -DEFAULT_MODEL = os.getenv("DEFAULT_MODEL", DEFAULT_MODEL_NAME) -PROVIDER = os.getenv("LLM_PROVIDER", DEFAULT_PROVIDER.value) +# Get model and provider from environment or use defaults. Strip surrounding +# whitespace so a stray space in .env (e.g. "claude_cli ") does not silently +# fail to match a provider and fall back to Ollama. +DEFAULT_MODEL = os.getenv("DEFAULT_MODEL", DEFAULT_MODEL_NAME).strip() +PROVIDER = os.getenv("LLM_PROVIDER", DEFAULT_PROVIDER.value).strip() + +# Whether LLM_PROVIDER was explicitly set by the user. When it is, the provider +# choice is authoritative; otherwise we infer it from MODEL_PROVIDER_MAPPING. +PROVIDER_EXPLICITLY_SET = os.getenv("LLM_PROVIDER") is not None -# Validate provider +# Validate provider. An explicitly set but unknown value is surfaced as a +# warning instead of being swallowed silently. if PROVIDER not in [p.value for p in ModelProvider]: + if PROVIDER_EXPLICITLY_SET: + logger.warning( + f"āš ļø Unknown LLM_PROVIDER '{PROVIDER}'. Falling back to " + f"'{DEFAULT_PROVIDER.value}'. Valid values: " + f"{', '.join(p.value for p in ModelProvider)}." + ) PROVIDER = DEFAULT_PROVIDER.value + PROVIDER_EXPLICITLY_SET = False # Model-specific parameters MODEL_PARAMETERS = { @@ -41,6 +58,10 @@ "gemini-2.5-flash-lite": {"temperature": 0.1, "top_p": 0.9}, "gemini-3.5-flash": {"temperature": 0.1, "top_p": 0.9}, "gemini-3.1-flash-lite": {"temperature": 0.1, "top_p": 0.9}, + # Anthropic Claude models (HTTP API or Claude Code CLI) + "claude-opus-4-8": {"temperature": 0.1, "top_p": 0.9}, + "claude-sonnet-4-6": {"temperature": 0.1, "top_p": 0.9}, + "claude-haiku-4-5": {"temperature": 0.1, "top_p": 0.9}, } # Model provider mapping @@ -61,7 +82,18 @@ "gemini-2.5-pro": ModelProvider.GEMINI, "gemini-3.5-flash": ModelProvider.GEMINI, "gemini-3.1-flash-lite": ModelProvider.GEMINI, + # Anthropic Claude models (default to the HTTP API; use LLM_PROVIDER=claude_cli + # to route the same model names through the Claude Code CLI instead) + "claude-opus-4-8": ModelProvider.CLAUDE, + "claude-sonnet-4-6": ModelProvider.CLAUDE, + "claude-haiku-4-5": ModelProvider.CLAUDE, } # Get API keys from environment GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "") +ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "") + +# CLI provider command overrides (useful if the binaries are not named +# "claude"/"gemini" or are not on PATH). +CLAUDE_CLI_COMMAND = os.getenv("CLAUDE_CLI_COMMAND", "claude") +GEMINI_CLI_COMMAND = os.getenv("GEMINI_CLI_COMMAND", "gemini") diff --git a/requirements.txt b/requirements.txt index df14bd5d..2e280d30 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,5 +5,6 @@ requests==2.32.4 pymupdf4llm==0.0.27 Jinja2==3.1.6 google-generativeai==0.4.0 +anthropic==0.107.0 python-dotenv==1.0.1 black==25.9.0 \ No newline at end of file