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})
}
// 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)

View File

@ -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]

View File

@ -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")

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"`
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)
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
}