remarkbox/remarkbox/models/spam_llm.py
russell@unturf.com 66964f736a Add spam prevention, superuser system, and LLM relevance checking
- Thread creation rate limit: 1 per 7 min per user/IP via API
- is_superuser column on User model with alembic migration
- Superusers bypass namespace-scoped is_moderator() checks
- super_fly_required now checks is_superuser instead of hardcoded names
- /topsecret/users admin page to promote/demote superusers
- Spam scoring module with 6 signals (link density, patterns, duplicates,
  new account velocity, IP reputation, content length)
- Hard threshold (0.8) rejects, soft threshold (0.5) holds for moderation
- LLM relevance checking via Hermes (hermes.ai.unturf.com) on every
  message when enabled, including embed mode parent page URL context
- Admin API endpoints: GET /api/v1/admin/namespaces, recent-nodes
- Spam hunting scripts: scan.py, disable_spam.py, promote_superuser.py
- Updated Python client with admin methods
2026-02-02 09:19:16 -05:00

202 lines
6.7 KiB
Python

"""
LLM-based relevance checking for spam detection.
Uses an OpenAI-compatible inference endpoint (e.g. hermes.ai.unturf.com)
to check whether a post is relevant to its context (namespace or thread).
Configuration (from .ini):
spam.llm.enabled = true
spam.llm.endpoint = https://hermes.ai.unturf.com/v1/chat/completions
spam.llm.model = adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic
spam.llm.timeout = 5
"""
import json
import logging
import urllib.request
import urllib.error
log = logging.getLogger(__name__)
# Defaults
DEFAULT_ENDPOINT = "https://hermes.ai.unturf.com/v1/chat/completions"
DEFAULT_MODEL = "adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic"
DEFAULT_TIMEOUT = 5 # seconds
def _llm_request(endpoint, model, messages, timeout):
"""Make a chat completion request to an OpenAI-compatible endpoint."""
body = json.dumps({
"model": model,
"messages": messages,
"max_tokens": 150,
"temperature": 0.1,
}).encode("utf-8")
req = urllib.request.Request(
endpoint,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
resp = urllib.request.urlopen(req, timeout=timeout)
data = json.loads(resp.read().decode("utf-8"))
return data["choices"][0]["message"]["content"].strip()
except (urllib.error.URLError, urllib.error.HTTPError, KeyError, Exception) as e:
log.warning("LLM relevance check failed: %s", e)
return None
def check_thread_relevance(namespace_name, namespace_description, title, content, settings=None):
"""Check if a new thread is relevant to the namespace.
Args:
namespace_name: The namespace (e.g. "meta.remarkbox.com")
namespace_description: The namespace description (may be None)
title: The thread title
content: The thread body text
settings: Pyramid registry settings dict
Returns:
(relevant, explanation) where relevant is True/False/None (None = LLM unavailable)
"""
if not _is_enabled(settings):
return None, None
endpoint, model, timeout = _get_config(settings)
ns_context = namespace_name
if namespace_description:
ns_context = "{} ({})".format(namespace_name, namespace_description)
messages = [
{
"role": "system",
"content": (
"You are a content moderation assistant. Your job is to determine if "
"a new discussion thread is relevant to the site it is being posted on. "
"Respond with exactly 'RELEVANT' or 'IRRELEVANT' on the first line, "
"followed by a brief one-sentence explanation."
),
},
{
"role": "user",
"content": (
"Site: {ns}\n\n"
"New thread title: {title}\n\n"
"Thread content (first 500 chars):\n{content}\n\n"
"Is this thread relevant to this site?"
).format(
ns=ns_context,
title=title or "(no title)",
content=(content or "")[:500],
),
},
]
response = _llm_request(endpoint, model, messages, timeout)
return _parse_verdict(response)
def check_reply_relevance(thread_title, thread_content, parent_content,
reply_content, page_url=None, namespace_name=None,
settings=None):
"""Check if a reply is relevant to the thread.
Args:
thread_title: Root thread title
thread_content: Root thread body (first 300 chars)
parent_content: Direct parent node body (first 300 chars)
reply_content: The reply being checked
page_url: Parent page URL if this is an embed-mode thread (may be None)
namespace_name: Namespace/site name (may be None)
settings: Pyramid registry settings dict
Returns:
(relevant, explanation) where relevant is True/False/None (None = LLM unavailable)
"""
if not _is_enabled(settings):
return None, None
endpoint, model, timeout = _get_config(settings)
# Build context block -- include parent page info when available (embed mode)
context_parts = []
if namespace_name:
context_parts.append("Site: {}".format(namespace_name))
if page_url:
context_parts.append("Parent page URL: {}".format(page_url))
if thread_title:
context_parts.append("Thread title: {}".format(thread_title))
if thread_content:
context_parts.append(
"Thread content (first 300 chars):\n{}".format(
(thread_content or "")[:300]
)
)
if parent_content:
context_parts.append(
"Parent comment (first 300 chars):\n{}".format(
(parent_content or "")[:300]
)
)
user_msg = "{context}\n\nNew reply (first 500 chars):\n{reply}\n\n" \
"Is this reply relevant to the discussion?".format(
context="\n\n".join(context_parts),
reply=(reply_content or "")[:500],
)
messages = [
{
"role": "system",
"content": (
"You are a content moderation assistant. Your job is to determine if "
"a reply is relevant to the discussion thread it is being posted in. "
"Off-topic spam, promotional content, and gibberish should be marked irrelevant. "
"Respond with exactly 'RELEVANT' or 'IRRELEVANT' on the first line, "
"followed by a brief one-sentence explanation."
),
},
{
"role": "user",
"content": user_msg,
},
]
response = _llm_request(endpoint, model, messages, timeout)
return _parse_verdict(response)
def _is_enabled(settings):
"""Check if LLM relevance checking is enabled."""
if not settings:
return False
return settings.get("spam.llm.enabled", "false").strip().lower() in ("true", "1", "yes")
def _get_config(settings):
"""Extract LLM config from settings."""
endpoint = settings.get("spam.llm.endpoint", DEFAULT_ENDPOINT)
model = settings.get("spam.llm.model", DEFAULT_MODEL)
timeout = int(settings.get("spam.llm.timeout", DEFAULT_TIMEOUT))
return endpoint, model, timeout
def _parse_verdict(response):
"""Parse a RELEVANT/IRRELEVANT response from the LLM."""
if not response:
return None, None
first_line = response.split("\n")[0].strip().upper()
explanation = response.split("\n", 1)[1].strip() if "\n" in response else ""
if "IRRELEVANT" in first_line:
return False, explanation
elif "RELEVANT" in first_line:
return True, explanation
# Ambiguous response -- treat as inconclusive
return None, response