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:
Ben
2026-01-06 03:58:08 +00:00
parent e9479e442a
commit e09f74578d
5 changed files with 215 additions and 59 deletions

View File

@@ -8,7 +8,7 @@ dependencies = [
"httpx>=0.27", "httpx>=0.27",
"click>=8.1", "click>=8.1",
"rich>=13.0", "rich>=13.0",
"anthropic>=0.30", "openai>=1.0",
"python-dateutil>=2.9", "python-dateutil>=2.9",
"python-dotenv>=1.0", "python-dotenv>=1.0",
] ]

View File

@@ -5,14 +5,13 @@ import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Callable from typing import Any, Callable
from anthropic import Anthropic from openai import OpenAI
from anthropic.types import TextBlock
from .discourse import UserPost from .discourse import UserPost
from .rules import ALL_RULES, RULES_BY_ID, Rule, Severity, format_rules_for_prompt from .rules import ALL_RULES, RULES_BY_ID, Rule, Severity, format_rules_for_prompt
# Default model to use # Default model to use (OpenAI-compatible format for the proxy)
DEFAULT_MODEL = "claude-sonnet-4-5-20250514" DEFAULT_MODEL = "antigravity/claude-sonnet-4-5"
# Batch size for analyzing posts # Batch size for analyzing posts
BATCH_SIZE = 5 BATCH_SIZE = 5
@@ -73,18 +72,23 @@ SYSTEM_PROMPT = """You are an EVE Online forum moderator assistant. Your role is
## Important Context ## 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: 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 - In-game rivalry and competitive banter about corporations/alliances is generally acceptable
- Personal attacks that go beyond the game are not 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" - Discrimination, hate speech, and harassment are NEVER acceptable regardless of "roleplay"
- Criticism of game mechanics or CCP decisions is allowed if constructive - 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 ## Your Task
For each post provided, analyze whether it violates any forum rules. Consider: For each post provided, analyze whether it violates any forum rules. Be thorough - moderators need to catch violations, not excuse them. Consider:
1. The context of EVE Online's competitive culture 1. The tone and intent of the post
2. Whether the post is directed at in-game entities (acceptable) vs. real people (less acceptable) 2. Whether insults are directed at players vs in-game entities
3. The severity of the violation if one exists 3. Patterns of behavior (repeated baiting, antagonizing)
4. Your confidence level in the assessment 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 ## 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. 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 self.model = model
# Initialize Anthropic client with custom base URL # Initialize OpenAI client with custom base URL (proxy uses OpenAI-compatible API)
self.client = Anthropic( self.client = OpenAI(
api_key=self.api_key, api_key=self.api_key,
base_url=self.base_url, base_url=self.base_url,
) )
@@ -273,21 +277,19 @@ CONTENT:
Analyze each post and return your findings in the specified JSON format.""" Analyze each post and return your findings in the specified JSON format."""
try: try:
response = self.client.messages.create( response = self.client.chat.completions.create(
model=self.model, model=self.model,
max_tokens=4096, max_tokens=4096,
system=system_prompt, messages=[
messages=[{"role": "user", "content": user_message}], {"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
],
) )
# Extract text from the response (handle different content block types) # Extract text from the response
text_block = next( response_text = response.choices[0].message.content
(block for block in response.content if isinstance(block, TextBlock)), if response_text is None:
None,
)
if text_block is None:
raise AnalyzerError("No text content in LLM response") raise AnalyzerError("No text content in LLM response")
response_text = text_block.text
return self._parse_response(response_text, posts) return self._parse_response(response_text, posts)
except Exception as e: except Exception as e:

View File

@@ -61,6 +61,11 @@ def cli() -> None:
default=True, default=True,
help="Fetch full post content (slower but more accurate)", 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( def review(
username: str, username: str,
days: int, days: int,
@@ -69,6 +74,7 @@ def review(
verbose: bool, verbose: bool,
max_posts: int, max_posts: int,
enrich: bool, enrich: bool,
debug: bool,
) -> None: ) -> None:
""" """
Review a user's forum posts for potential rule violations. Review a user's forum posts for potential rule violations.
@@ -94,6 +100,7 @@ def review(
verbose=verbose, verbose=verbose,
max_posts=max_posts, max_posts=max_posts,
enrich=enrich, enrich=enrich,
debug=debug,
) )
) )
@@ -118,6 +125,7 @@ async def _review_user(
verbose: bool, verbose: bool,
max_posts: int, max_posts: int,
enrich: bool, enrich: bool,
debug: bool = False,
) -> None: ) -> None:
"""Async implementation of user review.""" """Async implementation of user review."""
try: try:
@@ -134,14 +142,16 @@ async def _review_user(
console.print(f"[red]User not found:[/red] {username}") console.print(f"[red]User not found:[/red] {username}")
raise SystemExit(1) 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..."): 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, username=username,
days=days, days=days,
max_posts=max_posts, 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: if not posts:
console.print( console.print(
@@ -155,6 +165,26 @@ async def _review_user(
posts = await client.enrich_posts(posts) posts = await client.enrich_posts(posts)
console.print("[green]Enriched posts with full content[/green]") 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: except DiscourseError as e:
console.print(f"[red]Forum Error:[/red] {e}") console.print(f"[red]Forum Error:[/red] {e}")
raise SystemExit(1) raise SystemExit(1)

View File

@@ -338,3 +338,124 @@ class DiscourseClient:
enriched.append(post) enriched.append(post)
return enriched 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
View File

@@ -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" }, { 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]] [[package]]
name = "anyio" name = "anyio"
version = "4.12.0" 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" }, { 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]] [[package]]
name = "eve-forum-moderator" name = "eve-forum-moderator"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "anthropic" },
{ name = "click" }, { name = "click" },
{ name = "httpx" }, { name = "httpx" },
{ name = "openai" },
{ name = "python-dateutil" }, { name = "python-dateutil" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "rich" }, { name = "rich" },
@@ -106,9 +78,9 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "anthropic", specifier = ">=0.30" },
{ name = "click", specifier = ">=8.1" }, { name = "click", specifier = ">=8.1" },
{ name = "httpx", specifier = ">=0.27" }, { name = "httpx", specifier = ">=0.27" },
{ name = "openai", specifier = ">=1.0" },
{ name = "python-dateutil", specifier = ">=2.9" }, { name = "python-dateutil", specifier = ">=2.9" },
{ name = "python-dotenv", specifier = ">=1.0" }, { name = "python-dotenv", specifier = ">=1.0" },
{ name = "rich", specifier = ">=13.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" }, { 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]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.12.5" 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" }, { 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]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.15.0" version = "4.15.0"