From e09f74578d08cf95016919433b4e3d8a8d8aabb7 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 6 Jan 2026 03:58:08 +0000 Subject: [PATCH] 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 --- pyproject.toml | 2 +- src/eve_mod/analyzer.py | 52 +++++++++-------- src/eve_mod/cli.py | 36 +++++++++++- src/eve_mod/discourse.py | 121 +++++++++++++++++++++++++++++++++++++++ uv.lock | 63 ++++++++++---------- 5 files changed, 215 insertions(+), 59 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a94a8ef..adf0e88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/src/eve_mod/analyzer.py b/src/eve_mod/analyzer.py index f7865aa..6061002 100644 --- a/src/eve_mod/analyzer.py +++ b/src/eve_mod/analyzer.py @@ -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: diff --git a/src/eve_mod/cli.py b/src/eve_mod/cli.py index 1face46..5d7185e 100644 --- a/src/eve_mod/cli.py +++ b/src/eve_mod/cli.py @@ -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) diff --git a/src/eve_mod/discourse.py b/src/eve_mod/discourse.py index 0458940..26fb638 100644 --- a/src/eve_mod/discourse.py +++ b/src/eve_mod/discourse.py @@ -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] diff --git a/uv.lock b/uv.lock index 2a3862d..3f35851 100644 --- a/uv.lock +++ b/uv.lock @@ -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"