From 4e56df6574f110f243e8ee417f9c21b59fc67b49 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 17:00:30 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20react=20to=20lead=20message=20with?= =?UTF-8?q?=20=E2=9C=85/=E2=9D=8C=20for=20visible=20triage=20outcome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an emoji reaction to the original HubSpot message after the thread reply posts — ✅ when promising/acted on, ❌ when ignored — so channel readers see the outcome without opening the thread. Failures are logged and swallowed: the thread reply is the primary signal and the reaction is additive. `already_reacted` is treated as benign (e.g., reprocessed events). Requires the new `reactions:write` scope, added to the manifest and architecture docs. Closes #6 Co-authored-by: Francisco González --- docs/ARCHITECTURE.md | 1 + slack-app-manifest.yml | 1 + src/leads_agent/core/processor.py | 57 +++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f0fd952..d7ab7ab 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/slack-app-manifest.yml b/slack-app-manifest.yml index 3ece358..899a87a 100644 --- a/slack-app-manifest.yml +++ b/slack-app-manifest.yml @@ -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: diff --git a/src/leads_agent/core/processor.py b/src/leads_agent/core/processor.py index 8b0a4bb..6a1ed9f 100644 --- a/src/leads_agent/core/processor.py +++ b/src/leads_agent/core/processor.py @@ -1,6 +1,9 @@ +import logging from dataclasses import dataclass from typing import TYPE_CHECKING +from slack_sdk.errors import SlackApiError + from leads_agent.agent import classify_lead from leads_agent.models import EnrichedLeadClassification, HubSpotLead, LeadClassification from leads_agent.slack import slack_client @@ -8,6 +11,13 @@ 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: @@ -187,6 +197,53 @@ def post_to_slack( client.chat_postMessage(**kwargs) + # React to the original HubSpot message so the outcome is visible in the + # channel without opening the thread. Only applies when replying in-thread + # (production mode); test mode posts to a separate channel where the + # source message isn't necessarily reachable. + if thread_ts: + react_to_lead_message( + settings, + channel_id=channel_id, + timestamp=thread_ts, + is_promising=processed.is_promising, + client=client, + ) + + +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", From cdec36690226111aed93dbc5c27259c9dd5529d8 Mon Sep 17 00:00:00 2001 From: Francisco Gonzalez <15077029+panchgonzalez@users.noreply.github.com> Date: Thu, 14 May 2026 12:40:45 -0500 Subject: [PATCH 2/3] Refactor lead agent flow with react + responses --- src/leads_agent/agent.py | 21 +++++++++++ src/leads_agent/cli.py | 7 ++-- src/leads_agent/core/processor.py | 60 ++++++++++++++++++++----------- src/leads_agent/core/replay.py | 58 +++++++++++++++++++----------- 4 files changed, 103 insertions(+), 43 deletions(-) diff --git a/src/leads_agent/agent.py b/src/leads_agent/agent.py index 0a7cd11..1a6181d 100644 --- a/src/leads_agent/agent.py +++ b/src/leads_agent/agent.py @@ -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, diff --git a/src/leads_agent/cli.py b/src/leads_agent/cli.py index ec5cf37..bfe673d 100644 --- a/src/leads_agent/cli.py +++ b/src/leads_agent/cli.py @@ -190,6 +190,7 @@ 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. @@ -197,15 +198,17 @@ def replay_command( 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") diff --git a/src/leads_agent/core/processor.py b/src/leads_agent/core/processor.py index 6a1ed9f..5cc1b50 100644 --- a/src/leads_agent/core/processor.py +++ b/src/leads_agent/core/processor.py @@ -4,7 +4,7 @@ from slack_sdk.errors import SlackApiError -from leads_agent.agent import classify_lead +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 @@ -126,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. @@ -134,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) @@ -197,19 +201,6 @@ def post_to_slack( client.chat_postMessage(**kwargs) - # React to the original HubSpot message so the outcome is visible in the - # channel without opening the thread. Only applies when replying in-thread - # (production mode); test mode posts to a separate channel where the - # source message isn't necessarily reachable. - if thread_ts: - react_to_lead_message( - settings, - channel_id=channel_id, - timestamp=thread_ts, - is_promising=processed.is_promising, - client=client, - ) - def react_to_lead_message( settings: "Settings", @@ -253,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 @@ -266,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, diff --git a/src/leads_agent/core/replay.py b/src/leads_agent/core/replay.py index ed699f6..52a3277 100644 --- a/src/leads_agent/core/replay.py +++ b/src/leads_agent/core/replay.py @@ -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() @@ -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) @@ -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 From 611914c5857625394fef4c3905f44b91945980f3 Mon Sep 17 00:00:00 2001 From: Francisco Gonzalez <15077029+panchgonzalez@users.noreply.github.com> Date: Thu, 14 May 2026 12:44:52 -0500 Subject: [PATCH 3/3] Update Claude Code Review workflow to post review comments --- .github/workflows/claude-code-review.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 4f6145b..3c335d1 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -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" @@ -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 }}'