Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ For each post, sentinel sends the title + body + top comments to a local Qwen 3.
- **Marks JUNK posts** (requires sentinel/admin role on the platform)
- **Tags languages** on non-English posts using ISO 639-1 codes
- **Flags PII** in posts and individual comments (requires sentinel role) — names/addresses/phones/etc. exposing an identifiable individual
- **Flags advertisements** (`is_ad`, requires sentinel role) when the LLM judges a post to be primarily promotional. The flag is recorded everywhere, but the dedicated **`ads` colony** (`/c/ads`) gets a carve-out: a post is **not downvoted merely for being an ad** when it lives there — ads are welcome in that colony. Scams/gibberish are still caught (the model rates those JUNK regardless of colony).
- **Relocates test posts** out of community colonies into the `test-posts` sandbox when the LLM detects placeholder/test content (requires sentinel role)
- **Tracks state** in a local JSON file to avoid re-analyzing posts

Expand Down
100 changes: 97 additions & 3 deletions sentinel.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@

Test-post detection: set "is_test_post" to true when the post is clearly someone exercising the platform rather than communicating — for example: title or body is "test", "testing", "hello world", "first post", random keysmashed strings, placeholder lorem ipsum, single-character bodies, or otherwise content with no apparent intent to convey information to other users. Be conservative — a short genuine question is NOT a test post. When in doubt, leave it false.

Advertising detection: set "is_ad" to true when the post is primarily an advertisement or promotional content — selling or marketing a product, service, token, project, or paid offering; recruitment/affiliate/referral pushing; or marketing copy whose main purpose is to promote rather than inform or discuss. A post that merely mentions a product while making a genuine point is NOT an ad. Leave it false when unsure.

Colony-aware advertising rule: the post's colony is given as "Colony:" in the user message. The "ads" colony exists specifically FOR advertisements — when a post is in the "ads" colony, advertising is welcome and expected. Do NOT classify such a post as BAD or JUNK, and do NOT recommend "downvote", merely because it is promotional or an advertisement. Judge ads in the "ads" colony only on whether they are scams, deceptive, malicious, or incoherent gibberish — a legitimate advertisement there is at least OKAY with vote "none". In every OTHER colony an advertisement is off-topic and should be scored on its merits as usual.

Output ONLY valid JSON in this exact format (no extra text):
{
"score": 1-10,
Expand All @@ -113,7 +117,8 @@
"language": "en" | "es" | "fr" | "ja" | ... (ISO 639-1 code),
"post_has_pii": true | false,
"pii_comment_indices": [1, 3],
"is_test_post": true | false
"is_test_post": true | false,
"is_ad": true | false
}

"pii_comment_indices" is a list of 1-based indices of top replies that contain PII (matching the "TOP REPLIES" numbering in the user message). Empty list if none.
Expand Down Expand Up @@ -309,6 +314,26 @@ def mark_post_junk(client: ColonyClient, post_id: str, junk: bool) -> bool:
return False


def flag_post_ad(client: ColonyClient, post_id: str, is_ad: bool) -> bool:
"""PUT /posts/{id}/ad?is_ad=true|false. Requires sentinel role.

Records the platform-side ``Post.is_ad`` flag (three-state on the
server: unset / true / false). Sentinel-only endpoint, reached via the
SDK's raw hatch like junk/pii.
"""
flag = "true" if is_ad else "false"
try:
_sdk_raw(client, "PUT", f"/posts/{post_id}/ad?is_ad={flag}")
logger.info("Post %s ad flag set to %s", post_id[:8], is_ad)
return True
except ColonyAPIError as e:
if getattr(e, "status", None) == 403:
logger.error("Insufficient permissions to flag ad (need sentinel role)")
return False
logger.warning("Ad flag failed for post %s: %s", post_id[:8], e)
return False


def flag_post_pii(client: ColonyClient, post_id: str, has_pii: bool) -> bool:
"""PUT /posts/{id}/pii?has_pii=true|false. Requires sentinel role."""
flag = "true" if has_pii else "false"
Expand Down Expand Up @@ -438,6 +463,42 @@ def _is_sandbox_colony(client: ColonyClient, colony_id: str) -> bool:
return _SANDBOX_CACHE.get(colony_id, False)


# The dedicated "ads" colony (/c/ads) is where advertisements are welcome.
# A post the model flags as an ad must NOT be downvoted merely for being an
# ad when it lives here — see ``_pending_actions``. Matched by colony NAME
# (immutable, human-facing) so it keeps working if the colony row is ever
# recreated with a new id.
ADS_COLONY_NAME = "ads"
_COLONY_NAME_CACHE: dict[str, str] = {}


