Add function execution support to Job system
- Introduced `AddFunc` method to the `Job` struct for adding Go functions as job executions, allowing for dynamic execution of functions with specified arguments. - Enhanced internal execution handling to register functions in a global registry, ensuring proper cleanup after execution. - Implemented `ExecuteFunc` method in the `Goroutine` struct to handle the execution of registered functions, including error handling and context management. - Added comprehensive unit tests for `AddFunc`, verifying function registration, execution, and memory cleanup post-execution. - Updated related documentation to reflect the new functionality and usage patterns for adding and executing Go functions within the job system.
This commit is contained in:
parent
36939cd53a
commit
01820d9fe2
24 changed files with 4463 additions and 1451 deletions
|
|
@ -29,6 +29,19 @@ func (j *Job) AddCommand(options *ExecutionOptions, command string, args []strin
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddFunc adds a new execution with a Go function
|
||||||
|
// The function is registered in a global registry and will be cleaned up after execution
|
||||||
|
// Note: The function is stored in memory registry and will be lost if the process restarts
|
||||||
|
func (j *Job) AddFunc(options *ExecutionOptions, name string, fn ExecutionFunc, args map[string]interface{}) error {
|
||||||
|
// fn will be registered in addExecution after ExecutionID is generated
|
||||||
|
return j.addExecution(options, &ExecutionConfig{
|
||||||
|
Type: ExecutionTypeFunc,
|
||||||
|
Func: fn, // Temporarily store here, will be moved to registry
|
||||||
|
FuncName: name,
|
||||||
|
FuncArgs: args,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// addExecution is the internal method to create execution records
|
// addExecution is the internal method to create execution records
|
||||||
func (j *Job) addExecution(options *ExecutionOptions, config *ExecutionConfig) error {
|
func (j *Job) addExecution(options *ExecutionOptions, config *ExecutionConfig) error {
|
||||||
// Set default options if nil
|
// Set default options if nil
|
||||||
|
|
@ -39,6 +52,14 @@ func (j *Job) addExecution(options *ExecutionOptions, config *ExecutionConfig) e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For ExecutionTypeFunc, we need to register the function after getting ExecutionID
|
||||||
|
// Store the function temporarily and clear it before serialization
|
||||||
|
var funcToRegister ExecutionFunc
|
||||||
|
if config.Type == ExecutionTypeFunc && config.Func != nil {
|
||||||
|
funcToRegister = config.Func
|
||||||
|
config.Func = nil // Clear before serialization (can't be serialized anyway)
|
||||||
|
}
|
||||||
|
|
||||||
// Serialize ExecutionConfig to JSON for ConfigSnapshot
|
// Serialize ExecutionConfig to JSON for ConfigSnapshot
|
||||||
configBytes, err := jsoniter.Marshal(config)
|
configBytes, err := jsoniter.Marshal(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -61,11 +82,17 @@ func (j *Job) addExecution(options *ExecutionOptions, config *ExecutionConfig) e
|
||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save execution to database
|
// Save execution to database (this generates ExecutionID)
|
||||||
if err := SaveExecution(execution); err != nil {
|
if err := SaveExecution(execution); err != nil {
|
||||||
return fmt.Errorf("failed to create execution record: %w", err)
|
return fmt.Errorf("failed to create execution record: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For ExecutionTypeFunc, register the function in global registry using ExecutionID
|
||||||
|
if config.Type == ExecutionTypeFunc && funcToRegister != nil {
|
||||||
|
config.FuncID = execution.ExecutionID // Set FuncID for later lookup
|
||||||
|
RegisterFunc(execution.ExecutionID, funcToRegister)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,84 @@ func UpdateExecutionProgress(executionID string, progressData map[string]interfa
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExecuteFunc executes a Go function using goroutine mode
|
||||||
|
func (g *Goroutine) ExecuteFunc(ctx context.Context, work *WorkRequest, progress *Progress) error {
|
||||||
|
config := work.Execution.ExecutionConfig
|
||||||
|
|
||||||
|
// Get function from global registry using FuncID (ExecutionID)
|
||||||
|
funcID := config.FuncID
|
||||||
|
if funcID == "" {
|
||||||
|
funcID = work.Execution.ExecutionID // Fallback to ExecutionID
|
||||||
|
}
|
||||||
|
|
||||||
|
fn, ok := GetFunc(funcID)
|
||||||
|
if !ok || fn == nil {
|
||||||
|
return fmt.Errorf("execution function not found in registry (funcID: %s)", funcID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure cleanup after execution (success or failure)
|
||||||
|
defer UnregisterFunc(funcID)
|
||||||
|
|
||||||
|
funcName := config.FuncName
|
||||||
|
if funcName == "" {
|
||||||
|
funcName = "anonymous"
|
||||||
|
}
|
||||||
|
|
||||||
|
work.Execution.Info("Executing function: %s (goroutine mode, funcID: %s)", funcName, funcID)
|
||||||
|
|
||||||
|
// Create execution context
|
||||||
|
execCtx := &ExecutionContext{
|
||||||
|
Ctx: ctx,
|
||||||
|
Execution: work.Execution,
|
||||||
|
Args: config.FuncArgs,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the function
|
||||||
|
err := fn(execCtx)
|
||||||
|
if err != nil {
|
||||||
|
// Check if it was cancelled
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
work.Execution.Warn("Function cancelled: %s", ctx.Err().Error())
|
||||||
|
work.Execution.Status = "cancelled"
|
||||||
|
} else {
|
||||||
|
work.Execution.Error("Function failed: %s", err.Error())
|
||||||
|
work.Execution.Status = "failed"
|
||||||
|
|
||||||
|
// Store error info
|
||||||
|
errorInfo := map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"func_name": funcName,
|
||||||
|
}
|
||||||
|
if errorBytes, jsonErr := jsoniter.Marshal(errorInfo); jsonErr == nil {
|
||||||
|
work.Execution.ErrorInfo = (*json.RawMessage)(&errorBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save execution status
|
||||||
|
if saveErr := SaveExecution(work.Execution); saveErr != nil {
|
||||||
|
work.Execution.Error("Failed to save execution error: %s", saveErr.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
return fmt.Errorf("function execution failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
work.Execution.Info("Function completed successfully (funcID: %s)", funcID)
|
||||||
|
|
||||||
|
// Update execution with success result
|
||||||
|
work.Execution.Status = "completed"
|
||||||
|
work.Execution.Progress = 100
|
||||||
|
|
||||||
|
if saveErr := SaveExecution(work.Execution); saveErr != nil {
|
||||||
|
work.Execution.Error("Failed to save execution result: %s", saveErr.Error())
|
||||||
|
return fmt.Errorf("failed to save execution result: %w", saveErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// extractProgressData extracts progress and message from callback data
|
// extractProgressData extracts progress and message from callback data
|
||||||
func extractProgressData(data map[string]interface{}) (int, string) {
|
func extractProgressData(data map[string]interface{}) (int, string) {
|
||||||
var progressInt int = -1 // Default to -1 to indicate no progress value
|
var progressInt int = -1 // Default to -1 to indicate no progress value
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,9 @@ func DaemonAndSave(mode ModeType, data map[string]interface{}) (*Job, error) {
|
||||||
|
|
||||||
// Push pushes the job to execution queue (renamed from Start for better semantics)
|
// Push pushes the job to execution queue (renamed from Start for better semantics)
|
||||||
func (j *Job) Push() error {
|
func (j *Job) Push() error {
|
||||||
// Get executions for this job
|
// Get executions from database
|
||||||
|
// For ExecutionTypeFunc, the function is stored in global registry (funcRegistry)
|
||||||
|
// and will be looked up by FuncID (ExecutionID) during execution
|
||||||
executions, err := j.GetExecutions()
|
executions, err := j.GetExecutions()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get executions: %w", err)
|
return fmt.Errorf("failed to get executions: %w", err)
|
||||||
|
|
|
||||||
271
job/job_test.go
271
job/job_test.go
|
|
@ -678,3 +678,274 @@ func TestDaemonAndSave(t *testing.T) {
|
||||||
|
|
||||||
t.Log("DaemonAndSave job created and saved successfully")
|
t.Log("DaemonAndSave job created and saved successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAddFunc tests the AddFunc method for adding Go functions as job executions
|
||||||
|
func TestAddFunc(t *testing.T) {
|
||||||
|
// Setup
|
||||||
|
test.Prepare(&testing.T{}, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
// Create a job
|
||||||
|
testJob, err := job.OnceAndSave(job.GOROUTINE, map[string]interface{}{
|
||||||
|
"name": "Test AddFunc Job",
|
||||||
|
"description": "Testing Go function execution",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create job: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track if function was called
|
||||||
|
funcCalled := false
|
||||||
|
funcArgs := make(map[string]interface{})
|
||||||
|
|
||||||
|
// Add a Go function execution
|
||||||
|
err = testJob.AddFunc(&job.ExecutionOptions{
|
||||||
|
Priority: 1,
|
||||||
|
}, "test.func", func(ctx *job.ExecutionContext) error {
|
||||||
|
funcCalled = true
|
||||||
|
funcArgs = ctx.Args
|
||||||
|
t.Logf("Function executed with args: %v", ctx.Args)
|
||||||
|
return nil
|
||||||
|
}, map[string]interface{}{
|
||||||
|
"key1": "value1",
|
||||||
|
"key2": 42,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to add function execution: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the execution to verify it was saved
|
||||||
|
executions, err := testJob.GetExecutions()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get executions: %v", err)
|
||||||
|
}
|
||||||
|
if len(executions) != 1 {
|
||||||
|
t.Fatalf("Expected 1 execution, got %d", len(executions))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify function is registered in global registry
|
||||||
|
funcID := executions[0].ExecutionID
|
||||||
|
fn, ok := job.GetFunc(funcID)
|
||||||
|
if !ok || fn == nil {
|
||||||
|
t.Error("Expected function to be registered in global registry")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push the job
|
||||||
|
err = testJob.Push()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to push job: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for execution to complete
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
|
// Verify function was called
|
||||||
|
if !funcCalled {
|
||||||
|
t.Error("Expected function to be called")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify args were passed
|
||||||
|
if funcArgs["key1"] != "value1" {
|
||||||
|
t.Errorf("Expected key1=value1, got %v", funcArgs["key1"])
|
||||||
|
}
|
||||||
|
// Note: JSON unmarshaling converts numbers to float64
|
||||||
|
key2Val, ok := funcArgs["key2"].(float64)
|
||||||
|
if !ok {
|
||||||
|
// Try int in case it wasn't serialized
|
||||||
|
if intVal, ok := funcArgs["key2"].(int); ok {
|
||||||
|
key2Val = float64(intVal)
|
||||||
|
} else {
|
||||||
|
t.Errorf("Expected key2 to be a number, got %T: %v", funcArgs["key2"], funcArgs["key2"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if key2Val != 42 {
|
||||||
|
t.Errorf("Expected key2=42, got %v", key2Val)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify function was cleaned up from registry after execution
|
||||||
|
fn, ok = job.GetFunc(funcID)
|
||||||
|
if ok || fn != nil {
|
||||||
|
t.Error("Expected function to be removed from global registry after execution")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("AddFunc test completed successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAddFuncMemoryCleanup tests that memory is properly cleaned up after function execution
|
||||||
|
func TestAddFuncMemoryCleanup(t *testing.T) {
|
||||||
|
// Setup
|
||||||
|
test.Prepare(&testing.T{}, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
// Create a job
|
||||||
|
testJob, err := job.OnceAndSave(job.GOROUTINE, map[string]interface{}{
|
||||||
|
"name": "Test AddFunc Memory Cleanup",
|
||||||
|
"description": "Testing memory cleanup after function execution",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create job: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a large closure to make memory leak more detectable
|
||||||
|
largeData := make([]byte, 1024*1024) // 1MB
|
||||||
|
for i := range largeData {
|
||||||
|
largeData[i] = byte(i % 256)
|
||||||
|
}
|
||||||
|
|
||||||
|
executed := false
|
||||||
|
|
||||||
|
// Add a Go function with large closure
|
||||||
|
err = testJob.AddFunc(&job.ExecutionOptions{
|
||||||
|
Priority: 1,
|
||||||
|
}, "test.cleanup", func(ctx *job.ExecutionContext) error {
|
||||||
|
// Use largeData to ensure it's captured in closure
|
||||||
|
_ = len(largeData)
|
||||||
|
executed = true
|
||||||
|
return nil
|
||||||
|
}, map[string]interface{}{
|
||||||
|
"test": "cleanup",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to add function execution: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the execution to verify FuncID is set
|
||||||
|
executions, err := testJob.GetExecutions()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get executions: %v", err)
|
||||||
|
}
|
||||||
|
if len(executions) != 1 {
|
||||||
|
t.Fatalf("Expected 1 execution, got %d", len(executions))
|
||||||
|
}
|
||||||
|
funcID := executions[0].ExecutionID
|
||||||
|
t.Logf("FuncID (ExecutionID): %s", funcID)
|
||||||
|
|
||||||
|
// Verify function is registered in global registry before execution
|
||||||
|
fn, ok := job.GetFunc(funcID)
|
||||||
|
if !ok || fn == nil {
|
||||||
|
t.Error("Expected function to be registered in global registry before execution")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push the job
|
||||||
|
err = testJob.Push()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to push job: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for execution to complete with polling
|
||||||
|
maxWait := 10 * time.Second
|
||||||
|
pollInterval := 200 * time.Millisecond
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
for time.Since(startTime) < maxWait {
|
||||||
|
if executed {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(pollInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify function was executed
|
||||||
|
if !executed {
|
||||||
|
t.Error("Expected function to be executed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for execution to complete in database
|
||||||
|
var finalStatus string
|
||||||
|
for time.Since(startTime) < maxWait {
|
||||||
|
executions, err := testJob.GetExecutions()
|
||||||
|
if err == nil && len(executions) > 0 {
|
||||||
|
finalStatus = executions[0].Status
|
||||||
|
t.Logf("Execution status: %s", finalStatus)
|
||||||
|
if finalStatus == "completed" || finalStatus == "failed" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(pollInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait a bit more for cleanup to complete
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify memory cleanup: function should be removed from global registry
|
||||||
|
fn, ok = job.GetFunc(funcID)
|
||||||
|
if ok || fn != nil {
|
||||||
|
t.Errorf("Expected function to be removed from global registry after completion")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify execution status in database
|
||||||
|
executions, err = testJob.GetExecutions()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get executions: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(executions) != 1 {
|
||||||
|
t.Errorf("Expected 1 execution in database, got %d", len(executions))
|
||||||
|
}
|
||||||
|
|
||||||
|
if executions[0].Status != "completed" {
|
||||||
|
t.Errorf("Expected execution status 'completed', got '%s'", executions[0].Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("AddFunc memory cleanup test completed successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAddFuncError tests error handling in AddFunc execution
|
||||||
|
func TestAddFuncError(t *testing.T) {
|
||||||
|
// Setup
|
||||||
|
test.Prepare(&testing.T{}, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
// Create a job
|
||||||
|
testJob, err := job.OnceAndSave(job.GOROUTINE, map[string]interface{}{
|
||||||
|
"name": "Test AddFunc Error",
|
||||||
|
"description": "Testing error handling in function execution",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create job: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a Go function that returns an error
|
||||||
|
err = testJob.AddFunc(&job.ExecutionOptions{
|
||||||
|
Priority: 1,
|
||||||
|
}, "test.error", func(ctx *job.ExecutionContext) error {
|
||||||
|
return fmt.Errorf("intentional test error")
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to add function execution: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push the job
|
||||||
|
err = testJob.Push()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to push job: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for execution to complete
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
|
// Verify execution failed
|
||||||
|
executions, err := testJob.GetExecutions()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get executions: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(executions) != 1 {
|
||||||
|
t.Errorf("Expected 1 execution, got %d", len(executions))
|
||||||
|
}
|
||||||
|
|
||||||
|
if executions[0].Status != "failed" {
|
||||||
|
t.Errorf("Expected execution status 'failed', got '%s'", executions[0].Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify memory cleanup even on error: function should be removed from global registry
|
||||||
|
// Get the execution ID first
|
||||||
|
if len(executions) > 0 {
|
||||||
|
funcID := executions[0].ExecutionID
|
||||||
|
fn, ok := job.GetFunc(funcID)
|
||||||
|
if ok || fn != nil {
|
||||||
|
t.Errorf("Expected function to be removed from global registry after failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("AddFunc error handling test completed successfully")
|
||||||
|
}
|
||||||
|
|
|
||||||
55
job/types.go
55
job/types.go
|
|
@ -59,6 +59,7 @@ type ExecutionType string
|
||||||
const (
|
const (
|
||||||
ExecutionTypeProcess ExecutionType = "process" // Yao process (default)
|
ExecutionTypeProcess ExecutionType = "process" // Yao process (default)
|
||||||
ExecutionTypeCommand ExecutionType = "command" // System command
|
ExecutionTypeCommand ExecutionType = "command" // System command
|
||||||
|
ExecutionTypeFunc ExecutionType = "func" // Go function (internal use)
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExecutionOptions holds common execution options
|
// ExecutionOptions holds common execution options
|
||||||
|
|
@ -96,14 +97,56 @@ func (o *ExecutionOptions) AddSharedData(key string, value interface{}) *Executi
|
||||||
return o
|
return o
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExecutionFunc is the function signature for ExecutionTypeFunc
|
||||||
|
// The function receives the execution context and returns an error if failed
|
||||||
|
type ExecutionFunc func(ctx *ExecutionContext) error
|
||||||
|
|
||||||
|
// ExecutionContext provides context for ExecutionFunc
|
||||||
|
type ExecutionContext struct {
|
||||||
|
Ctx context.Context // Go context
|
||||||
|
Execution *Execution // Current execution
|
||||||
|
Args map[string]interface{} // Function arguments
|
||||||
|
}
|
||||||
|
|
||||||
|
// funcRegistry is a global registry for ExecutionFunc
|
||||||
|
// Key is the funcID (execution_id), value is the function
|
||||||
|
var funcRegistry = make(map[string]ExecutionFunc)
|
||||||
|
var funcRegistryMutex sync.RWMutex
|
||||||
|
|
||||||
|
// RegisterFunc registers a function in the global registry
|
||||||
|
func RegisterFunc(funcID string, fn ExecutionFunc) {
|
||||||
|
funcRegistryMutex.Lock()
|
||||||
|
defer funcRegistryMutex.Unlock()
|
||||||
|
funcRegistry[funcID] = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFunc retrieves a function from the global registry
|
||||||
|
func GetFunc(funcID string) (ExecutionFunc, bool) {
|
||||||
|
funcRegistryMutex.RLock()
|
||||||
|
defer funcRegistryMutex.RUnlock()
|
||||||
|
fn, ok := funcRegistry[funcID]
|
||||||
|
return fn, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnregisterFunc removes a function from the global registry
|
||||||
|
func UnregisterFunc(funcID string) {
|
||||||
|
funcRegistryMutex.Lock()
|
||||||
|
defer funcRegistryMutex.Unlock()
|
||||||
|
delete(funcRegistry, funcID)
|
||||||
|
}
|
||||||
|
|
||||||
// ExecutionConfig holds execution configuration based on type
|
// ExecutionConfig holds execution configuration based on type
|
||||||
type ExecutionConfig struct {
|
type ExecutionConfig struct {
|
||||||
Type ExecutionType `json:"type"`
|
Type ExecutionType `json:"type"`
|
||||||
ProcessName string `json:"process_name,omitempty"` // Yao process name
|
ProcessName string `json:"process_name,omitempty"` // Yao process name
|
||||||
ProcessArgs []interface{} `json:"process_args,omitempty"` // Yao process arguments
|
ProcessArgs []interface{} `json:"process_args,omitempty"` // Yao process arguments
|
||||||
Command string `json:"command,omitempty"` // System command
|
Command string `json:"command,omitempty"` // System command
|
||||||
CommandArgs []string `json:"command_args,omitempty"` // Command arguments
|
CommandArgs []string `json:"command_args,omitempty"` // Command arguments
|
||||||
Environment map[string]string `json:"environment,omitempty"` // Environment variables
|
Environment map[string]string `json:"environment,omitempty"` // Environment variables
|
||||||
|
Func ExecutionFunc `json:"-"` // Go function (not serialized, use FuncID instead)
|
||||||
|
FuncID string `json:"func_id,omitempty"` // Function ID for registry lookup
|
||||||
|
FuncName string `json:"func_name,omitempty"` // Function name for logging
|
||||||
|
FuncArgs map[string]interface{} `json:"func_args,omitempty"` // Function arguments
|
||||||
}
|
}
|
||||||
|
|
||||||
// Job represents the main job entity
|
// Job represents the main job entity
|
||||||
|
|
|
||||||
|
|
@ -371,11 +371,11 @@ func (w *Worker) processWork(work *WorkRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up execution context from job
|
// Clean up execution context from job
|
||||||
|
work.Job.executionMutex.Lock()
|
||||||
if work.Job.executionContexts != nil {
|
if work.Job.executionContexts != nil {
|
||||||
work.Job.executionMutex.Lock()
|
|
||||||
delete(work.Job.executionContexts, work.Execution.ExecutionID)
|
delete(work.Job.executionContexts, work.Execution.ExecutionID)
|
||||||
work.Job.executionMutex.Unlock()
|
|
||||||
}
|
}
|
||||||
|
work.Job.executionMutex.Unlock()
|
||||||
|
|
||||||
log.Debug("Worker %s finished processing job %s", w.ID, work.Job.JobID)
|
log.Debug("Worker %s finished processing job %s", w.ID, work.Job.JobID)
|
||||||
}
|
}
|
||||||
|
|
@ -397,6 +397,9 @@ func (w *Worker) executeInGoroutine(ctx context.Context, work *WorkRequest, prog
|
||||||
case ExecutionTypeCommand:
|
case ExecutionTypeCommand:
|
||||||
return goroutineExecutor.ExecuteSystemCommand(ctx, work, progress)
|
return goroutineExecutor.ExecuteSystemCommand(ctx, work, progress)
|
||||||
|
|
||||||
|
case ExecutionTypeFunc:
|
||||||
|
return goroutineExecutor.ExecuteFunc(ctx, work, progress)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported execution type: %s", work.Execution.ExecutionConfig.Type)
|
return fmt.Errorf("unsupported execution type: %s", work.Execution.ExecutionConfig.Type)
|
||||||
}
|
}
|
||||||
|
|
@ -423,6 +426,11 @@ func (w *Worker) executeInProcess(ctx context.Context, work *WorkRequest, progre
|
||||||
case ExecutionTypeCommand:
|
case ExecutionTypeCommand:
|
||||||
return processExecutor.ExecuteSystemCommand(ctx, work, progress)
|
return processExecutor.ExecuteSystemCommand(ctx, work, progress)
|
||||||
|
|
||||||
|
case ExecutionTypeFunc:
|
||||||
|
// ExecutionTypeFunc is not supported in process mode, fall back to goroutine
|
||||||
|
goroutineExecutor := &Goroutine{}
|
||||||
|
return goroutineExecutor.ExecuteFunc(ctx, work, progress)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported execution type: %s", work.Execution.ExecutionConfig.Type)
|
return fmt.Errorf("unsupported execution type: %s", work.Execution.ExecutionConfig.Type)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
303
kb/api/addfile.go
Normal file
303
kb/api/addfile.go
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/yaoapp/gou/graphrag/utils"
|
||||||
|
"github.com/yaoapp/kun/maps"
|
||||||
|
"github.com/yaoapp/yao/attachment"
|
||||||
|
"github.com/yaoapp/yao/job"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddFile adds a file to a collection (sync)
|
||||||
|
func (instance *KBInstance) AddFile(ctx context.Context, params *AddFileParams) (*AddDocumentResult, error) {
|
||||||
|
// Validate required parameters
|
||||||
|
if params.CollectionID == "" {
|
||||||
|
return nil, fmt.Errorf("collection_id is required")
|
||||||
|
}
|
||||||
|
if params.FileID == "" {
|
||||||
|
return nil, fmt.Errorf("file_id is required")
|
||||||
|
}
|
||||||
|
if params.Chunking == nil {
|
||||||
|
return nil, fmt.Errorf("chunking configuration is required")
|
||||||
|
}
|
||||||
|
if params.Embedding == nil {
|
||||||
|
return nil, fmt.Errorf("embedding configuration is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default uploader
|
||||||
|
uploader := params.Uploader
|
||||||
|
if uploader == "" {
|
||||||
|
uploader = DefaultUploader
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate document ID if not provided
|
||||||
|
docID := params.DocID
|
||||||
|
if docID == "" {
|
||||||
|
docID = utils.GenDocIDWithCollectionID(params.CollectionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file manager
|
||||||
|
m, ok := attachment.Managers[uploader]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("invalid uploader: %s not found", uploader)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the file exists
|
||||||
|
exists := m.Exists(ctx, params.FileID)
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("file not found: %s", params.FileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file info and path
|
||||||
|
path, contentType, err := m.LocalPath(ctx, params.FileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get local path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInfo, err := m.Info(ctx, params.FileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get file info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create document record
|
||||||
|
documentData := map[string]interface{}{
|
||||||
|
"document_id": docID,
|
||||||
|
"collection_id": params.CollectionID,
|
||||||
|
"name": fileInfo.Filename,
|
||||||
|
"type": "file",
|
||||||
|
"status": "pending",
|
||||||
|
"uploader_id": uploader,
|
||||||
|
"file_id": params.FileID,
|
||||||
|
"file_name": fileInfo.Filename,
|
||||||
|
"file_path": path,
|
||||||
|
"file_mime_type": contentType,
|
||||||
|
"size": int64(fileInfo.Bytes),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add auth scope fields
|
||||||
|
if params.AuthScope != nil {
|
||||||
|
for k, v := range params.AuthScope {
|
||||||
|
documentData[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add base fields
|
||||||
|
addBaseFieldsFromParams(documentData, params.Locale, params.Metadata, params.Chunking, params.Embedding, params.Extraction, params.Fetcher, params.Converter)
|
||||||
|
|
||||||
|
// Create database record
|
||||||
|
_, err = instance.Config.CreateDocument(maps.MapStrAny(documentData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to save document metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process file content
|
||||||
|
params.DocID = docID // Ensure docID is set
|
||||||
|
err = instance.processFile(ctx, docID, params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AddDocumentResult{
|
||||||
|
Message: "File added successfully",
|
||||||
|
CollectionID: params.CollectionID,
|
||||||
|
DocID: docID,
|
||||||
|
FileID: params.FileID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddFileAsync adds a file to a collection (async)
|
||||||
|
func (instance *KBInstance) AddFileAsync(ctx context.Context, params *AddFileParams) (*AddDocumentAsyncResult, error) {
|
||||||
|
// Validate required parameters
|
||||||
|
if params.CollectionID == "" {
|
||||||
|
return nil, fmt.Errorf("collection_id is required")
|
||||||
|
}
|
||||||
|
if params.FileID == "" {
|
||||||
|
return nil, fmt.Errorf("file_id is required")
|
||||||
|
}
|
||||||
|
if params.Chunking == nil {
|
||||||
|
return nil, fmt.Errorf("chunking configuration is required")
|
||||||
|
}
|
||||||
|
if params.Embedding == nil {
|
||||||
|
return nil, fmt.Errorf("embedding configuration is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default uploader
|
||||||
|
uploader := params.Uploader
|
||||||
|
if uploader == "" {
|
||||||
|
uploader = DefaultUploader
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate document ID if not provided
|
||||||
|
docID := params.DocID
|
||||||
|
if docID == "" {
|
||||||
|
docID = utils.GenDocIDWithCollectionID(params.CollectionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file manager
|
||||||
|
m, ok := attachment.Managers[uploader]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("invalid uploader: %s not found", uploader)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the file exists
|
||||||
|
exists := m.Exists(ctx, params.FileID)
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("file not found: %s", params.FileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file info and path
|
||||||
|
path, contentType, err := m.LocalPath(ctx, params.FileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get local path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInfo, err := m.Info(ctx, params.FileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get file info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get job options with defaults
|
||||||
|
jobName, jobDescription, jobIcon, jobCategory := getJobOptions(params.Job,
|
||||||
|
"Knowledge Base File Processing",
|
||||||
|
"Processing and indexing file content for knowledge base search",
|
||||||
|
"library_add",
|
||||||
|
"Knowledge Base",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create job data
|
||||||
|
jobCreateData := map[string]interface{}{
|
||||||
|
"name": jobName,
|
||||||
|
"description": jobDescription,
|
||||||
|
"category_name": jobCategory,
|
||||||
|
}
|
||||||
|
if jobIcon != "" {
|
||||||
|
jobCreateData["icon"] = jobIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add auth scope fields
|
||||||
|
if params.AuthScope != nil {
|
||||||
|
for k, v := range params.AuthScope {
|
||||||
|
jobCreateData[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and save Job
|
||||||
|
j, err := job.OnceAndSave(job.GOROUTINE, jobCreateData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create and save job: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create document record
|
||||||
|
documentData := map[string]interface{}{
|
||||||
|
"document_id": docID,
|
||||||
|
"collection_id": params.CollectionID,
|
||||||
|
"name": fileInfo.Filename,
|
||||||
|
"type": "file",
|
||||||
|
"status": "pending",
|
||||||
|
"uploader_id": uploader,
|
||||||
|
"file_id": params.FileID,
|
||||||
|
"file_name": fileInfo.Filename,
|
||||||
|
"file_path": path,
|
||||||
|
"file_mime_type": contentType,
|
||||||
|
"size": int64(fileInfo.Bytes),
|
||||||
|
"job_id": j.JobID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add auth scope fields
|
||||||
|
if params.AuthScope != nil {
|
||||||
|
for k, v := range params.AuthScope {
|
||||||
|
documentData[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add base fields
|
||||||
|
addBaseFieldsFromParams(documentData, params.Locale, params.Metadata, params.Chunking, params.Embedding, params.Extraction, params.Fetcher, params.Converter)
|
||||||
|
|
||||||
|
// Create database record
|
||||||
|
_, err = instance.Config.CreateDocument(maps.MapStrAny(documentData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to save document metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture parameters for the async function
|
||||||
|
asyncDocID := docID
|
||||||
|
asyncParams := &AddFileParams{
|
||||||
|
CollectionID: params.CollectionID,
|
||||||
|
FileID: params.FileID,
|
||||||
|
Uploader: uploader,
|
||||||
|
Locale: params.Locale,
|
||||||
|
Chunking: params.Chunking,
|
||||||
|
Embedding: params.Embedding,
|
||||||
|
Extraction: params.Extraction,
|
||||||
|
Fetcher: params.Fetcher,
|
||||||
|
Converter: params.Converter,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add function execution to job
|
||||||
|
err = j.AddFunc(&job.ExecutionOptions{Priority: 1}, "kb.addfile", func(execCtx *job.ExecutionContext) error {
|
||||||
|
return instance.processFile(execCtx.Ctx, asyncDocID, asyncParams)
|
||||||
|
}, map[string]interface{}{
|
||||||
|
"doc_id": asyncDocID,
|
||||||
|
"collection_id": params.CollectionID,
|
||||||
|
"file_id": params.FileID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// Rollback: remove document record
|
||||||
|
instance.Config.RemoveDocument(docID)
|
||||||
|
return nil, fmt.Errorf("failed to add job execution: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push the job to execution queue
|
||||||
|
err = j.Push()
|
||||||
|
if err != nil {
|
||||||
|
// Rollback: remove document record
|
||||||
|
instance.Config.RemoveDocument(docID)
|
||||||
|
return nil, fmt.Errorf("failed to push job: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AddDocumentAsyncResult{
|
||||||
|
JobID: j.JobID,
|
||||||
|
DocID: docID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// processFile processes file content and updates the knowledge base
|
||||||
|
func (instance *KBInstance) processFile(ctx context.Context, docID string, params *AddFileParams) error {
|
||||||
|
uploader := params.Uploader
|
||||||
|
if uploader == "" {
|
||||||
|
uploader = DefaultUploader
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file manager
|
||||||
|
m, ok := attachment.Managers[uploader]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("invalid uploader: %s not found", uploader)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file path
|
||||||
|
path, contentType, err := m.LocalPath(ctx, params.FileID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get local path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to UpsertOptions
|
||||||
|
upsertOptions, err := instance.toUpsertOptions(docID, params.CollectionID, params.Locale, path, contentType, params.Chunking, params.Embedding, params.Extraction, params.Fetcher, params.Converter)
|
||||||
|
if err != nil {
|
||||||
|
instance.Config.UpdateDocument(docID, maps.MapStrAny{"status": "error", "error_message": err.Error()})
|
||||||
|
return fmt.Errorf("failed to convert to upsert options: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add file to GraphRag
|
||||||
|
_, err = instance.GraphRag.AddFile(ctx, path, upsertOptions)
|
||||||
|
if err != nil {
|
||||||
|
instance.Config.UpdateDocument(docID, maps.MapStrAny{"status": "error", "error_message": err.Error()})
|
||||||
|
return fmt.Errorf("failed to add file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status and segment count
|
||||||
|
instance.updateDocumentAfterProcessing(ctx, docID, params.CollectionID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
585
kb/api/addfile_test.go
Normal file
585
kb/api/addfile_test.go
Normal file
|
|
@ -0,0 +1,585 @@
|
||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"mime/multipart"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
graphragtypes "github.com/yaoapp/gou/graphrag/types"
|
||||||
|
"github.com/yaoapp/yao/attachment"
|
||||||
|
"github.com/yaoapp/yao/kb"
|
||||||
|
"github.com/yaoapp/yao/kb/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Note: TestMain is defined in collection_test.go, which handles environment setup
|
||||||
|
// Run tests with: source env.local.sh && go test -v ./kb/api/...
|
||||||
|
|
||||||
|
// createTestCollectionForFile is a helper to create a test collection for file tests
|
||||||
|
func createTestCollectionForFile(t *testing.T, ctx context.Context) string {
|
||||||
|
if kb.API == nil {
|
||||||
|
t.Skip("KB API not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionID := fmt.Sprintf("test_file_%d", time.Now().UnixNano())
|
||||||
|
|
||||||
|
params := &api.CreateCollectionParams{
|
||||||
|
ID: collectionID,
|
||||||
|
Metadata: map[string]interface{}{
|
||||||
|
"name": "Test File Collection",
|
||||||
|
"description": "Collection for AddFile tests",
|
||||||
|
},
|
||||||
|
EmbeddingProviderID: "__yao.openai",
|
||||||
|
EmbeddingOptionID: "text-embedding-3-small",
|
||||||
|
Locale: "en",
|
||||||
|
Config: &graphragtypes.CreateCollectionOptions{
|
||||||
|
Distance: "cosine",
|
||||||
|
IndexType: "hnsw",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := kb.API.CreateCollection(ctx, params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create test collection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return collectionID
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupTestCollectionForFile removes a test collection
|
||||||
|
func cleanupTestCollectionForFile(ctx context.Context, collectionID string) {
|
||||||
|
if kb.API != nil {
|
||||||
|
_, _ = kb.API.RemoveCollection(ctx, collectionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== AddFile Tests ==========
|
||||||
|
// Note: Full AddFile tests require actual files to be uploaded via attachment manager
|
||||||
|
// These tests verify parameter validation and error handling
|
||||||
|
|
||||||
|
func TestAddFile(t *testing.T) {
|
||||||
|
if kb.API == nil {
|
||||||
|
t.Skip("KB API not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
collectionID := createTestCollectionForFile(t, ctx)
|
||||||
|
defer cleanupTestCollectionForFile(ctx, collectionID)
|
||||||
|
|
||||||
|
t.Run("AddFileMissingCollectionID", func(t *testing.T) {
|
||||||
|
params := &api.AddFileParams{
|
||||||
|
FileID: "some_file_id",
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddFile(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "collection_id is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddFileMissingFileID", func(t *testing.T) {
|
||||||
|
params := &api.AddFileParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddFile(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "file_id is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddFileMissingChunking", func(t *testing.T) {
|
||||||
|
params := &api.AddFileParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
FileID: "some_file_id",
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddFile(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "chunking configuration is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddFileMissingEmbedding", func(t *testing.T) {
|
||||||
|
params := &api.AddFileParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
FileID: "some_file_id",
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddFile(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "embedding configuration is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddFileInvalidUploader", func(t *testing.T) {
|
||||||
|
params := &api.AddFileParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
FileID: "some_file_id",
|
||||||
|
Uploader: "invalid_uploader",
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddFile(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "invalid uploader")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddFileNotFound", func(t *testing.T) {
|
||||||
|
params := &api.AddFileParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
FileID: "nonexistent_file_id",
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddFile(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
// Error could be "file not found" or "invalid uploader" depending on environment
|
||||||
|
assert.True(t, err != nil, "Expected an error")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== AddFileAsync Tests ==========
|
||||||
|
|
||||||
|
func TestAddFileAsync(t *testing.T) {
|
||||||
|
if kb.API == nil {
|
||||||
|
t.Skip("KB API not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
collectionID := createTestCollectionForFile(t, ctx)
|
||||||
|
defer cleanupTestCollectionForFile(ctx, collectionID)
|
||||||
|
|
||||||
|
t.Run("AddFileAsyncMissingCollectionID", func(t *testing.T) {
|
||||||
|
params := &api.AddFileParams{
|
||||||
|
FileID: "some_file_id",
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddFileAsync(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "collection_id is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddFileAsyncMissingFileID", func(t *testing.T) {
|
||||||
|
params := &api.AddFileParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddFileAsync(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "file_id is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddFileAsyncMissingChunking", func(t *testing.T) {
|
||||||
|
params := &api.AddFileParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
FileID: "some_file_id",
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddFileAsync(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "chunking configuration is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddFileAsyncMissingEmbedding", func(t *testing.T) {
|
||||||
|
params := &api.AddFileParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
FileID: "some_file_id",
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddFileAsync(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "embedding configuration is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddFileAsyncInvalidUploader", func(t *testing.T) {
|
||||||
|
params := &api.AddFileParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
FileID: "some_file_id",
|
||||||
|
Uploader: "invalid_uploader",
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddFileAsync(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "invalid uploader")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddFileAsyncNotFound", func(t *testing.T) {
|
||||||
|
params := &api.AddFileParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
FileID: "nonexistent_file_id",
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddFileAsync(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
// Error could be "file not found" or "invalid uploader" depending on environment
|
||||||
|
assert.True(t, err != nil, "Expected an error")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== AddFile with Real File Tests ==========
|
||||||
|
|
||||||
|
// getTestUploader returns the uploader name and manager for testing
|
||||||
|
func getTestUploader(t *testing.T) (string, *attachment.Manager) {
|
||||||
|
// Try __yao.attachment first (system uploader)
|
||||||
|
if manager, ok := attachment.Managers["__yao.attachment"]; ok {
|
||||||
|
return "__yao.attachment", manager
|
||||||
|
}
|
||||||
|
// Try local manager
|
||||||
|
if manager, ok := attachment.Managers["local"]; ok {
|
||||||
|
return "local", manager
|
||||||
|
}
|
||||||
|
// List available managers for debugging
|
||||||
|
var available []string
|
||||||
|
for name := range attachment.Managers {
|
||||||
|
available = append(available, name)
|
||||||
|
}
|
||||||
|
t.Fatalf("No attachment manager available. Available managers: %v", available)
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadTestFile uploads a test file using the attachment manager and returns the file ID
|
||||||
|
func uploadTestFile(t *testing.T, ctx context.Context, filename, content string) string {
|
||||||
|
_, manager := getTestUploader(t)
|
||||||
|
|
||||||
|
// Create file header
|
||||||
|
fileHeader := &attachment.FileHeader{
|
||||||
|
FileHeader: &multipart.FileHeader{
|
||||||
|
Filename: filename,
|
||||||
|
Size: int64(len(content)),
|
||||||
|
Header: make(map[string][]string),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fileHeader.Header.Set("Content-Type", "text/plain")
|
||||||
|
|
||||||
|
// Upload the file
|
||||||
|
reader := strings.NewReader(content)
|
||||||
|
file, err := manager.Upload(ctx, fileHeader, reader, attachment.UploadOption{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to upload test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Uploaded test file: %s (ID: %s)", filename, file.ID)
|
||||||
|
return file.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupTestFile removes a test file
|
||||||
|
func cleanupTestFile(ctx context.Context, t *testing.T, fileID string) {
|
||||||
|
_, manager := getTestUploader(t)
|
||||||
|
_ = manager.Delete(ctx, fileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddFileWithRealFile(t *testing.T) {
|
||||||
|
if kb.API == nil {
|
||||||
|
t.Skip("KB API not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
collectionID := createTestCollectionForFile(t, ctx)
|
||||||
|
defer cleanupTestCollectionForFile(ctx, collectionID)
|
||||||
|
|
||||||
|
// Get the uploader name
|
||||||
|
uploaderName, _ := getTestUploader(t)
|
||||||
|
|
||||||
|
// Upload a test file
|
||||||
|
testContent := `This is a test document for the knowledge base.
|
||||||
|
It contains content to test the file processing functionality.`
|
||||||
|
|
||||||
|
fileID := uploadTestFile(t, ctx, "test_document.txt", testContent)
|
||||||
|
defer cleanupTestFile(ctx, t, fileID)
|
||||||
|
|
||||||
|
t.Run("AddFileSuccess", func(t *testing.T) {
|
||||||
|
params := &api.AddFileParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
FileID: fileID,
|
||||||
|
Uploader: uploaderName,
|
||||||
|
Locale: "en",
|
||||||
|
Metadata: map[string]interface{}{
|
||||||
|
"description": "A test file document",
|
||||||
|
},
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
OptionID: "standard",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
OptionID: "text-embedding-3-small",
|
||||||
|
},
|
||||||
|
AuthScope: map[string]interface{}{
|
||||||
|
"__yao_created_by": "test_user",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddFile(ctx, params)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
if result != nil {
|
||||||
|
assert.Equal(t, collectionID, result.CollectionID)
|
||||||
|
assert.NotEmpty(t, result.DocID)
|
||||||
|
assert.Equal(t, fileID, result.FileID)
|
||||||
|
assert.Contains(t, result.Message, "successfully")
|
||||||
|
t.Logf("Added file document: %s", result.DocID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify document was created
|
||||||
|
if result != nil {
|
||||||
|
doc, err := kb.API.GetDocument(ctx, result.DocID, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, doc)
|
||||||
|
assert.Equal(t, "file", doc["type"])
|
||||||
|
assert.Equal(t, "completed", doc["status"])
|
||||||
|
t.Logf("✅ File Document verified: type=%v, status=%v", doc["type"], doc["status"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddFileAsyncWithRealFile(t *testing.T) {
|
||||||
|
if kb.API == nil {
|
||||||
|
t.Skip("KB API not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
collectionID := createTestCollectionForFile(t, ctx)
|
||||||
|
defer cleanupTestCollectionForFile(ctx, collectionID)
|
||||||
|
|
||||||
|
// Get the uploader name
|
||||||
|
uploaderName, _ := getTestUploader(t)
|
||||||
|
|
||||||
|
// Upload a test file for async processing
|
||||||
|
testContent := `Async test document content.
|
||||||
|
|
||||||
|
This document will be processed asynchronously.
|
||||||
|
|
||||||
|
The job system should handle the processing in the background.`
|
||||||
|
|
||||||
|
fileID := uploadTestFile(t, ctx, "async_test_document.txt", testContent)
|
||||||
|
defer cleanupTestFile(ctx, t, fileID)
|
||||||
|
|
||||||
|
t.Run("AddFileAsyncSuccess", func(t *testing.T) {
|
||||||
|
params := &api.AddFileParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
FileID: fileID,
|
||||||
|
Uploader: uploaderName,
|
||||||
|
Locale: "en",
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
OptionID: "standard",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
OptionID: "text-embedding-3-small",
|
||||||
|
},
|
||||||
|
Job: &api.JobOptionsParams{
|
||||||
|
Name: "Test Async File Job",
|
||||||
|
Description: "Testing async file processing",
|
||||||
|
Category: "Test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddFileAsync(ctx, params)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
if result != nil {
|
||||||
|
assert.NotEmpty(t, result.JobID)
|
||||||
|
assert.NotEmpty(t, result.DocID)
|
||||||
|
t.Logf("Created async file job: %s for document: %s", result.JobID, result.DocID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify document was created with pending status
|
||||||
|
if result != nil {
|
||||||
|
doc, err := kb.API.GetDocument(ctx, result.DocID, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, doc)
|
||||||
|
assert.Equal(t, "file", doc["type"])
|
||||||
|
assert.Equal(t, result.JobID, doc["job_id"])
|
||||||
|
t.Logf("✅ Async file document created: status=%v, job_id=%v", doc["status"], doc["job_id"])
|
||||||
|
|
||||||
|
// Wait for job to complete (max 30 seconds)
|
||||||
|
maxWait := 30 * time.Second
|
||||||
|
pollInterval := 500 * time.Millisecond
|
||||||
|
startTime := time.Now()
|
||||||
|
var finalStatus string
|
||||||
|
|
||||||
|
for time.Since(startTime) < maxWait {
|
||||||
|
doc, err = kb.API.GetDocument(ctx, result.DocID, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Error getting document: %v", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
finalStatus, _ = doc["status"].(string)
|
||||||
|
if finalStatus == "completed" || finalStatus == "error" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(pollInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✅ Job completed: final status=%s, elapsed=%v", finalStatus, time.Since(startTime))
|
||||||
|
assert.Equal(t, "completed", finalStatus, "Job should complete successfully")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddFileIntegration(t *testing.T) {
|
||||||
|
if kb.API == nil {
|
||||||
|
t.Skip("KB API not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
collectionID := createTestCollectionForFile(t, ctx)
|
||||||
|
defer cleanupTestCollectionForFile(ctx, collectionID)
|
||||||
|
|
||||||
|
// Get the uploader name
|
||||||
|
uploaderName, _ := getTestUploader(t)
|
||||||
|
|
||||||
|
t.Run("FullFileLifecycle", func(t *testing.T) {
|
||||||
|
// Upload a test file
|
||||||
|
testContent := `Integration test document.
|
||||||
|
|
||||||
|
This document tests the full lifecycle of file processing:
|
||||||
|
1. Upload file
|
||||||
|
2. Add to knowledge base
|
||||||
|
3. Verify document creation
|
||||||
|
4. List documents
|
||||||
|
5. Remove document
|
||||||
|
|
||||||
|
End of test content.`
|
||||||
|
|
||||||
|
fileID := uploadTestFile(t, ctx, "lifecycle_test.txt", testContent)
|
||||||
|
defer cleanupTestFile(ctx, t, fileID)
|
||||||
|
|
||||||
|
// 1. Add File Document
|
||||||
|
addParams := &api.AddFileParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
FileID: fileID,
|
||||||
|
Uploader: uploaderName,
|
||||||
|
Locale: "en",
|
||||||
|
Metadata: map[string]interface{}{
|
||||||
|
"title": "File Lifecycle Test",
|
||||||
|
"description": "Full lifecycle integration test",
|
||||||
|
},
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
OptionID: "standard",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
OptionID: "text-embedding-3-small",
|
||||||
|
},
|
||||||
|
AuthScope: map[string]interface{}{
|
||||||
|
"__yao_created_by": "integration_test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddFile(ctx, addParams)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
if result == nil {
|
||||||
|
t.Fatalf("Failed to create file document: result is nil")
|
||||||
|
}
|
||||||
|
t.Logf("1. Created file document: %s", result.DocID)
|
||||||
|
|
||||||
|
// 2. Get Document
|
||||||
|
doc, err := kb.API.GetDocument(ctx, result.DocID, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, doc)
|
||||||
|
assert.Equal(t, "file", doc["type"])
|
||||||
|
assert.Equal(t, "completed", doc["status"])
|
||||||
|
t.Logf("2. Retrieved document: name=%v, type=%v, status=%v", doc["name"], doc["type"], doc["status"])
|
||||||
|
|
||||||
|
// 3. List Documents
|
||||||
|
listFilter := &api.ListDocumentsFilter{
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 20,
|
||||||
|
CollectionID: collectionID,
|
||||||
|
}
|
||||||
|
listResult, err := kb.API.ListDocuments(ctx, listFilter)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, len(listResult.Data), 1)
|
||||||
|
t.Logf("3. Found document in list: %d documents", len(listResult.Data))
|
||||||
|
|
||||||
|
// 4. Remove Document
|
||||||
|
removeParams := &api.RemoveDocumentsParams{
|
||||||
|
DocumentIDs: []string{result.DocID},
|
||||||
|
}
|
||||||
|
removeResult, err := kb.API.RemoveDocuments(ctx, removeParams)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, removeResult)
|
||||||
|
t.Logf("4. Removed document: %d deleted", removeResult.DeletedCount)
|
||||||
|
|
||||||
|
// 5. Verify Removal
|
||||||
|
_, err = kb.API.GetDocument(ctx, result.DocID, nil)
|
||||||
|
assert.Error(t, err)
|
||||||
|
t.Logf("5. Verified document removal")
|
||||||
|
|
||||||
|
t.Logf("✅ Full file lifecycle test completed successfully")
|
||||||
|
})
|
||||||
|
}
|
||||||
230
kb/api/addtext.go
Normal file
230
kb/api/addtext.go
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/yaoapp/gou/graphrag/utils"
|
||||||
|
"github.com/yaoapp/kun/maps"
|
||||||
|
"github.com/yaoapp/yao/job"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddText adds text to a collection (sync)
|
||||||
|
func (instance *KBInstance) AddText(ctx context.Context, params *AddTextParams) (*AddDocumentResult, error) {
|
||||||
|
// Validate required parameters
|
||||||
|
if params.CollectionID == "" {
|
||||||
|
return nil, fmt.Errorf("collection_id is required")
|
||||||
|
}
|
||||||
|
if params.Text == "" {
|
||||||
|
return nil, fmt.Errorf("text is required")
|
||||||
|
}
|
||||||
|
if params.Chunking == nil {
|
||||||
|
return nil, fmt.Errorf("chunking configuration is required")
|
||||||
|
}
|
||||||
|
if params.Embedding == nil {
|
||||||
|
return nil, fmt.Errorf("embedding configuration is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate document ID if not provided
|
||||||
|
docID := params.DocID
|
||||||
|
if docID == "" {
|
||||||
|
docID = utils.GenDocIDWithCollectionID(params.CollectionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create document record
|
||||||
|
documentData := map[string]interface{}{
|
||||||
|
"document_id": docID,
|
||||||
|
"collection_id": params.CollectionID,
|
||||||
|
"name": "Text Document",
|
||||||
|
"type": "text",
|
||||||
|
"status": "pending",
|
||||||
|
"text_content": params.Text,
|
||||||
|
"size": int64(len(params.Text)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use title from metadata if available
|
||||||
|
if params.Metadata != nil {
|
||||||
|
if title, ok := params.Metadata["title"].(string); ok && title != "" {
|
||||||
|
documentData["name"] = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add auth scope fields
|
||||||
|
if params.AuthScope != nil {
|
||||||
|
for k, v := range params.AuthScope {
|
||||||
|
documentData[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add base fields
|
||||||
|
addBaseFieldsFromParams(documentData, params.Locale, params.Metadata, params.Chunking, params.Embedding, params.Extraction, params.Fetcher, params.Converter)
|
||||||
|
|
||||||
|
// Create database record
|
||||||
|
_, err := instance.Config.CreateDocument(maps.MapStrAny(documentData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to save document metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process text content
|
||||||
|
params.DocID = docID // Ensure docID is set
|
||||||
|
err = instance.processText(ctx, docID, params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AddDocumentResult{
|
||||||
|
Message: "Text added successfully",
|
||||||
|
CollectionID: params.CollectionID,
|
||||||
|
DocID: docID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTextAsync adds text to a collection (async)
|
||||||
|
func (instance *KBInstance) AddTextAsync(ctx context.Context, params *AddTextParams) (*AddDocumentAsyncResult, error) {
|
||||||
|
// Validate required parameters
|
||||||
|
if params.CollectionID == "" {
|
||||||
|
return nil, fmt.Errorf("collection_id is required")
|
||||||
|
}
|
||||||
|
if params.Text == "" {
|
||||||
|
return nil, fmt.Errorf("text is required")
|
||||||
|
}
|
||||||
|
if params.Chunking == nil {
|
||||||
|
return nil, fmt.Errorf("chunking configuration is required")
|
||||||
|
}
|
||||||
|
if params.Embedding == nil {
|
||||||
|
return nil, fmt.Errorf("embedding configuration is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate document ID if not provided
|
||||||
|
docID := params.DocID
|
||||||
|
if docID == "" {
|
||||||
|
docID = utils.GenDocIDWithCollectionID(params.CollectionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get job options with defaults
|
||||||
|
jobName, jobDescription, jobIcon, jobCategory := getJobOptions(params.Job,
|
||||||
|
"Knowledge Base Text Processing",
|
||||||
|
"Processing and indexing text content for knowledge base search",
|
||||||
|
"library_add",
|
||||||
|
"Knowledge Base",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create job data
|
||||||
|
jobCreateData := map[string]interface{}{
|
||||||
|
"name": jobName,
|
||||||
|
"description": jobDescription,
|
||||||
|
"category_name": jobCategory,
|
||||||
|
}
|
||||||
|
if jobIcon != "" {
|
||||||
|
jobCreateData["icon"] = jobIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add auth scope fields
|
||||||
|
if params.AuthScope != nil {
|
||||||
|
for k, v := range params.AuthScope {
|
||||||
|
jobCreateData[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and save Job
|
||||||
|
j, err := job.OnceAndSave(job.GOROUTINE, jobCreateData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create and save job: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create document record
|
||||||
|
documentData := map[string]interface{}{
|
||||||
|
"document_id": docID,
|
||||||
|
"collection_id": params.CollectionID,
|
||||||
|
"name": "Text Document",
|
||||||
|
"type": "text",
|
||||||
|
"status": "pending",
|
||||||
|
"text_content": params.Text,
|
||||||
|
"size": int64(len(params.Text)),
|
||||||
|
"job_id": j.JobID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use title from metadata if available
|
||||||
|
if params.Metadata != nil {
|
||||||
|
if title, ok := params.Metadata["title"].(string); ok && title != "" {
|
||||||
|
documentData["name"] = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add auth scope fields
|
||||||
|
if params.AuthScope != nil {
|
||||||
|
for k, v := range params.AuthScope {
|
||||||
|
documentData[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add base fields
|
||||||
|
addBaseFieldsFromParams(documentData, params.Locale, params.Metadata, params.Chunking, params.Embedding, params.Extraction, params.Fetcher, params.Converter)
|
||||||
|
|
||||||
|
// Create database record
|
||||||
|
_, err = instance.Config.CreateDocument(maps.MapStrAny(documentData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to save document metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture parameters for the async function
|
||||||
|
asyncDocID := docID
|
||||||
|
asyncParams := &AddTextParams{
|
||||||
|
CollectionID: params.CollectionID,
|
||||||
|
Text: params.Text,
|
||||||
|
Locale: params.Locale,
|
||||||
|
Chunking: params.Chunking,
|
||||||
|
Embedding: params.Embedding,
|
||||||
|
Extraction: params.Extraction,
|
||||||
|
Fetcher: params.Fetcher,
|
||||||
|
Converter: params.Converter,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add function execution to job
|
||||||
|
err = j.AddFunc(&job.ExecutionOptions{Priority: 1}, "kb.addtext", func(execCtx *job.ExecutionContext) error {
|
||||||
|
return instance.processText(execCtx.Ctx, asyncDocID, asyncParams)
|
||||||
|
}, map[string]interface{}{
|
||||||
|
"doc_id": asyncDocID,
|
||||||
|
"collection_id": params.CollectionID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// Rollback: remove document record
|
||||||
|
instance.Config.RemoveDocument(docID)
|
||||||
|
return nil, fmt.Errorf("failed to add job execution: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push the job to execution queue
|
||||||
|
err = j.Push()
|
||||||
|
if err != nil {
|
||||||
|
// Rollback: remove document record
|
||||||
|
instance.Config.RemoveDocument(docID)
|
||||||
|
return nil, fmt.Errorf("failed to push job: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AddDocumentAsyncResult{
|
||||||
|
JobID: j.JobID,
|
||||||
|
DocID: docID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// processText processes text content and updates the knowledge base
|
||||||
|
func (instance *KBInstance) processText(ctx context.Context, docID string, params *AddTextParams) error {
|
||||||
|
// Convert to UpsertOptions
|
||||||
|
upsertOptions, err := instance.toUpsertOptions(docID, params.CollectionID, params.Locale, "", "", params.Chunking, params.Embedding, params.Extraction, params.Fetcher, params.Converter)
|
||||||
|
if err != nil {
|
||||||
|
instance.Config.UpdateDocument(docID, maps.MapStrAny{"status": "error", "error_message": err.Error()})
|
||||||
|
return fmt.Errorf("failed to convert to upsert options: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add text to GraphRag
|
||||||
|
_, err = instance.GraphRag.AddText(ctx, params.Text, upsertOptions)
|
||||||
|
if err != nil {
|
||||||
|
instance.Config.UpdateDocument(docID, maps.MapStrAny{"status": "error", "error_message": err.Error()})
|
||||||
|
return fmt.Errorf("failed to add text: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status and segment count
|
||||||
|
instance.updateDocumentAfterProcessing(ctx, docID, params.CollectionID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
485
kb/api/addtext_test.go
Normal file
485
kb/api/addtext_test.go
Normal file
|
|
@ -0,0 +1,485 @@
|
||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
graphragtypes "github.com/yaoapp/gou/graphrag/types"
|
||||||
|
"github.com/yaoapp/yao/kb"
|
||||||
|
"github.com/yaoapp/yao/kb/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Note: TestMain is defined in collection_test.go, which handles environment setup
|
||||||
|
// Run tests with: source env.local.sh && go test -v ./kb/api/...
|
||||||
|
|
||||||
|
// createTestCollectionForText is a helper to create a test collection for text tests
|
||||||
|
func createTestCollectionForText(t *testing.T, ctx context.Context) string {
|
||||||
|
if kb.API == nil {
|
||||||
|
t.Skip("KB API not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionID := fmt.Sprintf("test_text_%d", time.Now().UnixNano())
|
||||||
|
|
||||||
|
params := &api.CreateCollectionParams{
|
||||||
|
ID: collectionID,
|
||||||
|
Metadata: map[string]interface{}{
|
||||||
|
"name": "Test Text Collection",
|
||||||
|
"description": "Collection for AddText tests",
|
||||||
|
},
|
||||||
|
EmbeddingProviderID: "__yao.openai",
|
||||||
|
EmbeddingOptionID: "text-embedding-3-small",
|
||||||
|
Locale: "en",
|
||||||
|
Config: &graphragtypes.CreateCollectionOptions{
|
||||||
|
Distance: "cosine",
|
||||||
|
IndexType: "hnsw",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := kb.API.CreateCollection(ctx, params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create test collection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return collectionID
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupTestCollectionForText removes a test collection
|
||||||
|
func cleanupTestCollectionForText(ctx context.Context, collectionID string) {
|
||||||
|
if kb.API != nil {
|
||||||
|
_, _ = kb.API.RemoveCollection(ctx, collectionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== AddText Tests ==========
|
||||||
|
|
||||||
|
func TestAddText(t *testing.T) {
|
||||||
|
if kb.API == nil {
|
||||||
|
t.Skip("KB API not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
collectionID := createTestCollectionForText(t, ctx)
|
||||||
|
defer cleanupTestCollectionForText(ctx, collectionID)
|
||||||
|
|
||||||
|
t.Run("AddTextSuccess", func(t *testing.T) {
|
||||||
|
params := &api.AddTextParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
Text: "This is a test document content for knowledge base testing. It contains some sample text that will be chunked and embedded.",
|
||||||
|
Locale: "en",
|
||||||
|
Metadata: map[string]interface{}{
|
||||||
|
"title": "Test Text Document",
|
||||||
|
"description": "A test document",
|
||||||
|
},
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
OptionID: "standard",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
OptionID: "text-embedding-3-small",
|
||||||
|
},
|
||||||
|
AuthScope: map[string]interface{}{
|
||||||
|
"__yao_created_by": "test_user",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddText(ctx, params)
|
||||||
|
if err != nil {
|
||||||
|
// Skip if connector not loaded (environment issue)
|
||||||
|
if assert.Contains(t, err.Error(), "connector") {
|
||||||
|
t.Skipf("Skipping due to connector not loaded: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
if result != nil {
|
||||||
|
assert.Equal(t, collectionID, result.CollectionID)
|
||||||
|
assert.NotEmpty(t, result.DocID)
|
||||||
|
assert.Contains(t, result.Message, "successfully")
|
||||||
|
t.Logf("Added text document: %s", result.DocID)
|
||||||
|
|
||||||
|
// Verify document was created
|
||||||
|
doc, err := kb.API.GetDocument(ctx, result.DocID, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, doc)
|
||||||
|
assert.Equal(t, "text", doc["type"])
|
||||||
|
assert.Equal(t, "Test Text Document", doc["name"])
|
||||||
|
assert.Equal(t, "completed", doc["status"])
|
||||||
|
t.Logf("✅ Document verified: type=%v, name=%v, status=%v", doc["type"], doc["name"], doc["status"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddTextMissingCollectionID", func(t *testing.T) {
|
||||||
|
params := &api.AddTextParams{
|
||||||
|
Text: "Some text content",
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddText(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "collection_id is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddTextMissingText", func(t *testing.T) {
|
||||||
|
params := &api.AddTextParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddText(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "text is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddTextMissingChunking", func(t *testing.T) {
|
||||||
|
params := &api.AddTextParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
Text: "Some text content",
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddText(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "chunking configuration is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddTextMissingEmbedding", func(t *testing.T) {
|
||||||
|
params := &api.AddTextParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
Text: "Some text content",
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddText(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "embedding configuration is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddTextWithCustomDocID", func(t *testing.T) {
|
||||||
|
customDocID := fmt.Sprintf("custom_text_doc_%d", time.Now().UnixNano())
|
||||||
|
params := &api.AddTextParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
DocID: customDocID,
|
||||||
|
Text: "Text with custom document ID",
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
OptionID: "standard",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
OptionID: "text-embedding-3-small",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddText(ctx, params)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping due to error: %v", err)
|
||||||
|
}
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
if result != nil {
|
||||||
|
assert.Equal(t, customDocID, result.DocID)
|
||||||
|
t.Logf("Added text with custom DocID: %s", result.DocID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddTextWithTitleFromMetadata", func(t *testing.T) {
|
||||||
|
params := &api.AddTextParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
Text: "Text content with title from metadata",
|
||||||
|
Metadata: map[string]interface{}{
|
||||||
|
"title": "Custom Title From Metadata",
|
||||||
|
},
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
OptionID: "standard",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
OptionID: "text-embedding-3-small",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddText(ctx, params)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
|
||||||
|
if result != nil {
|
||||||
|
doc, err := kb.API.GetDocument(ctx, result.DocID, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "Custom Title From Metadata", doc["name"])
|
||||||
|
t.Logf("✅ Title from metadata verified: %v", doc["name"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== AddTextAsync Tests ==========
|
||||||
|
|
||||||
|
func TestAddTextAsync(t *testing.T) {
|
||||||
|
if kb.API == nil {
|
||||||
|
t.Skip("KB API not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
collectionID := createTestCollectionForText(t, ctx)
|
||||||
|
defer cleanupTestCollectionForText(ctx, collectionID)
|
||||||
|
|
||||||
|
t.Run("AddTextAsyncSuccess", func(t *testing.T) {
|
||||||
|
params := &api.AddTextParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
Text: "This is async text content for testing background processing.",
|
||||||
|
Locale: "en",
|
||||||
|
Metadata: map[string]interface{}{
|
||||||
|
"title": "Async Text Document",
|
||||||
|
},
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
OptionID: "standard",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
OptionID: "text-embedding-3-small",
|
||||||
|
},
|
||||||
|
Job: &api.JobOptionsParams{
|
||||||
|
Name: "Test Async Text Job",
|
||||||
|
Description: "Testing async text processing",
|
||||||
|
Category: "Test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddTextAsync(ctx, params)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
if result != nil {
|
||||||
|
assert.NotEmpty(t, result.JobID)
|
||||||
|
assert.NotEmpty(t, result.DocID)
|
||||||
|
t.Logf("Created async job: %s for document: %s", result.JobID, result.DocID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify document was created with pending status
|
||||||
|
if result != nil {
|
||||||
|
doc, err := kb.API.GetDocument(ctx, result.DocID, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, doc)
|
||||||
|
assert.Equal(t, "text", doc["type"])
|
||||||
|
assert.Equal(t, result.JobID, doc["job_id"])
|
||||||
|
t.Logf("✅ Async document created: status=%v, job_id=%v", doc["status"], doc["job_id"])
|
||||||
|
|
||||||
|
// Wait for job to complete (max 30 seconds)
|
||||||
|
maxWait := 30 * time.Second
|
||||||
|
pollInterval := 500 * time.Millisecond
|
||||||
|
startTime := time.Now()
|
||||||
|
var finalStatus string
|
||||||
|
|
||||||
|
for time.Since(startTime) < maxWait {
|
||||||
|
doc, err = kb.API.GetDocument(ctx, result.DocID, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Error getting document: %v", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
finalStatus, _ = doc["status"].(string)
|
||||||
|
if finalStatus == "completed" || finalStatus == "error" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(pollInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✅ Job completed: final status=%s, elapsed=%v", finalStatus, time.Since(startTime))
|
||||||
|
if finalStatus == "error" {
|
||||||
|
if errMsg, ok := doc["error_message"].(string); ok {
|
||||||
|
t.Logf("Error message: %s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Equal(t, "completed", finalStatus, "Job should complete successfully")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddTextAsyncMissingCollectionID", func(t *testing.T) {
|
||||||
|
params := &api.AddTextParams{
|
||||||
|
Text: "Some text",
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddTextAsync(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "collection_id is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddTextAsyncMissingText", func(t *testing.T) {
|
||||||
|
params := &api.AddTextParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddTextAsync(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "text is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddTextAsyncMissingChunking", func(t *testing.T) {
|
||||||
|
params := &api.AddTextParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
Text: "Some text",
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddTextAsync(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "chunking configuration is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddTextAsyncMissingEmbedding", func(t *testing.T) {
|
||||||
|
params := &api.AddTextParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
Text: "Some text",
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddTextAsync(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "embedding configuration is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddTextAsyncWithCustomDocID", func(t *testing.T) {
|
||||||
|
customDocID := fmt.Sprintf("async_custom_text_%d", time.Now().UnixNano())
|
||||||
|
params := &api.AddTextParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
DocID: customDocID,
|
||||||
|
Text: "Async text with custom DocID",
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
OptionID: "standard",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
OptionID: "text-embedding-3-small",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddTextAsync(ctx, params)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
if result != nil {
|
||||||
|
assert.Equal(t, customDocID, result.DocID)
|
||||||
|
t.Logf("Created async text with custom DocID: %s", result.DocID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== AddText Integration Test ==========
|
||||||
|
|
||||||
|
func TestAddTextIntegration(t *testing.T) {
|
||||||
|
if kb.API == nil {
|
||||||
|
t.Skip("KB API not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
collectionID := createTestCollectionForText(t, ctx)
|
||||||
|
defer cleanupTestCollectionForText(ctx, collectionID)
|
||||||
|
|
||||||
|
t.Run("FullTextLifecycle", func(t *testing.T) {
|
||||||
|
// 1. Add Text Document
|
||||||
|
addParams := &api.AddTextParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
Text: "This is a comprehensive test of the text document lifecycle including creation, retrieval, and removal.",
|
||||||
|
Locale: "en",
|
||||||
|
Metadata: map[string]interface{}{
|
||||||
|
"title": "Text Lifecycle Test",
|
||||||
|
"description": "Full lifecycle integration test",
|
||||||
|
},
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
OptionID: "text-embedding-3-small",
|
||||||
|
},
|
||||||
|
AuthScope: map[string]interface{}{
|
||||||
|
"__yao_created_by": "integration_test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddText(ctx, addParams)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping integration test due to AddText error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
t.Logf("1. Created text document: %s", result.DocID)
|
||||||
|
|
||||||
|
// 2. Get Document
|
||||||
|
doc, err := kb.API.GetDocument(ctx, result.DocID, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, doc)
|
||||||
|
assert.Equal(t, "Text Lifecycle Test", doc["name"])
|
||||||
|
assert.Equal(t, "text", doc["type"])
|
||||||
|
assert.Equal(t, "completed", doc["status"])
|
||||||
|
t.Logf("2. Retrieved document: name=%v, status=%v", doc["name"], doc["status"])
|
||||||
|
|
||||||
|
// 3. List Documents
|
||||||
|
listFilter := &api.ListDocumentsFilter{
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 20,
|
||||||
|
CollectionID: collectionID,
|
||||||
|
Keywords: "Text Lifecycle",
|
||||||
|
}
|
||||||
|
listResult, err := kb.API.ListDocuments(ctx, listFilter)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, len(listResult.Data), 1)
|
||||||
|
t.Logf("3. Found document in list: %d documents", len(listResult.Data))
|
||||||
|
|
||||||
|
// 4. Remove Document
|
||||||
|
removeParams := &api.RemoveDocumentsParams{
|
||||||
|
DocumentIDs: []string{result.DocID},
|
||||||
|
}
|
||||||
|
removeResult, err := kb.API.RemoveDocuments(ctx, removeParams)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, removeResult)
|
||||||
|
t.Logf("4. Removed document: %d deleted", removeResult.DeletedCount)
|
||||||
|
|
||||||
|
// 5. Verify Removal
|
||||||
|
_, err = kb.API.GetDocument(ctx, result.DocID, nil)
|
||||||
|
assert.Error(t, err)
|
||||||
|
t.Logf("5. Verified document removal")
|
||||||
|
|
||||||
|
t.Logf("✅ Full text lifecycle test completed successfully")
|
||||||
|
})
|
||||||
|
}
|
||||||
230
kb/api/addurl.go
Normal file
230
kb/api/addurl.go
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/yaoapp/gou/graphrag/utils"
|
||||||
|
"github.com/yaoapp/kun/maps"
|
||||||
|
"github.com/yaoapp/yao/job"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddURL adds a URL to a collection (sync)
|
||||||
|
func (instance *KBInstance) AddURL(ctx context.Context, params *AddURLParams) (*AddDocumentResult, error) {
|
||||||
|
// Validate required parameters
|
||||||
|
if params.CollectionID == "" {
|
||||||
|
return nil, fmt.Errorf("collection_id is required")
|
||||||
|
}
|
||||||
|
if params.URL == "" {
|
||||||
|
return nil, fmt.Errorf("url is required")
|
||||||
|
}
|
||||||
|
if params.Chunking == nil {
|
||||||
|
return nil, fmt.Errorf("chunking configuration is required")
|
||||||
|
}
|
||||||
|
if params.Embedding == nil {
|
||||||
|
return nil, fmt.Errorf("embedding configuration is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate document ID if not provided
|
||||||
|
docID := params.DocID
|
||||||
|
if docID == "" {
|
||||||
|
docID = utils.GenDocIDWithCollectionID(params.CollectionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create document record
|
||||||
|
documentData := map[string]interface{}{
|
||||||
|
"document_id": docID,
|
||||||
|
"collection_id": params.CollectionID,
|
||||||
|
"name": "URL Document",
|
||||||
|
"type": "url",
|
||||||
|
"status": "pending",
|
||||||
|
"url": params.URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use title from metadata if available
|
||||||
|
if params.Metadata != nil {
|
||||||
|
if title, ok := params.Metadata["title"].(string); ok && title != "" {
|
||||||
|
documentData["name"] = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add auth scope fields
|
||||||
|
if params.AuthScope != nil {
|
||||||
|
for k, v := range params.AuthScope {
|
||||||
|
documentData[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add base fields
|
||||||
|
addBaseFieldsFromParams(documentData, params.Locale, params.Metadata, params.Chunking, params.Embedding, params.Extraction, params.Fetcher, params.Converter)
|
||||||
|
|
||||||
|
// Create database record
|
||||||
|
_, err := instance.Config.CreateDocument(maps.MapStrAny(documentData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to save document metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process URL content
|
||||||
|
params.DocID = docID // Ensure docID is set
|
||||||
|
err = instance.processURL(ctx, docID, params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AddDocumentResult{
|
||||||
|
Message: "URL added successfully",
|
||||||
|
CollectionID: params.CollectionID,
|
||||||
|
DocID: docID,
|
||||||
|
URL: params.URL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddURLAsync adds a URL to a collection (async)
|
||||||
|
func (instance *KBInstance) AddURLAsync(ctx context.Context, params *AddURLParams) (*AddDocumentAsyncResult, error) {
|
||||||
|
// Validate required parameters
|
||||||
|
if params.CollectionID == "" {
|
||||||
|
return nil, fmt.Errorf("collection_id is required")
|
||||||
|
}
|
||||||
|
if params.URL == "" {
|
||||||
|
return nil, fmt.Errorf("url is required")
|
||||||
|
}
|
||||||
|
if params.Chunking == nil {
|
||||||
|
return nil, fmt.Errorf("chunking configuration is required")
|
||||||
|
}
|
||||||
|
if params.Embedding == nil {
|
||||||
|
return nil, fmt.Errorf("embedding configuration is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate document ID if not provided
|
||||||
|
docID := params.DocID
|
||||||
|
if docID == "" {
|
||||||
|
docID = utils.GenDocIDWithCollectionID(params.CollectionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get job options with defaults
|
||||||
|
jobName, jobDescription, jobIcon, jobCategory := getJobOptions(params.Job,
|
||||||
|
"Knowledge Base Web Content Processing",
|
||||||
|
"Fetching and indexing web content for knowledge base search",
|
||||||
|
"library_add",
|
||||||
|
"Knowledge Base",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create job data
|
||||||
|
jobCreateData := map[string]interface{}{
|
||||||
|
"name": jobName,
|
||||||
|
"description": jobDescription,
|
||||||
|
"category_name": jobCategory,
|
||||||
|
}
|
||||||
|
if jobIcon != "" {
|
||||||
|
jobCreateData["icon"] = jobIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add auth scope fields
|
||||||
|
if params.AuthScope != nil {
|
||||||
|
for k, v := range params.AuthScope {
|
||||||
|
jobCreateData[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and save Job
|
||||||
|
j, err := job.OnceAndSave(job.GOROUTINE, jobCreateData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create and save job: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create document record
|
||||||
|
documentData := map[string]interface{}{
|
||||||
|
"document_id": docID,
|
||||||
|
"collection_id": params.CollectionID,
|
||||||
|
"name": "URL Document",
|
||||||
|
"type": "url",
|
||||||
|
"status": "pending",
|
||||||
|
"url": params.URL,
|
||||||
|
"job_id": j.JobID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use title from metadata if available
|
||||||
|
if params.Metadata != nil {
|
||||||
|
if title, ok := params.Metadata["title"].(string); ok && title != "" {
|
||||||
|
documentData["name"] = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add auth scope fields
|
||||||
|
if params.AuthScope != nil {
|
||||||
|
for k, v := range params.AuthScope {
|
||||||
|
documentData[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add base fields
|
||||||
|
addBaseFieldsFromParams(documentData, params.Locale, params.Metadata, params.Chunking, params.Embedding, params.Extraction, params.Fetcher, params.Converter)
|
||||||
|
|
||||||
|
// Create database record
|
||||||
|
_, err = instance.Config.CreateDocument(maps.MapStrAny(documentData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to save document metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture parameters for the async function
|
||||||
|
asyncDocID := docID
|
||||||
|
asyncParams := &AddURLParams{
|
||||||
|
CollectionID: params.CollectionID,
|
||||||
|
URL: params.URL,
|
||||||
|
Locale: params.Locale,
|
||||||
|
Chunking: params.Chunking,
|
||||||
|
Embedding: params.Embedding,
|
||||||
|
Extraction: params.Extraction,
|
||||||
|
Fetcher: params.Fetcher,
|
||||||
|
Converter: params.Converter,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add function execution to job
|
||||||
|
err = j.AddFunc(&job.ExecutionOptions{Priority: 1}, "kb.addurl", func(execCtx *job.ExecutionContext) error {
|
||||||
|
return instance.processURL(execCtx.Ctx, asyncDocID, asyncParams)
|
||||||
|
}, map[string]interface{}{
|
||||||
|
"doc_id": asyncDocID,
|
||||||
|
"collection_id": params.CollectionID,
|
||||||
|
"url": params.URL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// Rollback: remove document record
|
||||||
|
instance.Config.RemoveDocument(docID)
|
||||||
|
return nil, fmt.Errorf("failed to add job execution: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push the job to execution queue
|
||||||
|
err = j.Push()
|
||||||
|
if err != nil {
|
||||||
|
// Rollback: remove document record
|
||||||
|
instance.Config.RemoveDocument(docID)
|
||||||
|
return nil, fmt.Errorf("failed to push job: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AddDocumentAsyncResult{
|
||||||
|
JobID: j.JobID,
|
||||||
|
DocID: docID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// processURL processes URL content and updates the knowledge base
|
||||||
|
func (instance *KBInstance) processURL(ctx context.Context, docID string, params *AddURLParams) error {
|
||||||
|
// Convert to UpsertOptions
|
||||||
|
upsertOptions, err := instance.toUpsertOptions(docID, params.CollectionID, params.Locale, "", "", params.Chunking, params.Embedding, params.Extraction, params.Fetcher, params.Converter)
|
||||||
|
if err != nil {
|
||||||
|
instance.Config.UpdateDocument(docID, maps.MapStrAny{"status": "error", "error_message": err.Error()})
|
||||||
|
return fmt.Errorf("failed to convert to upsert options: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add URL to GraphRag
|
||||||
|
_, err = instance.GraphRag.AddURL(ctx, params.URL, upsertOptions)
|
||||||
|
if err != nil {
|
||||||
|
instance.Config.UpdateDocument(docID, maps.MapStrAny{"status": "error", "error_message": err.Error()})
|
||||||
|
return fmt.Errorf("failed to add URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status and segment count
|
||||||
|
instance.updateDocumentAfterProcessing(ctx, docID, params.CollectionID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
499
kb/api/addurl_test.go
Normal file
499
kb/api/addurl_test.go
Normal file
|
|
@ -0,0 +1,499 @@
|
||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
graphragtypes "github.com/yaoapp/gou/graphrag/types"
|
||||||
|
"github.com/yaoapp/yao/kb"
|
||||||
|
"github.com/yaoapp/yao/kb/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Note: TestMain is defined in collection_test.go, which handles environment setup
|
||||||
|
// Run tests with: source env.local.sh && go test -v ./kb/api/...
|
||||||
|
|
||||||
|
// createTestCollectionForURL is a helper to create a test collection for URL tests
|
||||||
|
func createTestCollectionForURL(t *testing.T, ctx context.Context) string {
|
||||||
|
if kb.API == nil {
|
||||||
|
t.Skip("KB API not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionID := fmt.Sprintf("test_url_%d", time.Now().UnixNano())
|
||||||
|
|
||||||
|
params := &api.CreateCollectionParams{
|
||||||
|
ID: collectionID,
|
||||||
|
Metadata: map[string]interface{}{
|
||||||
|
"name": "Test URL Collection",
|
||||||
|
"description": "Collection for AddURL tests",
|
||||||
|
},
|
||||||
|
EmbeddingProviderID: "__yao.openai",
|
||||||
|
EmbeddingOptionID: "text-embedding-3-small",
|
||||||
|
Locale: "en",
|
||||||
|
Config: &graphragtypes.CreateCollectionOptions{
|
||||||
|
Distance: "cosine",
|
||||||
|
IndexType: "hnsw",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := kb.API.CreateCollection(ctx, params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create test collection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return collectionID
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupTestCollectionForURL removes a test collection
|
||||||
|
func cleanupTestCollectionForURL(ctx context.Context, collectionID string) {
|
||||||
|
if kb.API != nil {
|
||||||
|
_, _ = kb.API.RemoveCollection(ctx, collectionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== AddURL Tests ==========
|
||||||
|
|
||||||
|
func TestAddURL(t *testing.T) {
|
||||||
|
if kb.API == nil {
|
||||||
|
t.Skip("KB API not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
collectionID := createTestCollectionForURL(t, ctx)
|
||||||
|
defer cleanupTestCollectionForURL(ctx, collectionID)
|
||||||
|
|
||||||
|
t.Run("AddURLSuccess", func(t *testing.T) {
|
||||||
|
params := &api.AddURLParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
URL: "https://example.com",
|
||||||
|
Locale: "en",
|
||||||
|
Metadata: map[string]interface{}{
|
||||||
|
"title": "Example Website",
|
||||||
|
"description": "A test URL document",
|
||||||
|
},
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
OptionID: "standard",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
OptionID: "text-embedding-3-small",
|
||||||
|
},
|
||||||
|
Fetcher: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.http",
|
||||||
|
OptionID: "http",
|
||||||
|
},
|
||||||
|
AuthScope: map[string]interface{}{
|
||||||
|
"__yao_created_by": "test_user",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddURL(ctx, params)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
if result != nil {
|
||||||
|
assert.Equal(t, collectionID, result.CollectionID)
|
||||||
|
assert.NotEmpty(t, result.DocID)
|
||||||
|
assert.Equal(t, "https://example.com", result.URL)
|
||||||
|
assert.Contains(t, result.Message, "successfully")
|
||||||
|
t.Logf("Added URL document: %s", result.DocID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify document was created
|
||||||
|
if result != nil {
|
||||||
|
doc, err := kb.API.GetDocument(ctx, result.DocID, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, doc)
|
||||||
|
assert.Equal(t, "url", doc["type"])
|
||||||
|
assert.Equal(t, "https://example.com", doc["url"])
|
||||||
|
t.Logf("✅ URL Document verified: type=%v, url=%v, status=%v", doc["type"], doc["url"], doc["status"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddURLMissingCollectionID", func(t *testing.T) {
|
||||||
|
params := &api.AddURLParams{
|
||||||
|
URL: "https://example.com",
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddURL(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "collection_id is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddURLMissingURL", func(t *testing.T) {
|
||||||
|
params := &api.AddURLParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddURL(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "url is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddURLMissingChunking", func(t *testing.T) {
|
||||||
|
params := &api.AddURLParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
URL: "https://example.com",
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddURL(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "chunking configuration is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddURLMissingEmbedding", func(t *testing.T) {
|
||||||
|
params := &api.AddURLParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
URL: "https://example.com",
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddURL(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "embedding configuration is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddURLWithCustomDocID", func(t *testing.T) {
|
||||||
|
customDocID := fmt.Sprintf("custom_url_doc_%d", time.Now().UnixNano())
|
||||||
|
params := &api.AddURLParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
DocID: customDocID,
|
||||||
|
URL: "https://example.com", // Use root URL which always exists
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
OptionID: "standard",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
OptionID: "text-embedding-3-small",
|
||||||
|
},
|
||||||
|
Fetcher: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.http",
|
||||||
|
OptionID: "http",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddURL(ctx, params)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
if result != nil {
|
||||||
|
assert.Equal(t, customDocID, result.DocID)
|
||||||
|
t.Logf("Added URL with custom DocID: %s", result.DocID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddURLWithTitleFromMetadata", func(t *testing.T) {
|
||||||
|
params := &api.AddURLParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
URL: "https://example.com", // Use root URL which always exists
|
||||||
|
Metadata: map[string]interface{}{
|
||||||
|
"title": "Custom URL Title From Metadata",
|
||||||
|
},
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
OptionID: "standard",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
OptionID: "text-embedding-3-small",
|
||||||
|
},
|
||||||
|
Fetcher: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.http",
|
||||||
|
OptionID: "http",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddURL(ctx, params)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
|
||||||
|
if result != nil {
|
||||||
|
doc, err := kb.API.GetDocument(ctx, result.DocID, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "Custom URL Title From Metadata", doc["name"])
|
||||||
|
t.Logf("✅ Title from metadata verified: %v", doc["name"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== AddURLAsync Tests ==========
|
||||||
|
|
||||||
|
func TestAddURLAsync(t *testing.T) {
|
||||||
|
if kb.API == nil {
|
||||||
|
t.Skip("KB API not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
collectionID := createTestCollectionForURL(t, ctx)
|
||||||
|
defer cleanupTestCollectionForURL(ctx, collectionID)
|
||||||
|
|
||||||
|
t.Run("AddURLAsyncSuccess", func(t *testing.T) {
|
||||||
|
params := &api.AddURLParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
URL: "https://example.com",
|
||||||
|
Locale: "en",
|
||||||
|
Metadata: map[string]interface{}{
|
||||||
|
"title": "Async URL Document",
|
||||||
|
},
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
OptionID: "standard",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
OptionID: "text-embedding-3-small",
|
||||||
|
},
|
||||||
|
Fetcher: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.http",
|
||||||
|
OptionID: "http",
|
||||||
|
},
|
||||||
|
Job: &api.JobOptionsParams{
|
||||||
|
Name: "Test Async URL Job",
|
||||||
|
Description: "Testing async URL processing",
|
||||||
|
Category: "Test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddURLAsync(ctx, params)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
if result != nil {
|
||||||
|
assert.NotEmpty(t, result.JobID)
|
||||||
|
assert.NotEmpty(t, result.DocID)
|
||||||
|
t.Logf("Created async URL job: %s for document: %s", result.JobID, result.DocID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify document was created
|
||||||
|
if result != nil {
|
||||||
|
doc, err := kb.API.GetDocument(ctx, result.DocID, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, doc)
|
||||||
|
assert.Equal(t, "url", doc["type"])
|
||||||
|
assert.Equal(t, result.JobID, doc["job_id"])
|
||||||
|
t.Logf("✅ Async URL document created: status=%v, job_id=%v", doc["status"], doc["job_id"])
|
||||||
|
|
||||||
|
// Wait for job to complete (max 30 seconds)
|
||||||
|
maxWait := 30 * time.Second
|
||||||
|
pollInterval := 500 * time.Millisecond
|
||||||
|
startTime := time.Now()
|
||||||
|
var finalStatus string
|
||||||
|
|
||||||
|
for time.Since(startTime) < maxWait {
|
||||||
|
doc, err = kb.API.GetDocument(ctx, result.DocID, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Error getting document: %v", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
finalStatus, _ = doc["status"].(string)
|
||||||
|
if finalStatus == "completed" || finalStatus == "error" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(pollInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✅ Job completed: final status=%s, elapsed=%v", finalStatus, time.Since(startTime))
|
||||||
|
assert.Equal(t, "completed", finalStatus, "Job should complete successfully")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddURLAsyncMissingCollectionID", func(t *testing.T) {
|
||||||
|
params := &api.AddURLParams{
|
||||||
|
URL: "https://example.com",
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddURLAsync(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "collection_id is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddURLAsyncMissingURL", func(t *testing.T) {
|
||||||
|
params := &api.AddURLParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddURLAsync(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "url is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddURLAsyncMissingChunking", func(t *testing.T) {
|
||||||
|
params := &api.AddURLParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
URL: "https://example.com",
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddURLAsync(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "chunking configuration is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddURLAsyncMissingEmbedding", func(t *testing.T) {
|
||||||
|
params := &api.AddURLParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
URL: "https://example.com",
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddURLAsync(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "embedding configuration is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddURLAsyncWithCustomDocID", func(t *testing.T) {
|
||||||
|
customDocID := fmt.Sprintf("async_custom_url_%d", time.Now().UnixNano())
|
||||||
|
params := &api.AddURLParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
DocID: customDocID,
|
||||||
|
URL: "https://example.com/async-custom",
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
OptionID: "standard",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
OptionID: "text-embedding-3-small",
|
||||||
|
},
|
||||||
|
Fetcher: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.http",
|
||||||
|
OptionID: "http",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddURLAsync(ctx, params)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
if result != nil {
|
||||||
|
assert.Equal(t, customDocID, result.DocID)
|
||||||
|
t.Logf("Created async URL with custom DocID: %s", result.DocID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== AddURL Integration Test ==========
|
||||||
|
|
||||||
|
func TestAddURLIntegration(t *testing.T) {
|
||||||
|
if kb.API == nil {
|
||||||
|
t.Skip("KB API not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
collectionID := createTestCollectionForURL(t, ctx)
|
||||||
|
defer cleanupTestCollectionForURL(ctx, collectionID)
|
||||||
|
|
||||||
|
t.Run("FullURLLifecycle", func(t *testing.T) {
|
||||||
|
// 1. Add URL Document
|
||||||
|
addParams := &api.AddURLParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
URL: "https://example.com", // Use root URL which always exists
|
||||||
|
Locale: "en",
|
||||||
|
Metadata: map[string]interface{}{
|
||||||
|
"title": "URL Lifecycle Test",
|
||||||
|
"description": "Full lifecycle integration test",
|
||||||
|
},
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
OptionID: "standard",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
OptionID: "text-embedding-3-small",
|
||||||
|
},
|
||||||
|
Fetcher: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.http",
|
||||||
|
OptionID: "http",
|
||||||
|
},
|
||||||
|
AuthScope: map[string]interface{}{
|
||||||
|
"__yao_created_by": "integration_test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.AddURL(ctx, addParams)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
if result == nil {
|
||||||
|
t.Fatalf("Failed to create URL document: result is nil")
|
||||||
|
}
|
||||||
|
t.Logf("1. Created URL document: %s", result.DocID)
|
||||||
|
|
||||||
|
// 2. Get Document
|
||||||
|
doc, err := kb.API.GetDocument(ctx, result.DocID, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, doc)
|
||||||
|
assert.Equal(t, "URL Lifecycle Test", doc["name"])
|
||||||
|
assert.Equal(t, "url", doc["type"])
|
||||||
|
assert.Equal(t, "https://example.com", doc["url"])
|
||||||
|
t.Logf("2. Retrieved document: name=%v, url=%v, status=%v", doc["name"], doc["url"], doc["status"])
|
||||||
|
|
||||||
|
// 3. List Documents
|
||||||
|
listFilter := &api.ListDocumentsFilter{
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 20,
|
||||||
|
CollectionID: collectionID,
|
||||||
|
Keywords: "URL Lifecycle",
|
||||||
|
}
|
||||||
|
listResult, err := kb.API.ListDocuments(ctx, listFilter)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, len(listResult.Data), 1)
|
||||||
|
t.Logf("3. Found document in list: %d documents", len(listResult.Data))
|
||||||
|
|
||||||
|
// 4. Remove Document
|
||||||
|
removeParams := &api.RemoveDocumentsParams{
|
||||||
|
DocumentIDs: []string{result.DocID},
|
||||||
|
}
|
||||||
|
removeResult, err := kb.API.RemoveDocuments(ctx, removeParams)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, removeResult)
|
||||||
|
t.Logf("4. Removed document: %d deleted", removeResult.DeletedCount)
|
||||||
|
|
||||||
|
// 5. Verify Removal
|
||||||
|
_, err = kb.API.GetDocument(ctx, result.DocID, nil)
|
||||||
|
assert.Error(t, err)
|
||||||
|
t.Logf("5. Verified document removal")
|
||||||
|
|
||||||
|
t.Logf("✅ Full URL lifecycle test completed successfully")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
graphragtypes "github.com/yaoapp/gou/graphrag/types"
|
graphragtypes "github.com/yaoapp/gou/graphrag/types"
|
||||||
"github.com/yaoapp/gou/model"
|
"github.com/yaoapp/gou/model"
|
||||||
|
"github.com/yaoapp/yao/attachment"
|
||||||
"github.com/yaoapp/yao/config"
|
"github.com/yaoapp/yao/config"
|
||||||
"github.com/yaoapp/yao/kb"
|
"github.com/yaoapp/yao/kb"
|
||||||
"github.com/yaoapp/yao/kb/api"
|
"github.com/yaoapp/yao/kb/api"
|
||||||
|
|
@ -21,8 +22,14 @@ func TestMain(m *testing.M) {
|
||||||
test.Prepare(&testing.T{}, config.Conf)
|
test.Prepare(&testing.T{}, config.Conf)
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
||||||
|
// Load attachment managers (needed for file upload tests)
|
||||||
|
err := attachment.Load(config.Conf)
|
||||||
|
if err != nil {
|
||||||
|
panic("Failed to load attachment managers: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
// Load knowledge base
|
// Load knowledge base
|
||||||
_, err := kb.Load(config.Conf)
|
_, err = kb.Load(config.Conf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic("Failed to load knowledge base: " + err.Error())
|
panic("Failed to load knowledge base: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,3 +55,51 @@ var DefaultSort = []model.QueryOrder{
|
||||||
const (
|
const (
|
||||||
DefaultLocale = "en"
|
DefaultLocale = "en"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Document field definitions
|
||||||
|
var (
|
||||||
|
// AvailableDocumentFields defines all available fields for security filtering
|
||||||
|
AvailableDocumentFields = map[string]bool{
|
||||||
|
"id": true, "document_id": true, "collection_id": true, "name": true,
|
||||||
|
"description": true, "status": true, "type": true, "size": true,
|
||||||
|
"segment_count": true, "job_id": true, "uploader_id": true, "tags": true,
|
||||||
|
"locale": true, "system": true, "readonly": true, "sort": true, "cover": true,
|
||||||
|
"file_id": true, "file_name": true, "file_mime_type": true,
|
||||||
|
"url": true, "url_title": true, "text_content": true,
|
||||||
|
"converter_provider_id": true, "converter_option_id": true, "converter_properties": true,
|
||||||
|
"fetcher_provider_id": true, "fetcher_option_id": true, "fetcher_properties": true,
|
||||||
|
"chunking_provider_id": true, "chunking_option_id": true, "chunking_properties": true,
|
||||||
|
"extraction_provider_id": true, "extraction_option_id": true, "extraction_properties": true,
|
||||||
|
"processed_at": true, "error_message": true, "created_at": true, "updated_at": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultDocumentFields defines the default compact field list
|
||||||
|
DefaultDocumentFields = []interface{}{
|
||||||
|
"id", "document_id", "collection_id", "name", "description",
|
||||||
|
"cover", "tags", "type", "size", "segment_count", "status", "locale",
|
||||||
|
"system", "readonly", "file_id", "file_name", "file_mime_type", "uploader_id",
|
||||||
|
"url", "url_title", "text_content", "job_id",
|
||||||
|
"error_message", "created_at", "updated_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidDocumentSortFields defines valid fields for sorting
|
||||||
|
ValidDocumentSortFields = map[string]bool{
|
||||||
|
"created_at": true,
|
||||||
|
"updated_at": true,
|
||||||
|
"name": true,
|
||||||
|
"size": true,
|
||||||
|
"segment_count": true,
|
||||||
|
"sort": true,
|
||||||
|
"processed_at": true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultDocumentSort defines the default sort order for document queries
|
||||||
|
var DefaultDocumentSort = []model.QueryOrder{
|
||||||
|
{Column: DefaultSortField, Option: DefaultSortOrder},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default uploader
|
||||||
|
const (
|
||||||
|
DefaultUploader = "local"
|
||||||
|
)
|
||||||
|
|
|
||||||
279
kb/api/document.go
Normal file
279
kb/api/document.go
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/yaoapp/gou/model"
|
||||||
|
"github.com/yaoapp/kun/log"
|
||||||
|
"github.com/yaoapp/kun/maps"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListDocuments lists documents with pagination and filtering
|
||||||
|
func (instance *KBInstance) ListDocuments(ctx context.Context, filter *ListDocumentsFilter) (*ListDocumentsResult, error) {
|
||||||
|
page := filter.Page
|
||||||
|
if page <= 0 {
|
||||||
|
page = DefaultPage
|
||||||
|
}
|
||||||
|
|
||||||
|
pageSize := filter.PageSize
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = DefaultPageSize
|
||||||
|
} else if pageSize > MaxPageSize {
|
||||||
|
pageSize = MaxPageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process select fields
|
||||||
|
selectFields := filter.Select
|
||||||
|
if len(selectFields) == 0 {
|
||||||
|
selectFields = DefaultDocumentFields
|
||||||
|
} else {
|
||||||
|
// Filter valid fields
|
||||||
|
validFields := []interface{}{}
|
||||||
|
for _, field := range selectFields {
|
||||||
|
if fieldStr, ok := field.(string); ok && AvailableDocumentFields[fieldStr] {
|
||||||
|
validFields = append(validFields, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(validFields) == 0 {
|
||||||
|
selectFields = DefaultDocumentFields
|
||||||
|
} else {
|
||||||
|
selectFields = validFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build query parameters
|
||||||
|
param := model.QueryParam{Select: selectFields}
|
||||||
|
|
||||||
|
// Build wheres
|
||||||
|
var wheres []model.QueryWhere
|
||||||
|
|
||||||
|
// Add auth filters
|
||||||
|
if len(filter.AuthFilters) > 0 {
|
||||||
|
wheres = append(wheres, filter.AuthFilters...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by collection_id
|
||||||
|
if filter.CollectionID != "" {
|
||||||
|
wheres = append(wheres, model.QueryWhere{
|
||||||
|
Column: "collection_id",
|
||||||
|
Value: filter.CollectionID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by keywords (search in name and description)
|
||||||
|
if filter.Keywords != "" {
|
||||||
|
wheres = append(wheres, model.QueryWhere{
|
||||||
|
Column: "name",
|
||||||
|
Value: "%" + filter.Keywords + "%",
|
||||||
|
OP: "like",
|
||||||
|
})
|
||||||
|
wheres = append(wheres, model.QueryWhere{
|
||||||
|
Column: "description",
|
||||||
|
Value: "%" + filter.Keywords + "%",
|
||||||
|
OP: "like",
|
||||||
|
Method: "orwhere",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by tag
|
||||||
|
if filter.Tag != "" {
|
||||||
|
wheres = append(wheres, model.QueryWhere{
|
||||||
|
Column: "tags",
|
||||||
|
Value: "%" + filter.Tag + "%",
|
||||||
|
OP: "like",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by status
|
||||||
|
if len(filter.Status) > 0 {
|
||||||
|
statusValues := []interface{}{}
|
||||||
|
for _, status := range filter.Status {
|
||||||
|
if status != "" {
|
||||||
|
statusValues = append(statusValues, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(statusValues) > 0 {
|
||||||
|
if len(statusValues) == 1 {
|
||||||
|
wheres = append(wheres, model.QueryWhere{
|
||||||
|
Column: "status",
|
||||||
|
Value: statusValues[0],
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
wheres = append(wheres, model.QueryWhere{
|
||||||
|
Column: "status",
|
||||||
|
Value: statusValues,
|
||||||
|
OP: "in",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by status_not (exclude specific statuses)
|
||||||
|
if len(filter.StatusNot) > 0 {
|
||||||
|
for _, status := range filter.StatusNot {
|
||||||
|
if status != "" {
|
||||||
|
wheres = append(wheres, model.QueryWhere{
|
||||||
|
Column: "status",
|
||||||
|
Value: status,
|
||||||
|
OP: "!=",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
param.Wheres = wheres
|
||||||
|
|
||||||
|
// Process sort orders
|
||||||
|
orders := filter.Sort
|
||||||
|
if len(orders) == 0 {
|
||||||
|
orders = DefaultDocumentSort
|
||||||
|
} else {
|
||||||
|
// Validate sort fields
|
||||||
|
validOrders := []model.QueryOrder{}
|
||||||
|
for _, order := range orders {
|
||||||
|
if ValidDocumentSortFields[order.Column] {
|
||||||
|
// Validate sort order
|
||||||
|
if order.Option != "asc" && order.Option != "desc" {
|
||||||
|
order.Option = "desc"
|
||||||
|
}
|
||||||
|
validOrders = append(validOrders, order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(validOrders) == 0 {
|
||||||
|
orders = DefaultDocumentSort
|
||||||
|
} else {
|
||||||
|
orders = validOrders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
param.Orders = orders
|
||||||
|
|
||||||
|
// Query documents
|
||||||
|
result, err := instance.Config.SearchDocuments(param, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to search documents: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert result to ListDocumentsResult
|
||||||
|
listResult := &ListDocumentsResult{
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
Data: make([]map[string]interface{}, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract pagination data from result
|
||||||
|
if data, ok := result["data"].([]map[string]interface{}); ok {
|
||||||
|
listResult.Data = data
|
||||||
|
} else if data, ok := result["data"].([]interface{}); ok {
|
||||||
|
converted := make([]map[string]interface{}, 0, len(data))
|
||||||
|
for _, item := range data {
|
||||||
|
if mapItem, ok := item.(map[string]interface{}); ok {
|
||||||
|
converted = append(converted, mapItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listResult.Data = converted
|
||||||
|
} else if data, ok := result["data"].([]maps.MapStr); ok {
|
||||||
|
converted := make([]map[string]interface{}, 0, len(data))
|
||||||
|
for _, item := range data {
|
||||||
|
converted = append(converted, map[string]interface{}(item))
|
||||||
|
}
|
||||||
|
listResult.Data = converted
|
||||||
|
}
|
||||||
|
|
||||||
|
if next, ok := result["next"].(int); ok {
|
||||||
|
listResult.Next = next
|
||||||
|
}
|
||||||
|
if prev, ok := result["prev"].(int); ok {
|
||||||
|
listResult.Prev = prev
|
||||||
|
}
|
||||||
|
if total, ok := result["total"].(int); ok {
|
||||||
|
listResult.Total = total
|
||||||
|
}
|
||||||
|
if pagecnt, ok := result["pagecnt"].(int); ok {
|
||||||
|
listResult.PageCnt = pagecnt
|
||||||
|
}
|
||||||
|
|
||||||
|
return listResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDocument retrieves a document by ID
|
||||||
|
func (instance *KBInstance) GetDocument(ctx context.Context, docID string, params *GetDocumentParams) (map[string]interface{}, error) {
|
||||||
|
if docID == "" {
|
||||||
|
return nil, fmt.Errorf("document ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process select fields
|
||||||
|
var selectFields []interface{}
|
||||||
|
if params != nil && len(params.Select) > 0 {
|
||||||
|
for _, field := range params.Select {
|
||||||
|
if fieldStr, ok := field.(string); ok && AvailableDocumentFields[fieldStr] {
|
||||||
|
selectFields = append(selectFields, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(selectFields) == 0 {
|
||||||
|
selectFields = DefaultDocumentFields
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build query parameters
|
||||||
|
param := model.QueryParam{
|
||||||
|
Select: selectFields,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query single document
|
||||||
|
result, err := instance.Config.FindDocument(docID, param)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveDocuments removes documents by IDs
|
||||||
|
func (instance *KBInstance) RemoveDocuments(ctx context.Context, params *RemoveDocumentsParams) (*RemoveDocumentsResult, error) {
|
||||||
|
if len(params.DocumentIDs) == 0 {
|
||||||
|
return nil, fmt.Errorf("document IDs are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove documents using GraphRag
|
||||||
|
deletedCount, err := instance.GraphRag.RemoveDocs(ctx, params.DocumentIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to remove documents from GraphRag: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also remove documents from the database and track collections to update
|
||||||
|
dbDeletedCount := 0
|
||||||
|
collectionsToUpdate := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, docID := range params.DocumentIDs {
|
||||||
|
// Get document info before deletion to track collection
|
||||||
|
if docInfo, err := instance.Config.FindDocument(docID, model.QueryParam{
|
||||||
|
Select: []interface{}{"collection_id"},
|
||||||
|
}); err == nil && docInfo != nil {
|
||||||
|
if collectionID, ok := docInfo["collection_id"].(string); ok && collectionID != "" {
|
||||||
|
collectionsToUpdate[collectionID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := instance.Config.RemoveDocument(docID); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to remove document from database: %w", err)
|
||||||
|
}
|
||||||
|
dbDeletedCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update document counts for affected collections and sync to GraphRag
|
||||||
|
for collectionID := range collectionsToUpdate {
|
||||||
|
if err := instance.updateDocumentCountWithSync(ctx, collectionID); err != nil {
|
||||||
|
log.Error("Failed to update document count for collection %s: %v", collectionID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &RemoveDocumentsResult{
|
||||||
|
Message: "Documents removed successfully",
|
||||||
|
DeletedCount: deletedCount,
|
||||||
|
RequestedCount: len(params.DocumentIDs),
|
||||||
|
DBDeletedCount: dbDeletedCount,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
291
kb/api/document_test.go
Normal file
291
kb/api/document_test.go
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
graphragtypes "github.com/yaoapp/gou/graphrag/types"
|
||||||
|
"github.com/yaoapp/yao/kb"
|
||||||
|
"github.com/yaoapp/yao/kb/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Note: TestMain is defined in collection_test.go, which handles environment setup
|
||||||
|
// Run tests with: source env.local.sh && go test -v ./kb/api/...
|
||||||
|
|
||||||
|
// createTestCollectionForDoc is a helper to create a test collection for document tests
|
||||||
|
func createTestCollectionForDoc(t *testing.T, ctx context.Context) string {
|
||||||
|
if kb.API == nil {
|
||||||
|
t.Skip("KB API not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionID := fmt.Sprintf("test_doc_%d", time.Now().UnixNano())
|
||||||
|
|
||||||
|
params := &api.CreateCollectionParams{
|
||||||
|
ID: collectionID,
|
||||||
|
Metadata: map[string]interface{}{
|
||||||
|
"name": "Test Document Collection",
|
||||||
|
"description": "Collection for document tests",
|
||||||
|
},
|
||||||
|
EmbeddingProviderID: "__yao.openai",
|
||||||
|
EmbeddingOptionID: "text-embedding-3-small",
|
||||||
|
Locale: "en",
|
||||||
|
Config: &graphragtypes.CreateCollectionOptions{
|
||||||
|
Distance: "cosine",
|
||||||
|
IndexType: "hnsw",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := kb.API.CreateCollection(ctx, params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create test collection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return collectionID
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupTestCollectionForDoc removes a test collection
|
||||||
|
func cleanupTestCollectionForDoc(ctx context.Context, collectionID string) {
|
||||||
|
if kb.API != nil {
|
||||||
|
_, _ = kb.API.RemoveCollection(ctx, collectionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addTestDocument adds a test document and returns its ID
|
||||||
|
func addTestDocument(t *testing.T, ctx context.Context, collectionID, title string) string {
|
||||||
|
params := &api.AddTextParams{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
Text: fmt.Sprintf("Test document content for %s", title),
|
||||||
|
Metadata: map[string]interface{}{
|
||||||
|
"title": title,
|
||||||
|
},
|
||||||
|
Chunking: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.structured",
|
||||||
|
OptionID: "standard",
|
||||||
|
},
|
||||||
|
Embedding: &api.ProviderConfigParams{
|
||||||
|
ProviderID: "__yao.openai",
|
||||||
|
OptionID: "text-embedding-3-small",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result, err := kb.API.AddText(ctx, params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to add test document: %v", err)
|
||||||
|
}
|
||||||
|
return result.DocID
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== ListDocuments Tests ==========
|
||||||
|
|
||||||
|
func TestListDocuments(t *testing.T) {
|
||||||
|
if kb.API == nil {
|
||||||
|
t.Skip("KB API not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
collectionID := createTestCollectionForDoc(t, ctx)
|
||||||
|
defer cleanupTestCollectionForDoc(ctx, collectionID)
|
||||||
|
|
||||||
|
// Add some test documents
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
addTestDocument(t, ctx, collectionID, fmt.Sprintf("Test Document %d", i+1))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("ListDocumentsDefault", func(t *testing.T) {
|
||||||
|
filter := &api.ListDocumentsFilter{
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 20,
|
||||||
|
CollectionID: collectionID,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.ListDocuments(ctx, filter)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
assert.GreaterOrEqual(t, len(result.Data), 3)
|
||||||
|
assert.Equal(t, 1, result.Page)
|
||||||
|
assert.Equal(t, 20, result.PageSize)
|
||||||
|
t.Logf("Found %d documents in collection", len(result.Data))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ListDocumentsWithPagination", func(t *testing.T) {
|
||||||
|
filter := &api.ListDocumentsFilter{
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 2,
|
||||||
|
CollectionID: collectionID,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.ListDocuments(ctx, filter)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
assert.LessOrEqual(t, len(result.Data), 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ListDocumentsWithKeywords", func(t *testing.T) {
|
||||||
|
filter := &api.ListDocumentsFilter{
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 20,
|
||||||
|
CollectionID: collectionID,
|
||||||
|
Keywords: "Test Document 1",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.ListDocuments(ctx, filter)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
assert.GreaterOrEqual(t, len(result.Data), 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ListDocumentsWithStatus", func(t *testing.T) {
|
||||||
|
filter := &api.ListDocumentsFilter{
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 20,
|
||||||
|
CollectionID: collectionID,
|
||||||
|
Status: []string{"completed"},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.ListDocuments(ctx, filter)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
|
||||||
|
for _, doc := range result.Data {
|
||||||
|
status, ok := doc["status"].(string)
|
||||||
|
if ok {
|
||||||
|
assert.Equal(t, "completed", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ListDocumentsEmptyResult", func(t *testing.T) {
|
||||||
|
filter := &api.ListDocumentsFilter{
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 20,
|
||||||
|
CollectionID: collectionID,
|
||||||
|
Keywords: "nonexistent_keyword_xyz123",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.ListDocuments(ctx, filter)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
assert.Equal(t, 0, len(result.Data))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== GetDocument Tests ==========
|
||||||
|
|
||||||
|
func TestGetDocument(t *testing.T) {
|
||||||
|
if kb.API == nil {
|
||||||
|
t.Skip("KB API not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
collectionID := createTestCollectionForDoc(t, ctx)
|
||||||
|
defer cleanupTestCollectionForDoc(ctx, collectionID)
|
||||||
|
|
||||||
|
// Add a test document
|
||||||
|
docID := addTestDocument(t, ctx, collectionID, "GetDocument Test")
|
||||||
|
|
||||||
|
t.Run("GetDocumentSuccess", func(t *testing.T) {
|
||||||
|
doc, err := kb.API.GetDocument(ctx, docID, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, doc)
|
||||||
|
assert.Equal(t, docID, doc["document_id"])
|
||||||
|
assert.Equal(t, collectionID, doc["collection_id"])
|
||||||
|
assert.Equal(t, "GetDocument Test", doc["name"])
|
||||||
|
assert.Equal(t, "text", doc["type"])
|
||||||
|
t.Logf("Retrieved document: %v", doc["name"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetDocumentWithSelect", func(t *testing.T) {
|
||||||
|
params := &api.GetDocumentParams{
|
||||||
|
Select: []interface{}{"document_id", "name", "type", "status"},
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, err := kb.API.GetDocument(ctx, docID, params)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, doc)
|
||||||
|
assert.NotNil(t, doc["document_id"])
|
||||||
|
assert.NotNil(t, doc["name"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetDocumentNotFound", func(t *testing.T) {
|
||||||
|
doc, err := kb.API.GetDocument(ctx, "nonexistent_doc_id", nil)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, doc)
|
||||||
|
assert.Contains(t, err.Error(), "not found")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetDocumentEmptyID", func(t *testing.T) {
|
||||||
|
doc, err := kb.API.GetDocument(ctx, "", nil)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, doc)
|
||||||
|
assert.Contains(t, err.Error(), "required")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== RemoveDocuments Tests ==========
|
||||||
|
|
||||||
|
func TestRemoveDocuments(t *testing.T) {
|
||||||
|
if kb.API == nil {
|
||||||
|
t.Skip("KB API not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
collectionID := createTestCollectionForDoc(t, ctx)
|
||||||
|
defer cleanupTestCollectionForDoc(ctx, collectionID)
|
||||||
|
|
||||||
|
// Add test documents
|
||||||
|
var docIDs []string
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
docID := addTestDocument(t, ctx, collectionID, fmt.Sprintf("Remove Test %d", i+1))
|
||||||
|
docIDs = append(docIDs, docID)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("RemoveDocumentsSuccess", func(t *testing.T) {
|
||||||
|
params := &api.RemoveDocumentsParams{
|
||||||
|
DocumentIDs: docIDs[:2], // Remove first 2 documents
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.RemoveDocuments(ctx, params)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
assert.Equal(t, 2, result.RequestedCount)
|
||||||
|
assert.GreaterOrEqual(t, result.DeletedCount, 0)
|
||||||
|
t.Logf("Removed documents: requested=%d, deleted=%d", result.RequestedCount, result.DeletedCount)
|
||||||
|
|
||||||
|
// Verify documents are removed
|
||||||
|
for _, docID := range docIDs[:2] {
|
||||||
|
doc, err := kb.API.GetDocument(ctx, docID, nil)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify remaining document still exists
|
||||||
|
doc, err := kb.API.GetDocument(ctx, docIDs[2], nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, doc)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RemoveDocumentsEmptyList", func(t *testing.T) {
|
||||||
|
params := &api.RemoveDocumentsParams{
|
||||||
|
DocumentIDs: []string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.RemoveDocuments(ctx, params)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RemoveDocumentsNonexistent", func(t *testing.T) {
|
||||||
|
params := &api.RemoveDocumentsParams{
|
||||||
|
DocumentIDs: []string{"nonexistent_doc_1", "nonexistent_doc_2"},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := kb.API.RemoveDocuments(ctx, params)
|
||||||
|
// Should succeed but with 0 deleted
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
assert.Equal(t, 2, result.RequestedCount)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -17,10 +17,20 @@ type API interface {
|
||||||
ListCollections(ctx context.Context, filter *ListCollectionsFilter) (*ListCollectionsResult, error)
|
ListCollections(ctx context.Context, filter *ListCollectionsFilter) (*ListCollectionsResult, error)
|
||||||
UpdateCollectionMetadata(ctx context.Context, collectionID string, params *UpdateMetadataParams) (*UpdateMetadataResult, error)
|
UpdateCollectionMetadata(ctx context.Context, collectionID string, params *UpdateMetadataParams) (*UpdateMetadataResult, error)
|
||||||
|
|
||||||
// Document operations (future)
|
// Document operations
|
||||||
// AddDocument(ctx context.Context, params *AddDocumentParams) (*AddDocumentResult, error)
|
ListDocuments(ctx context.Context, filter *ListDocumentsFilter) (*ListDocumentsResult, error)
|
||||||
// RemoveDocument(ctx context.Context, documentID string) (*RemoveDocumentResult, error)
|
GetDocument(ctx context.Context, docID string, params *GetDocumentParams) (map[string]interface{}, error)
|
||||||
// ...
|
RemoveDocuments(ctx context.Context, params *RemoveDocumentsParams) (*RemoveDocumentsResult, error)
|
||||||
|
|
||||||
|
// Document add operations (sync)
|
||||||
|
AddFile(ctx context.Context, params *AddFileParams) (*AddDocumentResult, error)
|
||||||
|
AddText(ctx context.Context, params *AddTextParams) (*AddDocumentResult, error)
|
||||||
|
AddURL(ctx context.Context, params *AddURLParams) (*AddDocumentResult, error)
|
||||||
|
|
||||||
|
// Document add operations (async)
|
||||||
|
AddFileAsync(ctx context.Context, params *AddFileParams) (*AddDocumentAsyncResult, error)
|
||||||
|
AddTextAsync(ctx context.Context, params *AddTextParams) (*AddDocumentAsyncResult, error)
|
||||||
|
AddURLAsync(ctx context.Context, params *AddURLParams) (*AddDocumentAsyncResult, error)
|
||||||
|
|
||||||
// Segment operations (future)
|
// Segment operations (future)
|
||||||
// ...
|
// ...
|
||||||
|
|
@ -28,7 +38,8 @@ type API interface {
|
||||||
|
|
||||||
// KBInstance holds the KB instance dependencies required by the API
|
// KBInstance holds the KB instance dependencies required by the API
|
||||||
type KBInstance struct {
|
type KBInstance struct {
|
||||||
GraphRag types.GraphRag // GraphRag instance for vector/graph operations
|
GraphRag types.GraphRag // GraphRag instance
|
||||||
|
// for vector/graph operations
|
||||||
Config *kbtypes.Config // KB configuration
|
Config *kbtypes.Config // KB configuration
|
||||||
Providers *kbtypes.ProviderConfig // Provider configurations
|
Providers *kbtypes.ProviderConfig // Provider configurations
|
||||||
}
|
}
|
||||||
|
|
|
||||||
124
kb/api/types.go
124
kb/api/types.go
|
|
@ -71,3 +71,127 @@ type UpdateMetadataResult struct {
|
||||||
CollectionID string `json:"collection_id" yaml:"collection_id"`
|
CollectionID string `json:"collection_id" yaml:"collection_id"`
|
||||||
Message string `json:"message" yaml:"message"`
|
Message string `json:"message" yaml:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Document Types ==========
|
||||||
|
|
||||||
|
// ListDocumentsFilter represents the filter options for listing documents
|
||||||
|
type ListDocumentsFilter struct {
|
||||||
|
Page int `json:"page" yaml:"page"`
|
||||||
|
PageSize int `json:"pagesize" yaml:"pagesize"`
|
||||||
|
CollectionID string `json:"collection_id,omitempty" yaml:"collection_id,omitempty"`
|
||||||
|
Keywords string `json:"keywords,omitempty" yaml:"keywords,omitempty"`
|
||||||
|
Tag string `json:"tag,omitempty" yaml:"tag,omitempty"`
|
||||||
|
Status []string `json:"status,omitempty" yaml:"status,omitempty"`
|
||||||
|
StatusNot []string `json:"status_not,omitempty" yaml:"status_not,omitempty"`
|
||||||
|
Select []interface{} `json:"select,omitempty" yaml:"select,omitempty"`
|
||||||
|
Sort []model.QueryOrder `json:"sort,omitempty" yaml:"sort,omitempty"`
|
||||||
|
AuthFilters []model.QueryWhere `json:"-" yaml:"-"` // Internal: authentication filters
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDocumentsResult represents the result of listing documents
|
||||||
|
type ListDocumentsResult struct {
|
||||||
|
Data []map[string]interface{} `json:"data" yaml:"data"`
|
||||||
|
Next int `json:"next" yaml:"next"`
|
||||||
|
Prev int `json:"prev" yaml:"prev"`
|
||||||
|
Page int `json:"page" yaml:"page"`
|
||||||
|
PageSize int `json:"pagesize" yaml:"pagesize"`
|
||||||
|
Total int `json:"total" yaml:"total"`
|
||||||
|
PageCnt int `json:"pagecnt" yaml:"pagecnt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDocumentParams represents the parameters for getting a document
|
||||||
|
type GetDocumentParams struct {
|
||||||
|
Select []interface{} `json:"select,omitempty" yaml:"select,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveDocumentsParams represents the parameters for removing documents
|
||||||
|
type RemoveDocumentsParams struct {
|
||||||
|
DocumentIDs []string `json:"document_ids" yaml:"document_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveDocumentsResult represents the result of removing documents
|
||||||
|
type RemoveDocumentsResult struct {
|
||||||
|
Message string `json:"message" yaml:"message"`
|
||||||
|
DeletedCount int `json:"deleted_count" yaml:"deleted_count"`
|
||||||
|
RequestedCount int `json:"requested_count" yaml:"requested_count"`
|
||||||
|
DBDeletedCount int `json:"db_deleted_count" yaml:"db_deleted_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddFileParams represents the parameters for adding a file
|
||||||
|
type AddFileParams struct {
|
||||||
|
CollectionID string `json:"collection_id" yaml:"collection_id"`
|
||||||
|
FileID string `json:"file_id" yaml:"file_id"`
|
||||||
|
Uploader string `json:"uploader,omitempty" yaml:"uploader,omitempty"`
|
||||||
|
DocID string `json:"doc_id,omitempty" yaml:"doc_id,omitempty"`
|
||||||
|
Locale string `json:"locale,omitempty" yaml:"locale,omitempty"`
|
||||||
|
Metadata map[string]interface{} `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||||
|
Chunking *ProviderConfigParams `json:"chunking" yaml:"chunking"`
|
||||||
|
Embedding *ProviderConfigParams `json:"embedding" yaml:"embedding"`
|
||||||
|
Extraction *ProviderConfigParams `json:"extraction,omitempty" yaml:"extraction,omitempty"`
|
||||||
|
Fetcher *ProviderConfigParams `json:"fetcher,omitempty" yaml:"fetcher,omitempty"`
|
||||||
|
Converter *ProviderConfigParams `json:"converter,omitempty" yaml:"converter,omitempty"`
|
||||||
|
Job *JobOptionsParams `json:"job,omitempty" yaml:"job,omitempty"`
|
||||||
|
AuthScope map[string]interface{} `json:"-" yaml:"-"` // Internal: authentication scope fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTextParams represents the parameters for adding text
|
||||||
|
type AddTextParams struct {
|
||||||
|
CollectionID string `json:"collection_id" yaml:"collection_id"`
|
||||||
|
Text string `json:"text" yaml:"text"`
|
||||||
|
DocID string `json:"doc_id,omitempty" yaml:"doc_id,omitempty"`
|
||||||
|
Locale string `json:"locale,omitempty" yaml:"locale,omitempty"`
|
||||||
|
Metadata map[string]interface{} `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||||
|
Chunking *ProviderConfigParams `json:"chunking" yaml:"chunking"`
|
||||||
|
Embedding *ProviderConfigParams `json:"embedding" yaml:"embedding"`
|
||||||
|
Extraction *ProviderConfigParams `json:"extraction,omitempty" yaml:"extraction,omitempty"`
|
||||||
|
Fetcher *ProviderConfigParams `json:"fetcher,omitempty" yaml:"fetcher,omitempty"`
|
||||||
|
Converter *ProviderConfigParams `json:"converter,omitempty" yaml:"converter,omitempty"`
|
||||||
|
Job *JobOptionsParams `json:"job,omitempty" yaml:"job,omitempty"`
|
||||||
|
AuthScope map[string]interface{} `json:"-" yaml:"-"` // Internal: authentication scope fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddURLParams represents the parameters for adding a URL
|
||||||
|
type AddURLParams struct {
|
||||||
|
CollectionID string `json:"collection_id" yaml:"collection_id"`
|
||||||
|
URL string `json:"url" yaml:"url"`
|
||||||
|
DocID string `json:"doc_id,omitempty" yaml:"doc_id,omitempty"`
|
||||||
|
Locale string `json:"locale,omitempty" yaml:"locale,omitempty"`
|
||||||
|
Metadata map[string]interface{} `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||||
|
Chunking *ProviderConfigParams `json:"chunking" yaml:"chunking"`
|
||||||
|
Embedding *ProviderConfigParams `json:"embedding" yaml:"embedding"`
|
||||||
|
Extraction *ProviderConfigParams `json:"extraction,omitempty" yaml:"extraction,omitempty"`
|
||||||
|
Fetcher *ProviderConfigParams `json:"fetcher,omitempty" yaml:"fetcher,omitempty"`
|
||||||
|
Converter *ProviderConfigParams `json:"converter,omitempty" yaml:"converter,omitempty"`
|
||||||
|
Job *JobOptionsParams `json:"job,omitempty" yaml:"job,omitempty"`
|
||||||
|
AuthScope map[string]interface{} `json:"-" yaml:"-"` // Internal: authentication scope fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderConfigParams represents a provider configuration
|
||||||
|
type ProviderConfigParams struct {
|
||||||
|
ProviderID string `json:"provider_id" yaml:"provider_id"`
|
||||||
|
OptionID string `json:"option_id,omitempty" yaml:"option_id,omitempty"`
|
||||||
|
Properties map[string]interface{} `json:"properties,omitempty" yaml:"properties,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobOptionsParams contains job options for async operations
|
||||||
|
type JobOptionsParams struct {
|
||||||
|
Name string `json:"name,omitempty" yaml:"name,omitempty"`
|
||||||
|
Description string `json:"description,omitempty" yaml:"description,omitempty"`
|
||||||
|
Icon string `json:"icon,omitempty" yaml:"icon,omitempty"`
|
||||||
|
Category string `json:"category,omitempty" yaml:"category,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddDocumentResult represents the result of adding a document (sync)
|
||||||
|
type AddDocumentResult struct {
|
||||||
|
Message string `json:"message" yaml:"message"`
|
||||||
|
CollectionID string `json:"collection_id" yaml:"collection_id"`
|
||||||
|
DocID string `json:"doc_id" yaml:"doc_id"`
|
||||||
|
FileID string `json:"file_id,omitempty" yaml:"file_id,omitempty"`
|
||||||
|
URL string `json:"url,omitempty" yaml:"url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddDocumentAsyncResult represents the result of adding a document (async)
|
||||||
|
type AddDocumentAsyncResult struct {
|
||||||
|
JobID string `json:"job_id" yaml:"job_id"`
|
||||||
|
DocID string `json:"doc_id" yaml:"doc_id"`
|
||||||
|
}
|
||||||
|
|
|
||||||
325
kb/api/utils.go
Normal file
325
kb/api/utils.go
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
graphragtypes "github.com/yaoapp/gou/graphrag/types"
|
||||||
|
"github.com/yaoapp/kun/log"
|
||||||
|
"github.com/yaoapp/kun/maps"
|
||||||
|
"github.com/yaoapp/yao/kb/providers/factory"
|
||||||
|
kbtypes "github.com/yaoapp/yao/kb/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// updateDocumentAfterProcessing updates document status and segment count after processing
|
||||||
|
func (instance *KBInstance) updateDocumentAfterProcessing(ctx context.Context, docID, collectionID string) {
|
||||||
|
// Update status to completed
|
||||||
|
if err := instance.Config.UpdateDocument(docID, maps.MapStrAny{"status": "completed"}); err != nil {
|
||||||
|
log.Error("Failed to update document status to completed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update segment count
|
||||||
|
if segmentCount, err := instance.GraphRag.SegmentCount(ctx, docID); err != nil {
|
||||||
|
log.Error("Failed to get segment count for document %s: %v", docID, err)
|
||||||
|
} else {
|
||||||
|
if err := instance.Config.UpdateSegmentCount(docID, segmentCount); err != nil {
|
||||||
|
log.Error("Failed to update segment count for document %s: %v", docID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update document count for collection
|
||||||
|
if err := instance.updateDocumentCountWithSync(ctx, collectionID); err != nil {
|
||||||
|
log.Error("Failed to update document count for collection %s: %v", collectionID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateDocumentCountWithSync updates document count and syncs to GraphRag
|
||||||
|
func (instance *KBInstance) updateDocumentCountWithSync(ctx context.Context, collectionID string) error {
|
||||||
|
// Get document count
|
||||||
|
count, err := instance.Config.DocumentCount(collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get document count: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update collection in database
|
||||||
|
if err := instance.Config.UpdateCollection(collectionID, maps.MapStrAny{"document_count": count}); err != nil {
|
||||||
|
return fmt.Errorf("failed to update collection document count: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync to GraphRag
|
||||||
|
metadata := map[string]interface{}{"document_count": count}
|
||||||
|
if err := instance.GraphRag.UpdateCollectionMetadata(ctx, collectionID, metadata); err != nil {
|
||||||
|
return fmt.Errorf("failed to sync document count to GraphRag: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// toUpsertOptions converts provider config params to UpsertOptions
|
||||||
|
func (instance *KBInstance) toUpsertOptions(docID, collectionID, locale, filename, contentType string, chunking, embedding, extraction, fetcher, converter *ProviderConfigParams) (*graphragtypes.UpsertOptions, error) {
|
||||||
|
if locale == "" {
|
||||||
|
locale = DefaultLocale
|
||||||
|
}
|
||||||
|
|
||||||
|
options := &graphragtypes.UpsertOptions{
|
||||||
|
CollectionID: collectionID,
|
||||||
|
DocID: docID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create chunking provider
|
||||||
|
if chunking != nil {
|
||||||
|
chunkingOption, err := instance.getProviderOption("chunking", chunking.ProviderID, chunking.OptionID, locale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve chunking provider: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkingProvider, err := factory.MakeChunking(chunking.ProviderID, chunkingOption)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create chunking provider: %w", err)
|
||||||
|
}
|
||||||
|
options.Chunking = chunkingProvider
|
||||||
|
|
||||||
|
chunkingOpts, err := factory.ChunkingOptions(chunking.ProviderID, chunkingOption)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get chunking options: %w", err)
|
||||||
|
}
|
||||||
|
options.ChunkingOptions = chunkingOpts
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create embedding provider
|
||||||
|
if embedding != nil {
|
||||||
|
embeddingOption, err := instance.getProviderOption("embedding", embedding.ProviderID, embedding.OptionID, locale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve embedding provider: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
embeddingProvider, err := factory.MakeEmbedding(embedding.ProviderID, embeddingOption)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create embedding provider: %w", err)
|
||||||
|
}
|
||||||
|
options.Embedding = embeddingProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create extraction provider (optional, but required if graph is enabled)
|
||||||
|
// If extraction is not provided, try to use the default extraction provider
|
||||||
|
if extraction != nil {
|
||||||
|
extractionOption, err := instance.getProviderOption("extraction", extraction.ProviderID, extraction.OptionID, locale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve extraction provider: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
extractionProvider, err := factory.MakeExtraction(extraction.ProviderID, extractionOption)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create extraction provider: %w", err)
|
||||||
|
}
|
||||||
|
options.Extraction = extractionProvider
|
||||||
|
} else {
|
||||||
|
// Try to get default extraction provider to avoid gou's DetectExtractor with hardcoded connector
|
||||||
|
defaultExtraction := instance.getDefaultProvider("extraction", locale)
|
||||||
|
if defaultExtraction != nil {
|
||||||
|
extractionOption, err := instance.getProviderOption("extraction", defaultExtraction.ID, "", locale)
|
||||||
|
if err == nil {
|
||||||
|
extractionProvider, err := factory.MakeExtraction(defaultExtraction.ID, extractionOption)
|
||||||
|
if err == nil {
|
||||||
|
options.Extraction = extractionProvider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create fetcher provider (optional)
|
||||||
|
if fetcher != nil {
|
||||||
|
fetcherOption, err := instance.getProviderOption("fetcher", fetcher.ProviderID, fetcher.OptionID, locale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve fetcher provider: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fetcherProvider, err := factory.MakeFetcher(fetcher.ProviderID, fetcherOption)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create fetcher provider: %w", err)
|
||||||
|
}
|
||||||
|
options.Fetcher = fetcherProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create converter provider (optional or auto-detect)
|
||||||
|
if converter != nil {
|
||||||
|
converterOption, err := instance.getProviderOption("converter", converter.ProviderID, converter.OptionID, locale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve converter provider: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
converterProvider, err := factory.MakeConverter(converter.ProviderID, converterOption)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create converter provider: %w", err)
|
||||||
|
}
|
||||||
|
options.Converter = converterProvider
|
||||||
|
} else if filename != "" || contentType != "" {
|
||||||
|
// Auto-detect converter
|
||||||
|
matched, converterID, err := factory.AutoDetectConverter(filename, contentType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to auto-detect converter: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if matched {
|
||||||
|
converterOption, err := instance.getProviderOption("converter", converterID, "", locale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve auto-detected converter provider: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
converterProvider, err := factory.MakeConverter(converterID, converterOption)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create auto-detected converter provider: %w", err)
|
||||||
|
}
|
||||||
|
options.Converter = converterProvider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getProviderOption gets a provider option by provider type, ID and option ID
|
||||||
|
func (instance *KBInstance) getProviderOption(providerType, providerID, optionID, locale string) (*kbtypes.ProviderOption, error) {
|
||||||
|
provider, err := instance.Providers.GetProvider(providerType, providerID, locale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("provider %s not found for locale %s: %w", providerID, locale, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if optionID != "" {
|
||||||
|
option, exists := provider.GetOption(optionID)
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("option %s not found in provider %s", optionID, providerID)
|
||||||
|
}
|
||||||
|
return option, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return default option
|
||||||
|
if provider.Options != nil {
|
||||||
|
for _, option := range provider.Options {
|
||||||
|
if option.Default {
|
||||||
|
return option, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(provider.Options) > 0 {
|
||||||
|
return provider.Options[0], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no option specified and no default option found for provider %s", providerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDefaultProvider returns the default provider for a given type and locale
|
||||||
|
func (instance *KBInstance) getDefaultProvider(providerType, locale string) *kbtypes.Provider {
|
||||||
|
if instance.Providers == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
providers := instance.Providers.GetProviders(providerType, locale)
|
||||||
|
if len(providers) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find provider with default=true
|
||||||
|
for _, provider := range providers {
|
||||||
|
if provider.Default {
|
||||||
|
return provider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return first provider if no default is set
|
||||||
|
return providers[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// getJobOptions returns job options with defaults
|
||||||
|
func getJobOptions(job *JobOptionsParams, defaultName, defaultDescription, defaultIcon, defaultCategory string) (string, string, string, string) {
|
||||||
|
name := defaultName
|
||||||
|
description := defaultDescription
|
||||||
|
icon := defaultIcon
|
||||||
|
category := defaultCategory
|
||||||
|
|
||||||
|
if job != nil {
|
||||||
|
if job.Name != "" {
|
||||||
|
name = job.Name
|
||||||
|
}
|
||||||
|
if job.Description != "" {
|
||||||
|
description = job.Description
|
||||||
|
}
|
||||||
|
if job.Icon != "" {
|
||||||
|
icon = job.Icon
|
||||||
|
}
|
||||||
|
if job.Category != "" {
|
||||||
|
category = job.Category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return name, description, icon, category
|
||||||
|
}
|
||||||
|
|
||||||
|
// addBaseFieldsFromParams adds base fields from parameters to document data
|
||||||
|
func addBaseFieldsFromParams(data map[string]interface{}, locale string, metadata map[string]interface{}, chunking, embedding, extraction, fetcher, converter *ProviderConfigParams) {
|
||||||
|
if locale != "" {
|
||||||
|
data["locale"] = locale
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract fields from metadata
|
||||||
|
if metadata != nil {
|
||||||
|
if description, ok := metadata["description"]; ok && description != nil {
|
||||||
|
data["description"] = description
|
||||||
|
}
|
||||||
|
if cover, ok := metadata["cover"]; ok && cover != nil {
|
||||||
|
data["cover"] = cover
|
||||||
|
}
|
||||||
|
if tags, ok := metadata["tags"]; ok && tags != nil {
|
||||||
|
data["tags"] = tags
|
||||||
|
}
|
||||||
|
if name, ok := metadata["name"]; ok && name != nil {
|
||||||
|
data["name"] = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add provider configurations
|
||||||
|
if converter != nil {
|
||||||
|
data["converter_provider_id"] = converter.ProviderID
|
||||||
|
if converter.OptionID != "" {
|
||||||
|
data["converter_option_id"] = converter.OptionID
|
||||||
|
}
|
||||||
|
if converter.Properties != nil {
|
||||||
|
data["converter_properties"] = converter.Properties
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fetcher != nil {
|
||||||
|
data["fetcher_provider_id"] = fetcher.ProviderID
|
||||||
|
if fetcher.OptionID != "" {
|
||||||
|
data["fetcher_option_id"] = fetcher.OptionID
|
||||||
|
}
|
||||||
|
if fetcher.Properties != nil {
|
||||||
|
data["fetcher_properties"] = fetcher.Properties
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if chunking != nil {
|
||||||
|
data["chunking_provider_id"] = chunking.ProviderID
|
||||||
|
if chunking.OptionID != "" {
|
||||||
|
data["chunking_option_id"] = chunking.OptionID
|
||||||
|
}
|
||||||
|
if chunking.Properties != nil {
|
||||||
|
data["chunking_properties"] = chunking.Properties
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if embedding != nil {
|
||||||
|
data["embedding_provider_id"] = embedding.ProviderID
|
||||||
|
if embedding.OptionID != "" {
|
||||||
|
data["embedding_option_id"] = embedding.OptionID
|
||||||
|
}
|
||||||
|
if embedding.Properties != nil {
|
||||||
|
data["embedding_properties"] = embedding.Properties
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if extraction != nil {
|
||||||
|
data["extraction_provider_id"] = extraction.ProviderID
|
||||||
|
if extraction.OptionID != "" {
|
||||||
|
data["extraction_option_id"] = extraction.OptionID
|
||||||
|
}
|
||||||
|
if extraction.Properties != nil {
|
||||||
|
data["extraction_properties"] = extraction.Properties
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,239 +6,24 @@ import (
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/yaoapp/gou/graphrag/utils"
|
"github.com/yaoapp/gou/graphrag/utils"
|
||||||
"github.com/yaoapp/gou/model"
|
|
||||||
"github.com/yaoapp/gou/process"
|
"github.com/yaoapp/gou/process"
|
||||||
"github.com/yaoapp/kun/exception"
|
"github.com/yaoapp/kun/exception"
|
||||||
"github.com/yaoapp/kun/log"
|
"github.com/yaoapp/kun/log"
|
||||||
"github.com/yaoapp/kun/maps"
|
"github.com/yaoapp/kun/maps"
|
||||||
"github.com/yaoapp/yao/attachment"
|
"github.com/yaoapp/yao/attachment"
|
||||||
"github.com/yaoapp/yao/job"
|
|
||||||
"github.com/yaoapp/yao/kb"
|
"github.com/yaoapp/yao/kb"
|
||||||
|
kbapi "github.com/yaoapp/yao/kb/api"
|
||||||
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
||||||
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
|
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
"github.com/yaoapp/yao/openapi/response"
|
"github.com/yaoapp/yao/openapi/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateDocumentRecord creates a document record in the database immediately
|
// AddFile adds a file to a collection (sync)
|
||||||
// This is called synchronously when the API request comes in
|
|
||||||
func CreateDocumentRecord(ctx context.Context, authInfo *oauthtypes.AuthorizedInfo, req *AddFileRequest, jobID string) error {
|
|
||||||
// Check if kb.Instance is available
|
|
||||||
if kb.Instance == nil {
|
|
||||||
return fmt.Errorf("knowledge base not initialized")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get file manager
|
|
||||||
m, ok := attachment.Managers[req.Uploader]
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("invalid uploader: %s not found", req.Uploader)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the file exists
|
|
||||||
exists := m.Exists(ctx, req.FileID)
|
|
||||||
if !exists {
|
|
||||||
return fmt.Errorf("file not found: %s", req.FileID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get file info and path
|
|
||||||
path, contentType, err := m.LocalPath(ctx, req.FileID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get local path: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fileInfo, err := m.Info(ctx, req.FileID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get file info: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get KB config
|
|
||||||
config, err := kb.GetConfig()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get KB config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare document data for database
|
|
||||||
documentData := map[string]interface{}{
|
|
||||||
"document_id": req.DocID,
|
|
||||||
"collection_id": req.CollectionID,
|
|
||||||
"name": fileInfo.Filename,
|
|
||||||
"type": "file",
|
|
||||||
"status": "pending",
|
|
||||||
"uploader_id": req.Uploader,
|
|
||||||
"file_id": req.FileID,
|
|
||||||
"file_name": fileInfo.Filename,
|
|
||||||
"file_path": path,
|
|
||||||
"file_mime_type": contentType,
|
|
||||||
"size": int64(fileInfo.Bytes),
|
|
||||||
"job_id": jobID,
|
|
||||||
}
|
|
||||||
|
|
||||||
// With create scope
|
|
||||||
if authInfo != nil {
|
|
||||||
documentData = authInfo.WithCreateScope(documentData)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add base request fields
|
|
||||||
req.BaseUpsertRequest.AddBaseFields(documentData)
|
|
||||||
|
|
||||||
// Create database record
|
|
||||||
_, err = config.CreateDocument(maps.MapStrAny(documentData))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to save document metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleFileContent processes the actual file content and updates the knowledge base
|
|
||||||
// This is called asynchronously by the job system
|
|
||||||
func HandleFileContent(ctx context.Context, req *AddFileRequest) error {
|
|
||||||
// Check if kb.Instance is available
|
|
||||||
if kb.Instance == nil {
|
|
||||||
return fmt.Errorf("knowledge base not initialized")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get file manager
|
|
||||||
m, ok := attachment.Managers[req.Uploader]
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("invalid uploader: %s not found", req.Uploader)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get file info and path
|
|
||||||
path, contentType, err := m.LocalPath(ctx, req.FileID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get local path: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get KB config
|
|
||||||
config, err := kb.GetConfig()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get KB config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert request to UpsertOptions
|
|
||||||
upsertOptions, err := req.BaseUpsertRequest.ToUpsertOptions(path, contentType)
|
|
||||||
if err != nil {
|
|
||||||
// Update status to error
|
|
||||||
config.UpdateDocument(req.DocID, maps.MapStrAny{"status": "error", "error_message": err.Error()})
|
|
||||||
return fmt.Errorf("failed to convert request to upsert options: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform upsert operation with file path
|
|
||||||
_, err = kb.Instance.AddFile(ctx, path, upsertOptions)
|
|
||||||
if err != nil {
|
|
||||||
// Update status to error
|
|
||||||
config.UpdateDocument(req.DocID, maps.MapStrAny{"status": "error", "error_message": err.Error()})
|
|
||||||
return fmt.Errorf("failed to add file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update status to completed after successful processing
|
|
||||||
if err := config.UpdateDocument(req.DocID, maps.MapStrAny{"status": "completed"}); err != nil {
|
|
||||||
log.Error("Failed to update document status to completed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update segment count for the document
|
|
||||||
if segmentCount, err := kb.Instance.SegmentCount(ctx, req.DocID); err != nil {
|
|
||||||
log.Error("Failed to get segment count for document %s: %v", req.DocID, err)
|
|
||||||
} else {
|
|
||||||
log.Info("Got segment count %d for document %s", segmentCount, req.DocID)
|
|
||||||
if err := config.UpdateSegmentCount(req.DocID, segmentCount); err != nil {
|
|
||||||
log.Error("Failed to update segment count for document %s: %v", req.DocID, err)
|
|
||||||
} else {
|
|
||||||
log.Info("Successfully updated segment count to %d for document %s", segmentCount, req.DocID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update document count for the collection and sync to GraphRag
|
|
||||||
if err := UpdateDocumentCountWithSync(req.CollectionID, config); err != nil {
|
|
||||||
log.Error("Failed to update document count for collection %s: %v", req.CollectionID, err)
|
|
||||||
} else {
|
|
||||||
log.Info("Successfully updated document count for collection %s", req.CollectionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddFileHandler processes a file addition request with business logic only
|
|
||||||
// This function combines both document creation and content processing for sync operations
|
|
||||||
func AddFileHandler(ctx context.Context, authInfo *oauthtypes.AuthorizedInfo, req *AddFileRequest, jobID ...string) error {
|
|
||||||
// Validate request
|
|
||||||
if err := req.Validate(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// DocID should be generated by the caller before calling this function
|
|
||||||
if req.DocID == "" {
|
|
||||||
return fmt.Errorf("document ID is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
// For sync operations, create document record and process content immediately
|
|
||||||
var jid string
|
|
||||||
if len(jobID) > 0 {
|
|
||||||
jid = jobID[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create document record
|
|
||||||
if err := CreateDocumentRecord(ctx, authInfo, req, jid); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process file content
|
|
||||||
return HandleFileContent(ctx, req)
|
|
||||||
}
|
|
||||||
|
|
||||||
// addFileWithRequest processes a file addition with pre-parsed request using Gin context
|
|
||||||
func addFileWithRequest(c *gin.Context, req *AddFileRequest) {
|
|
||||||
|
|
||||||
// Check collection permission
|
|
||||||
authInfo := authorized.GetInfo(c)
|
|
||||||
hasPermission, err := checkCollectionPermission(authInfo, req.CollectionID)
|
|
||||||
if err != nil {
|
|
||||||
errorResp := &response.ErrorResponse{
|
|
||||||
Code: response.ErrServerError.Code,
|
|
||||||
ErrorDescription: err.Error(),
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 403 Forbidden
|
|
||||||
if !hasPermission {
|
|
||||||
errorResp := &response.ErrorResponse{
|
|
||||||
Code: response.ErrAccessDenied.Code,
|
|
||||||
ErrorDescription: "Forbidden: No permission to update collection",
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the business logic function
|
|
||||||
err = AddFileHandler(c.Request.Context(), authInfo, req)
|
|
||||||
if err != nil {
|
|
||||||
errorResp := &response.ErrorResponse{
|
|
||||||
Code: response.ErrServerError.Code,
|
|
||||||
ErrorDescription: err.Error(),
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return success response
|
|
||||||
result := gin.H{
|
|
||||||
"message": "File added successfully",
|
|
||||||
"collection_id": req.CollectionID,
|
|
||||||
"file_id": req.FileID,
|
|
||||||
"doc_id": req.DocID,
|
|
||||||
}
|
|
||||||
|
|
||||||
response.RespondWithSuccess(c, response.StatusCreated, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddFile adds a file to a collection
|
|
||||||
func AddFile(c *gin.Context) {
|
func AddFile(c *gin.Context) {
|
||||||
var req AddFileRequest
|
var req AddFileRequest
|
||||||
|
|
||||||
// Check if kb.Instance is available
|
// Check if kb.API is available
|
||||||
if !checkKBInstance(c) {
|
if !checkKBAPI(c) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -267,8 +52,44 @@ func AddFile(c *gin.Context) {
|
||||||
req.DocID = utils.GenDocIDWithCollectionID(req.CollectionID)
|
req.DocID = utils.GenDocIDWithCollectionID(req.CollectionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the request
|
// Check collection permission
|
||||||
addFileWithRequest(c, &req)
|
authInfo := authorized.GetInfo(c)
|
||||||
|
hasPermission, err := checkCollectionPermission(authInfo, req.CollectionID)
|
||||||
|
if err != nil {
|
||||||
|
errorResp := &response.ErrorResponse{
|
||||||
|
Code: response.ErrServerError.Code,
|
||||||
|
ErrorDescription: err.Error(),
|
||||||
|
}
|
||||||
|
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 403 Forbidden
|
||||||
|
if !hasPermission {
|
||||||
|
errorResp := &response.ErrorResponse{
|
||||||
|
Code: response.ErrAccessDenied.Code,
|
||||||
|
ErrorDescription: "Forbidden: No permission to update collection",
|
||||||
|
}
|
||||||
|
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert request to API params
|
||||||
|
params := convertAddFileRequest(&req, authInfo)
|
||||||
|
|
||||||
|
// Call kb.API
|
||||||
|
result, err := kb.API.AddFile(c.Request.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
errorResp := &response.ErrorResponse{
|
||||||
|
Code: response.ErrServerError.Code,
|
||||||
|
ErrorDescription: err.Error(),
|
||||||
|
}
|
||||||
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return success response
|
||||||
|
response.RespondWithSuccess(c, response.StatusCreated, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddFileAsync adds file to a collection asynchronously
|
// AddFileAsync adds file to a collection asynchronously
|
||||||
|
|
@ -277,9 +98,9 @@ func AddFileAsync(c *gin.Context) {
|
||||||
|
|
||||||
log.Info("AddFileAsync: Starting async file addition")
|
log.Info("AddFileAsync: Starting async file addition")
|
||||||
|
|
||||||
// Check if kb.Instance is available
|
// Check if kb.API is available
|
||||||
if !checkKBInstance(c) {
|
if !checkKBAPI(c) {
|
||||||
log.Error("AddFileAsync: KB instance check failed")
|
log.Error("AddFileAsync: KB API check failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -294,7 +115,7 @@ func AddFileAsync(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("AddFileAsync: Request parsed successfully: %+v", req)
|
log.Info("AddFileAsync: Request parsed successfully")
|
||||||
|
|
||||||
// Validate request
|
// Validate request
|
||||||
if err := req.Validate(); err != nil {
|
if err := req.Validate(); err != nil {
|
||||||
|
|
@ -309,24 +130,14 @@ func AddFileAsync(c *gin.Context) {
|
||||||
|
|
||||||
log.Info("AddFileAsync: Request validation passed")
|
log.Info("AddFileAsync: Request validation passed")
|
||||||
|
|
||||||
// Validate file and get path
|
// Validate file exists
|
||||||
_, _, err := validateFileAndGetPath(c, &req)
|
if err := validateFileExists(c, &req); err != nil {
|
||||||
if err != nil {
|
|
||||||
log.Error("AddFileAsync: File validation failed: %v", err)
|
log.Error("AddFileAsync: File validation failed: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("AddFileAsync: File validation passed")
|
log.Info("AddFileAsync: File validation passed")
|
||||||
|
|
||||||
// Convert request to UpsertOptions (just for validation)
|
|
||||||
_, err = getUpsertOptions(c, &req.BaseUpsertRequest)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("AddFileAsync: UpsertOptions validation failed: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("AddFileAsync: UpsertOptions validation passed")
|
|
||||||
|
|
||||||
// Generate document ID if not provided
|
// Generate document ID if not provided
|
||||||
if req.DocID == "" {
|
if req.DocID == "" {
|
||||||
req.DocID = utils.GenDocIDWithCollectionID(req.CollectionID)
|
req.DocID = utils.GenDocIDWithCollectionID(req.CollectionID)
|
||||||
|
|
@ -356,112 +167,25 @@ func AddFileAsync(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Get job options with defaults
|
// Convert request to API params
|
||||||
jobName, jobDescription, jobIcon, jobCategory := req.GetJobOptions(
|
params := convertAddFileRequest(&req, authInfo)
|
||||||
"Knowledge Base File Processing", // default name
|
|
||||||
"Processing and indexing file content for knowledge base search", // default description
|
|
||||||
"library_add", // default icon (Material Icon)
|
|
||||||
"Knowledge Base", // default category
|
|
||||||
)
|
|
||||||
|
|
||||||
// Create job data
|
// Call kb.API async
|
||||||
jobCreateData := map[string]interface{}{
|
result, err := kb.API.AddFileAsync(c.Request.Context(), params)
|
||||||
"name": jobName,
|
|
||||||
"description": jobDescription,
|
|
||||||
"category_name": jobCategory, // Pass category name directly, let SaveJob handle it
|
|
||||||
}
|
|
||||||
if jobIcon != "" {
|
|
||||||
jobCreateData["icon"] = jobIcon
|
|
||||||
}
|
|
||||||
|
|
||||||
// With create scope
|
|
||||||
if authInfo != nil {
|
|
||||||
jobCreateData = authInfo.WithCreateScope(jobCreateData)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create and save Job in one step to get JobID
|
|
||||||
j, err := job.OnceAndSave(job.GOROUTINE, jobCreateData)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("AddFileAsync: Job creation and save failed: %v", err)
|
log.Error("AddFileAsync: Failed to add file async: %v", err)
|
||||||
errorResp := &response.ErrorResponse{
|
errorResp := &response.ErrorResponse{
|
||||||
Code: response.ErrServerError.Code,
|
Code: response.ErrServerError.Code,
|
||||||
ErrorDescription: "Failed to create and save job: " + err.Error(),
|
ErrorDescription: err.Error(),
|
||||||
}
|
}
|
||||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("AddFileAsync: Job created and saved with ID: %s", j.JobID)
|
log.Info("AddFileAsync: Job created with ID: %s", result.JobID)
|
||||||
|
|
||||||
// Step 2: Create document record immediately with job_id
|
|
||||||
err = CreateDocumentRecord(c.Request.Context(), authInfo, &req, j.JobID)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("AddFileAsync: Failed to create document record: %v", err)
|
|
||||||
errorResp := &response.ErrorResponse{
|
|
||||||
Code: response.ErrServerError.Code,
|
|
||||||
ErrorDescription: "Failed to create document record: " + err.Error(),
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("AddFileAsync: Document record created successfully")
|
|
||||||
|
|
||||||
// Step 4: Add execution to job
|
|
||||||
jobData := map[string]interface{}{
|
|
||||||
"collection_id": req.CollectionID,
|
|
||||||
"file_id": req.FileID,
|
|
||||||
"uploader": req.Uploader,
|
|
||||||
"locale": req.Locale,
|
|
||||||
"doc_id": req.DocID,
|
|
||||||
"metadata": req.Metadata,
|
|
||||||
"chunking": req.Chunking,
|
|
||||||
"embedding": req.Embedding,
|
|
||||||
"extraction": req.Extraction,
|
|
||||||
"fetcher": req.Fetcher,
|
|
||||||
"converter": req.Converter,
|
|
||||||
}
|
|
||||||
|
|
||||||
err = j.Add(&job.ExecutionOptions{
|
|
||||||
Priority: 1,
|
|
||||||
}, "kb.documents.addfile", jobData)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("AddFileAsync: Failed to add job execution: %v", err)
|
|
||||||
// Rollback: remove document record
|
|
||||||
if config, err := kb.GetConfig(); err == nil {
|
|
||||||
config.RemoveDocument(req.DocID)
|
|
||||||
}
|
|
||||||
errorResp := &response.ErrorResponse{
|
|
||||||
Code: response.ErrServerError.Code,
|
|
||||||
ErrorDescription: "Failed to add job execution: " + err.Error(),
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: Push the job to execution queue
|
|
||||||
err = j.Push()
|
|
||||||
if err != nil {
|
|
||||||
log.Error("AddFileAsync: Failed to push job: %v", err)
|
|
||||||
// Rollback: remove document record
|
|
||||||
if config, err := kb.GetConfig(); err == nil {
|
|
||||||
config.RemoveDocument(req.DocID)
|
|
||||||
}
|
|
||||||
errorResp := &response.ErrorResponse{
|
|
||||||
Code: response.ErrServerError.Code,
|
|
||||||
ErrorDescription: "Failed to push job: " + err.Error(),
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("AddFileAsync: Job pushed successfully")
|
|
||||||
|
|
||||||
// Return job_id and doc_id
|
// Return job_id and doc_id
|
||||||
response.RespondWithSuccess(c, response.StatusCreated, gin.H{
|
response.RespondWithSuccess(c, response.StatusCreated, result)
|
||||||
"job_id": j.JobID,
|
|
||||||
"doc_id": req.DocID,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessAddFile documents.addfile Knowledge Base add file processor
|
// ProcessAddFile documents.addfile Knowledge Base add file processor
|
||||||
|
|
@ -473,101 +197,141 @@ func ProcessAddFile(process *process.Process) interface{} {
|
||||||
// Get parameters
|
// Get parameters
|
||||||
reqMap := process.ArgsMap(0)
|
reqMap := process.ArgsMap(0)
|
||||||
|
|
||||||
// Check knowledge base instance
|
// Check knowledge base API
|
||||||
if kb.Instance == nil {
|
if kb.API == nil {
|
||||||
exception.New("knowledge base not initialized", 500).Throw()
|
exception.New("knowledge base API not initialized", 500).Throw()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert parameters to AddFileRequest structure
|
// Convert parameters to AddFileParams
|
||||||
req := parseAddFileRequest(reqMap)
|
params := parseAddFileParams(reqMap)
|
||||||
|
|
||||||
// Get KB config to check if document exists
|
// Get context
|
||||||
config, err := kb.GetConfig()
|
|
||||||
if err != nil {
|
|
||||||
exception.New("failed to get KB config: %s", 500, err.Error()).Throw()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if document already exists
|
|
||||||
ctx := process.Context
|
ctx := process.Context
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
}
|
}
|
||||||
|
|
||||||
existingDoc, err := config.FindDocument(req.DocID, model.QueryParam{})
|
// Call kb.API
|
||||||
if err != nil || existingDoc == nil {
|
result, err := kb.API.AddFile(ctx, params)
|
||||||
// Document doesn't exist, create it first (sync scenario)
|
|
||||||
log.Info("ProcessAddFile: Document %s not found, creating new record", req.DocID)
|
|
||||||
|
|
||||||
// Get job_id from request if provided (for async scenario)
|
|
||||||
var jobID string
|
|
||||||
if jid, ok := reqMap["job_id"].(string); ok {
|
|
||||||
jobID = jid
|
|
||||||
}
|
|
||||||
err = CreateDocumentRecord(ctx, authorized.ProcessAuthInfo(process), req, jobID)
|
|
||||||
if err != nil {
|
|
||||||
exception.New("failed to create document record: %s", 500, err.Error()).Throw()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.Info("ProcessAddFile: Document %s already exists, processing content only", req.DocID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process file content
|
|
||||||
err = HandleFileContent(ctx, req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exception.New("failed to process file: %s", 500, err.Error()).Throw()
|
exception.New("failed to add file: %s", 500, err.Error()).Throw()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return result
|
// Return result
|
||||||
return maps.MapStrAny{
|
return maps.MapStrAny{
|
||||||
"doc_id": req.DocID,
|
"doc_id": result.DocID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseAddFileRequest parses request map into AddFileRequest structure
|
// convertAddFileRequest converts AddFileRequest to kbapi.AddFileParams
|
||||||
func parseAddFileRequest(reqMap map[string]interface{}) *AddFileRequest {
|
func convertAddFileRequest(req *AddFileRequest, authInfo *oauthtypes.AuthorizedInfo) *kbapi.AddFileParams {
|
||||||
req := &AddFileRequest{}
|
params := &kbapi.AddFileParams{
|
||||||
|
CollectionID: req.CollectionID,
|
||||||
|
FileID: req.FileID,
|
||||||
|
Uploader: req.Uploader,
|
||||||
|
DocID: req.DocID,
|
||||||
|
Locale: req.Locale,
|
||||||
|
Metadata: req.Metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert provider configs
|
||||||
|
if req.Chunking != nil {
|
||||||
|
params.Chunking = &kbapi.ProviderConfigParams{
|
||||||
|
ProviderID: req.Chunking.ProviderID,
|
||||||
|
OptionID: req.Chunking.OptionID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Embedding != nil {
|
||||||
|
params.Embedding = &kbapi.ProviderConfigParams{
|
||||||
|
ProviderID: req.Embedding.ProviderID,
|
||||||
|
OptionID: req.Embedding.OptionID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Extraction != nil {
|
||||||
|
params.Extraction = &kbapi.ProviderConfigParams{
|
||||||
|
ProviderID: req.Extraction.ProviderID,
|
||||||
|
OptionID: req.Extraction.OptionID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Fetcher != nil {
|
||||||
|
params.Fetcher = &kbapi.ProviderConfigParams{
|
||||||
|
ProviderID: req.Fetcher.ProviderID,
|
||||||
|
OptionID: req.Fetcher.OptionID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Converter != nil {
|
||||||
|
params.Converter = &kbapi.ProviderConfigParams{
|
||||||
|
ProviderID: req.Converter.ProviderID,
|
||||||
|
OptionID: req.Converter.OptionID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Job != nil {
|
||||||
|
params.Job = &kbapi.JobOptionsParams{
|
||||||
|
Name: req.Job.Name,
|
||||||
|
Description: req.Job.Description,
|
||||||
|
Icon: req.Job.Icon,
|
||||||
|
Category: req.Job.Category,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set auth scope
|
||||||
|
if authInfo != nil {
|
||||||
|
params.AuthScope = authInfo.WithCreateScope(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAddFileParams parses request map into kbapi.AddFileParams
|
||||||
|
func parseAddFileParams(reqMap map[string]interface{}) *kbapi.AddFileParams {
|
||||||
|
params := &kbapi.AddFileParams{}
|
||||||
|
|
||||||
// Required fields
|
// Required fields
|
||||||
if collectionID, ok := reqMap["collection_id"].(string); ok {
|
if collectionID, ok := reqMap["collection_id"].(string); ok {
|
||||||
req.CollectionID = collectionID
|
params.CollectionID = collectionID
|
||||||
} else {
|
} else {
|
||||||
exception.New("collection_id is required", 400).Throw()
|
exception.New("collection_id is required", 400).Throw()
|
||||||
}
|
}
|
||||||
|
|
||||||
if fileID, ok := reqMap["file_id"].(string); ok {
|
if fileID, ok := reqMap["file_id"].(string); ok {
|
||||||
req.FileID = fileID
|
params.FileID = fileID
|
||||||
} else {
|
} else {
|
||||||
exception.New("file_id is required", 400).Throw()
|
exception.New("file_id is required", 400).Throw()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional fields
|
// Optional fields
|
||||||
if uploader, ok := reqMap["uploader"].(string); ok {
|
if uploader, ok := reqMap["uploader"].(string); ok {
|
||||||
req.Uploader = uploader
|
params.Uploader = uploader
|
||||||
} else {
|
} else {
|
||||||
req.Uploader = "local" // Default to local uploader
|
params.Uploader = "local" // Default to local uploader
|
||||||
}
|
}
|
||||||
|
|
||||||
if locale, ok := reqMap["locale"].(string); ok {
|
if locale, ok := reqMap["locale"].(string); ok {
|
||||||
req.Locale = locale
|
params.Locale = locale
|
||||||
}
|
}
|
||||||
|
|
||||||
if docID, ok := reqMap["doc_id"].(string); ok {
|
if docID, ok := reqMap["doc_id"].(string); ok {
|
||||||
req.DocID = docID
|
params.DocID = docID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate doc_id if not provided
|
// Generate doc_id if not provided
|
||||||
if req.DocID == "" {
|
if params.DocID == "" {
|
||||||
req.DocID = utils.GenDocIDWithCollectionID(req.CollectionID)
|
params.DocID = utils.GenDocIDWithCollectionID(params.CollectionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle metadata
|
// Handle metadata
|
||||||
if metadata, ok := reqMap["metadata"].(map[string]interface{}); ok {
|
if metadata, ok := reqMap["metadata"].(map[string]interface{}); ok {
|
||||||
req.Metadata = metadata
|
params.Metadata = metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle chunking configuration
|
// Handle chunking configuration
|
||||||
if chunkingMap, ok := reqMap["chunking"].(map[string]interface{}); ok {
|
if chunkingMap, ok := reqMap["chunking"].(map[string]interface{}); ok {
|
||||||
chunking := &ProviderConfig{}
|
chunking := &kbapi.ProviderConfigParams{}
|
||||||
if providerID, ok := chunkingMap["provider_id"].(string); ok {
|
if providerID, ok := chunkingMap["provider_id"].(string); ok {
|
||||||
chunking.ProviderID = providerID
|
chunking.ProviderID = providerID
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -576,14 +340,14 @@ func parseAddFileRequest(reqMap map[string]interface{}) *AddFileRequest {
|
||||||
if optionID, ok := chunkingMap["option_id"].(string); ok {
|
if optionID, ok := chunkingMap["option_id"].(string); ok {
|
||||||
chunking.OptionID = optionID
|
chunking.OptionID = optionID
|
||||||
}
|
}
|
||||||
req.Chunking = chunking
|
params.Chunking = chunking
|
||||||
} else {
|
} else {
|
||||||
exception.New("chunking configuration is required", 400).Throw()
|
exception.New("chunking configuration is required", 400).Throw()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle embedding configuration
|
// Handle embedding configuration
|
||||||
if embeddingMap, ok := reqMap["embedding"].(map[string]interface{}); ok {
|
if embeddingMap, ok := reqMap["embedding"].(map[string]interface{}); ok {
|
||||||
embedding := &ProviderConfig{}
|
embedding := &kbapi.ProviderConfigParams{}
|
||||||
if providerID, ok := embeddingMap["provider_id"].(string); ok {
|
if providerID, ok := embeddingMap["provider_id"].(string); ok {
|
||||||
embedding.ProviderID = providerID
|
embedding.ProviderID = providerID
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -592,50 +356,50 @@ func parseAddFileRequest(reqMap map[string]interface{}) *AddFileRequest {
|
||||||
if optionID, ok := embeddingMap["option_id"].(string); ok {
|
if optionID, ok := embeddingMap["option_id"].(string); ok {
|
||||||
embedding.OptionID = optionID
|
embedding.OptionID = optionID
|
||||||
}
|
}
|
||||||
req.Embedding = embedding
|
params.Embedding = embedding
|
||||||
} else {
|
} else {
|
||||||
exception.New("embedding configuration is required", 400).Throw()
|
exception.New("embedding configuration is required", 400).Throw()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle optional extraction configuration
|
// Handle optional extraction configuration
|
||||||
if extractionMap, ok := reqMap["extraction"].(map[string]interface{}); ok {
|
if extractionMap, ok := reqMap["extraction"].(map[string]interface{}); ok {
|
||||||
extraction := &ProviderConfig{}
|
extraction := &kbapi.ProviderConfigParams{}
|
||||||
if providerID, ok := extractionMap["provider_id"].(string); ok {
|
if providerID, ok := extractionMap["provider_id"].(string); ok {
|
||||||
extraction.ProviderID = providerID
|
extraction.ProviderID = providerID
|
||||||
}
|
}
|
||||||
if optionID, ok := extractionMap["option_id"].(string); ok {
|
if optionID, ok := extractionMap["option_id"].(string); ok {
|
||||||
extraction.OptionID = optionID
|
extraction.OptionID = optionID
|
||||||
}
|
}
|
||||||
req.Extraction = extraction
|
params.Extraction = extraction
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle optional fetcher configuration
|
// Handle optional fetcher configuration
|
||||||
if fetcherMap, ok := reqMap["fetcher"].(map[string]interface{}); ok {
|
if fetcherMap, ok := reqMap["fetcher"].(map[string]interface{}); ok {
|
||||||
fetcher := &ProviderConfig{}
|
fetcher := &kbapi.ProviderConfigParams{}
|
||||||
if providerID, ok := fetcherMap["provider_id"].(string); ok {
|
if providerID, ok := fetcherMap["provider_id"].(string); ok {
|
||||||
fetcher.ProviderID = providerID
|
fetcher.ProviderID = providerID
|
||||||
}
|
}
|
||||||
if optionID, ok := fetcherMap["option_id"].(string); ok {
|
if optionID, ok := fetcherMap["option_id"].(string); ok {
|
||||||
fetcher.OptionID = optionID
|
fetcher.OptionID = optionID
|
||||||
}
|
}
|
||||||
req.Fetcher = fetcher
|
params.Fetcher = fetcher
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle optional converter configuration
|
// Handle optional converter configuration
|
||||||
if converterMap, ok := reqMap["converter"].(map[string]interface{}); ok {
|
if converterMap, ok := reqMap["converter"].(map[string]interface{}); ok {
|
||||||
converter := &ProviderConfig{}
|
converter := &kbapi.ProviderConfigParams{}
|
||||||
if providerID, ok := converterMap["provider_id"].(string); ok {
|
if providerID, ok := converterMap["provider_id"].(string); ok {
|
||||||
converter.ProviderID = providerID
|
converter.ProviderID = providerID
|
||||||
}
|
}
|
||||||
if optionID, ok := converterMap["option_id"].(string); ok {
|
if optionID, ok := converterMap["option_id"].(string); ok {
|
||||||
converter.OptionID = optionID
|
converter.OptionID = optionID
|
||||||
}
|
}
|
||||||
req.Converter = converter
|
params.Converter = converter
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle job options
|
// Handle job options
|
||||||
if jobMap, ok := reqMap["job"].(map[string]interface{}); ok {
|
if jobMap, ok := reqMap["job"].(map[string]interface{}); ok {
|
||||||
job := &JobOptions{}
|
job := &kbapi.JobOptionsParams{}
|
||||||
if name, ok := jobMap["name"].(string); ok {
|
if name, ok := jobMap["name"].(string); ok {
|
||||||
job.Name = name
|
job.Name = name
|
||||||
}
|
}
|
||||||
|
|
@ -648,8 +412,35 @@ func parseAddFileRequest(reqMap map[string]interface{}) *AddFileRequest {
|
||||||
if category, ok := jobMap["category"].(string); ok {
|
if category, ok := jobMap["category"].(string); ok {
|
||||||
job.Category = category
|
job.Category = category
|
||||||
}
|
}
|
||||||
req.Job = job
|
params.Job = job
|
||||||
}
|
}
|
||||||
|
|
||||||
return req
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateFileExists validates that the file exists in the attachment manager
|
||||||
|
func validateFileExists(c *gin.Context, req *AddFileRequest) error {
|
||||||
|
// Get file manager
|
||||||
|
m, ok := attachment.Managers[req.Uploader]
|
||||||
|
if !ok {
|
||||||
|
errorResp := &response.ErrorResponse{
|
||||||
|
Code: response.ErrInvalidRequest.Code,
|
||||||
|
ErrorDescription: "Invalid uploader: " + req.Uploader + " not found",
|
||||||
|
}
|
||||||
|
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
||||||
|
return fmt.Errorf("invalid uploader: %s not found", req.Uploader)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the file exists
|
||||||
|
exists := m.Exists(c.Request.Context(), req.FileID)
|
||||||
|
if !exists {
|
||||||
|
errorResp := &response.ErrorResponse{
|
||||||
|
Code: response.ErrInvalidRequest.Code,
|
||||||
|
ErrorDescription: "File not found: " + req.FileID,
|
||||||
|
}
|
||||||
|
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
||||||
|
return fmt.Errorf("file not found: %s", req.FileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,179 +2,26 @@ package kb
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/yaoapp/gou/graphrag/utils"
|
"github.com/yaoapp/gou/graphrag/utils"
|
||||||
"github.com/yaoapp/gou/model"
|
|
||||||
"github.com/yaoapp/gou/process"
|
"github.com/yaoapp/gou/process"
|
||||||
"github.com/yaoapp/kun/exception"
|
"github.com/yaoapp/kun/exception"
|
||||||
"github.com/yaoapp/kun/log"
|
"github.com/yaoapp/kun/log"
|
||||||
"github.com/yaoapp/kun/maps"
|
"github.com/yaoapp/kun/maps"
|
||||||
"github.com/yaoapp/yao/job"
|
|
||||||
"github.com/yaoapp/yao/kb"
|
"github.com/yaoapp/yao/kb"
|
||||||
|
kbapi "github.com/yaoapp/yao/kb/api"
|
||||||
|
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
||||||
|
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
"github.com/yaoapp/yao/openapi/response"
|
"github.com/yaoapp/yao/openapi/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateTextDocumentRecord creates a text document record in the database immediately
|
// AddText adds text to a collection (sync)
|
||||||
// This is called synchronously when the API request comes in
|
|
||||||
func CreateTextDocumentRecord(ctx context.Context, req *AddTextRequest, jobID string) error {
|
|
||||||
// Check if kb.Instance is available
|
|
||||||
if kb.Instance == nil {
|
|
||||||
return fmt.Errorf("knowledge base not initialized")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get KB config
|
|
||||||
config, err := kb.GetConfig()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get KB config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare document data for database
|
|
||||||
documentData := map[string]interface{}{
|
|
||||||
"document_id": req.DocID,
|
|
||||||
"collection_id": req.CollectionID,
|
|
||||||
"name": "Text Document",
|
|
||||||
"type": "text",
|
|
||||||
"status": "pending",
|
|
||||||
"text_content": req.Text,
|
|
||||||
"size": int64(len(req.Text)),
|
|
||||||
"job_id": jobID,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use title from metadata if available
|
|
||||||
if req.Metadata != nil {
|
|
||||||
if title, ok := req.Metadata["title"].(string); ok && title != "" {
|
|
||||||
documentData["name"] = title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add base request fields
|
|
||||||
req.BaseUpsertRequest.AddBaseFields(documentData)
|
|
||||||
|
|
||||||
// Create database record
|
|
||||||
_, err = config.CreateDocument(maps.MapStrAny(documentData))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to save document metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleTextContent processes the actual text content and updates the knowledge base
|
|
||||||
// This is called asynchronously by the job system
|
|
||||||
func HandleTextContent(ctx context.Context, req *AddTextRequest) error {
|
|
||||||
// Check if kb.Instance is available
|
|
||||||
if kb.Instance == nil {
|
|
||||||
return fmt.Errorf("knowledge base not initialized")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get KB config
|
|
||||||
config, err := kb.GetConfig()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get KB config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert request to UpsertOptions
|
|
||||||
upsertOptions, err := req.BaseUpsertRequest.ToUpsertOptions()
|
|
||||||
if err != nil {
|
|
||||||
// Update status to error
|
|
||||||
config.UpdateDocument(req.DocID, maps.MapStrAny{"status": "error", "error_message": err.Error()})
|
|
||||||
return fmt.Errorf("failed to convert request to upsert options: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform upsert operation with text
|
|
||||||
_, err = kb.Instance.AddText(ctx, req.Text, upsertOptions)
|
|
||||||
if err != nil {
|
|
||||||
// Update status to error
|
|
||||||
config.UpdateDocument(req.DocID, maps.MapStrAny{"status": "error", "error_message": err.Error()})
|
|
||||||
return fmt.Errorf("failed to add text: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update status to completed after successful processing
|
|
||||||
if err := config.UpdateDocument(req.DocID, maps.MapStrAny{"status": "completed"}); err != nil {
|
|
||||||
log.Error("Failed to update document status to completed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update segment count for the document
|
|
||||||
if segmentCount, err := kb.Instance.SegmentCount(ctx, req.DocID); err != nil {
|
|
||||||
log.Error("Failed to get segment count for document %s: %v", req.DocID, err)
|
|
||||||
} else {
|
|
||||||
log.Info("Got segment count %d for document %s", segmentCount, req.DocID)
|
|
||||||
if err := config.UpdateSegmentCount(req.DocID, segmentCount); err != nil {
|
|
||||||
log.Error("Failed to update segment count for document %s: %v", req.DocID, err)
|
|
||||||
} else {
|
|
||||||
log.Info("Successfully updated segment count to %d for document %s", segmentCount, req.DocID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update document count for the collection and sync to GraphRag
|
|
||||||
if err := UpdateDocumentCountWithSync(req.CollectionID, config); err != nil {
|
|
||||||
log.Error("Failed to update document count for collection %s: %v", req.CollectionID, err)
|
|
||||||
} else {
|
|
||||||
log.Info("Successfully updated document count for collection %s", req.CollectionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddTextHandler processes a text addition request with business logic only
|
|
||||||
// This function combines both document creation and content processing for sync operations
|
|
||||||
func AddTextHandler(ctx context.Context, req *AddTextRequest, jobID ...string) error {
|
|
||||||
// Validate request
|
|
||||||
if err := req.Validate(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// DocID should be generated by the caller before calling this function
|
|
||||||
if req.DocID == "" {
|
|
||||||
return fmt.Errorf("document ID is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
// For sync operations, create document record and process content immediately
|
|
||||||
var jid string
|
|
||||||
if len(jobID) > 0 {
|
|
||||||
jid = jobID[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create document record
|
|
||||||
if err := CreateTextDocumentRecord(ctx, req, jid); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process text content
|
|
||||||
return HandleTextContent(ctx, req)
|
|
||||||
}
|
|
||||||
|
|
||||||
// addTextWithRequest processes a text addition with pre-parsed request using Gin context
|
|
||||||
func addTextWithRequest(c *gin.Context, req *AddTextRequest) {
|
|
||||||
// Use the business logic function
|
|
||||||
err := AddTextHandler(c.Request.Context(), req)
|
|
||||||
if err != nil {
|
|
||||||
errorResp := &response.ErrorResponse{
|
|
||||||
Code: response.ErrServerError.Code,
|
|
||||||
ErrorDescription: err.Error(),
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return success response
|
|
||||||
result := gin.H{
|
|
||||||
"message": "Text added successfully",
|
|
||||||
"collection_id": req.CollectionID,
|
|
||||||
"doc_id": req.DocID,
|
|
||||||
}
|
|
||||||
|
|
||||||
response.RespondWithSuccess(c, response.StatusCreated, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddText adds text to a collection
|
|
||||||
func AddText(c *gin.Context) {
|
func AddText(c *gin.Context) {
|
||||||
var req AddTextRequest
|
var req AddTextRequest
|
||||||
|
|
||||||
// Check if kb.Instance is available
|
// Check if kb.API is available
|
||||||
if !checkKBInstance(c) {
|
if !checkKBAPI(c) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,8 +50,44 @@ func AddText(c *gin.Context) {
|
||||||
req.DocID = utils.GenDocIDWithCollectionID(req.CollectionID)
|
req.DocID = utils.GenDocIDWithCollectionID(req.CollectionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the request
|
// Check collection permission
|
||||||
addTextWithRequest(c, &req)
|
authInfo := authorized.GetInfo(c)
|
||||||
|
hasPermission, err := checkCollectionPermission(authInfo, req.CollectionID)
|
||||||
|
if err != nil {
|
||||||
|
errorResp := &response.ErrorResponse{
|
||||||
|
Code: response.ErrServerError.Code,
|
||||||
|
ErrorDescription: err.Error(),
|
||||||
|
}
|
||||||
|
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 403 Forbidden
|
||||||
|
if !hasPermission {
|
||||||
|
errorResp := &response.ErrorResponse{
|
||||||
|
Code: response.ErrAccessDenied.Code,
|
||||||
|
ErrorDescription: "Forbidden: No permission to update collection",
|
||||||
|
}
|
||||||
|
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert request to API params
|
||||||
|
params := convertAddTextRequest(&req, authInfo)
|
||||||
|
|
||||||
|
// Call kb.API
|
||||||
|
result, err := kb.API.AddText(c.Request.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
errorResp := &response.ErrorResponse{
|
||||||
|
Code: response.ErrServerError.Code,
|
||||||
|
ErrorDescription: err.Error(),
|
||||||
|
}
|
||||||
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return success response
|
||||||
|
response.RespondWithSuccess(c, response.StatusCreated, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddTextAsync adds text to a collection asynchronously
|
// AddTextAsync adds text to a collection asynchronously
|
||||||
|
|
@ -213,9 +96,9 @@ func AddTextAsync(c *gin.Context) {
|
||||||
|
|
||||||
log.Info("AddTextAsync: Starting async text addition")
|
log.Info("AddTextAsync: Starting async text addition")
|
||||||
|
|
||||||
// Check if kb.Instance is available
|
// Check if kb.API is available
|
||||||
if !checkKBInstance(c) {
|
if !checkKBAPI(c) {
|
||||||
log.Error("AddTextAsync: KB instance check failed")
|
log.Error("AddTextAsync: KB API check failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,7 +113,7 @@ func AddTextAsync(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("AddTextAsync: Request parsed successfully: %+v", req)
|
log.Info("AddTextAsync: Request parsed successfully")
|
||||||
|
|
||||||
// Validate request
|
// Validate request
|
||||||
if err := req.Validate(); err != nil {
|
if err := req.Validate(); err != nil {
|
||||||
|
|
@ -245,15 +128,6 @@ func AddTextAsync(c *gin.Context) {
|
||||||
|
|
||||||
log.Info("AddTextAsync: Request validation passed")
|
log.Info("AddTextAsync: Request validation passed")
|
||||||
|
|
||||||
// Convert request to UpsertOptions (just for validation)
|
|
||||||
_, err := getUpsertOptions(c, &req.BaseUpsertRequest)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("AddTextAsync: UpsertOptions validation failed: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("AddTextAsync: UpsertOptions validation passed")
|
|
||||||
|
|
||||||
// Generate document ID if not provided
|
// Generate document ID if not provided
|
||||||
if req.DocID == "" {
|
if req.DocID == "" {
|
||||||
req.DocID = utils.GenDocIDWithCollectionID(req.CollectionID)
|
req.DocID = utils.GenDocIDWithCollectionID(req.CollectionID)
|
||||||
|
|
@ -261,109 +135,50 @@ func AddTextAsync(c *gin.Context) {
|
||||||
|
|
||||||
log.Info("AddTextAsync: Generated doc_id: %s", req.DocID)
|
log.Info("AddTextAsync: Generated doc_id: %s", req.DocID)
|
||||||
|
|
||||||
// Step 1: Get job options with defaults
|
// Check collection permission
|
||||||
jobName, jobDescription, jobIcon, jobCategory := req.GetJobOptions(
|
authInfo := authorized.GetInfo(c)
|
||||||
"Knowledge Base Text Processing", // default name
|
hasPermission, err := checkCollectionPermission(authInfo, req.CollectionID)
|
||||||
"Processing and indexing text content for knowledge base search", // default description
|
|
||||||
"library_add", // default icon (Material Icon)
|
|
||||||
"Knowledge Base", // default category
|
|
||||||
)
|
|
||||||
|
|
||||||
// Create job data
|
|
||||||
jobCreateData := map[string]interface{}{
|
|
||||||
"name": jobName,
|
|
||||||
"description": jobDescription,
|
|
||||||
"category_name": jobCategory, // Pass category name directly, let SaveJob handle it
|
|
||||||
}
|
|
||||||
if jobIcon != "" {
|
|
||||||
jobCreateData["icon"] = jobIcon
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create and save Job in one step to get JobID
|
|
||||||
j, err := job.OnceAndSave(job.GOROUTINE, jobCreateData)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("AddTextAsync: Job creation and save failed: %v", err)
|
|
||||||
errorResp := &response.ErrorResponse{
|
errorResp := &response.ErrorResponse{
|
||||||
Code: response.ErrServerError.Code,
|
Code: response.ErrServerError.Code,
|
||||||
ErrorDescription: "Failed to create and save job: " + err.Error(),
|
ErrorDescription: err.Error(),
|
||||||
|
}
|
||||||
|
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 403 Forbidden
|
||||||
|
if !hasPermission {
|
||||||
|
errorResp := &response.ErrorResponse{
|
||||||
|
Code: response.ErrAccessDenied.Code,
|
||||||
|
ErrorDescription: "Forbidden: No permission to update collection",
|
||||||
|
}
|
||||||
|
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert request to API params
|
||||||
|
params := convertAddTextRequest(&req, authInfo)
|
||||||
|
|
||||||
|
// Call kb.API async
|
||||||
|
result, err := kb.API.AddTextAsync(c.Request.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("AddTextAsync: Failed to add text async: %v", err)
|
||||||
|
errorResp := &response.ErrorResponse{
|
||||||
|
Code: response.ErrServerError.Code,
|
||||||
|
ErrorDescription: err.Error(),
|
||||||
}
|
}
|
||||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("AddTextAsync: Job created and saved with ID: %s", j.JobID)
|
log.Info("AddTextAsync: Job created with ID: %s", result.JobID)
|
||||||
|
|
||||||
// Step 2: Create document record immediately with job_id
|
|
||||||
err = CreateTextDocumentRecord(c.Request.Context(), &req, j.JobID)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("AddTextAsync: Failed to create document record: %v", err)
|
|
||||||
errorResp := &response.ErrorResponse{
|
|
||||||
Code: response.ErrServerError.Code,
|
|
||||||
ErrorDescription: "Failed to create document record: " + err.Error(),
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("AddTextAsync: Document record created successfully")
|
|
||||||
|
|
||||||
// Step 3: Add execution to job
|
|
||||||
jobData := map[string]interface{}{
|
|
||||||
"collection_id": req.CollectionID,
|
|
||||||
"text": req.Text,
|
|
||||||
"locale": req.Locale,
|
|
||||||
"doc_id": req.DocID,
|
|
||||||
"metadata": req.Metadata,
|
|
||||||
"chunking": req.Chunking,
|
|
||||||
"embedding": req.Embedding,
|
|
||||||
"extraction": req.Extraction,
|
|
||||||
"fetcher": req.Fetcher,
|
|
||||||
"converter": req.Converter,
|
|
||||||
}
|
|
||||||
|
|
||||||
err = j.Add(&job.ExecutionOptions{
|
|
||||||
Priority: 1,
|
|
||||||
}, "kb.documents.addtext", jobData)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("AddTextAsync: Failed to add job execution: %v", err)
|
|
||||||
// Rollback: remove document record
|
|
||||||
if config, err := kb.GetConfig(); err == nil {
|
|
||||||
config.RemoveDocument(req.DocID)
|
|
||||||
}
|
|
||||||
errorResp := &response.ErrorResponse{
|
|
||||||
Code: response.ErrServerError.Code,
|
|
||||||
ErrorDescription: "Failed to add job execution: " + err.Error(),
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Push the job to execution queue
|
|
||||||
err = j.Push()
|
|
||||||
if err != nil {
|
|
||||||
log.Error("AddTextAsync: Failed to push job: %v", err)
|
|
||||||
// Rollback: remove document record
|
|
||||||
if config, err := kb.GetConfig(); err == nil {
|
|
||||||
config.RemoveDocument(req.DocID)
|
|
||||||
}
|
|
||||||
errorResp := &response.ErrorResponse{
|
|
||||||
Code: response.ErrServerError.Code,
|
|
||||||
ErrorDescription: "Failed to push job: " + err.Error(),
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("AddTextAsync: Job pushed successfully")
|
|
||||||
|
|
||||||
// Return job_id and doc_id
|
// Return job_id and doc_id
|
||||||
response.RespondWithSuccess(c, response.StatusCreated, gin.H{
|
response.RespondWithSuccess(c, response.StatusCreated, result)
|
||||||
"job_id": j.JobID,
|
|
||||||
"doc_id": req.DocID,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessAddText documents.addtext Knowledge Base add text processor (sync version)
|
// ProcessAddText documents.addtext Knowledge Base add text processor
|
||||||
// Args[0] map: Request parameters {"collection_id": "collection", "text": "content", ...}
|
// Args[0] map: Request parameters {"collection_id": "collection", "text": "content", ...}
|
||||||
// Return: map: Response data {"doc_id": "document_id"}
|
// Return: map: Response data {"doc_id": "document_id"}
|
||||||
func ProcessAddText(process *process.Process) interface{} {
|
func ProcessAddText(process *process.Process) interface{} {
|
||||||
|
|
@ -372,96 +187,134 @@ func ProcessAddText(process *process.Process) interface{} {
|
||||||
// Get parameters
|
// Get parameters
|
||||||
reqMap := process.ArgsMap(0)
|
reqMap := process.ArgsMap(0)
|
||||||
|
|
||||||
// Check knowledge base instance
|
// Check knowledge base API
|
||||||
if kb.Instance == nil {
|
if kb.API == nil {
|
||||||
exception.New("knowledge base not initialized", 500).Throw()
|
exception.New("knowledge base API not initialized", 500).Throw()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert parameters to AddTextRequest structure
|
// Convert parameters to AddTextParams
|
||||||
req := parseAddTextRequest(reqMap)
|
params := parseAddTextParams(reqMap)
|
||||||
|
|
||||||
// Get KB config to check if document exists
|
// Get context
|
||||||
config, err := kb.GetConfig()
|
|
||||||
if err != nil {
|
|
||||||
exception.New("failed to get KB config: %s", 500, err.Error()).Throw()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if document already exists
|
|
||||||
ctx := process.Context
|
ctx := process.Context
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
}
|
}
|
||||||
|
|
||||||
existingDoc, err := config.FindDocument(req.DocID, model.QueryParam{})
|
// Call kb.API
|
||||||
if err != nil || existingDoc == nil {
|
result, err := kb.API.AddText(ctx, params)
|
||||||
// Document doesn't exist, create it first (sync scenario)
|
|
||||||
log.Info("ProcessAddText: Document %s not found, creating new record", req.DocID)
|
|
||||||
|
|
||||||
// Get job_id from request if provided (for async scenario)
|
|
||||||
var jobID string
|
|
||||||
if jid, ok := reqMap["job_id"].(string); ok {
|
|
||||||
jobID = jid
|
|
||||||
}
|
|
||||||
|
|
||||||
err = CreateTextDocumentRecord(ctx, req, jobID)
|
|
||||||
if err != nil {
|
|
||||||
exception.New("failed to create document record: %s", 500, err.Error()).Throw()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.Info("ProcessAddText: Document %s already exists, processing content only", req.DocID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process text content
|
|
||||||
err = HandleTextContent(ctx, req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exception.New("failed to process text: %s", 500, err.Error()).Throw()
|
exception.New("failed to add text: %s", 500, err.Error()).Throw()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return result
|
// Return result
|
||||||
return maps.MapStrAny{
|
return maps.MapStrAny{
|
||||||
"doc_id": req.DocID,
|
"doc_id": result.DocID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseAddTextRequest parses request map into AddTextRequest structure
|
// convertAddTextRequest converts AddTextRequest to kbapi.AddTextParams
|
||||||
func parseAddTextRequest(reqMap map[string]interface{}) *AddTextRequest {
|
func convertAddTextRequest(req *AddTextRequest, authInfo *oauthtypes.AuthorizedInfo) *kbapi.AddTextParams {
|
||||||
req := &AddTextRequest{}
|
params := &kbapi.AddTextParams{
|
||||||
|
CollectionID: req.CollectionID,
|
||||||
|
Text: req.Text,
|
||||||
|
DocID: req.DocID,
|
||||||
|
Locale: req.Locale,
|
||||||
|
Metadata: req.Metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert provider configs
|
||||||
|
if req.Chunking != nil {
|
||||||
|
params.Chunking = &kbapi.ProviderConfigParams{
|
||||||
|
ProviderID: req.Chunking.ProviderID,
|
||||||
|
OptionID: req.Chunking.OptionID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Embedding != nil {
|
||||||
|
params.Embedding = &kbapi.ProviderConfigParams{
|
||||||
|
ProviderID: req.Embedding.ProviderID,
|
||||||
|
OptionID: req.Embedding.OptionID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Extraction != nil {
|
||||||
|
params.Extraction = &kbapi.ProviderConfigParams{
|
||||||
|
ProviderID: req.Extraction.ProviderID,
|
||||||
|
OptionID: req.Extraction.OptionID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Fetcher != nil {
|
||||||
|
params.Fetcher = &kbapi.ProviderConfigParams{
|
||||||
|
ProviderID: req.Fetcher.ProviderID,
|
||||||
|
OptionID: req.Fetcher.OptionID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Converter != nil {
|
||||||
|
params.Converter = &kbapi.ProviderConfigParams{
|
||||||
|
ProviderID: req.Converter.ProviderID,
|
||||||
|
OptionID: req.Converter.OptionID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Job != nil {
|
||||||
|
params.Job = &kbapi.JobOptionsParams{
|
||||||
|
Name: req.Job.Name,
|
||||||
|
Description: req.Job.Description,
|
||||||
|
Icon: req.Job.Icon,
|
||||||
|
Category: req.Job.Category,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set auth scope
|
||||||
|
if authInfo != nil {
|
||||||
|
params.AuthScope = authInfo.WithCreateScope(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAddTextParams parses request map into kbapi.AddTextParams
|
||||||
|
func parseAddTextParams(reqMap map[string]interface{}) *kbapi.AddTextParams {
|
||||||
|
params := &kbapi.AddTextParams{}
|
||||||
|
|
||||||
// Required fields
|
// Required fields
|
||||||
if collectionID, ok := reqMap["collection_id"].(string); ok {
|
if collectionID, ok := reqMap["collection_id"].(string); ok {
|
||||||
req.CollectionID = collectionID
|
params.CollectionID = collectionID
|
||||||
} else {
|
} else {
|
||||||
exception.New("collection_id is required", 400).Throw()
|
exception.New("collection_id is required", 400).Throw()
|
||||||
}
|
}
|
||||||
|
|
||||||
if text, ok := reqMap["text"].(string); ok {
|
if text, ok := reqMap["text"].(string); ok {
|
||||||
req.Text = text
|
params.Text = text
|
||||||
} else {
|
} else {
|
||||||
exception.New("text is required", 400).Throw()
|
exception.New("text is required", 400).Throw()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional fields
|
// Optional fields
|
||||||
if locale, ok := reqMap["locale"].(string); ok {
|
if locale, ok := reqMap["locale"].(string); ok {
|
||||||
req.Locale = locale
|
params.Locale = locale
|
||||||
}
|
}
|
||||||
|
|
||||||
if docID, ok := reqMap["doc_id"].(string); ok {
|
if docID, ok := reqMap["doc_id"].(string); ok {
|
||||||
req.DocID = docID
|
params.DocID = docID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate doc_id if not provided
|
// Generate doc_id if not provided
|
||||||
if req.DocID == "" {
|
if params.DocID == "" {
|
||||||
req.DocID = utils.GenDocIDWithCollectionID(req.CollectionID)
|
params.DocID = utils.GenDocIDWithCollectionID(params.CollectionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle metadata
|
// Handle metadata
|
||||||
if metadata, ok := reqMap["metadata"].(map[string]interface{}); ok {
|
if metadata, ok := reqMap["metadata"].(map[string]interface{}); ok {
|
||||||
req.Metadata = metadata
|
params.Metadata = metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle chunking configuration
|
// Handle chunking configuration
|
||||||
if chunkingMap, ok := reqMap["chunking"].(map[string]interface{}); ok {
|
if chunkingMap, ok := reqMap["chunking"].(map[string]interface{}); ok {
|
||||||
chunking := &ProviderConfig{}
|
chunking := &kbapi.ProviderConfigParams{}
|
||||||
if providerID, ok := chunkingMap["provider_id"].(string); ok {
|
if providerID, ok := chunkingMap["provider_id"].(string); ok {
|
||||||
chunking.ProviderID = providerID
|
chunking.ProviderID = providerID
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -470,14 +323,14 @@ func parseAddTextRequest(reqMap map[string]interface{}) *AddTextRequest {
|
||||||
if optionID, ok := chunkingMap["option_id"].(string); ok {
|
if optionID, ok := chunkingMap["option_id"].(string); ok {
|
||||||
chunking.OptionID = optionID
|
chunking.OptionID = optionID
|
||||||
}
|
}
|
||||||
req.Chunking = chunking
|
params.Chunking = chunking
|
||||||
} else {
|
} else {
|
||||||
exception.New("chunking configuration is required", 400).Throw()
|
exception.New("chunking configuration is required", 400).Throw()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle embedding configuration
|
// Handle embedding configuration
|
||||||
if embeddingMap, ok := reqMap["embedding"].(map[string]interface{}); ok {
|
if embeddingMap, ok := reqMap["embedding"].(map[string]interface{}); ok {
|
||||||
embedding := &ProviderConfig{}
|
embedding := &kbapi.ProviderConfigParams{}
|
||||||
if providerID, ok := embeddingMap["provider_id"].(string); ok {
|
if providerID, ok := embeddingMap["provider_id"].(string); ok {
|
||||||
embedding.ProviderID = providerID
|
embedding.ProviderID = providerID
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -486,50 +339,50 @@ func parseAddTextRequest(reqMap map[string]interface{}) *AddTextRequest {
|
||||||
if optionID, ok := embeddingMap["option_id"].(string); ok {
|
if optionID, ok := embeddingMap["option_id"].(string); ok {
|
||||||
embedding.OptionID = optionID
|
embedding.OptionID = optionID
|
||||||
}
|
}
|
||||||
req.Embedding = embedding
|
params.Embedding = embedding
|
||||||
} else {
|
} else {
|
||||||
exception.New("embedding configuration is required", 400).Throw()
|
exception.New("embedding configuration is required", 400).Throw()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle optional extraction configuration
|
// Handle optional extraction configuration
|
||||||
if extractionMap, ok := reqMap["extraction"].(map[string]interface{}); ok {
|
if extractionMap, ok := reqMap["extraction"].(map[string]interface{}); ok {
|
||||||
extraction := &ProviderConfig{}
|
extraction := &kbapi.ProviderConfigParams{}
|
||||||
if providerID, ok := extractionMap["provider_id"].(string); ok {
|
if providerID, ok := extractionMap["provider_id"].(string); ok {
|
||||||
extraction.ProviderID = providerID
|
extraction.ProviderID = providerID
|
||||||
}
|
}
|
||||||
if optionID, ok := extractionMap["option_id"].(string); ok {
|
if optionID, ok := extractionMap["option_id"].(string); ok {
|
||||||
extraction.OptionID = optionID
|
extraction.OptionID = optionID
|
||||||
}
|
}
|
||||||
req.Extraction = extraction
|
params.Extraction = extraction
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle optional fetcher configuration
|
// Handle optional fetcher configuration
|
||||||
if fetcherMap, ok := reqMap["fetcher"].(map[string]interface{}); ok {
|
if fetcherMap, ok := reqMap["fetcher"].(map[string]interface{}); ok {
|
||||||
fetcher := &ProviderConfig{}
|
fetcher := &kbapi.ProviderConfigParams{}
|
||||||
if providerID, ok := fetcherMap["provider_id"].(string); ok {
|
if providerID, ok := fetcherMap["provider_id"].(string); ok {
|
||||||
fetcher.ProviderID = providerID
|
fetcher.ProviderID = providerID
|
||||||
}
|
}
|
||||||
if optionID, ok := fetcherMap["option_id"].(string); ok {
|
if optionID, ok := fetcherMap["option_id"].(string); ok {
|
||||||
fetcher.OptionID = optionID
|
fetcher.OptionID = optionID
|
||||||
}
|
}
|
||||||
req.Fetcher = fetcher
|
params.Fetcher = fetcher
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle optional converter configuration
|
// Handle optional converter configuration
|
||||||
if converterMap, ok := reqMap["converter"].(map[string]interface{}); ok {
|
if converterMap, ok := reqMap["converter"].(map[string]interface{}); ok {
|
||||||
converter := &ProviderConfig{}
|
converter := &kbapi.ProviderConfigParams{}
|
||||||
if providerID, ok := converterMap["provider_id"].(string); ok {
|
if providerID, ok := converterMap["provider_id"].(string); ok {
|
||||||
converter.ProviderID = providerID
|
converter.ProviderID = providerID
|
||||||
}
|
}
|
||||||
if optionID, ok := converterMap["option_id"].(string); ok {
|
if optionID, ok := converterMap["option_id"].(string); ok {
|
||||||
converter.OptionID = optionID
|
converter.OptionID = optionID
|
||||||
}
|
}
|
||||||
req.Converter = converter
|
params.Converter = converter
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle job options
|
// Handle job options
|
||||||
if jobMap, ok := reqMap["job"].(map[string]interface{}); ok {
|
if jobMap, ok := reqMap["job"].(map[string]interface{}); ok {
|
||||||
job := &JobOptions{}
|
job := &kbapi.JobOptionsParams{}
|
||||||
if name, ok := jobMap["name"].(string); ok {
|
if name, ok := jobMap["name"].(string); ok {
|
||||||
job.Name = name
|
job.Name = name
|
||||||
}
|
}
|
||||||
|
|
@ -542,8 +395,8 @@ func parseAddTextRequest(reqMap map[string]interface{}) *AddTextRequest {
|
||||||
if category, ok := jobMap["category"].(string); ok {
|
if category, ok := jobMap["category"].(string); ok {
|
||||||
job.Category = category
|
job.Category = category
|
||||||
}
|
}
|
||||||
req.Job = job
|
params.Job = job
|
||||||
}
|
}
|
||||||
|
|
||||||
return req
|
return params
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,179 +2,26 @@ package kb
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/yaoapp/gou/graphrag/utils"
|
"github.com/yaoapp/gou/graphrag/utils"
|
||||||
"github.com/yaoapp/gou/model"
|
|
||||||
"github.com/yaoapp/gou/process"
|
"github.com/yaoapp/gou/process"
|
||||||
"github.com/yaoapp/kun/exception"
|
"github.com/yaoapp/kun/exception"
|
||||||
"github.com/yaoapp/kun/log"
|
"github.com/yaoapp/kun/log"
|
||||||
"github.com/yaoapp/kun/maps"
|
"github.com/yaoapp/kun/maps"
|
||||||
"github.com/yaoapp/yao/job"
|
|
||||||
"github.com/yaoapp/yao/kb"
|
"github.com/yaoapp/yao/kb"
|
||||||
|
kbapi "github.com/yaoapp/yao/kb/api"
|
||||||
|
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
||||||
|
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
"github.com/yaoapp/yao/openapi/response"
|
"github.com/yaoapp/yao/openapi/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateURLDocumentRecord creates a URL document record in the database immediately
|
// AddURL adds a URL to a collection (sync)
|
||||||
// This is called synchronously when the API request comes in
|
|
||||||
func CreateURLDocumentRecord(ctx context.Context, req *AddURLRequest, jobID string) error {
|
|
||||||
// Check if kb.Instance is available
|
|
||||||
if kb.Instance == nil {
|
|
||||||
return fmt.Errorf("knowledge base not initialized")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get KB config
|
|
||||||
config, err := kb.GetConfig()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get KB config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare document data for database
|
|
||||||
documentData := map[string]interface{}{
|
|
||||||
"document_id": req.DocID,
|
|
||||||
"collection_id": req.CollectionID,
|
|
||||||
"name": "URL Document",
|
|
||||||
"type": "url",
|
|
||||||
"status": "pending",
|
|
||||||
"url": req.URL,
|
|
||||||
"job_id": jobID,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use title from metadata if available
|
|
||||||
if req.Metadata != nil {
|
|
||||||
if title, ok := req.Metadata["title"].(string); ok && title != "" {
|
|
||||||
documentData["name"] = title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add base request fields
|
|
||||||
req.BaseUpsertRequest.AddBaseFields(documentData)
|
|
||||||
|
|
||||||
// Create database record
|
|
||||||
_, err = config.CreateDocument(maps.MapStrAny(documentData))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to save document metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleURLContent processes the actual URL content and updates the knowledge base
|
|
||||||
// This is called asynchronously by the job system
|
|
||||||
func HandleURLContent(ctx context.Context, req *AddURLRequest) error {
|
|
||||||
// Check if kb.Instance is available
|
|
||||||
if kb.Instance == nil {
|
|
||||||
return fmt.Errorf("knowledge base not initialized")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get KB config
|
|
||||||
config, err := kb.GetConfig()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get KB config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert request to UpsertOptions
|
|
||||||
upsertOptions, err := req.BaseUpsertRequest.ToUpsertOptions()
|
|
||||||
if err != nil {
|
|
||||||
// Update status to error
|
|
||||||
config.UpdateDocument(req.DocID, maps.MapStrAny{"status": "error", "error_message": err.Error()})
|
|
||||||
return fmt.Errorf("failed to convert request to upsert options: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform upsert operation with URL
|
|
||||||
_, err = kb.Instance.AddURL(ctx, req.URL, upsertOptions)
|
|
||||||
if err != nil {
|
|
||||||
// Update status to error
|
|
||||||
config.UpdateDocument(req.DocID, maps.MapStrAny{"status": "error", "error_message": err.Error()})
|
|
||||||
return fmt.Errorf("failed to add URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update status to completed after successful processing
|
|
||||||
if err := config.UpdateDocument(req.DocID, maps.MapStrAny{"status": "completed"}); err != nil {
|
|
||||||
log.Error("Failed to update document status to completed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update segment count for the document
|
|
||||||
if segmentCount, err := kb.Instance.SegmentCount(ctx, req.DocID); err != nil {
|
|
||||||
log.Error("Failed to get segment count for document %s: %v", req.DocID, err)
|
|
||||||
} else {
|
|
||||||
log.Info("Got segment count %d for document %s", segmentCount, req.DocID)
|
|
||||||
if err := config.UpdateSegmentCount(req.DocID, segmentCount); err != nil {
|
|
||||||
log.Error("Failed to update segment count for document %s: %v", req.DocID, err)
|
|
||||||
} else {
|
|
||||||
log.Info("Successfully updated segment count to %d for document %s", segmentCount, req.DocID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update document count for the collection and sync to GraphRag
|
|
||||||
if err := UpdateDocumentCountWithSync(req.CollectionID, config); err != nil {
|
|
||||||
log.Error("Failed to update document count for collection %s: %v", req.CollectionID, err)
|
|
||||||
} else {
|
|
||||||
log.Info("Successfully updated document count for collection %s", req.CollectionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddURLHandler processes a URL addition request with business logic only
|
|
||||||
// This function combines both document creation and content processing for sync operations
|
|
||||||
func AddURLHandler(ctx context.Context, req *AddURLRequest, jobID ...string) error {
|
|
||||||
// Validate request
|
|
||||||
if err := req.Validate(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// DocID should be generated by the caller before calling this function
|
|
||||||
if req.DocID == "" {
|
|
||||||
return fmt.Errorf("document ID is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
// For sync operations, create document record and process content immediately
|
|
||||||
var jid string
|
|
||||||
if len(jobID) > 0 {
|
|
||||||
jid = jobID[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create document record
|
|
||||||
if err := CreateURLDocumentRecord(ctx, req, jid); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process URL content
|
|
||||||
return HandleURLContent(ctx, req)
|
|
||||||
}
|
|
||||||
|
|
||||||
// addURLWithRequest processes a URL addition with pre-parsed request using Gin context
|
|
||||||
func addURLWithRequest(c *gin.Context, req *AddURLRequest) {
|
|
||||||
// Use the business logic function
|
|
||||||
err := AddURLHandler(c.Request.Context(), req)
|
|
||||||
if err != nil {
|
|
||||||
errorResp := &response.ErrorResponse{
|
|
||||||
Code: response.ErrServerError.Code,
|
|
||||||
ErrorDescription: err.Error(),
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return success response
|
|
||||||
result := gin.H{
|
|
||||||
"message": "URL added successfully",
|
|
||||||
"collection_id": req.CollectionID,
|
|
||||||
"url": req.URL,
|
|
||||||
"doc_id": req.DocID,
|
|
||||||
}
|
|
||||||
|
|
||||||
response.RespondWithSuccess(c, response.StatusCreated, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddURL adds a URL to a collection
|
|
||||||
func AddURL(c *gin.Context) {
|
func AddURL(c *gin.Context) {
|
||||||
var req AddURLRequest
|
var req AddURLRequest
|
||||||
|
|
||||||
// Check if kb.Instance is available
|
// Check if kb.API is available
|
||||||
if !checkKBInstance(c) {
|
if !checkKBAPI(c) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,8 +50,44 @@ func AddURL(c *gin.Context) {
|
||||||
req.DocID = utils.GenDocIDWithCollectionID(req.CollectionID)
|
req.DocID = utils.GenDocIDWithCollectionID(req.CollectionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the request
|
// Check collection permission
|
||||||
addURLWithRequest(c, &req)
|
authInfo := authorized.GetInfo(c)
|
||||||
|
hasPermission, err := checkCollectionPermission(authInfo, req.CollectionID)
|
||||||
|
if err != nil {
|
||||||
|
errorResp := &response.ErrorResponse{
|
||||||
|
Code: response.ErrServerError.Code,
|
||||||
|
ErrorDescription: err.Error(),
|
||||||
|
}
|
||||||
|
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 403 Forbidden
|
||||||
|
if !hasPermission {
|
||||||
|
errorResp := &response.ErrorResponse{
|
||||||
|
Code: response.ErrAccessDenied.Code,
|
||||||
|
ErrorDescription: "Forbidden: No permission to update collection",
|
||||||
|
}
|
||||||
|
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert request to API params
|
||||||
|
params := convertAddURLRequest(&req, authInfo)
|
||||||
|
|
||||||
|
// Call kb.API
|
||||||
|
result, err := kb.API.AddURL(c.Request.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
errorResp := &response.ErrorResponse{
|
||||||
|
Code: response.ErrServerError.Code,
|
||||||
|
ErrorDescription: err.Error(),
|
||||||
|
}
|
||||||
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return success response
|
||||||
|
response.RespondWithSuccess(c, response.StatusCreated, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddURLAsync adds a URL to a collection asynchronously
|
// AddURLAsync adds a URL to a collection asynchronously
|
||||||
|
|
@ -213,9 +96,9 @@ func AddURLAsync(c *gin.Context) {
|
||||||
|
|
||||||
log.Info("AddURLAsync: Starting async URL addition")
|
log.Info("AddURLAsync: Starting async URL addition")
|
||||||
|
|
||||||
// Check if kb.Instance is available
|
// Check if kb.API is available
|
||||||
if !checkKBInstance(c) {
|
if !checkKBAPI(c) {
|
||||||
log.Error("AddURLAsync: KB instance check failed")
|
log.Error("AddURLAsync: KB API check failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,7 +113,7 @@ func AddURLAsync(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("AddURLAsync: Request parsed successfully: %+v", req)
|
log.Info("AddURLAsync: Request parsed successfully")
|
||||||
|
|
||||||
// Validate request
|
// Validate request
|
||||||
if err := req.Validate(); err != nil {
|
if err := req.Validate(); err != nil {
|
||||||
|
|
@ -245,15 +128,6 @@ func AddURLAsync(c *gin.Context) {
|
||||||
|
|
||||||
log.Info("AddURLAsync: Request validation passed")
|
log.Info("AddURLAsync: Request validation passed")
|
||||||
|
|
||||||
// Convert request to UpsertOptions (just for validation)
|
|
||||||
_, err := getUpsertOptions(c, &req.BaseUpsertRequest)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("AddURLAsync: UpsertOptions validation failed: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("AddURLAsync: UpsertOptions validation passed")
|
|
||||||
|
|
||||||
// Generate document ID if not provided
|
// Generate document ID if not provided
|
||||||
if req.DocID == "" {
|
if req.DocID == "" {
|
||||||
req.DocID = utils.GenDocIDWithCollectionID(req.CollectionID)
|
req.DocID = utils.GenDocIDWithCollectionID(req.CollectionID)
|
||||||
|
|
@ -261,109 +135,50 @@ func AddURLAsync(c *gin.Context) {
|
||||||
|
|
||||||
log.Info("AddURLAsync: Generated doc_id: %s", req.DocID)
|
log.Info("AddURLAsync: Generated doc_id: %s", req.DocID)
|
||||||
|
|
||||||
// Step 1: Get job options with defaults
|
// Check collection permission
|
||||||
jobName, jobDescription, jobIcon, jobCategory := req.GetJobOptions(
|
authInfo := authorized.GetInfo(c)
|
||||||
"Knowledge Base Web Content Processing", // default name
|
hasPermission, err := checkCollectionPermission(authInfo, req.CollectionID)
|
||||||
"Fetching and indexing web content for knowledge base search", // default description
|
|
||||||
"library_add", // default icon (Material Icon)
|
|
||||||
"Knowledge Base", // default category
|
|
||||||
)
|
|
||||||
|
|
||||||
// Create job data
|
|
||||||
jobCreateData := map[string]interface{}{
|
|
||||||
"name": jobName,
|
|
||||||
"description": jobDescription,
|
|
||||||
"category_name": jobCategory, // Pass category name directly, let SaveJob handle it
|
|
||||||
}
|
|
||||||
if jobIcon != "" {
|
|
||||||
jobCreateData["icon"] = jobIcon
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create and save Job in one step to get JobID
|
|
||||||
j, err := job.OnceAndSave(job.GOROUTINE, jobCreateData)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("AddURLAsync: Job creation and save failed: %v", err)
|
|
||||||
errorResp := &response.ErrorResponse{
|
errorResp := &response.ErrorResponse{
|
||||||
Code: response.ErrServerError.Code,
|
Code: response.ErrServerError.Code,
|
||||||
ErrorDescription: "Failed to create and save job: " + err.Error(),
|
ErrorDescription: err.Error(),
|
||||||
|
}
|
||||||
|
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 403 Forbidden
|
||||||
|
if !hasPermission {
|
||||||
|
errorResp := &response.ErrorResponse{
|
||||||
|
Code: response.ErrAccessDenied.Code,
|
||||||
|
ErrorDescription: "Forbidden: No permission to update collection",
|
||||||
|
}
|
||||||
|
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert request to API params
|
||||||
|
params := convertAddURLRequest(&req, authInfo)
|
||||||
|
|
||||||
|
// Call kb.API async
|
||||||
|
result, err := kb.API.AddURLAsync(c.Request.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("AddURLAsync: Failed to add URL async: %v", err)
|
||||||
|
errorResp := &response.ErrorResponse{
|
||||||
|
Code: response.ErrServerError.Code,
|
||||||
|
ErrorDescription: err.Error(),
|
||||||
}
|
}
|
||||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("AddURLAsync: Job created and saved with ID: %s", j.JobID)
|
log.Info("AddURLAsync: Job created with ID: %s", result.JobID)
|
||||||
|
|
||||||
// Step 2: Create document record immediately with job_id
|
|
||||||
err = CreateURLDocumentRecord(c.Request.Context(), &req, j.JobID)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("AddURLAsync: Failed to create document record: %v", err)
|
|
||||||
errorResp := &response.ErrorResponse{
|
|
||||||
Code: response.ErrServerError.Code,
|
|
||||||
ErrorDescription: "Failed to create document record: " + err.Error(),
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("AddURLAsync: Document record created successfully")
|
|
||||||
|
|
||||||
// Step 3: Add execution to job
|
|
||||||
jobData := map[string]interface{}{
|
|
||||||
"collection_id": req.CollectionID,
|
|
||||||
"url": req.URL,
|
|
||||||
"locale": req.Locale,
|
|
||||||
"doc_id": req.DocID,
|
|
||||||
"metadata": req.Metadata,
|
|
||||||
"chunking": req.Chunking,
|
|
||||||
"embedding": req.Embedding,
|
|
||||||
"extraction": req.Extraction,
|
|
||||||
"fetcher": req.Fetcher,
|
|
||||||
"converter": req.Converter,
|
|
||||||
}
|
|
||||||
|
|
||||||
err = j.Add(&job.ExecutionOptions{
|
|
||||||
Priority: 1,
|
|
||||||
}, "kb.documents.addurl", jobData)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("AddURLAsync: Failed to add job execution: %v", err)
|
|
||||||
// Rollback: remove document record
|
|
||||||
if config, err := kb.GetConfig(); err == nil {
|
|
||||||
config.RemoveDocument(req.DocID)
|
|
||||||
}
|
|
||||||
errorResp := &response.ErrorResponse{
|
|
||||||
Code: response.ErrServerError.Code,
|
|
||||||
ErrorDescription: "Failed to add job execution: " + err.Error(),
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Push the job to execution queue
|
|
||||||
err = j.Push()
|
|
||||||
if err != nil {
|
|
||||||
log.Error("AddURLAsync: Failed to push job: %v", err)
|
|
||||||
// Rollback: remove document record
|
|
||||||
if config, err := kb.GetConfig(); err == nil {
|
|
||||||
config.RemoveDocument(req.DocID)
|
|
||||||
}
|
|
||||||
errorResp := &response.ErrorResponse{
|
|
||||||
Code: response.ErrServerError.Code,
|
|
||||||
ErrorDescription: "Failed to push job: " + err.Error(),
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("AddURLAsync: Job pushed successfully")
|
|
||||||
|
|
||||||
// Return job_id and doc_id
|
// Return job_id and doc_id
|
||||||
response.RespondWithSuccess(c, response.StatusCreated, gin.H{
|
response.RespondWithSuccess(c, response.StatusCreated, result)
|
||||||
"job_id": j.JobID,
|
|
||||||
"doc_id": req.DocID,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessAddURL documents.addurl Knowledge Base add URL processor (sync version)
|
// ProcessAddURL documents.addurl Knowledge Base add URL processor
|
||||||
// Args[0] map: Request parameters {"collection_id": "collection", "url": "https://example.com", ...}
|
// Args[0] map: Request parameters {"collection_id": "collection", "url": "https://example.com", ...}
|
||||||
// Return: map: Response data {"doc_id": "document_id"}
|
// Return: map: Response data {"doc_id": "document_id"}
|
||||||
func ProcessAddURL(process *process.Process) interface{} {
|
func ProcessAddURL(process *process.Process) interface{} {
|
||||||
|
|
@ -372,96 +187,134 @@ func ProcessAddURL(process *process.Process) interface{} {
|
||||||
// Get parameters
|
// Get parameters
|
||||||
reqMap := process.ArgsMap(0)
|
reqMap := process.ArgsMap(0)
|
||||||
|
|
||||||
// Check knowledge base instance
|
// Check knowledge base API
|
||||||
if kb.Instance == nil {
|
if kb.API == nil {
|
||||||
exception.New("knowledge base not initialized", 500).Throw()
|
exception.New("knowledge base API not initialized", 500).Throw()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert parameters to AddURLRequest structure
|
// Convert parameters to AddURLParams
|
||||||
req := parseAddURLRequest(reqMap)
|
params := parseAddURLParams(reqMap)
|
||||||
|
|
||||||
// Get KB config to check if document exists
|
// Get context
|
||||||
config, err := kb.GetConfig()
|
|
||||||
if err != nil {
|
|
||||||
exception.New("failed to get KB config: %s", 500, err.Error()).Throw()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if document already exists
|
|
||||||
ctx := process.Context
|
ctx := process.Context
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
}
|
}
|
||||||
|
|
||||||
existingDoc, err := config.FindDocument(req.DocID, model.QueryParam{})
|
// Call kb.API
|
||||||
if err != nil || existingDoc == nil {
|
result, err := kb.API.AddURL(ctx, params)
|
||||||
// Document doesn't exist, create it first (sync scenario)
|
|
||||||
log.Info("ProcessAddURL: Document %s not found, creating new record", req.DocID)
|
|
||||||
|
|
||||||
// Get job_id from request if provided (for async scenario)
|
|
||||||
var jobID string
|
|
||||||
if jid, ok := reqMap["job_id"].(string); ok {
|
|
||||||
jobID = jid
|
|
||||||
}
|
|
||||||
|
|
||||||
err = CreateURLDocumentRecord(ctx, req, jobID)
|
|
||||||
if err != nil {
|
|
||||||
exception.New("failed to create document record: %s", 500, err.Error()).Throw()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.Info("ProcessAddURL: Document %s already exists, processing content only", req.DocID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process URL content
|
|
||||||
err = HandleURLContent(ctx, req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exception.New("failed to process URL: %s", 500, err.Error()).Throw()
|
exception.New("failed to add URL: %s", 500, err.Error()).Throw()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return result
|
// Return result
|
||||||
return maps.MapStrAny{
|
return maps.MapStrAny{
|
||||||
"doc_id": req.DocID,
|
"doc_id": result.DocID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseAddURLRequest parses request map into AddURLRequest structure
|
// convertAddURLRequest converts AddURLRequest to kbapi.AddURLParams
|
||||||
func parseAddURLRequest(reqMap map[string]interface{}) *AddURLRequest {
|
func convertAddURLRequest(req *AddURLRequest, authInfo *oauthtypes.AuthorizedInfo) *kbapi.AddURLParams {
|
||||||
req := &AddURLRequest{}
|
params := &kbapi.AddURLParams{
|
||||||
|
CollectionID: req.CollectionID,
|
||||||
|
URL: req.URL,
|
||||||
|
DocID: req.DocID,
|
||||||
|
Locale: req.Locale,
|
||||||
|
Metadata: req.Metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert provider configs
|
||||||
|
if req.Chunking != nil {
|
||||||
|
params.Chunking = &kbapi.ProviderConfigParams{
|
||||||
|
ProviderID: req.Chunking.ProviderID,
|
||||||
|
OptionID: req.Chunking.OptionID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Embedding != nil {
|
||||||
|
params.Embedding = &kbapi.ProviderConfigParams{
|
||||||
|
ProviderID: req.Embedding.ProviderID,
|
||||||
|
OptionID: req.Embedding.OptionID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Extraction != nil {
|
||||||
|
params.Extraction = &kbapi.ProviderConfigParams{
|
||||||
|
ProviderID: req.Extraction.ProviderID,
|
||||||
|
OptionID: req.Extraction.OptionID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Fetcher != nil {
|
||||||
|
params.Fetcher = &kbapi.ProviderConfigParams{
|
||||||
|
ProviderID: req.Fetcher.ProviderID,
|
||||||
|
OptionID: req.Fetcher.OptionID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Converter != nil {
|
||||||
|
params.Converter = &kbapi.ProviderConfigParams{
|
||||||
|
ProviderID: req.Converter.ProviderID,
|
||||||
|
OptionID: req.Converter.OptionID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Job != nil {
|
||||||
|
params.Job = &kbapi.JobOptionsParams{
|
||||||
|
Name: req.Job.Name,
|
||||||
|
Description: req.Job.Description,
|
||||||
|
Icon: req.Job.Icon,
|
||||||
|
Category: req.Job.Category,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set auth scope
|
||||||
|
if authInfo != nil {
|
||||||
|
params.AuthScope = authInfo.WithCreateScope(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAddURLParams parses request map into kbapi.AddURLParams
|
||||||
|
func parseAddURLParams(reqMap map[string]interface{}) *kbapi.AddURLParams {
|
||||||
|
params := &kbapi.AddURLParams{}
|
||||||
|
|
||||||
// Required fields
|
// Required fields
|
||||||
if collectionID, ok := reqMap["collection_id"].(string); ok {
|
if collectionID, ok := reqMap["collection_id"].(string); ok {
|
||||||
req.CollectionID = collectionID
|
params.CollectionID = collectionID
|
||||||
} else {
|
} else {
|
||||||
exception.New("collection_id is required", 400).Throw()
|
exception.New("collection_id is required", 400).Throw()
|
||||||
}
|
}
|
||||||
|
|
||||||
if url, ok := reqMap["url"].(string); ok {
|
if url, ok := reqMap["url"].(string); ok {
|
||||||
req.URL = url
|
params.URL = url
|
||||||
} else {
|
} else {
|
||||||
exception.New("url is required", 400).Throw()
|
exception.New("url is required", 400).Throw()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional fields
|
// Optional fields
|
||||||
if locale, ok := reqMap["locale"].(string); ok {
|
if locale, ok := reqMap["locale"].(string); ok {
|
||||||
req.Locale = locale
|
params.Locale = locale
|
||||||
}
|
}
|
||||||
|
|
||||||
if docID, ok := reqMap["doc_id"].(string); ok {
|
if docID, ok := reqMap["doc_id"].(string); ok {
|
||||||
req.DocID = docID
|
params.DocID = docID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate doc_id if not provided
|
// Generate doc_id if not provided
|
||||||
if req.DocID == "" {
|
if params.DocID == "" {
|
||||||
req.DocID = utils.GenDocIDWithCollectionID(req.CollectionID)
|
params.DocID = utils.GenDocIDWithCollectionID(params.CollectionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle metadata
|
// Handle metadata
|
||||||
if metadata, ok := reqMap["metadata"].(map[string]interface{}); ok {
|
if metadata, ok := reqMap["metadata"].(map[string]interface{}); ok {
|
||||||
req.Metadata = metadata
|
params.Metadata = metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle chunking configuration
|
// Handle chunking configuration
|
||||||
if chunkingMap, ok := reqMap["chunking"].(map[string]interface{}); ok {
|
if chunkingMap, ok := reqMap["chunking"].(map[string]interface{}); ok {
|
||||||
chunking := &ProviderConfig{}
|
chunking := &kbapi.ProviderConfigParams{}
|
||||||
if providerID, ok := chunkingMap["provider_id"].(string); ok {
|
if providerID, ok := chunkingMap["provider_id"].(string); ok {
|
||||||
chunking.ProviderID = providerID
|
chunking.ProviderID = providerID
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -470,14 +323,14 @@ func parseAddURLRequest(reqMap map[string]interface{}) *AddURLRequest {
|
||||||
if optionID, ok := chunkingMap["option_id"].(string); ok {
|
if optionID, ok := chunkingMap["option_id"].(string); ok {
|
||||||
chunking.OptionID = optionID
|
chunking.OptionID = optionID
|
||||||
}
|
}
|
||||||
req.Chunking = chunking
|
params.Chunking = chunking
|
||||||
} else {
|
} else {
|
||||||
exception.New("chunking configuration is required", 400).Throw()
|
exception.New("chunking configuration is required", 400).Throw()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle embedding configuration
|
// Handle embedding configuration
|
||||||
if embeddingMap, ok := reqMap["embedding"].(map[string]interface{}); ok {
|
if embeddingMap, ok := reqMap["embedding"].(map[string]interface{}); ok {
|
||||||
embedding := &ProviderConfig{}
|
embedding := &kbapi.ProviderConfigParams{}
|
||||||
if providerID, ok := embeddingMap["provider_id"].(string); ok {
|
if providerID, ok := embeddingMap["provider_id"].(string); ok {
|
||||||
embedding.ProviderID = providerID
|
embedding.ProviderID = providerID
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -486,50 +339,50 @@ func parseAddURLRequest(reqMap map[string]interface{}) *AddURLRequest {
|
||||||
if optionID, ok := embeddingMap["option_id"].(string); ok {
|
if optionID, ok := embeddingMap["option_id"].(string); ok {
|
||||||
embedding.OptionID = optionID
|
embedding.OptionID = optionID
|
||||||
}
|
}
|
||||||
req.Embedding = embedding
|
params.Embedding = embedding
|
||||||
} else {
|
} else {
|
||||||
exception.New("embedding configuration is required", 400).Throw()
|
exception.New("embedding configuration is required", 400).Throw()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle optional extraction configuration
|
// Handle optional extraction configuration
|
||||||
if extractionMap, ok := reqMap["extraction"].(map[string]interface{}); ok {
|
if extractionMap, ok := reqMap["extraction"].(map[string]interface{}); ok {
|
||||||
extraction := &ProviderConfig{}
|
extraction := &kbapi.ProviderConfigParams{}
|
||||||
if providerID, ok := extractionMap["provider_id"].(string); ok {
|
if providerID, ok := extractionMap["provider_id"].(string); ok {
|
||||||
extraction.ProviderID = providerID
|
extraction.ProviderID = providerID
|
||||||
}
|
}
|
||||||
if optionID, ok := extractionMap["option_id"].(string); ok {
|
if optionID, ok := extractionMap["option_id"].(string); ok {
|
||||||
extraction.OptionID = optionID
|
extraction.OptionID = optionID
|
||||||
}
|
}
|
||||||
req.Extraction = extraction
|
params.Extraction = extraction
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle optional fetcher configuration
|
// Handle optional fetcher configuration
|
||||||
if fetcherMap, ok := reqMap["fetcher"].(map[string]interface{}); ok {
|
if fetcherMap, ok := reqMap["fetcher"].(map[string]interface{}); ok {
|
||||||
fetcher := &ProviderConfig{}
|
fetcher := &kbapi.ProviderConfigParams{}
|
||||||
if providerID, ok := fetcherMap["provider_id"].(string); ok {
|
if providerID, ok := fetcherMap["provider_id"].(string); ok {
|
||||||
fetcher.ProviderID = providerID
|
fetcher.ProviderID = providerID
|
||||||
}
|
}
|
||||||
if optionID, ok := fetcherMap["option_id"].(string); ok {
|
if optionID, ok := fetcherMap["option_id"].(string); ok {
|
||||||
fetcher.OptionID = optionID
|
fetcher.OptionID = optionID
|
||||||
}
|
}
|
||||||
req.Fetcher = fetcher
|
params.Fetcher = fetcher
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle optional converter configuration
|
// Handle optional converter configuration
|
||||||
if converterMap, ok := reqMap["converter"].(map[string]interface{}); ok {
|
if converterMap, ok := reqMap["converter"].(map[string]interface{}); ok {
|
||||||
converter := &ProviderConfig{}
|
converter := &kbapi.ProviderConfigParams{}
|
||||||
if providerID, ok := converterMap["provider_id"].(string); ok {
|
if providerID, ok := converterMap["provider_id"].(string); ok {
|
||||||
converter.ProviderID = providerID
|
converter.ProviderID = providerID
|
||||||
}
|
}
|
||||||
if optionID, ok := converterMap["option_id"].(string); ok {
|
if optionID, ok := converterMap["option_id"].(string); ok {
|
||||||
converter.OptionID = optionID
|
converter.OptionID = optionID
|
||||||
}
|
}
|
||||||
req.Converter = converter
|
params.Converter = converter
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle job options
|
// Handle job options
|
||||||
if jobMap, ok := reqMap["job"].(map[string]interface{}); ok {
|
if jobMap, ok := reqMap["job"].(map[string]interface{}); ok {
|
||||||
job := &JobOptions{}
|
job := &kbapi.JobOptionsParams{}
|
||||||
if name, ok := jobMap["name"].(string); ok {
|
if name, ok := jobMap["name"].(string); ok {
|
||||||
job.Name = name
|
job.Name = name
|
||||||
}
|
}
|
||||||
|
|
@ -542,8 +395,8 @@ func parseAddURLRequest(reqMap map[string]interface{}) *AddURLRequest {
|
||||||
if category, ok := jobMap["category"].(string); ok {
|
if category, ok := jobMap["category"].(string); ok {
|
||||||
job.Category = category
|
job.Category = category
|
||||||
}
|
}
|
||||||
req.Job = job
|
params.Job = job
|
||||||
}
|
}
|
||||||
|
|
||||||
return req
|
return params
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,59 +6,19 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/yaoapp/gou/graphrag/types"
|
|
||||||
kbutils "github.com/yaoapp/gou/graphrag/utils"
|
|
||||||
"github.com/yaoapp/gou/model"
|
"github.com/yaoapp/gou/model"
|
||||||
"github.com/yaoapp/yao/attachment"
|
|
||||||
"github.com/yaoapp/yao/kb"
|
"github.com/yaoapp/yao/kb"
|
||||||
|
kbapi "github.com/yaoapp/yao/kb/api"
|
||||||
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
||||||
"github.com/yaoapp/yao/openapi/response"
|
"github.com/yaoapp/yao/openapi/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Document field definitions
|
|
||||||
var (
|
|
||||||
// availableDocumentFields defines all available fields for security filtering
|
|
||||||
availableDocumentFields = map[string]bool{
|
|
||||||
"id": true, "document_id": true, "collection_id": true, "name": true,
|
|
||||||
"description": true, "status": true, "type": true, "size": true,
|
|
||||||
"segment_count": true, "job_id": true, "uploader_id": true, "tags": true,
|
|
||||||
"locale": true, "system": true, "readonly": true, "sort": true, "cover": true,
|
|
||||||
"file_id": true, "file_name": true, "file_mime_type": true,
|
|
||||||
"url": true, "url_title": true, "text_content": true,
|
|
||||||
"converter_provider_id": true, "converter_option_id": true, "converter_properties": true,
|
|
||||||
"fetcher_provider_id": true, "fetcher_option_id": true, "fetcher_properties": true,
|
|
||||||
"chunking_provider_id": true, "chunking_option_id": true, "chunking_properties": true,
|
|
||||||
"extraction_provider_id": true, "extraction_option_id": true, "extraction_properties": true,
|
|
||||||
"processed_at": true, "error_message": true, "created_at": true, "updated_at": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// defaultDocumentFields defines the default compact field list
|
|
||||||
defaultDocumentFields = []interface{}{
|
|
||||||
"id", "document_id", "collection_id", "name", "description",
|
|
||||||
"cover", "tags", "type", "size", "segment_count", "status", "locale",
|
|
||||||
"system", "readonly", "file_id", "file_name", "file_mime_type", "uploader_id",
|
|
||||||
"url", "url_title", "text_content", // 添加 URL 和文本内容字段
|
|
||||||
"error_message", "created_at", "updated_at",
|
|
||||||
}
|
|
||||||
|
|
||||||
// validSortFields defines valid fields for sorting
|
|
||||||
validSortFields = map[string]bool{
|
|
||||||
"created_at": true,
|
|
||||||
"updated_at": true,
|
|
||||||
"name": true,
|
|
||||||
"size": true,
|
|
||||||
"segment_count": true,
|
|
||||||
"sort": true,
|
|
||||||
"processed_at": true,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Document Management Handlers
|
// Document Management Handlers
|
||||||
|
|
||||||
// ListDocuments lists documents with pagination
|
// ListDocuments lists documents with pagination
|
||||||
func ListDocuments(c *gin.Context) {
|
func ListDocuments(c *gin.Context) {
|
||||||
// Check if kb.Instance is available
|
// Check if kb.API is available
|
||||||
if !checkKBInstance(c) {
|
if !checkKBAPI(c) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,70 +37,39 @@ func ListDocuments(c *gin.Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get KB instance and config
|
|
||||||
kbInstance := kb.Instance.(*kb.KnowledgeBase)
|
|
||||||
config := kbInstance.Config
|
|
||||||
|
|
||||||
// Parse select parameter
|
// Parse select parameter
|
||||||
var selectFields []interface{}
|
var selectFields []interface{}
|
||||||
if selectParam := strings.TrimSpace(c.Query("select")); selectParam != "" {
|
if selectParam := strings.TrimSpace(c.Query("select")); selectParam != "" {
|
||||||
requestedFields := strings.Split(selectParam, ",")
|
requestedFields := strings.Split(selectParam, ",")
|
||||||
for _, field := range requestedFields {
|
for _, field := range requestedFields {
|
||||||
field = strings.TrimSpace(field)
|
field = strings.TrimSpace(field)
|
||||||
if field != "" && availableDocumentFields[field] {
|
if field != "" && kbapi.AvailableDocumentFields[field] {
|
||||||
selectFields = append(selectFields, field)
|
selectFields = append(selectFields, field)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If no valid fields found, use default
|
// If no valid fields found, use default
|
||||||
if len(selectFields) == 0 {
|
if len(selectFields) == 0 {
|
||||||
selectFields = defaultDocumentFields
|
selectFields = kbapi.DefaultDocumentFields
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
selectFields = defaultDocumentFields
|
selectFields = kbapi.DefaultDocumentFields
|
||||||
}
|
|
||||||
|
|
||||||
// Build query parameters
|
|
||||||
param := model.QueryParam{
|
|
||||||
Select: selectFields,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add filters
|
|
||||||
var wheres []model.QueryWhere
|
|
||||||
|
|
||||||
// Filter by keywords (search in name and description)
|
|
||||||
if keywords := strings.TrimSpace(c.Query("keywords")); keywords != "" {
|
|
||||||
wheres = append(wheres, model.QueryWhere{
|
|
||||||
Column: "name",
|
|
||||||
Value: "%" + keywords + "%",
|
|
||||||
OP: "like",
|
|
||||||
})
|
|
||||||
wheres = append(wheres, model.QueryWhere{
|
|
||||||
Column: "description",
|
|
||||||
Value: "%" + keywords + "%",
|
|
||||||
OP: "like",
|
|
||||||
Wheres: []model.QueryWhere{},
|
|
||||||
Method: "orwhere",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by tag
|
|
||||||
if tag := strings.TrimSpace(c.Query("tag")); tag != "" {
|
|
||||||
wheres = append(wheres, model.QueryWhere{
|
|
||||||
Column: "tags",
|
|
||||||
Value: "%" + tag + "%",
|
|
||||||
OP: "like",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get authorized information
|
// Get authorized information
|
||||||
authInfo := authorized.GetInfo(c)
|
authInfo := authorized.GetInfo(c)
|
||||||
|
|
||||||
|
// Build filter for kb.API
|
||||||
|
filter := &kbapi.ListDocumentsFilter{
|
||||||
|
Page: page,
|
||||||
|
PageSize: pagesize,
|
||||||
|
Keywords: strings.TrimSpace(c.Query("keywords")),
|
||||||
|
Tag: strings.TrimSpace(c.Query("tag")),
|
||||||
|
Select: selectFields,
|
||||||
|
}
|
||||||
|
|
||||||
// Filter by collection_id
|
// Filter by collection_id
|
||||||
// If collection_id is provided, validate collection permission
|
|
||||||
// If not provided, filter by authorization constraints (TeamOnly or OwnerOnly)
|
|
||||||
collectionID := strings.TrimSpace(c.Query("collection_id"))
|
collectionID := strings.TrimSpace(c.Query("collection_id"))
|
||||||
if collectionID != "" {
|
if collectionID != "" {
|
||||||
|
|
||||||
// Validate collection permission
|
// Validate collection permission
|
||||||
hasPermission, err := checkCollectionPermission(authInfo, collectionID, true)
|
hasPermission, err := checkCollectionPermission(authInfo, collectionID, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -156,84 +85,44 @@ func ListDocuments(c *gin.Context) {
|
||||||
if !hasPermission {
|
if !hasPermission {
|
||||||
errorResp := &response.ErrorResponse{
|
errorResp := &response.ErrorResponse{
|
||||||
Code: response.ErrAccessDenied.Code,
|
Code: response.ErrAccessDenied.Code,
|
||||||
ErrorDescription: "Forbidden: No permission to update collection",
|
ErrorDescription: "Forbidden: No permission to view collection",
|
||||||
}
|
}
|
||||||
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
wheres = append(wheres, model.QueryWhere{Column: "collection_id", Value: collectionID})
|
filter.CollectionID = collectionID
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Filter by authorization constraints
|
// Filter by authorization constraints
|
||||||
wheres = append(wheres, AuthFilter(c, authInfo)...)
|
filter.AuthFilters = AuthFilter(c, authInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by status (support multiple values separated by comma)
|
// Filter by status (support multiple values separated by comma)
|
||||||
if statusParam := strings.TrimSpace(c.Query("status")); statusParam != "" {
|
if statusParam := strings.TrimSpace(c.Query("status")); statusParam != "" {
|
||||||
statusList := strings.Split(statusParam, ",")
|
statusList := strings.Split(statusParam, ",")
|
||||||
var statusValues []interface{}
|
var statusValues []string
|
||||||
for _, status := range statusList {
|
for _, status := range statusList {
|
||||||
status = strings.TrimSpace(status)
|
status = strings.TrimSpace(status)
|
||||||
if status != "" {
|
if status != "" {
|
||||||
statusValues = append(statusValues, status)
|
statusValues = append(statusValues, status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
filter.Status = statusValues
|
||||||
if len(statusValues) > 0 {
|
|
||||||
if len(statusValues) == 1 {
|
|
||||||
// Single status
|
|
||||||
wheres = append(wheres, model.QueryWhere{
|
|
||||||
Column: "status",
|
|
||||||
Value: statusValues[0],
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// Multiple status - use IN clause
|
|
||||||
wheres = append(wheres, model.QueryWhere{
|
|
||||||
Column: "status",
|
|
||||||
Value: statusValues,
|
|
||||||
OP: "in",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by status_not (exclude specific statuses)
|
// Filter by status_not (exclude specific statuses)
|
||||||
if statusNotParam := strings.TrimSpace(c.Query("status_not")); statusNotParam != "" {
|
if statusNotParam := strings.TrimSpace(c.Query("status_not")); statusNotParam != "" {
|
||||||
statusNotList := strings.Split(statusNotParam, ",")
|
statusNotList := strings.Split(statusNotParam, ",")
|
||||||
var statusNotValues []interface{}
|
var statusNotValues []string
|
||||||
for _, status := range statusNotList {
|
for _, status := range statusNotList {
|
||||||
status = strings.TrimSpace(status)
|
status = strings.TrimSpace(status)
|
||||||
if status != "" {
|
if status != "" {
|
||||||
statusNotValues = append(statusNotValues, status)
|
statusNotValues = append(statusNotValues, status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
filter.StatusNot = statusNotValues
|
||||||
if len(statusNotValues) > 0 {
|
|
||||||
if len(statusNotValues) == 1 {
|
|
||||||
// Single status exclusion
|
|
||||||
wheres = append(wheres, model.QueryWhere{
|
|
||||||
Column: "status",
|
|
||||||
Value: statusNotValues[0],
|
|
||||||
OP: "!=",
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// Multiple status exclusion - use NOT IN clause
|
|
||||||
// Since gou/model doesn't support "notin" OP directly,
|
|
||||||
// we need to use a different approach or multiple != conditions
|
|
||||||
for _, status := range statusNotValues {
|
|
||||||
wheres = append(wheres, model.QueryWhere{
|
|
||||||
Column: "status",
|
|
||||||
Value: status,
|
|
||||||
OP: "!=",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
param.Wheres = wheres
|
|
||||||
|
|
||||||
// Add ordering
|
// Add ordering
|
||||||
sortParam := strings.TrimSpace(c.Query("sort"))
|
sortParam := strings.TrimSpace(c.Query("sort"))
|
||||||
if sortParam == "" {
|
if sortParam == "" {
|
||||||
|
|
@ -263,7 +152,7 @@ func ListDocuments(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate sort field
|
// Validate sort field
|
||||||
if !validSortFields[sortField] {
|
if !kbapi.ValidDocumentSortFields[sortField] {
|
||||||
continue // Skip invalid fields
|
continue // Skip invalid fields
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -284,11 +173,10 @@ func ListDocuments(c *gin.Context) {
|
||||||
{Column: "created_at", Option: "desc"},
|
{Column: "created_at", Option: "desc"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
filter.Sort = orders
|
||||||
|
|
||||||
param.Orders = orders
|
// Query documents using kb.API
|
||||||
|
result, err := kb.API.ListDocuments(c.Request.Context(), filter)
|
||||||
// Query documents using KB config
|
|
||||||
result, err := config.SearchDocuments(param, page, pagesize)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorResp := &response.ErrorResponse{
|
errorResp := &response.ErrorResponse{
|
||||||
Code: response.ErrServerError.Code,
|
Code: response.ErrServerError.Code,
|
||||||
|
|
@ -303,8 +191,8 @@ func ListDocuments(c *gin.Context) {
|
||||||
|
|
||||||
// GetDocument gets document details by document ID
|
// GetDocument gets document details by document ID
|
||||||
func GetDocument(c *gin.Context) {
|
func GetDocument(c *gin.Context) {
|
||||||
// Check if kb.Instance is available
|
// Check if kb.API is available
|
||||||
if !checkKBInstance(c) {
|
if !checkKBAPI(c) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -318,35 +206,31 @@ func GetDocument(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get KB instance and config
|
|
||||||
kbInstance := kb.Instance.(*kb.KnowledgeBase)
|
|
||||||
config := kbInstance.Config
|
|
||||||
|
|
||||||
// Parse select parameter - same logic as ListDocuments
|
// Parse select parameter - same logic as ListDocuments
|
||||||
var selectFields []interface{}
|
var selectFields []interface{}
|
||||||
if selectParam := strings.TrimSpace(c.Query("select")); selectParam != "" {
|
if selectParam := strings.TrimSpace(c.Query("select")); selectParam != "" {
|
||||||
requestedFields := strings.Split(selectParam, ",")
|
requestedFields := strings.Split(selectParam, ",")
|
||||||
for _, field := range requestedFields {
|
for _, field := range requestedFields {
|
||||||
field = strings.TrimSpace(field)
|
field = strings.TrimSpace(field)
|
||||||
if field != "" && availableDocumentFields[field] {
|
if field != "" && kbapi.AvailableDocumentFields[field] {
|
||||||
selectFields = append(selectFields, field)
|
selectFields = append(selectFields, field)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If no valid fields found, use default
|
// If no valid fields found, use default
|
||||||
if len(selectFields) == 0 {
|
if len(selectFields) == 0 {
|
||||||
selectFields = defaultDocumentFields
|
selectFields = kbapi.DefaultDocumentFields
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
selectFields = defaultDocumentFields
|
selectFields = kbapi.DefaultDocumentFields
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build query parameters
|
// Build params for kb.API
|
||||||
param := model.QueryParam{
|
params := &kbapi.GetDocumentParams{
|
||||||
Select: selectFields,
|
Select: selectFields,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query single document using KB config
|
// Query single document using kb.API
|
||||||
result, err := config.FindDocument(docID, param)
|
result, err := kb.API.GetDocument(c.Request.Context(), docID, params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "document not found") {
|
if strings.Contains(err.Error(), "document not found") {
|
||||||
errorResp := &response.ErrorResponse{
|
errorResp := &response.ErrorResponse{
|
||||||
|
|
@ -370,8 +254,8 @@ func GetDocument(c *gin.Context) {
|
||||||
|
|
||||||
// RemoveDocs removes documents by IDs
|
// RemoveDocs removes documents by IDs
|
||||||
func RemoveDocs(c *gin.Context) {
|
func RemoveDocs(c *gin.Context) {
|
||||||
// Check if kb.Instance is available
|
// Check if kb.API is available
|
||||||
if !checkKBInstance(c) {
|
if !checkKBAPI(c) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -405,29 +289,20 @@ func RemoveDocs(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get KB config for database operations
|
|
||||||
config, err := kb.GetConfig()
|
|
||||||
if err != nil {
|
|
||||||
errorResp := &response.ErrorResponse{
|
|
||||||
Code: response.ErrServerError.Code,
|
|
||||||
ErrorDescription: "Failed to get KB config: " + err.Error(),
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate document permissions
|
// Validate document permissions
|
||||||
collectionIDs := []string{}
|
|
||||||
authInfo := authorized.GetInfo(c)
|
authInfo := authorized.GetInfo(c)
|
||||||
|
checkedCollections := make(map[string]bool)
|
||||||
for _, docID := range validDocIDs {
|
for _, docID := range validDocIDs {
|
||||||
collectionID, _ := kbutils.ExtractCollectionIDFromDocID(docID)
|
collectionID := extractCollectionIDFromDocID(docID)
|
||||||
if collectionID == "" {
|
if collectionID == "" {
|
||||||
collectionID = "default"
|
collectionID = "default"
|
||||||
}
|
}
|
||||||
collectionIDs = append(collectionIDs, collectionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, collectionID := range collectionIDs {
|
// Skip if already checked
|
||||||
|
if checkedCollections[collectionID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
checkedCollections[collectionID] = true
|
||||||
|
|
||||||
// Check update permission
|
// Check update permission
|
||||||
hasPermission, err := checkCollectionPermission(authInfo, collectionID)
|
hasPermission, err := checkCollectionPermission(authInfo, collectionID)
|
||||||
|
|
@ -451,8 +326,10 @@ func RemoveDocs(c *gin.Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove documents using GraphRAG
|
// Remove documents using kb.API
|
||||||
deletedCount, err := kb.Instance.RemoveDocs(c.Request.Context(), validDocIDs)
|
result, err := kb.API.RemoveDocuments(c.Request.Context(), &kbapi.RemoveDocumentsParams{
|
||||||
|
DocumentIDs: validDocIDs,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorResp := &response.ErrorResponse{
|
errorResp := &response.ErrorResponse{
|
||||||
Code: response.ErrServerError.Code,
|
Code: response.ErrServerError.Code,
|
||||||
|
|
@ -462,62 +339,16 @@ func RemoveDocs(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also remove documents from the database and track collections to update
|
|
||||||
dbDeletedCount := 0
|
|
||||||
collectionsToUpdate := make(map[string]bool) // Track unique collection IDs
|
|
||||||
|
|
||||||
for _, docID := range validDocIDs {
|
|
||||||
// Get document info before deletion to track collection
|
|
||||||
if docInfo, err := config.FindDocument(docID, model.QueryParam{
|
|
||||||
Select: []interface{}{"collection_id"},
|
|
||||||
}); err == nil && docInfo != nil {
|
|
||||||
if collectionID, ok := docInfo["collection_id"].(string); ok && collectionID != "" {
|
|
||||||
collectionsToUpdate[collectionID] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := config.RemoveDocument(docID); err != nil {
|
|
||||||
// Log the error but don't fail the entire operation
|
|
||||||
// since the document was already removed from GraphRAG
|
|
||||||
errorResp := &response.ErrorResponse{
|
|
||||||
Code: response.ErrServerError.Code,
|
|
||||||
ErrorDescription: "Failed to remove document from database: " + err.Error(),
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dbDeletedCount++
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update document counts for affected collections and sync to GraphRag
|
|
||||||
for collectionID := range collectionsToUpdate {
|
|
||||||
if err := UpdateDocumentCountWithSync(collectionID, config); err != nil {
|
|
||||||
// Log error but don't fail the operation
|
|
||||||
// TODO: Add proper logging
|
|
||||||
// log.Error("Failed to update document count for collection %s: %v", collectionID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return success response with deletion count
|
// Return success response with deletion count
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, result)
|
||||||
"message": "Documents removed successfully",
|
|
||||||
"deleted_count": deletedCount,
|
|
||||||
"requested_count": len(validDocIDs),
|
|
||||||
"db_deleted_count": dbDeletedCount,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validator interface for request validation
|
// checkKBAPI checks if kb.API is available
|
||||||
type Validator interface {
|
func checkKBAPI(c *gin.Context) bool {
|
||||||
Validate() error
|
if kb.API == nil {
|
||||||
}
|
|
||||||
|
|
||||||
// checkKBInstance checks if kb.Instance is available
|
|
||||||
func checkKBInstance(c *gin.Context) bool {
|
|
||||||
if kb.Instance == nil {
|
|
||||||
errorResp := &response.ErrorResponse{
|
errorResp := &response.ErrorResponse{
|
||||||
Code: response.ErrServerError.Code,
|
Code: response.ErrServerError.Code,
|
||||||
ErrorDescription: "Knowledge base not initialized",
|
ErrorDescription: "Knowledge base API not initialized",
|
||||||
}
|
}
|
||||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||||
return false
|
return false
|
||||||
|
|
@ -525,54 +356,19 @@ func checkKBInstance(c *gin.Context) bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// getUpsertOptions converts BaseUpsertRequest to UpsertOptions with optional file info
|
// extractCollectionIDFromDocID extracts collection ID from document ID
|
||||||
func getUpsertOptions(c *gin.Context, req *BaseUpsertRequest, fileInfo ...string) (*types.UpsertOptions, error) {
|
// Document ID format: {prefix}_{collection_id}__{random_id}
|
||||||
upsertOptions, err := req.ToUpsertOptions(fileInfo...)
|
func extractCollectionIDFromDocID(docID string) string {
|
||||||
if err != nil {
|
parts := strings.Split(docID, "__")
|
||||||
errorResp := &response.ErrorResponse{
|
if len(parts) < 2 {
|
||||||
Code: response.ErrInvalidRequest.Code,
|
return ""
|
||||||
ErrorDescription: "Failed to convert request to upsert options: " + err.Error(),
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
return upsertOptions, nil
|
// First part contains prefix_collection_id
|
||||||
}
|
prefix := parts[0]
|
||||||
|
// Find the first underscore to skip the prefix
|
||||||
// validateFileAndGetPath validates file manager, file existence and gets local path
|
idx := strings.Index(prefix, "_")
|
||||||
func validateFileAndGetPath(c *gin.Context, req *AddFileRequest) (string, string, error) {
|
if idx == -1 {
|
||||||
// Get file manager
|
return prefix
|
||||||
m, ok := attachment.Managers[req.Uploader]
|
}
|
||||||
if !ok {
|
return prefix[idx+1:]
|
||||||
errorResp := &response.ErrorResponse{
|
|
||||||
Code: response.ErrInvalidRequest.Code,
|
|
||||||
ErrorDescription: "Invalid uploader: " + req.Uploader + " not found",
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
|
||||||
return "", "", response.ErrInvalidRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the file exists
|
|
||||||
exists := m.Exists(c.Request.Context(), req.FileID)
|
|
||||||
if !exists {
|
|
||||||
errorResp := &response.ErrorResponse{
|
|
||||||
Code: response.ErrInvalidRequest.Code,
|
|
||||||
ErrorDescription: "File not found: " + req.FileID,
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
|
||||||
return "", "", response.ErrInvalidRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the options of the manager
|
|
||||||
path, contentType, err := m.LocalPath(c.Request.Context(), req.FileID)
|
|
||||||
if err != nil {
|
|
||||||
errorResp := &response.ErrorResponse{
|
|
||||||
Code: response.ErrServerError.Code,
|
|
||||||
ErrorDescription: "Failed to get local path: " + err.Error(),
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return path, contentType, nil
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/yaoapp/gou/graphrag/utils"
|
|
||||||
"github.com/yaoapp/kun/maps"
|
"github.com/yaoapp/kun/maps"
|
||||||
"github.com/yaoapp/yao/attachment"
|
|
||||||
"github.com/yaoapp/yao/kb"
|
"github.com/yaoapp/yao/kb"
|
||||||
kbtypes "github.com/yaoapp/yao/kb/types"
|
kbtypes "github.com/yaoapp/yao/kb/types"
|
||||||
apiutils "github.com/yaoapp/yao/openapi/utils"
|
apiutils "github.com/yaoapp/yao/openapi/utils"
|
||||||
|
|
@ -96,134 +94,9 @@ func PrepareCreateCollection(c *gin.Context) (*CreateCollectionRequest, map[stri
|
||||||
data["num_probes"] = req.Config.NumProbes
|
data["num_probes"] = req.Config.NumProbes
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add context fields (permissions, user info, etc.)
|
|
||||||
addContextFields(c, data)
|
|
||||||
|
|
||||||
return &req, data, nil
|
return &req, data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PrepareAddFile prepares AddFile request and database data
|
|
||||||
func PrepareAddFile(c *gin.Context, req *AddFileRequest) (*AddFileRequest, map[string]interface{}, error) {
|
|
||||||
// Validate request
|
|
||||||
if err := req.Validate(); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file and get path
|
|
||||||
path, contentType, err := validateFileAndGetPath(c, req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get file info
|
|
||||||
m, _ := attachment.Managers[req.Uploader]
|
|
||||||
fileInfo, _ := m.Info(c.Request.Context(), req.FileID)
|
|
||||||
|
|
||||||
// Generate document ID if not provided
|
|
||||||
if req.DocID == "" {
|
|
||||||
req.DocID = utils.GenDocIDWithCollectionID(req.CollectionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare document data for database
|
|
||||||
data := map[string]interface{}{
|
|
||||||
"document_id": req.DocID,
|
|
||||||
"collection_id": req.CollectionID,
|
|
||||||
"name": fileInfo.Filename,
|
|
||||||
"type": "file",
|
|
||||||
"status": "pending",
|
|
||||||
"uploader_id": req.Uploader,
|
|
||||||
"file_name": fileInfo.Filename,
|
|
||||||
"file_path": path,
|
|
||||||
"file_mime_type": contentType,
|
|
||||||
"size": int64(fileInfo.Bytes),
|
|
||||||
}
|
|
||||||
|
|
||||||
req.BaseUpsertRequest.AddBaseFields(data)
|
|
||||||
addContextFields(c, data)
|
|
||||||
|
|
||||||
return req, data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PrepareAddText prepares AddText request and database data
|
|
||||||
func PrepareAddText(c *gin.Context, req *AddTextRequest) (*AddTextRequest, map[string]interface{}, error) {
|
|
||||||
// Validate request
|
|
||||||
if err := req.Validate(); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate document ID if not provided
|
|
||||||
if req.DocID == "" {
|
|
||||||
req.DocID = utils.GenDocIDWithCollectionID(req.CollectionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare document data for database
|
|
||||||
data := map[string]interface{}{
|
|
||||||
"document_id": req.DocID,
|
|
||||||
"collection_id": req.CollectionID,
|
|
||||||
"name": "Text Document",
|
|
||||||
"type": "text",
|
|
||||||
"status": "pending",
|
|
||||||
"text_content": req.Text,
|
|
||||||
"size": int64(len(req.Text)),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use title from metadata if available
|
|
||||||
if req.Metadata != nil {
|
|
||||||
if title, ok := req.Metadata["title"].(string); ok && title != "" {
|
|
||||||
data["name"] = title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
req.BaseUpsertRequest.AddBaseFields(data)
|
|
||||||
addContextFields(c, data)
|
|
||||||
|
|
||||||
return req, data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PrepareAddURL prepares AddURL request and database data
|
|
||||||
func PrepareAddURL(c *gin.Context, req *AddURLRequest) (*AddURLRequest, map[string]interface{}, error) {
|
|
||||||
// Validate request
|
|
||||||
if err := req.Validate(); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate document ID if not provided
|
|
||||||
if req.DocID == "" {
|
|
||||||
req.DocID = utils.GenDocIDWithCollectionID(req.CollectionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare document data for database
|
|
||||||
data := map[string]interface{}{
|
|
||||||
"document_id": req.DocID,
|
|
||||||
"collection_id": req.CollectionID,
|
|
||||||
"name": req.URL,
|
|
||||||
"type": "url",
|
|
||||||
"status": "pending",
|
|
||||||
"url": req.URL,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use title from metadata if available
|
|
||||||
if req.Metadata != nil {
|
|
||||||
if title, ok := req.Metadata["title"].(string); ok && title != "" {
|
|
||||||
data["name"] = title
|
|
||||||
data["url_title"] = title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
req.BaseUpsertRequest.AddBaseFields(data)
|
|
||||||
addContextFields(c, data)
|
|
||||||
|
|
||||||
return req, data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// addContextFields adds context-specific fields like permissions, user info
|
|
||||||
func addContextFields(c *gin.Context, data map[string]interface{}) {
|
|
||||||
// TODO: Add permission-related fields from Guard
|
|
||||||
// Example: data["user_id"] = c.GetString("user_id")
|
|
||||||
// Example: data["permissions"] = c.Get("permissions")
|
|
||||||
// Example: data["tenant_id"] = c.GetString("tenant_id")
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateCollectionWithSync updates collection metadata in database and syncs to GraphRag
|
// UpdateCollectionWithSync updates collection metadata in database and syncs to GraphRag
|
||||||
func UpdateCollectionWithSync(collectionID string, data maps.MapStrAny, config *kbtypes.Config) error {
|
func UpdateCollectionWithSync(collectionID string, data maps.MapStrAny, config *kbtypes.Config) error {
|
||||||
// Create a copy of data for GraphRag to avoid contamination from database operations
|
// Create a copy of data for GraphRag to avoid contamination from database operations
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue