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
3 changes: 2 additions & 1 deletion .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Claude Code Review

on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
types: [opened, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
Expand Down Expand Up @@ -36,6 +36,7 @@ jobs:
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ github.token }}
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
1 change: 1 addition & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ leads-agent prompts --json # Output as JSON
| `groups:history` | Read private channel messages |
| `groups:read` | View private channel info |
| `chat:write` | Post replies |
| `reactions:write` | Add ✅ / ❌ to the original lead message |

### Required Tokens

Expand Down
1 change: 1 addition & 0 deletions slack-app-manifest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ oauth_config:
- groups:history # Read messages in private channels the bot is invited to
- groups:read # View basic private channel info
- chat:write # Send messages as the bot
- reactions:write # Add emoji reactions to messages (triage outcome)

settings:
event_subscriptions:
Expand Down
21 changes: 21 additions & 0 deletions src/leads_agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,27 @@ def _score_lead(
return output


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"
agent = _create_triage_agent(settings, api_key)
run = agent.run_sync(lead.to_prompt_text())
return run.output


def enrich_lead(
settings: Settings,
lead: HubSpotLead,
triage: LeadClassification,
*,
max_searches: int = 4,
) -> EnrichedLeadClassification:
"""Run research + scoring on a promising lead (call after triage_lead)."""
enriched, _, _ = _research_lead(settings, lead, triage, max_searches=max_searches, return_debug=True)
scored, _, _ = _score_lead(settings, lead, triage=triage, enriched=enriched, return_debug=True)
return scored


def classify_message(
settings: Settings,
text: str,
Expand Down
7 changes: 5 additions & 2 deletions src/leads_agent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,22 +190,25 @@ def replay_command(
channel_id: str = typer.Option(None, "--channel", "-c", help="Channel ID (defaults to SLACK_CHANNEL_ID)"),
dry_run: bool = typer.Option(None, "--dry-run/--live", help="Dry-run prints output instead of posting"),
max_searches: int = typer.Option(4, "--max-searches", help="Max web searches per lead"),
reactions_only: bool = typer.Option(False, "--reactions-only", help="Only add reactions based on existing thread replies, skip re-classification"),
):
"""
Replay HubSpot lead messages from Slack channel history.

Fetches recent channel messages, finds HubSpot bot lead messages, runs the normal
processing pipeline, and either posts the result back to Slack (thread reply) or
prints the generated message when --dry-run is enabled.

Use --reactions-only to skip re-classification and only add emoji reactions
based on the existing bot reply already present in each thread.
"""
from leads_agent.core import replay

rprint(Panel.fit("⏪ [bold blue]Replaying Channel History[/]", border_style="blue"))
rprint(f"[dim]Channel: {channel_id} | Leads to replay: {limit} | Dry run: {dry_run}[/]\n")

configure_logfire()

replay(channel_id=channel_id, limit=limit, dry_run=dry_run, max_searches=max_searches)
replay(channel_id=channel_id, limit=limit, dry_run=dry_run, max_searches=max_searches, reactions_only=reactions_only)


@app.command(name="classify")
Expand Down
91 changes: 83 additions & 8 deletions src/leads_agent/core/processor.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING

from leads_agent.agent import classify_lead
from slack_sdk.errors import SlackApiError

from leads_agent.agent import classify_lead, enrich_lead, triage_lead
from leads_agent.models import EnrichedLeadClassification, HubSpotLead, LeadClassification
from leads_agent.slack import slack_client

if TYPE_CHECKING:
from leads_agent.config import Settings

logger = logging.getLogger(__name__)

# Slack emoji names (without surrounding colons) used to signal triage outcome
# on the original HubSpot message.
REACTION_PROMISING = "white_check_mark"
REACTION_IGNORED = "x"


@dataclass
class ProcessedLead:
Expand Down Expand Up @@ -116,6 +126,7 @@ def process_lead(
lead: HubSpotLead,
*,
max_searches: int = 4,
skip_research: bool = False,
) -> ProcessedLead:
"""
Process a single lead: classify and format response.
Expand All @@ -124,15 +135,18 @@ def process_lead(
settings: Application settings
lead: Parsed HubSpot lead
max_searches: Max web searches for enrichment
skip_research: If True, run triage only (no web research or scoring)

Returns:
ProcessedLead with classification and formatted Slack message
"""
classification = classify_lead(settings, lead, max_searches=max_searches)

# Handle ClassificationResult wrapper (from debug mode)
if hasattr(classification, "classification"):
classification = classification.classification
if skip_research:
classification: LeadClassification | EnrichedLeadClassification = triage_lead(settings, lead)
else:
classification = classify_lead(settings, lead, max_searches=max_searches)
# Handle ClassificationResult wrapper (from debug mode)
if hasattr(classification, "classification"):
classification = classification.classification

slack_message = format_slack_message(lead, classification, include_lead_info=False)

Expand Down Expand Up @@ -188,6 +202,40 @@ def post_to_slack(
client.chat_postMessage(**kwargs)


def react_to_lead_message(
settings: "Settings",
*,
channel_id: str,
timestamp: str,
is_promising: bool,
client=None,
) -> None:
"""
Add an emoji reaction to the original lead message indicating triage outcome.

Uses ✅ (`white_check_mark`) for promising leads and ❌ (`x`) for ignored
leads. Failures are logged but never raised — the thread reply is the
primary signal and the reaction is purely additive.
"""
emoji = REACTION_PROMISING if is_promising else REACTION_IGNORED

if settings.dry_run:
print(f"[DRY RUN] Would react :{emoji}: on {channel_id}/{timestamp}")
return

client = client or slack_client(settings)
try:
client.reactions_add(channel=channel_id, timestamp=timestamp, name=emoji)
except SlackApiError as e:
error = e.response.get("error", "unknown") if e.response else "unknown"
# `already_reacted` is benign (e.g., reprocessing the same event) — log
# at debug instead of warning so it doesn't look like a failure.
if error == "already_reacted":
logger.debug("Reaction :%s: already present on %s/%s", emoji, channel_id, timestamp)
else:
logger.warning("Failed to add :%s: reaction on %s/%s: %s", emoji, channel_id, timestamp, error)


def process_and_post(
settings: "Settings",
lead: HubSpotLead,
Expand All @@ -196,11 +244,13 @@ def process_and_post(
thread_ts: str | None = None,
max_searches: int = 4,
include_lead_info: bool = False,
skip_research: bool = False,
) -> ProcessedLead:
"""
Process a lead and post the result to Slack.

This is the main entry point for both production and testing modes.
Reacts to the original message immediately after triage, before any
web research runs, so the outcome is visible as early as possible.

Args:
settings: Application settings
Expand All @@ -209,11 +259,36 @@ def process_and_post(
thread_ts: If provided, post as thread reply (production mode)
max_searches: Max web searches for enrichment
include_lead_info: Include lead details in message (test mode)
skip_research: If True, run triage only (no web research or scoring)

Returns:
ProcessedLead with results
"""
processed = process_lead(settings, lead, max_searches=max_searches)
# Step 1: Triage only — fast, no web searches
triaged = triage_lead(settings, lead)

# Step 2: React immediately so the outcome is visible before research starts
if thread_ts:
react_to_lead_message(
settings,
channel_id=channel_id,
timestamp=thread_ts,
is_promising=triaged.label.value == "promising",
)

# Step 3: Enrich promising leads (research + scoring) unless skipped
if triaged.label.value == "promising" and not skip_research:
classification: LeadClassification | EnrichedLeadClassification = enrich_lead(
settings, lead, triaged, max_searches=max_searches
)
else:
classification = triaged

processed = ProcessedLead(
lead=lead,
classification=classification,
slack_message=format_slack_message(lead, classification, include_lead_info=False),
)

post_to_slack(
settings,
Expand Down
58 changes: 38 additions & 20 deletions src/leads_agent/core/replay.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
from slack_sdk.errors import SlackApiError

from leads_agent.config import get_settings
from leads_agent.core.processor import process_and_post
from leads_agent.core.processor import REACTION_IGNORED, REACTION_PROMISING, process_and_post, process_lead, react_to_lead_message
from leads_agent.models import HubSpotLead
from leads_agent.slack import slack_client


def replay(channel_id: str, limit: int, dry_run: bool, max_searches: int):
def replay(channel_id: str, limit: int, dry_run: bool, max_searches: int, reactions_only: bool = False):
settings = get_settings()
try:
settings.require_slack_client()
Expand All @@ -26,6 +26,8 @@ def replay(channel_id: str, limit: int, dry_run: bool, max_searches: int):
rprint("[red]Error:[/] No channel ID provided. Use --channel or set SLACK_CHANNEL_ID")
raise typer.Exit(1)

rprint(f"[dim]Channel: {target_channel} | Leads to replay: {limit} | Dry run: {settings.dry_run} | Reactions only: {reactions_only}[/]\n")

if limit <= 0:
rprint("[red]Error:[/] --limit must be >= 1")
raise typer.Exit(1)
Expand Down Expand Up @@ -71,26 +73,42 @@ def replay(channel_id: str, limit: int, dry_run: bool, max_searches: int):
continue

processed += 1

result = process_and_post(
settings,
lead,
channel_id=target_channel,
thread_ts=event.get("ts"), # replay as thread reply, like production
max_searches=max_searches,
)

if settings.dry_run:
rprint(
Panel(
result.slack_message,
title=f"Replay {processed}/{limit}",
border_style="yellow",
ts = event.get("ts", "?")

if reactions_only:
processed_lead = process_lead(settings, lead, max_searches=max_searches, skip_research=True)
emoji = REACTION_PROMISING if processed_lead.is_promising else REACTION_IGNORED
if settings.dry_run:
rprint(f"[dim][DRY RUN] Would react :{emoji}: on {ts} ({processed}/{limit})[/]")
else:
react_to_lead_message(
settings,
channel_id=target_channel,
timestamp=event.get("ts"),
is_promising=processed_lead.is_promising,
client=client,
)
)
rprint(f"[green]✓[/] Reacted :{emoji}: on {ts} ({processed}/{limit})")
else:
ts = event.get("ts", "?")
rprint(f"[green]✓[/] Posted replay {processed}/{limit} (thread_ts={ts})")
result = process_and_post(
settings,
lead,
channel_id=target_channel,
thread_ts=event.get("ts"), # replay as thread reply, like production
max_searches=max_searches,
skip_research=settings.dry_run,
)

if settings.dry_run:
rprint(
Panel(
result.slack_message,
title=f"Replay {processed}/{limit}",
border_style="yellow",
)
)
else:
rprint(f"[green]✓[/] Posted replay {processed}/{limit} (thread_ts={ts})")

if processed >= limit:
break
Expand Down