diff --git a/backend/internal/ai/pipeline.go b/backend/internal/ai/pipeline.go index 716208e..769c763 100644 --- a/backend/internal/ai/pipeline.go +++ b/backend/internal/ai/pipeline.go @@ -296,6 +296,60 @@ func (p *Pipeline) callProviderForReport(ctx context.Context, excerpt, question return provider.Summarize(ctx, prompt, GenOptions{Think: true, NumCtx: 16384}) } +// GenerateReportMessageAsync génère une réponse de conversation en arrière-plan. +// history contient tous les messages précédents (user + assistant), dans l'ordre. +func (p *Pipeline) GenerateReportMessageAsync(messageID string, report *models.Report, history []models.ReportMessage, mgr *ReportManager) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + mgr.Register(messageID, cancel) + + go func() { + defer cancel() + defer mgr.Remove(messageID) + + answer, err := p.callProviderForConversation(ctx, report, history) + if err != nil { + if ctx.Err() != nil { + return + } + _ = p.repo.UpdateReportMessage(messageID, "error", err.Error()) + return + } + _ = p.repo.UpdateReportMessage(messageID, "done", answer) + }() +} + +func (p *Pipeline) callProviderForConversation(ctx context.Context, report *models.Report, history []models.ReportMessage) (string, error) { + provider, _, err := p.buildProviderForRole("report") + if err != nil { + return "", err + } + + var sb strings.Builder + sb.WriteString("Tu es un assistant financier expert engagé dans une conversation avec un trader.\n\n") + sb.WriteString("## Contexte initial\n") + sb.WriteString("Extraits sélectionnés :\n") + sb.WriteString(report.ContextExcerpt) + sb.WriteString("\n\nQuestion initiale : ") + sb.WriteString(report.Question) + sb.WriteString("\nRéponse initiale : ") + sb.WriteString(report.Answer) + sb.WriteString("\n\n## Suite de la conversation\n") + + for _, msg := range history { + if msg.Role == "user" { + sb.WriteString("Trader : ") + } else { + sb.WriteString("Assistant : ") + } + sb.WriteString(msg.Content) + sb.WriteString("\n") + } + + sb.WriteString("\nRéponds en français, de façon précise et orientée trading.") + + return provider.Summarize(ctx, sb.String(), GenOptions{Think: true, NumCtx: 16384}) +} + func buildPrompt(systemPrompt string, symbols []string, articles []models.Article, tz string) string { var sb strings.Builder sb.WriteString(systemPrompt) diff --git a/backend/internal/api/handlers/reports.go b/backend/internal/api/handlers/reports.go index 75378bd..62fc09d 100644 --- a/backend/internal/api/handlers/reports.go +++ b/backend/internal/api/handlers/reports.go @@ -69,6 +69,79 @@ func (h *Handler) GetGeneratingStatus(c *gin.Context) { httputil.OK(c, gin.H{"generating": h.pipeline.IsGenerating()}) } +func (h *Handler) ListReportMessages(c *gin.Context) { + userID := c.GetString("userID") + reportID := c.Param("id") + + report, err := h.repo.GetReport(reportID, userID) + if err != nil { + httputil.InternalError(c, err) + return + } + if report == nil { + c.Status(http.StatusNotFound) + return + } + + msgs, err := h.repo.ListReportMessages(reportID) + if err != nil { + httputil.InternalError(c, err) + return + } + httputil.OK(c, msgs) +} + +func (h *Handler) CreateReportMessage(c *gin.Context) { + userID := c.GetString("userID") + reportID := c.Param("id") + + var req struct { + Content string `json:"content" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + httputil.BadRequest(c, err) + return + } + + report, err := h.repo.GetReport(reportID, userID) + if err != nil { + httputil.InternalError(c, err) + return + } + if report == nil || report.Status != "done" { + c.Status(http.StatusNotFound) + return + } + + // Récupérer l'historique pour le contexte IA + history, err := h.repo.ListReportMessages(reportID) + if err != nil { + httputil.InternalError(c, err) + return + } + + // Persister le message utilisateur + userMsg, err := h.repo.CreateReportMessage(reportID, "user", req.Content, "done") + if err != nil { + httputil.InternalError(c, err) + return + } + + // Créer le message assistant en attente + assistantMsg, err := h.repo.CreateReportMessage(reportID, "assistant", "", "generating") + if err != nil { + httputil.InternalError(c, err) + return + } + + // Ajouter le message utilisateur à l'historique avant de lancer la génération + history = append(history, *userMsg) + + h.pipeline.GenerateReportMessageAsync(assistantMsg.ID, report, history, h.reportManager) + + c.JSON(http.StatusCreated, assistantMsg) +} + func buildExcerptContext(excerpts []string) string { if len(excerpts) == 1 { return excerpts[0] diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 41415a3..039a619 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -49,6 +49,8 @@ func SetupRouter(h *handlers.Handler, jwtSecret string) *gin.Engine { authed.GET("/reports", h.ListReports) authed.POST("/reports", h.CreateReport) authed.DELETE("/reports/:id", h.DeleteReport) + authed.GET("/reports/:id/messages", h.ListReportMessages) + authed.POST("/reports/:id/messages", h.CreateReportMessage) // Admin admin := authed.Group("/admin") diff --git a/backend/internal/database/migrations/000010_report_messages.down.sql b/backend/internal/database/migrations/000010_report_messages.down.sql new file mode 100644 index 0000000..783fbaa --- /dev/null +++ b/backend/internal/database/migrations/000010_report_messages.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS report_messages; diff --git a/backend/internal/database/migrations/000010_report_messages.up.sql b/backend/internal/database/migrations/000010_report_messages.up.sql new file mode 100644 index 0000000..5a1c65c --- /dev/null +++ b/backend/internal/database/migrations/000010_report_messages.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE report_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + report_id UUID NOT NULL REFERENCES reports(id) ON DELETE CASCADE, + role VARCHAR(10) NOT NULL CHECK (role IN ('user', 'assistant')), + content TEXT NOT NULL, + status VARCHAR(10) NOT NULL DEFAULT 'done' CHECK (status IN ('done', 'generating', 'error')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index 2f86af6..072abe3 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -116,3 +116,12 @@ type Report struct { ErrorMsg string `json:"error_msg"` CreatedAt time.Time `json:"created_at"` } + +type ReportMessage struct { + ID string `json:"id"` + ReportID string `json:"report_id"` + Role string `json:"role"` // user | assistant + Content string `json:"content"` + Status string `json:"status"` // done | generating | error + CreatedAt time.Time `json:"created_at"` +} diff --git a/backend/internal/models/repository.go b/backend/internal/models/repository.go index b35d860..f787bbd 100644 --- a/backend/internal/models/repository.go +++ b/backend/internal/models/repository.go @@ -666,3 +666,55 @@ func (r *Repository) DeleteReport(id, userID string) error { _, err := r.db.Exec(`DELETE FROM reports WHERE id=$1 AND user_id=$2`, id, userID) return err } + +func (r *Repository) GetReport(id, userID string) (*Report, error) { + rep := &Report{} + err := r.db.QueryRow(` + SELECT id, user_id, summary_id, context_excerpt, question, answer, status, error_msg, created_at + FROM reports WHERE id=$1 AND user_id=$2`, id, userID, + ).Scan(&rep.ID, &rep.UserID, &rep.SummaryID, &rep.ContextExcerpt, &rep.Question, &rep.Answer, &rep.Status, &rep.ErrorMsg, &rep.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + return rep, err +} + +// ── Report messages ──────────────────────────────────────────────────────── + +func (r *Repository) CreateReportMessage(reportID, role, content, status string) (*ReportMessage, error) { + msg := &ReportMessage{} + err := r.db.QueryRow(` + INSERT INTO report_messages (report_id, role, content, status) + VALUES ($1, $2, $3, $4) + RETURNING id, report_id, role, content, status, created_at`, + reportID, role, content, status, + ).Scan(&msg.ID, &msg.ReportID, &msg.Role, &msg.Content, &msg.Status, &msg.CreatedAt) + return msg, err +} + +func (r *Repository) UpdateReportMessage(id, status, content string) error { + _, err := r.db.Exec(` + UPDATE report_messages SET status=$1, content=$2 WHERE id=$3`, + status, content, id) + return err +} + +func (r *Repository) ListReportMessages(reportID string) ([]ReportMessage, error) { + rows, err := r.db.Query(` + SELECT id, report_id, role, content, status, created_at + FROM report_messages WHERE report_id=$1 + ORDER BY created_at ASC`, reportID) + if err != nil { + return nil, err + } + defer rows.Close() + var msgs []ReportMessage + for rows.Next() { + var m ReportMessage + if err := rows.Scan(&m.ID, &m.ReportID, &m.Role, &m.Content, &m.Status, &m.CreatedAt); err != nil { + return nil, err + } + msgs = append(msgs, m) + } + return msgs, nil +} diff --git a/frontend/src/api/reports.ts b/frontend/src/api/reports.ts index 382ad31..8f64db2 100644 --- a/frontend/src/api/reports.ts +++ b/frontend/src/api/reports.ts @@ -12,9 +12,21 @@ export interface Report { created_at: string } +export interface ReportMessage { + id: string + report_id: string + role: 'user' | 'assistant' + content: string + status: 'done' | 'generating' | 'error' + created_at: string +} + export const reportsApi = { list: () => api.get('/reports'), create: (data: { summary_id?: string; excerpts: string[]; question: string }) => api.post('/reports', data), delete: (id: string) => api.delete(`/reports/${id}`), + listMessages: (reportId: string) => api.get(`/reports/${reportId}/messages`), + createMessage: (reportId: string, content: string) => + api.post(`/reports/${reportId}/messages`, { content }), } diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx index 0e3d7fd..f33ed70 100644 --- a/frontend/src/pages/Reports.tsx +++ b/frontend/src/pages/Reports.tsx @@ -1,9 +1,10 @@ -import { useState, useEffect, useCallback } from 'react' -import { Trash2, FileText, Clock, Loader2, XCircle, AlertCircle } from 'lucide-react' -import { reportsApi, type Report } from '@/api/reports' +import { useState, useEffect, useCallback, useRef } from 'react' +import { Trash2, FileText, Clock, Loader2, XCircle, AlertCircle, Send } from 'lucide-react' +import { reportsApi, type Report, type ReportMessage } from '@/api/reports' import { Card, CardContent } from '@/components/ui/card' import { Markdown } from '@/components/ui/markdown' import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' import { Spinner } from '@/components/ui/spinner' import { Badge } from '@/components/ui/badge' @@ -21,6 +22,126 @@ function StatusBadge({ status }: { status: Report['status'] }) { return null } +function MessageBubble({ msg }: { msg: ReportMessage }) { + const isUser = msg.role === 'user' + return ( +
+
+ {msg.status === 'generating' ? ( +
+ + Génération… +
+ ) : msg.status === 'error' ? ( + {msg.content || 'Erreur lors de la génération.'} + ) : isUser ? ( +

{msg.content}

+ ) : ( + + )} +
+
+ ) +} + +function ReportChat({ report }: { report: Report }) { + const [messages, setMessages] = useState([]) + const [input, setInput] = useState('') + const [sending, setSending] = useState(false) + const bottomRef = useRef(null) + + const loadMessages = useCallback(async () => { + const msgs = await reportsApi.listMessages(report.id) + setMessages(msgs ?? []) + }, [report.id]) + + useEffect(() => { loadMessages() }, [loadMessages]) + + // Poll tant qu'un message assistant est en cours de génération + useEffect(() => { + const hasGenerating = messages.some(m => m.status === 'generating') + if (!hasGenerating) return + const interval = setInterval(loadMessages, 2000) + return () => clearInterval(interval) + }, [messages, loadMessages]) + + // Scroll vers le bas à chaque nouveau message + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages]) + + async function send() { + const content = input.trim() + if (!content || sending) return + setInput('') + setSending(true) + try { + // Optimistic: ajouter le message user immédiatement + const optimisticUser: ReportMessage = { + id: `tmp-${Date.now()}`, + report_id: report.id, + role: 'user', + content, + status: 'done', + created_at: new Date().toISOString(), + } + const optimisticAssistant: ReportMessage = { + id: `tmp-assistant-${Date.now()}`, + report_id: report.id, + role: 'assistant', + content: '', + status: 'generating', + created_at: new Date().toISOString(), + } + setMessages(prev => [...prev, optimisticUser, optimisticAssistant]) + await reportsApi.createMessage(report.id, content) + await loadMessages() + } finally { + setSending(false) + } + } + + const hasGenerating = messages.some(m => m.status === 'generating') + + return ( +
+

Conversation

+ + {messages.length > 0 && ( +
+ {messages.map(msg => ( + + ))} +
+
+ )} + +
+ setInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && !e.shiftKey && send()} + placeholder="Posez une question sur cette réponse…" + disabled={hasGenerating || sending} + className="text-sm" + /> + +
+
+ ) +} + export function Reports() { const [reports, setReports] = useState([]) const [loading, setLoading] = useState(true) @@ -101,7 +222,7 @@ export function Reports() { {/* Question */}

{r.question}

- {/* Réponse */} + {/* Réponse initiale */} {r.status === 'generating' && (
@@ -118,6 +239,9 @@ export function Reports() { Erreur : {r.error_msg || 'Une erreur est survenue lors de la génération.'}
)} + + {/* Chat de suivi (uniquement quand le rapport est terminé) */} + {r.status === 'done' && } ))} diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index e047b3a..a7bff5c 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/api/admin.ts","./src/api/articles.ts","./src/api/assets.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/summaries.ts","./src/components/layout/AppLayout.tsx","./src/components/layout/MobileNav.tsx","./src/components/layout/Sidebar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/spinner.tsx","./src/lib/auth.tsx","./src/lib/cn.ts","./src/lib/router.tsx","./src/pages/Dashboard.tsx","./src/pages/Feed.tsx","./src/pages/Login.tsx","./src/pages/Watchlist.tsx","./src/pages/admin/AIProviders.tsx","./src/pages/admin/AdminLayout.tsx","./src/pages/admin/AdminSettings.tsx","./src/pages/admin/AdminUsers.tsx","./src/pages/admin/Credentials.tsx","./src/pages/admin/Jobs.tsx","./src/pages/admin/Sources.tsx"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/api/admin.ts","./src/api/articles.ts","./src/api/assets.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/reports.ts","./src/api/summaries.ts","./src/components/layout/AppLayout.tsx","./src/components/layout/MobileNav.tsx","./src/components/layout/Sidebar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/markdown.tsx","./src/components/ui/select.tsx","./src/components/ui/spinner.tsx","./src/lib/auth.tsx","./src/lib/cn.ts","./src/lib/router.tsx","./src/pages/Dashboard.tsx","./src/pages/Feed.tsx","./src/pages/Login.tsx","./src/pages/Reports.tsx","./src/pages/Watchlist.tsx","./src/pages/admin/AIProviders.tsx","./src/pages/admin/AdminLayout.tsx","./src/pages/admin/AdminSettings.tsx","./src/pages/admin/AdminUsers.tsx","./src/pages/admin/Credentials.tsx","./src/pages/admin/Jobs.tsx","./src/pages/admin/Schedule.tsx","./src/pages/admin/Sources.tsx"],"version":"5.9.3"} \ No newline at end of file