diff --git a/README.md b/README.md index 07b0d8a..c97ad5d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/sentinel.py b/sentinel.py index 2a802de..58d6292 100644 --- a/sentinel.py +++ b/sentinel.py @@ -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, @@ -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. @@ -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" @@ -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 = { @@ -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: @@ -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): @@ -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 @@ -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}) @@ -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": diff --git a/tests/test_actions.py b/tests/test_actions.py index 50db2a2..47d6c4a 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -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