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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@
- **Manual allowlist ("Indulgence") override**
- **Known spammers lookup** from local imports and online checks against LoLs bot and CAS/Combot
- **External quote heuristic** for obvious cross-chat spam patterns
- **LLM-powered binary classification** with built-in and chat-specific spam examples
- **LLM-powered binary classification** with a general or Jobs & HR profile plus chat-specific allowed and spam examples
2. If the message is considered spam, the user is either immediately banned or sent into community voting, depending on chat settings.
3. Chat users can report missed spam with `/voteban` or by mentioning the bot in reply to the message. Reports are rechecked by the LLM first, then either moderated immediately or sent to community voting without pre-deleting the original message.
4. Clean messages before the deadline remain bound for future edit checks. A distinct clean message after the deadline durably completes probation; commands and media without text start the clock but cannot complete it.

## Admin panel
1. Run `/settings` in a group where the bot is an admin.
2. The bot sends a deep-link that opens a private admin panel for that chat.
3. From there you can configure gatekeeper, new-user message probation, community voting, spam examples, language, and manual not-spammer overrides.
3. From there you can configure gatekeeper, new-user message probation, community voting, the LLM moderation profile, allowed/spam examples, language, and manual not-spammer overrides.
4. The home screen includes a one-tap `Recommended Protection` preset and a compact 7-day protection summary.

## Installation
Expand Down Expand Up @@ -202,7 +202,7 @@ Don't hesitate to contact me
## Notes

- Gemini requests can reuse server-side explicit caching for the static moderation prefix when the provider supports it.
- Chat-specific settings, spam examples, and the private settings UI are already implemented.
- Chat-specific settings, moderation profiles, labeled examples, and the private settings UI are already implemented.

## Acknowledgements

Expand Down
115 changes: 115 additions & 0 deletions docs/superpowers/plans/2026-08-18-context-aware-vacancy-moderation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Context-aware vacancy moderation implementation plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Make detailed vacancies safe by default and explicitly on-topic in Jobs & HR chats without weakening scam and gambling-ad detection.

**Architecture:** Persist a small per-chat moderation-profile enum and a binary label on chat examples. Pass both through a typed classification context to the existing LLM detector, whose static policy defines the corrected semantic boundary. Reuse the existing cascading admin panel for profile selection and labeled example management.

**Tech Stack:** Go 1.25.13, SQLite migrations, sqlx, Telegram Bot API, Gemini/OpenAI-compatible LLM adapters, YAML i18n.

**Spec:** `docs/superpowers/specs/2026-08-18-context-aware-vacancy-moderation-design.md`

## Global Constraints

- Preserve existing chats as `general` and existing examples as spam.
- A contact CTA alone never establishes spam.
- iGaming employment is not gambling promotion.
- Candidate/profile/admin data stays live, structured, and untrusted.
- Every new admin UI key must exist in every supported locale.
- Do not modify `docs/CODEBASE_MAP.md`.
- Deploy only the merged, verified revision through `scripts/release.sh`.

---

### Task 1: Persist moderation profile and labeled examples

**Files:**
- Modify: `internal/db/entities.go`
- Modify: `internal/db/settings.go`
- Modify: `internal/db/settings_test.go`
- Modify: `internal/db/sqlite/client_settings_members.go`
- Modify: `internal/db/sqlite/admin_panel.go`
- Modify: `internal/db/sqlite/migrations_test.go`
- Create: `resources/migrations/20260818000000-add-context-aware-moderation.sql`

**Interfaces:**
- Produces: `db.LLMModerationProfileGeneral`, `db.LLMModerationProfileJobsHR`, `db.SpamClassificationAllowed`, `db.SpamClassificationSpam`.
- Produces: `Settings.LLMModerationProfile` and `ChatSpamExample.Classification`.
- Produces filtered list/count methods accepting `classification int`.

- [x] Write failing tests for defaults, profile normalization, example labels, filtered queries, and migration columns.
- [x] Run focused DB tests and confirm failures are caused by missing fields/schema.
- [x] Add the migration and minimal persistence implementation.
- [x] Run focused DB tests and confirm they pass.

