feat: add AI chat for repports
This commit is contained in:
@ -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)
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS report_messages;
|
||||
@ -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()
|
||||
);
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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<Report[]>('/reports'),
|
||||
create: (data: { summary_id?: string; excerpts: string[]; question: string }) =>
|
||||
api.post<Report>('/reports', data),
|
||||
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 }),
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<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() {
|
||||
const [reports, setReports] = useState<Report[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@ -101,7 +222,7 @@ export function Reports() {
|
||||
{/* Question */}
|
||||
<p className="text-sm font-medium">{r.question}</p>
|
||||
|
||||
{/* Réponse */}
|
||||
{/* Réponse initiale */}
|
||||
{r.status === 'generating' && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<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.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat de suivi (uniquement quand le rapport est terminé) */}
|
||||
{r.status === 'done' && <ReportChat report={r} />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@ -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"}
|
||||
Reference in New Issue
Block a user