committtttt
This commit is contained in:
394
backend/internal/ai/ai.go
Normal file
394
backend/internal/ai/ai.go
Normal file
@@ -0,0 +1,394 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"customer-support-system/internal/models"
|
||||
"customer-support-system/pkg/config"
|
||||
"customer-support-system/pkg/logger"
|
||||
)
|
||||
|
||||
// OpenAIRequest represents a request to the OpenAI API
|
||||
type OpenAIRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []OpenAIMessage `json:"messages"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
TopP float64 `json:"top_p,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
}
|
||||
|
||||
// OpenAIMessage represents a message in the OpenAI API
|
||||
type OpenAIMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// OpenAIResponse represents a response from the OpenAI API
|
||||
type OpenAIResponse struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []OpenAIChoice `json:"choices"`
|
||||
Usage OpenAIUsage `json:"usage"`
|
||||
}
|
||||
|
||||
// OpenAIChoice represents a choice in the OpenAI API response
|
||||
type OpenAIChoice struct {
|
||||
Index int `json:"index"`
|
||||
Message OpenAIMessage `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
// OpenAIUsage represents usage statistics in the OpenAI API response
|
||||
type OpenAIUsage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
|
||||
// OllamaRequest represents a request to the Ollama API
|
||||
type OllamaRequest struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Options map[string]any `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// OllamaResponse represents a response from the Ollama API
|
||||
type OllamaResponse struct {
|
||||
Model string `json:"model"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Response string `json:"response"`
|
||||
Done bool `json:"done"`
|
||||
}
|
||||
|
||||
// AIService handles AI operations
|
||||
type AIService struct {
|
||||
openAIConfig config.APIConfig
|
||||
localConfig config.APIConfig
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewAIService creates a new AI service
|
||||
func NewAIService() *AIService {
|
||||
return &AIService{
|
||||
openAIConfig: config.AppConfig.AI.OpenAI,
|
||||
localConfig: config.AppConfig.AI.Local,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// QueryOpenAI sends a query to the OpenAI API
|
||||
func (s *AIService) QueryOpenAI(ctx context.Context, prompt string, conversationHistory []models.Message) (string, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Convert conversation history to OpenAI messages
|
||||
messages := s.convertToOpenAIMessages(prompt, conversationHistory)
|
||||
|
||||
// Create request
|
||||
req := OpenAIRequest{
|
||||
Model: s.openAIConfig.Model,
|
||||
Messages: messages,
|
||||
MaxTokens: s.openAIConfig.MaxTokens,
|
||||
Temperature: s.openAIConfig.Temperature,
|
||||
TopP: s.openAIConfig.TopP,
|
||||
}
|
||||
|
||||
// Marshal request to JSON
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to marshal OpenAI request")
|
||||
return "", fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", "https://api.openai.com/v1/chat/completions", bytes.NewBuffer(reqBody))
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to create OpenAI HTTP request")
|
||||
return "", fmt.Errorf("failed to create HTTP request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.openAIConfig.APIKey))
|
||||
|
||||
// Send request
|
||||
resp, err := s.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to send OpenAI request")
|
||||
return "", fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response body
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to read OpenAI response")
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
// Check status code
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logger.WithField("status_code", resp.StatusCode).
|
||||
WithField("response", string(respBody)).
|
||||
Error("OpenAI API returned non-200 status code")
|
||||
return "", fmt.Errorf("OpenAI API returned status code %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var openAIResp OpenAIResponse
|
||||
if err := json.Unmarshal(respBody, &openAIResp); err != nil {
|
||||
logger.WithError(err).Error("Failed to parse OpenAI response")
|
||||
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
// Extract response text
|
||||
if len(openAIResp.Choices) == 0 {
|
||||
logger.Error("OpenAI API returned no choices")
|
||||
return "", fmt.Errorf("OpenAI API returned no choices")
|
||||
}
|
||||
|
||||
responseText := openAIResp.Choices[0].Message.Content
|
||||
|
||||
// Log AI interaction
|
||||
duration := time.Since(startTime)
|
||||
logger.LogAIInteraction(
|
||||
s.openAIConfig.Model,
|
||||
len(prompt),
|
||||
len(responseText),
|
||||
duration,
|
||||
true,
|
||||
nil,
|
||||
)
|
||||
|
||||
return responseText, nil
|
||||
}
|
||||
|
||||
// QueryOllama sends a query to the Ollama API
|
||||
func (s *AIService) QueryOllama(ctx context.Context, prompt string, conversationHistory []models.Message) (string, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Create request
|
||||
req := OllamaRequest{
|
||||
Model: s.localConfig.Model,
|
||||
Prompt: s.buildOllamaPrompt(prompt, conversationHistory),
|
||||
Stream: false,
|
||||
Options: map[string]any{
|
||||
"temperature": s.localConfig.Temperature,
|
||||
"top_p": s.localConfig.TopP,
|
||||
"num_predict": s.localConfig.MaxTokens,
|
||||
},
|
||||
}
|
||||
|
||||
// Marshal request to JSON
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to marshal Ollama request")
|
||||
return "", fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s/api/generate", s.localConfig.Endpoint), bytes.NewBuffer(reqBody))
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to create Ollama HTTP request")
|
||||
return "", fmt.Errorf("failed to create HTTP request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Send request
|
||||
resp, err := s.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to send Ollama request")
|
||||
return "", fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response body
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to read Ollama response")
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
// Check status code
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logger.WithField("status_code", resp.StatusCode).
|
||||
WithField("response", string(respBody)).
|
||||
Error("Ollama API returned non-200 status code")
|
||||
return "", fmt.Errorf("Ollama API returned status code %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var ollamaResp OllamaResponse
|
||||
if err := json.Unmarshal(respBody, &ollamaResp); err != nil {
|
||||
logger.WithError(err).Error("Failed to parse Ollama response")
|
||||
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
// Extract response text
|
||||
responseText := ollamaResp.Response
|
||||
|
||||
// Log AI interaction
|
||||
duration := time.Since(startTime)
|
||||
logger.LogAIInteraction(
|
||||
s.localConfig.Model,
|
||||
len(prompt),
|
||||
len(responseText),
|
||||
duration,
|
||||
true,
|
||||
nil,
|
||||
)
|
||||
|
||||
return responseText, nil
|
||||
}
|
||||
|
||||
// Query sends a query to the appropriate AI model based on complexity
|
||||
func (s *AIService) Query(ctx context.Context, prompt string, conversationHistory []models.Message, complexity int) (string, error) {
|
||||
// Determine which AI model to use based on complexity
|
||||
if complexity >= 7 { // High complexity, use OpenAI
|
||||
return s.QueryOpenAI(ctx, prompt, conversationHistory)
|
||||
} else { // Low to medium complexity, use local LLM
|
||||
return s.QueryOllama(ctx, prompt, conversationHistory)
|
||||
}
|
||||
}
|
||||
|
||||
// AnalyzeComplexity analyzes the complexity of a prompt
|
||||
func (s *AIService) AnalyzeComplexity(prompt string) int {
|
||||
// Simple heuristic for complexity analysis
|
||||
// In a real implementation, this would be more sophisticated
|
||||
|
||||
complexity := 0
|
||||
|
||||
// Length factor
|
||||
if len(prompt) > 100 {
|
||||
complexity += 2
|
||||
}
|
||||
if len(prompt) > 200 {
|
||||
complexity += 2
|
||||
}
|
||||
|
||||
// Question type factor
|
||||
if strings.Contains(prompt, "?") {
|
||||
complexity += 1
|
||||
}
|
||||
|
||||
// Technical terms factor
|
||||
technicalTerms := []string{"API", "database", "server", "code", "programming", "software", "algorithm"}
|
||||
for _, term := range technicalTerms {
|
||||
if strings.Contains(strings.ToLower(prompt), strings.ToLower(term)) {
|
||||
complexity += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Multiple questions factor
|
||||
questionCount := strings.Count(prompt, "?")
|
||||
if questionCount > 1 {
|
||||
complexity += questionCount - 1
|
||||
}
|
||||
|
||||
// Cap complexity at 10
|
||||
if complexity > 10 {
|
||||
complexity = 10
|
||||
}
|
||||
|
||||
return complexity
|
||||
}
|
||||
|
||||
// convertToOpenAIMessages converts conversation history to OpenAI messages
|
||||
func (s *AIService) convertToOpenAIMessages(prompt string, conversationHistory []models.Message) []OpenAIMessage {
|
||||
messages := []OpenAIMessage{}
|
||||
|
||||
// Add system message
|
||||
messages = append(messages, OpenAIMessage{
|
||||
Role: "system",
|
||||
Content: "You are a helpful customer support assistant. Provide clear, concise, and accurate answers to customer questions.",
|
||||
})
|
||||
|
||||
// Add conversation history
|
||||
for _, msg := range conversationHistory {
|
||||
role := "user"
|
||||
if msg.IsAI {
|
||||
role = "assistant"
|
||||
}
|
||||
messages = append(messages, OpenAIMessage{
|
||||
Role: role,
|
||||
Content: msg.Content,
|
||||
})
|
||||
}
|
||||
|
||||
// Add current prompt
|
||||
messages = append(messages, OpenAIMessage{
|
||||
Role: "user",
|
||||
Content: prompt,
|
||||
})
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
// buildOllamaPrompt builds a prompt for Ollama from conversation history
|
||||
func (s *AIService) buildOllamaPrompt(prompt string, conversationHistory []models.Message) string {
|
||||
var builder strings.Builder
|
||||
|
||||
// Add system instruction
|
||||
builder.WriteString("You are a helpful customer support assistant. Provide clear, concise, and accurate answers to customer questions.\n\n")
|
||||
|
||||
// Add conversation history
|
||||
for _, msg := range conversationHistory {
|
||||
if msg.IsAI {
|
||||
builder.WriteString("Assistant: ")
|
||||
} else {
|
||||
builder.WriteString("User: ")
|
||||
}
|
||||
builder.WriteString(msg.Content)
|
||||
builder.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Add current prompt
|
||||
builder.WriteString("User: ")
|
||||
builder.WriteString(prompt)
|
||||
builder.WriteString("\n\nAssistant: ")
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// GetAvailableModels returns the available AI models
|
||||
func (s *AIService) GetAvailableModels() []models.AIModel {
|
||||
return []models.AIModel{
|
||||
{
|
||||
Name: "OpenAI GPT-4",
|
||||
Type: "openai",
|
||||
Model: s.openAIConfig.Model,
|
||||
MaxTokens: s.openAIConfig.MaxTokens,
|
||||
Temperature: s.openAIConfig.Temperature,
|
||||
TopP: s.openAIConfig.TopP,
|
||||
Active: true,
|
||||
Priority: 2,
|
||||
Description: "OpenAI's GPT-4 model for complex queries",
|
||||
},
|
||||
{
|
||||
Name: "Local LLaMA",
|
||||
Type: "local",
|
||||
Model: s.localConfig.Model,
|
||||
Endpoint: s.localConfig.Endpoint,
|
||||
MaxTokens: s.localConfig.MaxTokens,
|
||||
Temperature: s.localConfig.Temperature,
|
||||
TopP: s.localConfig.TopP,
|
||||
Active: true,
|
||||
Priority: 1,
|
||||
Description: "Local LLaMA model for simple queries",
|
||||
},
|
||||
}
|
||||
}
|
362
backend/internal/auth/auth.go
Normal file
362
backend/internal/auth/auth.go
Normal file
@@ -0,0 +1,362 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"customer-support-system/internal/database"
|
||||
"customer-support-system/internal/models"
|
||||
"customer-support-system/pkg/config"
|
||||
"customer-support-system/pkg/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserInactive = errors.New("user is inactive")
|
||||
ErrAccountLocked = errors.New("account is locked")
|
||||
ErrTokenExpired = errors.New("token has expired")
|
||||
ErrInvalidToken = errors.New("invalid token")
|
||||
)
|
||||
|
||||
// AuthService handles authentication operations
|
||||
type AuthService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAuthService creates a new authentication service
|
||||
func NewAuthService(db *gorm.DB) *AuthService {
|
||||
return &AuthService{db: db}
|
||||
}
|
||||
|
||||
// Login authenticates a user and returns a JWT token
|
||||
func (s *AuthService) Login(username, password, clientIP string) (*models.LoginResponse, error) {
|
||||
// Find user by username
|
||||
var user models.User
|
||||
if err := s.db.Where("username = ?", username).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
logger.WithField("username", username).Warn("Login attempt with non-existent username")
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to find user: %w", err)
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if !user.Active {
|
||||
logger.WithField("user_id", user.ID).Warn("Login attempt by inactive user")
|
||||
return nil, ErrUserInactive
|
||||
}
|
||||
|
||||
// Check password
|
||||
if !user.ComparePassword(password) {
|
||||
logger.WithField("user_id", user.ID).Warn("Login attempt with incorrect password")
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
token, err := s.GenerateJWTToken(user.ID)
|
||||
if err != nil {
|
||||
logger.WithError(err).WithField("user_id", user.ID).Error("Failed to generate JWT token")
|
||||
return nil, fmt.Errorf("failed to generate token: %w", err)
|
||||
}
|
||||
|
||||
// Log successful login
|
||||
logger.LogAuthEvent("login", fmt.Sprintf("%d", user.ID), clientIP, true, nil)
|
||||
|
||||
return &models.LoginResponse{
|
||||
Token: token,
|
||||
User: user.ToSafeUser(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Register creates a new user
|
||||
func (s *AuthService) Register(req *models.CreateUserRequest) (*models.SafeUser, error) {
|
||||
// Check if username already exists
|
||||
var existingUser models.User
|
||||
if err := s.db.Where("username = ?", req.Username).First(&existingUser).Error; err == nil {
|
||||
return nil, fmt.Errorf("username already exists")
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("failed to check username: %w", err)
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
if err := s.db.Where("email = ?", req.Email).First(&existingUser).Error; err == nil {
|
||||
return nil, fmt.Errorf("email already exists")
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("failed to check email: %w", err)
|
||||
}
|
||||
|
||||
// Create new user
|
||||
user := models.User{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
FirstName: req.FirstName,
|
||||
LastName: req.LastName,
|
||||
Role: req.Role,
|
||||
Active: true,
|
||||
}
|
||||
|
||||
if err := s.db.Create(&user).Error; err != nil {
|
||||
logger.WithError(err).WithField("username", req.Username).Error("Failed to create user")
|
||||
return nil, fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
|
||||
// Log user registration
|
||||
logger.WithField("user_id", user.ID).Info("User registered successfully")
|
||||
|
||||
safeUser := user.ToSafeUser()
|
||||
return &safeUser, nil
|
||||
}
|
||||
|
||||
// GenerateJWTToken generates a JWT token for a user
|
||||
func (s *AuthService) GenerateJWTToken(userID uint) (string, error) {
|
||||
// Get JWT configuration
|
||||
jwtConfig := config.AppConfig.JWT
|
||||
|
||||
// Create claims
|
||||
claims := jwt.MapClaims{
|
||||
"user_id": userID,
|
||||
"exp": time.Now().Add(time.Hour * time.Duration(jwtConfig.ExpirationHours)).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
"iss": jwtConfig.Issuer,
|
||||
"aud": jwtConfig.Audience,
|
||||
}
|
||||
|
||||
// Create token
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
// Sign token
|
||||
tokenString, err := token.SignedString(jwtConfig.GetJWTSigningKey())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign token: %w", err)
|
||||
}
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
// ValidateJWTToken validates a JWT token and returns the user ID
|
||||
func (s *AuthService) ValidateJWTToken(tokenString string) (uint, error) {
|
||||
// Get JWT configuration
|
||||
jwtConfig := config.AppConfig.JWT
|
||||
|
||||
// Parse token
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
// Validate signing method
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return jwtConfig.GetJWTSigningKey(), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to parse token: %w", err)
|
||||
}
|
||||
|
||||
// Validate claims
|
||||
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
|
||||
// Check expiration
|
||||
if exp, ok := claims["exp"].(float64); ok {
|
||||
if time.Now().Unix() > int64(exp) {
|
||||
return 0, ErrTokenExpired
|
||||
}
|
||||
}
|
||||
|
||||
// Get user ID
|
||||
userID, ok := claims["user_id"].(float64)
|
||||
if !ok {
|
||||
return 0, ErrInvalidToken
|
||||
}
|
||||
|
||||
return uint(userID), nil
|
||||
}
|
||||
|
||||
return 0, ErrInvalidToken
|
||||
}
|
||||
|
||||
// GetUserByID returns a user by ID
|
||||
func (s *AuthService) GetUserByID(userID uint) (*models.User, error) {
|
||||
var user models.User
|
||||
if err := s.db.First(&user, userID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// UpdateUser updates a user
|
||||
func (s *AuthService) UpdateUser(userID uint, req *models.UpdateUserRequest) (*models.SafeUser, error) {
|
||||
var user models.User
|
||||
if err := s.db.First(&user, userID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
// Update fields
|
||||
if req.FirstName != "" {
|
||||
user.FirstName = req.FirstName
|
||||
}
|
||||
if req.LastName != "" {
|
||||
user.LastName = req.LastName
|
||||
}
|
||||
if req.Email != "" {
|
||||
user.Email = req.Email
|
||||
}
|
||||
if req.Active != nil {
|
||||
user.Active = *req.Active
|
||||
}
|
||||
if req.Role != "" {
|
||||
user.Role = req.Role
|
||||
}
|
||||
|
||||
if err := s.db.Save(&user).Error; err != nil {
|
||||
logger.WithError(err).WithField("user_id", userID).Error("Failed to update user")
|
||||
return nil, fmt.Errorf("failed to update user: %w", err)
|
||||
}
|
||||
|
||||
// Log user update
|
||||
logger.WithField("user_id", userID).Info("User updated successfully")
|
||||
|
||||
safeUser := user.ToSafeUser()
|
||||
return &safeUser, nil
|
||||
}
|
||||
|
||||
// ChangePassword changes a user's password
|
||||
func (s *AuthService) ChangePassword(userID uint, req *models.ChangePasswordRequest) error {
|
||||
var user models.User
|
||||
if err := s.db.First(&user, userID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
// Check current password
|
||||
if !user.ComparePassword(req.CurrentPassword) {
|
||||
logger.WithField("user_id", userID).Warn("Password change attempt with incorrect current password")
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// Update password
|
||||
user.Password = req.NewPassword
|
||||
if err := s.db.Save(&user).Error; err != nil {
|
||||
logger.WithError(err).WithField("user_id", userID).Error("Failed to change password")
|
||||
return fmt.Errorf("failed to change password: %w", err)
|
||||
}
|
||||
|
||||
// Log password change
|
||||
logger.WithField("user_id", userID).Info("Password changed successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HashPassword hashes a password using bcrypt
|
||||
func HashPassword(password string) (string, error) {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
return string(hashedPassword), nil
|
||||
}
|
||||
|
||||
// CheckPassword checks if a password matches a hashed password
|
||||
func CheckPassword(password, hashedPassword string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// AuthMiddleware returns a gin middleware for authentication
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Get token from Authorization header
|
||||
tokenString := c.GetHeader("Authorization")
|
||||
if tokenString == "" {
|
||||
c.JSON(401, gin.H{"error": "Authorization header is required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Remove "Bearer " prefix
|
||||
if len(tokenString) > 7 && tokenString[:7] == "Bearer " {
|
||||
tokenString = tokenString[7:]
|
||||
}
|
||||
|
||||
// Validate token
|
||||
authService := NewAuthService(database.GetDB())
|
||||
userID, err := authService.ValidateJWTToken(tokenString)
|
||||
if err != nil {
|
||||
c.JSON(401, gin.H{"error": "Invalid or expired token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Get user
|
||||
user, err := authService.GetUserByID(userID)
|
||||
if err != nil {
|
||||
c.JSON(401, gin.H{"error": "User not found"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if !user.Active {
|
||||
c.JSON(401, gin.H{"error": "User account is inactive"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Set user ID in context
|
||||
c.Set("userID", userID)
|
||||
c.Set("userRole", user.Role)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RoleMiddleware returns a gin middleware for role-based authorization
|
||||
func RoleMiddleware(roles ...string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Get user role from context
|
||||
userRole, exists := c.Get("userRole")
|
||||
if !exists {
|
||||
c.JSON(401, gin.H{"error": "User not authenticated"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user has required role
|
||||
roleStr, ok := userRole.(string)
|
||||
if !ok {
|
||||
c.JSON(500, gin.H{"error": "Invalid user role"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user role is in allowed roles
|
||||
allowed := false
|
||||
for _, role := range roles {
|
||||
if roleStr == role {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
c.JSON(403, gin.H{"error": "Insufficient permissions"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
436
backend/internal/conversation/conversation.go
Normal file
436
backend/internal/conversation/conversation.go
Normal file
@@ -0,0 +1,436 @@
|
||||
package conversation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"customer-support-system/internal/ai"
|
||||
"customer-support-system/internal/database"
|
||||
"customer-support-system/internal/models"
|
||||
"customer-support-system/pkg/logger"
|
||||
)
|
||||
|
||||
// ConversationService handles conversation operations
|
||||
type ConversationService struct {
|
||||
db *gorm.DB
|
||||
aiService *ai.AIService
|
||||
}
|
||||
|
||||
// NewConversationService creates a new conversation service
|
||||
func NewConversationService() *ConversationService {
|
||||
return &ConversationService{
|
||||
db: database.GetDB(),
|
||||
aiService: ai.NewAIService(),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateConversation creates a new conversation
|
||||
func (s *ConversationService) CreateConversation(userID uint, req *models.CreateConversationRequest) (*models.Conversation, error) {
|
||||
conversation := models.Conversation{
|
||||
Title: req.Title,
|
||||
UserID: userID,
|
||||
Department: req.Department,
|
||||
Priority: req.Priority,
|
||||
Tags: req.Tags,
|
||||
Status: "active",
|
||||
}
|
||||
|
||||
if err := s.db.Create(&conversation).Error; err != nil {
|
||||
logger.WithError(err).WithField("user_id", userID).Error("Failed to create conversation")
|
||||
return nil, fmt.Errorf("failed to create conversation: %w", err)
|
||||
}
|
||||
|
||||
logger.WithField("conversation_id", conversation.ID).Info("Conversation created successfully")
|
||||
|
||||
return &conversation, nil
|
||||
}
|
||||
|
||||
// GetConversation retrieves a conversation by ID
|
||||
func (s *ConversationService) GetConversation(conversationID uint, userID uint) (*models.Conversation, error) {
|
||||
var conversation models.Conversation
|
||||
if err := s.db.Where("id = ? AND user_id = ?", conversationID, userID).First(&conversation).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, fmt.Errorf("conversation not found")
|
||||
}
|
||||
logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to get conversation")
|
||||
return nil, fmt.Errorf("failed to get conversation: %w", err)
|
||||
}
|
||||
|
||||
return &conversation, nil
|
||||
}
|
||||
|
||||
// ListConversations retrieves a list of conversations for a user
|
||||
func (s *ConversationService) ListConversations(userID uint, page, pageSize int, status string) (*models.ConversationListResponse, error) {
|
||||
var conversations []models.Conversation
|
||||
var total int64
|
||||
|
||||
query := s.db.Model(&models.Conversation{}).Where("user_id = ?", userID)
|
||||
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
// Get total count
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
logger.WithError(err).WithField("user_id", userID).Error("Failed to count conversations")
|
||||
return nil, fmt.Errorf("failed to count conversations: %w", err)
|
||||
}
|
||||
|
||||
// Get paginated results
|
||||
offset := (page - 1) * pageSize
|
||||
if err := query.Order("last_message_at DESC").Offset(offset).Limit(pageSize).Find(&conversations).Error; err != nil {
|
||||
logger.WithError(err).WithField("user_id", userID).Error("Failed to list conversations")
|
||||
return nil, fmt.Errorf("failed to list conversations: %w", err)
|
||||
}
|
||||
|
||||
return &models.ConversationListResponse{
|
||||
Conversations: conversations,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateConversation updates a conversation
|
||||
func (s *ConversationService) UpdateConversation(conversationID uint, userID uint, req *models.UpdateConversationRequest) (*models.Conversation, error) {
|
||||
var conversation models.Conversation
|
||||
if err := s.db.Where("id = ? AND user_id = ?", conversationID, userID).First(&conversation).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, fmt.Errorf("conversation not found")
|
||||
}
|
||||
logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to get conversation")
|
||||
return nil, fmt.Errorf("failed to get conversation: %w", err)
|
||||
}
|
||||
|
||||
// Update fields
|
||||
if req.Title != "" {
|
||||
conversation.Title = req.Title
|
||||
}
|
||||
if req.Status != "" {
|
||||
conversation.Status = req.Status
|
||||
}
|
||||
if req.Department != "" {
|
||||
conversation.Department = req.Department
|
||||
}
|
||||
if req.Priority != "" {
|
||||
conversation.Priority = req.Priority
|
||||
}
|
||||
if req.Tags != "" {
|
||||
conversation.Tags = req.Tags
|
||||
}
|
||||
if req.AgentID != nil {
|
||||
conversation.AgentID = req.AgentID
|
||||
}
|
||||
|
||||
if err := s.db.Save(&conversation).Error; err != nil {
|
||||
logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to update conversation")
|
||||
return nil, fmt.Errorf("failed to update conversation: %w", err)
|
||||
}
|
||||
|
||||
logger.WithField("conversation_id", conversationID).Info("Conversation updated successfully")
|
||||
|
||||
return &conversation, nil
|
||||
}
|
||||
|
||||
// DeleteConversation deletes a conversation
|
||||
func (s *ConversationService) DeleteConversation(conversationID uint, userID uint) error {
|
||||
var conversation models.Conversation
|
||||
if err := s.db.Where("id = ? AND user_id = ?", conversationID, userID).First(&conversation).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return fmt.Errorf("conversation not found")
|
||||
}
|
||||
logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to get conversation")
|
||||
return fmt.Errorf("failed to get conversation: %w", err)
|
||||
}
|
||||
|
||||
if err := s.db.Delete(&conversation).Error; err != nil {
|
||||
logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to delete conversation")
|
||||
return fmt.Errorf("failed to delete conversation: %w", err)
|
||||
}
|
||||
|
||||
logger.WithField("conversation_id", conversationID).Info("Conversation deleted successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateMessage creates a new message in a conversation
|
||||
func (s *ConversationService) CreateMessage(conversationID uint, userID uint, req *models.CreateMessageRequest) (*models.Message, error) {
|
||||
// Check if conversation exists and belongs to user
|
||||
var conversation models.Conversation
|
||||
if err := s.db.Where("id = ? AND user_id = ?", conversationID, userID).First(&conversation).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, fmt.Errorf("conversation not found")
|
||||
}
|
||||
logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to get conversation")
|
||||
return nil, fmt.Errorf("failed to get conversation: %w", err)
|
||||
}
|
||||
|
||||
// Create message
|
||||
message := models.Message{
|
||||
ConversationID: conversationID,
|
||||
UserID: userID,
|
||||
Content: req.Content,
|
||||
Type: req.Type,
|
||||
Status: "sent",
|
||||
IsAI: false,
|
||||
}
|
||||
|
||||
if err := s.db.Create(&message).Error; err != nil {
|
||||
logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to create message")
|
||||
return nil, fmt.Errorf("failed to create message: %w", err)
|
||||
}
|
||||
|
||||
logger.WithField("message_id", message.ID).Info("Message created successfully")
|
||||
|
||||
return &message, nil
|
||||
}
|
||||
|
||||
// GetMessages retrieves messages in a conversation
|
||||
func (s *ConversationService) GetMessages(conversationID uint, userID uint, page, pageSize int) (*models.MessageListResponse, error) {
|
||||
// Check if conversation exists and belongs to user
|
||||
var conversation models.Conversation
|
||||
if err := s.db.Where("id = ? AND user_id = ?", conversationID, userID).First(&conversation).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, fmt.Errorf("conversation not found")
|
||||
}
|
||||
logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to get conversation")
|
||||
return nil, fmt.Errorf("failed to get conversation: %w", err)
|
||||
}
|
||||
|
||||
var messages []models.Message
|
||||
var total int64
|
||||
|
||||
// Get total count
|
||||
if err := s.db.Model(&models.Message{}).Where("conversation_id = ?", conversationID).Count(&total).Error; err != nil {
|
||||
logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to count messages")
|
||||
return nil, fmt.Errorf("failed to count messages: %w", err)
|
||||
}
|
||||
|
||||
// Get paginated results
|
||||
offset := (page - 1) * pageSize
|
||||
if err := s.db.Where("conversation_id = ?", conversationID).Order("created_at ASC").Offset(offset).Limit(pageSize).Find(&messages).Error; err != nil {
|
||||
logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to get messages")
|
||||
return nil, fmt.Errorf("failed to get messages: %w", err)
|
||||
}
|
||||
|
||||
return &models.MessageListResponse{
|
||||
Messages: messages,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateMessage updates a message
|
||||
func (s *ConversationService) UpdateMessage(messageID uint, userID uint, req *models.UpdateMessageRequest) (*models.Message, error) {
|
||||
var message models.Message
|
||||
if err := s.db.Where("id = ? AND user_id = ?", messageID, userID).First(&message).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, fmt.Errorf("message not found")
|
||||
}
|
||||
logger.WithError(err).WithField("message_id", messageID).Error("Failed to get message")
|
||||
return nil, fmt.Errorf("failed to get message: %w", err)
|
||||
}
|
||||
|
||||
// Update fields
|
||||
if req.Content != "" {
|
||||
message.Content = req.Content
|
||||
}
|
||||
if req.Status != "" {
|
||||
message.Status = req.Status
|
||||
}
|
||||
|
||||
if err := s.db.Save(&message).Error; err != nil {
|
||||
logger.WithError(err).WithField("message_id", messageID).Error("Failed to update message")
|
||||
return nil, fmt.Errorf("failed to update message: %w", err)
|
||||
}
|
||||
|
||||
logger.WithField("message_id", messageID).Info("Message updated successfully")
|
||||
|
||||
return &message, nil
|
||||
}
|
||||
|
||||
// DeleteMessage deletes a message
|
||||
func (s *ConversationService) DeleteMessage(messageID uint, userID uint) error {
|
||||
var message models.Message
|
||||
if err := s.db.Where("id = ? AND user_id = ?", messageID, userID).First(&message).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return fmt.Errorf("message not found")
|
||||
}
|
||||
logger.WithError(err).WithField("message_id", messageID).Error("Failed to get message")
|
||||
return fmt.Errorf("failed to get message: %w", err)
|
||||
}
|
||||
|
||||
if err := s.db.Delete(&message).Error; err != nil {
|
||||
logger.WithError(err).WithField("message_id", messageID).Error("Failed to delete message")
|
||||
return fmt.Errorf("failed to delete message: %w", err)
|
||||
}
|
||||
|
||||
logger.WithField("message_id", messageID).Info("Message deleted successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendMessageWithAI sends a message and gets an AI response
|
||||
func (s *ConversationService) SendMessageWithAI(conversationID uint, userID uint, content string) (*models.Message, *models.Message, error) {
|
||||
// Check if conversation exists and belongs to user
|
||||
var conversation models.Conversation
|
||||
if err := s.db.Where("id = ? AND user_id = ?", conversationID, userID).First(&conversation).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil, fmt.Errorf("conversation not found")
|
||||
}
|
||||
logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to get conversation")
|
||||
return nil, nil, fmt.Errorf("failed to get conversation: %w", err)
|
||||
}
|
||||
|
||||
// Create user message
|
||||
userMessage := models.Message{
|
||||
ConversationID: conversationID,
|
||||
UserID: userID,
|
||||
Content: content,
|
||||
Type: "text",
|
||||
Status: "sent",
|
||||
IsAI: false,
|
||||
}
|
||||
|
||||
if err := s.db.Create(&userMessage).Error; err != nil {
|
||||
logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to create user message")
|
||||
return nil, nil, fmt.Errorf("failed to create user message: %w", err)
|
||||
}
|
||||
|
||||
// Get conversation history
|
||||
var messages []models.Message
|
||||
if err := s.db.Where("conversation_id = ?", conversationID).Order("created_at ASC").Find(&messages).Error; err != nil {
|
||||
logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to get conversation history")
|
||||
return nil, nil, fmt.Errorf("failed to get conversation history: %w", err)
|
||||
}
|
||||
|
||||
// Analyze complexity of the message
|
||||
complexity := s.aiService.AnalyzeComplexity(content)
|
||||
|
||||
// Get AI response
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
aiResponse, err := s.aiService.Query(ctx, content, messages, complexity)
|
||||
if err != nil {
|
||||
logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to get AI response")
|
||||
return &userMessage, nil, fmt.Errorf("failed to get AI response: %w", err)
|
||||
}
|
||||
|
||||
// Create AI message
|
||||
aiMessage := models.Message{
|
||||
ConversationID: conversationID,
|
||||
UserID: userID, // AI responses are associated with the user who asked the question
|
||||
Content: aiResponse,
|
||||
Type: "text",
|
||||
Status: "sent",
|
||||
IsAI: true,
|
||||
AIModel: "gpt-4", // This should be determined based on which AI model was used
|
||||
}
|
||||
|
||||
if err := s.db.Create(&aiMessage).Error; err != nil {
|
||||
logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to create AI message")
|
||||
return &userMessage, nil, fmt.Errorf("failed to create AI message: %w", err)
|
||||
}
|
||||
|
||||
logger.WithFields(map[string]interface{}{
|
||||
"conversation_id": conversationID,
|
||||
"user_message_id": userMessage.ID,
|
||||
"ai_message_id": aiMessage.ID,
|
||||
}).Info("AI response created successfully")
|
||||
|
||||
return &userMessage, &aiMessage, nil
|
||||
}
|
||||
|
||||
// GetConversationStats retrieves statistics for a conversation
|
||||
func (s *ConversationService) GetConversationStats(conversationID uint, userID uint) (*models.ConversationStats, error) {
|
||||
// Check if conversation exists and belongs to user
|
||||
var conversation models.Conversation
|
||||
if err := s.db.Where("id = ? AND user_id = ?", conversationID, userID).First(&conversation).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, fmt.Errorf("conversation not found")
|
||||
}
|
||||
logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to get conversation")
|
||||
return nil, fmt.Errorf("failed to get conversation: %w", err)
|
||||
}
|
||||
|
||||
var stats models.ConversationStats
|
||||
|
||||
// Get total messages count
|
||||
if err := s.db.Model(&models.Message{}).Where("conversation_id = ?", conversationID).Count(&stats.TotalMessages).Error; err != nil {
|
||||
logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to count messages")
|
||||
return nil, fmt.Errorf("failed to count messages: %w", err)
|
||||
}
|
||||
|
||||
// Get average sentiment
|
||||
var avgSentiment sql.NullFloat64
|
||||
if err := s.db.Model(&models.Message{}).Where("conversation_id = ?", conversationID).Select("AVG(sentiment)").Scan(&avgSentiment).Error; err != nil {
|
||||
logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to calculate average sentiment")
|
||||
return nil, fmt.Errorf("failed to calculate average sentiment: %w", err)
|
||||
}
|
||||
if avgSentiment.Valid {
|
||||
stats.AverageSentiment = avgSentiment.Float64
|
||||
}
|
||||
|
||||
// Get first and last message timestamps
|
||||
var firstMessage, lastMessage models.Message
|
||||
if err := s.db.Where("conversation_id = ?", conversationID).Order("created_at ASC").First(&firstMessage).Error; err != nil {
|
||||
logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to get first message")
|
||||
return nil, fmt.Errorf("failed to get first message: %w", err)
|
||||
}
|
||||
stats.FirstMessageAt = firstMessage.CreatedAt
|
||||
|
||||
if err := s.db.Where("conversation_id = ?", conversationID).Order("created_at DESC").First(&lastMessage).Error; err != nil {
|
||||
logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to get last message")
|
||||
return nil, fmt.Errorf("failed to get last message: %w", err)
|
||||
}
|
||||
stats.LastMessageAt = lastMessage.CreatedAt
|
||||
|
||||
// Calculate average response time (time between user messages and AI responses)
|
||||
// This is a simplified calculation and could be improved
|
||||
if stats.TotalMessages > 1 {
|
||||
var responseTimes []int64
|
||||
var prevMessage models.Message
|
||||
isUserMessage := false
|
||||
|
||||
var allMessages []models.Message
|
||||
if err := s.db.Where("conversation_id = ?", conversationID).Order("created_at ASC").Find(&allMessages).Error; err != nil {
|
||||
logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to get messages for response time calculation")
|
||||
return nil, fmt.Errorf("failed to get messages for response time calculation: %w", err)
|
||||
}
|
||||
|
||||
for _, msg := range allMessages {
|
||||
if !msg.IsAI {
|
||||
if isUserMessage {
|
||||
// Consecutive user messages, skip
|
||||
prevMessage = msg
|
||||
continue
|
||||
}
|
||||
isUserMessage = true
|
||||
prevMessage = msg
|
||||
} else {
|
||||
if isUserMessage {
|
||||
// AI response to user message, calculate response time
|
||||
responseTime := msg.CreatedAt.Sub(prevMessage.CreatedAt).Seconds()
|
||||
responseTimes = append(responseTimes, int64(responseTime))
|
||||
}
|
||||
isUserMessage = false
|
||||
}
|
||||
}
|
||||
|
||||
if len(responseTimes) > 0 {
|
||||
var totalResponseTime int64 = 0
|
||||
for _, rt := range responseTimes {
|
||||
totalResponseTime += rt
|
||||
}
|
||||
stats.ResponseTime = totalResponseTime / int64(len(responseTimes))
|
||||
}
|
||||
}
|
||||
|
||||
return &stats, nil
|
||||
}
|
217
backend/internal/database/database.go
Normal file
217
backend/internal/database/database.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"customer-support-system/internal/models"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
// Connect initializes the database connection
|
||||
func Connect() error {
|
||||
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=%s TimeZone=%s",
|
||||
getEnv("DB_HOST", "localhost"),
|
||||
getEnv("DB_USER", "postgres"),
|
||||
getEnv("DB_PASSWORD", "postgres"),
|
||||
getEnv("DB_NAME", "support"),
|
||||
getEnv("DB_PORT", "5432"),
|
||||
getEnv("DB_SSLMODE", "disable"),
|
||||
getEnv("DB_TIMEZONE", "UTC"),
|
||||
)
|
||||
|
||||
newLogger := logger.New(
|
||||
log.New(os.Stdout, "\n", log.LstdFlags),
|
||||
logger.Config{
|
||||
SlowThreshold: time.Second,
|
||||
LogLevel: logger.Info,
|
||||
Colorful: false,
|
||||
},
|
||||
)
|
||||
|
||||
var err error
|
||||
DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: newLogger,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
log.Println("Database connected successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// AutoMigrate runs the database migrations
|
||||
func AutoMigrate() error {
|
||||
err := DB.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.Conversation{},
|
||||
&models.Message{},
|
||||
&models.KnowledgeBase{},
|
||||
&models.KnowledgeBaseFeedback{},
|
||||
&models.AIModel{},
|
||||
&models.AIInteraction{},
|
||||
&models.AIFallback{},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run database migrations: %w", err)
|
||||
}
|
||||
|
||||
log.Println("Database migrations completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func Close() error {
|
||||
sqlDB, err := DB.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get database instance: %w", err)
|
||||
}
|
||||
|
||||
err = sqlDB.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to close database connection: %w", err)
|
||||
}
|
||||
|
||||
log.Println("Database connection closed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDB returns the database instance
|
||||
func GetDB() *gorm.DB {
|
||||
return DB
|
||||
}
|
||||
|
||||
// getEnv gets an environment variable with a default value
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value, exists := os.LookupEnv(key); exists {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// SeedDatabase seeds the database with initial data
|
||||
func SeedDatabase() error {
|
||||
// Create default admin user if not exists
|
||||
var userCount int64
|
||||
if err := DB.Model(&models.User{}).Where("role = ?", "admin").Count(&userCount).Error; err != nil {
|
||||
return fmt.Errorf("failed to count admin users: %w", err)
|
||||
}
|
||||
|
||||
if userCount == 0 {
|
||||
adminUser := models.User{
|
||||
Username: "admin",
|
||||
Email: "admin@example.com",
|
||||
Password: "admin123",
|
||||
FirstName: "Admin",
|
||||
LastName: "User",
|
||||
Role: "admin",
|
||||
Active: true,
|
||||
}
|
||||
|
||||
if err := DB.Create(&adminUser).Error; err != nil {
|
||||
return fmt.Errorf("failed to create admin user: %w", err)
|
||||
}
|
||||
|
||||
log.Println("Default admin user created")
|
||||
}
|
||||
|
||||
// Create default AI models if not exists
|
||||
var aiModelCount int64
|
||||
if err := DB.Model(&models.AIModel{}).Count(&aiModelCount).Error; err != nil {
|
||||
return fmt.Errorf("failed to count AI models: %w", err)
|
||||
}
|
||||
|
||||
if aiModelCount == 0 {
|
||||
// Create OpenAI GPT-4 model
|
||||
openAIModel := models.AIModel{
|
||||
Name: "OpenAI GPT-4",
|
||||
Type: "openai",
|
||||
Model: "gpt-4",
|
||||
MaxTokens: 4000,
|
||||
Temperature: 0.7,
|
||||
TopP: 1.0,
|
||||
Active: true,
|
||||
Priority: 2,
|
||||
Description: "OpenAI's GPT-4 model for complex queries",
|
||||
}
|
||||
|
||||
if err := DB.Create(&openAIModel).Error; err != nil {
|
||||
return fmt.Errorf("failed to create OpenAI model: %w", err)
|
||||
}
|
||||
|
||||
// Create Local LLaMA model
|
||||
localModel := models.AIModel{
|
||||
Name: "Local LLaMA",
|
||||
Type: "local",
|
||||
Model: "llama2",
|
||||
Endpoint: "http://localhost:11434",
|
||||
MaxTokens: 2000,
|
||||
Temperature: 0.7,
|
||||
TopP: 1.0,
|
||||
Active: true,
|
||||
Priority: 1,
|
||||
Description: "Local LLaMA model for simple queries",
|
||||
}
|
||||
|
||||
if err := DB.Create(&localModel).Error; err != nil {
|
||||
return fmt.Errorf("failed to create local model: %w", err)
|
||||
}
|
||||
|
||||
log.Println("Default AI models created")
|
||||
}
|
||||
|
||||
// Create sample knowledge base entries if not exists
|
||||
var kbCount int64
|
||||
if err := DB.Model(&models.KnowledgeBase{}).Count(&kbCount).Error; err != nil {
|
||||
return fmt.Errorf("failed to count knowledge base entries: %w", err)
|
||||
}
|
||||
|
||||
if kbCount == 0 {
|
||||
sampleEntries := []models.KnowledgeBase{
|
||||
{
|
||||
Question: "How do I reset my password?",
|
||||
Answer: "To reset your password, click on the 'Forgot Password' link on the login page. Enter your email address and follow the instructions sent to your inbox.",
|
||||
Category: "Account",
|
||||
Tags: "password,reset,account",
|
||||
Priority: 5,
|
||||
Active: true,
|
||||
},
|
||||
{
|
||||
Question: "What payment methods do you accept?",
|
||||
Answer: "We accept all major credit cards including Visa, Mastercard, and American Express. We also support payments through PayPal and bank transfers.",
|
||||
Category: "Billing",
|
||||
Tags: "payment,billing,methods",
|
||||
Priority: 4,
|
||||
Active: true,
|
||||
},
|
||||
{
|
||||
Question: "How can I contact customer support?",
|
||||
Answer: "You can contact our customer support team through the chat feature on our website, by emailing support@example.com, or by calling our toll-free number at 1-800-123-4567.",
|
||||
Category: "Support",
|
||||
Tags: "contact,support,help",
|
||||
Priority: 3,
|
||||
Active: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, entry := range sampleEntries {
|
||||
if err := DB.Create(&entry).Error; err != nil {
|
||||
return fmt.Errorf("failed to create knowledge base entry: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("Sample knowledge base entries created")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
211
backend/internal/handlers/ai.go
Normal file
211
backend/internal/handlers/ai.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"customer-support-system/internal/ai"
|
||||
"customer-support-system/internal/models"
|
||||
"customer-support-system/pkg/logger"
|
||||
)
|
||||
|
||||
// AIHandler handles AI-related HTTP requests
|
||||
type AIHandler struct {
|
||||
aiService *ai.AIService
|
||||
}
|
||||
|
||||
// NewAIHandler creates a new AI handler
|
||||
func NewAIHandler() *AIHandler {
|
||||
return &AIHandler{
|
||||
aiService: ai.NewAIService(),
|
||||
}
|
||||
}
|
||||
|
||||
// QueryAI handles querying the AI service
|
||||
func (h *AIHandler) QueryAI(c *gin.Context) {
|
||||
// Check if user is authenticated
|
||||
_, exists := c.Get("userID")
|
||||
if !exists {
|
||||
logger.Error("Failed to get user ID from context")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Prompt string `json:"prompt" binding:"required"`
|
||||
ConversationHistory []models.Message `json:"conversationHistory"`
|
||||
Complexity int `json:"complexity"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.WithError(err).Error("Failed to parse query AI request")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// If complexity is not provided, analyze it
|
||||
if req.Complexity == 0 {
|
||||
req.Complexity = h.aiService.AnalyzeComplexity(req.Prompt)
|
||||
}
|
||||
|
||||
// Query AI
|
||||
ctx := context.Background()
|
||||
response, err := h.aiService.Query(ctx, req.Prompt, req.ConversationHistory, req.Complexity)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to query AI")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to query AI",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "AI query successful",
|
||||
"data": gin.H{
|
||||
"response": response,
|
||||
"complexity": req.Complexity,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// AnalyzeComplexity handles analyzing the complexity of a prompt
|
||||
func (h *AIHandler) AnalyzeComplexity(c *gin.Context) {
|
||||
var req struct {
|
||||
Prompt string `json:"prompt" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.WithError(err).Error("Failed to parse analyze complexity request")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Analyze complexity
|
||||
complexity := h.aiService.AnalyzeComplexity(req.Prompt)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Complexity analysis successful",
|
||||
"data": gin.H{
|
||||
"complexity": complexity,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetAvailableModels handles getting available AI models
|
||||
func (h *AIHandler) GetAvailableModels(c *gin.Context) {
|
||||
// Get available models
|
||||
models := h.aiService.GetAvailableModels()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"models": models,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// QueryOpenAI handles querying the OpenAI API
|
||||
func (h *AIHandler) QueryOpenAI(c *gin.Context) {
|
||||
// Check if user is authenticated
|
||||
_, exists := c.Get("userID")
|
||||
if !exists {
|
||||
logger.Error("Failed to get user ID from context")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Prompt string `json:"prompt" binding:"required"`
|
||||
ConversationHistory []models.Message `json:"conversationHistory"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.WithError(err).Error("Failed to parse query OpenAI request")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Query OpenAI
|
||||
ctx := context.Background()
|
||||
response, err := h.aiService.QueryOpenAI(ctx, req.Prompt, req.ConversationHistory)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to query OpenAI")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to query OpenAI",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "OpenAI query successful",
|
||||
"data": gin.H{
|
||||
"response": response,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// QueryOllama handles querying the Ollama API
|
||||
func (h *AIHandler) QueryOllama(c *gin.Context) {
|
||||
// Check if user is authenticated
|
||||
_, exists := c.Get("userID")
|
||||
if !exists {
|
||||
logger.Error("Failed to get user ID from context")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Prompt string `json:"prompt" binding:"required"`
|
||||
ConversationHistory []models.Message `json:"conversationHistory"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.WithError(err).Error("Failed to parse query Ollama request")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Query Ollama
|
||||
ctx := context.Background()
|
||||
response, err := h.aiService.QueryOllama(ctx, req.Prompt, req.ConversationHistory)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to query Ollama")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to query Ollama",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Ollama query successful",
|
||||
"data": gin.H{
|
||||
"response": response,
|
||||
},
|
||||
})
|
||||
}
|
558
backend/internal/handlers/conversation.go
Normal file
558
backend/internal/handlers/conversation.go
Normal file
@@ -0,0 +1,558 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"customer-support-system/internal/conversation"
|
||||
"customer-support-system/internal/models"
|
||||
"customer-support-system/pkg/logger"
|
||||
)
|
||||
|
||||
// ConversationHandler handles conversation-related HTTP requests
|
||||
type ConversationHandler struct {
|
||||
conversationService *conversation.ConversationService
|
||||
}
|
||||
|
||||
// NewConversationHandler creates a new conversation handler
|
||||
func NewConversationHandler() *ConversationHandler {
|
||||
return &ConversationHandler{
|
||||
conversationService: conversation.NewConversationService(),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateConversation handles creating a new conversation
|
||||
func (h *ConversationHandler) CreateConversation(c *gin.Context) {
|
||||
// Get user ID from context
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
logger.Error("Failed to get user ID from context")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.CreateConversationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.WithError(err).Error("Failed to parse create conversation request")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Create conversation
|
||||
conv, err := h.conversationService.CreateConversation(userID.(uint), &req)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to create conversation")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to create conversation",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"message": "Conversation created successfully",
|
||||
"data": gin.H{
|
||||
"conversation": conv,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetConversation handles getting a conversation by ID
|
||||
func (h *ConversationHandler) GetConversation(c *gin.Context) {
|
||||
// Get user ID from context
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
logger.Error("Failed to get user ID from context")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get conversation ID from URL params
|
||||
conversationID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to parse conversation ID")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid conversation ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get conversation
|
||||
conv, err := h.conversationService.GetConversation(uint(conversationID), userID.(uint))
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to get conversation")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to get conversation",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"conversation": conv,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ListConversations handles listing conversations for a user
|
||||
func (h *ConversationHandler) ListConversations(c *gin.Context) {
|
||||
// Get user ID from context
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
logger.Error("Failed to get user ID from context")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
|
||||
status := c.DefaultQuery("status", "")
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 10
|
||||
}
|
||||
|
||||
// List conversations
|
||||
response, err := h.conversationService.ListConversations(userID.(uint), page, pageSize, status)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to list conversations")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to list conversations",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"conversations": response.Conversations,
|
||||
"total": response.Total,
|
||||
"page": response.Page,
|
||||
"pageSize": response.PageSize,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateConversation handles updating a conversation
|
||||
func (h *ConversationHandler) UpdateConversation(c *gin.Context) {
|
||||
// Get user ID from context
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
logger.Error("Failed to get user ID from context")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get conversation ID from URL params
|
||||
conversationID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to parse conversation ID")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid conversation ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpdateConversationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.WithError(err).Error("Failed to parse update conversation request")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Update conversation
|
||||
conv, err := h.conversationService.UpdateConversation(uint(conversationID), userID.(uint), &req)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to update conversation")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to update conversation",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Conversation updated successfully",
|
||||
"data": gin.H{
|
||||
"conversation": conv,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteConversation handles deleting a conversation
|
||||
func (h *ConversationHandler) DeleteConversation(c *gin.Context) {
|
||||
// Get user ID from context
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
logger.Error("Failed to get user ID from context")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get conversation ID from URL params
|
||||
conversationID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to parse conversation ID")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid conversation ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete conversation
|
||||
err = h.conversationService.DeleteConversation(uint(conversationID), userID.(uint))
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to delete conversation")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to delete conversation",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Conversation deleted successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// CreateMessage handles creating a new message in a conversation
|
||||
func (h *ConversationHandler) CreateMessage(c *gin.Context) {
|
||||
// Get user ID from context
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
logger.Error("Failed to get user ID from context")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get conversation ID from URL params
|
||||
conversationID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to parse conversation ID")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid conversation ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.CreateMessageRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.WithError(err).Error("Failed to parse create message request")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Create message
|
||||
message, err := h.conversationService.CreateMessage(uint(conversationID), userID.(uint), &req)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to create message")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to create message",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"message": "Message created successfully",
|
||||
"data": gin.H{
|
||||
"message": message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetMessages handles getting messages in a conversation
|
||||
func (h *ConversationHandler) GetMessages(c *gin.Context) {
|
||||
// Get user ID from context
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
logger.Error("Failed to get user ID from context")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get conversation ID from URL params
|
||||
conversationID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to parse conversation ID")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid conversation ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
// Get messages
|
||||
response, err := h.conversationService.GetMessages(uint(conversationID), userID.(uint), page, pageSize)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to get messages")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to get messages",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"messages": response.Messages,
|
||||
"total": response.Total,
|
||||
"page": response.Page,
|
||||
"pageSize": response.PageSize,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateMessage handles updating a message
|
||||
func (h *ConversationHandler) UpdateMessage(c *gin.Context) {
|
||||
// Get user ID from context
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
logger.Error("Failed to get user ID from context")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get message ID from URL params
|
||||
messageID, err := strconv.ParseUint(c.Param("messageId"), 10, 32)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to parse message ID")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid message ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpdateMessageRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.WithError(err).Error("Failed to parse update message request")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Update message
|
||||
message, err := h.conversationService.UpdateMessage(uint(messageID), userID.(uint), &req)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to update message")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to update message",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Message updated successfully",
|
||||
"data": gin.H{
|
||||
"message": message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteMessage handles deleting a message
|
||||
func (h *ConversationHandler) DeleteMessage(c *gin.Context) {
|
||||
// Get user ID from context
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
logger.Error("Failed to get user ID from context")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get message ID from URL params
|
||||
messageID, err := strconv.ParseUint(c.Param("messageId"), 10, 32)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to parse message ID")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid message ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete message
|
||||
err = h.conversationService.DeleteMessage(uint(messageID), userID.(uint))
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to delete message")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to delete message",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Message deleted successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// SendMessageWithAI handles sending a message and getting an AI response
|
||||
func (h *ConversationHandler) SendMessageWithAI(c *gin.Context) {
|
||||
// Get user ID from context
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
logger.Error("Failed to get user ID from context")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get conversation ID from URL params
|
||||
conversationID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to parse conversation ID")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid conversation ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Content string `json:"content" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.WithError(err).Error("Failed to parse send message request")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Send message and get AI response
|
||||
userMessage, aiMessage, err := h.conversationService.SendMessageWithAI(uint(conversationID), userID.(uint), req.Content)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to send message with AI")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to send message with AI",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"message": "Message sent and AI response received",
|
||||
"data": gin.H{
|
||||
"userMessage": userMessage,
|
||||
"aiMessage": aiMessage,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetConversationStats handles getting statistics for a conversation
|
||||
func (h *ConversationHandler) GetConversationStats(c *gin.Context) {
|
||||
// Get user ID from context
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
logger.Error("Failed to get user ID from context")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get conversation ID from URL params
|
||||
conversationID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to parse conversation ID")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid conversation ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get conversation stats
|
||||
stats, err := h.conversationService.GetConversationStats(uint(conversationID), userID.(uint))
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to get conversation stats")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to get conversation stats",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"stats": stats,
|
||||
},
|
||||
})
|
||||
}
|
475
backend/internal/handlers/knowledge.go
Normal file
475
backend/internal/handlers/knowledge.go
Normal file
@@ -0,0 +1,475 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"customer-support-system/internal/knowledge"
|
||||
"customer-support-system/internal/models"
|
||||
"customer-support-system/pkg/logger"
|
||||
)
|
||||
|
||||
// KnowledgeHandler handles knowledge base-related HTTP requests
|
||||
type KnowledgeHandler struct {
|
||||
knowledgeService *knowledge.KnowledgeService
|
||||
}
|
||||
|
||||
// NewKnowledgeHandler creates a new knowledge handler
|
||||
func NewKnowledgeHandler() *KnowledgeHandler {
|
||||
return &KnowledgeHandler{
|
||||
knowledgeService: knowledge.NewKnowledgeService(),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateKnowledgeEntry handles creating a new knowledge base entry
|
||||
func (h *KnowledgeHandler) CreateKnowledgeEntry(c *gin.Context) {
|
||||
// Get user ID from context
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
logger.Error("Failed to get user ID from context")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.CreateKnowledgeBaseRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.WithError(err).Error("Failed to parse create knowledge entry request")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Create knowledge entry
|
||||
entry, err := h.knowledgeService.CreateKnowledgeEntry(userID.(uint), &req)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to create knowledge entry")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to create knowledge entry",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"message": "Knowledge entry created successfully",
|
||||
"data": gin.H{
|
||||
"entry": entry,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetKnowledgeEntry handles getting a knowledge base entry by ID
|
||||
func (h *KnowledgeHandler) GetKnowledgeEntry(c *gin.Context) {
|
||||
// Get knowledge entry ID from URL params
|
||||
entryID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to parse knowledge entry ID")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid knowledge entry ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get knowledge entry
|
||||
entry, err := h.knowledgeService.GetKnowledgeEntry(uint(entryID))
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to get knowledge entry")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to get knowledge entry",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"entry": entry,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ListKnowledgeEntries handles listing knowledge base entries
|
||||
func (h *KnowledgeHandler) ListKnowledgeEntries(c *gin.Context) {
|
||||
// Parse query parameters
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
|
||||
category := c.DefaultQuery("category", "")
|
||||
activeStr := c.DefaultQuery("active", "true")
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 10
|
||||
}
|
||||
|
||||
// Parse active parameter
|
||||
var active *bool
|
||||
if activeStr != "" {
|
||||
activeVal := activeStr == "true"
|
||||
active = &activeVal
|
||||
}
|
||||
|
||||
// List knowledge entries
|
||||
response, err := h.knowledgeService.ListKnowledgeEntries(page, pageSize, category, active)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to list knowledge entries")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to list knowledge entries",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"entries": response.Entries,
|
||||
"total": response.Total,
|
||||
"page": response.Page,
|
||||
"pageSize": response.PageSize,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateKnowledgeEntry handles updating a knowledge base entry
|
||||
func (h *KnowledgeHandler) UpdateKnowledgeEntry(c *gin.Context) {
|
||||
// Get user ID from context
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
logger.Error("Failed to get user ID from context")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get knowledge entry ID from URL params
|
||||
entryID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to parse knowledge entry ID")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid knowledge entry ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpdateKnowledgeBaseRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.WithError(err).Error("Failed to parse update knowledge entry request")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Update knowledge entry
|
||||
entry, err := h.knowledgeService.UpdateKnowledgeEntry(uint(entryID), userID.(uint), &req)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to update knowledge entry")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to update knowledge entry",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Knowledge entry updated successfully",
|
||||
"data": gin.H{
|
||||
"entry": entry,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteKnowledgeEntry handles deleting a knowledge base entry
|
||||
func (h *KnowledgeHandler) DeleteKnowledgeEntry(c *gin.Context) {
|
||||
// Get user ID from context
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
logger.Error("Failed to get user ID from context")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get knowledge entry ID from URL params
|
||||
entryID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to parse knowledge entry ID")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid knowledge entry ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete knowledge entry
|
||||
err = h.knowledgeService.DeleteKnowledgeEntry(uint(entryID), userID.(uint))
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to delete knowledge entry")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to delete knowledge entry",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Knowledge entry deleted successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// SearchKnowledge handles searching knowledge base entries
|
||||
func (h *KnowledgeHandler) SearchKnowledge(c *gin.Context) {
|
||||
// Parse query parameters
|
||||
query := c.DefaultQuery("query", "")
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
|
||||
category := c.DefaultQuery("category", "")
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 10
|
||||
}
|
||||
|
||||
// Search knowledge entries
|
||||
response, err := h.knowledgeService.SearchKnowledge(query, page, pageSize, category)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to search knowledge entries")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to search knowledge entries",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"results": response.Results,
|
||||
"total": response.Total,
|
||||
"query": response.Query,
|
||||
"page": response.Page,
|
||||
"pageSize": response.PageSize,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetCategories handles getting all unique categories in the knowledge base
|
||||
func (h *KnowledgeHandler) GetCategories(c *gin.Context) {
|
||||
// Get categories
|
||||
categories, err := h.knowledgeService.GetCategories()
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to get knowledge categories")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to get knowledge categories",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"categories": categories,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetTags handles getting all unique tags in the knowledge base
|
||||
func (h *KnowledgeHandler) GetTags(c *gin.Context) {
|
||||
// Get tags
|
||||
tags, err := h.knowledgeService.GetTags()
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to get knowledge tags")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to get knowledge tags",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"tags": tags,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// RateKnowledgeEntry handles rating a knowledge base entry as helpful or not
|
||||
func (h *KnowledgeHandler) RateKnowledgeEntry(c *gin.Context) {
|
||||
// Get user ID from context
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
logger.Error("Failed to get user ID from context")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get knowledge entry ID from URL params
|
||||
entryID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to parse knowledge entry ID")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid knowledge entry ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.CreateKnowledgeBaseFeedbackRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.WithError(err).Error("Failed to parse rate knowledge entry request")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Rate knowledge entry
|
||||
err = h.knowledgeService.RateKnowledgeEntry(uint(entryID), userID.(uint), req.Helpful, req.Comment)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to rate knowledge entry")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to rate knowledge entry",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Knowledge entry rated successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// GetPopularKnowledge handles getting popular knowledge base entries
|
||||
func (h *KnowledgeHandler) GetPopularKnowledge(c *gin.Context) {
|
||||
// Parse query parameters
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
// Get popular knowledge entries
|
||||
entries, err := h.knowledgeService.GetPopularKnowledge(limit)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to get popular knowledge entries")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to get popular knowledge entries",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"entries": entries,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetRecentKnowledge handles getting recent knowledge base entries
|
||||
func (h *KnowledgeHandler) GetRecentKnowledge(c *gin.Context) {
|
||||
// Parse query parameters
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
// Get recent knowledge entries
|
||||
entries, err := h.knowledgeService.GetRecentKnowledge(limit)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to get recent knowledge entries")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to get recent knowledge entries",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"entries": entries,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// FindBestMatch handles finding the best matching knowledge base entry for a query
|
||||
func (h *KnowledgeHandler) FindBestMatch(c *gin.Context) {
|
||||
// Parse query parameters
|
||||
query := c.DefaultQuery("query", "")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Query parameter is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Find best match
|
||||
entry, err := h.knowledgeService.FindBestMatch(query)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to find best match")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to find best match",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"entry": entry,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetKnowledgeStats handles getting statistics for the knowledge base
|
||||
func (h *KnowledgeHandler) GetKnowledgeStats(c *gin.Context) {
|
||||
// Get knowledge stats
|
||||
stats, err := h.knowledgeService.GetKnowledgeStats()
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to get knowledge stats")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to get knowledge stats",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"stats": stats,
|
||||
},
|
||||
})
|
||||
}
|
453
backend/internal/handlers/user.go
Normal file
453
backend/internal/handlers/user.go
Normal file
@@ -0,0 +1,453 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"customer-support-system/internal/auth"
|
||||
"customer-support-system/internal/database"
|
||||
"customer-support-system/internal/models"
|
||||
"customer-support-system/pkg/logger"
|
||||
)
|
||||
|
||||
// UserHandler handles user-related HTTP requests
|
||||
type UserHandler struct {
|
||||
authService *auth.AuthService
|
||||
}
|
||||
|
||||
// NewUserHandler creates a new user handler
|
||||
func NewUserHandler() *UserHandler {
|
||||
return &UserHandler{
|
||||
authService: auth.NewAuthService(database.GetDB()),
|
||||
}
|
||||
}
|
||||
|
||||
// Register handles user registration
|
||||
func (h *UserHandler) Register(c *gin.Context) {
|
||||
var req models.CreateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.WithError(err).Error("Failed to parse register request")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate request
|
||||
if err := validateRegisterRequest(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Create user
|
||||
user, err := h.authService.Register(&req)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to register user")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to register user",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"message": "User registered successfully",
|
||||
"data": gin.H{
|
||||
"user": user,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Login handles user login
|
||||
func (h *UserHandler) Login(c *gin.Context) {
|
||||
var req models.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.WithError(err).Error("Failed to parse login request")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate request
|
||||
if err := validateLoginRequest(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get client IP
|
||||
clientIP := c.ClientIP()
|
||||
|
||||
// Authenticate user
|
||||
response, err := h.authService.Login(req.Username, req.Password, clientIP)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to login user")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid username or password",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "User logged in successfully",
|
||||
"data": gin.H{
|
||||
"user": response.User,
|
||||
"token": response.Token,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetProfile handles getting user profile
|
||||
func (h *UserHandler) GetProfile(c *gin.Context) {
|
||||
// Get user ID from context
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
logger.Error("Failed to get user ID from context")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get user profile
|
||||
user, err := h.authService.GetUserByID(userID.(uint))
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to get user profile")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to get user profile",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"user": user.ToSafeUser(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateProfile handles updating user profile
|
||||
func (h *UserHandler) UpdateProfile(c *gin.Context) {
|
||||
// Get user ID from context
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
logger.Error("Failed to get user ID from context")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpdateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.WithError(err).Error("Failed to parse update profile request")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Update user profile
|
||||
user, err := h.authService.UpdateUser(userID.(uint), &req)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to update user profile")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to update user profile",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "User profile updated successfully",
|
||||
"data": gin.H{
|
||||
"user": user,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ChangePassword handles changing user password
|
||||
func (h *UserHandler) ChangePassword(c *gin.Context) {
|
||||
// Get user ID from context
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
logger.Error("Failed to get user ID from context")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.ChangePasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.WithError(err).Error("Failed to parse change password request")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate request
|
||||
if err := validateChangePasswordRequest(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Change password
|
||||
err := h.authService.ChangePassword(userID.(uint), &req)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to change password")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to change password",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Password changed successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// validateRegisterRequest validates the register request
|
||||
func validateRegisterRequest(req *models.CreateUserRequest) error {
|
||||
if req.Username == "" {
|
||||
return fmt.Errorf("username is required")
|
||||
}
|
||||
if req.Password == "" {
|
||||
return fmt.Errorf("password is required")
|
||||
}
|
||||
if req.Email == "" {
|
||||
return fmt.Errorf("email is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateLoginRequest validates the login request
|
||||
func validateLoginRequest(req *models.LoginRequest) error {
|
||||
if req.Username == "" {
|
||||
return fmt.Errorf("username is required")
|
||||
}
|
||||
if req.Password == "" {
|
||||
return fmt.Errorf("password is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateChangePasswordRequest validates the change password request
|
||||
func validateChangePasswordRequest(req *models.ChangePasswordRequest) error {
|
||||
if req.CurrentPassword == "" {
|
||||
return fmt.Errorf("current password is required")
|
||||
}
|
||||
if req.NewPassword == "" {
|
||||
return fmt.Errorf("new password is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdminGetUsers handles getting all users (admin only)
|
||||
func (h *UserHandler) AdminGetUsers(c *gin.Context) {
|
||||
// Parse query parameters
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 10
|
||||
}
|
||||
|
||||
// Get users
|
||||
var users []models.User
|
||||
var total int64
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
// Get total count
|
||||
if err := db.Model(&models.User{}).Count(&total).Error; err != nil {
|
||||
logger.WithError(err).Error("Failed to count users")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to count users",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get paginated results
|
||||
offset := (page - 1) * pageSize
|
||||
if err := db.Offset(offset).Limit(pageSize).Find(&users).Error; err != nil {
|
||||
logger.WithError(err).Error("Failed to get users")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to get users",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to safe users
|
||||
safeUsers := make([]models.SafeUser, len(users))
|
||||
for i, user := range users {
|
||||
safeUsers[i] = user.ToSafeUser()
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"users": safeUsers,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// AdminGetUser handles getting a user by ID (admin only)
|
||||
func (h *UserHandler) AdminGetUser(c *gin.Context) {
|
||||
// Get target user ID from URL params
|
||||
targetUserID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to parse user ID")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid user ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get user
|
||||
user, err := h.authService.GetUserByID(uint(targetUserID))
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to get user")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to get user",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"user": user.ToSafeUser(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// AdminUpdateUser handles updating a user (admin only)
|
||||
func (h *UserHandler) AdminUpdateUser(c *gin.Context) {
|
||||
// Get target user ID from URL params
|
||||
targetUserID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to parse user ID")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid user ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpdateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.WithError(err).Error("Failed to parse update user request")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Update user
|
||||
updatedUser, err := h.authService.UpdateUser(uint(targetUserID), &req)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to update user")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to update user",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "User updated successfully",
|
||||
"data": gin.H{
|
||||
"user": updatedUser,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// AdminDeleteUser handles deleting a user (admin only)
|
||||
func (h *UserHandler) AdminDeleteUser(c *gin.Context) {
|
||||
// Get target user ID from URL params
|
||||
targetUserID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to parse user ID")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid user ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get current user ID from context
|
||||
currentUserID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
logger.Error("Failed to get current user ID from context")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent self-deletion
|
||||
if currentUserID.(uint) == uint(targetUserID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Cannot delete your own account",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete user
|
||||
db := database.GetDB()
|
||||
if err := db.Delete(&models.User{}, uint(targetUserID)).Error; err != nil {
|
||||
logger.WithError(err).Error("Failed to delete user")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "Failed to delete user",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "User deleted successfully",
|
||||
})
|
||||
}
|
553
backend/internal/knowledge/knowledge.go
Normal file
553
backend/internal/knowledge/knowledge.go
Normal file
@@ -0,0 +1,553 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"customer-support-system/internal/database"
|
||||
"customer-support-system/internal/models"
|
||||
"customer-support-system/pkg/logger"
|
||||
)
|
||||
|
||||
// KnowledgeService handles knowledge base operations
|
||||
type KnowledgeService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewKnowledgeService creates a new knowledge service
|
||||
func NewKnowledgeService() *KnowledgeService {
|
||||
return &KnowledgeService{
|
||||
db: database.GetDB(),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateKnowledgeEntry creates a new knowledge base entry
|
||||
func (s *KnowledgeService) CreateKnowledgeEntry(userID uint, req *models.CreateKnowledgeBaseRequest) (*models.KnowledgeBase, error) {
|
||||
knowledge := models.KnowledgeBase{
|
||||
Question: req.Question,
|
||||
Answer: req.Answer,
|
||||
Category: req.Category,
|
||||
Tags: req.Tags,
|
||||
Priority: req.Priority,
|
||||
ViewCount: 0,
|
||||
Helpful: 0,
|
||||
NotHelpful: 0,
|
||||
Active: true,
|
||||
CreatedBy: userID,
|
||||
UpdatedBy: userID,
|
||||
}
|
||||
|
||||
if err := s.db.Create(&knowledge).Error; err != nil {
|
||||
logger.WithError(err).WithField("user_id", userID).Error("Failed to create knowledge entry")
|
||||
return nil, fmt.Errorf("failed to create knowledge entry: %w", err)
|
||||
}
|
||||
|
||||
logger.WithField("knowledge_id", knowledge.ID).Info("Knowledge entry created successfully")
|
||||
|
||||
return &knowledge, nil
|
||||
}
|
||||
|
||||
// GetKnowledgeEntry retrieves a knowledge base entry by ID
|
||||
func (s *KnowledgeService) GetKnowledgeEntry(knowledgeID uint) (*models.KnowledgeBase, error) {
|
||||
var knowledge models.KnowledgeBase
|
||||
if err := s.db.First(&knowledge, knowledgeID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, fmt.Errorf("knowledge entry not found")
|
||||
}
|
||||
logger.WithError(err).WithField("knowledge_id", knowledgeID).Error("Failed to get knowledge entry")
|
||||
return nil, fmt.Errorf("failed to get knowledge entry: %w", err)
|
||||
}
|
||||
|
||||
// Increment view count
|
||||
s.db.Model(&knowledge).Update("view_count", knowledge.ViewCount+1)
|
||||
|
||||
return &knowledge, nil
|
||||
}
|
||||
|
||||
// ListKnowledgeEntries retrieves a list of knowledge base entries
|
||||
func (s *KnowledgeService) ListKnowledgeEntries(page, pageSize int, category string, active *bool) (*models.KnowledgeBaseListResponse, error) {
|
||||
var knowledge []models.KnowledgeBase
|
||||
var total int64
|
||||
|
||||
query := s.db.Model(&models.KnowledgeBase{})
|
||||
|
||||
if category != "" {
|
||||
query = query.Where("category = ?", category)
|
||||
}
|
||||
|
||||
if active != nil {
|
||||
query = query.Where("active = ?", *active)
|
||||
}
|
||||
|
||||
// Get total count
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
logger.WithError(err).Error("Failed to count knowledge entries")
|
||||
return nil, fmt.Errorf("failed to count knowledge entries: %w", err)
|
||||
}
|
||||
|
||||
// Get paginated results
|
||||
offset := (page - 1) * pageSize
|
||||
if err := query.Order("priority DESC, view_count DESC, created_at DESC").Offset(offset).Limit(pageSize).Find(&knowledge).Error; err != nil {
|
||||
logger.WithError(err).Error("Failed to list knowledge entries")
|
||||
return nil, fmt.Errorf("failed to list knowledge entries: %w", err)
|
||||
}
|
||||
|
||||
return &models.KnowledgeBaseListResponse{
|
||||
Entries: knowledge,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateKnowledgeEntry updates a knowledge base entry
|
||||
func (s *KnowledgeService) UpdateKnowledgeEntry(knowledgeID uint, userID uint, req *models.UpdateKnowledgeBaseRequest) (*models.KnowledgeBase, error) {
|
||||
var knowledge models.KnowledgeBase
|
||||
if err := s.db.First(&knowledge, knowledgeID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, fmt.Errorf("knowledge entry not found")
|
||||
}
|
||||
logger.WithError(err).WithField("knowledge_id", knowledgeID).Error("Failed to get knowledge entry")
|
||||
return nil, fmt.Errorf("failed to get knowledge entry: %w", err)
|
||||
}
|
||||
|
||||
// Check if user is the creator or an admin
|
||||
if knowledge.CreatedBy != userID {
|
||||
// Check if user is admin
|
||||
var user models.User
|
||||
if err := s.db.First(&user, userID).Error; err != nil {
|
||||
logger.WithError(err).WithField("user_id", userID).Error("Failed to get user")
|
||||
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
if user.Role != "admin" {
|
||||
return nil, fmt.Errorf("unauthorized to update this knowledge entry")
|
||||
}
|
||||
}
|
||||
|
||||
// Update fields
|
||||
if req.Question != "" {
|
||||
knowledge.Question = req.Question
|
||||
}
|
||||
if req.Answer != "" {
|
||||
knowledge.Answer = req.Answer
|
||||
}
|
||||
if req.Category != "" {
|
||||
knowledge.Category = req.Category
|
||||
}
|
||||
if req.Tags != "" {
|
||||
knowledge.Tags = req.Tags
|
||||
}
|
||||
if req.Priority != 0 {
|
||||
knowledge.Priority = req.Priority
|
||||
}
|
||||
if req.Active != nil {
|
||||
knowledge.Active = *req.Active
|
||||
}
|
||||
knowledge.UpdatedBy = userID
|
||||
|
||||
if err := s.db.Save(&knowledge).Error; err != nil {
|
||||
logger.WithError(err).WithField("knowledge_id", knowledgeID).Error("Failed to update knowledge entry")
|
||||
return nil, fmt.Errorf("failed to update knowledge entry: %w", err)
|
||||
}
|
||||
|
||||
logger.WithField("knowledge_id", knowledgeID).Info("Knowledge entry updated successfully")
|
||||
|
||||
return &knowledge, nil
|
||||
}
|
||||
|
||||
// DeleteKnowledgeEntry deletes a knowledge base entry
|
||||
func (s *KnowledgeService) DeleteKnowledgeEntry(knowledgeID uint, userID uint) error {
|
||||
var knowledge models.KnowledgeBase
|
||||
if err := s.db.First(&knowledge, knowledgeID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return fmt.Errorf("knowledge entry not found")
|
||||
}
|
||||
logger.WithError(err).WithField("knowledge_id", knowledgeID).Error("Failed to get knowledge entry")
|
||||
return fmt.Errorf("failed to get knowledge entry: %w", err)
|
||||
}
|
||||
|
||||
// Check if user is the creator or an admin
|
||||
if knowledge.CreatedBy != userID {
|
||||
// Check if user is admin
|
||||
var user models.User
|
||||
if err := s.db.First(&user, userID).Error; err != nil {
|
||||
logger.WithError(err).WithField("user_id", userID).Error("Failed to get user")
|
||||
return fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
if user.Role != "admin" {
|
||||
return fmt.Errorf("unauthorized to delete this knowledge entry")
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.db.Delete(&knowledge).Error; err != nil {
|
||||
logger.WithError(err).WithField("knowledge_id", knowledgeID).Error("Failed to delete knowledge entry")
|
||||
return fmt.Errorf("failed to delete knowledge entry: %w", err)
|
||||
}
|
||||
|
||||
logger.WithField("knowledge_id", knowledgeID).Info("Knowledge entry deleted successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SearchKnowledge searches for knowledge base entries
|
||||
func (s *KnowledgeService) SearchKnowledge(query string, page, pageSize int, category string) (*models.KnowledgeBaseSearchResponse, error) {
|
||||
var knowledge []models.KnowledgeBase
|
||||
var total int64
|
||||
|
||||
// Build search query
|
||||
searchQuery := s.db.Model(&models.KnowledgeBase{})
|
||||
|
||||
if query != "" {
|
||||
// Search in question, answer, and tags
|
||||
searchQuery = searchQuery.Where(
|
||||
"active = ? AND (question ILIKE ? OR answer ILIKE ? OR tags ILIKE ?)",
|
||||
true,
|
||||
"%"+query+"%",
|
||||
"%"+query+"%",
|
||||
"%"+query+"%",
|
||||
)
|
||||
} else {
|
||||
searchQuery = searchQuery.Where("active = ?", true)
|
||||
}
|
||||
|
||||
if category != "" {
|
||||
searchQuery = searchQuery.Where("category = ?", category)
|
||||
}
|
||||
|
||||
// Get total count
|
||||
if err := searchQuery.Count(&total).Error; err != nil {
|
||||
logger.WithError(err).WithField("query", query).Error("Failed to count knowledge entries")
|
||||
return nil, fmt.Errorf("failed to count knowledge entries: %w", err)
|
||||
}
|
||||
|
||||
// Get paginated results
|
||||
offset := (page - 1) * pageSize
|
||||
if err := searchQuery.Order("priority DESC, view_count DESC, created_at DESC").Offset(offset).Limit(pageSize).Find(&knowledge).Error; err != nil {
|
||||
logger.WithError(err).WithField("query", query).Error("Failed to search knowledge entries")
|
||||
return nil, fmt.Errorf("failed to search knowledge entries: %w", err)
|
||||
}
|
||||
|
||||
// Convert to search results with relevance scores
|
||||
results := make([]models.KnowledgeBaseSearchResult, len(knowledge))
|
||||
for i, entry := range knowledge {
|
||||
results[i] = models.KnowledgeBaseSearchResult{
|
||||
KnowledgeBase: entry,
|
||||
RelevanceScore: s.calculateRelevanceScore(query, entry),
|
||||
MatchedFields: s.getMatchedFields(query, entry),
|
||||
}
|
||||
}
|
||||
|
||||
return &models.KnowledgeBaseSearchResponse{
|
||||
Results: results,
|
||||
Total: total,
|
||||
Query: query,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCategories retrieves all unique categories in the knowledge base
|
||||
func (s *KnowledgeService) GetCategories() ([]string, error) {
|
||||
var categories []string
|
||||
if err := s.db.Model(&models.KnowledgeBase{}).Where("active = ?", true).Distinct().Pluck("category", &categories).Error; err != nil {
|
||||
logger.WithError(err).Error("Failed to get knowledge categories")
|
||||
return nil, fmt.Errorf("failed to get knowledge categories: %w", err)
|
||||
}
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
// GetTags retrieves all unique tags in the knowledge base
|
||||
func (s *KnowledgeService) GetTags() ([]string, error) {
|
||||
var tags []string
|
||||
if err := s.db.Model(&models.KnowledgeBase{}).Where("active = ?", true).Find(&[]models.KnowledgeBase{}).Error; err != nil {
|
||||
logger.WithError(err).Error("Failed to get knowledge entries for tags")
|
||||
return nil, fmt.Errorf("failed to get knowledge entries for tags: %w", err)
|
||||
}
|
||||
|
||||
// Extract and deduplicate tags
|
||||
tagMap := make(map[string]bool)
|
||||
for _, entry := range []models.KnowledgeBase{} {
|
||||
if entry.Tags != "" {
|
||||
entryTags := strings.Split(entry.Tags, ",")
|
||||
for _, tag := range entryTags {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag != "" {
|
||||
tagMap[tag] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert map to slice
|
||||
tags = make([]string, 0, len(tagMap))
|
||||
for tag := range tagMap {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
// RateKnowledgeEntry rates a knowledge base entry as helpful or not
|
||||
func (s *KnowledgeService) RateKnowledgeEntry(knowledgeID uint, userID uint, helpful bool, comment string) error {
|
||||
var knowledge models.KnowledgeBase
|
||||
if err := s.db.First(&knowledge, knowledgeID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return fmt.Errorf("knowledge entry not found")
|
||||
}
|
||||
logger.WithError(err).WithField("knowledge_id", knowledgeID).Error("Failed to get knowledge entry")
|
||||
return fmt.Errorf("failed to get knowledge entry: %w", err)
|
||||
}
|
||||
|
||||
// Create feedback record
|
||||
feedback := models.KnowledgeBaseFeedback{
|
||||
KnowledgeBaseID: knowledgeID,
|
||||
UserID: userID,
|
||||
Helpful: helpful,
|
||||
Comment: comment,
|
||||
}
|
||||
|
||||
if err := s.db.Create(&feedback).Error; err != nil {
|
||||
logger.WithError(err).WithField("knowledge_id", knowledgeID).Error("Failed to create knowledge feedback")
|
||||
return fmt.Errorf("failed to create knowledge feedback: %w", err)
|
||||
}
|
||||
|
||||
// Update helpful/not helpful counts
|
||||
if helpful {
|
||||
knowledge.Helpful++
|
||||
} else {
|
||||
knowledge.NotHelpful++
|
||||
}
|
||||
|
||||
if err := s.db.Save(&knowledge).Error; err != nil {
|
||||
logger.WithError(err).WithField("knowledge_id", knowledgeID).Error("Failed to rate knowledge entry")
|
||||
return fmt.Errorf("failed to rate knowledge entry: %w", err)
|
||||
}
|
||||
|
||||
logger.WithFields(map[string]interface{}{
|
||||
"knowledge_id": knowledgeID,
|
||||
"user_id": userID,
|
||||
"helpful": helpful,
|
||||
}).Info("Knowledge entry rated successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPopularKnowledge retrieves popular knowledge base entries
|
||||
func (s *KnowledgeService) GetPopularKnowledge(limit int) ([]models.KnowledgeBase, error) {
|
||||
var knowledge []models.KnowledgeBase
|
||||
if err := s.db.Where("active = ?", true).
|
||||
Order("view_count DESC, helpful DESC").
|
||||
Limit(limit).
|
||||
Find(&knowledge).Error; err != nil {
|
||||
logger.WithError(err).Error("Failed to get popular knowledge entries")
|
||||
return nil, fmt.Errorf("failed to get popular knowledge entries: %w", err)
|
||||
}
|
||||
return knowledge, nil
|
||||
}
|
||||
|
||||
// GetRecentKnowledge retrieves recent knowledge base entries
|
||||
func (s *KnowledgeService) GetRecentKnowledge(limit int) ([]models.KnowledgeBase, error) {
|
||||
var knowledge []models.KnowledgeBase
|
||||
if err := s.db.Where("active = ?", true).
|
||||
Order("created_at DESC").
|
||||
Limit(limit).
|
||||
Find(&knowledge).Error; err != nil {
|
||||
logger.WithError(err).Error("Failed to get recent knowledge entries")
|
||||
return nil, fmt.Errorf("failed to get recent knowledge entries: %w", err)
|
||||
}
|
||||
return knowledge, nil
|
||||
}
|
||||
|
||||
// FindBestMatch finds the best matching knowledge base entry for a query
|
||||
func (s *KnowledgeService) FindBestMatch(query string) (*models.KnowledgeBase, error) {
|
||||
// This is a simple implementation that looks for exact matches in tags
|
||||
// In a real implementation, this would use more sophisticated algorithms like TF-IDF or embeddings
|
||||
|
||||
var knowledge models.KnowledgeBase
|
||||
if err := s.db.Where("active = ? AND tags ILIKE ?", true, "%"+query+"%").
|
||||
Order("priority DESC, view_count DESC, helpful DESC").
|
||||
First(&knowledge).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Try to find a match in the question
|
||||
if err := s.db.Where("active = ? AND question ILIKE ?", true, "%"+query+"%").
|
||||
Order("priority DESC, view_count DESC, helpful DESC").
|
||||
First(&knowledge).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Try to find a match in the answer
|
||||
if err := s.db.Where("active = ? AND answer ILIKE ?", true, "%"+query+"%").
|
||||
Order("priority DESC, view_count DESC, helpful DESC").
|
||||
First(&knowledge).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, fmt.Errorf("no matching knowledge entry found")
|
||||
}
|
||||
logger.WithError(err).WithField("query", query).Error("Failed to find best match in answer")
|
||||
return nil, fmt.Errorf("failed to find best match in answer: %w", err)
|
||||
}
|
||||
}
|
||||
logger.WithError(err).WithField("query", query).Error("Failed to find best match in question")
|
||||
return nil, fmt.Errorf("failed to find best match in question: %w", err)
|
||||
}
|
||||
}
|
||||
logger.WithError(err).WithField("query", query).Error("Failed to find best match in tags")
|
||||
return nil, fmt.Errorf("failed to find best match in tags: %w", err)
|
||||
}
|
||||
|
||||
return &knowledge, nil
|
||||
}
|
||||
|
||||
// GetKnowledgeStats retrieves statistics for the knowledge base
|
||||
func (s *KnowledgeService) GetKnowledgeStats() (*models.KnowledgeBaseStats, error) {
|
||||
var stats models.KnowledgeBaseStats
|
||||
|
||||
// Get total entries
|
||||
if err := s.db.Model(&models.KnowledgeBase{}).Where("active = ?", true).Count(&stats.TotalEntries).Error; err != nil {
|
||||
logger.WithError(err).Error("Failed to count knowledge entries")
|
||||
return nil, fmt.Errorf("failed to count knowledge entries: %w", err)
|
||||
}
|
||||
|
||||
// Get total views
|
||||
var totalViews sql.NullInt64
|
||||
if err := s.db.Model(&models.KnowledgeBase{}).Where("active = ?", true).Select("SUM(view_count)").Scan(&totalViews).Error; err != nil {
|
||||
logger.WithError(err).Error("Failed to calculate total views")
|
||||
return nil, fmt.Errorf("failed to calculate total views: %w", err)
|
||||
}
|
||||
if totalViews.Valid {
|
||||
stats.TotalViews = totalViews.Int64
|
||||
}
|
||||
|
||||
// Get average helpful percentage
|
||||
var avgHelpful sql.NullFloat64
|
||||
if err := s.db.Model(&models.KnowledgeBase{}).Where("active = ? AND helpful + not_helpful > 0", true).
|
||||
Select("AVG(helpful::float / (helpful + not_helpful)::float * 100)").Scan(&avgHelpful).Error; err != nil {
|
||||
logger.WithError(err).Error("Failed to calculate average helpful percentage")
|
||||
return nil, fmt.Errorf("failed to calculate average helpful percentage: %w", err)
|
||||
}
|
||||
if avgHelpful.Valid {
|
||||
stats.AverageHelpful = avgHelpful.Float64
|
||||
}
|
||||
|
||||
// Get top categories
|
||||
var categoryStats []models.CategoryStat
|
||||
if err := s.db.Model(&models.KnowledgeBase{}).
|
||||
Where("active = ?", true).
|
||||
Select("category, COUNT(*) as count").
|
||||
Group("category").
|
||||
Order("count DESC").
|
||||
Limit(5).
|
||||
Scan(&categoryStats).Error; err != nil {
|
||||
logger.WithError(err).Error("Failed to get category statistics")
|
||||
return nil, fmt.Errorf("failed to get category statistics: %w", err)
|
||||
}
|
||||
stats.TopCategories = categoryStats
|
||||
|
||||
// Get top tags
|
||||
var tagStats []models.TagStat
|
||||
if err := s.db.Model(&models.KnowledgeBase{}).
|
||||
Where("active = ? AND tags != ''", true).
|
||||
Select("unnest(string_to_array(tags, ',')) as tag, COUNT(*) as count").
|
||||
Group("tag").
|
||||
Order("count DESC").
|
||||
Limit(10).
|
||||
Scan(&tagStats).Error; err != nil {
|
||||
logger.WithError(err).Error("Failed to get tag statistics")
|
||||
return nil, fmt.Errorf("failed to get tag statistics: %w", err)
|
||||
}
|
||||
stats.TopTags = tagStats
|
||||
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
// calculateRelevanceScore calculates a relevance score for a knowledge base entry against a query
|
||||
func (s *KnowledgeService) calculateRelevanceScore(query string, entry models.KnowledgeBase) float64 {
|
||||
if query == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
query = strings.ToLower(query)
|
||||
score := 0.0
|
||||
|
||||
// Check for exact match in question
|
||||
if strings.Contains(strings.ToLower(entry.Question), query) {
|
||||
score += 10.0
|
||||
}
|
||||
|
||||
// Check for exact match in answer
|
||||
if strings.Contains(strings.ToLower(entry.Answer), query) {
|
||||
score += 5.0
|
||||
}
|
||||
|
||||
// Check for exact match in tags
|
||||
if strings.Contains(strings.ToLower(entry.Tags), query) {
|
||||
score += 7.0
|
||||
}
|
||||
|
||||
// Check for word matches
|
||||
queryWords := strings.Fields(query)
|
||||
questionWords := strings.Fields(strings.ToLower(entry.Question))
|
||||
answerWords := strings.Fields(strings.ToLower(entry.Answer))
|
||||
tagsWords := strings.Fields(strings.ToLower(entry.Tags))
|
||||
|
||||
for _, word := range queryWords {
|
||||
// Check question words
|
||||
for _, qWord := range questionWords {
|
||||
if strings.EqualFold(word, qWord) {
|
||||
score += 2.0
|
||||
}
|
||||
}
|
||||
|
||||
// Check answer words
|
||||
for _, aWord := range answerWords {
|
||||
if strings.EqualFold(word, aWord) {
|
||||
score += 1.0
|
||||
}
|
||||
}
|
||||
|
||||
// Check tag words
|
||||
for _, tWord := range tagsWords {
|
||||
if strings.EqualFold(word, tWord) {
|
||||
score += 3.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Boost score based on priority
|
||||
score += float64(entry.Priority) * 0.5
|
||||
|
||||
// Boost score based on helpfulness
|
||||
if entry.Helpful+entry.NotHelpful > 0 {
|
||||
helpfulness := float64(entry.Helpful) / float64(entry.Helpful+entry.NotHelpful)
|
||||
score += helpfulness * 2.0
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
// getMatchedFields returns the fields that matched the query
|
||||
func (s *KnowledgeService) getMatchedFields(query string, entry models.KnowledgeBase) []string {
|
||||
if query == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
query = strings.ToLower(query)
|
||||
matchedFields := []string{}
|
||||
|
||||
// Check question
|
||||
if strings.Contains(strings.ToLower(entry.Question), query) {
|
||||
matchedFields = append(matchedFields, "question")
|
||||
}
|
||||
|
||||
// Check answer
|
||||
if strings.Contains(strings.ToLower(entry.Answer), query) {
|
||||
matchedFields = append(matchedFields, "answer")
|
||||
}
|
||||
|
||||
// Check tags
|
||||
if strings.Contains(strings.ToLower(entry.Tags), query) {
|
||||
matchedFields = append(matchedFields, "tags")
|
||||
}
|
||||
|
||||
return matchedFields
|
||||
}
|
127
backend/internal/models/ai.go
Normal file
127
backend/internal/models/ai.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AIModel represents an AI model configuration
|
||||
type AIModel struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Type string `json:"type" gorm:"not null"` // 'openai', 'local', 'custom'
|
||||
Endpoint string `json:"endpoint"`
|
||||
APIKey string `json:"apiKey"` // Encrypted in database
|
||||
Model string `json:"model"` // e.g., 'gpt-4', 'llama2', etc.
|
||||
MaxTokens int `json:"maxTokens" gorm:"default:1000"`
|
||||
Temperature float64 `json:"temperature" gorm:"default:0.7"`
|
||||
TopP float64 `json:"topP" gorm:"default:1.0"`
|
||||
Active bool `json:"active" gorm:"default:true"`
|
||||
Priority int `json:"priority" gorm:"default:1"` // Higher number = higher priority
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
}
|
||||
|
||||
// AIInteraction represents an interaction with an AI model
|
||||
type AIInteraction struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
ConversationID uint `json:"conversationId"`
|
||||
MessageID uint `json:"messageId"`
|
||||
AIModelID uint `json:"aiModelId"`
|
||||
Prompt string `json:"prompt" gorm:"not null"`
|
||||
Response string `json:"response" gorm:"not null"`
|
||||
TokensUsed int `json:"tokensUsed"`
|
||||
ResponseTime int64 `json:"responseTime"` // in milliseconds
|
||||
Cost float64 `json:"cost"`
|
||||
Success bool `json:"success"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
|
||||
// Relationships
|
||||
Conversation Conversation `json:"conversation" gorm:"foreignKey:ConversationID"`
|
||||
Message Message `json:"message" gorm:"foreignKey:MessageID"`
|
||||
AIModel AIModel `json:"aiModel" gorm:"foreignKey:AIModelID"`
|
||||
}
|
||||
|
||||
// AIFallback represents a fallback event when an AI model fails
|
||||
type AIFallback struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
ConversationID uint `json:"conversationId"`
|
||||
MessageID uint `json:"messageId"`
|
||||
FromAIModelID uint `json:"fromAiModelId"`
|
||||
ToAIModelID uint `json:"toAiModelId"`
|
||||
Reason string `json:"reason" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
|
||||
// Relationships
|
||||
Conversation Conversation `json:"conversation" gorm:"foreignKey:ConversationID"`
|
||||
Message Message `json:"message" gorm:"foreignKey:MessageID"`
|
||||
FromAIModel AIModel `json:"fromAiModel" gorm:"foreignKey:FromAIModelID"`
|
||||
ToAIModel AIModel `json:"toAiModel" gorm:"foreignKey:ToAIModelID"`
|
||||
}
|
||||
|
||||
|
||||
// BeforeCreate is a GORM hook that validates the AI model before creation
|
||||
func (a *AIModel) BeforeCreate(tx *gorm.DB) error {
|
||||
if a.Priority < 1 {
|
||||
a.Priority = 1
|
||||
}
|
||||
if a.MaxTokens < 1 {
|
||||
a.MaxTokens = 1000
|
||||
}
|
||||
if a.Temperature < 0 {
|
||||
a.Temperature = 0
|
||||
} else if a.Temperature > 2 {
|
||||
a.Temperature = 2
|
||||
}
|
||||
if a.TopP < 0 {
|
||||
a.TopP = 0
|
||||
} else if a.TopP > 1 {
|
||||
a.TopP = 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeUpdate is a GORM hook that validates the AI model before update
|
||||
func (a *AIModel) BeforeUpdate(tx *gorm.DB) error {
|
||||
if a.Priority < 1 {
|
||||
a.Priority = 1
|
||||
}
|
||||
if a.MaxTokens < 1 {
|
||||
a.MaxTokens = 1000
|
||||
}
|
||||
if a.Temperature < 0 {
|
||||
a.Temperature = 0
|
||||
} else if a.Temperature > 2 {
|
||||
a.Temperature = 2
|
||||
}
|
||||
if a.TopP < 0 {
|
||||
a.TopP = 0
|
||||
} else if a.TopP > 1 {
|
||||
a.TopP = 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsActive checks if the AI model is active
|
||||
func (a *AIModel) IsActive() bool {
|
||||
return a.Active
|
||||
}
|
||||
|
||||
// IsOpenAI checks if the AI model is an OpenAI model
|
||||
func (a *AIModel) IsOpenAI() bool {
|
||||
return a.Type == "openai"
|
||||
}
|
||||
|
||||
// IsLocal checks if the AI model is a local model
|
||||
func (a *AIModel) IsLocal() bool {
|
||||
return a.Type == "local"
|
||||
}
|
||||
|
||||
// IsCustom checks if the AI model is a custom model
|
||||
func (a *AIModel) IsCustom() bool {
|
||||
return a.Type == "custom"
|
||||
}
|
63
backend/internal/models/conversation.go
Normal file
63
backend/internal/models/conversation.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Conversation represents a chat conversation between users and/or agents
|
||||
type Conversation struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Title string `json:"title"`
|
||||
UserID uint `json:"userId"`
|
||||
AgentID *uint `json:"agentId,omitempty"`
|
||||
Status string `json:"status" gorm:"default:'active'"` // 'active', 'closed', 'escalated'
|
||||
Department string `json:"department"`
|
||||
Priority string `json:"priority" gorm:"default:'medium'"` // 'low', 'medium', 'high', 'urgent'
|
||||
Tags string `json:"tags"` // Comma-separated tags
|
||||
LastMessageAt time.Time `json:"lastMessageAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// Relationships
|
||||
User User `json:"user" gorm:"foreignKey:UserID"`
|
||||
Agent *User `json:"agent,omitempty" gorm:"foreignKey:AgentID"`
|
||||
Messages []Message `json:"messages" gorm:"foreignKey:ConversationID"`
|
||||
}
|
||||
|
||||
// Message represents a single message in a conversation
|
||||
type Message struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
ConversationID uint `json:"conversationId"`
|
||||
UserID uint `json:"userId"`
|
||||
Content string `json:"content" gorm:"not null"`
|
||||
Type string `json:"type" gorm:"default:'text'"` // 'text', 'image', 'file', 'system'
|
||||
Status string `json:"status" gorm:"default:'sent'"` // 'sent', 'delivered', 'read'
|
||||
Sentiment float64 `json:"sentiment"` // Sentiment score from -1 (negative) to 1 (positive)
|
||||
IsAI bool `json:"isAI" gorm:"default:false"`
|
||||
AIModel string `json:"aiModel,omitempty"` // Which AI model generated this message
|
||||
ReadAt *time.Time `json:"readAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// Relationships
|
||||
Conversation Conversation `json:"conversation" gorm:"foreignKey:ConversationID"`
|
||||
User User `json:"user" gorm:"foreignKey:UserID"`
|
||||
}
|
||||
|
||||
|
||||
// BeforeCreate is a GORM hook that sets the LastMessageAt field when creating a conversation
|
||||
func (c *Conversation) BeforeCreate(tx *gorm.DB) error {
|
||||
c.LastMessageAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeCreate is a GORM hook that updates the conversation's LastMessageAt when creating a message
|
||||
func (m *Message) BeforeCreate(tx *gorm.DB) error {
|
||||
// Update the conversation's LastMessageAt
|
||||
tx.Model(&Conversation{}).Where("id = ?", m.ConversationID).Update("last_message_at", time.Now())
|
||||
return nil
|
||||
}
|
85
backend/internal/models/knowledge.go
Normal file
85
backend/internal/models/knowledge.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// KnowledgeBase represents a knowledge base entry (FAQ)
|
||||
type KnowledgeBase struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Question string `json:"question" gorm:"not null"`
|
||||
Answer string `json:"answer" gorm:"not null"`
|
||||
Category string `json:"category" gorm:"not null"`
|
||||
Tags string `json:"tags"` // Comma-separated tags
|
||||
Priority int `json:"priority" gorm:"default:1"` // Higher number = higher priority
|
||||
ViewCount int `json:"viewCount" gorm:"default:0"`
|
||||
Helpful int `json:"helpful" gorm:"default:0"` // Number of times marked as helpful
|
||||
NotHelpful int `json:"notHelpful" gorm:"default:0"` // Number of times marked as not helpful
|
||||
Active bool `json:"active" gorm:"default:true"`
|
||||
CreatedBy uint `json:"createdBy"`
|
||||
UpdatedBy uint `json:"updatedBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// Relationships
|
||||
Creator User `json:"creator" gorm:"foreignKey:CreatedBy"`
|
||||
Updater User `json:"updater" gorm:"foreignKey:UpdatedBy"`
|
||||
}
|
||||
|
||||
// KnowledgeBaseFeedback represents user feedback on a knowledge base entry
|
||||
type KnowledgeBaseFeedback struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
KnowledgeBaseID uint `json:"knowledgeBaseId" gorm:"not null"`
|
||||
UserID uint `json:"userId" gorm:"not null"`
|
||||
Helpful bool `json:"helpful"` // true if helpful, false if not helpful
|
||||
Comment string `json:"comment"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
|
||||
// Relationships
|
||||
KnowledgeBase KnowledgeBase `json:"knowledgeBase" gorm:"foreignKey:KnowledgeBaseID"`
|
||||
User User `json:"user" gorm:"foreignKey:UserID"`
|
||||
}
|
||||
|
||||
|
||||
// BeforeCreate is a GORM hook that validates the knowledge base entry before creation
|
||||
func (k *KnowledgeBase) BeforeCreate(tx *gorm.DB) error {
|
||||
if k.Priority < 1 {
|
||||
k.Priority = 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeUpdate is a GORM hook that validates the knowledge base entry before update
|
||||
func (k *KnowledgeBase) BeforeUpdate(tx *gorm.DB) error {
|
||||
if k.Priority < 1 {
|
||||
k.Priority = 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IncrementViewCount increments the view count of a knowledge base entry
|
||||
func (k *KnowledgeBase) IncrementViewCount(tx *gorm.DB) error {
|
||||
return tx.Model(k).UpdateColumn("view_count", gorm.Expr("view_count + ?", 1)).Error
|
||||
}
|
||||
|
||||
// MarkHelpful marks a knowledge base entry as helpful
|
||||
func (k *KnowledgeBase) MarkHelpful(tx *gorm.DB) error {
|
||||
return tx.Model(k).UpdateColumn("helpful", gorm.Expr("helpful + ?", 1)).Error
|
||||
}
|
||||
|
||||
// MarkNotHelpful marks a knowledge base entry as not helpful
|
||||
func (k *KnowledgeBase) MarkNotHelpful(tx *gorm.DB) error {
|
||||
return tx.Model(k).UpdateColumn("not_helpful", gorm.Expr("not_helpful + ?", 1)).Error
|
||||
}
|
||||
|
||||
// GetHelpfulnessPercentage returns the helpfulness percentage
|
||||
func (k *KnowledgeBase) GetHelpfulnessPercentage() float64 {
|
||||
total := k.Helpful + k.NotHelpful
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(k.Helpful) / float64(total) * 100
|
||||
}
|
143
backend/internal/models/models_test.go
Normal file
143
backend/internal/models/models_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestUserModel(t *testing.T) {
|
||||
// Test user model fields
|
||||
user := User{
|
||||
ID: 1,
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Password: "hashedpassword",
|
||||
FirstName: "Test",
|
||||
LastName: "User",
|
||||
Role: "user",
|
||||
Active: true,
|
||||
}
|
||||
|
||||
// Verify user fields
|
||||
assert.Equal(t, uint(1), user.ID)
|
||||
assert.Equal(t, "testuser", user.Username)
|
||||
assert.Equal(t, "test@example.com", user.Email)
|
||||
assert.Equal(t, "hashedpassword", user.Password)
|
||||
assert.Equal(t, "Test", user.FirstName)
|
||||
assert.Equal(t, "User", user.LastName)
|
||||
assert.Equal(t, "user", user.Role)
|
||||
assert.True(t, user.Active)
|
||||
}
|
||||
|
||||
func TestConversationModel(t *testing.T) {
|
||||
// Test conversation model fields
|
||||
conversation := Conversation{
|
||||
ID: 1,
|
||||
UserID: 1,
|
||||
Title: "Test Conversation",
|
||||
Status: "active",
|
||||
Department: "support",
|
||||
Priority: "medium",
|
||||
Tags: "test,sample",
|
||||
}
|
||||
|
||||
// Verify conversation fields
|
||||
assert.Equal(t, uint(1), conversation.ID)
|
||||
assert.Equal(t, uint(1), conversation.UserID)
|
||||
assert.Equal(t, "Test Conversation", conversation.Title)
|
||||
assert.Equal(t, "active", conversation.Status)
|
||||
assert.Equal(t, "support", conversation.Department)
|
||||
assert.Equal(t, "medium", conversation.Priority)
|
||||
assert.Equal(t, "test,sample", conversation.Tags)
|
||||
}
|
||||
|
||||
func TestMessageModel(t *testing.T) {
|
||||
// Test message model fields
|
||||
message := Message{
|
||||
ID: 1,
|
||||
ConversationID: 1,
|
||||
UserID: 1,
|
||||
Content: "Test message content",
|
||||
Type: "text",
|
||||
Status: "sent",
|
||||
Sentiment: 0.5,
|
||||
IsAI: false,
|
||||
AIModel: "",
|
||||
}
|
||||
|
||||
// Verify message fields
|
||||
assert.Equal(t, uint(1), message.ID)
|
||||
assert.Equal(t, uint(1), message.ConversationID)
|
||||
assert.Equal(t, uint(1), message.UserID)
|
||||
assert.Equal(t, "Test message content", message.Content)
|
||||
assert.Equal(t, "text", message.Type)
|
||||
assert.Equal(t, "sent", message.Status)
|
||||
assert.Equal(t, 0.5, message.Sentiment)
|
||||
assert.False(t, message.IsAI)
|
||||
assert.Equal(t, "", message.AIModel)
|
||||
}
|
||||
|
||||
func TestKnowledgeModel(t *testing.T) {
|
||||
// Test knowledge model fields
|
||||
knowledge := KnowledgeBase{
|
||||
ID: 1,
|
||||
Question: "Test Question",
|
||||
Answer: "Test knowledge content",
|
||||
Category: "general",
|
||||
Tags: "test,sample",
|
||||
Priority: 1,
|
||||
ViewCount: 10,
|
||||
Helpful: 5,
|
||||
NotHelpful: 2,
|
||||
Active: true,
|
||||
CreatedBy: 1,
|
||||
UpdatedBy: 1,
|
||||
}
|
||||
|
||||
// Verify knowledge fields
|
||||
assert.Equal(t, uint(1), knowledge.ID)
|
||||
assert.Equal(t, "Test Question", knowledge.Question)
|
||||
assert.Equal(t, "Test knowledge content", knowledge.Answer)
|
||||
assert.Equal(t, "general", knowledge.Category)
|
||||
assert.Equal(t, "test,sample", knowledge.Tags)
|
||||
assert.Equal(t, 1, knowledge.Priority)
|
||||
assert.Equal(t, 10, knowledge.ViewCount)
|
||||
assert.Equal(t, 5, knowledge.Helpful)
|
||||
assert.Equal(t, 2, knowledge.NotHelpful)
|
||||
assert.True(t, knowledge.Active)
|
||||
assert.Equal(t, uint(1), knowledge.CreatedBy)
|
||||
assert.Equal(t, uint(1), knowledge.UpdatedBy)
|
||||
}
|
||||
|
||||
func TestAIModel(t *testing.T) {
|
||||
// Test AI model fields
|
||||
aiModel := AIModel{
|
||||
ID: 1,
|
||||
Name: "Test AI Model",
|
||||
Type: "openai",
|
||||
Endpoint: "https://api.openai.com/v1/chat/completions",
|
||||
APIKey: "test-api-key",
|
||||
Model: "gpt-4",
|
||||
MaxTokens: 4000,
|
||||
Temperature: 0.7,
|
||||
TopP: 1.0,
|
||||
Active: true,
|
||||
Priority: 1,
|
||||
Description: "Test AI model description",
|
||||
}
|
||||
|
||||
// Verify AI model fields
|
||||
assert.Equal(t, uint(1), aiModel.ID)
|
||||
assert.Equal(t, "Test AI Model", aiModel.Name)
|
||||
assert.Equal(t, "openai", aiModel.Type)
|
||||
assert.Equal(t, "https://api.openai.com/v1/chat/completions", aiModel.Endpoint)
|
||||
assert.Equal(t, "test-api-key", aiModel.APIKey)
|
||||
assert.Equal(t, "gpt-4", aiModel.Model)
|
||||
assert.Equal(t, 4000, aiModel.MaxTokens)
|
||||
assert.Equal(t, 0.7, aiModel.Temperature)
|
||||
assert.Equal(t, 1.0, aiModel.TopP)
|
||||
assert.True(t, aiModel.Active)
|
||||
assert.Equal(t, 1, aiModel.Priority)
|
||||
assert.Equal(t, "Test AI model description", aiModel.Description)
|
||||
}
|
261
backend/internal/models/requests.go
Normal file
261
backend/internal/models/requests.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// User Request/Response types
|
||||
type CreateUserRequest struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=50"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
User SafeUser `json:"user"`
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
Email string `json:"email" binding:"omitempty,email"`
|
||||
Active *bool `json:"active"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type ChangePasswordRequest struct {
|
||||
CurrentPassword string `json:"currentPassword" binding:"required"`
|
||||
NewPassword string `json:"newPassword" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
// Conversation Request/Response types
|
||||
type CreateConversationRequest struct {
|
||||
Title string `json:"title" binding:"required,min=1,max=100"`
|
||||
Department string `json:"department" binding:"required"`
|
||||
Priority string `json:"priority" binding:"oneof=low medium high urgent"`
|
||||
Tags string `json:"tags"`
|
||||
}
|
||||
|
||||
type UpdateConversationRequest struct {
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status" binding:"oneof=active closed escalated"`
|
||||
Department string `json:"department"`
|
||||
Priority string `json:"priority" binding:"oneof=low medium high urgent"`
|
||||
Tags string `json:"tags"`
|
||||
AgentID *uint `json:"agentId"`
|
||||
}
|
||||
|
||||
type CreateMessageRequest struct {
|
||||
ConversationID uint `json:"conversationId" binding:"required"`
|
||||
Content string `json:"content" binding:"required,min=1,max=5000"`
|
||||
Type string `json:"type" binding:"oneof=text image file system"`
|
||||
}
|
||||
|
||||
type UpdateMessageRequest struct {
|
||||
Content string `json:"content" binding:"omitempty,min=1,max=5000"`
|
||||
Status string `json:"status" binding:"oneof=sent delivered read"`
|
||||
}
|
||||
|
||||
type ConversationListResponse struct {
|
||||
Conversations []Conversation `json:"conversations"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
type MessageListResponse struct {
|
||||
Messages []Message `json:"messages"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
type ConversationStats struct {
|
||||
TotalMessages int64 `json:"totalMessages"`
|
||||
AverageSentiment float64 `json:"averageSentiment"`
|
||||
ResponseTime int64 `json:"responseTime"` // in seconds
|
||||
FirstMessageAt time.Time `json:"firstMessageAt"`
|
||||
LastMessageAt time.Time `json:"lastMessageAt"`
|
||||
}
|
||||
|
||||
// Knowledge Base Request/Response types
|
||||
type CreateKnowledgeBaseRequest struct {
|
||||
Question string `json:"question" binding:"required,min=1,max=500"`
|
||||
Answer string `json:"answer" binding:"required,min=1,max=5000"`
|
||||
Category string `json:"category" binding:"required,min=1,max=100"`
|
||||
Tags string `json:"tags"`
|
||||
Priority int `json:"priority" binding:"min=1,max=10"`
|
||||
}
|
||||
|
||||
type UpdateKnowledgeBaseRequest struct {
|
||||
Question string `json:"question" binding:"omitempty,min=1,max=500"`
|
||||
Answer string `json:"answer" binding:"omitempty,min=1,max=5000"`
|
||||
Category string `json:"category" binding:"omitempty,min=1,max=100"`
|
||||
Tags string `json:"tags"`
|
||||
Priority int `json:"priority" binding:"omitempty,min=1,max=10"`
|
||||
Active *bool `json:"active"`
|
||||
}
|
||||
|
||||
type CreateKnowledgeBaseFeedbackRequest struct {
|
||||
KnowledgeBaseID uint `json:"knowledgeBaseId" binding:"required"`
|
||||
Helpful bool `json:"helpful"`
|
||||
Comment string `json:"comment" binding:"max=500"`
|
||||
}
|
||||
|
||||
type KnowledgeBaseSearchRequest struct {
|
||||
Query string `json:"query" binding:"required,min=1,max=100"`
|
||||
Category string `json:"category"`
|
||||
Tags string `json:"tags"`
|
||||
Page int `json:"page" binding:"min=1"`
|
||||
PageSize int `json:"pageSize" binding:"min=1,max=100"`
|
||||
}
|
||||
|
||||
type KnowledgeBaseListResponse struct {
|
||||
Entries []KnowledgeBase `json:"entries"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
type KnowledgeBaseSearchResponse struct {
|
||||
Results []KnowledgeBaseSearchResult `json:"results"`
|
||||
Total int64 `json:"total"`
|
||||
Query string `json:"query"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
type KnowledgeBaseSearchResult struct {
|
||||
KnowledgeBase
|
||||
RelevanceScore float64 `json:"relevanceScore"`
|
||||
MatchedFields []string `json:"matchedFields"`
|
||||
}
|
||||
|
||||
type KnowledgeBaseStats struct {
|
||||
TotalEntries int64 `json:"totalEntries"`
|
||||
TotalViews int64 `json:"totalViews"`
|
||||
AverageHelpful float64 `json:"averageHelpful"` // Average helpful rating
|
||||
TopCategories []CategoryStat `json:"topCategories"`
|
||||
TopTags []TagStat `json:"topTags"`
|
||||
}
|
||||
|
||||
type CategoryStat struct {
|
||||
Category string `json:"category"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type TagStat struct {
|
||||
Tag string `json:"tag"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// AI Model Request/Response types
|
||||
type CreateAIModelRequest struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=100"`
|
||||
Type string `json:"type" binding:"required,oneof=openai local custom"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
APIKey string `json:"apiKey"`
|
||||
Model string `json:"model" binding:"required,min=1,max=100"`
|
||||
MaxTokens int `json:"maxTokens" binding:"min=1,max=100000"`
|
||||
Temperature float64 `json:"temperature" binding:"min=0,max=2"`
|
||||
TopP float64 `json:"topP" binding:"min=0,max=1"`
|
||||
Priority int `json:"priority" binding:"min=1,max=10"`
|
||||
Description string `json:"description" binding:"max=500"`
|
||||
}
|
||||
|
||||
type UpdateAIModelRequest struct {
|
||||
Name string `json:"name" binding:"omitempty,min=1,max=100"`
|
||||
Type string `json:"type" binding:"omitempty,oneof=openai local custom"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
APIKey string `json:"apiKey"`
|
||||
Model string `json:"model" binding:"omitempty,min=1,max=100"`
|
||||
MaxTokens int `json:"maxTokens" binding:"omitempty,min=1,max=100000"`
|
||||
Temperature float64 `json:"temperature" binding:"omitempty,min=0,max=2"`
|
||||
TopP float64 `json:"topP" binding:"omitempty,min=0,max=1"`
|
||||
Active *bool `json:"active"`
|
||||
Priority int `json:"priority" binding:"omitempty,min=1,max=10"`
|
||||
Description string `json:"description" binding:"omitempty,max=500"`
|
||||
}
|
||||
|
||||
type AIModelListResponse struct {
|
||||
Models []AIModel `json:"models"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
type AIInteractionListResponse struct {
|
||||
Interactions []AIInteraction `json:"interactions"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
type AIFallbackListResponse struct {
|
||||
Fallbacks []AIFallback `json:"fallbacks"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
type AIStats struct {
|
||||
TotalInteractions int64 `json:"totalInteractions"`
|
||||
TotalFallbacks int64 `json:"totalFallbacks"`
|
||||
AverageResponseTime float64 `json:"averageResponseTime"` // in milliseconds
|
||||
TotalTokensUsed int64 `json:"totalTokensUsed"`
|
||||
TotalCost float64 `json:"totalCost"`
|
||||
ModelStats []AIModelStats `json:"modelStats"`
|
||||
SuccessRate float64 `json:"successRate"`
|
||||
}
|
||||
|
||||
type AIModelStats struct {
|
||||
AIModel AIModel `json:"aiModel"`
|
||||
InteractionsCount int64 `json:"interactionsCount"`
|
||||
FallbacksCount int64 `json:"fallbacksCount"`
|
||||
AverageResponseTime float64 `json:"averageResponseTime"`
|
||||
SuccessRate float64 `json:"successRate"`
|
||||
TokensUsed int64 `json:"tokensUsed"`
|
||||
Cost float64 `json:"cost"`
|
||||
}
|
||||
|
||||
// Common Response types
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
type SuccessResponse struct {
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
// Response helpers
|
||||
func NewErrorResponse(c *gin.Context, message string, statusCode int) {
|
||||
c.JSON(statusCode, ErrorResponse{
|
||||
Error: http.StatusText(statusCode),
|
||||
Message: message,
|
||||
Status: statusCode,
|
||||
})
|
||||
}
|
||||
|
||||
func NewSuccessResponse(c *gin.Context, message string, data interface{}, statusCode int) {
|
||||
c.JSON(statusCode, SuccessResponse{
|
||||
Message: message,
|
||||
Data: data,
|
||||
Status: statusCode,
|
||||
})
|
||||
}
|
69
backend/internal/models/user.go
Normal file
69
backend/internal/models/user.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// User represents a user in the system
|
||||
type User struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Username string `json:"username" gorm:"uniqueIndex;not null"`
|
||||
Email string `json:"email" gorm:"uniqueIndex;not null"`
|
||||
Password string `json:"-" gorm:"not null"`
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
Role string `json:"role" gorm:"default:'user'"` // 'user', 'agent', 'admin'
|
||||
Active bool `json:"active" gorm:"default:true"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
}
|
||||
|
||||
// BeforeSave is a GORM hook that hashes the password before saving
|
||||
func (u *User) BeforeSave(tx *gorm.DB) error {
|
||||
if u.Password != "" {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.Password = string(hashedPassword)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ComparePassword compares a plaintext password with the user's hashed password
|
||||
func (u *User) ComparePassword(password string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ToSafeUser returns a user object without sensitive information
|
||||
func (u *User) ToSafeUser() SafeUser {
|
||||
return SafeUser{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Email: u.Email,
|
||||
FirstName: u.FirstName,
|
||||
LastName: u.LastName,
|
||||
Role: u.Role,
|
||||
Active: u.Active,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// SafeUser represents user information that can be safely exposed to clients
|
||||
type SafeUser struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
Role string `json:"role"`
|
||||
Active bool `json:"active"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
142
backend/internal/routes/routes.go
Normal file
142
backend/internal/routes/routes.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"customer-support-system/internal/auth"
|
||||
"customer-support-system/internal/handlers"
|
||||
)
|
||||
|
||||
// SetupRoutes configures all the routes for the application
|
||||
func SetupRoutes() *gin.Engine {
|
||||
// Create a new Gin engine
|
||||
r := gin.Default()
|
||||
|
||||
// Add CORS middleware
|
||||
r.Use(CORSMiddleware())
|
||||
|
||||
// Create handlers
|
||||
userHandler := handlers.NewUserHandler()
|
||||
conversationHandler := handlers.NewConversationHandler()
|
||||
knowledgeHandler := handlers.NewKnowledgeHandler()
|
||||
aiHandler := handlers.NewAIHandler()
|
||||
|
||||
// Health check endpoint
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"status": "ok",
|
||||
})
|
||||
})
|
||||
|
||||
// API version 1 group
|
||||
v1 := r.Group("/api/v1")
|
||||
{
|
||||
// Public routes (no authentication required)
|
||||
public := v1.Group("/public")
|
||||
{
|
||||
// User authentication routes
|
||||
public.POST("/register", userHandler.Register)
|
||||
public.POST("/login", userHandler.Login)
|
||||
}
|
||||
|
||||
// Protected routes (authentication required)
|
||||
protected := v1.Group("")
|
||||
protected.Use(auth.AuthMiddleware())
|
||||
{
|
||||
// User routes
|
||||
user := protected.Group("/user")
|
||||
{
|
||||
user.GET("/profile", userHandler.GetProfile)
|
||||
user.PUT("/profile", userHandler.UpdateProfile)
|
||||
user.PUT("/change-password", userHandler.ChangePassword)
|
||||
}
|
||||
|
||||
// Conversation routes
|
||||
conversations := protected.Group("/conversations")
|
||||
{
|
||||
conversations.GET("", conversationHandler.ListConversations)
|
||||
conversations.POST("", conversationHandler.CreateConversation)
|
||||
conversations.GET("/:id", conversationHandler.GetConversation)
|
||||
conversations.PUT("/:id", conversationHandler.UpdateConversation)
|
||||
conversations.DELETE("/:id", conversationHandler.DeleteConversation)
|
||||
conversations.GET("/:id/stats", conversationHandler.GetConversationStats)
|
||||
|
||||
// Message routes
|
||||
conversations.POST("/:id/messages", conversationHandler.CreateMessage)
|
||||
conversations.GET("/:id/messages", conversationHandler.GetMessages)
|
||||
conversations.PUT("/:id/messages/:messageId", conversationHandler.UpdateMessage)
|
||||
conversations.DELETE("/:id/messages/:messageId", conversationHandler.DeleteMessage)
|
||||
|
||||
// AI interaction routes
|
||||
conversations.POST("/:id/ai", conversationHandler.SendMessageWithAI)
|
||||
}
|
||||
|
||||
// Knowledge base routes
|
||||
knowledge := protected.Group("/knowledge")
|
||||
{
|
||||
knowledge.GET("", knowledgeHandler.ListKnowledgeEntries)
|
||||
knowledge.GET("/search", knowledgeHandler.SearchKnowledge)
|
||||
knowledge.GET("/categories", knowledgeHandler.GetCategories)
|
||||
knowledge.GET("/tags", knowledgeHandler.GetTags)
|
||||
knowledge.GET("/popular", knowledgeHandler.GetPopularKnowledge)
|
||||
knowledge.GET("/recent", knowledgeHandler.GetRecentKnowledge)
|
||||
knowledge.GET("/best-match", knowledgeHandler.FindBestMatch)
|
||||
knowledge.GET("/stats", knowledgeHandler.GetKnowledgeStats)
|
||||
knowledge.GET("/:id", knowledgeHandler.GetKnowledgeEntry)
|
||||
knowledge.POST("/:id/rate", knowledgeHandler.RateKnowledgeEntry)
|
||||
}
|
||||
|
||||
// AI routes
|
||||
ai := protected.Group("/ai")
|
||||
{
|
||||
ai.POST("/query", aiHandler.QueryAI)
|
||||
ai.POST("/analyze-complexity", aiHandler.AnalyzeComplexity)
|
||||
ai.GET("/models", aiHandler.GetAvailableModels)
|
||||
ai.POST("/openai", aiHandler.QueryOpenAI)
|
||||
ai.POST("/ollama", aiHandler.QueryOllama)
|
||||
}
|
||||
|
||||
// Admin routes (admin role required)
|
||||
admin := protected.Group("/admin")
|
||||
admin.Use(auth.RoleMiddleware("admin"))
|
||||
{
|
||||
// User management
|
||||
admin.GET("/users", userHandler.AdminGetUsers)
|
||||
admin.GET("/users/:id", userHandler.AdminGetUser)
|
||||
admin.PUT("/users/:id", userHandler.AdminUpdateUser)
|
||||
admin.DELETE("/users/:id", userHandler.AdminDeleteUser)
|
||||
|
||||
// Knowledge base management
|
||||
admin.POST("/knowledge", knowledgeHandler.CreateKnowledgeEntry)
|
||||
admin.PUT("/knowledge/:id", knowledgeHandler.UpdateKnowledgeEntry)
|
||||
admin.DELETE("/knowledge/:id", knowledgeHandler.DeleteKnowledgeEntry)
|
||||
}
|
||||
|
||||
// Agent routes (agent or admin role required)
|
||||
agent := protected.Group("/agent")
|
||||
agent.Use(auth.RoleMiddleware("agent", "admin"))
|
||||
{
|
||||
// Additional agent-only endpoints can be added here
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// CORSMiddleware adds CORS headers to the response
|
||||
func CORSMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user