diff --git a/remarkbox/api/views.py b/remarkbox/api/views.py index 184e859..6ede2e1 100644 --- a/remarkbox/api/views.py +++ b/remarkbox/api/views.py @@ -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() diff --git a/remarkbox/models/namespace.py b/remarkbox/models/namespace.py index 5a2dd23..2714600 100644 --- a/remarkbox/models/namespace.py +++ b/remarkbox/models/namespace.py @@ -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) diff --git a/remarkbox/models/node.py b/remarkbox/models/node.py index 8b33053..992ec76 100644 --- a/remarkbox/models/node.py +++ b/remarkbox/models/node.py @@ -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) diff --git a/remarkbox/routes.py b/remarkbox/routes.py index 69c3db7..08ad2f3 100644 --- a/remarkbox/routes.py +++ b/remarkbox/routes.py @@ -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") diff --git a/remarkbox/scripts/alembic/versions/e99b1524710c_add_spam_score_spam_reason_to_node_and_.py b/remarkbox/scripts/alembic/versions/e99b1524710c_add_spam_score_spam_reason_to_node_and_.py new file mode 100644 index 0000000..685c93e --- /dev/null +++ b/remarkbox/scripts/alembic/versions/e99b1524710c_add_spam_score_spam_reason_to_node_and_.py @@ -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') diff --git a/remarkbox/templates/list-nodes.j2 b/remarkbox/templates/list-nodes.j2 index 483a547..4c495a9 100644 --- a/remarkbox/templates/list-nodes.j2 +++ b/remarkbox/templates/list-nodes.j2 @@ -28,6 +28,57 @@ page: {{ request.page_number }}
/api/v1/ requests targeting this namespace.
+