Merge pull request #1388 from trheyi/main

Add KB Search API, Update Go to 1.25, and Bump Version to 1.0.0
This commit is contained in:
Max 2025-12-20 12:16:04 +08:00 committed by GitHub
commit a97ed818a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 8196 additions and 1514 deletions

View file

@ -10,7 +10,7 @@ jobs:
build: build:
runs-on: "ubuntu-latest" runs-on: "ubuntu-latest"
container: container:
image: yaoapp/yao-build:0.10.5 image: yaoapp/yao-build:1.0.0
env: env:
CF_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }} CF_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }}

View file

@ -7,13 +7,13 @@ on:
description: "Version tags" description: "Version tags"
env: env:
VERSION: 0.10.5 VERSION: 1.0.0
jobs: jobs:
build: build:
strategy: strategy:
matrix: matrix:
go: [1.24.3] go: ["1.25"]
runs-on: "macos-latest" runs-on: "macos-latest"
steps: steps:
- name: Setup Node.js - name: Setup Node.js

View file

@ -164,6 +164,192 @@ env:
TWILIO_TEST_PHONE: ${{ secrets.TWILIO_TEST_PHONE }} TWILIO_TEST_PHONE: ${{ secrets.TWILIO_TEST_PHONE }}
jobs: jobs:
# =============================================================================
# KB Tests (kb) - Run once with SQLite (requires Qdrant, Neo4j, FastEmbed)
# =============================================================================
KBTest:
runs-on: ubuntu-latest
services:
qdrant:
image: qdrant/qdrant:latest
ports:
- 6333:6333
- 6334:6334
fastembed:
image: yaoapp/fastembed:latest-amd64
env:
FASTEMBED_PASSWORD: Yao@2026
ports:
- 6001:8000
neo4j:
image: neo4j:latest
ports:
- "7687:7687"
env:
NEO4J_AUTH: neo4j/Yao2026Neo4j
mongodb:
image: mongo:6.0
ports:
- 27017:27017
env:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: 123456
MONGO_INITDB_DATABASE: test
strategy:
matrix:
go: ["1.25"]
if: >
${{ github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success' }}
steps:
- name: "Download artifact"
uses: actions/github-script@v7
with:
script: |
var artifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: ${{github.event.workflow_run.id }},
});
var matchArtifact = artifacts.data.artifacts.filter((artifact) => {
return artifact.name == "pr"
})[0];
var download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: matchArtifact.id,
archive_format: 'zip',
});
var fs = require('fs');
fs.writeFileSync('${{github.workspace}}/pr.zip', Buffer.from(download.data));
- name: "Read NR & SHA"
run: |
unzip pr.zip
cat NR
cat SHA
echo HEAD=$(cat SHA) >> $GITHUB_ENV
echo NR=$(cat NR) >> $GITHUB_ENV
- name: "Comment on PR"
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { NR } = process.env
var issue_number = NR;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue_number,
body: '🤖 KB Tests (kb) running with SQLite...'
});
- name: Checkout Kun
uses: actions/checkout@v4
with:
repository: yaoapp/kun
path: kun
- name: Checkout Xun
uses: actions/checkout@v4
with:
repository: yaoapp/xun
path: xun
- name: Checkout Gou
uses: actions/checkout@v4
with:
repository: yaoapp/gou
path: gou
- name: Checkout V8Go
uses: actions/checkout@v4
with:
repository: yaoapp/v8go
path: v8go
- name: Unzip libv8
run: |
files=$(find ./v8go -name "libv8*.zip")
for file in $files; do
dir=$(dirname "$file")
echo "Extracting $file to directory $dir"
unzip -o -d $dir $file
rm -rf $dir/__MACOSX
done
- name: Checkout Demo App
uses: actions/checkout@v4
with:
repository: yaoapp/yao-dev-app
path: app
- name: Checkout Extension
uses: actions/checkout@v4
with:
repository: yaoapp/yao-extensions-dev
path: extension
- name: Move Dependencies
run: |
mv kun ../
mv xun ../
mv gou ../
mv v8go ../
mv app ../
mv extension ../
- name: Checkout pull request HEAD commit
uses: actions/checkout@v4
with:
ref: ${{ env.HEAD }}
- name: Setup Apple Private Key
run: |
mkdir -p ../app/openapi/certs/apple
echo "${{ secrets.APPLE_PRIVATE_KEY_USER }}" > ../app/openapi/certs/apple/signin_client_secret_key.p8
- name: Setup Go ${{ matrix.go }}
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- name: Start Redis
uses: supercharge/redis-github-action@1.4.0
with:
redis-version: 6
- name: Setup Go Tools
run: make tools
- name: Setup ENV (SQLite)
run: |
mkdir -p ${{ github.WORKSPACE }}/../app/db
echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
- name: Run KB Tests (kb)
run: make unit-test-kb
- name: "Comment on PR - KB Tests Done"
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { NR } = process.env
var issue_number = NR;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue_number,
body: '✅ KB Tests (kb) passed!'
});
# ============================================================================= # =============================================================================
# AI Tests (agent, aigc) - Run once with SQLite # AI Tests (agent, aigc) - Run once with SQLite
# ============================================================================= # =============================================================================
@ -207,7 +393,7 @@ jobs:
strategy: strategy:
matrix: matrix:
go: [1.24] go: ["1.25"]
if: > if: >
${{ github.event.workflow_run.event == 'pull_request' && ${{ github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success' }} github.event.workflow_run.conclusion == 'success' }}
@ -379,7 +565,7 @@ jobs:
strategy: strategy:
matrix: matrix:
go: [1.24] go: ["1.25"]
db: [MySQL8.0, SQLite3] db: [MySQL8.0, SQLite3]
if: > if: >
${{ github.event.workflow_run.event == 'pull_request' && ${{ github.event.workflow_run.event == 'pull_request' &&
@ -547,7 +733,7 @@ jobs:
strategy: strategy:
matrix: matrix:
go: [1.24] go: ["1.25"]
db: [MySQL8.0, SQLite3] db: [MySQL8.0, SQLite3]
redis: [4, 5, 6] redis: [4, 5, 6]
mongo: ["6.0"] mongo: ["6.0"]

View file

@ -171,6 +171,130 @@ env:
TWILIO_TEST_PHONE: ${{ secrets.TWILIO_TEST_PHONE }} TWILIO_TEST_PHONE: ${{ secrets.TWILIO_TEST_PHONE }}
jobs: jobs:
# =============================================================================
# KB Tests (kb) - Run once with SQLite (requires Qdrant, Neo4j, FastEmbed)
# =============================================================================
kb-test:
runs-on: ubuntu-latest
services:
qdrant:
image: qdrant/qdrant:latest
ports:
- 6333:6333
- 6334:6334
fastembed:
image: yaoapp/fastembed:latest-amd64
env:
FASTEMBED_PASSWORD: Yao@2026
ports:
- 6001:8000
neo4j:
image: neo4j:latest
ports:
- "7687:7687"
env:
NEO4J_AUTH: neo4j/Yao2026Neo4j
mongodb:
image: mongo:6.0
ports:
- 27017:27017
env:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: 123456
MONGO_INITDB_DATABASE: test
strategy:
matrix:
go: ["1.25"]
steps:
- name: Checkout Kun
uses: actions/checkout@v4
with:
repository: ${{ env.REPO_KUN }}
path: kun
- name: Checkout Xun
uses: actions/checkout@v4
with:
repository: ${{ env.REPO_XUN }}
path: xun
- name: Checkout Gou
uses: actions/checkout@v4
with:
repository: ${{ env.REPO_GOU }}
path: gou
- name: Checkout V8Go
uses: actions/checkout@v4
with:
repository: yaoapp/v8go
path: v8go
- name: Unzip libv8
run: |
files=$(find ./v8go -name "libv8*.zip")
for file in $files; do
dir=$(dirname "$file")
echo "Extracting $file to directory $dir"
unzip -o -d $dir $file
rm -rf $dir/__MACOSX
done
- name: Checkout Demo App
uses: actions/checkout@v4
with:
repository: yaoapp/yao-dev-app
path: app
- name: Checkout Extension
uses: actions/checkout@v4
with:
repository: yaoapp/yao-extensions-dev
path: extension
- name: Move Dependencies
run: |
mv kun ../
mv xun ../
mv gou ../
mv v8go ../
mv app ../
mv extension ../
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Apple Private Key
run: |
mkdir -p ../app/openapi/certs/apple
echo "${{ secrets.APPLE_PRIVATE_KEY_USER }}" > ../app/openapi/certs/apple/signin_client_secret_key.p8
- name: Setup Go ${{ matrix.go }}
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- name: Start Redis
uses: supercharge/redis-github-action@1.4.0
with:
redis-version: 6
- name: Setup Go Tools
run: make tools
- name: Setup ENV (SQLite)
run: |
mkdir -p ${{ github.WORKSPACE }}/../app/db
echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
- name: Run KB Tests (kb)
run: make unit-test-kb
# ============================================================================= # =============================================================================
# AI Tests (agent, aigc) - Run once with SQLite # AI Tests (agent, aigc) - Run once with SQLite
# ============================================================================= # =============================================================================
@ -214,7 +338,7 @@ jobs:
strategy: strategy:
matrix: matrix:
go: [1.24] go: ["1.25"]
steps: steps:
- name: Checkout Kun - name: Checkout Kun
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -324,7 +448,7 @@ jobs:
strategy: strategy:
matrix: matrix:
go: [1.24] go: ["1.25"]
db: [MySQL8.0, SQLite3] db: [MySQL8.0, SQLite3]
steps: steps:
- name: Checkout Kun - name: Checkout Kun
@ -457,7 +581,7 @@ jobs:
strategy: strategy:
matrix: matrix:
go: [1.24] go: ["1.25"]
db: [MySQL8.0, SQLite3] db: [MySQL8.0, SQLite3]
redis: [4, 5, 6] redis: [4, 5, 6]
mongo: ["6.0"] mongo: ["6.0"]

View file

@ -11,10 +11,12 @@ OS := $(shell uname)
# ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) # ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
TESTFOLDER := $(shell $(GO) list ./... | grep -vE 'examples|openai|aigc|neo|twilio|share*' | awk '!/\/tests\// || /openapi\/tests/') TESTFOLDER := $(shell $(GO) list ./... | grep -vE 'examples|openai|aigc|neo|twilio|share*' | awk '!/\/tests\// || /openapi\/tests/')
# Core tests (exclude AI-related: agent, aigc, openai) # Core tests (exclude AI-related: agent, aigc, openai, and KB)
TESTFOLDER_CORE := $(shell $(GO) list ./... | grep -vE 'examples|openai|aigc|neo|twilio|share*|agent' | awk '!/\/tests\// || /openapi\/tests/') TESTFOLDER_CORE := $(shell $(GO) list ./... | grep -vE 'examples|openai|aigc|neo|twilio|share*|agent|kb' | awk '!/\/tests\// || /openapi\/tests/')
# AI tests (agent, aigc) # AI tests (agent, aigc)
TESTFOLDER_AI := $(shell $(GO) list ./agent/... ./aigc/...) TESTFOLDER_AI := $(shell $(GO) list ./agent/... ./aigc/...)
# KB tests (kb)
TESTFOLDER_KB := $(shell $(GO) list ./kb/...)
TESTTAGS ?= "" TESTTAGS ?= ""
# TESTWIDGETS := $(shell $(GO) list ./widgets/...) # TESTWIDGETS := $(shell $(GO) list ./widgets/...)
@ -103,6 +105,38 @@ unit-test-ai:
fi; \ fi; \
done done
# KB Unit Test (kb)
.PHONY: unit-test-kb
unit-test-kb:
echo "mode: count" > coverage-kb.out
for d in $(TESTFOLDER_KB); do \
$(GO) test -tags $(TESTTAGS) -v -timeout=20m -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") -skip='TestMemoryLeak|TestIsolateDisposal|TestSearchCleanup' $$d > tmp.out; \
cat tmp.out; \
if grep -q "^--- FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^panic:" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "build failed" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "setup failed" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "runtime error" tmp.out; then \
rm tmp.out; \
exit 1; \
fi; \
if [ -f profile.out ]; then \
cat profile.out | grep -v "mode:" >> coverage-kb.out; \
rm profile.out; \
fi; \
done
# Benchmark Test # Benchmark Test
.PHONY: benchmark .PHONY: benchmark
benchmark: benchmark:

View file

@ -518,6 +518,7 @@ func (ast *Assistant) executeAutoSearch(ctx *context.Context, messages []context
Keywords: extractedKeywords, Keywords: extractedKeywords,
Config: ast.configToMap(searchConfig), Config: ast.configToMap(searchConfig),
RefCtx: refCtx, RefCtx: refCtx,
Results: results,
Duration: duration, Duration: duration,
SearchType: "auto", SearchType: "auto",
}) })
@ -925,6 +926,7 @@ type SearchExecutionResult struct {
Keywords []searchTypes.Keyword // Extracted keywords with weights Keywords []searchTypes.Keyword // Extracted keywords with weights
Config map[string]any // Search config used Config map[string]any // Search config used
RefCtx *searchTypes.ReferenceContext // Reference context with results RefCtx *searchTypes.ReferenceContext // Reference context with results
Results []*searchTypes.Result // Raw search results (for extracting DSL, etc.)
Duration int64 // Search duration in ms Duration int64 // Search duration in ms
Error error // Error if failed Error error // Error if failed
SearchType string // "auto", "web", "kb", "db" SearchType string // "auto", "web", "kb", "db"
@ -1012,6 +1014,16 @@ func (ast *Assistant) saveSearch(ctx *context.Context, execResult *SearchExecuti
searchRecord.Prompt = execResult.RefCtx.Prompt searchRecord.Prompt = execResult.RefCtx.Prompt
} }
// Extract DSL from DB search results
if execResult.Results != nil {
for _, result := range execResult.Results {
if result != nil && result.Type == searchTypes.SearchTypeDB && result.DSL != nil {
searchRecord.DSL = result.DSL
break // Only store the first DSL (usually there's only one DB search)
}
}
}
// Save to store // Save to store
if err := store.SaveSearch(searchRecord); err != nil { if err := store.SaveSearch(searchRecord); err != nil {
ctx.Logger.Warn("Failed to save search record: %v", err) ctx.Logger.Warn("Failed to save search record: %v", err)

View file

@ -207,6 +207,9 @@ func (h *Handler) SearchWithContext(ctx *agentContext.Context, req *types.Reques
items = items[:maxResults] items = items[:maxResults]
} }
// 7. Convert DSL to map for storage
dslMap := h.dslToMap(result.DSL)
return &types.Result{ return &types.Result{
Type: types.SearchTypeDB, Type: types.SearchTypeDB,
Query: req.Query, Query: req.Query,
@ -214,6 +217,7 @@ func (h *Handler) SearchWithContext(ctx *agentContext.Context, req *types.Reques
Items: items, Items: items,
Total: len(items), Total: len(items),
Duration: time.Since(start).Milliseconds(), Duration: time.Since(start).Milliseconds(),
DSL: dslMap,
}, nil }, nil
} }
@ -377,3 +381,23 @@ func (h *Handler) extractContent(rec map[string]interface{}, mod *model.Model) s
} }
return string(content) return string(content)
} }
// dslToMap converts QueryDSL to map for storage
func (h *Handler) dslToMap(dsl *gou.QueryDSL) map[string]interface{} {
if dsl == nil {
return nil
}
// Marshal and unmarshal to get a clean map
data, err := json.Marshal(dsl)
if err != nil {
return nil
}
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
return nil
}
return result
}

