Add spam moderation UI, node spam fields, and namespace spam toggle
- spam_score and spam_reason columns on Node model (alembic migration)
- spam_filter_enabled column on Namespace (owners can disable Hermes)
- check_spam always returns full result dict with score/signals/reason
- Spam data stored on every node for moderation display
- "(spam)" label on flagged nodes with hover showing Hermes explanation
- Spam moderation page at /ns/{namespace}/nodes?spam with bulk actions
- Bulk approve/disable for spam nodes with checkboxes
- Hermes reason shown prominently on spam page for each flagged node
- Namespace settings checkbox: "Enable Spam Filter (Hermes AI)"
- Namespace.spam_filter_enabled respected by LLM check gate
- spam_nodes property on Namespace for querying flagged nodes
- Unit tests for spam scoring, LLM mocked tests, live Hermes integration
tests covering embed mode, site mode, edge cases (44 new tests)
This commit is contained in:
parent
5042a7d9f1
commit
070a6b678d
13 changed files with 949 additions and 57 deletions
|
|
@ -64,20 +64,28 @@ def get_param(request, key, default=None):
|
|||
|
||||
def check_spam(request, data, user=None, namespace=None, title=None,
|
||||
parent_node=None):
|
||||
"""Check content for spam. Returns error dict or None.
|
||||
"""Check content for spam. Always returns a result dict.
|
||||
|
||||
- Hard threshold: reject with 403.
|
||||
- Soft threshold: returns {"spam_held": True} so caller can set approved=False.
|
||||
- LLM relevance check: if enabled and basic score is suspicious, ask the LLM
|
||||
whether the content is relevant to its context.
|
||||
- Superusers bypass all checks.
|
||||
Returns dict with keys:
|
||||
action: None (clean), "held", or "rejected"
|
||||
spam_score: float 0.0-1.0
|
||||
signals: list of signal strings
|
||||
spam_reason: human-readable LLM explanation or None
|
||||
error: present only when action == "rejected"
|
||||
|
||||
- Hard threshold: action="rejected", sets 403 on response.
|
||||
- Soft threshold: action="held", caller should set approved=False.
|
||||
- Below thresholds: action=None, spam data still returned for storage.
|
||||
- Superusers bypass all checks (returns score 0.0).
|
||||
"""
|
||||
settings = request.registry.settings
|
||||
result = {"action": None, "spam_score": 0.0, "signals": [], "spam_reason": None}
|
||||
|
||||
if settings.get("spam.enabled", "true").strip().lower() not in ("true", "1", "yes"):
|
||||
return None
|
||||
return result
|
||||
|
||||
if user and getattr(user, "is_superuser", False):
|
||||
return None
|
||||
return result
|
||||
|
||||
hard_threshold = float(settings.get("spam.hard_threshold", 0.8))
|
||||
soft_threshold = float(settings.get("spam.soft_threshold", 0.5))
|
||||
|
|
@ -90,26 +98,41 @@ def check_spam(request, data, user=None, namespace=None, title=None,
|
|||
settings=settings,
|
||||
)
|
||||
|
||||
# LLM relevance check: runs on every message when spam.llm.enabled is set
|
||||
if settings.get("spam.llm.enabled", "false").strip().lower() in ("true", "1", "yes"):
|
||||
spam_reason = None
|
||||
|
||||
# LLM relevance check: runs when globally enabled AND namespace allows it
|
||||
llm_globally_enabled = settings.get(
|
||||
"spam.llm.enabled", "false"
|
||||
).strip().lower() in ("true", "1", "yes")
|
||||
ns_filter_enabled = True
|
||||
if namespace and hasattr(namespace, "spam_filter_enabled"):
|
||||
ns_filter_enabled = namespace.spam_filter_enabled is not False
|
||||
|
||||
if llm_globally_enabled and ns_filter_enabled:
|
||||
relevant, explanation = _llm_relevance_check(
|
||||
settings, data, title=title, namespace=namespace,
|
||||
parent_node=parent_node,
|
||||
)
|
||||
if explanation:
|
||||
spam_reason = explanation
|
||||
if relevant is False:
|
||||
spam_score = min(spam_score + 0.4, 1.0)
|
||||
signals.append("llm_irrelevant")
|
||||
if explanation:
|
||||
signals.append("llm_reason:{}".format(explanation[:80]))
|
||||
|
||||
result["spam_score"] = spam_score
|
||||
result["signals"] = signals
|
||||
result["spam_reason"] = spam_reason
|
||||
|
||||
if spam_score >= hard_threshold:
|
||||
request.response.status_code = 403
|
||||
return {"error": "Content flagged as spam", "spam_score": spam_score}
|
||||
result["action"] = "rejected"
|
||||
result["error"] = "Content flagged as spam"
|
||||
elif spam_score >= soft_threshold:
|
||||
result["action"] = "held"
|
||||
|
||||
if spam_score >= soft_threshold:
|
||||
return {"spam_held": True, "spam_score": spam_score, "signals": signals}
|
||||
|
||||
return None
|
||||
return result
|
||||
|
||||
|
||||
def _llm_relevance_check(settings, data, title=None, namespace=None,
|
||||
|
|
@ -489,9 +512,8 @@ def api_create_thread(request):
|
|||
# Spam check (before creating anything)
|
||||
spam_result = check_spam(request, data, user=user, namespace=namespace,
|
||||
title=title)
|
||||
if spam_result and "error" in spam_result:
|
||||
return spam_result
|
||||
spam_held = spam_result and spam_result.get("spam_held")
|
||||
if spam_result.get("action") == "rejected":
|
||||
return {"error": spam_result["error"], "spam_score": spam_result["spam_score"]}
|
||||
|
||||
if namespace.allow_anonymous and not user:
|
||||
if not anonymous_name:
|
||||
|
|
@ -569,9 +591,13 @@ def api_create_thread(request):
|
|||
node_event = node.new_event(user, "created")
|
||||
request.dbsession.add(user)
|
||||
|
||||
if spam_held:
|
||||
if spam_result.get("action") == "held":
|
||||
node.approved = False
|
||||
|
||||
# Store spam data on node for moderation UI
|
||||
node.spam_score = spam_result.get("spam_score")
|
||||
node.spam_reason = spam_result.get("spam_reason")
|
||||
|
||||
request.dbsession.add(node)
|
||||
if node_event:
|
||||
request.dbsession.add(node_event)
|
||||
|
|
@ -659,9 +685,8 @@ def api_reply(request):
|
|||
# Spam check (before creating anything)
|
||||
spam_result = check_spam(request, data, user=user, namespace=namespace,
|
||||
parent_node=parent)
|
||||
if spam_result and "error" in spam_result:
|
||||
return spam_result
|
||||
spam_held = spam_result and spam_result.get("spam_held")
|
||||
if spam_result.get("action") == "rejected":
|
||||
return {"error": spam_result["error"], "spam_score": spam_result["spam_score"]}
|
||||
|
||||
if namespace.allow_anonymous and not user:
|
||||
if not anonymous_name:
|
||||
|
|
@ -688,7 +713,7 @@ def api_reply(request):
|
|||
child.verified = user.authenticated
|
||||
child_event = child.new_event(user, "commented")
|
||||
|
||||
if spam_held:
|
||||
if spam_result.get("action") == "held":
|
||||
child.approved = False
|
||||
elif namespace.hide_unless_approved:
|
||||
if user:
|
||||
|
|
@ -696,6 +721,10 @@ def api_reply(request):
|
|||
else:
|
||||
child.approved = False
|
||||
|
||||
# Store spam data on node for moderation UI
|
||||
child.spam_score = spam_result.get("spam_score")
|
||||
child.spam_reason = spam_result.get("spam_reason")
|
||||
|
||||
# Bump thread
|
||||
parent.root.changed = child.changed
|
||||
parent._invalidate_cache()
|
||||
|
|
|
|||
|
|
@ -130,6 +130,8 @@ class Namespace(RBase, Base):
|
|||
# T8: nesting depth settings
|
||||
max_nesting_depth = Column(Integer, default=None, nullable=True)
|
||||
collapse_depth = Column(Integer, default=None, nullable=True)
|
||||
# Spam filter: namespace owners can disable Hermes LLM checks.
|
||||
spam_filter_enabled = Column(Boolean, default=True)
|
||||
# the type of subscription of this Namespace.
|
||||
subscription_type = Column(
|
||||
Enum(*SUBSCRIPTION_TYPES, name="subscription_type"),
|
||||
|
|
@ -330,6 +332,12 @@ class Namespace(RBase, Base):
|
|||
or_(Node.approved == False, Node.approved.is_(None)), Node.disabled == False
|
||||
)
|
||||
|
||||
@property
|
||||
def spam_nodes(self):
|
||||
return self.nodes.filter(
|
||||
Node.spam_score >= 0.5, Node.disabled == False
|
||||
)
|
||||
|
||||
@property
|
||||
def visible_nodes(self):
|
||||
return get_nodes_who_share_roots_query(self.dbsession, self.visible_roots)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from collections import (
|
|||
)
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger, Boolean, Column, Integer, Unicode, UnicodeText, or_, func
|
||||
BigInteger, Boolean, Column, Float, Integer, Unicode, UnicodeText, or_, func
|
||||
)
|
||||
|
||||
from sqlalchemy.orm import relationship, backref
|
||||
|
|
@ -84,6 +84,9 @@ class Node(RBase, Base):
|
|||
locked = Column(Boolean, default=False)
|
||||
# by default comments are approved. unless Namespace hide_unless_approved.
|
||||
approved = Column(Boolean, default=True)
|
||||
# Spam detection: score (0.0-1.0) and human-readable reason from Hermes LLM.
|
||||
spam_score = Column(Float, default=None)
|
||||
spam_reason = Column(UnicodeText, default=None)
|
||||
# is there a related Uri model to this node?
|
||||
has_uri = Column(Boolean, default=False, nullable=False)
|
||||
ip_address = Column(Unicode(45), default=None)
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ def includeme(config):
|
|||
config.add_route("basic-verification-challenge", "/verification-challenge")
|
||||
|
||||
config.add_route("basic-namespace-nodes", "/ns/{namespace}/nodes")
|
||||
config.add_route("basic-namespace-spam-bulk", "/ns/{namespace}/spam-bulk")
|
||||
config.add_route("basic-namespace-delete", "/ns/{namespace}/delete")
|
||||
config.add_route("basic-namespace-settings", "/ns/{namespace}/settings")
|
||||
config.add_route("basic-namespace-import-comments", "/ns/{namespace}/import-comments")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
"""Add spam_score spam_reason to Node and spam_filter_enabled to Namespace
|
||||
|
||||
Revision ID: e99b1524710c
|
||||
Revises: 03061161fc3d
|
||||
Create Date: 2026-02-02 13:04:45.514654
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'e99b1524710c'
|
||||
down_revision = '03061161fc3d'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('rb_node', sa.Column('spam_score', sa.Float(), nullable=True))
|
||||
op.add_column('rb_node', sa.Column('spam_reason', sa.UnicodeText(), nullable=True))
|
||||
op.add_column('rb_namespace', sa.Column('spam_filter_enabled', sa.Boolean(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('rb_namespace', 'spam_filter_enabled')
|
||||
op.drop_column('rb_node', 'spam_reason')
|
||||
op.drop_column('rb_node', 'spam_score')
|
||||
|
|
@ -28,6 +28,57 @@ page: {{ request.page_number }}
|
|||
|
||||
<h3>{{ the_title }}</h3>
|
||||
|
||||
{% if 'spam' in request.params and nodes %}
|
||||
<form method="POST" action="{{ request.link_prefix }}/ns/{{ request.namespace.name }}/spam-bulk">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}" />
|
||||
|
||||
<div class="remarkbox-sub-menu" style="margin-bottom: 1em;">
|
||||
<label><input type="checkbox" onclick="var boxes = document.querySelectorAll('input[name=node_ids]'); for(var i=0;i<boxes.length;i++){boxes[i].checked=this.checked;}"> select all</label>
|
||||
|
||||
<button type="submit" name="bulk_action" value="approve">approve selected</button>
|
||||
<button type="submit" name="bulk_action" value="disable">disable selected</button>
|
||||
</div>
|
||||
|
||||
{%- for node in nodes %}
|
||||
|
||||
<br>
|
||||
|
||||
<a class="anchor" id="{{ node.id }}"></a>
|
||||
<div id="node-{{ node.id }}" class="node" style="border-left: 3px solid #c44; padding-left: 0.5em;">
|
||||
<input type="checkbox" name="node_ids" value="{{ node.id }}" style="float: left; margin-right: 0.5em; margin-top: 0.3em;">
|
||||
{{ snippets.node_avatar(node) }}
|
||||
{{ snippets.author_and_date(node) }}
|
||||
{{ snippets.status(node) }}
|
||||
|
||||
{% if node.spam_reason %}
|
||||
<div class="status" style="margin: 0.3em 0; padding: 0.3em 0.5em; background: #fff8f0; border: 1px solid #e0d0c0;">
|
||||
<b>Hermes:</b> {{ node.spam_reason }}
|
||||
</div>
|
||||
{% elif node.spam_score is not none %}
|
||||
<div class="status" style="margin: 0.3em 0; padding: 0.3em 0.5em; background: #fff8f0; border: 1px solid #e0d0c0;">
|
||||
<b>Spam score:</b> {{ "%.1f"|format(node.spam_score) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="node-data-{{ node.id }}">
|
||||
{{ node.data_html | safe }}
|
||||
</div>
|
||||
|
||||
{{ snippets.permalinks(node) }}
|
||||
{{ snippets.actions(node) }}
|
||||
</div>
|
||||
|
||||
{% endfor -%}
|
||||
|
||||
<br>
|
||||
<div class="remarkbox-sub-menu">
|
||||
<button type="submit" name="bulk_action" value="approve">approve selected</button>
|
||||
<button type="submit" name="bulk_action" value="disable">disable selected</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% else %}
|
||||
|
||||
{%- for node in nodes %}
|
||||
|
||||
<br>
|
||||
|
|
@ -56,4 +107,6 @@ page: {{ request.page_number }}
|
|||
|
||||
{% endfor -%}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{%- endblock -%}
|
||||
|
|
|
|||
|
|
@ -79,6 +79,11 @@ If checked, hide the Remarkbox Logo in footer.{% if request.namespace.hide_power
|
|||
<label>Allow API Access:</label>
|
||||
<input type="checkbox" name="api-access-checkbox" id="api-access-checkbox" {% if request.namespace.api_access %}checked{% endif %}></input>
|
||||
If checked, allow JSON API access to this namespace for agents and integrations. Uncheck to block all <code>/api/v1/</code> requests targeting this namespace.
|
||||
<br>
|
||||
<br>
|
||||
<label>Enable Spam Filter (Hermes AI):</label>
|
||||
<input type="checkbox" name="spam-filter-enabled-checkbox" id="spam-filter-enabled-checkbox" {% if request.namespace.spam_filter_enabled is not sameas false %}checked{% endif %}></input>
|
||||
If checked, use AI to check whether comments are relevant to this namespace. Irrelevant or spammy posts will be labeled.
|
||||
|
||||
<br>
|
||||
<br>
|
||||
|
|
|
|||
|
|
@ -76,6 +76,8 @@
|
|||
<span class="status">
|
||||
{% if node.disabled %}
|
||||
<span>(waiting for deletion)</span>
|
||||
{% elif node.spam_score is not none and node.spam_score >= 0.5 %}
|
||||
<span title="{{ node.spam_reason or 'Flagged by automated spam filter' }}">(spam)</span>
|
||||
{% elif request.namespace.hide_unless_approved and not node.approved %}
|
||||
<span title="Only you can see a hidden node.">(hidden: waiting for approval)</span>
|
||||
{% endif %}
|
||||
|
|
@ -245,6 +247,7 @@
|
|||
{% set namespace_unapproved_node_count = namespace.unapproved_nodes.count() %}
|
||||
{% set namespace_approved_node_count = namespace.approved_nodes.count() %}
|
||||
{% set namespace_disabled_node_count = namespace.disabled_nodes.count() %}
|
||||
{% set namespace_spam_node_count = namespace.spam_nodes.count() %}
|
||||
|
||||
<div class="remarkbox-sub-menu hidden-on-phone">
|
||||
|
||||
|
|
@ -260,6 +263,9 @@
|
|||
{% else %}
|
||||
<a href="{{ request.link_prefix }}/ns/{{ namespace.name }}/nodes">nodes ({{ namespace_all_node_count }})</a> |
|
||||
{% endif %}
|
||||
{% if namespace_spam_node_count > 0 %}
|
||||
<a href="{{ request.link_prefix }}/ns/{{ namespace.name }}/nodes?spam">spam ({{ namespace_spam_node_count }})</a> |
|
||||
{% endif %}
|
||||
<a href="{{ request.link_prefix }}/ns/{{ namespace.name }}/nodes?disabled">disabled nodes ({{ namespace_disabled_node_count }})</a>
|
||||
|
||||
{% if request.user in namespace.owners %}
|
||||
|
|
|
|||
132
remarkbox/tests/test_spam.py
Normal file
132
remarkbox/tests/test_spam.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
"""Unit tests for spam scoring module."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from remarkbox.models.spam import (
|
||||
score_content,
|
||||
_link_density,
|
||||
_link_count,
|
||||
_content_hash,
|
||||
)
|
||||
|
||||
|
||||
class TestLinkDensity(unittest.TestCase):
|
||||
|
||||
def test_no_links(self):
|
||||
self.assertEqual(_link_density("Hello world"), 0.0)
|
||||
|
||||
def test_all_links(self):
|
||||
text = "https://example.com"
|
||||
density = _link_density(text)
|
||||
self.assertAlmostEqual(density, 1.0)
|
||||
|
||||
def test_mixed_content(self):
|
||||
text = "Check out https://example.com for more info"
|
||||
density = _link_density(text)
|
||||
self.assertGreater(density, 0.0)
|
||||
self.assertLess(density, 1.0)
|
||||
|
||||
def test_empty_string(self):
|
||||
self.assertEqual(_link_density(""), 0.0)
|
||||
|
||||
def test_none(self):
|
||||
self.assertEqual(_link_density(None), 0.0)
|
||||
|
||||
|
||||
class TestLinkCount(unittest.TestCase):
|
||||
|
||||
def test_no_links(self):
|
||||
self.assertEqual(_link_count("Hello world"), 0)
|
||||
|
||||
def test_multiple_links(self):
|
||||
text = "Visit https://a.com and https://b.com and https://c.com"
|
||||
self.assertEqual(_link_count(text), 3)
|
||||
|
||||
def test_empty(self):
|
||||
self.assertEqual(_link_count(""), 0)
|
||||
|
||||
|
||||
class TestContentHash(unittest.TestCase):
|
||||
|
||||
def test_same_content(self):
|
||||
self.assertEqual(_content_hash("hello"), _content_hash("hello"))
|
||||
|
||||
def test_different_content(self):
|
||||
self.assertNotEqual(_content_hash("hello"), _content_hash("world"))
|
||||
|
||||
def test_whitespace_normalization(self):
|
||||
self.assertEqual(
|
||||
_content_hash("hello world"),
|
||||
_content_hash("hello world"),
|
||||
)
|
||||
|
||||
def test_case_normalization(self):
|
||||
self.assertEqual(_content_hash("HELLO"), _content_hash("hello"))
|
||||
|
||||
|
||||
class TestScoreContent(unittest.TestCase):
|
||||
|
||||
def test_clean_content_scores_zero(self):
|
||||
score, signals = score_content("This is a normal discussion post.")
|
||||
self.assertEqual(score, 0.0)
|
||||
self.assertEqual(signals, [])
|
||||
|
||||
def test_empty_content(self):
|
||||
score, signals = score_content("")
|
||||
self.assertEqual(score, 0.0)
|
||||
|
||||
def test_high_link_density(self):
|
||||
text = "https://spam1.com https://spam2.com https://spam3.com"
|
||||
score, signals = score_content(text)
|
||||
self.assertGreater(score, 0.0)
|
||||
self.assertTrue(any("link_density" in s for s in signals))
|
||||
|
||||
def test_many_links(self):
|
||||
text = "Visit " + " ".join(
|
||||
"https://site{}.com".format(i) for i in range(10)
|
||||
)
|
||||
score, signals = score_content(text)
|
||||
self.assertTrue(any("link_count" in s for s in signals))
|
||||
|
||||
def test_spam_pattern_single(self):
|
||||
text = "You should buy now before the sale ends!"
|
||||
score, signals = score_content(text)
|
||||
self.assertGreater(score, 0.0)
|
||||
self.assertTrue(any("spam_patterns" in s for s in signals))
|
||||
|
||||
def test_spam_pattern_multiple(self):
|
||||
text = "Buy now! Click here to get a free trial. Act now! Limited time offer!"
|
||||
score, signals = score_content(text)
|
||||
self.assertGreaterEqual(score, 0.5)
|
||||
|
||||
def test_duplicate_content(self):
|
||||
# First post is fine
|
||||
score1, signals1 = score_content(
|
||||
"Duplicate test post xyz123",
|
||||
ip_address="10.99.99.99",
|
||||
)
|
||||
# Second identical post from same IP triggers duplicate
|
||||
score2, signals2 = score_content(
|
||||
"Duplicate test post xyz123",
|
||||
ip_address="10.99.99.99",
|
||||
)
|
||||
self.assertTrue(any("duplicate_content" in s for s in signals2))
|
||||
|
||||
def test_score_capped_at_one(self):
|
||||
# Content with every signal firing should not exceed 1.0
|
||||
text = (
|
||||
"Buy now! Click here to free trial! Act now! "
|
||||
+ " ".join("https://spam{}.com".format(i) for i in range(20))
|
||||
)
|
||||
score, signals = score_content(text)
|
||||
self.assertLessEqual(score, 1.0)
|
||||
|
||||
def test_short_content_from_new_user(self):
|
||||
import time
|
||||
user = MagicMock()
|
||||
user.id = "test-short"
|
||||
user.created = int(time.time() * 1000) - 1000 # 1 second old
|
||||
user.nodes.count.return_value = 0
|
||||
score, signals = score_content("Hi", user=user)
|
||||
self.assertTrue(any("very_short_content" in s for s in signals))
|
||||
571
remarkbox/tests/test_spam_llm.py
Normal file
571
remarkbox/tests/test_spam_llm.py
Normal file
|
|
@ -0,0 +1,571 @@
|
|||
"""Unit and integration tests for LLM-based spam relevance checking.
|
||||
|
||||
Unit tests mock the HTTP call. Integration tests hit the real Hermes endpoint.
|
||||
"""
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from remarkbox.models.spam_llm import (
|
||||
check_thread_relevance,
|
||||
check_reply_relevance,
|
||||
_parse_verdict,
|
||||
_is_enabled,
|
||||
_llm_request,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests (mocked, no network)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseVerdict(unittest.TestCase):
|
||||
|
||||
def test_relevant(self):
|
||||
relevant, explanation = _parse_verdict("RELEVANT\nThis is on topic.")
|
||||
self.assertTrue(relevant)
|
||||
self.assertEqual(explanation, "This is on topic.")
|
||||
|
||||
def test_irrelevant(self):
|
||||
relevant, explanation = _parse_verdict("IRRELEVANT\nThis is spam.")
|
||||
self.assertFalse(relevant)
|
||||
self.assertEqual(explanation, "This is spam.")
|
||||
|
||||
def test_relevant_no_explanation(self):
|
||||
relevant, explanation = _parse_verdict("RELEVANT")
|
||||
self.assertTrue(relevant)
|
||||
self.assertEqual(explanation, "")
|
||||
|
||||
def test_irrelevant_no_explanation(self):
|
||||
relevant, explanation = _parse_verdict("IRRELEVANT")
|
||||
self.assertFalse(relevant)
|
||||
self.assertEqual(explanation, "")
|
||||
|
||||
def test_none_response(self):
|
||||
relevant, explanation = _parse_verdict(None)
|
||||
self.assertIsNone(relevant)
|
||||
self.assertIsNone(explanation)
|
||||
|
||||
def test_empty_response(self):
|
||||
relevant, explanation = _parse_verdict("")
|
||||
self.assertIsNone(relevant)
|
||||
self.assertIsNone(explanation)
|
||||
|
||||
def test_ambiguous_response(self):
|
||||
relevant, explanation = _parse_verdict("I'm not sure about this.")
|
||||
self.assertIsNone(relevant)
|
||||
self.assertEqual(explanation, "I'm not sure about this.")
|
||||
|
||||
def test_case_insensitive(self):
|
||||
relevant, _ = _parse_verdict("relevant\nYes it is.")
|
||||
self.assertTrue(relevant)
|
||||
|
||||
def test_irrelevant_in_sentence(self):
|
||||
relevant, _ = _parse_verdict("IRRELEVANT - clearly spam\nPromo content.")
|
||||
self.assertFalse(relevant)
|
||||
|
||||
|
||||
class TestIsEnabled(unittest.TestCase):
|
||||
|
||||
def test_none_settings(self):
|
||||
self.assertFalse(_is_enabled(None))
|
||||
|
||||
def test_missing_key(self):
|
||||
self.assertFalse(_is_enabled({}))
|
||||
|
||||
def test_enabled_true(self):
|
||||
self.assertTrue(_is_enabled({"spam.llm.enabled": "true"}))
|
||||
|
||||
def test_enabled_yes(self):
|
||||
self.assertTrue(_is_enabled({"spam.llm.enabled": "yes"}))
|
||||
|
||||
def test_enabled_one(self):
|
||||
self.assertTrue(_is_enabled({"spam.llm.enabled": "1"}))
|
||||
|
||||
def test_disabled_false(self):
|
||||
self.assertFalse(_is_enabled({"spam.llm.enabled": "false"}))
|
||||
|
||||
def test_disabled_empty(self):
|
||||
self.assertFalse(_is_enabled({"spam.llm.enabled": ""}))
|
||||
|
||||
|
||||
class TestCheckThreadRelevanceMocked(unittest.TestCase):
|
||||
|
||||
def test_disabled_returns_none(self):
|
||||
relevant, explanation = check_thread_relevance(
|
||||
"example.com", None, "Hello", "World",
|
||||
settings={"spam.llm.enabled": "false"},
|
||||
)
|
||||
self.assertIsNone(relevant)
|
||||
self.assertIsNone(explanation)
|
||||
|
||||
@patch("remarkbox.models.spam_llm._llm_request")
|
||||
def test_relevant_thread(self, mock_req):
|
||||
mock_req.return_value = "RELEVANT\nThe thread is about the site topic."
|
||||
relevant, explanation = check_thread_relevance(
|
||||
"cooking.example.com", "A cooking forum",
|
||||
"Best pasta recipe", "I love making pasta with fresh ingredients.",
|
||||
settings={"spam.llm.enabled": "true"},
|
||||
)
|
||||
self.assertTrue(relevant)
|
||||
mock_req.assert_called_once()
|
||||
|
||||
@patch("remarkbox.models.spam_llm._llm_request")
|
||||
def test_irrelevant_thread(self, mock_req):
|
||||
mock_req.return_value = "IRRELEVANT\nThis is unrelated spam."
|
||||
relevant, explanation = check_thread_relevance(
|
||||
"cooking.example.com", "A cooking forum",
|
||||
"Buy cheap watches", "Best prices on luxury watches online!",
|
||||
settings={"spam.llm.enabled": "true"},
|
||||
)
|
||||
self.assertFalse(relevant)
|
||||
|
||||
@patch("remarkbox.models.spam_llm._llm_request")
|
||||
def test_llm_failure_returns_none(self, mock_req):
|
||||
mock_req.return_value = None
|
||||
relevant, explanation = check_thread_relevance(
|
||||
"example.com", None, "Test", "Test",
|
||||
settings={"spam.llm.enabled": "true"},
|
||||
)
|
||||
self.assertIsNone(relevant)
|
||||
|
||||
|
||||
class TestCheckReplyRelevanceMocked(unittest.TestCase):
|
||||
|
||||
@patch("remarkbox.models.spam_llm._llm_request")
|
||||
def test_relevant_reply(self, mock_req):
|
||||
mock_req.return_value = "RELEVANT\nThe reply contributes to the discussion."
|
||||
relevant, explanation = check_reply_relevance(
|
||||
thread_title="Best pasta recipe",
|
||||
thread_content="I love making pasta.",
|
||||
parent_content="What kind of pasta do you use?",
|
||||
reply_content="I prefer fresh fettuccine.",
|
||||
settings={"spam.llm.enabled": "true"},
|
||||
)
|
||||
self.assertTrue(relevant)
|
||||
|
||||
@patch("remarkbox.models.spam_llm._llm_request")
|
||||
def test_irrelevant_reply(self, mock_req):
|
||||
mock_req.return_value = "IRRELEVANT\nThis is promotional spam."
|
||||
relevant, explanation = check_reply_relevance(
|
||||
thread_title="Best pasta recipe",
|
||||
thread_content="I love making pasta.",
|
||||
parent_content="What kind of pasta do you use?",
|
||||
reply_content="Buy cheap watches at watches.com!",
|
||||
settings={"spam.llm.enabled": "true"},
|
||||
)
|
||||
self.assertFalse(relevant)
|
||||
|
||||
@patch("remarkbox.models.spam_llm._llm_request")
|
||||
def test_reply_with_page_url(self, mock_req):
|
||||
"""Embed mode: page_url and namespace_name are included in context."""
|
||||
mock_req.return_value = "RELEVANT\nOn topic for the page."
|
||||
relevant, explanation = check_reply_relevance(
|
||||
thread_title="How to bake bread",
|
||||
thread_content="A guide to baking.",
|
||||
parent_content=None,
|
||||
reply_content="Great guide, thanks!",
|
||||
page_url="https://cooking-blog.com/bread-guide",
|
||||
namespace_name="cooking-blog.com",
|
||||
settings={"spam.llm.enabled": "true"},
|
||||
)
|
||||
self.assertTrue(relevant)
|
||||
# Verify the LLM was called with context containing the page URL
|
||||
call_args = mock_req.call_args
|
||||
messages = call_args[0][2] # third positional arg
|
||||
user_msg = messages[1]["content"]
|
||||
self.assertIn("cooking-blog.com", user_msg)
|
||||
self.assertIn("https://cooking-blog.com/bread-guide", user_msg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests (real Hermes endpoint)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
HERMES_SETTINGS = {
|
||||
"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": "10",
|
||||
}
|
||||
|
||||
|
||||
class TestHermesIntegration(unittest.TestCase):
|
||||
"""Integration tests that hit the real Hermes LLM endpoint.
|
||||
|
||||
These are not mocked -- they make real HTTP requests to hermes.ai.unturf.com.
|
||||
Skipped if the endpoint is unreachable.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
"https://hermes.ai.unturf.com/v1/models",
|
||||
method="GET",
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
cls.hermes_available = True
|
||||
except Exception:
|
||||
cls.hermes_available = False
|
||||
|
||||
def setUp(self):
|
||||
if not self.hermes_available:
|
||||
self.skipTest("Hermes endpoint not reachable")
|
||||
|
||||
def test_thread_relevant_to_namespace(self):
|
||||
relevant, explanation = check_thread_relevance(
|
||||
namespace_name="meta.remarkbox.com",
|
||||
namespace_description="Discussion about the Remarkbox commenting platform",
|
||||
title="Feature request: dark mode",
|
||||
content="It would be great if Remarkbox had a dark mode option for embedded comments.",
|
||||
settings=HERMES_SETTINGS,
|
||||
)
|
||||
self.assertTrue(relevant, "Expected relevant, got: {}".format(explanation))
|
||||
|
||||
def test_thread_irrelevant_to_namespace(self):
|
||||
relevant, explanation = check_thread_relevance(
|
||||
namespace_name="meta.remarkbox.com",
|
||||
namespace_description="Discussion about the Remarkbox commenting platform",
|
||||
title="Best pizza in New York",
|
||||
content="Looking for recommendations for pizza places in Manhattan. I love thin crust.",
|
||||
settings=HERMES_SETTINGS,
|
||||
)
|
||||
self.assertFalse(relevant, "Expected irrelevant, got: {}".format(explanation))
|
||||
|
||||
def test_reply_relevant_to_thread(self):
|
||||
relevant, explanation = check_reply_relevance(
|
||||
thread_title="How to install Remarkbox",
|
||||
thread_content="I'm trying to install Remarkbox on my server and need help with the configuration.",
|
||||
parent_content="Have you tried checking the documentation?",
|
||||
reply_content="Yes, I followed the docs but got stuck on the database setup step.",
|
||||
settings=HERMES_SETTINGS,
|
||||
)
|
||||
self.assertTrue(relevant, "Expected relevant, got: {}".format(explanation))
|
||||
|
||||
def test_reply_spam_irrelevant(self):
|
||||
relevant, explanation = check_reply_relevance(
|
||||
thread_title="How to install Remarkbox",
|
||||
thread_content="I'm trying to install Remarkbox on my server.",
|
||||
parent_content="Have you tried checking the documentation?",
|
||||
reply_content="Buy cheap designer watches at www.fake-watches-sale.com! Best prices guaranteed!",
|
||||
settings=HERMES_SETTINGS,
|
||||
)
|
||||
self.assertFalse(relevant, "Expected irrelevant, got: {}".format(explanation))
|
||||
|
||||
def test_reply_with_embed_page_context(self):
|
||||
"""Test that embed mode page URL helps the LLM understand context."""
|
||||
relevant, explanation = check_reply_relevance(
|
||||
thread_title="Getting Started with Python",
|
||||
thread_content="",
|
||||
parent_content=None,
|
||||
reply_content="This tutorial helped me understand list comprehensions, thanks!",
|
||||
page_url="https://python-tutorial.example.com/getting-started",
|
||||
namespace_name="python-tutorial.example.com",
|
||||
settings=HERMES_SETTINGS,
|
||||
)
|
||||
self.assertTrue(relevant, "Expected relevant, got: {}".format(explanation))
|
||||
|
||||
def test_raw_llm_request(self):
|
||||
"""Verify we can make a raw request to Hermes and get a response."""
|
||||
response = _llm_request(
|
||||
endpoint="https://hermes.ai.unturf.com/v1/chat/completions",
|
||||
model="adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic",
|
||||
messages=[
|
||||
{"role": "system", "content": "Reply with exactly the word PONG."},
|
||||
{"role": "user", "content": "PING"},
|
||||
],
|
||||
timeout=10,
|
||||
)
|
||||
self.assertIsNotNone(response)
|
||||
self.assertIn("PONG", response.upper())
|
||||
|
||||
|
||||
class TestHermesEmbedMode(unittest.TestCase):
|
||||
"""Integration tests for embed mode with parent page context.
|
||||
|
||||
In embed mode, threads represent comment sections on external pages.
|
||||
The LLM receives the parent page URL and namespace to understand what
|
||||
the page is about, even when the thread has minimal content.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
import urllib.request
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
"https://hermes.ai.unturf.com/v1/models", method="GET",
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
cls.hermes_available = True
|
||||
except Exception:
|
||||
cls.hermes_available = False
|
||||
|
||||
def setUp(self):
|
||||
if not self.hermes_available:
|
||||
self.skipTest("Hermes endpoint not reachable")
|
||||
|
||||
def test_embed_relevant_comment_on_blog(self):
|
||||
"""Comment that's relevant to a blog post page."""
|
||||
relevant, explanation = check_reply_relevance(
|
||||
thread_title="10 Tips for Growing Tomatoes in Small Spaces",
|
||||
thread_content="", # embed threads often have no body
|
||||
parent_content=None,
|
||||
reply_content="Tip #3 about container size really helped me. "
|
||||
"I switched to 5-gallon buckets and my cherry "
|
||||
"tomatoes are thriving now.",
|
||||
page_url="https://gardening-blog.example.com/tomatoes-small-spaces",
|
||||
namespace_name="gardening-blog.example.com",
|
||||
settings=HERMES_SETTINGS,
|
||||
)
|
||||
self.assertTrue(relevant, "Expected relevant: {}".format(explanation))
|
||||
|
||||
def test_embed_spam_comment_on_blog(self):
|
||||
"""Obvious spam comment on a gardening blog post."""
|
||||
relevant, explanation = check_reply_relevance(
|
||||
thread_title="10 Tips for Growing Tomatoes in Small Spaces",
|
||||
thread_content="",
|
||||
parent_content=None,
|
||||
reply_content="Amazing deals on designer handbags! Visit "
|
||||
"cheapbags.example.com for 90% off Louis Vuitton!",
|
||||
page_url="https://gardening-blog.example.com/tomatoes-small-spaces",
|
||||
namespace_name="gardening-blog.example.com",
|
||||
settings=HERMES_SETTINGS,
|
||||
)
|
||||
self.assertFalse(relevant, "Expected irrelevant: {}".format(explanation))
|
||||
|
||||
def test_embed_crypto_spam_on_tech_blog(self):
|
||||
"""Crypto spam on an unrelated tech article."""
|
||||
relevant, explanation = check_reply_relevance(
|
||||
thread_title="Understanding CSS Grid Layout",
|
||||
thread_content="A comprehensive guide to CSS Grid for modern web layouts.",
|
||||
parent_content=None,
|
||||
reply_content="I made $5000 in one week with this crypto trading bot! "
|
||||
"Sign up at crypto-scam.example.com and start earning now!",
|
||||
page_url="https://webdev-tutorials.example.com/css-grid-guide",
|
||||
namespace_name="webdev-tutorials.example.com",
|
||||
settings=HERMES_SETTINGS,
|
||||
)
|
||||
self.assertFalse(relevant, "Expected irrelevant: {}".format(explanation))
|
||||
|
||||
def test_embed_tangential_but_relevant_comment(self):
|
||||
"""A comment that's loosely related to the page topic."""
|
||||
relevant, explanation = check_reply_relevance(
|
||||
thread_title="Python asyncio Tutorial",
|
||||
thread_content="",
|
||||
parent_content="I found the event loop explanation very clear.",
|
||||
reply_content="If you liked asyncio, you should also check out "
|
||||
"trio which has a simpler API for structured concurrency.",
|
||||
page_url="https://python-guides.example.com/asyncio-tutorial",
|
||||
namespace_name="python-guides.example.com",
|
||||
settings=HERMES_SETTINGS,
|
||||
)
|
||||
self.assertTrue(relevant, "Expected relevant: {}".format(explanation))
|
||||
|
||||
def test_embed_no_thread_content_only_page_url(self):
|
||||
"""Embed thread with only a page URL, no title or content."""
|
||||
relevant, explanation = check_reply_relevance(
|
||||
thread_title=None,
|
||||
thread_content=None,
|
||||
parent_content=None,
|
||||
reply_content="This recipe is delicious! I added extra garlic.",
|
||||
page_url="https://recipes.example.com/garlic-bread",
|
||||
namespace_name="recipes.example.com",
|
||||
settings=HERMES_SETTINGS,
|
||||
)
|
||||
self.assertTrue(relevant, "Expected relevant: {}".format(explanation))
|
||||
|
||||
|
||||
class TestHermesSiteMode(unittest.TestCase):
|
||||
"""Integration tests for site mode (standalone threads with full context).
|
||||
|
||||
In site mode, threads have titles and content. The LLM checks whether
|
||||
new threads are relevant to the namespace and replies to the discussion.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
import urllib.request
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
"https://hermes.ai.unturf.com/v1/models", method="GET",
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
cls.hermes_available = True
|
||||
except Exception:
|
||||
cls.hermes_available = False
|
||||
|
||||
def setUp(self):
|
||||
if not self.hermes_available:
|
||||
self.skipTest("Hermes endpoint not reachable")
|
||||
|
||||
def test_site_relevant_thread_on_forum(self):
|
||||
"""Thread about Remarkbox features on meta.remarkbox.com."""
|
||||
relevant, explanation = check_thread_relevance(
|
||||
namespace_name="meta.remarkbox.com",
|
||||
namespace_description="Discussion about Remarkbox, the privacy-first comment system",
|
||||
title="Email notifications are delayed",
|
||||
content="I noticed that email notifications for new replies are taking "
|
||||
"about 30 minutes to arrive. Is this expected or is something "
|
||||
"wrong with the mail server? I'm using the hosted version at "
|
||||
"my.remarkbox.com with the default settings.",
|
||||
settings=HERMES_SETTINGS,
|
||||
)
|
||||
self.assertTrue(relevant, "Expected relevant: {}".format(explanation))
|
||||
|
||||
def test_site_irrelevant_thread_on_forum(self):
|
||||
"""Completely unrelated thread on meta.remarkbox.com."""
|
||||
relevant, explanation = check_thread_relevance(
|
||||
namespace_name="meta.remarkbox.com",
|
||||
namespace_description="Discussion about Remarkbox, the privacy-first comment system",
|
||||
title="How to train for a marathon",
|
||||
content="I'm starting my marathon training plan and wondering about "
|
||||
"the best shoes for long distance running. I currently run "
|
||||
"about 20 miles per week and want to increase to 40.",
|
||||
settings=HERMES_SETTINGS,
|
||||
)
|
||||
self.assertFalse(relevant, "Expected irrelevant: {}".format(explanation))
|
||||
|
||||
def test_site_reply_to_deep_thread(self):
|
||||
"""Reply deep in a conversation thread, relevant to the discussion."""
|
||||
relevant, explanation = check_reply_relevance(
|
||||
thread_title="Custom CSS for embedded comments",
|
||||
thread_content="I want to customize the look of Remarkbox comments "
|
||||
"on my website. The default styling clashes with my "
|
||||
"dark theme. How can I override the default CSS?",
|
||||
parent_content="You can use the remarkbox-theme-meta approach. "
|
||||
"Add a <meta> tag with name='remarkbox-css' and "
|
||||
"point it to your custom stylesheet URL.",
|
||||
reply_content="Thanks! I added the meta tag and it works. One issue "
|
||||
"though - the reply form textarea still uses the default "
|
||||
"white background. Do I need a more specific selector?",
|
||||
namespace_name="meta.remarkbox.com",
|
||||
settings=HERMES_SETTINGS,
|
||||
)
|
||||
self.assertTrue(relevant, "Expected relevant: {}".format(explanation))
|
||||
|
||||
def test_site_gibberish_reply(self):
|
||||
"""Gibberish/nonsense reply to a legitimate thread."""
|
||||
relevant, explanation = check_reply_relevance(
|
||||
thread_title="Custom CSS for embedded comments",
|
||||
thread_content="I want to customize the look of Remarkbox comments "
|
||||
"on my website.",
|
||||
parent_content="You can use remarkbox-theme-meta.",
|
||||
reply_content="asdf jkl; qwerty uiop zxcv bnm 12345 "
|
||||
"hgfdsa poiuytrewq mnbvcxz",
|
||||
namespace_name="meta.remarkbox.com",
|
||||
settings=HERMES_SETTINGS,
|
||||
)
|
||||
self.assertFalse(relevant, "Expected irrelevant: {}".format(explanation))
|
||||
|
||||
def test_site_thread_with_rich_namespace_description(self):
|
||||
"""Namespace with a detailed description gives the LLM more context."""
|
||||
relevant, explanation = check_thread_relevance(
|
||||
namespace_name="recipes.cooking-community.example.com",
|
||||
namespace_description=(
|
||||
"A community for sharing and discussing recipes. "
|
||||
"Topics include baking, grilling, meal prep, international cuisine, "
|
||||
"dietary restrictions, and kitchen equipment reviews."
|
||||
),
|
||||
title="Best cast iron skillet for beginners",
|
||||
content="I want to get my first cast iron skillet. I've been using "
|
||||
"non-stick pans but want to try cast iron for better searing. "
|
||||
"Budget is around $40. Any recommendations?",
|
||||
settings=HERMES_SETTINGS,
|
||||
)
|
||||
self.assertTrue(relevant, "Expected relevant: {}".format(explanation))
|
||||
|
||||
def test_site_seo_spam_thread(self):
|
||||
"""SEO spam disguised as a question."""
|
||||
relevant, explanation = check_thread_relevance(
|
||||
namespace_name="recipes.cooking-community.example.com",
|
||||
namespace_description="A community for sharing and discussing recipes.",
|
||||
title="Best SEO services for your website",
|
||||
content="Are you looking to rank #1 on Google? Our professional SEO "
|
||||
"team can help you get more traffic and leads. Visit "
|
||||
"seo-services.example.com for a free audit. We guarantee "
|
||||
"first page rankings within 30 days or your money back!",
|
||||
settings=HERMES_SETTINGS,
|
||||
)
|
||||
self.assertFalse(relevant, "Expected irrelevant: {}".format(explanation))
|
||||
|
||||
|
||||
class TestHermesEdgeCases(unittest.TestCase):
|
||||
"""Integration tests for edge cases and tricky content.
|
||||
|
||||
Tests that exercise Hermes with content that might be ambiguous,
|
||||
minimal, or challenging to classify.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
import urllib.request
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
"https://hermes.ai.unturf.com/v1/models", method="GET",
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
cls.hermes_available = True
|
||||
except Exception:
|
||||
cls.hermes_available = False
|
||||
|
||||
def setUp(self):
|
||||
if not self.hermes_available:
|
||||
self.skipTest("Hermes endpoint not reachable")
|
||||
|
||||
def test_short_agreement_reply(self):
|
||||
"""Very short reply that just agrees -- should be relevant."""
|
||||
relevant, explanation = check_reply_relevance(
|
||||
thread_title="Should we add dark mode?",
|
||||
thread_content="I think Remarkbox needs a dark mode option.",
|
||||
parent_content="I agree, dark mode would be great.",
|
||||
reply_content="+1",
|
||||
namespace_name="meta.remarkbox.com",
|
||||
settings=HERMES_SETTINGS,
|
||||
)
|
||||
self.assertTrue(relevant, "Expected relevant: {}".format(explanation))
|
||||
|
||||
def test_link_that_is_relevant(self):
|
||||
"""A reply that's mostly a link but is relevant to the discussion."""
|
||||
relevant, explanation = check_reply_relevance(
|
||||
thread_title="Resources for learning Python",
|
||||
thread_content="Let's collect good resources for learning Python.",
|
||||
parent_content="I found the official tutorial helpful.",
|
||||
reply_content="Here's another great resource: "
|
||||
"https://docs.python.org/3/tutorial/index.html",
|
||||
namespace_name="programming.example.com",
|
||||
settings=HERMES_SETTINGS,
|
||||
)
|
||||
self.assertTrue(relevant, "Expected relevant: {}".format(explanation))
|
||||
|
||||
def test_promotional_but_relevant(self):
|
||||
"""Self-promotion that is still on-topic for the namespace."""
|
||||
relevant, explanation = check_thread_relevance(
|
||||
namespace_name="meta.remarkbox.com",
|
||||
namespace_description="Discussion about the Remarkbox comment system",
|
||||
title="I built a Remarkbox plugin for WordPress",
|
||||
content="Hey everyone, I created a WordPress plugin that makes it "
|
||||
"easy to embed Remarkbox comments. It handles the JavaScript "
|
||||
"snippet injection and lets you configure settings from the "
|
||||
"WP admin panel. Free and open source on GitHub.",
|
||||
settings=HERMES_SETTINGS,
|
||||
)
|
||||
self.assertTrue(relevant, "Expected relevant: {}".format(explanation))
|
||||
|
||||
def test_different_language_spam(self):
|
||||
"""Spam in a different language on an English namespace."""
|
||||
relevant, explanation = check_reply_relevance(
|
||||
thread_title="How to configure email notifications",
|
||||
thread_content="I want to set up email notifications for Remarkbox.",
|
||||
parent_content="Check the namespace settings panel.",
|
||||
reply_content="Compre relogios baratos! Melhor preco garantido! "
|
||||
"Visite nossa loja online: relogios-baratos.example.com",
|
||||
namespace_name="meta.remarkbox.com",
|
||||
settings=HERMES_SETTINGS,
|
||||
)
|
||||
self.assertFalse(relevant, "Expected irrelevant: {}".format(explanation))
|
||||
|
|
@ -84,6 +84,7 @@ def namespace_settings(request):
|
|||
ignore_query_string_checkbox = p.get("ignore-query-string-checkbox", "off")
|
||||
hide_powered_by_checkbox = p.get("hide-powered-by-checkbox", "off")
|
||||
api_access_checkbox = p.get("api-access-checkbox", "off")
|
||||
spam_filter_enabled_checkbox = p.get("spam-filter-enabled-checkbox", "off")
|
||||
|
||||
hide_unless_approved = checkbox_to_bool(hide_unless_approved_checkbox)
|
||||
allow_anonymous = checkbox_to_bool(allow_anonymous_checkbox)
|
||||
|
|
@ -94,6 +95,7 @@ def namespace_settings(request):
|
|||
ignore_query_string = checkbox_to_bool(ignore_query_string_checkbox)
|
||||
hide_powered_by = checkbox_to_bool(hide_powered_by_checkbox)
|
||||
api_access = checkbox_to_bool(api_access_checkbox)
|
||||
spam_filter_enabled = checkbox_to_bool(spam_filter_enabled_checkbox)
|
||||
|
||||
if stylesheet_embed != request.namespace.stylesheet_embed and (
|
||||
stylesheet_embed or request.namespace.stylesheet_embed
|
||||
|
|
@ -252,6 +254,17 @@ def namespace_settings(request):
|
|||
)
|
||||
)
|
||||
|
||||
if spam_filter_enabled != (request.namespace.spam_filter_enabled is not False):
|
||||
request.namespace.spam_filter_enabled = spam_filter_enabled
|
||||
request.session.flash(
|
||||
(
|
||||
"You turned {} spam_filter (Hermes AI)".format(
|
||||
spam_filter_enabled_checkbox
|
||||
),
|
||||
"success",
|
||||
)
|
||||
)
|
||||
|
||||
# T4: customizable button text and comment labels
|
||||
if submit_button_text != request.namespace.submit_button_text and (
|
||||
submit_button_text or request.namespace.submit_button_text
|
||||
|
|
|
|||
|
|
@ -68,43 +68,39 @@ def namespace(request):
|
|||
def namespace_nodes(request):
|
||||
# TODO: the way I'm currently protecting views (or in this case certain urls)
|
||||
# is causing a lot of copy and paste and even confusing conditional logic.
|
||||
if request.namespace.hide_unless_approved:
|
||||
if "disabled" in request.params or "pending" in request.params:
|
||||
# TODO: maybe dry this out and turn it into a decorator?
|
||||
if not request.user or not request.user.authenticated:
|
||||
request.session.flash(("You must log in to access that area.", "error"))
|
||||
return HTTPFound(get_join_or_log_in_route_uri(request))
|
||||
|
||||
# TODO: maybe dry this out and turn it into a decorator?
|
||||
if request.user not in request.namespace.moderators:
|
||||
request.session.flash(
|
||||
("You must be a moderator in to access that area.", "error")
|
||||
)
|
||||
return HTTPFound(get_join_or_log_in_route_uri(request))
|
||||
# Spam, disabled, and pending views require moderator access.
|
||||
mod_views = ("disabled", "pending", "spam")
|
||||
if any(v in request.params for v in mod_views):
|
||||
if not request.user or not request.user.authenticated:
|
||||
request.session.flash(("You must log in to access that area.", "error"))
|
||||
return HTTPFound(get_join_or_log_in_route_uri(request))
|
||||
|
||||
if "disabled" in request.params:
|
||||
# TODO: pagination.
|
||||
state = "disabled"
|
||||
nodes = request.namespace.disabled_nodes
|
||||
elif "pending" in request.params:
|
||||
# TODO: pagination.
|
||||
state = "pending"
|
||||
nodes = request.namespace.unapproved_nodes
|
||||
else:
|
||||
state = "active"
|
||||
nodes = request.namespace.page_nodes(
|
||||
limit=request.page_size, offset=request.page_offset
|
||||
is_mod = (request.user in request.namespace.moderators
|
||||
or getattr(request.user, "is_superuser", False))
|
||||
if not is_mod:
|
||||
request.session.flash(
|
||||
("You must be a moderator to access that area.", "error")
|
||||
)
|
||||
return HTTPFound(get_join_or_log_in_route_uri(request))
|
||||
|
||||
if "spam" in request.params:
|
||||
state = "spam"
|
||||
nodes = request.namespace.spam_nodes
|
||||
elif "disabled" in request.params:
|
||||
state = "disabled"
|
||||
nodes = request.namespace.disabled_nodes
|
||||
elif "pending" in request.params and request.namespace.hide_unless_approved:
|
||||
state = "pending"
|
||||
nodes = request.namespace.unapproved_nodes
|
||||
elif "approved" in request.params and request.namespace.hide_unless_approved:
|
||||
state = "active"
|
||||
nodes = request.namespace.approved_nodes
|
||||
else:
|
||||
if "disabled" in request.params:
|
||||
# TODO: pagination.
|
||||
state = "disabled"
|
||||
nodes = request.namespace.disabled_nodes
|
||||
else:
|
||||
state = "active"
|
||||
nodes = request.namespace.page_nodes(
|
||||
limit=request.page_size, offset=request.page_offset
|
||||
)
|
||||
state = "active"
|
||||
nodes = request.namespace.page_nodes(
|
||||
limit=request.page_size, offset=request.page_offset
|
||||
)
|
||||
return {
|
||||
"nodes": nodes,
|
||||
"the_title": "{} - {} nodes".format(request.namespace.name, state),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ from pyramid.view import view_config
|
|||
|
||||
from pyramid.httpexceptions import HTTPFound
|
||||
|
||||
from remarkbox.models.node import Node
|
||||
|
||||
from . import get_referer_or_home, get_node_route_uri, get_embed_route_uri
|
||||
|
||||
|
||||
|
|
@ -98,3 +100,48 @@ def deny_node(request):
|
|||
request.dbsession.add(request.node)
|
||||
request.dbsession.flush()
|
||||
return HTTPFound(_node_route_uri(request))
|
||||
|
||||
|
||||
@view_config(route_name="basic-namespace-spam-bulk", request_method="POST")
|
||||
def spam_bulk_action(request):
|
||||
"""Bulk approve or disable spam-flagged nodes."""
|
||||
if not request.user or not request.user.authenticated:
|
||||
request.session.flash(("You must log in to access that.", "error"))
|
||||
return HTTPFound(get_referer_or_home(request))
|
||||
|
||||
is_mod = (request.user in request.namespace.moderators
|
||||
or getattr(request.user, "is_superuser", False))
|
||||
if not is_mod:
|
||||
request.session.flash(("You must be a moderator.", "error"))
|
||||
return HTTPFound(get_referer_or_home(request))
|
||||
|
||||
action = request.params.get("bulk_action", "")
|
||||
node_ids = request.params.getall("node_ids")
|
||||
count = 0
|
||||
|
||||
for node_id in node_ids:
|
||||
node = request.dbsession.query(Node).filter(
|
||||
Node.id == node_id,
|
||||
Node.namespace_id == request.namespace.id,
|
||||
).first()
|
||||
if node is None:
|
||||
continue
|
||||
if action == "approve":
|
||||
node.approved = True
|
||||
node.spam_score = 0.0
|
||||
node.spam_reason = None
|
||||
request.dbsession.add(node)
|
||||
count += 1
|
||||
elif action == "disable":
|
||||
node.disable()
|
||||
request.dbsession.add(node)
|
||||
count += 1
|
||||
|
||||
request.dbsession.flush()
|
||||
request.session.flash(
|
||||
("{} node(s) {}d.".format(count, action), "success")
|
||||
)
|
||||
return HTTPFound(
|
||||
request.route_url("basic-namespace-nodes", namespace=request.namespace.name)
|
||||
+ "?spam"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue