Files
Enterprise-Knowledge-Base/internal/agent/tools/database_query.go
T

427 lines
13 KiB
Go

package tools
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"gorm.io/gorm"
)
// DatabaseQueryTool allows AI to query the database with auto-injected tenant_id for security
type DatabaseQueryTool struct {
BaseTool
db *gorm.DB
tenantID uint64
}
// NewDatabaseQueryTool creates a new database query tool
func NewDatabaseQueryTool(db *gorm.DB, tenantID uint64) *DatabaseQueryTool {
description := `Execute SQL queries to retrieve information from the database.
## Security Features
- Automatic tenant_id injection: All queries are automatically filtered by the logged-in user's tenant_id
- Read-only queries: Only SELECT statements are allowed
- Safe tables: Only allow queries on authorized tables
## Available Tables and Columns
### knowledge_bases
- id (VARCHAR): Knowledge base ID
- name (VARCHAR): Knowledge base name
- description (TEXT): Description
- tenant_id (INTEGER): Owner tenant ID
- embedding_model_id, summary_model_id, rerank_model_id (VARCHAR): Model IDs
- vlm_config (JSON): Includes VLM settings such as enabled flag and model_id
- created_at, updated_at (TIMESTAMP)
### knowledges (documents)
- id (VARCHAR): Document ID
- tenant_id (INTEGER): Owner tenant ID
- knowledge_base_id (VARCHAR): Parent knowledge base ID
- type (VARCHAR): Document type
- title (VARCHAR): Document title
- description (TEXT): Description
- source (VARCHAR): Source location
- parse_status (VARCHAR): Processing status (unprocessed/processing/completed/failed)
- enable_status (VARCHAR): Enable status (enabled/disabled)
- file_name, file_type (VARCHAR): File information
- file_size, storage_size (BIGINT): Size in bytes
- created_at, updated_at, processed_at (TIMESTAMP)
### chunks
- id (VARCHAR): Chunk ID
- tenant_id (INTEGER): Owner tenant ID
- knowledge_base_id (VARCHAR): Parent knowledge base ID
- knowledge_id (VARCHAR): Parent document ID
- content (TEXT): Chunk content
- chunk_index (INTEGER): Index in document
- is_enabled (BOOLEAN): Enable status
- chunk_type (VARCHAR): Type (text/image/table)
- created_at, updated_at (TIMESTAMP)
## Usage Examples
Query knowledge base information:
{
"sql": "SELECT id, name, description FROM knowledge_bases ORDER BY created_at DESC LIMIT 10"
}
Count documents by status:
{
"sql": "SELECT parse_status, COUNT(*) as count FROM knowledges GROUP BY parse_status"
}
Find recent sessions:
{
"sql": "SELECT id, title, created_at FROM sessions ORDER BY created_at DESC LIMIT 5"
}
Get storage usage:
{
"sql": "SELECT SUM(storage_size) as total_storage FROM knowledges"
}
Join knowledge bases and documents:
{
"sql": "SELECT kb.name as kb_name, COUNT(k.id) as doc_count FROM knowledge_bases kb LEFT JOIN knowledges k ON kb.id = k.knowledge_base_id GROUP BY kb.id, kb.name"
}
## Important Notes
- DO NOT include tenant_id in WHERE clause - it's automatically added
- Only SELECT queries are allowed
- Limit results with LIMIT clause for better performance
- Use appropriate JOINs when querying across tables
- All timestamps are in UTC with time zone`
return &DatabaseQueryTool{
BaseTool: NewBaseTool("database_query", description),
db: db,
tenantID: tenantID,
}
}
// Parameters returns the JSON schema for the tool's parameters
func (t *DatabaseQueryTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"sql": map[string]interface{}{
"type": "string",
"description": "The SELECT SQL query to execute. DO NOT include tenant_id condition - it will be automatically added for security.",
},
},
"required": []string{"sql"},
}
}
// Execute executes the database query tool
func (t *DatabaseQueryTool) Execute(ctx context.Context, args map[string]interface{}) (*types.ToolResult, error) {
logger.Infof(ctx, "[Tool][DatabaseQuery] Execute started")
// Extract SQL from args
sqlQuery, ok := args["sql"].(string)
if !ok || sqlQuery == "" {
logger.Errorf(ctx, "[Tool][DatabaseQuery] Missing or invalid SQL parameter")
return &types.ToolResult{
Success: false,
Error: "Missing or invalid 'sql' parameter",
}, fmt.Errorf("missing sql parameter")
}
logger.Infof(ctx, "[Tool][DatabaseQuery] Original SQL query:\n%s", sqlQuery)
logger.Infof(ctx, "[Tool][DatabaseQuery] Tenant ID: %d", t.tenantID)
// Validate and secure the SQL query
logger.Debugf(ctx, "[Tool][DatabaseQuery] Validating and securing SQL...")
securedSQL, err := t.validateAndSecureSQL(sqlQuery)
if err != nil {
logger.Errorf(ctx, "[Tool][DatabaseQuery] SQL validation failed: %v", err)
return &types.ToolResult{
Success: false,
Error: fmt.Sprintf("SQL validation failed: %v", err),
}, err
}
logger.Infof(ctx, "[Tool][DatabaseQuery] Secured SQL query:\n%s", securedSQL)
logger.Infof(ctx, "Executing secured SQL query - original: %s, secured: %s, tenant_id: %d",
sqlQuery, securedSQL, t.tenantID)
// Execute the query
logger.Infof(ctx, "[Tool][DatabaseQuery] Executing query against database...")
rows, err := t.db.WithContext(ctx).Raw(securedSQL).Rows()
if err != nil {
logger.Errorf(ctx, "[Tool][DatabaseQuery] Query execution failed: %v", err)
return &types.ToolResult{
Success: false,
Error: fmt.Sprintf("Query execution failed: %v", err),
}, err
}
defer rows.Close()
logger.Debugf(ctx, "[Tool][DatabaseQuery] Query executed successfully, processing rows...")
// Get column names
columns, err := rows.Columns()
if err != nil {
return &types.ToolResult{
Success: false,
Error: fmt.Sprintf("Failed to get columns: %v", err),
}, err
}
// Process results
results := make([]map[string]interface{}, 0)
for rows.Next() {
// Create a slice of interface{} to hold each column value
columnValues := make([]interface{}, len(columns))
columnPointers := make([]interface{}, len(columns))
for i := range columnValues {
columnPointers[i] = &columnValues[i]
}
// Scan the row
if err := rows.Scan(columnPointers...); err != nil {
return &types.ToolResult{
Success: false,
Error: fmt.Sprintf("Failed to scan row: %v", err),
}, err
}
// Create a map for this row
rowMap := make(map[string]interface{})
for i, colName := range columns {
val := columnValues[i]
// Convert []byte to string for better readability
if b, ok := val.([]byte); ok {
rowMap[colName] = string(b)
} else {
rowMap[colName] = val
}
}
results = append(results, rowMap)
}
if err := rows.Err(); err != nil {
return &types.ToolResult{
Success: false,
Error: fmt.Sprintf("Error iterating rows: %v", err),
}, err
}
logger.Infof(ctx, "[Tool][DatabaseQuery] Retrieved %d rows with %d columns", len(results), len(columns))
logger.Debugf(ctx, "[Tool][DatabaseQuery] Columns: %v", columns)
// Log first few rows for debugging
if len(results) > 0 {
logger.Debugf(ctx, "[Tool][DatabaseQuery] First row sample:")
for key, value := range results[0] {
logger.Debugf(ctx, "[Tool][DatabaseQuery] %s: %v", key, value)
}
}
// Format output
logger.Debugf(ctx, "[Tool][DatabaseQuery] Formatting query results...")
output := t.formatQueryResults(columns, results, securedSQL)
logger.Infof(ctx, "[Tool][DatabaseQuery] Execute completed successfully: %d rows returned", len(results))
return &types.ToolResult{
Success: true,
Output: output,
Data: map[string]interface{}{
"columns": columns,
"rows": results,
"row_count": len(results),
"query": securedSQL,
"tenant_id": t.tenantID,
"display_type": "database_query",
},
}, nil
}
// validateAndSecureSQL validates the SQL query and injects tenant_id conditions
func (t *DatabaseQueryTool) validateAndSecureSQL(sqlQuery string) (string, error) {
// Normalize SQL: trim and convert to lowercase for analysis
normalizedSQL := strings.TrimSpace(sqlQuery)
lowerSQL := strings.ToLower(normalizedSQL)
// 1. Check if it's a SELECT statement
if !strings.HasPrefix(lowerSQL, "select") {
return "", fmt.Errorf("only SELECT queries are allowed")
}
// 2. Check for dangerous keywords
dangerousKeywords := []string{
"drop", "delete", "insert", "update", "alter", "create",
"truncate", "replace", "execute", "exec", "grant", "revoke",
}
for _, keyword := range dangerousKeywords {
// Use word boundary to avoid false positives (e.g., "description" contains "script")
pattern := fmt.Sprintf(`\b%s\b`, keyword)
matched, _ := regexp.MatchString(pattern, lowerSQL)
if matched {
return "", fmt.Errorf("dangerous keyword detected: %s", keyword)
}
}
// 3. Check for allowed tables
allowedTables := []string{
"tenants", "knowledge_bases", "knowledges", "sessions",
"messages", "chunks", "embeddings", "models",
}
// Extract table names from FROM and JOIN clauses
tablePattern := regexp.MustCompile(`(?i)\b(?:from|join)\s+([a-z_]+)(?:\s+as\s+[a-z_]+|\s+[a-z_]+)?`)
matches := tablePattern.FindAllStringSubmatch(lowerSQL, -1)
tablesInQuery := make(map[string]bool)
for _, match := range matches {
if len(match) > 1 {
tableName := strings.ToLower(match[1])
tablesInQuery[tableName] = true
}
}
// Verify all tables are allowed
for tableName := range tablesInQuery {
allowed := false
for _, allowedTable := range allowedTables {
if tableName == allowedTable {
allowed = true
break
}
}
if !allowed {
return "", fmt.Errorf("table not allowed: %s", tableName)
}
}
// 4. Inject tenant_id conditions for tables that have tenant_id
tablesWithTenantID := map[string]bool{
"tenants": true,
"knowledge_bases": true,
"knowledges": true,
"sessions": true,
"chunks": true,
}
// Build tenant_id injection
securedSQL := normalizedSQL
// Check if WHERE clause exists
wherePattern := regexp.MustCompile(`(?i)\bwhere\b`)
hasWhere := wherePattern.MatchString(securedSQL)
// Find table aliases
aliasPattern := regexp.MustCompile(`(?i)(?:from|join)\s+([a-z_]+)(?:\s+(?:as\s+)?([a-z_]+))?`)
aliasMatches := aliasPattern.FindAllStringSubmatch(lowerSQL, -1)
tableAliases := make(map[string]string) // table -> alias
for _, match := range aliasMatches {
if len(match) >= 3 && match[2] != "" {
// Has explicit alias
tableAliases[match[1]] = match[2]
} else if len(match) >= 2 {
// No alias, use table name itself
tableAliases[match[1]] = match[1]
}
}
// Build tenant_id conditions
var tenantConditions []string
for tableName := range tablesInQuery {
if tablesWithTenantID[tableName] {
alias := tableAliases[tableName]
if alias == "" {
alias = tableName
}
// Special handling for tenants table - use id instead of tenant_id
if tableName == "tenants" {
tenantConditions = append(tenantConditions, fmt.Sprintf("%s.id = %d", alias, t.tenantID))
} else {
tenantConditions = append(tenantConditions, fmt.Sprintf("%s.tenant_id = %d", alias, t.tenantID))
}
}
}
if len(tenantConditions) > 0 {
tenantFilter := strings.Join(tenantConditions, " AND ")
if hasWhere {
// Add to existing WHERE clause
securedSQL = wherePattern.ReplaceAllString(securedSQL, fmt.Sprintf("WHERE %s AND ", tenantFilter))
} else {
// Add new WHERE clause before ORDER BY, GROUP BY, LIMIT, etc.
orderByPattern := regexp.MustCompile(`(?i)\b(group\s+by|order\s+by|limit|offset|having)\b`)
if orderByPattern.MatchString(securedSQL) {
securedSQL = orderByPattern.ReplaceAllStringFunc(securedSQL, func(match string) string {
return fmt.Sprintf(" WHERE %s %s", tenantFilter, match)
})
} else {
// Add WHERE clause at the end
securedSQL = fmt.Sprintf("%s WHERE %s", securedSQL, tenantFilter)
}
}
}
return securedSQL, nil
}
// formatQueryResults formats query results into readable text
func (t *DatabaseQueryTool) formatQueryResults(
columns []string,
results []map[string]interface{},
query string,
) string {
output := "=== 查询结果 ===\n\n"
output += fmt.Sprintf("执行的SQL: %s\n\n", query)
output += fmt.Sprintf("返回 %d 行数据\n\n", len(results))
if len(results) == 0 {
output += "未找到匹配的记录。\n"
return output
}
output += "=== 数据详情 ===\n\n"
// Format each row
for i, row := range results {
output += fmt.Sprintf("--- 记录 #%d ---\n", i+1)
for _, col := range columns {
value := row[col]
// Format the value
var formattedValue string
if value == nil {
formattedValue = "<NULL>"
} else if jsonData, err := json.Marshal(value); err == nil {
// Check if it's a complex type
switch v := value.(type) {
case string:
formattedValue = v
case []byte:
formattedValue = string(v)
default:
formattedValue = string(jsonData)
}
} else {
formattedValue = fmt.Sprintf("%v", value)
}
output += fmt.Sprintf(" %s: %s\n", col, formattedValue)
}
output += "\n"
}
// Add summary statistics if applicable
if len(results) > 10 {
output += fmt.Sprintf("注意: 显示了前 %d 条记录,共 %d 条。建议使用 LIMIT 子句限制结果数量。\n", len(results), len(results))
}
return output
}