View file

@ -1,6 +1,7 @@
package web_test package web_test
import ( import (
"context"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -278,7 +279,7 @@ func createTestContext(t *testing.T) *agentContext.Context {
UserID: "test-user", UserID: "test-user",
TenantID: "test-tenant", TenantID: "test-tenant",
} }
ctx := agentContext.New(nil, authorized, "test-chat-id") ctx := agentContext.New(context.Background(), authorized, "test-chat-id")
ctx.AssistantID = "tests.web-agent-caller" ctx.AssistantID = "tests.web-agent-caller"
return ctx return ctx
} }

View file

@ -116,7 +116,7 @@ type serpAPIAnswerBox struct {
// serpAPIKnowledge represents knowledge graph data // serpAPIKnowledge represents knowledge graph data
type serpAPIKnowledge struct { type serpAPIKnowledge struct {
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Type string `json:"type,omitempty"` Type interface{} `json:"type,omitempty"` // Can be string or object depending on query
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
} }

View file

@ -39,7 +39,7 @@ func TestSerpAPIProviderWithAssistantConfig(t *testing.T) {
// Execute search // Execute search
req := &types.Request{ req := &types.Request{
Query: "Yao App Engine", Query: "golang programming language",
Type: types.SearchTypeWeb, Type: types.SearchTypeWeb,
Source: types.SourceAuto, Source: types.SourceAuto,
Limit: 5, Limit: 5,
@ -51,7 +51,7 @@ func TestSerpAPIProviderWithAssistantConfig(t *testing.T) {
// Verify result structure // Verify result structure
assert.Equal(t, types.SearchTypeWeb, result.Type) assert.Equal(t, types.SearchTypeWeb, result.Type)
assert.Equal(t, "Yao App Engine", result.Query) assert.Equal(t, "golang programming language", result.Query)
assert.Equal(t, types.SourceAuto, result.Source) assert.Equal(t, types.SourceAuto, result.Source)
// API key must be valid - search should succeed // API key must be valid - search should succeed

View file

@ -81,6 +81,9 @@ type Result struct {
// Graph associations (KB only, if enabled) // Graph associations (KB only, if enabled)
GraphNodes []*GraphNode `json:"graph_nodes,omitempty"` GraphNodes []*GraphNode `json:"graph_nodes,omitempty"`
// DB specific
DSL map[string]interface{} `json:"dsl,omitempty"` // Generated QueryDSL (DB only)
} }
// ResultItem represents a single search result item // ResultItem represents a single search result item

View file

@ -2,14 +2,14 @@
# Yao Build Environment (Ubuntu 24.04 AMD64) # Yao Build Environment (Ubuntu 24.04 AMD64)
# #
# Build: # Build:
# docker build --platform linux/amd64 -t yaoapp/yao-build:0.10.5 . # docker build --platform linux/amd64 -t yaoapp/yao-build:1.0.0 .
# #
# Usage: # Usage:
# docker run --rm -it -v /local/path/dist:/data yaoapp/yao-build:0.10.5 # docker run --rm -it -v /local/path/dist:/data yaoapp/yao-build:1.0.0
# #
# Tests: # Tests:
# docker run --rm -it yaoapp/yao-build:0.10.5 /bin/bash # docker run --rm -it yaoapp/yao-build:1.0.0 /bin/bash
# docker run --rm -it -v ./test:/data yaoapp/yao-build:0.10.5 /bin/bash # docker run --rm -it -v ./test:/data yaoapp/yao-build:1.0.0 /bin/bash
# #
# =========================================== # ===========================================
FROM ubuntu:24.04 FROM ubuntu:24.04
@ -29,10 +29,10 @@ RUN apt-get update && \
apt-get install -y git && \ apt-get install -y git && \
apt-get install -y unzip apt-get install -y unzip
# Install Go 1.24.3 # Install Go 1.25.0
RUN wget https://golang.org/dl/go1.24.3.linux-amd64.tar.gz && \ RUN wget https://golang.org/dl/go1.25.0.linux-amd64.tar.gz && \
tar -C /usr/local -xzf go1.24.3.linux-amd64.tar.gz && \ tar -C /usr/local -xzf go1.25.0.linux-amd64.tar.gz && \
rm go1.24.3.linux-amd64.tar.gz rm go1.25.0.linux-amd64.tar.gz
# Install Node.js 18.x # Install Node.js 18.x
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \ RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \

View file

@ -6,12 +6,12 @@
# -t yaoapp/yao-dev:${VERSION}-${ARCH} . # -t yaoapp/yao-dev:${VERSION}-${ARCH} .
# #
# Build: # Build:
# docker build --platform linux/amd64 --build-arg VERSION=0.9.1 --build-arg ARCH=amd64 -t yaoapp/yao:0.9.1-amd64-dev . # docker build --platform linux/amd64 --build-arg VERSION=1.0.0 --build-arg ARCH=amd64 -t yaoapp/yao:1.0.0-amd64-dev .
# docker build --platform linux/arm64 --build-arg VERSION=0.9.1 --build-arg ARCH=arm64 -t yaoapp/yao:0.9.1-arm64-dev . # docker build --platform linux/arm64 --build-arg VERSION=1.0.0 --build-arg ARCH=arm64 -t yaoapp/yao:1.0.0-arm64-dev .
# #
# Tests: # Tests:
# docker run --rm yaoapp/yao:0.9.1-amd64-dev yao version # docker run --rm yaoapp/yao:1.0.0-amd64-dev yao version
# docker run -d -p 5099:5099 yaoapp/yao:0.9.1-amd64-dev # docker run -d -p 5099:5099 yaoapp/yao:1.0.0-amd64-dev
# #
# =========================================== # ===========================================
FROM ubuntu:24.04 FROM ubuntu:24.04

View file

@ -3,15 +3,15 @@
# docker build \ # docker build \
# --build-arg VERSION="${VERSION}" \ # --build-arg VERSION="${VERSION}" \
# --build-arg ARCH="${ARCH}" \ # --build-arg ARCH="${ARCH}" \
# -t yaoapp/yao-dev:${VERSION}-${ARCH} . # -t yaoapp/yao:${VERSION}-${ARCH} .
# #
# Build: # Build:
# docker build --platform linux/amd64 --build-arg VERSION=0.9.1 --build-arg ARCH=amd64 -t yaoapp/yao:0.9.1-amd64 . # docker build --platform linux/amd64 --build-arg VERSION=1.0.0 --build-arg ARCH=amd64 -t yaoapp/yao:1.0.0-amd64 .
# docker build --platform linux/arm64 --build-arg VERSION=0.9.1 --build-arg ARCH=arm64 -t yaoapp/yao:0.9.1-arm64 . # docker build --platform linux/arm64 --build-arg VERSION=1.0.0 --build-arg ARCH=arm64 -t yaoapp/yao:1.0.0-arm64 .
# #
# Tests: # Tests:
# docker run --rm yaoapp/yao:0.9.1-amd64 yao version # docker run --rm yaoapp/yao:1.0.0-amd64 yao version
# docker run -d -p 5099:5099 yaoapp/yao:0.9.1-amd64 # docker run -d -p 5099:5099 yaoapp/yao:1.0.0-amd64
# #
# =========================================== # ===========================================
FROM alpine:latest FROM alpine:latest

View file

@ -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
} }

View file

@ -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

View file

@ -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)

View file

@ -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")
}

View file

@ -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,6 +97,44 @@ 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"`
@ -104,6 +143,10 @@ type ExecutionConfig struct {
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

View file

@ -371,11 +371,11 @@ func (w *Worker) processWork(work *WorkRequest) {
} }
// Clean up execution context from job // Clean up execution context from job
if work.Job.executionContexts != nil {
work.Job.executionMutex.Lock() work.Job.executionMutex.Lock()
if work.Job.executionContexts != nil {
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)
} }

334
kb/api/README.md Normal file
View file

@ -0,0 +1,334 @@
# KB API
The `kb/api` package provides a unified Go API for Knowledge Base operations including collection management, document ingestion, and semantic search.
## Quick Start
```go
import (
"context"
"github.com/yaoapp/yao/kb"
"github.com/yaoapp/yao/kb/api"
)
// After kb.Load(), use kb.API to access all operations
ctx := context.Background()
```
## API Interface
```go
type API interface {
// Collection operations
CreateCollection(ctx, params) (*CreateCollectionResult, error)
RemoveCollection(ctx, collectionID) (*RemoveCollectionResult, error)
GetCollection(ctx, collectionID) (map[string]interface{}, error)
CollectionExists(ctx, collectionID) (*CollectionExistsResult, error)
ListCollections(ctx, filter) (*ListCollectionsResult, error)
UpdateCollectionMetadata(ctx, collectionID, params) (*UpdateMetadataResult, error)
// Document operations
ListDocuments(ctx, filter) (*ListDocumentsResult, error)
GetDocument(ctx, docID, params) (map[string]interface{}, error)
RemoveDocuments(ctx, params) (*RemoveDocumentsResult, error)
// Document add operations (sync)
AddFile(ctx, params) (*AddDocumentResult, error)
AddText(ctx, params) (*AddDocumentResult, error)
AddURL(ctx, params) (*AddDocumentResult, error)
// Document add operations (async)
AddFileAsync(ctx, params) (*AddDocumentAsyncResult, error)
AddTextAsync(ctx, params) (*AddDocumentAsyncResult, error)
AddURLAsync(ctx, params) (*AddDocumentAsyncResult, error)
// Search operations
Search(ctx, queries) (*SearchResult, error)
}
```
## Collection Operations
### Create Collection
```go
params := &api.CreateCollectionParams{
ID: "my_collection",
Metadata: map[string]interface{}{
"name": "My Knowledge Base",
"description": "Collection description",
},
EmbeddingProviderID: "__yao.openai",
EmbeddingOptionID: "text-embedding-3-small",
Locale: "en",
Config: &types.CreateCollectionOptions{
Distance: "cosine",
IndexType: "hnsw",
},
}
result, err := kb.API.CreateCollection(ctx, params)
// result.CollectionID = "my_collection"
```
### Get Collection
```go
collection, err := kb.API.GetCollection(ctx, "my_collection")
// collection["id"], collection["name"], collection["config"], etc.
```
### List Collections
```go
filter := &api.ListCollectionsFilter{
Page: 1,
PageSize: 20,
Keywords: "knowledge",
Status: []string{"active"},
}
result, err := kb.API.ListCollections(ctx, filter)
// result.Data, result.Total, result.PageCnt
```
### Remove Collection
```go
result, err := kb.API.RemoveCollection(ctx, "my_collection")
// result.Removed = true
```
## Document Operations
### Add Text
```go
params := &api.AddTextParams{
CollectionID: "my_collection",
Text: "Einstein developed the theory of relativity...",
DocID: "einstein_bio", // optional, auto-generated if empty
Metadata: map[string]interface{}{
"title": "Einstein Biography",
"author": "John Doe",
},
Chunking: &api.ProviderConfigParams{
ProviderID: "__yao.structured",
OptionID: "standard",
},
Embedding: &api.ProviderConfigParams{
ProviderID: "__yao.openai",
OptionID: "text-embedding-3-small",
},
Extraction: &api.ProviderConfigParams{ // optional, for graph extraction
ProviderID: "__yao.openai",
OptionID: "gpt-4o-mini",
},
}
result, err := kb.API.AddText(ctx, params)
// result.DocID = "einstein_bio"
```
### Add File
```go
params := &api.AddFileParams{
CollectionID: "my_collection",
FileID: "uploaded_file_id",
Uploader: "local", // or "s3", etc.
Chunking: &api.ProviderConfigParams{...},
Embedding: &api.ProviderConfigParams{...},
}
result, err := kb.API.AddFile(ctx, params)
```
### Add URL
```go
params := &api.AddURLParams{
CollectionID: "my_collection",
URL: "https://example.com/article",
Chunking: &api.ProviderConfigParams{...},
Embedding: &api.ProviderConfigParams{...},
}
result, err := kb.API.AddURL(ctx, params)
```
### List Documents
```go
filter := &api.ListDocumentsFilter{
Page: 1,
PageSize: 20,
CollectionID: "my_collection",
Status: []string{"active"},
}
result, err := kb.API.ListDocuments(ctx, filter)
```
### Remove Documents
```go
params := &api.RemoveDocumentsParams{
DocumentIDs: []string{"doc1", "doc2"},
}
result, err := kb.API.RemoveDocuments(ctx, params)
```
## Search Operations
The Search API supports batch queries with three search modes:
| Mode | Description |
|------|-------------|
| `vector` | Pure vector similarity search |
| `graph` | Graph traversal to find related segments via entities |
| `expand` | Graph-based entity expansion + vector search (default) |
### Basic Vector Search
```go
queries := []api.Query{
{
CollectionID: "my_collection",
Input: "What is the theory of relativity?",
Mode: api.SearchModeVector,
PageSize: 10,
},
}
result, err := kb.API.Search(ctx, queries)
// result.Segments - matched text segments with scores
// result.Total - total count
```
### Graph-Enhanced Search (Expand Mode)
```go
queries := []api.Query{
{
CollectionID: "my_collection",
Input: "Einstein's contributions to physics",
Mode: api.SearchModeExpand, // default
MaxDepth: 2, // graph traversal depth
PageSize: 10,
},
}
result, err := kb.API.Search(ctx, queries)
// result.Segments - segments from vector + graph expansion
// result.Graph.Nodes - related entities
// result.Graph.Relationships - entity relationships
```
### Multi-Query Search
Queries can span multiple collections; results are merged and deduplicated:
```go
queries := []api.Query{
{
CollectionID: "science_kb",
Input: "quantum mechanics",
Mode: api.SearchModeVector,
},
{
CollectionID: "tech_kb",
Input: "machine learning",
Mode: api.SearchModeVector,
},
}
result, err := kb.API.Search(ctx, queries)
// Merged results from both collections
```
### Search with Messages (Conversation Context)
```go
queries := []api.Query{
{
CollectionID: "my_collection",
Messages: []types.ChatMessage{
{Role: "user", Content: "Tell me about Einstein"},
{Role: "assistant", Content: "Einstein was a physicist..."},
{Role: "user", Content: "What about his discoveries?"}, // used as query
},
Mode: api.SearchModeExpand,
},
}
result, err := kb.API.Search(ctx, queries)
```
### Search with Filters
```go
queries := []api.Query{
{
CollectionID: "my_collection",
Input: "physics",
DocumentID: "specific_doc_id", // filter to specific document
MinScore: 0.5, // minimum similarity score
Metadata: map[string]interface{}{
"category": "science",
},
Page: 1,
PageSize: 20,
},
}
result, err := kb.API.Search(ctx, queries)
```
## Query Parameters
| Field | Type | Description |
|-------|------|-------------|
| `CollectionID` | string | Collection to search (required) |
| `Input` | string | Direct query text |
| `Messages` | []ChatMessage | Conversation history (last user message used as query) |
| `Mode` | SearchMode | `vector`, `graph`, or `expand` (default: `expand`) |
| `DocumentID` | string | Filter to specific document |
| `MinScore` | float64 | Minimum similarity threshold |
| `Metadata` | map | Filter by metadata fields |
| `MaxDepth` | int | Graph traversal depth (default: 2) |
| `Page` | int | Page number (1-based) |
| `PageSize` | int | Results per page |
## Search Result
```go
type SearchResult struct {
Segments []types.Segment // Matched segments with scores
Graph *GraphData // Nodes and relationships (graph/expand mode)
Total int // Total results count
Page int // Current page
PageSize int // Results per page
TotalPages int // Total pages
Next int // Next page number
Prev int // Previous page number
}
```
## Provider Configuration
Providers handle text processing:
```go
type ProviderConfigParams struct {
ProviderID string // e.g., "__yao.openai", "__yao.structured"
OptionID string // e.g., "text-embedding-3-small", "gpt-4o-mini"
}
```
Common providers:
- **Chunking**: `__yao.structured` - text splitting
- **Embedding**: `__yao.openai` - vector embeddings
- **Extraction**: `__yao.openai` - entity/relationship extraction for graph

303
kb/api/addfile.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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://raw.githubusercontent.com/trheyi/yao/refs/heads/main/agent/caller/caller.go",
Locale: "en",
Metadata: map[string]interface{}{
"title": "Yao Agent Caller",
"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://raw.githubusercontent.com/trheyi/yao/refs/heads/main/agent/caller/caller.go", 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://raw.githubusercontent.com/trheyi/yao/refs/heads/main/agent/caller/caller.go", 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")
})
}

View file

@ -274,61 +274,79 @@ func (instance *KBInstance) RemoveCollection(ctx context.Context, collectionID s
} }
// GetCollection retrieves a collection by ID // GetCollection retrieves a collection by ID
// Reads from database first, then merges with GraphRag metadata
func (instance *KBInstance) GetCollection(ctx context.Context, collectionID string) (map[string]interface{}, error) { func (instance *KBInstance) GetCollection(ctx context.Context, collectionID string) (map[string]interface{}, error) {
if collectionID == "" { if collectionID == "" {
return nil, fmt.Errorf("collection ID is required") return nil, fmt.Errorf("collection ID is required")
} }
collection, err := instance.GraphRag.GetCollection(ctx, collectionID) // Read from database (source of truth for existence and permissions)
dbRecord, err := instance.Config.FindCollection(collectionID, model.QueryParam{})
if err != nil { if err != nil {
// Check if it's a "not found" error
if err.Error() == fmt.Sprintf("collection with ID '%s' not found", collectionID) {
return nil, fmt.Errorf("collection not found") return nil, fmt.Errorf("collection not found")
} }
return nil, fmt.Errorf("failed to get collection: %w", err)
}
// Convert CollectionInfo to map[string]interface{} // Convert database record to result map (flatten to top level)
// Use a hybrid structure: flatten metadata to top level AND include metadata object
// This ensures backward compatibility with both access patterns:
// - collection.id / collection.collection_id (for ID)
// - collection.metadata.name (for nested access)
result := make(map[string]interface{}) result := make(map[string]interface{})
result["id"] = collection.ID // Primary ID field for frontend for k, v := range dbRecord {
result["collection_id"] = collection.ID // Alias for backward compatibility
// Flatten metadata fields to top level for backward compatibility
if collection.Metadata != nil {
for k, v := range collection.Metadata {
result[k] = v result[k] = v
} }
// Also include the metadata object itself
result["metadata"] = collection.Metadata // Set standard ID fields
result["id"] = collectionID
result["collection_id"] = collectionID
// Read from GraphRag and merge (for config and metadata object)
graphRagCollection, err := instance.GraphRag.GetCollection(ctx, collectionID)
if err == nil && graphRagCollection != nil {
// Set GraphRag config (vector store configuration)
if graphRagCollection.Config != nil {
result["config"] = graphRagCollection.Config
} }
if collection.Config != nil { // Set GraphRag metadata as nested object (for backward compatibility)
result["config"] = collection.Config // This allows access via collection["metadata"]["field"]
if graphRagCollection.Metadata != nil {
result["metadata"] = graphRagCollection.Metadata
// Also flatten GraphRag metadata fields to top level
// Only add fields that don't exist in database record
for k, v := range graphRagCollection.Metadata {
if _, exists := result[k]; !exists {
result[k] = v
}
}
}
} }
return result, nil return result, nil
} }
// CollectionExists checks if a collection exists by ID // CollectionExists checks if a collection exists by ID
// Checks both database and GraphRag for consistency
func (instance *KBInstance) CollectionExists(ctx context.Context, collectionID string) (*CollectionExistsResult, error) { func (instance *KBInstance) CollectionExists(ctx context.Context, collectionID string) (*CollectionExistsResult, error) {
if collectionID == "" { if collectionID == "" {
return nil, fmt.Errorf("collection ID is required") return nil, fmt.Errorf("collection ID is required")
} }
exists, err := instance.GraphRag.CollectionExists(ctx, collectionID) // Check database (source of truth for existence)
if err != nil { _, dbErr := instance.Config.FindCollection(collectionID, model.QueryParam{})
return nil, fmt.Errorf("failed to check collection existence: %w", err) dbExists := dbErr == nil
// Check GraphRag for consistency
graphRagExists, _ := instance.GraphRag.CollectionExists(ctx, collectionID)
// Collection exists if it exists in database
// Log warning if there's inconsistency (for debugging)
if dbExists != graphRagExists {
log.Warn("Collection %s existence mismatch: database=%v, graphrag=%v", collectionID, dbExists, graphRagExists)
} }
return &CollectionExistsResult{ return &CollectionExistsResult{
CollectionID: collectionID, CollectionID: collectionID,
Exists: exists, Exists: dbExists,
}, nil }, nil
} }

View file

@ -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())
} }

View file

@ -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
View 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
View 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)
})
}

View file

@ -17,18 +17,29 @@ 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)
// Segment operations (future) // 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)
// Search operations
Search(ctx context.Context, queries []Query) (*SearchResult, error)
} }
// 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
} }

556
kb/api/search.go Normal file
View file

@ -0,0 +1,556 @@
package api
import (
"context"
"fmt"
"sort"
"sync"
graphragtypes "github.com/yaoapp/gou/graphrag/types"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/kb/providers/factory"
)
// Default search parameters
const (
DefaultSearchK = 10
DefaultMaxDepth = 2
DefaultMinScore = 0.0
MaxSearchK = 100
DefaultSearchPageSize = 20
)
// Search performs batch search operations on the knowledge base
// Queries can span multiple collections; implementation groups by CollectionID
// Mode, providers (embedding/extraction/reranker) are read from each collection's config
// All results are merged and deduplicated
func (kb *KBInstance) Search(ctx context.Context, queries []Query) (*SearchResult, error) {
if len(queries) == 0 {
return &SearchResult{
Segments: []graphragtypes.Segment{},
Total: 0,
}, nil
}
// 1. Validate queries
if err := kb.validateQueries(queries); err != nil {
return nil, err
}
// 2. Group queries by CollectionID
groupedQueries := kb.groupQueriesByCollection(queries)
// 3. Process each collection group in parallel
var (
allSegments []graphragtypes.Segment
allGraph *GraphData
mu sync.Mutex
wg sync.WaitGroup
errChan = make(chan error, len(groupedQueries))
)
for collectionID, collQueries := range groupedQueries {
wg.Add(1)
go func(collID string, qs []Query) {
defer wg.Done()
segments, graph, err := kb.searchCollection(ctx, collID, qs)
if err != nil {
errChan <- fmt.Errorf("search in collection %s failed: %w", collID, err)
return
}
mu.Lock()
allSegments = append(allSegments, segments...)
if graph != nil {
allGraph = mergeGraphData(allGraph, graph)
}
mu.Unlock()
}(collectionID, collQueries)
}
wg.Wait()
close(errChan)
// Collect errors
var errs []error
for err := range errChan {
errs = append(errs, err)
}
if len(errs) > 0 {
// Combine all error messages
errMsgs := make([]string, len(errs))
for i, e := range errs {
errMsgs[i] = e.Error()
}
return nil, fmt.Errorf("search failed: %v", errMsgs)
}
// 4. Merge and deduplicate results
mergedSegments := kb.deduplicateSegments(allSegments)
// 5. Sort by score (descending)
sort.Slice(mergedSegments, func(i, j int) bool {
return mergedSegments[i].Score > mergedSegments[j].Score
})
// 6. Apply pagination from first query (if specified)
result := kb.applyPagination(mergedSegments, queries[0])
result.Graph = allGraph
return result, nil
}
// ========== Validation ==========
// validateQueries validates all queries
func (kb *KBInstance) validateQueries(queries []Query) error {
for i, q := range queries {
if q.CollectionID == "" {
return fmt.Errorf("query %d: collection_id is required", i)
}
if q.Input == "" && len(q.Messages) == 0 {
return fmt.Errorf("query %d: either input or messages is required", i)
}
}
return nil
}
// ========== Query Grouping ==========
// groupQueriesByCollection groups queries by their CollectionID
func (kb *KBInstance) groupQueriesByCollection(queries []Query) map[string][]Query {
grouped := make(map[string][]Query)
for _, q := range queries {
grouped[q.CollectionID] = append(grouped[q.CollectionID], q)
}
return grouped
}
// ========== Collection Search ==========
// searchCollection processes all queries for a single collection
func (kb *KBInstance) searchCollection(ctx context.Context, collectionID string, queries []Query) ([]graphragtypes.Segment, *GraphData, error) {
// Get collection config
collection, err := kb.GetCollection(ctx, collectionID)
if err != nil {
return nil, nil, fmt.Errorf("failed to get collection: %w", err)
}
// Get embedding provider from collection config
embeddingProviderID, _ := collection["embedding_provider_id"].(string)
embeddingOptionID, _ := collection["embedding_option_id"].(string)
if embeddingProviderID == "" || embeddingOptionID == "" {
return nil, nil, fmt.Errorf("collection %s missing embedding configuration", collectionID)
}
// Create embedding function
embedding, err := kb.createEmbedding(embeddingProviderID, embeddingOptionID, "en")
if err != nil {
return nil, nil, fmt.Errorf("failed to create embedding: %w", err)
}
var (
allSegments []graphragtypes.Segment
allGraph *GraphData
mu sync.Mutex
wg sync.WaitGroup
errChan = make(chan error, len(queries))
)
// Process queries in parallel
for _, query := range queries {
wg.Add(1)
go func(q Query) {
defer wg.Done()
segments, graph, err := kb.executeQuery(ctx, collectionID, q, embedding, collection)
if err != nil {
errChan <- err
return
}
mu.Lock()
allSegments = append(allSegments, segments...)
if graph != nil {
allGraph = mergeGraphData(allGraph, graph)
}
mu.Unlock()
}(query)
}
wg.Wait()
close(errChan)
// Collect errors
var errors []error
for err := range errChan {
errors = append(errors, err)
}
if len(errors) > 0 {
return allSegments, allGraph, errors[0]
}
return allSegments, allGraph, nil
}
// executeQuery executes a single query based on its mode
func (kb *KBInstance) executeQuery(ctx context.Context, collectionID string, query Query, embedding graphragtypes.Embedding, collection map[string]interface{}) ([]graphragtypes.Segment, *GraphData, error) {
// Determine search mode
mode := query.Mode
if mode == "" {
// Default to expand mode
mode = SearchModeExpand
}
// Get query text
queryText := kb.getQueryText(query)
if queryText == "" {
return nil, nil, fmt.Errorf("no query text found")
}
// Execute based on mode
switch mode {
case SearchModeVector:
return kb.searchVector(ctx, collectionID, queryText, query, embedding)
case SearchModeGraph:
return kb.searchGraph(ctx, collectionID, queryText, query, collection)
case SearchModeExpand:
return kb.searchExpand(ctx, collectionID, queryText, query, embedding, collection)
default:
return nil, nil, fmt.Errorf("unknown search mode: %s", mode)
}
}
// getQueryText extracts query text from Input or Messages
func (kb *KBInstance) getQueryText(query Query) string {
// Input takes precedence
if query.Input != "" {
return query.Input
}
// Extract from last user message
for i := len(query.Messages) - 1; i >= 0; i-- {
if query.Messages[i].Role == "user" {
return query.Messages[i].Content
}
}
return ""
}
// ========== Vector Search ==========
// searchVector performs pure vector similarity search
func (kb *KBInstance) searchVector(ctx context.Context, collectionID string, queryText string, query Query, embedding graphragtypes.Embedding) ([]graphragtypes.Segment, *GraphData, error) {
// Build search options
k := query.PageSize
if k <= 0 {
k = DefaultSearchK
}
if k > MaxSearchK {
k = MaxSearchK
}
options := &graphragtypes.VectorSearchOptions{
CollectionID: collectionID,
DocumentID: query.DocumentID,
Query: queryText,
K: k,
MinScore: query.MinScore,
Embedding: embedding,
}
// Add metadata filter
if len(query.Metadata) > 0 {
options.Filter = query.Metadata
}
// Execute search
result, err := kb.GraphRag.SearchVector(ctx, options)
if err != nil {
return nil, nil, fmt.Errorf("vector search failed: %w", err)
}
return result.Segments, nil, nil
}
// ========== Graph Search ==========
// searchGraph performs pure graph traversal search
func (kb *KBInstance) searchGraph(ctx context.Context, collectionID string, queryText string, query Query, collection map[string]interface{}) ([]graphragtypes.Segment, *GraphData, error) {
// Get extraction provider for entity extraction
extraction, err := kb.createExtraction(collection)
if err != nil {
return nil, nil, fmt.Errorf("failed to create extraction: %w", err)
}
// Build graph search options
maxDepth := query.MaxDepth
if maxDepth <= 0 {
maxDepth = DefaultMaxDepth
}
options := &graphragtypes.GraphSearchOptions{
CollectionID: collectionID,
DocumentID: query.DocumentID,
Query: queryText,
MaxDepth: maxDepth,
Extraction: extraction,
}
// Execute search
result, err := kb.GraphRag.SearchGraph(ctx, options)
if err != nil {
return nil, nil, fmt.Errorf("graph search failed: %w", err)
}
// Convert to GraphData
graph := &GraphData{
Nodes: result.Nodes,
Relationships: result.Relationships,
}
return result.Segments, graph, nil
}
// ========== Expand Search (Graph + Vector) ==========
// searchExpand performs graph-based entity expansion + vector search
// This mode uses graph to find related entities, then enhances vector search
func (kb *KBInstance) searchExpand(ctx context.Context, collectionID string, queryText string, query Query, embedding graphragtypes.Embedding, collection map[string]interface{}) ([]graphragtypes.Segment, *GraphData, error) {
// Step 1: Extract entities from query using graph search
extraction, err := kb.createExtraction(collection)
if err != nil {
// Fall back to pure vector search if extraction is not available
log.Warn("Extraction not available, falling back to vector search: %v", err)
return kb.searchVector(ctx, collectionID, queryText, query, embedding)
}
maxDepth := query.MaxDepth
if maxDepth <= 0 {
maxDepth = DefaultMaxDepth
}
graphOptions := &graphragtypes.GraphSearchOptions{
CollectionID: collectionID,
DocumentID: query.DocumentID,
Query: queryText,
MaxDepth: maxDepth,
Extraction: extraction,
}
// Execute graph search to find related entities
graphResult, graphErr := kb.GraphRag.SearchGraph(ctx, graphOptions)
// Step 2: Perform vector search
k := query.PageSize
if k <= 0 {
k = DefaultSearchK
}
if k > MaxSearchK {
k = MaxSearchK
}
vectorOptions := &graphragtypes.VectorSearchOptions{
CollectionID: collectionID,
DocumentID: query.DocumentID,
Query: queryText,
K: k,
MinScore: query.MinScore,
Embedding: embedding,
}
if len(query.Metadata) > 0 {
vectorOptions.Filter = query.Metadata
}
vectorResult, err := kb.GraphRag.SearchVector(ctx, vectorOptions)
if err != nil {
return nil, nil, fmt.Errorf("vector search failed: %w", err)
}
// Step 3: Merge results
segments := vectorResult.Segments
var graph *GraphData
if graphErr == nil && graphResult != nil {
// Add graph segments (deduplicated later)
segments = append(segments, graphResult.Segments...)
// Include graph data
graph = &GraphData{
Nodes: graphResult.Nodes,
Relationships: graphResult.Relationships,
}
}
return segments, graph, nil
}
// ========== Helper Functions ==========
// createEmbedding creates an embedding function from provider config
func (kb *KBInstance) createEmbedding(providerID, optionID, locale string) (graphragtypes.Embedding, error) {
if locale == "" {
locale = "en"
}
// Get provider option
option, err := kb.getProviderOption("embedding", providerID, optionID, locale)
if err != nil {
return nil, fmt.Errorf("failed to get embedding option: %w", err)
}
// Create embedding provider
return factory.MakeEmbedding(providerID, option)
}
// createExtraction creates an extraction function from collection config
func (kb *KBInstance) createExtraction(collection map[string]interface{}) (graphragtypes.Extraction, error) {
// Try to get extraction provider from collection metadata
metadata, _ := collection["metadata"].(map[string]interface{})
if metadata == nil {
metadata = collection
}
extractionProviderID, _ := metadata["__extraction_provider"].(string)
extractionOptionID, _ := metadata["__extraction_option"].(string)
// Fall back to default extraction provider
if extractionProviderID == "" {
extractionProviderID = "__yao.openai"
extractionOptionID = "gpt-4o-mini"
}
// Get provider option
option, err := kb.getProviderOption("extraction", extractionProviderID, extractionOptionID, "en")
if err != nil {
return nil, fmt.Errorf("failed to get extraction option: %w", err)
}
// Create extraction provider
return factory.MakeExtraction(extractionProviderID, option)
}
// deduplicateSegments removes duplicate segments by ID, keeping highest score
func (kb *KBInstance) deduplicateSegments(segments []graphragtypes.Segment) []graphragtypes.Segment {
seen := make(map[string]int) // ID -> index in result
result := make([]graphragtypes.Segment, 0, len(segments))
for _, seg := range segments {
if idx, exists := seen[seg.ID]; exists {
// Keep the one with higher score
if seg.Score > result[idx].Score {
result[idx] = seg
}
} else {
seen[seg.ID] = len(result)
result = append(result, seg)
}
}
return result
}
// mergeGraphData merges two GraphData objects
func mergeGraphData(a, b *GraphData) *GraphData {
if a == nil {
return b
}
if b == nil {
return a
}
// Merge nodes (deduplicate by ID)
nodeMap := make(map[string]graphragtypes.GraphNode)
for _, n := range a.Nodes {
nodeMap[n.ID] = n
}
for _, n := range b.Nodes {
nodeMap[n.ID] = n
}
nodes := make([]graphragtypes.GraphNode, 0, len(nodeMap))
for _, n := range nodeMap {
nodes = append(nodes, n)
}
// Merge relationships (deduplicate by ID)
relMap := make(map[string]graphragtypes.GraphRelationship)
for _, r := range a.Relationships {
relMap[r.ID] = r
}
for _, r := range b.Relationships {
relMap[r.ID] = r
}
relationships := make([]graphragtypes.GraphRelationship, 0, len(relMap))
for _, r := range relMap {
relationships = append(relationships, r)
}
return &GraphData{
Nodes: nodes,
Relationships: relationships,
}
}
// applyPagination applies pagination to segments
func (kb *KBInstance) applyPagination(segments []graphragtypes.Segment, query Query) *SearchResult {
total := len(segments)
// If no pagination requested, return all
if query.Page <= 0 && query.PageSize <= 0 {
return &SearchResult{
Segments: segments,
Total: total,
}
}
page := query.Page
if page <= 0 {
page = 1
}
pageSize := query.PageSize
if pageSize <= 0 {
pageSize = DefaultSearchPageSize
}
// Calculate pagination
totalPages := (total + pageSize - 1) / pageSize
start := (page - 1) * pageSize
end := start + pageSize
if start >= total {
return &SearchResult{
Segments: []graphragtypes.Segment{},
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
}
}
if end > total {
end = total
}
result := &SearchResult{
Segments: segments[start:end],
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
}
// Set next/prev page
if page < totalPages {
result.Next = page + 1
}
if page > 1 {
result.Prev = page - 1
}
return result
}

400
kb/api/search_setup_test.go Normal file
View file