def _colony_name_for(client: ColonyClient, colony_id: str) -> str | None:
"""Return the lowercased name of the colony ``colony_id`` belongs to,
or ``None`` on lookup failure.

One ``/colonies`` request on the first miss; O(1) thereafter — colony
names are effectively immutable, so a per-process cache is safe. Shares
nothing with ``_SANDBOX_CACHE`` deliberately: the two are independent
signals and one populating shouldn't mask the other's misses.
"""
if not colony_id:
return None
if colony_id in _COLONY_NAME_CACHE:
return _COLONY_NAME_CACHE[colony_id]
try:
data = client.get_colonies(limit=200)
except ColonyAPIError as e:
logger.warning("Failed to fetch colony list for name lookup: %s", e)
return None
colonies = data if isinstance(data, list) else data.get("colonies", [])
for c in colonies:
cid = c.get("id")
name = c.get("name")
if cid and name:
_COLONY_NAME_CACHE[str(cid)] = str(name).strip().lower()
return _COLONY_NAME_CACHE.get(colony_id)


# ─── Ollama call ────────────────────────────────────────────────────────
def call_ollama(model: str, messages: list[dict]) -> dict | None:
payload = {
Expand Down Expand Up @@ -477,7 +538,11 @@ def fetch_post_with_comments(client: ColonyClient, post_id: str) -> dict | None:
comments = list(client.iter_comments(post_id, max_results=MAX_COMMENTS))
except ColonyAPIError:
comments = []
return {"post": post, "comments": comments}
# Resolve the colony NAME once here (we have the client) so the LLM
# prompt can show it and the ads-colony downvote carve-out in
# ``_pending_actions`` can run without another API round-trip.
colony_name = _colony_name_for(client, str(post.get("colony_id") or ""))
return {"post": post, "comments": comments, "colony_name": colony_name}


def build_analysis_text(post_data: dict) -> str:
Expand All @@ -486,7 +551,9 @@ def build_analysis_text(post_data: dict) -> str:
body = p.get("body", "") or p.get("content", "")
author = (p.get("author") or {}).get("username", "anonymous")
timestamp = p.get("created_at", "")
text = f"POST by {author} at {timestamp}\nTitle: {title}\n\nBody:\n{body}\n\n"
colony = post_data.get("colony_name")
colony_line = f"Colony: {colony}\n" if colony else ""
text = f"POST by {author} at {timestamp}\n{colony_line}Title: {title}\n\nBody:\n{body}\n\n"
if post_data["comments"]:
text += "TOP REPLIES:\n"
for i, c in enumerate(post_data["comments"], 1):
Expand Down Expand Up @@ -516,6 +583,11 @@ def analyze_post(post_data: dict, model: str) -> dict | None:
# "move to sandbox" action is needed — skipped when the post is
# already in a sandbox colony.
result["_colony_id"] = post_data["post"].get("colony_id")
# Carry the colony NAME too (resolved once in fetch_post_with_comments)
# so _pending_actions can apply the ads-colony downvote carve-out
# without another API round-trip — and so a replayed memory entry keeps
# the decision.
result["_colony_name"] = post_data.get("colony_name")
return result


Expand All @@ -538,12 +610,32 @@ def _pending_actions(judgement: dict) -> list[dict]:
score = 0
if score < UPVOTE_MIN_SCORE:
value = 0

# Ads-colony carve-out: a post the model flagged as an advertisement
# must NOT be downvoted *merely* for being an ad when it lives in the
# dedicated ``ads`` colony — that colony exists for exactly this
# content. Scams/gibberish are still penalised: the model returns JUNK
# (→ junk action below) for genuinely harmful posts regardless of
# colony, and the upvote path is untouched. Keyed on the colony NAME
# carried in the judgement so this stays a pure, replayable decision.
is_ad = judgement.get("is_ad") is True
in_ads_colony = (judgement.get("_colony_name") or "").strip().lower() == ADS_COLONY_NAME
if value < 0 and is_ad and in_ads_colony:
value = 0

if value != 0:
actions.append({"kind": "vote", "value": value})

if (judgement.get("category") or "").upper() == "JUNK":
actions.append({"kind": "junk"})

# Record the advertisement classification on the platform (sentinel-only
# ``is_ad`` flag). Colony-INDEPENDENT: the flag is useful metadata
# everywhere (ad transparency, future ad-only feeds); it's only the
# downVOTE that the ads colony exempts, not the flag itself.
if is_ad:
actions.append({"kind": "ad"})

lang = (judgement.get("language") or "en").strip().lower()
if lang and lang != "en":
actions.append({"kind": "language", "code": lang})
Expand Down Expand Up @@ -602,6 +694,8 @@ def _apply_action(client: ColonyClient, post_id: str, action: dict) -> bool:
return False
if kind == "junk":
return mark_post_junk(client, post_id, True)
if kind == "ad":
return flag_post_ad(client, post_id, True)
if kind == "language":
return set_post_language(client, post_id, str(action.get("code", "")))
if kind == "post_pii":
Expand Down
99 changes: 99 additions & 0 deletions tests/test_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,3 +312,102 @@ def test_one_action_failing_does_not_block_others(
failed = s.act_on_judgement(mock_client, "p1", j)
mock_client.vote_post.assert_called_once()
assert {a["kind"] for a in failed} == {"mark_scanned_post"}


# ────────────────────────────────────────────────────────────────────
# Advertisement flag + ads-colony downvote carve-out
# ────────────────────────────────────────────────────────────────────
class TestAdActions:
def test_is_ad_emits_ad_action(self, make_judgement):
j = make_judgement(is_ad=True)
kinds = [a["kind"] for a in s._pending_actions(j)]
assert "ad" in kinds

def test_not_ad_emits_no_ad_action(self, make_judgement):
j = make_judgement(is_ad=False)
kinds = [a["kind"] for a in s._pending_actions(j)]
assert "ad" not in kinds

def test_ad_flag_is_colony_independent(self, make_judgement):
"""The ``is_ad`` flag itself is recorded everywhere — it's only the
downvote that the ads colony exempts, not the flag."""
j = make_judgement(is_ad=True, _colony_name="general")
kinds = [a["kind"] for a in s._pending_actions(j)]
assert "ad" in kinds

def test_downvote_suppressed_for_ad_in_ads_colony(self, make_judgement):
j = make_judgement(
vote_recommendation="downvote", score=3, category="BAD",
is_ad=True, _colony_name="ads",
)
kinds = [a["kind"] for a in s._pending_actions(j)]
# No downvote — being an ad is not a downvote-worthy offence in /c/ads.
assert "vote" not in kinds
# ...but the post is still flagged as an ad.
assert "ad" in kinds

def test_downvote_kept_for_ad_in_other_colony(self, make_judgement):
j = make_judgement(
vote_recommendation="downvote", score=3, category="BAD",
is_ad=True, _colony_name="general",
)
vote = next(a for a in s._pending_actions(j) if a["kind"] == "vote")
assert vote["value"] == -1

def test_downvote_kept_for_non_ad_in_ads_colony(self, make_judgement):
"""The carve-out only fires for ads. A genuinely bad NON-ad post in
the ads colony is still downvoted."""
j = make_judgement(
vote_recommendation="downvote", score=3, category="BAD",
is_ad=False, _colony_name="ads",
)
vote = next(a for a in s._pending_actions(j) if a["kind"] == "vote")
assert vote["value"] == -1

def test_upvote_unaffected_for_ad_in_ads_colony(self, make_judgement):
"""The carve-out only suppresses downvotes — a great ad can still be
upvoted."""
j = make_judgement(
vote_recommendation="upvote", score=9, category="GOOD",
is_ad=True, _colony_name="ads",
)
vote = next(a for a in s._pending_actions(j) if a["kind"] == "vote")
assert vote["value"] == 1

def test_junk_still_marked_for_ad_in_ads_colony(self, make_judgement):
"""A scam/gibberish post the model still rates JUNK in the ads colony
is marked junk — the carve-out is downvote-only, not a free pass."""
j = make_judgement(
vote_recommendation="downvote", score=1, category="JUNK",
is_ad=True, _colony_name="ads",
)
kinds = [a["kind"] for a in s._pending_actions(j)]
assert "junk" in kinds
assert "vote" not in kinds # downvote still suppressed

def test_apply_ad_action_calls_endpoint(self, mock_client):
ok = s._apply_action(mock_client, "p1", {"kind": "ad"})
assert ok is True
mock_client._raw_request.assert_called_once_with("PUT", "/posts/p1/ad?is_ad=true")

def test_flag_post_ad_false_on_403(self, mock_client):
mock_client._raw_request.side_effect = make_api_error(403)
assert s.flag_post_ad(mock_client, "p1", True) is False

def test_colony_name_resolution_matches_ads(self, mock_client):
mock_client.get_colonies.return_value = [
{"id": "c-ads", "name": "Ads"},
{"id": "c-gen", "name": "general"},
]
s._COLONY_NAME_CACHE.clear()
assert s._colony_name_for(mock_client, "c-ads") == "ads"
assert s._colony_name_for(mock_client, "c-gen") == "general"
s._COLONY_NAME_CACHE.clear()

def test_build_analysis_text_includes_colony(self):
text = s.build_analysis_text({
"post": {"title": "Buy now", "body": "cheap widgets", "author": {"username": "x"}},
"comments": [],
"colony_name": "ads",
})
assert "Colony: ads" in text
Loading