### Task 2: Correct the classifier boundary and carry chat context

**Files:**
- Modify: `internal/handlers/moderation/spam_detector.go`
- Modify: `internal/handlers/moderation/spam_detector_test.go`
- Modify: `internal/handlers/chat/reactor.go`
- Modify: `internal/handlers/chat/reactor_message_pipeline.go`
- Modify: `internal/handlers/chat/reactor_message_pipeline_test.go`
- Modify: `internal/handlers/chat/reactor_reaction_profile_check.go`

**Interfaces:**
- Produces: `moderation.ClassificationContext{Profile string, Examples []ClassificationExample}`.
- Consumes: persisted profile and labeled chat examples from Task 1.

- [x] Write failing detector tests for structured profile/example framing and paired vacancy/scam boundaries.
- [x] Write failing reactor tests for loading both labels and propagating the Jobs & HR profile.
- [x] Run focused moderation/chat tests and confirm expected failures.
- [x] Implement the typed context and corrected prompts with representative safe/spam boundary examples.
- [x] Run focused moderation/chat tests and confirm they pass.

### Task 3: Expose profile and safe examples in the admin panel

**Files:**
- Modify: `internal/handlers/admin/admin.go`
- Modify: `internal/handlers/admin/panel_types.go`
- Modify: `internal/handlers/admin/panel_session_service.go`
- Modify: `internal/handlers/admin/panel_renderer.go`
- Modify: `internal/handlers/admin/panel_commands.go`
- Modify: `internal/handlers/admin/panel_render.go`
- Modify: `internal/handlers/admin/panel_handler.go`
- Modify: `internal/handlers/admin/panel_recommended.go`
- Modify or create focused tests in `internal/handlers/admin/`
- Modify: `resources/i18n/translations.yml`

**Interfaces:**
- Consumes: profile and classification constants plus filtered persistence from Task 1.
- Produces: LLM profile leaf screen and separate safe/spam example lists using the existing workflow.

- [x] Write failing tests for state synchronization, profile selection, and classification-preserving example creation.
- [x] Run focused admin and i18n tests and confirm expected failures.
- [x] Implement the leaf screen, commands, labeled list workflow, and complete locale keys.
- [x] Run focused admin and i18n tests and confirm they pass.

### Task 4: Document and validate the complete change

**Files:**
- Modify: `README.md`

**Interfaces:**
- Documents the operator-visible profile and safe-example behavior.

- [x] Update README behavior and admin-panel instructions.
- [x] Run `gofmt` on changed Go files and `git diff --check`.
- [x] Run focused tests, `go vet ./...`, `go test ./...`, `go test -race ./...`, `go test -shuffle=on ./...`, configured golangci-lint, `go mod tidy -diff`, and `docker build .`.
- [x] Inspect the complete diff against the design and verify no unrelated changes.

### Task 5: Publish, release, and verify production

**Files:**
- Use: `.github/workflows/ci.yml`
- Use: `scripts/release.sh`
- Use: `scripts/validate-deployment.sh`

**Interfaces:**
- Produces a merged GitHub revision and the matching production image.

- [ ] Commit the verified scope and push `agent/context-aware-vacancy-moderation`.
- [ ] Open a PR, wait for terminal CI, and merge only the verified head.
- [ ] Run the production release from the current `master` revision.
- [ ] Verify live revision/image, container health and restart count, migration presence, SQLite integrity, probes, bounded live classification cases, and fresh logs.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Context-aware vacancy moderation design

## Goal

Prevent genuine, detailed job vacancies from being classified as spam merely because they invite applicants to contact a recruiter, while continuing to catch vague job scams and actual gambling promotion.

## Decision boundary

A recruiter contact, phone number, Telegram username, application instruction, industry name, or `#vacancy` tag is not independent spam evidence. A detailed vacancy is benign when it identifies a real role or professional function and provides substantive duties, requirements, conditions, or hiring context. Employment at an iGaming company is distinct from advertising a casino.