@ -0,0 +1,400 @@
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
// ========== Fixed Test Collection IDs ==========
// Use fixed IDs so we can reuse them across test runs during development
const (
// SearchTestScienceCollection is the fixed ID for science test collection
SearchTestScienceCollection = "search_test_science"
// SearchTestTechCollection is the fixed ID for tech test collection
SearchTestTechCollection = "search_test_tech"
)
// ========== Setup Test - Run Once ==========
// TestSearchSetup creates test collections and documents for search testing.
// Run this once before running search tests:
//
// go test -v -run "TestSearchSetup" ./kb/api/...
//
// Then run search tests multiple times without waiting for data setup:
//
// go test -v -run "TestSearchQuery" ./kb/api/...
func TestSearchSetup(t *testing.T) {
if kb.API == nil {
t.Skip("KB API not initialized")
}
ctx := context.Background()
// Check if collections already exist and are complete
// We check both GraphRag (vector store) and document count
scienceComplete := false
techComplete := false
// Check Science collection
scienceCollection, scienceErr := kb.API.GetCollection(ctx, SearchTestScienceCollection)
if scienceErr == nil && scienceCollection != nil {
scienceDocs, _ := kb.API.ListDocuments(ctx, &api.ListDocumentsFilter{
Page: 1,
PageSize: 20,
CollectionID: SearchTestScienceCollection,
})
if scienceDocs != nil && len(scienceDocs.Data) >= 5 {
scienceComplete = true
t.Logf("✓ Science collection exists: %s (%d docs)", SearchTestScienceCollection, len(scienceDocs.Data))
}
}
// Check Tech collection
techCollection, techErr := kb.API.GetCollection(ctx, SearchTestTechCollection)
if techErr == nil && techCollection != nil {
techDocs, _ := kb.API.ListDocuments(ctx, &api.ListDocumentsFilter{
Page: 1,
PageSize: 20,
CollectionID: SearchTestTechCollection,
})
if techDocs != nil && len(techDocs.Data) >= 5 {
techComplete = true
t.Logf("✓ Tech collection exists: %s (%d docs)", SearchTestTechCollection, len(techDocs.Data))
}
}
// If both collections are complete, skip setup
if scienceComplete && techComplete {
t.Log("✓ All test collections already exist with sufficient documents")
t.Log(" Skipping setup. Run TestSearchCleanup first to recreate.")
return
}
// Clean up any existing collections (handles both complete and incomplete states)
// RemoveCollection cleans both database and GraphRag (including orphaned vector collections)
t.Log("Cleaning up existing collections...")
if result, err := kb.API.RemoveCollection(ctx, SearchTestScienceCollection); err == nil && result.Removed {
t.Logf(" Removed: %s", SearchTestScienceCollection)
}
if result, err := kb.API.RemoveCollection(ctx, SearchTestTechCollection); err == nil && result.Removed {
t.Logf(" Removed: %s", SearchTestTechCollection)
}
time.Sleep(1 * time.Second) // Wait for cleanup
// Create Science Collection
t.Log("Creating Science collection...")
scienceParams := &api.CreateCollectionParams{
ID: SearchTestScienceCollection,
Metadata: map[string]interface{}{
"name": "Science Knowledge Base",
"description": "Scientists and their discoveries for search testing",
},
EmbeddingProviderID: "__yao.openai",
EmbeddingOptionID: "text-embedding-3-small",
Locale: "en",
Config: &graphragtypes.CreateCollectionOptions{
Distance: "cosine",
IndexType: "hnsw",
},
}
_, err := kb.API.CreateCollection(ctx, scienceParams)
if err != nil {
t.Fatalf("Failed to create science collection: %v", err)
}
t.Logf("✓ Created collection: %s", SearchTestScienceCollection)
// Create Tech Collection
t.Log("Creating Tech collection...")
techParams := &api.CreateCollectionParams{
ID: SearchTestTechCollection,
Metadata: map[string]interface{}{
"name": "Tech Knowledge Base",
"description": "Technology companies and products for search testing",
},
EmbeddingProviderID: "__yao.openai",
EmbeddingOptionID: "text-embedding-3-small",
Locale: "en",
Config: &graphragtypes.CreateCollectionOptions{
Distance: "cosine",
IndexType: "hnsw",
},
}
_, err = kb.API.CreateCollection(ctx, techParams)
if err != nil {
t.Fatalf("Failed to create tech collection: %v", err)
}
t.Logf("✓ Created collection: %s", SearchTestTechCollection)
// Add Science Documents
// Entity relationships: Einstein -> Relativity -> Physics -> Nobel Prize
scienceDocs := []struct {
title string
content string
}{
{
title: "Albert Einstein Biography",
content: `Albert Einstein was a theoretical physicist born in Germany in 1879.
He developed the theory of relativity, one of the two pillars of modern physics.
Einstein received the Nobel Prize in Physics in 1921 for his discovery of the photoelectric effect.
He later emigrated to the United States and worked at Princeton University until his death in 1955.`,
},
{
title: "Theory of Relativity",
content: `The theory of relativity was developed by Albert Einstein in the early 20th century.
It consists of special relativity (1905) and general relativity (1915).
Special relativity introduced E=mc², showing the relationship between energy and mass.
General relativity describes gravity as the curvature of spacetime caused by mass and energy.`,
},
{
title: "Marie Curie Biography",
content: `Marie Curie was a Polish-French physicist and chemist who conducted pioneering research on radioactivity.
She was the first woman to win a Nobel Prize and the only person to win Nobel Prizes in two different sciences (Physics and Chemistry).
Curie discovered the elements polonium and radium. She founded the Curie Institutes in Paris and Warsaw.`,
},
{
title: "Nobel Prize in Physics",
content: `The Nobel Prize in Physics is awarded annually by the Royal Swedish Academy of Sciences.
Notable recipients include Albert Einstein (1921) for the photoelectric effect,
Marie Curie (1903) for research on radiation phenomena,
and Niels Bohr (1922) for his contributions to understanding atomic structure.`,
},
{
title: "Quantum Mechanics Foundations",
content: `Quantum mechanics emerged in the early 20th century through the work of many physicists.
Max Planck introduced the concept of energy quanta in 1900.
Niels Bohr proposed the Bohr model of the atom.
Werner Heisenberg developed the uncertainty principle.
These discoveries built upon Einstein's work on the photoelectric effect.`,
},
}
t.Log("Adding Science documents...")
for _, doc := range scienceDocs {
docID := addFixedTestDocument(t, ctx, SearchTestScienceCollection, doc.title, doc.content)
if docID != "" {
t.Logf(" ✓ Added: %s", doc.title)
}
}
// Add Tech Documents
// Entity relationships: Apple -> Steve Jobs -> iPhone -> iOS
techDocs := []struct {
title string
content string
}{
{
title: "Apple Inc History",
content: `Apple Inc. was founded by Steve Jobs, Steve Wozniak, and Ronald Wayne in 1976.
The company revolutionized personal computing with the Macintosh in 1984.
Under Steve Jobs' leadership, Apple introduced the iPhone in 2007, which transformed the smartphone industry.
Apple is headquartered in Cupertino, California.`,
},
{
title: "iPhone Development",
content: `The iPhone was introduced by Steve Jobs at Macworld 2007.
It combined a mobile phone, widescreen iPod, and internet device into one product.
The iPhone runs on iOS, Apple's mobile operating system.
The App Store, launched in 2008, created a new ecosystem for mobile applications.`,
},
{
title: "Google and AI",
content: `Google has been a pioneer in artificial intelligence and machine learning.
The company developed TensorFlow, an open-source machine learning framework.
Google's AI research includes natural language processing, computer vision, and deep learning.
Google Brain and DeepMind are the company's main AI research divisions.`,
},
{
title: "Machine Learning Applications",
content: `Machine learning is transforming various industries through AI applications.
Google uses ML for search ranking, language translation, and image recognition.
TensorFlow enables developers to build and train neural networks.
Deep learning models can now understand natural language and generate human-like text.`,
},
{
title: "Tech Industry Leaders",
content: `The technology industry has been shaped by visionary leaders.
Steve Jobs transformed Apple into the world's most valuable company.
Larry Page and Sergey Brin founded Google and pioneered internet search.
Elon Musk leads Tesla and SpaceX, pushing boundaries in electric vehicles and space exploration.`,
},
}
t.Log("Adding Tech documents...")
for _, doc := range techDocs {
docID := addFixedTestDocument(t, ctx, SearchTestTechCollection, doc.title, doc.content)
if docID != "" {
t.Logf(" ✓ Added: %s", doc.title)
}
}
// Wait for indexing
t.Log("Waiting for indexing...")
time.Sleep(2 * time.Second)
// Verify setup
t.Log("Verifying setup...")
scienceDocsResult, _ := kb.API.ListDocuments(ctx, &api.ListDocumentsFilter{
Page: 1,
PageSize: 20,
CollectionID: SearchTestScienceCollection,
})
techDocsResult, _ := kb.API.ListDocuments(ctx, &api.ListDocumentsFilter{
Page: 1,
PageSize: 20,
CollectionID: SearchTestTechCollection,
})
t.Logf("✓ Setup complete!")
t.Logf(" Science collection: %d documents", len(scienceDocsResult.Data))
t.Logf(" Tech collection: %d documents", len(techDocsResult.Data))
t.Logf("")
t.Logf("Now run search tests with:")
t.Logf(" go test -v -run 'TestSearchQuery' ./kb/api/...")
}
// ========== Cleanup Test ==========
// TestSearchCleanup removes test collections.
// Run this to clean up test data:
//
// go test -v -run "TestSearchCleanup" ./kb/api/...
func TestSearchCleanup(t *testing.T) {
if kb.API == nil {
t.Skip("KB API not initialized")
}
ctx := context.Background()
t.Log("Removing test collections...")
result1, err := kb.API.RemoveCollection(ctx, SearchTestScienceCollection)
if err != nil {
t.Logf(" Science collection removal: %v", err)
} else if result1.Removed {
t.Logf("✓ Removed: %s", SearchTestScienceCollection)
}
result2, err := kb.API.RemoveCollection(ctx, SearchTestTechCollection)
if err != nil {
t.Logf(" Tech collection removal: %v", err)
} else if result2.Removed {
t.Logf("✓ Removed: %s", SearchTestTechCollection)
}
t.Log("✓ Cleanup complete!")
}
// ========== Verify Test ==========
// TestSearchVerify checks if test collections exist and have documents.
// Run this to verify test data:
//
// go test -v -run "TestSearchVerify" ./kb/api/...
func TestSearchVerify(t *testing.T) {
if kb.API == nil {
t.Skip("KB API not initialized")
}
ctx := context.Background()
// Check Science collection
scienceExists, err := kb.API.CollectionExists(ctx, SearchTestScienceCollection)
if err != nil {
t.Fatalf("Failed to check science collection: %v", err)
}
if !scienceExists.Exists {
t.Fatalf("✗ Science collection does not exist. Run TestSearchSetup first.")
}
scienceDocs, err := kb.API.ListDocuments(ctx, &api.ListDocumentsFilter{
Page: 1,
PageSize: 20,
CollectionID: SearchTestScienceCollection,
})
assert.NoError(t, err)
t.Logf("✓ Science collection: %s (%d documents)", SearchTestScienceCollection, len(scienceDocs.Data))
for _, doc := range scienceDocs.Data {
t.Logf(" - %s", doc["name"])
}
// Check Tech collection
techExists, err := kb.API.CollectionExists(ctx, SearchTestTechCollection)
if err != nil {
t.Fatalf("Failed to check tech collection: %v", err)
}
if !techExists.Exists {
t.Fatalf("✗ Tech collection does not exist. Run TestSearchSetup first.")
}
techDocs, err := kb.API.ListDocuments(ctx, &api.ListDocumentsFilter{
Page: 1,
PageSize: 20,
CollectionID: SearchTestTechCollection,
})
assert.NoError(t, err)
t.Logf("✓ Tech collection: %s (%d documents)", SearchTestTechCollection, len(techDocs.Data))
for _, doc := range techDocs.Data {
t.Logf(" - %s", doc["name"])
}
t.Log("")
t.Log("✓ Test data verified! Ready for search tests.")
}
// ========== Helper Functions ==========
// addFixedTestDocument adds a document for search testing
func addFixedTestDocument(t *testing.T, ctx context.Context, collectionID, title, content string) string {
params := &api.AddTextParams{
CollectionID: collectionID,
Text: content,
DocID: fmt.Sprintf("%s__%s", collectionID, sanitizeTitle(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",
},
// Enable extraction for graph-based search
Extraction: &api.ProviderConfigParams{
ProviderID: "__yao.openai",
OptionID: "gpt-4o-mini",
},
}
result, err := kb.API.AddText(ctx, params)
if err != nil {
t.Logf("Warning: Failed to add document '%s': %v", title, err)
return ""
}
return result.DocID
}
// sanitizeTitle converts title to a safe ID format
func sanitizeTitle(title string) string {
result := ""
for _, c := range title {
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') {
result += string(c)
} else if c == ' ' {
result += "_"
}
}
return result
}

436
kb/api/search_test.go Normal file
View file

