fix: switch to OpenAI client for llm-proxy compatibility
- Replace Anthropic SDK with OpenAI SDK (proxy uses OpenAI-compatible API) - Add combined post fetching to include hidden/moderated posts - Update system prompt to be more thorough at catching violations - Add --debug flag to CLI for troubleshooting
This commit is contained in:
@@ -8,7 +8,7 @@ dependencies = [
|
||||
"httpx>=0.27",
|
||||
"click>=8.1",
|
||||
"rich>=13.0",
|
||||
"anthropic>=0.30",
|
||||
"openai>=1.0",
|
||||
"python-dateutil>=2.9",
|
||||
"python-dotenv>=1.0",
|
||||
]
|
||||
|
||||
@@ -5,14 +5,13 @@ import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable
|
||||
|
||||
from anthropic import Anthropic
|
||||
from anthropic.types import TextBlock
|
||||
from openai import OpenAI
|
||||
|
||||
from .discourse import UserPost
|
||||
from .rules import ALL_RULES, RULES_BY_ID, Rule, Severity, format_rules_for_prompt
|
||||
|
||||
# Default model to use
|
||||
DEFAULT_MODEL = "claude-sonnet-4-5-20250514"
|
||||
# Default model to use (OpenAI-compatible format for the proxy)
|
||||
DEFAULT_MODEL = "antigravity/claude-sonnet-4-5"
|
||||
|
||||
# Batch size for analyzing posts
|
||||
BATCH_SIZE = 5
|
||||
@@ -73,18 +72,23 @@ SYSTEM_PROMPT = """You are an EVE Online forum moderator assistant. Your role is
|
||||
## Important Context
|
||||
|
||||
EVE Online is a competitive PvP MMO where trash talk between rival corporations and alliances is part of the culture. However, there are limits:
|
||||
- In-game rivalry and competitive banter is generally acceptable
|
||||
- Personal attacks that go beyond the game are not acceptable
|
||||
- Discrimination, hate speech, and harassment are never acceptable regardless of "roleplay"
|
||||
- In-game rivalry and competitive banter about corporations/alliances is generally acceptable
|
||||
- Personal attacks directed at real players (not just their in-game characters) are NOT acceptable
|
||||
- Discrimination, hate speech, and harassment are NEVER acceptable regardless of "roleplay"
|
||||
- Criticism of game mechanics or CCP decisions is allowed if constructive
|
||||
- Excessive profanity, especially when directed at other players, should be flagged
|
||||
- Trolling and baiting behavior should be identified even if individually posts seem mild
|
||||
|
||||
## Your Task
|
||||
|
||||
For each post provided, analyze whether it violates any forum rules. Consider:
|
||||
1. The context of EVE Online's competitive culture
|
||||
2. Whether the post is directed at in-game entities (acceptable) vs. real people (less acceptable)
|
||||
3. The severity of the violation if one exists
|
||||
4. Your confidence level in the assessment
|
||||
For each post provided, analyze whether it violates any forum rules. Be thorough - moderators need to catch violations, not excuse them. Consider:
|
||||
1. The tone and intent of the post
|
||||
2. Whether insults are directed at players vs in-game entities
|
||||
3. Patterns of behavior (repeated baiting, antagonizing)
|
||||
4. The severity of the violation if one exists
|
||||
5. Your confidence level in the assessment
|
||||
|
||||
Flag posts that are borderline - it's better to surface potential issues for human review than to miss violations.
|
||||
|
||||
## Output Format
|
||||
|
||||
@@ -108,7 +112,7 @@ Respond with valid JSON in this exact format:
|
||||
}}
|
||||
|
||||
If a post has no violations, return an empty violations array and clean=true.
|
||||
Be conservative - only flag clear violations. Borderline cases should have lower confidence scores.
|
||||
For borderline cases, include them with lower confidence scores (0.3-0.5) so moderators can review.
|
||||
"""
|
||||
|
||||
|
||||
@@ -141,8 +145,8 @@ class Analyzer:
|
||||
)
|
||||
self.model = model
|
||||
|
||||
# Initialize Anthropic client with custom base URL
|
||||
self.client = Anthropic(
|
||||
# Initialize OpenAI client with custom base URL (proxy uses OpenAI-compatible API)
|
||||
self.client = OpenAI(
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url,
|
||||
)
|
||||
@@ -273,21 +277,19 @@ CONTENT:
|
||||
Analyze each post and return your findings in the specified JSON format."""
|
||||
|
||||
try:
|
||||
response = self.client.messages.create(
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
max_tokens=4096,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_message}],
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_message},
|
||||
],
|
||||
)
|
||||
|
||||
# Extract text from the response (handle different content block types)
|
||||
text_block = next(
|
||||
(block for block in response.content if isinstance(block, TextBlock)),
|
||||
None,
|
||||
)
|
||||
if text_block is None:
|
||||
# Extract text from the response
|
||||
response_text = response.choices[0].message.content
|
||||
if response_text is None:
|
||||
raise AnalyzerError("No text content in LLM response")
|
||||
response_text = text_block.text
|
||||
return self._parse_response(response_text, posts)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -61,6 +61,11 @@ def cli() -> None:
|
||||
default=True,
|
||||
help="Fetch full post content (slower but more accurate)",
|
||||
)
|
||||
@click.option(
|
||||
"--debug",
|
||||
is_flag=True,
|
||||
help="Show debug output including sample post content",
|
||||
)
|
||||
def review(
|
||||
username: str,
|
||||
days: int,
|
||||
@@ -69,6 +74,7 @@ def review(
|
||||
verbose: bool,
|
||||
max_posts: int,
|
||||
enrich: bool,
|
||||
debug: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Review a user's forum posts for potential rule violations.
|
||||
@@ -94,6 +100,7 @@ def review(
|
||||
verbose=verbose,
|
||||
max_posts=max_posts,
|
||||
enrich=enrich,
|
||||
debug=debug,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -118,6 +125,7 @@ async def _review_user(
|
||||
verbose: bool,
|
||||
max_posts: int,
|
||||
enrich: bool,
|
||||
debug: bool = False,
|
||||
) -> None:
|
||||
"""Async implementation of user review."""
|
||||
try:
|
||||
@@ -134,14 +142,16 @@ async def _review_user(
|
||||
console.print(f"[red]User not found:[/red] {username}")
|
||||
raise SystemExit(1)
|
||||
|
||||
# Fetch posts
|
||||
# Fetch posts (using combined method to include hidden posts)
|
||||
with console.status(f"[bold blue]Fetching posts from last {days} days..."):
|
||||
posts = await client.get_user_posts(
|
||||
posts = await client.get_user_posts_combined(
|
||||
username=username,
|
||||
days=days,
|
||||
max_posts=max_posts,
|
||||
)
|
||||
console.print(f"[green]Found {len(posts)} posts[/green]")
|
||||
console.print(
|
||||
f"[green]Found {len(posts)} posts (including hidden)[/green]"
|
||||
)
|
||||
|
||||
if not posts:
|
||||
console.print(
|
||||
@@ -155,6 +165,26 @@ async def _review_user(
|
||||
posts = await client.enrich_posts(posts)
|
||||
console.print("[green]Enriched posts with full content[/green]")
|
||||
|
||||
# Debug: show sample post content
|
||||
if debug and posts:
|
||||
console.print("\n[bold yellow]DEBUG: Sample post content[/bold yellow]")
|
||||
for i, post in enumerate(posts[:3]): # Show first 3 posts
|
||||
console.print(f"\n[cyan]--- Post {i + 1} ---[/cyan]")
|
||||
console.print(f"[dim]Topic:[/dim] {post.topic_title}")
|
||||
console.print(f"[dim]URL:[/dim] {post.url}")
|
||||
console.print(f"[dim]Date:[/dim] {post.created_at}")
|
||||
console.print(
|
||||
f"[dim]Raw length:[/dim] {len(post.content_raw)} chars"
|
||||
)
|
||||
console.print(
|
||||
f"[dim]Text length:[/dim] {len(post.content_text)} chars"
|
||||
)
|
||||
content_preview = post.content_text[:500]
|
||||
if len(post.content_text) > 500:
|
||||
content_preview += "..."
|
||||
console.print(f"[dim]Content:[/dim]\n{content_preview}")
|
||||
console.print("\n")
|
||||
|
||||
except DiscourseError as e:
|
||||
console.print(f"[red]Forum Error:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
|
||||
@@ -338,3 +338,124 @@ class DiscourseClient:
|
||||
enriched.append(post)
|
||||
|
||||
return enriched
|
||||
|
||||
async def get_user_posts_via_search(
|
||||
self,
|
||||
username: str,
|
||||
days: int = 30,
|
||||
max_posts: int = 200,
|
||||
include_hidden: bool = True,
|
||||
) -> list[UserPost]:
|
||||
"""
|
||||
Get a user's posts using the search API, which includes hidden posts.
|
||||
|
||||
This method is slower but catches posts that don't appear in the
|
||||
activity feed (e.g., hidden/moderated posts).
|
||||
|
||||
Args:
|
||||
username: The forum username
|
||||
days: Number of days to look back
|
||||
max_posts: Maximum number of posts to fetch
|
||||
include_hidden: Whether to include hidden posts
|
||||
|
||||
Returns:
|
||||
List of UserPost objects, newest first
|
||||
"""
|
||||
posts: list[UserPost] = []
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
page = 1
|
||||
|
||||
while len(posts) < max_posts:
|
||||
# Use Discourse search API with @username filter
|
||||
data = await self._request(
|
||||
"GET",
|
||||
"/search.json",
|
||||
params={
|
||||
"q": f"@{username} order:latest",
|
||||
"page": page,
|
||||
},
|
||||
)
|
||||
|
||||
search_posts = data.get("posts", [])
|
||||
if not search_posts:
|
||||
break
|
||||
|
||||
for post_data in search_posts:
|
||||
# Only include posts by this user
|
||||
if post_data.get("username", "").lower() != username.lower():
|
||||
continue
|
||||
|
||||
created_str = post_data.get("created_at", "")
|
||||
if not created_str:
|
||||
continue
|
||||
|
||||
created_at = parse_date(created_str)
|
||||
if created_at.tzinfo is None:
|
||||
created_at = created_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
# Stop if we've gone past the cutoff
|
||||
if created_at < cutoff:
|
||||
return posts
|
||||
|
||||
post = UserPost(
|
||||
post_id=post_data.get("id", 0),
|
||||
post_number=post_data.get("post_number", 1),
|
||||
topic_id=post_data.get("topic_id", 0),
|
||||
topic_title=post_data.get("topic_title", "Unknown Topic"),
|
||||
topic_slug=post_data.get("topic_slug", "topic"),
|
||||
content_raw=post_data.get("blurb", ""),
|
||||
content_cooked=post_data.get("blurb", ""),
|
||||
created_at=created_at,
|
||||
category_name=None,
|
||||
)
|
||||
posts.append(post)
|
||||
|
||||
if len(posts) >= max_posts:
|
||||
break
|
||||
|
||||
page += 1
|
||||
|
||||
# Safety limit on pages
|
||||
if page > 20:
|
||||
break
|
||||
|
||||
return posts
|
||||
|
||||
async def get_user_posts_combined(
|
||||
self,
|
||||
username: str,
|
||||
days: int = 30,
|
||||
max_posts: int = 200,
|
||||
) -> list[UserPost]:
|
||||
"""
|
||||
Get a user's posts using multiple methods to ensure hidden posts are included.
|
||||
|
||||
Combines the activity feed (fast) with search API (catches hidden posts).
|
||||
|
||||
Args:
|
||||
username: The forum username
|
||||
days: Number of days to look back
|
||||
max_posts: Maximum number of posts to fetch
|
||||
|
||||
Returns:
|
||||
List of UserPost objects, newest first, deduplicated
|
||||
"""
|
||||
# Get posts from activity feed (fast, but misses hidden)
|
||||
activity_posts = await self.get_user_posts(username, days, max_posts)
|
||||
|
||||
# Get posts from search (slower, but includes hidden)
|
||||
search_posts = await self.get_user_posts_via_search(username, days, max_posts)
|
||||
|
||||
# Combine and deduplicate by post_id
|
||||
seen_ids: set[int] = set()
|
||||
combined: list[UserPost] = []
|
||||
|
||||
for post in activity_posts + search_posts:
|
||||
if post.post_id not in seen_ids:
|
||||
seen_ids.add(post.post_id)
|
||||
combined.append(post)
|
||||
|
||||
# Sort by date, newest first
|
||||
combined.sort(key=lambda p: p.created_at, reverse=True)
|
||||
|
||||
return combined[:max_posts]
|
||||
|
||||
63
uv.lock
generated
63
uv.lock
generated
@@ -11,25 +11,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anthropic"
|
||||
version = "0.75.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "distro" },
|
||||
{ name = "docstring-parser" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jiter" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/1f/08e95f4b7e2d35205ae5dcbb4ae97e7d477fc521c275c02609e2931ece2d/anthropic-0.75.0.tar.gz", hash = "sha256:e8607422f4ab616db2ea5baacc215dd5f028da99ce2f022e33c7c535b29f3dfb", size = 439565, upload-time = "2025-11-24T20:41:45.28Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/60/1c/1cd02b7ae64302a6e06724bf80a96401d5313708651d277b1458504a1730/anthropic-0.75.0-py3-none-any.whl", hash = "sha256:ea8317271b6c15d80225a9f3c670152746e88805a7a61e14d4a374577164965b", size = 388164, upload-time = "2025-11-24T20:41:43.587Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.12.0"
|
||||
@@ -82,23 +63,14 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "docstring-parser"
|
||||
version = "0.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "eve-forum-moderator"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "anthropic" },
|
||||
{ name = "click" },
|
||||
{ name = "httpx" },
|
||||
{ name = "openai" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "rich" },
|
||||
@@ -106,9 +78,9 @@ dependencies = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "anthropic", specifier = ">=0.30" },
|
||||
{ name = "click", specifier = ">=8.1" },
|
||||
{ name = "httpx", specifier = ">=0.27" },
|
||||
{ name = "openai", specifier = ">=1.0" },
|
||||
{ name = "python-dateutil", specifier = ">=2.9" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0" },
|
||||
{ name = "rich", specifier = ">=13.0" },
|
||||
@@ -266,6 +238,25 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "2.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "distro" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jiter" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "sniffio" },
|
||||
{ name = "tqdm" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/b1/12fe1c196bea326261718eb037307c1c1fe1dedc2d2d4de777df822e6238/openai-2.14.0.tar.gz", hash = "sha256:419357bedde9402d23bf8f2ee372fca1985a73348debba94bddff06f19459952", size = 626938, upload-time = "2025-12-19T03:28:45.742Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/27/4b/7c1a00c2c3fbd004253937f7520f692a9650767aa73894d7a34f0d65d3f4/openai-2.14.0-py3-none-any.whl", hash = "sha256:7ea40aca4ffc4c4a776e77679021b47eec1160e341f42ae086ba949c9dcc9183", size = 1067558, upload-time = "2025-12-19T03:28:43.727Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.5"
|
||||
@@ -439,6 +430,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tqdm"
|
||||
version = "4.67.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
|
||||
Reference in New Issue
Block a user