feat(summarizer): add SummarizeForPush and use for Pushover; keep full WebUI/on-demand output; clamp only on push\ndocs: add AGENTS.md; revert CLAUDE.md release section

This commit is contained in:
Thomas Cravey 2025-09-05 06:58:38 -05:00
parent 2f9ab6a414
commit 8dc52976eb
7 changed files with 512 additions and 21 deletions

View file

@ -156,7 +156,14 @@ func (s *Server) handleTrigger(w http.ResponseWriter, r *http.Request) {
}
ctxSum, cancel := context.WithTimeout(ctx, tout)
defer cancel()
summary, err := s.Summarizer.Summarize(ctxSum, channel, msgs, window)
var summary string
var err error
// If we will push, use a push-optimized prompt; otherwise allow full-length
if strings.EqualFold(r.URL.Query().Get("push"), "1") || strings.EqualFold(r.URL.Query().Get("push"), "true") {
summary, err = s.Summarizer.SummarizeForPush(ctxSum, channel, msgs, window)
} else {
summary, err = s.Summarizer.Summarize(ctxSum, channel, msgs, window)
}
if err != nil {
if s.Logger != nil {
s.Logger.Error("http trigger summarizer", "err", err)
@ -167,13 +174,18 @@ func (s *Server) handleTrigger(w http.ResponseWriter, r *http.Request) {
}
// Only push if explicitly requested
pushFlag := strings.EqualFold(r.URL.Query().Get("push"), "1") || strings.EqualFold(r.URL.Query().Get("push"), "true")
if pushFlag && s.Notifier != nil {
title := fmt.Sprintf("IRC digest %s (%s)", channel, window)
_ = s.Notifier.Notify(ctx, title, summary)
if s.Metrics != nil {
atomic.AddInt64(&s.Metrics.NotificationsSent, 1)
}
}
if pushFlag && s.Notifier != nil {
title := fmt.Sprintf("IRC digest %s (%s)", channel, window)
// Trim only for push notifications (Pushover limit ~1024)
msg := strings.TrimSpace(summary)
if len(msg) > 1000 {
msg = msg[:1000] + "…"
}
_ = s.Notifier.Notify(ctx, title, msg)
if s.Metrics != nil {
atomic.AddInt64(&s.Metrics.NotificationsSent, 1)
}
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte(summary))
}
@ -720,13 +732,16 @@ func (s *Server) handleTriggerJSON(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("summarizer error"))
return
}
if push && s.Notifier != nil {
title := fmt.Sprintf("IRC digest %s (%s)", channel, dur)
_ = s.Notifier.Notify(r.Context(), title, sum)
if s.Metrics != nil {
atomic.AddInt64(&s.Metrics.NotificationsSent, 1)
}
}
if push && s.Notifier != nil {
title := fmt.Sprintf("IRC digest %s (%s)", channel, dur)
// Safety clamp for provider limits
msg := strings.TrimSpace(sum)
if len(msg) > 1024 { msg = msg[:1024] }
_ = s.Notifier.Notify(r.Context(), title, msg)
if s.Metrics != nil {
atomic.AddInt64(&s.Metrics.NotificationsSent, 1)
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"summary": sum})
}

View file