@ -0,0 +1,436 @@
package api_test
import (
"context"
"testing"
"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
// Note: Test data setup is in search_setup_test.go
// ========== Search Query Tests ==========
// ensureTestDataExists ensures test collections exist by running setup if needed
// Setup will skip creation if data already exists
func ensureTestDataExists(t *testing.T, ctx context.Context) {
// Run setup - it checks if data exists and skips if already complete
TestSearchSetup(t)
}
func TestSearchQuery(t *testing.T) {
if kb.API == nil {
t.Skip("KB API not initialized")
}
ctx := context.Background()
ensureTestDataExists(t, ctx)
t.Run("VectorSearch_SingleCollection", func(t *testing.T) {
// Test: Simple vector search in science collection
// Query about Einstein should find Einstein-related documents
queries := []api.Query{
{
CollectionID: SearchTestScienceCollection,
Input: "Who is Albert Einstein and what did he discover?",
Mode: api.SearchModeVector,
PageSize: 5,
},
}
result, err := kb.API.Search(ctx, queries)
if err != nil {
t.Logf("Search error (may be expected if not implemented): %v", err)
return
}
if result == nil {
t.Skip("Search not implemented yet (returned nil)")
}
assert.Greater(t, len(result.Segments), 0, "Should find segments about Einstein")
t.Logf("Vector search returned %d segments", len(result.Segments))
// Verify relevance - top results should mention Einstein
for i, seg := range result.Segments {
t.Logf(" Segment %d (score: %.4f): %s...", i, seg.Score, truncateText(seg.Text, 100))
}
})
t.Run("VectorSearch_MultipleQueries", func(t *testing.T) {
// Test: Multiple queries in same collection, results should be merged
queries := []api.Query{
{
CollectionID: SearchTestScienceCollection,
Input: "relativity theory",
Mode: api.SearchModeVector,
PageSize: 3,
},
{
CollectionID: SearchTestScienceCollection,
Input: "Nobel Prize physics",
Mode: api.SearchModeVector,
PageSize: 3,
},
}
result, err := kb.API.Search(ctx, queries)
if err != nil {
t.Logf("Search error: %v", err)
return
}
assert.NotNil(t, result)
t.Logf("Multi-query search returned %d merged segments", len(result.Segments))
})
t.Run("VectorSearch_CrossCollection", func(t *testing.T) {
// Test: Search across both collections
queries := []api.Query{
{
CollectionID: SearchTestScienceCollection,
Input: "innovation and discovery",
Mode: api.SearchModeVector,
PageSize: 3,
},
{
CollectionID: SearchTestTechCollection,
Input: "technology innovation",
Mode: api.SearchModeVector,
PageSize: 3,
},
}
result, err := kb.API.Search(ctx, queries)
if err != nil {
t.Logf("Search error: %v", err)
return
}
assert.NotNil(t, result)
t.Logf("Cross-collection search returned %d segments", len(result.Segments))
})
t.Run("ExpandSearch_EntityExpansion", func(t *testing.T) {
// Test: Expand mode should find related entities through graph
// Query: "photoelectric effect" should expand to find:
// - Einstein (discovered it)
// - Nobel Prize (awarded for it)
// - Quantum mechanics (built upon it)
queries := []api.Query{
{
CollectionID: SearchTestScienceCollection,
Input: "photoelectric effect",
Mode: api.SearchModeExpand,
MaxDepth: 2,
PageSize: 5,
},
}
result, err := kb.API.Search(ctx, queries)
if err != nil {
t.Logf("Expand search error: %v", err)
return
}
assert.NotNil(t, result)
t.Logf("Expand search returned %d segments", len(result.Segments))
// Check if graph data is returned
if result.Graph != nil {
t.Logf(" Graph nodes: %d, relationships: %d",
len(result.Graph.Nodes), len(result.Graph.Relationships))
}
// Verify expanded results include related entities
for i, seg := range result.Segments {
t.Logf(" Segment %d (score: %.4f): %s...", i, seg.Score, truncateText(seg.Text, 100))
}
})
t.Run("ExpandSearch_DeepAssociation", func(t *testing.T) {
// Test: Deep association through entity relationships
// Query: "Germany physics" should expand to find:
// - Einstein (born in Germany, physicist)
// - Relativity (Einstein's theory)
// - Planck (German physicist, quantum theory)
queries := []api.Query{
{
CollectionID: SearchTestScienceCollection,
Input: "German physicist contributions",
Mode: api.SearchModeExpand,
MaxDepth: 3,
PageSize: 5,
},
}
result, err := kb.API.Search(ctx, queries)
if err != nil {
t.Logf("Deep expand search error: %v", err)
return
}
assert.NotNil(t, result)
t.Logf("Deep expand search returned %d segments", len(result.Segments))
})
t.Run("GraphSearch_EntityTraversal", func(t *testing.T) {
// Test: Pure graph search - find segments through entity relationships
queries := []api.Query{
{
CollectionID: SearchTestTechCollection,
Input: "Steve Jobs",
Mode: api.SearchModeGraph,
MaxDepth: 2,
PageSize: 5,
},
}
result, err := kb.API.Search(ctx, queries)
if err != nil {
t.Logf("Graph search error: %v", err)
return
}
assert.NotNil(t, result)
t.Logf("Graph search returned %d segments", len(result.Segments))
if result.Graph != nil {
t.Logf(" Found %d nodes, %d relationships",
len(result.Graph.Nodes), len(result.Graph.Relationships))
for _, node := range result.Graph.Nodes {
t.Logf(" Node: %s (%s)", node.ID, node.EntityType)
}
}
})
t.Run("Search_WithMessages", func(t *testing.T) {
// Test: Search using conversation history instead of direct input
queries := []api.Query{
{
CollectionID: SearchTestScienceCollection,
Messages: []graphragtypes.ChatMessage{
{Role: "user", Content: "Tell me about famous physicists"},
{Role: "assistant", Content: "There are many famous physicists throughout history..."},
{Role: "user", Content: "What about Einstein specifically?"},
},
Mode: api.SearchModeVector,
PageSize: 5,
},
}
result, err := kb.API.Search(ctx, queries)
if err != nil {
t.Logf("Message-based search error: %v", err)
return
}
assert.NotNil(t, result)
t.Logf("Message-based search returned %d segments", len(result.Segments))
})
t.Run("Search_WithDocumentFilter", func(t *testing.T) {
// Test: Search within a specific document
// First, get a document ID
filter := &api.ListDocumentsFilter{
Page: 1,
PageSize: 1,
CollectionID: SearchTestScienceCollection,
}
listResult, err := kb.API.ListDocuments(ctx, filter)
if err != nil || len(listResult.Data) == 0 {
t.Skip("No documents available for filter test")
}
docID, ok := listResult.Data[0]["document_id"].(string)
if !ok {
t.Skip("Could not get document ID")
}
queries := []api.Query{
{
CollectionID: SearchTestScienceCollection,
DocumentID: docID,
Input: "physics discovery",
Mode: api.SearchModeVector,
PageSize: 5,
},
}
result, err := kb.API.Search(ctx, queries)
if err != nil {
t.Logf("Document-filtered search error: %v", err)
return
}
assert.NotNil(t, result)
t.Logf("Document-filtered search returned %d segments", len(result.Segments))
// Verify all results are from the specified document
for _, seg := range result.Segments {
if seg.DocumentID != "" {
assert.Equal(t, docID, seg.DocumentID, "All segments should be from filtered document")
}
}
})
t.Run("Search_WithPagination", func(t *testing.T) {
// Test: Pagination
queries := []api.Query{
{
CollectionID: SearchTestScienceCollection,
Input: "physics",
Mode: api.SearchModeVector,
Page: 1,
PageSize: 2,
},
}
result, err := kb.API.Search(ctx, queries)
if err != nil {
t.Logf("Paginated search error: %v", err)
return
}
assert.NotNil(t, result)
assert.LessOrEqual(t, len(result.Segments), 2, "Should respect page size")
t.Logf("Page 1: %d segments, Total: %d, TotalPages: %d",
len(result.Segments), result.Total, result.TotalPages)
// Get page 2
queries[0].Page = 2
result2, err := kb.API.Search(ctx, queries)
if err != nil {
t.Logf("Page 2 search error: %v", err)
return
}
if result2 != nil && len(result2.Segments) > 0 {
t.Logf("Page 2: %d segments", len(result2.Segments))
}
})
t.Run("Search_WithMinScore", func(t *testing.T) {
// Test: Filter by minimum score
queries := []api.Query{
{
CollectionID: SearchTestScienceCollection,
Input: "Einstein relativity",
Mode: api.SearchModeVector,
MinScore: 0.5,
PageSize: 10,
},
}
result, err := kb.API.Search(ctx, queries)
if err != nil {
t.Logf("MinScore search error: %v", err)
return
}
assert.NotNil(t, result)
t.Logf("MinScore search returned %d segments", len(result.Segments))
// Verify all results meet minimum score
for _, seg := range result.Segments {
assert.GreaterOrEqual(t, seg.Score, 0.5, "All segments should meet minimum score")
}
})
t.Run("Search_WithMetadataFilter", func(t *testing.T) {
// Test: Filter by metadata
queries := []api.Query{
{
CollectionID: SearchTestScienceCollection,
Input: "physics",
Mode: api.SearchModeVector,
Metadata: map[string]interface{}{
"title": "Albert Einstein Biography",
},
PageSize: 10,
},
}
result, err := kb.API.Search(ctx, queries)
if err != nil {
t.Logf("Metadata filter search error: %v", err)
return
}
assert.NotNil(t, result)
t.Logf("Metadata-filtered search returned %d segments", len(result.Segments))
})
}
// ========== Error Handling Tests ==========
func TestSearchErrorHandling(t *testing.T) {
if kb.API == nil {
t.Skip("KB API not initialized")
}
ctx := context.Background()
t.Run("EmptyQueries", func(t *testing.T) {
result, err := kb.API.Search(ctx, []api.Query{})
// Empty queries should return empty result or error
if err != nil {
assert.Contains(t, err.Error(), "required")
} else {
assert.NotNil(t, result)
assert.Equal(t, 0, len(result.Segments))
}
})
t.Run("MissingCollectionID", func(t *testing.T) {
queries := []api.Query{
{
Input: "test query",
Mode: api.SearchModeVector,
},
}
_, err := kb.API.Search(ctx, queries)
assert.Error(t, err)
assert.Contains(t, err.Error(), "collection")
})
t.Run("MissingInputAndMessages", func(t *testing.T) {
queries := []api.Query{
{
CollectionID: "some_collection",
Mode: api.SearchModeVector,
},
}
_, err := kb.API.Search(ctx, queries)
assert.Error(t, err)
assert.Contains(t, err.Error(), "input")
})
t.Run("NonexistentCollection", func(t *testing.T) {
queries := []api.Query{
{
CollectionID: "nonexistent_collection_xyz",
Input: "test query",
Mode: api.SearchModeVector,
},
}
_, err := kb.API.Search(ctx, queries)
assert.Error(t, err)
})
}
// ========== Helper Functions ==========
func truncateText(text string, maxLen int) string {
if len(text) <= maxLen {
return text
}
return text[:maxLen] + "..."
}

View file

@ -71,3 +71,204 @@ 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"`
}
// ========== Search Types ==========
// SearchMode defines the search strategy
type SearchMode string
const (
// SearchModeVector performs pure vector similarity search
SearchModeVector SearchMode = "vector"
// SearchModeGraph performs graph traversal to find related segments
SearchModeGraph SearchMode = "graph"
// SearchModeExpand uses graph to expand/associate entities, then enhances vector search
// This enables deeper semantic connections through entity relationships
SearchModeExpand SearchMode = "expand"
)
// Query represents a single search query
type Query struct {
// CollectionID is the collection to search in (required)
CollectionID string `json:"collection_id" yaml:"collection_id"`
// Input is the direct search query text (e.g., LLM-summarized query)
// Either Input or Messages is required; Input takes precedence if both provided
Input string `json:"input,omitempty" yaml:"input,omitempty"`
// Messages is the conversation history for context-aware search
// The last user message is used as the query if Input is empty
Messages []types.ChatMessage `json:"messages,omitempty" yaml:"messages,omitempty"`
// Mode determines the search strategy (optional, defaults to collection config or "expand")
// - vector: pure vector similarity search
// - graph: graph traversal to find related segments
// - expand: graph-based entity expansion/association + vector search
Mode SearchMode `json:"mode,omitempty" yaml:"mode,omitempty"`
// DocumentID filters results to a specific document (optional)
DocumentID string `json:"document_id,omitempty" yaml:"document_id,omitempty"`
// MinScore filters results below this similarity threshold (optional)
MinScore float64 `json:"min_score,omitempty" yaml:"min_score,omitempty"`
// Metadata filters segments by metadata fields (optional)
Metadata map[string]interface{} `json:"metadata,omitempty" yaml:"metadata,omitempty"`
// Graph search options (used when Mode is graph or expand)
MaxDepth int `json:"max_depth,omitempty" yaml:"max_depth,omitempty"` // Max traversal depth (default: 2)
// Pagination options
// If not specified, returns default number of results
Page int `json:"page,omitempty" yaml:"page,omitempty"` // Page number (1-based), 0 means no pagination
PageSize int `json:"pagesize,omitempty" yaml:"pagesize,omitempty"` // Number of results per page
Cursor string `json:"cursor,omitempty" yaml:"cursor,omitempty"` // Cursor for cursor-based pagination
}
// GraphData contains graph-specific search results
type GraphData struct {
Nodes []types.GraphNode `json:"nodes,omitempty" yaml:"nodes,omitempty"`
Relationships []types.GraphRelationship `json:"relationships,omitempty" yaml:"relationships,omitempty"`
}
// SearchResult represents the merged result of search operations
type SearchResult struct {
// Segments contains the merged and deduplicated text segments with scores
Segments []types.Segment `json:"segments" yaml:"segments"`
// Graph contains merged nodes and relationships (only for graph/hybrid mode)
Graph *GraphData `json:"graph,omitempty" yaml:"graph,omitempty"`
// Pagination info
Page int `json:"page,omitempty" yaml:"page,omitempty"` // Current page number
PageSize int `json:"pagesize,omitempty" yaml:"pagesize,omitempty"` // Results per page
Total int `json:"total" yaml:"total"` // Total number of results
TotalPages int `json:"pagecnt,omitempty" yaml:"pagecnt,omitempty"` // Total pages
Next int `json:"next,omitempty" yaml:"next,omitempty"` // Next page number
Prev int `json:"prev,omitempty" yaml:"prev,omitempty"` // Previous page number
Cursor string `json:"cursor,omitempty" yaml:"cursor,omitempty"` // Cursor for next page
}

325
kb/api/utils.go Normal file
View 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
}
}
}

View file

