From 5b219cf0e74a99dd435d30ff12d944e09a15cab4 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Mon, 9 Feb 2026 10:06:13 +0100 Subject: [PATCH] feat(ia): implement Smart Digest and fix UI alignment --- cmd/server/main.go | 4 +- internal/domain/article.go | 2 + internal/handler/article.go | 71 +++++++++++++++++- internal/repository/article.go | 53 +++++++++---- internal/service/ai.go | 107 +++++++++++++++++++++++++++ migrations/006_add_ai_summary.up.sql | 4 + web/src/api/articles.ts | 9 +++ web/src/components/ArticleCard.tsx | 46 +++++++++++- web/src/components/Sidebar.tsx | 4 +- web/src/components/ThemeToggle.tsx | 2 +- 10 files changed, 281 insertions(+), 21 deletions(-) create mode 100644 internal/service/ai.go create mode 100644 migrations/006_add_ai_summary.up.sql diff --git a/cmd/server/main.go b/cmd/server/main.go index 9249c2d..f36a87f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -46,6 +46,7 @@ func main() { // Initialize services authService := service.NewAuthService(userRepo, sessionRepo) feedService := service.NewFeedService(feedRepo) + aiService := service.NewAIService() // Initialize WS Hub hub := ws.NewHub() @@ -56,7 +57,7 @@ func main() { // Initialize handlers authHandler := handler.NewAuthHandler(authService) feedHandler := handler.NewFeedHandler(feedService, fetchService, authService) - articleHandler := handler.NewArticleHandler(articleRepo, feedService, authService, hub) + articleHandler := handler.NewArticleHandler(articleRepo, feedService, authService, aiService, hub) wsHandler := handler.NewWSHandler(hub, authService) adminHandler := handler.NewAdminHandler(userRepo, authService) @@ -129,6 +130,7 @@ func main() { r.Post("/{id}/read", articleHandler.MarkRead) r.Delete("/{id}/read", articleHandler.MarkUnread) r.Post("/{id}/favorite", articleHandler.ToggleFavorite) + r.Post("/{id}/summarize", articleHandler.Summarize) }) // WebSocket route diff --git a/internal/domain/article.go b/internal/domain/article.go index 348eb5b..d59cb46 100644 --- a/internal/domain/article.go +++ b/internal/domain/article.go @@ -15,6 +15,7 @@ type Article struct { URL string `json:"url,omitempty"` Content string `json:"content,omitempty"` Summary string `json:"summary,omitempty"` + AISummary string `json:"ai_summary,omitempty"` Author string `json:"author,omitempty"` ImageURL string `json:"image_url,omitempty"` PublishedAt *time.Time `json:"published_at,omitempty"` @@ -43,4 +44,5 @@ type ArticleRepository interface { GetFavorites(userID uuid.UUID, limit, offset int) ([]*Article, error) CountUnread(feedID uuid.UUID) (int, error) Search(userID uuid.UUID, query string, limit, offset int) ([]*Article, error) + UpdateAISummary(id uuid.UUID, summary string) error } diff --git a/internal/handler/article.go b/internal/handler/article.go index c9edce9..b3211c9 100644 --- a/internal/handler/article.go +++ b/internal/handler/article.go @@ -1,6 +1,7 @@ package handler import ( + "fmt" "net/http" "strconv" @@ -17,16 +18,18 @@ type ArticleHandler struct { articleRepo domain.ArticleRepository feedService *service.FeedService authService *service.AuthService + aiService *service.AIService sanitizer *utils.ContentSanitizer hub *ws.Hub } // NewArticleHandler creates a new article handler. -func NewArticleHandler(articleRepo domain.ArticleRepository, feedService *service.FeedService, authService *service.AuthService, hub *ws.Hub) *ArticleHandler { +func NewArticleHandler(articleRepo domain.ArticleRepository, feedService *service.FeedService, authService *service.AuthService, aiService *service.AIService, hub *ws.Hub) *ArticleHandler { return &ArticleHandler{ articleRepo: articleRepo, feedService: feedService, authService: authService, + aiService: aiService, sanitizer: utils.NewContentSanitizer(), hub: hub, } @@ -388,3 +391,69 @@ func (h *ArticleHandler) Search(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, articles) } + +// Summarize handles POST /api/v1/articles/{id}/summarize +func (h *ArticleHandler) Summarize(w http.ResponseWriter, r *http.Request) { + userID, err := h.getUserFromRequest(r) + if err != nil { + respondError(w, http.StatusUnauthorized, "Not authenticated") + return + } + + articleID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + respondError(w, http.StatusBadRequest, "Invalid article ID") + return + } + + article, err := h.articleRepo.GetByID(articleID) + if err != nil || article == nil { + respondError(w, http.StatusNotFound, "Article not found") + return + } + + // Verify feed ownership + _, err = h.feedService.GetFeed(article.FeedID, userID) + if err != nil { + respondError(w, http.StatusForbidden, "Access denied") + return + } + + // If already summarized, return it + if article.AISummary != "" { + respondJSON(w, http.StatusOK, map[string]string{"summary": article.AISummary}) + return + } + + // Context for AI is title + content (or summary if content empty) + content := article.Content + if content == "" { + content = article.Summary + } + + aiInput := fmt.Sprintf("Titre: %s\n\nContenu: %s", article.Title, content) + + // Summary generation (can be slow, but for this demo/small app we do it synchronously + // or we could use WS to notify when done. Here we follow the simple POST -> String pattern). + summary, err := h.aiService.Summarize(r.Context(), aiInput) + if err != nil { + respondError(w, http.StatusInternalServerError, "Failed to generate summary: "+err.Error()) + return + } + + // Save to DB + if err := h.articleRepo.UpdateAISummary(articleID, summary); err != nil { + respondError(w, http.StatusInternalServerError, "Failed to persist summary") + return + } + + // Broadcast update via WebSocket + if h.hub != nil { + h.hub.Broadcast("article_updated", map[string]interface{}{ + "id": articleID, + "ai_summary": summary, + }) + } + + respondJSON(w, http.StatusOK, map[string]string{"summary": summary}) +} diff --git a/internal/repository/article.go b/internal/repository/article.go index f92e168..42da6c8 100644 --- a/internal/repository/article.go +++ b/internal/repository/article.go @@ -27,8 +27,8 @@ func (r *ArticleRepository) Create(article *domain.Article) error { ctx := context.Background() query := ` - INSERT INTO articles (id, feed_id, guid, title, url, content, summary, author, image_url, published_at, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + INSERT INTO articles (id, feed_id, guid, title, url, content, summary, ai_summary, author, image_url, published_at, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (feed_id, guid) DO NOTHING ` @@ -40,6 +40,7 @@ func (r *ArticleRepository) Create(article *domain.Article) error { nullString(article.URL), nullString(article.Content), nullString(article.Summary), + nullString(article.AISummary), nullString(article.Author), nullString(article.ImageURL), article.PublishedAt, @@ -59,8 +60,8 @@ func (r *ArticleRepository) CreateBatch(articles []*domain.Article) error { batch := &pgx.Batch{} query := ` - INSERT INTO articles (id, feed_id, guid, title, url, content, summary, author, image_url, published_at, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + INSERT INTO articles (id, feed_id, guid, title, url, content, summary, ai_summary, author, image_url, published_at, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (feed_id, guid) DO NOTHING ` @@ -73,6 +74,7 @@ func (r *ArticleRepository) CreateBatch(articles []*domain.Article) error { nullString(article.URL), nullString(article.Content), nullString(article.Summary), + nullString(article.AISummary), nullString(article.Author), nullString(article.ImageURL), article.PublishedAt, @@ -97,7 +99,7 @@ func (r *ArticleRepository) GetByID(id uuid.UUID) (*domain.Article, error) { ctx := context.Background() query := ` - SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author, + SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_summary, a.author, a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at, f.title as feed_title FROM articles a @@ -121,7 +123,7 @@ func (r *ArticleRepository) GetByFeedID(feedID uuid.UUID, limit, offset int) ([] ctx := context.Background() query := ` - SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author, + SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_summary, a.author, a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at, f.title as feed_title FROM articles a @@ -147,7 +149,7 @@ func (r *ArticleRepository) GetByUserID(userID uuid.UUID, limit, offset int, unr var query string if unreadOnly { query = ` - SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author, + SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_summary, a.author, a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at, f.title as feed_title FROM articles a @@ -158,7 +160,7 @@ func (r *ArticleRepository) GetByUserID(userID uuid.UUID, limit, offset int, unr ` } else { query = ` - SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author, + SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_summary, a.author, a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at, f.title as feed_title FROM articles a @@ -183,7 +185,7 @@ func (r *ArticleRepository) GetByGUID(feedID uuid.UUID, guid string) (*domain.Ar ctx := context.Background() query := ` - SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author, + SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_summary, a.author, a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at, f.title as feed_title FROM articles a @@ -269,7 +271,7 @@ func (r *ArticleRepository) GetFavorites(userID uuid.UUID, limit, offset int) ([ ctx := context.Background() query := ` - SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author, + SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_summary, a.author, a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at, f.title as feed_title FROM articles a @@ -305,7 +307,7 @@ func (r *ArticleRepository) CountUnread(feedID uuid.UUID) (int, error) { // scanArticle scans a single article row. func (r *ArticleRepository) scanArticle(row pgx.Row) (*domain.Article, error) { var article domain.Article - var url, content, summary, author, imageURL, feedTitle *string + var url, content, summary, aiSummary, author, imageURL, feedTitle *string var publishedAt, readAt *time.Time err := row.Scan( @@ -316,6 +318,7 @@ func (r *ArticleRepository) scanArticle(row pgx.Row) (*domain.Article, error) { &url, &content, &summary, + &aiSummary, &author, &imageURL, &publishedAt, @@ -339,6 +342,9 @@ func (r *ArticleRepository) scanArticle(row pgx.Row) (*domain.Article, error) { if summary != nil { article.Summary = *summary } + if aiSummary != nil { + article.AISummary = *aiSummary + } if author != nil { article.Author = *author } @@ -359,7 +365,7 @@ func (r *ArticleRepository) scanArticles(rows pgx.Rows) ([]*domain.Article, erro var articles []*domain.Article for rows.Next() { var article domain.Article - var url, content, summary, author, imageURL, feedTitle *string + var url, content, summary, aiSummary, author, imageURL, feedTitle *string var publishedAt, readAt *time.Time err := rows.Scan( @@ -370,6 +376,7 @@ func (r *ArticleRepository) scanArticles(rows pgx.Rows) ([]*domain.Article, erro &url, &content, &summary, + &aiSummary, &author, &imageURL, &publishedAt, @@ -393,6 +400,9 @@ func (r *ArticleRepository) scanArticles(rows pgx.Rows) ([]*domain.Article, erro if summary != nil { article.Summary = *summary } + if aiSummary != nil { + article.AISummary = *aiSummary + } if author != nil { article.Author = *author } @@ -417,7 +427,7 @@ func (r *ArticleRepository) Search(userID uuid.UUID, query string, limit, offset // Use plainto_tsquery or websearch_to_tsquery for natural language search sql := ` - SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author, + SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_summary, a.author, a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at, f.title as feed_title, ts_rank_cd(a.tsv, websearch_to_tsquery('french', $2)) as rank @@ -442,7 +452,7 @@ func (r *ArticleRepository) scanArticlesWithRank(rows pgx.Rows) ([]*domain.Artic var articles []*domain.Article for rows.Next() { var article domain.Article - var url, content, summary, author, imageURL, feedTitle *string + var url, content, summary, aiSummary, author, imageURL, feedTitle *string var publishedAt, readAt *time.Time var rank float32 @@ -454,6 +464,7 @@ func (r *ArticleRepository) scanArticlesWithRank(rows pgx.Rows) ([]*domain.Artic &url, &content, &summary, + &aiSummary, &author, &imageURL, &publishedAt, @@ -478,6 +489,9 @@ func (r *ArticleRepository) scanArticlesWithRank(rows pgx.Rows) ([]*domain.Artic if summary != nil { article.Summary = *summary } + if aiSummary != nil { + article.AISummary = *aiSummary + } if author != nil { article.Author = *author } @@ -496,6 +510,17 @@ func (r *ArticleRepository) scanArticlesWithRank(rows pgx.Rows) ([]*domain.Artic return articles, nil } +// UpdateAISummary updates the AI-generated summary of an article. +func (r *ArticleRepository) UpdateAISummary(id uuid.UUID, summary string) error { + ctx := context.Background() + query := `UPDATE articles SET ai_summary = $2 WHERE id = $1` + _, err := r.pool.Exec(ctx, query, id, summary) + if err != nil { + return fmt.Errorf("updating AI summary: %w", err) + } + return nil +} + // nullString returns nil if string is empty. func nullString(s string) *string { if s == "" { diff --git a/internal/service/ai.go b/internal/service/ai.go new file mode 100644 index 0000000..b001ef2 --- /dev/null +++ b/internal/service/ai.go @@ -0,0 +1,107 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" +) + +// AIService handles interactions with AI providers (OpenRouter). +type AIService struct { + apiKey string + client *http.Client +} + +// NewAIService creates a new AI service. +func NewAIService() *AIService { + return &AIService{ + apiKey: os.Getenv("OPENROUTER_API_KEY"), + client: &http.Client{}, + } +} + +// OpenRouterRequest represents the request body for OpenRouter. +type OpenRouterRequest struct { + Model string `json:"model"` + Messages []Message `json:"messages"` +} + +// Message represents a message in the conversation. +type Message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// OpenRouterResponse represents the response body from OpenRouter. +type OpenRouterResponse struct { + Choices []struct { + Message Message `json:"message"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} + +// Summarize generates a concise summary of the given content. +func (s *AIService) Summarize(ctx context.Context, content string) (string, error) { + if s.apiKey == "" { + return "", fmt.Errorf("OPENROUTER_API_KEY not set") + } + + prompt := fmt.Sprintf("Résume l'article suivant en 3 à 5 phrases percutantes. Sois direct et informatif :\n\n%s", content) + + reqBody := OpenRouterRequest{ + Model: "google/gemini-2.0-flash-001", // Économique et performant + Messages: []Message{ + {Role: "user", Content: prompt}, + }, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("marshaling request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", "https://openrouter.ai/api/v1/chat/completions", bytes.NewBuffer(jsonData)) + if err != nil { + return "", fmt.Errorf("creating request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+s.apiKey) + req.Header.Set("HTTP-Referer", "https://github.com/michael/flowreader") // Optionnel pour OpenRouter + + resp, err := s.client.Do(req) + if err != nil { + return "", fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + var orResp OpenRouterResponse + if err := json.Unmarshal(body, &orResp); err != nil { + return "", fmt.Errorf("unmarshaling response: %w", err) + } + + if orResp.Error != nil { + return "", fmt.Errorf("OpenRouter error: %s", orResp.Error.Message) + } + + if len(orResp.Choices) == 0 { + return "", fmt.Errorf("no summary generated") + } + + return orResp.Choices[0].Message.Content, nil +} diff --git a/migrations/006_add_ai_summary.up.sql b/migrations/006_add_ai_summary.up.sql new file mode 100644 index 0000000..e3ecb3d --- /dev/null +++ b/migrations/006_add_ai_summary.up.sql @@ -0,0 +1,4 @@ +-- Migration: 006_add_ai_summary +-- Description: Add ai_summary column to articles table + +ALTER TABLE articles ADD COLUMN IF NOT EXISTS ai_summary TEXT; diff --git a/web/src/api/articles.ts b/web/src/api/articles.ts index 4ea3e64..375f548 100644 --- a/web/src/api/articles.ts +++ b/web/src/api/articles.ts @@ -17,6 +17,7 @@ export interface Article { url?: string; content?: string; summary?: string; + ai_summary?: string; author?: string; image_url?: string; published_at?: string; @@ -118,4 +119,12 @@ export const articlesApi = { }); return handleResponse(response); }, + + async summarize(id: string): Promise<{ summary: string }> { + const response = await fetch(`${API_BASE}/articles/${id}/summarize`, { + method: 'POST', + credentials: 'include', + }); + return handleResponse(response); + }, }; diff --git a/web/src/components/ArticleCard.tsx b/web/src/components/ArticleCard.tsx index 09d2c12..8b6869c 100644 --- a/web/src/components/ArticleCard.tsx +++ b/web/src/components/ArticleCard.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useSwipeable } from 'react-swipeable'; -import type { Article } from '../api/articles'; +import { type Article, articlesApi } from '../api/articles'; interface ArticleCardProps { article: Article; @@ -12,6 +12,7 @@ interface ArticleCardProps { export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }: ArticleCardProps) { const [swipeOffset, setSwipeOffset] = useState(0); const [isSwiping, setIsSwiping] = useState(false); + const [isSummarizing, setIsSummarizing] = useState(false); const handlers = useSwipeable({ onSwiping: (event) => { @@ -45,6 +46,22 @@ export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite } setIsSwiping(false); }; + const handleSummarize = async (e: React.MouseEvent) => { + e.stopPropagation(); + if (article.ai_summary || isSummarizing) return; + + setIsSummarizing(true); + try { + await articlesApi.summarize(article.id); + // The article will be updated via WebSocket broadcast + } catch (error) { + console.error('Failed to summarize:', error); + alert('Erreur lors de la génération du résumé. Vérifiez votre clé API OpenRouter.'); + } finally { + setIsSummarizing(false); + } + }; + return (
{/* Action Layers behind the card */} @@ -132,7 +149,14 @@ export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite } {article.title} - {article.summary && ( + {article.ai_summary ? ( +
+ ✨ +

+ {article.ai_summary} +

+
+ ) : article.summary && (

{article.summary.replace(/<[^>]*>?/gm, '').substring(0, 160)}...

@@ -164,6 +188,24 @@ export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite } +
diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index f05c14d..d7e53de 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -80,10 +80,10 @@ export function Sidebar({ onSelectFeed, selectedFeedId }: SidebarProps) { diff --git a/web/src/components/ThemeToggle.tsx b/web/src/components/ThemeToggle.tsx index a3215c9..cffefd6 100644 --- a/web/src/components/ThemeToggle.tsx +++ b/web/src/components/ThemeToggle.tsx @@ -20,7 +20,7 @@ export function ThemeToggle() { return (