| """ |
| 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_dotenv() |
|
|
| |
| from paper_selector import find_interesting_papers, format_paper_for_slack |
| from slack_utils import ( |
| post_slack_message, |
| verify_slack_request, |
| parse_slack_payload |
| ) |
|
|
| |
| app = FastAPI(title="Paper Reproduction Agent (HuggingFace)") |
|
|
| |
| 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]}...") |
|
|
|
|
| |
| |
| |
|
|
| @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}" |
| } |
|
|
|
|
| |
| |
| |
|
|
| @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) |
| """ |
|
|
| |
| body = await request.body() |
| signature = request.headers.get("x-slack-request-signature", "") |
| timestamp = request.headers.get("x-slack-request-timestamp", "") |
|
|
| |
| if isinstance(body, bytes): |
| body_str = body.decode('utf-8') |
| else: |
| body_str = body |
|
|
| print(f"📨 Raw body: {body_str[:100]}") |
|
|
| |
| 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: |
| payload = json.loads(body_str) |
| print(f"✅ Parsed as JSON: {payload.get('type', 'unknown')}") |
| except json.JSONDecodeError: |
| |
| print(f"⚠️ Not JSON, trying form data...") |
| try: |
| from urllib.parse import parse_qs |
| form_data = parse_qs(body_str) |
| |
| 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) |
|
|
| |
| |
| |
| if payload.get("type") == "url_verification": |
| print(f"✅ URL verification request - responding with challenge") |
| return JSONResponse({"challenge": payload["challenge"]}) |
|
|
| |
| |
| |
| |
| |
| if timestamp: |
| print(f"🔐 Signature verification temporarily disabled") |
| |
| |
| |
| |
| else: |
| print("⚠️ No timestamp header") |
|
|
| |
| |
| |
| command = payload.get("command") |
| if command: |
| print(f"🔥 Slash command: {command}") |
| channel_id = payload.get("channel_id") |
| user_id = payload.get("user_id") |
|
|
| |
| 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") |
| |
| 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}"}) |
|
|
| |
| |
| |
| if payload.get("type") == "event_callback": |
| event = payload.get("event", {}) |
| channel_id = event.get("channel") |
| user_id = event.get("user") |
| text = event.get("text", "") |
|
|
| |
| |
| if event.get("bot_id") or event.get("subtype") == "bot_message": |
| print(f"🤖 Ignoring bot message (preventing loop)") |
| return JSONResponse({"ok": True}) |
|
|
| |
| if not user_id: |
| print(f"⚠️ Ignoring message with no user") |
| return JSONResponse({"ok": True}) |
|
|
| |
| if event.get("type") == "app_mention": |
| asyncio.create_task( |
| handle_app_mention(channel_id, user_id, text) |
| ) |
|
|
| |
| 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}) |
|
|
|
|
| |
| |
| |
|
|
| @app.post("/slack/interactive") |
| async def handle_slack_interactive(request: Request): |
| """ |
| Handle interactive components (button clicks, select menus, etc) |
| """ |
|
|
| |
| 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") |
|
|
| |
| 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") |
|
|
| |
| 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}") |
|
|
| |
| 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") |
|
|
| |
| |
| |
| 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") |
|
|
| |
| |
| |
| elif action_id.startswith("skip_paper_"): |
| await post_slack_message(channel_id, ":zzz: Skipped.") |
|
|
| |
| |
| |
| 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") |
|
|
| |
| return JSONResponse({"ok": True}) |
|
|
|
|
| |
| |
| |
|
|
| 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: |
| |
| 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)" |
| ) |
|
|
| |
| 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!" |
| ) |
|
|
| |
| 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')}*" |
| ) |
|
|
| |
| summary = generate_reproducibility_summary(paper) |
|
|
| |
| 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" |
| ) |
|
|
| |
| result = await execute_reproduction_skill(github_url, datasets, paper) |
|
|
| |
| 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]}" |
| ) |
|
|
|
|
| |
| |
| |
|
|
| 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')}") |
|
|
| |
| 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)" |
|
|
| |
| 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️⃣ *<https://claude.ai|Open Claude in a new tab>* |
| |
| 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._ |
| """ |
|
|
|
|
| |
| |
| |
|
|
| 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" |
| } |
| ] |
| } |
| ] |
|
|
|
|
| |
| |
| |
|
|
| @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) |