diff --git a/.env.example b/.env.example index cae00ffb..950755dc 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,11 @@ # LLM Provider Configuration -# Options: "ollama" or "gemini" +# Options: "ollama", "gemini", or "claude_agent" 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. +# For Claude Agent: "sonnet", "opus", "fable", etc. DEFAULT_MODEL=gemma3:4b # Google Gemini API Key (required if using Gemini provider) diff --git a/README.md b/README.md index 0396ef45..1b82bfc8 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 Google Gemini, or your Claude Pro / Max subscription with Claude Code. --- @@ -85,11 +85,12 @@ 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 one of these) - **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). + - **Claude Agent SDK** if you have a Claude Pro or Max subscription. Install Claude Code and log in once; the provider then reuses that session, so no API key is needed. ### Quick setup with pip @@ -136,12 +137,12 @@ $ 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`, or `claude_agent` | Chooses provider. Defaults to Ollama. | +| `DEFAULT_MODEL` | for example `gemma3:4b`, `gemini-2.5-pro`, or `sonnet` | 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. | Provider mapping lives in `prompt.py` and `models.py`. The `config.py` file has a single flag: @@ -266,6 +267,13 @@ What happens: - Provide `GEMINI_API_KEY` - The wrapper in `models.GeminiProvider` adapts responses to a unified format +### Claude Agent SDK + +- Set `LLM_PROVIDER=claude_agent` +- Install Claude Code and log in (no API key required) +- Set `DEFAULT_MODEL` to a supported Claude model, for example `sonnet` +- The wrapper in `models.ClaudeAgentProvider` runs a single-turn `query` and returns the text in a unified format + --- ## Contributing diff --git a/evaluator.py b/evaluator.py index 1f9e91f5..43a04afb 100644 --- a/evaluator.py +++ b/evaluator.py @@ -79,7 +79,7 @@ def evaluate_resume(self, resume_text: str) -> EvaluationData: response_text = response["message"]["content"] response_text = extract_json_from_response(response_text) - logger.error(f"🔤 Prompt response: {response_text}") + logger.debug(f"🔤 Prompt response: {response_text}") evaluation_dict = json.loads(response_text) evaluation_data = EvaluationData(**evaluation_dict) diff --git a/llm_utils.py b/llm_utils.py index 7e1d96d3..9f03d796 100644 --- a/llm_utils.py +++ b/llm_utils.py @@ -4,7 +4,7 @@ import logging from typing import Any, Dict, Optional -from models import ModelProvider, OllamaProvider, GeminiProvider +from models import ModelProvider, OllamaProvider, GeminiProvider, ClaudeAgentProvider from prompt import MODEL_PROVIDER_MAPPING, GEMINI_API_KEY logger = logging.getLogger(__name__) @@ -45,7 +45,7 @@ def initialize_llm_provider(model_name: str) -> Any: model_name: The name of the model to use Returns: - An initialized LLM provider (either OllamaProvider or GeminiProvider) + An initialized LLM provider (OllamaProvider, GeminiProvider, or ClaudeAgentProvider) """ # Default to Ollama provider provider = OllamaProvider() @@ -57,6 +57,9 @@ def initialize_llm_provider(model_name: str) -> Any: else: logger.info(f"🔄 Using Google Gemini API provider with model {model_name}") provider = GeminiProvider(api_key=GEMINI_API_KEY) + elif model_provider == ModelProvider.CLAUDE_AGENT: + logger.info("Using Claude Agent SDK (assuming logged in to Claude Code)") + provider = ClaudeAgentProvider() else: logger.info(f"🔄 Using Ollama provider with model {model_name}") return provider diff --git a/models.py b/models.py index e7146005..9bf85e6b 100644 --- a/models.py +++ b/models.py @@ -2,12 +2,18 @@ from pydantic import BaseModel, Field, field_validator from enum import Enum +import asyncio +from claude_agent_sdk import ( + query, ClaudeAgentOptions, AssistantMessage, ResultMessage, TextBlock, +) + class ModelProvider(Enum): """Enum for supported model providers.""" OLLAMA = "ollama" GEMINI = "gemini" + CLAUDE_AGENT = "claude_agent" @runtime_checkable @@ -389,3 +395,60 @@ def chat( f"Retrying in {sleep_time}s..." ) time.sleep(sleep_time) + +class ClaudeAgentProvider: + """Anthropic Claude Agent provider implementation. It assumes you have installed Claude Code and authenticated with your account in Claude Code.""" + + def chat( + self, + model: str, + messages: List[Dict[str, str]], + options: Dict[str, Any] = None, + **kwargs + ) -> Dict[str, Any]: + """Send a chat request to Claude Agent. Options parameter is not used.""" + return asyncio.run(self._chat_claude_async(model, messages, **kwargs)) + + async def _chat_claude_async( + self, + model: str, + messages: List[Dict[str, str]], + **kwargs + ) -> Dict[str, Any]: + system_prompt = None + transcript = [] + for m in messages: + if m["role"] == "system": + system_prompt = m["content"] + elif m["role"] == "user": + transcript.append(f"User: {m['content']}") + elif m["role"] == "assistant": + transcript.append(f"Assistant: {m['content']}") + + if len(transcript) == 1 and messages[-1]["role"] == "user": + prompt = messages[-1]["content"] + else: + prompt = ( + "Below is the conversation so far. Reply as the Assistant " + "to the last message.\n\n" + "\n\n".join(transcript) + ) + + options = ClaudeAgentOptions( + model=model, + system_prompt=system_prompt, + tools=[], + max_turns=1 + ) + + text_parts = [] + async for msg in query(prompt=prompt, options=options): + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + text_parts.append(block.text) + return { + "message": { + "role": "assistant", + "content": "".join(text_parts) + } + } \ No newline at end of file diff --git a/prompt.py b/prompt.py index 108e970c..bbc4c4b2 100644 --- a/prompt.py +++ b/prompt.py @@ -61,6 +61,10 @@ "gemini-2.5-pro": ModelProvider.GEMINI, "gemini-3.5-flash": ModelProvider.GEMINI, "gemini-3.1-flash-lite": ModelProvider.GEMINI, + # Claude Agent models + "sonnet": ModelProvider.CLAUDE_AGENT, + "opus": ModelProvider.CLAUDE_AGENT, + "fable": ModelProvider.CLAUDE_AGENT } # Get API keys from environment diff --git a/requirements.txt b/requirements.txt index df14bd5d..454c7ff5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,59 @@ -PyMuPDF==1.26.3 +annotated-types==0.7.0 +anyio==4.14.1 +attrs==26.1.0 +black==25.9.0 +certifi==2026.6.17 +cffi==2.1.0 +charset-normalizer==3.4.9 +claude-agent-sdk==0.2.114 +click==8.4.2 +colorama==0.4.6 +cryptography==49.0.0 +google-ai-generativelanguage==0.4.0 +google-api-core==2.30.3 +google-auth==2.55.2 +google-generativeai==0.4.0 +googleapis-common-protos==1.75.0 +grpcio==1.82.1 +grpcio-status==1.62.3 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +httpx-sse==0.4.3 +idna==3.18 +Jinja2==3.1.6 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +MarkupSafe==3.0.3 +mcp==1.28.1 +mypy_extensions==1.1.0 ollama==0.5.1 +packaging==26.2 +pathspec==1.1.1 +platformdirs==4.10.0 +proto-plus==1.28.1 +protobuf==4.25.9 +pyasn1==0.6.3 +pyasn1_modules==0.4.2 +pycparser==3.0 pydantic==2.11.7 -requests==2.32.4 +pydantic-settings==2.14.2 +pydantic_core==2.33.2 +PyJWT==2.13.0 +PyMuPDF==1.26.3 pymupdf4llm==0.0.27 -Jinja2==3.1.6 -google-generativeai==0.4.0 python-dotenv==1.0.1 -black==25.9.0 \ No newline at end of file +python-multipart==0.0.32 +pytokens==0.4.1 +pywin32==312 +referencing==0.37.0 +requests==2.32.4 +rpds-py==2026.6.3 +sniffio==1.3.1 +sse-starlette==3.4.5 +starlette==1.3.1 +tqdm==4.68.4 +typing-inspection==0.4.2 +typing_extensions==4.16.0 +urllib3==2.7.0 +uvicorn==0.51.0