""" Slack Paper Reproduction Agent Backend for discovering, evaluating, and reproducing research papers Hosted on Hugging Face Spaces Uses Hugging Face Inference API instead of Claude API. Extracts GitHub URLs directly from HuggingFace paper metadata. Integrates with custom reproduction skill. """ import os import json import hmac import hashlib import asyncio from typing import Optional, Dict, List from datetime import datetime from fastapi import FastAPI, Request, HTTPException from fastapi.responses import JSONResponse import httpx from dotenv import load_dotenv # Load environment variables load_dotenv() # Import custom modules from paper_selector import find_interesting_papers, format_paper_for_slack from slack_utils import ( post_slack_message, verify_slack_request, parse_slack_payload ) # Initialize FastAPI app app = FastAPI(title="Paper Reproduction Agent (HuggingFace)") # Configuration SLACK_BOT_TOKEN = os.getenv("SLACK_BOT_TOKEN") SLACK_SIGNING_SECRET = os.getenv("SLACK_SIGNING_SECRET") HF_TOKEN = os.getenv("HF_TOKEN") HF_MODEL = os.getenv("HF_MODEL", "mistralai/Mistral-7B-Instruct-v0.1") if not all([SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET, HF_TOKEN]): raise ValueError("Missing required environment variables: SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET, HF_TOKEN") print("āœ… Paper Reproduction Agent initialized (HuggingFace)") print(f" Slack Bot: {SLACK_BOT_TOKEN[:10]}...") print(f" HF Model: {HF_MODEL}") print(f" HF Token: {HF_TOKEN[:10]}...") # ============================================================================ # HEALTH CHECK # ============================================================================ @app.get("/health") async def health_check(): """Health check endpoint for HF Spaces""" return { "status": "ok", "timestamp": datetime.now().isoformat(), "service": "Paper Reproduction Agent (HuggingFace)", "llm": f"HuggingFace - {HF_MODEL}" } # ============================================================================ # SLACK EVENTS ENDPOINT # ============================================================================ @app.post("/slack/events") async def handle_slack_events(request: Request): """ Main Slack events endpoint Handles: - URL verification (JSON) - Slash commands (form data: /reproduce-papers) - Event subscriptions (JSON: message.im, app_mention) """ # Get request body and signature body = await request.body() signature = request.headers.get("x-slack-request-signature", "") timestamp = request.headers.get("x-slack-request-timestamp", "") # Convert body to string if isinstance(body, bytes): body_str = body.decode('utf-8') else: body_str = body print(f"šŸ“Ø Raw body: {body_str[:100]}") # Handle empty body if not body_str or not body_str.strip(): print("āš ļø Empty request body received") return JSONResponse({"ok": False, "error": "Empty body"}, status_code=400) # Try parsing as JSON first (events) try: payload = json.loads(body_str) print(f"āœ… Parsed as JSON: {payload.get('type', 'unknown')}") except json.JSONDecodeError: # If JSON fails, try parsing as form data (slash commands) print(f"āš ļø Not JSON, trying form data...") try: from urllib.parse import parse_qs form_data = parse_qs(body_str) # Convert from {key: [value]} to {key: value} payload = {k: v[0] if isinstance(v, list) and v else v for k, v in form_data.items()} print(f"āœ… Parsed as form data: command={payload.get('command')}") except Exception as e: print(f"āŒ Failed to parse body: {e}") return JSONResponse({"ok": False, "error": f"Invalid format: {str(e)}"}, status_code=400) # ======================================================================== # URL VERIFICATION (NO SIGNATURE CHECK - Slack's initial handshake) # ======================================================================== if payload.get("type") == "url_verification": print(f"āœ… URL verification request - responding with challenge") return JSONResponse({"challenge": payload["challenge"]}) # ======================================================================== # SIGNATURE VERIFICATION (for all other requests) # ======================================================================== # TEMPORARILY DISABLED FOR DEBUGGING # TODO: Fix signature verification once basic flow is working if timestamp: print(f"šŸ” Signature verification temporarily disabled") # raw_body = body if isinstance(body, bytes) else body.encode('utf-8') # if not verify_slack_request(raw_body, signature, timestamp, SLACK_SIGNING_SECRET): # print("āŒ Invalid Slack request signature") # raise HTTPException(status_code=401, detail="Unauthorized") else: print("āš ļø No timestamp header") # ======================================================================== # SLASH COMMAND: /reproduce-papers # ======================================================================== command = payload.get("command") if command: print(f"šŸ”„ Slash command: {command}") channel_id = payload.get("channel_id") user_id = payload.get("user_id") # Check if command is allowed in this channel allowed_channel = os.getenv("SLACK_ALLOWED_CHANNEL") if allowed_channel and channel_id != allowed_channel: print(f"āš ļø Command not allowed in this channel: {channel_id}") target = user_id if channel_id.startswith("D") else channel_id asyncio.create_task( post_slack_message( target, f":lock: This bot only works in <#{allowed_channel}>. Please use it there!" ) ) return JSONResponse({"type": "in_channel", "text": ""}) if command == "/reproduce-paper-codes": print(f"āœ… Found /reproduce-paper-codes command") # Acknowledge immediately asyncio.create_task( handle_reproduce_papers_command(channel_id, user_id) ) return JSONResponse({"type": "in_channel", "text": ""}) else: print(f"āš ļø Unknown command: {command}") return JSONResponse({"text": f"Unknown command: {command}"}) # ======================================================================== # EVENT SUBSCRIPTION: message.im, app_mention # ======================================================================== if payload.get("type") == "event_callback": event = payload.get("event", {}) channel_id = event.get("channel") user_id = event.get("user") text = event.get("text", "") # āš ļø CRITICAL: Ignore messages from bots (including ourselves) # This prevents infinite loops if event.get("bot_id") or event.get("subtype") == "bot_message": print(f"šŸ¤– Ignoring bot message (preventing loop)") return JSONResponse({"ok": True}) # Also ignore if there's no user (system messages) if not user_id: print(f"āš ļø Ignoring message with no user") return JSONResponse({"ok": True}) # Handle app mentions if event.get("type") == "app_mention": asyncio.create_task( handle_app_mention(channel_id, user_id, text) ) # Handle direct messages elif event.get("type") == "message" and channel_id and channel_id.startswith("D"): asyncio.create_task( handle_direct_message(channel_id, user_id, text) ) return JSONResponse({"ok": True}) print(f"āš ļø No handler for payload type: {payload.get('type')}") return JSONResponse({"ok": True}) # ============================================================================ # SLACK INTERACTIVE ENDPOINT (Button Clicks) # ============================================================================ @app.post("/slack/interactive") async def handle_slack_interactive(request: Request): """ Handle interactive components (button clicks, select menus, etc) """ # Get request body and verify body = await request.body() signature = request.headers.get("x-slack-request-signature", "") timestamp = request.headers.get("x-slack-request-timestamp", "") print(f"šŸ–±ļø Interactive request received") # Skip signature check if timestamp missing if timestamp: raw_body = body if isinstance(body, bytes) else body.encode('utf-8') if not verify_slack_request(raw_body, signature, timestamp, SLACK_SIGNING_SECRET): print("āŒ Invalid signature") raise HTTPException(status_code=401, detail="Unauthorized") else: print("āš ļø No timestamp, skipping signature verification") # Parse form data form_data = await request.form() payload_json = form_data.get("payload", "{}") try: payload = json.loads(payload_json) print(f"āœ… Parsed payload") except json.JSONDecodeError as e: print(f"āŒ Failed to parse payload: {e}") return JSONResponse({"ok": False, "error": str(e)}, status_code=400) action_id = payload.get('actions', [{}])[0].get('action_id', 'unknown') print(f"šŸ–±ļø Action: {action_id}") # Extract action info action = payload.get("actions", [{}])[0] action_id = action.get("action_id", "") channel_id = payload.get("channel", {}).get("id") user_id = payload.get("user", {}).get("id") # ======================================================================== # BUTTON: Approve paper for reproduction # ======================================================================== if action_id.startswith("approve_paper_"): try: paper_data = json.loads(action.get("value", "{}")) asyncio.create_task( handle_paper_approval(channel_id, user_id, paper_data) ) except json.JSONDecodeError: print(f"āŒ Failed to parse paper data") # ======================================================================== # BUTTON: Skip paper # ======================================================================== elif action_id.startswith("skip_paper_"): await post_slack_message(channel_id, ":zzz: Skipped.") # ======================================================================== # BUTTON: Start reproduction # ======================================================================== elif action_id.startswith("start_repro_"): try: repro_data = json.loads(action.get("value", "{}")) asyncio.create_task( handle_start_reproduction( channel_id, user_id, repro_data.get("paper"), repro_data.get("github_url"), repro_data.get("datasets", []) ) ) except json.JSONDecodeError: print(f"āŒ Failed to parse reproduction data") # Acknowledge the action immediately return JSONResponse({"ok": True}) # ============================================================================ # COMMAND HANDLERS # ============================================================================ async def handle_reproduce_papers_command(channel_id: str, user_id: str): """ Handle /reproduce-paper-codes slash command For DMs, use user_id; for channels, use channel_id """ try: # DM channel IDs start with 'D', use user_id for DMs target = user_id if channel_id.startswith("D") else channel_id await post_slack_message( target, ":hourglass_flowing_sand: Searching for interesting papers from today... (This may take a moment)" ) # Find papers papers = await find_interesting_papers() if not papers: await post_slack_message( target, ":thinking_face: No suitable papers found today. Try again tomorrow!" ) return await post_slack_message( target, f":tada: Found {len(papers)} interesting papers with public code to potentially reproduce!" ) # Post each paper with approval buttons for paper in papers: blocks = create_paper_blocks(paper) await post_slack_message(target, paper["title"], blocks) except Exception as e: print(f"āŒ Error handling /reproduce-paper-codes: {str(e)}") try: target = user_id if channel_id.startswith("D") else channel_id await post_slack_message( target, f":x: Error: {str(e)[:100]}" ) except: print(f"āŒ Could not send error message") async def handle_app_mention(channel_id: str, user_id: str, text: str): """ Handle when bot is mentioned in a message """ await post_slack_message( channel_id, f":wave: Hi <@{user_id}>! I help find and reproduce research papers. " f"Use `/reproduce-papers` to get started!" ) async def handle_direct_message(channel_id: str, user_id: str, text: str): """ Handle direct messages to the bot """ await post_slack_message( channel_id, f"Hi <@{user_id}>! Use `/reproduce-papers` to discover papers worth reproducing." ) async def handle_paper_approval(channel_id: str, user_id: str, paper: Dict): """ Handle when user approves a paper for reproducibility analysis GitHub URL is already extracted from HuggingFace metadata """ try: github_url = paper.get("github_url") dataset_urls = paper.get("dataset_urls", []) if not github_url: await post_slack_message( channel_id, ":warning: Paper doesn't have a GitHub URL in metadata. Skipping." ) return await post_slack_message( channel_id, f":hourglass_flowing_sand: Analyzing reproducibility for: *{paper.get('title', 'Unknown')}*" ) # Generate reproducibility summary summary = generate_reproducibility_summary(paper) # Create blocks for report blocks = create_reproducibility_blocks(summary, paper) await post_slack_message(channel_id, "Reproducibility Report", blocks) except Exception as e: print(f"āŒ Error analyzing paper: {str(e)}") await post_slack_message( channel_id, f":x: Error analyzing paper: {str(e)[:100]}" ) async def handle_start_reproduction( channel_id: str, user_id: str, paper: Dict, github_url: str, datasets: List[str] ): """ Handle when user clicks 'Start Reproduction' Calls your custom reproduction skill """ try: await post_slack_message( channel_id, f":rocket: Starting reproduction of '*{paper.get('title', 'Paper')}*'...\n" ) # Call your custom reproduction skill result = await execute_reproduction_skill(github_url, datasets, paper) # Post results await post_slack_message(channel_id, result) except Exception as e: print(f"āŒ Error starting reproduction: {str(e)}") await post_slack_message( channel_id, f":x: Error: {str(e)[:100]}" ) # ============================================================================ # SKILL EXECUTION (PLACEHOLDER - REPLACE WITH YOUR SKILL) # ============================================================================ async def execute_reproduction_skill( github_url: str, datasets: List[str], paper: Dict ) -> str: """ Generate interactive Claude reproduction instructions. Instead of executing automatically, this provides: 1. The GitHub URL and dataset URLs 2. Copy-paste prompt for Claude 3. Link to open Claude with the skill The reproduction happens interactively in Claude using the reproduce-paper skill for full error handling and custom reports. """ print(f"šŸ“ Generating reproduction instructions") print(f" GitHub: {github_url}") print(f" Datasets: {len(datasets)}") print(f" Paper: {paper.get('title')}") # Format datasets dataset_text = "" if datasets: dataset_text = "\n*Datasets:*\n" + "\n".join(f"• {d}" for d in datasets[:5]) if len(datasets) > 5: dataset_text += f"\n• ... and {len(datasets) - 5} more" else: dataset_text = "\n*Datasets:* None specified (code-only reproduction)" # Create the reproduction message return f""" āœ… *Ready to Reproduce: {paper.get('title', 'Paper')}* šŸ“¦ *Code Repository:* <{github_url}|{github_url}>{dataset_text} --- šŸ¤– *How to Reproduce (Interactive):* 1ļøāƒ£ *Copy this prompt:* ``` Reproduce this paper for me: Title: {paper.get('title', 'Unknown')} GitHub: {github_url} Datasets: {', '.join(datasets) if datasets else 'None (code-only)'} Please analyze the code, run it if possible, and generate a report. ``` 2ļøāƒ£ ** 3ļøāƒ£ *Paste the prompt* 4ļøāƒ£ *Claude will:* • Fetch your code from GitHub • Detect the environment (environment.yml, requirements.txt, etc.) • Build a Docker image • Execute notebooks/scripts cell-by-cell with live progress • Handle errors (missing packages, missing data, resource limits) • Diagnose & fix issues interactively • Generate a tailored HTML report ā±ļø *Typically takes 5-15 minutes* depending on code size --- šŸ“Š *You'll get one of two reports:* • āœ… **Replicability Audit** (if it's your code) → Score (0-5 points), issues to fix, priority actions • āœ… **Reproduction Report** (if it's someone else's paper) → What ran, what failed, figures extracted, setup instructions šŸ’” *Why interactive in Claude?* Your skill narrates progress, diagnoses errors, and asks for your judgment — missing data might need aliases, hardcoded paths need patching, resource limits need sampling. You stay in control. --- _Questions? Check the paper's README or documentation._ """ # ============================================================================ # SLACK MESSAGE BUILDERS # ============================================================================ def create_paper_blocks(paper: Dict) -> List[Dict]: """ Create Slack blocks for paper summary """ score = paper.get("reproducibility_score", 0) score_bar = "🟩" * (score // 10) + "⬜" * (10 - score // 10) why_reproduce = "\n".join( f"{i}. {reason}" for i, reason in enumerate(paper.get("why_reproduce", []), 1) ) bottlenecks = paper.get("bottlenecks", []) bottleneck_text = ( "āš ļø *Bottlenecks:*\n" + "\n".join(f"• {b}" for b in bottlenecks) if bottlenecks else "āœ… No major bottlenecks" ) effort = paper.get("estimated_effort", {}) effort_text = f""" *Estimated Effort:* • Setup: {effort.get('setup_complexity', 'unknown')} • Time: ~{effort.get('time_to_reproduce_hours', '?')} hours • Docs: {effort.get('documentation_quality', 'unknown')}""" github_url = paper.get("github_url", "") github_link = f"\n<{github_url}|:github: View Repository>" if github_url else "" main_text = f"""*{paper.get('title', 'Unknown Paper')}* _{paper.get('authors', 'Unknown Authors')}_ {paper.get('abstract', '')}{github_link} *Reproducibility Score: {score_bar} {score}/100* *Why reproduce this:* {why_reproduce} {bottleneck_text} {effort_text} _{paper.get('recommendation', '')}_""" return [ { "type": "section", "text": { "type": "mrkdwn", "text": main_text } }, { "type": "actions", "elements": [ { "type": "button", "text": {"type": "plain_text", "text": "āœ… Reproduce this"}, "action_id": f"approve_paper_{int(datetime.now().timestamp() * 1000)}", "value": json.dumps(paper), "style": "primary" }, { "type": "button", "text": {"type": "plain_text", "text": "ā­ļø Skip"}, "action_id": f"skip_paper_{int(datetime.now().timestamp() * 1000)}", "style": "danger" } ] } ] def generate_reproducibility_summary(paper: Dict) -> str: """ Generate summary of reproducibility analysis """ github_url = paper.get("github_url", "Not found") datasets = paper.get("dataset_urls", []) dataset_text = "" if datasets: dataset_text = "\n*Datasets:*\n" + "\n".join(f"• <{d}|Dataset>" for d in datasets[:3]) if len(datasets) > 3: dataset_text += f"\n• ... and {len(datasets) - 3} more" else: dataset_text = "\n*Datasets:* None specified" return f""" āœ… *Reproducibility Confirmed* šŸ“¦ *GitHub Repository:* <{github_url}|View Code>{dataset_text} *Ready to Reproduce:* All code and resources are publicly available. Click the button below to start reproduction. """ def create_reproducibility_blocks(summary: str, paper: Dict) -> List[Dict]: """ Create Slack blocks for reproducibility report """ return [ { "type": "section", "text": { "type": "mrkdwn", "text": summary } }, { "type": "actions", "elements": [ { "type": "button", "text": {"type": "plain_text", "text": "ā–¶ļø Start Reproduction"}, "action_id": f"start_repro_{int(datetime.now().timestamp() * 1000)}", "value": json.dumps({ "paper": paper, "github_url": paper.get("github_url"), "datasets": paper.get("dataset_urls", []) }), "style": "primary" }, { "type": "button", "text": {"type": "plain_text", "text": "ā­ļø Skip"}, "action_id": f"skip_repro_{int(datetime.now().timestamp() * 1000)}", "style": "danger" } ] } ] # ============================================================================ # ROOT # ============================================================================ @app.get("/") async def root(): """Root endpoint""" return { "name": "Paper Reproduction Agent", "description": "Discover and reproduce research papers from HF daily papers", "version": "3.0.0-HuggingFace", "llm": f"HuggingFace Inference API - {HF_MODEL}", "features": [ "Extracts GitHub URLs from HuggingFace paper metadata", "Scores papers on reproducibility (code, data, compute)", "Integrates with custom reproduction skill (placeholder provided)", "Posts results to Slack with interactive buttons", "Uses open-source models via HuggingFace Inference API" ], "endpoints": { "health": "/health", "slack_events": "/slack/events", "slack_interactive": "/slack/interactive" } } if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=7860)