Add Reddit Toolkit (#336)
| Name | Description | Package | Version | |----------------------------|------------------------------------------------------------------|---------|---------| | Reddit.SubmitTextPost | Submit a text-based post to a subreddit | Reddit | 0.0.1 | | Reddit.CommentOnPost | Comment on a Reddit post | Reddit | 0.0.1 | | Reddit.ReplyToComment | Reply to a Reddit comment | Reddit | 0.0.1 | | Reddit.GetPostsInSubreddit | Gets posts titles, links, and other metadata in the specified subreddit | Reddit | 0.0.1 | | Reddit.GetContentOfPost | Get the content (body) of a Reddit post by its identifier. | Reddit | 0.0.1 | | Reddit.GetContentOfMultiplePosts | Get the content (body) of multiple Reddit posts by their identifiers. | Reddit | 0.0.1 | | Reddit.GetTopLevelComments | Get the first page of top-level comments of a Reddit post. | Reddit | 0.0.1 | ### Why not use an SDK? Reddit API does not have an official SDK, although [PRAW](https://github.com/praw-dev/praw) has large community support. I played around with PRAW, but ultimately decided to not use an SDK. PRAW made it incredibly easy to work with Reddit Objects, but there were a few drawbacks that ultimately swayed me to not use it: 1. PRAW assumes that it will do the auth for you. A client ID and secret must be passed to PRAW, but a tool only has the auth token. I was able to hack around this by manipulating private properties - but it felt too hacky 2. PRAW does not support Python 3.13 3. PRAW is not async. There is [AsyncPRAW](https://github.com/praw-dev/asyncpraw), but the community does not look active there.
This commit is contained in:
parent
e604e8bde3
commit
7c6a739f25
18 changed files with 1520 additions and 0 deletions
18
toolkits/reddit/.pre-commit-config.yaml
Normal file
18
toolkits/reddit/.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
files: ^./
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: "v4.4.0"
|
||||
hooks:
|
||||
- id: check-case-conflict
|
||||
- id: check-merge-conflict
|
||||
- id: check-toml
|
||||
- id: check-yaml
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.6.7
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
44
toolkits/reddit/.ruff.toml
Normal file
44
toolkits/reddit/.ruff.toml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
target-version = "py39"
|
||||
line-length = 100
|
||||
fix = true
|
||||
|
||||
[lint]
|
||||
select = [
|
||||
# flake8-2020
|
||||
"YTT",
|
||||
# flake8-bandit
|
||||
"S",
|
||||
# flake8-bugbear
|
||||
"B",
|
||||
# flake8-builtins
|
||||
"A",
|
||||
# flake8-comprehensions
|
||||
"C4",
|
||||
# flake8-debugger
|
||||
"T10",
|
||||
# flake8-simplify
|
||||
"SIM",
|
||||
# isort
|
||||
"I",
|
||||
# mccabe
|
||||
"C90",
|
||||
# pycodestyle
|
||||
"E", "W",
|
||||
# pyflakes
|
||||
"F",
|
||||
# pygrep-hooks
|
||||
"PGH",
|
||||
# pyupgrade
|
||||
"UP",
|
||||
# ruff
|
||||
"RUF",
|
||||
# tryceratops
|
||||
"TRY",
|
||||
]
|
||||
|
||||
[lint.per-file-ignores]
|
||||
"**/tests/*" = ["S101"]
|
||||
|
||||
[format]
|
||||
preview = true
|
||||
skip-magic-trailing-comma = false
|
||||
21
toolkits/reddit/LICENSE
Normal file
21
toolkits/reddit/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025, Arcade
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
53
toolkits/reddit/Makefile
Normal file
53
toolkits/reddit/Makefile
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
.PHONY: help
|
||||
|
||||
help:
|
||||
@echo "🛠️ reddit Commands:\n"
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.PHONY: install
|
||||
install: ## Install the poetry environment and install the pre-commit hooks
|
||||
@echo "📦 Checking if Poetry is installed"
|
||||
@if ! command -v poetry &> /dev/null; then \
|
||||
echo "📦 Installing Poetry with pip"; \
|
||||
pip install poetry==1.8.5; \
|
||||
else \
|
||||
echo "📦 Poetry is already installed"; \
|
||||
fi
|
||||
@echo "🚀 Installing package in development mode with all extras"
|
||||
poetry install --all-extras
|
||||
|
||||
.PHONY: build
|
||||
build: clean-build ## Build wheel file using poetry
|
||||
@echo "🚀 Creating wheel file"
|
||||
poetry build
|
||||
|
||||
.PHONY: clean-build
|
||||
clean-build: ## clean build artifacts
|
||||
@echo "🗑️ Cleaning dist directory"
|
||||
rm -rf dist
|
||||
|
||||
.PHONY: test
|
||||
test: ## Test the code with pytest
|
||||
@echo "🚀 Testing code: Running pytest"
|
||||
@poetry run pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
|
||||
|
||||
.PHONY: coverage
|
||||
coverage: ## Generate coverage report
|
||||
@echo "coverage report"
|
||||
coverage report
|
||||
@echo "Generating coverage report"
|
||||
coverage html
|
||||
|
||||
.PHONY: bump-version
|
||||
bump-version: ## Bump the version in the pyproject.toml file
|
||||
@echo "🚀 Bumping version in pyproject.toml"
|
||||
poetry version patch
|
||||
|
||||
.PHONY: check
|
||||
check: ## Run code quality tools.
|
||||
@echo "🚀 Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check"
|
||||
@poetry check
|
||||
@echo "🚀 Linting code: Running pre-commit"
|
||||
@poetry run pre-commit run -a
|
||||
@echo "🚀 Static type checking: Running mypy"
|
||||
@poetry run mypy --config-file=pyproject.toml
|
||||
0
toolkits/reddit/arcade_reddit/__init__.py
Normal file
0
toolkits/reddit/arcade_reddit/__init__.py
Normal file
28
toolkits/reddit/arcade_reddit/client.py
Normal file
28
toolkits/reddit/arcade_reddit/client.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class RedditClient:
|
||||
BASE_URL = "https://oauth.reddit.com/"
|
||||
|
||||
def __init__(self, token: str):
|
||||
self.token = token
|
||||
|
||||
async def request(self, method: str, path: str, **kwargs: Any) -> Any:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"User-Agent": "arcade-reddit",
|
||||
}
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.request(
|
||||
method, f"{self.BASE_URL}/{path.lstrip('/')}", headers=headers, **kwargs
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get(self, path: str, **kwargs: Any) -> Any:
|
||||
return await self.request("GET", path, **kwargs)
|
||||
|
||||
async def post(self, path: str, **kwargs: Any) -> Any:
|
||||
return await self.request("POST", path, **kwargs)
|
||||
47
toolkits/reddit/arcade_reddit/enums.py
Normal file
47
toolkits/reddit/arcade_reddit/enums.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from enum import Enum
|
||||
|
||||
|
||||
class SubredditListingType(str, Enum):
|
||||
HOT = "hot"
|
||||
NEW = "new"
|
||||
RISING = "rising"
|
||||
TOP = "top" # time-based
|
||||
CONTROVERSIAL = "controversial" # time-based
|
||||
|
||||
def is_time_based(self) -> bool:
|
||||
return self in [SubredditListingType.TOP, SubredditListingType.CONTROVERSIAL]
|
||||
|
||||
|
||||
class RedditTimeFilter(str, Enum):
|
||||
NOW = "NOW"
|
||||
TODAY = "TODAY"
|
||||
THIS_WEEK = "THIS_WEEK"
|
||||
THIS_MONTH = "THIS_MONTH"
|
||||
THIS_YEAR = "THIS_YEAR"
|
||||
ALL_TIME = "ALL_TIME"
|
||||
|
||||
def to_api_value(self) -> str:
|
||||
_map = {
|
||||
RedditTimeFilter.NOW: "hour",
|
||||
RedditTimeFilter.TODAY: "day",
|
||||
RedditTimeFilter.THIS_WEEK: "week",
|
||||
RedditTimeFilter.THIS_MONTH: "month",
|
||||
RedditTimeFilter.THIS_YEAR: "year",
|
||||
RedditTimeFilter.ALL_TIME: "all",
|
||||
}
|
||||
return _map[self]
|
||||
|
||||
|
||||
class RedditThingType(str, Enum):
|
||||
"""The type of a Reddit 'thing'.
|
||||
|
||||
Typically used as a prefix for fullnames, e.g. t1_1234567890
|
||||
is the fullname of a comment with id 1234567890
|
||||
"""
|
||||
|
||||
COMMENT = "t1"
|
||||
ACCOUNT = "t2"
|
||||
LINK = "t3"
|
||||
MESSAGE = "t4"
|
||||
SUBREDDIT = "t5"
|
||||
AWARD = "t6"
|
||||
19
toolkits/reddit/arcade_reddit/tools/__init__.py
Normal file
19
toolkits/reddit/arcade_reddit/tools/__init__.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from arcade_reddit.tools.read import (
|
||||
get_content_of_post,
|
||||
get_posts_in_subreddit,
|
||||
get_top_level_comments,
|
||||
)
|
||||
from arcade_reddit.tools.submit import (
|
||||
comment_on_post,
|
||||
reply_to_comment,
|
||||
submit_text_post,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"get_content_of_post",
|
||||
"get_posts_in_subreddit",
|
||||
"get_top_level_comments",
|
||||
"comment_on_post",
|
||||
"reply_to_comment",
|
||||
"submit_text_post",
|
||||
]
|
||||
125
toolkits/reddit/arcade_reddit/tools/read.py
Normal file
125
toolkits/reddit/arcade_reddit/tools/read.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
from typing import Annotated, Optional
|
||||
|
||||
from arcade.sdk import ToolContext, tool
|
||||
from arcade.sdk.auth import Reddit
|
||||
|
||||
from arcade_reddit.client import RedditClient
|
||||
from arcade_reddit.enums import (
|
||||
RedditTimeFilter,
|
||||
SubredditListingType,
|
||||
)
|
||||
from arcade_reddit.utils import (
|
||||
create_fullname_for_multiple_posts,
|
||||
create_path_for_post,
|
||||
normalize_subreddit_name,
|
||||
parse_get_content_of_multiple_posts_response,
|
||||
parse_get_content_of_post_response,
|
||||
parse_get_posts_in_subreddit_response,
|
||||
parse_get_top_level_comments_response,
|
||||
remove_none_values,
|
||||
)
|
||||
|
||||
|
||||
@tool(requires_auth=Reddit(scopes=["read"]))
|
||||
async def get_posts_in_subreddit(
|
||||
context: ToolContext,
|
||||
subreddit: Annotated[str, "The name of the subreddit to fetch posts from"],
|
||||
listing: Annotated[
|
||||
SubredditListingType,
|
||||
(
|
||||
"The type of listing to fetch. For simple listings such as 'hot', 'new', or 'rising', "
|
||||
"the 'time_range' parameter is ignored. For time-based listings such as "
|
||||
"'top' or 'controversial', the 'time_range' parameter is required."
|
||||
),
|
||||
] = SubredditListingType.HOT,
|
||||
limit: Annotated[int, "The maximum number of posts to fetch. Default is 10, max is 100."] = 10,
|
||||
cursor: Annotated[Optional[str], "The pagination token from a previous call"] = None,
|
||||
time_range: Annotated[
|
||||
RedditTimeFilter,
|
||||
"The time range for filtering posts. Must be provided if the listing type is "
|
||||
f"{SubredditListingType.TOP.value} or {SubredditListingType.CONTROVERSIAL.value}. "
|
||||
f"Otherwise, it is ignored. Defaults to {RedditTimeFilter.TODAY.value}.",
|
||||
] = RedditTimeFilter.TODAY,
|
||||
) -> Annotated[dict, "A dictionary with a cursor for the next page and a list of posts"]:
|
||||
"""Gets posts titles, links, and other metadata in the specified subreddit
|
||||
|
||||
The time_range is required if the listing type is 'top' or 'controversial'.
|
||||
"""
|
||||
client = RedditClient(context.get_auth_token_or_empty())
|
||||
|
||||
params = {"limit": limit, "after": cursor}
|
||||
if listing.is_time_based():
|
||||
params["t"] = time_range.to_api_value()
|
||||
|
||||
params = remove_none_values(params)
|
||||
subreddit = normalize_subreddit_name(subreddit)
|
||||
data = await client.get(f"r/{subreddit}/{listing.value}", params=params)
|
||||
result = parse_get_posts_in_subreddit_response(data)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@tool(requires_auth=Reddit(scopes=["read"]))
|
||||
async def get_content_of_post(
|
||||
context: ToolContext,
|
||||
post_identifier: Annotated[
|
||||
str,
|
||||
"The identifier of the Reddit post. "
|
||||
"The identifier may be a reddit URL to the post, a permalink to the post, "
|
||||
"a fullname for the post, or a post id.",
|
||||
],
|
||||
) -> Annotated[dict, "The content (body) of the Reddit post"]:
|
||||
"""Get the content (body) of a Reddit post by its identifier."""
|
||||
client = RedditClient(context.get_auth_token_or_empty())
|
||||
|
||||
path = create_path_for_post(post_identifier)
|
||||
data = await client.get(f"{path}.json")
|
||||
result = parse_get_content_of_post_response(data)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@tool(requires_auth=Reddit(scopes=["read"]))
|
||||
async def get_content_of_multiple_posts(
|
||||
context: ToolContext,
|
||||
post_identifiers: Annotated[
|
||||
list[str],
|
||||
"A list of Reddit post identifiers. "
|
||||
"The identifiers may be reddit URLs to the posts, permalinks to the posts, "
|
||||
"fullnames for the posts, or post ids. Must be less than or equal to 100 identifiers.",
|
||||
],
|
||||
) -> Annotated[dict, "A dictionary containing the content of multiple Reddit posts"]:
|
||||
"""Get the content (body) of multiple Reddit posts by their identifiers.
|
||||
|
||||
Efficiently retrieve the content of multiple posts in a single request.
|
||||
Always use this tool to retrieve more than one post's content.
|
||||
"""
|
||||
client = RedditClient(context.get_auth_token_or_empty())
|
||||
|
||||
fullnames, warnings = create_fullname_for_multiple_posts(post_identifiers)
|
||||
|
||||
data = await client.get("api/info.json", params={"id": ",".join(fullnames)})
|
||||
|
||||
posts = parse_get_content_of_multiple_posts_response(data)
|
||||
|
||||
return {"posts": posts, "warnings": warnings}
|
||||
|
||||
|
||||
@tool(requires_auth=Reddit(scopes=["read"]))
|
||||
async def get_top_level_comments(
|
||||
context: ToolContext,
|
||||
post_identifier: Annotated[
|
||||
str,
|
||||
"The identifier of the Reddit post to fetch comments from. "
|
||||
"The identifier may be a reddit URL, a permalink, a fullname, or a post id.",
|
||||
],
|
||||
) -> Annotated[dict, "A dictionary with a list of top level comments"]:
|
||||
"""Get the first page of top-level comments of a Reddit post."""
|
||||
client = RedditClient(context.get_auth_token_or_empty())
|
||||
|
||||
path = create_path_for_post(post_identifier)
|
||||
|
||||
data = await client.get(f"{path}.json")
|
||||
result = parse_get_top_level_comments_response(data)
|
||||
|
||||
return result
|
||||
113
toolkits/reddit/arcade_reddit/tools/submit.py
Normal file
113
toolkits/reddit/arcade_reddit/tools/submit.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
from typing import Annotated, Optional
|
||||
|
||||
from arcade.sdk import ToolContext, tool
|
||||
from arcade.sdk.auth import Reddit
|
||||
|
||||
from arcade_reddit.client import RedditClient
|
||||
from arcade_reddit.utils import (
|
||||
create_fullname_for_comment,
|
||||
create_fullname_for_post,
|
||||
normalize_subreddit_name,
|
||||
parse_api_comment_response,
|
||||
remove_none_values,
|
||||
)
|
||||
|
||||
|
||||
@tool(requires_auth=Reddit(scopes=["submit"]))
|
||||
async def submit_text_post(
|
||||
context: ToolContext,
|
||||
subreddit: Annotated[str, "The name of the subreddit to which the post will be submitted"],
|
||||
title: Annotated[str, "The title of the submission"],
|
||||
body: Annotated[
|
||||
Optional[str],
|
||||
"The body of the post in markdown format. Should never be the same as the title",
|
||||
] = None,
|
||||
nsfw: Annotated[
|
||||
Optional[bool],
|
||||
"Indicates if the submission has content that is 'Not Safe For Work' (NSFW). "
|
||||
"Default is False",
|
||||
] = False,
|
||||
spoiler: Annotated[
|
||||
Optional[bool],
|
||||
"Indicates if the post is marked as a spoiler. Default is False",
|
||||
] = False,
|
||||
send_replies: Annotated[
|
||||
Optional[bool], "If true, sends replies to the user's inbox. Default is True"
|
||||
] = True,
|
||||
) -> Annotated[dict, "Response from Reddit after submission"]:
|
||||
"""Submit a text-based post to a subreddit"""
|
||||
|
||||
client = RedditClient(context.get_auth_token_or_empty())
|
||||
|
||||
subreddit = normalize_subreddit_name(subreddit)
|
||||
|
||||
params = {
|
||||
"api_type": "json",
|
||||
"sr": subreddit,
|
||||
"title": title,
|
||||
"kind": "self",
|
||||
"nsfw": nsfw,
|
||||
"spoiler": spoiler,
|
||||
"sendreplies": send_replies,
|
||||
"text": body,
|
||||
}
|
||||
params = remove_none_values(params)
|
||||
|
||||
data = await client.post("api/submit", data=params)
|
||||
return {"data": data["json"].get("data", {}), "errors": data["json"].get("errors", [])}
|
||||
|
||||
|
||||
@tool(requires_auth=Reddit(scopes=["submit"]))
|
||||
async def comment_on_post(
|
||||
context: ToolContext,
|
||||
post_identifier: Annotated[
|
||||
str,
|
||||
"The identifier of the Reddit post. "
|
||||
"The identifier may be a reddit URL, a permalink, a fullname, or a post id.",
|
||||
],
|
||||
text: Annotated[str, "The body of the comment in markdown format"],
|
||||
) -> Annotated[dict, "Response from Reddit after submission"]:
|
||||
"""Comment on a Reddit post"""
|
||||
|
||||
client = RedditClient(context.get_auth_token_or_empty())
|
||||
|
||||
fullname = create_fullname_for_post(post_identifier)
|
||||
|
||||
params = {
|
||||
"api_type": "json",
|
||||
"thing_id": fullname,
|
||||
"text": text,
|
||||
"return_rtjson": True,
|
||||
}
|
||||
|
||||
data = await client.post("api/comment", data=params)
|
||||
|
||||
return parse_api_comment_response(data)
|
||||
|
||||
|
||||
@tool(requires_auth=Reddit(scopes=["submit"]))
|
||||
async def reply_to_comment(
|
||||
context: ToolContext,
|
||||
comment_identifier: Annotated[
|
||||
str,
|
||||
"The identifier of the Reddit comment to reply to. "
|
||||
"The identifier may be a comment ID, a reddit URL to the comment, "
|
||||
"a permalink to the comment, or the fullname of the comment.",
|
||||
],
|
||||
text: Annotated[str, "The body of the reply in markdown format"],
|
||||
) -> Annotated[dict, "Response from Reddit after submission"]:
|
||||
"""Reply to a Reddit comment"""
|
||||
|
||||
client = RedditClient(context.get_auth_token_or_empty())
|
||||
|
||||
fullname = create_fullname_for_comment(comment_identifier)
|
||||
|
||||
params = {
|
||||
"api_type": "json",
|
||||
"thing_id": fullname,
|
||||
"text": text,
|
||||
"return_rtjson": True,
|
||||
}
|
||||
|
||||
data = await client.post("api/comment", data=params)
|
||||
return parse_api_comment_response(data)
|
||||
375
toolkits/reddit/arcade_reddit/utils.py
Normal file
375
toolkits/reddit/arcade_reddit/utils.py
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from arcade.sdk.errors import ToolExecutionError
|
||||
|
||||
from arcade_reddit.enums import RedditThingType
|
||||
|
||||
|
||||
def remove_none_values(data: dict) -> dict:
|
||||
"""Remove all keys with None values from a dictionary"""
|
||||
return {k: v for k, v in data.items() if v is not None}
|
||||
|
||||
|
||||
def normalize_subreddit_name(subreddit: str) -> str:
|
||||
"""Normalize a subreddit name"""
|
||||
return subreddit.lower().replace("r/", "").replace(" ", "")
|
||||
|
||||
|
||||
def _simplify_post_data(post_data: dict, include_body: bool = False) -> dict:
|
||||
simplified_data = {
|
||||
"id": post_data.get("id"),
|
||||
"name": post_data.get("name"),
|
||||
"title": post_data.get("title"),
|
||||
"author": post_data.get("author"),
|
||||
"subreddit": post_data.get("subreddit"),
|
||||
"created_utc": post_data.get("created_utc"),
|
||||
"num_comments": post_data.get("num_comments"),
|
||||
"score": post_data.get("score"),
|
||||
"upvote_ratio": post_data.get("upvote_ratio"),
|
||||
"upvotes": post_data.get("ups"),
|
||||
"permalink": post_data.get("permalink"),
|
||||
"url": post_data.get("url"),
|
||||
"is_video": post_data.get("is_video"),
|
||||
}
|
||||
if include_body:
|
||||
simplified_data["body"] = post_data.get("selftext")
|
||||
return simplified_data
|
||||
|
||||
|
||||
def parse_get_posts_in_subreddit_response(data: dict) -> dict:
|
||||
"""Parse the response from the Reddit API for getting posts in a subreddit
|
||||
|
||||
Associated Reddit API endpoints:
|
||||
https://www.reddit.com/dev/api/#GET_hot
|
||||
https://www.reddit.com/dev/api/#GET_new
|
||||
https://www.reddit.com/dev/api/#GET_rising
|
||||
https://www.reddit.com/dev/api/#GET_{sort}
|
||||
|
||||
Args:
|
||||
data: The response from the Reddit API deserialized as a dictionary.
|
||||
NOTE: The response doesn't contain the body of the posts.
|
||||
|
||||
Returns:
|
||||
A dictionary with a cursor for the next page and a list of posts
|
||||
"""
|
||||
posts = []
|
||||
for child in data.get("data", {}).get("children", []):
|
||||
post_data = child.get("data", {})
|
||||
post = _simplify_post_data(post_data)
|
||||
posts.append(post)
|
||||
result = {"cursor": data.get("data", {}).get("after"), "posts": posts}
|
||||
return result
|
||||
|
||||
|
||||
def parse_get_content_of_post_response(data: list) -> dict:
|
||||
"""Parse the json representation of a Reddit post to get the content of a post
|
||||
|
||||
Args:
|
||||
data: The json representation of a Reddit post
|
||||
(retrieved by appending .json to the permalink)
|
||||
|
||||
Returns:
|
||||
A dictionary with the content of the post
|
||||
"""
|
||||
if not data or not isinstance(data, list) or len(data) == 0:
|
||||
return {}
|
||||
|
||||
try:
|
||||
post_data = data[0].get("data", {}).get("children", [{}])[0].get("data", {})
|
||||
return _simplify_post_data(post_data, include_body=True)
|
||||
except (IndexError, AttributeError, KeyError):
|
||||
return {}
|
||||
|
||||
|
||||
def parse_get_content_of_multiple_posts_response(data: dict) -> list[dict]:
|
||||
"""Parse the json representation of multiple Reddit posts to get the content of each post
|
||||
|
||||
Args:
|
||||
data: The json representation of multiple Reddit posts
|
||||
(retrieved from the /api/info.json endpoint)
|
||||
|
||||
Returns:
|
||||
A dictionary with the simplified content of each post
|
||||
"""
|
||||
if not data or not isinstance(data, dict) or len(data) == 0:
|
||||
return []
|
||||
|
||||
result = []
|
||||
for post in data.get("data", {}).get("children", []):
|
||||
post_data = post.get("data", {})
|
||||
result.append(_simplify_post_data(post_data, include_body=True))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse_get_top_level_comments_response(data: list) -> dict:
|
||||
"""Parse the json representation of a Reddit post to get the top-level comments
|
||||
|
||||
Args:
|
||||
data: The json representation of a Reddit post
|
||||
|
||||
Returns:
|
||||
A dictionary with a list of top-level comments
|
||||
"""
|
||||
try:
|
||||
comments_listing = data[1]["data"]["children"]
|
||||
except (IndexError, KeyError):
|
||||
return {"comments": [], "num_comments": 0}
|
||||
|
||||
comments = []
|
||||
for comment in comments_listing:
|
||||
if comment.get("kind") != RedditThingType.COMMENT.value:
|
||||
continue
|
||||
comment_data = comment.get("data", {})
|
||||
comments.append({
|
||||
"id": comment_data.get("id"),
|
||||
"author": comment_data.get("author"),
|
||||
"body": comment_data.get("body"),
|
||||
"score": comment_data.get("score"),
|
||||
"created_utc": comment_data.get("created_utc"),
|
||||
})
|
||||
|
||||
return {"comments": comments, "num_comments": len(comments)}
|
||||
|
||||
|
||||
def parse_api_comment_response(data: dict) -> dict:
|
||||
"""Parse the response from the Reddit API's /api/comment endpoint
|
||||
|
||||
Args:
|
||||
data: The response from the Reddit API deserialized as a dictionary
|
||||
|
||||
Returns:
|
||||
A dictionary with the comment data
|
||||
"""
|
||||
result = {
|
||||
"created_utc": data.get("created_utc"),
|
||||
"name": data.get("name"),
|
||||
"parent_id": data.get("parent_id"),
|
||||
"permalink": data.get("permalink"),
|
||||
"subreddit": data.get("subreddit"),
|
||||
"subreddit_id": data.get("subreddit_id"),
|
||||
"subreddit_name_prefixed": data.get("subreddit_name_prefixed"),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _extract_id_from_url(identifier: str, regex: str, error_msg: str) -> str:
|
||||
"""
|
||||
Extract an ID from a Reddit URL using the provided regular expression.
|
||||
|
||||
Args:
|
||||
identifier: The URL string from which to extract the ID.
|
||||
regex: The regular expression pattern containing a capturing group for the ID.
|
||||
error_msg: The error message to use if no ID can be extracted.
|
||||
|
||||
Returns:
|
||||
The extracted ID as a string.
|
||||
|
||||
Raises:
|
||||
ToolExecutionError: If the URL is not a Reddit URL or the pattern does not match.
|
||||
"""
|
||||
parsed = urlparse(identifier)
|
||||
if not parsed.netloc.endswith("reddit.com"):
|
||||
raise ToolExecutionError(
|
||||
message=f"Expected a reddit URL, but got: {identifier}",
|
||||
developer_message="The identifier should be a valid Reddit URL.",
|
||||
)
|
||||
match = re.search(regex, parsed.path)
|
||||
if not match:
|
||||
raise ToolExecutionError(
|
||||
message=f"Could not extract id from URL: {identifier}",
|
||||
developer_message=error_msg,
|
||||
)
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def _extract_id_from_permalink(identifier: str, regex: str, error_msg: str) -> str:
|
||||
"""
|
||||
Extract an ID from a Reddit permalink using the provided regular expression.
|
||||
|
||||
Args:
|
||||
identifier: The permalink string from which to extract the ID.
|
||||
regex: The regular expression pattern containing a capturing group for the ID.
|
||||
error_msg: The error message to use if no ID can be extracted.
|
||||
|
||||
Returns:
|
||||
The extracted ID as a string.
|
||||
|
||||
Raises:
|
||||
ToolExecutionError: If the pattern does not match the permalink.
|
||||
"""
|
||||
match = re.search(regex, identifier)
|
||||
if not match:
|
||||
raise ToolExecutionError(
|
||||
message=f"Could not extract id from permalink: {identifier}",
|
||||
developer_message=error_msg,
|
||||
)
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def _get_post_id(identifier: str) -> str:
|
||||
"""
|
||||
Retrieve the post ID from various types of Reddit post identifiers.
|
||||
|
||||
The identifier can be a Reddit URL to the post, a permalink for the post,
|
||||
a fullname for the post (starting with 't3_'), or a raw post ID.
|
||||
|
||||
Args:
|
||||
identifier: The Reddit post identifier.
|
||||
|
||||
Returns:
|
||||
The post ID as a string.
|
||||
|
||||
Raises:
|
||||
ToolExecutionError: If the identifier does not contain a valid post ID.
|
||||
"""
|
||||
if identifier.startswith("http://") or identifier.startswith("https://"):
|
||||
return _extract_id_from_url(
|
||||
identifier,
|
||||
r"/comments/([A-Za-z0-9]+)",
|
||||
"The reddit URL does not contain a valid post id.",
|
||||
)
|
||||
elif identifier.startswith("/r/"):
|
||||
return _extract_id_from_permalink(
|
||||
identifier,
|
||||
r"/comments/([A-Za-z0-9]+)",
|
||||
"The permalink does not contain a valid post id.",
|
||||
)
|
||||
else:
|
||||
pattern = re.compile(r"^(t3_)?([A-Za-z0-9]+)$")
|
||||
match = pattern.match(identifier)
|
||||
if match:
|
||||
return match.group(2)
|
||||
raise ToolExecutionError(
|
||||
message=f"Invalid identifier: {identifier}",
|
||||
developer_message=(
|
||||
"The identifier should be a valid Reddit URL, permalink, fullname, or post id."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _get_comment_id(identifier: str) -> str:
|
||||
"""
|
||||
Retrieve the comment ID from various types of Reddit comment identifiers.
|
||||
|
||||
The identifier can be a Reddit URL to the comment, a permalink for the comment,
|
||||
a fullname for the comment (starting with 't1_'), or a raw comment ID.
|
||||
|
||||
Args:
|
||||
identifier: The Reddit comment identifier.
|
||||
|
||||
Returns:
|
||||
The comment ID as a string.
|
||||
|
||||
Raises:
|
||||
ToolExecutionError: If the identifier does not contain a valid comment ID.
|
||||
"""
|
||||
if identifier.startswith("http://") or identifier.startswith("https://"):
|
||||
return _extract_id_from_url(
|
||||
identifier,
|
||||
r"/comment/([A-Za-z0-9]+)",
|
||||
"The reddit URL does not contain a valid comment id.",
|
||||
)
|
||||
elif identifier.startswith("/r/"):
|
||||
return _extract_id_from_permalink(
|
||||
identifier,
|
||||
r"/comment/([A-Za-z0-9]+)",
|
||||
"The permalink does not contain a valid comment id.",
|
||||
)
|
||||
else:
|
||||
if identifier.startswith("t1_"):
|
||||
return identifier[3:]
|
||||
if re.fullmatch(r"[A-Za-z0-9]+", identifier):
|
||||
return identifier
|
||||
raise ToolExecutionError(
|
||||
message=f"Invalid identifier: {identifier}",
|
||||
developer_message=(
|
||||
"The identifier should be a valid Reddit URL, permalink, fullname, or comment id."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def create_path_for_post(identifier: str) -> str:
|
||||
"""
|
||||
Create a path for a Reddit post.
|
||||
|
||||
Args:
|
||||
identifier: The identifier of the post. The identifier may be a reddit URL,
|
||||
a permalink for the post, a fullname for the post, or a post id.
|
||||
|
||||
Returns:
|
||||
The path for the post.
|
||||
"""
|
||||
if identifier.startswith("http://") or identifier.startswith("https://"):
|
||||
parsed = urlparse(identifier)
|
||||
if not parsed.netloc.endswith("reddit.com"):
|
||||
raise ToolExecutionError(
|
||||
message=f"Expected a reddit URL, but got: {identifier}",
|
||||
developer_message="The identifier should be a valid Reddit URL.",
|
||||
)
|
||||
return parsed.path
|
||||
if identifier.startswith("/r/"):
|
||||
return identifier
|
||||
post_id = _get_post_id(identifier)
|
||||
return f"/comments/{post_id}"
|
||||
|
||||
|
||||
def create_fullname_for_post(identifier: str) -> str:
|
||||
"""
|
||||
Create a fullname for a Reddit post.
|
||||
|
||||
Args:
|
||||
identifier: The identifier of the post. The identifier may be a reddit URL,
|
||||
a permalink for the post, a fullname for the post, or a post id.
|
||||
|
||||
Returns:
|
||||
The fullname for the post.
|
||||
"""
|
||||
if identifier.startswith("t3_"):
|
||||
return identifier
|
||||
post_id = _get_post_id(identifier)
|
||||
return f"t3_{post_id}"
|
||||
|
||||
|
||||
def create_fullname_for_multiple_posts(post_identifiers: list[str]) -> tuple[list[str], list[dict]]:
|
||||
"""
|
||||
Create fullnames for multiple Reddit posts.
|
||||
|
||||
Args:
|
||||
post_identifiers: A list of Reddit post identifiers. The identifiers may be
|
||||
reddit URLs, permalinks, fullnames, or post ids.
|
||||
|
||||
Returns:
|
||||
(fullnames, warnings): A tuple of a list of fullnames for the posts and
|
||||
a list of warnings if any of the identifiers are invalid.
|
||||
"""
|
||||
fullnames = []
|
||||
warnings = []
|
||||
for identifier in post_identifiers:
|
||||
try:
|
||||
fullnames.append(create_fullname_for_post(identifier))
|
||||
except ToolExecutionError:
|
||||
message = f"'{identifier}' is not a valid Reddit post identifier."
|
||||
warnings.append({"message": message, "identifier": identifier})
|
||||
|
||||
return fullnames, warnings
|
||||
|
||||
|
||||
def create_fullname_for_comment(identifier: str) -> str:
|
||||
"""
|
||||
Create a fullname for a Reddit comment.
|
||||
|
||||
Args:
|
||||
identifier: The identifier of the comment. The identifier may be a
|
||||
reddit URL to the comment, a permalink for the comment, a fullname for
|
||||
the comment, or a comment id.
|
||||
|
||||
Returns:
|
||||
The fullname for the comment.
|
||||
"""
|
||||
if identifier.startswith("t1_"):
|
||||
return identifier
|
||||
comment_id = _get_comment_id(identifier)
|
||||
return f"t1_{comment_id}"
|
||||
30
toolkits/reddit/evals/additional_messages.py
Normal file
30
toolkits/reddit/evals/additional_messages.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
get_post_in_subreddit_messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "get 1 post from AskReddit that are contentious at this moment",
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_ZjRic1hS43o10EjutTFgotBh",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "Reddit_GetPostsInSubreddit",
|
||||
"arguments": '{"subreddit":"AskReddit","listing":"controversial","time_range":"NOW","limit":1}', # noqa: E501
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": '{"cursor":"t3_1abcdef","posts":[{"author":"StewieGriffin","created_utc":1743457013,"id":"1abcdef","is_video":false,"name":"t3_1abcdef","num_comments":55,"permalink":"/r/AskReddit/comments/1abcdef/why_does_my_dog_have_four_legs/","score":2,"subreddit":"AskReddit","title":"Why does my dog have four legs?","upvote_ratio":0.55,"upvotes":2,"url":"https://www.reddit.com/r/AskReddit/comments/1abcdef/why_does_my_dog_have_four_legs/"}]}', # noqa: E501
|
||||
"tool_call_id": "call_ZjRic1hS43o10EjutTFgotBh",
|
||||
"name": "Reddit_GetPostsInSubreddit",
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Here's a contentious post from AskReddit that is generating some discussion:\n\n**Title:** Why does my dog have four legs?\n\n- **Author:**StewieGriffin\n- **Score:** 2\n- **Upvote Ratio:** 55%\n- **Number of Comments:** 55\n- **Posted:**[Link](https://www.reddit.com/r/AskReddit/comments/1abcdef/why_does_my_dog_have_four_legs/)", # noqa: E501
|
||||
},
|
||||
]
|
||||
42
toolkits/reddit/evals/critics.py
Normal file
42
toolkits/reddit/evals/critics.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from arcade.sdk.eval.critic import Critic
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnyOfCritic(Critic):
|
||||
"""
|
||||
A critic that checks if the actual value matches any of the expected values.
|
||||
In other words, it checks if the actual value is in the expected list.
|
||||
"""
|
||||
|
||||
def evaluate(self, expected: list[Any], actual: Any) -> dict[str, float | bool]:
|
||||
match = actual in expected
|
||||
return {"match": match, "score": self.weight if match else 0.0}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ListCritic(Critic):
|
||||
"""
|
||||
A critic for comparing two lists.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
critic_field: str,
|
||||
weight: float = 1.0,
|
||||
order_matters: bool = True,
|
||||
duplicates_matter: bool = True,
|
||||
):
|
||||
self.critic_field = critic_field
|
||||
self.weight = weight
|
||||
self.order_matters = order_matters
|
||||
self.duplicates_matter = duplicates_matter
|
||||
|
||||
def evaluate(self, expected: list[Any], actual: list[Any]) -> dict[str, float | bool]:
|
||||
match = actual == expected if self.order_matters else set(actual) == set(expected)
|
||||
if self.duplicates_matter:
|
||||
match = match and len(actual) == len(expected)
|
||||
|
||||
return {"match": match, "score": self.weight if match else 0.0}
|
||||
269
toolkits/reddit/evals/eval_reddit_read.py
Normal file
269
toolkits/reddit/evals/eval_reddit_read.py
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
from arcade.sdk import ToolCatalog
|
||||
from arcade.sdk.eval import (
|
||||
EvalRubric,
|
||||
EvalSuite,
|
||||
ExpectedToolCall,
|
||||
tool_eval,
|
||||
)
|
||||
from arcade.sdk.eval.critic import BinaryCritic
|
||||
|
||||
import arcade_reddit
|
||||
from arcade_reddit.enums import RedditTimeFilter, SubredditListingType
|
||||
from arcade_reddit.tools import (
|
||||
get_content_of_post,
|
||||
get_posts_in_subreddit,
|
||||
get_top_level_comments,
|
||||
)
|
||||
from arcade_reddit.tools.read import get_content_of_multiple_posts
|
||||
from evals.additional_messages import get_post_in_subreddit_messages
|
||||
from evals.critics import AnyOfCritic, ListCritic
|
||||
|
||||
# Evaluation rubric
|
||||
rubric = EvalRubric(
|
||||
fail_threshold=0.85,
|
||||
warn_threshold=0.95,
|
||||
)
|
||||
|
||||
|
||||
catalog = ToolCatalog()
|
||||
catalog.add_module(arcade_reddit)
|
||||
|
||||
|
||||
@tool_eval()
|
||||
def reddit_get_posts_in_subreddit_eval_suite() -> EvalSuite:
|
||||
suite = EvalSuite(
|
||||
name="reddit_get_posts_in_subreddit_1",
|
||||
system_message=(
|
||||
"You are an AI assistant with access to reddit tools. "
|
||||
"Use them to help the user with their tasks."
|
||||
),
|
||||
catalog=catalog,
|
||||
rubric=rubric,
|
||||
)
|
||||
|
||||
suite.add_case(
|
||||
name="reddit_get_posts_in_subreddit_1",
|
||||
user_message="Get 30 posts from AskReddit that are contentious at this moment.",
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=get_posts_in_subreddit,
|
||||
args={
|
||||
"subreddit": "AskReddit",
|
||||
"listing": SubredditListingType.CONTROVERSIAL.value,
|
||||
"limit": 30,
|
||||
"cursor": None,
|
||||
"time_range": RedditTimeFilter.NOW.value,
|
||||
},
|
||||
),
|
||||
],
|
||||
rubric=rubric,
|
||||
critics=[
|
||||
BinaryCritic(critic_field="subreddit", weight=0.3),
|
||||
BinaryCritic(critic_field="listing", weight=0.2),
|
||||
BinaryCritic(critic_field="limit", weight=0.2),
|
||||
BinaryCritic(critic_field="cursor", weight=0.1),
|
||||
BinaryCritic(critic_field="time_range", weight=0.2),
|
||||
],
|
||||
)
|
||||
|
||||
suite.add_case(
|
||||
name="reddit_get_posts_in_subreddit_2",
|
||||
user_message="Get the next 5 posts from AskReddit that are contentious at this moment.",
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=get_posts_in_subreddit,
|
||||
args={
|
||||
"subreddit": "AskReddit",
|
||||
"listing": SubredditListingType.CONTROVERSIAL.value,
|
||||
"limit": 5,
|
||||
"cursor": "t3_1abcdef",
|
||||
},
|
||||
),
|
||||
],
|
||||
rubric=rubric,
|
||||
critics=[
|
||||
BinaryCritic(critic_field="subreddit", weight=0.2),
|
||||
BinaryCritic(critic_field="listing", weight=0.2),
|
||||
BinaryCritic(critic_field="limit", weight=0.1),
|
||||
BinaryCritic(critic_field="cursor", weight=0.3),
|
||||
BinaryCritic(critic_field="time_range", weight=0.2),
|
||||
],
|
||||
additional_messages=get_post_in_subreddit_messages,
|
||||
)
|
||||
|
||||
suite.add_case( # time-based listing, but don't provide a specific time range
|
||||
name="reddit_get_posts_in_subreddit_3",
|
||||
user_message="Get 5 top posts from AskReddit",
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=get_posts_in_subreddit,
|
||||
args={
|
||||
"subreddit": "AskReddit",
|
||||
"listing": SubredditListingType.TOP.value,
|
||||
"limit": 5,
|
||||
},
|
||||
),
|
||||
],
|
||||
rubric=rubric,
|
||||
critics=[
|
||||
BinaryCritic(critic_field="subreddit", weight=0.3),
|
||||
BinaryCritic(critic_field="listing", weight=0.3),
|
||||
BinaryCritic(critic_field="limit", weight=0.3),
|
||||
],
|
||||
)
|
||||
|
||||
suite.add_case(
|
||||
name="reddit_get_posts_in_subreddit_4",
|
||||
user_message="Get posts from AskReddit that are gaining traction as we speak",
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=get_posts_in_subreddit,
|
||||
args={
|
||||
"subreddit": "AskReddit",
|
||||
"listing": SubredditListingType.RISING.value,
|
||||
},
|
||||
),
|
||||
],
|
||||
rubric=rubric,
|
||||
critics=[
|
||||
BinaryCritic(critic_field="subreddit", weight=0.3),
|
||||
BinaryCritic(critic_field="listing", weight=0.7),
|
||||
],
|
||||
)
|
||||
|
||||
return suite
|
||||
|
||||
|
||||
@tool_eval()
|
||||
def reddit_get_content_of_post_eval_suite() -> EvalSuite:
|
||||
suite = EvalSuite(
|
||||
name="reddit_get_content_of_post",
|
||||
system_message=(
|
||||
"You are an AI assistant with access to reddit tools. "
|
||||
"Use them to help the user with their tasks."
|
||||
),
|
||||
catalog=catalog,
|
||||
rubric=rubric,
|
||||
)
|
||||
|
||||
suite.add_case(
|
||||
name="reddit_get_content_of_post_1",
|
||||
user_message="Get the content of the post with the id t3_1abcdef",
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=get_content_of_post,
|
||||
args={
|
||||
"post_identifier": [ # post_identifier can be any of the following
|
||||
"1abcdef",
|
||||
"t3_1abcdef",
|
||||
"https://www.reddit.com/r/AskReddit/comments/1abcdef/why_does_my_dog_have_four_legs/",
|
||||
"/r/AskReddit/comments/1abcdef/why_does_my_dog_have_four_legs/",
|
||||
],
|
||||
},
|
||||
),
|
||||
],
|
||||
rubric=rubric,
|
||||
critics=[
|
||||
AnyOfCritic(
|
||||
critic_field="post_identifier",
|
||||
weight=1.0,
|
||||
),
|
||||
],
|
||||
additional_messages=get_post_in_subreddit_messages,
|
||||
)
|
||||
|
||||
return suite
|
||||
|
||||
|
||||
@tool_eval()
|
||||
def reddit_get_content_of_multiple_posts_eval_suite() -> EvalSuite:
|
||||
suite = EvalSuite(
|
||||
name="reddit_get_content_of_multiple_posts",
|
||||
system_message=(
|
||||
"You are an AI assistant with access to reddit tools. "
|
||||
"Use them to help the user with their tasks."
|
||||
),
|
||||
catalog=catalog,
|
||||
rubric=rubric,
|
||||
)
|
||||
|
||||
suite.add_case(
|
||||
name="reddit_get_content_of_multiple_posts_1",
|
||||
user_message=(
|
||||
"Get the content of the posts t3_1abcdef, "
|
||||
"https://www.reddit.com/r/AskReddit/comments/1jdfgk1vn/why_is_water_wet/, "
|
||||
"t3_3abcdef, t3_4abcdef, and t3_5abcdef, t3_6abcdef, and t3_7abcdef, 4jfnsklf, "
|
||||
"/r/AskReddit/comments/2dsghr/, and /r/AskReddit/comments/1jdfg35dvn/"
|
||||
),
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=get_content_of_multiple_posts,
|
||||
args={
|
||||
"post_identifiers": [
|
||||
"t3_1abcdef",
|
||||
"https://www.reddit.com/r/AskReddit/comments/1jdfgk1vn/why_is_water_wet/",
|
||||
"t3_3abcdef",
|
||||
"t3_4abcdef",
|
||||
"t3_5abcdef",
|
||||
"t3_6abcdef",
|
||||
"t3_7abcdef",
|
||||
"4jfnsklf",
|
||||
"/r/AskReddit/comments/2dsghr/",
|
||||
"/r/AskReddit/comments/1jdfg35dvn/",
|
||||
],
|
||||
},
|
||||
),
|
||||
],
|
||||
rubric=rubric,
|
||||
critics=[
|
||||
ListCritic(
|
||||
critic_field="post_identifiers",
|
||||
weight=1.0,
|
||||
order_matters=False,
|
||||
duplicates_matter=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
return suite
|
||||
|
||||
|
||||
@tool_eval()
|
||||
def reddit_get_top_level_comments_eval_suite() -> EvalSuite:
|
||||
suite = EvalSuite(
|
||||
name="reddit_get_top_level_comments",
|
||||
system_message=(
|
||||
"You are an AI assistant with access to reddit tools. "
|
||||
"Use them to help the user with their tasks."
|
||||
),
|
||||
catalog=catalog,
|
||||
rubric=rubric,
|
||||
)
|
||||
|
||||
suite.add_case(
|
||||
name="reddit_get_top_level_comments_1",
|
||||
user_message="What are people saying in response?",
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=get_top_level_comments,
|
||||
args={
|
||||
"post_identifier": [ # post_identifier can be any of the following
|
||||
"1abcdef",
|
||||
"t3_1abcdef",
|
||||
"https://www.reddit.com/r/AskReddit/comments/1abcdef/why_does_my_dog_have_four_legs/",
|
||||
"/r/AskReddit/comments/1abcdef/why_does_my_dog_have_four_legs/",
|
||||
],
|
||||
},
|
||||
),
|
||||
],
|
||||
rubric=rubric,
|
||||
critics=[
|
||||
AnyOfCritic(
|
||||
critic_field="post_identifier",
|
||||
weight=1.0,
|
||||
),
|
||||
],
|
||||
additional_messages=get_post_in_subreddit_messages,
|
||||
)
|
||||
|
||||
return suite
|
||||
106
toolkits/reddit/evals/eval_reddit_submit.py
Normal file
106
toolkits/reddit/evals/eval_reddit_submit.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
from arcade.sdk import ToolCatalog
|
||||
from arcade.sdk.eval import (
|
||||
EvalRubric,
|
||||
EvalSuite,
|
||||
ExpectedToolCall,
|
||||
tool_eval,
|
||||
)
|
||||
from arcade.sdk.eval.critic import BinaryCritic, SimilarityCritic
|
||||
|
||||
import arcade_reddit
|
||||
from arcade_reddit.tools import (
|
||||
submit_text_post,
|
||||
)
|
||||
from arcade_reddit.tools.submit import comment_on_post
|
||||
from evals.additional_messages import get_post_in_subreddit_messages
|
||||
from evals.critics import AnyOfCritic
|
||||
|
||||
# Evaluation rubric
|
||||
rubric = EvalRubric(
|
||||
fail_threshold=0.85,
|
||||
warn_threshold=0.95,
|
||||
)
|
||||
|
||||
|
||||
catalog = ToolCatalog()
|
||||
catalog.add_module(arcade_reddit)
|
||||
|
||||
|
||||
@tool_eval()
|
||||
def reddit_submit_text_post_eval_suite() -> EvalSuite:
|
||||
suite = EvalSuite(
|
||||
name="reddit_submit_text_post_1",
|
||||
system_message=(
|
||||
"You are an AI assistant with access to reddit tools. "
|
||||
"Use them to help the user with their tasks."
|
||||
),
|
||||
catalog=catalog,
|
||||
rubric=rubric,
|
||||
)
|
||||
|
||||
suite.add_case(
|
||||
name="reddit_submit_text_post_1",
|
||||
user_message=(
|
||||
"Post this in AskReddit - 'Why is the sky blue?'. I dont want replies sent to my dms"
|
||||
),
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=submit_text_post,
|
||||
args={
|
||||
"subreddit": "AskReddit",
|
||||
"title": "Why is the sky blue?",
|
||||
"send_replies": False,
|
||||
},
|
||||
),
|
||||
],
|
||||
rubric=rubric,
|
||||
critics=[
|
||||
BinaryCritic(critic_field="subreddit", weight=0.3),
|
||||
BinaryCritic(critic_field="title", weight=0.3),
|
||||
BinaryCritic(critic_field="body", weight=0.3),
|
||||
BinaryCritic(critic_field="send_replies", weight=0.1),
|
||||
],
|
||||
)
|
||||
|
||||
return suite
|
||||
|
||||
|
||||
@tool_eval()
|
||||
def reddit_comment_on_post_eval_suite() -> EvalSuite:
|
||||
suite = EvalSuite(
|
||||
name="reddit_comment_on_post_1",
|
||||
system_message=(
|
||||
"You are an AI assistant with access to reddit tools. "
|
||||
"Use them to help the user with their tasks."
|
||||
),
|
||||
catalog=catalog,
|
||||
rubric=rubric,
|
||||
)
|
||||
|
||||
comment = "Your dog's four-legged structure is a manifestation of tetrapodal evolution, where natural selection has optimized limb development for biomechanical stability and efficient terrestrial locomotion. Duh!" # noqa: E501
|
||||
suite.add_case(
|
||||
name="reddit_comment_on_post_1",
|
||||
user_message=(f"here's my comment - {comment}"),
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=comment_on_post,
|
||||
args={
|
||||
"post_identifier": [ # post_identifier can be any of the following
|
||||
"1abcdef",
|
||||
"t3_1abcdef",
|
||||
"https://www.reddit.com/r/AskReddit/comments/1abcdef/why_does_my_dog_have_four_legs/",
|
||||
"/r/AskReddit/comments/1abcdef/why_does_my_dog_have_four_legs/",
|
||||
],
|
||||
"text": comment,
|
||||
},
|
||||
),
|
||||
],
|
||||
rubric=rubric,
|
||||
critics=[
|
||||
AnyOfCritic(critic_field="post_identifier", weight=0.5),
|
||||
SimilarityCritic(critic_field="text", weight=0.5),
|
||||
],
|
||||
additional_messages=get_post_in_subreddit_messages,
|
||||
)
|
||||
|
||||
return suite
|
||||
39
toolkits/reddit/pyproject.toml
Normal file
39
toolkits/reddit/pyproject.toml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
[tool.poetry]
|
||||
name = "arcade_reddit"
|
||||
version = "0.0.1"
|
||||
description = "LLM tools for interacting with Reddit"
|
||||
authors = ["Arcade <dev@arcade.dev>"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.10"
|
||||
arcade-ai = "^1.0.5"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pytest = "^8.3.0"
|
||||
pytest-cov = "^4.0.0"
|
||||
mypy = "^1.5.1"
|
||||
pre-commit = "^3.4.0"
|
||||
tox = "^4.11.1"
|
||||
ruff = "^0.7.4"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.mypy]
|
||||
files = ["arcade_reddit/**/*.py"]
|
||||
python_version = "3.10"
|
||||
disallow_untyped_defs = "True"
|
||||
disallow_any_unimported = "True"
|
||||
no_implicit_optional = "True"
|
||||
check_untyped_defs = "True"
|
||||
warn_return_any = "True"
|
||||
warn_unused_ignores = "True"
|
||||
show_error_codes = "True"
|
||||
ignore_missing_imports = "True"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.coverage.report]
|
||||
skip_empty = true
|
||||
0
toolkits/reddit/tests/__init__.py
Normal file
0
toolkits/reddit/tests/__init__.py
Normal file
191
toolkits/reddit/tests/test_utils.py
Normal file
191
toolkits/reddit/tests/test_utils.py
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import pytest
|
||||
from arcade.sdk.errors import ToolExecutionError
|
||||
|
||||
from arcade_reddit.utils import (
|
||||
create_fullname_for_comment,
|
||||
create_fullname_for_multiple_posts,
|
||||
create_fullname_for_post,
|
||||
create_path_for_post,
|
||||
parse_get_content_of_multiple_posts_response,
|
||||
parse_get_content_of_post_response,
|
||||
parse_get_posts_in_subreddit_response,
|
||||
parse_get_top_level_comments_response,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"identifier, expected",
|
||||
[
|
||||
("abcdef", "t1_abcdef"),
|
||||
("https://www.reddit.com/r/test/comments/1234567890/comment/1abcdef/", "t1_1abcdef"),
|
||||
("t1_abcdef", "t1_abcdef"),
|
||||
("/r/test/comments/1234567890/comment/1abcdef/", "t1_1abcdef"),
|
||||
("not-an-id", pytest.raises(ToolExecutionError)),
|
||||
# Fullname: Invalid (not a Reddit comment id, but a Reddit post id)
|
||||
("t2_abcdef", pytest.raises(ToolExecutionError)),
|
||||
# URL: Invalid (not a Reddit url to a comment, but to a post)
|
||||
("https://www.reddit.com/r/test/comments/1234567890", pytest.raises(ToolExecutionError)),
|
||||
# Permalink: Invalid (not a Reddit permalink to a comment, but to a post)
|
||||
("/r/test/comments/1234567890", pytest.raises(ToolExecutionError)),
|
||||
],
|
||||
)
|
||||
def test_create_fullname_for_comment(identifier, expected) -> None:
|
||||
if isinstance(expected, str):
|
||||
assert create_fullname_for_comment(identifier) == expected
|
||||
else:
|
||||
with expected:
|
||||
create_fullname_for_comment(identifier)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"identifier, expected",
|
||||
[
|
||||
("https://www.reddit.com/r/test/comments/1234abc/", "/r/test/comments/1234abc/"),
|
||||
("1234abc", "/comments/1234abc"),
|
||||
("/r/test/comments/1234abc/", "/r/test/comments/1234abc/"),
|
||||
("t3_1234abc", "/comments/1234abc"),
|
||||
# URL: invalid (non-reddit domain)
|
||||
("https://www.example.com/r/test/comments/1234abc/", pytest.raises(ToolExecutionError)),
|
||||
# Post ID: invalid (non-alphanumeric character)
|
||||
("12!abc", pytest.raises(ToolExecutionError)),
|
||||
# Permalink: invalid (missing the leading "/" so not recognized as a proper permalink)
|
||||
("r/test/comments/1234abc/", pytest.raises(ToolExecutionError)),
|
||||
# Fullname: invalid (contains an illegal character)
|
||||
("t3_1234*abc", pytest.raises(ToolExecutionError)),
|
||||
],
|
||||
)
|
||||
def test_create_path_for_post(identifier, expected):
|
||||
if isinstance(expected, str):
|
||||
result = create_path_for_post(identifier)
|
||||
assert result == expected
|
||||
else:
|
||||
with expected:
|
||||
create_path_for_post(identifier)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"identifier, expected",
|
||||
[
|
||||
("https://www.reddit.com/r/test/comments/1234abc/", "t3_1234abc"),
|
||||
("1234abc", "t3_1234abc"),
|
||||
("/r/test/comments/1234abc/", "t3_1234abc"),
|
||||
("t3_1234abc", "t3_1234abc"),
|
||||
# URL: invalid (missing "/comments/" segment)
|
||||
("https://www.reddit.com/r/test/1234abc/", pytest.raises(ToolExecutionError)),
|
||||
# Post ID: invalid (non-alphanumeric)
|
||||
("12!abc", pytest.raises(ToolExecutionError)),
|
||||
# Permalink: invalid (missing "/comments/" segment)
|
||||
("/r/test/1234abc/", pytest.raises(ToolExecutionError)),
|
||||
# Fullname: invalid (type prefix is for a message, not a post
|
||||
("t4_1234abc", pytest.raises(ToolExecutionError)),
|
||||
],
|
||||
)
|
||||
def test_create_fullname_for_post(identifier, expected):
|
||||
if isinstance(expected, str):
|
||||
result = create_fullname_for_post(identifier)
|
||||
assert result == expected
|
||||
else:
|
||||
with expected:
|
||||
create_fullname_for_post(identifier)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"identifiers, expected_fullnames, expected_warnings",
|
||||
[
|
||||
([], [], []),
|
||||
(
|
||||
["t3_1234abc", "https://www.reddit.com/r/test/comments/1234abc/"],
|
||||
["t3_1234abc", "t3_1234abc"],
|
||||
[],
|
||||
),
|
||||
(
|
||||
["t3_1234abc", "not-a-valid-identifier"],
|
||||
["t3_1234abc"],
|
||||
[
|
||||
{
|
||||
"message": "'not-a-valid-identifier' is not a valid Reddit post identifier.",
|
||||
"identifier": "not-a-valid-identifier",
|
||||
}
|
||||
],
|
||||
),
|
||||
(
|
||||
["inv@lid", "not-a-valid-identifier"],
|
||||
[],
|
||||
[
|
||||
{
|
||||
"message": "'inv@lid' is not a valid Reddit post identifier.",
|
||||
"identifier": "inv@lid",
|
||||
},
|
||||
{
|
||||
"message": "'not-a-valid-identifier' is not a valid Reddit post identifier.",
|
||||
"identifier": "not-a-valid-identifier",
|
||||
},
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_create_fullname_for_multiple_posts(identifiers, expected_fullnames, expected_warnings):
|
||||
actual_fullnames, actual_warnings = create_fullname_for_multiple_posts(identifiers)
|
||||
assert actual_fullnames == expected_fullnames
|
||||
assert actual_warnings == expected_warnings
|
||||
|
||||
|
||||
def test_parse_get_posts_in_subreddit_response_empty():
|
||||
data = {}
|
||||
expected = {"cursor": None, "posts": []}
|
||||
result = parse_get_posts_in_subreddit_response(data)
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_parse_get_content_of_post_response_empty_and_malformed():
|
||||
data = []
|
||||
result = parse_get_content_of_post_response(data)
|
||||
assert result == {}
|
||||
|
||||
data = None
|
||||
result = parse_get_content_of_post_response(data)
|
||||
assert result == {}
|
||||
|
||||
# missing expected keys
|
||||
data = [{}]
|
||||
expected = {
|
||||
"id": None,
|
||||
"name": None,
|
||||
"title": None,
|
||||
"author": None,
|
||||
"subreddit": None,
|
||||
"created_utc": None,
|
||||
"num_comments": None,
|
||||
"score": None,
|
||||
"upvote_ratio": None,
|
||||
"upvotes": None,
|
||||
"permalink": None,
|
||||
"url": None,
|
||||
"is_video": None,
|
||||
"body": None,
|
||||
}
|
||||
result = parse_get_content_of_post_response(data)
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_parse_get_content_of_multiple_posts_response_empty_and_malformed():
|
||||
expected = []
|
||||
|
||||
data = {}
|
||||
result = parse_get_content_of_multiple_posts_response(data)
|
||||
assert result == expected
|
||||
|
||||
data = None
|
||||
result = parse_get_content_of_multiple_posts_response(data)
|
||||
assert result == expected
|
||||
|
||||
data = [{}]
|
||||
result = parse_get_content_of_multiple_posts_response(data)
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_parse_get_top_level_comments_response_missing_data():
|
||||
data = [{}]
|
||||
expected = {"comments": [], "num_comments": 0}
|
||||
result = parse_get_top_level_comments_response(data)
|
||||
assert result == expected
|
||||
Loading…
Reference in a new issue