Job-related spam still includes vague or anonymous income offers, hidden duties, unrealistic earnings, mass recruitment, passive-income or investment schemes, referral promotion, evasion through mixed alphabets, and requests to write `+` merely to reveal essential details.

When evidence is insufficient, the classifier returns non-spam.

## Per-chat context

Each chat has an LLM moderation profile:

- `general`: the existing default, with the corrected global vacancy boundary.
- `jobs_hr`: vacancies, recruiting, candidate discussions, and recruiter contacts are explicitly on-topic; scam signals remain enforceable.

The profile is selected by a chat administrator on a dedicated leaf screen in the existing LLM settings menu. Existing chats migrate to `general` without behavioral changes unrelated to the corrected boundary.

## Chat-specific examples

Existing chat examples remain labeled spam. A new classification column allows administrators to add safe examples as well. The same list/detail/add/delete workflow is reused, filtered by classification. The classifier receives both labels as structured, live, untrusted JSON.

## Data flow

The reactor loads the chat profile from `db.Settings`, loads up to 20 spam and 20 safe examples, normalizes the candidate text as before, and sends a structured classification context to the detector. Reaction-profile checks use the `general` profile and no chat examples.

The system prompt remains cacheable. Candidate text, profile selection, and administrator examples remain live data and cannot add privileged instructions.

## Verification