@ -111,7 +111,8 @@ func (o *OpenAI) Summarize(ctx context.Context, channel string, msgs []store.Mes
b.WriteString("- Focus on what happened and why it matters.\n")
b.WriteString("- Integrate linked content and pasted multi-line posts naturally.\n")
b.WriteString("- Avoid rigid sections; use short paragraphs or light bullets if helpful.\n")
b.WriteString("- Keep it compact but dont omit important context.\n")
b.WriteString("- Keep it compact but dont omit important context.\n")
prompt := b.String()
sys := "You summarize IRC transcripts. Be concise, natural, and informative."
@ -162,6 +163,95 @@ func (o *OpenAI) Summarize(ctx context.Context, channel string, msgs []store.Mes
return out, nil
}
// SummarizeForPush produces a digest tailored for push notifications (e.g., Pushover ~1024 chars).
// It uses a slightly more constrained prompt to encourage succinct output.
func (o *OpenAI) SummarizeForPush(ctx context.Context, channel string, msgs []store.Message, window time.Duration) (string, error) {
if o == nil || o.apiKey == "" {
return "", nil
}
cfg := openai.DefaultConfig(o.apiKey)
if strings.TrimSpace(o.baseURL) != "" {
cfg.BaseURL = o.baseURL
}
client := openai.NewClientWithConfig(cfg)
grouped := groupMessages(msgs, o.groupWindow)
if o.maxGroups > 0 && len(grouped) > o.maxGroups {
grouped = grouped[len(grouped)-o.maxGroups:]
}
links := extractLinks(grouped)
var imageURLs []string
var nonImageLinks []linkSnippet
for _, l := range links {
if isImageURL(l.url) { imageURLs = append(imageURLs, l.url) } else { nonImageLinks = append(nonImageLinks, l) }
}
if o.followLinks && len(nonImageLinks) > 0 {
nonImageLinks = fetchLinkSnippets(ctx, nonImageLinks, o.linkTimeout, o.linkMaxBytes, o.maxLinks)
}
var b strings.Builder
b.WriteString("Channel: ")
b.WriteString(channel)
b.WriteString("\nTime window: ")
b.WriteString(window.String())
b.WriteString("\n\nTranscript (grouped by author):\n")
for _, g := range grouped {
b.WriteString(g.time.Format(time.RFC3339))
b.WriteString(" ")
b.WriteString(g.author)
b.WriteString(": ")
b.WriteString(g.text)
b.WriteString("\n")
}
if len(nonImageLinks) > 0 {
b.WriteString("\nReferenced content (snippets):\n")
for _, ln := range nonImageLinks {
b.WriteString("- ")
b.WriteString(ln.url)
b.WriteString(" → ")
b.WriteString(ln.snippet)
b.WriteString("\n")
}
}
b.WriteString("\nWrite a concise, readable summary of the conversation above.\n")
b.WriteString("- Focus on what happened and why it matters.\n")
b.WriteString("- Integrate linked content and pasted multi-line posts naturally.\n")
b.WriteString("- Avoid rigid sections; use short paragraphs or light bullets if helpful.\n")
b.WriteString("- Keep it compact but dont omit important context.\n")
b.WriteString("- Keep the final output under ~900 characters suitable for a single push notification.\n")
prompt := b.String()
sys := "You summarize IRC transcripts for a push notification. Be concise, natural, and informative."
model := o.model
if strings.TrimSpace(model) == "" { model = "gpt-4o-mini" }
reasoningLike := strings.HasPrefix(model, "gpt-5") || strings.HasPrefix(model, "o1") || strings.Contains(model, "reasoning")
var userParts []openai.ChatMessagePart
userParts = append(userParts, openai.ChatMessagePart{Type: openai.ChatMessagePartTypeText, Text: prompt})
for _, u := range imageURLs {
userParts = append(userParts, openai.ChatMessagePart{Type: openai.ChatMessagePartTypeImageURL, ImageURL: &openai.ChatMessageImageURL{URL: u}})
}
req := openai.ChatCompletionRequest{
Model: model,
Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleSystem, Content: sys},
{Role: openai.ChatMessageRoleUser, MultiContent: userParts},
},
MaxCompletionTokens: o.maxTokens,
}
if !reasoningLike { req.Temperature = 0.3 }
resp, err := client.CreateChatCompletion(ctx, req)
if err != nil { return "", err }
if len(resp.Choices) == 0 { return localFallbackSummary(grouped, append(nonImageLinks, linksFromImages(imageURLs)...)), nil }
out := strings.TrimSpace(resp.Choices[0].Message.Content)
if out == "" { return localFallbackSummary(grouped, append(nonImageLinks, linksFromImages(imageURLs)...)), nil }
return out, nil
}
func (o *OpenAI) SummarizeLink(ctx context.Context, rawURL string) (string, error) {
if o == nil || o.apiKey == "" {
return "", nil
@ -274,7 +364,7 @@ func (o *OpenAI) SummarizeLink(ctx context.Context, rawURL string) (string, erro
b.WriteString(content)
b.WriteString("\n\n")
}
b.WriteString("Write a short, skimmable summary of the page/video/image above. If relevant, include key takeaways and any notable cautions. Keep it under a few short paragraphs.")
b.WriteString("Write a short, skimmable summary of the page/video/image above. If relevant, include key takeaways and any notable cautions. Keep it under a few short paragraphs.")
userParts = append(userParts, openai.ChatMessagePart{Type: openai.ChatMessagePartTypeText, Text: b.String()})
if img != "" {
userParts = append(userParts, openai.ChatMessagePart{Type: openai.ChatMessagePartTypeImageURL, ImageURL: &openai.ChatMessageImageURL{URL: img}})

View file

@ -8,6 +8,9 @@ import (
)
type Summarizer interface {
Summarize(ctx context.Context, channel string, msgs []store.Message, window time.Duration) (string, error)
SummarizeLink(ctx context.Context, rawURL string) (string, error)
Summarize(ctx context.Context, channel string, msgs []store.Message, window time.Duration) (string, error)
// SummarizeForPush creates a digest tuned for push notifications (e.g., Pushover limits).
// Implementations should keep the output succinct and within ~1k characters.
SummarizeForPush(ctx context.Context, channel string, msgs []store.Message, window time.Duration) (string, error)
SummarizeLink(ctx context.Context, rawURL string) (string, error)
}