feat(ia): implement Smart Digest and fix UI alignment

This commit is contained in:
Michael committed 2026-02-09 10:06:13 +01:00
1 parent 7e802df2a7
commit 5b219cf0e7
10 files changed
+281 -21

No files matched your search

+2
View File
@@ -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
}
+70 -1
View File
@@ -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})
}
+39 -14
View File
@@ -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 == "" {
+107
View File
@@ -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
}