From 5a0c8fc57a083cb0f7c25b3bb80b55095f030335 Mon Sep 17 00:00:00 2001 From: cuogne Date: Sun, 16 Aug 2026 12:56:36 +0700 Subject: [PATCH 1/3] feat(summary): improve summarization prompt and model selection --- .env.example | 3 +- config/models.go | 46 +++++++++++++++++++++++ internal/service/prompt.go | 69 +++++++++++++++++++++++++++++++++++ internal/service/summarize.go | 21 +++-------- 4 files changed, 122 insertions(+), 17 deletions(-) create mode 100644 config/models.go create mode 100644 internal/service/prompt.go diff --git a/.env.example b/.env.example index 200769f..93d54f3 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here GEMINI_API_KEY=your_gemini_api_key_here -SUPABASE_URL=your_supabase_url \ No newline at end of file +SUPABASE_URL=your_supabase_url +GEMINI_MODELS=add_your_gemini_models_here_separated_by_commas \ No newline at end of file diff --git a/config/models.go b/config/models.go new file mode 100644 index 0000000..ea0e1bd --- /dev/null +++ b/config/models.go @@ -0,0 +1,46 @@ +package config + +import ( + "log" + "os" + "strings" + "sync" +) + +// default models to use if GEMINI_MODELS env is not set (fallback order) +var defaultModels = []string{ + "gemini-3.5-flash-lite", + "gemini-3.7-flash", + "gemini-3.6-flash", + "gemini-3.5-flash", +} + +var ( + models []string + modelsOnce sync.Once +) + +// Models returns gemini models from GEMINI_MODELS env (comma-separated), +// falling back to default list if unset. +func Models() []string { + modelsOnce.Do(func() { + raw := strings.TrimSpace(os.Getenv("GEMINI_MODELS")) + if raw == "" { + models = defaultModels + log.Printf("GEMINI_MODELS not set, using default models: %v", models) + return + } + + for m := range strings.SplitSeq(raw, ",") { + if m = strings.TrimSpace(m); m != "" { + models = append(models, m) + } + } + + if len(models) == 0 { + models = defaultModels + } + }) + + return models +} diff --git a/internal/service/prompt.go b/internal/service/prompt.go new file mode 100644 index 0000000..282b808 --- /dev/null +++ b/internal/service/prompt.go @@ -0,0 +1,69 @@ +package service + +const summarizePrompt = ` +Bạn là một biên tập viên tin tức chuyên nghiệp, có nhiệm vụ tóm tắt các bài báo tiếng Việt của HCMUS cho người đọc. +Nhiệm vụ của bạn là: +- Tóm tắt bài báo bên dưới thành 3 câu ngắn gọn nhưng đầy đủ ý chính. + +Mục tiêu là để một người khi thấy thông báo tin mới, chưa mở bài báo gốc vẫn có thể hiểu: +- Bài báo đang nói về sự kiện/vấn đề gì. +- Những diễn biến hoặc thông tin quan trọng nhất. +- Các nhân vật, tổ chức, địa điểm, công ty, đơn vị, thời gian hoặc con số đáng chú ý. +- Kết quả, tác động hoặc thông tin đáng chú ý khác nếu có. + +RULE QUAN TRỌNG KHI TÓM TẮT BÀI BÁO: +- Chỉ sử dụng thông tin xuất hiện trong bài báo. +- Tuyệt đối không bịa, suy diễn hoặc bổ sung thông tin bên ngoài. +- Giữ chính xác tên người, tổ chức, địa điểm, công ty, đơn vị, thời gian và số liệu quan trọng. +- Ưu tiên thông tin quan trọng nhất thay vì tóm tắt từng đoạn theo tỷ lệ. +- Tóm tắt theo mạch diễn biến tự nhiên của bài viết. +- Văn phong tự nhiên, giống một biên tập viên đang viết bản tin ngắn. Có thể biến đổi câu từ 1 tí để tạo cảm giác hấp dẫn cho câu văn nhưng vẫn giữ được ý chính. +- Sử dụng các từ ngữ hấp dẫn, lôi cuốn và có thể lôi kéo, mời gọi người đọc đọc tiếp tạo cảm giác tò mò, hấp dẫn cho bài báo. Tuy nhiên hấp dẫn nhưng không được phóng đại quá sự thật trong bài. +- Ngắn gọn, dễ đọc nhưng vẫn đủ thông tin để người đọc hiểu nội dung. +- Viết liền mạch, không xuống dòng giữa các câu, không dùng bullet point hoặc đánh số. +- Không dùng các câu mở đầu máy móc như "Bài viết đề cập đến...", "Theo bài báo...", "Nội dung bài viết cho biết...". +- Không sử dụng những câu sáo rỗng hoặc nhận xét không có trong bài. +- Không biến thông tin dự kiến, tin đồn, cáo buộc hoặc nhận định thành sự thật. + +LƯỢC BỎ NỘI DUNG KHÔNG CẦN THIẾT: +Bỏ qua hoàn toàn: +- Header, navigation, menu, breadcrumb. +- Footer, copyright, thông tin liên hệ. +- Quảng cáo, banner, popup. +- Cookie/privacy notice. +- Nút chia sẻ, đăng nhập, đăng ký. +- Newsletter. +- Bài viết liên quan/bài viết đề xuất. +- Bình luận. +- Các nội dung không thuộc bài báo. + +Đặc biệt, nếu nội dung cookie chiếm phần lớn dữ liệu được trích xuất và phần bài báo thực tế quá ít hoặc không đủ thông tin để tóm tắt có ý nghĩa, chỉ trả về một chuỗi rỗng hoàn toàn, không kèm bất kỳ ký tự hay giải thích nào. + +OUTPUT YÊU CẦU: +- Chỉ trả về bản tóm tắt. +- Không có tiêu đề. +- Không có "Tóm tắt:". +- Không bullet point. +- Không đánh số. +- Không Markdown. +- Không JSON. +- Không code block. +- Không giải thích thêm. +- Bắt buộc gồm 3 câu, viết liền mạch, không xuống dòng giữa các câu. +- Nếu bài báo quá ngắn, không có nội dung thực tế hoặc không đủ thông tin để tóm tắt có ý nghĩa, chỉ trả về một chuỗi rỗng hoàn toàn, không kèm bất kỳ ký tự hay giải thích nào. + +HÃY CHECK LẠI NỘI DUNG TRƯỚC KHI TRẢ VỀ +1. Có đúng 3 câu không? +2. Có bao quát ý chính không? +3. Có thông tin nào bị bịa hoặc suy diễn không? +4. Có bỏ qua nội dung rác không? +5. Có giữ đúng tên riêng, thời gian và số liệu không? +6. Người đọc chưa mở bài gốc có hiểu được bài báo nói gì không? +7. Văn phong có tự nhiên, hấp dẫn, lôi cuốn và thu hút người đọc không? + +Nếu chưa đạt, hãy tự chỉnh sửa trước khi trả về. + +TUÂN THỦ NGHIÊM NGẶT CÁC RULE TRÊN. KHÔNG ĐƯỢC PHÁ CÁCH. + +Đây là nội dung bài báo cần tóm tắt: +%s` diff --git a/internal/service/summarize.go b/internal/service/summarize.go index 997e81c..9367c68 100644 --- a/internal/service/summarize.go +++ b/internal/service/summarize.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "hcmus-news-tele-bot/config" "hcmus-news-tele-bot/internal/model" "log" "os" @@ -19,19 +20,14 @@ var ( initErr error ) -var geminiModels = []string{ - "gemini-3.5-flash", - "gemini-2.5-flash", - "gemini-3.1-flash-lite", -} - func isGeminiFallbackError(err error) bool { var apiErr genai.APIError if !errors.As(err, &apiErr) { return false } - return apiErr.Code == 429 || apiErr.Code == 503 + // 429 (Too Many Requests), 503 (Service Unavailable), 500 (Internal Server Error) + return apiErr.Code == 429 || apiErr.Code == 503 || apiErr.Code == 500 } func geminiErrorCode(err error) int { @@ -69,6 +65,7 @@ func getGeminiClient(ctx context.Context) (*genai.Client, error) { func SummarizeContentWithGemini(content string) (model.SummaryResult, error) { ctx := context.Background() + geminiModels := config.Models() client, err := getGeminiClient(ctx) if err != nil { @@ -79,15 +76,7 @@ func SummarizeContentWithGemini(content string) (model.SummaryResult, error) { Temperature: genai.Ptr[float32](0.4), } - prompt := fmt.Sprintf(` - Bạn là một biên tập viên tóm tắt tin tức chuyên nghiệp. Nhiệm vụ của bạn là: - - Tóm tắt nội dung tin tức sau không vượt quá 3 dòng -> người dùng sẽ cảm thấy quá dài và không đọc (Bắt buộc - Key). - - Văn phong tóm tắt phải tự nhiên, không quá máy móc, bám sát nội dung bài viết. - - Phải đi qua đủ hết nội dung của trang web, tóm tắt lại đầy đủ -> người dùng chưa cần ấn vào link vẫn có thể nắm được sơ qua nội dung chính của bài viết. - - Chọn những dòng quan trọng/hấp dẫn để tóm tắt -> người dùng hứng thú -> vào link đọc tiếp. - - Không cần chào hỏi, vô thẳng nội dung chính, không cần nói thêm gì khác. - - Nếu tóm tắt xong, nội dung có câu: Trang web này sử dụng cookie, thì không ghi đoạn này, nếu không đủ nội dung thì để rỗng. - Nội dung bài viết như sau: %s`, content) + prompt := fmt.Sprintf(summarizePrompt, content) var resp *genai.GenerateContentResponse From d509dacd81118479a3be1bc1ecba7a77554ca74d Mon Sep 17 00:00:00 2001 From: cuogne Date: Sun, 16 Aug 2026 13:23:55 +0700 Subject: [PATCH 2/3] fix(news): escape HTML in message, auto-unsubscribe blocked users, retry failed fetches --- internal/handler/handler.go | 14 +++++++-- internal/model/Summary.go | 1 + internal/service/handle_unreachable_user.go | 32 +++++++++++++++++++++ internal/service/retry_fetch.go | 31 ++++++++++++++++++++ internal/service/send_tele.go | 14 ++++++--- internal/service/worker.go | 15 +++++++++- 6 files changed, 99 insertions(+), 8 deletions(-) create mode 100644 internal/service/handle_unreachable_user.go create mode 100644 internal/service/retry_fetch.go diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 7e70d68..c8236ab 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -94,8 +94,16 @@ func runCronCycle(b *tele.Bot, dbPool *pgxpool.Pool) { close(jobs) for range newArticles { - res := <-results // receive summary from gemini - service.SendTele(b, users, res) // send for subscribed users - service.SaveToDB(dbPool, res) // save to db + res := <-results // receive summary from gemini + + // content fetch failed but will be retried next cycle, + // do not send or save so the article is not lost + if res.Retryable { + log.Printf("Fetch failed, will retry next cycle: %s", res.Article.URL) + continue + } + + service.SendTele(b, dbPool, users, res) // send for subscribed users + service.SaveToDB(dbPool, res) // save to db } } diff --git a/internal/model/Summary.go b/internal/model/Summary.go index 208538a..822daa0 100644 --- a/internal/model/Summary.go +++ b/internal/model/Summary.go @@ -12,5 +12,6 @@ type ( Summary string PromptToken int CompletionToken int + Retryable bool // true when fetch failed and should be retried next cycle } ) diff --git a/internal/service/handle_unreachable_user.go b/internal/service/handle_unreachable_user.go new file mode 100644 index 0000000..74a7aa2 --- /dev/null +++ b/internal/service/handle_unreachable_user.go @@ -0,0 +1,32 @@ +package service + +import ( + "context" + "errors" + "hcmus-news-tele-bot/internal/repository" + "log" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + tele "gopkg.in/telebot.v4" +) + +// isUserUnreachable checks if the user blocked the bot or deactivated their account, +// meaning further sending attempts are pointless. +func isUserUnreachable(err error) bool { + return errors.Is(err, tele.ErrBlockedByUser) || + errors.Is(err, tele.ErrUserIsDeactivated) || + errors.Is(err, tele.ErrNotStartedByUser) +} + +func autoUnsubscribe(dbPool *pgxpool.Pool, userID string) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := repository.UnsubscribeUser(dbPool, ctx, userID); err != nil { + log.Printf("Error auto-unsubscribing user %s: %v\n", userID, err) + return + } + + log.Printf("User %s blocked/deactivated the bot, auto-unsubscribed", userID) +} diff --git a/internal/service/retry_fetch.go b/internal/service/retry_fetch.go new file mode 100644 index 0000000..17037e9 --- /dev/null +++ b/internal/service/retry_fetch.go @@ -0,0 +1,31 @@ +package service + +import "sync" + +// maxFetchAttempts limits how many cron cycles retry a failed content fetch +// before falling back to sending the article with an empty summary. +// The attempt counter is in-memory only, so it resets on restart. +const maxFetchAttempts = 3 + +var ( + fetchMu sync.Mutex + fetchAttempts = make(map[string]int) +) + +// shouldRetryFetch records a failed fetch for the url and reports +// whether it should be retried on the next cron cycle. +func shouldRetryFetch(url string) bool { + fetchMu.Lock() + defer fetchMu.Unlock() + + fetchAttempts[url]++ + return fetchAttempts[url] < maxFetchAttempts +} + +// clearFetchAttempt removes the retry counter after the url is processed. +func clearFetchAttempt(url string) { + fetchMu.Lock() + defer fetchMu.Unlock() + + delete(fetchAttempts, url) +} diff --git a/internal/service/send_tele.go b/internal/service/send_tele.go index c7b7734..ee80246 100644 --- a/internal/service/send_tele.go +++ b/internal/service/send_tele.go @@ -3,17 +3,19 @@ package service import ( "fmt" "hcmus-news-tele-bot/internal/model" + "html" "log" "strconv" "strings" + "github.com/jackc/pgx/v5/pgxpool" tele "gopkg.in/telebot.v4" ) -func SendTele(b *tele.Bot, users []string, res model.SummaryResult) { - title := strings.TrimSpace(res.Article.Title) - summary := strings.TrimSpace(res.Summary) - link := res.Article.URL +func SendTele(b *tele.Bot, dbPool *pgxpool.Pool, users []string, res model.SummaryResult) { + title := html.EscapeString(strings.TrimSpace(res.Article.Title)) + summary := html.EscapeString(strings.TrimSpace(res.Summary)) + link := html.EscapeString(res.Article.URL) msg := "" @@ -33,6 +35,10 @@ func SendTele(b *tele.Bot, users []string, res model.SummaryResult) { _, err = b.Send(recipient, msg, tele.ModeHTML) if err != nil { log.Printf("Error sending message to user %s: %v\n", uID, err) + + if isUserUnreachable(err) { + autoUnsubscribe(dbPool, uID) + } } } } diff --git a/internal/service/worker.go b/internal/service/worker.go index 3157254..e9a9708 100644 --- a/internal/service/worker.go +++ b/internal/service/worker.go @@ -29,11 +29,24 @@ func Worker(id int, jobs <-chan model.SummaryJob, results chan<- model.SummaryRe content, err := GetContentFromURL(job.Article.URL) if err != nil { - results <- emptyResult() log.Printf("Error getting content while fetching URL %s: %v", job.Article.URL, err) + + if shouldRetryFetch(job.Article.URL) { + results <- model.SummaryResult{ + Article: job.Article, + Category: job.Category, + Retryable: true, + } + continue + } + + clearFetchAttempt(job.Article.URL) + results <- emptyResult() continue } + clearFetchAttempt(job.Article.URL) + // content is empty -> no need to summarize, just return empty summary // ("", err) or ("", nil) if content == "" { From 76ce8ac3cc869a97fd927f75d9d4a121b96ad36c Mon Sep 17 00:00:00 2001 From: cuogne Date: Sun, 16 Aug 2026 13:33:53 +0700 Subject: [PATCH 3/3] update README.md --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index f4b4e1c..4ad4170 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,7 @@ B4. Sau khi chạy xong, bạn sẽ có file `.env` trong thư mục với các TELEGRAM_BOT_TOKEN=your_telegram_bot_token SUPABASE_URL=your_supabase_url GEMINI_API_KEY=your_gemini_api_key +GEMINI_MODELS=add_your_gemini_models_here_separated_by_commas ``` Thay các token trong file `.env` vừa được tạo bằng token của bạn, cách lấy như sau: @@ -191,6 +192,10 @@ Thay các token trong file `.env` vừa được tạo bằng token của bạn, - **Gemini API Key**: tạo tài khoản trên [Google AI Studio](https://aistudio.google.com/), chọn `Get API Key` và lấy key. +- **Gemini Models** *(tùy chọn)*: list model Gemini dùng để tóm tắt, phân tách bằng dấu phẩy. Ví dụ: `gemini-3.5-flash-lite,gemini-3.7-flash,gemini-3.6-flash`. + +> Bạn có thể check các model Gemini có sẵn tại [https://ai.google.dev/gemini-api/docs/models](https://ai.google.dev/gemini-api/docs/models) và thêm vào các model tương thích với nhu cầu của bạn. + B5. Run bot: ```zsh