Skip to content
Merged
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
8 changes: 3 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,10 @@ SLACK_TEST_CHANNEL_ID=
# =============================================================================
# LLM Configuration
# =============================================================================
# Default: OpenAI API
# For local Ollama: set LLM_BASE_URL=http://localhost:11434/v1
# Default: Anthropic Claude (claude-sonnet-4-6)

OPENAI_API_KEY=sk-your-key-here
LLM_MODEL_NAME=gpt-5-nano
# LLM_BASE_URL=http://localhost:11434/v1
ANTHROPIC_API_KEY=sk-ant-your-key-here
LLM_MODEL_NAME=claude-sonnet-4-6
#
# Optional: override max output tokens for all agents
LLM_MAX_TOKENS=4000
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
permissions:
contents: read
pull-requests: read
issues: read
issues: write
id-token: write

steps:
Expand All @@ -37,6 +37,9 @@ jobs:
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ github.token }}
track_progress: true
claude_args: |
--model claude-sonnet-4-6
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[project]
name = "leads-agent"
version = "0.1.0"
description = "Slack leads classifier using Socket Mode + local/OpenAI-compatible LLM."
version = "0.2.0"
description = "Slack leads classifier using Socket Mode + Anthropic Claude."
readme = "README.md"
requires-python = ">=3.11"
license = { file = "LICENSE" }
Expand All @@ -12,7 +12,7 @@ dependencies = [
"slack_sdk>=3.27",
"pydantic>=2.6",
"pydantic-settings>=2.2",
"pydantic-ai-slim[openai,duckduckgo]>=0.0.12",
"pydantic-ai-slim[anthropic,duckduckgo]>=0.0.12",
"python-dotenv>=1.0",
"typer>=0.12",
"rich>=13.7",
Expand Down
35 changes: 15 additions & 20 deletions src/leads_agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
from pydantic_ai import Agent
from pydantic_ai.common_tools.duckduckgo import duckduckgo_search_tool
from pydantic_ai.messages import ModelMessage
from pydantic_ai.models.openai import OpenAIChatModel, OpenAIChatModelSettings
from pydantic_ai.providers.openai import OpenAIProvider
from pydantic_ai.models.anthropic import AnthropicModel, AnthropicModelSettings
from pydantic_ai.providers.anthropic import AnthropicProvider

from leads_agent.config import Settings
from leads_agent.models import EnrichedLeadClassification, HubSpotLead, LeadClassification
Expand Down Expand Up @@ -77,33 +77,31 @@ def print_debug(self, verbose: bool = False) -> None:
@overload
def agent_factory(
*,
llm_base_url: str,
llm_model_name: str,
llm_api_key: str = "ollama",
llm_api_key: str,
instructions: str | None = None,
output_type: type[TOutput],
model_settings: OpenAIChatModelSettings,
model_settings: AnthropicModelSettings,
extra_tools: tuple[Callable, ...] | None = None,
use_duckduckgo_search: bool = False,
) -> Agent[None, TOutput]: ...


def agent_factory(
*,
llm_base_url: str,
llm_model_name: str,
llm_api_key: str = "ollama",
llm_api_key: str,
instructions: str | None = None,
output_type: type[TOutput],
model_settings: OpenAIChatModelSettings,
model_settings: AnthropicModelSettings,
extra_tools: tuple[Callable, ...] | None = None,
use_duckduckgo_search: bool = False,
) -> Agent[None, TOutput]:
"""
Create an agent in a consistent way across triage/research/scoring.
"""
provider = OpenAIProvider(base_url=llm_base_url, api_key=llm_api_key)
model = OpenAIChatModel(model_name=llm_model_name, provider=provider)
provider = AnthropicProvider(api_key=llm_api_key)
model = AnthropicModel(model_name=llm_model_name, provider=provider)

tools: list[Any] = list(extra_tools) if extra_tools else []
if use_duckduckgo_search:
Expand Down Expand Up @@ -136,37 +134,34 @@ def _usage_snapshot(result: Any) -> dict[str, Any]:
def _create_triage_agent(settings: Settings, api_key: str) -> Agent[None, LeadClassification]:
pm = get_prompt_manager()
return agent_factory(
llm_base_url=settings.llm_base_url,
llm_model_name=settings.llm_model_name,
llm_api_key=api_key,
instructions=pm.build_triage_prompt(),
output_type=LeadClassification,
model_settings=OpenAIChatModelSettings(temperature=0.0, max_tokens=settings.llm_max_tokens),
model_settings=AnthropicModelSettings(temperature=0.0, max_tokens=settings.llm_max_tokens),
)


def _create_research_agent(settings: Settings, api_key: str) -> Agent[None, EnrichedLeadClassification]:
pm = get_prompt_manager()
return agent_factory(
llm_base_url=settings.llm_base_url,
llm_model_name=settings.llm_model_name,
llm_api_key=api_key,
instructions=pm.build_research_prompt(),
output_type=EnrichedLeadClassification,
model_settings=OpenAIChatModelSettings(temperature=0.0, max_tokens=settings.llm_max_tokens),
model_settings=AnthropicModelSettings(temperature=0.0, max_tokens=settings.llm_max_tokens),
use_duckduckgo_search=True,
)


def _create_scoring_agent(settings: Settings, api_key: str) -> Agent[None, EnrichedLeadClassification]:
pm = get_prompt_manager()
return agent_factory(
llm_base_url=settings.llm_base_url,
llm_model_name=settings.llm_model_name,
llm_api_key=api_key,
instructions=pm.build_scoring_prompt(),
output_type=EnrichedLeadClassification,
model_settings=OpenAIChatModelSettings(temperature=0.0, max_tokens=settings.llm_max_tokens),
model_settings=AnthropicModelSettings(temperature=0.0, max_tokens=settings.llm_max_tokens),
)


Expand All @@ -181,7 +176,7 @@ def classify_lead(
Classify a HubSpot lead using a multi-stage pipeline:
triage → (if promising) web research → (if promising) final 1–5 scoring.
"""
api_key = settings.openai_api_key.get_secret_value() if settings.openai_api_key else "ollama"
api_key = settings.anthropic_api_key.get_secret_value() if settings.anthropic_api_key else ""

triage_agent = _create_triage_agent(settings, api_key)
prompt = lead.to_prompt_text()
Expand Down Expand Up @@ -234,7 +229,7 @@ def _research_lead(
max_searches: int = 4,
return_debug: bool = False,
) -> EnrichedLeadClassification | tuple[EnrichedLeadClassification, list[ModelMessage], dict[str, Any]]:
api_key = settings.openai_api_key.get_secret_value() if settings.openai_api_key else "ollama"
api_key = settings.anthropic_api_key.get_secret_value() if settings.anthropic_api_key else ""
research_agent = _create_research_agent(settings, api_key)

email_domain = ""
Expand Down Expand Up @@ -309,7 +304,7 @@ def _score_lead(
enriched: EnrichedLeadClassification | None,
return_debug: bool = False,
) -> EnrichedLeadClassification | tuple[EnrichedLeadClassification, list[ModelMessage], dict[str, Any]]:
api_key = settings.openai_api_key.get_secret_value() if settings.openai_api_key else "ollama"
api_key = settings.anthropic_api_key.get_secret_value() if settings.anthropic_api_key else ""
scoring_agent = _create_scoring_agent(settings, api_key)

name = f"{lead.first_name or ''} {lead.last_name or ''}".strip()
Expand Down Expand Up @@ -345,7 +340,7 @@ def _score_lead(

def triage_lead(settings: Settings, lead: HubSpotLead) -> LeadClassification:
"""Run only the triage stage — no research or scoring."""
api_key = settings.openai_api_key.get_secret_value() if settings.openai_api_key else "ollama"
api_key = settings.anthropic_api_key.get_secret_value() if settings.anthropic_api_key else ""
agent = _create_triage_agent(settings, api_key)
run = agent.run_sync(lead.to_prompt_text())
return run.output
Expand Down
10 changes: 4 additions & 6 deletions src/leads_agent/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,9 @@ class Settings(BaseSettings):
slack_channel_id: str | None = Field(default=None, validation_alias="SLACK_CHANNEL_ID")
slack_test_channel_id: str | None = Field(default=None, validation_alias="SLACK_TEST_CHANNEL_ID")

# LLM (OpenAI by default; works with any OpenAI-compatible API)
llm_base_url: str = Field(default="https://api.openai.com/v1", validation_alias="LLM_BASE_URL")
llm_model_name: str = Field(default="gpt-5-nano", validation_alias="LLM_MODEL_NAME")
openai_api_key: SecretStr | None = Field(default=None, validation_alias="OPENAI_API_KEY")
# LLM (Anthropic Claude)
llm_model_name: str = Field(default="claude-sonnet-4-6", validation_alias="LLM_MODEL_NAME")
anthropic_api_key: SecretStr | None = Field(default=None, validation_alias="ANTHROPIC_API_KEY")
llm_max_tokens: int = Field(default=4000, validation_alias="LLM_MAX_TOKENS")

# Observability
Expand Down Expand Up @@ -127,8 +126,7 @@ def display_config():
table.add_row("SLACK_APP_TOKEN", mask_secret(settings.slack_app_token))
table.add_row("SLACK_CHANNEL_ID", settings.slack_channel_id or "[not set]")
table.add_row("SLACK_TEST_CHANNEL_ID", settings.slack_test_channel_id or "[not set]")
table.add_row("OPENAI_API_KEY", mask_secret(settings.openai_api_key))
table.add_row("LLM_BASE_URL", settings.llm_base_url)
table.add_row("ANTHROPIC_API_KEY", mask_secret(settings.anthropic_api_key))
table.add_row("LLM_MODEL_NAME", settings.llm_model_name)
table.add_row("LLM_MAX_TOKENS", str(settings.llm_max_tokens))
table.add_row("LOGFIRE_TOKEN", mask_secret(settings.logfire_token))
Expand Down
16 changes: 7 additions & 9 deletions src/leads_agent/core/init_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,15 @@ def init_wizard(output: Path, force: bool):
)

rprint("\n[bold]LLM Configuration[/]")
rprint("[dim]Default uses OpenAI; set LLM_BASE_URL for Ollama/other providers[/]\n")
rprint("[dim]Default uses Anthropic Claude (claude-sonnet-4-6)[/]\n")

openai_api_key = Prompt.ask(
" [cyan]OPENAI_API_KEY[/]",
default="sk-...",
anthropic_api_key = Prompt.ask(
" [cyan]ANTHROPIC_API_KEY[/]",
default="sk-ant-...",
)
llm_model_name = Prompt.ask(
" [cyan]LLM_MODEL_NAME[/]",
default="gpt-5-nano",
default="claude-sonnet-4-6",
)
llm_max_tokens = Prompt.ask(
" [cyan]LLM_MAX_TOKENS[/] [dim](max output tokens)[/]",
Expand Down Expand Up @@ -122,12 +122,10 @@ def init_wizard(output: Path, force: bool):
env_lines.extend(
[
"",
"# LLM configuration (OpenAI by default)",
f"OPENAI_API_KEY={openai_api_key}",
"# LLM configuration (Anthropic Claude)",
f"ANTHROPIC_API_KEY={anthropic_api_key}",
f"LLM_MODEL_NAME={llm_model_name}",
f"LLM_MAX_TOKENS={llm_max_tokens}",
"# Uncomment for Ollama or other OpenAI-compatible providers:",
"# LLM_BASE_URL=http://localhost:11434/v1",
"",
"# Runtime",
f"DRY_RUN={str(dry_run).lower()}",
Expand Down
Loading