feat(link-summ): add SummarizeLink API and server integration; youtube oEmbed returns HTML only to avoid duplicate thumbs

This commit is contained in:
Thomas Cravey 2025-08-17 18:52:39 -05:00
parent 29d94c13d5
commit 575622b45c
3 changed files with 116 additions and 17 deletions

View file

@ -161,6 +161,90 @@ func (o *OpenAI) Summarize(ctx context.Context, channel string, msgs []store.Mes
return out, nil
}
func (o *OpenAI) SummarizeLink(ctx context.Context, rawURL string) (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)
content := ""
img := ""
if isImageURL(rawURL) {
img = rawURL
} else if o.followLinks {
ctx2, cancel := context.WithTimeout(ctx, o.linkTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx2, http.MethodGet, rawURL, nil)
if err == nil {
resp, err := http.DefaultClient.Do(req)
if err == nil {
func() {
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
lr := &io.LimitedReader{R: resp.Body, N: int64(o.linkMaxBytes)}
b, _ := io.ReadAll(lr)
text := string(b)
if base, perr := url.Parse(rawURL); perr == nil {
if art, err := readability.FromReader(strings.NewReader(text), base); err == nil {
if at := strings.TrimSpace(art.TextContent); at != "" {
text = at
}
}
}
text = strings.ReplaceAll(text, "\r", "")
text = strings.TrimSpace(text)
if len(text) > 6000 { text = text[:6000] }
content = text
}
}()
}
}
}
// Build link-specific prompt
sys := "You summarize the content at a single URL. Ignore surrounding chat context. Be concise and natural."
var userParts []openai.ChatMessagePart
b := strings.Builder{}
b.WriteString("URL: ")
b.WriteString(rawURL)
b.WriteString("\n\n")
if content != "" {
b.WriteString("Extracted content (may be truncated):\n")
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.")
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}})
}
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")
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.2 }
resp, err := client.CreateChatCompletion(ctx, req)
if err != nil { return "", err }
if len(resp.Choices) == 0 { return "", nil }
return strings.TrimSpace(resp.Choices[0].Message.Content), nil
}
func linksFromImages(imgs []string) []linkSnippet {
out := make([]linkSnippet, 0, len(imgs))
for _, u := range imgs {

View file

@ -9,6 +9,7 @@ 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)
}