diff --git a/go.mod b/go.mod index 02cf678..b85f856 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,7 @@ require ( go.uber.org/zap v1.28.0 golang.org/x/text v0.41.0 golang.org/x/time v0.15.0 - google.golang.org/genai v1.69.0 + google.golang.org/genai v1.70.0 ) require ( diff --git a/go.sum b/go.sum index 72fe193..4f0a1a2 100644 --- a/go.sum +++ b/go.sum @@ -218,8 +218,8 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genai v1.69.0 h1:quP3Rbiz0Mn+zPfXsWHQQwOx8IfO2MnQehZUbrJ/jPo= -google.golang.org/genai v1.69.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU= +google.golang.org/genai v1.70.0 h1:V9oYOBvTDYbmeklOHjvxGeeLau71WiS2CWKrktQtDok= +google.golang.org/genai v1.70.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU= google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a h1:qI/YMH1ep2qQtqcp00gMQyoU7mjvbhg88GJKCvfoLj0= google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= diff --git a/internal/app/router.go b/internal/app/router.go index 1dbfb0e..bd5f3bb 100644 --- a/internal/app/router.go +++ b/internal/app/router.go @@ -76,6 +76,11 @@ func (r *Router) RegisterRoutes(bh *th.BotHandler) { return nil }, th.CommandEqual("help")) + bh.HandleMessage(func(ctx *th.Context, m telego.Message) error { + r.handlers.Grok.FactCheck(ctx, &m) + return nil + }, grokFactCheckPredicate()) + // Non-command messages containing a URL start a direct download; registered last so commands win. bh.HandleMessage(func(ctx *th.Context, m telego.Message) error { r.handlers.Clip.DirectLink(ctx, &m, downloader.ExtractFirstURL(m.Text)) @@ -118,6 +123,30 @@ func clipHandler(h *handlers.Handlers, command string) th.MessageHandler { } } +func grokFactCheckPredicate() th.Predicate { + return func(_ context.Context, update telego.Update) bool { + return isGrokFactCheck(update.Message) + } +} + +func isGrokFactCheck(m *telego.Message) bool { + if m == nil || m.ReplyToMessage == nil || m.Text == "" { + return false + } + replacer := strings.NewReplacer(",", " ", ":", " ", "?", " ") + words := strings.Fields(replacer.Replace(strings.ToLower(m.Text))) + if len(words) < 3 { + return false + } + if words[0] != "@grok" { + return false + } + if words[1] == "это" && words[2] == "правда" { + return true + } + return words[1] == "is" && words[2] == "this" && len(words) >= 4 && words[3] == "true" +} + // messageWithURL matches non-command text messages carrying a downloadable URL. func messageWithURL() th.Predicate { return func(_ context.Context, update telego.Update) bool { diff --git a/internal/app/router_test.go b/internal/app/router_test.go new file mode 100644 index 0000000..4c1e748 --- /dev/null +++ b/internal/app/router_test.go @@ -0,0 +1,120 @@ +package app + +import ( + "testing" + + "github.com/mymmrac/telego" + "github.com/stretchr/testify/assert" +) + +func TestIsGrokFactCheck(t *testing.T) { + replyMsg := &telego.Message{ + MessageID: 100, + Text: "Target text to verify", + } + + tests := []struct { + name string + msg *telego.Message + expected bool + }{ + { + name: "nil message", + msg: nil, + expected: false, + }, + { + name: "no reply to message", + msg: &telego.Message{ + Text: "@grok это правда?", + }, + expected: false, + }, + { + name: "empty text", + msg: &telego.Message{ + ReplyToMessage: replyMsg, + Text: "", + }, + expected: false, + }, + { + name: "ru exact match", + msg: &telego.Message{ + ReplyToMessage: replyMsg, + Text: "@grok это правда", + }, + expected: true, + }, + { + name: "ru with comma and question mark", + msg: &telego.Message{ + ReplyToMessage: replyMsg, + Text: "@grok, это правда?", + }, + expected: true, + }, + { + name: "ru with colon and extra text", + msg: &telego.Message{ + ReplyToMessage: replyMsg, + Text: "@grok: это правда или фейк?", + }, + expected: true, + }, + { + name: "ru uppercase", + msg: &telego.Message{ + ReplyToMessage: replyMsg, + Text: "@GROK ЭТО ПРАВДА?", + }, + expected: true, + }, + { + name: "en exact match", + msg: &telego.Message{ + ReplyToMessage: replyMsg, + Text: "@grok is this true", + }, + expected: true, + }, + { + name: "en with question mark and comma", + msg: &telego.Message{ + ReplyToMessage: replyMsg, + Text: "@grok, is this true?", + }, + expected: true, + }, + { + name: "en with extra words", + msg: &telego.Message{ + ReplyToMessage: replyMsg, + Text: "@grok is this true: please check", + }, + expected: true, + }, + { + name: "unrelated grok mention", + msg: &telego.Message{ + ReplyToMessage: replyMsg, + Text: "@grok hello there", + }, + expected: false, + }, + { + name: "just mention", + msg: &telego.Message{ + ReplyToMessage: replyMsg, + Text: "@grok", + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, isGrokFactCheck(tt.msg)) + }) + } +} diff --git a/internal/handlers/grok.go b/internal/handlers/grok.go new file mode 100644 index 0000000..1089c55 --- /dev/null +++ b/internal/handlers/grok.go @@ -0,0 +1,117 @@ +package handlers + +import ( + "context" + "html" + "strconv" + "strings" + "time" + + "github.com/mymmrac/telego" + "go.uber.org/zap" + + "gemfactory/internal/middleware" + "gemfactory/internal/translate" +) + +type GrokHandlers struct { + *BaseHandler + limiter *middleware.GrokLimiter +} + +func NewGrokHandlers(base *BaseHandler) *GrokHandlers { + return &GrokHandlers{ + BaseHandler: base, + limiter: middleware.NewGrokLimiter(), + } +} + +func (h *GrokHandlers) FactCheck(ctx context.Context, message *telego.Message) { + if message == nil || message.ReplyToMessage == nil { + return + } + + enabledStr, err := h.Services.Config.Get(ctx, "GROK_ENABLED") + if err == nil && !translate.IsTruthy(enabledStr) { + return + } + + isAdmin := message.From != nil && (message.From.Username == h.Config.AdminUsername || h.IsAdmin(message.From)) + if !isAdmin { + rateLimit := 3 + if rlStr, err := h.Services.Config.Get(ctx, "GROK_RATE_LIMIT"); err == nil && rlStr != "" { + if v, err := strconv.Atoi(rlStr); err == nil && v > 0 { + rateLimit = v + } + } + + userID := int64(0) + if message.From != nil { + userID = message.From.ID + } + + allowed, shouldNotify := h.limiter.Check(userID, rateLimit, time.Minute) + if !allowed { + if shouldNotify { + _, _ = h.TG.SendMessageRaw(ctx, message.Chat.ID, "Мне лень", message.MessageID) + } + return + } + } + + replyMsg := message.ReplyToMessage + replyText := replyMsg.Text + if replyText == "" { + replyText = replyMsg.Caption + } + + var targetText string + if message.Quote != nil && strings.TrimSpace(message.Quote.Text) != "" { + targetText = "Проверяемая цитата:\n\"" + message.Quote.Text + "\"\n\nКонтекст исходного сообщения:\n\"" + replyText + "\"" + } else { + targetText = "Текст для проверки:\n\"" + replyText + "\"" + } + + if strings.TrimSpace(replyText) == "" && (message.Quote == nil || strings.TrimSpace(message.Quote.Text) == "") { + return + } + + maxChars := 3000 + if mcStr, err := h.Services.Config.Get(ctx, "GROK_MAX_CHARS"); err == nil && mcStr != "" { + if v, err := strconv.Atoi(mcStr); err == nil && v > 0 { + maxChars = v + } + } + + runes := []rune(targetText) + if len(runes) > maxChars { + targetText = string(runes[:maxChars]) + } + if strings.TrimSpace(targetText) == "" { + return + } + + prompt, err := h.Services.Config.Get(ctx, "GROK_PROMPT") + if err != nil || strings.TrimSpace(prompt) == "" { + prompt = translate.DefaultGrokPrompt + } + + cfg := translate.ResolveConfig(func(k string) (string, bool) { + if h.Services == nil || h.Services.Config == nil { + return "", false + } + v, err := h.Services.Config.Get(ctx, k) + if err != nil || strings.TrimSpace(v) == "" { + return "", false + } + return strings.TrimSpace(v), true + }) + + resp, _, err := translate.Complete(ctx, prompt, targetText, cfg) + if err != nil { + h.Logger.Error("grok factcheck failed", zap.Error(err)) + return + } + + _, _ = h.TG.SendMessageRaw(ctx, message.Chat.ID, html.EscapeString(resp), message.MessageID) +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 78f712b..b3ca0b2 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -15,6 +15,7 @@ type Handlers struct { User *UserHandlers Admin *AdminHandlers Clip *ClipHandlers + Grok *GrokHandlers } func New(services *service.Services, config *config.Config, keyboard *keyboard.Manager, logger *zap.Logger, tg *telegram.Client, downloads *downloader.Service) *Handlers { @@ -23,11 +24,13 @@ func New(services *service.Services, config *config.Config, keyboard *keyboard.M user := NewUserHandlers(base) admin := NewAdminHandlers(base) clip := NewClipHandlers(base, downloads, user) + grok := NewGrokHandlers(base) return &Handlers{ User: user, Admin: admin, Clip: clip, + Grok: grok, } } diff --git a/internal/middleware/grok_limiter.go b/internal/middleware/grok_limiter.go new file mode 100644 index 0000000..c86c63b --- /dev/null +++ b/internal/middleware/grok_limiter.go @@ -0,0 +1,56 @@ +package middleware + +import ( + "slices" + "sync" + "time" + + lru "github.com/hashicorp/golang-lru/v2/expirable" +) + +type grokUserState struct { + requests []time.Time + notified bool +} + +type GrokLimiter struct { + mu sync.Mutex + cache *lru.LRU[int64, *grokUserState] +} + +func NewGrokLimiter() *GrokLimiter { + return &GrokLimiter{ + cache: lru.NewLRU[int64, *grokUserState](maxTrackedUsers, nil, 10*time.Minute), + } +} + +func (l *GrokLimiter) Check(userID int64, limit int, window time.Duration) (bool, bool) { + l.mu.Lock() + defer l.mu.Unlock() + + now := time.Now() + windowStart := now.Add(-window) + + state, ok := l.cache.Get(userID) + if !ok || state == nil { + state = &grokUserState{} + l.cache.Add(userID, state) + } + + state.requests = slices.DeleteFunc(state.requests, func(reqTime time.Time) bool { + return !reqTime.After(windowStart) + }) + + if len(state.requests) < limit { + state.requests = append(state.requests, now) + state.notified = false + return true, false + } + + if !state.notified { + state.notified = true + return false, true + } + + return false, false +} diff --git a/internal/middleware/grok_limiter_test.go b/internal/middleware/grok_limiter_test.go new file mode 100644 index 0000000..c07c277 --- /dev/null +++ b/internal/middleware/grok_limiter_test.go @@ -0,0 +1,83 @@ +package middleware + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestGrokLimiter_Check(t *testing.T) { + limiter := NewGrokLimiter() + userID := int64(12345) + limit := 3 + window := 100 * time.Millisecond + + // 1st request - allowed + allowed, notify := limiter.Check(userID, limit, window) + assert.True(t, allowed) + assert.False(t, notify) + + // 2nd request - allowed + allowed, notify = limiter.Check(userID, limit, window) + assert.True(t, allowed) + assert.False(t, notify) + + // 3rd request - allowed + allowed, notify = limiter.Check(userID, limit, window) + assert.True(t, allowed) + assert.False(t, notify) + + // 4th request - rejected, first notification + allowed, notify = limiter.Check(userID, limit, window) + assert.False(t, allowed) + assert.True(t, notify) + + // 5th request - rejected, silent + allowed, notify = limiter.Check(userID, limit, window) + assert.False(t, allowed) + assert.False(t, notify) + + // Wait for window to expire + time.Sleep(120 * time.Millisecond) + + // Next request after expiry - allowed + allowed, notify = limiter.Check(userID, limit, window) + assert.True(t, allowed) + assert.False(t, notify) +} + +func TestGrokLimiter_Concurrent(t *testing.T) { + limiter := NewGrokLimiter() + userID := int64(99999) + limit := 5 + window := time.Second + + var wg sync.WaitGroup + var allowedCount, notifyCount, silentCount int + var mu sync.Mutex + + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + allowed, notify := limiter.Check(userID, limit, window) + mu.Lock() + switch { + case allowed: + allowedCount++ + case notify: + notifyCount++ + default: + silentCount++ + } + mu.Unlock() + }() + } + + wg.Wait() + assert.Equal(t, limit, allowedCount) + assert.Equal(t, 1, notifyCount) + assert.Equal(t, 20-limit-1, silentCount) +} diff --git a/internal/translate/config.go b/internal/translate/config.go index 62d3778..ffd6dc5 100644 --- a/internal/translate/config.go +++ b/internal/translate/config.go @@ -3,6 +3,7 @@ package translate import ( "os" + "strconv" "strings" "time" ) @@ -49,6 +50,23 @@ const DefaultTranslationPrompt = `Translate subtitles for entertainment videos: 5. Song lyrics: translate the thought and rhythm, not word-by-word. 6. Silently fix obvious ASR/speech-recognition errors from context.` +const DefaultGrokPrompt = `Ты — Grok, остроумный и прямолинейный фактчекер в Telegram. Твоя задача — проверить утверждение пользователя, оценить его достоверность и выдать краткий, точный вердикт. + +Принципы: +1. Истина превыше всего: факты и логика железные. Никогда не выдумывай вымышленные события, даты, цитаты или имена. +2. Тон: лаконичный, уверенный, с сухим юмором и здоровым скептицизмом. +3. СТРОГИЙ ЗАПРЕТ НА ЭМОДЗИ: Не используй абсолютно никаких эмодзи и смайлов в ответе. +4. Категории вердиктов: + - Объективный факт -> База + - Прямая ложь -> Фейк или Вброс + - Субъективное мнение -> Субъектив + - Слух без подтверждений -> Не подтверждено + +Формат ответа (строго 3 блока): +- 1-я строка: Вердикт: [выбери категорию] +- Основная часть: 1–2 емких предложения с сутью дела без лишней воды. +- Финальная строка: [Достоверность: X/10]` + const ruAddendum = `For Russian: informal "ты", lively youth phrasing; transliterate Korean honorifics (онни, оппа, макнэ, хён); names transliterate by sound (Liv -> Лив, May -> Мэй), a common noun next to a name stays a common word (리브 미모 = красота Лив, not "Liv Mimo"); bracketed notes translate naturally ([Laughter] -> [Смех]).` // DefaultSourcePrefRU is the fallback source-language order for ru subtitles. @@ -138,6 +156,62 @@ func DefaultConfig() Config { return ApplyDefaults(cfg) } +// ResolveConfig layers DB-backed settings on top of env defaults. +func ResolveConfig(getter func(string) (string, bool)) Config { + cfg := DefaultConfig() + if getter == nil { + return cfg + } + if v, ok := getter("GEMINI_API_KEY"); ok { + cfg.GeminiKey = v + } + if v, ok := getter("GROQ_API_KEY"); ok { + cfg.GroqKey = v + } + if v, ok := getter("OPENCODE_API_KEY"); ok { + cfg.OpencodeKey = v + } + if v, ok := getter("NVIDIA_API_KEY"); ok { + cfg.NvidiaKey = v + } + if v, ok := getter("OPENROUTER_API_KEY"); ok { + cfg.OpenRouterKey = v + } + if v, ok := getter("GEMINI_MODELS"); ok { + cfg.GeminiModels = ParseCSV(v) + } + if v, ok := getter("GROQ_MODELS"); ok { + cfg.GroqModels = ParseCSV(v) + } + if v, ok := getter("OPENCODE_MODELS"); ok { + cfg.OpencodeModels = ParseCSV(v) + } + if v, ok := getter("NVIDIA_MODELS"); ok { + cfg.NvidiaModels = ParseCSV(v) + } + if v, ok := getter("OPENROUTER_MODELS"); ok { + cfg.OpenRouterModels = ParseCSV(v) + } + if v, ok := getter("TRANSLATION_FALLBACK_ORDER"); ok { + cfg.FallbackOrder = ParseCSV(v) + } + if v, ok := getter("TRANSLATION_PROMPT"); ok { + cfg.Prompt = v + } + if v, ok := getter("SUBS_SOURCE_PREF_RU"); ok { + cfg.SourcePrefRU = ParseCSV(v) + } + if v, ok := getter("SUBS_GOOGLE_ONLY"); ok { + cfg.GoogleOnly = IsTruthy(v) + } + if v, ok := getter("TRANSLATION_TIMEOUT"); ok { + if sec, err := strconv.Atoi(v); err == nil && sec > 0 { + cfg.Timeout = time.Duration(sec) * time.Second + } + } + return ApplyDefaults(cfg) +} + func ApplyDefaults(cfg Config) Config { if len(cfg.GeminiModels) == 0 { cfg.GeminiModels = DefaultGeminiModels diff --git a/internal/translate/providers.go b/internal/translate/providers.go index 03d1fe2..a3a7e66 100644 --- a/internal/translate/providers.go +++ b/internal/translate/providers.go @@ -566,3 +566,162 @@ func parseNumberedLines(raw string, expectedCount int) ([]string, error) { return nil, fmt.Errorf("translated line count mismatch: expected %d, got %d numbered lines", expectedCount, foundCount) } + +// Complete walks the fallback chain for a free-form chat completion. +func Complete(ctx context.Context, systemPrompt, userMsg string, cfg Config) (string, string, error) { + chain := BuildFallbackChain(cfg) + var lastErr error + for _, provider := range chain { + switch provider { + case ProviderOpenRouter: + if cfg.OpenRouterKey == "" { + continue + } + text, model, err := completeOpenAICompatible(ctx, ProviderOpenRouter, openRouterBaseURL, cfg.OpenRouterKey, cfg.OpenRouterModels, systemPrompt, userMsg, cfg.Timeout) + if err == nil && strings.TrimSpace(text) != "" { + return text, ProviderOpenRouter + "/" + model, nil + } + lastErr = err + + case ProviderOpencode: + if cfg.OpencodeKey == "" { + continue + } + text, model, err := completeOpenAICompatible(ctx, ProviderOpencode, opencodeBaseURL, cfg.OpencodeKey, cfg.OpencodeModels, systemPrompt, userMsg, cfg.Timeout) + if err == nil && strings.TrimSpace(text) != "" { + return text, ProviderOpencode + "/" + model, nil + } + lastErr = err + + case ProviderNvidia: + if cfg.NvidiaKey == "" { + continue + } + text, model, err := completeOpenAICompatible(ctx, ProviderNvidia, nvidiaBaseURL, cfg.NvidiaKey, cfg.NvidiaModels, systemPrompt, userMsg, cfg.Timeout) + if err == nil && strings.TrimSpace(text) != "" { + return text, ProviderNvidia + "/" + model, nil + } + lastErr = err + + case ProviderGroq: + if cfg.GroqKey == "" { + continue + } + text, model, err := completeOpenAICompatible(ctx, ProviderGroq, groqBaseURL, cfg.GroqKey, cfg.GroqModels, systemPrompt, userMsg, cfg.Timeout) + if err == nil && strings.TrimSpace(text) != "" { + return text, ProviderGroq + "/" + model, nil + } + lastErr = err + + case ProviderGemini: + if cfg.GeminiKey == "" { + continue + } + text, model, err := completeGemini(ctx, cfg.GeminiKey, cfg.GeminiModels, systemPrompt, userMsg, cfg.Timeout) + if err == nil && strings.TrimSpace(text) != "" { + return text, ProviderGemini + "/" + model, nil + } + lastErr = err + } + } + if lastErr != nil { + return "", "", lastErr + } + return "", "", fmt.Errorf("all LLM providers failed or unconfigured") +} + +func completeOpenAICompatible(ctx context.Context, provider, baseURL, apiKey string, models []string, systemPrompt, userMsg string, timeout time.Duration) (string, string, error) { + cfg := openai.DefaultConfig(apiKey) + cfg.BaseURL = baseURL + cfg.HTTPClient = &http.Client{Timeout: requestTimeout(timeout)} + client := openai.NewClientWithConfig(cfg) + + var lastErr error + for _, model := range models { + req := openai.ChatCompletionRequest{ + Model: model, + Messages: []openai.ChatCompletionMessage{ + {Role: openai.ChatMessageRoleSystem, Content: systemPrompt}, + {Role: openai.ChatMessageRoleUser, Content: userMsg}, + }, + Temperature: 0.7, + } + if isReasoningModel(model) { + req.ReasoningEffort = "low" + } + if kwargs := reasoningDisableKwargs(provider, model); kwargs != nil { + req.ChatTemplateKwargs = kwargs + } + + resp, err := createChatCompletionWithRetry(ctx, client, req) + if err != nil { + lastErr = fmt.Errorf("%s (%s): %w", provider, model, err) + continue + } + + if len(resp.Choices) > 0 { + out := strings.TrimSpace(thinkTagRe.ReplaceAllString(resp.Choices[0].Message.Content, "")) + if out != "" { + return out, model, nil + } + } + lastErr = fmt.Errorf("%s (%s): empty response", provider, model) + } + return "", "", lastErr +} + +func completeGemini(ctx context.Context, apiKey string, models []string, systemPrompt, userMsg string, timeout time.Duration) (string, string, error) { + geminiModels := firstModels([]([]string){models}, DefaultGeminiModels) + client, err := genai.NewClient(ctx, &genai.ClientConfig{ + APIKey: apiKey, + Backend: genai.BackendGeminiAPI, + HTTPClient: &http.Client{Timeout: requestTimeout(timeout)}, + }) + if err != nil { + return "", "", fmt.Errorf("gemini client: %w", err) + } + + temp := float32(0.7) + var lastErr error + for _, model := range geminiModels { + withSafety, withThinking := true, true + for attempt := 0; attempt < 3; attempt++ { + config := &genai.GenerateContentConfig{ + SystemInstruction: &genai.Content{ + Parts: []*genai.Part{genai.NewPartFromText(systemPrompt)}, + }, + Temperature: &temp, + } + if withThinking { + budget := int32(0) + config.ThinkingConfig = &genai.ThinkingConfig{ThinkingBudget: &budget} + } + if withSafety { + config.SafetySettings = geminiSafetySettings() + } + + resp, cerr := client.Models.GenerateContent(ctx, model, genai.Text(userMsg), config) + if cerr != nil { + lastErr = fmt.Errorf("gemini (%s): %w", model, cerr) + code, _ := geminiAPIErrorDetails(cerr) + switch { + case code == http.StatusBadRequest && withThinking: + withThinking = false + continue + case code == http.StatusBadRequest && withSafety: + withSafety = false + continue + } + break + } + + out := strings.TrimSpace(thinkTagRe.ReplaceAllString(resp.Text(), "")) + if out != "" { + return out, model, nil + } + lastErr = fmt.Errorf("empty response from gemini (%s)", model) + break + } + } + return "", "", lastErr +} diff --git a/internal/web/server.go b/internal/web/server.go index c060efc..b47f5b5 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -530,7 +530,8 @@ func (s *Server) listConfig(w http.ResponseWriter, r *http.Request) { "DOWNLOAD_CONCURRENCY", "CLIP_CRF", "SUBS_CRF", "CLIP_PRESET", "CLIP_AUDIO_BITRATE", "CLIP_DELETE_STATUS", "NVIDIA_API_KEY", "NVIDIA_MODELS", "DOWNLOAD_RETENTION_HOURS", "TRANSLATION_TIMEOUT", "SUBS_GOOGLE_ONLY", - "TG_FILE_LIMIT_MB", "YTDLP_PROXY": + "TG_FILE_LIMIT_MB", "YTDLP_PROXY", + "GROK_ENABLED", "GROK_RATE_LIMIT", "GROK_MAX_CHARS", "GROK_PROMPT": return true default: return false diff --git a/internal/web/settings.go b/internal/web/settings.go index 1e25e57..2c6dbde 100644 --- a/internal/web/settings.go +++ b/internal/web/settings.go @@ -103,6 +103,10 @@ func settingRegistry() []settingSpec { validate: csvOf(translate.ProviderGoogle, translate.ProviderGemini, translate.ProviderNvidia, translate.ProviderGroq, translate.ProviderOpencode, translate.ProviderOpenRouter)}, {key: "SUBS_SOURCE_PREF_RU", def: "en,ko"}, {key: "TRANSLATION_PROMPT", def: translate.DefaultTranslationPrompt}, + {key: "GROK_ENABLED", def: "true", validate: boolSpec()}, + {key: "GROK_RATE_LIMIT", def: "30", validate: intRange(1, 1000)}, + {key: "GROK_MAX_CHARS", def: "4000", validate: intRange(100, 50000)}, + {key: "GROK_PROMPT", def: translate.DefaultGrokPrompt}, {key: "GEMINI_API_KEY", masked: true}, {key: "GROQ_API_KEY", masked: true}, {key: "OPENCODE_API_KEY", masked: true}, @@ -215,7 +219,7 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) { return } } - if spec.key == "CLIP_DELETE_STATUS" || spec.key == "SUBS_GOOGLE_ONLY" { + if spec.key == "CLIP_DELETE_STATUS" || spec.key == "SUBS_GOOGLE_ONLY" || spec.key == "GROK_ENABLED" { v = normalizeBool(v) } updates = append(updates, pending{spec: spec, val: v}) diff --git a/internal/web/static/js/settings.js b/internal/web/static/js/settings.js index e5aad1a..1a84746 100644 --- a/internal/web/static/js/settings.js +++ b/internal/web/static/js/settings.js @@ -33,6 +33,16 @@ const SECTIONS = [ FIELD('CLIP_AUDIO_BITRATE', 'Audio Bitrate', 'select', { options: [['128k', '128k'], ['192k', '192k'], ['256k', '256k'], ['320k', '320k']], hint: 'AAC audio encoding bitrate' }), ], }, + { + id: 'grok', + title: 'Grok Factchecker', + fields: [ + FIELD('GROK_ENABLED', 'Enable @grok factchecker', 'bool', { hint: 'Enable @grok factchecking via Grok' }), + FIELD('GROK_RATE_LIMIT', 'Rate Limit (requests per min)', 'number', { min: 1, max: 1000, unit: 'req/min', hint: 'Maximum allowed Grok calls per minute' }), + FIELD('GROK_MAX_CHARS', 'Max Target Text Length (chars)', 'number', { min: 100, max: 50000, unit: 'chars', hint: 'Max input character length for verification' }), + ], + extra: 'grok', + }, { id: 'translation', title: 'Translation', extra: 'translation' }, { id: 'advanced', title: 'Advanced', extra: 'advanced' }, { id: 'system', title: 'System Info', extra: 'system' }, @@ -1183,6 +1193,41 @@ function renderSystemSection(root) { // Page assembly, load and save // --------------------------------------------------------------------------- +function renderGrokPrompt(root) { + const details = el('details', { class: 'pipeline-prompt-details' }); + const summary = el('summary', { class: 'pipeline-prompt-summary' }, 'Grok System Prompt'); + const body = el('div', { class: 'pipeline-prompt-body' }); + const desc = el('div', { class: 'pipeline-section-desc' }, 'Instructions and guidelines passed to Grok factchecker.'); + const promptArea = el('textarea', { + rows: 6, + class: 'pipeline-prompt-textarea', + placeholder: 'Enter Grok system prompt (leave empty for default)...', + }); + promptArea.value = effectiveValue('GROK_PROMPT'); + promptArea.addEventListener('input', () => { + state.edited.set('GROK_PROMPT', promptArea.value); + updateSaveBar(); + }); + + const actions = el('div', { style: 'display:flex; justify-content:flex-end; margin-top:0.5rem;' }); + const resetBtn = el('button', { class: 'btn btn-ghost btn-sm', type: 'button' }, 'Reset to Default'); + resetBtn.addEventListener('click', () => { + const def = entryOf('GROK_PROMPT').default || ''; + promptArea.value = def; + state.edited.set('GROK_PROMPT', def); + updateSaveBar(); + toast('Prompt reset to default', 'info'); + }); + actions.appendChild(resetBtn); + + body.appendChild(desc); + body.appendChild(promptArea); + body.appendChild(actions); + details.appendChild(summary); + details.appendChild(body); + root.appendChild(details); +} + function renderSectionBody(sectionId) { const body = $('#settings-body'); if (!body) return; @@ -1197,6 +1242,11 @@ function renderSectionBody(sectionId) { renderSectionFields(section, card); body.appendChild(card); break; + case 'grok': + renderSectionFields(section, card); + body.appendChild(card); + renderGrokPrompt(body); + break; case 'translation': renderTranslationSection(body); break; diff --git a/migrations/000015_add_grok_config.down.sql b/migrations/000015_add_grok_config.down.sql new file mode 100644 index 0000000..85f0816 --- /dev/null +++ b/migrations/000015_add_grok_config.down.sql @@ -0,0 +1 @@ +DELETE FROM gemfactory.config WHERE key IN ('GROK_ENABLED', 'GROK_PROMPT', 'GROK_RATE_LIMIT', 'GROK_MAX_CHARS'); diff --git a/migrations/000015_add_grok_config.up.sql b/migrations/000015_add_grok_config.up.sql new file mode 100644 index 0000000..a807231 --- /dev/null +++ b/migrations/000015_add_grok_config.up.sql @@ -0,0 +1,21 @@ +INSERT INTO gemfactory.config (key, value, description) VALUES +('GROK_ENABLED', 'true', 'Enable @grok factchecker in replies'), +('GROK_PROMPT', 'Ты — Grok, остроумный и прямолинейный фактчекер в Telegram. Твоя задача — проверить утверждение пользователя, оценить его достоверность и выдать краткий, точный вердикт. + +Принципы: +1. Истина превыше всего: факты и логика железные. Никогда не выдумывай вымышленные события, даты, цитаты или имена. +2. Тон: лаконичный, уверенный, с сухим юмором и здоровым скептицизмом. +3. СТРОГИЙ ЗАПРЕТ НА ЭМОДЗИ: Не используй абсолютно никаких эмодзи и смайлов в ответе. +4. Категории вердиктов: + - Объективный факт -> База + - Прямая ложь -> Фейк или Вброс + - Субъективное мнение -> Субъектив + - Слух без подтверждений -> Не подтверждено + +Формат ответа (строго 3 блока): +- 1-я строка: Вердикт: [выбери категорию] +- Основная часть: 1–2 емких предложения с сутью дела без лишней воды. +- Финальная строка: [Достоверность: X/10]', 'System prompt for @grok factchecker'), +('GROK_RATE_LIMIT', '3', 'Max @grok requests per minute per user'), +('GROK_MAX_CHARS', '3000', 'Max characters of target text passed to @grok LLM') +ON CONFLICT (key) DO NOTHING;