feat: add AI chat for repports

This commit is contained in:
2026-04-28 07:19:49 +02:00
parent 087bcab16b
commit 490a364c00
10 changed files with 340 additions and 5 deletions

View File

@ -296,6 +296,60 @@ func (p *Pipeline) callProviderForReport(ctx context.Context, excerpt, question
return provider.Summarize(ctx, prompt, GenOptions{Think: true, NumCtx: 16384}) 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 { func buildPrompt(systemPrompt string, symbols []string, articles []models.Article, tz string) string {
var sb strings.Builder var sb strings.Builder
sb.WriteString(systemPrompt) sb.WriteString(systemPrompt)

View File

@ -69,6 +69,79 @@ func (h *Handler) GetGeneratingStatus(c *gin.Context) {
httputil.OK(c, gin.H{"generating": h.pipeline.IsGenerating()}) 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 { func buildExcerptContext(excerpts []string) string {
if len(excerpts) == 1 { if len(excerpts) == 1 {
return excerpts[0] return excerpts[0]

View File

@ -49,6 +49,8 @@ func SetupRouter(h *handlers.Handler, jwtSecret string) *gin.Engine {
authed.GET("/reports", h.ListReports) authed.GET("/reports", h.ListReports)
authed.POST("/reports", h.CreateReport) authed.POST("/reports", h.CreateReport)
authed.DELETE("/reports/:id", h.DeleteReport) authed.DELETE("/reports/:id", h.DeleteReport)
authed.GET("/reports/:id/messages", h.ListReportMessages)
authed.POST("/reports/:id/messages", h.CreateReportMessage)
// Admin // Admin
admin := authed.Group("/admin") admin := authed.Group("/admin")

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS report_messages;

View File

@ -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()
);

View File

@ -116,3 +116,12 @@ type Report struct {
ErrorMsg string `json:"error_msg"` ErrorMsg string `json:"error_msg"`
CreatedAt time.Time `json:"created_at"` 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"`
}

View File

@ -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) _, err := r.db.Exec(`DELETE FROM reports WHERE id=$1 AND user_id=$2`, id, userID)
return err 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
}

View File

@ -12,9 +12,21 @@ export interface Report {
created_at: string 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 = { export const reportsApi = {
list: () => api.get<Report[]>('/reports'), list: () => api.get<Report[]>('/reports'),
create: (data: { summary_id?: string; excerpts: string[]; question: string }) => create: (data: { summary_id?: string; excerpts: string[]; question: string }) =>
api.post<Report>('/reports', data), api.post<Report>('/reports', data),
delete: (id: string) => api.delete<void>(`/reports/${id}`), delete: (id: string) => api.delete<void>(`/reports/${id}`),
listMessages: (reportId: string) => api.get<ReportMessage[]>(`/reports/${reportId}/messages`),
createMessage: (reportId: string, content: string) =>
api.post<ReportMessage>(`/reports/${reportId}/messages`, { content }),
} }

View File

@ -1,9 +1,10 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { Trash2, FileText, Clock, Loader2, XCircle, AlertCircle } from 'lucide-react' import { Trash2, FileText, Clock, Loader2, XCircle, AlertCircle, Send } from 'lucide-react'
import { reportsApi, type Report } from '@/api/reports' import { reportsApi, type Report, type ReportMessage } from '@/api/reports'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Markdown } from '@/components/ui/markdown' import { Markdown } from '@/components/ui/markdown'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@ -21,6 +22,126 @@ function StatusBadge({ status }: { status: Report['status'] }) {
return null return null
} }
function MessageBubble({ msg }: { msg: ReportMessage }) {
const isUser = msg.role === 'user'
return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
isUser
? 'bg-primary text-primary-foreground'
: 'bg-muted'
}`}>
{msg.status === 'generating' ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="text-xs">Génération</span>
</div>
) : msg.status === 'error' ? (
<span className="text-destructive text-xs">{msg.content || 'Erreur lors de la génération.'}</span>
) : isUser ? (
<p>{msg.content}</p>
) : (
<Markdown content={msg.content} />
)}
</div>
</div>
)
}
function ReportChat({ report }: { report: Report }) {
const [messages, setMessages] = useState<ReportMessage[]>([])
const [input, setInput] = useState('')
const [sending, setSending] = useState(false)
const bottomRef = useRef<HTMLDivElement>(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 (
<div className="border-t pt-3 space-y-3">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Conversation</p>
{messages.length > 0 && (
<div className="space-y-2">
{messages.map(msg => (
<MessageBubble key={msg.id} msg={msg} />
))}
<div ref={bottomRef} />
</div>
)}
<div className="flex gap-2">
<Input
value={input}
onChange={e => 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"
/>
<Button
size="sm"
onClick={send}
disabled={!input.trim() || hasGenerating || sending}
className="shrink-0"
>
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
</div>
</div>
)
}
export function Reports() { export function Reports() {
const [reports, setReports] = useState<Report[]>([]) const [reports, setReports] = useState<Report[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -101,7 +222,7 @@ export function Reports() {
{/* Question */} {/* Question */}
<p className="text-sm font-medium">{r.question}</p> <p className="text-sm font-medium">{r.question}</p>
{/* Réponse */} {/* Réponse initiale */}
{r.status === 'generating' && ( {r.status === 'generating' && (
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin text-primary" /> <Loader2 className="h-4 w-4 animate-spin text-primary" />
@ -118,6 +239,9 @@ export function Reports() {
Erreur : {r.error_msg || 'Une erreur est survenue lors de la génération.'} Erreur : {r.error_msg || 'Une erreur est survenue lors de la génération.'}
</div> </div>
)} )}
{/* Chat de suivi (uniquement quand le rapport est terminé) */}
{r.status === 'done' && <ReportChat report={r} />}
</CardContent> </CardContent>
</Card> </Card>
))} ))}

View File

@ -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"} {"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"}