Regression tests cover the two supplied vacancy families, the CTA boundary, iGaming employment versus casino advertising, profile propagation, labeled example persistence and filtering, admin state synchronization, migration up/down behavior, and translation completeness. The repository's Go tests, race tests, shuffle tests, vet, lint, module diff, Docker build, release checks, production revision, migrations, container health, fresh logs, and a bounded live Gemini evaluation form the delivery gate.
6 changes: 6 additions & 0 deletions internal/db/entities.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type (
GatekeeperCaptchaOptionsCount int `db:"gatekeeper_captcha_options_count"`
GatekeeperGreetingText string `db:"gatekeeper_greeting_text"`
LLMFirstMessageEnabled bool `db:"llm_first_message_enabled"`
LLMModerationProfile string `db:"llm_moderation_profile"`
ReactionProfileCheckEnabled bool `db:"reaction_profile_check_enabled"`
CommunityVotingEnabled bool `db:"community_voting_enabled"`
CommunityVotingTimeoutOverrideNS int64 `db:"community_voting_timeout_override_ns"`
Expand Down Expand Up @@ -172,6 +173,7 @@ type (
ID int64 `db:"id"`
ChatID int64 `db:"chat_id"`
Text string `db:"text"`
Classification int `db:"classification"`
CreatedByUserID int64 `db:"created_by_user_id"`
CreatedAt time.Time `db:"created_at"`
}
Expand Down Expand Up @@ -277,6 +279,10 @@ const (
SpamCaseStatusSpam = "spam"
SpamCaseStatusFalsePositive = "false_positive"
SpamCaseStatusNotEnforced = "not_enforced"
LLMModerationProfileGeneral = "general"
LLMModerationProfileJobsHR = "jobs_hr"
SpamClassificationAllowed = 0
SpamClassificationSpam = 1
TelegramUpdateStatusPending = "pending"
TelegramUpdateStatusProcessing = "processing"
TelegramUpdateStatusRetry = "retry"
Expand Down
1 change: 1 addition & 0 deletions internal/db/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ func DefaultSettings(chatID int64) *Settings {
GatekeeperCaptchaOptionsCount: 5,
GatekeeperGreetingText: "",
LLMFirstMessageEnabled: true,
LLMModerationProfile: LLMModerationProfileGeneral,
ReactionProfileCheckEnabled: true,
CommunityVotingEnabled: true,
CommunityVotingTimeoutOverrideNS: int64(SettingsOverrideInherit),
Expand Down
9 changes: 9 additions & 0 deletions internal/db/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,12 @@ func TestDefaultSettingsEnableGatekeeperCaptcha(t *testing.T) {
t.Fatalf("expected gatekeeper captcha to be enabled by default: %#v", settings)
}
}

func TestDefaultSettingsUseGeneralLLMModerationProfile(t *testing.T) {
t.Parallel()

settings := DefaultSettings(42)
if settings.LLMModerationProfile != LLMModerationProfileGeneral {
t.Fatalf("default LLM moderation profile = %q, want %q", settings.LLMModerationProfile, LLMModerationProfileGeneral)
}
}
22 changes: 11 additions & 11 deletions internal/db/sqlite/admin_panel.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,10 +313,10 @@ func (c *sqliteClient) CreateChatSpamExample(ctx context.Context, example *db.Ch
}

query := `
INSERT INTO chat_spam_examples (chat_id, text, created_by_user_id, created_at)
VALUES (?, ?, ?, ?)
INSERT INTO chat_spam_examples (chat_id, text, classification, created_by_user_id, created_at)
VALUES (?, ?, ?, ?, ?)
`
result, err := c.db.ExecContext(ctx, query, example.ChatID, example.Text, example.CreatedByUserID, example.CreatedAt)
result, err := c.db.ExecContext(ctx, query, example.ChatID, example.Text, example.Classification, example.CreatedByUserID, example.CreatedAt)
if err != nil {
return nil, fmt.Errorf("failed to create chat spam example: %w", err)
}
Expand All @@ -339,7 +339,7 @@ func (c *sqliteClient) GetChatSpamExample(ctx context.Context, id int64) (*db.Ch
c.mutex.RLock()
defer c.mutex.RUnlock()

query := `SELECT id, chat_id, text, created_by_user_id, created_at FROM chat_spam_examples WHERE id = ?`
query := `SELECT id, chat_id, text, classification, created_by_user_id, created_at FROM chat_spam_examples WHERE id = ?`
example := &db.ChatSpamExample{}
if err := c.db.QueryRowxContext(ctx, query, id).StructScan(example); err != nil {
if errors.Is(err, sql.ErrNoRows) {
Expand All @@ -350,18 +350,18 @@ func (c *sqliteClient) GetChatSpamExample(ctx context.Context, id int64) (*db.Ch
return example, nil
}

func (c *sqliteClient) ListChatSpamExamples(ctx context.Context, chatID int64, limit int, offset int) ([]*db.ChatSpamExample, error) {
func (c *sqliteClient) ListChatSpamExamples(ctx context.Context, chatID int64, classification int, limit int, offset int) ([]*db.ChatSpamExample, error) {
c.mutex.RLock()
defer c.mutex.RUnlock()

query := `
SELECT id, chat_id, text, created_by_user_id, created_at
SELECT id, chat_id, text, classification, created_by_user_id, created_at
FROM chat_spam_examples
WHERE chat_id = ?
WHERE chat_id = ? AND classification = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`
rows, err := c.db.QueryxContext(ctx, query, chatID, limit, offset)
rows, err := c.db.QueryxContext(ctx, query, chatID, classification, limit, offset)
if err != nil {
return nil, fmt.Errorf("failed to list chat spam examples: %w", err)
}
Expand All @@ -381,13 +381,13 @@ func (c *sqliteClient) ListChatSpamExamples(ctx context.Context, chatID int64, l
return examples, nil
}

func (c *sqliteClient) CountChatSpamExamples(ctx context.Context, chatID int64) (int, error) {
func (c *sqliteClient) CountChatSpamExamples(ctx context.Context, chatID int64, classification int) (int, error) {
c.mutex.RLock()
defer c.mutex.RUnlock()

query := `SELECT COUNT(*) FROM chat_spam_examples WHERE chat_id = ?`
query := `SELECT COUNT(*) FROM chat_spam_examples WHERE chat_id = ? AND classification = ?`
var count int
if err := c.db.QueryRowxContext(ctx, query, chatID).Scan(&count); err != nil {
if err := c.db.QueryRowxContext(ctx, query, chatID, classification).Scan(&count); err != nil {
return 0, fmt.Errorf("failed to count chat spam examples: %w", err)
}
return count, nil
Expand Down
90 changes: 90 additions & 0 deletions internal/db/sqlite/client_moderation_examples_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package sqlite

import (
"testing"
"time"

"github.com/iamwavecut/ngbot/internal/db"
)

func TestCommitSettingsPersistsAndNormalizesLLMModerationProfile(t *testing.T) {
t.Parallel()

client, err := NewSQLiteClient(t.Context(), t.TempDir(), "test.db")
if err != nil {
t.Fatalf("new sqlite client: %v", err)
}
t.Cleanup(func() { _ = client.Close() })

settings := db.DefaultSettings(-100)
settings.LLMModerationProfile = db.LLMModerationProfileJobsHR
if err := client.SetSettings(t.Context(), settings); err != nil {
t.Fatalf("set Jobs & HR profile: %v", err)
}
stored, err := client.GetSettings(t.Context(), settings.ID)
if err != nil {
t.Fatalf("get Jobs & HR profile: %v", err)
}
if stored.LLMModerationProfile != db.LLMModerationProfileJobsHR {
t.Fatalf("stored profile = %q, want %q", stored.LLMModerationProfile, db.LLMModerationProfileJobsHR)
}

settings.LLMModerationProfile = "unknown"
if err := client.SetSettings(t.Context(), settings); err != nil {
t.Fatalf("normalize unknown profile: %v", err)
}
stored, err = client.GetSettings(t.Context(), settings.ID)
if err != nil {
t.Fatalf("get normalized profile: %v", err)
}
if stored.LLMModerationProfile != db.LLMModerationProfileGeneral {
t.Fatalf("normalized profile = %q, want %q", stored.LLMModerationProfile, db.LLMModerationProfileGeneral)
}
}

func TestChatModerationExamplesAreFilteredByClassification(t *testing.T) {
t.Parallel()

client, err := NewSQLiteClient(t.Context(), t.TempDir(), "test.db")
if err != nil {
t.Fatalf("new sqlite client: %v", err)
}
t.Cleanup(func() { _ = client.Close() })

const chatID = int64(-100)
for _, example := range []*db.ChatSpamExample{
{ChatID: chatID, Text: "Detailed project manager vacancy", Classification: db.SpamClassificationAllowed, CreatedByUserID: 1, CreatedAt: time.Unix(1, 0)},
{ChatID: chatID, Text: "Vague remote income offer", Classification: db.SpamClassificationSpam, CreatedByUserID: 1, CreatedAt: time.Unix(2, 0)},
} {
if _, err := client.CreateChatSpamExample(t.Context(), example); err != nil {
t.Fatalf("create classification %d example: %v", example.Classification, err)
}
}

allowed, err := client.ListChatSpamExamples(t.Context(), chatID, db.SpamClassificationAllowed, 20, 0)
if err != nil {
t.Fatalf("list allowed examples: %v", err)
}
if len(allowed) != 1 || allowed[0].Text != "Detailed project manager vacancy" || allowed[0].Classification != db.SpamClassificationAllowed {
t.Fatalf("allowed examples = %#v", allowed)
}
spam, err := client.ListChatSpamExamples(t.Context(), chatID, db.SpamClassificationSpam, 20, 0)
if err != nil {
t.Fatalf("list spam examples: %v", err)
}
if len(spam) != 1 || spam[0].Text != "Vague remote income offer" || spam[0].Classification != db.SpamClassificationSpam {
t.Fatalf("spam examples = %#v", spam)
}

allowedCount, err := client.CountChatSpamExamples(t.Context(), chatID, db.SpamClassificationAllowed)
if err != nil {
t.Fatalf("count allowed examples: %v", err)
}
spamCount, err := client.CountChatSpamExamples(t.Context(), chatID, db.SpamClassificationSpam)
if err != nil {
t.Fatalf("count spam examples: %v", err)
}
if allowedCount != 1 || spamCount != 1 {
t.Fatalf("classification counts = allowed:%d spam:%d, want 1 and 1", allowedCount, spamCount)
}
}
Loading