Skip to content

Commit c436007

Browse files
author
ForeverInLaw
committed
feat: configure llm history context
1 parent 2e0d6be commit c436007

18 files changed

Lines changed: 255 additions & 113 deletions

app/assembly.go

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ func activateServer(
163163
MetaGiveaway: opts.Meta.Giveaway,
164164
MultiLangLimit: opts.MultiLangWords,
165165
LLMConsensus: opts.LLM.Consensus,
166+
LLMHistoryContextSize: opts.LLM.HistoryContextSize,
166167
OpenAIEnabled: opts.OpenAI.Token != "" || opts.OpenAI.APIBase != "",
167168
OpenAIVeto: opts.OpenAI.Veto,
168169
OpenAIHistorySize: opts.OpenAI.HistorySize,
@@ -332,24 +333,25 @@ func buildDetectorConfig(opts options, ruleSet rules.RuleSet) tgspam.Config {
332333
casAPI = opts.CAS.API
333334
}
334335
cfg := tgspam.Config{
335-
MaxAllowedEmoji: ruleSet.Detection.MaxEmoji,
336-
MinMsgLen: ruleSet.Detection.MinMsgLen,
337-
SimilarityThreshold: ruleSet.Detection.SimilarityThreshold,
338-
MinSpamProbability: ruleSet.Detection.MinSpamProbability,
339-
CasAPI: casAPI,
340-
CasUserAgent: opts.CAS.UserAgent,
341-
HTTPClient: &http.Client{Timeout: opts.CAS.Timeout},
342-
FirstMessageOnly: !ruleSet.Detection.ParanoidMode,
343-
FirstMessagesCount: ruleSet.Detection.FirstMessagesCount,
344-
OpenAIVeto: ruleSet.OpenAI.Veto,
345-
OpenAIHistorySize: ruleSet.OpenAI.HistorySize,
346-
GeminiVeto: ruleSet.Gemini.Veto,
347-
GeminiHistorySize: ruleSet.Gemini.HistorySize,
348-
LLMMode: tgspam.LLMMode(ruleSet.LLM.Mode),
349-
LLMConsensus: tgspam.LLMConsensusMode(ruleSet.LLM.Consensus),
350-
LLMRequestTimeout: opts.LLM.RequestTimeout,
351-
MultiLangWords: ruleSet.Detection.MultiLangWords,
352-
HistorySize: ruleSet.Detection.HistorySize,
336+
MaxAllowedEmoji: ruleSet.Detection.MaxEmoji,
337+
MinMsgLen: ruleSet.Detection.MinMsgLen,
338+
SimilarityThreshold: ruleSet.Detection.SimilarityThreshold,
339+
MinSpamProbability: ruleSet.Detection.MinSpamProbability,
340+
CasAPI: casAPI,
341+
CasUserAgent: opts.CAS.UserAgent,
342+
HTTPClient: &http.Client{Timeout: opts.CAS.Timeout},
343+
FirstMessageOnly: !ruleSet.Detection.ParanoidMode,
344+
FirstMessagesCount: ruleSet.Detection.FirstMessagesCount,
345+
OpenAIVeto: ruleSet.OpenAI.Veto,
346+
OpenAIHistorySize: ruleSet.OpenAI.HistorySize,
347+
GeminiVeto: ruleSet.Gemini.Veto,
348+
GeminiHistorySize: ruleSet.Gemini.HistorySize,
349+
LLMMode: tgspam.LLMMode(ruleSet.LLM.Mode),
350+
LLMConsensus: tgspam.LLMConsensusMode(ruleSet.LLM.Consensus),
351+
LLMHistoryContextSize: ruleSet.LLM.HistoryContextSize,
352+
LLMRequestTimeout: opts.LLM.RequestTimeout,
353+
MultiLangWords: ruleSet.Detection.MultiLangWords,
354+
HistorySize: ruleSet.Detection.HistorySize,
353355
}
354356

355357
if ruleSet.Detection.FirstMessagesCount > 0 {

app/main.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,10 @@ type options struct {
109109
} `group:"gemini" namespace:"gemini" env-namespace:"GEMINI"`
110110

111111
LLM struct {
112-
Consensus string `long:"consensus" env:"CONSENSUS" choice:"any" choice:"all" default:"any" description:"how eligible LLMs flip the base decision"`
113-
Mode string `long:"mode" env:"MODE" choice:"" choice:"missed" choice:"flagged" choice:"always" default:"" description:"which messages LLM checks: missed, flagged, or always; empty preserves provider veto flags"`
114-
RequestTimeout time.Duration `long:"request-timeout" env:"REQUEST_TIMEOUT" default:"30s" description:"timeout for individual LLM requests"`
112+
Consensus string `long:"consensus" env:"CONSENSUS" choice:"any" choice:"all" default:"any" description:"how eligible LLMs flip the base decision"`
113+
Mode string `long:"mode" env:"MODE" choice:"" choice:"missed" choice:"flagged" choice:"always" default:"" description:"which messages LLM checks: missed, flagged, or always; empty preserves provider veto flags"`
114+
HistoryContextSize int `long:"history-context-size" env:"HISTORY_CONTEXT_SIZE" default:"0" description:"recent chat messages to include in LLM context, 0 disables"`
115+
RequestTimeout time.Duration `long:"request-timeout" env:"REQUEST_TIMEOUT" default:"30s" description:"timeout for individual LLM requests"`
115116
} `group:"llm" namespace:"llm" env-namespace:"LLM"`
116117

117118
LuaPlugins struct {

app/main_runtime_assembly_test.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ func Test_makeDetector(t *testing.T) {
253253
HistorySize: 5,
254254
CheckShortMessages: true,
255255
},
256+
LLM: rules.LLMCommonRules{HistoryContextSize: 3},
256257
}
257258

258259
res := makeDetectorWithRuleSet(opts, ruleSet)
@@ -268,6 +269,7 @@ func Test_makeDetector(t *testing.T) {
268269
assert.Equal(t, 4, res.OpenAIHistorySize)
269270
assert.True(t, res.GeminiVeto)
270271
assert.Equal(t, 5, res.GeminiHistorySize)
272+
assert.Equal(t, 3, res.LLMHistoryContextSize)
271273
assert.Empty(t, res.LLMMode)
272274
})
273275
}
@@ -437,7 +439,7 @@ func TestAssembleRuntimeUsesActiveRuleSet(t *testing.T) {
437439
require.NoError(t, err)
438440
_, err = db.ExecContext(ctx,
439441
"INSERT INTO rule_set_versions (workspace_id, gid, tenant_id, version, source, payload, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
440-
"gr1", opts.InstanceID, opts.InstanceID, 2, "test", `{"workspace_id":"gr1","version":2,"source":"test","meta":{"links_limit":5,"mentions_limit":4},"duplicates":{"threshold":4,"window":60000000000},"abnormal_spacing":{"enabled":true,"space_ratio_threshold":0.25,"short_word_ratio_threshold":0.6,"short_word_len":5,"min_words":8},"moderation":{"first_strike":900000000000,"second_strike":21600000000000,"soft_ban":true,"dry_run":true},"reports":{"enabled":true,"threshold":5,"auto_ban_threshold":6,"rate_limit":2,"rate_period":120000000000},"openai":{"enabled":false,"veto":true,"model":"gpt-active","history_size":3,"check_short_messages":true},"gemini":{"enabled":false,"veto":false,"model":"gemini-active","history_size":7,"check_short_messages":false}}`,
442+
"gr1", opts.InstanceID, opts.InstanceID, 2, "test", `{"workspace_id":"gr1","version":2,"source":"test","meta":{"links_limit":5,"mentions_limit":4},"duplicates":{"threshold":4,"window":60000000000},"abnormal_spacing":{"enabled":true,"space_ratio_threshold":0.25,"short_word_ratio_threshold":0.6,"short_word_len":5,"min_words":8},"moderation":{"first_strike":900000000000,"second_strike":21600000000000,"soft_ban":true,"dry_run":true},"reports":{"enabled":true,"threshold":5,"auto_ban_threshold":6,"rate_limit":2,"rate_period":120000000000},"llm":{"history_context_size":6},"openai":{"enabled":false,"veto":true,"model":"gpt-active","history_size":3,"check_short_messages":true},"gemini":{"enabled":false,"veto":false,"model":"gemini-active","history_size":7,"check_short_messages":false}}`,
441443
time.Now().UTC(),
442444
)
443445
require.NoError(t, err)
@@ -457,6 +459,7 @@ func TestAssembleRuntimeUsesActiveRuleSet(t *testing.T) {
457459
assert.Equal(t, 4, assembly.Detector.DuplicateDetection.Threshold)
458460
assert.True(t, assembly.Detector.AbnormalSpacing.Enabled)
459461
assert.Equal(t, 3, assembly.Detector.OpenAIHistorySize)
462+
assert.Equal(t, 6, assembly.Detector.LLMHistoryContextSize)
460463

461464
tbAPI := &tbapi.BotAPI{Self: tbapi.User{UserName: "bot"}}
462465
listener := assembly.makeTelegramListener(opts, tbAPI)
@@ -607,7 +610,7 @@ func TestBuildDetectorConfig_ReadsDetectionFromRuleSet(t *testing.T) {
607610
FirstMessagesCount: 1,
608611
ParanoidMode: false,
609612
},
610-
LLM: rules.LLMCommonRules{Mode: "flagged", Consensus: "any"},
613+
LLM: rules.LLMCommonRules{Mode: "flagged", Consensus: "any", HistoryContextSize: 4},
611614
}
612615

613616
cfg := buildDetectorConfig(opts, rs)
@@ -620,6 +623,7 @@ func TestBuildDetectorConfig_ReadsDetectionFromRuleSet(t *testing.T) {
620623
assert.Equal(t, 1, cfg.FirstMessagesCount)
621624
assert.Equal(t, tgspam.LLMMode("flagged"), cfg.LLMMode)
622625
assert.Equal(t, tgspam.LLMConsensusMode("any"), cfg.LLMConsensus)
626+
assert.Equal(t, 4, cfg.LLMHistoryContextSize)
623627
assert.Equal(t, "https://api.cas.chat", cfg.CasAPI)
624628
}
625629

app/rules/ruleset.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import "time"
44

55
// CurrentSchemaVersion is the RuleSet payload schema version. Bump it whenever new
66
// fields are added so older persisted rulesets can be detected and backfilled.
7-
const CurrentSchemaVersion = 2
7+
const CurrentSchemaVersion = 3
88

99
// RuleSet is the single-tenant moderation configuration snapshot.
1010
type RuleSet struct {
@@ -97,7 +97,8 @@ type DetectionRules struct {
9797

9898
// LLMCommonRules holds LLM settings shared across providers.
9999
type LLMCommonRules struct {
100-
Mode string `json:"mode"` // "" | missed | flagged | always
101-
Consensus string `json:"consensus"` // any | all
102-
VisionPrompt string `json:"vision_prompt"`
100+
Mode string `json:"mode"` // "" | missed | flagged | always
101+
Consensus string `json:"consensus"` // any | all
102+
HistoryContextSize int `json:"history_context_size"`
103+
VisionPrompt string `json:"vision_prompt"`
103104
}

app/rules/ruleset_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,16 +53,17 @@ func TestRuleSet_LegacyPayloadDecodesNewFieldsAsZero(t *testing.T) {
5353
}
5454

5555
func TestCurrentSchemaVersion(t *testing.T) {
56-
assert.Equal(t, 2, CurrentSchemaVersion)
56+
assert.Equal(t, 3, CurrentSchemaVersion)
5757
}
5858

59-
func TestRuleSet_VisionPromptJSONRoundTrip(t *testing.T) {
60-
rs := RuleSet{LLM: LLMCommonRules{Mode: "flagged", VisionPrompt: "scan this image"}}
59+
func TestRuleSet_LLMCommonJSONRoundTrip(t *testing.T) {
60+
rs := RuleSet{LLM: LLMCommonRules{Mode: "flagged", HistoryContextSize: 5, VisionPrompt: "scan this image"}}
6161

6262
data, err := json.Marshal(rs)
6363
require.NoError(t, err)
6464

6565
var got RuleSet
6666
require.NoError(t, json.Unmarshal(data, &got))
67+
assert.Equal(t, 5, got.LLM.HistoryContextSize)
6768
assert.Equal(t, "scan this image", got.LLM.VisionPrompt)
6869
}

app/runtime_assembly.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -379,8 +379,9 @@ func bootstrapRuleSet(opts options) rules.RuleSet {
379379
ParanoidMode: opts.ParanoidMode,
380380
},
381381
LLM: rules.LLMCommonRules{
382-
Mode: opts.LLM.Mode,
383-
Consensus: opts.LLM.Consensus,
382+
Mode: opts.LLM.Mode,
383+
Consensus: opts.LLM.Consensus,
384+
HistoryContextSize: opts.LLM.HistoryContextSize,
384385
},
385386
OpenAI: rules.LLMRules{
386387
Enabled: opts.OpenAI.Token != "" || opts.OpenAI.APIBase != "",
@@ -416,14 +417,30 @@ func backfillRuleSetSchema(rs rules.RuleSet, opts options) (rules.RuleSet, bool)
416417
seed := bootstrapRuleSet(opts)
417418
rs.SchemaVersion = rules.CurrentSchemaVersion
418419
rs.Detection = seed.Detection
419-
rs.LLM = seed.LLM
420+
rs.LLM = backfillLLMCommonRules(rs.LLM, seed.LLM)
420421
rs.OpenAI.Prompt = seed.OpenAI.Prompt
421422
rs.OpenAI.VisionModel = seed.OpenAI.VisionModel
422423
rs.Gemini.Prompt = seed.Gemini.Prompt
423424
rs.Gemini.VisionModel = seed.Gemini.VisionModel
424425
return rs, true
425426
}
426427

428+
func backfillLLMCommonRules(current, seed rules.LLMCommonRules) rules.LLMCommonRules {
429+
if current.Mode == "" {
430+
current.Mode = seed.Mode
431+
}
432+
if current.Consensus == "" {
433+
current.Consensus = seed.Consensus
434+
}
435+
if current.HistoryContextSize == 0 {
436+
current.HistoryContextSize = seed.HistoryContextSize
437+
}
438+
if current.VisionPrompt == "" {
439+
current.VisionPrompt = seed.VisionPrompt
440+
}
441+
return current
442+
}
443+
427444
func makeApprovedUsersStore(
428445
ctx context.Context, dataDB *engine.SQL, detector *tgspam.Detector,
429446
) (int, *storage.ApprovedUsers, error) {

app/settings_precedence.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,9 @@ func applyExplicitDetectionOverrides(rs *rules.RuleSet, opts options) {
199199
if configured("llm.consensus", "LLM_CONSENSUS") {
200200
rs.LLM.Consensus = opts.LLM.Consensus
201201
}
202+
if configured("llm.history-context-size", "LLM_HISTORY_CONTEXT_SIZE") {
203+
rs.LLM.HistoryContextSize = opts.LLM.HistoryContextSize
204+
}
202205
}
203206

204207
// envPinnedKey maps a RuleSet JSON path to its CLI flag and env var.
@@ -219,6 +222,7 @@ var envPinnedRegistry = map[string]envPinnedKey{
219222
"detection.cas_enabled": {"cas.api", "CAS_API"},
220223
"llm.mode": {"llm.mode", "LLM_MODE"},
221224
"llm.consensus": {"llm.consensus", "LLM_CONSENSUS"},
225+
"llm.history_context_size": {"llm.history-context-size", "LLM_HISTORY_CONTEXT_SIZE"},
222226
"openai.veto": {"openai.veto", "OPENAI_VETO"},
223227
"openai.model": {"openai.model", "OPENAI_MODEL"},
224228
"openai.history_size": {"openai.history-size", "OPENAI_HISTORY_SIZE"},

app/settings_precedence_test.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,22 @@ func TestConfigured_NonEmptyEnvIsSet(t *testing.T) {
2121
func TestApplyExplicitOverrides_DetectionFields(t *testing.T) {
2222
t.Setenv("MAX_EMOJI", "9")
2323
t.Setenv("LLM_MODE", "always")
24+
t.Setenv("LLM_HISTORY_CONTEXT_SIZE", "4")
2425

2526
var opts options
2627
opts.MaxEmoji = 9
2728
opts.LLM.Mode = "always"
29+
opts.LLM.HistoryContextSize = 4
2830

2931
rs := rules.RuleSet{
3032
Detection: rules.DetectionRules{MaxEmoji: 2},
31-
LLM: rules.LLMCommonRules{Mode: "flagged"},
33+
LLM: rules.LLMCommonRules{Mode: "flagged", HistoryContextSize: 9},
3234
}
3335
applyExplicitRuleSetOverrides(&rs, opts)
3436

3537
assert.Equal(t, 9, rs.Detection.MaxEmoji, "MAX_EMOJI env must override the ruleset")
3638
assert.Equal(t, "always", rs.LLM.Mode, "LLM_MODE env must override the ruleset")
39+
assert.Equal(t, 4, rs.LLM.HistoryContextSize, "LLM_HISTORY_CONTEXT_SIZE env must override the ruleset")
3740
}
3841

3942
func TestApplyExplicitOverrides_DetectionFieldsUntouchedWithoutEnv(t *testing.T) {

app/webapi/assets/settings.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ <h2 class="page-title">Application Settings</h2>
9292
<tr><th>First Messages Count</th><td>{{.FirstMessagesCount}}</td></tr>
9393
<tr><th>Multi Lingual Words</th><td>{{.MultiLangLimit}}</td></tr>
9494
<tr><th>LLM Consensus</th><td>{{.LLMConsensus}}</td></tr>
95+
<tr><th>LLM History Context Size</th><td>{{.LLMHistoryContextSize}}</td></tr>
9596
<tr><th>Abnormal Spacing Enabled</th><td>{{.AbnormalSpacingEnabled}}</td></tr>
9697
<tr><th>History Size</th><td>{{.HistorySize}}</td></tr>
9798
<tr><th>Paranoid Mode</th><td>{{.ParanoidMode}}</td></tr>

app/webapi/assets/settings_edit.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ <h2 class="page-title">Edit Settings</h2>
3939
<div class="card-body">
4040
{{template "settings_enum" dict "Key" "llm.mode" "Label" "LLM mode" "Val" .RuleSet.LLM.Mode "Options" .LLMModes "Pinned" $pinned}}
4141
{{template "settings_enum" dict "Key" "llm.consensus" "Label" "LLM consensus" "Val" .RuleSet.LLM.Consensus "Options" .LLMConsensus "Pinned" $pinned}}
42+
{{template "settings_int" dict "Key" "llm.history_context_size" "Label" "History context size" "Val" .RuleSet.LLM.HistoryContextSize "Pinned" $pinned}}
4243
{{template "settings_text" dict "Key" "llm.vision_prompt" "Label" "Vision prompt" "Val" .RuleSet.LLM.VisionPrompt "Pinned" $pinned}}
4344
</div>
4445
</div>

0 commit comments

Comments
 (0)