@ -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 { if err != nil {
exception.New("failed to create document record: %s", 500, err.Error()).Throw() exception.New("failed to add file: %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 {
exception.New("failed to process 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
} }

View file

@ -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 { if err != nil {
exception.New("failed to create document record: %s", 500, err.Error()).Throw() exception.New("failed to add text: %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 {
exception.New("failed to process 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
} }

View file

@ -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 { if err != nil {
exception.New("failed to create document record: %s", 500, err.Error()).Throw() exception.New("failed to add URL: %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 {
exception.New("failed to process 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
} }

View file

@ -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,83 +85,43 @@ 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"))
@ -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) // First part contains prefix_collection_id
return nil, err prefix := parts[0]
// Find the first underscore to skip the prefix
idx := strings.Index(prefix, "_")
if idx == -1 {
return prefix
} }
return upsertOptions, nil return prefix[idx+1:]
}
// validateFileAndGetPath validates file manager, file existence and gets local path
func validateFileAndGetPath(c *gin.Context, req *AddFileRequest) (string, string, 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 "", "", 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
} }

View file

@ -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

View file

@ -0,0 +1,368 @@
package openapi_test
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/openapi"
"github.com/yaoapp/yao/openapi/tests/testutils"
)
// TestAddFile tests the add file endpoint (sync)
func TestAddFile(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register test client and get token
client := testutils.RegisterTestClient(t, "KB AddFile Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Create a test collection first
testCollectionID := fmt.Sprintf("test_addfile_collection_%d", time.Now().UnixNano())
testutils.RegisterTestCollection(testCollectionID)
createData := map[string]interface{}{
"id": testCollectionID,
"metadata": map[string]interface{}{
"name": "Test Collection for AddFile",
"category": "test",
},
"config": map[string]interface{}{
"embedding_provider_id": "__yao.openai",
"embedding_option_id": "text-embedding-3-small",
"locale": "en",
"index_type": "hnsw",
"distance": "cosine",
},
}
body, _ := json.Marshal(createData)
req, _ := http.NewRequest("POST", serverURL+baseURL+"/kb/collections", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Failed to create test collection: %v", err)
}
resp.Body.Close()
t.Run("AddFileInvalidRequest", func(t *testing.T) {
// Test with missing required fields
invalidData := map[string]interface{}{
"collection_id": testCollectionID,
// Missing file_id, chunking, embedding
}
body, err := json.Marshal(invalidData)
assert.NoError(t, err)
req, err := http.NewRequest("POST", serverURL+baseURL+"/kb/collections/"+testCollectionID+"/documents/file", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 400 Bad Request
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("AddFileMissingFileID", func(t *testing.T) {
addData := map[string]interface{}{
"collection_id": testCollectionID,
// Missing file_id
"chunking": map[string]interface{}{
"provider_id": "__yao.structured",
"option_id": "standard",
},
"embedding": map[string]interface{}{
"provider_id": "__yao.openai",
"option_id": "text-embedding-3-small",
},
}
body, err := json.Marshal(addData)
assert.NoError(t, err)
req, err := http.NewRequest("POST", serverURL+baseURL+"/kb/collections/"+testCollectionID+"/documents/file", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 400 Bad Request
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
// Error message contains FileID (case insensitive check)
assert.Contains(t, response["error_description"], "FileID")
})
t.Run("AddFileNonExistentCollection", func(t *testing.T) {
// Test with a non-existent collection
addData := map[string]interface{}{
"collection_id": "non_existent_collection_12345",
"file_id": "test_file_123",
"uploader": "local",
"chunking": map[string]interface{}{
"provider_id": "__yao.structured",
"option_id": "standard",
},
"embedding": map[string]interface{}{
"provider_id": "__yao.openai",
"option_id": "text-embedding-3-small",
},
}
body, err := json.Marshal(addData)
assert.NoError(t, err)
req, err := http.NewRequest("POST", serverURL+baseURL+"/kb/collections/non_existent_collection_12345/documents/file", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 403 Forbidden, 404 Not Found, or 500 Internal Server Error for non-existent collection
// (depends on whether permission check or collection lookup happens first)
assert.True(t, resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusInternalServerError,
"Expected 403, 404, or 500, got %d", resp.StatusCode)
})
t.Run("AddFileUnauthorized", func(t *testing.T) {
addData := map[string]interface{}{
"collection_id": testCollectionID,
"file_id": "test_file_123",
"uploader": "local",
"chunking": map[string]interface{}{
"provider_id": "__yao.structured",
"option_id": "standard",
},
"embedding": map[string]interface{}{
"provider_id": "__yao.openai",
"option_id": "text-embedding-3-small",
},
}
body, err := json.Marshal(addData)
assert.NoError(t, err)
req, err := http.NewRequest("POST", serverURL+baseURL+"/kb/collections/"+testCollectionID+"/documents/file", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
// No Authorization header
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 401 Unauthorized
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}
// TestAddFileAsync tests the add file async endpoint
func TestAddFileAsync(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register test client and get token
client := testutils.RegisterTestClient(t, "KB AddFileAsync Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Create a test collection first
testCollectionID := fmt.Sprintf("test_addfile_async_collection_%d", time.Now().UnixNano())
testutils.RegisterTestCollection(testCollectionID)
createData := map[string]interface{}{
"id": testCollectionID,
"metadata": map[string]interface{}{
"name": "Test Collection for AddFileAsync",
"category": "test",
},
"config": map[string]interface{}{
"embedding_provider_id": "__yao.openai",
"embedding_option_id": "text-embedding-3-small",
"locale": "en",
"index_type": "hnsw",
"distance": "cosine",
},
}
body, _ := json.Marshal(createData)
req, _ := http.NewRequest("POST", serverURL+baseURL+"/kb/collections", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Failed to create test collection: %v", err)
}
resp.Body.Close()
t.Run("AddFileAsyncInvalidRequest", func(t *testing.T) {
// Test with missing required fields
invalidData := map[string]interface{}{
"collection_id": testCollectionID,
// Missing file_id, chunking, embedding
}
body, err := json.Marshal(invalidData)
assert.NoError(t, err)
req, err := http.NewRequest("POST", serverURL+baseURL+"/kb/collections/"+testCollectionID+"/documents/file/async", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 400 Bad Request
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("AddFileAsyncMissingFileID", func(t *testing.T) {
addData := map[string]interface{}{
"collection_id": testCollectionID,
// Missing file_id
"chunking": map[string]interface{}{
"provider_id": "__yao.structured",
"option_id": "standard",
},
"embedding": map[string]interface{}{
"provider_id": "__yao.openai",
"option_id": "text-embedding-3-small",
},
}
body, err := json.Marshal(addData)
assert.NoError(t, err)
req, err := http.NewRequest("POST", serverURL+baseURL+"/kb/collections/"+testCollectionID+"/documents/file/async", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 400 Bad Request
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
// Error message contains FileID (case insensitive check)
assert.Contains(t, response["error_description"], "FileID")
})
t.Run("AddFileAsyncUnauthorized", func(t *testing.T) {
addData := map[string]interface{}{
"collection_id": testCollectionID,
"file_id": "test_file_123",
"uploader": "local",
"chunking": map[string]interface{}{
"provider_id": "__yao.structured",
"option_id": "standard",
},
"embedding": map[string]interface{}{
"provider_id": "__yao.openai",
"option_id": "text-embedding-3-small",
},
}
body, err := json.Marshal(addData)
assert.NoError(t, err)
req, err := http.NewRequest("POST", serverURL+baseURL+"/kb/collections/"+testCollectionID+"/documents/file/async", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
// No Authorization header
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 401 Unauthorized
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("AddFileAsyncFileNotFound", func(t *testing.T) {
// Test with a file_id that doesn't exist
addData := map[string]interface{}{
"collection_id": testCollectionID,
"file_id": "non_existent_file_12345",
"uploader": "local",
"chunking": map[string]interface{}{
"provider_id": "__yao.structured",
"option_id": "standard",
},
"embedding": map[string]interface{}{
"provider_id": "__yao.openai",
"option_id": "text-embedding-3-small",
},
}
body, err := json.Marshal(addData)
assert.NoError(t, err)
req, err := http.NewRequest("POST", serverURL+baseURL+"/kb/collections/"+testCollectionID+"/documents/file/async", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 404 Not Found for non-existent file
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
})
}

View file

@ -0,0 +1,401 @@
package openapi_test
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/openapi"
"github.com/yaoapp/yao/openapi/tests/testutils"
)
// TestAddText tests the add text endpoint (sync)
func TestAddText(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register test client and get token
client := testutils.RegisterTestClient(t, "KB AddText Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Create a test collection first
testCollectionID := fmt.Sprintf("test_addtext_collection_%d", time.Now().UnixNano())
testutils.RegisterTestCollection(testCollectionID)
createData := map[string]interface{}{
"id": testCollectionID,
"metadata": map[string]interface{}{
"name": "Test Collection for AddText",
"category": "test",
},
"config": map[string]interface{}{
"embedding_provider_id": "__yao.openai",
"embedding_option_id": "text-embedding-3-small",
"locale": "en",
"index_type": "hnsw",
"distance": "cosine",
},
}
body, _ := json.Marshal(createData)
req, _ := http.NewRequest("POST", serverURL+baseURL+"/kb/collections", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Failed to create test collection: %v", err)
}
resp.Body.Close()
t.Run("AddTextInvalidRequest", func(t *testing.T) {
// Test with missing required fields
invalidData := map[string]interface{}{
"collection_id": testCollectionID,
// Missing text, chunking, embedding
}
body, err := json.Marshal(invalidData)
assert.NoError(t, err)
req, err := http.NewRequest("POST", serverURL+baseURL+"/kb/collections/"+testCollectionID+"/documents/text", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 400 Bad Request
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("AddTextMissingText", func(t *testing.T) {
addData := map[string]interface{}{
"collection_id": testCollectionID,
// Missing text
"chunking": map[string]interface{}{
"provider_id": "__yao.structured",
"option_id": "standard",
},
"embedding": map[string]interface{}{
"provider_id": "__yao.openai",
"option_id": "text-embedding-3-small",
},
}
body, err := json.Marshal(addData)
assert.NoError(t, err)
req, err := http.NewRequest("POST", serverURL+baseURL+"/kb/collections/"+testCollectionID+"/documents/text", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 400 Bad Request
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
// Error message contains Text (case insensitive check)
assert.Contains(t, response["error_description"], "Text")
})
t.Run("AddTextNonExistentCollection", func(t *testing.T) {
// Test with a non-existent collection
addData := map[string]interface{}{
"collection_id": "non_existent_collection_12345",
"text": "This is a test text content for the knowledge base.",
"chunking": map[string]interface{}{
"provider_id": "__yao.structured",
"option_id": "standard",
},
"embedding": map[string]interface{}{
"provider_id": "__yao.openai",
"option_id": "text-embedding-3-small",
},
}
body, err := json.Marshal(addData)
assert.NoError(t, err)
req, err := http.NewRequest("POST", serverURL+baseURL+"/kb/collections/non_existent_collection_12345/documents/text", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 403 Forbidden, 404 Not Found, or 500 Internal Server Error for non-existent collection
// (depends on whether permission check or collection lookup happens first)
assert.True(t, resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusInternalServerError,
"Expected 403, 404, or 500, got %d", resp.StatusCode)
})
t.Run("AddTextMissingChunking", func(t *testing.T) {
addData := map[string]interface{}{
"collection_id": testCollectionID,
"text": "This is a test text content.",
// Missing chunking
"embedding": map[string]interface{}{
"provider_id": "__yao.openai",
"option_id": "text-embedding-3-small",
},
}
body, err := json.Marshal(addData)
assert.NoError(t, err)
req, err := http.NewRequest("POST", serverURL+baseURL+"/kb/collections/"+testCollectionID+"/documents/text", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 400 Bad Request
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
// Error message contains Chunking (case insensitive check)
assert.Contains(t, response["error_description"], "Chunking")
})
t.Run("AddTextMissingEmbedding", func(t *testing.T) {
addData := map[string]interface{}{
"collection_id": testCollectionID,
"text": "This is a test text content.",
"chunking": map[string]interface{}{
"provider_id": "__yao.structured",
"option_id": "standard",
},
// Missing embedding
}
body, err := json.Marshal(addData)
assert.NoError(t, err)
req, err := http.NewRequest("POST", serverURL+baseURL+"/kb/collections/"+testCollectionID+"/documents/text", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 400 Bad Request
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
// Error message contains Embedding (case insensitive check)
assert.Contains(t, response["error_description"], "Embedding")
})
t.Run("AddTextUnauthorized", func(t *testing.T) {
addData := map[string]interface{}{
"collection_id": testCollectionID,
"text": "This is a test text content.",
"chunking": map[string]interface{}{
"provider_id": "__yao.structured",
"option_id": "standard",
},
"embedding": map[string]interface{}{
"provider_id": "__yao.openai",
"option_id": "text-embedding-3-small",
},
}
body, err := json.Marshal(addData)
assert.NoError(t, err)
req, err := http.NewRequest("POST", serverURL+baseURL+"/kb/collections/"+testCollectionID+"/documents/text", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
// No Authorization header
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 401 Unauthorized
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}
// TestAddTextAsync tests the add text async endpoint
func TestAddTextAsync(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register test client and get token
client := testutils.RegisterTestClient(t, "KB AddTextAsync Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Create a test collection first
testCollectionID := fmt.Sprintf("test_addtext_async_collection_%d", time.Now().UnixNano())
testutils.RegisterTestCollection(testCollectionID)
createData := map[string]interface{}{
"id": testCollectionID,
"metadata": map[string]interface{}{
"name": "Test Collection for AddTextAsync",
"category": "test",
},
"config": map[string]interface{}{
"embedding_provider_id": "__yao.openai",
"embedding_option_id": "text-embedding-3-small",
"locale": "en",
"index_type": "hnsw",
"distance": "cosine",
},
}
body, _ := json.Marshal(createData)
req, _ := http.NewRequest("POST", serverURL+baseURL+"/kb/collections", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Failed to create test collection: %v", err)
}
resp.Body.Close()
t.Run("AddTextAsyncInvalidRequest", func(t *testing.T) {
// Test with missing required fields
invalidData := map[string]interface{}{
"collection_id": testCollectionID,
// Missing text, chunking, embedding
}
body, err := json.Marshal(invalidData)
assert.NoError(t, err)
req, err := http.NewRequest("POST", serverURL+baseURL+"/kb/collections/"+testCollectionID+"/documents/text/async", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 400 Bad Request
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("AddTextAsyncMissingText", func(t *testing.T) {
addData := map[string]interface{}{
"collection_id": testCollectionID,
// Missing text
"chunking": map[string]interface{}{
"provider_id": "__yao.structured",
"option_id": "standard",
},
"embedding": map[string]interface{}{
"provider_id": "__yao.openai",
"option_id": "text-embedding-3-small",
},
}
body, err := json.Marshal(addData)
assert.NoError(t, err)
req, err := http.NewRequest("POST", serverURL+baseURL+"/kb/collections/"+testCollectionID+"/documents/text/async", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 400 Bad Request
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
// Error message contains Text (case insensitive check)
assert.Contains(t, response["error_description"], "Text")
})
t.Run("AddTextAsyncUnauthorized", func(t *testing.T) {
addData := map[string]interface{}{
"collection_id": testCollectionID,
"text": "This is a test text content for async processing.",
"chunking": map[string]interface{}{
"provider_id": "__yao.structured",
"option_id": "standard",
},
"embedding": map[string]interface{}{
"provider_id": "__yao.openai",
"option_id": "text-embedding-3-small",
},
}
body, err := json.Marshal(addData)
assert.NoError(t, err)
req, err := http.NewRequest("POST", serverURL+baseURL+"/kb/collections/"+testCollectionID+"/documents/text/async", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
// No Authorization header
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 401 Unauthorized
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}

View file

@ -0,0 +1,401 @@
package openapi_test
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/openapi"
"github.com/yaoapp/yao/openapi/tests/testutils"
)
// TestAddURL tests the add URL endpoint (sync)
func TestAddURL(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register test client and get token
client := testutils.RegisterTestClient(t, "KB AddURL Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Create a test collection first
testCollectionID := fmt.Sprintf("test_addurl_collection_%d", time.Now().UnixNano())
testutils.RegisterTestCollection(testCollectionID)
createData := map[string]interface{}{
"id": testCollectionID,
"metadata": map[string]interface{}{
"name": "Test Collection for AddURL",
"category": "test",
},
"config": map[string]interface{}{
"embedding_provider_id": "__yao.openai",
"embedding_option_id": "text-embedding-3-small",
"locale": "en",
"index_type": "hnsw",
"distance": "cosine",
},
}
body, _ := json.Marshal(createData)
req, _ := http.NewRequest("POST", serverURL+baseURL+"/kb/collections", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Failed to create test collection: %v", err)
}
resp.Body.Close()
t.Run("AddURLInvalidRequest", func(t *testing.T) {
// Test with missing required fields
invalidData := map[string]interface{}{
"collection_id": testCollectionID,
// Missing url, chunking, embedding
}
body, err := json.Marshal(invalidData)
assert.NoError(t, err)
req, err := http.NewRequest("POST", serverURL+baseURL+"/kb/collections/"+testCollectionID+"/documents/url", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 400 Bad Request
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("AddURLMissingURL", func(t *testing.T) {
addData := map[string]interface{}{
"collection_id": testCollectionID,
// Missing url
"chunking": map[string]interface{}{
"provider_id": "__yao.structured",
"option_id": "standard",
},
"embedding": map[string]interface{}{
"provider_id": "__yao.openai",
"option_id": "text-embedding-3-small",
},
}
body, err := json.Marshal(addData)
assert.NoError(t, err)
req, err := http.NewRequest("POST", serverURL+baseURL+"/kb/collections/"+testCollectionID+"/documents/url", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 400 Bad Request
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
// Error message contains URL (case insensitive check)
assert.Contains(t, response["error_description"], "URL")
})
t.Run("AddURLNonExistentCollection", func(t *testing.T) {
// Test with a non-existent collection
addData := map[string]interface{}{
"collection_id": "non_existent_collection_12345",
"url": "https://example.com/test-page",
"chunking": map[string]interface{}{
"provider_id": "__yao.structured",
"option_id": "standard",
},
"embedding": map[string]interface{}{
"provider_id": "__yao.openai",
"option_id": "text-embedding-3-small",
},
}
body, err := json.Marshal(addData)
assert.NoError(t, err)
req, err := http.NewRequest("POST", serverURL+baseURL+"/kb/collections/non_existent_collection_12345/documents/url", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 403 Forbidden, 404 Not Found, or 500 Internal Server Error for non-existent collection
// (depends on whether permission check or collection lookup happens first)
assert.True(t, resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusInternalServerError,
"Expected 403, 404, or 500, got %d", resp.StatusCode)
})
t.Run("AddURLMissingChunking", func(t *testing.T) {
addData := map[string]interface{}{
"collection_id": testCollectionID,
"url": "https://example.com/test-page",
// Missing chunking
"embedding": map[string]interface{}{
"provider_id": "__yao.openai",
"option_id": "text-embedding-3-small",
},
}
body, err := json.Marshal(addData)
assert.NoError(t, err)
req, err := http.NewRequest("POST", serverURL+baseURL+"/kb/collections/"+testCollectionID+"/documents/url", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 400 Bad Request
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
// Error message contains Chunking (case insensitive check)
assert.Contains(t, response["error_description"], "Chunking")
})
t.Run("AddURLMissingEmbedding", func(t *testing.T) {
addData := map[string]interface{}{
"collection_id": testCollectionID,
"url": "https://example.com/test-page",
"chunking": map[string]interface{}{
"provider_id": "__yao.structured",
"option_id": "standard",
},
// Missing embedding
}
body, err := json.Marshal(addData)
assert.NoError(t, err)
req, err := http.NewRequest("POST", serverURL+baseURL+"/kb/collections/"+testCollectionID+"/documents/url", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 400 Bad Request
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
// Error message contains Embedding (case insensitive check)
assert.Contains(t, response["error_description"], "Embedding")
})
t.Run("AddURLUnauthorized", func(t *testing.T) {
addData := map[string]interface{}{
"collection_id": testCollectionID,
"url": "https://example.com/test-page",
"chunking": map[string]interface{}{
"provider_id": "__yao.structured",
"option_id": "standard",
},
"embedding": map[string]interface{}{
"provider_id": "__yao.openai",
"option_id": "text-embedding-3-small",
},
}
body, err := json.Marshal(addData)
assert.NoError(t, err)
req, err := http.NewRequest("POST", serverURL+baseURL+"/kb/collections/"+testCollectionID+"/documents/url", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
// No Authorization header
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 401 Unauthorized
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}
// TestAddURLAsync tests the add URL async endpoint
func TestAddURLAsync(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register test client and get token
client := testutils.RegisterTestClient(t, "KB AddURLAsync Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Create a test collection first
testCollectionID := fmt.Sprintf("test_addurl_async_collection_%d", time.Now().UnixNano())
testutils.RegisterTestCollection(testCollectionID)
createData := map[string]interface{}{
"id": testCollectionID,
"metadata": map[string]interface{}{
"name": "Test Collection for AddURLAsync",
"category": "test",
},
"config": map[string]interface{}{
"embedding_provider_id": "__yao.openai",
"embedding_option_id": "text-embedding-3-small",
"locale": "en",
"index_type": "hnsw",
"distance": "cosine",
},
}
body, _ := json.Marshal(createData)
req, _ := http.NewRequest("POST", serverURL+baseURL+"/kb/collections", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Failed to create test collection: %v", err)
}
resp.Body.Close()
t.Run("AddURLAsyncInvalidRequest", func(t *testing.T) {
// Test with missing required fields
invalidData := map[string]interface{}{
"collection_id": testCollectionID,
// Missing url, chunking, embedding
}
body, err := json.Marshal(invalidData)
assert.NoError(t, err)
req, err := http.NewRequest("POST", serverURL+baseURL+"/kb/collections/"+testCollectionID+"/documents/url/async", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 400 Bad Request
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("AddURLAsyncMissingURL", func(t *testing.T) {
addData := map[string]interface{}{
"collection_id": testCollectionID,
// Missing url
"chunking": map[string]interface{}{
"provider_id": "__yao.structured",
"option_id": "standard",
},
"embedding": map[string]interface{}{
"provider_id": "__yao.openai",
"option_id": "text-embedding-3-small",
},
}
body, err := json.Marshal(addData)
assert.NoError(t, err)
req, err := http.NewRequest("POST", serverURL+baseURL+"/kb/collections/"+testCollectionID+"/documents/url/async", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 400 Bad Request
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
// Error message contains URL (case insensitive check)
assert.Contains(t, response["error_description"], "URL")
})
t.Run("AddURLAsyncUnauthorized", func(t *testing.T) {
addData := map[string]interface{}{
"collection_id": testCollectionID,
"url": "https://example.com/async-test-page",
"chunking": map[string]interface{}{
"provider_id": "__yao.structured",
"option_id": "standard",
},
"embedding": map[string]interface{}{
"provider_id": "__yao.openai",
"option_id": "text-embedding-3-small",
},
}
body, err := json.Marshal(addData)
assert.NoError(t, err)
req, err := http.NewRequest("POST", serverURL+baseURL+"/kb/collections/"+testCollectionID+"/documents/url/async", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
// No Authorization header
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 401 Unauthorized
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}

View file

@ -0,0 +1,295 @@
package openapi_test
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/openapi"
"github.com/yaoapp/yao/openapi/tests/testutils"
)
// TestListDocuments tests the document listing endpoint
func TestListDocuments(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register test client and get token
client := testutils.RegisterTestClient(t, "KB Document List Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Create a test collection first
testCollectionID := fmt.Sprintf("test_doc_list_collection_%d", time.Now().UnixNano())
testutils.RegisterTestCollection(testCollectionID)
createData := map[string]interface{}{
"id": testCollectionID,
"metadata": map[string]interface{}{
"name": "Test Collection for Document List",
"category": "test",
},
"config": map[string]interface{}{
"embedding_provider_id": "__yao.openai",
"embedding_option_id": "text-embedding-3-small",
"locale": "en",
"index_type": "hnsw",
"distance": "cosine",
},
}
body, _ := json.Marshal(createData)
req, _ := http.NewRequest("POST", serverURL+baseURL+"/kb/collections", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Failed to create test collection: %v", err)
}
resp.Body.Close()
t.Run("ListDocumentsSuccess", func(t *testing.T) {
req, err := http.NewRequest("GET", serverURL+baseURL+"/kb/documents?collection_id="+testCollectionID, nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
// Verify pagination fields exist
assert.Contains(t, response, "data")
assert.Contains(t, response, "page")
assert.Contains(t, response, "pagesize")
assert.Contains(t, response, "total")
})
t.Run("ListDocumentsWithPagination", func(t *testing.T) {
req, err := http.NewRequest("GET", serverURL+baseURL+"/kb/documents?collection_id="+testCollectionID+"&page=1&pagesize=10", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
// Verify pagination values
assert.Equal(t, float64(1), response["page"])
assert.Equal(t, float64(10), response["pagesize"])
})
t.Run("ListDocumentsWithStatusFilter", func(t *testing.T) {
req, err := http.NewRequest("GET", serverURL+baseURL+"/kb/documents?collection_id="+testCollectionID+"&status=completed", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("ListDocumentsWithSort", func(t *testing.T) {
req, err := http.NewRequest("GET", serverURL+baseURL+"/kb/documents?collection_id="+testCollectionID+"&sort=created_at+desc", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("ListDocumentsUnauthorized", func(t *testing.T) {
req, err := http.NewRequest("GET", serverURL+baseURL+"/kb/documents?collection_id="+testCollectionID, nil)
assert.NoError(t, err)
// No Authorization header
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 401 Unauthorized
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}
// TestGetDocument tests the get document endpoint
func TestGetDocument(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register test client and get token
client := testutils.RegisterTestClient(t, "KB Document Get Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
t.Run("GetDocumentNotFound", func(t *testing.T) {
req, err := http.NewRequest("GET", serverURL+baseURL+"/kb/documents/non_existent_doc_id", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 404 Not Found
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
})
t.Run("GetDocumentWithSelectFields", func(t *testing.T) {
// This test verifies the select parameter works
req, err := http.NewRequest("GET", serverURL+baseURL+"/kb/documents/test_doc_id?select=id,name,status", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 404 since doc doesn't exist, but the request format is valid
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
})
t.Run("GetDocumentUnauthorized", func(t *testing.T) {
req, err := http.NewRequest("GET", serverURL+baseURL+"/kb/documents/test_doc_id", nil)
assert.NoError(t, err)
// No Authorization header
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 401 Unauthorized
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}
// TestRemoveDocuments tests the remove documents endpoint
func TestRemoveDocuments(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register test client and get token
client := testutils.RegisterTestClient(t, "KB Document Remove Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
t.Run("RemoveDocumentsMissingIDs", func(t *testing.T) {
req, err := http.NewRequest("DELETE", serverURL+baseURL+"/kb/documents", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 400 Bad Request for missing document_ids
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
assert.Contains(t, response["error_description"], "document_ids")
})
t.Run("RemoveDocumentsEmptyIDs", func(t *testing.T) {
req, err := http.NewRequest("DELETE", serverURL+baseURL+"/kb/documents?document_ids=", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 400 Bad Request for empty document_ids
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("RemoveDocumentsNonExistent", func(t *testing.T) {
req, err := http.NewRequest("DELETE", serverURL+baseURL+"/kb/documents?document_ids=non_existent_doc_1,non_existent_doc_2", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// The behavior depends on implementation - could be 200 with 0 removed or 404
// Accept either as valid
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusForbidden)
})
t.Run("RemoveDocumentsUnauthorized", func(t *testing.T) {
req, err := http.NewRequest("DELETE", serverURL+baseURL+"/kb/documents?document_ids=doc1,doc2", nil)
assert.NoError(t, err)
// No Authorization header
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 401 Unauthorized
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}