feat: implement chat and conversation history management with database schema and API endpoints
All checks were successful
Build and Release / release (push) Successful in 1m30s
All checks were successful
Build and Release / release (push) Successful in 1m30s
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"history-api/internal/dtos/request"
|
||||
"history-api/internal/dtos/response"
|
||||
"history-api/internal/models"
|
||||
"history-api/internal/services"
|
||||
"history-api/pkg/validator"
|
||||
"time"
|
||||
@@ -67,3 +68,51 @@ func (cx *ChatbotController) Chat(c fiber.Ctx) error {
|
||||
Data: answer,
|
||||
})
|
||||
}
|
||||
|
||||
// GetHistory godoc
|
||||
// @Summary Get chatbot history
|
||||
// @Description Get chatbot history for the current user
|
||||
// @Tags Chatbot
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param cursor query string false "Cursor ID for pagination"
|
||||
// @Param limit query int false "Limit number of items returned, default 10"
|
||||
// @Success 200 {object} response.CommonResponse{data=response.GetChatbotHistoryResponse} "Successful response"
|
||||
// @Failure 400 {object} response.CommonResponse "Invalid request"
|
||||
// @Failure 500 {object} response.CommonResponse "Internal server error"
|
||||
// @Router /chatbot/history [get]
|
||||
func (cx *ChatbotController) GetHistory(c fiber.Ctx) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
dto := &request.GetChatbotHistoryDto{}
|
||||
if err := validator.ValidateQueryDto(c, dto); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{
|
||||
Status: false,
|
||||
Errors: err,
|
||||
})
|
||||
}
|
||||
|
||||
uid := c.Locals("uid").(string)
|
||||
histories, err := cx.chatbotService.GetHistory(ctx, uid, dto)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{
|
||||
Status: false,
|
||||
Message: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
var preCursor string
|
||||
if len(histories) > 0 {
|
||||
preCursor = histories[0].ID
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(response.CommonResponse{
|
||||
Status: true,
|
||||
Data: response.GetChatbotHistoryResponse{
|
||||
Items: models.ChatbotHistoryEntitiesToResponse(histories),
|
||||
PreCursor: preCursor,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,3 +4,8 @@ type ChatbotDto struct {
|
||||
ProjectID *string `json:"project_id"`
|
||||
Question string `json:"question" validate:"required"`
|
||||
}
|
||||
|
||||
type GetChatbotHistoryDto struct {
|
||||
Cursor *string `json:"cursor" query:"cursor" validate:"omitempty,uuid"`
|
||||
Limit int `json:"limit" query:"limit" validate:"omitempty,min=1,max=100"`
|
||||
}
|
||||
|
||||
18
internal/dtos/response/chatbot.go
Normal file
18
internal/dtos/response/chatbot.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type ChatbotHistoryDto struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Question string `json:"question"`
|
||||
Answer string `json:"answer"`
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
type GetChatbotHistoryResponse struct {
|
||||
Items []*ChatbotHistoryDto `json:"items"`
|
||||
PreCursor string `json:"pre_cursor,omitempty"`
|
||||
}
|
||||
297
internal/gen/sqlc/chat.sql.go
Normal file
297
internal/gen/sqlc/chat.sql.go
Normal file
@@ -0,0 +1,297 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: chat.sql
|
||||
|
||||
package sqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createChatbotHistory = `-- name: CreateChatbotHistory :one
|
||||
INSERT INTO chatbot_histories (user_id, question, answer)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, user_id, question, answer, created_at
|
||||
`
|
||||
|
||||
type CreateChatbotHistoryParams struct {
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
Question string `json:"question"`
|
||||
Answer string `json:"answer"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateChatbotHistory(ctx context.Context, arg CreateChatbotHistoryParams) (ChatbotHistory, error) {
|
||||
row := q.db.QueryRow(ctx, createChatbotHistory, arg.UserID, arg.Question, arg.Answer)
|
||||
var i ChatbotHistory
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Question,
|
||||
&i.Answer,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createConversation = `-- name: CreateConversation :one
|
||||
INSERT INTO conversations (user_id, status)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, user_id, mod_id, status, closed_at, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateConversationParams struct {
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
Status int16 `json:"status"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateConversation(ctx context.Context, arg CreateConversationParams) (Conversation, error) {
|
||||
row := q.db.QueryRow(ctx, createConversation, arg.UserID, arg.Status)
|
||||
var i Conversation
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.ModID,
|
||||
&i.Status,
|
||||
&i.ClosedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createMessage = `-- name: CreateMessage :one
|
||||
INSERT INTO messages (conversation_id, sender_id, content)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, conversation_id, sender_id, content, created_at
|
||||
`
|
||||
|
||||
type CreateMessageParams struct {
|
||||
ConversationID pgtype.UUID `json:"conversation_id"`
|
||||
SenderID pgtype.UUID `json:"sender_id"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) {
|
||||
row := q.db.QueryRow(ctx, createMessage, arg.ConversationID, arg.SenderID, arg.Content)
|
||||
var i Message
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ConversationID,
|
||||
&i.SenderID,
|
||||
&i.Content,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getChatbotHistoriesByIDs = `-- name: GetChatbotHistoriesByIDs :many
|
||||
SELECT id, user_id, question, answer, created_at FROM chatbot_histories WHERE id = ANY($1::uuid[])
|
||||
`
|
||||
|
||||
func (q *Queries) GetChatbotHistoriesByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]ChatbotHistory, error) {
|
||||
rows, err := q.db.Query(ctx, getChatbotHistoriesByIDs, dollar_1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ChatbotHistory{}
|
||||
for rows.Next() {
|
||||
var i ChatbotHistory
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Question,
|
||||
&i.Answer,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getChatbotHistory = `-- name: GetChatbotHistory :many
|
||||
SELECT id, user_id, question, answer, created_at FROM (
|
||||
SELECT id, user_id, question, answer, created_at FROM chatbot_histories
|
||||
WHERE user_id = $1
|
||||
AND ($2::uuid IS NULL OR id < $2::uuid)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3
|
||||
) sub
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
||||
type GetChatbotHistoryParams struct {
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
CursorID pgtype.UUID `json:"cursor_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetChatbotHistory(ctx context.Context, arg GetChatbotHistoryParams) ([]ChatbotHistory, error) {
|
||||
rows, err := q.db.Query(ctx, getChatbotHistory, arg.UserID, arg.CursorID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ChatbotHistory{}
|
||||
for rows.Next() {
|
||||
var i ChatbotHistory
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Question,
|
||||
&i.Answer,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getConversationsByIDs = `-- name: GetConversationsByIDs :many
|
||||
SELECT id, user_id, mod_id, status, closed_at, created_at, updated_at FROM conversations WHERE id = ANY($1::uuid[])
|
||||
`
|
||||
|
||||
func (q *Queries) GetConversationsByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Conversation, error) {
|
||||
rows, err := q.db.Query(ctx, getConversationsByIDs, dollar_1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Conversation{}
|
||||
for rows.Next() {
|
||||
var i Conversation
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.ModID,
|
||||
&i.Status,
|
||||
&i.ClosedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getMessagesByConversation = `-- name: GetMessagesByConversation :many
|
||||
SELECT id, conversation_id, sender_id, content, created_at FROM messages
|
||||
WHERE conversation_id = $1
|
||||
AND ($2::uuid IS NULL OR id < $2::uuid)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3
|
||||
`
|
||||
|
||||
type GetMessagesByConversationParams struct {
|
||||
ConversationID pgtype.UUID `json:"conversation_id"`
|
||||
CursorID pgtype.UUID `json:"cursor_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetMessagesByConversation(ctx context.Context, arg GetMessagesByConversationParams) ([]Message, error) {
|
||||
rows, err := q.db.Query(ctx, getMessagesByConversation, arg.ConversationID, arg.CursorID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Message{}
|
||||
for rows.Next() {
|
||||
var i Message
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ConversationID,
|
||||
&i.SenderID,
|
||||
&i.Content,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getMessagesByIDs = `-- name: GetMessagesByIDs :many
|
||||
SELECT id, conversation_id, sender_id, content, created_at FROM messages WHERE id = ANY($1::uuid[])
|
||||
`
|
||||
|
||||
func (q *Queries) GetMessagesByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Message, error) {
|
||||
rows, err := q.db.Query(ctx, getMessagesByIDs, dollar_1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Message{}
|
||||
for rows.Next() {
|
||||
var i Message
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ConversationID,
|
||||
&i.SenderID,
|
||||
&i.Content,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateConversationStatus = `-- name: UpdateConversationStatus :one
|
||||
UPDATE conversations
|
||||
SET status = $2, mod_id = COALESCE($3, mod_id), closed_at = $4, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, user_id, mod_id, status, closed_at, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpdateConversationStatusParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Status int16 `json:"status"`
|
||||
ModID pgtype.UUID `json:"mod_id"`
|
||||
ClosedAt pgtype.Timestamptz `json:"closed_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateConversationStatus(ctx context.Context, arg UpdateConversationStatusParams) (Conversation, error) {
|
||||
row := q.db.QueryRow(ctx, updateConversationStatus,
|
||||
arg.ID,
|
||||
arg.Status,
|
||||
arg.ModID,
|
||||
arg.ClosedAt,
|
||||
)
|
||||
var i Conversation
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.ModID,
|
||||
&i.Status,
|
||||
&i.ClosedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -11,6 +11,14 @@ import (
|
||||
"github.com/pgvector/pgvector-go"
|
||||
)
|
||||
|
||||
type ChatbotHistory struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
Question string `json:"question"`
|
||||
Answer string `json:"answer"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Commit struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
@@ -22,6 +30,16 @@ type Commit struct {
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Conversation struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
ModID pgtype.UUID `json:"mod_id"`
|
||||
Status int16 `json:"status"`
|
||||
ClosedAt pgtype.Timestamptz `json:"closed_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Entity struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
@@ -74,6 +92,14 @@ type Media struct {
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ConversationID pgtype.UUID `json:"conversation_id"`
|
||||
SenderID pgtype.UUID `json:"sender_id"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Title string `json:"title"`
|
||||
|
||||
53
internal/models/chat.go
Normal file
53
internal/models/chat.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"history-api/internal/dtos/response"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ConversationEntity struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
ModID *string `json:"mod_id,omitempty"`
|
||||
Status int16 `json:"status"`
|
||||
ClosedAt *time.Time `json:"closed_at,omitempty"`
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type MessageEntity struct {
|
||||
ID string `json:"id"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
SenderID string `json:"sender_id"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
type ChatbotHistoryEntity struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Question string `json:"question"`
|
||||
Answer string `json:"answer"`
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
func (c *ChatbotHistoryEntity) ToResponse() *response.ChatbotHistoryDto {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return &response.ChatbotHistoryDto{
|
||||
ID: c.ID,
|
||||
UserID: c.UserID,
|
||||
Question: c.Question,
|
||||
Answer: c.Answer,
|
||||
CreatedAt: c.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func ChatbotHistoryEntitiesToResponse(entities []*ChatbotHistoryEntity) []*response.ChatbotHistoryDto {
|
||||
res := make([]*response.ChatbotHistoryDto, 0, len(entities))
|
||||
for _, e := range entities {
|
||||
res = append(res, e.ToResponse())
|
||||
}
|
||||
return res
|
||||
}
|
||||
379
internal/repositories/chatRepository.go
Normal file
379
internal/repositories/chatRepository.go
Normal file
@@ -0,0 +1,379 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"history-api/internal/gen/sqlc"
|
||||
"history-api/internal/models"
|
||||
"history-api/pkg/cache"
|
||||
"history-api/pkg/constants"
|
||||
"history-api/pkg/convert"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type ChatRepository interface {
|
||||
CreateConversation(ctx context.Context, params sqlc.CreateConversationParams) (*models.ConversationEntity, error)
|
||||
UpdateConversationStatus(ctx context.Context, params sqlc.UpdateConversationStatusParams) (*models.ConversationEntity, error)
|
||||
CreateMessage(ctx context.Context, params sqlc.CreateMessageParams) (*models.MessageEntity, error)
|
||||
GetMessagesByConversation(ctx context.Context, params sqlc.GetMessagesByConversationParams) ([]*models.MessageEntity, error)
|
||||
CreateChatbotHistory(ctx context.Context, params sqlc.CreateChatbotHistoryParams) (*models.ChatbotHistoryEntity, error)
|
||||
GetChatbotHistory(ctx context.Context, params sqlc.GetChatbotHistoryParams) ([]*models.ChatbotHistoryEntity, error)
|
||||
}
|
||||
|
||||
type chatRepository struct {
|
||||
db *pgxpool.Pool
|
||||
q *sqlc.Queries
|
||||
c cache.Cache
|
||||
}
|
||||
|
||||
func NewChatRepository(db *pgxpool.Pool, c cache.Cache) ChatRepository {
|
||||
return &chatRepository{
|
||||
db: db,
|
||||
q: sqlc.New(db),
|
||||
c: c,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *chatRepository) generateQueryKey(prefix string, params any) string {
|
||||
b, _ := json.Marshal(params)
|
||||
hash := fmt.Sprintf("%x", md5.Sum(b))
|
||||
return fmt.Sprintf("%s:query:%s", prefix, hash)
|
||||
}
|
||||
|
||||
func (r *chatRepository) getConversationsByIDsWithFallback(ctx context.Context, ids []string) ([]*models.ConversationEntity, error) {
|
||||
if len(ids) == 0 {
|
||||
return []*models.ConversationEntity{}, nil
|
||||
}
|
||||
keys := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
keys[i] = fmt.Sprintf("conversation:id:%s", id)
|
||||
}
|
||||
raws := r.c.MGet(ctx, keys...)
|
||||
|
||||
var results []*models.ConversationEntity
|
||||
missingToCache := make(map[string]any)
|
||||
|
||||
var missingPgIds []pgtype.UUID
|
||||
for i, b := range raws {
|
||||
if len(b) == 0 {
|
||||
pgId := pgtype.UUID{}
|
||||
err := pgId.Scan(ids[i])
|
||||
if err == nil {
|
||||
missingPgIds = append(missingPgIds, pgId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dbMap := make(map[string]*models.ConversationEntity)
|
||||
if len(missingPgIds) > 0 {
|
||||
dbRows, err := r.q.GetConversationsByIDs(ctx, missingPgIds)
|
||||
if err == nil {
|
||||
for _, row := range dbRows {
|
||||
item := models.ConversationEntity{
|
||||
ID: convert.UUIDToString(row.ID),
|
||||
UserID: convert.UUIDToString(row.UserID),
|
||||
ModID: convert.UUIDToStringPtr(row.ModID),
|
||||
Status: row.Status,
|
||||
ClosedAt: convert.TimeToPtr(row.ClosedAt),
|
||||
CreatedAt: convert.TimeToPtr(row.CreatedAt),
|
||||
UpdatedAt: convert.TimeToPtr(row.UpdatedAt),
|
||||
}
|
||||
dbMap[item.ID] = &item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, b := range raws {
|
||||
if len(b) > 0 {
|
||||
var c models.ConversationEntity
|
||||
if err := json.Unmarshal(b, &c); err == nil {
|
||||
results = append(results, &c)
|
||||
}
|
||||
} else {
|
||||
if item, ok := dbMap[ids[i]]; ok {
|
||||
results = append(results, item)
|
||||
missingToCache[keys[i]] = item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(missingToCache) > 0 {
|
||||
_ = r.c.MSet(ctx, missingToCache, constants.NormalCacheDuration)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (r *chatRepository) getMessagesByIDsWithFallback(ctx context.Context, ids []string) ([]*models.MessageEntity, error) {
|
||||
if len(ids) == 0 {
|
||||
return []*models.MessageEntity{}, nil
|
||||
}
|
||||
keys := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
keys[i] = fmt.Sprintf("message:id:%s", id)
|
||||
}
|
||||
raws := r.c.MGet(ctx, keys...)
|
||||
|
||||
var results []*models.MessageEntity
|
||||
missingToCache := make(map[string]any)
|
||||
|
||||
var missingPgIds []pgtype.UUID
|
||||
for i, b := range raws {
|
||||
if len(b) == 0 {
|
||||
pgId := pgtype.UUID{}
|
||||
err := pgId.Scan(ids[i])
|
||||
if err == nil {
|
||||
missingPgIds = append(missingPgIds, pgId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dbMap := make(map[string]*models.MessageEntity)
|
||||
if len(missingPgIds) > 0 {
|
||||
dbRows, err := r.q.GetMessagesByIDs(ctx, missingPgIds)
|
||||
if err == nil {
|
||||
for _, row := range dbRows {
|
||||
item := models.MessageEntity{
|
||||
ID: convert.UUIDToString(row.ID),
|
||||
ConversationID: convert.UUIDToString(row.ConversationID),
|
||||
SenderID: convert.UUIDToString(row.SenderID),
|
||||
Content: row.Content,
|
||||
CreatedAt: convert.TimeToPtr(row.CreatedAt),
|
||||
}
|
||||
dbMap[item.ID] = &item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, b := range raws {
|
||||
if len(b) > 0 {
|
||||
var c models.MessageEntity
|
||||
if err := json.Unmarshal(b, &c); err == nil {
|
||||
results = append(results, &c)
|
||||
}
|
||||
} else {
|
||||
if item, ok := dbMap[ids[i]]; ok {
|
||||
results = append(results, item)
|
||||
missingToCache[keys[i]] = item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(missingToCache) > 0 {
|
||||
_ = r.c.MSet(ctx, missingToCache, constants.NormalCacheDuration)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (r *chatRepository) getChatbotHistoriesByIDsWithFallback(ctx context.Context, ids []string) ([]*models.ChatbotHistoryEntity, error) {
|
||||
if len(ids) == 0 {
|
||||
return []*models.ChatbotHistoryEntity{}, nil
|
||||
}
|
||||
keys := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
keys[i] = fmt.Sprintf("chatbot_history:id:%s", id)
|
||||
}
|
||||
raws := r.c.MGet(ctx, keys...)
|
||||
|
||||
var results []*models.ChatbotHistoryEntity
|
||||
missingToCache := make(map[string]any)
|
||||
|
||||
var missingPgIds []pgtype.UUID
|
||||
for i, b := range raws {
|
||||
if len(b) == 0 {
|
||||
pgId := pgtype.UUID{}
|
||||
err := pgId.Scan(ids[i])
|
||||
if err == nil {
|
||||
missingPgIds = append(missingPgIds, pgId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dbMap := make(map[string]*models.ChatbotHistoryEntity)
|
||||
if len(missingPgIds) > 0 {
|
||||
dbRows, err := r.q.GetChatbotHistoriesByIDs(ctx, missingPgIds)
|
||||
if err == nil {
|
||||
for _, row := range dbRows {
|
||||
item := models.ChatbotHistoryEntity{
|
||||
ID: convert.UUIDToString(row.ID),
|
||||
UserID: convert.UUIDToString(row.UserID),
|
||||
Question: row.Question,
|
||||
Answer: row.Answer,
|
||||
CreatedAt: convert.TimeToPtr(row.CreatedAt),
|
||||
}
|
||||
dbMap[item.ID] = &item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, b := range raws {
|
||||
if len(b) > 0 {
|
||||
var c models.ChatbotHistoryEntity
|
||||
if err := json.Unmarshal(b, &c); err == nil {
|
||||
results = append(results, &c)
|
||||
}
|
||||
} else {
|
||||
if item, ok := dbMap[ids[i]]; ok {
|
||||
results = append(results, item)
|
||||
missingToCache[keys[i]] = item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(missingToCache) > 0 {
|
||||
_ = r.c.MSet(ctx, missingToCache, constants.NormalCacheDuration)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (r *chatRepository) CreateConversation(ctx context.Context, params sqlc.CreateConversationParams) (*models.ConversationEntity, error) {
|
||||
row, err := r.q.CreateConversation(ctx, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entity := &models.ConversationEntity{
|
||||
ID: convert.UUIDToString(row.ID),
|
||||
UserID: convert.UUIDToString(row.UserID),
|
||||
ModID: convert.UUIDToStringPtr(row.ModID),
|
||||
Status: row.Status,
|
||||
ClosedAt: convert.TimeToPtr(row.ClosedAt),
|
||||
CreatedAt: convert.TimeToPtr(row.CreatedAt),
|
||||
UpdatedAt: convert.TimeToPtr(row.UpdatedAt),
|
||||
}
|
||||
_ = r.c.Set(ctx, fmt.Sprintf("conversation:id:%s", entity.ID), entity, constants.NormalCacheDuration)
|
||||
return entity, nil
|
||||
}
|
||||
|
||||
func (r *chatRepository) UpdateConversationStatus(ctx context.Context, params sqlc.UpdateConversationStatusParams) (*models.ConversationEntity, error) {
|
||||
row, err := r.q.UpdateConversationStatus(ctx, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entity := &models.ConversationEntity{
|
||||
ID: convert.UUIDToString(row.ID),
|
||||
UserID: convert.UUIDToString(row.UserID),
|
||||
ModID: convert.UUIDToStringPtr(row.ModID),
|
||||
Status: row.Status,
|
||||
ClosedAt: convert.TimeToPtr(row.ClosedAt),
|
||||
CreatedAt: convert.TimeToPtr(row.CreatedAt),
|
||||
UpdatedAt: convert.TimeToPtr(row.UpdatedAt),
|
||||
}
|
||||
_ = r.c.Set(ctx, fmt.Sprintf("conversation:id:%s", entity.ID), entity, constants.NormalCacheDuration)
|
||||
return entity, nil
|
||||
}
|
||||
|
||||
func (r *chatRepository) CreateMessage(ctx context.Context, params sqlc.CreateMessageParams) (*models.MessageEntity, error) {
|
||||
row, err := r.q.CreateMessage(ctx, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entity := &models.MessageEntity{
|
||||
ID: convert.UUIDToString(row.ID),
|
||||
ConversationID: convert.UUIDToString(row.ConversationID),
|
||||
SenderID: convert.UUIDToString(row.SenderID),
|
||||
Content: row.Content,
|
||||
CreatedAt: convert.TimeToPtr(row.CreatedAt),
|
||||
}
|
||||
_ = r.c.Set(ctx, fmt.Sprintf("message:id:%s", entity.ID), entity, constants.NormalCacheDuration)
|
||||
return entity, nil
|
||||
}
|
||||
|
||||
func (r *chatRepository) GetMessagesByConversation(ctx context.Context, params sqlc.GetMessagesByConversationParams) ([]*models.MessageEntity, error) {
|
||||
queryKey := r.generateQueryKey("message:conversation", params)
|
||||
var cachedIDs []string
|
||||
if err := r.c.Get(ctx, queryKey, &cachedIDs); err == nil && len(cachedIDs) > 0 {
|
||||
return r.getMessagesByIDsWithFallback(ctx, cachedIDs)
|
||||
}
|
||||
|
||||
rows, err := r.q.GetMessagesByConversation(ctx, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var results []*models.MessageEntity
|
||||
var ids []string
|
||||
toCache := make(map[string]any)
|
||||
|
||||
for _, row := range rows {
|
||||
item := &models.MessageEntity{
|
||||
ID: convert.UUIDToString(row.ID),
|
||||
ConversationID: convert.UUIDToString(row.ConversationID),
|
||||
SenderID: convert.UUIDToString(row.SenderID),
|
||||
Content: row.Content,
|
||||
CreatedAt: convert.TimeToPtr(row.CreatedAt),
|
||||
}
|
||||
ids = append(ids, item.ID)
|
||||
results = append(results, item)
|
||||
toCache[fmt.Sprintf("message:id:%s", item.ID)] = item
|
||||
}
|
||||
|
||||
if len(toCache) > 0 {
|
||||
_ = r.c.MSet(ctx, toCache, constants.NormalCacheDuration)
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
_ = r.c.Set(ctx, queryKey, ids, constants.ListCacheDuration)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (r *chatRepository) CreateChatbotHistory(ctx context.Context, params sqlc.CreateChatbotHistoryParams) (*models.ChatbotHistoryEntity, error) {
|
||||
row, err := r.q.CreateChatbotHistory(ctx, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entity := &models.ChatbotHistoryEntity{
|
||||
ID: convert.UUIDToString(row.ID),
|
||||
UserID: convert.UUIDToString(row.UserID),
|
||||
Question: row.Question,
|
||||
Answer: row.Answer,
|
||||
CreatedAt: convert.TimeToPtr(row.CreatedAt),
|
||||
}
|
||||
_ = r.c.Set(ctx, fmt.Sprintf("chatbot_history:id:%s", entity.ID), entity, constants.NormalCacheDuration)
|
||||
return entity, nil
|
||||
}
|
||||
|
||||
func (r *chatRepository) GetChatbotHistory(ctx context.Context, params sqlc.GetChatbotHistoryParams) ([]*models.ChatbotHistoryEntity, error) {
|
||||
queryKey := r.generateQueryKey("chatbot_history:user", params)
|
||||
var cachedIDs []string
|
||||
if err := r.c.Get(ctx, queryKey, &cachedIDs); err == nil && len(cachedIDs) > 0 {
|
||||
return r.getChatbotHistoriesByIDsWithFallback(ctx, cachedIDs)
|
||||
}
|
||||
|
||||
rows, err := r.q.GetChatbotHistory(ctx, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var results []*models.ChatbotHistoryEntity
|
||||
var ids []string
|
||||
toCache := make(map[string]any)
|
||||
|
||||
for _, row := range rows {
|
||||
item := &models.ChatbotHistoryEntity{
|
||||
ID: convert.UUIDToString(row.ID),
|
||||
UserID: convert.UUIDToString(row.UserID),
|
||||
Question: row.Question,
|
||||
Answer: row.Answer,
|
||||
CreatedAt: convert.TimeToPtr(row.CreatedAt),
|
||||
}
|
||||
ids = append(ids, item.ID)
|
||||
results = append(results, item)
|
||||
toCache[fmt.Sprintf("chatbot_history:id:%s", item.ID)] = item
|
||||
}
|
||||
|
||||
if len(toCache) > 0 {
|
||||
_ = r.c.MSet(ctx, toCache, constants.NormalCacheDuration)
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
_ = r.c.Set(ctx, queryKey, ids, constants.ListCacheDuration)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
@@ -13,4 +13,5 @@ func ChatbotRoutes(app *fiber.App, controller *controllers.ChatbotController, us
|
||||
|
||||
route.Use(middlewares.JwtAccess(userRepo))
|
||||
route.Post("/chat", controller.Chat)
|
||||
route.Get("/history", controller.GetHistory)
|
||||
}
|
||||
|
||||
@@ -4,25 +4,34 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"history-api/internal/dtos/request"
|
||||
"history-api/internal/gen/sqlc"
|
||||
"history-api/internal/models"
|
||||
"history-api/internal/repositories"
|
||||
"history-api/pkg/ai"
|
||||
"history-api/pkg/constants"
|
||||
"history-api/pkg/convert"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type ChatbotService interface {
|
||||
Chat(ctx context.Context, userID string, projectID *string, question string) (string, error)
|
||||
GetHistory(ctx context.Context, userID string, dto *request.GetChatbotHistoryDto) ([]*models.ChatbotHistoryEntity, error)
|
||||
}
|
||||
|
||||
type chatbotService struct {
|
||||
repo repositories.RagRepository
|
||||
usageRepo repositories.UsageRepository
|
||||
chatRepo repositories.ChatRepository
|
||||
ragUtils *ai.RagUtils
|
||||
}
|
||||
|
||||
func NewChatbotService(repo repositories.RagRepository, usageRepo repositories.UsageRepository, ragUtils *ai.RagUtils) ChatbotService {
|
||||
func NewChatbotService(repo repositories.RagRepository, usageRepo repositories.UsageRepository, chatRepo repositories.ChatRepository, ragUtils *ai.RagUtils) ChatbotService {
|
||||
return &chatbotService{
|
||||
repo: repo,
|
||||
usageRepo: usageRepo,
|
||||
chatRepo: chatRepo,
|
||||
ragUtils: ragUtils,
|
||||
}
|
||||
}
|
||||
@@ -51,15 +60,38 @@ func (s *chatbotService) Chat(ctx context.Context, userID string, projectID *str
|
||||
contextStr += fmt.Sprintf("[Document %d]: %s\n", i+1, res.Content)
|
||||
}
|
||||
|
||||
pgUserID, err := convert.StringToUUID(userID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid user id: %w", err)
|
||||
}
|
||||
|
||||
histories, err := s.chatRepo.GetChatbotHistory(ctx, sqlc.GetChatbotHistoryParams{
|
||||
UserID: pgUserID,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to get chatbot history: %v\n", err)
|
||||
}
|
||||
|
||||
historyStr := ""
|
||||
for _, h := range histories {
|
||||
historyStr += fmt.Sprintf("User: %s\nAssistant: %s\n\n", h.Question, h.Answer)
|
||||
}
|
||||
|
||||
var prompt string
|
||||
if contextStr == "" {
|
||||
prompt = fmt.Sprintf(`You are a friendly history assistant chatbot. The user said: "%s"
|
||||
prompt = fmt.Sprintf(`You are a friendly history assistant chatbot.
|
||||
|
||||
Recent Chat History:
|
||||
%s
|
||||
|
||||
The user said: "%s"
|
||||
|
||||
Rules:
|
||||
- If it is a greeting (like "hello", "hi", "xin chào"), respond with a friendly greeting and briefly introduce yourself.
|
||||
- If it is a history question, say that you don't have relevant documents to answer.
|
||||
- You MUST wrap your final response inside <answer> tags. Example: <answer>Hello!</answer>
|
||||
- Do NOT show your reasoning outside or inside the tags if possible, but the final answer MUST be in <answer> tags.`, question)
|
||||
- Do NOT show your reasoning outside or inside the tags if possible, but the final answer MUST be in <answer> tags.`, historyStr, question)
|
||||
} else {
|
||||
prompt = fmt.Sprintf(`You are a helpful history assistant. Answer the question using ONLY the provided context.
|
||||
|
||||
@@ -71,7 +103,10 @@ Rules:
|
||||
Context:
|
||||
%s
|
||||
|
||||
Question: %s`, contextStr, question)
|
||||
Recent Chat History:
|
||||
%s
|
||||
|
||||
Question: %s`, contextStr, historyStr, question)
|
||||
}
|
||||
|
||||
response, err := s.ragUtils.GenerateResponse(ctx, prompt)
|
||||
@@ -80,5 +115,40 @@ Question: %s`, contextStr, question)
|
||||
}
|
||||
_, _ = s.usageRepo.IncrementAIUsage(ctx, userID)
|
||||
|
||||
_, err = s.chatRepo.CreateChatbotHistory(ctx, sqlc.CreateChatbotHistoryParams{
|
||||
UserID: pgUserID,
|
||||
Question: question,
|
||||
Answer: response,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to save chatbot history: %v\n", err)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *chatbotService) GetHistory(ctx context.Context, userID string, dto *request.GetChatbotHistoryDto) ([]*models.ChatbotHistoryEntity, error) {
|
||||
pgUserID, err := convert.StringToUUID(userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid user id: %w", err)
|
||||
}
|
||||
|
||||
var pgCursorID pgtype.UUID
|
||||
if dto.Cursor != nil {
|
||||
if err := pgCursorID.Scan(*dto.Cursor); err != nil {
|
||||
return nil, fmt.Errorf("invalid cursor id: %w", err)
|
||||
}
|
||||
} else {
|
||||
pgCursorID.Valid = false
|
||||
}
|
||||
|
||||
if dto.Limit <= 0 {
|
||||
dto.Limit = 10
|
||||
}
|
||||
|
||||
return s.chatRepo.GetChatbotHistory(ctx, sqlc.GetChatbotHistoryParams{
|
||||
UserID: pgUserID,
|
||||
CursorID: pgCursorID,
|
||||
Limit: int32(dto.Limit),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user