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:
@@ -97,6 +97,7 @@ func (s *FiberServer) SetupServer(
|
||||
raguRepo := repositories.NewRagRepository(poolPg, redis)
|
||||
usageRepo := repositories.NewUsageRepository(redis)
|
||||
statisticRepo := repositories.NewStatisticRepository(poolPg, redis)
|
||||
chatRepo := repositories.NewChatRepository(poolPg, redis)
|
||||
|
||||
// service setup
|
||||
authService := services.NewAuthService(userRepo, roleRepo, tokenRepo, redis, poolPg)
|
||||
@@ -116,7 +117,7 @@ func (s *FiberServer) SetupServer(
|
||||
userRepo, wikiRepo, geometryRepo, entityRepo,
|
||||
raguRepo, raguUtils, poolPg, redis,
|
||||
)
|
||||
chatbotService := services.NewChatbotService(raguRepo, usageRepo, raguUtils)
|
||||
chatbotService := services.NewChatbotService(raguRepo, usageRepo, chatRepo, raguUtils)
|
||||
statisticService := services.NewStatisticService(statisticRepo)
|
||||
|
||||
// controller setup
|
||||
|
||||
3
db/migrations/0000015_chat_and_support.down.sql
Normal file
3
db/migrations/0000015_chat_and_support.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS chatbot_histories CASCADE;
|
||||
DROP TABLE IF EXISTS messages CASCADE;
|
||||
DROP TABLE IF EXISTS conversations CASCADE;
|
||||
34
db/migrations/0000015_chat_and_support.up.sql
Normal file
34
db/migrations/0000015_chat_and_support.up.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id UUID PRIMARY KEY DEFAULT uuidv7(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
mod_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
status SMALLINT NOT NULL DEFAULT 1, -- 1: waiting, 2: active, 3: resolved
|
||||
closed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_conversations_user_id ON conversations (user_id);
|
||||
CREATE INDEX idx_conversations_mod_id ON conversations (mod_id);
|
||||
CREATE INDEX idx_conversations_status ON conversations (status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id UUID PRIMARY KEY DEFAULT uuidv7(),
|
||||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
sender_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_messages_conversation_id ON messages (conversation_id);
|
||||
CREATE INDEX idx_messages_created_at ON messages (created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chatbot_histories (
|
||||
id UUID PRIMARY KEY DEFAULT uuidv7(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
question TEXT NOT NULL,
|
||||
answer TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_chatbot_histories_user_id ON chatbot_histories (user_id, created_at DESC);
|
||||
46
db/query/chat.sql
Normal file
46
db/query/chat.sql
Normal file
@@ -0,0 +1,46 @@
|
||||
-- name: CreateConversation :one
|
||||
INSERT INTO conversations (user_id, status)
|
||||
VALUES ($1, $2)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateConversationStatus :one
|
||||
UPDATE conversations
|
||||
SET status = $2, mod_id = COALESCE($3, mod_id), closed_at = $4, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: CreateMessage :one
|
||||
INSERT INTO messages (conversation_id, sender_id, content)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetMessagesByConversation :many
|
||||
SELECT * FROM messages
|
||||
WHERE conversation_id = $1
|
||||
AND (sqlc.narg('cursor_id')::uuid IS NULL OR id < sqlc.narg('cursor_id')::uuid)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT sqlc.arg('limit');
|
||||
|
||||
-- name: GetConversationsByIDs :many
|
||||
SELECT * FROM conversations WHERE id = ANY($1::uuid[]);
|
||||
|
||||
-- name: GetMessagesByIDs :many
|
||||
SELECT * FROM messages WHERE id = ANY($1::uuid[]);
|
||||
|
||||
-- name: CreateChatbotHistory :one
|
||||
INSERT INTO chatbot_histories (user_id, question, answer)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetChatbotHistory :many
|
||||
SELECT * FROM (
|
||||
SELECT * FROM chatbot_histories
|
||||
WHERE user_id = $1
|
||||
AND (sqlc.narg('cursor_id')::uuid IS NULL OR id < sqlc.narg('cursor_id')::uuid)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT sqlc.arg('limit')
|
||||
) sub
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
-- name: GetChatbotHistoriesByIDs :many
|
||||
SELECT * FROM chatbot_histories WHERE id = ANY($1::uuid[]);
|
||||
@@ -208,3 +208,30 @@ CREATE TABLE IF NOT EXISTS system_statistics (
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id UUID PRIMARY KEY DEFAULT uuidv7(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
mod_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
status SMALLINT NOT NULL DEFAULT 1,
|
||||
closed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id UUID PRIMARY KEY DEFAULT uuidv7(),
|
||||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
sender_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chatbot_histories (
|
||||
id UUID PRIMARY KEY DEFAULT uuidv7(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
question TEXT NOT NULL,
|
||||
answer TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
|
||||
123
docs/docs.go
123
docs/docs.go
@@ -462,6 +462,72 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/chatbot/history": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get chatbot history for the current user",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Chatbot"
|
||||
],
|
||||
"summary": "Get chatbot history",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Cursor ID for pagination",
|
||||
"name": "cursor",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Limit number of items returned, default 10",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.GetChatbotHistoryResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/entities": {
|
||||
"get": {
|
||||
"description": "Search entities with cursor pagination",
|
||||
@@ -4208,12 +4274,6 @@ const docTemplate = `{
|
||||
"$ref": "#/definitions/history-api_internal_dtos_request.EntityWikiLinkSnapshot"
|
||||
}
|
||||
},
|
||||
"entity_wikis": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_request.EntityWikiLinkSnapshot"
|
||||
}
|
||||
},
|
||||
"geometries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -4378,8 +4438,7 @@ const docTemplate = `{
|
||||
"history-api_internal_dtos_request.EntitySnapshot": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"name"
|
||||
"id"
|
||||
],
|
||||
"properties": {
|
||||
"base_hash": {
|
||||
@@ -4452,7 +4511,8 @@ const docTemplate = `{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"reference",
|
||||
"delete"
|
||||
"delete",
|
||||
"binding"
|
||||
]
|
||||
},
|
||||
"wiki_id": {
|
||||
@@ -4586,14 +4646,21 @@ const docTemplate = `{
|
||||
},
|
||||
"geometry_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"reference",
|
||||
"delete",
|
||||
"binding"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"history-api_internal_dtos_request.GeometrySnapshot": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"type"
|
||||
"id"
|
||||
],
|
||||
"properties": {
|
||||
"base_hash": {
|
||||
@@ -4926,6 +4993,26 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"history-api_internal_dtos_response.ChatbotHistoryDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"answer": {
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"question": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"history-api_internal_dtos_response.CommonResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -4939,6 +5026,20 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"history-api_internal_dtos_response.GetChatbotHistoryResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.ChatbotHistoryDto"
|
||||
}
|
||||
},
|
||||
"pre_cursor": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"history-api_internal_dtos_response.PaginatedResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -455,6 +455,72 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/chatbot/history": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get chatbot history for the current user",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Chatbot"
|
||||
],
|
||||
"summary": "Get chatbot history",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Cursor ID for pagination",
|
||||
"name": "cursor",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Limit number of items returned, default 10",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.GetChatbotHistoryResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/entities": {
|
||||
"get": {
|
||||
"description": "Search entities with cursor pagination",
|
||||
@@ -4201,12 +4267,6 @@
|
||||
"$ref": "#/definitions/history-api_internal_dtos_request.EntityWikiLinkSnapshot"
|
||||
}
|
||||
},
|
||||
"entity_wikis": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_request.EntityWikiLinkSnapshot"
|
||||
}
|
||||
},
|
||||
"geometries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -4371,8 +4431,7 @@
|
||||
"history-api_internal_dtos_request.EntitySnapshot": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"name"
|
||||
"id"
|
||||
],
|
||||
"properties": {
|
||||
"base_hash": {
|
||||
@@ -4445,7 +4504,8 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"reference",
|
||||
"delete"
|
||||
"delete",
|
||||
"binding"
|
||||
]
|
||||
},
|
||||
"wiki_id": {
|
||||
@@ -4579,14 +4639,21 @@
|
||||
},
|
||||
"geometry_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"reference",
|
||||
"delete",
|
||||
"binding"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"history-api_internal_dtos_request.GeometrySnapshot": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"type"
|
||||
"id"
|
||||
],
|
||||
"properties": {
|
||||
"base_hash": {
|
||||
@@ -4919,6 +4986,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"history-api_internal_dtos_response.ChatbotHistoryDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"answer": {
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"question": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"history-api_internal_dtos_response.CommonResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -4932,6 +5019,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"history-api_internal_dtos_response.GetChatbotHistoryResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.ChatbotHistoryDto"
|
||||
}
|
||||
},
|
||||
"pre_cursor": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"history-api_internal_dtos_response.PaginatedResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -80,10 +80,6 @@ definitions:
|
||||
items:
|
||||
$ref: '#/definitions/history-api_internal_dtos_request.EntityWikiLinkSnapshot'
|
||||
type: array
|
||||
entity_wikis:
|
||||
items:
|
||||
$ref: '#/definitions/history-api_internal_dtos_request.EntityWikiLinkSnapshot'
|
||||
type: array
|
||||
geometries:
|
||||
items:
|
||||
$ref: '#/definitions/history-api_internal_dtos_request.GeometrySnapshot'
|
||||
@@ -232,7 +228,6 @@ definitions:
|
||||
type: number
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
type: object
|
||||
history-api_internal_dtos_request.EntityWikiLinkSnapshot:
|
||||
properties:
|
||||
@@ -247,6 +242,7 @@ definitions:
|
||||
enum:
|
||||
- reference
|
||||
- delete
|
||||
- binding
|
||||
type: string
|
||||
wiki_id:
|
||||
type: string
|
||||
@@ -338,6 +334,12 @@ definitions:
|
||||
type: string
|
||||
geometry_id:
|
||||
type: string
|
||||
operation:
|
||||
enum:
|
||||
- reference
|
||||
- delete
|
||||
- binding
|
||||
type: string
|
||||
required:
|
||||
- entity_id
|
||||
- geometry_id
|
||||
@@ -380,7 +382,6 @@ definitions:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- type
|
||||
type: object
|
||||
history-api_internal_dtos_request.MediaBulkDeleteDto:
|
||||
properties:
|
||||
@@ -577,6 +578,19 @@ definitions:
|
||||
- id
|
||||
- title
|
||||
type: object
|
||||
history-api_internal_dtos_response.ChatbotHistoryDto:
|
||||
properties:
|
||||
answer:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
question:
|
||||
type: string
|
||||
user_id:
|
||||
type: string
|
||||
type: object
|
||||
history-api_internal_dtos_response.CommonResponse:
|
||||
properties:
|
||||
data: {}
|
||||
@@ -586,6 +600,15 @@ definitions:
|
||||
status:
|
||||
type: boolean
|
||||
type: object
|
||||
history-api_internal_dtos_response.GetChatbotHistoryResponse:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/definitions/history-api_internal_dtos_response.ChatbotHistoryDto'
|
||||
type: array
|
||||
pre_cursor:
|
||||
type: string
|
||||
type: object
|
||||
history-api_internal_dtos_response.PaginatedResponse:
|
||||
properties:
|
||||
data: {}
|
||||
@@ -962,6 +985,45 @@ paths:
|
||||
summary: Ask the AI chatbot
|
||||
tags:
|
||||
- Chatbot
|
||||
/chatbot/history:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Get chatbot history for the current user
|
||||
parameters:
|
||||
- description: Cursor ID for pagination
|
||||
in: query
|
||||
name: cursor
|
||||
type: string
|
||||
- description: Limit number of items returned, default 10
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Successful response
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/history-api_internal_dtos_response.GetChatbotHistoryResponse'
|
||||
type: object
|
||||
"400":
|
||||
description: Invalid request
|
||||
schema:
|
||||
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Get chatbot history
|
||||
tags:
|
||||
- Chatbot
|
||||
/entities:
|
||||
get:
|
||||
consumes:
|
||||
|
||||
@@ -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