EmmaScharfmann's picture
EmmaScharfmann HF Staff
test
12690f0
Raw
History Blame Contribute Delete
24 kB
"""
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️⃣ *<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._
"""
# ============================================================================
# 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)